session-orchestrator 3.17.0 → 3.20.0

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 (221) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -0,0 +1,680 @@
1
+ /**
2
+ * guard-source-loader.mjs — degradation-VISIBLE module loading for deny-capable hooks.
3
+ *
4
+ * ## The defect this closes (#992)
5
+ *
6
+ * `hooks/hooks.json` runs `sh run-node.sh <hook>.mjs`, which `exec node "$@"`.
7
+ * A deny-capable hook that STATICALLY imports a repo module inherits that
8
+ * module's parse failure at ESM LINK time — before the first statement of the
9
+ * hook body runs. Consequences, all measured:
10
+ *
11
+ * - the hook's own `main().catch(...)` handler is structurally unreachable
12
+ * (it only covers runtime errors inside `main()`),
13
+ * - node terminates with EXIT 1, **stdout 0 bytes**, stderr = a SyntaxError
14
+ * stack trace.
15
+ *
16
+ * Under the exit-0 PreToolUse protocol (#906) the exit code no longer carries a
17
+ * decision: ALLOW is `exit 0` + empty stdout, DENY is `exit 0` + exactly one
18
+ * `hookSpecificOutput` line. A crashed hook produces 0 bytes on stdout and is
19
+ * therefore, ON THE ONLY DECISION-BEARING CHANNEL, indistinguishable from an
20
+ * explicit `emitAllow()`. `git reset --hard` runs through. One broken
21
+ * `scripts/lib/command-blocker.mjs` silently disarms 4 of the 7 deny-capable
22
+ * hooks at once (destructive-guard, enforce-scope, enforce-commands,
23
+ * sessions-ledger-guard) — Bash *and* Edit/Write enforcement.
24
+ *
25
+ * ## The two parts, and why the banner is the base
26
+ *
27
+ * 1. **Loud once-per-session banner on EVERY load failure.** The damage was not
28
+ * "no fallback", it was an *invisible* outage: an ambiguous harness error
29
+ * line that wave agents read as a crash rather than a policy block, and
30
+ * began routing around. The banner therefore names the CONSEQUENCE ("guard
31
+ * INACTIVE" / "running against HEAD, not your working tree"), not just a
32
+ * file path.
33
+ * 2. **`git show HEAD:<path>` fallback — for dependency-free modules ONLY.** A
34
+ * module whose every import is a `node:*` builtin is `data:`-URL loadable, so
35
+ * its committed source can be re-imported from HEAD. Deliberately NOT
36
+ * generalised to modules with RELATIVE imports, which a `data:` URL cannot
37
+ * resolve — that would need its own recursive resolver. Opt-in per module via
38
+ * `headFallback: true`, gated by {@link HEAD_FALLBACK_ALLOWLIST}; for every
39
+ * other module part 1 (banner) stands alone.
40
+ *
41
+ * ## Generalised across all four deny-capable hooks (#993)
42
+ *
43
+ * This started life hard-wired to `pre-bash-destructive-guard` +
44
+ * `command-blocker.mjs` (the banner text, the required-export list, and the
45
+ * repo-relative path were all module constants). {@link armGuard} lifts every
46
+ * one of those into a PARAMETER — `hookName`, per-spec `requires`, per-spec
47
+ * `headFallback`, and the `consequence` prose — so `enforce-scope`,
48
+ * `enforce-commands` and `sessions-ledger-guard` bind their own repo
49
+ * dependencies through the same loader without re-freezing this hook's name into
50
+ * a banner literal. `hookName` is MANDATORY on both public exports: a default
51
+ * would silently reintroduce exactly the drift #993 removes.
52
+ *
53
+ * **The coupling is mandatory: the fallback must never fire silently.** A
54
+ * successful HEAD fallback banners too ("running against HEAD, not the working
55
+ * tree"), otherwise a visible hole is traded for an invisible semantic drift.
56
+ *
57
+ * **The HEAD fallback presupposes a git checkout.** It shells out to
58
+ * `git show HEAD:<path>` in the PLUGIN ROOT (not the project dir). For an
59
+ * npm-installed plugin — or any tarball/vendored copy without a `.git` — that
60
+ * command fails by construction, so half of this module is structurally
61
+ * unavailable there: EVERY load failure degrades straight to the GUARD INACTIVE
62
+ * banner below. That is the designed fail-loud direction, not a regression, but
63
+ * it means the banner is the ONLY protection npm consumers get.
64
+ *
65
+ * ## Why the marker is not a mute switch (post-#992 hardening)
66
+ *
67
+ * The once-per-session throttle used to key on the mere EXISTENCE of a
68
+ * predictably-named tmp file, and it gated BOTH banner classes. Measured: an
69
+ * agent could `touch` that path (not a destructive command), then append a
70
+ * syntax error to a `scripts/lib/` module (not on any redirect denylist) and the
71
+ * total-outage banner went silent while the decision channel said ALLOW —
72
+ * strictly worse than the pre-#992 crash, which at least printed a stack trace.
73
+ * Two changes close it:
74
+ *
75
+ * - **The `inactive` (total-outage) banner is NOT throttled at all.** Once per
76
+ * call, every call. Throttling is for the *degraded* case (guard armed,
77
+ * evaluating HEAD); a guard that is OFF must keep saying so. Noise there is
78
+ * strictly cheaper than a silent disarm.
79
+ * - **Where the marker remains (`head-fallback`), CONTENT decides, not
80
+ * existence**: a scoped payload (magic + kind + banner key + projectDir
81
+ * digest + boot epoch) that an empty `touch` cannot forge, opened
82
+ * `O_CREAT|O_EXCL|O_NOFOLLOW` for write and `O_NOFOLLOW` for read — so the
83
+ * marker path can no longer be aimed at an arbitrary file via a symlink,
84
+ * and a foreign or stale file makes the banner REPEAT rather than vanish.
85
+ *
86
+ * Honest residual: an attacker who runs as the same uid and reproduces the
87
+ * payload format can still suppress the `head-fallback` banner. That is why the
88
+ * class that actually means "unprotected" no longer depends on the marker.
89
+ *
90
+ * Measured cost: `git show` median 4.2 ms (n=21) against a hook allow-path
91
+ * median of 61 ms (n=15) — +11 ms, and only in the defect case. Zero in normal
92
+ * operation: nothing here runs unless an import already threw.
93
+ *
94
+ * ## Hard constraint on this file
95
+ *
96
+ * Everything below runs on the error path of a module-loading failure, so it
97
+ * MUST NOT import any repo module that could itself be the broken one —
98
+ * `node:*` builtins only. Keep it that way. An on-disk source cache under
99
+ * `.orchestrator/runtime/` was considered and REJECTED: its cold-start failure
100
+ * mode is exactly the target scenario (a fresh worktree mid-merge), and it
101
+ * creates a deletable trust anchor inside the writable repo.
102
+ *
103
+ * Known, accepted gap: a COMMITTED conflict marker breaks the HEAD copy too —
104
+ * then only the banner fires. That is why the banner is the base and the
105
+ * fallback the topping.
106
+ *
107
+ * Issue: #992.
108
+ */
109
+
110
+ import fs from 'node:fs';
111
+ import os from 'node:os';
112
+ import path from 'node:path';
113
+ import crypto from 'node:crypto';
114
+ import { fileURLToPath } from 'node:url';
115
+ import { execFileSync } from 'node:child_process';
116
+
117
+ /** TTL for the session-id-less marker fallback, mirroring `run-node.sh` (6h). */
118
+ const BANNER_TTL_MS = 6 * 60 * 60 * 1000;
119
+
120
+ /** Marker payload discriminator — an empty `touch` matches none of it. */
121
+ const MARKER_MAGIC = 'session-orchestrator/guard-banner';
122
+ const MARKER_VERSION = 1;
123
+
124
+ /** Bucket width for the boot-epoch field (seconds), and its accepted drift. */
125
+ const BOOT_BUCKET_S = 10;
126
+
127
+ /**
128
+ * The ONLY module basenames a `git show HEAD:` fallback is sound for.
129
+ *
130
+ * The fallback re-imports committed source through a `data:` URL (see
131
+ * {@link importFromSource}), and a `data:` URL has NO base against which a
132
+ * RELATIVE import specifier could resolve. So the fallback is correct only for a
133
+ * module whose every import is a `node:*` builtin (or which imports nothing at
134
+ * all). This set is that dependency-free allowlist; a `headFallback: true` on a
135
+ * module NOT in it (e.g. `hardening.mjs` / `platform.mjs`, which carry relative
136
+ * imports) would silently produce an unloadable `data:` module — so
137
+ * {@link armGuard} rejects it as a hard CONFIG error rather than arming a guard
138
+ * whose fallback can never fire.
139
+ *
140
+ * Keyed on BASENAME deliberately: it is the `git show HEAD:<relPath>` leaf, and
141
+ * a dependency-free file keeps that property wherever in the tree it sits. To
142
+ * add a module, verify its import list is `node:*`-only first.
143
+ */
144
+ const HEAD_FALLBACK_ALLOWLIST = new Set([
145
+ 'command-blocker.mjs',
146
+ 'io.mjs',
147
+ 'path-utils.mjs',
148
+ 'common.mjs',
149
+ 'plugin-root.mjs',
150
+ ]);
151
+
152
+ /**
153
+ * The ONLY environment keys `git show HEAD:<path>` may inherit (see
154
+ * {@link readFromHead}).
155
+ *
156
+ * An ALLOWLIST, deliberately — the predecessor was a denylist that named the
157
+ * git repository-/object-/config-discovery vars one by one, and a denylist is
158
+ * unsound by construction: it silently re-opens on every git release that adds a
159
+ * new config channel, and it had already MISSED three independent ones —
160
+ * `GIT_CONFIG_PARAMETERS` (a command-line config channel with NO
161
+ * `GIT_CONFIG_COUNT` gate: `GIT_CONFIG_PARAMETERS="'user.name=X'" git config
162
+ * user.name` prints `X`), `GIT_CEILING_DIRECTORIES` (can force the shell-out to
163
+ * fail), and the whole `GIT_TRACE*` family (writes to an attacker-named path as
164
+ * the invoking user). An allowlist omits every unlisted key — present and
165
+ * future — so no future git config channel can ride in.
166
+ *
167
+ * Kept, and only these: `PATH` (execFileSync resolves the `git` binary through
168
+ * it), `HOME` (git's own config-discovery root — the USER's `~/.gitconfig`, not
169
+ * an attacker-set channel), and the locale/tmp/tz vars git honours for messages
170
+ * and temp files. `git show HEAD:<blob>` on a pipe needs nothing else: no pager
171
+ * (not a tty), no default smudge/textconv filter, and aliases cannot shadow the
172
+ * `show` builtin.
173
+ */
174
+ const GIT_ENV_ALLOWLIST = Object.freeze([
175
+ 'PATH',
176
+ 'HOME',
177
+ 'LANG',
178
+ 'LC_ALL',
179
+ 'TMPDIR',
180
+ 'TZ',
181
+ ]);
182
+
183
+ /**
184
+ * Resolve the once-per-session banner key.
185
+ *
186
+ * At ESM link-time failure stdin has NOT been read yet, so the payload's
187
+ * `session_id` is unavailable — and `readStdin` lives in `io.mjs`, which may
188
+ * itself be the broken module. So the id is read with `node:fs` alone from
189
+ * `.orchestrator/session.lock`; when that fails we fall back to a time-TTL
190
+ * marker exactly like `run-node.sh` does.
191
+ *
192
+ * ## Why the id alone is not the key (#998.3)
193
+ *
194
+ * There is exactly ONE `session.lock` per working copy, so two parallel sessions
195
+ * in the SAME working copy resolve the same `session_id` — and the second one
196
+ * never sees its own degradation banner, because the first already wrote the
197
+ * marker. The key is therefore composed with a per-session PROCESS identity.
198
+ *
199
+ * **`ppid`, deliberately not `pid`.** Every hook invocation is its own short-lived
200
+ * node process, so a `pid`-keyed marker would be unique per tool call and the
201
+ * banner would fire on every call — the noisy-flood class the throttle exists to
202
+ * prevent, not the shared-marker class it is fixing. `ppid` is the harness
203
+ * process that spawns the hooks and is stable for the session's life:
204
+ * `hooks/run-node.sh` uses `exec node` on every branch, so no intermediate shell
205
+ * survives to become the parent and the ppid IS the harness. This is load-bearing
206
+ * — an added non-exec branch in `run-node.sh` would silently re-break the key.
207
+ *
208
+ * Total key length stays ≤ 64 (48-char id slice + `-pNNNNN`). Markers written in
209
+ * the old (id-only) format live at different paths and are simply never consulted
210
+ * — no migration, the worst case is one extra banner.
211
+ *
212
+ * @param {string} projectDir
213
+ * @returns {{key: string, ttl: boolean}} `ttl: true` means "key is not
214
+ * session-scoped — apply the 6h time TTL instead of pure existence".
215
+ */
216
+ function resolveBannerKey(projectDir) {
217
+ const proc = `p${typeof process.ppid === 'number' ? process.ppid : 0}`;
218
+ try {
219
+ const raw = fs.readFileSync(path.join(projectDir, '.orchestrator', 'session.lock'), 'utf8');
220
+ const id = JSON.parse(raw)?.session_id;
221
+ if (typeof id === 'string' && id.length > 0) {
222
+ return { key: `${id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 48)}-${proc}`, ttl: false };
223
+ }
224
+ } catch {
225
+ /* no lock, unreadable, or malformed — fall through to the time TTL */
226
+ }
227
+ return { key: `ttl-${proc}`, ttl: true };
228
+ }
229
+
230
+ /** Per-project marker scope — the digest half of the marker file name. */
231
+ function markerScope(projectDir) {
232
+ return crypto.createHash('sha256').update(projectDir).digest('hex').slice(0, 12);
233
+ }
234
+
235
+ /**
236
+ * Coarse boot epoch (unix seconds, bucketed), used to invalidate markers left
237
+ * behind by a previous boot in a persistent `/tmp`. Bucketing absorbs the
238
+ * sub-second jitter between two `os.uptime()` reads; the reader additionally
239
+ * accepts ±1 bucket, so a call straddling a bucket edge is not a false miss.
240
+ */
241
+ function bootEpochBucket() {
242
+ return Math.round((Date.now() / 1000 - os.uptime()) / BOOT_BUCKET_S);
243
+ }
244
+
245
+ /**
246
+ * Absolute path of the marker file that makes the banner once-per-session.
247
+ *
248
+ * Lives in the OS temp dir, NOT in the repo: the error path must not presuppose
249
+ * repo write access, and a marker inside the repo would be another deletable
250
+ * trust anchor. Built with `path.join(os.tmpdir(), …)` rather than string
251
+ * concatenation on `$TMPDIR` — that env var carries a trailing slash on macOS
252
+ * and is unset on a Linux container.
253
+ *
254
+ * The path is intentionally still derivable (it must be, across processes) —
255
+ * which is exactly why the path alone no longer decides anything: see
256
+ * `readMarker` for the payload the file has to carry.
257
+ *
258
+ * @param {string} scope - `markerScope(projectDir)`, so parallel repos (and
259
+ * per-test fixture dirs) never share a marker.
260
+ * @param {string} kind - banner class (`head-fallback`).
261
+ * @param {string} key
262
+ * @returns {string}
263
+ */
264
+ function bannerMarkerPath(scope, kind, key) {
265
+ return path.join(os.tmpdir(), `session-orchestrator-guard-${kind}-${scope}-${key}`);
266
+ }
267
+
268
+ /**
269
+ * Read + VALIDATE a marker. Returns its write time, or `null` for "no marker of
270
+ * ours here" — which makes the banner fire.
271
+ *
272
+ * Every rejection path is deliberately the fail-LOUD one. A file that exists but
273
+ * does not carry this exact payload (an empty `touch`, a foreign file, a marker
274
+ * from another project, kind, session, or boot) is NOT a suppression signal.
275
+ *
276
+ * `O_NOFOLLOW` matters on both halves of the marker lifecycle: without it the
277
+ * predictable path is an arbitrary-file-write primitive (aim a symlink at any
278
+ * file the session can write, and the marker write truncates it) and an
279
+ * arbitrary-file-READ oracle.
280
+ *
281
+ * @param {string} marker
282
+ * @param {{kind: string, key: string, scope: string}} expected
283
+ * @returns {{at: number}|null}
284
+ */
285
+ function readMarker(marker, expected) {
286
+ let fd;
287
+ try {
288
+ fd = fs.openSync(marker, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
289
+ const st = fs.fstatSync(fd);
290
+ // Regular file, single link, owned by us. A hard link or a foreign-uid file
291
+ // means somebody else controls this path — never honour it.
292
+ if (!st.isFile() || st.nlink !== 1) return null;
293
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return null;
294
+
295
+ const raw = fs.readFileSync(fd, 'utf8');
296
+ const rec = JSON.parse(raw);
297
+ if (rec?.magic !== MARKER_MAGIC || rec?.v !== MARKER_VERSION) return null;
298
+ if (rec.kind !== expected.kind || rec.key !== expected.key || rec.scope !== expected.scope) {
299
+ return null;
300
+ }
301
+ if (Math.abs(Number(rec.boot) - bootEpochBucket()) > 1) return null;
302
+
303
+ const at = Date.parse(rec.at);
304
+ if (!Number.isFinite(at) || at > Date.now() + 60_000) return null; // no future stamps
305
+ return { at };
306
+ } catch {
307
+ return null; // absent, symlinked (ELOOP), unreadable, or malformed
308
+ } finally {
309
+ if (fd !== undefined) {
310
+ try { fs.closeSync(fd); } catch { /* nothing to do on the error path */ }
311
+ }
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Create the marker exclusively. Never overwrites: `O_EXCL` fails when anything
317
+ * already sits at the path, and a pre-planted file is therefore left alone —
318
+ * the banner then simply repeats on every call, which is the safe direction.
319
+ *
320
+ * @param {string} marker
321
+ * @param {{kind: string, key: string, scope: string}} fields
322
+ */
323
+ function writeMarker(marker, fields) {
324
+ let fd;
325
+ try {
326
+ fd = fs.openSync(
327
+ marker,
328
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0),
329
+ 0o600
330
+ );
331
+ fs.writeSync(
332
+ fd,
333
+ `${JSON.stringify({
334
+ magic: MARKER_MAGIC,
335
+ v: MARKER_VERSION,
336
+ ...fields,
337
+ boot: bootEpochBucket(),
338
+ at: new Date().toISOString(),
339
+ })}\n`
340
+ );
341
+ } catch {
342
+ /* unwritable tmp / already present: emit anyway, repeatedly if need be */
343
+ } finally {
344
+ if (fd !== undefined) {
345
+ try { fs.closeSync(fd); } catch { /* nothing to do on the error path */ }
346
+ }
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Write stderr unconditionally. stdout is NEVER an option here: stdout is the
352
+ * decision channel and an allow REQUIRES an empty stdout (see
353
+ * `tests/_helpers/hook-decision.mjs`) — a banner there would corrupt every
354
+ * decision this hook makes.
355
+ *
356
+ * @param {string} message
357
+ */
358
+ function writeBanner(message) {
359
+ process.stderr.write(message.endsWith('\n') ? message : `${message}\n`);
360
+ }
361
+
362
+ /**
363
+ * Write a guard-DEGRADATION banner to stderr, at most once per session.
364
+ *
365
+ * Throttling is confined to the degraded class on purpose: there the guard is
366
+ * still armed, so a per-call banner is pure noise an operator learns to ignore.
367
+ * The total-outage banner does NOT come through here — see
368
+ * `emitGuardInactiveBanner`.
369
+ *
370
+ * @param {{projectDir: string, kind: string, message: string}} opts
371
+ * @returns {boolean} whether the banner was emitted this call.
372
+ */
373
+ function emitGuardBannerOnce({ projectDir, kind, message }) {
374
+ const { key, ttl } = resolveBannerKey(projectDir);
375
+ const scope = markerScope(projectDir);
376
+ const marker = bannerMarkerPath(scope, kind, key);
377
+ const fields = { kind, key, scope };
378
+
379
+ const existing = readMarker(marker, fields);
380
+ if (existing) {
381
+ if (!ttl) return false; // session-keyed: already bannered this session
382
+ if (Date.now() - existing.at < BANNER_TTL_MS) return false;
383
+ // Session-id-less TTL expiry: our own marker, verified above — refresh it.
384
+ try { fs.unlinkSync(marker); } catch { /* keep going; the write may still fail */ }
385
+ }
386
+
387
+ writeMarker(marker, fields);
388
+ writeBanner(message);
389
+ return true;
390
+ }
391
+
392
+ /**
393
+ * Read a repo file's committed content via `git show HEAD:<relPath>`.
394
+ *
395
+ * ## Why the git env is scrubbed here (#998.1 — defense in depth)
396
+ *
397
+ * The bytes this returns are handed straight to {@link importFromSource}, i.e.
398
+ * IMPORTED as code inside a deny-capable hook. `-C <repoRoot>` only sets the
399
+ * child's cwd; it does NOT win against `GIT_DIR`, which overrides repository
400
+ * discovery outright. With `GIT_DIR=<foreign repo>/.git` in the environment the
401
+ * call returns a FOREIGN repository's blob at the same relative path, and that
402
+ * blob is then executed — one env var plus one broken module away from code
403
+ * execution in the guard itself. Measured on this loader: without `GIT_DIR`
404
+ * 57,446 bytes of real source, with a foreign `GIT_DIR` 128 bytes of
405
+ * attacker-controlled content. **No vector is currently known by which a Bash
406
+ * command sets the LATER hook process's environment — this is defense in depth,
407
+ * not a fix for a reachable exploit.** It is applied at this single
408
+ * trust-sensitive shell-out rather than at {@link armGuard} entry, because this
409
+ * is the only place whose output becomes code.
410
+ *
411
+ * ## Allowlist, not denylist (LOW-4)
412
+ *
413
+ * The child env is BUILT from {@link GIT_ENV_ALLOWLIST} rather than
414
+ * `process.env` with a hand-maintained set of deletions. `execFileSync`'s `env`
415
+ * REPLACES the child environment wholesale (it does not merge into
416
+ * `process.env`), so every key not on the allowlist is omitted — including
417
+ * every git config/discovery channel the old denylist did not enumerate. The
418
+ * denylist had already MISSED `GIT_CONFIG_PARAMETERS` (an independent
419
+ * command-line config channel with no `GIT_CONFIG_COUNT` gate),
420
+ * `GIT_CEILING_DIRECTORIES`, and the `GIT_TRACE*` family; an allowlist closes
421
+ * those and any channel a future git release adds, which is the only form that
422
+ * survives such a release. See {@link GIT_ENV_ALLOWLIST} for the kept keys and
423
+ * why each is safe.
424
+ *
425
+ * @param {string} repoRoot
426
+ * @param {string} relPath - POSIX, repo-relative.
427
+ * @returns {string} file content at HEAD.
428
+ * @throws when git is absent, the dir is not a repo, or the path is not at HEAD.
429
+ */
430
+ function readFromHead(repoRoot, relPath) {
431
+ const env = {};
432
+ for (const key of GIT_ENV_ALLOWLIST) {
433
+ if (process.env[key] !== undefined) env[key] = process.env[key];
434
+ }
435
+ return execFileSync('git', ['-C', repoRoot, 'show', `HEAD:${relPath}`], {
436
+ encoding: 'utf8',
437
+ stdio: ['ignore', 'pipe', 'ignore'],
438
+ maxBuffer: 8 * 1024 * 1024,
439
+ env,
440
+ });
441
+ }
442
+
443
+ /**
444
+ * Import an ESM module from an in-memory source string.
445
+ *
446
+ * Only sound for a DEPENDENCY-FREE module (or one importing `node:*` only): a
447
+ * `data:` URL has no base for relative specifier resolution. `command-blocker.mjs`
448
+ * qualifies — its single import is `node:path`.
449
+ *
450
+ * @param {string} source
451
+ * @returns {Promise<object>} the module namespace.
452
+ */
453
+ function importFromSource(source) {
454
+ const b64 = Buffer.from(source, 'utf8').toString('base64');
455
+ return import(`data:text/javascript;base64,${b64}`);
456
+ }
457
+
458
+ /**
459
+ * Derive a module's repo-relative POSIX path from its import `specifier`,
460
+ * relative to `repoRoot`. This is what `git show HEAD:<relPath>` consumes and
461
+ * what the banners name — it REPLACES the former hard-wired `COMMAND_BLOCKER_REL`
462
+ * constant, so a second `headFallback` module needs no new constant.
463
+ *
464
+ * Accepts a `file:` URL (the shape `pathToFileURL(...).href` produces) or an
465
+ * absolute path; a bare relative specifier is returned verbatim (it cannot be
466
+ * resolved against `repoRoot` without guessing the importing module's dir, and
467
+ * `headFallback` callers always pass an absolute `file:` URL).
468
+ *
469
+ * @param {string} specifier
470
+ * @param {string} repoRoot
471
+ * @returns {string} repo-relative POSIX path
472
+ */
473
+ function deriveRelPath(specifier, repoRoot) {
474
+ let absPath;
475
+ if (typeof specifier === 'string' && specifier.startsWith('file:')) {
476
+ absPath = fileURLToPath(specifier);
477
+ } else if (typeof specifier === 'string' && path.isAbsolute(specifier)) {
478
+ absPath = specifier;
479
+ } else {
480
+ return specifier;
481
+ }
482
+ return path.relative(repoRoot, absPath).split(path.sep).join('/');
483
+ }
484
+
485
+ /**
486
+ * Assert a loaded module namespace exports every name in `requires` as a
487
+ * function. A partial namespace is not a degraded guard — it is a guard that
488
+ * throws on the first call and fails open with an `internal error` line, so it
489
+ * must surface here, at the one place that can still fall back or banner.
490
+ *
491
+ * `requires` is passed PER MODULE by the call site — it replaces the former
492
+ * module-wide `COMMAND_BLOCKER_EXPORTS`, which was both hard-wired to one module
493
+ * and already INCOMPLETE (it listed 6 of the 8 exports command-blocker.mjs
494
+ * actually ships). When a spec entry omits `requires`, the shape check is
495
+ * skipped by construction: correct for a module (io.mjs, events.mjs, …) whose
496
+ * missing export surfaces as a plain TypeError at its single call site, with no
497
+ * half-armed fallback to guard against.
498
+ *
499
+ * Why the check must cover ALL required names: it used to assert 2 of 6, so a
500
+ * HEAD copy OLDER than the working tree — the normal case when a newly added
501
+ * export is the very thing that broke (#982/#983/#988 history) — passed as
502
+ * "DEGRADED, enforcement IS still armed" and then allowed every command with an
503
+ * `⚠ internal error — <fn> is not a function` line.
504
+ *
505
+ * @param {object} mod
506
+ * @param {string} origin - human label for the banner ("working-tree copy" | "HEAD copy")
507
+ * @param {string[]} requires - export names that must be functions
508
+ * @param {string} relPath - repo-relative path, for the error message
509
+ * @throws {Error} naming every missing export.
510
+ */
511
+ function assertShape(mod, origin, requires, relPath) {
512
+ const missing = requires.filter((name) => typeof mod?.[name] !== 'function');
513
+ if (missing.length > 0) {
514
+ throw new Error(
515
+ `${origin} of ${relPath} is missing required export(s): ${missing.join(', ')}`
516
+ );
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Arm a deny-capable hook's repo dependencies, making every load failure VISIBLE
522
+ * and — for the dependency-free modules that opt in — recoverable from HEAD.
523
+ *
524
+ * The generalised successor to the former `loadCommandBlocker` (#993): the hook
525
+ * name, the required-export set, and which modules get a HEAD fallback are ALL
526
+ * parameters now, so `enforce-scope`, `enforce-commands` and
527
+ * `sessions-ledger-guard` share this one loader without each re-hard-wiring
528
+ * `pre-bash-destructive-guard` into a banner literal.
529
+ *
530
+ * ## The frozen contract (A2/A3 build on this — #993)
531
+ *
532
+ * @param {Record<string, {specifier: string, headFallback?: boolean, requires?: string[]}>} specMap
533
+ * One entry per module the hook binds, keyed by a stable LABEL the caller reads
534
+ * back from the returned `modules`. `specifier` is the normal-operation import
535
+ * URL (an absolute `file:` URL for `headFallback` entries — a relative one
536
+ * cannot be resolved from this module). `headFallback: true` opts a
537
+ * DEPENDENCY-FREE module into the `git show HEAD:` recovery (legal only for a
538
+ * {@link HEAD_FALLBACK_ALLOWLIST} basename — a hard error otherwise).
539
+ * `requires` lists the export names that must be functions; omit it to skip the
540
+ * shape check for that module.
541
+ * @param {{hookName: string, repoRoot: string, projectDir: string, consequence?: {degraded?: string[], inactive?: string[]}}} opts
542
+ * `hookName` is MANDATORY — no default, because a default would re-freeze the
543
+ * #993 drift. `repoRoot` is where `git show` runs; `projectDir` keys the
544
+ * once-per-session degradation banner; `consequence.degraded` is spliced,
545
+ * verbatim, into the DEGRADED banner.
546
+ * @returns {Promise<{modules: Record<string, object>, degraded: string[]}>}
547
+ * `modules` maps each label to its namespace; `degraded` lists the labels that
548
+ * loaded from HEAD (empty in the healthy path).
549
+ * @throws the ORIGINAL working-tree error (with `.headFallbackError` attached
550
+ * when a HEAD fallback also failed) so the caller's catch can banner GUARD
551
+ * INACTIVE with the real cause.
552
+ */
553
+ export async function armGuard(specMap, { hookName, repoRoot, projectDir, consequence } = {}) {
554
+ if (typeof hookName !== 'string' || hookName.length === 0) {
555
+ throw new Error(
556
+ 'armGuard: hookName is required and has no default — a default would reintroduce the exact #993 drift this refactor removes.'
557
+ );
558
+ }
559
+
560
+ // Insertion order, EXCEPT headFallback entries move LAST: the cheap plain
561
+ // imports fail first, so a broken dep-free module never pays for a pointless
562
+ // `git show` on a headFallback module that would have loaded fine.
563
+ const entries = Object.entries(specMap);
564
+ entries.sort(([, a], [, b]) => (a.headFallback ? 1 : 0) - (b.headFallback ? 1 : 0));
565
+
566
+ const modules = {};
567
+ const degraded = [];
568
+
569
+ for (const [label, spec] of entries) {
570
+ const { specifier, headFallback = false, requires } = spec;
571
+ const relPath = deriveRelPath(specifier, repoRoot);
572
+
573
+ if (!headFallback) {
574
+ // No fallback: a missing export or parse error is a plain throw the caller
575
+ // banners as GUARD INACTIVE. No `git show`, no half-arming to guard.
576
+ const module = await import(specifier);
577
+ if (Array.isArray(requires)) assertShape(module, 'working-tree copy', requires, relPath);
578
+ modules[label] = module;
579
+ continue;
580
+ }
581
+
582
+ // headFallback is sound ONLY for a dependency-free (node:*-only) module — a
583
+ // data: URL cannot resolve the relative imports of e.g. hardening.mjs, so a
584
+ // headFallback:true there would produce a silently-unloadable fallback. Fail
585
+ // LOUD on the misconfiguration instead of arming a guard that can never
586
+ // recover.
587
+ if (!HEAD_FALLBACK_ALLOWLIST.has(path.posix.basename(relPath))) {
588
+ throw new Error(
589
+ `armGuard: headFallback:true is only sound for a dependency-free module ` +
590
+ `(a data: URL cannot resolve relative imports); '${relPath}' is not on the allowlist ` +
591
+ `[${[...HEAD_FALLBACK_ALLOWLIST].join(', ')}].`
592
+ );
593
+ }
594
+
595
+ try {
596
+ const module = await import(specifier);
597
+ // The shape check runs on BOTH paths. A working-tree copy that parses but
598
+ // lost an export is the same defect class as a stale HEAD copy: without
599
+ // this it would arm "successfully" and then fail open per command.
600
+ if (Array.isArray(requires)) assertShape(module, 'working-tree copy', requires, relPath);
601
+ modules[label] = module;
602
+ } catch (workingTreeError) {
603
+ let headMod;
604
+ try {
605
+ headMod = await importFromSource(readFromHead(repoRoot, relPath));
606
+ // A HEAD copy that parses but lost part of the API is NOT a usable
607
+ // fallback — treat it as a total failure rather than half-arming.
608
+ if (Array.isArray(requires)) assertShape(headMod, 'HEAD copy', requires, relPath);
609
+ } catch (headError) {
610
+ workingTreeError.headFallbackError = headError;
611
+ throw workingTreeError;
612
+ }
613
+
614
+ emitGuardBannerOnce({
615
+ projectDir,
616
+ kind: 'head-fallback',
617
+ message: [
618
+ '',
619
+ `⚠️ ${hookName}: DEGRADED — running against HEAD, not your working tree.`,
620
+ ` ${relPath} failed to load from the working tree:`,
621
+ ` ${String(workingTreeError?.message || workingTreeError).split('\n')[0]}`,
622
+ ...(consequence?.degraded ?? []),
623
+ ` Fix: repair ${relPath} (conflict markers? partial edit?) and re-run.`,
624
+ ' See: issue #992.',
625
+ '',
626
+ ].join('\n'),
627
+ });
628
+
629
+ modules[label] = headMod;
630
+ degraded.push(label);
631
+ }
632
+ }
633
+
634
+ return { modules, degraded };
635
+ }
636
+
637
+ /**
638
+ * Banner for the total-failure case: no usable module, guard NOT armed.
639
+ *
640
+ * **Deliberately NOT throttled** — this is the one message that must survive
641
+ * every attempt to quiet it. Its predecessor went through the once-per-session
642
+ * marker, and because that marker was suppressible by a bare `touch` on a
643
+ * derivable path, the loudest signal in the system had the weakest lock on it:
644
+ * plant the file, break a module, and an unarmed guard said nothing at all
645
+ * while the decision channel said ALLOW. There is nothing to key on here
646
+ * anyway — every call after the first is equally unprotected, so every call
647
+ * has equal right to say so. Repetition is the point.
648
+ *
649
+ * `hookName` is MANDATORY and has no default (see {@link armGuard}) — a default
650
+ * would silently reintroduce the #993 drift, naming one hook in every hook's
651
+ * banner. `consequence.inactive` is spliced verbatim, so each hook states the
652
+ * concrete commands its outage stops blocking. `projectDir` is accepted for
653
+ * signature symmetry with the degraded banner; it deliberately gates nothing.
654
+ *
655
+ * @param {{hookName: string, projectDir?: string, error: unknown, consequence?: {inactive?: string[]}}} opts
656
+ */
657
+ export function emitGuardInactiveBanner({ hookName, error, consequence } = {}) {
658
+ if (typeof hookName !== 'string' || hookName.length === 0) {
659
+ throw new Error(
660
+ 'emitGuardInactiveBanner: hookName is required and has no default (see armGuard — a default would reintroduce the #993 drift).'
661
+ );
662
+ }
663
+ const primary = String(error?.message || error).split('\n')[0];
664
+ const secondary = error?.headFallbackError
665
+ ? String(error.headFallbackError.message || error.headFallbackError).split('\n')[0]
666
+ : null;
667
+
668
+ writeBanner(
669
+ [
670
+ '',
671
+ `🚨 ${hookName}: GUARD INACTIVE — this session is NOT protected.`,
672
+ ` Module load failed: ${primary}`,
673
+ ...(secondary ? [` HEAD fallback also failed: ${secondary}`] : []),
674
+ ...(consequence?.inactive ?? []),
675
+ ' Fix: repair the failing module under scripts/lib/, then re-run.',
676
+ ' See: issue #992, .claude/rules/parallel-sessions.md (PSA-003).',
677
+ '',
678
+ ].join('\n')
679
+ );
680
+ }