@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
@@ -16,8 +16,11 @@
16
16
  # R3 join — every standing_consent key resolves to a registered class
17
17
  # R4 floor — every standing_consent key resolves to a promotion_eligible class
18
18
  # R5 lease — every grant carries `expires` and is not past it
19
- # R6 scope — every grant records `effects` AND `target`, so the subset check has something to
19
+ # R6 scope — every grant records the FULL fingerprint the rule binds consent to — `owner`,
20
+ # `mode`, `effects`, `target`, `sinks` — so the subset check has something to
20
21
  # compare against (a grant whose scope was never recorded cannot be re-validated)
22
+ # R7 drift — the recorded fingerprint still matches the registry: effects ⊆ capabilities,
23
+ # target/owner/mode identical, and the class's current `sinks` ⊆ the granted ones
21
24
  #
22
25
  # WHAT IT DOES *NOT* CHECK (named, so the prose above it cannot over-claim)
23
26
  # - the effect-SUBSET comparison itself. R6 proves a baseline was recorded; it does not compare a
@@ -70,7 +73,7 @@ REG="${1:-$ROOT/tracks/_meta/consent_classes.yaml}"
70
73
  UAP="${2:-$ROOT/tracks/_meta/user_adaptation_profile.md}"
71
74
 
72
75
  python3 - "$REG" "$UAP" <<'PY'
73
- import sys, os, re, datetime
76
+ import sys, os, re, datetime, unicodedata
74
77
  reg_path, uap_path = sys.argv[1], sys.argv[2]
75
78
 
76
79
  def out(sym, msg): print(f" {sym} {msg}")
@@ -98,9 +101,19 @@ def _no_dup(loader, node, deep=False):
98
101
  # a merge key exited 1 as an unregistered class). Over-blocking is not a safety win — it trains
99
102
  # the override reflex and turns the gate into decoration, so it counts as a defect like any
100
103
  # fail-open.
101
- loader.flatten_mapping(node)
104
+ # ORDER MATTERS. Duplicates are detected over the node's OWN keys, BEFORE merge resolution —
105
+ # then the merge is resolved. Running flatten_mapping first put the anchor's keys and the
106
+ # explicit keys into one list, so a canonical YAML override (`<<: *d` then `target: t`) looked
107
+ # like a duplicate and a legitimate grant was refused. YAML merge semantics are explicit that
108
+ # the explicit key WINS; refusing it is us disagreeing with the format, not catching a defect.
109
+ # This is a reorder plus skipping the `<<` key itself — NOT the full-file parser rewrite that
110
+ # regressed 16 of 41 lanes and was reverted. A literal duplicate in one mapping still fails.
111
+ # (Pinned as K1 over-block for two rounds; closed 2026-08-02 once the lane count made the change
112
+ # verifiable — 85 lanes, both directions mutation-checked.)
102
113
  seen = set()
103
114
  for k, _ in node.value:
115
+ if getattr(k, "tag", "") == "tag:yaml.org,2002:merge":
116
+ continue # the `<<` key is a directive, not a data key
104
117
  key = loader.construct_object(k, deep=deep)
105
118
  try:
106
119
  if key in seen:
@@ -108,6 +121,7 @@ def _no_dup(loader, node, deep=False):
108
121
  except TypeError:
109
122
  pass
110
123
  seen.add(key)
124
+ loader.flatten_mapping(node)
111
125
  return yaml.SafeLoader.construct_mapping(loader, node, deep)
112
126
 
113
127
  NoDupLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_dup)
@@ -126,6 +140,17 @@ reg = load(open(reg_path), "registry")
126
140
  REQUIRED = ["name", "owner", "mode", "target", "capabilities", "sinks", "feeds", "promotion_eligible"]
127
141
  STR_FIELDS = ["name", "owner", "mode", "target"]
128
142
  IRREVERSIBLE = {"go-public", "publish", "delete", "history-rewrite", "unknown"}
143
+ # The closed vocabulary. Anything outside it is UNKNOWN, and unknown is not reversible (the floor's
144
+ # own words). Kept next to IRREVERSIBLE so the two cannot drift apart. All entries are pre-normalised
145
+ # (lower-case) because _norm case-folds both sides.
146
+ # READ FROM THE SOURCE, not invented: templates/consent_classes.yaml.example line 52 and
147
+ # operational_adaptation.md line 51 both enumerate capabilities as
148
+ # `read · local-write · network · dispatch · repo-mutation`. The irreversible names are unioned in
149
+ # because they may legally APPEAR in a capabilities list — that is exactly what R2-b exists to catch,
150
+ # so they must be recognised rather than flagged as unknown.
151
+ # (First draft of this set was guessed and broke 61 of 76 lanes: it invented `write`/`publish` as
152
+ # capabilities and omitted `repo-mutation`. The vocabulary lives in two files; read them.)
153
+ KNOWN_EFFECTS = {"read", "local-write", "network", "dispatch", "repo-mutation"} | IRREVERSIBLE
129
154
  NON_GRANT = {"declined", "unset", "revoked"}
130
155
  MAX_LEASE_DAYS = 365
131
156
 
@@ -133,8 +158,35 @@ MAX_LEASE_DAYS = 365
133
158
  # an identifier, not prose) and whitespace-insensitive; the point is that one function decides, so
134
159
  # the two sides of a comparison can never drift apart.
135
160
  def _norm(s):
161
+ # IDENTITY / SCOPE comparison. Strip surrounding whitespace and NOTHING ELSE. Case, width and
162
+ # invisible characters are all REAL DIFFERENCES here: `OwnerOne` is not `ownerone`, and a target
163
+ # carrying a zero-width space is not the target without it. A difference the comparison cannot
164
+ # see is a re-ask that never fires.
165
+ #
166
+ # WHY THIS IS BACK TO STRIP-ONLY (2026-08-02): a previous session left a residual — "_norm does
167
+ # not case-fold, so `History-Rewrite` evades the irreversible floor" — and explicitly warned that
168
+ # case-folding would change R7's semantics and needed its own change. A cross-family round proved
169
+ # the fail-open was real, so I case-folded _norm — and the very next round measured the predicted
170
+ # consequence: owner/mode/target drift stopped being detected. The evidence was right and the
171
+ # warning was right; what was wrong was using ONE normalizer for two different questions.
136
172
  return str(s).strip()
137
173
 
174
+
175
+ def _norm_vocab(s):
176
+ # VOCABULARY matching only — is this token one of the known / irreversible effect classes? Here
177
+ # case, width and invisible characters are NOISE, not signal: `History-Rewrite`, `history-rewrite`
178
+ # and a zero-width-suffixed variant all name the same irreversible surface, and treating them as
179
+ # distinct is what let a history-rewrite class declare itself promotable.
180
+ #
181
+ # TWO NORMALIZERS IS THE POINT, NOT A SLIP. The known trap is two normalizers for the SAME
182
+ # question drifting apart in leniency (one accepts what the other silently drops). These answer
183
+ # DIFFERENT questions — "are these the same identity?" vs "is this token in this closed set?" —
184
+ # and each is used for exactly one of them. Keep it that way: if a third call site appears, decide
185
+ # which question it is asking before picking.
186
+ t = unicodedata.normalize("NFKC", str(s))
187
+ t = "".join(ch for ch in t if unicodedata.category(ch) not in ("Cf", "Cc"))
188
+ return t.strip().casefold()
189
+
138
190
  # H1 FALSY CONTAINERS. `reg or {}` / `classes or []` laundered `false`, `0`, `[]` and a bare
139
191
  # `classes:` into a valid-empty registry that exited 0 — the same falsy-collapse defect already
140
192
  # fixed on the GRANT side, left unfixed here. Half a fix propagated is a hole. Sentinel, then type,
@@ -151,9 +203,13 @@ if classes is None:
151
203
  if not isinstance(classes, list):
152
204
  print(f"consent-registry: FAIL — `classes` is {type(classes).__name__}, not a list; fail-closed")
153
205
  sys.exit(1)
154
- if not classes:
155
- print("consent-registry: N/A registry declares zero classes; promotion DISABLED (not a PASS)")
156
- sys.exit(3) # UNMEASURED, not verified see EXIT CONTRACT
206
+ ZERO_CLASSES = not classes
207
+ # The zero-class verdict is DEFERRED, not decided here. Deciding it early required peeking at the
208
+ # profile with a second, weaker parser (a bespoke frontmatter regex, a bare non-empty-dict test), and
209
+ # two parsers for one file disagree by construction — measured: `--- # metadata` and
210
+ # `standing_consent: []` and a declined-only profile each got the wrong code. One parser, one answer.
211
+ # The main body below reads the profile properly; the verdict is taken at the end with everything
212
+ # else. (cross-family round 2, 2026-08-02 — same lesson as collapsing three write paths into one.)
157
213
 
158
214
  by_name = {}
159
215
  for i, c in enumerate(classes):
@@ -219,7 +275,16 @@ for i, c in enumerate(classes):
219
275
  # floor into decoration. Residual, named rather than fixed here: _norm strips whitespace but
220
276
  # does NOT case-fold, so `History-Rewrite` still evades BOTH R2-b and R7 — case-folding would
221
277
  # change R7's semantics too and belongs in its own change with its own lanes.
222
- cap_bad = {_norm(x) for x in (c["capabilities"] or [])} & IRREVERSIBLE
278
+ caps_n = {_norm_vocab(x) for x in (c["capabilities"] or [])}
279
+ cap_bad = caps_n & IRREVERSIBLE
280
+ # CLOSED vocabulary. An effect class nobody enumerated cannot be judged reversible, and the floor
281
+ # already says unknown is not reversible — so an unrecognised capability is treated as one.
282
+ # Without this, a typo (`local-wrtie`) or a novel string silently classified itself as safe.
283
+ unknown_caps = caps_n - KNOWN_EFFECTS
284
+ if c["promotion_eligible"] and unknown_caps:
285
+ out("❌", f"R2-c `{nm}` promotion_eligible:true with capabilities outside the declared "
286
+ f"vocabulary {sorted(unknown_caps)} — unknown is not reversible")
287
+ fails += 1
223
288
  if c["promotion_eligible"] and bad:
224
289
  out("❌", f"R2 `{nm}` claims promotion_eligible:true but sinks/feeds include {sorted(bad)}")
225
290
  fails += 1
@@ -345,10 +410,23 @@ else:
345
410
  out("❌", f"R3 standing_consent is not a mapping ({type(grants).__name__}); fail-closed")
346
411
  fails += 1; grants = None
347
412
 
413
+ no_active_grant = False
348
414
  if grants is None:
349
415
  pass
350
416
  elif not grants:
351
- out("✅", "R3-R6 no standing consent recorded (nothing to validate)")
417
+ # F4-b (2026-07-31, same round, same principle, a path F4's fix did not reach). F4 gave
418
+ # "nothing to join" its own exit code for a MISSING registry, ZERO classes and a MISSING UAP —
419
+ # and left a present UAP holding ZERO grants returning 0. That is the identical state judged by
420
+ # two different codes: lane D1-d already asserts in its own name that "no grants is not a
421
+ # verified pass", and the EXIT CONTRACT at the top of this file already defines 3 as "there was
422
+ # nothing to join". The code disagreed with both. A caller writing the conventional
423
+ # if scripts/consent_registry_check.sh; then run_unprompted; fi
424
+ # therefore ran unprompted against a UAP that had granted it nothing at all — the exact
425
+ # conversion of "keep asking" into "success" that F4 exists to stop, one branch over.
426
+ # Not a failure and not painted red: N/A, exit 3, keep asking.
427
+ print("consent-registry: N/A — registry is well-formed but NO standing consent is recorded; "
428
+ "nothing granted, keep asking (not a PASS)")
429
+ no_active_grant = True
352
430
  else:
353
431
  today = datetime.date.today()
354
432
  validated = 0
@@ -428,10 +506,71 @@ else:
428
506
  # caught (measured with that control, cross-family round 7). Half a fix propagated is a hole:
429
507
  # a baseline that is not a list-of-effects and a real target string cannot be compared against
430
508
  # anything later, which is the entire purpose of recording it.
509
+ # R6-c FINGERPRINT COMPLETENESS (cross-family round 9, finding F3, 2026-07-31).
510
+ # The rule says consent binds to the action's SHAPE and enumerates that shape:
511
+ # "a grant records ... the owning gate/skill, and the set of effect classes ... plus the
512
+ # `target` scope and the `sinks` fingerprint" (operational_adaptation.md §Consent binds
513
+ # to the action's SHAPE), and the offer quoted to the user is `<mode · target ·
514
+ # capabilities · sinks>`.
515
+ # The baseline recorded here was `effects` + `target` ONLY. So a class could keep its name,
516
+ # target, capabilities and empty sinks while its `owner` (which gate/skill does the acting)
517
+ # or its `mode` (what it does when it acts) was swapped underneath the grant — R6 found its
518
+ # two fields present and R7 found the effects still a subset, and the checker returned 0.
519
+ # "The name is exactly what does not change when the danger does" — and so, it turned out,
520
+ # were the only two fields the floor was reading. A fingerprint missing the fields the rule
521
+ # names is not a fingerprint; it is a partial hash that collides on the dangerous case.
522
+ # Sentinel-then-type, never `or`: `sinks: []` is a REAL fingerprint ("crossed nothing at
523
+ # grant time") and must not be laundered into "not recorded" by a falsy test — the same
524
+ # falsy-collapse defect fixed twice above.
525
+ FP_STR = ("owner", "mode", "target")
526
+ missing_fp = [f for f in ("owner", "mode", "target", "effects", "sinks") if g.get(f) is None]
527
+ if missing_fp:
528
+ out("❌", f"R6 `{name}` grant records no {'+'.join(missing_fp)} — consent binds to the "
529
+ f"action's SHAPE (owner·mode·target·effects·sinks), and a fingerprint missing "
530
+ f"a field cannot detect drift in that field")
531
+ fails += 1
431
532
  eff, tgt = g.get("effects"), g.get("target")
432
- if eff is None or tgt is None:
433
- out("❌", f"R6 `{name}` grant records no `effects`+`target` — the subset check has no baseline")
533
+ for fld in FP_STR:
534
+ v = g.get(fld)
535
+ if v is not None and (not isinstance(v, str) or not v.strip()):
536
+ out("❌", f"R6 `{name}` `{fld}` must be a non-blank string, got "
537
+ f"{type(v).__name__} {v!r} — an unreadable baseline is no baseline")
538
+ fails += 1
539
+ gs = g.get("sinks")
540
+ if gs is not None and (not isinstance(gs, list)
541
+ or not all(isinstance(x, str) and x.strip() for x in gs)):
542
+ out("❌", f"R6 `{name}` `sinks` must be a list of non-blank strings, got "
543
+ f"{type(gs).__name__} {gs!r} — an unreadable sink is an UNDECLARED sink")
434
544
  fails += 1
545
+ else:
546
+ # R7-c SINK WIDENING. Rule: "on any later run whose fingerprint is not a subset of the
547
+ # granted one, standing consent reverts to unset and asks again ... widening is the
548
+ # trigger; narrowing is not." The class's CURRENT sinks are the later fingerprint.
549
+ # HONEST SCOPE — this comparison is defence-in-depth, not an independent catch today:
550
+ # R2 already refuses `promotion_eligible: true` on ANY non-empty sinks/feeds, so on an
551
+ # eligible class this branch is unreachable and no lane can discriminate it. It is kept
552
+ # because it survives an R2 relaxation and because it is the half that makes the RECORD
553
+ # meaningful; the lane that pins F3 pins the R6 *presence* requirement above, which is
554
+ # reachable. Do not read a PASS here as "sink drift was independently checked".
555
+ if isinstance(gs, list):
556
+ widened = sorted({_norm(x) for x in c["sinks"]} - {_norm(x) for x in gs})
557
+ if widened:
558
+ out("❌", f"R7 `{name}` class now declares sink(s) {widened} that were not in the "
559
+ f"granted fingerprint {gs!r} — widening reverts consent to unset")
560
+ fails += 1
561
+ # Identity fields: equality, not subset. `owner`/`mode` name WHO acts and HOW; there is no
562
+ # "narrower owner". _norm (the single normalizer) on both sides, so the two spellings of one
563
+ # value can never be judged by two different rules — the divergent-normalizer class this
564
+ # file has already been bitten by three times.
565
+ for fld in ("owner", "mode"):
566
+ gv = g.get(fld)
567
+ if isinstance(gv, str) and gv.strip() and _norm(gv) != _norm(str(c[fld])):
568
+ out("❌", f"R7 `{name}` grant {fld} {gv!r} does not match the registered {fld} "
569
+ f"{c[fld]!r} — the class was re-pointed under a live grant; consent binds "
570
+ f"to the action's shape, not its name")
571
+ fails += 1
572
+ if eff is None or tgt is None:
573
+ pass # already reported by R6 above; nothing left to compare
435
574
  else:
436
575
  if not isinstance(eff, list) or not eff or not all(isinstance(e, str) and e.strip() for e in eff):
437
576
  out("❌", f"R6 `{name}` `effects` must be a non-empty list of strings, got "
@@ -451,7 +590,14 @@ else:
451
590
  # registry side was not, so `[" read "]` matched while `[READ]` did not — whitespace
452
591
  # forgiving, case strict, for no stated reason. Divergent normalizers on the two
453
592
  # sides of a comparison is the same defect class this file already fixed twice.
454
- over = sorted({_norm(e) for e in eff} - {_norm(x) for x in c["capabilities"]})
593
+ # VOCABULARY, not identity. `effects` and `capabilities` are both drawn from the
594
+ # closed effect vocabulary, so `READ` and `read` name the same class — comparing them
595
+ # with the identity normalizer refused a schema-conformant grant. Over-blocking is a
596
+ # defect of equal rank here: a gate that refuses correct input teaches the operator
597
+ # to bypass it. The sink-WIDENING comparison a few lines above stays on `_norm`,
598
+ # because that one asks fingerprint identity, not vocabulary. Per-site judgment, not
599
+ # a blanket swap. (cross-family round 3, 2026-08-02.)
600
+ over = sorted({_norm_vocab(e) for e in eff} - {_norm_vocab(x) for x in c["capabilities"]})
455
601
  if over:
456
602
  out("❌", f"R7 `{name}` grant claims effect(s) {over} outside its registered "
457
603
  f"capabilities {c['capabilities']} — the grant is wider than the class")
@@ -460,13 +606,39 @@ else:
460
606
  out("❌", f"R7 `{name}` grant target {tgt!r} does not match the registered target "
461
607
  f"{c['target']!r} — scope drift between grant and class")
462
608
  fails += 1
463
- if fails == 0:
464
- skipped = len(grants) - validated
609
+ skipped = len(grants) - validated
610
+ if validated == 0:
611
+ # Same state as the empty-grants branch above, reached differently: every key present was
612
+ # `declined`/`unset`/`revoked`. A file that records only refusals has granted nothing, and a
613
+ # refusal must never be the reason a prompt is skipped. UNMEASURED, not verified.
614
+ print(f"consent-registry: N/A — {skipped} recorded state(s), NONE of them an active grant; "
615
+ f"nothing granted, keep asking (not a PASS)")
616
+ no_active_grant = True
617
+ elif fails == 0:
465
618
  note = f" ({skipped} non-grant state(s) skipped)" if skipped else ""
466
619
  out("✅", f"R3-R6 all {validated} active grant(s) registered, eligible, unexpired, "
467
620
  f"scope-recorded{note}")
468
621
 
469
622
  print("----")
470
- print(f"consent-registry: {'PASS' if fails == 0 else f'{fails} violation(s)'}")
471
- sys.exit(0 if fails == 0 else 1)
623
+ # The human-facing summary must agree with the typed exit. It previously printed PASS on a run whose
624
+ # own line above said "nothing granted, keep asking (not a PASS)" and whose exit code was 3 — so an
625
+ # operator reading the tail saw an approval that the machine channel was refusing. "Do not grep the
626
+ # prose" binds machines; people read the prose, and a summary that contradicts the verdict is a
627
+ # false green with extra steps. (Caught by hand 2026-08-02 while verifying the exit-3 fix.)
628
+ if fails:
629
+ print(f"consent-registry: {fails} violation(s)")
630
+ elif no_active_grant:
631
+ print("consent-registry: UNMEASURED — nothing granted, keep asking (exit 3)")
632
+ else:
633
+ print("consent-registry: PASS")
634
+ # BROKEN outranks UNMEASURED: a violation is a decided negative, "nothing granted" is merely nothing
635
+ # to join. Both outrank VERIFIED, which stays reachable ONLY by a real join against a real grant.
636
+ if ZERO_CLASSES and not fails:
637
+ # A live grant against a registry that declares zero classes is an UNREGISTERED grant — R3's own
638
+ # verdict — which is BROKEN, not "nothing to join". No grant → genuinely nothing to join.
639
+ if not no_active_grant:
640
+ print("consent-registry: FAIL — a standing grant exists but the registry declares zero "
641
+ "classes; every such grant is UNREGISTERED (R3), which is BROKEN, not unmeasured")
642
+ sys.exit(1)
643
+ sys.exit(1 if fails else (3 if no_active_grant else 0))
472
644
  PY
@@ -41,6 +41,13 @@
41
41
  # payload with a destructive line 1 and a `# noqa: destructive-op` on line 2 exempts both.
42
42
  # Accepted: noqa is self-grantable by design on this advisory surface (same trust channel as
43
43
  # the DESTRUCTIVE_OP_OK env ack) — the hard floor for the irreversible half stays pre-push.
44
+ # - `git -c alias.<name>='!<shell>' <name>` — the normalizer CONSUMES `-c k=v` (it must: that is
45
+ # how `git -c core.x=y reset --hard` is caught), and the alias name that follows is not a
46
+ # destructive row, so arbitrary shell runs CLEAN. Concrete realization of the indirection class
47
+ # above, named separately because the normalizer is what makes it reachable (terra round 2,
48
+ # 2026-08-01). Not fixed: closing it means interpreting alias payloads, i.e. parsing shell —
49
+ # over-build for an advisory layer, and squarely inside the deliberate-obfuscation threat model
50
+ # this guard excludes. It counters SELF-JUSTIFICATION, not evasion.
44
51
  # - Shell-escape reconstruction (`\g\i\t reset --hard`, `$'git' reset`) and quoted-space git -C
45
52
  # paths (`git -C "/tmp/my repo" reset --hard`) — regex-with-normalization cannot dequote like
46
53
  # a shell; a Bash-compatible tokenizer would be over-build for an advisory layer (GPT-round,
@@ -75,7 +82,7 @@ import json,sys
75
82
  try: d = json.load(sys.stdin)
76
83
  except Exception: sys.exit(0)
77
84
  if d.get("tool_name") != "Bash": sys.exit(0)
78
- sys.stdout.write(d.get("tool_input", {}).get("command", "") or "")
85
+ sys.stdout.buffer.write((d.get("tool_input", {}).get("command", "") or "").encode("utf-8"))
79
86
  ' 2>/dev/null) || CMD=""
80
87
  fi
81
88
  [ -n "$CMD" ] || exit 0
@@ -110,7 +117,13 @@ FLAT=" $(printf '%s' "$JOINED" | tr -d '\r' | tr '\n' ';' | sed 's/;/; /g' \
110
117
  # TRAINS that shape ("cwd resets between calls, use absolute paths / git -C"), so the origin
111
118
  # defect (a self-justifying model) would most naturally emit exactly it. GPT-round extended the
112
119
  # consumed set: --no-pager, -c k=v, -p/-P, and the space-separated --git-dir/--work-tree forms.
113
- FLAT=$(printf '%s' "$FLAT" | sed -E 's/git( +(-C +[^ ;]+|--git-dir[= ][^ ;]+|--work-tree[= ][^ ;]+|-c +[^ ;]+|--no-pager|-[pP]))+/git/g')
120
+ # Leg-C MED round (2026-08-01) completed the allowlist against `git --help`'s global-option table:
121
+ # the valueless pathspec/behavior toggles (--literal/--glob/--noglob/--icase-pathspecs,
122
+ # --no-optional-locks, --no-replace-objects, --no-lazy-fetch, --no-advice, --bare, --paginate) and
123
+ # the value-carrying --namespace/--super-prefix/--config-env/--exec-path= — any one of which let
124
+ # `git <opt> reset --hard` sail past every git row. --exec-path WITHOUT `=` is deliberately not
125
+ # consumed: bare --exec-path prints and exits, so nothing destructive follows it.
126
+ FLAT=$(printf '%s' "$FLAT" | sed -E 's/git( +(-C +[^ ;]+|--git-dir[= ][^ ;]+|--work-tree[= ][^ ;]+|-c +[^ ;]+|--namespace[= ][^ ;]+|--super-prefix[= ][^ ;]+|--config-env[= ][^ ;]+|--exec-path=[^ ;]+|--no-pager|--no-optional-locks|--no-replace-objects|--no-lazy-fetch|--no-advice|--literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--bare|--paginate|-[pP]))+/git/g')
114
127
 
115
128
  # Dry-run neutralizer: `git clean -fdn` / `--dry-run` is non-destructive; rewrite it to a token no
116
129
  # row matches, so the clean row cannot FP on a dry run (Axis-2 #6 — an FP here violates the very
@@ -124,6 +137,16 @@ FLAT=$(printf '%s' "$FLAT" | sed -E 's/git clean( +--?[a-zA-Z][a-zA-Z=-]*)* +(-[
124
137
  # alternation sees the canonical spelling (GPT-round; brace-expansion globs stay a residual).
125
138
  FLAT=$(printf '%s' "$FLAT" | sed -E 's/(\.\/)+/.\//g')
126
139
 
140
+ # Staged-only restore neutralizer (GPT leg-C round, 2026-08-01): `git restore --staged .` only
141
+ # unstages (worktree untouched, re-addable) — the broadened restore-dot row below would FP on it.
142
+ # Neutralize ONLY when --staged is present AND no worktree flag is (staged+worktree IS destructive
143
+ # and the -W/--worktree row catches it). Whole-payload rewrite: a compound payload mixing a
144
+ # staged-only restore with a plain destructive restore is a named residual (rare shape).
145
+ if printf '%s' "$FLAT" | LC_ALL=C grep -qE 'git restore [^|;&]*--staged' \
146
+ && ! printf '%s' "$FLAT" | LC_ALL=C grep -qE 'git restore [^|;&]*(--worktree|-[a-zA-Z]*W)'; then
147
+ FLAT=$(printf '%s' "$FLAT" | sed -E 's/git restore /git restore_stagedonly /g')
148
+ fi
149
+
127
150
  # ── Destructive pattern table: regex@@description ─────────────────────────────────────────────
128
151
  # Delimiter is @@ because the regexes themselves carry `|` (alternation) — a `|` delimiter
129
152
  # truncated every alternation-bearing pattern at split time (caught by the known-pair lanes on
@@ -138,9 +161,9 @@ FLAT=$(printf '%s' "$FLAT" | sed -E 's/(\.\/)+/.\//g')
138
161
  PATTERNS=(
139
162
  '[ (/]git reset ([^|;&]* )?--hard[ ;]@@git reset --hard discards ALL uncommitted changes irreversibly'
140
163
  '[ (/]git clean ([^|;&]* )?(--force[ ;]|-[a-zA-Z]*[fxX][a-zA-Z]*[ ;])@@git clean -f/-x permanently deletes untracked files'
141
- '[ (/]git checkout ([^|;&]*-- )?\.\/? @@git checkout . reverts every local modification'
164
+ '[ (/]git checkout ([^|;&]* )?\.\/? @@git checkout . reverts every local modification'
142
165
  '[ (/]git restore ([^|;&]* )?(--worktree|-[a-zA-Z]*W[a-zA-Z]*[ ;])@@git restore --worktree reverts working-tree changes'
143
- '[ (/]git restore \.\/? @@git restore . reverts every local modification'
166
+ '[ (/]git restore ([^|;&]* )?\.\/? @@git restore . reverts every local modification'
144
167
  '[ (/]git push ([^|;&]* )?(--force(-with-lease(=[^ ;]+)?)?[ ;]|-[a-zA-Z]*f[a-zA-Z]*[ ;]|\+[^ ;]+[ ;])@@force push rewrites remote history (pre-push hook will also gate this — enumerate first)'
145
168
  '[ (/]git branch ([^|;&]* )?(-[a-zA-Z]*D[ ;]|--delete( [^|;&]*)? --force[ ;]|--force( [^|;&]*)? --delete[ ;])@@git branch -D force-deletes a branch without merge check'
146
169
  '[ (/]git stash (drop|clear)[ ;]@@git stash drop/clear permanently discards stashed work'
@@ -162,7 +185,11 @@ for entry in "${PATTERNS[@]}"; do
162
185
  done
163
186
  [ -n "$hits" ] || exit 0
164
187
 
165
- hits="${hits} Before running: is uncommitted/stashed state enumerated (git status / predelete_check.sh)?
188
+ hits="${hits} Advisory timing: this context reaches the model on the NEXT turn — the call may have
189
+ already run (pre-action blocking = FH_DESTRUCTIVE_BLOCK=1). If it ran un-enumerated,
190
+ recover FIRST: git status / git stash list / git reflog /
191
+ \`bash templates/predelete_check.sh <repo> [base]\` (repo-root relative — leg-C MED: an
192
+ uncited healing path is a dead pointer to the session that needs it mid-incident).
166
193
  Destructive-Op Gate order: enumerate → recover → destroy — never destroy-then-check.
167
194
  Intentional and reviewed → re-run with trailing \`# noqa: destructive-op\`."
168
195
 
@@ -67,6 +67,16 @@ ACCEPTED_ABSENT=(
67
67
  ".claude/regression/probes.md"
68
68
  "scripts/sync-to-be.sh"
69
69
  "scripts/sync_guard_check.sh"
70
+ # Return path (companion store → hub) and its anchor. Same reason as the forward path above: the
71
+ # transport only means anything on a machine that HAS the operator's companion store, and shipping
72
+ # it would hand every consumer a script that resolves `$BE` to nothing. selfcheck references the
73
+ # anchor but guards on the subject's presence, so package mode SKIPs rather than falling through.
74
+ "scripts/sync-from-be.sh"
75
+ "scripts/sync_from_be_lanes.sh"
76
+ # Its only input is `.claude/regression/probes.md`, itself ACCEPTED_ABSENT above (a consumer's
77
+ # regression run must not compare against this harness's probe set). Shipping the reader without
78
+ # its corpus would put a script in the package that can only ever report "instrument error".
79
+ "scripts/probe_scope_check.sh"
70
80
  )
71
81
 
72
82
  out=$(python3 - "${ACCEPTED_ABSENT[@]}" <<'PY'
@@ -42,6 +42,12 @@
42
42
  # auto-approve the flagged command past the permission system — the guard must never grant what
43
43
  # it exists to question. Block mode keeps the exit-2 path (stderr → fed to Claude, call blocked);
44
44
  # on exit 2 stdout/JSON is ignored by contract, so stderr is the correct channel there.
45
+ # NAMED RESIDUAL (terra round 3, 2026-08-01) — a shell COMMENT inside a wrapper group hides the
46
+ # closer from R2's required-closer branch, so `| (tail -5; # note<newline>); rc=$?` MISSES.
47
+ # Accepted, not fixed, and the reason is the trade direction: stripping `#…` in the flatten is
48
+ # quote-blind, so `curl "https://x/a#frag" | tail -3; rc=$?` — which HITs today, measured — would
49
+ # become a miss. That swaps a contrived miss for a realistic one. Recall loss on a shape that does
50
+ # not occur in interactively-composed commands is the cheaper side; lane-pinned as a known miss.
45
51
  # NAMED RESIDUAL (cross-family, 2026-07-31): with python3 broken/absent, payload extraction
46
52
  # yields CMD="" and the guard exits 0 even under FH_PIPE_VERDICT_BLOCK=1 — block mode fails
47
53
  # open on a dead interpreter. Accepted, not fixed: the Bash-call surface is re-runnable
@@ -67,7 +73,7 @@ import json,sys
67
73
  try: d = json.load(sys.stdin)
68
74
  except Exception: sys.exit(0)
69
75
  if d.get("tool_name") != "Bash": sys.exit(0)
70
- sys.stdout.write(d.get("tool_input", {}).get("command", "") or "")
76
+ sys.stdout.buffer.write((d.get("tool_input", {}).get("command", "") or "").encode("utf-8"))
71
77
  ' 2>/dev/null) || CMD=""
72
78
  fi
73
79
  [ -n "$CMD" ] || exit 0
@@ -84,7 +90,25 @@ add() { hits="${hits} ⚠️ PIPE-VERDICT $1
84
90
  # every multi-line command missed — which is the worse half, because the invocations that actually
85
91
  # recur here are multi-line. A newline is a statement separator, so `; ` is the faithful substitute.
86
92
  # (Found by the Axis-2 adversarial pass on this guard, 2026-07-31; lanes A* pin it.)
87
- FLAT=$(printf '%s' "$CMD" | tr '\n' ';' | sed 's/;/; /g')
93
+ # EXCEPT where the shell itself continues the statement (GPT leg-C MED, 2026-08-01): a newline
94
+ # after `|`/`&&`/`||` or a backslash-newline is a CONTINUATION, not a separator — the blanket
95
+ # `\n → ;` rewrite turned `cmd |\n tail; echo $?` into `cmd | ; tail…`, un-matching R2 on the
96
+ # exact multi-line shape the flatten exists to catch. Join those first (backslash-newline joins
97
+ # with the EMPTY string, matching shell semantics — the destructive_pre_gate R4 lesson), then
98
+ # separate the remaining newlines. \001 is the newline sentinel (never occurs in command text).
99
+ # terra round (2026-08-01): `|&` continuation and newline-after-`(`/`{` join too — both continue
100
+ # the statement in the shells this guard serves. KNOWN FP INHERITED: the join cannot see quotes,
101
+ # so a QUOTED multi-line string containing `false |\ntail; echo $?` now reads as a live pipeline
102
+ # (mention-as-data — same advisory-tolerated class destructive_pre_gate documents; a
103
+ # quote-aware parser is over-build for an advisory layer). Lane-pinned as expected-HIT.
104
+ NL=$'\001'
105
+ FLAT=$(printf '%s' "$CMD" | tr '\n' "$NL" | sed \
106
+ -e "s/\\\\${NL}[[:space:]]*//g" \
107
+ -e "s/|&[[:space:]]*${NL}[[:space:]]*/|\& /g" \
108
+ -e "s/|[[:space:]]*${NL}[[:space:]]*/| /g" \
109
+ -e "s/&&[[:space:]]*${NL}[[:space:]]*/\&\& /g" \
110
+ -e "s/\([({]\)[[:space:]]*${NL}[[:space:]]*/\1 /g" \
111
+ -e "s/${NL}/;/g" -e 's/;/; /g')
88
112
 
89
113
  # ── R1 — PIPESTATUS under zsh: the value is empty, so the verdict is absent. ──────────────────
90
114
  # Brace-optional: zsh accepts `$PIPESTATUS[0]` as well, and the brace-anchored form missed it (lane B*).
@@ -99,8 +123,24 @@ fi
99
123
  # `set -o pipefail` in the same command makes `$?` after a pipeline correct — not a finding.
100
124
  NORM=$(printf '%s' "$FLAT" | sed 's/||/__OR__/g')
101
125
  if ! printf '%s' "$NORM" | grep -qE 'set -o pipefail|set -[a-zA-Z]*o[a-zA-Z]* pipefail'; then
126
+ # `[({]?` — a subshell/group wrapper around the filter (`| (tail -5); echo $?`) is the same
127
+ # verdict mistake one paren deeper; without it the wrapper bypassed R2 (GPT leg-C MED, 2026-08-01).
128
+ # The closing paren rides in the arg class (`)` ∈ [^|;&]) or the explicit `[)}]?` for the no-arg form.
129
+ # `&?` after the pipe — `|&` (stderr-merged pipeline, zsh/bash4) is the same verdict mistake
130
+ # with a merged stream; it evaded the whitespace-anchored matcher (terra round, 2026-08-01).
131
+ # TWO BRANCHES, and the wrapped one REQUIRES A CLOSER (terra round 2, 2026-08-01): with the
132
+ # closer optional, `| (tail -5; test -n "$x"); echo $?` matched on the `;` INSIDE the group —
133
+ # but there `$?` is `test`'s status, which is exactly right, so the warning was a false positive
134
+ # the pre-wrapper matcher never produced. The filter must be the wrapper's FINAL command:
135
+ # (a) bare filter, then a statement separator
136
+ # (b) wrapper open · optional earlier statements ending in `;` · filter · args (no `;`, no
137
+ # closer chars) · optional `;` · REQUIRED `)`/`}`
138
+ # Branch (b)'s optional `([^|&]*;)?` prefix keeps `| (sort; tail -5); echo $?` caught — the
139
+ # filter need not be the group's FIRST command, only its LAST — while the `;` requirement keeps
140
+ # the filter at a statement start, so `| (echo cat); rc=$?` does not match through a bare word.
141
+ _F='(tail|head|cat|less|more)'
102
142
  if printf '%s' "$NORM" \
103
- | grep -qE '\|[[:space:]]*(tail|head|cat|less|more)([[:space:]][^|;&]*)?[[:space:]]*[;&].*\$\?'; then
143
+ | grep -qE "\|&?[[:space:]]*(${_F}([[:space:]][^|;&]*)?|[({]([^|&]*;)?[[:space:]]*${_F}([[:space:]][^|;&()}]*)?[[:space:]]*;?[[:space:]]*[)}])[[:space:]]*[;&].*\\\$\?"; then
104
144
  add "R2 \$? after a display filter" \
105
145
  "\$? holds the filter's status (tail/head/cat almost always succeed), not the command's — a FAILED check reads as 0. Capture first: \`out=\$(cmd 2>&1); rc=\$?\` then print \"\$out\" | tail."
106
146
  fi
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # selfcheck.sh — mandatory-pass (deterministic) checks on FH's own executable surface.
3
- # Class: mandatory-pass (harness_6axis_framework.md §Axis 5 check classes) — blocks on fail.
3
+ # Class: mandatory-pass (harness_6axis_framework.md §Check classes) — blocks on fail.
4
4
  # Scope: executables shipped via npm files[] + the bash infra driving the FH gate chain.
5
5
  # NOT syntax-only any more, and this line used to say it was. Syntax checks (node --check / bash -n)
6
6
  # are only the first section; behavioural lane suites follow and they DO have side effects and
@@ -253,7 +253,19 @@ for _anchor in scripts/test_session_close_lanes.sh scripts/test_card_drift_probe
253
253
  if [ ! -f scripts/session_close_check.sh ]; then
254
254
  echo "SKIP ${_anchor##*/} (subject scripts/session_close_check.sh absent)"
255
255
  elif [ -f "$_anchor" ]; then
256
- if ! bash "$_anchor"; then
256
+ # Preserve the anchor's two failure CLASSES instead of flattening them into one `fail=1`.
257
+ # An anchor exits 3 when a fixture's premise never obtained — "this run's verdicts prove
258
+ # nothing" — which is a different instruction to whoever reads the CI summary than exit 1's
259
+ # "the gate is broken". Collapsing them here would rebuild, at the only wired caller, the
260
+ # triage ambiguity the anchors' own exit codes exist to remove (Wave-3, 2026-08-02).
261
+ # 3 and not 2: bash itself returns 2 on a syntax error, so a rotted anchor must not be able to
262
+ # impersonate a fixture premise failure. Both classes still set fail=1 — a fixture error is a
263
+ # failed run, it is just a differently-diagnosed one.
264
+ bash "$_anchor"; _rc=$?
265
+ if [ "$_rc" -eq 3 ]; then
266
+ echo "FIXTURE ERROR ${_anchor##*/}: a lane premise never obtained — its verdicts prove nothing (exit 3, not a gate failure)"
267
+ fail=1
268
+ elif [ "$_rc" -ne 0 ]; then
257
269
  fail=1
258
270
  fi
259
271
  else
@@ -263,6 +275,109 @@ for _anchor in scripts/test_session_close_lanes.sh scripts/test_card_drift_probe
263
275
  fi
264
276
  done
265
277
 
278
+ # sync_guard_check.sh — same anchor contract, wired here for the first time (2026-08-02). It had NO
279
+ # automated caller at all: a known-pair anchor for the destination-newer guard that only ever ran
280
+ # when a human remembered to type it. "A guard nobody re-tests degrades into a comment" is that
281
+ # file's own opening argument, and it applied to the anchor itself. Guarded on its subject's
282
+ # presence because the npm package ships a narrower surface than the source tree.
283
+ if [ ! -f scripts/sync-to-be.sh ]; then
284
+ echo "SKIP sync_guard_check.sh (subject scripts/sync-to-be.sh absent)"
285
+ elif [ -f scripts/sync_guard_check.sh ]; then
286
+ bash scripts/sync_guard_check.sh; _rc=$?
287
+ if [ "$_rc" -eq 3 ]; then
288
+ echo "FIXTURE ERROR sync_guard_check.sh: a lane premise never obtained — its verdicts prove nothing (exit 3, not a guard failure)"
289
+ fail=1
290
+ elif [ "$_rc" -ne 0 ]; then
291
+ fail=1
292
+ fi
293
+ else
294
+ echo "FAIL sync_guard_check.sh: sync-to-be.sh present but its anchor is missing"
295
+ fail=1
296
+ fi
297
+
298
+ # probe_scope_check.sh — its known-pair controls. Wired here because the probe set is hand-maintained and
299
+ # nothing else enforces its own anti-stale rule: a heading-direction
300
+ # bug scored a 7-probe section as UNMEASURED (98% vs the true 51%), then a narrow anchor regex reported
301
+ # 7 live probe scopes as stale. Control B now walks EVERY scope in probes.md and fails closed (exit 3,
302
+ # number withheld) when one no longer resolves — which is also the anti-stale rule probes.md already
303
+ # states for itself, finally given a checker.
304
+ # Package mode is decided by the SUBJECT's own absence. The first draft used `.claude/rules` as the
305
+ # discriminator on the belief that it does not ship — measured false: package.json files[] carries
306
+ # `.claude/rules/fh_4axis_gate.md`, so in an installed package `.claude/rules` EXISTS while the probe
307
+ # corpus does not. That made the SKIP arm unreachable and every consumer's `npm test` hard-fail with a
308
+ # message misdiagnosing the package as a source tree. Avoiding a silent pass is not a licence to
309
+ # over-block the normal case. `scripts/probe_scope_check.sh` is ACCEPTED_ABSENT and genuinely never
310
+ # ships, so its absence is the one honest package signal here.
311
+ if [ ! -f scripts/probe_scope_check.sh ] && [ ! -f .claude/regression/probes.md ]; then
312
+ echo "SKIP probe_scope_check.sh (package mode: neither the instrument nor its corpus ships)"
313
+ elif [ ! -f .claude/regression/probes.md ]; then
314
+ echo "FAIL probe_scope_check.sh: source tree but .claude/regression/probes.md is missing — the check cannot run — UNVERIFIED, not clean"
315
+ fail=1
316
+ elif [ -f scripts/probe_scope_check.sh ]; then
317
+ bash scripts/probe_scope_check.sh --self-test >/dev/null 2>&1; _rc=$?
318
+ if [ "$_rc" -eq 3 ]; then
319
+ echo "FAIL probe_scope_check.sh: CONTROL FAILED — a probe Scope no longer resolves to a section (the probe set no longer says what it defends)"
320
+ bash scripts/probe_scope_check.sh --self-test 2>&1 | grep -E "STALE|NOFILE|control" | head -12
321
+ fail=1
322
+ elif [ "$_rc" -ne 0 ]; then
323
+ echo "FAIL probe_scope_check.sh: self-test exited $_rc"
324
+ fail=1
325
+ else
326
+ echo "PASS probe_scope_check.sh (known-pair + scope-resolution controls hold)"
327
+ fi
328
+ else
329
+ echo "FAIL probe_scope_check.sh: probe set present but the scope checker is missing"
330
+ fail=1
331
+ fi
332
+
333
+ # ④-e dispatch-log reconciliation + its tally hook. Wired in the same commit that ships them: the
334
+ # obligation they mechanize lost 20/20 in a single session, so leaving the checker itself unrun
335
+ # would be the same defect one layer up.
336
+ if [ -f scripts/test_dispatch_log_lanes.sh ]; then
337
+ if ! bash scripts/test_dispatch_log_lanes.sh >/dev/null 2>&1; then
338
+ echo "FAIL test_dispatch_log_lanes.sh: the dispatch-log reconciliation would mis-report"
339
+ bash scripts/test_dispatch_log_lanes.sh 2>&1 | tail -14
340
+ fail=1
341
+ else
342
+ echo "PASS test_dispatch_log_lanes.sh (date-spelling + verdict + tally-hook lanes)"
343
+ fi
344
+ fi
345
+
346
+ # selfcheck's own subject-presence discriminators. Every other guard under scripts/ has a lane suite;
347
+ # this decision had none, and it shipped two mis-routings in one session — a two-arm form that fell
348
+ # through in silence, then a package discriminator keyed on a file that actually ships. Both are
349
+ # known-POSITIVEs in the suite, so neither can come back green.
350
+ if [ -f scripts/test_selfcheck_state_lanes.sh ]; then
351
+ if ! bash scripts/test_selfcheck_state_lanes.sh >/dev/null 2>&1; then
352
+ echo "FAIL test_selfcheck_state_lanes.sh: a subject-presence discriminator would mis-route"
353
+ bash scripts/test_selfcheck_state_lanes.sh 2>&1 | tail -12
354
+ fail=1
355
+ else
356
+ echo "PASS test_selfcheck_state_lanes.sh (four input states + both shipped mis-routings)"
357
+ fi
358
+ fi
359
+
360
+ # sync_from_be_lanes.sh — the RETURN path's anchor. Wired in the same change that ships it: the
361
+ # script had an operator-side caller (a SessionStart hook outside this repo) while its 70 lanes had
362
+ # NO caller anywhere, which is the shape this repo keeps re-finding — a transport that writes into
363
+ # the hub, guarded by a suite nothing runs. Same subject-presence idiom as the block above: the
364
+ # subject is operator-private and does not ship, so package mode legitimately skips; subject present
365
+ # with the anchor gone is a deleted calibration, not a skip.
366
+ if [ ! -f scripts/sync-from-be.sh ]; then
367
+ echo "SKIP sync_from_be_lanes.sh (subject scripts/sync-from-be.sh absent)"
368
+ elif [ -f scripts/sync_from_be_lanes.sh ]; then
369
+ if ! bash scripts/sync_from_be_lanes.sh >/dev/null 2>&1; then
370
+ echo "FAIL sync_from_be_lanes.sh: return-path lanes failed"
371
+ bash scripts/sync_from_be_lanes.sh 2>&1 | tail -20
372
+ fail=1
373
+ else
374
+ echo "PASS sync_from_be_lanes.sh (return-path lanes)"
375
+ fi
376
+ else
377
+ echo "FAIL sync_from_be_lanes.sh: sync-from-be.sh present but its anchor is missing"
378
+ fail=1
379
+ fi
380
+
266
381
  # Referenced-path existence is a source-tree check. The npm package intentionally
267
382
  # ships a narrower runtime surface, so package-mode selfcheck skips this section.
268
383
  if [ -d ".claude/rules" ]; then