@chrono-meta/fh-gate 1.4.89 → 1.4.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.claude/judgment_circuits.txt +14 -0
  2. package/.claude/rules/fh_4axis_gate.md +7 -0
  3. package/.claude-plugin/marketplace.json +2 -2
  4. package/AGENTS.md +25 -0
  5. package/CLAUDE.md +179 -12
  6. package/knowledge/shared/harness-core/dispatch_conditional_prohibition.md +105 -0
  7. package/knowledge/shared/harness-core/fh_three_layer_canon.md +165 -0
  8. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +100 -0
  9. package/knowledge/shared/harness-core/onboarding_acceleration_autopilot.md +3 -1
  10. package/knowledge/shared/harness-core/ship_readiness_gate.md +181 -13
  11. package/knowledge/shared/learnings/subagent_invocations_log.yaml +528 -0
  12. package/package.json +16 -2
  13. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  14. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  15. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +56 -8
  16. package/plugins/fh-meta/skills/install-wizard/SKILL.md +33 -0
  17. package/plugins/fh-meta/skills/install-wizard/SKILL_detail.md +104 -15
  18. package/scripts/chamber_run.sh +64 -2
  19. package/scripts/chamber_witness.sh +439 -0
  20. package/scripts/compaction_probe.sh +456 -0
  21. package/scripts/digest_landing_check.sh +385 -0
  22. package/scripts/directional_diff_gate.sh +459 -0
  23. package/scripts/judgment_circuit_lint.sh +239 -0
  24. package/scripts/novelty_claim_check.sh +193 -0
  25. package/scripts/relay_channel.sh +645 -0
  26. package/scripts/reviewer_capability_corpus.tsv +124 -0
  27. package/scripts/selfcheck.sh +66 -0
  28. package/scripts/test_marker_crossfamily_lanes.sh +132 -0
  29. package/scripts/test_marker_floor_lanes.sh +9 -8
  30. package/scripts/test_relay_channel_lanes.sh +583 -0
  31. package/scripts/test_reviewer_capability_conformance.sh +173 -0
  32. package/scripts/test_wizard_snippet_merge_lanes.sh +104 -11
  33. package/scripts/utterance_landing_check.sh +209 -0
  34. package/templates/.git-hooks/pre-commit +253 -13
  35. package/templates/settings.Compaction.snippet.json +56 -0
@@ -197,13 +197,54 @@ mode**, with an honest *same-family* note but **no "reduced value / degraded" fr
197
197
  this floor. `unset` → ask-once at first load-bearing need (accept → proceed; decline → record + this
198
198
  branch). Only proceed to the discovery ladder below when consent is `accepted`.
199
199
 
200
- 1. frontier cross-family CLI present recruit it (decorrelated, at-floor)best.
201
- 2. only local 4090 present canary pre-screen + in-session opus governor (canary, not full decorrelation).
202
- 3. nothing present → in-session same-family + **honest below-floor/same-family note** (residual named).
203
-
204
- Env non-determinism (CLI presence varies) → **silent degrade, never hard-fail**. Distinguish this
205
- **unavailable-but-wanted** case (consent given, panel down degrade-with-note; for a *load-bearing corp*
206
- surface, fail-closed per `local_pmh_context.md`) from the **declined** case above (chosen floor, first-class).
200
+ Every rung **emits a typed `crossfamily:` value into the Axes 2–3 marker** the rung is not
201
+ done until its verdict is recorded. The value is a closed enum, validated by `pre-commit`
202
+ (`validate_marker_floor`, fixtures in `scripts/test_marker_crossfamily_lanes.sh`); free prose is
203
+ rejected at commit time.
204
+
205
+ | # | Panel state | Emit | Ack |
206
+ |---|---|---|---|
207
+ | 1 | frontier cross-family CLI present → recruit it (decorrelated, at-floor) — best | `panel(<families>)` | — |
208
+ | 2 | only local 4090 → canary pre-screen + in-session opus governor (canary, not full decorrelation) | `panel(<families>)` **only if** a capable non-Claude model actually reviewed; otherwise rung 3 | — |
209
+ | 3 | **probed and nothing capable reachable** → in-session same-family | `DEGRADED_SINGLE_FAMILY` | **required** |
210
+ | — | **capable panel reachable, not recruited** (a choice, not a constraint) | `DEGRADED_PANEL_UNUSED` | **required** |
211
+ | — | consent `declined` (branch above — chosen floor, not a degrade) | `declined` | — |
212
+ | — | change is not load-bearing — decorrelation not required | `single-family` | — |
213
+ | — | **panel never probed** | `UNKNOWN` | **required** |
214
+
215
+ **The three degrade values are the load-bearing split**: *could not* (`DEGRADED_SINGLE_FAMILY`) ·
216
+ *did not* (`DEGRADED_PANEL_UNUSED`) · *did not look* (`UNKNOWN`). Free prose merges all three, and
217
+ each merge hides a different thing — an unrun probe renders as a zero finding
218
+ (`[[feedback_not_found_is_not_zero_family]]`), and an unused panel renders as an unavailable one.
219
+ The sibling-harness signal that motivated this field said it in its own words about itself:
220
+ *"못 한 것이 아니라 안 한 것이다."* All three require an ack naming *what was probed and what it
221
+ returned*. **`DEGRADED_PANEL_UNUSED` was added by dogfooding**: the first real marker written under
222
+ this lane found the panel reachable (codex · agy · gemini) and no cross-family review run — a state
223
+ the original five-value enum could not express without lying
224
+ (`[[feedback_adversarial_review_not_substitute_for_first_use]]`).
225
+
226
+ **Panel membership is capability-gated, and ineligibility is tested FIRST.** A runtime `/models`
227
+ list is a **serving** list, not a generative one. Measured in a sibling harness (2026-08-08): 6 of
228
+ 14 served entries could not review anything (embedding · reranker · OCR · safeguard), and because
229
+ the embeddings' ids matched no family prefix **each counted as its own family — satisfying the
230
+ family-diversity check while the panel was incapable**. Order is the invariant, not the list:
231
+ `GLM-OCR` matches both `ocr` and `glm`, `Qwen3-Embedding` matches both `embed` and `qwen`, so an
232
+ eligibility-first test lets them through silently. If nothing capable survives the filter, that is
233
+ rung 3 — **not** a smaller panel.
234
+
235
+ **Attribution: `panel(...)` records the families *requested*, not confirmed.** At least one
236
+ procurement path (an internal corp-gateway CLI) returns **no model attribution in its response**, so "3/3 responded"
237
+ does not establish which model contributed. Record request-fact and response-fact separately; do not
238
+ launder the former into the latter (`[[feedback_instrument_cannot_discriminate_hypotheses]]`).
239
+
240
+ Env non-determinism (CLI presence varies) → **never hard-fail the run** — but never *silent* either.
241
+ The two are different properties, and the prose this replaces conflated them: the degrade proceeds
242
+ (a commit is a reversible surface) **and** lands in the verdict with an ack. That is how this rung
243
+ and `CLAUDE.md` §Field-Harness gate ("never a silent same-family pass") are simultaneously true.
244
+ Distinguish the **unavailable-but-wanted** case (consent given, panel down → `DEGRADED_SINGLE_FAMILY`
245
+ + ack; for a *load-bearing corp* surface, fail-closed per `local_pmh_context.md` — note that file is
246
+ **pmh-repo-local and unreadable from an FH session**, so on this side the typed value + ack is the
247
+ enforceable layer) from the **declined** case above (chosen floor, first-class, no degrade framing).
207
248
 
208
249
  ## Step 7 — Output
209
250
 
@@ -221,7 +262,14 @@ at N per run (no loop).
221
262
 
222
263
  - Mechanical sidecar discovery returns the available panel. *[mandatory-pass]*
223
264
  - Family-diversity recruitment picks ≥1 cross-family verifier when present (family by model probe). *[mandatory-pass]*
224
- - Degrade ladder applied: local-only canary+note · none same-family note. *[mandatory-pass]*
265
+ - Degrade ladder applied AND its verdict emitted as a typed `crossfamily:` value in the Axes 2–3
266
+ marker (`panel(<families>)` | `declined` | `DEGRADED_SINGLE_FAMILY` | `DEGRADED_PANEL_UNUSED` |
267
+ `UNKNOWN`); the three degrade values carry substantive grounds on the same line. `single-family`
268
+ is NOT accepted on a load-bearing change — that block only runs there, so "decorrelation not
269
+ required" is a contradiction, and as a no-ack pass it was a free bypass of the lane.
270
+ *[mandatory-pass — enforced by pre-commit, fixtures `scripts/test_marker_crossfamily_lanes.sh`]*
271
+ - Panel members are review-capable: embedding/reranker/OCR/safeguard classes are excluded **before**
272
+ family-diversity is counted, never after. *[mandatory-pass — same fixtures, ordering anchor c4/c5]*
225
273
  - Paid recruit was per-run spend-gated (or `paid_auto` set); free local tier may auto-fire. *[mandatory-pass]*
226
274
  - Governor (CC) retains terminal verdict; every accepted sidecar finding is source-grounded. *[judged, pair: judge-robustness / phantom-quench back-trace]*
227
275
  - Marker records panel + families only with a captured sidecar transcript. *[mandatory-pass]*
@@ -170,6 +170,39 @@ Execution priority (behavioral):
170
170
 
171
171
  > **Detail**: See `SKILL_detail.md §Step3-Execution` — action preview example, FH plugin auto-install bash + error handling table + success format, agent-composer mapping update format — read when executing Step 3.
172
172
 
173
+ ### Step 3-D. Dispatch Standing Request — the one moment this is cheap to settle
174
+
175
+ FH/PMH treat isolated delegation as part of what the harness is, so **parallel dispatch is the
176
+ intended default for anyone running them**. It is not default *by assertion*: some runtimes ship a
177
+ line making unprompted dispatch conditional on a user request, and `CLAUDE.md §Agent Dispatch
178
+ Operation` will not read that condition as met unless a standing request is on disk. **A session with
179
+ no such record asks before every dispatch** — which is the friction this step exists to remove, once,
180
+ here, instead of in every future session.
181
+
182
+ So ask, and write the answer down. Three parts, all required — a record missing one is not a record,
183
+ and a session may never supply the missing part itself:
184
+
185
+ ```
186
+ ① the user's own words, quoted — ask them to say it, then quote what they said
187
+ ② a lease — THE USER picks the length. Do not suggest a default and
188
+ do not fill it in. Renewal needs a fresh utterance later
189
+ ③ a scope — which prohibition line this covers. Granting one never
190
+ grants another (agents ≠ workflows ≠ deep-research):
191
+ name each one the user actually intends
192
+ ```
193
+
194
+ Write it into `CLAUDE.local.md` (gitignored). **Declined is a valid, recorded answer** — write that
195
+ down too, so later sessions stop re-asking rather than reading silence as an unanswered question.
196
+
197
+ **Any mechanical settings change belongs here as well**, under the same consent: if enabling the
198
+ intended default needs a `settings.json` edit, propose it in this step with the diff shown, and apply
199
+ it only on approval. Do not change settings silently on the strength of the doctrine alone — the
200
+ doctrine says dispatch is the default posture, not that the wizard may edit a user's config unasked.
201
+
202
+ ⚠️ **The lease is honoured by reading, not by machinery.** `scripts/consent_registry_check.sh`
203
+ enforces leases for *registered consent classes*, and this grant is not one of those — say so when
204
+ recording it rather than implying an expiry alarm exists.
205
+
173
206
  ### Step 4. Acceleration Baseline Setup
174
207
 
175
208
  After executing approved items, install the automatic maintenance structure: zshrc hook (idempotent, preview-then-confirm) + sentinel initialization (per-project) + weekly-audit cadence (no cron — zshrc hook + session-start detection are the durable mechanisms).
@@ -526,31 +526,120 @@ fi
526
526
  # scripts/package_coverage_check.sh caught the omission — without it an npm-installed wizard hit
527
527
  # FileNotFoundError here and registered nothing while reporting success upstream).
528
528
  python3 - "$FH_DIR" <<'PY'
529
- import json, os, sys, collections
529
+ import json, os, sys, glob, collections, re, shutil
530
530
  hub = sys.argv[1]
531
- snippet = os.path.join(hub, "templates", "settings.SessionStart.snippet.json")
532
531
  target = os.path.join(hub, ".claude", "settings.json")
533
- entry = json.load(open(snippet))["project_settings_json"]["hooks"]["SessionStart"]
532
+
533
+ # DISCOVER every shipped snippet, do not name one. Hardcoding a single snippet path is why the
534
+ # compaction hooks shipped unwired while their own README recited the shipping-is-not-wiring lesson
535
+ # (high review, 2026-08-08 — and PreToolUse was already sitting in the same hole, so N=2).
536
+ # A new templates/settings.*.snippet.json is picked up with zero edits here: generation, not detection.
537
+ snippets = sorted(glob.glob(os.path.join(hub, "templates", "settings.*.snippet.json")))
538
+ if not snippets:
539
+ # exit 1, NOT 0. The pre-rewrite code raised FileNotFoundError here, and that loud failure is what
540
+ # made a broken npm `files[]` detectable — the comment above records it happening for real once.
541
+ # The first rewrite replaced it with a quiet exit 0, i.e. it deleted the detector while keeping the
542
+ # bug (high re-review 2026-08-08 #1). Zero snippets means zero hooks registered; that is never a
543
+ # success, and every other zero-registration path below already exits 1.
544
+ print("NO SNIPPETS under templates/settings.*.snippet.json — zero hooks registered.")
545
+ print(" → package `files[]` may have dropped templates/, or this is not an FH hub. NOT a success.")
546
+ raise SystemExit(1)
547
+
534
548
  d = collections.OrderedDict()
535
549
  if os.path.exists(target):
536
550
  d = json.load(open(target), object_pairs_hook=collections.OrderedDict)
537
- import shutil; shutil.copy2(target, target + ".prewizard") # back up before rewriting
551
+ shutil.copy2(target, target + ".prewizard") # back up before rewriting
538
552
  hooks = d.setdefault("hooks", collections.OrderedDict())
539
- # Merge at HOOK level, not group level. A group-level filter drops the whole group when a user's own
540
- # hook shares a group with the FH one — the common shape when someone hand-edits or appends to an
541
- # older wizard's output. (Cross-family review 2026-07-30 reproduced the loss: a group holding
542
- # [my_telemetry.sh, fh_session_load.sh] lost my_telemetry.sh entirely.)
543
- kept = []
544
- for g in hooks.get("SessionStart", []):
545
- survivors = [h for h in g.get("hooks", []) if "fh_node_check" not in h.get("command", "")]
546
- if survivors:
547
- g = dict(g); g["hooks"] = survivors; kept.append(g)
548
- hooks["SessionStart"] = kept + entry
553
+
554
+ def fh_keys(entry_groups):
555
+ """FH script basenames this snippet owns the replace key is DERIVED from the snippet's own
556
+ commands, never hardcoded, so it cannot drift from what is actually being installed."""
557
+ # Key on the snippet's own PATH FORM (scripts/<name>.sh), not a bare basename. A bare basename
558
+ # made the survivor filter delete a user's own hook that merely shared a filename — e.g. their
559
+ # ~/tools/compaction_probe.sh which is the exact loss the hook-level merge exists to prevent,
560
+ # reintroduced through key derivation (high re-review 2026-08-08 #2).
561
+ keys = set()
562
+ for g in entry_groups:
563
+ for h in g.get("hooks", []):
564
+ for m in re.findall(r'((?:[A-Za-z0-9_\-]+/)*scripts/[A-Za-z0-9_\-]+\.sh)', h.get("command", "")):
565
+ keys.add("scripts/" + m.rsplit("scripts/", 1)[1])
566
+ return keys
567
+
568
+ registered, bad_schema, intended = [], [], {}
569
+ for snip in snippets:
570
+ try:
571
+ blob = json.load(open(snip))
572
+ except Exception as e:
573
+ print("SKIP (unparsable):", os.path.basename(snip), e); bad_schema.append(snip); continue
574
+ proj = (blob.get("project_settings_json") or {}).get("hooks") or {}
575
+ if not proj:
576
+ # settings_local_json_MODE_D_ONLY entries carry private paths — never auto-written here.
577
+ print("SKIP (no project_settings_json):", os.path.basename(snip)); bad_schema.append(snip); continue
578
+ for event, entry in proj.items():
579
+ # Shape-validate before touching the user's file. A valid-JSON / invalid-SCHEMA snippet used
580
+ # to be written anyway and reported as success, then detonate on the NEXT run inside the
581
+ # survivor filter — far from the snippet that caused it (lanes BS-*, LT-*).
582
+ if not isinstance(entry, list) or not all(
583
+ isinstance(g, dict) and isinstance(g.get("hooks"), list) for g in entry):
584
+ print("SKIP (bad schema):", os.path.basename(snip), event); bad_schema.append(snip); continue
585
+ keys = fh_keys(entry)
586
+ if not keys:
587
+ print("SKIP (no FH script in commands):", os.path.basename(snip), event)
588
+ bad_schema.append(snip); continue
589
+ intended[event] = intended.get(event, set()) | keys
590
+ # Merge at HOOK level, not group level. A group-level filter drops the whole group when a
591
+ # user's own hook shares a group with the FH one (cross-family review 2026-07-30 reproduced
592
+ # the loss: [my_telemetry.sh, fh_session_load.sh] lost my_telemetry.sh entirely).
593
+ kept = []
594
+ for g in hooks.get(event, []):
595
+ survivors = [h for h in g.get("hooks", [])
596
+ if not any(k in h.get("command", "") for k in keys)]
597
+ if survivors:
598
+ g = dict(g); g["hooks"] = survivors; kept.append(g)
599
+ hooks[event] = kept + entry
600
+ registered.append(f"{event}({','.join(sorted(keys)) or '?'})")
601
+
549
602
  os.makedirs(os.path.dirname(target), exist_ok=True)
550
603
  with open(target, "w") as fh:
551
604
  json.dump(d, fh, indent=2, ensure_ascii=False); fh.write("\n")
552
- print("node-check SessionStart hook registered ->", target)
605
+ # RE-READ WHAT WE JUST WROTE. The success message used to be unconditional — it never checked the
606
+ # serialized result, so a snippet that failed to register still printed "registered ->". Verify, then
607
+ # claim (`[[feedback_gate_verification_must_execute]]`).
608
+ written = json.load(open(target))
609
+ missing = []
610
+ for event, keys in intended.items():
611
+ blob = json.dumps(written.get("hooks", {}).get(event, []))
612
+ for k in sorted(keys):
613
+ if k not in blob:
614
+ missing.append(f"{event}:{k}")
615
+ for r in registered: print(" hook registered ->", r)
616
+ print("snippets scanned:", len(snippets), "· hook entries registered:", len(registered),
617
+ "· skipped:", len(bad_schema), "->", target)
618
+ if missing:
619
+ print("FAILED to register:", ", ".join(missing)); raise SystemExit(1)
620
+ # FAIL CLOSED on any skipped snippet. The good ones are already written (a malformed sibling must not
621
+ # cost you the working hooks), but the RUN exits non-zero — an installer that skips a snippet and
622
+ # reports success leaves hooks unwired, which is the exact defect this rewrite exists to close.
623
+ # Lanes MG-N1..N4 caught this: the first draft skipped-and-exited-0 = fail-open.
624
+ if bad_schema:
625
+ print("SKIPPED snippet(s):", ", ".join(sorted({os.path.basename(b) for b in bad_schema})))
626
+ print(" → non-zero exit on purpose: a skipped snippet is unregistered hooks, not a warning.")
627
+ raise SystemExit(1)
628
+ if not registered:
629
+ print("no hook entries registered — nothing to claim"); raise SystemExit(1)
553
630
  PY
631
+ # CONSUME the merge block's exit status. It raises SystemExit(1) on a skipped/failed registration,
632
+ # and the surrounding bash has no `set -e` — so without this check the wizard sailed past a failed
633
+ # registration and §Step5 still printed success, which is the exact "reports success while unwired"
634
+ # shape the fail-closed direction was added to prevent (high re-review 2026-08-08 #6).
635
+ # The failure is SURFACED, not swallowed: the operator sees which snippet did not register.
636
+ _MERGE_RC=$?
637
+ if [ "$_MERGE_RC" -ne 0 ]; then
638
+ echo "🟥 hook registration FAILED (exit $_MERGE_RC) — one or more snippets are UNREGISTERED."
639
+ echo " Do not read the completion report below as 'hooks are wired'. Fix the snippet and re-run"
640
+ echo " this step; the rest of the wizard continues so the remaining setup is not lost."
641
+ WIZARD_HOOK_REG_FAILED=1
642
+ fi
554
643
  chmod +x "$FH_DIR/scripts/fh_node_check.sh" 2>/dev/null
555
644
  # VERIFY against a THROWAWAY state file (FH_NODE_STATE). Verifying against the real state would
556
645
  # consume the user's one-shot event report, so their actual first session goes quiet and the notice
@@ -43,6 +43,25 @@ TODAY="$(date +%Y-%m-%d)"
43
43
  _status() { [ -f "$STATUS_F" ] && cat "$STATUS_F" || echo "step-0"; }
44
44
  _stamp() { printf '%s\n' "$1" > "$STATUS_F"; }
45
45
 
46
+ # ── 순서 증인 (ship_readiness_gate §② P1) ────────────────────────────────────
47
+ # 워크스페이스는 `tracks/**` 라 gitignored 다. 그래서 게이트 아티팩트의 **해시만**
48
+ # 추적되는 원장에 적어 커밋한다 — **본문** 유출 0(단 slug·파일명·시각·해시는 남는다),
49
+ # 순서는 로컬 커밋 그래프가 증언(되쓰기 방지는 push 후 서버 ruleset 이 준다).
50
+ # 기록 실패는 **런을 막지 않는다**(KILL 런까지 세우는 건 과차단이고, 과차단은
51
+ # override 를 습관화시킨다). 대신 증인 부재를 **말한다** — 조용히 넘어가지 않는다.
52
+ _WITNESS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/chamber_witness.sh"
53
+ _witness_record() { # $1 = artifact path
54
+ if [ ! -x "$_WITNESS" ] && [ ! -f "$_WITNESS" ]; then
55
+ echo " ⚠️ 순서 증인 미기록 — chamber_witness.sh 부재 (이 런은 ② 승격 근거로 못 쓴다)"
56
+ return 0
57
+ fi
58
+ if bash "$_WITNESS" record "$SLUG" "$1" >/dev/null 2>&1; then
59
+ echo " ↳ 순서 증인: $(basename "$1") 해시 기록됨 — **커밋해야** 증인이 된다"
60
+ else
61
+ echo " ⚠️ 순서 증인 기록 실패: $(basename "$1") (증인 없음 — 미측정이지 0이 아니다)"
62
+ fi
63
+ }
64
+
46
65
  if [ "$CMD" = "status" ]; then
47
66
  echo "chamber run '$SLUG' → STATUS: $(_status)"
48
67
  [ -d "$WS" ] && ls -1 "$WS" 2>/dev/null | sed 's/^/ /'
@@ -85,9 +104,18 @@ fi
85
104
  # gate (a) is "artifact exists WITH real content", not just "placeholder removed" (Axis-2 LOW-6): require
86
105
  # at least one non-empty numbered success condition so a gutted INTENT.md doesn't pass.
87
106
  if ! awk '/^## Success conditions/{f=1;next} /^## /{f=0} f && /^[0-9]+\.[[:space:]]*[^[:space:]]/{print; exit}' "$WS/INTENT.md" | grep -q .; then
88
- echo " ⛔ step 2 BLOCKED: $WS/INTENT.md has no filled success condition (need a numbered line with content), re-run."; exit 1
107
+ # 계약을 **에러 메시지가 가르친다.** 이 형식은 CHAMBER_RUN_SKELETON.md 글자도 없어서,
108
+ # 스켈레톤만 읽고 쓴 INTENT 가 반려됐다 — gate-locality(계약이 게이트 구현에만 산다).
109
+ # 같은 런에서 step 6 도 같은 이유로 막혔다(N=2). 문서는 드리프트하지만 에러는 코드와 같이 산다.
110
+ echo " ⛔ step 2 BLOCKED: $WS/INTENT.md has no filled success condition."
111
+ echo " Expected — an English heading, then NUMBERED lines with content:"
112
+ echo " ## Success conditions"
113
+ echo " 1. <condition> · [measured|mandatory-pass|judged]"
114
+ echo " 2. ..."
115
+ echo " A table alone does NOT satisfy this check. Re-run after adding it."; exit 1
89
116
  fi
90
117
  _stamp "step-2-done"; echo " ✓ step 2: INTENT.md present"
118
+ _witness_record "$WS/INTENT.md"
91
119
 
92
120
  # STEP 3 — budget-entry gate (G2). Cannot invoke goal-quench from bash; MECHANICALLY require a recorded
93
121
  # estimate before any (expensive) simulation runs. No ESTIMATE = no run — that IS the entry cap.
@@ -106,6 +134,7 @@ if grep -qE '^ESTIMATE:[[:space:]]*<' "$WS/BUDGET.md" || ! grep -qE '^ESTIMATE:[
106
134
  echo " ⛔ step 3 BLOCKED: $WS/BUDGET.md ESTIMATE is empty/placeholder (G2 entry cap), then re-run."; exit 1
107
135
  fi
108
136
  _stamp "step-3-done"; echo " ✓ step 3: budget ESTIMATE recorded (entry cap satisfied)"
137
+ _witness_record "$WS/BUDGET.md"
109
138
 
110
139
  # STEP 4 — persona simulation (G1-forcing gate). Dispatch is human/Claude-side (bash can't spawn Agents);
111
140
  # gate on the ≥3 blind persona artifact. Isolation is the mechanism — the runner enforces the artifact, not the spawn.
@@ -126,6 +155,7 @@ if [ "$NPERS" -lt 3 ]; then
126
155
  echo " ⛔ step 4 BLOCKED: SIM_NOTES.md has $NPERS/3 DISTINCT blind persona sections (need all of beginner + main-player + challenger)."; exit 1
127
156
  fi
128
157
  _stamp "step-4-done"; echo " ✓ step 4: $NPERS blind persona sections present (isolation-gate satisfied)"
158
+ _witness_record "$WS/SIM_NOTES.md"
129
159
 
130
160
  # STEP 5 — Emission Gate. Require a VERDICT: EMIT | PARTIAL-EMIT | KILL.
131
161
  if [ ! -f "$WS/EMISSION_VERDICT.md" ]; then
@@ -149,10 +179,36 @@ if [ -z "$VERDICT" ]; then
149
179
  echo " ⛔ step 5 BLOCKED: no VERDICT (EMIT|PARTIAL-EMIT|KILL) found in $WS/EMISSION_VERDICT.md, re-run."; exit 1
150
180
  fi
151
181
  _stamp "step-5-done"; echo " ✓ step 5: Emission Gate verdict = $VERDICT"
182
+ _witness_record "$WS/EMISSION_VERDICT.md"
183
+ # 순서 판정을 **여기서 인쇄한다.** verdict 가 나온 자리가 "게이트가 결과를 알기 전에
184
+ # 돌았는가" 를 물어야 하는 유일한 자리다. 차단하지 않는 이유: KILL 런까지 막으면
185
+ # 과차단이고, 과차단은 override 를 습관화시켜 게이트 전체를 무장해제한다.
186
+ # 대신 **EMIT 일 때는 승격 사용 가부를 명시적으로 말한다** — 침묵이 곧 승인으로
187
+ # 읽히는 것이 이 채널이 존재하는 이유다.
188
+ if [ -f "$_WITNESS" ]; then
189
+ echo " ── 순서 증인 판정 ──"
190
+ bash "$_WITNESS" verify "$SLUG" "$WS" 2>&1 | sed 's/^/ /'
191
+ _wrc="${PIPESTATUS[0]}"
192
+ if [ "$VERDICT" = "EMIT" ] || [ "$VERDICT" = "PARTIAL-EMIT" ]; then
193
+ if [ "${_wrc:-9}" = "0" ]; then
194
+ echo " ✅ 이 EMIT 은 순서 증인을 가진다 — identity ② 승격 근거로 사용 가능"
195
+ else
196
+ echo " 🟥 이 EMIT 에는 순서 증인이 없다(rc=${_wrc:-?}) — identity ② 승격 근거로 쓰지 마라."
197
+ echo " (원장 해시를 커밋한 뒤 재실행하면 증인이 성립한다)"
198
+ # ★ 출력만 하고 exit 0 으로 끝내면, stdout 을 안 읽는 호출자에게는 증인 없는 EMIT 도
199
+ # **성공**이다(cross-family F8). EMIT 은 승격 주장이므로 기계적으로 구분되어야 한다.
200
+ WITNESS_EMIT_FAIL=1
201
+ fi
202
+ fi
203
+ fi
152
204
 
153
205
  # STEP 6 — actual cost / carry-forward record.
154
206
  if grep -qE '^ACTUAL:[[:space:]]*<' "$WS/BUDGET.md" || ! grep -qE '^ACTUAL:[[:space:]]*\S' "$WS/BUDGET.md"; then
155
- echo " ⛔ step 6 BLOCKED: record ACTUAL cost in $WS/BUDGET.md (actual-vs-estimate calibration), re-run."; exit 1
207
+ echo " ⛔ step 6 BLOCKED: record ACTUAL cost in $WS/BUDGET.md (actual-vs-estimate calibration)."
208
+ echo " Expected — a LINE PREFIX, not a heading:"
209
+ echo " ACTUAL: <number/summary> ← this form is checked"
210
+ echo " ## ACTUAL ← a heading does NOT satisfy it"
211
+ echo " Re-run after adding the line."; exit 1
156
212
  fi
157
213
  _stamp "step-6-done"; echo " ✓ step 6: actual cost recorded"
158
214
 
@@ -181,4 +237,10 @@ case "$VERDICT" in
181
237
  echo " No emit. Workspace stays as the evidence record; seen-filter will skip re-listing it." ;;
182
238
  esac
183
239
  echo "chamber run '$SLUG' COMPLETE (STATUS: step-7-done, verdict $VERDICT)."
240
+ # EMIT/PARTIAL-EMIT 인데 순서 증인이 없으면 **비영 종료**한다. KILL 은 영향 없다 —
241
+ # KILL 까지 실패로 세우면 과차단이고, 과차단은 override 를 습관화시킨다.
242
+ if [ "${WITNESS_EMIT_FAIL:-0}" = "1" ]; then
243
+ echo "⚠️ exit 3 — EMIT 이지만 순서 증인 미성립. 승격 주장에 쓰지 마라."
244
+ exit 3
245
+ fi
184
246
  exit 0