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
@@ -35,17 +35,159 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
35
35
  if (!shouldRunHook('enforce-commands')) process.exit(0);
36
36
 
37
37
  import path from 'node:path';
38
+ import { pathToFileURL } from 'node:url';
38
39
 
39
- import { readStdin, emitAllow, emitDeny, emitWarn } from '../scripts/lib/io.mjs';
40
- import { resolveProjectDir } from '../scripts/lib/platform.mjs';
41
- import {
42
- findScopeFile,
43
- commandMatchesBlocked,
44
- suggestForCommandBlock,
45
- extractBashWriteTargets,
46
- pathMatchesPattern,
47
- } from '../scripts/lib/hardening.mjs';
48
- import { readJson } from '../scripts/lib/common.mjs';
40
+ // ---------------------------------------------------------------------------
41
+ // #993 late-bound repo dependencies
42
+ //
43
+ // These used to be STATIC imports. A SyntaxError in any of them failed at ESM
44
+ // LINK time, before the first statement here ran: node exited 1 with 0 bytes on
45
+ // stdout, and the `main().catch(...)` handler at the bottom of this file was
46
+ // structurally unreachable (it only covers runtime errors inside `main()`).
47
+ // Under the exit-0 PreToolUse protocol (#906) that crash is, on the only
48
+ // decision-bearing channel, INDISTINGUISHABLE from an explicit `emitAllow()` —
49
+ // the guard failed open and SILENTLY. This is the sibling defect #992 fixed for
50
+ // pre-bash-destructive-guard; #993 generalises the same repair here.
51
+ //
52
+ // Binding them late (dynamic `import()` inside `bootstrap()`, below) turns that
53
+ // link-time crash into a catchable runtime error, which is what makes the
54
+ // GUARD INACTIVE banner in `_lib/guard-source-loader.mjs` reachable at all.
55
+ //
56
+ // `profile-gate.mjs` stays static on purpose — it has ZERO imports of its own
57
+ // and gates whether this hook runs at all.
58
+ // ---------------------------------------------------------------------------
59
+ /** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
60
+ /** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
61
+ /** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
62
+ /** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
63
+ let resolveProjectDir;
64
+ let readJson;
65
+ let findScopeFile;
66
+ let extractBashWriteTargets;
67
+ let pathMatchesPattern;
68
+ /**
69
+ * The `command-blocker.mjs` namespace, imported DIRECTLY (not via the
70
+ * hardening.mjs barrel — which does not carry the `headFallback` recovery, and
71
+ * which transitively imports command-blocker via scope-gate.mjs, so it fails
72
+ * anyway when command-blocker breaks). Mirrors the direct binding in
73
+ * pre-bash-destructive-guard / sessions-ledger-guard. Held as ONE object so the
74
+ * required-export list lives in exactly one place: the `requires` array on the
75
+ * `blocker` spec passed to `armGuard`.
76
+ *
77
+ * @type {Record<string, Function>|null}
78
+ */
79
+ let blocker = null;
80
+
81
+ /**
82
+ * Module labels `armGuard` recovered from HEAD because the working-tree copy
83
+ * failed (parse error OR shape check). Non-empty ⇒ this hook is armed against
84
+ * COMMITTED source. Surfaced on the visible stdout channel by {@link flushNotices}
85
+ * (#1001) — the stderr DEGRADED banner alone is discarded under the exit-0
86
+ * protocol, which made a degraded ALLOW indistinguishable from a healthy one.
87
+ *
88
+ * @type {string[]}
89
+ */
90
+ let degradedLabels = [];
91
+
92
+ const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
93
+
94
+ /** This hook's name — threaded into the guard banner (#993: no hard-wired literal). */
95
+ const HOOK_NAME = 'enforce-commands';
96
+
97
+ /**
98
+ * The consequence block spliced VERBATIM into the DEGRADED and GUARD INACTIVE
99
+ * banners (#993), naming the enforcement this hook's outage stops applying.
100
+ */
101
+ const GUARD_CONSEQUENCE = {
102
+ degraded: [
103
+ ' Consequence: command enforcement IS still armed, but it is evaluating the',
104
+ ' COMMITTED (HEAD) command-blocker — any uncommitted change to it is NOT in effect.',
105
+ ],
106
+ inactive: [
107
+ ' Consequence: blocked Bash commands (rm -rf, git push --force, git reset',
108
+ ' --hard, and the wave blockedCommands list) are NOT being screened. This is',
109
+ ' a BROKEN GUARD, not a policy decision — do not route around it, repair it.',
110
+ ],
111
+ };
112
+
113
+ /**
114
+ * Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
115
+ * is one of the ones that may have failed to load.
116
+ *
117
+ * @returns {string}
118
+ */
119
+ function bannerProjectDir() {
120
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
121
+ }
122
+
123
+ /**
124
+ * Bind every repo dependency late, making a load failure VISIBLE (GUARD INACTIVE
125
+ * banner) instead of a silent exit-1 / 0-byte disarm. Throws on any load failure;
126
+ * the entry-point catch banners.
127
+ *
128
+ * `hardening.mjs` is bound (no headFallback — it carries relative imports) for
129
+ * `findScopeFile` / `extractBashWriteTargets` / `pathMatchesPattern`; the two
130
+ * command-matching primitives come from the direct `blocker` namespace, which is
131
+ * the only entry that opts into the `git show HEAD:` recovery. Because hardening
132
+ * transitively imports command-blocker (via scope-gate.mjs), a broken command-blocker
133
+ * fails hardening FIRST (it arms before the headFallback entry) — so that case
134
+ * degrades straight to GUARD INACTIVE, git or no git.
135
+ *
136
+ * @returns {Promise<void>}
137
+ */
138
+ async function bootstrap() {
139
+ const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
140
+
141
+ const { armGuard } = await import('./_lib/guard-source-loader.mjs');
142
+ const { modules, degraded } = await armGuard(
143
+ {
144
+ io: { specifier: lib('io.mjs') },
145
+ platform: { specifier: lib('platform.mjs') },
146
+ common: { specifier: lib('common.mjs') },
147
+ hardening: { specifier: lib('hardening.mjs') },
148
+ blocker: {
149
+ specifier: lib('command-blocker.mjs'),
150
+ headFallback: true,
151
+ requires: ['commandMatchesBlocked', 'suggestForCommandBlock'],
152
+ },
153
+ },
154
+ {
155
+ hookName: HOOK_NAME,
156
+ repoRoot: PLUGIN_ROOT,
157
+ projectDir: bannerProjectDir(),
158
+ consequence: GUARD_CONSEQUENCE,
159
+ }
160
+ );
161
+
162
+ ({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
163
+ ({ resolveProjectDir } = modules.platform);
164
+ ({ readJson } = modules.common);
165
+ ({ findScopeFile, extractBashWriteTargets, pathMatchesPattern } = modules.hardening);
166
+ blocker = modules.blocker;
167
+ degradedLabels = degraded;
168
+ }
169
+
170
+ /**
171
+ * Flush the aggregated allow-with-notice channel (#1001).
172
+ *
173
+ * Notices raised during gate evaluation accumulate in `notices` and are emitted
174
+ * ONCE, here, on the VISIBLE stdout channel via `emitWarn` (allow-with-notice) —
175
+ * else a plain `emitAllow`. Both exit 0 and never return, so this is always the
176
+ * LAST statement on an allow path.
177
+ *
178
+ * Why aggregate instead of `emitWarn`-ing inline at the degraded site: `emitWarn`
179
+ * is `@returns never` (scripts/lib/io.mjs), so an inline call before the G6
180
+ * blocked-pattern loop would exit BEFORE any pattern was matched — turning every
181
+ * would-be DENY into an ALLOW-with-notice for the whole degraded session. A deny,
182
+ * when it fires, exits via `emitDeny` and these notices are simply dropped: DENY
183
+ * wins, and armGuard's stderr banner remains for CI/debug.
184
+ *
185
+ * @param {string[]} notices
186
+ * @returns {never}
187
+ */
188
+ function flushNotices(notices) {
189
+ return notices.length > 0 ? emitWarn(notices.join('\n')) : emitAllow();
190
+ }
49
191
 
50
192
  // Fallback safety list — applied when scope.blockedCommands is empty.
51
193
  // Keep in sync with hooks/enforce-commands.sh; v3 additions (#138, SECURITY-REQ-07)
@@ -71,11 +213,29 @@ async function main() {
71
213
  const command = input?.tool_input?.command;
72
214
  if (typeof command !== 'string' || command.length === 0) return emitAllow();
73
215
 
216
+ // #1001 — aggregated allow-with-notice channel, opened only AFTER G1/G2 (a
217
+ // non-Bash tool call or an empty command is not this hook's business, and must
218
+ // stay a bare allow so the notice does not ride every unrelated tool call).
219
+ // Flushed ONCE at the end of whichever allow path is taken; see flushNotices
220
+ // for why an inline emitWarn here would disarm the G6 deny below.
221
+ const notices = [];
222
+ // A guard armed from HEAD (working-tree module unparseable or shape-invalid) is
223
+ // a visible-channel concern: the DEGRADED banner rides stderr only, which exit 0
224
+ // discards. armGuard already fired that banner once per session; surface it on
225
+ // stdout too so a degraded ALLOW is not silently indistinguishable from a
226
+ // healthy one.
227
+ if (degradedLabels.length > 0) {
228
+ notices.push(
229
+ `${HOOK_NAME}: DEGRADED — guard module(s) loaded from HEAD, not your working tree ` +
230
+ `(${degradedLabels.join(', ')}); uncommitted changes to them are NOT in effect. See #992.`
231
+ );
232
+ }
233
+
74
234
  const projectRoot = resolveProjectDir();
75
235
 
76
236
  // G3 — no scope file → allow
77
237
  const scopePath = findScopeFile(projectRoot);
78
- if (!scopePath) return emitAllow();
238
+ if (!scopePath) return flushNotices(notices);
79
239
 
80
240
  // SECURITY-REQ-08: read scope file exactly once; use the parsed object
81
241
  // for all subsequent gate checks.
@@ -106,30 +266,35 @@ async function main() {
106
266
  }
107
267
 
108
268
  // G4 — gate disabled → allow
109
- if (!gateOn) return emitAllow();
269
+ if (!gateOn) return flushNotices(notices);
110
270
  // G5 — enforcement "off" → allow
111
- if (enforcement === 'off') return emitAllow();
271
+ if (enforcement === 'off') return flushNotices(notices);
112
272
 
113
273
  // G6 — determine which list to check
114
274
  const useFallback = blockedCommands.length === 0;
115
275
  const patternsToCheck = useFallback ? FALLBACK_BLOCKED : blockedCommands;
116
276
 
117
277
  for (const pattern of patternsToCheck) {
118
- if (commandMatchesBlocked(command, pattern)) {
278
+ if (blocker.commandMatchesBlocked(command, pattern)) {
119
279
  const prefix = useFallback
120
280
  ? 'Blocked by fallback safety list'
121
281
  : 'Blocked command';
122
282
  const reason = `${prefix}: '${pattern}' found in command`;
123
- const suggestion = suggestForCommandBlock(pattern);
283
+ const suggestion = blocker.suggestForCommandBlock(pattern);
124
284
  if (enforcement === 'strict') {
125
285
  return emitDeny(reason, suggestion);
126
286
  }
127
- return emitWarn(`${reason} ${suggestion}`);
287
+ // Aggregated, not inline: when the guard is DEGRADED the notice list is
288
+ // non-empty and both lines ride ONE envelope (a second emitWarn would be
289
+ // unreachable — emitWarn never returns). Undegraded, notices is empty and
290
+ // this stays the single-line warn it has always been.
291
+ notices.push(`${reason} — ${suggestion}`);
292
+ return flushNotices(notices);
128
293
  }
129
294
  }
130
295
 
131
296
  // G7 — no match → allow
132
- return emitAllow();
297
+ return flushNotices(notices);
133
298
  }
134
299
 
135
300
  /**
@@ -181,6 +346,40 @@ function targetInWaveScope(target, allowedPaths, projectRoot) {
181
346
  );
182
347
  }
183
348
 
349
+ // ---------------------------------------------------------------------------
350
+ // Entry point (#993)
351
+ //
352
+ // TWO distinct failure classes, two distinct handlers — do NOT merge them:
353
+ //
354
+ // 1. LOAD failure (`bootstrap()` throws): the guard never armed. Under the
355
+ // exit-0 protocol a bare exit-1 crash with 0 bytes of stdout is, on the
356
+ // only decision-bearing channel, indistinguishable from an allow. Now it
357
+ // exits 0 (still fail-OPEN — a broken module must not brick the session,
358
+ // and emitDeny itself may be the module that failed to load) but SAYS SO,
359
+ // loudly: GUARD INACTIVE.
360
+ // 2. RUNTIME failure inside `main()`: pre-existing behaviour, unchanged. The
361
+ // guard armed and then tripped over a specific command; that fails CLOSED
362
+ // via emitDeny (SECURITY-REQ-01). The two paths MUST stay separate.
363
+ // ---------------------------------------------------------------------------
364
+ try {
365
+ await bootstrap();
366
+ } catch (loadError) {
367
+ try {
368
+ const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
369
+ // hookName is threaded explicitly (#993 — no hard-wired literal in the loader).
370
+ emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
371
+ } catch {
372
+ // Last resort: even the banner helper failed to load. Emit unconditionally —
373
+ // repeated noise beats a silent disarm.
374
+ process.stderr.write(
375
+ '🚨 enforce-commands: GUARD INACTIVE — module load failed ' +
376
+ `(${String(loadError?.message || loadError).split('\n')[0]}). ` +
377
+ 'Blocked Bash commands are NOT being screened. See issue #993.\n'
378
+ );
379
+ }
380
+ process.exit(0); // fail-open, but no longer fail-silent
381
+ }
382
+
184
383
  // SECURITY-REQ-01 (F-03): top-level try/catch — never let exit 1 leak.
185
384
  main().catch((e) => {
186
385
  emitDeny('Internal hook error — request blocked for safety', `${e?.message || e}`);
@@ -48,20 +48,110 @@
48
48
 
49
49
  import path from 'node:path';
50
50
  import { promises as fs } from 'node:fs';
51
+ import { pathToFileURL } from 'node:url';
51
52
 
52
53
  import { shouldRunHook } from './_lib/profile-gate.mjs';
53
54
  // #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
54
55
  if (!shouldRunHook('enforce-scope')) process.exit(0);
55
56
 
56
- import { readStdin, emitAllow, emitDeny, emitWarn } from '../scripts/lib/io.mjs';
57
- import { isPathInside, relativeFromRoot } from '../scripts/lib/path-utils.mjs';
58
- import { resolveProjectDir } from '../scripts/lib/platform.mjs';
59
- import {
60
- findScopeFile,
61
- pathMatchesPattern,
62
- suggestForScopeViolation,
63
- } from '../scripts/lib/hardening.mjs';
64
- import { readJson } from '../scripts/lib/common.mjs';
57
+ // ---------------------------------------------------------------------------
58
+ // #993 late-bound repo dependencies
59
+ //
60
+ // These used to be STATIC imports. A SyntaxError in any of them failed at ESM
61
+ // LINK time, before the first statement here ran: node exited 1 with 0 bytes on
62
+ // stdout, and the `main().catch(...)` handler at the bottom of this file was
63
+ // structurally unreachable. Under the exit-0 PreToolUse protocol (#906) that
64
+ // crash is, on the only decision-bearing channel, INDISTINGUISHABLE from an
65
+ // explicit `emitAllow()` the guard failed open and SILENTLY. This is the
66
+ // sibling defect #992 fixed for pre-bash-destructive-guard; #993 generalises the
67
+ // same repair here.
68
+ //
69
+ // Binding them late (dynamic `import()` inside `bootstrap()`, below) turns that
70
+ // link-time crash into a catchable runtime error, which is what makes the
71
+ // GUARD INACTIVE banner in `_lib/guard-source-loader.mjs` reachable at all.
72
+ //
73
+ // BANNER-ONLY (#993 D1): this hook consumes ZERO command-blocker symbols, so no
74
+ // module here opts into the `git show HEAD:` fallback — every load failure
75
+ // degrades straight to GUARD INACTIVE, never DEGRADED.
76
+ //
77
+ // `profile-gate.mjs` and `node:*` builtins stay static — they cannot be the
78
+ // broken repo module.
79
+ // ---------------------------------------------------------------------------
80
+ /** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
81
+ /** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
82
+ /** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
83
+ /** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
84
+ let isPathInside;
85
+ let relativeFromRoot;
86
+ let resolveProjectDir;
87
+ let findScopeFile;
88
+ let pathMatchesPattern;
89
+ let suggestForScopeViolation;
90
+ let readJson;
91
+
92
+ const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
93
+
94
+ /** This hook's name — threaded into the guard banner (#993: no hard-wired literal). */
95
+ const HOOK_NAME = 'enforce-scope';
96
+
97
+ /**
98
+ * The consequence block spliced VERBATIM into the GUARD INACTIVE banner (#993),
99
+ * naming the enforcement this hook's outage stops applying. No `degraded` block:
100
+ * this hook has no headFallback module, so it can never DEGRADE — only go INACTIVE.
101
+ */
102
+ const GUARD_CONSEQUENCE = {
103
+ inactive: [
104
+ ' Consequence: Edit/Write/MultiEdit scope enforcement is OFF — writes',
105
+ ' outside the wave allowedPaths (and outside the project root) are NOT',
106
+ ' being blocked. This is a BROKEN GUARD, not a policy decision — do not',
107
+ ' route around it, repair it.',
108
+ ],
109
+ };
110
+
111
+ /**
112
+ * Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
113
+ * is one of the ones that may have failed to load.
114
+ *
115
+ * @returns {string}
116
+ */
117
+ function bannerProjectDir() {
118
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
119
+ }
120
+
121
+ /**
122
+ * Bind every repo dependency late, making a load failure VISIBLE (GUARD INACTIVE
123
+ * banner) instead of a silent exit-1 / 0-byte disarm. Throws on any load failure;
124
+ * the entry-point catch banners. No entry opts into headFallback — this hook is
125
+ * banner-only (#993 D1).
126
+ *
127
+ * @returns {Promise<void>}
128
+ */
129
+ async function bootstrap() {
130
+ const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
131
+
132
+ const { armGuard } = await import('./_lib/guard-source-loader.mjs');
133
+ const { modules } = await armGuard(
134
+ {
135
+ io: { specifier: lib('io.mjs') },
136
+ pathUtils: { specifier: lib('path-utils.mjs') },
137
+ platform: { specifier: lib('platform.mjs') },
138
+ hardening: { specifier: lib('hardening.mjs') },
139
+ common: { specifier: lib('common.mjs') },
140
+ },
141
+ {
142
+ hookName: HOOK_NAME,
143
+ repoRoot: PLUGIN_ROOT,
144
+ projectDir: bannerProjectDir(),
145
+ consequence: GUARD_CONSEQUENCE,
146
+ }
147
+ );
148
+
149
+ ({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
150
+ ({ isPathInside, relativeFromRoot } = modules.pathUtils);
151
+ ({ resolveProjectDir } = modules.platform);
152
+ ({ findScopeFile, pathMatchesPattern, suggestForScopeViolation } = modules.hardening);
153
+ ({ readJson } = modules.common);
154
+ }
65
155
 
66
156
  async function main() {
67
157
  // SECURITY-REQ-01: null-guard empty stdin — treat as allow (no input = not a real hook call)
@@ -264,6 +354,40 @@ function matchesAbsoluteAllowlist(resolvedPath, allowedPaths) {
264
354
  return abs.some((pat) => pathMatchesPattern(normalizedAbs, pat.split(path.sep).join('/')));
265
355
  }
266
356
 
357
+ // ---------------------------------------------------------------------------
358
+ // Entry point (#993)
359
+ //
360
+ // TWO distinct failure classes, two distinct handlers — do NOT merge them:
361
+ //
362
+ // 1. LOAD failure (`bootstrap()` throws): the guard never armed. Under the
363
+ // exit-0 protocol a bare exit-1 crash with 0 bytes of stdout is, on the
364
+ // only decision-bearing channel, indistinguishable from an allow. Now it
365
+ // exits 0 (still fail-OPEN — a broken module must not brick the session,
366
+ // and emitDeny itself may be the module that failed to load) but SAYS SO:
367
+ // GUARD INACTIVE. Banner-only — no headFallback module here (#993 D1).
368
+ // 2. RUNTIME failure inside `main()`: pre-existing behaviour, unchanged. The
369
+ // guard armed and then tripped over a specific path; that fails CLOSED via
370
+ // emitDeny (SECURITY-REQ-01). The two paths MUST stay separate.
371
+ // ---------------------------------------------------------------------------
372
+ try {
373
+ await bootstrap();
374
+ } catch (loadError) {
375
+ try {
376
+ const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
377
+ // hookName is threaded explicitly (#993 — no hard-wired literal in the loader).
378
+ emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
379
+ } catch {
380
+ // Last resort: even the banner helper failed to load. Emit unconditionally —
381
+ // repeated noise beats a silent disarm.
382
+ process.stderr.write(
383
+ '🚨 enforce-scope: GUARD INACTIVE — module load failed ' +
384
+ `(${String(loadError?.message || loadError).split('\n')[0]}). ` +
385
+ 'Edit/Write/MultiEdit scope enforcement is OFF. See issue #993.\n'
386
+ );
387
+ }
388
+ process.exit(0); // fail-open, but no longer fail-silent
389
+ }
390
+
267
391
  // SECURITY-REQ-01 (fail-closed): any unhandled rejection → structured deny, never bare exit 1
268
392
  main().catch((e) => {
269
393
  emitDeny(
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v3.19.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v3.20.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
11
11
  "async": false
12
12
  },
13
13
  {
package/hooks/hooks.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "echo '🎯 Session Orchestrator v3.19.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v3.20.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
10
  "async": false
11
11
  },
12
12
  {
@@ -458,12 +458,15 @@ async function main() {
458
458
  let bannerData = null;
459
459
  if (await isHostBannerEnabled(projectRoot)) {
460
460
  bannerData = await emitHostBanner(projectRoot);
461
- // Always-on nudge: user decisions must go through AskUserQuestion, not inline
462
- // markdown lists. The coordinator chat stream is dense and prose questions
463
- // are reliably missed. Full rationale + exceptions in .claude/rules/ask-via-tool.md.
461
+ // Always-on nudge: a user decision has three legitimate forms and AUQ-001
462
+ // routes between them in order operator verb first (nothing is blocked
463
+ // while the operator picks his moment), then derive-and-report from Session
464
+ // Config / STATE.md / git, and only then the tool. The banner carries the
465
+ // ORDER, not an absolute; full routing + exceptions in
466
+ // .claude/rules/ask-via-tool.md.
464
467
  try {
465
468
  console.log(JSON.stringify({
466
- systemMessage: '🎯 User decisions AskUserQuestion tool. Inline choice lists = bug (.claude/rules/ask-via-tool.md).',
469
+ systemMessage: '🎯 Decide: operator verb (/go) > derive+report > AUQ if blocking (.claude/rules/ask-via-tool.md).',
467
470
  }));
468
471
  } catch { /* best effort */ }
469
472
  }