@chrono-meta/fh-gate 1.4.83 → 1.4.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.claude/rules/fh_4axis_gate.md +45 -1
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/CATALOG.md +23 -1
  4. package/CHEATSHEET.md +1 -1
  5. package/CLAUDE.md +21 -7
  6. package/knowledge/shared/harness-core/field_harness_diagnostic.md +1 -0
  7. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +10 -0
  8. package/knowledge/shared/harness-core/harness_verification_core_extended.md +167 -0
  9. package/knowledge/shared/learnings/subagent_invocations_log.yaml +206 -0
  10. package/knowledge/shared/rules/operational_adaptation.md +35 -9
  11. package/package.json +3 -1
  12. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  13. package/plugins/fh-commons/skills/convergence-loop/SKILL.md +25 -10
  14. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  15. package/plugins/fh-meta/agents/challenger.md +1 -1
  16. package/plugins/fh-meta/skills/phantom-quench/SKILL.md +1 -1
  17. package/plugins/fh-meta/skills/steel-quench/SKILL.md +78 -10
  18. package/plugins/fh-meta/skills/steel-quench/SKILL_detail.md +3 -3
  19. package/scripts/consent_registry_check.sh +187 -15
  20. package/scripts/destructive_pre_gate.sh +32 -5
  21. package/scripts/package_coverage_check.sh +10 -0
  22. package/scripts/pipe_verdict_guard.sh +43 -3
  23. package/scripts/selfcheck.sh +117 -2
  24. package/scripts/session_close_check.sh +103 -1
  25. package/scripts/stale_clone_guard.sh +49 -6
  26. package/scripts/substrate_jump_detector.sh +21 -0
  27. package/scripts/test_consent_registry.sh +341 -42
  28. package/scripts/test_destructive_pre_gate_lanes.sh +42 -0
  29. package/scripts/test_dispatch_log_lanes.sh +99 -0
  30. package/scripts/test_pipe_verdict_guard_lanes.sh +71 -0
  31. package/scripts/test_selfcheck_state_lanes.sh +102 -0
  32. package/scripts/test_session_close_lanes.sh +187 -15
  33. package/scripts/test_stale_clone_guard_lanes.sh +54 -0
  34. package/templates/.git-hooks/pre-push +10 -1
  35. package/templates/consent_classes.yaml.example +18 -0
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ # test_dispatch_log_lanes.sh — known-pair anchor for session_close_check.sh ④-e (dispatch-log
3
+ # reconciliation) and for the SubagentStop tally hook that feeds it.
4
+ #
5
+ # WHY (2026-08-02): CLAUDE.md makes an invocation-log entry mandatory immediately after any custom
6
+ # sub-agent invocation — it feeds the 60/40 promotion gate and the UAP loop. Measured: one session
7
+ # dispatched 20+ subagents and logged ZERO, in the same session that recovered that very log file
8
+ # from a branch queued for deletion. An obligation that loses 20/20 is not under-emphasised, it is
9
+ # unmechanized, so ④-e now reconciles a hook-written tally against the day's entries.
10
+ #
11
+ # The reconciliation itself then shipped a defect this suite exists to prevent from returning: the
12
+ # log carries TWO date spellings — `- date: 2026-08-02` for hand-written entries and
13
+ # `- date: '2026-08-02'` for anything appended through yaml.dump — and the first matcher saw only
14
+ # one, counting half the entries as absent. A missing-records check that itself miscounts records is
15
+ # the worst possible shape, so both spellings are lanes below.
16
+ #
17
+ # Exit 0 = the reconciliation discriminates · 1 = it would mis-report.
18
+
19
+ set -uo pipefail
20
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ CHECK="$SCRIPT_DIR/session_close_check.sh"
22
+ SETTINGS="$SCRIPT_DIR/../.claude/settings.json"
23
+ FAILED=0; PASS=0
24
+ chk() { if [ "$1" -eq 0 ]; then PASS=$((PASS+1)); echo " ✅ $2"; else FAILED=1; echo " ❌ $2"; fi; }
25
+
26
+ [ -f "$CHECK" ] || { echo "FAIL dispatch-log lanes: subject $CHECK missing"; exit 1; }
27
+
28
+ # The matcher under test, lifted from the subject rather than re-spelled (a hand-copied predicate is
29
+ # a divergent normalizer — which is exactly the bug being anchored). If it is gone, fail loudly.
30
+ MATCHER=$(grep -oE "grep -cE \"\^- date: \*'\?\\\$TODAY'\?\"" "$CHECK" | head -1)
31
+ if [ -z "$MATCHER" ]; then
32
+ echo "FAIL the ④-e date matcher is no longer in session_close_check.sh in the expected form —"
33
+ echo " this lane cannot verify what it claims to. Update the lane WITH the subject."
34
+ exit 1
35
+ fi
36
+ echo "── matcher located in the subject"
37
+
38
+ T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
39
+ D=2026-08-02
40
+
41
+ echo "── date-spelling lanes (the shipped miscount) ──"
42
+ printf -- "- date: %s\n" "$D" > "$T/plain.yaml"
43
+ printf -- "- date: '%s'\n" "$D" > "$T/quoted.yaml"
44
+ printf -- "- date: %s\n- date: '%s'\n- date: 2020-01-01\n" "$D" "$D" > "$T/both.yaml"
45
+ count() { grep -cE "^- date: *'?$D'?" "$1" 2>/dev/null | tr -d ' '; }
46
+ [ "$(count "$T/plain.yaml")" = 1 ] ; chk $? "unquoted date counted"
47
+ [ "$(count "$T/quoted.yaml")" = 1 ] ; chk $? "yaml.dump-quoted date counted (the half the first matcher missed)"
48
+ [ "$(count "$T/both.yaml")" = 2 ] ; chk $? "mixed file counts both, and ignores another date"
49
+ # known-POSITIVE for the bug itself: the reverted single-form matcher DOES undercount
50
+ [ "$(grep -c "^- date: *$D" "$T/both.yaml")" = 1 ] ; chk $? "known-POSITIVE: the reverted matcher undercounts (1 of 2) — the defect is real"
51
+
52
+ echo "── reconciliation verdict lanes ──"
53
+ verdict() { # $1=dispatched $2=logged -> BLOCK | OK | NONE
54
+ if [ "$1" -gt 0 ] && [ "$2" -eq 0 ]; then echo BLOCK
55
+ elif [ "$1" -gt 0 ]; then echo OK
56
+ else echo NONE; fi
57
+ }
58
+ [ "$(verdict 20 0)" = BLOCK ] ; chk $? "dispatches with ZERO entries → BLOCK (this is the 2026-08-02 failure)"
59
+ [ "$(verdict 20 4)" = OK ] ; chk $? "consolidated logging (20 dispatches, 4 entries) → OK, not punished"
60
+ [ "$(verdict 1 1)" = OK ] ; chk $? "1:1 logging → OK"
61
+ [ "$(verdict 0 0)" = NONE ] ; chk $? "no dispatches → silent, not a failure"
62
+
63
+ echo "── the tally hook that feeds it ──"
64
+ if [ -f "$SETTINGS" ]; then
65
+ python3 - "$SETTINGS" <<'PY' >"$T/hookcmd" 2>/dev/null || true
66
+ import json,sys
67
+ d=json.load(open(sys.argv[1]))
68
+ g=d.get("hooks",{}).get("SubagentStop",[])
69
+ print(g[0]["hooks"][0]["command"] if g and g[0].get("hooks") else "")
70
+ PY
71
+ cmd=$(cat "$T/hookcmd")
72
+ [ -n "$cmd" ] ; chk $? "SubagentStop hook is configured (an untallied dispatch is invisible to ④-e)"
73
+ if [ -n "$cmd" ]; then
74
+ case "$cmd" in *"exit 0"*) ok=0 ;; *) ok=1 ;; esac
75
+ [ "$ok" -eq 0 ] ; chk $? "hook ends in exit 0 — a non-zero hook exit discards its stdout SILENTLY"
76
+ # run it against a scratch HUB and confirm it appends today's date
77
+ CLAUDE_PROJECT_DIR="$T/hub" bash -c "$cmd" >/dev/null 2>&1
78
+ [ "$(grep -c "$(date +%Y-%m-%d)" "$T/hub/tracks/_meta/.subagent_dispatch_tally" 2>/dev/null || echo 0)" -ge 1 ]
79
+ chk $? "hook actually appends a dated line when run (not merely present)"
80
+ fi
81
+ else
82
+ echo " ⏭️ .claude/settings.json absent — hook lanes unchecked (not a pass)"
83
+ fi
84
+
85
+ echo "── absence-is-not-zero (the fail-open this fix nearly shipped) ──"
86
+ # .claude/settings.json is GITIGNORED, so a fresh clone has no hook and an empty tally. Without an
87
+ # explicit branch the reconciliation would read that as "no dispatches" and pass in silence.
88
+ hookless_verdict() { # $1=hook_configured -> MEASURED | NOT-MEASURED
89
+ [ "$1" -eq 1 ] && echo MEASURED || echo NOT-MEASURED
90
+ }
91
+ [ "$(hookless_verdict 0)" = NOT-MEASURED ] ; chk $? "no hook → NOT MEASURED (an unmeasured count is not zero)"
92
+ [ "$(hookless_verdict 1)" = MEASURED ] ; chk $? "hook present → measured"
93
+ grep -q "NOT MEASURED" "$CHECK" ; chk $? "the subject actually carries that branch (not just this lane)"
94
+ [ -f "$SCRIPT_DIR/../templates/subagent-tally-hook.json" ] ; chk $? "installable snippet exists for a clone that has no settings.json"
95
+
96
+ echo ""
97
+ if [ "$FAILED" -ne 0 ]; then echo "DISPATCH-LOG LANES: FAIL"; exit 1; fi
98
+ echo "DISPATCH-LOG LANES: PASS ($PASS/$PASS)"
99
+ exit 0
@@ -85,6 +85,77 @@ rc=$?'
85
85
  expect "B PIPESTATUS without braces" HIT 'a | b; rc=$PIPESTATUS[0]'
86
86
  expect "B braced form still caught" HIT 'a | b; rc=${PIPESTATUS[0]}'
87
87
 
88
+ echo "-- D: statement-continuation flatten + wrapped filter (leg-C MED round, 2026-08-01) --"
89
+ # The blanket newline→`;` rewrite broke the CONTINUATION shapes: a newline after `|`/`&&` or a
90
+ # backslash-newline continues the statement, and inserting `;` un-matched R2 on exactly the
91
+ # multi-line pipelines the flatten exists to catch.
92
+ expect "D pipe-continuation newline then \$?" HIT 'bash gate.sh |
93
+ tail -20
94
+ echo "exit=$?"'
95
+ expect "D &&-continuation newline then \$?" HIT 'bash gate.sh | tail -3 &&
96
+ echo "exit=$?"'
97
+ expect "D backslash-continuation mid-args" HIT 'bash gate.sh | tail \
98
+ -20; echo "exit=$?"'
99
+ expect "D pipe-continuation to grep -q CLEAN" CLEAN 'bash gate.sh |
100
+ grep -q ok
101
+ rc=$?'
102
+ # Subshell/group wrapper around the filter is the same mistake one paren deeper.
103
+ expect "D subshell-wrapped filter" HIT 'bash gate.sh | (tail -5); echo "exit=$?"'
104
+ expect "D subshell-wrapped filter, no args" HIT 'bash gate.sh | (tail); rc=$?'
105
+ expect "D brace-group filter" HIT 'bash gate.sh | { tail -5; }; echo "exit=$?"'
106
+ expect "D subshell non-filter stays CLEAN" CLEAN 'bash gate.sh | (grep -q ok); rc=$?'
107
+
108
+ echo "-- T: terra decorrelation round (2026-08-01) --"
109
+ # T2: |& merges stderr into the pipe — same verdict mistake, evaded the whitespace-anchored matcher.
110
+ expect "T |& then \$?" HIT 'false |& tail -5; echo "exit=$?"'
111
+ expect "T |& to grep -q stays CLEAN" CLEAN 'false |& grep -q x; rc=$?'
112
+ expect "T |&-continuation newline then \$?" HIT 'false |&
113
+ tail -5; echo "exit=$?"'
114
+ # T3: newline directly after the wrapper open used to insert `;` and un-match R2.
115
+ expect "T multiline subshell wrapper" HIT 'false | (
116
+ tail -5
117
+ ); echo "exit=$?"'
118
+ # T4: KNOWN FP, pinned as expected — the quote-blind join reads quoted multi-line data as a live
119
+ # pipeline (mention-as-data, advisory-tolerated; see the flatten comment in the guard).
120
+ expect "T quoted-data FP is pinned HIT" HIT "printf 'false |
121
+ tail; echo \$?'"
122
+
123
+ echo "-- T2: terra round-2 (defects the round-1 FIXES introduced) --"
124
+ # The wrapper matcher must require the filter to be the wrapper's FINAL command: when a real
125
+ # command follows it inside the group, `$?` is THAT command's status and the warning is an FP
126
+ # the pre-wrapper matcher never produced.
127
+ expect "T2 filter not last in group is CLEAN" CLEAN 'false | (tail -5; test -n "$x"); echo "exit=$?"'
128
+ expect "T2 brace, filter not last is CLEAN" CLEAN 'false | { tail -5; grep -q ok; }; rc=$?'
129
+ expect "T2 filter IS last in group still HIT" HIT 'false | (sort; tail -5); echo "exit=$?"'
130
+ expect "T2 wrapped filter alone still HIT" HIT 'false | (tail -5); echo "exit=$?"'
131
+ # The `;` in branch (b)'s prefix keeps the filter at a statement start — a bare word ending in a
132
+ # filter name must not satisfy it.
133
+ expect "T2 bare word inside group is CLEAN" CLEAN 'false | (echo cat); rc=$?'
134
+ # KNOWN MISS, pinned so it cannot be silently "fixed" into a worse trade: a comment inside the
135
+ # wrapper hides the closer. The available fix (strip `#…`) is quote-blind and would turn the
136
+ # NEXT lane — a real, currently-caught shape — into a miss. Recall loss accepted, direction chosen.
137
+ expect "T2 comment-in-group is a KNOWN MISS" CLEAN 'false | (tail -5; # note
138
+ ); rc=$?'
139
+ expect "T2 url-fragment shape must stay HIT" HIT 'curl "https://x.dev/a#frag" | tail -3; rc=$?'
140
+
141
+ echo "-- E: spec'd-but-unpinned shapes (triad-lens Sonnet floor run, 2026-08-01: F1/F3) --"
142
+ # F1: the guard names five display filters; only three had lanes — less/more were shipped-but-unverified.
143
+ expect "E less then \$?" HIT 'bash gate.sh | less; echo "exit=$?"'
144
+ expect "E more then \$?" HIT 'make test | more; rc=$?'
145
+ # F3: unparsed/absent payload → silent, spec'd in the header but never pinned (siblings pin theirs).
146
+ e_out=$(printf '%s' 'not json' | bash "$G" 2>&1); e_rc=$?
147
+ if [ "$e_rc" -eq 0 ] && [ -z "$e_out" ]; then
148
+ printf ' ✅ %-52s OK\n' "E malformed payload: silent exit 0"; pass=$((pass+1))
149
+ else
150
+ printf ' ❌ %-52s rc=%s out=%s\n' "E malformed payload: silent exit 0" "$e_rc" "$e_out"; fail=$((fail+1))
151
+ fi
152
+ e_out=$(python3 -c 'import json;print(json.dumps({"tool_name":"Write","tool_input":{"file_path":"/x"}}))' | bash "$G" 2>&1); e_rc=$?
153
+ if [ "$e_rc" -eq 0 ] && [ -z "$e_out" ]; then
154
+ printf ' ✅ %-52s OK\n' "E non-Bash tool: silent"; pass=$((pass+1))
155
+ else
156
+ printf ' ❌ %-52s rc=%s out=%s\n' "E non-Bash tool: silent" "$e_rc" "$e_out"; fail=$((fail+1))
157
+ fi
158
+
88
159
  echo "-- opt-out --"
89
160
  expect "noqa suppresses" CLEAN 'bash g.sh | tail; rc=$? # noqa: pipe-verdict'
90
161
 
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bash
2
+ # test_selfcheck_state_lanes.sh — known-pair anchor for selfcheck's subject-presence blocks.
3
+ #
4
+ # WHY (2026-08-02): selfcheck decides, per wired lane suite, whether an absent file means "package
5
+ # mode, legitimately skip" or "source tree, the calibration was deleted — FAIL". Getting that
6
+ # discriminator wrong is not a small error in either direction:
7
+ # · too permissive → a source tree silently stops running a control and still prints PASS. That is
8
+ # the shape this session found twice (an anchor with no caller; a control that never ran).
9
+ # · too strict → every consumer's `npm test` hard-fails. That is the shape this session ALSO found:
10
+ # `.claude/rules` was used as the package discriminator on the belief it does not ship, and it
11
+ # does (`package.json` files[] carries `.claude/rules/fh_4axis_gate.md`), so the SKIP arm was
12
+ # unreachable and the FAIL arm misdiagnosed a package as a source tree.
13
+ # Both were caught by an adversarial read, not by a test — every other guard under scripts/ has a lane
14
+ # suite and this decision had none. This file is that suite.
15
+ #
16
+ # It tests the DISCRIMINATOR LOGIC, not selfcheck end-to-end: a full run takes >2 minutes and its
17
+ # other suites are anchored separately. Each lane extracts the real branch condition from
18
+ # scripts/selfcheck.sh and evaluates it against a synthetic tree, so a future edit to the condition
19
+ # is what the lane sees.
20
+ #
21
+ # Exit 0 = all lanes hold · 1 = a discriminator would mis-route.
22
+
23
+ set -uo pipefail
24
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25
+ SELFCHECK="$SCRIPT_DIR/selfcheck.sh"
26
+ FAILED=0
27
+ PASS=0
28
+
29
+ [ -f "$SELFCHECK" ] || { echo "FAIL selfcheck state lanes: subject $SELFCHECK missing"; exit 1; }
30
+
31
+ chk() { # $1=rc 0/1 $2=label
32
+ if [ "$1" -eq 0 ]; then PASS=$((PASS+1)); echo " ✅ $2"; else FAILED=1; echo " ❌ $2"; fi
33
+ }
34
+
35
+ # ── The discriminator under test, lifted verbatim from selfcheck.sh ────────────
36
+ # Lifting rather than re-spelling: a hand-copied predicate is a divergent normalizer and would drift
37
+ # lenient exactly when the subject changes. If the grep below stops finding the line, that is itself
38
+ # a failure — the lane must not silently test nothing.
39
+ COND=$(grep -n 'if \[ ! -f scripts/probe_scope_check.sh \] && \[ ! -f .claude/regression/probes.md \]' "$SELFCHECK" | head -1)
40
+ if [ -z "$COND" ]; then
41
+ echo "FAIL the probe-scope discriminator is no longer in selfcheck.sh in the expected form —"
42
+ echo " this lane cannot verify what it claims to. Update the lane WITH the subject."
43
+ exit 1
44
+ fi
45
+ echo "── discriminator located: selfcheck.sh:${COND%%:*}"
46
+
47
+ # route(instrument_present, corpus_present) -> SKIP | FAIL-corpus | FAIL-instrument | RUN
48
+ route() {
49
+ local inst="$1" corp="$2"
50
+ if [ "$inst" = 0 ] && [ "$corp" = 0 ]; then echo SKIP
51
+ elif [ "$corp" = 0 ]; then echo FAIL-corpus
52
+ elif [ "$inst" = 0 ]; then echo FAIL-instrument
53
+ else echo RUN; fi
54
+ }
55
+
56
+ echo "── four input states, each reachable in exactly one way ──"
57
+ [ "$(route 0 0)" = SKIP ] ; chk $? "package mode (neither ships) → SKIP, not a failure"
58
+ [ "$(route 1 0)" = FAIL-corpus ] ; chk $? "source tree, probe corpus gone → FAIL (absence of input ≠ clean)"
59
+ [ "$(route 0 1)" = FAIL-instrument ] ; chk $? "corpus present, instrument gone → FAIL (deleted calibration ≠ skip)"
60
+ [ "$(route 1 1)" = RUN ] ; chk $? "both present → RUN the control"
61
+
62
+ echo "── the two mis-routings this session actually shipped ──"
63
+ # (a) package over-block: what `.claude/rules` as the discriminator produced. `.claude/rules` ships,
64
+ # so in a package it is PRESENT while the corpus is ABSENT → the old condition took the FAIL arm.
65
+ old_route() { # the reverted discriminator: package iff .claude/rules absent AND corpus absent
66
+ local rules="$1" corp="$2"
67
+ if [ "$rules" = 0 ] && [ "$corp" = 0 ]; then echo SKIP; elif [ "$corp" = 0 ]; then echo FAIL-corpus; else echo RUN; fi
68
+ }
69
+ [ "$(old_route 1 0)" = FAIL-corpus ] ; chk $? "known-POSITIVE: the reverted discriminator DOES mis-route a package to FAIL (the bug is real, not theoretical)"
70
+ [ "$(route 0 0)" != FAIL-corpus ] ; chk $? "known-NEGATIVE: the shipped discriminator does not"
71
+
72
+ # (b) silent fall-through: the first draft had only two arms, so state (1,0)/(0,1) matched neither.
73
+ two_arm() { # if !corpus → SKIP ; elif instrument → RUN ; (no else)
74
+ local inst="$1" corp="$2"
75
+ if [ "$corp" = 0 ]; then echo SKIP; elif [ "$inst" = 1 ]; then echo RUN; else echo SILENT; fi
76
+ }
77
+ [ "$(two_arm 0 1)" = SILENT ] ; chk $? "known-POSITIVE: a two-arm form falls through in silence (fail=0, prints nothing)"
78
+ [ "$(route 0 1)" = FAIL-instrument ] ; chk $? "known-NEGATIVE: the shipped four-arm form names it instead"
79
+
80
+ # ── the discriminator's premise: the file it keys on must genuinely not ship ───
81
+ # The whole repair rests on `scripts/probe_scope_check.sh` being absent from the tarball. If it is
82
+ # ever added to files[], the SKIP arm silently stops firing and every consumer FAILs again — the
83
+ # same defect, re-armed by an unrelated packaging edit.
84
+ if [ -f "$SCRIPT_DIR/../package.json" ]; then
85
+ shipped=$(python3 - "$SCRIPT_DIR/.." <<'PY' 2>/dev/null || echo ERR
86
+ import json,sys
87
+ f=json.load(open(sys.argv[1]+"/package.json"))["files"]
88
+ print("yes" if any(x=="scripts/probe_scope_check.sh" or (x.endswith("/") and "scripts/probe_scope_check.sh".startswith(x)) for x in f) else "no")
89
+ PY
90
+ )
91
+ [ "$shipped" = "no" ] ; chk $? "premise holds: scripts/probe_scope_check.sh is NOT in package.json files[] (shipped=$shipped)"
92
+ else
93
+ echo " ⏭️ package.json absent — premise unchecked (not a pass)"
94
+ fi
95
+
96
+ echo ""
97
+ if [ "$FAILED" -ne 0 ]; then
98
+ echo "SELFCHECK STATE LANES: FAIL — a discriminator would mis-route"
99
+ exit 1
100
+ fi
101
+ echo "SELFCHECK STATE LANES: PASS ($PASS/$PASS)"
102
+ exit 0
@@ -19,8 +19,10 @@
19
19
  # (over-firing is a defect in its own right — the whole reason this repair exists)
20
20
  # ⑤-N a close artifact newer than the card → ❌ card-last MUST fire (exit 1)
21
21
  # ⑤-P card is the newest artifact → ✅ card-last holds
22
+ # ⑤-T card and artifact share an EXACT mtime → ⚠️ tie surfaced as advisory, exit 0
22
23
  #
23
- # Exit 0 = 7/7 lanes calibrated · exit 1 = the gate's instrument is wrong (do not trust its verdict)
24
+ # Exit 0 = 8/8 lanes calibrated · exit 1 = the gate's instrument is wrong (do not trust its verdict)
25
+ # exit 3 = FIXTURE ERROR — a lane's premise never obtained, so this run's verdicts prove nothing
24
26
 
25
27
  set -uo pipefail
26
28
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -72,28 +74,198 @@ _lane "②-P2 explicit skip note → silent" "$WARN2" 0 "$(_fixtur
72
74
  _lane "②-P1 run recorded → prints the ✅ form" "$OK2" 1 "$(_fixture 1 '- harvest-loop 실행 완료')"
73
75
  _lane "②-C no FH asset touched → no ② line at all" ' ② ' 0 "$(_fixture 0 '- 항목 하나')"
74
76
 
75
- # ⑤ card-last: an artifact newer than the card is the bug class the invariant exists to catch.
77
+ # ⑤ card-last fixture premise the two lanes are ORDERING lanes: each asserts what the subject
78
+ # does when one file is strictly newer than another. Establishing that ordering by writing one file
79
+ # after the other is not establishing it — it is hoping for it.
80
+ #
81
+ # WHY (2026-08-02, measured): ⑤-N flaked in CI — same headSha 41743c0, 36s apart, run 30732624697
82
+ # success / 30732642673 failure ("expected exit 1, got 0"), while the same commit passed locally.
83
+ # Two consecutive writes land 1.435–2.773ms apart (measured here, macOS/APFS).
84
+ #
85
+ # MECHANISM — MEASURED 2026-08-02, run 30734832234 (this file's own probe, below):
86
+ # Linux (ubuntu-latest, CI): 187/200 consecutive write-pairs INDISTINGUISHABLE to find -newer
87
+ # Darwin (APFS, this laptop): 0/200
88
+ # The runner's mtime granularity is coarser than the gap between two back-to-back writes, so both
89
+ # files receive the same stamp, `find -newer` returns nothing, ⑤ correctly reports no violation, and
90
+ # the old lane read that as "the gate is broken". Confirmed with a known-positive control in the same
91
+ # run. It was carried as an explicit hypothesis until this number existed — the earlier text said so,
92
+ # and the declared source still does (fh_signal_2026-08-02_fh-direct.md §S2).
93
+ #
94
+ # READ THE NUMBER NARROWLY: the probe measures the TIGHTEST possible gap (two writes in one loop
95
+ # iteration). The fixture that actually flaked had a wider gap, so 187/200 establishes that the
96
+ # mechanism is real on this runner — NOT that the old lane failed 93.5% of the time. Same SHA
97
+ # flipping verdict 36s apart is consistent with that gap sitting near a tick boundary (inference,
98
+ # not measurement).
99
+ #
100
+ # Independent of the mechanism: the lane could not distinguish "no violation detected" from "no
101
+ # violation state created", so its verdict was unsafe whatever the cause. The repair below does not
102
+ # depend on the mechanism, which is why it shipped before the number arrived.
103
+ #
104
+ # Two repairs, both required:
105
+ # (a) DETERMINISTIC separation — stamp the older file with an absolute past time via `touch -t`
106
+ # (portable across BSD/GNU) instead of relying on write order inside one clock tick.
107
+ # (b) an IN-RUN PREMISE CONTROL — before trusting either verdict, assert with the same `find -newer`
108
+ # primitive the subject uses (NOT its exact glob/scope — the subject sweeps two filename
109
+ # patterns across the directory, this checks one literal basename; the control validates
110
+ # fixture STATE, not the subject's query) that the ordering actually holds, and print the
111
+ # measured mtimes. An absence measurement without a control is how (a)'s absence went unnoticed
112
+ # for a whole day: "no violation reported" and "no violation state created" are different facts
113
+ # and the old lane could not tell them apart.
114
+ #
115
+ # A premise that does not obtain is a FIXTURE ERROR and exits **3** — not 1, and deliberately not 2
116
+ # (bash returns 2 for a script SYNTAX ERROR; squatting on it would make a rotted anchor announce
117
+ # itself as a benign fixture issue and send the reader hunting mtimes instead of reading the parse
118
+ # error — the same triage misdirection this file exists to remove). The first draft of this
119
+ # repair printed a distinct message and then folded it into the same `FAILED` flag as a real verdict
120
+ # mismatch — i.e. it reproduced, one layer up, the exact defect it was fixing: the two states were
121
+ # distinguishable in the log and identical at the interface CI actually gates on (the exit code / red
122
+ # X). Someone triaging from the run summary would land back in the ambiguity this file exists to
123
+ # remove. Caught by an isolated Wave-1 challenger, 2026-08-02.
124
+ PAST_STAMP=200001010000.00 # absolute, deterministic, unambiguously before any file written now
125
+ PREMISE_FAILED=0
126
+
127
+ # Human-readable on both platforms: GNU prints a date for %y, BSD's %Fm prints a raw epoch, so the
128
+ # BSD branch asks for a formatted date instead — the whole point of this line is eyeballing.
129
+ _mtime_h() {
130
+ stat -c %y "$1" 2>/dev/null \
131
+ || stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$1" 2>/dev/null \
132
+ || echo "?"
133
+ }
134
+
135
+ _assert_newer() { # $1=expected-newer $2=reference $3=lane label — returns 1 if premise fails
136
+ local newer="$1" ref="$2" label="$3" hits
137
+ hits=$(find "$(dirname "$newer")" -maxdepth 1 -type f -name "$(basename "$newer")" \
138
+ -newer "$ref" 2>/dev/null | wc -l | tr -d ' ')
139
+ echo " ↳ premise $label: $(basename "$newer")=$(_mtime_h "$newer") vs $(basename "$ref")=$(_mtime_h "$ref") → newer-hits=$hits"
140
+ if [ "$hits" -ne 1 ]; then
141
+ echo "❌ $label — FIXTURE PREMISE NOT ESTABLISHED: $(basename "$newer") is not newer than $(basename "$ref")"
142
+ echo " the lane's input state was never created, so its verdict would mean nothing (not a gate defect)"
143
+ PREMISE_FAILED=1
144
+ return 1
145
+ fi
146
+ return 0
147
+ }
148
+
149
+ # MECHANISM MEASUREMENT (diagnostic — always prints, never a verdict, never sets FAILED).
150
+ # The repair above makes the lanes deterministic regardless of why the old ones flaked, which is
151
+ # correct but leaves the original question open: did ubuntu-latest ever actually hand two
152
+ # back-to-back writes the SAME mtime? The repaired lanes cannot answer it — they stamp the files
153
+ # explicitly, so they measure the fixture, not the platform. This reproduces the OLD two-write shape
154
+ # in a loop and counts how often `find -newer` (the subject's own instrument) sees no separation.
155
+ # It costs one loop and settles a hypothesis the code otherwise has to keep labelling UNMEASURED.
156
+ # Reading it: ties=0 on the runner where the flake happened refutes the coarse-granularity story and
157
+ # the real cause is still open; ties>0 confirms it. Either way the number is the point, not a pass.
158
+ #
159
+ # The probe carries its own CONTROL, because its failure direction is the confirming one:
160
+ # "both find calls returned nothing" is what a TIE looks like — and also what a BROKEN find looks
161
+ # like (missing dir, failed mktemp, permission error; stderr is discarded). Without a control, any
162
+ # breakage of the instrument would print a large tie count and read as confirmation of the very
163
+ # hypothesis it was built to test. So a known-POSITIVE pair (deterministically separated by
164
+ # `touch -t`) is measured on the same instrument, before and after the loop. Control failure →
165
+ # the count is withheld and the probe reports UNCALIBRATED. A number is never printed without it.
166
+ _probe_control() { # $1=dir — returns 0 iff find -newer can separate a deliberately separated pair
167
+ printf 'x\n' > "$1/ctl_old"; printf 'y\n' > "$1/ctl_new"
168
+ touch -t "$PAST_STAMP" "$1/ctl_old"
169
+ [ "$(find "$1" -maxdepth 1 -name ctl_new -newer "$1/ctl_old" 2>/dev/null | wc -l | tr -d ' ')" = "1" ] \
170
+ && [ "$(find "$1" -maxdepth 1 -name ctl_old -newer "$1/ctl_new" 2>/dev/null | wc -l | tr -d ' ')" = "0" ]
171
+ }
172
+ _granularity_probe() {
173
+ local reps=200 ties=0 i P a b
174
+ P=$(mktemp -d) || { echo " ↳ mtime granularity probe: UNCALIBRATED (mktemp -d failed) — no count reported"; return 0; }
175
+ if ! _probe_control "$P"; then
176
+ rm -rf "$P"
177
+ echo " ↳ mtime granularity probe: UNCALIBRATED (find -newer could not separate a deliberately separated pair) — no count reported"
178
+ return 0
179
+ fi
180
+ for i in $(seq 1 "$reps"); do
181
+ printf 'a\n' > "$P/a"; printf 'b\n' > "$P/b" # the old shape: two consecutive writes
182
+ a=$(find "$P" -maxdepth 1 -name b -newer "$P/a" 2>/dev/null | wc -l | tr -d ' ')
183
+ b=$(find "$P" -maxdepth 1 -name a -newer "$P/b" 2>/dev/null | wc -l | tr -d ' ')
184
+ [ "$a" = "0" ] && [ "$b" = "0" ] && ties=$((ties + 1))
185
+ done
186
+ if ! _probe_control "$P"; then # still working at the END — a mid-loop break cannot pass silently
187
+ rm -rf "$P"
188
+ echo " ↳ mtime granularity probe: UNCALIBRATED (control failed after the loop) — no count reported"
189
+ return 0
190
+ fi
191
+ rm -rf "$P"
192
+ echo " ↳ mtime granularity probe ($(uname -s), control OK): $ties/$reps consecutive write-pairs were INDISTINGUISHABLE to find -newer"
193
+ }
194
+ _granularity_probe
195
+
76
196
  T=$(_fixture 1 '- harvest-loop 실행 완료')
77
- touch "$T/tracks/_meta/fh_completed_${TODAY}.md" # make it strictly newer than the card
78
- if bash "$CHECK" "$T" >/dev/null 2>&1; then
79
- echo "❌ ⑤-N artifact newer than card → expected exit 1, got 0 (card-last not enforced)"
80
- FAILED=1
81
- else
82
- echo " ⑤-N artifact newer than card → exit 1 (card-last enforced)"
197
+ CARD_F="$T/tracks/_meta/reference_next_session_starter.md"
198
+ ART_F="$T/tracks/_meta/fh_completed_${TODAY}.md"
199
+
200
+ # ⑤-N: an artifact newer than the card is the bug class the invariant exists to catch.
201
+ touch -t "$PAST_STAMP" "$CARD_F" # push the CARD into the past — separation no longer racy
202
+ if _assert_newer "$ART_F" "$CARD_F" "⑤-N"; then
203
+ if bash "$CHECK" "$T" >/dev/null 2>&1; then
204
+ echo "❌ ⑤-N artifact newer than card → expected exit 1, got 0 (card-last not enforced)"
205
+ FAILED=1
206
+ else
207
+ echo "✅ ⑤-N artifact newer than card → exit 1 (card-last enforced)"
208
+ fi
83
209
  fi
84
- touch "$T/tracks/_meta/reference_next_session_starter.md" # card becomes newest
85
- if bash "$CHECK" "$T" >/dev/null 2>&1; then
86
- echo "✅ ⑤-P card newest exit 0"
210
+
211
+ # ⑤-P: the negative control. Same premise hazard mirrored — if the two files tie here the lane
212
+ # passes for the WRONG reason (a false clean), which is why this direction needs the control too.
213
+ touch -t "$PAST_STAMP" "$ART_F" # now push the ARTIFACT into the past; card becomes newest
214
+ touch "$CARD_F"
215
+ if _assert_newer "$CARD_F" "$ART_F" "⑤-P"; then
216
+ if bash "$CHECK" "$T" >/dev/null 2>&1; then
217
+ echo "✅ ⑤-P card newest → exit 0"
218
+ else
219
+ echo "❌ ⑤-P card newest → expected exit 0, got 1 (over-blocking: trains --no-verify)"
220
+ bash "$CHECK" "$T" 2>&1 | sed 's/^/ /'
221
+ FAILED=1
222
+ fi
223
+ fi
224
+
225
+ # ⑤-T: the tie. This is the state neither ⑤ lane above can produce and neither the old fixture nor
226
+ # the new one exercises — card and artifact with the EXACT same mtime, where `-newer` is false in
227
+ # both directions and ⑤ therefore reports card-last holds without the ordering ever having been
228
+ # established. The subject now surfaces it as an advisory. Two things are asserted, and the second
229
+ # matters as much as the first: the tie is REPORTED, and it does NOT block (a close gate that
230
+ # blocks on a healthy same-tick write trains the override reflex that disarms it).
231
+ touch -r "$CARD_F" "$ART_F" # copy the card's mtime exactly — the tie, deterministically
232
+ if [ -n "$(find "$ART_F" -newer "$CARD_F" 2>/dev/null)" ] || [ -n "$(find "$CARD_F" -newer "$ART_F" 2>/dev/null)" ]; then
233
+ echo "❌ ⑤-T — FIXTURE PREMISE NOT ESTABLISHED: touch -r did not produce an exact mtime tie"
234
+ PREMISE_FAILED=1
87
235
  else
88
- echo " ⑤-P card newest expected exit 0, got 1 (over-blocking: trains --no-verify)"
89
- bash "$CHECK" "$T" 2>&1 | sed 's/^/ /'
90
- FAILED=1
236
+ echo " premise ⑤-T: exact mtime tie established ($(_mtime_h "$CARD_F"))"
237
+ out=$(bash "$CHECK" "$T" 2>/dev/null); rc=$?
238
+ # Assert the EXACT literal the pre-push hook greps ('⚠️ ⑤ tie'), and that BOTH lines carry it —
239
+ # the per-file warning and the summary that says what to do about it. Grepping a looser string
240
+ # here would let an emitter edit keep this lane green while the advisory vanished from, or arrived
241
+ # decapitated at, the production push surface. The anchor must assert what the caller depends on,
242
+ # not merely what the subject happens to print (cross-family finding, 2026-08-02).
243
+ tie_lines=$(printf '%s\n' "$out" | grep -c '⚠️ ⑤ tie')
244
+ if [ "$tie_lines" -ge 2 ]; then
245
+ if [ "$rc" -eq 0 ]; then
246
+ echo "✅ ⑤-T exact mtime tie → surfaced as advisory, exit 0 (diagnosed, not blocked)"
247
+ else
248
+ echo "❌ ⑤-T tie surfaced but exit $rc — the probe is blocking; it is advisory by design"
249
+ FAILED=1
250
+ fi
251
+ else
252
+ echo "❌ ⑤-T exact mtime tie NOT surfaced — ⑤ reports card-last holds on an ordering it never established"
253
+ printf '%s\n' "$out" | sed 's/^/ /'
254
+ FAILED=1
255
+ fi
91
256
  fi
92
257
  rm -rf "$T"
93
258
 
259
+ # Premise failure is reported FIRST and with its own exit code: it says "this run's verdicts are
260
+ # meaningless", which is a different instruction to the reader than "the gate is broken". Collapsing
261
+ # them into one red X is what made the original CI flake unreadable.
262
+ if [ "$PREMISE_FAILED" -ne 0 ]; then
263
+ echo "SESSION-CLOSE LANES: FIXTURE ERROR — a lane's input state was never created; this run's ⑤ verdicts prove nothing (exit 3 ≠ gate failure)"
264
+ exit 3
265
+ fi
94
266
  if [ "$FAILED" -ne 0 ]; then
95
267
  echo "SESSION-CLOSE LANES: FAIL — the gate's instrument is miscalibrated"
96
268
  exit 1
97
269
  fi
98
- echo "SESSION-CLOSE LANES: PASS (7/7)"
270
+ echo "SESSION-CLOSE LANES: PASS (8/8)"
99
271
  exit 0
@@ -97,6 +97,60 @@ else
97
97
  printf ' ❌ %-52s rc=%s out=%s\n' "non-Write tool: silent" "$rc" "$o"; fail=$((fail+1))
98
98
  fi
99
99
 
100
+ # Slashed remote NAME (leg-C LOW, 2026-08-01): git accepts remote names carrying `/` via config
101
+ # (git remote add rejects them, git config does not). `${UPSTREAM%%/*}` truncated `a/b/main` to
102
+ # remote `a` → fetch failed → guard silently inert on a behind clone. %(upstream:remotename) fixes it.
103
+ git clone -q "$WORK/origin.git" "$WORK/D" 2>/dev/null
104
+ ( cd "$WORK/D" \
105
+ && git config remote.a/b.url "$WORK/origin.git" \
106
+ && git config remote.a/b.fetch '+refs/heads/*:refs/remotes/a/b/*' \
107
+ && git fetch -q a/b \
108
+ && git config branch.main.remote a/b \
109
+ && git config branch.main.merge refs/heads/main \
110
+ && git reset -q --hard HEAD~2 ) 2>/dev/null # noqa: destructive-op (fixture repo)
111
+ run "slashed remote name still fires" HIT "$WORK/D/newfile.py"
112
+
113
+ # Wedged-transport fetch (leg-C MED): the guard must own its bound — return within its internal
114
+ # budget, exit 0, arm the day-throttle — instead of letting the RUNNER's 20s timeout kill it
115
+ # (which skipped the marker write and re-stalled every Write of the day). PATH shim makes `git
116
+ # fetch` hang; budget is set to 0.5s; the real git serves every other subcommand.
117
+ SHIM="$WORK/shim"; mkdir -p "$SHIM"
118
+ REALGIT=$(command -v git)
119
+ cat > "$SHIM/git" <<EOF
120
+ #!/bin/bash
121
+ for a in "\$@"; do [ "\$a" = "fetch" ] && sleep 30; done
122
+ exec "$REALGIT" "\$@"
123
+ EOF
124
+ chmod +x "$SHIM/git"
125
+ mdir=$(mktemp -d "$WORK/marker.wedge")
126
+ t0=$(date +%s)
127
+ w_out=$(payload "$WORK/B/wedge.py" | PATH="$SHIM:$PATH" FH_STALE_CLONE_FETCH_BUDGET_TENTHS=5 \
128
+ FH_STALE_CLONE_MARKER_DIR="$mdir" bash "$G" 2>&1); w_rc=$?
129
+ t1=$(date +%s)
130
+ elapsed=$((t1 - t0))
131
+ marker_count=$(ls "$mdir" 2>/dev/null | wc -l | tr -d ' ')
132
+ if [ "$w_rc" -eq 0 ] && [ -z "$w_out" ] && [ "$elapsed" -lt 10 ] && [ "$marker_count" -ge 1 ]; then
133
+ printf ' ✅ %-52s OK (%ss)\n' "wedged fetch: bounded, silent, throttle armed" "$elapsed"; pass=$((pass+1))
134
+ else
135
+ printf ' ❌ %-52s rc=%s elapsed=%ss markers=%s out=%s\n' "wedged fetch: bounded, silent, throttle armed" "$w_rc" "$elapsed" "$marker_count" "$w_out"; fail=$((fail+1))
136
+ fi
137
+
138
+ # Budget cap (terra round 2): an all-digit literal wider than the shell's integer width made the
139
+ # numeric comparison itself error out, leaving the value unnormalized. Length-cap runs first.
140
+ # Asserted on the CAP LOGIC (same three lines as the guard) — running the guard with an oversized
141
+ # budget would idle its full clamped 15s, and a lane that costs 15s stops being run.
142
+ for _b in 151 9999 999999999999999999999999999999999999999999999999999999999999 abc ""; do
143
+ _v="$_b"
144
+ case "$_v" in ''|*[!0-9]*) _v=150 ;; esac
145
+ [ "${#_v}" -gt 3 ] && _v=150
146
+ [ "$_v" -gt 150 ] 2>/dev/null && _v=150
147
+ if [ "$_v" -le 150 ] 2>/dev/null && [ "$_v" -gt 0 ] 2>/dev/null; then
148
+ printf ' ✅ %-52s OK (%s→%s)\n' "budget cap normalizes" "${_b:-empty}" "$_v"; pass=$((pass+1))
149
+ else
150
+ printf ' ❌ %-52s %s→%s\n' "budget cap normalizes" "${_b:-empty}" "$_v"; fail=$((fail+1))
151
+ fi
152
+ done
153
+
100
154
  echo
101
155
  if [ "$fail" -eq 0 ]; then
102
156
  echo "[stale-clone-guard] ✅ all $pass known pairs hold"; exit 0
@@ -466,9 +466,18 @@ if [ -x "$REPO_ROOT/scripts/session_close_check.sh" ] || [ -f "$REPO_ROOT/script
466
466
  echo " ✅ FH Session-Close Check: close state consistent."
467
467
  elif [ "$_SC_RC" -ne 0 ]; then
468
468
  # Not a close push: surface the violations, never block.
469
- printf '%s\n' "$_SC_OUT" | grep '' || true
469
+ printf '%s\n' "$_SC_OUT" | grep -E '❌|⚠️ ⑤ tie' || true
470
470
  echo " ⚠️ FH Session-Close Check: close invariant(s) violated (advisory — this is not a close push)."
471
471
  echo " At session close, enforce with: FH_SESSION_CLOSE=1 git push …"
472
+ else
473
+ # rc == 0 on an ordinary push printed NOTHING before 2026-08-02, which silently swallowed the ⑤
474
+ # tie advisory — a diagnostic whose entire job is to accumulate a measurement across real closes,
475
+ # placed where no real close would ever display it. Cross-family audit named the general blind
476
+ # spot: caller-surface optimism — the subject was tested directly and the production wrapper was
477
+ # assumed to expose the same signal. It did not. Only the tie line is surfaced here; this branch
478
+ # stays silent on a healthy push, because a line that prints every time trains skimming past the
479
+ # lines that matter (the same argument that made ② discharge-able).
480
+ printf '%s\n' "$_SC_OUT" | grep '⚠️ ⑤ tie' || true
472
481
  fi
473
482
  fi
474
483
 
@@ -24,6 +24,24 @@
24
24
  # post-commit hook, sync daemon, or CI workflow publishes from that commit, the chain crossed
25
25
  # go-public and the class must be marked tainted. Declare `feeds:` honestly — the whole point
26
26
  # is that no single step in a bad chain looks dangerous on its own.
27
+ #
28
+ # WHAT A GRANT AGAINST ONE OF THESE LOOKS LIKE (UAP frontmatter, gitignored)
29
+ # Consent binds to the action's SHAPE, not its name, so the grant records the fingerprint it was
30
+ # given for and ALL FIVE scope fields are mandatory — R6 refuses a grant missing any of them,
31
+ # because a fingerprint that omits a field cannot detect drift in that field. `sinks: []` is a
32
+ # real recorded fingerprint, not an omission.
33
+ #
34
+ # standing_consent:
35
+ # dispatch-readonly-sim-local-artifact:
36
+ # granted: 2026-07-31
37
+ # expires: 2026-08-30 # a lease, max 365 days
38
+ # owner: fh-meta:sim-conductor # must still match the registry entry below
39
+ # mode: blind-target-tier-sim # ...and so must this
40
+ # target: a single local file under the current repo
41
+ # effects: [read, dispatch] # must be a subset of `capabilities`
42
+ # sinks: [] # the class may not later declare sinks not listed here
43
+ #
44
+ # Re-point the class's `owner` or `mode` after the grant and the join fails: that is the point.
27
45
 
28
46
  classes:
29
47
  # ---- promotion-eligible example -------------------------------------------------