@chrono-meta/fh-gate 1.4.97 → 1.4.98

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 (38) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CATALOG.md +19 -0
  3. package/CHEATSHEET.md +9 -1
  4. package/CLAUDE.md +28 -2
  5. package/README.ja.md +10 -0
  6. package/README.ko.md +10 -0
  7. package/README.md +11 -0
  8. package/README.zh.md +10 -0
  9. package/docs/pillars.svg +3 -7
  10. package/knowledge/shared/harness-core/fh_ecosystem_positioning.md +2 -0
  11. package/knowledge/shared/harness-core/fh_global_positioning_and_distribution_roadmap.md +136 -0
  12. package/knowledge/shared/harness-core/fh_three_layer_canon.md +20 -0
  13. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +215 -2
  14. package/knowledge/shared/harness-core/ship_readiness_gate.md +112 -0
  15. package/knowledge/shared/learnings/subagent_invocations_log.yaml +55 -0
  16. package/package.json +5 -1
  17. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  18. package/plugins/fh-commons/skills/ko-tech-writer/SKILL.md +63 -12
  19. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  20. package/plugins/fh-meta/CHANGELOG.md +65 -0
  21. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +30 -0
  22. package/scripts/degrade_direction_scan.sh +10 -1
  23. package/scripts/digest_landing_check.sh +20 -4
  24. package/scripts/fh_node_check.sh +60 -1
  25. package/scripts/fh_session_load.sh +22 -2
  26. package/scripts/frontier_digest_autopilot.sh +229 -0
  27. package/scripts/lane_runner_check.sh +237 -25
  28. package/scripts/package_coverage_check.sh +17 -0
  29. package/scripts/postinstall_notice.js +34 -0
  30. package/scripts/selfcheck.sh +111 -5
  31. package/scripts/test_degrade_scan_shell_probes.sh +75 -0
  32. package/scripts/test_field_canon_lanes.sh +29 -5
  33. package/scripts/test_lane_runner_lanes.sh +221 -0
  34. package/scripts/test_stale_clone_guard_lanes.sh +21 -7
  35. package/scripts/test_version_lockstep_lanes.sh +62 -0
  36. package/scripts/version_lockstep_check.sh +143 -1
  37. package/templates/.git-hooks/pre-commit +22 -1
  38. package/templates/degrade_direction_scan.sh +10 -1
@@ -0,0 +1,229 @@
1
+ #!/bin/bash
2
+ # frontier_digest_autopilot.sh — daily digest + autonomous improvement pipeline, PR-only.
3
+ #
4
+ # WHY: frontier-digest itself has run unattended (launchd) since 2026-06 — collecting signal
5
+ # needs no approval. Turning a signal into an actual FH change was, until now, always a session
6
+ # the operator had to start by hand. Operator decision (2026-08-15): push automation one stage
7
+ # further — digest → persona-innovator → harvest-loop → 4-axis gate → PR, entirely unattended —
8
+ # but stop at the PR. Merge stays a human decision, per CLAUDE.md §AI Contribution Model ("AI does
9
+ # not commit directly to shared repositories") and §PR-Only Policy on main. This script is the
10
+ # proposal-only half of that split, not an exception to it.
11
+ #
12
+ # THIS IS STAGE 2 OF THE EXISTING DAILY JOB, NOT A SEPARATE CRON:
13
+ # Stage 1 (unchanged) = frontier_digest_daily.sh — collects the digest, hardened with its own
14
+ # retry/watchdog/lock (see that file's header). This script calls it first, then only proceeds
15
+ # to Stage 2 if Stage 1 actually produced today's digest.
16
+ # Stage 2 (new) = a single headless `claude -p` invocation that reads the digest and, ONLY IF a
17
+ # concrete change survives harvest-loop's own 4-axis gate, opens a PR. No gate-passing candidate
18
+ # → no PR, no forced busywork (operator's explicit threshold choice: "harvest-loop이 실제 게이트를
19
+ # 통과한 때만"). This mirrors the digest's own honesty rule (absence ≠ zero, but also: a thin
20
+ # signal is not manufactured into a change just to have shipped something today).
21
+ #
22
+ # BACK-END SHIPPING DOCTRINE (operator, 2026-08-15): "개발의 앞단-영혼심기, 중간단-탈상관 가속화,
23
+ # 뒷단 출하전-4단검증 및 하네스오너 리뷰" — the back end of shipping is 4-axis verification AND a
24
+ # standpoint-axis review, not 4-axis alone. CORRECTED same session (an earlier draft of this
25
+ # header named fh-meta:hub-cc-pr-reviewer here — wrong skill, caught by the operator: "내가 말한건
26
+ # 하네스오너(너) 아니라 그 하네스에 에이전트가 들어가서 그 입장에서 리뷰한다는거야"). "하네스오너
27
+ # 리뷰" is NOT the human operator (that gate is merge, unconditionally human, unchanged) NOR
28
+ # hub-cc-pr-reviewer (which checks FH's diff against FH's OWN conventions — same-repo
29
+ # self-consistency, a different question). It is the STANDPOINT AXIS
30
+ # (`knowledge/shared/harness-core/field_verdict_crossfamily_gate.md §7`, the mechanism behind the
31
+ # if(kakao)26 keynote's p15 "(c) 탈상관의 확장" slide — "계열을 늘려도 못 잡는 결함이 있습니다,
32
+ # 입장을 바꾸면 보입니다"): an agent actually running the diff's effect from ANOTHER harness's own
33
+ # repo/standpoint, which is orthogonal to family diversity and catches a documented, distinct class
34
+ # of defect family diversity alone does not. Wired into the Stage-2 prompt as two back-end
35
+ # checkpoints beyond the ordinary 4-axis gate: (1) an irreversible/load-bearing-surface diff holds —
36
+ # no PR, branch+signal only, operator decides whether a PR should even exist; (2) a diff that meets
37
+ # §7's own BEHAVIORAL trigger (alters another harness's actual behavior/gate-outcome/interaction
38
+ # contract — not merely a file-class match) gets a standpoint-axis pass, recorded on the closed
39
+ # tier1/tier2/tier2b/tier3/not-applicable/DEGRADED_* enum, before a PR opens (or folded into the
40
+ # held signal, if both checkpoints fire). Most ordinary digest-driven self-improvement will
41
+ # correctly land on `not-applicable` — that is the expected common case, not a shortfall.
42
+ # SAFETY RAILS (why each exists) — hardened by fh-meta:challenger 2026-08-15 [S×2 fixed]:
43
+ # ① git status must be clean before Stage 2 starts. A shared checkout may have another session's
44
+ # in-progress uncommitted work — Stage 2 must never commit, stash, or otherwise touch state
45
+ # it did not create. Dirty tree → skip, log why, leave everything untouched.
46
+ # ② skip if a LIVE PEER SESSION CLAIM exists at all (branch_claim.sh show), not just if the tree
47
+ # is dirty. FIXED 2026-08-15: an earlier revision's header claimed branch_claim.sh protection
48
+ # but never actually called it — a human could have a clean-but-mid-thought tree (branch
49
+ # switched, nothing edited yet) and this script would have force-checked-out their branch out
50
+ # from under them in the EXIT trap with zero warning (challenger [S], confirmed real: the
51
+ # 45-min Stage 2 window has no lock held the whole time — this check narrows, does not close,
52
+ # that TOCTOU; a peer session starting mid-run is a named residual, not fully closed). Any
53
+ # live peer at entry → skip Stage 2 entirely, log it, touch nothing.
54
+ # ③ skip if an autopilot PR is already open (branch prefix `auto/digest-improve-`) — one at a
55
+ # time, so a quiet week of "nothing concrete" backlog does not pile into an open-PR queue the
56
+ # operator has to triage. FIXED 2026-08-15: an earlier revision used
57
+ # `gh pr list --search 'head:auto/digest-improve-'` — GitHub's `head:` search qualifier is an
58
+ # EXACT branch-name match, not a prefix match, against branches that are actually named
59
+ # `auto/digest-improve-<full-date>` — the check would almost always find nothing and silently
60
+ # let PRs pile up unbounded (challenger [S], confirmed: verified via a live gh pr list --json
61
+ # call against this repo, see verify_next in edit_manifest.yaml). Fixed to filter headRefName
62
+ # client-side with jq's startswith(), which cannot have this exact-vs-prefix mismatch.
63
+ # ④ working tree is restored to the pre-run branch on exit (trap) ONLY IF still no live peer
64
+ # claim differs from where we started — re-checked at exit, not just at entry, because rail ②
65
+ # only guards the START of the window. If a peer claim appeared during the run, the trap logs
66
+ # a warning and leaves the tree where Stage 2 left it rather than yanking a now-live session's
67
+ # ground (mirrors branch_claim.sh's own printed guidance: "먼저 한 줄 알려라" — an unattended
68
+ # script cannot tell them first, so the fail-safe direction is: don't act, not: act anyway).
69
+ # ⑤ Stage 2 runs with --permission-mode bypassPermissions (no human present to answer a Bash
70
+ # approval prompt — acceptEdits, used by Stage 1, only covers Write/Edit, not git/gh). The
71
+ # actual floor is NOT this permission mode — it is the git hooks (pre-commit 4-axis gate,
72
+ # pre-push main-block, pre-push force-push block), which fire regardless of CC's own
73
+ # permission layer, plus merge being structurally impossible (this script and its prompt never
74
+ # invoke `gh pr merge`). NAMED RESIDUAL (challenger [S], not mitigated in v1 — accepted, not
75
+ # silently assumed covered): bypassPermissions removes CC's own approval layer for
76
+ # EVERYTHING, not just git in this repo — nothing here technically stops the subprocess from
77
+ # touching files elsewhere under this OS user (~/.ssh, other repos, arbitrary rm) or making
78
+ # outbound network calls beyond the git hooks' scope. The prompt instructs it not to; that is
79
+ # an instruction, not an enforcement boundary. Accepted for v1 because Stage 2 only fires after
80
+ # rails ①–③ pass and the blast radius is bounded by what a headless single-purpose prompt
81
+ # would plausibly do, not by a technical sandbox — revisit if this is ever wired to a more
82
+ # capability-rich prompt.
83
+ # ⑥ NAMED RESIDUAL — prompt injection via digest content (challenger [S], unmitigated): the
84
+ # digest ingests HN/arXiv content verbatim, and Stage 2 reads that digest under
85
+ # bypassPermissions with git-push/PR-create ability. Adversarial text embedded in fetched
86
+ # content could in principle steer the unattended session toward a misleading PR. No
87
+ # sanitization pass exists. Mitigant in practice: the PR is proposal-only (never merges
88
+ # itself) and still has to pass a real 4-axis gate with real challenger evidence — but that
89
+ # gate is text-based judgment, not a hard technical filter against this class.
90
+ # ⑦ NAMED RESIDUAL — log/secret exposure (challenger [A]): stdout+stderr of the Stage 2 claude
91
+ # process is captured verbatim into tracks/_meta/logs/, which a Stop hook may sync to a private
92
+ # companion store unattended, if one is configured. Any secret the agent happens to touch
93
+ # during the run propagates into a second store with no redaction pass. Accepted for v1 (same
94
+ # log-capture pattern as
95
+ # Stage 1's frontier_digest_daily.sh, already running this way since 2026-06) — revisit if
96
+ # Stage 2 is ever given credentials Stage 1 never touched.
97
+ #
98
+ # WHAT THIS DOES NOT DO: pick a "topic of the day" to force a contribution, retry on a NOT-CONVERGED
99
+ # outcome, touch anything if Stage 1 (digest) itself failed today, or act while any live peer
100
+ # session is claimed in this checkout.
101
+
102
+ FH_DIR="${FD_FH_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
103
+ CLAUDE_BIN="${FD_CLAUDE_BIN:-$(command -v claude || echo "${HOME}/.local/bin/claude")}"
104
+ TODAY=$(date +%Y_%m_%d)
105
+ HUMAN_DATE=$(date +%Y-%m-%d)
106
+ LOG_DIR="${FH_DIR}/tracks/_meta/logs"
107
+ LOG_FILE="${LOG_DIR}/frontier_digest_autopilot_${TODAY}.log"
108
+ mkdir -p "$LOG_DIR"
109
+ _log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"; }
110
+
111
+ # ── Stage 1: unchanged digest run ───────────────────────────────────────────
112
+ bash "${FH_DIR}/scripts/frontier_digest_daily.sh"
113
+ DIGEST_EXIT=$?
114
+ if [ "$DIGEST_EXIT" -ne 0 ]; then
115
+ _log "Stage 1 (digest) failed (exit ${DIGEST_EXIT}) — Stage 2 skipped, nothing to act on."
116
+ exit "$DIGEST_EXIT"
117
+ fi
118
+ _log "Stage 1 (digest) OK for ${HUMAN_DATE}."
119
+
120
+ cd "$FH_DIR" || { _log "cannot cd to \$FH_DIR ($FH_DIR)"; exit 1; }
121
+
122
+ # ── Stage 2 preconditions (rails ① ② ③) ─────────────────────────────────────
123
+ if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
124
+ _log "Stage 2 SKIPPED: working tree is dirty (another session's uncommitted work may be live) — never touching it unattended."
125
+ exit 0
126
+ fi
127
+
128
+ # rail ②: any LIVE peer claim at all → skip. branch_claim.sh's own "show" lists each recorded
129
+ # claim with a live/dead tag (dead = the recording session's PID no longer exists). We do not try
130
+ # to be clever about "does the peer's branch differ from ours" here — any live peer means a human
131
+ # is plausibly mid-thought in this exact checkout right now, full stop.
132
+ if [ -x "${FH_DIR}/scripts/branch_claim.sh" ]; then
133
+ if bash "${FH_DIR}/scripts/branch_claim.sh" show 2>/dev/null | grep -q '(live)'; then
134
+ _log "Stage 2 SKIPPED: a live peer session claim exists in this checkout — not touching HEAD/committing while a human may be mid-thought here. Will try again on the next scheduled run."
135
+ exit 0
136
+ fi
137
+ else
138
+ _log "Stage 2 SKIPPED: scripts/branch_claim.sh not found or not executable — cannot verify no live peer session, so refusing to touch the shared checkout unattended."
139
+ exit 0
140
+ fi
141
+
142
+ # rail ③: fixed 2026-08-15 — `gh pr list --search 'head:...'` is an EXACT-match qualifier, not a
143
+ # prefix match, so it silently never matched our date-suffixed branch names (challenger [S]).
144
+ # Filter client-side instead: list open PRs' head branch names, test the prefix ourselves.
145
+ if ! command -v gh >/dev/null 2>&1; then
146
+ _log "Stage 2 SKIPPED: gh CLI not found — cannot check for an existing open autopilot PR, and cannot open one."
147
+ exit 0
148
+ fi
149
+ OPEN_AUTO_COUNT="$(gh pr list --state open --json headRefName \
150
+ -q '[.[] | select(.headRefName | startswith("auto/digest-improve-"))] | length' 2>/dev/null)"
151
+ case "$OPEN_AUTO_COUNT" in
152
+ ''|*[!0-9]*)
153
+ _log "Stage 2 SKIPPED: could not measure open-autopilot-PR count (gh call failed/unparseable) — not measurable is not the same as zero, refusing to risk piling up a PR unseen."
154
+ exit 0
155
+ ;;
156
+ 0) ;; # clear to proceed
157
+ *)
158
+ _log "Stage 2 SKIPPED: ${OPEN_AUTO_COUNT} autopilot PR(s) already open (branch prefix auto/digest-improve-) — one at a time, operator has not triaged it yet."
159
+ exit 0
160
+ ;;
161
+ esac
162
+
163
+ ORIG_BRANCH="$(git symbolic-ref --short HEAD 2>/dev/null || echo main)"
164
+ # rail ④: re-check for a live peer at exit time too — a peer starting mid-run must not get its
165
+ # ground yanked by our restorative checkout. This narrows, not closes, the TOCTOU window (see
166
+ # header rail ② note) — a peer appearing in the few seconds between this check and the actual
167
+ # `git checkout` call below is a residual, not claimed as fully closed.
168
+ _fh_autopilot_exit_trap() {
169
+ if [ -x "${FH_DIR}/scripts/branch_claim.sh" ] \
170
+ && bash "${FH_DIR}/scripts/branch_claim.sh" show 2>/dev/null | grep -q '(live)'; then
171
+ _log "EXIT: a live peer session claim appeared during this run — leaving the tree as Stage 2 left it, NOT force-checking-out ${ORIG_BRANCH} (would yank a now-live session's ground)."
172
+ else
173
+ _log "EXIT: restoring working tree to ${ORIG_BRANCH}"
174
+ git checkout --quiet "$ORIG_BRANCH" 2>/dev/null
175
+ fi
176
+ }
177
+ trap _fh_autopilot_exit_trap EXIT
178
+
179
+ ATTEMPT_TIMEOUT_SECS="${FD_AUTOPILOT_TIMEOUT_SECS:-2700}"
180
+
181
+ # bash 3.2 (macOS default, confirmed this machine) has a known parser bug: a heredoc directly
182
+ # inside a `$(...)` command substitution mis-tracks quote balance once the heredoc BODY contains an
183
+ # unescaped apostrophe (e.g. "Today's") — `bash -n` fails with "unexpected EOF while looking for
184
+ # matching `'". Writing the heredoc to a plain file first, then capturing that file's content via
185
+ # `$(cat ...)` (a command substitution with NO heredoc inside it), sidesteps the bug entirely.
186
+ PROMPT_FILE="${LOG_DIR}/.autopilot_prompt_${TODAY}.txt"
187
+ cat > "$PROMPT_FILE" <<PROMPT_EOF
188
+ [automated-run: launchd, unattended — no human present to answer prompts] Today's frontier digest just landed at tracks/_meta/frontier_digest_${TODAY}.md (or the companion-store mirror if this node is not the digest runner). Run the digest -> persona-innovator (Mode F, full: internal gap scan + external frontier) -> harvest-loop pipeline against it, exactly as documented in CLAUDE.md and the relevant SKILL.md files - do not shortcut the 4-axis gate (edit-manifest entry, a real fh-meta:challenger adversarial pass with actual evidence, not a fabricated marker).
189
+
190
+ Threshold (operator's explicit choice, 2026-08-15): only act if a CONCRETE, well-scoped candidate survives harvest-loop and actually passes the 4-axis gate as a real committable diff. If nothing concrete surfaces today, do nothing further - do not manufacture a change to have shipped something. A quiet day is a correct outcome, not a failure.
191
+
192
+ BACK-END CHECKPOINTS (operator instruction, 2026-08-15 - required steps on this pipeline's shipping stage, not suggestions; this is the "뒷단 출하전 = 4축검증 + 하네스오너 리뷰" doctrine, applied to an unattended run):
193
+
194
+ 1. IRREVERSIBILITY CHECK: does the gate-passing diff touch an IRREVERSIBLE surface per CLAUDE.md's Irreversibility Gates section (a publish/delete/history-rewrite path) OR a load-bearing surface per the Field-Harness Load-Bearing Change Gate (a verdict/gate enum or exit code, an irreversible-op path, or a safety invariant such as a floor, verdict-binding, or a pre-push/pre-commit hook)? This includes changes to the git hooks themselves (templates/.git-hooks/*), scripts a hook calls, or anything in the Irreversibility Gates / Destructive-Op Gate / Pre-Publish Gate sections of CLAUDE.md.
195
+ - If YES: do NOT run gh pr create at all. Create the branch, commit, push it (so the work is not lost), write a signal file at tracks/_meta/fh_signal_${HUMAN_DATE}_autopilot-irreversible-hold.md with frontmatter 'status: NEEDS-OWNER-REVIEW' naming the branch, exactly which surface it touches and why, and what the change does - then stop. No PR exists yet; the operator decides whether one should even be opened.
196
+
197
+ 2. STANDPOINT AXIS (knowledge/shared/harness-core/field_verdict_crossfamily_gate.md §7 - read it before applying this step, this summary is not the full spec): does the diff alter ANOTHER harness's actual behavior, gate outcome, or interaction contract (not merely touch a path that happens to be synced elsewhere - the trigger is behavioral, not file-class)? Most ordinary FH self-improvement from a digest signal will correctly land on standpoint: not-applicable - that is a correct, expected answer, not a shortfall to fix. This is a genuinely different axis from "harness-owner reviewing FH's own conventions" - it means an agent actually running the diff's effect FROM the standpoint of the OTHER harness's own repo (family diversity alone does not catch this: the mechanism behind the if(kakao)26 keynote's p15 slide "(c) 탈상관의 확장" - "계열을 늘려도 못 잡는 결함이 있습니다, 입장을 바꾸면 보입니다" - §7 is built on three field incidents where full cross-family review missed a defect that only one execution-from-the-target's-own-repo caught).
198
+ - If NOT applicable (the common case): record standpoint: not-applicable in the marker/signal, state briefly what was checked (per the spec's own discipline - asserting non-applicability without naming what was checked is indistinguishable from not having looked at all), and move on.
199
+ - If applicable: check whether a local clone of the target harness exists on this machine (e.g. under the parent of ${FH_DIR} - sibling directories like pmh-dev, qasp-dev, or similar). If one exists, run the change against THAT repo's own content/rules from its own standpoint (tier2) - this must happen BEFORE any push or gh pr create for THIS diff, same non-negotiable pre-push timing as the irreversibility check above and for the identical reason (PR #370, 2026-08-14: a post-PR standpoint review still caught 2 residency leaks that had already sat in public view before the fix - public exposure is effectively irreversible, so this cannot run after the diff is visible). If it finds anything, fix it in the local diff and re-run until clean - only then push and open the PR (or, if step 1 already routed to hold, fold the finding into that signal file instead). If no local clone of the target harness is reachable, record standpoint: DEGRADED_NO_TARGET_ACCESS (could not, not did not) and proceed - do not block indefinitely on a target you structurally cannot reach, but do not silently claim not-applicable either when it actually is applicable and merely unreachable.
200
+ Do NOT conflate this with fh-meta:challenger (family/adversarial-correctness axis, already required by the 4-axis gate above) or with fh-meta:hub-cc-pr-reviewer (checks FH's own diff against FH's OWN baseline conventions - a same-repo self-consistency check, not a standpoint-axis review at all). All three are different lenses; running one is not a substitute for another.
201
+
202
+ 3. If the diff is neither irreversible/load-bearing (step 1: NO) nor standpoint-applicable (step 2: NO or DEGRADED) - the common case, ordinary small reversible self-improvement: skip straight to branch/commit/push/PR below.
203
+
204
+ In every case that reaches a PR: create branch auto/digest-improve-${HUMAN_DATE}, commit there, push it, open with gh pr create --fill, noting in the PR body which digest item motivated it. Never commit to main (the pre-push hook enforces this regardless). Do NOT run gh pr merge under any circumstances - merge is always the operator's decision. Do NOT push to main directly. Do NOT force-push. Do NOT touch any file outside what this specific candidate change requires.
205
+
206
+ If you are blocked (git status was unexpectedly dirty, gh auth failed, the gate genuinely cannot converge in one pass), stop and log why - do not retry, do not fall back to a weaker gate, do not touch main.
207
+ PROMPT_EOF
208
+
209
+ PROMPT="$(cat "$PROMPT_FILE")"
210
+ rm -f "$PROMPT_FILE"
211
+
212
+ _log "Stage 2 starting (timeout ${ATTEMPT_TIMEOUT_SECS}s)"
213
+ "$CLAUDE_BIN" -p --permission-mode bypassPermissions "$PROMPT" >> "$LOG_FILE" 2>&1 &
214
+ CLAUDE_PID=$!
215
+ DEADLINE=$((SECONDS + ATTEMPT_TIMEOUT_SECS))
216
+ while kill -0 "$CLAUDE_PID" 2>/dev/null && [ "$SECONDS" -lt "$DEADLINE" ]; do
217
+ sleep 30 & wait $!
218
+ done
219
+ if kill -0 "$CLAUDE_PID" 2>/dev/null; then
220
+ pkill -P "$CLAUDE_PID" 2>/dev/null
221
+ kill "$CLAUDE_PID" 2>/dev/null
222
+ wait "$CLAUDE_PID" 2>/dev/null
223
+ _log "Stage 2 killed by watchdog (${ATTEMPT_TIMEOUT_SECS}s) — no retry (this is not the digest's hard-realtime path)."
224
+ else
225
+ wait "$CLAUDE_PID"
226
+ _log "Stage 2 finished (exit $?)."
227
+ fi
228
+
229
+ exit 0
@@ -121,30 +121,37 @@ EXEMPT=(
121
121
  # 11 not 12), and test_marker_floor_lanes.sh calibrates pre-commit's live validate_marker_floor().
122
122
  # Both are wired now.
123
123
  #
124
- # 🟥 THE TWO BELOW WERE WIRED, MEASURED RED IN CI, AND DELIBERATELY MOVED BACK. Making them green
125
- # was available and is the wrong move: their failures are TRUE the suites really do depend on
126
- # things a CI runner does not have so forcing a pass would be routing a real failure to green in
127
- # the same delta that spent nine repairs closing exactly that. A suite whose preconditions are
128
- # undeclared belongs in the todo list, not in the mandatory path.
129
- DEBT=(
130
- # PASS 19 · FAIL 0 locally; PASS 12 · FAIL 7 in CI. Reproduced locally by pointing HOME at an
131
- # empty directory 12/7, the same split so the dependency is exact and not a guess:
132
- # field_canon_preload.sh resolves `${HOME}/projects` and seven lanes assume the operator's
133
- # mapped-project layout is there. Nothing in the suite declares that precondition, so on a
134
- # machine without it the suite reports a REGRESSION rather than "not exercised here".
135
- # Fix before re-wiring: the suite must detect the missing layout and exit NOT-EXERCISED (the
136
- # shape test_sessionstart_multihook_lanes.sh already uses for its CLI dependency), or build its
137
- # own fixture HOME for those seven the way it already does for the no-HOME lane.
138
- "test_field_canon_lanes.sh"
139
- # 17/17 locally; 2/17 in CI. And this one was PREDICTED: the adversarial review of this very
140
- # delta flagged `elapsed < 10` as a wall-clock assertion that would go falsely red on a loaded CI
141
- # runner, naming this file and that line. It was filed as a low-severity residual and not acted
142
- # on; CI then produced exactly it. The finding named a MECHANISM (a wall-clock assertion is now
143
- # on a mandatory path), and a mechanism does not become less true for being labelled R.
144
- # Fix before re-wiring: give the timing assertions headroom or gate them behind an explicit
145
- # opt-in, so the default run measures logic and not the runner's load.
146
- "test_stale_clone_guard_lanes.sh"
147
- )
124
+ # 🟢 THE TWO THAT WERE HERE ARE RE-WIRED (2026-08-14) DEBT 2 0. Both entries below are
125
+ # HISTORICAL: they record why the suites were pulled out and what the fix had to be, kept because
126
+ # the reasoning (and the exact CI-reproduction numbers) are load-bearing evidence for the next
127
+ # person who touches either suite, not because either is still debt.
128
+ #
129
+ # test_field_canon_lanes.sh — was: PASS 19 · FAIL 0 locally; PASS 12 · FAIL 7 in CI. Reproduced
130
+ # locally by pointing HOME at an empty directory 12/7, the same split so the dependency was
131
+ # exact and not a guess: field_canon_preload.sh resolves `${HOME}/projects` and seven lanes assumed
132
+ # the operator's mapped-project layout was there. Nothing in the suite declared that precondition,
133
+ # so on a machine without it the suite reported a REGRESSION rather than "not exercised here". Fix
134
+ # (built the second option this entry named, not the first): the suite now carries its own fixture
135
+ # hub+project-root (mirroring the shape lane already used) instead of depending on the operator's
136
+ # real filesystem `run()`/`runD()` inject `CLAUDE_PROJECT_DIR`/`FIELD_CANON_PROJECT_ROOT` at a
137
+ # fixed fixture path. Verified both directions: PASS 19/19 under an empty HOME (the exact CI
138
+ # reproduction) AND a known-negative check — breaking the `-dev`-suffix repo-resolution fallback in
139
+ # field_canon_preload.sh reproduces the identical PASS 12 / FAIL 7 split, so the fixture measures
140
+ # real behavior, not a vacuously-green rewrite.
141
+ #
142
+ # test_stale_clone_guard_lanes.sh was: 17/17 locally; 2/17 in CI. Predicted by an adversarial
143
+ # review of the delta that introduced `elapsed < 10` as a wall-clock assertion on a mandatory path;
144
+ # CI then produced exactly it. Fix (the first option this entry named): the wedge sleep widened
145
+ # 30s→60s and the assertion changed from a fixed `elapsed < 10` to a relative `elapsed <
146
+ # (wedge/2)=30s` — proving the guard's internal 0.5s budget bounded it (60x headroom) rather than an
147
+ # absolute constant tuned to one machine's speed. Known-negative check: neutering the budget's kill
148
+ # condition in stale_clone_guard.sh (so it waits out the full wedge instead of self-bounding)
149
+ # correctly fails exactly this one lane, confirming the widened threshold still discriminates.
150
+ #
151
+ # Re-wired into scripts/selfcheck.sh's pair-loop alongside the other ten (found→extend, same shape:
152
+ # "scripts/field_canon_preload.sh|scripts/test_field_canon_lanes.sh" and
153
+ # "scripts/stale_clone_guard.sh|scripts/test_stale_clone_guard_lanes.sh").
154
+ DEBT=()
148
155
 
149
156
  if [ "${1:-}" = "--list-debt" ]; then
150
157
  # Same bash-3.2 empty-array guard as the scan call below. This site was MISSED when that one was
@@ -169,6 +176,96 @@ if [ -f scripts/selfcheck.sh ] && ! grep -qE '^[[:space:]]*(if !? ?)?bash script
169
176
  exit 1
170
177
  fi
171
178
 
179
+ # ── ORG SEAM — a downstream org declares ITS OWN suites in ITS OWN file ───────────────────────
180
+ # Why this exists (requested 2026-08-13 by a downstream org fork of this harness, with evidence):
181
+ # EXEMPT and DEBT above are hardcoded arrays in a SHARED-LAYER file. A downstream fork carries
182
+ # suites this repo has never heard of (`test_rest_push_lanes.sh`, `test_company_delta_lanes.sh` …),
183
+ # and all three remedies this check offers land in files that fork does not own:
184
+ # wire into selfcheck.sh → shared layer · EXEMPT array → this file, shared layer
185
+ # DEBT array → this file, shared layer
186
+ # So the fork's only choices were "violate the sync boundary" or "live at rc=1 forever". Measured:
187
+ # 5 such suites on their first run of this check. That is not a preference — a permanently red
188
+ # check stops being read, and one of those five suites had just caught a real regression when it
189
+ # was run by hand.
190
+ #
191
+ # Shape: OPTIONAL file, absent by default. This repo ships none, so the no-op arm is the arm that
192
+ # runs here — verified by the known-pair in scripts/test_lane_runner_lanes.sh.
193
+ #
194
+ # 🟥 A MALFORMED FILE IS FAIL-CLOSED, a stale entry is advisory. Those are different failures:
195
+ # · cannot parse → the instrument did not look. Reporting "no declarations" would render
196
+ # UNMEASURED as ZERO, which is the exact family this repo keeps closing.
197
+ # · names a suite that no longer exists → hygiene, same as a stale DEBT entry above, so it warns
198
+ # with the same voice. Making the downstream rule STRICTER than the upstream
199
+ # one it mirrors would just train people to delete the file.
200
+ ORG_DECL="company/lane_declarations.yaml"
201
+ ORG_EXEMPT=(); ORG_DEBT=()
202
+ if [ -f "$ORG_DECL" ]; then
203
+ _org_out=$(python3 - "$ORG_DECL" <<'ORGPY'
204
+ import re, sys
205
+ path = sys.argv[1]
206
+ try:
207
+ txt = open(path, encoding='utf-8').read()
208
+ except OSError as e:
209
+ print(f"ERR\tcannot read {path}: {e}"); sys.exit(0)
210
+ sect = None; seen = {}
211
+ for i, ln in enumerate(txt.splitlines(), 1):
212
+ s = ln.split('#', 1)[0].rstrip()
213
+ if not s.strip():
214
+ continue
215
+ m = re.match(r'^([A-Za-z_]+):\s*$', s)
216
+ if m:
217
+ sect = m.group(1)
218
+ if sect not in ('exempt', 'debt'):
219
+ print(f"ERR\t{path}:{i}: unknown section '{sect}' (expected 'exempt' or 'debt')")
220
+ sys.exit(0)
221
+ continue
222
+ m = re.match(r'^\s+-\s+(\S+)\s*$', s)
223
+ if m:
224
+ if sect is None:
225
+ print(f"ERR\t{path}:{i}: entry before any section header"); sys.exit(0)
226
+ name = m.group(1)
227
+ if name in seen and seen[name] != sect:
228
+ print(f"ERR\t{path}:{i}: '{name}' declared both exempt and debt — ambiguous")
229
+ sys.exit(0)
230
+ seen[name] = sect
231
+ print(f"{sect}\t{name}")
232
+ continue
233
+ print(f"ERR\t{path}:{i}: unparseable line: {s.strip()[:60]}")
234
+ sys.exit(0)
235
+ ORGPY
236
+ )
237
+ # `grep -q '^ERR'` — NOT `'^ERR\t'`. In a POSIX BRE, GNU grep reads `\t` as a literal `t`
238
+ # (so the pattern becomes `^ERRt` and matches nothing) while BSD grep on macOS takes it as a tab.
239
+ # Measured 2026-08-13: this exact line passed all 11 lanes locally and failed L6/L7/L8 in CI —
240
+ # the fail-closed arm was silently dead on Linux, which is the platform that gates merges.
241
+ # Same family as the `"$DDSCAN" "` anchor divergence a sibling harness measured the same day:
242
+ # a regex that means different things on the two greps is an instrument, and an instrument that
243
+ # answers differently per platform has not measured anything.
244
+ if printf '%s' "$_org_out" | grep -q '^ERR'; then
245
+ echo "FAIL lane-runner: $ORG_DECL exists but could not be read as declarations —"
246
+ printf '%s' "$_org_out" | sed 's/^ERR[[:space:]]*/ /'
247
+ echo " An unreadable declaration file is UNMEASURED, not empty. Fix the file or delete it."
248
+ exit 2
249
+ fi
250
+ while IFS=$'\t' read -r _k _v; do
251
+ [ -z "${_v:-}" ] && continue
252
+ case "$_k" in
253
+ exempt) ORG_EXEMPT+=("$_v") ;;
254
+ debt) ORG_DEBT+=("$_v") ;;
255
+ esac
256
+ done <<< "$_org_out"
257
+ # Stale-entry warning, same voice as the DEBT hygiene warnings below.
258
+ for _n in "${ORG_EXEMPT[@]+"${ORG_EXEMPT[@]}"}" "${ORG_DEBT[@]+"${ORG_DEBT[@]}"}"; do
259
+ [ -f "scripts/$_n" ] || echo "⚠️ lane-runner: $ORG_DECL declares '$_n' but scripts/$_n does not exist (renamed/deleted?)"
260
+ done
261
+ # Visibility: an org-declared suite must never be indistinguishable from an upstream one. A
262
+ # reader who sees "0 debt" has to be able to tell whether that is this repo's zero or a fork's
263
+ # declaration absorbing its own list.
264
+ echo " lane-runner: $ORG_DECL — ${#ORG_EXEMPT[@]} exempt · ${#ORG_DEBT[@]} debt (org-declared)"
265
+ EXEMPT+=("${ORG_EXEMPT[@]+"${ORG_EXEMPT[@]}"}")
266
+ DEBT+=("${ORG_DEBT[@]+"${ORG_DEBT[@]}"}")
267
+ fi
268
+
172
269
  # `"${ARR[@]+"${ARR[@]}"}"` and not the plain `"${ARR[@]}"`: on bash 3.2 (stock macOS) `set -u`
173
270
  # treats an EMPTY array's expansion as an unbound variable and aborts. DEBT is now empty by design,
174
271
  # which is exactly the state the plain form cannot survive — measured here 2026-08-13, and it
@@ -256,6 +353,99 @@ def runner_dispatches(suite, txt):
256
353
 
257
354
  wired = {s for s in suites if has_runner(s)}
258
355
 
356
+ # ── Embedded --self-test dispatchers — a class the name-pattern `suites` glob cannot see ──────
357
+ # Measured 2026-08-13, the header of this file, §WHAT DEBT:0 DOES NOT MEAN: a lane suite that
358
+ # lives INSIDE its subject as a `--self-test` flag — not a separate `test_*.sh`/`*_lanes.sh` file
359
+ # — is structurally invisible to the glob above. 4 scripts carry one — chamber_witness.sh ·
360
+ # capability_registry_check.sh · digest_landing_check.sh · directional_diff_gate.sh — and none
361
+ # showed up as WIRED or UNWIRED anywhere in this report; the wiring line for the 3 that ARE wired,
362
+ # the `for _subj in ...` loop at scripts/selfcheck.sh:478, could be deleted and nothing here would
363
+ # go red. found→extend, not a new file: same idiom as `suites`/`has_runner` above — discover
364
+ # subjects, detect dispatch, report undeclared — new predicates for the shape this pattern uses.
365
+ #
366
+ # 🟥 A PARENTHESIS TRAP LIVES IN THIS SPECIFIC HEREDOC, READ BEFORE ADDING A LINE HERE.
367
+ # Measured 2026-08-14: a single heredoc-body line whose own paren count was unbalanced — one more
368
+ # close-paren than open-paren, from a regex needing a literal close-paren character — broke bash's
369
+ # parse of every line after it, on THIS file only. The reason is that the heredoc below sits inside
370
+ # a command-substitution wrapper, and that wrapper's own close-paren-matching scan turned out not
371
+ # to be fully heredoc-blind in the bash build this repo has tested. `bash -n` then failed dozens of
372
+ # lines later with an unrelated-looking error, because by then the parser believed the heredoc had
373
+ # already closed. Reproduced in isolation: a bare regex assignment needing a literal close-paren,
374
+ # spliced into this file at this exact position, alone, with nothing else added. The fix is
375
+ # structural, not "be careful" — every line added inside this heredoc must carry a matched
376
+ # open-paren and close-paren count on that same line, and a matched pair spanning two lines is
377
+ # exactly the shape that tripped this the first time. Collapse it back to one line, or spell the
378
+ # literal paren out as an escape sequence instead of a bare character, rather than splitting it
379
+ # across lines.
380
+ # A bare substring match on --self-test would also catch prose that only DISCUSSES the flag
381
+ # (measured: scripts/selfcheck.sh:482 has a comment naming it as an example of what NOT to grep
382
+ # for, which is exactly the false positive this narrower check exists to avoid). Require one of
383
+ # the two real dispatcher shapes instead: `"--self-test"` in a quoted comparison, or `--self-test)`
384
+ # as a bare case-pattern. The close-paren is built via chr — see the paren-trap note above; a
385
+ # literal `)` character on this line, however it is spelled, throws this file's parser off.
386
+ _CP = chr(41)
387
+ SELFTEST_DISPATCH_FORMS = ('"--self-test"', '--self-test' + _CP)
388
+
389
+ def _read(path):
390
+ try:
391
+ return open(path, encoding='utf-8', errors='replace').read()
392
+ except OSError:
393
+ return ''
394
+
395
+ # One line on purpose — see the paren-trap note above the SELFTEST_PAT definition.
396
+ _st_candidates = [f for f in glob.glob('scripts/*.sh') if os.path.basename(f) not in suites and os.path.basename(f) != 'lane_runner_check.sh']
397
+ _st_names = [os.path.basename(f)[:-3] for f in _st_candidates if any(_form in _read(f) for _form in SELFTEST_DISPATCH_FORMS)]
398
+ selftest_subjects = sorted(set(_st_names))
399
+
400
+ def selftest_dispatched(bare_name, txt):
401
+ """Two shapes, both real in this repo. Cross-family review (2026-08-14) caught the first draft
402
+ shipping only the second — it read scripts/selfcheck.sh:478's `_subj` for-loop but missed
403
+ :898/:933's direct `bash scripts/probe_scope_check.sh --self-test` / `bash scripts/
404
+ utterance_landing_check.sh --self-test`, so those two subjects were reported UNDECLARED while
405
+ selfcheck.sh runs them every time. This is the exact failure the header above names by cite —
406
+ a reader trusting the count over the source would have been told a false thing with confidence.
407
+
408
+ Shape 1 (direct): `bash scripts/<name>.sh ... --self-test` on one line — mirrors
409
+ runner_dispatches' direct branch, structurally simpler than the indirect case below.
410
+ Shape 2 (indirect): bare_name sits in a `for VAR in ... bare_name ...; do` loop whose body
411
+ dispatches $VAR with --self-test — mirrors the indirect-branch reasoning of runner_dispatches:
412
+ the literal name is in a list construct, the invocation runs through the loop variable, so a
413
+ direct-dispatch grep alone structurally cannot see it (scripts/selfcheck.sh:478)."""
414
+ if re.search(rf'\bbash\s+scripts/{re.escape(bare_name)}\.sh\b[^\n]*--self-test', txt):
415
+ return True
416
+ in_loop = False; loop_var = None; has_name = False
417
+ for ln in txt.split('\n'):
418
+ s = ln.strip()
419
+ m = re.match(r'for\s+(\w+)\s+in\b(.*)', s)
420
+ if m:
421
+ loop_var = m.group(1)
422
+ has_name = bool(re.search(rf'\b{re.escape(bare_name)}\b', m.group(2)))
423
+ in_loop = True
424
+ continue
425
+ if in_loop:
426
+ if has_name and '--self-test' in ln and re.search(rf'\${{?{re.escape(loop_var)}\b', ln):
427
+ return True
428
+ if s == 'done':
429
+ in_loop = False; loop_var = None; has_name = False
430
+ return False
431
+
432
+ def has_selftest_runner(bare_name):
433
+ return any(selftest_dispatched(bare_name, _read(r)) for r in runners)
434
+
435
+ selftest_wired = {s for s in selftest_subjects if has_selftest_runner(s)}
436
+ selftest_undeclared = sorted(s for s in selftest_subjects if s not in selftest_wired)
437
+
438
+ # Minimal known-pair — proportionate to the size of this addition, not the full CTL apparatus
439
+ # below, but a dead predicate must still be caught rather than trusted on read-through alone.
440
+ _ST_POS_FIXTURE = 'for _subj in alpha beta; do\n bash "scripts/$_subj.sh" --self-test\ndone\n'
441
+ _ST_NEG_FIXTURE = 'for _subj in alpha beta; do\n bash "scripts/$_subj.sh" --normal-run\ndone\n'
442
+ if not selftest_dispatched('alpha', _ST_POS_FIXTURE):
443
+ print("CONTROL_FAILED\tself-test known-positive fixture read as undispatched — detector is blind")
444
+ raise SystemExit(2)
445
+ if selftest_dispatched('alpha', _ST_NEG_FIXTURE):
446
+ print("CONTROL_FAILED\tself-test known-negative fixture with no --self-test flag read as dispatched")
447
+ raise SystemExit(2)
448
+
259
449
  # ── CONTROL: the instrument must be able to see a suite known to be wired, and must NOT see one
260
450
  # known to be dead. Without both arms a broken detector reports "all clean" or "all broken" and
261
451
  # either reads as a verdict. [[feedback_absence_measurement_needs_control]]
@@ -360,6 +550,9 @@ for s in resolved:
360
550
  print(f"RESOLVED\t{s}")
361
551
  for s in gone:
362
552
  print(f"GONE\t{s}")
553
+ print(f"SELFTEST_COUNTS\t{len(selftest_subjects)}\t{len(selftest_wired)}")
554
+ for s in selftest_undeclared:
555
+ print(f"SELFTEST_UNDECLARED\t{s}")
363
556
  PY
364
557
  )
365
558
  rc=$?
@@ -421,5 +614,24 @@ if [ "$N_DEBT" -gt 0 ]; then
421
614
  echo " only go down; a new one fails the check rather than joining the list silently."
422
615
  fi
423
616
 
424
- echo "PASS lane-runner: ${TOTAL} suites ${WIRED} wired · ${N_EXEMPT} exempt · ${N_DEBT} declared debt"
617
+ SELFTEST_COUNTS=$(printf '%s\n' "$out" | awk -F'\t' '$1=="SELFTEST_COUNTS"{print $2" "$3}')
618
+ set -- $SELFTEST_COUNTS
619
+ ST_TOTAL="${1:-0}"; ST_WIRED="${2:-0}"
620
+ SELFTEST_UNDECLARED=$(printf '%s\n' "$out" | awk -F'\t' '$1=="SELFTEST_UNDECLARED"{print $2}')
621
+ ST_UNDECLARED_N=0
622
+ if [ -n "$SELFTEST_UNDECLARED" ]; then
623
+ ST_UNDECLARED_N=$(printf '%s\n' "$SELFTEST_UNDECLARED" | wc -l | tr -d ' ')
624
+ # Advisory, not blocking — these are pre-existing (measured 2026-08-13, before this check saw
625
+ # them at all), not something introduced by whatever change is running this check right now.
626
+ # Same DEBT philosophy as above: loudly counted, never silent, must only go down from here.
627
+ echo "⚠️ lane-runner: ${ST_UNDECLARED_N} embedded --self-test subject(s) with no --self-test"
628
+ echo " dispatcher anywhere (self-test code exists, nothing calls it — see this file's own"
629
+ echo " §Embedded --self-test comment for why the suites glob above cannot see this class):"
630
+ printf '%s\n' "$SELFTEST_UNDECLARED" | sed 's/^/ /'
631
+ echo " Fix by wiring \`bash scripts/<name>.sh --self-test\` into scripts/selfcheck.sh's"
632
+ echo " _subj for-loop (scripts/selfcheck.sh:478), same shape as the 3 already there."
633
+ fi
634
+
635
+ echo "PASS lane-runner: ${TOTAL} suites — ${WIRED} wired · ${N_EXEMPT} exempt · ${N_DEBT} declared debt" \
636
+ "· self-test: ${ST_WIRED}/${ST_TOTAL} wired"
425
637
  exit 0
@@ -36,6 +36,8 @@ cd "$REPO_ROOT" || exit 1
36
36
  # a file of that name to exercise the LOW allowlist — not a pointer to this file at all.)
37
37
  # scripts/sync_guard_check.sh — anchor for that same operator-private mirror sync;
38
38
  # no shipped hook invokes it.
39
+ # scripts/sync_to_be_lanes.sh — forward-path lane suite for sync-to-be.sh, itself
40
+ # ACCEPTED_ABSENT above; added 2026-08-14, pmh-dev#69.
39
41
  ACCEPTED_ABSENT=(
40
42
  ".claude/registry/LOCAL_SKILL_REGISTRY.md"
41
43
  # An INSTALL DESTINATION the user creates (`cp templates/local_fh_context.md
@@ -57,6 +59,21 @@ ACCEPTED_ABSENT=(
57
59
  # anchor but guards on the subject's presence, so package mode SKIPs rather than falling through.
58
60
  "scripts/sync-from-be.sh"
59
61
  "scripts/sync_from_be_lanes.sh"
62
+ # Forward path's own lane suite (added 2026-08-14, pmh-dev#69). Same reason as its return-path
63
+ # sibling directly above: it exercises scripts/sync-to-be.sh, itself ACCEPTED_ABSENT — a lane
64
+ # suite for a script that never ships has nothing to verify on a consumer's machine either.
65
+ "scripts/sync_to_be_lanes.sh"
66
+ # Shared identity resolver for the two transports directly above (added to files[] by #368,
67
+ # REMOVED from it 2026-08-15). It was the odd one out: its own callers — sync-to-be.sh and
68
+ # sync-from-be.sh — are ACCEPTED_ABSENT here for a reason that applies to it verbatim (the
69
+ # namespacing it computes only means anything on a machine that HAS the operator's companion
70
+ # store), yet it alone was listed for shipping. The pre-publish confidentiality scan is what
71
+ # surfaced the misclassification: it carries the companion store's own directory vocabulary,
72
+ # which would have reached the registry for the first time in 1.4.98. The one shipped caller,
73
+ # fh_session_load.sh, sources it behind `[ -f ]` and documents its own degrade ("an unresolved
74
+ # identity degrades to the historical unsuffixed tracks-meta rather than erroring — that is the
75
+ # pre-fix behavior, not a new failure mode"), so a consumer loses nothing that consumer ever had.
76
+ "scripts/fh_hub_identity.sh"
60
77
  # ── The three lane suites selfcheck.sh's DEBT-12 pair-loop names but does not ship ────────────
61
78
  # Added 2026-08-13, and the way they got here is the point: this check CAUGHT them. Before that
62
79
  # loop existed, these names lived in lane_runner_check.sh's DEBT array as bare basenames
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // postinstall_notice.js — one-line, stderr-only, CI-silent install notice.
4
+ //
5
+ // WHY: 16k+ npm downloads (measured live, 2026-08-15) vs 7 GitHub stars on the same day —
6
+ // people are using fh-gate and have no reason to ever see the GitHub repo, since `npx
7
+ // @chrono-meta/fh-gate` never surfaces it. This closes that gap at the one moment every
8
+ // installer passes through, without touching fh-gate.js's own stdout (that IS the CI
9
+ // contract per its own header comment — this notice must never risk polluting it).
10
+ //
11
+ // WHY stderr, not stdout: npm's own install-time output already goes to stderr by
12
+ // convention (npm's progress/warnings), and more importantly this keeps it structurally
13
+ // impossible for the notice to ever be mistaken for gate output — a script piping
14
+ // `npx @chrono-meta/fh-gate`'s stdout for FH_GATE_VERDICT: parsing never sees this line,
15
+ // even if this file were somehow invoked in the same process (it isn't — separate
16
+ // lifecycle script, separate process).
17
+ //
18
+ // WHY CI-silent: postinstall banners are a known source of npm-ecosystem noise complaints,
19
+ // and this package explicitly markets itself for CI use (CHEATSHEET.md §9.5). A line that
20
+ // reprints on every CI run install is exactly the annoyance that gets a package reported,
21
+ // not starred. Skip whenever a CI environment is plausible OR the operator opts out.
22
+ if (process.env.CI || process.env.FH_NO_BANNER || process.env.CONTINUOUS_INTEGRATION) {
23
+ process.exit(0);
24
+ }
25
+ try {
26
+ process.stderr.write(
27
+ '\n⭐ forge-harness (fh-gate) — if this is useful, a star helps others find it:\n' +
28
+ ' https://github.com/chrono-meta/forge-harness\n' +
29
+ ' (set FH_NO_BANNER=1 to silence this message)\n\n'
30
+ );
31
+ } catch (_) {
32
+ // Never fail an install over a courtesy message.
33
+ }
34
+ process.exit(0);