@chrono-meta/fh-gate 1.4.88 → 1.4.89

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.
@@ -11,13 +11,13 @@
11
11
  "plugins": [
12
12
  {
13
13
  "name": "fh-meta",
14
- "version": "1.4.88",
14
+ "version": "1.4.89",
15
15
  "description": "Hub meta-operations toolkit — 35 skills + 7 agents. New in 1.4.53: `fh-codex-doctor` (npm bin) — Codex adapter drift scanner; reads the documented M1/M2/M3 skill tier map + skill/agent source and reports codex-native/adapter-required/claude-native/unclassified per unit, wired into `npm test`/`prepublishOnly` (fail-closed on unclassified Claude-native primitives). New in 1.4.49: steel-quench gains Step 0.6 Verdict-Invariance Probe (groundedness axis — a load-bearing judged gate's verdict must track behavior, not rubric phrasing; measured flip-count over cross-family paraphrases; arXiv:2605.06161 Policy Invariance anchor); multi_model_sidecar_strategy §Vendor-native harness (a model is strongest in its own vendor CLI — Claude/CC, GPT/codex, Gemini/Antigravity; a universal router degrades all of them, so it stays an autocomplete/QA sidecar, never orchestration); predelete_check.sh fail-closed rewrite; memory-hygiene A-TMA anchor. New in 1.4.48: phantom-quench + steel-quench gain external frontier anchors (arXiv:2607.02052 package-hallucination; arXiv:2607.02057 prompt-coverage-adequacy); README model-flat claim reframed from a per-release point-curve to structural invariants (operation flattens across tiers; depth tier-order fixed within a generation). New in 1.4.47: onboarding step ① surfaces the Mode D companion-store session-start load in the auto-read salience anchor (previously only in the local binding + rules, so a greeting could skip the load). New in 1.4.46: context-doctor command-output axis (route to rtk/proxy for verbose CLI stdout, complementing .claudeignore; risk-gated to token-scarce envs). New in 1.4.41: context-doctor 2026 trigger vocab (context engineering/rot/collapse) + phantom-citation hardening; hub measurement-integrity-checklist (cross-model measurement pre-flight: display-name pin/reps≥3/discriminating probe). New in 1.4.40: install-wizard queryable-wiki scaffold (INDEX + session-start read + R/W/C ingest). New in 1.4.39: auto-decorrelation (cross-family verifier sidecar recruitment) + video-ingest (capability-routed video ingestion). New in 1.4.x: verify-axis check-class taxonomy (mandatory-pass/measured/judged), no-reinvention Tier-0 inventory, 7-class failure taxonomy, Destructive-Op Gate, Wave-T (Temper), tier-floor governance, Mode D Model Notice, FC consent lane, default-Sonnet guidance. New in 1.3.0: public-surface-audit, field-harvest Mode B auto-trigger, 4-axis gate scope ext. Validated cross-CLI: Claude Code, Codex, Gemini.",
16
16
  "source": "./plugins/fh-meta"
17
17
  },
18
18
  {
19
19
  "name": "fh-commons",
20
- "version": "1.4.88",
20
+ "version": "1.4.89",
21
21
  "description": "Project-agnostic utility skills — 4 skills (convergence-loop · deliberation · mcp-circuit-breaker · token-budget-gate) + 1 agent (quench-challenger). Domain-independent utilities transplantable into any project.",
22
22
  "source": "./plugins/fh-commons"
23
23
  }
package/CLAUDE.md CHANGED
@@ -602,6 +602,42 @@ Default operation is a **standard interactive session**. Agent dispatch (single
602
602
 
603
603
  **Why not Agent View by default**: Agent View introduces worktree isolation (blocks settings.json writes, Stop hook timing differs), session context gaps (session card stale content bug), and path friction — with no benefit unless the user is actively managing multiple agent sessions. Parallel agents via `Agent` tool work identically in a standard session.
604
604
 
605
+ **Fourth reason — gate-integrity in a worktree, and the answer is CONDITIONAL on how `core.hooksPath`
606
+ was set (measured 2026-08-05, both arms).** Do not carry a single verdict here; the two installs
607
+ behave differently:
608
+
609
+ | `core.hooksPath` | Which hook actually runs in a worktree | Consequence |
610
+ |---|---|---|
611
+ | **relative** — `templates/.git-hooks`, the form every FH doc installs (`CHEATSHEET.md`, `.claude/rules/fh_4axis_gate.md`, `install-wizard`, `self_evolution_routine.md`) | the **worktree's own copy** | Editing that copy *inside the worktree* disables the gate for that worktree — measured: neutralized hook → FH-asset commit with no marker succeeded (`rc=0`). The verifier becomes the verified, and the edit is invisible to `git status` in the main tree. |
612
+ | **absolute** — a hand-set full path (this operator's machine; **not** what any doc tells you to run) | the **main tree's copy** | A worktree-local edit has no effect; a known-positive is blocked there exactly as in the main tree (`rc=1`). |
613
+
614
+ An earlier draft of this section reported only the absolute-path arm and declared the
615
+ "worktree bypasses the gates" hypothesis *refuted* — from **n=1 on a non-canonical setting**, with a
616
+ do-not-revisit label attached. The relative-path arm, which is what everyone else runs, reproduces
617
+ the bypass. Freezing a conclusion is a defect when the measurement did not cover the shipped
618
+ configuration.
619
+
620
+ Separately and in **both** arms, the **evidence side** breaks: `tracks/` is gitignored, so it does not
621
+ follow into a worktree, so the Axis 2+3 marker and the Axis 4 `edit_manifest.yaml` are *structurally
622
+ absent* — an FH-asset commit in a worktree fails on evidence it has no way to have. That degrades
623
+ fail-closed (correct), but a gate that **cannot** be satisfied is what trains the bypass. Note the
624
+ hook itself prints `mkdir -p …/tracks/_meta` on that failure, i.e. the actor's own error message
625
+ teaches the marker-creation path — so "just don't fabricate it" is prose sitting under a machine
626
+ instruction pointing the other way.
627
+
628
+ **Therefore: do not commit FH assets from a worktree.** Not "carry the evidence in carefully" — a
629
+ carried marker and a fabricated one are byte-identical, so *marker provenance* is unenforceable by
630
+ construction. Land FH-asset changes from the standard session.
631
+
632
+ **Do not let that unenforceability launder the enforceable part** (caught by an adversarial round on
633
+ the paragraph above, which had used it to do exactly that): *being in a worktree* is trivially
634
+ detectable — `git rev-parse --git-common-dir` differs from `--git-dir` there and matches in the main
635
+ tree — and `templates/.git-hooks/pre-commit` currently has **zero** lines of worktree detection. A
636
+ true statement about one thing (provenance) was standing in for an untested claim about another
637
+ (location). It is left un-mechanized for a *scope* reason, not an impossibility one: measured
638
+ recurrence is 1, below this repo's own N≥3 mechanization threshold. If it recurs, the check is a
639
+ two-line hook addition, not a research problem.
640
+
605
641
  **Forbidden responses**: *"I can't do that — I'm not in that project's cwd"* — self-check Agent dispatch first.
606
642
 
607
643
  Mapped paths: check `auto_project_mapping.md` or `find ~/projects -maxdepth 1 -type d` for actuals.
@@ -196,6 +196,13 @@ destroys live state without anyone noticing. This is why the loss class is calle
196
196
  plaintext only (encoded tokens out of scope); a line-split backstop catches a token wrapped across
197
197
  lines; `PUBLIC_SURFACE_OK=1` overrides and is logged to a gitignored audit trail for the weekly audit.
198
198
  Residuals (split-encoding, override-not-populated, override abuse) are documented, not silent.
199
+ **Verdict labelling (2026-08-06)**: with the override absent, a clean scan reports
200
+ `⚠️ PARTIAL — company/companion literals UNMEASURED`, **never `✅ PASS`**. The commit still
201
+ proceeds (reversible surface → advisory degrade), but a run whose operator-literal layer never
202
+ executed may not present the same verdict as one where it did — a missing measurement is not a
203
+ zero. Anchored in `universal_guard_check.sh` as a **pair**: absent override must say `PARTIAL`,
204
+ and the control (override present, no hit) must still say `✅ PASS`, so the label cannot drift
205
+ back to a bare PASS *or* become an unconditional warning.
199
206
  - **(c) `npm publish`** — mechanically gated by `scripts/public_surface_scan_files.sh`, wired into
200
207
  `prepublishOnly` (`npm run release` also runs it *outside* the lifecycle). Unlike (b) it scans the
201
208
  **full content of the exact npm-published file set** (`npm pack --dry-run`), *not* a commit diff — so a
@@ -204,6 +211,36 @@ destroys live state without anyone noticing. This is why the loss class is calle
204
211
  or the file set are unresolved, when the parse looks partial, **or when the gitignored operator override
205
212
  is absent** — defaults-only would otherwise green-PASS a HIGH company literal on a fresh clone or CI runner.
206
213
 
214
+ **The git-push surface, and why it was the lenient one (2026-08-06).** (c) blocked on an absent
215
+ override; the `git push` gate in `templates/.git-hooks/pre-push` only warned. Both make content
216
+ public, so two irreversible surfaces were degrading in **opposite directions on the same state** —
217
+ the actual defect, and `git push` (the one nobody publishes through deliberately) was the permissive
218
+ side. The 2026-07-26 reasoning behind that warn was not wrong, it was **unscoped**: an absent
219
+ override in a fresh clone / CI runner / worktree is a legitimate per-operator configuration gap (the
220
+ file is gitignored, so it is absent there *by construction*), and blocking it trains
221
+ `PUBLIC_SURFACE_OK` into a reflex — which disarms the same channel the publish gate depends on.
222
+
223
+ So the warn is **scoped, not reverted**. `psa_detect_operator_context` (`scripts/psa_scan_lib.sh`)
224
+ splits the state: in an **operator-configured checkout** an absent override is *evidence missing
225
+ where evidence is expected* → BLOCK; everywhere else → WARN, exactly as before. The signal is
226
+ `CLAUDE.local.md`, the operator's own gitignored binding file.
227
+
228
+ **Calibration matters here more than the rule** — a second candidate signal, "`tracks/_meta` is
229
+ non-empty", reads as the same test and is not: `tracks/_meta/.gitkeep` and one sibling are
230
+ **tracked**, so a fresh clone satisfies it and would have been blocked, re-shipping the 07-26
231
+ over-block under a new name. It was rejected by measuring it against a known pair, not by reasoning
232
+ about it. **Named residual, deliberately in the under-blocking direction**: an operator who never
233
+ created a `CLAUDE.local.md` stays in the WARN arm. **Second residual, and it is the sharper one**:
234
+ deleting `CLAUDE.local.md` drops this checkout back into the WARN arm, and unlike `PUBLIC_SURFACE_OK=1`
235
+ that bypass **writes no log line**. It is a conscious act on the operator's own file, so it is not a
236
+ weak-model fail-open — but it is a quieter exit than the sanctioned one, which is the wrong ordering
237
+ for a bypass. Not closed here: making it loud means the hook must distinguish "never had one" from
238
+ "had one and lost it", and that needs state the hook does not currently keep. Named rather than
239
+ mechanized, per this repo's own threshold — mechanize on the first measured recurrence.
240
+ Anchored as a pair in `prepush_guard_check.sh`
241
+ (6-a absent override → PASS · 6-b absent override + operator checkout → BLOCK); both arms are
242
+ required, since 6-b passing alone would not distinguish a scoped block from a blanket one.
243
+
207
244
  **Named residuals for (c)** — it is a denylist **on the npm CLI path with scripts enabled**, not a
208
245
  universal secret-scanner:
209
246
 
@@ -996,3 +996,76 @@
996
996
  invented: a fabricated outcome would poison the 60/40 promotion gate worse than a missing one,
997
997
  which is the stated reason the hook only tallies and never writes an entry. Open question for
998
998
  the next session that touches the tally: which tool events actually fire SubagentStop here.
999
+
1000
+ - date: '2026-08-05'
1001
+ agent: persona-innovator ×2 · quench-challenger ×3 · beginner ×5 (Agent tool) + codex gpt-5.5 ×2 (CLI)
1002
+ context: >-
1003
+ Innovator autopilot session. Two sister-asset absorption scans (innovator); steel-quench Wave 1
1004
+ and Wave 3 on the selfcheck run-twice repair, plus one opus Axis-2 pass on the AGENTS.md change
1005
+ (quench-challenger); five Sonnet-pinned cold-read sims on one AGENTS.md checklist item
1006
+ (beginner); two cross-family audits (codex CLI, tool-use disabled — not counted by the tally
1007
+ hook, which only fires on Agent-tool SubagentStop).
1008
+ dispatches: '10 via Agent tool + 2 CLI sidecars (tally recorded 14 — the delta is unattributed,
1009
+ see the 2026-08-05 entry above about unknown tally sources; not invented here either)'
1010
+ outcome: accepted
1011
+ evidence: >-
1012
+ Every class changed the outcome, which is why this is `accepted` rather than `partial`.
1013
+ quench-challenger: Wave 1 produced 2S/3A/4B and Wave 3 produced 2S/1A/3B on the REPAIR — one of
1014
+ those overturned a judgment already reported to the operator (a "refuted" verdict measured on a
1015
+ non-canonical absolute core.hooksPath; the documented relative-path install reproduces the
1016
+ bypass). The opus Axis-2 pass produced 2S that caused a full revert of the AGENTS.md change.
1017
+ beginner: 5 rounds, each blocking at a DIFFERENT point, each block introduced by the previous
1018
+ round's repair; author self-catch across rounds 1-4 was ZERO. innovator: 4 external repos
1019
+ verified by gh api with known-pair controls, 3 net-new items confirmed by in-repo greps with
1020
+ live controls. codex: round 1 was fed a STALE diff (my error — the target was not frozen) so 2
1021
+ of its findings were already closed and 1 was a misread; round 2 on a fingerprinted target found
1022
+ the BSD `tr -d '[:space:]'` defect that both same-family rounds missed.
1023
+ note: >-
1024
+ Logged at close, after the session_close_check ④-e block caught the omission — the obligation
1025
+ is prose in CLAUDE.md and I did not discharge it during the session despite dispatching 10+
1026
+ agents. That is the same shape the ④-e check was built for. Consolidated per class with counts
1027
+ rather than one stub per dispatch, which the check explicitly permits.
1028
+
1029
+ - date: '2026-08-06'
1030
+ agent: beginner ×5 (Sonnet-pinned cold read) · quench-challenger ×1 (opus Axis-2) — same session as 2026-08-05
1031
+ context: >-
1032
+ The innovator autopilot session crossed midnight; the tally records 14 dispatches under
1033
+ 2026-08-06 while the substantive description lives in the 2026-08-05 entry above. These are not
1034
+ additional work — they are the same session's later half: the five cold-read rounds on the
1035
+ AGENTS.md worktree item and the opus adversarial pass that ended it.
1036
+ dispatches: '6 attributable here (tally: 14 under this date; the remainder shares the
1037
+ unattributed-source question already recorded on 2026-08-05)'
1038
+ outcome: accepted
1039
+ evidence: >-
1040
+ The opus Axis-2 pass returned 2S — a recipe that commits to the WRONG BRANCH (git forbids two
1041
+ worktrees sharing a branch, so the main checkout and a worktree are always on different
1042
+ branches, and the recipe never aligned them), and a hook that hands out a marker-fabrication
1043
+ one-liner at the exact moment it blocks. Both were reproduced against the live repo, and the
1044
+ change was REVERTED in full rather than patched. The five beginner rounds each blocked at a
1045
+ different point with author self-catch of zero across rounds 1-4.
1046
+ note: >-
1047
+ Written after the close check refused the push a SECOND time: the first correction logged the
1048
+ session under 2026-08-05 while the check counts today's date, so "logged" and "logged where the
1049
+ check looks" came apart. Recorded rather than back-dated — the split is real (the tally itself
1050
+ splits 34/14 across the boundary) and pretending otherwise would misstate when the work ran.
1051
+
1052
+ - date: 2026-08-06
1053
+ agent: general-purpose ×2 (블라인드 응시자), fh-commons:quench-challenger ×2 (Axis 2)
1054
+ purpose: >-
1055
+ qasp 풀체인 단련 — 출제자/응시자 분리 블라인드 실행 2회(2막 대조 · 3층 난이도)와,
1056
+ FH 마감 체커 fail-open 수리에 대한 Axis 2 적대검증 2라운드(sonnet → at-floor opus).
1057
+ model: sonnet ×3, opus ×1
1058
+ outcome: accepted
1059
+ evidence: >-
1060
+ 블라인드 2회가 계기 결함 2건을 직접 산출했다 — 1차는 두 런 차이를 "플레이키"로 오귀속해
1061
+ 리포트에 코드 상태 지문이 없음을 드러냈고(#84→PR #86), 2차는 counts 만 대조해 "변화 없음"이라
1062
+ 결론내 집계가 개별을 가리는 문제를 드러냈다(→PR #87). 두 오귀속 모두 **주어진 산출물만 보고는
1063
+ 그렇게 결론낼 수밖에 없었다** — 그래서 결함이 응시자가 아니라 계기 쪽임이 판별됐다.
1064
+ 챌린저 R1(sonnet)은 수리 대상 바로 윗줄에 같은 fail-open 이 남아 있음을 재현으로 잡았고,
1065
+ 게이트가 floor 미달로 거부해 R2(opus, at-floor)를 돌리자 R1 이 못 본 4건이 추가로 나왔다
1066
+ (재발견 0건, 전부 실제 재현). known-pair 6/6.
1067
+ note: >-
1068
+ 45 dispatch 대 1 로그 항목으로 ④-e 가 통과한 상태였다 — 이 항목이 그 간극을 메운다.
1069
+ 카운트가 큰 것은 Explore 등 내부 에이전트가 함께 집계되기 때문이고, 의미 있는 위임은 위 4건이다.
1070
+ ★방법론 소득이 결함보다 크다: 출제자=응시자면 "잘 나온다"로 끝나고, 분리하면 매 라운드
1071
+ 계기 결함이 나온다. 그리고 floor 는 장식이 아니었다 — sonnet R1 이 놓친 HIGH 를 opus R2 가 잡았다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrono-meta/fh-gate",
3
- "version": "1.4.88",
3
+ "version": "1.4.89",
4
4
  "description": "FH runtime adapters — run FH governance, skills, and agents via Claude or Codex with machine-parseable gates.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -26,7 +26,7 @@
26
26
  "scripts": {
27
27
  "prepare": "chmod +x bin/fh-gate.js bin/fh-run.js bin/fh-goal.js bin/fh-codex-doctor.js scripts/fh-gate.sh scripts/fh-run.sh scripts/fh-goal.sh",
28
28
  "test": "bash scripts/selfcheck.sh",
29
- "prepublishOnly": "bash scripts/selfcheck.sh && bash scripts/public_surface_scan_files.sh",
29
+ "prepublishOnly": "bash scripts/version_lockstep_check.sh && bash scripts/selfcheck.sh && bash scripts/public_surface_scan_files.sh",
30
30
  "release": "bash scripts/public_surface_scan_files.sh && npm publish"
31
31
  },
32
32
  "engines": {
@@ -64,7 +64,9 @@
64
64
  "scripts/fh-goal.sh",
65
65
  "scripts/count_check.sh",
66
66
  "scripts/selfcheck.sh",
67
+ "scripts/version_lockstep_check.sh",
67
68
  "scripts/test_selfcheck_state_lanes.sh",
69
+ "scripts/test_version_lockstep_lanes.sh",
68
70
  "scripts/package_coverage_check.sh",
69
71
  "scripts/test_package_coverage_lanes.sh",
70
72
  "scripts/test_fh_gate_regressions.sh",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-commons",
3
- "version": "1.4.88",
3
+ "version": "1.4.89",
4
4
  "engines": {
5
5
  "claudeCode": ">=1.0.0"
6
6
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-meta",
3
- "version": "1.4.88",
3
+ "version": "1.4.89",
4
4
  "engines": {
5
5
  "claudeCode": ">=1.0.0"
6
6
  },
@@ -166,6 +166,21 @@ check "operator override absent (per-operator) → PASS " "$R" pass \
166
166
  "refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
167
167
  rm -rf "$R"
168
168
 
169
+ # ── Pair 6-b (2026-08-06): the OTHER arm of the same state, which the 07-26 flag could not see.
170
+ # Identical to 6-a except the checkout carries CLAUDE.local.md — the operator's gitignored binding
171
+ # file, absent in every clone/CI/worktree by construction. There the missing override is not an
172
+ # unset per-operator config, it is evidence missing where evidence is expected, on a surface that
173
+ # publishes. This is what `npm publish` already blocked while `git push` waved through; the two
174
+ # irreversible surfaces now degrade in the same direction.
175
+ # The PAIR is what makes it a measurement: 6-a (no CLAUDE.local.md → PASS) is the known-negative and
176
+ # must keep passing, or this is not a scoped block, it is the 07-26 over-block re-shipped. ──
177
+ R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
178
+ ( cd "$R" && echo ok > g2.md && git add g2.md && git commit -qm g2 >/dev/null \
179
+ && rm -f .claude/rules/.public-surface-patterns && printf '# operator binding\n' > CLAUDE.local.md )
180
+ check "override absent + operator checkout → BLOCK" "$R" block \
181
+ "refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
182
+ rm -rf "$R"
183
+
169
184
  R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
170
185
  ( cd "$R" && echo ok > g.md && git add g.md && git commit -qm g >/dev/null \
171
186
  && : > .claude/rules/.public-surface-patterns.defaults ) # present but EMPTY = broken, not unconfigured
@@ -38,6 +38,9 @@
38
38
  # PSA_DEFAULTS_OK 1 = committed defaults present, readable, non-empty
39
39
  # PSA_OVERRIDE_PRESENT 1 = operator override present, readable, non-empty
40
40
  # PSA_BAD_ROWS count of rows dropped as unusable (uncompilable regex, or no TAB)
41
+ # State set by psa_detect_operator_context (separate call — see that function):
42
+ # PSA_OPERATOR_CONTEXT 1 = an operator-configured checkout, where an absent override is missing
43
+ # evidence rather than a legitimate fresh-clone absence
41
44
  # A caller that treats PSA_BAD_ROWS>0 or PSA_DEFAULTS_OK=0 as "clean" has a hole: those states mean
42
45
  # the instrument is incomplete, and an incomplete instrument cannot certify anything.
43
46
 
@@ -115,6 +118,39 @@ PSA_ROWS
115
118
  PSA_STREAM="$valid"
116
119
  }
117
120
 
121
+ # psa_detect_operator_context <repo_root> → sets PSA_OPERATOR_CONTEXT (0/1)
122
+ #
123
+ # Answers ONE question: is an absent operator override a *legitimate absence* (fresh clone, CI
124
+ # runner, worktree — the file is gitignored, so it is absent there by construction) or *evidence
125
+ # missing where evidence is expected* (a checkout the operator has configured, whose literals should
126
+ # be there and are not)?
127
+ #
128
+ # 2026-07-26 changed pre-push from BLOCK to WARN on an absent override, and that change was right for
129
+ # the first case: this repo's own selfcheck flagged the block as over-firing, and over-blocking trains
130
+ # PUBLIC_SURFACE_OK into a reflex, which disarms the same channel the publish gate depends on. It was
131
+ # wrong for the second case, which the flag could not distinguish. This function is that distinction —
132
+ # it does not reopen the reverted block, it scopes it.
133
+ #
134
+ # Signal: CLAUDE.local.md — the operator's own gitignored binding file. Chosen because it is
135
+ # gitignored (verified: `.gitignore:17`), so no clone, CI checkout or worktree carries it.
136
+ #
137
+ # CALIBRATED, and one candidate was REJECTED by that calibration: "tracks/_meta is non-empty" looks
138
+ # like the same signal and is not — `tracks/_meta/.gitkeep` and one more file are TRACKED, so a fresh
139
+ # clone satisfies it and would have been blocked. The discriminator was measured against a known
140
+ # pair before it was trusted, not reasoned about.
141
+ #
142
+ # NAMED RESIDUAL (deliberate, and in the safe direction): an operator who never created a
143
+ # CLAUDE.local.md stays in the WARN arm. This under-blocks rather than over-blocks — the failure mode
144
+ # this scoping exists to avoid is the reflex, not the miss, and the push gate's content scan still
145
+ # runs on the committed defaults in that arm.
146
+ psa_detect_operator_context() {
147
+ local root="$1"
148
+ PSA_OPERATOR_CONTEXT=0
149
+ [ -n "$root" ] || return 0
150
+ [ -f "$root/CLAUDE.local.md" ] && PSA_OPERATOR_CONTEXT=1
151
+ return 0
152
+ }
153
+
118
154
  # psa_scan_tagged — reads "path<TAB>content" lines on stdin, prints one line per reportable hit.
119
155
  # Returns 0 = nothing reportable, 1 = at least one hit.
120
156
  #
@@ -25,6 +25,74 @@ check() { # check <label> <cmd...>
25
25
  fail=1
26
26
  fi
27
27
  }
28
+ # ⚠️ KNOWN DEFECT, NOT FIXED HERE — `check()` above has the same evidence-discarding shape the
29
+ # lane blocks below were repaired for (2026-08-05): it decides on `"$@" 2>/dev/null` (stderr of the
30
+ # DECIDING run is destroyed) and then re-runs to print. It is left alone deliberately: `check()` is
31
+ # called by every `node --check` / `bash -n` line in this file, so changing it changes the whole
32
+ # surface at once, which is a different job from repairing the four lane blocks (CLAUDE.md
33
+ # §Added-Scope Gate question 2). `scripts/probe_scope_check.sh`'s caller near the probe-scope block
34
+ # carries the same shape. Both are tracked separately — do NOT read the lane-block repair below as
35
+ # having cleared this file.
36
+
37
+ # _show_failure <captured-output> — print a FAILING suite's evidence without truncating it away.
38
+ # Single source for all four lane blocks (a second copy would drift; the divergent-normalizer class).
39
+ # WHY NOT `tail -N`: measured 2026-08-05 on sync_from_be_lanes.sh — output is 98 lines and a planted
40
+ # lane failure at line ~22 is INVISIBLE to `tail -20` (0 hits), while the summary banner still reads
41
+ # "1 failed". The reader gets a FAIL verdict sitting on top of passing log lines — the exact shape
42
+ # this whole repair exists to remove. The failing-line extraction finds it (1 hit, known pair).
43
+ # All four suites mark failures with `❌` (`no()` in sync_from_be_lanes.sh:21, `chk()` in the other
44
+ # three) or an early `FAIL ` line when a subject is missing; grep handles the multi-byte glyph
45
+ # (known pair: 1 hit on a ❌ line, 0 on a ✅-only line — verified, not assumed).
46
+ _show_failure() {
47
+ local out="$1" fails n banner shown nonblank
48
+ # Whitespace-only counts as empty: guarding with [ -z "$out" ] alone let a suite emitting " "
49
+ # fall into the died-early branch and print indented blank lines — silence rendered as evidence.
50
+ # SHELL PATTERN, NOT `tr`: the obvious `tr -d '[:space:]'` is a measured defect on BSD. Given a
51
+ # line containing invalid UTF-8, macOS `tr` aborts with `tr: Illegal byte sequence` and emits
52
+ # NOTHING, so the guard concludes "empty" and reports "no output captured" while real evidence is
53
+ # sitting in $out — the exact mis-report this helper exists to prevent, reintroduced by the guard
54
+ # against it. (GNU tr passes the bytes through; the arms disagree, and the failing arm is the
55
+ # author's own machine.) Case-matching is a shell builtin: no subprocess, no charset decoding, so
56
+ # invalid bytes cannot make it lie. Known pair: invalid-byte→nonblank, spaces/tabs/newlines→blank,
57
+ # ""→blank, "hello"→nonblank (4/4), while the tr form returns 0 bytes on arm 1.
58
+ case "$out" in *[![:space:]]*) nonblank=1 ;; *) nonblank= ;; esac
59
+ # NO LOCALE PIN HERE, and its absence is a measured result — same disposition, and same reasoning,
60
+ # as the pin `.github/workflows/validate.yml` removed after refuting its own locale hypothesis.
61
+ # Two model families independently suspected that matching the multi-byte `❌` would break under a
62
+ # C/POSIX locale (one filed it as UNCALIBRATED for the GNU arm, the other as an unpinned-locale
63
+ # defect), so `LC_ALL=C` was added — then both arms were actually measured:
64
+ # BSD grep (macOS) : C, UTF-8 → 1 hit each
65
+ # GNU grep 3.12 (Linux) : C, C.UTF-8, unset → 1 hit each; and with invalid UTF-8 bytes mixed
66
+ # in, the ❌ line still extracts (no binary-file
67
+ # collapse, the specific feared mode)
68
+ # The hypothesis is REFUTED on both arms, so the pin demonstrated nothing and was removed rather
69
+ # than kept as insurance — a knob retained because it might help is indistinguishable from one
70
+ # that does, and the next reader would inherit it as evidence that the danger is real.
71
+ fails=$(printf '%s\n' "$out" | grep -E '❌|^FAIL ' || true)
72
+ banner=$(printf '%s\n' "$out" | grep -E '════' | tail -1 || true)
73
+ if [ -n "$fails" ]; then
74
+ shown=$(printf '%s\n' "$fails" | head -25)
75
+ printf '%s\n' "$shown" | sed 's/^/ /'
76
+ n=$(printf '%s\n' "$fails" | wc -l | tr -d ' ')
77
+ [ "$n" -gt 25 ] && echo " … ($((n - 25)) more failing lines not shown)"
78
+ elif [ -z "$nonblank" ]; then
79
+ echo " (no output captured — the suite produced nothing before failing)"
80
+ else
81
+ echo " (no ❌/FAIL line found — suite likely died early; showing tail)"
82
+ printf '%s\n' "$out" | tail -12 | sed 's/^/ /'
83
+ fi
84
+ # Summary banner: matched by shape, not by position. A blind `tail -2` re-printed lines already
85
+ # shown above (measured: a ❌ within the last 2 lines appeared twice, and the "N more not shown"
86
+ # notice was immediately followed by one of the lines it had just declined to show), and on empty
87
+ # input it emitted a stray indented line. Print it only when it exists and is not already on screen.
88
+ # Dedupe against what was ACTUALLY PRINTED ($shown), not against the full $fails set. Searching
89
+ # $fails suppressed the banner whenever a failing line beyond the head -25 cut merely contained
90
+ # the banner text — i.e. it hid the banner precisely because a line the reader never saw mentioned
91
+ # it. $shown is empty in the non-fails branches, so the banner prints there as before.
92
+ if [ -n "$banner" ] && ! printf '%s\n' "${shown:-}" | grep -qF -- "$banner"; then
93
+ printf ' %s\n' "$banner"
94
+ fi
95
+ }
28
96
 
29
97
  # Node executables (npm-shipped)
30
98
  for f in bin/*.js; do
@@ -34,7 +102,18 @@ done
34
102
  # Codex adapter drift: the thin Codex runtime must keep reading canonical FH
35
103
  # skill/agent surfaces without silently accepting Claude-native primitives as
36
104
  # Codex-native.
37
- check "fh-codex-doctor --strict" bash -c 'node bin/fh-codex-doctor.js --strict >/dev/null'
105
+ # NOT via check(): that helper decides on a run whose stderr is discarded, and this call site used to
106
+ # additionally discard the subject's STDOUT (`--strict >/dev/null`). fh-codex-doctor writes 100% of
107
+ # its drift diagnostics to stdout (measured 2026-08-05: 686 B stdout / 0 B stderr), so a failure
108
+ # printed a bare `FAIL` line carrying no diagnosis at all — worse than the truncation this session
109
+ # repaired in the lane blocks. Fixed at the call site; check() itself is a separate job (see above).
110
+ if _out=$(node bin/fh-codex-doctor.js --strict 2>&1); then
111
+ echo "PASS fh-codex-doctor --strict"
112
+ else
113
+ echo "FAIL fh-codex-doctor --strict"
114
+ _show_failure "$_out"
115
+ fail=1
116
+ fi
38
117
 
39
118
  # Bash surface: npm-shipped scripts + local bin wrappers + gate-chain infra
40
119
  for f in scripts/*.sh bin/fh-gate bin/fh-run bin/fh-goal \
@@ -447,28 +526,48 @@ fi
447
526
  if [ ! -f templates/.git-hooks/pre-push ]; then
448
527
  echo "SKIP test_tag_version_lanes.sh (subject templates/.git-hooks/pre-push absent)"
449
528
  elif [ -f scripts/test_tag_version_lanes.sh ]; then
450
- if ! bash scripts/test_tag_version_lanes.sh >/dev/null 2>&1; then
529
+ # RUN-ONCE, CAPTURE (2026-08-05) rationale in the sync_from_be_lanes block later in this file.
530
+ if _out=$(bash scripts/test_tag_version_lanes.sh 2>&1); then
531
+ echo "PASS test_tag_version_lanes.sh (mismatch blocks · match silent · scope · override)"
532
+ else
451
533
  echo "FAIL test_tag_version_lanes.sh: the tag/version guard would mis-route"
452
- bash scripts/test_tag_version_lanes.sh 2>&1 | tail -12
534
+ _show_failure "$_out"
453
535
  fail=1
454
- else
455
- echo "PASS test_tag_version_lanes.sh (mismatch blocks · match silent · scope · override)"
456
536
  fi
457
537
  else
458
538
  echo "FAIL test_tag_version_lanes.sh: pre-push present but its anchor is missing"
459
539
  fail=1
460
540
  fi
461
541
 
542
+ # Shipped-manifest version lockstep. Distinct from the tag lane above: that one compares the git TAG
543
+ # to package.json; this one compares package.json to every version string it SHIPS — including the
544
+ # per-plugin entries inside marketplace.json, which the tag lane never opens. Measured 2026-08-06:
545
+ # a bump left the second marketplace entry behind and the tag lane passed 8/8 straight through it.
546
+ if [ ! -f scripts/version_lockstep_check.sh ]; then
547
+ echo "SKIP test_version_lockstep_lanes.sh (subject scripts/version_lockstep_check.sh absent)"
548
+ elif [ -f scripts/test_version_lockstep_lanes.sh ]; then
549
+ if _out=$(bash scripts/test_version_lockstep_lanes.sh 2>&1); then
550
+ echo "PASS test_version_lockstep_lanes.sh (drift blocks · aligned silent · unreadable = exit 2, not pass)"
551
+ else
552
+ echo "FAIL test_version_lockstep_lanes.sh: the shipped-manifest lockstep guard would mis-route"
553
+ _show_failure "$_out"
554
+ fail=1
555
+ fi
556
+ else
557
+ echo "FAIL test_version_lockstep_lanes.sh: version_lockstep_check.sh present but its anchor is missing"
558
+ fail=1
559
+ fi
560
+
462
561
  # ④-e dispatch-log reconciliation + its tally hook. Wired in the same commit that ships them: the
463
562
  # obligation they mechanize lost 20/20 in a single session, so leaving the checker itself unrun
464
563
  # would be the same defect one layer up.
465
564
  if [ -f scripts/test_dispatch_log_lanes.sh ]; then
466
- if ! bash scripts/test_dispatch_log_lanes.sh >/dev/null 2>&1; then
565
+ if _out=$(bash scripts/test_dispatch_log_lanes.sh 2>&1); then
566
+ echo "PASS test_dispatch_log_lanes.sh (date-spelling + verdict + tally-hook lanes)"
567
+ else
467
568
  echo "FAIL test_dispatch_log_lanes.sh: the dispatch-log reconciliation would mis-report"
468
- bash scripts/test_dispatch_log_lanes.sh 2>&1 | tail -14
569
+ _show_failure "$_out"
469
570
  fail=1
470
- else
471
- echo "PASS test_dispatch_log_lanes.sh (date-spelling + verdict + tally-hook lanes)"
472
571
  fi
473
572
  fi
474
573
 
@@ -477,12 +576,12 @@ fi
477
576
  # through in silence, then a package discriminator keyed on a file that actually ships. Both are
478
577
  # known-POSITIVEs in the suite, so neither can come back green.
479
578
  if [ -f scripts/test_selfcheck_state_lanes.sh ]; then
480
- if ! bash scripts/test_selfcheck_state_lanes.sh >/dev/null 2>&1; then
579
+ if _out=$(bash scripts/test_selfcheck_state_lanes.sh 2>&1); then
580
+ echo "PASS test_selfcheck_state_lanes.sh (four input states + both shipped mis-routings)"
581
+ else
481
582
  echo "FAIL test_selfcheck_state_lanes.sh: a subject-presence discriminator would mis-route"
482
- bash scripts/test_selfcheck_state_lanes.sh 2>&1 | tail -12
583
+ _show_failure "$_out"
483
584
  fail=1
484
- else
485
- echo "PASS test_selfcheck_state_lanes.sh (four input states + both shipped mis-routings)"
486
585
  fi
487
586
  fi
488
587
 
@@ -495,12 +594,34 @@ fi
495
594
  if [ ! -f scripts/sync-from-be.sh ]; then
496
595
  echo "SKIP sync_from_be_lanes.sh (subject scripts/sync-from-be.sh absent)"
497
596
  elif [ -f scripts/sync_from_be_lanes.sh ]; then
498
- if ! bash scripts/sync_from_be_lanes.sh >/dev/null 2>&1; then
597
+ # ── RUN-ONCE, CAPTURE canonical note for the four LANE BLOCKS (2026-08-05) ─────────────────
598
+ # SCOPE, stated precisely because the first draft of this note over-claimed: this covers the four
599
+ # lane blocks only (tag-version · dispatch-log · selfcheck-state · sync_from_be). The same
600
+ # evidence-discarding shape SURVIVES in `check()` at the top of this file and in the
601
+ # probe_scope_check caller — both named there, both deliberately out of scope, both still open.
602
+ # An adversarial round caught the original "all four sites in this file" wording as a false
603
+ # completion claim: it would have stopped the next reader from re-searching. Half a fix with a
604
+ # done-label on it is worse than half a fix.
605
+ # The old form ran the suite twice: once discarded to /dev/null to decide, once re-run to print.
606
+ # For a DETERMINISTIC suite that is merely wasteful. For a non-deterministic one it destroys the
607
+ # evidence: the failing run's output goes to /dev/null and the reader is shown the SECOND run,
608
+ # which may pass. Measured here 2026-08-04 (run 30955950695) — CI printed
609
+ # FAIL sync_from_be_lanes.sh: return-path lanes failed
610
+ # ════ lanes: 70 passed · 0 failed ════
611
+ # i.e. a FAIL verdict over a PASSING transcript, and the actual failure was never recorded
612
+ # anywhere. That is why this lane sat "flaky, cause unknown" on the session card for two days:
613
+ # the instrument was discarding the only evidence that could close it. Reproduced as a known
614
+ # pair before this edit (arm A run-twice → evidence lost + self-contradiction; arm B run-once →
615
+ # evidence preserved), so the fix is anchored, not asserted.
616
+ # NOTE this does NOT make the suite deterministic — the underlying non-determinism is still
617
+ # UNDIAGNOSED and stays an open item. It makes the next occurrence diagnosable instead of
618
+ # self-erasing. Do not read a green CI after this change as the flake being fixed.
619
+ if _out=$(bash scripts/sync_from_be_lanes.sh 2>&1); then
620
+ echo "PASS sync_from_be_lanes.sh (return-path lanes)"
621
+ else
499
622
  echo "FAIL sync_from_be_lanes.sh: return-path lanes failed"
500
- bash scripts/sync_from_be_lanes.sh 2>&1 | tail -20
623
+ _show_failure "$_out"
501
624
  fail=1
502
- else
503
- echo "PASS sync_from_be_lanes.sh (return-path lanes)"
504
625
  fi
505
626
  else
506
627
  echo "FAIL sync_from_be_lanes.sh: sync-from-be.sh present but its anchor is missing"
@@ -15,6 +15,9 @@
15
15
 
16
16
  set -uo pipefail
17
17
 
18
+ # 상속된 git 환경변수를 끊는다 — export 된 GIT_DIR 이 있으면 `git -C "$FH"` 가 인자로 받은
19
+ # 레포가 아니라 그 레포를 잰다(Axis 2 at-floor LOW, 2026-08-06). READ-ONLY 체커라 부작용 없음.
20
+ unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
18
21
  FH="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
19
22
  TODAY=$(date +%Y-%m-%d)
20
23
  CARD="$FH/tracks/_meta/reference_next_session_starter.md"
@@ -25,11 +28,58 @@ _mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0;
25
28
  echo "── session close check: $FH ($TODAY) ──"
26
29
 
27
30
  # ① status snapshot — uncommitted / unpushed work must be known, not forgotten
28
- DIRTY=$(git -C "$FH" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
29
- UNPUSHED=$(git -C "$FH" log --oneline @{u}.. 2>/dev/null | wc -l | tr -d ' ')
30
- [ "$DIRTY" -gt 0 ] && echo "⚠️ ① $DIRTY uncommitted path(s) decide: commit or leave deliberately"
31
- [ "$UNPUSHED" -gt 0 ] && echo "⚠️ ① $UNPUSHED unpushed commit(s) push before close or record why"
32
- [ "$DIRTY" -eq 0 ] && [ "$UNPUSHED" -eq 0 ] && echo "✅ ① working tree clean, nothing unpushed"
31
+ # DIRTY 같은 형태였다 — `status --porcelain 2>/dev/null | wc -l` git 죽어도 0 을 세어
32
+ # "깨끗함"으로 승격된다(재현: `.git/index` 손상 exit 128, 출력 0줄). 종료코드를 먼저 본다.
33
+ # ⚠️ 줄은 **바로 아래 UNPUSHED 수리와 같은 결함**이었고, 처음엔 아래만 고쳤다
34
+ # 반쪽 수리를 고치는 커밋에서 반쪽 수리를 뻔했다(Axis 2 챌린저가 잡음, 2026-08-06).
35
+ # `--untracked-files=all` `status.showUntrackedFiles=no` config 덮어쓴다. config 아래에서는
36
+ # git 이 **성공적으로 침묵**해(exit 0 · 빈 출력) 종료코드 가드로도 안 잡힌다 — 부재가 다시
37
+ # "깨끗함"으로 렌더된다(Axis 2 at-floor MED, 재현: 미추적 파일 1건이 0 으로 보고됨).
38
+ if _st=$(git -C "$FH" status --porcelain --untracked-files=all 2>/dev/null); then
39
+ DIRTY_KNOWN=1
40
+ DIRTY=$(printf '%s' "$_st" | grep -c . || true)
41
+ else
42
+ DIRTY_KNOWN=0
43
+ DIRTY=0
44
+ fi
45
+ # upstream 유무를 **먼저 판정**한다. `@{u}..` 는 upstream 이 없으면 실패해 0줄을 내고, 그 0을
46
+ # `wc -l` 이 0으로 세어 "nothing unpushed" 로 승격된다 — **한 번도 머신을 떠난 적 없는 커밋이
47
+ # '푸시할 것 없음'으로 읽히는 fail-open**. 부재를 깨끗함으로 읽는 것이라 0 을 신뢰하면 안 된다.
48
+ # (downstream fork's review lane caught it first; this file is the upstream original — 2026-08-06.)
49
+ if git -C "$FH" rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1; then
50
+ UPSTREAM_KNOWN=1
51
+ UNPUSHED=$(git -C "$FH" log --oneline @{u}.. 2>/dev/null | wc -l | tr -d ' ')
52
+ else
53
+ UPSTREAM_KNOWN=0
54
+ UNPUSHED=0
55
+ fi
56
+ [ "$DIRTY_KNOWN" -eq 0 ] && echo "⚠️ ① UNMEASURED — git status failed; working-tree cleanliness is UNKNOWN, not clean"
57
+ [ "$DIRTY_KNOWN" -eq 1 ] && [ "$DIRTY" -gt 0 ] && echo "⚠️ ① $DIRTY uncommitted path(s) — decide: commit or leave deliberately"
58
+ [ "$UPSTREAM_KNOWN" -eq 0 ] && echo "⚠️ ① UNMEASURED — no upstream for this branch; unpushed count is UNKNOWN, not zero"
59
+ [ "$UPSTREAM_KNOWN" -eq 1 ] && [ "$UNPUSHED" -gt 0 ] && echo "⚠️ ① $UNPUSHED unpushed commit(s) — push before close or record why"
60
+ # "as of last fetch" — 원격을 조회하지 않는다. 로컬 remote-tracking ref 가 낡았으면 이 0 도 낡은 값이다
61
+ # (Axis 2 챌린저 MED, 2026-08-06). fetch 를 넣지 않은 것은 마감 체커가 READ-ONLY·오프라인 안전이기 때문.
62
+ # 추적 파일이 assume-unchanged/skip-worktree 로 마킹돼 있으면 그 수정은 porcelain 에 **안 뜬다** —
63
+ # `-uall` 로도 안 잡히는 별개 계기다(Axis 2 at-floor MED, 재현 확인).
64
+ MASKED=$(git -C "$FH" ls-files -v 2>/dev/null | grep -c '^[a-z]' || true)
65
+ [ "${MASKED:-0}" -gt 0 ] \
66
+ && echo "⚠️ ① $MASKED file(s) assume-unchanged/skip-worktree — their edits are INVISIBLE here"
67
+
68
+ # ★ 잰 범위 ≠ 주장 범위 (Axis 2 at-floor HIGH, 2026-08-06).
69
+ # `@{u}..` 는 **현재 브랜치만** 잰다. 다른 로컬 브랜치에만 있는 미푸시 커밋은 통째로 안 보이는데
70
+ # 화면 문구는 "nothing unpushed"(레포 전체)라고 말한다 — 이 파일의 존재 이유 정중앙이다.
71
+ # 재현: 다른 브랜치에 미푸시 커밋 1건 → `✅ nothing unpushed` 가 그대로 떴다.
72
+ # 원격이 하나도 없으면 이 값이 전 커밋 수로 부풀므로 원격 존재를 먼저 가드한다.
73
+ OTHER_UNPUSHED=0
74
+ if [ -n "$(git -C "$FH" remote 2>/dev/null)" ]; then
75
+ OTHER_UNPUSHED=$(git -C "$FH" log --branches --not --remotes --oneline 2>/dev/null | wc -l | tr -d ' ')
76
+ fi
77
+ [ "${OTHER_UNPUSHED:-0}" -gt 0 ] \
78
+ && echo "⚠️ ① $OTHER_UNPUSHED commit(s) on local branches never pushed anywhere (all-branch scan)"
79
+
80
+ [ "$DIRTY_KNOWN" -eq 1 ] && [ "$DIRTY" -eq 0 ] && [ "$UPSTREAM_KNOWN" -eq 1 ] && [ "$UNPUSHED" -eq 0 ] \
81
+ && [ "${OTHER_UNPUSHED:-0}" -eq 0 ] && [ "${MASKED:-0}" -eq 0 ] \
82
+ && echo "✅ ① working tree clean, nothing unpushed anywhere (as of last fetch)"
33
83
 
34
84
  # ①-b open-PR sweep (surface-not-auto — requires gh; skip silently offline)
35
85
  if command -v gh >/dev/null 2>&1; then
@@ -93,6 +93,109 @@ else
93
93
  echo " ⏭️ package.json absent — premise unchecked (not a pass)"
94
94
  fi
95
95
 
96
+ echo ""
97
+ echo "── _show_failure: a FAILING suite's evidence must survive to the reader ──"
98
+ # WHY THIS LANE EXISTS (2026-08-05): the four lane blocks in selfcheck.sh used to decide on a
99
+ # discarded run (`>/dev/null`) and then RE-RUN to print. On a non-deterministic suite the re-run can
100
+ # pass, so CI printed a FAIL verdict above a PASSING transcript and the real failure was destroyed —
101
+ # measured in run 30955950695. The repair captures once; this lane is the mechanical anchor for the
102
+ # half that actually makes a failure readable. Without it the repair is unverifiable: reverting to
103
+ # `tail -20` leaves CI green, which is exactly [[feedback_built_but_not_wired]] / anchor-is-decorative.
104
+ # LIFTED, not re-spelled — same reason as the discriminator above.
105
+ FN=$(sed -n '/^_show_failure() {/,/^}$/p' "$SELFCHECK")
106
+ if [ -z "$FN" ]; then
107
+ echo "FAIL _show_failure is no longer defined in selfcheck.sh — this lane cannot verify what it claims."
108
+ echo " If the helper was renamed or removed, update the lane WITH the subject."
109
+ exit 1
110
+ fi
111
+ eval "$FN"
112
+
113
+ # Fixture: a long transcript whose ONLY failing line sits far above any tail window, plus a
114
+ # summary banner at the end that still says something failed. This is the shape that fooled the
115
+ # reader in the CI run above.
116
+ _LONG=$(for i in $(seq 1 40); do echo " ✅ lane L$i ok"; done; echo " ❌ lane L41 tripped — THE EVIDENCE"; for i in $(seq 42 96); do echo " ✅ lane L$i ok"; done; echo "════ lanes: 96 passed · 1 failed ════")
117
+
118
+ _OUT=$(_show_failure "$_LONG")
119
+ printf '%s' "$_OUT" | grep -q 'THE EVIDENCE'; chk $? "the failing line survives (it is 56 lines above the end)"
120
+ printf '%s' "$_OUT" | grep -q '1 failed' ; chk $? "the summary banner is still shown"
121
+
122
+ # CONTROL — the old form must FAIL this same fixture. Without this, the lane could pass for a
123
+ # reason unrelated to the repair (e.g. a fixture short enough that any tail window catches it).
124
+ printf '%s\n' "$_LONG" | tail -20 | grep -q 'THE EVIDENCE'; [ $? -ne 0 ]
125
+ chk $? "CONTROL: the pre-repair form (tail -20) does NOT surface it — the fixture discriminates"
126
+
127
+ # Degenerate inputs: silence must not read as evidence, and a suite that dies before printing any
128
+ # ❌ must say so rather than showing a blank.
129
+ _OUT=$(_show_failure "")
130
+ printf '%s' "$_OUT" | grep -q 'no output captured'; chk $? "empty output is named, not shown as a blank line"
131
+ _OUT=$(_show_failure "some early crash text
132
+ Traceback: boom")
133
+ printf '%s' "$_OUT" | grep -q 'died early'; chk $? "output with no ❌/FAIL falls back and says why"
134
+
135
+ # ── THE DEFECT ITSELF: decide-and-print must be ONE execution ────────────────
136
+ # An earlier version of this lane block tested only the _show_failure HELPER, in isolation, via eval.
137
+ # An adversarial round then reverted a lane block to the original run-twice form — decide on a
138
+ # discarded run, re-run to capture — and this suite still returned PASS (16/16, measured). The anchor
139
+ # was guarding the thing the repair BUILT and not the thing the repair FIXED. That is
140
+ # [[feedback_anchor_can_be_decorative]] with the reversal actually applied, which is the only check
141
+ # that distinguishes the two.
142
+ # The invariant that discriminates: a suite must be EXECUTED EXACTLY ONCE per selfcheck run. The
143
+ # run-twice form necessarily names its subject twice. Keying on the subject path (not on a variable
144
+ # name or a pipe shape) also removes the earlier grep's escape hatch — renaming `_out` no longer
145
+ # evades it, and adding a fifth lane block does not require editing a hardcoded count.
146
+ for _subj in test_tag_version_lanes test_dispatch_log_lanes test_selfcheck_state_lanes sync_from_be_lanes; do
147
+ _n=$(grep -c "bash scripts/${_subj}\.sh" "$SELFCHECK" || true)
148
+ [ "$_n" -eq 1 ]
149
+ chk $? "${_subj}.sh is executed exactly once (found $_n) — 2 means the run-twice form is back"
150
+ done
151
+
152
+ # WIRING — every lane block must route its captured output through the helper. Secondary to the
153
+ # once-only invariant above (this one IS evadable by renaming), kept because it names the intent.
154
+ _CALLS=$(grep -c '_show_failure "\$_out"' "$SELFCHECK")
155
+ [ "$_CALLS" -ge 4 ]; chk $? "every lane block routes failure output through _show_failure (found $_CALLS, expected ≥4)"
156
+ _TAILS=$(grep -c '"\$_out" | tail -' "$SELFCHECK" || true)
157
+ [ "$_TAILS" -eq 0 ]; chk $? "no lane block still truncates with a raw tail (found $_TAILS, expected 0)"
158
+
159
+ # The one non-lane caller that also destroys its evidence at the CALL SITE (not inside check()).
160
+ # `check "..." bash -c '... >/dev/null'` discards the subject's stdout, and fh-codex-doctor writes
161
+ # 100% of its diagnostics to stdout (measured: 686 B stdout / 0 B stderr) — so a strict-mode failure
162
+ # would print a bare FAIL line with zero diagnosis. Anchored here because the fix is one line at the
163
+ # call site and does NOT require touching check() itself.
164
+ _CD=$(grep -c "fh-codex-doctor.js --strict >/dev/null" "$SELFCHECK" || true)
165
+ [ "$_CD" -eq 0 ]; chk $? "fh-codex-doctor's stdout is not discarded at the call site (found $_CD, expected 0)"
166
+
167
+ # Byte-hostile input: a lane emitting invalid UTF-8 must not be reported as "no output". The
168
+ # `tr -d '[:space:]'` form this guard originally used aborts on BSD with "Illegal byte sequence"
169
+ # and emits nothing, so the emptiness check concluded empty while evidence was present.
170
+ # NOTE ON THIS FIXTURE — it deliberately contains NO ❌. The first draft included one, which routed
171
+ # the call into the failing-lines branch, so the emptiness guard (an `elif`) was never reached and the
172
+ # lane passed against the very defect it was written for. Verified by applying the reversion and
173
+ # confirming the diff landed: the lane went green anyway. A fixture that cannot reach the branch
174
+ # under test measures nothing.
175
+ _BAD=$(printf ' \xff\xfe garbage\n crashed before any lane ran BYTE_EVIDENCE\n')
176
+ _OUT=$(_show_failure "$_BAD")
177
+ printf '%s' "$_OUT" | grep -q 'BYTE_EVIDENCE'; chk $? "invalid UTF-8 in the stream does not swallow the evidence"
178
+ printf '%s' "$_OUT" | grep -qv 'no output captured'; chk $? "…and it is not mis-reported as empty output"
179
+
180
+ # The banner must not be suppressed by a line the reader never saw (a failing line past the head cut
181
+ # that merely quotes the banner text).
182
+ _MANY=$(for i in $(seq 1 26); do echo " ❌ f$i"; done; echo " ❌ f27 quoting ════ lanes: 9 passed · 1 failed ════"; echo "════ lanes: 9 passed · 1 failed ════")
183
+ _OUT=$(_show_failure "$_MANY")
184
+ [ "$(printf '%s\n' "$_OUT" | grep -c '════ lanes: 9 passed')" -ge 1 ]
185
+ chk $? "the summary banner still prints when a truncated-away line quotes it"
186
+
187
+ # ── SCOPE OF THIS ANCHOR — stated so it is not over-trusted ───────────────────
188
+ # These lanes catch REVERSION (the run-twice form coming back, the helper being gutted, the
189
+ # call-site redirect returning). They do NOT catch deliberate EVASION: a cross-family round
190
+ # demonstrated three forms that satisfy every check above while still destroying evidence —
191
+ # `_out=$(bash suite >/dev/null 2>&1)` (executed once, captured nothing), a wrapper function
192
+ # (`run_lane() { bash scripts/X.sh; }`, literal appears once, runs twice), and redirect spellings
193
+ # the greps do not enumerate (`1>/dev/null`, a variable). Chasing those with more patterns is the
194
+ # Grep-Collision Treadmill this repo has already logged as P10 — each added regex relocates the
195
+ # evasion instead of closing it. It is bounded rather than escalated: an evading form still routes
196
+ # through _show_failure, whose empty branch prints "(no output captured)" at runtime, so the failure
197
+ # is loud rather than silent. Regression is anchored; evasion is a named residual, not a solved one.
198
+
96
199
  echo ""
97
200
  if [ "$FAILED" -ne 0 ]; then
98
201
  echo "SELFCHECK STATE LANES: FAIL — a discriminator would mis-route"
@@ -85,6 +85,18 @@ _repo() { # $1=dirname ; makes a git repo with one commit dated $2 (default now
85
85
  else
86
86
  git commit -qm seed
87
87
  fi
88
+ # A real repo HAS an upstream. Without one the close check now (correctly) reports
89
+ # `① UNMEASURED — no upstream`, which is not the state any of these lanes means to express —
90
+ # a fixture with no upstream cannot assert "clean tree, nothing unpushed" because the second
91
+ # half of that sentence is genuinely unknown. Every lane below therefore runs on a pushed
92
+ # baseline; upstream ABSENCE is measured on purpose by its own lane (KP-2).
93
+ git init -q --bare "$TMPROOT/$1.git"
94
+ git remote add origin "$TMPROOT/$1.git"
95
+ # `-c core.hooksPath=` : a fixture push must never execute the HOST's git hooks. This repo sets
96
+ # core.hooksPath locally (so a temp repo does not inherit it) but a machine that sets it GLOBALLY
97
+ # would run FH's own pre-push Destructive-Op gate against a throwaway fixture — the suite's result
98
+ # would then depend on the operator's git config rather than on the code under test.
99
+ git -c core.hooksPath= push -q -u origin HEAD
88
100
  ) >/dev/null 2>&1
89
101
  mkdir -p "$T/tracks/_meta"
90
102
  printf '%s' "$T"
@@ -121,13 +133,10 @@ _line "①-N uncommitted path → ⚠️ fires" 'uncommitted path'
121
133
  _line "①-N uncommitted path → clean line absent" '✅ ① working tree clean' 0 "$OUT"
122
134
  _rc "①-N uncommitted is ADVISORY, not blocking" "$RC" 0
123
135
 
124
- # unpushed: needs a real upstream, so build a bare remote
136
+ # unpushed: _repo already pushed the seed, so one extra commit is exactly one unpushed commit
125
137
  T=$(_repo one_unpushed); _artifacts "$T"
126
138
  (
127
139
  cd "$T" || exit 1
128
- git init -q --bare "$TMPROOT/one_unpushed.git" 2>/dev/null
129
- git remote add origin "$TMPROOT/one_unpushed.git"
130
- git push -q -u origin HEAD 2>/dev/null
131
140
  echo more > second.txt && git add -A && git commit -qm second
132
141
  ) >/dev/null 2>&1
133
142
  _run "$T"
@@ -141,6 +150,72 @@ _gap "① non-repo reports CLEAN" "$_g" \
141
150
  "git is unavailable/not a repo → DIRTY=0, UNPUSHED=0 → the check reports '✅ working tree clean'. \
142
151
  An instrument that could not look is not a clean result (not found ≠ 0). Should say UNSCANNED."
143
152
 
153
+ # ── ① not-found ≠ 0 : the five states where git CANNOT answer ────────────────────
154
+ # Origin: the fix that introduced these five guards (2026-08-06) listed all six known pairs in its
155
+ # COMMIT MESSAGE and shipped none of them as a lane — the code changed, the suite did not, and CI
156
+ # went red on the two lanes the change broke rather than on the five it left unmeasured. Prose in a
157
+ # commit message is not a regression anchor: nothing re-runs it. Each pair below is
158
+ # known-positive (the instrument is blind) + a paired control (the clean line must NOT appear),
159
+ # because "the warning fired" and "the warning fired INSTEAD of a false all-clear" are two claims.
160
+ # KP-1 (the healthy case) is the ①-P lane at the top of this section.
161
+
162
+ # KP-2 upstream absent — `@{u}..` fails, prints 0 lines, and `wc -l` counts that 0 as "nothing
163
+ # unpushed". A commit that never left the machine reads as pushed. `--unset-upstream` (not
164
+ # `remote remove`) keeps a remote present, so the all-branch scan still runs: this isolates the
165
+ # upstream leg instead of quietly testing two things at once.
166
+ T=$(_repo kp2_no_upstream); _artifacts "$T"
167
+ git -C "$T" branch --unset-upstream >/dev/null 2>&1
168
+ _run "$T"
169
+ _line "KP-2 no upstream → UNMEASURED, not zero" 'unpushed count is UNKNOWN' 1 "$OUT"
170
+ _line "KP-2 → clean line absent (paired)" '✅ ① working tree clean' 0 "$OUT"
171
+ _rc "KP-2 → advisory, not blocking" "$RC" 0
172
+
173
+ # KP-3 git status itself fails — a corrupt index makes `status` exit non-zero with EMPTY output,
174
+ # and `| wc -l` renders that emptiness as "0 dirty paths" = clean.
175
+ T=$(_repo kp3_broken_index); _artifacts "$T"
176
+ printf 'garbage' > "$T/.git/index"
177
+ _run "$T"
178
+ _line "KP-3 corrupt index → cleanliness UNKNOWN" 'cleanliness is UNKNOWN' 1 "$OUT"
179
+ _line "KP-3 → clean line absent (paired)" '✅ ① working tree clean' 0 "$OUT"
180
+ # Measured while writing this lane: a corrupt index makes `ls-files -v` exit 128 too, so the
181
+ # assume-unchanged probe (MASKED) silently reads 0 — the same not-found-≠-0 shape, one layer in.
182
+ # It is NOT a false all-clear (DIRTY_KNOWN=0 already suppresses the clean line), so it is recorded
183
+ # as a residual rather than patched here. `rev-parse @{u}` still exits 0 under a corrupt index,
184
+ # which is what keeps this lane measuring cleanliness and not accidentally re-measuring KP-2.
185
+
186
+ # KP-4 measured scope ≠ claimed scope — `@{u}..` reads the CURRENT branch only, while the message
187
+ # says "nothing unpushed" about the repo. An unpushed commit parked on another local branch is
188
+ # invisible. The current branch stays clean and pushed on purpose: only the other branch is dirty,
189
+ # so a green here would be the exact false all-clear.
190
+ T=$(_repo kp4_other_branch); _artifacts "$T"
191
+ (
192
+ cd "$T" || exit 1
193
+ git checkout -q -b side
194
+ echo side > side.txt && git add -A && git commit -qm side
195
+ git checkout -q -
196
+ ) >/dev/null 2>&1
197
+ _run "$T"
198
+ _line "KP-4 unpushed on ANOTHER branch → ⚠️ fires" 'never pushed anywhere' 1 "$OUT"
199
+ _line "KP-4 → clean line absent (paired)" '✅ ① working tree clean' 0 "$OUT"
200
+
201
+ # KP-5 `status.showUntrackedFiles=no` — git succeeds and stays SILENT (exit 0, empty output), so
202
+ # the exit-code guard of KP-3 cannot catch this one. Only `--untracked-files=all` overrides it.
203
+ T=$(_repo kp5_untracked_off); _artifacts "$T"
204
+ git -C "$T" config status.showUntrackedFiles no
205
+ echo hidden > "$T/hidden.txt"
206
+ _run "$T"
207
+ _line "KP-5 showUntrackedFiles=no → still counted" 'uncommitted path' 1 "$OUT"
208
+ _line "KP-5 → clean line absent (paired)" '✅ ① working tree clean' 0 "$OUT"
209
+
210
+ # KP-6 assume-unchanged / skip-worktree — edits to a marked TRACKED file never reach porcelain at
211
+ # all, so `-uall` does not help either. A separate instrument (`ls-files -v`) has to surface it.
212
+ T=$(_repo kp6_assume_unchanged); _artifacts "$T"
213
+ git -C "$T" update-index --assume-unchanged unrelated.txt
214
+ echo edited >> "$T/unrelated.txt"
215
+ _run "$T"
216
+ _line "KP-6 assume-unchanged edit → surfaced" 'INVISIBLE here' 1 "$OUT"
217
+ _line "KP-6 → clean line absent (paired)" '✅ ① working tree clean' 0 "$OUT"
218
+
144
219
  echo
145
220
  echo "══ ①-b open-PR sweep ══"
146
221
  _ghstub() { # $1=repo $2=stdout $3=exit
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+ # Known pairs for scripts/version_lockstep_check.sh.
3
+ #
4
+ # The lane this suite exists to prevent: on 2026-08-06 a 1.4.88 → 1.4.89 bump edited the FIRST
5
+ # marketplace plugin entry and left the SECOND at the old version. `test_tag_version_lanes.sh`
6
+ # reported 8/8 PASS through it — that lane compares the git tag to package.json and never opens
7
+ # marketplace.json. The drift was caught by an ad-hoc `sort | uniq -c`. Luck is not a floor.
8
+ #
9
+ # Every lane runs against a THROWAWAY fixture tree, never against this repo, so the suite is
10
+ # hermetic: a real bump in progress can neither turn it green nor turn it red.
11
+
12
+ set -uo pipefail
13
+ CHECK="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/version_lockstep_check.sh"
14
+ TMPROOT=$(mktemp -d "${TMPDIR:-/tmp}/fh_lockstep.XXXXXX")
15
+ trap 'rm -rf "$TMPROOT"' EXIT
16
+ PASS=0; FAIL=0
17
+
18
+ _fixture() { # $1=name $2=pkg_ver $3=meta_ver $4=commons_ver $5=mk_entry0 $6=mk_entry1
19
+ local T="$TMPROOT/$1"
20
+ mkdir -p "$T/.claude-plugin" "$T/plugins/fh-meta/.claude-plugin" "$T/plugins/fh-commons/.claude-plugin"
21
+ printf '{"name":"fh","version":"%s"}\n' "$2" > "$T/package.json"
22
+ printf '{"name":"fh-meta","version":"%s"}\n' "$3" > "$T/plugins/fh-meta/.claude-plugin/plugin.json"
23
+ printf '{"name":"fh-commons","version":"%s"}\n' "$4" > "$T/plugins/fh-commons/.claude-plugin/plugin.json"
24
+ printf '{"plugins":[{"name":"fh-meta","version":"%s"},{"name":"fh-commons","version":"%s"}]}\n' \
25
+ "$5" "$6" > "$T/.claude-plugin/marketplace.json"
26
+ printf '%s' "$T"
27
+ }
28
+
29
+ _expect() { # $1=label $2=expected_rc $3=repo ; also captures OUT
30
+ OUT=$(bash "$CHECK" "$3" 2>&1); local rc=$?
31
+ if [ "$rc" = "$2" ]; then echo " ✅ $1 (rc=$rc)"; PASS=$((PASS+1))
32
+ else echo " ❌ $1 — rc=$rc, expected=$2"; printf '%s\n' "$OUT" | sed 's/^/ │ /'; FAIL=$((FAIL+1)); fi
33
+ }
34
+ _says() { # $1=label $2=pattern $3=expected_hit(1|0)
35
+ local h=0; printf '%s\n' "$OUT" | grep -q -- "$2" && h=1
36
+ if [ "$h" = "$3" ]; then echo " ✅ $1"; PASS=$((PASS+1))
37
+ else echo " ❌ $1 — hit=$h, expected=$3"; printf '%s\n' "$OUT" | sed 's/^/ │ /'; FAIL=$((FAIL+1)); fi
38
+ }
39
+
40
+ echo "══ version lockstep known pairs ══"
41
+
42
+ # KN — everything aligned. The control: without it, a checker that always reported DRIFT would
43
+ # still pass every positive lane below.
44
+ _expect "KN all five strings aligned → exit 0" 0 "$(_fixture kn 1.4.89 1.4.89 1.4.89 1.4.89 1.4.89)"
45
+ _says "KN → names the count it actually checked" "4 shipped version string" 1
46
+
47
+ # KP-1 — the measured miss, byte for byte: second marketplace entry left behind.
48
+ _expect "KP-1 second marketplace entry stale → exit 1" 1 "$(_fixture kp1 1.4.89 1.4.89 1.4.89 1.4.89 1.4.88)"
49
+ _says "KP-1 → names the offending entry by index+name" "plugins\[1\] fh-commons" 1
50
+ _says "KP-1 → reports both versions, not just 'mismatch'" "1.4.88 (package.json = 1.4.89)" 1
51
+
52
+ # KP-2 — a plugin.json left behind. This is the shape the tag lane also cannot see.
53
+ _expect "KP-2 plugin.json stale → exit 1" 1 "$(_fixture kp2 1.4.89 1.4.88 1.4.89 1.4.89 1.4.89)"
54
+ _says "KP-2 → names the file" "plugins/fh-meta/.claude-plugin/plugin.json" 1
55
+
56
+ # KP-3 — the reverse direction: package.json bumped alone. Same defect, opposite author error.
57
+ _expect "KP-3 only package.json bumped → exit 1" 1 "$(_fixture kp3 1.5.0 1.4.89 1.4.89 1.4.89 1.4.89)"
58
+ _says "KP-3 → all four downstream strings flagged" "4 of 4" 1
59
+
60
+ # HARNESS-ERROR — an instrument that could not look has not looked. These must be a THIRD state,
61
+ # never folded into PASS: this gate guards `npm publish`, an irreversible surface.
62
+ T=$(_fixture he1 1.4.89 1.4.89 1.4.89 1.4.89 1.4.89); rm "$T/.claude-plugin/marketplace.json" "$T"/plugins/*/.claude-plugin/plugin.json
63
+ _expect "HE-1 no manifests at all → exit 2, NOT 0" 2 "$T"
64
+ _says "HE-1 → says alignment is UNKNOWN, not aligned" "UNKNOWN, not aligned" 1
65
+
66
+ T=$(_fixture he2 1.4.89 1.4.89 1.4.89 1.4.89 1.4.89); printf '{ broken' > "$T/.claude-plugin/marketplace.json"
67
+ _expect "HE-2 unparseable manifest → exit 2, NOT 0" 2 "$T"
68
+
69
+ T=$(_fixture he3 1.4.89 1.4.89 1.4.89 1.4.89 1.4.89); rm "$T/package.json"
70
+ _expect "HE-3 missing package.json → exit 2, NOT 0" 2 "$T"
71
+
72
+ # A manifest present but carrying no version at all is absence, not agreement.
73
+ T=$(_fixture he4 1.4.89 1.4.89 1.4.89 1.4.89 1.4.89); printf '{"name":"fh-meta"}\n' > "$T/plugins/fh-meta/.claude-plugin/plugin.json"
74
+ _expect "HE-4 manifest with no version → exit 2, NOT 0" 2 "$T"
75
+ _says "HE-4 → says which file carries none" "carries no version string" 1
76
+
77
+ echo
78
+ echo "──────────────────────────────────────────────"
79
+ if [ "$FAIL" -gt 0 ]; then
80
+ echo "VERSION-LOCKSTEP LANES: FAIL ($PASS pass · $FAIL fail)"; exit 1
81
+ fi
82
+ echo "VERSION-LOCKSTEP LANES: PASS ($PASS/$PASS)"
@@ -196,8 +196,12 @@ run_case "placeholder BEFORE real, one line → BLOCK" \
196
196
  # Each of these previously passed at commit time while BLOCKING at publish time — the two copies
197
197
  # of this logic had diverged in leniency. run_state_case swaps the pattern source rather than the
198
198
  # staged content, so it needs its own runner.
199
- run_state_case() { # <name> <override-content|__NONE__> <defaults:keep|drop> <expect-exit: block|pass>
200
- local name="$1" ovc="$2" defmode="$3" expect="$4" out rc got
199
+ run_state_case() { # <name> <override-content|__NONE__> <defaults:keep|drop> <expect-exit: block|pass> [expect-verdict-substring]
200
+ # The 5th arg exists because exit code alone cannot see this defect class: a scan whose
201
+ # operator-literal layer never ran exits 0 and used to print the same `✅ PASS` as a scan where
202
+ # every layer ran. Identical verdicts for different amounts of measurement is the fail-open —
203
+ # so the anchor has to read the verdict LINE, not just the status.
204
+ local name="$1" ovc="$2" defmode="$3" expect="$4" want="${5:-}" out rc got
201
205
  printf 'operator literal zzsynthoperator\n' > "$SANDBOX/README.md"
202
206
  git -C "$SANDBOX" add -- README.md >/dev/null 2>&1
203
207
  local ov="$SANDBOX/.psa_state_override"
@@ -208,17 +212,26 @@ run_state_case() { # <name> <override-content|__NONE__> <defaults:keep|drop> <e
208
212
  if [ "$defmode" = drop ]; then mv "$defbak" "$SANDBOX/.claude/rules/.public-surface-patterns.defaults" 2>/dev/null; fi
209
213
  git -C "$SANDBOX" rm -q --cached -- README.md >/dev/null 2>&1; rm -f "$SANDBOX/README.md" "$ov"
210
214
  if [ "$rc" -ne 0 ]; then got=block; else got=pass; fi
211
- if [ "$got" = "$expect" ]; then
212
- echo " ✅ $name (expected $expect)"
213
- else
215
+ if [ "$got" != "$expect" ]; then
214
216
  echo " ❌ $name — expected $expect, got $got (exit $rc)"
215
217
  printf '%s\n' "$out" | sed 's/^/ | /' | head -10
216
218
  FAILED=1
219
+ elif [ -n "$want" ] && ! printf '%s' "$out" | grep -qF "$want"; then
220
+ echo " ❌ $name — exit was $got but the verdict line did not say: $want"
221
+ printf '%s\n' "$out" | grep -E 'PASS|PARTIAL|Confidentiality' | sed 's/^/ | /' | head -5
222
+ FAILED=1
223
+ else
224
+ echo " ✅ $name (expected $expect${want:+, verdict names \"$want\"})"
217
225
  fi
218
226
  }
219
227
  run_state_case "no patterns at all (instrument down) → BLOCK" "__NONE__" drop block
220
228
  run_state_case "malformed regex in override → BLOCK" "HIGH zzsynth[" keep block
221
- run_state_case "empty override + defaults present → PASS " "" keep pass
229
+ # 2026-08-06 this state still PASSES (a commit is reversible; blocking every fresh clone's first
230
+ # commit is what trains the override into a reflex), but it may no longer CLAIM a clean scan. Pinned
231
+ # as a pair so the label cannot drift back to a bare PASS, and so an unconditional PARTIAL — which
232
+ # would be just as wrong — is caught by the control below it.
233
+ run_state_case "empty override + defaults present → PASS " "" keep pass "PARTIAL"
234
+ run_state_case "control: override PRESENT, no hit → PASS " "HIGH zzunrelatedliteral" keep pass "✅ PASS"
222
235
 
223
236
  # ── Pair 7: pattern-ROW malformations that are not invalid regex. Each of these used to be skipped
224
237
  # in silence, i.e. a detector the author believed in that never existed, and a scan that certified
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ # version_lockstep_check.sh — every shipped manifest must carry package.json's version.
3
+ #
4
+ # Why this exists (measured 2026-08-06, while republishing 1.4.88 → 1.4.89):
5
+ # CLAUDE.md §④-b says to bump "package.json + every .claude-plugin/plugin.json + marketplace.json".
6
+ # That instruction reads as four files, but `.claude-plugin/marketplace.json` carries **one version
7
+ # per plugin entry** — five strings across four files. A bump that edited the first entry left the
8
+ # second at the old version, and `test_tag_version_lanes.sh` passed 8/8 anyway: that lane compares
9
+ # the git TAG to package.json and never opens marketplace.json. The stale entry was caught by an
10
+ # ad-hoc `sort | uniq -c`, i.e. by luck, not by an instrument.
11
+ #
12
+ # Codex caches plugins on the plugin.json version, so a stale entry ships a plugin that the other
13
+ # runtime believes it already has — the drift is silent on exactly the surface that cannot report it.
14
+ #
15
+ # Exit 0 = all aligned · exit 1 = drift (names every offending file:line) · exit 2 = harness error
16
+ # (a manifest is missing or unparseable — NOT a pass; an instrument that could not look has not
17
+ # looked, and this gate guards an irreversible surface).
18
+
19
+ set -uo pipefail
20
+ ROOT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
21
+
22
+ command -v python3 >/dev/null 2>&1 || { echo "LOCKSTEP: HARNESS-ERROR — python3 unavailable"; exit 2; }
23
+
24
+ python3 - "$ROOT" <<'PY'
25
+ import json, sys, glob, os
26
+ root = sys.argv[1]
27
+ pkg_path = os.path.join(root, 'package.json')
28
+ try:
29
+ want = json.load(open(pkg_path))['version']
30
+ except Exception as e:
31
+ print(f"LOCKSTEP: HARNESS-ERROR — cannot read {pkg_path}: {e}")
32
+ sys.exit(2)
33
+
34
+ # (path, list-of-(label, version)) — every manifest that ships a version string.
35
+ targets = []
36
+ for p in sorted(glob.glob(os.path.join(root, 'plugins', '*', '.claude-plugin', 'plugin.json'))):
37
+ targets.append(p)
38
+ mk = os.path.join(root, '.claude-plugin', 'marketplace.json')
39
+ if os.path.exists(mk):
40
+ targets.append(mk)
41
+
42
+ if not targets:
43
+ # Absence is not alignment. If the manifests vanished, say so rather than reporting a clean run.
44
+ print("LOCKSTEP: HARNESS-ERROR — no plugin/marketplace manifest found; alignment is UNKNOWN, not aligned")
45
+ sys.exit(2)
46
+
47
+ drift, checked = [], 0
48
+ for p in targets:
49
+ try:
50
+ d = json.load(open(p))
51
+ except Exception as e:
52
+ print(f"LOCKSTEP: HARNESS-ERROR — cannot parse {p}: {e}")
53
+ sys.exit(2)
54
+ rel = os.path.relpath(p, root)
55
+ # A manifest carries a version either at the top level (plugin.json) or once per plugin entry
56
+ # (marketplace.json). Walk both shapes so a new entry cannot be silently uncovered.
57
+ found = []
58
+ if isinstance(d.get('version'), str):
59
+ found.append(('(top-level)', d['version']))
60
+ for key in ('plugins', 'entries'):
61
+ for i, ent in enumerate(d.get(key) or []):
62
+ if isinstance(ent, dict) and isinstance(ent.get('version'), str):
63
+ found.append((f"{key}[{i}] {ent.get('name', '?')}", ent['version']))
64
+ if not found:
65
+ print(f"LOCKSTEP: HARNESS-ERROR — {rel} carries no version string; expected at least one")
66
+ sys.exit(2)
67
+ for label, got in found:
68
+ checked += 1
69
+ if got != want:
70
+ drift.append(f" {rel} :: {label} = {got} (package.json = {want})")
71
+
72
+ if drift:
73
+ print(f"LOCKSTEP: DRIFT — {len(drift)} of {checked} shipped version string(s) do not match package.json")
74
+ print("\n".join(drift))
75
+ print("Bump every string above in the same commit — Codex caches plugins on this version,")
76
+ print("so a stale entry ships as 'already installed' on the runtime that cannot report it.")
77
+ sys.exit(1)
78
+
79
+ print(f"LOCKSTEP: PASS — {checked} shipped version string(s) all at {want}")
80
+ PY
@@ -303,6 +303,14 @@ else
303
303
  echo " → '~' or '{project}'), or PUBLIC_SURFACE_OK=1 git commit … for a reviewed mention."
304
304
  FAILED=1
305
305
  fi
306
+ elif [ "$PSA_OVERRIDE_PRESENT" -eq 0 ]; then
307
+ # NOT `✅ PASS`. The scan found nothing, but one whole layer of it — the operator/company
308
+ # literals — never ran. A missing measurement is not a zero (CLAUDE.md §Instrument
309
+ # Calibration), and a verdict that reads identically whether a layer ran or not is the
310
+ # fail-open this repo names. The commit is still allowed: a commit is reversible, so the
311
+ # degrade here is advisory by the Surface-Class Degrade Invariant. Push and publish, which
312
+ # are the acts that make content public, block this same state.
313
+ echo " ⚠️ PARTIAL — no hit in the committed defaults; company/companion literals UNMEASURED."
306
314
  else
307
315
  echo " ✅ PASS"
308
316
  fi
@@ -298,9 +298,21 @@ else
298
298
  _pp_why=""
299
299
  [ "$_PP_APPLICABLE" -eq 1 ] && [ "$PSA_DEFAULTS_OK" -eq 0 ] && _pp_why="committed pattern defaults unreadable/empty"
300
300
  [ "$_PP_APPLICABLE" -eq 1 ] && [ "$PSA_BAD_ROWS" -gt 0 ] && _pp_why="${_pp_why:+$_pp_why; }$PSA_BAD_ROWS unusable pattern row(s)"
301
+ # 2026-08-06 — the 07-26 warn stays for the case it was right about, and is SCOPED, not reverted.
302
+ # An absent override is a legitimate configuration gap in a fresh clone / CI runner / worktree (the
303
+ # file is gitignored, so it is absent there by construction) → WARN, exactly as 07-26 concluded.
304
+ # In an operator-configured checkout the same state is missing evidence, not a missing config, and
305
+ # this is an irreversible surface → BLOCK, matching what `public_surface_scan_files.sh` already does
306
+ # at `npm publish`. Two irreversible surfaces degrading in opposite directions on the same state was
307
+ # the actual defect; git push was the lenient one.
308
+ psa_detect_operator_context "$REPO_ROOT"
301
309
  if [ "$_PP_APPLICABLE" -eq 1 ] && [ "$PSA_OVERRIDE_PRESENT" -eq 0 ]; then
302
- echo " ⚠️ operator-literal override absent — only the committed defaults (home paths + credential"
303
- echo " shapes) are active. Populate .claude/rules/.public-surface-patterns for company literals."
310
+ if [ "$PSA_OPERATOR_CONTEXT" -eq 1 ]; then
311
+ _pp_why="${_pp_why:+$_pp_why; }operator-literal override absent in an operator-configured checkout (company/companion literals NOT scanned)"
312
+ else
313
+ echo " ⚠️ operator-literal override absent — only the committed defaults (home paths + credential"
314
+ echo " shapes) are active. Populate .claude/rules/.public-surface-patterns for company literals."
315
+ fi
304
316
  fi
305
317
  if [ -n "$_pp_why" ]; then
306
318
  if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then