@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.
- package/.claude/rules/fh_4axis_gate.md +45 -1
- package/.claude-plugin/marketplace.json +2 -2
- package/CATALOG.md +23 -1
- package/CHEATSHEET.md +1 -1
- package/CLAUDE.md +21 -7
- package/knowledge/shared/harness-core/field_harness_diagnostic.md +1 -0
- package/knowledge/shared/harness-core/harness_incubator_doctrine.md +10 -0
- package/knowledge/shared/harness-core/harness_verification_core_extended.md +167 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +206 -0
- package/knowledge/shared/rules/operational_adaptation.md +35 -9
- package/package.json +3 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-commons/skills/convergence-loop/SKILL.md +25 -10
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/agents/challenger.md +1 -1
- package/plugins/fh-meta/skills/phantom-quench/SKILL.md +1 -1
- package/plugins/fh-meta/skills/steel-quench/SKILL.md +78 -10
- package/plugins/fh-meta/skills/steel-quench/SKILL_detail.md +3 -3
- package/scripts/consent_registry_check.sh +187 -15
- package/scripts/destructive_pre_gate.sh +32 -5
- package/scripts/package_coverage_check.sh +10 -0
- package/scripts/pipe_verdict_guard.sh +43 -3
- package/scripts/selfcheck.sh +117 -2
- package/scripts/session_close_check.sh +103 -1
- package/scripts/stale_clone_guard.sh +49 -6
- package/scripts/substrate_jump_detector.sh +21 -0
- package/scripts/test_consent_registry.sh +341 -42
- package/scripts/test_destructive_pre_gate_lanes.sh +42 -0
- package/scripts/test_dispatch_log_lanes.sh +99 -0
- package/scripts/test_pipe_verdict_guard_lanes.sh +71 -0
- package/scripts/test_selfcheck_state_lanes.sh +102 -0
- package/scripts/test_session_close_lanes.sh +187 -15
- package/scripts/test_stale_clone_guard_lanes.sh +54 -0
- package/templates/.git-hooks/pre-push +10 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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 ([^|;&]
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
|
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
|
package/scripts/selfcheck.sh
CHANGED
|
@@ -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 §
|
|
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
|
-
|
|
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
|