@chrono-meta/fh-gate 1.4.97 → 1.4.99

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 (44) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CATALOG.md +19 -0
  3. package/CHEATSHEET.md +9 -1
  4. package/CLAUDE.md +28 -2
  5. package/README.ja.md +229 -42
  6. package/README.ko.md +241 -45
  7. package/README.md +168 -31
  8. package/README.zh.md +219 -40
  9. package/docs/OUTPUT_EVIDENCE.md +21 -12
  10. package/docs/pillars.svg +3 -7
  11. package/knowledge/shared/harness-core/fh_ecosystem_positioning.md +2 -0
  12. package/knowledge/shared/harness-core/fh_global_positioning_and_distribution_roadmap.md +136 -0
  13. package/knowledge/shared/harness-core/fh_three_layer_canon.md +20 -0
  14. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +215 -2
  15. package/knowledge/shared/harness-core/ship_readiness_gate.md +112 -0
  16. package/knowledge/shared/learnings/subagent_invocations_log.yaml +65 -0
  17. package/package.json +5 -1
  18. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  19. package/plugins/fh-commons/skills/ko-tech-writer/SKILL.md +63 -12
  20. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  21. package/plugins/fh-meta/CHANGELOG.md +166 -0
  22. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +30 -0
  23. package/scripts/consent_registry_check.sh +124 -1
  24. package/scripts/degrade_direction_scan.sh +10 -1
  25. package/scripts/digest_landing_check.sh +20 -4
  26. package/scripts/fh_node_check.sh +128 -1
  27. package/scripts/fh_session_load.sh +22 -2
  28. package/scripts/frontier_digest_autopilot.sh +229 -0
  29. package/scripts/lane_runner_check.sh +294 -26
  30. package/scripts/package_coverage_check.sh +17 -0
  31. package/scripts/postinstall_notice.js +34 -0
  32. package/scripts/selfcheck.sh +183 -5
  33. package/scripts/test_consent_registry.sh +99 -0
  34. package/scripts/test_degrade_scan_shell_probes.sh +75 -0
  35. package/scripts/test_field_canon_lanes.sh +29 -5
  36. package/scripts/test_lane_runner_lanes.sh +295 -0
  37. package/scripts/test_node_check_lanes.sh +217 -0
  38. package/scripts/test_selfcheck_state_lanes.sh +61 -0
  39. package/scripts/test_stale_clone_guard_lanes.sh +21 -7
  40. package/scripts/test_version_lockstep_lanes.sh +62 -0
  41. package/scripts/version_lockstep_check.sh +143 -1
  42. package/templates/.git-hooks/pre-commit +22 -1
  43. package/templates/consent_classes.yaml.example +30 -0
  44. package/templates/degrade_direction_scan.sh +10 -1
@@ -58,7 +58,7 @@ git clone -q "$WORK/origin.git" "$WORK/C" 2>/dev/null
58
58
  run "non-origin remote name still fires" HIT "$WORK/C/newfile.py"
59
59
 
60
60
  # Throttle: SAME marker dir, two calls — second must be silent.
61
- mdir=$(mktemp -d "$WORK/marker.throttle")
61
+ mdir=$(mktemp -d "$WORK/marker.throttle.XXXXXX")
62
62
  o1=$(payload "$WORK/B/x.py" | FH_STALE_CLONE_NO_FETCH=1 FH_STALE_CLONE_MARKER_DIR="$mdir" bash "$G" 2>&1)
63
63
  o2=$(payload "$WORK/B/y.py" | FH_STALE_CLONE_NO_FETCH=1 FH_STALE_CLONE_MARKER_DIR="$mdir" bash "$G" 2>&1)
64
64
  if printf '%s' "$o1" | grep -q STALE-CLONE && [ -z "$o2" ]; then
@@ -68,7 +68,7 @@ else
68
68
  fi
69
69
 
70
70
  # Contract: HIT emits one JSON object with both channels and no permissionDecision.
71
- mdir=$(mktemp -d "$WORK/marker.json")
71
+ mdir=$(mktemp -d "$WORK/marker.json.XXXXXX")
72
72
  jo=$(payload "$WORK/B/z.py" | FH_STALE_CLONE_NO_FETCH=1 FH_STALE_CLONE_MARKER_DIR="$mdir" bash "$G" 2>/dev/null)
73
73
  if printf '%s' "$jo" | python3 -c '
74
74
  import json,sys
@@ -114,25 +114,39 @@ run "slashed remote name still fires" HIT "$WORK/D/newfile.py"
114
114
  # budget, exit 0, arm the day-throttle — instead of letting the RUNNER's 20s timeout kill it
115
115
  # (which skipped the marker write and re-stalled every Write of the day). PATH shim makes `git
116
116
  # fetch` hang; budget is set to 0.5s; the real git serves every other subcommand.
117
+ #
118
+ # DEBT fix (2026-08-14): `elapsed -lt 10` was a bare wall-clock assertion on a mandatory path — a
119
+ # loaded CI runner's fork/exec + scheduling overhead can push actual elapsed time past a fixed
120
+ # small constant even when the guard's OWN 0.5s internal budget fired correctly (named and
121
+ # predicted by an adversarial review of the original delta; CI then reproduced it, 2/17 lanes red
122
+ # where local was 17/17). What this lane actually needs to prove is RELATIVE, not absolute: the
123
+ # guard returned because its budget bounded it, not because the wedge itself woke up (30s) or an
124
+ # external timeout killed the process. So: widen the wedge to 60s (cheap — the guard should never
125
+ # come close to waiting it out) and assert elapsed is well under HALF of that, not a small fixed
126
+ # number. This keeps strong discriminating power (a guard that stopped bounding itself would still
127
+ # blow well past 30s) while absorbing CI-runner overhead that has nothing to do with the guard's
128
+ # own logic.
129
+ WEDGE_SLEEP=60
117
130
  SHIM="$WORK/shim"; mkdir -p "$SHIM"
118
131
  REALGIT=$(command -v git)
119
132
  cat > "$SHIM/git" <<EOF
120
133
  #!/bin/bash
121
- for a in "\$@"; do [ "\$a" = "fetch" ] && sleep 30; done
134
+ for a in "\$@"; do [ "\$a" = "fetch" ] && sleep $WEDGE_SLEEP; done
122
135
  exec "$REALGIT" "\$@"
123
136
  EOF
124
137
  chmod +x "$SHIM/git"
125
- mdir=$(mktemp -d "$WORK/marker.wedge")
138
+ mdir=$(mktemp -d "$WORK/marker.wedge.XXXXXX")
126
139
  t0=$(date +%s)
127
140
  w_out=$(payload "$WORK/B/wedge.py" | PATH="$SHIM:$PATH" FH_STALE_CLONE_FETCH_BUDGET_TENTHS=5 \
128
141
  FH_STALE_CLONE_MARKER_DIR="$mdir" bash "$G" 2>&1); w_rc=$?
129
142
  t1=$(date +%s)
130
143
  elapsed=$((t1 - t0))
144
+ elapsed_cap=$((WEDGE_SLEEP / 2))
131
145
  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))
146
+ if [ "$w_rc" -eq 0 ] && [ -z "$w_out" ] && [ "$elapsed" -lt "$elapsed_cap" ] && [ "$marker_count" -ge 1 ]; then
147
+ printf ' ✅ %-52s OK (%ss < %ss)\n' "wedged fetch: bounded, silent, throttle armed" "$elapsed" "$elapsed_cap"; pass=$((pass+1))
134
148
  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))
149
+ printf ' ❌ %-52s rc=%s elapsed=%ss(cap %ss) markers=%s out=%s\n' "wedged fetch: bounded, silent, throttle armed" "$w_rc" "$elapsed" "$elapsed_cap" "$marker_count" "$w_out"; fail=$((fail+1))
136
150
  fi
137
151
 
138
152
  # Budget cap (terra round 2): an all-digit literal wider than the shell's integer width made the
@@ -74,6 +74,68 @@ T=$(_fixture he4 1.4.89 1.4.89 1.4.89 1.4.89 1.4.89); printf '{"name":"fh-meta"}
74
74
  _expect "HE-4 manifest with no version → exit 2, NOT 0" 2 "$T"
75
75
  _says "HE-4 → says which file carries none" "carries no version string" 1
76
76
 
77
+ # ── Self-restatement — found→extend into this lens (2026-08-14) ──────────────────────────────
78
+ # RS-KN pins the exact false positive the naive first draft threw against its OWN calibration
79
+ # target (scripts/lane_runner_check.sh): a loose \D{0,12} gap matched across an unrelated
80
+ # exit-code legend entry ("declared DEBT · 1 =") and across a before→after transition narration
81
+ # ("DEBT 2 → 0"). Both shapes are known-negatives — they describe history/enumeration, not a
82
+ # live contradiction — and must stay silent.
83
+
84
+ T=$(_fixture rs1 1.4.89 1.4.89 1.4.89 1.4.89 1.4.89)
85
+ mkdir -p "$T/scripts"
86
+ cat > "$T/scripts/some_lane.sh" <<'EOF'
87
+ #!/usr/bin/env bash
88
+ # Exit: 0 = every suite is WIRED, EXEMPT, or declared DEBT · 1 = an undeclared suite has no runner
89
+ # THE TWO THAT WERE HERE ARE RE-WIRED (2026-08-14) — DEBT 2 → 0.
90
+ # WHAT `DEBT: 0` DOES NOT MEAN — read before quoting the number anywhere.
91
+ DEBT=()
92
+ EOF
93
+ _expect "RS-KN historical DEBT narration (2→0, exit-code legend) → exit 0" 0 "$T"
94
+ _says "RS-KN → does not fire on the transition/legend shapes" "self-restatement drift" 0
95
+
96
+ T=$(_fixture rs2 1.4.89 1.4.89 1.4.89 1.4.89 1.4.89)
97
+ mkdir -p "$T/scripts"
98
+ cat > "$T/scripts/broken_lane.sh" <<'EOF'
99
+ #!/usr/bin/env bash
100
+ # DEBT: 12 unwired suites remain, tracked below.
101
+ # ... (later, uncorrected after a partial fix) ...
102
+ # still DEBT 9 by last count.
103
+ DEBT=()
104
+ EOF
105
+ _expect "RS-KP shell comment restates DEBT with two different numbers → exit 0 (advisory)" 0 "$T"
106
+ _says "RS-KP → names the file and the disagreeing values" \
107
+ 'broken_lane.sh :: comments state DEBT as \[9, 12\]' 1
108
+
109
+ T=$(_fixture rs3 1.4.89 1.4.89 1.4.89 1.4.89 1.4.89)
110
+ mkdir -p "$T/plugins/fh-commons/skills/fake-skill"
111
+ cat > "$T/plugins/fh-commons/skills/fake-skill/SKILL.md" <<'EOF'
112
+ ---
113
+ name: fake-skill
114
+ description: uses v1.4.95 of the reference build
115
+ ---
116
+ # fake-skill
117
+
118
+ Body text never restates a version number, so there is nothing to cross-check against.
119
+ EOF
120
+ _expect "RS-KN2 SKILL.md frontmatter version, body silent → exit 0, no drift" 0 "$T"
121
+ _says "RS-KN2 → does not fire when body has zero version mentions" "self-restatement drift" 0
122
+
123
+ T=$(_fixture rs4 1.4.89 1.4.89 1.4.89 1.4.89 1.4.89)
124
+ mkdir -p "$T/plugins/fh-commons/skills/fake-skill2"
125
+ cat > "$T/plugins/fh-commons/skills/fake-skill2/SKILL.md" <<'EOF'
126
+ ---
127
+ name: fake-skill2
128
+ description: ships as part of v1.4.95
129
+ ---
130
+ # fake-skill2
131
+
132
+ Elsewhere in this doc we note the shipped build is v1.4.92, which is the value users should
133
+ actually expect to see when they check their installed version.
134
+ EOF
135
+ _expect "RS-KP2 SKILL.md frontmatter v1.4.95 vs body v1.4.92 → exit 0 (advisory)" 0 "$T"
136
+ _says "RS-KP2 → names the file and the drifted version" \
137
+ 'fake-skill2/SKILL.md :: frontmatter states v{1.4.95}' 1
138
+
77
139
  echo
78
140
  echo "──────────────────────────────────────────────"
79
141
  if [ "$FAIL" -gt 0 ]; then
@@ -22,7 +22,7 @@ ROOT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
22
22
  command -v python3 >/dev/null 2>&1 || { echo "LOCKSTEP: HARNESS-ERROR — python3 unavailable"; exit 2; }
23
23
 
24
24
  python3 - "$ROOT" <<'PY'
25
- import json, sys, glob, os
25
+ import json, sys, glob, os, re
26
26
  root = sys.argv[1]
27
27
  pkg_path = os.path.join(root, 'package.json')
28
28
  try:
@@ -69,6 +69,39 @@ for p in targets:
69
69
  if got != want:
70
70
  drift.append(f" {rel} :: {label} = {got} (package.json = {want})")
71
71
 
72
+ # ── CHANGELOG is a shipped version surface too, and it was outside this check ─────────────────
73
+ # Measured 2026-08-13, by a peer session, AFTER 1.4.97 was already published: the four JSON
74
+ # manifests all read 1.4.97 and plugins/fh-meta/CHANGELOG.md's newest entry was still [1.4.96].
75
+ # So the tarball a consumer downloads carries a changelog with no entry for the version they just
76
+ # installed — six merged PRs invisible on the record surface. This check reported PASS while that
77
+ # was true, because its target list was "manifests carrying a JSON version field" rather than
78
+ # "surfaces that state which version this is".
79
+ # ★ That is the half-fix propagation boundary in its purest form: the version was updated
80
+ # everywhere the CHECK looked, which is not the same set as everywhere it MATTERS.
81
+ # Non-blocking is deliberate and narrow: a missing entry is a documentation gap, not a broken
82
+ # package, and turning a publish red on prose would train the override this repo has already
83
+ # measured people reaching for. It is LOUD, it names the file, and it is impossible to miss in the
84
+ # publish output — which is what the JSON drift arms could not have been, since those genuinely
85
+ # break installs.
86
+ CHANGELOGS = sorted(glob.glob(os.path.join(root, 'plugins', '*', 'CHANGELOG.md')))
87
+ for p in CHANGELOGS:
88
+ rel = os.path.relpath(p, root)
89
+ try:
90
+ txt = open(p, encoding='utf-8', errors='replace').read()
91
+ except OSError as e:
92
+ print(f"LOCKSTEP: HARNESS-ERROR — cannot read {rel}: {e}")
93
+ sys.exit(2)
94
+ # The heading form this repo uses: `### [x.y.z] — DATE`. Absence of ANY such heading means the
95
+ # extractor stopped matching the file's real shape — report that rather than a clean run.
96
+ heads = re.findall(r'^#+\s*\[(\d+\.\d+\.\d+)\]', txt, re.M)
97
+ if not heads:
98
+ print(f"LOCKSTEP: HARNESS-ERROR — {rel} has no `[x.y.z]` heading; the extractor is blind, "
99
+ f"which is not the same as the changelog being current")
100
+ sys.exit(2)
101
+ if want not in heads:
102
+ print(f"LOCKSTEP: ⚠️ {rel} has no entry for {want} (newest is {heads[0]}) — the published "
103
+ f"tarball would carry a changelog that does not mention the version it ships")
104
+
72
105
  if drift:
73
106
  print(f"LOCKSTEP: DRIFT — {len(drift)} of {checked} shipped version string(s) do not match package.json")
74
107
  print("\n".join(drift))
@@ -76,5 +109,114 @@ if drift:
76
109
  print("so a stale entry ships as 'already installed' on the runtime that cannot report it.")
77
110
  sys.exit(1)
78
111
 
112
+ # ── Self-restatement — a file that states its own count/version twice can drift from itself ──
113
+ # Measured 2026-08-13 (peer session, reship axis): N=2 on two DIFFERENT surfaces, both real —
114
+ # (a) a memory file's YAML frontmatter `description:` stated an npm version differently from
115
+ # its own body (1.4.95 vs 1.4.92 — a lockstep drift, just in prose instead of JSON)
116
+ # (b) lane_runner_check.sh's own header comment stated "11/8" while a later comment said the
117
+ # DEBT count was 12 — caught by cross-family review, not by re-reading (see that file's own
118
+ # header for the incident write-up; it has since self-corrected into a historical note).
119
+ # «타표면 재발 = 기계화 의무» (recurrence on a second, different surface obligates mechanizing) —
120
+ # found→extend into THIS lens rather than a new scanner: same idiom (extract candidate values,
121
+ # compare, collect drift, report), new target surfaces.
122
+ #
123
+ # 🟥 (a) IS NOT CLOSED BY THIS ARM, AND THAT IS DELIBERATE — cross-family review (2026-08-14)
124
+ # caught an earlier draft of this comment reading as though citing incident (a) meant this scan
125
+ # reaches it. It does not: the memory store lives under the operator's home directory
126
+ # (~/.claude/projects/.../memory/), outside this repo's `root` entirely, so the candidate glob
127
+ # below structurally cannot see it — this script runs at `npm publish` time (package.json
128
+ # prepublishOnly), where only the git-tracked repo exists (CI has no access to a contributor's
129
+ # local home directory, and different operators have different paths). Incident (a) is the
130
+ # MOTIVATING PRECEDENT for the general pattern, not a case this specific scope closes. Closing it
131
+ # for real needs a separate, local-only check over the memory store — a different surface with
132
+ # different constraints, not a widening of this one.
133
+ #
134
+ # Advisory (like the CHANGELOG check above), same reasoning: a stale self-count is a documentation
135
+ # defect, not a broken package, and turning publish red on prose trains the override this repo has
136
+ # already measured people reaching for.
137
+ #
138
+ # Scope, deliberately narrow — the two false-positive traps that would have made this noise instead
139
+ # of signal, both avoided on purpose:
140
+ # 1. tracks/**/*.md and this repo's session cards are EXCLUDED. Those are running logs that
141
+ # legitimately cite dozens of historical version numbers across dated sessions — scanning them
142
+ # for "two differing v-numbers in one file" would fire on nearly every one. The mission here is
143
+ # "does a single-snapshot doc contradict itself", not "every version ever mentioned".
144
+ # 2. The DEBT counter only fires on 2+ DISTINCT values for the SAME literal label ("DEBT") within
145
+ # comment lines of one file — not on any two numbers that happen to appear near each other. A
146
+ # one-off historical narration ("this said 11/8 until it was fixed") does not by itself carry
147
+ # two live DEBT-labeled claims, so it does not false-positive as an active contradiction.
148
+ def _self_restate_md():
149
+ hits = []
150
+ candidates = [os.path.join(root, 'CLAUDE.md'), os.path.join(root, 'AGENTS.md')]
151
+ candidates += sorted(glob.glob(os.path.join(root, 'plugins', '*', 'skills', '*', 'SKILL.md')))
152
+ # v-prefix optional — cross-family review found the real incident that motivated this arm
153
+ # (a memory file's frontmatter `latest v1.4.97` vs body `latest **1.4.97**`) does not carry the
154
+ # prefix on the body side, so the mandatory-v form never fires on the actual shape it exists
155
+ # to catch. Bare N.N.N is noisier (could match an unrelated dependency version), but the
156
+ # candidate list above is narrow enough (CLAUDE.md/AGENTS.md/SKILL.md only) that this stays
157
+ # advisory-tolerable rather than explosive.
158
+ VERPAT = re.compile(r'\bv?(\d+\.\d+\.\d+)\b')
159
+ for p in candidates:
160
+ if not os.path.exists(p):
161
+ continue
162
+ try:
163
+ txt = open(p, encoding='utf-8', errors='replace').read()
164
+ except OSError:
165
+ continue
166
+ m = re.match(r'^---\n(.*?)\n---\n(.*)$', txt, re.S)
167
+ if not m:
168
+ continue
169
+ fm, body = m.group(1), m.group(2)
170
+ fm_vers = set(VERPAT.findall(fm))
171
+ body_vers = set(VERPAT.findall(body))
172
+ drifted = fm_vers - body_vers
173
+ if fm_vers and body_vers and drifted:
174
+ rel = os.path.relpath(p, root)
175
+ hits.append(f" {rel} :: frontmatter states v{{{','.join(sorted(drifted))}}} not "
176
+ f"found anywhere in body (body has v{{{','.join(sorted(body_vers))}}})")
177
+ return hits
178
+
179
+ def _self_restate_sh():
180
+ hits = []
181
+ # Tight: DEBT immediately followed by (optional :/=, whitespace) a number — not "any digit
182
+ # within 12 chars" (that matched an unrelated exit-code legend entry across a bullet, «DEBT ·
183
+ # 1»). A number immediately followed by an arrow (→ / ->) is a before→after transition
184
+ # narration ("DEBT 2 → 0"), not a live claim about the current count — excluded on purpose,
185
+ # calibrated against this repo's own lane_runner_check.sh (known-negative: 3 raw matches with
186
+ # the loose form, 1 real match with this form — verified by hand, 2026-08-14).
187
+ DEBTPAT = re.compile(r'\bDEBT\b\s*[:=]?\s*(\d+)\b(?!\s*(?:→|->))')
188
+ # test_*.sh / *_lanes.sh are excluded — measured 2026-08-14 on this check's own test file:
189
+ # a known-pair fixture legitimately embeds two differing DEBT numbers as heredoc TEST DATA
190
+ # (proving the detector can tell them apart), and that heredoc text is also literal source in
191
+ # the test file itself. Those are fixtures, not a claim about the test file's own state — same
192
+ # distinction the `suites` glob elsewhere in this repo already draws between subject scripts
193
+ # and the test scripts that exercise them.
194
+ for p in (sorted(f for f in glob.glob(os.path.join(root, 'scripts', '*.sh'))
195
+ if not re.match(r'^(test_.*|.*_lanes)\.sh$', os.path.basename(f)))
196
+ + sorted(f for f in glob.glob(os.path.join(root, 'templates', '*.sh'))
197
+ if not re.match(r'^(test_.*|.*_lanes)\.sh$', os.path.basename(f)))):
198
+ try:
199
+ txt = open(p, encoding='utf-8', errors='replace').read()
200
+ except OSError:
201
+ continue
202
+ # Comment lines only — a live `DEBT=()` array literal is code computing the fact, not a
203
+ # restated claim about it, and must not be treated as a second (possibly stale) copy.
204
+ vals = set()
205
+ for ln in txt.splitlines():
206
+ if not re.match(r'^\s*#', ln):
207
+ continue
208
+ vals |= {int(m) for m in DEBTPAT.findall(ln)}
209
+ if len(vals) > 1:
210
+ rel = os.path.relpath(p, root)
211
+ hits.append(f" {rel} :: comments state DEBT as {sorted(vals)} — pick one number, "
212
+ f"or point the stale comment at the live count instead of a literal")
213
+ return hits
214
+
215
+ restate_hits = _self_restate_md() + _self_restate_sh()
216
+ if restate_hits:
217
+ print(f"LOCKSTEP: ⚠️ self-restatement drift — {len(restate_hits)} file(s) state their own "
218
+ f"version/count more than once and the copies disagree:")
219
+ print("\n".join(restate_hits))
220
+
79
221
  print(f"LOCKSTEP: PASS — {checked} shipped version string(s) all at {want}")
80
222
  PY
@@ -1072,7 +1072,28 @@ if [ -n "$LOADBEARING" ]; then
1072
1072
  echo "[Gate] load-bearing change — degrade lint (advisory) + cross-family acknowledgment..."
1073
1073
  DDSCAN="$REPO_ROOT/scripts/degrade_direction_scan.sh"
1074
1074
  if [ -f "$DDSCAN" ]; then
1075
- _dd=$(bash "$DDSCAN" 2>/dev/null \
1075
+ # SCOPE THE SCAN TO THE STAGED FILES. This used to call `bash "$DDSCAN"` with NO ARGUMENTS,
1076
+ # which makes the scanner walk the WHOLE REPOSITORY recursively — and then the very next pipe
1077
+ # threw almost all of it away, keeping only the lines naming files we already had in hand.
1078
+ # Measured on this repo 2026-08-13: whole-repo 42s / 260 lines · one staged file 0s / 2 lines.
1079
+ # A sibling harness measured the same call under load at 2m07s inside a commit that took
1080
+ # 7m54s–9m40s, one of which hit a 10-minute tool ceiling and could not complete at all.
1081
+ # ★ The structure was inverted: it held the target list, scanned everything, then filtered.
1082
+ # WHY THIS IS WORSE THAN A SLOW CHECK: this lane declares itself ADVISORY (see the doctrine line
1083
+ # above — FP-tolerant, never a solo block). So a lane that cannot block was consuming most of
1084
+ # every load-bearing commit. That trains `--no-verify`, and the SAME hook carries the
1085
+ # Destructive-Op gate — one learned bypass disarms an irreversible-surface gate too. CLAUDE.md
1086
+ # warns repeatedly that over-blocking trains the override; over-DELAYING gets there just as well.
1087
+ # Read into an array rather than relying on word-splitting: $LOADBEARING is newline-separated and
1088
+ # an unquoted expansion would also split on spaces. `while read` and not `mapfile`, because
1089
+ # mapfile does not exist on bash 3.2 (stock macOS), which this hook still has to run on.
1090
+ _dd_targets=()
1091
+ while IFS= read -r _p; do
1092
+ [ -n "$_p" ] && _dd_targets+=("$_p")
1093
+ done <<< "$LOADBEARING"
1094
+ # The grep filter is KEPT even though the scan is now scoped: the scanner emits summary lines
1095
+ # ("degrade-scan: N smell(s) …") that name no file, and those must not read as findings.
1096
+ _dd=$(bash "$DDSCAN" "${_dd_targets[@]}" 2>/dev/null \
1076
1097
  | grep -Ff <(printf '%s\n' "$LOADBEARING") 2>/dev/null | head -5 || true)
1077
1098
  if [ -n "$_dd" ]; then
1078
1099
  echo " ⚠️ degrade-direction smell(s) in the staged load-bearing files (advisory):"
@@ -44,6 +44,36 @@
44
44
  # Re-point the class's `owner` or `mode` after the grant and the join fails: that is the point.
45
45
 
46
46
  classes:
47
+ # ---- REAL, ADOPTABLE class (not illustrative) -------------------------------------
48
+ # Ships with fh_node_check.sh's consent-gated auto-pull. Copy this block verbatim into your
49
+ # own tracks/_meta/consent_classes.yaml, then grant it in your UAP frontmatter to enable the
50
+ # apply arm. Without both, the check surfaces "N commits behind" and never touches the repo —
51
+ # absent is not granted.
52
+ #
53
+ # Read the reason before adopting: this class takes a DIFFERENT verdict from the `local-commit`
54
+ # example further down, on a deliberately narrow ground. If your integration branch is not
55
+ # PR-only/protected, that ground does not hold for you and you should not adopt it.
56
+ - name: repo-freshness-autopull
57
+ owner: scripts/fh_node_check.sh
58
+ mode: ff-only-merge-on-default-branch
59
+ target: this repo's own default branch, in the local clone, when it is already checked out
60
+ capabilities: [network, repo-mutation]
61
+ sinks: []
62
+ feeds: []
63
+ promotion_eligible: true
64
+ lease_days: 92
65
+ reason: >
66
+ A fast-forward pull introduces nothing new: every commit it brings down is already on the
67
+ integration branch, which is PR-only and server-side protected, so all of it has passed the
68
+ gates. `local-commit` below is classified feeds:[go-public] because it CREATES ungated
69
+ content that publish can then ship; this makes the tree MATCH canon instead. Publish packs
70
+ the working tree, so a tree matching origin is strictly safer to publish from than a stale
71
+ one. Blast radius also excludes personal state by construction — a pull touches tracked
72
+ files only, and session/memory/companion state is gitignored or outside the repo.
73
+ Envelope (enforced in code, not prose): applies ONLY when the default branch is already
74
+ checked out, never switches branches, and --ff-only refuses a divergence rather than
75
+ absorbing it. Lanes: scripts/test_node_check_lanes.sh lane10-a..e + CONTROL.
76
+
47
77
  # ---- promotion-eligible example -------------------------------------------------
48
78
  - name: dispatch-readonly-sim-local-artifact
49
79
  owner: fh-meta:sim-conductor # the gate/skill that does the asking
@@ -500,11 +500,20 @@ for f in "${FILES[@]}"; do
500
500
  # `tok in text` (paid⊂prepaid) when the variables aren't named verdict/state (M#4, steel-quench).
501
501
  # C2 closes that: simple var-in-var (not a collection literal / range / for) = a likely
502
502
  # containment check that should be exact/word-boundary if it grounds a verdict. Higher noise; advisory.
503
+ # 2026-08-14: pinned an ALL-CAPS-constant exclusion here that Probe C already carries a few lines
504
+ # up (`in [A-Z_]+\b`) — C2 had drifted out of sync with it. Measured on this repo's own scripts/:
505
+ # 6 raw C2 hits, all `if VAR in {seen,by_name,accepted,packed,PLACEHOLDERS}:` — genuine collection
506
+ # membership, not the substring-containment smell this probe exists to catch. Do NOT also add
507
+ # lowercase collection-name heuristics (`seen`/`by_name`/...) here — that class still needs a
508
+ # human to read the assignment and confirm it is a set/dict/list, and a naming guess would just
509
+ # move the false-negative risk instead of removing it. This narrows one already-established class,
510
+ # it does not invent a new one.
503
511
  while IFS= read -r m; do
504
512
  emit "$f" "${m%%:*}" "C2:substring-boolean" "bare 'X in Y' in if/return/assert — if this grounds a presence/verdict check, use exact/word-boundary match, not containment"
505
513
  done < <(grep -nE '^[[:space:]]*(if|elif|return|assert|while)[[:space:]]+[A-Za-z_][A-Za-z0-9_]*[[:space:]]+in[[:space:]]+[A-Za-z_][A-Za-z0-9_.]*[[:space:]]*[:)]?[[:space:]]*$' "$f" 2>/dev/null \
506
514
  | grep -vE '\bfor\b|in range|in enumerate|not in' \
507
- | grep -vE '\b(verdict|present|ground|state|match|expected)\w*\b')
515
+ | grep -vE '\b(verdict|present|ground|state|match|expected)\w*\b' \
516
+ | grep -vE 'in [A-Z_]+[[:space:]]*[:)]?[[:space:]]*$')
508
517
 
509
518
  # Probe E — negated-falsy guard returning permissive (dominance-benchmark round-2 f2 class): an error
510
519
  # SENTINEL (None / {} / "" / []) is falsy, so `if not X: return <PASS>` treats "the check errored / never