@chrono-meta/fh-gate 1.4.84 → 1.4.86

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.84",
14
+ "version": "1.4.86",
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.84",
20
+ "version": "1.4.86",
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
@@ -595,10 +595,18 @@ Default operation is a **standard interactive session**. Agent dispatch (single
595
595
 
596
596
  Mapped paths: check `auto_project_mapping.md` or `find ~/projects -maxdepth 1 -type d` for actuals.
597
597
 
598
- **Invocation log obligation (always-loaded do not rely on recall)**: immediately after any custom
599
- sub-agent invocation, append to `knowledge/shared/learnings/subagent_invocations_log.yaml` (8 fields ·
600
- outcome: `accepted`/`partial`/`rejected`/`sustained` — `sustained` = decided NOT to invoke, also recorded).
601
- This feeds the 60/40 promotion gate + UAP loop; detail: `knowledge/shared/rules/operations.md`.
598
+ **Invocation log obligation — now mechanically reconciled, not recall-dependent**: immediately after
599
+ any custom sub-agent invocation, append to `knowledge/shared/learnings/subagent_invocations_log.yaml`
600
+ (8 fields · outcome: `accepted`/`partial`/`rejected`/`sustained` — `sustained` = decided NOT to invoke,
601
+ also recorded). This feeds the 60/40 promotion gate + UAP loop; detail:
602
+ `knowledge/shared/rules/operations.md`.
603
+ **Floor (2026-08-02)**: a `SubagentStop` hook tallies every dispatch and `session_close_check.sh` ④-e
604
+ blocks a close push when dispatches happened and the day's entries are ZERO. Consolidating a class of
605
+ dispatches into one entry with measured counts is fine and is not penalised — the check catches the
606
+ total miss, not imperfect bookkeeping. **Why it needed a floor**: this line was prose-only and a single
607
+ session dispatched 20+ subagents and logged none of them, in the same session that recovered this very
608
+ log file from a branch queued for deletion. The hook only tallies; it never writes an entry, because a
609
+ fabricated `outcome`/`evidence` would poison the promotion gate worse than a missing one.
602
610
 
603
611
  ### Context Card — Required Format for Dispatch
604
612
  ```
@@ -812,3 +812,59 @@
812
812
  whether an agent can locate it. Both arms independently cited the same residual (the paths: trigger is a READ,
813
813
  so a from-scratch Write never loads the detail rule), which is exactly the path the probe encodes. Method costs
814
814
  ~2 min/section and replaces the coverage number that was cut for being wrong four times.'
815
+
816
+ - date: '2026-08-02'
817
+ agent: general-purpose x6 pinned to sonnet (ablation known pairs — 3 sections x 2 arms)
818
+ task: Ablation sweep — 3 sections x 2 arms (6 dispatches; the day's SubagentStop tally read 7, the extra being one earlier probe). Two largest MEASURABLE sections of CLAUDE.md — §Autonomous Initiative (12,224 chars,
819
+ 8 probes) and §Session Wrap-up (8,398 chars, 2 probes) — plus the earlier §New Skill Creation Pre-Commit Gate
820
+ mode: paired arms per section (asset WITH vs WITHOUT the section), floor tier, all file access denied except the
821
+ one arm file
822
+ outcome: accepted
823
+ evidence: '3 sections ablated, 3 KEEP. §Autonomous Initiative: 5 routing behaviours GROUNDED in arm A and NOT
824
+ IN MY CONTEXT in arm B (context-doctor, harvest-loop, goal-quench, deep-clarify, the already-running Guard).
825
+ §Session Wrap-up: 4 of 6 close-chain answers fully absent without it (the ordered sequence, the absolute-last
826
+ rule and its reason, the late-finding re-run rule, the open-PR sweep). §New Skill gate: 3 of 3 absent.'
827
+ note: 'By-product worth more than the verdicts: the Pre-Publish row IN the initiative table is redundant — arm
828
+ B answered the full chain and the order invariant correctly from the gate''s own section (719 chars, ablatable).
829
+ And the headline: three measurements, three KEEPs. The ''CLAUDE.md is bloated'' premise is not surviving contact
830
+ — the earlier 46%-unmeasured framing measured probe coverage, which is not necessity.'
831
+
832
+ - date: 2026-08-03
833
+ agent: general-purpose x5 pinned to sonnet (ablation arms + isolation control)
834
+ task: Re-run the ablation sweep on §Pre-Publish Surface Gate and §FH Improvement 4-Axis Auto-Gate;
835
+ then, when arm B quoted text that grep proved absent from its own arm file, one control agent given
836
+ NO file and forbidden every tool.
837
+ mode: paired arms per section, floor tier, "read only the one arm file" — i.e. the method recorded in
838
+ the 2026-08-02 entry above
839
+ outcome: rejected
840
+ evidence: 'The method does not work. The control agent, given no file and zero tool calls, reproduced
841
+ the ablated section verbatim and named its own source: "project instructions / CLAUDE.md included in
842
+ my system prompt". A Claude Code subagent inherits the project CLAUDE.md, so "all file access denied
843
+ except the one arm file" (the 2026-08-02 entry''s own mode line) never produced a blind arm. Behaviour
844
+ is non-deterministic: in the same run one arm answered from the injected copy while another reported
845
+ the section missing — so the method can return a false KEEP or a false CUT by luck.'
846
+ note: 'Consequence for the entry above: its 3 KEEPs are UNCALIBRATED, not disproved. Its by-product
847
+ claim — the Pre-Publish table row is "719 chars, ablatable" — was re-measured today on a repaired
848
+ instrument and REJECTED: the row is redundant on both retrievability and trigger-firing, but arm A
849
+ stopped for a second confirmation before publishing while arm B proceeded, and that direction is not
850
+ worth 719 bytes on an irreversible surface.'
851
+
852
+ - date: 2026-08-03
853
+ agent: fh-commons:quench-challenger (isolated Agent, opus) + codex gpt-5.5 (cross-family, stdin form)
854
+ task: Axis 2 of the 4-axis gate on the repair itself — scripts/probe_scope_check.sh (ablation procedure
855
+ canon replacement + control C, which gates the script's exit code)
856
+ mode: isolated dispatch with a measured target fingerprint in Step 0; codex leg piped on stdin per the
857
+ recorded sidecar form
858
+ outcome: accepted
859
+ evidence: '19 findings (challenger 4S/4A/4B, codex 2S/4A/1B, overlapping roots). Both families
860
+ independently found the env kill switch (PROBE_SCOPE_FIXTURE ran a 2-row fixture instead of the real
861
+ corpus, silently skipped control C, printed the green line, exit 0 — reproduced). The challenger
862
+ produced a FALSE GREEN in control C using a nonexistent-asset fixture, and measured the second leak
863
+ channel: claude -p retains Bash/Read/Glob, and an arm run from a scratch dir recovered the ablated
864
+ section from the real CLAUDE.md and cited the file. codex reproduced a trap syntax break on a TMPDIR
865
+ containing an apostrophe. It also caught a regression I had just introduced: my new option parser
866
+ rejected --self-test, breaking scripts/selfcheck.sh:317.'
867
+ note: 'The value here is not the count — it is that the findings landed exactly where self-review had
868
+ just declared success. Adversarial review does not substitute for first use; today is the mirror:
869
+ self-repair does not substitute for adversarial review. Axis 2 shipped NOT CONVERGED with the open
870
+ leak channel declared in the header rather than closed by assertion.'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrono-meta/fh-gate",
3
- "version": "1.4.84",
3
+ "version": "1.4.86",
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": [
@@ -104,6 +104,7 @@
104
104
  "scripts/prepush_guard_check.sh",
105
105
  "scripts/psa_scan_lib.sh",
106
106
  "scripts/session_close_check.sh",
107
+ "scripts/test_dispatch_log_lanes.sh",
107
108
  "scripts/memory_link_check.py",
108
109
  "scripts/test_memory_link_check.sh",
109
110
  "scripts/memory_nearcheck.py",
@@ -145,6 +146,8 @@
145
146
  "templates/settings.PreToolUse.snippet.json",
146
147
  "scripts/halffix_propagation_scan.sh",
147
148
  "scripts/test_halffix_lanes.sh",
148
- "scripts/test_ollama_panel_lanes.sh"
149
+ "scripts/test_ollama_panel_lanes.sh",
150
+ "scripts/test_tag_version_lanes.sh",
151
+ "templates/subagent-tally-hook.json"
149
152
  ]
150
153
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-commons",
3
- "version": "1.4.84",
3
+ "version": "1.4.86",
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.84",
3
+ "version": "1.4.86",
4
4
  "engines": {
5
5
  "claudeCode": ">=1.0.0"
6
6
  },
@@ -10,6 +10,31 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
10
10
 
11
11
  ## Plugin Level
12
12
 
13
+ ### [1.4.86] — 2026-08-03
14
+
15
+ **fix: 격리를 자칭하던 어블레이션 절차 + ambig 게이트 앵커 + 밀린 출하 자산 반영**
16
+
17
+ - `scripts/probe_scope_check.sh` — 어블레이션 절차 정본을 교체했다. 서브에이전트에는 프로젝트
18
+ CLAUDE.md 가 **시스템 프롬프트로 주입**되므로 "다른 파일 읽기 금지"는 블라인드 arm 을 만들지
19
+ 못한다(파일 0개·툴 0회 컨트롤이 잘린 절을 verbatim 재현하며 출처를 자백). 헤더는 이제
20
+ **"이 절차는 아직 검증되지 않았다"**로 시작한다 — 적대 검증이 두 번째 누출 채널(`claude -p`
21
+ 가 Bash/Read/Glob 을 쥐고 있어 arm 이 진짜 CLAUDE.md 를 찾아 인용)을 실측했고, 툴 차단 처방은
22
+ known-pair 가 없다(`--tools ''` 는 양성 컨트롤 실패 = 전 구간 거짓 KEEP). 기존 판정은
23
+ UNCALIBRATED, 오늘 판정 2건은 PROVISIONAL, CUT 후보는 **DO NOT CUT**.
24
+ - 같은 파일 — control B 의 `ambig` 항을 **control C**(픽스처 코퍼스 자기 재호출)로 앵커. 실 코퍼스는
25
+ 건드리지 않는다. 게이트 항을 되돌리면 arm 이 빨개지는 것까지 검증.
26
+ - 같은 파일 — 적대 검증이 잡은 자기결함 5건 봉합: env 킬스위치(`PROBE_SCOPE_FIXTURE` export 시
27
+ 픽스처를 실 코퍼스 대신 검사하고 control C 를 무음으로 끄고도 초록 + exit 0) → argv 플래그로
28
+ 교체 · 타입 없는 exit 3 을 증거로 받던 control C → 자식의 `ambiguous-basename` 카운트 동반 요구 ·
29
+ 따옴표 불안전한 RETURN trap · mktemp 실패가 exit 3 으로 대상 코드를 무고하던 것 → exit 1 ·
30
+ 새 옵션 파서가 `--self-test` 를 거부해 `selfcheck.sh` 를 깨뜨리던 자초 회귀.
31
+ - `knowledge/shared/learnings/subagent_invocations_log.yaml` — 호출 로그 2건. 그중 하나가
32
+ 전날 엔트리를 `rejected` 로 뒤집는다(그 엔트리의 mode 줄 "all file access denied except the one
33
+ arm file" 이 성립하지 않는다).
34
+ - 밀려 있던 출하 자산 반영(#239, 어제): `scripts/selfcheck.sh` · `scripts/package_coverage_check.sh` ·
35
+ `templates/.git-hooks/pre-push` — 태그가 자기 버전을 가리키는지 검사하는 가드.
36
+
37
+
13
38
  ### [1.4.1] — 2026-06-08
14
39
 
15
40
  **docs: repo-root declutter + pre-commit gate hardening**
@@ -330,6 +330,37 @@ else
330
330
  fail=1
331
331
  fi
332
332
 
333
+ # tag/version consistency guard. Wired with the guard it anchors: the guard exists because a wrong
334
+ # tag reached the remote and a publish from the wrong tree was stopped only by npm's own collision
335
+ # check, so an unrun anchor here would be the same luck-as-floor arrangement one layer up.
336
+ if [ ! -f templates/.git-hooks/pre-push ]; then
337
+ echo "SKIP test_tag_version_lanes.sh (subject templates/.git-hooks/pre-push absent)"
338
+ elif [ -f scripts/test_tag_version_lanes.sh ]; then
339
+ if ! bash scripts/test_tag_version_lanes.sh >/dev/null 2>&1; then
340
+ echo "FAIL test_tag_version_lanes.sh: the tag/version guard would mis-route"
341
+ bash scripts/test_tag_version_lanes.sh 2>&1 | tail -12
342
+ fail=1
343
+ else
344
+ echo "PASS test_tag_version_lanes.sh (mismatch blocks · match silent · scope · override)"
345
+ fi
346
+ else
347
+ echo "FAIL test_tag_version_lanes.sh: pre-push present but its anchor is missing"
348
+ fail=1
349
+ fi
350
+
351
+ # ④-e dispatch-log reconciliation + its tally hook. Wired in the same commit that ships them: the
352
+ # obligation they mechanize lost 20/20 in a single session, so leaving the checker itself unrun
353
+ # would be the same defect one layer up.
354
+ if [ -f scripts/test_dispatch_log_lanes.sh ]; then
355
+ if ! bash scripts/test_dispatch_log_lanes.sh >/dev/null 2>&1; then
356
+ echo "FAIL test_dispatch_log_lanes.sh: the dispatch-log reconciliation would mis-report"
357
+ bash scripts/test_dispatch_log_lanes.sh 2>&1 | tail -14
358
+ fail=1
359
+ else
360
+ echo "PASS test_dispatch_log_lanes.sh (date-spelling + verdict + tally-hook lanes)"
361
+ fi
362
+ fi
363
+
333
364
  # selfcheck's own subject-presence discriminators. Every other guard under scripts/ has a lane suite;
334
365
  # this decision had none, and it shipped two mis-routings in one session — a two-arm form that fell
335
366
  # through in silence, then a package discriminator keyed on a file that actually ships. Both are
@@ -117,6 +117,57 @@ if [ -n "$LAST_TAG" ] && [ -f "$FH/package.json" ]; then
117
117
  fi
118
118
  fi
119
119
 
120
+ # ④-e DISPATCH-LOG RECONCILIATION — mechanical, because prose failed completely.
121
+ # CLAUDE.md makes an invocation-log entry MANDATORY immediately after any custom sub-agent
122
+ # invocation (it feeds the 60/40 promotion gate and the UAP loop). Measured 2026-08-02: a single
123
+ # session dispatched 20+ subagents and logged ZERO — not a marginal lapse, a total one, in the same
124
+ # session that RECOVERED that very log file from a branch about to be deleted. An obligation that
125
+ # loses 20 times out of 20 is not under-emphasised; it is unmechanized. Per this repo's own rule
126
+ # (1-2 occurrences -> prose; N>=3 or a repeat on another surface -> mechanize), this is well past it.
127
+ #
128
+ # The check is a RECONCILIATION, not an auto-writer. A hook cannot fill `outcome` or `evidence`
129
+ # without fabricating judgment, and a fabricated log entry is worse than a missing one — it would
130
+ # poison the promotion gate with invented outcomes. So the hook only TALLIES (SubagentStop appends a
131
+ # date line) and this step compares the tally against today's entries.
132
+ #
133
+ # It deliberately does NOT demand 1:1. Consolidating twenty challenger rounds into one entry with
134
+ # measured counts is better bookkeeping than twenty stubs, and punishing it would train stub-spam.
135
+ # What it catches is the failure that actually happened: dispatches occurred and NOTHING was written.
136
+ # ABSENCE IS NOT ZERO. The tally comes from a SubagentStop hook configured in `.claude/settings.json`,
137
+ # which is GITIGNORED by design (it also carries local permissions). So a fresh clone, another
138
+ # machine, or a wiped settings file has no hook — and without this branch the check would read an
139
+ # empty tally as "no dispatches today" and pass in silence. That is the exact fail-open this whole
140
+ # step exists to close, re-created inside it; caught before commit by asking where the tally comes
141
+ # from. Installable snippet: templates/subagent-tally-hook.json.
142
+ TALLY="$FH/tracks/_meta/.subagent_dispatch_tally"
143
+ LOG="$FH/knowledge/shared/learnings/subagent_invocations_log.yaml"
144
+ HOOK_OK=0
145
+ if [ -f "$FH/.claude/settings.json" ]; then
146
+ grep -q '"SubagentStop"' "$FH/.claude/settings.json" 2>/dev/null && HOOK_OK=1
147
+ fi
148
+ if [ "$HOOK_OK" -eq 0 ]; then
149
+ echo "⚠️ ④-e dispatch log NOT MEASURED — no SubagentStop tally hook in .claude/settings.json"
150
+ echo " (that file is gitignored, so a fresh clone has none). An unmeasured dispatch count is"
151
+ echo " NOT a count of zero. Install: templates/subagent-tally-hook.json → .claude/settings.json"
152
+ fi
153
+ DISPATCHED=$(grep -c "^$TODAY$" "$TALLY" 2>/dev/null | tr -d ' ' || echo 0)
154
+ # Both quotings, because the file carries both: hand-written entries use `- date: 2026-08-02`
155
+ # while anything appended via yaml.dump renders `- date: '"'"'2026-08-02'"'"'`. Matching one form counted
156
+ # half the entries as absent — a divergent-normalizer miss inside the check that exists to catch
157
+ # missing records. Known-pair calibrated below in test_dispatch_log_lanes.sh.
158
+ LOGGED=$(grep -cE "^- date: *'?$TODAY'?" "$LOG" 2>/dev/null | tr -d ' ' || echo 0)
159
+ if [ "${DISPATCHED:-0}" -gt 0 ] && [ "${LOGGED:-0}" -eq 0 ]; then
160
+ echo "❌ ④-e $DISPATCHED sub-agent dispatch(es) today and ZERO invocation-log entries — the 60/40"
161
+ echo " promotion gate and the UAP loop both read that file; an unlogged session is invisible to"
162
+ echo " them. Append to knowledge/shared/learnings/subagent_invocations_log.yaml (consolidated"
163
+ echo " per class is fine — record counts and outcomes, not one stub per dispatch)."
164
+ FAIL=1
165
+ elif [ "${DISPATCHED:-0}" -gt 0 ]; then
166
+ echo "✅ ④-e dispatch log: $DISPATCHED dispatch(es) today, $LOGGED log entr(ies) recorded"
167
+ elif [ "$HOOK_OK" -eq 1 ]; then
168
+ echo "✅ ④-e dispatch log: no sub-agent dispatches tallied today"
169
+ fi
170
+
120
171
  # ⑤ CARD-LAST invariant — the card must be the NEWEST close artifact. A card older than
121
172
  # fh_completed / signal files written this session = ⑤ ran before ①–④ finished (the bug class).
122
173
  if [ -f "$CARD" ]; then
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ # test_dispatch_log_lanes.sh — known-pair anchor for session_close_check.sh ④-e (dispatch-log
3
+ # reconciliation) and for the SubagentStop tally hook that feeds it.
4
+ #
5
+ # WHY (2026-08-02): CLAUDE.md makes an invocation-log entry mandatory immediately after any custom
6
+ # sub-agent invocation — it feeds the 60/40 promotion gate and the UAP loop. Measured: one session
7
+ # dispatched 20+ subagents and logged ZERO, in the same session that recovered that very log file
8
+ # from a branch queued for deletion. An obligation that loses 20/20 is not under-emphasised, it is
9
+ # unmechanized, so ④-e now reconciles a hook-written tally against the day's entries.
10
+ #
11
+ # The reconciliation itself then shipped a defect this suite exists to prevent from returning: the
12
+ # log carries TWO date spellings — `- date: 2026-08-02` for hand-written entries and
13
+ # `- date: '2026-08-02'` for anything appended through yaml.dump — and the first matcher saw only
14
+ # one, counting half the entries as absent. A missing-records check that itself miscounts records is
15
+ # the worst possible shape, so both spellings are lanes below.
16
+ #
17
+ # Exit 0 = the reconciliation discriminates · 1 = it would mis-report.
18
+
19
+ set -uo pipefail
20
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ CHECK="$SCRIPT_DIR/session_close_check.sh"
22
+ SETTINGS="$SCRIPT_DIR/../.claude/settings.json"
23
+ FAILED=0; PASS=0
24
+ chk() { if [ "$1" -eq 0 ]; then PASS=$((PASS+1)); echo " ✅ $2"; else FAILED=1; echo " ❌ $2"; fi; }
25
+
26
+ [ -f "$CHECK" ] || { echo "FAIL dispatch-log lanes: subject $CHECK missing"; exit 1; }
27
+
28
+ # The matcher under test, lifted from the subject rather than re-spelled (a hand-copied predicate is
29
+ # a divergent normalizer — which is exactly the bug being anchored). If it is gone, fail loudly.
30
+ MATCHER=$(grep -oE "grep -cE \"\^- date: \*'\?\\\$TODAY'\?\"" "$CHECK" | head -1)
31
+ if [ -z "$MATCHER" ]; then
32
+ echo "FAIL the ④-e date matcher is no longer in session_close_check.sh in the expected form —"
33
+ echo " this lane cannot verify what it claims to. Update the lane WITH the subject."
34
+ exit 1
35
+ fi
36
+ echo "── matcher located in the subject"
37
+
38
+ T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
39
+ D=2026-08-02
40
+
41
+ echo "── date-spelling lanes (the shipped miscount) ──"
42
+ printf -- "- date: %s\n" "$D" > "$T/plain.yaml"
43
+ printf -- "- date: '%s'\n" "$D" > "$T/quoted.yaml"
44
+ printf -- "- date: %s\n- date: '%s'\n- date: 2020-01-01\n" "$D" "$D" > "$T/both.yaml"
45
+ count() { grep -cE "^- date: *'?$D'?" "$1" 2>/dev/null | tr -d ' '; }
46
+ [ "$(count "$T/plain.yaml")" = 1 ] ; chk $? "unquoted date counted"
47
+ [ "$(count "$T/quoted.yaml")" = 1 ] ; chk $? "yaml.dump-quoted date counted (the half the first matcher missed)"
48
+ [ "$(count "$T/both.yaml")" = 2 ] ; chk $? "mixed file counts both, and ignores another date"
49
+ # known-POSITIVE for the bug itself: the reverted single-form matcher DOES undercount
50
+ [ "$(grep -c "^- date: *$D" "$T/both.yaml")" = 1 ] ; chk $? "known-POSITIVE: the reverted matcher undercounts (1 of 2) — the defect is real"
51
+
52
+ echo "── reconciliation verdict lanes ──"
53
+ verdict() { # $1=dispatched $2=logged -> BLOCK | OK | NONE
54
+ if [ "$1" -gt 0 ] && [ "$2" -eq 0 ]; then echo BLOCK
55
+ elif [ "$1" -gt 0 ]; then echo OK
56
+ else echo NONE; fi
57
+ }
58
+ [ "$(verdict 20 0)" = BLOCK ] ; chk $? "dispatches with ZERO entries → BLOCK (this is the 2026-08-02 failure)"
59
+ [ "$(verdict 20 4)" = OK ] ; chk $? "consolidated logging (20 dispatches, 4 entries) → OK, not punished"
60
+ [ "$(verdict 1 1)" = OK ] ; chk $? "1:1 logging → OK"
61
+ [ "$(verdict 0 0)" = NONE ] ; chk $? "no dispatches → silent, not a failure"
62
+
63
+ echo "── the tally hook that feeds it ──"
64
+ if [ -f "$SETTINGS" ]; then
65
+ python3 - "$SETTINGS" <<'PY' >"$T/hookcmd" 2>/dev/null || true
66
+ import json,sys
67
+ d=json.load(open(sys.argv[1]))
68
+ g=d.get("hooks",{}).get("SubagentStop",[])
69
+ print(g[0]["hooks"][0]["command"] if g and g[0].get("hooks") else "")
70
+ PY
71
+ cmd=$(cat "$T/hookcmd")
72
+ [ -n "$cmd" ] ; chk $? "SubagentStop hook is configured (an untallied dispatch is invisible to ④-e)"
73
+ if [ -n "$cmd" ]; then
74
+ case "$cmd" in *"exit 0"*) ok=0 ;; *) ok=1 ;; esac
75
+ [ "$ok" -eq 0 ] ; chk $? "hook ends in exit 0 — a non-zero hook exit discards its stdout SILENTLY"
76
+ # run it against a scratch HUB and confirm it appends today's date
77
+ CLAUDE_PROJECT_DIR="$T/hub" bash -c "$cmd" >/dev/null 2>&1
78
+ [ "$(grep -c "$(date +%Y-%m-%d)" "$T/hub/tracks/_meta/.subagent_dispatch_tally" 2>/dev/null || echo 0)" -ge 1 ]
79
+ chk $? "hook actually appends a dated line when run (not merely present)"
80
+ fi
81
+ else
82
+ echo " ⏭️ .claude/settings.json absent — hook lanes unchecked (not a pass)"
83
+ fi
84
+
85
+ echo "── absence-is-not-zero (the fail-open this fix nearly shipped) ──"
86
+ # .claude/settings.json is GITIGNORED, so a fresh clone has no hook and an empty tally. Without an
87
+ # explicit branch the reconciliation would read that as "no dispatches" and pass in silence.
88
+ hookless_verdict() { # $1=hook_configured -> MEASURED | NOT-MEASURED
89
+ [ "$1" -eq 1 ] && echo MEASURED || echo NOT-MEASURED
90
+ }
91
+ [ "$(hookless_verdict 0)" = NOT-MEASURED ] ; chk $? "no hook → NOT MEASURED (an unmeasured count is not zero)"
92
+ [ "$(hookless_verdict 1)" = MEASURED ] ; chk $? "hook present → measured"
93
+ grep -q "NOT MEASURED" "$CHECK" ; chk $? "the subject actually carries that branch (not just this lane)"
94
+ [ -f "$SCRIPT_DIR/../templates/subagent-tally-hook.json" ] ; chk $? "installable snippet exists for a clone that has no settings.json"
95
+
96
+ echo ""
97
+ if [ "$FAILED" -ne 0 ]; then echo "DISPATCH-LOG LANES: FAIL"; exit 1; fi
98
+ echo "DISPATCH-LOG LANES: PASS ($PASS/$PASS)"
99
+ exit 0
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bash
2
+ # test_tag_version_lanes.sh — known-pair anchor for the pre-push tag/version consistency guard.
3
+ #
4
+ # WHY (measured 2026-08-02): a release tag was pushed onto the WRONG commit. `git pull --ff-only`
5
+ # had failed with a diverging-branches error that went unread, so `main` was still the pre-release
6
+ # tree; the tag landed there and `npm publish` was then attempted from that same tree. The ONLY
7
+ # thing that stopped the publish was npm's own "cannot publish over 1.4.84" collision check.
8
+ # Publishing does not un-happen — being saved by the registry's bookkeeping is luck, not a floor.
9
+ #
10
+ # Built at N=1 on purpose. This repo's escalation rule ("1-2 occurrences -> prose, N>=3 -> mechanize")
11
+ # is scoped to REVERSIBLE surfaces; a wrong tag on a public repo plus a publish from the wrong tree
12
+ # is the irreversible class, where the Surface-Class Degrade Invariant says fail-CLOSED immediately.
13
+ #
14
+ # The lanes drive the REAL hook through its real stdin contract (`<local_ref> <local_sha>
15
+ # <remote_ref> <remote_sha>`), not a re-spelling of its predicate — a hand-copied condition is a
16
+ # divergent normalizer and drifts lenient.
17
+ #
18
+ # Exit 0 = the guard discriminates · 1 = it would let a mismatched tag through, or block a good one.
19
+
20
+ set -uo pipefail
21
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
+ REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
23
+ HOOK="$REPO/templates/.git-hooks/pre-push"
24
+ FAILED=0; PASS=0
25
+ chk() { if [ "$1" -eq 0 ]; then PASS=$((PASS+1)); echo " ✅ $2"; else FAILED=1; echo " ❌ $2"; fi; }
26
+
27
+ [ -f "$HOOK" ] || { echo "FAIL tag-version lanes: subject $HOOK missing"; exit 1; }
28
+ ZERO=0000000000000000000000000000000000000000
29
+
30
+ # A throwaway repo so the lanes never depend on this repo's live history.
31
+ T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
32
+ ( cd "$T" && git init -q . && git config user.email a@b && git config user.name a
33
+ printf '{\n "name": "x",\n "version": "1.0.0"\n}\n' > package.json
34
+ git add -A && git commit -qm v100
35
+ printf '{\n "name": "x",\n "version": "1.1.0"\n}\n' > package.json
36
+ git add -A && git commit -qm v110
37
+ printf 'no manifest here\n' > README.md && rm -f package.json
38
+ git add -A && git commit -qm nomanifest ) >/dev/null 2>&1
39
+
40
+ C_100=$(cd "$T" && git rev-parse HEAD~2)
41
+ C_110=$(cd "$T" && git rev-parse HEAD~1)
42
+ C_NONE=$(cd "$T" && git rev-parse HEAD)
43
+
44
+ # Drive the hook exactly as git does. Run it INSIDE the scratch repo so its rev-parse/cat-file
45
+ # resolve against that history. Only the tag verdict is under test here, so a non-zero exit from an
46
+ # unrelated guard would be a false red — assert on the MESSAGE, and on rc only for the clean lane.
47
+ run() { # $1=tag $2=sha -> prints hook output
48
+ printf '%s %s %s %s\n' "refs/tags/$1" "$2" "refs/tags/$1" "$ZERO" \
49
+ | ( cd "$T" && bash "$HOOK" origin 2>&1 )
50
+ }
51
+ says_mismatch() { printf '%s' "$1" | grep -q "TAG/VERSION MISMATCH"; }
52
+ blocks() { printf '%s' "$1" | grep -q "FH Tag/Version Consistency"; }
53
+
54
+ echo "── the measured failure, reproduced ──"
55
+ out=$(run v1.1.0 "$C_100")
56
+ says_mismatch "$out" ; chk $? "known-POSITIVE: v1.1.0 on a commit whose package.json says 1.0.0 → MISMATCH"
57
+ blocks "$out" ; chk $? "…and it BLOCKS (detection wired to a blocker, not just printed)"
58
+
59
+ echo "── the good case must not be blocked (over-blocking trains the override) ──"
60
+ out=$(run v1.1.0 "$C_110")
61
+ if says_mismatch "$out"; then chk 1 "known-NEGATIVE: matching tag/version is silent"; else chk 0 "known-NEGATIVE: matching tag/version is silent"; fi
62
+ if blocks "$out"; then chk 1 "…and does not block"; else chk 0 "…and does not block"; fi
63
+
64
+ echo "── scope: not every tag is a release tag ──"
65
+ out=$(run v1.0.0 "$C_NONE")
66
+ if says_mismatch "$out"; then chk 1 "no package.json at that commit → not applicable, not a block"; else chk 0 "no package.json at that commit → not applicable, not a block"; fi
67
+ out=$(run sprint-42 "$C_100")
68
+ if says_mismatch "$out"; then chk 1 "non-version tag name (sprint-42) is out of scope"; else chk 0 "non-version tag name (sprint-42) is out of scope"; fi
69
+
70
+ echo "── the override channel exists and is explicit ──"
71
+ grep -q 'TAG_VERSION_OK' "$HOOK" ; chk $? "TAG_VERSION_OK override present (mirrors MAIN_PUSH_OK / DESTRUCTIVE_OP_OK)"
72
+ out=$(printf '%s %s %s %s\n' "refs/tags/v1.1.0" "$C_100" "refs/tags/v1.1.0" "$ZERO" \
73
+ | ( cd "$T" && TAG_VERSION_OK=1 bash "$HOOK" origin 2>&1 ))
74
+ if blocks "$out"; then chk 1 "override actually lets the push through"; else chk 0 "override actually lets the push through"; fi
75
+
76
+ echo ""
77
+ if [ "$FAILED" -ne 0 ]; then echo "TAG-VERSION LANES: FAIL"; exit 1; fi
78
+ echo "TAG-VERSION LANES: PASS ($PASS/$PASS)"
79
+ exit 0
@@ -54,6 +54,7 @@ DEL_OTHER="" # refs/tags/* refs/notes/* etc. being deleted
54
54
  FORCED_REFS=""
55
55
  UNCLASSIFIED="" # force-check impossible (remote tip not fetched)
56
56
  DIRECT_MAIN="" # non-delete update pushed straight at the integration branch (PR-only policy)
57
+ TAG_MISMATCH="" # vX.Y.Z tag whose commit's package.json disagrees (irreversible-adjacent)
57
58
  SEP=$'\n' # ranges are newline-separated and evaluated ONE REF AT A TIME: concatenating
58
59
  # them into one arg string let `--not` from ref A flip polarity for ref B, so a
59
60
  # multi-ref push could return an empty or wrong commit set (R6 audit 2026-07-26).
@@ -184,6 +185,40 @@ while read -r local_ref local_sha remote_ref remote_sha; do
184
185
  fi ;;
185
186
  esac
186
187
  fi
188
+ # TAG/VERSION CONSISTENCY — a `vX.Y.Z` tag must point at a commit whose package.json says X.Y.Z.
189
+ # WHY (measured 2026-08-02): a release tag was pushed onto the WRONG commit because `git pull`
190
+ # had failed with a diverging-branches error that went unread, so `main` was still the pre-release
191
+ # tree. `npm publish` was then attempted from that same tree — and the ONLY thing that stopped it
192
+ # was npm's own "cannot publish over 1.4.84" collision check. Publishing is irreversible; being
193
+ # saved by the registry's bookkeeping is luck, not a floor.
194
+ # This is N=1 and it is built anyway: the repetition rule ("1-2 occurrences -> prose") is scoped to
195
+ # REVERSIBLE surfaces. A wrong tag on a public repo plus a publish from the wrong tree is the
196
+ # irreversible class, where this repo's own Surface-Class Degrade Invariant says fail-CLOSED now.
197
+ # Scope is deliberately narrow: only a NEW `refs/tags/v<digits>` push, only when package.json
198
+ # exists at that commit. Absent package.json -> not applicable (a tag in a non-npm repo is fine);
199
+ # unreadable package.json at a commit that HAS one -> BLOCK (cannot decide != allowed).
200
+ if [ "$local_sha" != "$ZERO" ] && [ "${remote_sha:-$ZERO}" = "$ZERO" ]; then
201
+ case "$remote_ref" in
202
+ refs/tags/v[0-9]*)
203
+ _tv="${remote_ref#refs/tags/v}"
204
+ _commit=$(git rev-parse "${local_sha}^{commit}" 2>/dev/null)
205
+ if [ -z "$_commit" ]; then
206
+ echo " ⛔ TAG/VERSION: $remote_ref does not resolve to a commit — cannot verify, refusing"
207
+ TAG_MISMATCH="$TAG_MISMATCH $remote_ref"
208
+ elif git cat-file -e "${_commit}:package.json" 2>/dev/null; then
209
+ _pv=$(git show "${_commit}:package.json" 2>/dev/null \
210
+ | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
211
+ if [ -z "$_pv" ]; then
212
+ echo " ⛔ TAG/VERSION: package.json exists at ${_commit} but its version is unreadable — refusing"
213
+ TAG_MISMATCH="$TAG_MISMATCH $remote_ref"
214
+ elif [ "$_pv" != "$_tv" ]; then
215
+ echo " ⛔ TAG/VERSION MISMATCH: $remote_ref points at ${_commit}, whose package.json says $_pv"
216
+ TAG_MISMATCH="$TAG_MISMATCH $remote_ref"
217
+ fi
218
+ fi ;;
219
+ esac
220
+ fi
221
+
187
222
  # PR-only policy: a non-delete update aimed straight at the integration branch.
188
223
  # Detected INSIDE this loop because this is the only place git's ref list is readable.
189
224
  if [ "$local_sha" != "$ZERO" ]; then
@@ -403,6 +438,27 @@ fi
403
438
  # instead of a bare server rejection, and it keeps working if the server setting is ever relaxed.
404
439
  # It is deliberately NOT the hard floor — a client-side hook is bypassable by `--no-verify`.
405
440
  # Scope: blocks the PUSH, not the merge — `gh pr merge` operates server-side and is unaffected.
441
+ if [ -n "$TAG_MISMATCH" ] && [ "${TAG_VERSION_OK:-0}" != "1" ]; then
442
+ echo ""
443
+ echo "══════════════════════════════════════════════"
444
+ echo " ⛔ FH Tag/Version Consistency (pre-push)"
445
+ echo "══════════════════════════════════════════════"
446
+ echo " Tag(s) whose commit disagrees with package.json:$TAG_MISMATCH"
447
+ echo ""
448
+ echo " A release tag is the thing a human and a script both trust to answer \"what shipped\"."
449
+ echo " Measured 2026-08-02: a tag went onto the pre-release commit because a failed \`git pull\`"
450
+ echo " went unread, and \`npm publish\` was then attempted from that same tree. Only npm's own"
451
+ echo " version-collision check stopped it. Publishing does not un-happen; that was luck."
452
+ echo ""
453
+ echo " Fix: git fetch origin && git switch main && git pull --ff-only"
454
+ echo " git tag -d <tag> && git push origin :refs/tags/<tag> # if already pushed"
455
+ echo " # then re-tag on the commit whose package.json really carries that version"
456
+ echo ""
457
+ echo " Deliberate exception (explicit, logged — mirrors MAIN_PUSH_OK / DESTRUCTIVE_OP_OK):"
458
+ echo " TAG_VERSION_OK=1 git push …"
459
+ exit 1
460
+ fi
461
+
406
462
  if [ -n "$DIRECT_MAIN" ] && [ "${MAIN_PUSH_OK:-0}" != "1" ]; then
407
463
  echo ""
408
464
  echo "══════════════════════════════════════════════"
@@ -0,0 +1,17 @@
1
+ {
2
+ "_readme": "FH dispatch-tally hook. Merge the `hooks.SubagentStop` block below into your .claude/settings.json (that file is gitignored, which is why this template exists). It feeds session_close_check.sh ④-e, which blocks a close push when sub-agents were dispatched and the day's invocation-log entries are zero. The hook TALLIES ONLY — it never writes a log entry, because a fabricated outcome/evidence field would poison the 60/40 promotion gate worse than a missing one.",
3
+ "hooks": {
4
+ "SubagentStop": [
5
+ {
6
+ "matcher": "",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "bash -c 'HUB=\"${CLAUDE_PROJECT_DIR:-$HOME/projects/forge-harness}\"; T=\"$HUB/tracks/_meta/.subagent_dispatch_tally\"; mkdir -p \"$(dirname \"$T\")\" 2>/dev/null; printf \"%s\\n\" \"$(date +%Y-%m-%d)\" >> \"$T\" 2>/dev/null; exit 0'",
11
+ "timeout": 5
12
+ }
13
+ ]
14
+ }
15
+ ]
16
+ }
17
+ }