Skip to content

Commit c9e1a37

Browse files
committed
feat(Layer A): robustness — error logging, extended validation, hook contracts, R1 watchdog
Layer A (carried-over draft D, H, I, R1): D — index-builder error log: - Wrap main block in try/catch; on failure write .harness-anchor/last-error.log (timestamp + stack), exit(2). On success, unlink stale error log. - Fix: throw Error instead of exit(2) inside try so catch actually runs. H — extend validate-anchor.sh: - Add [5/9] agent frontmatter checks (name + description, ≤500 chars). - Add [6/9] command frontmatter checks (description only, no name — filename IS name). - Exclude tests/manifest-fixtures/ from JSON parse (regression guard for Layer C). - Renumber sections 7→9/9. - Pass count: 53 → 64 (baseline for regression gate). I — hook contract tests + rename ripple: - Move tests/self-correction/post-edit-warn.sh → tests/hook-contracts/post-tool-use-warn.sh. - Add session-start-banner.sh (happy path + non-anchored). - Add session-start-timeout.sh (R1 total watchdog verification). - Add stop-wrap-up.sh (in-progress feature reminder + non-anchored). - Add user-prompt-submit-scope-jump.sh (keyword detection + benign + no-active). - Remove empty tests/self-correction/ directory. - Rename ripple: README.md, tests/README.md, CLAUDE.md, validate.yml. R1 — session-start + post-tool-use total watchdog: - 5-second total cap using tempfile-guarded background + watchdog pattern. - On timeout: emit nothing, exit 0 (warn-only invariant #1). - Avoids partial-JSON pitfall: output written to tempfile, only emitted on rc=0. - Fix: stdin read BEFORE backgrounding (bash redirects bg stdin to /dev/null). - Fix: TOC freshness algorithm — validate anchor commit exists (git cat-file) before diffing; sanitize arithmetic with tr -cd 0-9; extend grep pattern to include underscores in commit SHAs. Baseline: 53/53 PASSED (confirmed 2026-05-29). Post-commit: 64/64 PASSED.
1 parent bdb0f99 commit c9e1a37

13 files changed

Lines changed: 917 additions & 258 deletions

.github/workflows/validate.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ jobs:
3636
- name: Plugin self-consistency (validate-anchor)
3737
run: bash scripts/validate-anchor.sh
3838

39-
- name: Hook contract (post-edit-warn)
40-
run: bash tests/self-correction/post-edit-warn.sh
39+
- name: Hook contract (post-tool-use-warn)
40+
run: bash tests/hook-contracts/post-tool-use-warn.sh
4141

4242
- name: cpp-detect — fixture coverage
4343
shell: bash

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Hooks are the **most dangerous** component because they fire automatically. Befo
4141
1. State why this can't be a skill instead.
4242
2. Provide a concrete failure mode being prevented.
4343
3. Implement timeout, silent fail, and bounded output.
44-
4. Add a contract test under `tests/self-correction/`.
44+
4. Add a contract test under `tests/hook-contracts/`.
4545

4646
## Authoring a Subagent
4747

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ bash init.sh # health-check the environment
128128

129129
```bash
130130
bash scripts/validate-anchor.sh # 53 self-consistency checks
131-
bash tests/self-correction/post-edit-warn.sh # PostToolUse hook contract
131+
bash tests/hook-contracts/post-tool-use-warn.sh # PostToolUse hook contract
132132
bash scripts/cpp-detect.sh --target tests/cpp-detection/cmake-fixture
133133
# cpp-detect on a known fixture
134134
```

hooks/post-tool-use

Lines changed: 114 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,25 @@
88
# Phase 4 adds C/C++ specific clang-tidy invocation.
99
#
1010
# Hard constraints:
11-
# - Must complete in <5 seconds (else timeout caller skips us)
11+
# - Must complete in <5 seconds (total watchdog, R1)
1212
# - Silent on missing tools (warn-only philosophy, never pollute context with infra errors)
1313
# - Never emits permissionDecision/stopReason/block
1414

1515
set -uo pipefail # no -e: a single check failure should not abort other checks
1616

17-
# 5-second hard wall-clock cap (in case any sub-check hangs).
18-
timeout_self() {
19-
# If the env supports `timeout`, set a global cap via a trap.
20-
# On macOS without coreutils, this is a no-op; the hook is fast anyway.
21-
:
22-
}
23-
timeout_self
17+
# ---- Read stdin BEFORE backgrounding (bash redirects bg stdin to /dev/null) ----
18+
INPUT_FILE=$(mktemp)
19+
cat > "$INPUT_FILE" 2>/dev/null || true
2420

25-
# Read stdin JSON; tolerate non-JSON / empty stdin.
26-
input="$(cat 2>/dev/null || true)"
21+
# ---- main() — all logic wrapped for R1 total watchdog ----
22+
main() {
23+
# Read pre-captured stdin from tempfile.
24+
input="$(cat "$INPUT_FILE" 2>/dev/null || true)"
2725

28-
# Extract file_path field (very narrow JSON parse — avoid jq dependency).
29-
file_path=""
30-
if [ -n "$input" ]; then
31-
file_path=$(printf '%s' "$input" | python3 -c "
26+
# Extract file_path field (very narrow JSON parse — avoid jq dependency).
27+
file_path=""
28+
if [ -n "$input" ]; then
29+
file_path=$(printf '%s' "$input" | python3 -c "
3230
import json, sys
3331
try:
3432
d = json.load(sys.stdin)
@@ -37,36 +35,36 @@ try:
3735
except Exception:
3836
pass
3937
" 2>/dev/null || echo "")
40-
fi
41-
42-
# No file path → nothing to check.
43-
if [ -z "$file_path" ]; then
44-
exit 0
45-
fi
38+
fi
4639

47-
# Find project root (where feature_list.json lives) — walk up from file_path.
48-
project_root=""
49-
search_dir="$(dirname "$file_path")"
50-
while [ "$search_dir" != "/" ] && [ -n "$search_dir" ]; do
51-
if [ -f "$search_dir/feature_list.json" ]; then
52-
project_root="$search_dir"
53-
break
40+
# No file path → nothing to check.
41+
if [ -z "$file_path" ]; then
42+
return 0
5443
fi
55-
search_dir="$(dirname "$search_dir")"
56-
done
5744

58-
# No anchored project → nothing to check.
59-
if [ -z "$project_root" ]; then
60-
exit 0
61-
fi
45+
# Find project root (where feature_list.json lives) — walk up from file_path.
46+
project_root=""
47+
search_dir="$(dirname "$file_path")"
48+
while [ "$search_dir" != "/" ] && [ -n "$search_dir" ]; do
49+
if [ -f "$search_dir/feature_list.json" ]; then
50+
project_root="$search_dir"
51+
break
52+
fi
53+
search_dir="$(dirname "$search_dir")"
54+
done
6255

63-
warnings=()
56+
# No anchored project → nothing to check.
57+
if [ -z "$project_root" ]; then
58+
return 0
59+
fi
60+
61+
warnings=()
6462

65-
# ---- Check 1: regression-warn ----
66-
# If the edited file is referenced in evidence.artifacts of a feature marked
67-
# status='pass', that's a regression risk. Surface a one-line warning.
68-
rel_path="${file_path#$project_root/}"
69-
regression=$(python3 - "$project_root/feature_list.json" "$rel_path" <<'PY' 2>/dev/null || true
63+
# ---- Check 1: regression-warn ----
64+
# If the edited file is referenced in evidence.artifacts of a feature marked
65+
# status='pass', that's a regression risk. Surface a one-line warning.
66+
rel_path="${file_path#$project_root/}"
67+
regression=$(python3 - "$project_root/feature_list.json" "$rel_path" <<'PY' 2>/dev/null || true
7068
import json, sys, os
7169
flist_path, rel = sys.argv[1], sys.argv[2]
7270
try:
@@ -92,73 +90,92 @@ except Exception:
9290
PY
9391
)
9492

95-
if [ -n "$regression" ]; then
96-
warnings+=("$regression")
97-
fi
93+
if [ -n "$regression" ]; then
94+
warnings+=("$regression")
95+
fi
9896

99-
# ---- Check 2: C/C++ static analysis (clang-tidy on changed files) ----
100-
# Phase 4: invoke clang-tidy if file is C/C++ and prerequisites are in place.
101-
# Silent skip on anything missing — never pollute agent context with infra errors.
102-
case "$file_path" in
103-
*.c|*.cc|*.cpp|*.cxx|*.h|*.hpp|*.hh)
104-
# Need clang-tidy on PATH
105-
if command -v clang-tidy >/dev/null 2>&1; then
106-
# Need compile_commands.json — check root, .build/, build/
107-
cc_dir=""
108-
for d in "$project_root" "$project_root/.build" "$project_root/build" "$project_root/builddir"; do
109-
if [ -f "$d/compile_commands.json" ]; then
110-
cc_dir="$d"
111-
break
112-
fi
113-
done
114-
if [ -n "$cc_dir" ]; then
115-
# 5-second timeout cap (BSD/macOS `timeout` may be `gtimeout`; try both)
116-
tidy_cmd="clang-tidy"
117-
if command -v timeout >/dev/null 2>&1; then
118-
tidy_cmd="timeout 5 clang-tidy"
119-
elif command -v gtimeout >/dev/null 2>&1; then
120-
tidy_cmd="gtimeout 5 clang-tidy"
121-
fi
122-
# --quiet suppresses progress; capture warnings only
123-
tidy_out=$($tidy_cmd -p "$cc_dir" --quiet "$file_path" 2>/dev/null | grep -E '(warning|error):' | head -20 || true)
124-
if [ -n "$tidy_out" ]; then
125-
# Truncate to ~1500 chars to stay under hook budget
126-
if [ "${#tidy_out}" -gt 1500 ]; then
127-
tidy_out="${tidy_out:0:1500}
128-
... (truncated; run clang-tidy directly for full output)"
97+
# ---- Check 2: C/C++ static analysis (clang-tidy on changed files) ----
98+
# Phase 4: invoke clang-tidy if file is C/C++ and prerequisites are in place.
99+
# Silent skip on anything missing — never pollute agent context with infra errors.
100+
case "$file_path" in
101+
*.c|*.cc|*.cpp|*.cxx|*.h|*.hpp|*.hh)
102+
# Need clang-tidy on PATH
103+
if command -v clang-tidy >/dev/null 2>&1; then
104+
# Need compile_commands.json — check root, .build/, build/
105+
cc_dir=""
106+
for d in "$project_root" "$project_root/.build" "$project_root/build" "$project_root/builddir"; do
107+
if [ -f "$d/compile_commands.json" ]; then
108+
cc_dir="$d"
109+
break
110+
fi
111+
done
112+
if [ -n "$cc_dir" ]; then
113+
# 5-second timeout cap (BSD/macOS `timeout` may be `gtimeout`; try both)
114+
tidy_cmd="clang-tidy"
115+
if command -v timeout >/dev/null 2>&1; then
116+
tidy_cmd="timeout 5 clang-tidy"
117+
elif command -v gtimeout >/dev/null 2>&1; then
118+
tidy_cmd="gtimeout 5 clang-tidy"
129119
fi
130-
warnings+=("clang-tidy on $(basename "$file_path"):
120+
# --quiet suppresses progress; capture warnings only
121+
tidy_out=$($tidy_cmd -p "$cc_dir" --quiet "$file_path" 2>/dev/null | grep -E '(warning|error):' | head -20 || true)
122+
if [ -n "$tidy_out" ]; then
123+
# Truncate to ~1500 chars to stay under hook budget
124+
if [ "${#tidy_out}" -gt 1500 ]; then
125+
tidy_out="${tidy_out:0:1500}
126+
... (truncated; run clang-tidy directly for full output)"
127+
fi
128+
warnings+=("clang-tidy on $(basename "$file_path"):
131129
$tidy_out")
130+
fi
132131
fi
133132
fi
134-
fi
135-
;;
136-
esac
133+
;;
134+
esac
137135

138-
# ---- Emit output ----
139-
if [ "${#warnings[@]}" -eq 0 ]; then
140-
exit 0
141-
fi
136+
# ---- Emit output ----
137+
if [ "${#warnings[@]}" -eq 0 ]; then
138+
return 0
139+
fi
142140

143-
# Pure-bash JSON escape (no jq).
144-
escape_for_json() {
145-
local s="$1"
146-
s="${s//\\/\\\\}"
147-
s="${s//\"/\\\"}"
148-
s="${s//$'\n'/\\n}"
149-
s="${s//$'\r'/\\r}"
150-
s="${s//$'\t'/\\t}"
151-
printf '%s' "$s"
141+
# Pure-bash JSON escape (no jq).
142+
escape_for_json() {
143+
local s="$1"
144+
s="${s//\\/\\\\}"
145+
s="${s//\"/\\\"}"
146+
s="${s//$'\n'/\\n}"
147+
s="${s//$'\r'/\\r}"
148+
s="${s//$'\t'/\\t}"
149+
printf '%s' "$s"
150+
}
151+
152+
# Join warnings with newlines, then escape.
153+
joined=""
154+
for w in "${warnings[@]}"; do
155+
if [ -z "$joined" ]; then joined="$w"; else joined="$joined
156+
$w"; fi
157+
done
158+
escaped=$(escape_for_json "$joined")
159+
160+
printf '{\n "hookSpecificOutput": {\n "hookEventName": "PostToolUse",\n "additionalContext": "<harness-anchor PostToolUse>\\n%s\\n</harness-anchor PostToolUse>"\n }\n}\n' "$escaped"
152161
}
153162

154-
# Join warnings with newlines, then escape.
155-
joined=""
156-
for w in "${warnings[@]}"; do
157-
if [ -z "$joined" ]; then joined="$w"; else joined="$joined
158-
$w"; fi
159-
done
160-
escaped=$(escape_for_json "$joined")
163+
# ---- R1: Total watchdog (5s cap, tempfile-guarded) ----
164+
OUTPUT_FILE=$(mktemp)
165+
main > "$OUTPUT_FILE" 2>/dev/null &
166+
main_pid=$!
161167

162-
printf '{\n "hookSpecificOutput": {\n "hookEventName": "PostToolUse",\n "additionalContext": "<harness-anchor PostToolUse>\\n%s\\n</harness-anchor PostToolUse>"\n }\n}\n' "$escaped"
168+
( sleep 5 && kill -TERM $main_pid 2>/dev/null ) &
169+
watchdog_pid=$!
170+
171+
wait $main_pid 2>/dev/null
172+
main_rc=$?
173+
kill $watchdog_pid 2>/dev/null
174+
wait $watchdog_pid 2>/dev/null
175+
176+
if [ "$main_rc" -eq 0 ] && [ -f "$OUTPUT_FILE" ] && [ -s "$OUTPUT_FILE" ]; then
177+
cat "$OUTPUT_FILE"
178+
fi
179+
rm -f "$OUTPUT_FILE" "$INPUT_FILE" 2>/dev/null
163180

164181
exit 0

0 commit comments

Comments
 (0)