session-orchestrator 3.19.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 (66) 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/CHANGELOG.md +80 -0
  5. package/README.md +9 -9
  6. package/commands/session.md +6 -2
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/instruction-delivery.md +350 -0
  9. package/docs/session-config-reference.md +1 -41
  10. package/docs/session-config-template.md +0 -23
  11. package/hooks/_lib/guard-source-loader.mjs +304 -91
  12. package/hooks/enforce-commands.mjs +216 -17
  13. package/hooks/enforce-scope.mjs +133 -9
  14. package/hooks/hooks-codex.json +1 -1
  15. package/hooks/hooks.json +1 -1
  16. package/hooks/on-session-start.mjs +7 -4
  17. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  18. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  19. package/package.json +2 -2
  20. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  21. package/scripts/emit-session.mjs +3 -40
  22. package/scripts/lib/command-blocker.mjs +322 -62
  23. package/scripts/lib/hardening.mjs +9 -9
  24. package/scripts/lib/learnings/affinity.mjs +434 -0
  25. package/scripts/lib/learnings/candidates.mjs +736 -0
  26. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  27. package/scripts/lib/learnings/judgment.mjs +782 -0
  28. package/scripts/lib/learnings/kebab.mjs +128 -0
  29. package/scripts/lib/learnings/select.mjs +550 -0
  30. package/scripts/lib/reconcile/emitter.mjs +107 -22
  31. package/scripts/lib/reconcile/engine.mjs +9 -15
  32. package/scripts/lib/reconcile/renderer.mjs +141 -25
  33. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  34. package/scripts/lib/reconcile/writer.mjs +95 -1
  35. package/scripts/lib/scope-gate.mjs +194 -72
  36. package/scripts/lib/session-close-backfill.mjs +2 -2
  37. package/scripts/lib/session-record-repair.mjs +551 -0
  38. package/scripts/lib/session-schema/serializer.mjs +54 -0
  39. package/scripts/lib/session-schema.mjs +1 -0
  40. package/scripts/lib/session-token-rollup.mjs +68 -6
  41. package/scripts/lib/soul-resolve.mjs +12 -0
  42. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  43. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  44. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  45. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  46. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  47. package/scripts/lib/validate/check-rules.mjs +31 -5
  48. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  49. package/scripts/print-applicable-rules.mjs +170 -7
  50. package/scripts/print-learnings-index.mjs +474 -0
  51. package/scripts/repair-invalid-sessions.mjs +209 -0
  52. package/scripts/sweep-expired-learnings.mjs +192 -32
  53. package/scripts/validate-plugin.mjs +21 -0
  54. package/skills/brainstorm/soul.md +47 -1
  55. package/skills/evolve/SKILL.md +116 -18
  56. package/skills/gitlab-ops/SKILL.md +5 -0
  57. package/skills/grill/soul.md +44 -1
  58. package/skills/plan/soul.md +46 -3
  59. package/skills/session-end/SKILL.md +1 -24
  60. package/skills/session-end/phase-3-6-tail.md +30 -1
  61. package/skills/session-end/plan-verification.md +1 -5
  62. package/skills/session-end/session-metrics-write.md +2 -0
  63. package/skills/session-start/SKILL.md +2 -0
  64. package/skills/session-start/soul.md +41 -1
  65. package/skills/wave-executor/SKILL.md +1 -5
  66. package/skills/wave-executor/wave-loop.md +36 -71
@@ -938,29 +938,6 @@ config-protection:
938
938
  mode: warn # warn | strict (strict blocks loosening, exit 2)
939
939
  allow-config-weakening: false # per-session bypass (mirrors allow-destructive-ops)
940
940
 
941
- # Strategic compact-nudge (ecc-analysis / #620)
942
- compact-nudge:
943
- enabled: false # opt-in advisory /compact nudge at inter-wave checkpoints
944
- after: [discovery, impl] # subset of {discovery, impl, failed-wave}
945
- # NOTE: `discovery` / `impl` are wave ROLES — the §3c gate matches them against
946
- # the wave's role string. `failed-wave` is NOT a role; it keys off the wave's
947
- # failure OUTCOME (any wave that did not pass its quality gate), so it can fire
948
- # after a wave of any role.
949
- mode: warn # warn | off
950
-
951
- # /goal continuation anchor at named seams (Lever 5 / #636)
952
- goal-integration:
953
- enabled: false # opt-in advisory; default off — zero behaviour change when absent
954
- seams: [session-end-backlog, inter-wave-fixloop] # subset of {session-end-backlog, inter-wave-fixloop}
955
- # ADVISORY ONLY. Surfaces ONE suggested /goal command at the named seam(s);
956
- # never auto-invokes /goal, never blocks. /goal = Adapter (ADR-0010): the
957
- # evaluator reads the transcript and runs NO tools — it anchors CONTINUATION,
958
- # never JUDGMENT. Deterministic exit-code gates (npm test/typecheck/lint) stay
959
- # the completion authority. ONE goal per session — the two seams cannot both
960
- # hold an active goal; the operator picks one. See `.claude/rules/loop-and-monitor.md`
961
- # § LM-008, `skills/session-end/SKILL.md § 1.3a`, and
962
- # `skills/wave-executor/wave-loop.md § /goal Continuation Anchor`.
963
-
964
941
  # Webhooks (URLs are required when used — no defaults)
965
942
  # webhooks:
966
943
  # slack:
@@ -30,12 +30,25 @@
30
30
  * began routing around. The banner therefore names the CONSEQUENCE ("guard
31
31
  * INACTIVE" / "running against HEAD, not your working tree"), not just a
32
32
  * file path.
33
- * 2. **`git show HEAD:<path>` fallback — for `command-blocker.mjs` ONLY.** That
34
- * module is dependency-free (its single import is `node:path`) and hence
35
- * `data:`-URL loadable. Deliberately NOT generalised to the guard's other 6
36
- * repo imports: those were never audited for RELATIVE imports, which a
37
- * `data:` URL cannot resolve — that would need its own recursive resolver.
38
- * For those modules part 1 (banner) stands alone.
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.
39
52
  *
40
53
  * **The coupling is mandatory: the fallback must never fire silently.** A
41
54
  * successful HEAD fallback banners too ("running against HEAD, not the working
@@ -98,6 +111,7 @@ import fs from 'node:fs';
98
111
  import os from 'node:os';
99
112
  import path from 'node:path';
100
113
  import crypto from 'node:crypto';
114
+ import { fileURLToPath } from 'node:url';
101
115
  import { execFileSync } from 'node:child_process';
102
116
 
103
117
  /** TTL for the session-id-less marker fallback, mirroring `run-node.sh` (6h). */
@@ -110,33 +124,61 @@ const MARKER_VERSION = 1;
110
124
  /** Bucket width for the boot-epoch field (seconds), and its accepted drift. */
111
125
  const BOOT_BUCKET_S = 10;
112
126
 
113
- /** Repo-relative path of the one module that gets the HEAD fallback. */
114
- const COMMAND_BLOCKER_REL = 'scripts/lib/command-blocker.mjs';
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
+ ]);
115
151
 
116
152
  /**
117
- * The FULL export set `pre-bash-destructive-guard.mjs` needs from
118
- * `command-blocker.mjs` — the single source of truth for the shape check.
119
- *
120
- * It lives here, and ONLY here, on purpose. The hook no longer destructures the
121
- * module (it holds the namespace object and calls through it), so there is no
122
- * second list to drift out of sync: a seventh export is added once, right here,
123
- * and both the working-tree and the HEAD copy are validated against it.
124
- *
125
- * Why the check must cover all of them: it used to assert 2 of the 6, so a HEAD
126
- * copy OLDER than the working tree the normal case when a newly added export
127
- * is the very thing that broke, i.e. the #982/#983/#988 history — passed as
128
- * "DEGRADED, enforcement IS still armed" and then allowed every command with an
129
- * `⚠ internal error <fn> is not a function` line. A fallback that cannot
130
- * enforce must banner as a TOTAL failure, never as "still armed".
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
+ * futureso 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.
131
173
  */
132
- const COMMAND_BLOCKER_EXPORTS = [
133
- 'tokenizeCommand',
134
- 'commandMatchesBlocked',
135
- 'extractRedirectTargets',
136
- 'redirectRuleMatches',
137
- 'resolveSegmentVerb',
138
- 'splitChainSegments',
139
- ];
174
+ const GIT_ENV_ALLOWLIST = Object.freeze([
175
+ 'PATH',
176
+ 'HOME',
177
+ 'LANG',
178
+ 'LC_ALL',
179
+ 'TMPDIR',
180
+ 'TZ',
181
+ ]);
140
182
 
141
183
  /**
142
184
  * Resolve the once-per-session banner key.
@@ -147,21 +189,42 @@ const COMMAND_BLOCKER_EXPORTS = [
147
189
  * `.orchestrator/session.lock`; when that fails we fall back to a time-TTL
148
190
  * marker exactly like `run-node.sh` does.
149
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
+ *
150
212
  * @param {string} projectDir
151
213
  * @returns {{key: string, ttl: boolean}} `ttl: true` means "key is not
152
214
  * session-scoped — apply the 6h time TTL instead of pure existence".
153
215
  */
154
216
  function resolveBannerKey(projectDir) {
217
+ const proc = `p${typeof process.ppid === 'number' ? process.ppid : 0}`;
155
218
  try {
156
219
  const raw = fs.readFileSync(path.join(projectDir, '.orchestrator', 'session.lock'), 'utf8');
157
220
  const id = JSON.parse(raw)?.session_id;
158
221
  if (typeof id === 'string' && id.length > 0) {
159
- return { key: id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64), ttl: false };
222
+ return { key: `${id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 48)}-${proc}`, ttl: false };
160
223
  }
161
224
  } catch {
162
225
  /* no lock, unreadable, or malformed — fall through to the time TTL */
163
226
  }
164
- return { key: 'ttl', ttl: true };
227
+ return { key: `ttl-${proc}`, ttl: true };
165
228
  }
166
229
 
167
230
  /** Per-project marker scope — the digest half of the marker file name. */
@@ -329,16 +392,51 @@ function emitGuardBannerOnce({ projectDir, kind, message }) {
329
392
  /**
330
393
  * Read a repo file's committed content via `git show HEAD:<relPath>`.
331
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
+ *
332
425
  * @param {string} repoRoot
333
426
  * @param {string} relPath - POSIX, repo-relative.
334
427
  * @returns {string} file content at HEAD.
335
428
  * @throws when git is absent, the dir is not a repo, or the path is not at HEAD.
336
429
  */
337
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
+ }
338
435
  return execFileSync('git', ['-C', repoRoot, 'show', `HEAD:${relPath}`], {
339
436
  encoding: 'utf8',
340
437
  stdio: ['ignore', 'pipe', 'ignore'],
341
438
  maxBuffer: 8 * 1024 * 1024,
439
+ env,
342
440
  });
343
441
  }
344
442
 
@@ -358,73 +456,182 @@ function importFromSource(source) {
358
456
  }
359
457
 
360
458
  /**
361
- * Assert a loaded `command-blocker.mjs` namespace carries the COMPLETE API the
362
- * guard calls through. A partial namespace is not a degraded guard it is a
363
- * guard that throws on the first command and fails open with an `internal
364
- * error` line, so it must be treated as a load failure here, at the one place
365
- * that can still fall back or banner.
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.
366
504
  *
367
505
  * @param {object} mod
368
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
369
509
  * @throws {Error} naming every missing export.
370
510
  */
371
- function assertBlockerShape(mod, origin) {
372
- const missing = COMMAND_BLOCKER_EXPORTS.filter((name) => typeof mod?.[name] !== 'function');
511
+ function assertShape(mod, origin, requires, relPath) {
512
+ const missing = requires.filter((name) => typeof mod?.[name] !== 'function');
373
513
  if (missing.length > 0) {
374
514
  throw new Error(
375
- `${origin} of ${COMMAND_BLOCKER_REL} is missing required export(s): ${missing.join(', ')}`
515
+ `${origin} of ${relPath} is missing required export(s): ${missing.join(', ')}`
376
516
  );
377
517
  }
378
518
  }
379
519
 
380
520
  /**
381
- * Load `command-blocker.mjs`, falling back to its HEAD version, never silently.
382
- *
383
- * @param {{specifier: string, repoRoot: string, projectDir: string}} opts
384
- * `specifier` is the import URL/path used in normal operation; `repoRoot` is
385
- * where `git show` runs; `projectDir` keys the banner marker.
386
- * @returns {Promise<{module: object, degraded: 'head'|null}>}
387
- * @throws the ORIGINAL working-tree error when the HEAD fallback also fails, so
388
- * the caller's catch can banner "guard INACTIVE" with the real cause.
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.
389
552
  */
390
- export async function loadCommandBlocker({ specifier, repoRoot, projectDir }) {
391
- try {
392
- const module = await import(specifier);
393
- // The shape check runs on BOTH paths. A working-tree copy that parses but
394
- // lost an export is the same defect class as a stale HEAD copy: without
395
- // this it would arm "successfully" and then fail open per command.
396
- assertBlockerShape(module, 'working-tree copy');
397
- return { module, degraded: null };
398
- } catch (workingTreeError) {
399
- let mod;
400
- try {
401
- mod = await importFromSource(readFromHead(repoRoot, COMMAND_BLOCKER_REL));
402
- // A HEAD copy that parses but lost part of the API is NOT a usable
403
- // fallback treat it as a total failure rather than half-arming.
404
- assertBlockerShape(mod, 'HEAD copy');
405
- } catch (headError) {
406
- workingTreeError.headFallbackError = headError;
407
- throw workingTreeError;
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;
408
580
  }
409
581
 
410
- emitGuardBannerOnce({
411
- projectDir,
412
- kind: 'head-fallback',
413
- message: [
414
- '',
415
- '⚠️ pre-bash-destructive-guard: DEGRADED — running against HEAD, not your working tree.',
416
- ` ${COMMAND_BLOCKER_REL} failed to load from the working tree:`,
417
- ` ${String(workingTreeError?.message || workingTreeError).split('\n')[0]}`,
418
- ' Consequence: destructive-command enforcement IS still armed, but it is evaluating the',
419
- ' COMMITTED (HEAD) command lexer — any uncommitted change to that file is NOT in effect.',
420
- ` Fix: repair ${COMMAND_BLOCKER_REL} (conflict markers? partial edit?) and re-run.`,
421
- ' See: issue #992.',
422
- '',
423
- ].join('\n'),
424
- });
425
-
426
- return { module: mod, degraded: 'head' };
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
+ }
427
632
  }
633
+
634
+ return { modules, degraded };
428
635
  }
429
636
 
430
637
  /**
@@ -439,12 +646,20 @@ export async function loadCommandBlocker({ specifier, repoRoot, projectDir }) {
439
646
  * anyway — every call after the first is equally unprotected, so every call
440
647
  * has equal right to say so. Repetition is the point.
441
648
  *
442
- * `projectDir` is retained for signature symmetry with the degraded banner and
443
- * for future scoping; it deliberately does not gate anything.
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.
444
654
  *
445
- * @param {{projectDir?: string, error: unknown}} opts
655
+ * @param {{hookName: string, projectDir?: string, error: unknown, consequence?: {inactive?: string[]}}} opts
446
656
  */
447
- export function emitGuardInactiveBanner({ error }) {
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
+ }
448
663
  const primary = String(error?.message || error).split('\n')[0];
449
664
  const secondary = error?.headFallbackError
450
665
  ? String(error.headFallbackError.message || error.headFallbackError).split('\n')[0]
@@ -453,12 +668,10 @@ export function emitGuardInactiveBanner({ error }) {
453
668
  writeBanner(
454
669
  [
455
670
  '',
456
- '🚨 pre-bash-destructive-guard: GUARD INACTIVE — this session is NOT protected.',
671
+ `🚨 ${hookName}: GUARD INACTIVE — this session is NOT protected.`,
457
672
  ` Module load failed: ${primary}`,
458
673
  ...(secondary ? [` HEAD fallback also failed: ${secondary}`] : []),
459
- ' Consequence: destructive Bash commands (git reset --hard, rm -rf, git push --force,',
460
- ' git stash, redirect-truncate of protected artefacts) are NOT being blocked. This is a',
461
- ' BROKEN GUARD, not a policy decision — do not route around it, repair it.',
674
+ ...(consequence?.inactive ?? []),
462
675
  ' Fix: repair the failing module under scripts/lib/, then re-run.',
463
676
  ' See: issue #992, .claude/rules/parallel-sessions.md (PSA-003).',
464
677
  '',