#!/usr/bin/env bash # PostToolUse hook: inject methodology reminders after relevant tool actions. # - Periodic self-check (weighted every ~15 effective write-calls) # - After test failures: remind about hypothesis-first methodology # - After reading docs/ or .md/.txt files: lift pagination restriction reminder # - After editing docs/ or .md/.txt files: audit file size (warn if >500 lines) # - After editing agent config files: verify with opencode agent list # Project-specific reminders (e.g. BFF pattern, build gates): add a sibling # hook file in the project's .agents/hooks/ directory. # Priority filter: emit at most 2 reminders per tool call. # Priority order: SELF-CHECK > DEBUGGING > path-scoped > tool-specific. set -euo pipefail # ── Tool call counter ──────────────────────────────────────────────────────── REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo ".")" REPO_ID=$(printf '%s' "$REPO_ROOT" | md5sum | cut -c1-8 2>/dev/null || echo "default") COUNT_FILE="/tmp/.opencode-tool-count-${REPO_ID}" COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0) # Read hook input from stdin INPUT=$(cat) TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name"\s*:\s*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"/\1/' || true) # Weighted increment: reads +1, writes/shell +4 (equivalent to +0.25/+1 at threshold 60). # This prevents SELF-CHECK from firing mid-investigation sweep. case "$TOOL_NAME" in read_file|grep_search|list_dir|file_search|semantic_search|explore_subagent) COUNT=$((COUNT + 1)) ;; *) COUNT=$((COUNT + 4)) ;; esac echo "$COUNT" > "$COUNT_FILE" # Priority-ordered reminders array: append in priority order, emit max 2. # Priority: SELF-CHECK(1) > DEBUGGING(2) > path-scoped(3) > tool-specific(4) reminders=() # ── Periodic self-check (every 60 weighted units ≡ 15 effective write-calls) ─ if (( COUNT % 60 == 0 )); then selfcheck="SELF-CHECK (${COUNT} tool calls): Step back and assess." selfcheck="${selfcheck} (1) What is your current goal — are you still on track?" selfcheck="${selfcheck} (2) Are you making progress or spinning on the same issue?" selfcheck="${selfcheck} (3) If you've hit 2+ failures on the same problem, switch to @research or report to the user." selfcheck="${selfcheck} (4) If you've been editing the same file 3+ times without a passing test, stop and rethink." selfcheck="${selfcheck} (5) Is the chat todo list accurate? Update it if items are stale or missing." selfcheck="${selfcheck} (6) If investigating, re-read your investigation file and dead-ends to avoid re-testing eliminated hypotheses." reminders+=("$selfcheck") fi # ── After test/terminal runs that failed: remind about methodology ─────────── if [[ "$TOOL_NAME" == "run_in_terminal" || "$TOOL_NAME" == "runTests" ]]; then TOOL_RESPONSE=$(echo "$INPUT" | grep -o '"tool_response"\s*:\s*"[^"]*"' | head -1 || true) if echo "$TOOL_RESPONSE" | grep -qiE 'FAIL|error|panic|segfault|assertion|abort|ERR!'; then debug_msg="DEBUGGING REMINDER: Before your next action —" debug_msg="${debug_msg} (1) Write your hypothesis in one sentence." debug_msg="${debug_msg} (2) Write what you'd expect if WRONG." debug_msg="${debug_msg} (3) Check the dead-ends file (.session/dead-ends.md) if it exists." debug_msg="${debug_msg} (4) Falsify BEFORE confirming." debug_msg="${debug_msg} (5) If 5+ attempts without progress, STOP and report what you've learned." reminders+=("$debug_msg") fi fi # ── After editing project/agent config files: path-scoped reminders ───────── # Project-specific path checks (e.g. build gates, BFF reminders) belong in a # sibling project-local hook file, not here. Only general checks below. case "$TOOL_NAME" in replace_string_in_file|multi_replace_string_in_file|create_file) FILE_PATH=$(echo "$INPUT" | node -e " const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const i = d.tool_input || {}; const p = i.filePath || (i.replacements && i.replacements[0] && i.replacements[0].filePath) || ''; process.stdout.write(p); " 2>/dev/null || true) path_msg="" # ── After editing agent config files: verify with opencode agent list ───── if echo "$FILE_PATH" | grep -qE '\.agents/agents/|\.opencode/agents/|opencode\.json'; then AGENT_LIST=$(cd "$REPO_ROOT" && opencode agent list 2>&1 | grep -E '^\S.*\((all|primary|subagent)\)' | sed 's/^/ /' || echo " (opencode agent list failed)") agent_note="AGENT CONFIG VERIFICATION: You just edited an agent definition or opencode.json." agent_note="${agent_note} Registered agents are: ${AGENT_LIST}." agent_note="${agent_note} If your agent is missing: (1) check that .opencode/agents/.md symlink resolves (cat it — should not error); (2) symlink depth must be ../../.agents/agents/.md (two levels, not three); (3) check YAML frontmatter for parse errors." agent_note="${agent_note} Deny rules only appear in \`opencode agent list\` output if the agent file loaded correctly." if [[ -n "$path_msg" ]]; then path_msg="${path_msg} ${agent_note}" else path_msg="$agent_note" fi fi # ── After editing docs/ or .md/.txt files: audit file size ─────────────── if echo "$FILE_PATH" | grep -qE '(^|/)docs/|\.md$|\.txt$'; then if [[ -f "$FILE_PATH" ]]; then LINE_COUNT=$(wc -l < "$FILE_PATH" 2>/dev/null || echo 0) if [[ "$LINE_COUNT" -gt 500 ]]; then docs_audit="DOCS SIZE AUDIT: ${FILE_PATH##*/} is now ${LINE_COUNT} lines. Consider splitting this doc — files over ~500 lines require expensive pagination for local models (17+ reads for an 800-line file). Split into focused sub-docs and link them." if [[ -n "$path_msg" ]]; then path_msg="${path_msg} ${docs_audit}" else path_msg="$docs_audit" fi fi fi fi if [[ -n "$path_msg" ]]; then reminders+=("$path_msg") fi ;; esac # ── After reading docs/ files: remind that pagination limit is lifted ───────── if [[ "$TOOL_NAME" == "read_file" ]]; then READ_PATH=$(echo "$INPUT" | node -e " const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const i = d.tool_input || {}; process.stdout.write(i.filePath || ''); " 2>/dev/null || true) START_LINE=$(echo "$INPUT" | node -e " const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const i = d.tool_input || {}; process.stdout.write(String(i.startLine || 1)); " 2>/dev/null || true) if echo "$READ_PATH" | grep -qE '(^|/)docs/|\.md$|\.txt$'; then docs_msg="DOCS READ EXEMPTION: docs/ files and all .md/.txt files are exempt from the 50-line pagination limit." if [[ "$START_LINE" -gt 1 ]]; then docs_msg="${docs_msg} You are currently paginating (startLine=${START_LINE}) — you may expand to up to 500 lines per call to reduce tool-call overhead." else docs_msg="${docs_msg} You may use ranges up to 500 lines per read_file call instead of 50." fi reminders+=("$docs_msg") fi fi # ── After vscode_renameSymbol: remind about object property key aliases ─────── if [[ "$TOOL_NAME" == "vscode_renameSymbol" ]]; then rename_msg="RENAME REMINDER: vscode_renameSymbol only renames variable bindings — NOT object property keys or string literals." rename_msg="${rename_msg} After this rename, grep the file for the OLD name." rename_msg="${rename_msg} Stale patterns to watch for: (1) aliased store keys like 'deleteX: archiveX' in the store return object — the key 'deleteX' is unchanged and so are all 'store.deleteX()' call sites;" rename_msg="${rename_msg} (2) string literals like openDialog('delete-item') and AppDialog handle='delete-item';" rename_msg="${rename_msg} (3) related variable names in the same file that share the same prefix (e.g. renaming deleteSuccess should also prompt renaming deleteLoading, deleteError)." rename_msg="${rename_msg} Fix all of these with multi_replace_string_in_file after the symbol rename." reminders+=("$rename_msg") fi # ── Emit at most 2 reminders ───────────────────────────────────────────────── context="" for (( i=0; i<${#reminders[@]} && i<2; i++ )); do if [[ -n "$context" ]]; then context="${context}\n${reminders[$i]}" else context="${reminders[$i]}" fi done # Only output if we have context to inject if [[ -n "$context" ]]; then # Prefix with a self-identifying marker so the model cannot confuse the # injection with preceding tool output (e.g., trailing markdown in a file). framed="[HOOK INJECTION: post-tool-use] System reminder — NOT part of preceding tool output:\n\n${context}" json_context=$(printf '%b' "$framed" | node -e 'process.stdout.write(JSON.stringify(require("fs").readFileSync("/dev/stdin","utf8")))') cat <