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
@@ -123,7 +123,12 @@
123
123
  * "If file exists and the -a flag is not specified, the file will be
124
124
  * overwritten"), while the verb is whatever `time` goes on to run. The target
125
125
  * is therefore invisible in both `verb` and `args`, which is why
126
- * `/usr/bin/time -o <ledger> npm test` was allowed and now denies. The bash
126
+ * `/usr/bin/time -o <ledger> npm test` was allowed and now denies.
127
+ * `resolveSegmentVerb` itself supplies the file-vs-option semantics: each
128
+ * write-destination operand is flagged `writesFile: true` from its per-wrapper
129
+ * `fileArgFlags` table (#996.1), so this guard keys on that flag directly and no
130
+ * longer keeps a local `<wrapper>:<flag>` pair list that had to track the lexer's
131
+ * grammar by hand. The bash
127
132
  * KEYWORD `time` is unaffected: it rejects the flag outright
128
133
  * (`bash -c 'time -o x echo hi'` → "-o: command not found"), so only
129
134
  * `/usr/bin/time`, `command time` and `env time` can carry it at all.
@@ -181,6 +186,15 @@
181
186
  * through. Under the #906 exit-0 protocol, "fail-open" is literally exit 0 with
182
187
  * no stdout envelope — the harness reads no decision and proceeds.
183
188
  *
189
+ * A LOAD failure is a distinct class since #993: the repo dependencies (`io.mjs`,
190
+ * `command-blocker.mjs`) are bound LATE (dynamic `import()` in `bootstrap()`), so
191
+ * a link-time SyntaxError in either becomes a catchable runtime error that
192
+ * banners GUARD INACTIVE on stderr instead of the pre-#993 exit-1 / 0-byte crash
193
+ * that disarmed the guard invisibly under the exit-0 protocol. The
194
+ * `command-blocker` half additionally recovers its COMMITTED source via
195
+ * `git show HEAD:` (banner: DEGRADED, guard still armed against HEAD) — the whole
196
+ * mechanism lives in `_lib/guard-source-loader.mjs` (`armGuard`).
197
+ *
184
198
  * ## Override
185
199
  *
186
200
  * SO_DISABLED_HOOKS=pre-bash-sessions-ledger-guard (session-level)
@@ -191,24 +205,141 @@
191
205
  * failure this hook exists to replace.
192
206
  */
193
207
 
194
- import { readStdin, emitAllow, emitDeny } from '../scripts/lib/io.mjs';
195
- // Single direct import from the source module (#991), matching the precedent in
196
- // hooks/pre-bash-destructive-guard.mjs: the hardening.mjs barrel re-exports
197
- // tokenizeCommand from this very module (same instance either way) but
198
- // deliberately does NOT re-export the #982/#983 primitives, so importing the
199
- // lexer from the barrel and the wrapper resolver from the source would give
200
- // this hook two dependency edges to one module. The barrel is unchanged.
201
- import {
202
- tokenizeCommand,
203
- resolveSegmentVerb,
204
- splitChainSegments,
205
- } from '../scripts/lib/command-blocker.mjs';
208
+ import { spawnSync } from 'node:child_process';
206
209
  import path from 'node:path';
210
+ import { pathToFileURL } from 'node:url';
207
211
 
208
212
  import { shouldRunHook } from './_lib/profile-gate.mjs';
209
213
  // #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
210
214
  if (!shouldRunHook('pre-bash-sessions-ledger-guard')) process.exit(0);
211
215
 
216
+ // ---------------------------------------------------------------------------
217
+ // #993 — late-bound repo dependencies
218
+ //
219
+ // `io.mjs` (readStdin/emitAllow/emitDeny) and `command-blocker.mjs`
220
+ // (tokenizeCommand/resolveSegmentVerb/splitChainSegments) used to be STATIC
221
+ // imports. A SyntaxError in either failed at ESM LINK time, before the first
222
+ // statement here ran: node exited 1 with 0 bytes on stdout, and the
223
+ // `main().catch(...)` handler at the bottom of this file was structurally
224
+ // unreachable. Under the exit-0 PreToolUse protocol (#906) that crash is, on the
225
+ // only decision-bearing channel, INDISTINGUISHABLE from an explicit
226
+ // `emitAllow()` — the guard failed open and silently.
227
+ //
228
+ // This hook is fail-open BY DESIGN (a nudge, not a boundary — see the module
229
+ // docblock), so a silent disarm is a smaller loss here than in the
230
+ // destructive-guard. It is still a loss: the #958 corruption class stops being
231
+ // caught with no sign it stopped. Binding these late (dynamic `import()` inside
232
+ // `bootstrap()`) turns the link-time crash into a catchable runtime error, which
233
+ // is what makes the GUARD INACTIVE banner in `_lib/guard-source-loader.mjs`
234
+ // reachable at all.
235
+ //
236
+ // `command-blocker.mjs` is held as a NAMESPACE object (`blocker.*`) rather than
237
+ // three destructured bindings on purpose: the required-export list then exists in
238
+ // exactly one place — the `requires` array on the `blocker` spec passed to
239
+ // `armGuard` — which validates BOTH the working-tree copy and the HEAD copy
240
+ // against it, so a partial namespace banners GUARD INACTIVE instead of arming a
241
+ // guard that fails open per command.
242
+ //
243
+ // `profile-gate.mjs` stays static on purpose — it has ZERO imports of its own and
244
+ // gates whether this hook runs at all.
245
+ // ---------------------------------------------------------------------------
246
+ /** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
247
+ /** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
248
+ /** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
249
+ /** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
250
+ /**
251
+ * The whole `command-blocker.mjs` namespace (#991: one direct import path, not
252
+ * via the hardening.mjs barrel, which deliberately does NOT re-export the
253
+ * #982/#983 primitives). Held as ONE object rather than destructured so the
254
+ * required-export set lives only on the `blocker` spec's `requires` array (#993).
255
+ *
256
+ * @type {Record<string, Function>|null}
257
+ */
258
+ let blocker = null;
259
+
260
+ /**
261
+ * Module labels `armGuard` recovered from HEAD because the working-tree copy
262
+ * failed (parse error OR shape check). Non-empty ⇒ this hook is analysing
263
+ * commands with the COMMITTED lexer. Surfaced on the visible stdout channel by
264
+ * {@link flushNotices} (#1001) — the stderr DEGRADED banner alone is discarded
265
+ * under the exit-0 protocol, which made a degraded ALLOW indistinguishable from
266
+ * a healthy one.
267
+ *
268
+ * @type {string[]}
269
+ */
270
+ let degradedLabels = [];
271
+
272
+ const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
273
+
274
+ /** This hook's name — threaded into the guard banners (#993: no hard-wired literal). */
275
+ const HOOK_NAME = 'pre-bash-sessions-ledger-guard';
276
+
277
+ /**
278
+ * Consequence prose spliced VERBATIM into the DEGRADED and GUARD INACTIVE banners
279
+ * (#993). This hook is fail-open by design, so the `inactive` text says so plainly
280
+ * rather than borrowing the destructive-guard's "do not route around it" framing.
281
+ */
282
+ const GUARD_CONSEQUENCE = {
283
+ degraded: [
284
+ ' Consequence: ledger-write enforcement IS still armed, but it is evaluating the',
285
+ ' COMMITTED (HEAD) command lexer — any uncommitted change to that file is NOT in effect.',
286
+ ],
287
+ inactive: [
288
+ ' Consequence: a direct shell write into .orchestrator/metrics/sessions.jsonl',
289
+ ' (>, >>, tee, dd of=, cp/mv destination) is NOT being blocked. This hook is a',
290
+ ' fail-open nudge, not a security boundary — but repair it so the #958 corruption',
291
+ ' class stays caught.',
292
+ ],
293
+ };
294
+
295
+ /**
296
+ * Project dir for banner keying, resolved WITHOUT any repo module — those are the
297
+ * ones that may have failed to load.
298
+ *
299
+ * @returns {string}
300
+ */
301
+ function bannerProjectDir() {
302
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
303
+ }
304
+
305
+ /**
306
+ * Bind every repo dependency. Throws on any load failure; the caller banners.
307
+ *
308
+ * `io` gets NO HEAD fallback (a missing export surfaces as a plain TypeError at
309
+ * its single call site — no half-armed guard to protect against). `blocker` opts
310
+ * into the `git show HEAD:` recovery (it is dependency-free — its only import is
311
+ * `node:path`) and carries the COMPLETE required-export set, so a partial
312
+ * namespace banners GUARD INACTIVE rather than arming a guard that fails open per
313
+ * command.
314
+ *
315
+ * @returns {Promise<void>}
316
+ */
317
+ async function bootstrap() {
318
+ const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
319
+
320
+ const { armGuard } = await import('./_lib/guard-source-loader.mjs');
321
+ const { modules, degraded } = await armGuard(
322
+ {
323
+ io: { specifier: lib('io.mjs') },
324
+ blocker: {
325
+ specifier: lib('command-blocker.mjs'),
326
+ headFallback: true,
327
+ requires: ['tokenizeCommand', 'resolveSegmentVerb', 'splitChainSegments'],
328
+ },
329
+ },
330
+ {
331
+ hookName: HOOK_NAME,
332
+ repoRoot: PLUGIN_ROOT,
333
+ projectDir: bannerProjectDir(),
334
+ consequence: GUARD_CONSEQUENCE,
335
+ }
336
+ );
337
+
338
+ ({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
339
+ blocker = modules.blocker;
340
+ degradedLabels = degraded;
341
+ }
342
+
212
343
  // ---------------------------------------------------------------------------
213
344
  // Constants
214
345
  // ---------------------------------------------------------------------------
@@ -216,6 +347,10 @@ if (!shouldRunHook('pre-bash-sessions-ledger-guard')) process.exit(0);
216
347
  /** The ledger's filename. A write target matches on basename equality. */
217
348
  const LEDGER_BASENAME = 'sessions.jsonl';
218
349
 
350
+ /** The repair CLI whose effective apply mode mutates the sessions ledger internally. */
351
+ const REPAIR_SCRIPT_BASENAME = 'repair-invalid-sessions.mjs';
352
+ const REPAIR_APPLY_MARKER = `${REPAIR_SCRIPT_BASENAME} --apply`;
353
+
219
354
  /**
220
355
  * How much of the offending target may appear in the deny reason.
221
356
  *
@@ -230,19 +365,6 @@ const TARGET_ECHO_MAX = 200;
230
365
  /** Verbs whose LAST non-flag argument is a write destination. */
231
366
  const DEST_LAST_VERBS = new Set(['cp', 'mv']);
232
367
 
233
- /**
234
- * Wrapper flags from {@link resolveSegmentVerb}'s `wrapperArgs` whose operand is
235
- * a FILE the wrapper itself writes, keyed `<wrapper>:<flag>`.
236
- *
237
- * Deliberately an explicit pair list rather than "any argFlag operand": most
238
- * value-taking wrapper flags name something that is NOT a path — `sudo -u` a
239
- * user, `nice -n` a niceness, `timeout -k` a duration, `env -u` a variable, and
240
- * `stdbuf -o` a BUFFERING MODE (`0`, `L`, `4096`), which shares its spelling
241
- * with `time -o` and would be the obvious false positive of a blanket rule.
242
- * Only `time -o`/`--output` opens a file for writing.
243
- */
244
- const WRAPPER_FILE_FLAGS = new Set(['time:-o', 'time:--output']);
245
-
246
368
  /**
247
369
  * How deep to follow a wrapper's command-string payload (`env -S '…'`).
248
370
  *
@@ -282,6 +404,147 @@ function refersToLedger(target) {
282
404
  return path.posix.basename(normalized) === LEDGER_BASENAME;
283
405
  }
284
406
 
407
+ /**
408
+ * Does this token name the repair CLI by its immediate script operand basename?
409
+ *
410
+ * @param {string} target
411
+ * @returns {boolean}
412
+ */
413
+ function refersToRepairScript(target) {
414
+ if (typeof target !== 'string' || target.length === 0) return false;
415
+ const normalized = target.replace(/\\/g, '/');
416
+ return path.posix.basename(normalized) === REPAIR_SCRIPT_BASENAME;
417
+ }
418
+
419
+ /**
420
+ * Node's own `--help` output is the source of truth for the runtime-option
421
+ * grammar. A hand-maintained option set is necessarily stale as Node adds
422
+ * runtime flags (for example, Node 24's `--experimental-worker-inspection`).
423
+ *
424
+ * The parser only records option names and whether the help specification says
425
+ * the option takes a value. Optional inline values such as `--inspect[=...]`
426
+ * deliberately do not consume the next token: Node treats a separated token as
427
+ * the script in that form. Aliases inherit the value-taking shape of the whole
428
+ * help row (`--loader, --experimental-loader=...`).
429
+ *
430
+ * @returns {{ available: true, grammar: Map<string, { takesNextValue: boolean }> } |
431
+ * { available: false, reason: string }}
432
+ */
433
+ function loadNodeOptionGrammar() {
434
+ const result = spawnSync(process.execPath, ['--help'], {
435
+ encoding: 'utf8',
436
+ maxBuffer: 2 * 1024 * 1024,
437
+ });
438
+ if (result.error || result.status !== 0 || typeof result.stdout !== 'string') {
439
+ return { available: false, reason: 'spawn-failed' };
440
+ }
441
+ if (!/^Usage: node \[options\]/m.test(result.stdout)) {
442
+ return { available: false, reason: 'malformed-help' };
443
+ }
444
+
445
+ const grammar = new Map();
446
+ for (const line of result.stdout.split('\n')) {
447
+ const trimmed = line.trim();
448
+ if (!trimmed.startsWith('-')) continue;
449
+
450
+ const specification = trimmed.split(/\s{2,}/, 1)[0];
451
+ const aliases = specification.split(',').map((alias) => alias.trim());
452
+ const names = aliases
453
+ .map((alias) => /^(-{1,2}[A-Za-z0-9][A-Za-z0-9-]*)/.exec(alias)?.[1])
454
+ .filter((name) => typeof name === 'string');
455
+ if (names.length === 0) continue;
456
+
457
+ const takesNextValue = aliases.some((alias) => {
458
+ const equals = alias.indexOf('=');
459
+ const optionalValue = alias.indexOf('[');
460
+ return equals >= 0 && (optionalValue < 0 || equals < optionalValue);
461
+ });
462
+ for (const name of names) grammar.set(name, { takesNextValue });
463
+ }
464
+
465
+ // These stable Node help entries make a truncated or otherwise incomplete
466
+ // output unavailable rather than an empty grammar that silently allows.
467
+ if (!grammar.has('--version') || !grammar.has('--eval')) {
468
+ return { available: false, reason: 'malformed-help' };
469
+ }
470
+ return { available: true, grammar };
471
+ }
472
+
473
+ /** Parsed lazily so ordinary non-Node Bash commands do not spawn a child process. */
474
+ let nodeOptionGrammar;
475
+
476
+ /**
477
+ * Return Node's runtime-option grammar, loading it only when a Node command
478
+ * actually presents an option token for resolution.
479
+ *
480
+ * @returns {{ available: true, grammar: Map<string, { takesNextValue: boolean }> } |
481
+ * { available: false, reason: string }}
482
+ */
483
+ function getNodeOptionGrammar() {
484
+ if (!nodeOptionGrammar) nodeOptionGrammar = loadNodeOptionGrammar();
485
+ return nodeOptionGrammar;
486
+ }
487
+
488
+ /** Node modes in which a later token is not an executing script operand. */
489
+ const NODE_EVAL_OPTIONS = new Set(['-e', '--eval', '-p', '--print']);
490
+ const NODE_CHECK_OPTIONS = new Set(['-c', '--check']);
491
+
492
+ /**
493
+ * Return the option name without an inline `=value` suffix.
494
+ *
495
+ * @param {string} text
496
+ * @returns {string}
497
+ */
498
+ function nodeOptionName(text) {
499
+ const equals = text.indexOf('=');
500
+ return equals > 0 ? text.slice(0, equals) : text;
501
+ }
502
+
503
+ /**
504
+ * Resolve Node's actual script operand from tokens immediately following `node`.
505
+ *
506
+ * Values consumed by the runtime's own help-derived options are never candidates
507
+ * for the script. Eval/print/check modes have no executing script operand for
508
+ * this guard's contract, so their following tokens cannot activate the repair
509
+ * mutation rule. Unknown options stop resolution rather than causing a later
510
+ * argument to be searched.
511
+ *
512
+ * @param {Array<{ text: string, quoted: boolean }>} args
513
+ * @returns {{ scriptIndex: number }|{ unavailable: true, reason: string }|null}
514
+ */
515
+ function resolveNodeScriptOperand(args) {
516
+ for (let i = 0; i < args.length;) {
517
+ const text = args[i].text;
518
+ const name = nodeOptionName(text);
519
+
520
+ if (text === '--') return i + 1 < args.length ? { scriptIndex: i + 1 } : null;
521
+
522
+ if (NODE_EVAL_OPTIONS.has(name)) return null;
523
+ if ((text.startsWith('-e') || text.startsWith('-p')) && !text.startsWith('--') && text.length > 2) {
524
+ return null;
525
+ }
526
+ if (NODE_CHECK_OPTIONS.has(name)) return null;
527
+
528
+ if (!text.startsWith('-')) return { scriptIndex: i };
529
+
530
+ const grammarState = getNodeOptionGrammar();
531
+ if (!grammarState.available) return { unavailable: true, reason: grammarState.reason };
532
+
533
+ const option = grammarState.grammar.get(name);
534
+ if (option?.takesNextValue) {
535
+ i += text.includes('=') ? 1 : 2;
536
+ continue;
537
+ }
538
+ if (option) {
539
+ i++;
540
+ continue;
541
+ }
542
+
543
+ return null;
544
+ }
545
+ return null;
546
+ }
547
+
285
548
  /**
286
549
  * Skip a quoted run that OPENS at `open`.
287
550
  *
@@ -491,56 +754,134 @@ function scanCommand(command) {
491
754
  * OPTIONS are consumed with it and `sudo -u root tee -a <ledger>` reaches the
492
755
  * real verb instead of stopping at `-u`.
493
756
  *
757
+ * DUAL PARSE (#1000). `resolveSegmentVerb` returns an optional `alt` reading —
758
+ * the value-taking interpretation of an unknown dash-flag — whenever the two
759
+ * readings disagree about the verb. Both are judged here and the FIRST hit wins,
760
+ * which is the safe direction: guessing wrong in the boolean direction hid the
761
+ * write verb behind an unrecognised flag, and two commands were MEASURED to slip
762
+ * through that way (`nice --unknown 5 tee -a <ledger>` read the verb as `5`;
763
+ * `env -Q x tee -a <ledger>` read it as `x`). Judging both cannot lose a deny
764
+ * parse A already found — parse A is still evaluated first and unchanged.
765
+ *
494
766
  * @param {string} command - sanitized command
495
767
  * @param {number} [depth] - payload recursion level (see MAX_PAYLOAD_DEPTH)
768
+ * @param {string[]} [marks] - OUT-param accumulator for fail-visible markers
769
+ * (#998): analysis this pass could not complete, e.g. a payload dropped at the
770
+ * MAX_PAYLOAD_DEPTH cut. Threaded through the recursion so a mark raised in a
771
+ * nested payload reaches `main()`. Never affects the return value — the
772
+ * `string|null` contract is unchanged; marks add VISIBILITY, never a deny.
496
773
  * @returns {string|null} the offending target, or null
497
774
  */
498
- function findWriteVerbTarget(command, depth = 0) {
499
- for (const segment of splitChainSegments(tokenizeCommand(command))) {
500
- const { verb, index, payloads, wrapperArgs } = resolveSegmentVerb(segment);
501
-
502
- // A wrapper can write a file WITHOUT being the verb: `/usr/bin/time -o F`
503
- // truncates F while the verb is whatever time runs. Checked before the verb
504
- // dispatch because `time -o <ledger>` alone resolves to verb null.
505
- for (const wa of wrapperArgs) {
506
- if (!WRAPPER_FILE_FLAGS.has(`${wa.wrapper}:${wa.flag}`)) continue;
507
- if (typeof wa.value === 'string' && refersToLedger(wa.value)) return wa.value;
775
+ function findWriteVerbTarget(command, depth = 0, marks = []) {
776
+ for (const segment of blocker.splitChainSegments(blocker.tokenizeCommand(command))) {
777
+ const resolved = blocker.resolveSegmentVerb(segment);
778
+ const readings = [resolved, resolved.alt].filter(Boolean);
779
+ // Payloads are deduped ACROSS readings: both readings usually report the same
780
+ // `env -S '…'` operand, and recursing twice on one string only doubles work.
781
+ const payloadSet = new Set();
782
+
783
+ for (const { payloads, wrapperArgs } of readings) {
784
+ for (const p of payloads) payloadSet.add(p);
785
+
786
+ // A wrapper can write a file WITHOUT being the verb: `/usr/bin/time -o F`
787
+ // truncates F while the verb is whatever time runs. Checked before the verb
788
+ // dispatch because `time -o <ledger>` alone resolves to verb null. The
789
+ // file-vs-option distinction is the LEXER's now (#996.1): resolveSegmentVerb
790
+ // marks a write-destination operand `writesFile: true` from its per-wrapper
791
+ // `fileArgFlags` table (command-blocker.mjs — the writesFile contract, the
792
+ // `resolveSegmentVerb` return docblock), so a local `<wrapper>:<flag>` pair
793
+ // list here is gone. The rationale it encoded — `stdbuf -o` is a BUFFERING
794
+ // MODE, not a file, and `time -o` is the only wrapper flag that opens one —
795
+ // lives beside that table (command-blocker.mjs, the WRAPPER_UNWRAP docblock).
796
+ for (const wa of wrapperArgs) {
797
+ if (wa.writesFile !== true) continue;
798
+ if (typeof wa.value === 'string' && refersToLedger(wa.value)) return wa.value;
799
+ }
508
800
  }
509
801
 
510
802
  // `env -S 'tee -a <ledger>'` hides a whole command line in one operand.
511
803
  // Recurse on the payload with the SAME matcher rather than a second,
512
- // weaker one — bounded by MAX_PAYLOAD_DEPTH.
513
- if (depth < MAX_PAYLOAD_DEPTH) {
514
- for (const payload of payloads) {
515
- const hit = findLedgerWrite(payload, depth + 1);
516
- if (hit) return hit;
804
+ // weaker one — bounded by MAX_PAYLOAD_DEPTH. Kept BEFORE the verb dispatch
805
+ // (pre-#1000 order), so which target a mixed command reports is unchanged.
806
+ if (payloadSet.size > 0) {
807
+ if (depth < MAX_PAYLOAD_DEPTH) {
808
+ for (const payload of payloadSet) {
809
+ const hit = findLedgerWrite(payload, depth + 1, marks);
810
+ if (hit) return hit;
811
+ }
812
+ } else if ([...payloadSet].some((p) => typeof p === 'string' && p.includes(LEDGER_BASENAME))) {
813
+ // The cut USED to be silent (#998 item 2): a payload nested past the cap
814
+ // was dropped with no trace, so an operator could not tell "analysed and
815
+ // clean" from "never looked at". Marked — but ONLY when the dropped
816
+ // payload actually mentions the ledger. An unfiltered mark would turn
817
+ // every ordinary deep-but-benign command into a warn envelope, which is
818
+ // noise this accident-guard must not generate. Still no deny: raising the
819
+ // cap into a deny is a policy change, not a visibility fix.
820
+ marks.push('depth-exceeded');
517
821
  }
518
822
  }
519
823
 
520
- if (!verb) continue;
521
- const args = segment.slice(index + 1);
824
+ for (const { verb, index } of readings) {
825
+ if (!verb) continue;
826
+ // `index` is the verb position OF THIS READING — never mix it with the
827
+ // other's (the lib docblock's positional-walk caveat).
828
+ const args = segment.slice(index + 1);
522
829
 
523
- if (verb === 'tee') {
524
- for (const arg of args) {
525
- if (!arg.quoted && arg.text.startsWith('-')) continue;
526
- if (refersToLedger(arg.text)) return arg.text;
830
+ // The repair CLI mutates the ledger from inside Node, so there is no
831
+ // redirect target for the structural matcher to see. Match only its
832
+ // effective apply contract at the resolved token seam: `node`, the
833
+ // actual script operand after Node runtime options, a later exact
834
+ // `--apply`, and no later exact `--dry-run`. The CLI remains the owner of
835
+ // target semantics.
836
+ if (verb === 'node') {
837
+ const script = resolveNodeScriptOperand(args);
838
+ const hasRepairMention = args.some((arg) => refersToRepairScript(arg.text));
839
+ const hasApply = args.some((arg) => arg.text === '--apply');
840
+ const hasDryRun = args.some((arg) => arg.text === '--dry-run');
841
+
842
+ if (script?.unavailable) {
843
+ // The runtime-derived grammar is an enforcement dependency. If it is
844
+ // unavailable, an ambiguous repair invocation must be visible and
845
+ // denied rather than falling through to the hook's normal allow path.
846
+ if (hasRepairMention && hasApply && !hasDryRun) {
847
+ return `${REPAIR_APPLY_MARKER} (Node option grammar unavailable: ${script.reason}; denied fail-closed)`;
848
+ }
849
+ } else {
850
+ const scriptToken = script && args[script.scriptIndex];
851
+ const laterArgs = script ? args.slice(script.scriptIndex + 1) : [];
852
+ if (
853
+ scriptToken &&
854
+ refersToRepairScript(scriptToken.text) &&
855
+ laterArgs.some((arg) => arg.text === '--apply') &&
856
+ !laterArgs.some((arg) => arg.text === '--dry-run')
857
+ ) {
858
+ return REPAIR_APPLY_MARKER;
859
+ }
860
+ }
527
861
  }
528
- continue;
529
- }
530
862
 
531
- if (verb === 'dd') {
532
- for (const arg of args) {
533
- const m = /^of=(.*)$/.exec(arg.text);
534
- if (m && refersToLedger(m[1])) return m[1];
863
+ if (verb === 'tee') {
864
+ for (const arg of args) {
865
+ if (!arg.quoted && arg.text.startsWith('-')) continue;
866
+ if (refersToLedger(arg.text)) return arg.text;
867
+ }
868
+ continue;
869
+ }
870
+
871
+ if (verb === 'dd') {
872
+ for (const arg of args) {
873
+ const m = /^of=(.*)$/.exec(arg.text);
874
+ if (m && refersToLedger(m[1])) return m[1];
875
+ }
876
+ continue;
535
877
  }
536
- continue;
537
- }
538
878
 
539
- if (DEST_LAST_VERBS.has(verb)) {
540
- const operands = args.filter((a) => a.quoted || !a.text.startsWith('-'));
541
- // `cp a b` writes b; `cp ledger backup` READS the ledger and must pass.
542
- const dest = operands.length >= 2 ? operands[operands.length - 1] : null;
543
- if (dest && refersToLedger(dest.text)) return dest.text;
879
+ if (DEST_LAST_VERBS.has(verb)) {
880
+ const operands = args.filter((a) => a.quoted || !a.text.startsWith('-'));
881
+ // `cp a b` writes b; `cp ledger backup` READS the ledger and must pass.
882
+ const dest = operands.length >= 2 ? operands[operands.length - 1] : null;
883
+ if (dest && refersToLedger(dest.text)) return dest.text;
884
+ }
544
885
  }
545
886
  }
546
887
  return null;
@@ -556,12 +897,16 @@ function findWriteVerbTarget(command, depth = 0) {
556
897
  *
557
898
  * @param {string} command
558
899
  * @param {number} [depth] - payload recursion level (see MAX_PAYLOAD_DEPTH)
900
+ * @param {string[]} [marks] - OUT-param accumulator for fail-visible markers
901
+ * (#998); see {@link findWriteVerbTarget}. Purely additive — the `string|null`
902
+ * return contract is unchanged and a mark never becomes a deny.
559
903
  * @returns {string|null}
560
904
  */
561
- function findLedgerWrite(command, depth = 0) {
905
+ function findLedgerWrite(command, depth = 0, marks = []) {
562
906
  if (typeof command !== 'string' || command.length === 0) return null;
563
- // Cheap pre-filter: no mention of the filename at all nothing to analyse.
564
- if (!command.includes(LEDGER_BASENAME)) return null;
907
+ // Cheap pre-filter: commands naming neither the ledger nor the internal repair
908
+ // CLI cannot reach either deny path.
909
+ if (!command.includes(LEDGER_BASENAME) && !command.includes(REPAIR_SCRIPT_BASENAME)) return null;
565
910
 
566
911
  const scan = scanCommand(command);
567
912
  for (const target of scan.targets) {
@@ -574,7 +919,28 @@ function findLedgerWrite(command, depth = 0) {
574
919
  if (!scan.balanced) {
575
920
  return `${LEDGER_BASENAME} (unbalanced quote — command not parseable, denied fail-closed)`;
576
921
  }
577
- return findWriteVerbTarget(scan.sanitized, depth);
922
+ return findWriteVerbTarget(scan.sanitized, depth, marks);
923
+ }
924
+
925
+ /**
926
+ * Flush the aggregated allow-with-notice channel (#1001).
927
+ *
928
+ * Notices accumulate during matching and are emitted ONCE, here, on the VISIBLE
929
+ * stdout channel via `emitWarn` (allow-with-notice) — else a plain `emitAllow`.
930
+ * Both exit 0 and never return, so this is always the LAST statement on an allow
931
+ * path.
932
+ *
933
+ * Why aggregate instead of `emitWarn`-ing inline: `emitWarn` is `@returns never`
934
+ * (scripts/lib/io.mjs), so an inline call before `findLedgerWrite` would exit
935
+ * BEFORE the matcher ran — turning every would-be DENY into an ALLOW-with-notice.
936
+ * The deny path never reaches here and drops the notices by design: DENY wins,
937
+ * and the stderr copies remain for CI/debug.
938
+ *
939
+ * @param {string[]} notices
940
+ * @returns {never}
941
+ */
942
+ function flushNotices(notices) {
943
+ return notices.length > 0 ? emitWarn(notices.join('\n')) : emitAllow();
578
944
  }
579
945
 
580
946
  // ---------------------------------------------------------------------------
@@ -592,9 +958,39 @@ async function main() {
592
958
  const command = input?.tool_input?.command;
593
959
  if (typeof command !== 'string' || command.length === 0) return emitAllow();
594
960
 
961
+ // #1001 — aggregated allow-with-notice channel, opened only AFTER G1/G2 (a
962
+ // non-Bash call or an empty command is not this hook's business and stays a
963
+ // bare allow). Flushed ONCE on the allow path; see flushNotices for why an
964
+ // inline emitWarn here would disarm the G4 deny below.
965
+ const notices = [];
966
+ // A guard armed from HEAD is a visible-channel concern: the DEGRADED banner
967
+ // rides stderr only, which exit 0 discards. armGuard already fired it once per
968
+ // session; surface it on stdout so a degraded ALLOW is not silently
969
+ // indistinguishable from a healthy one.
970
+ if (degradedLabels.length > 0) {
971
+ notices.push(
972
+ `${HOOK_NAME}: DEGRADED — guard module(s) loaded from HEAD, not your working tree ` +
973
+ `(${degradedLabels.join(', ')}); uncommitted changes to them are NOT in effect. See #992.`
974
+ );
975
+ }
976
+
595
977
  // G3 — matcher. No direct ledger write → allow.
596
- const target = findLedgerWrite(command);
597
- if (!target) return emitAllow();
978
+ const marks = [];
979
+ const target = findLedgerWrite(command, 0, marks);
980
+ if (!target) {
981
+ // Fail-VISIBLE (#998 item 2): the matcher completed without a hit, but part
982
+ // of the command was never analysed. Say so rather than reporting a clean
983
+ // allow. Still an ALLOW — this hook is an accident-guard, fail-open by
984
+ // design, and a depth cut is not evidence of a write.
985
+ if (marks.length > 0) {
986
+ const msg =
987
+ `${HOOK_NAME}: unresolved payload (${[...new Set(marks)].join(', ')}) — ` +
988
+ `not analysed (fail-visible)`;
989
+ process.stderr.write(`⚠ ${msg}\n`);
990
+ notices.push(msg);
991
+ }
992
+ return flushNotices(notices);
993
+ }
598
994
 
599
995
  // G4 — deny. emitDeny writes the envelope with fs.writeSync (#906/#914): a
600
996
  // console.log here would be dropped above the 64 KiB pipe buffer, and a
@@ -617,6 +1013,37 @@ async function main() {
617
1013
  );
618
1014
  }
619
1015
 
1016
+ // ---------------------------------------------------------------------------
1017
+ // Entry point (#993)
1018
+ //
1019
+ // TWO distinct failure classes, two distinct handlers — do not merge them:
1020
+ // 1. LOAD failure (`bootstrap()` throws): the guard never armed, nothing was
1021
+ // evaluated. This used to be a bare exit-1 crash with 0 bytes of stdout —
1022
+ // indistinguishable from an allow, and therefore invisible. Now it exits 0
1023
+ // (still fail-open, so a broken module cannot brick the session) but SAYS SO
1024
+ // via the GUARD INACTIVE banner, once loud on stderr.
1025
+ // 2. RUNTIME failure inside `main()`: pre-existing fail-open behaviour,
1026
+ // unchanged (this hook is fail-open by design — see the module docblock).
1027
+ // ---------------------------------------------------------------------------
1028
+ try {
1029
+ await bootstrap();
1030
+ } catch (loadError) {
1031
+ try {
1032
+ const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
1033
+ // hookName is threaded explicitly (#993 — no hard-wired literal in the loader).
1034
+ emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
1035
+ } catch {
1036
+ // Last resort: even the banner helper failed to load. Emit unconditionally —
1037
+ // repeated noise beats a silent disarm.
1038
+ process.stderr.write(
1039
+ '🚨 pre-bash-sessions-ledger-guard: GUARD INACTIVE — module load failed ' +
1040
+ `(${String(loadError?.message || loadError).split('\n')[0]}). ` +
1041
+ 'Direct shell writes to the sessions ledger are NOT being blocked. See issue #992.\n'
1042
+ );
1043
+ }
1044
+ process.exit(0); // fail-open, but no longer fail-silent
1045
+ }
1046
+
620
1047
  // Top-level error handler — fail-OPEN (see the module docblock). Never let a
621
1048
  // non-zero exit leak: on this protocol exit 0 + empty stdout is "no decision".
622
1049
  main().catch((e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "3.19.0",
3
+ "version": "3.20.0",
4
4
  "description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
5
5
  "type": "module",
6
6
  "homepage": "https://session-orchestrator.com",
@@ -62,7 +62,7 @@
62
62
  "dependencies": {
63
63
  "@babel/parser": "^7.26.0",
64
64
  "ajv": "^8.17.1",
65
- "js-yaml": "^4.2.0",
65
+ "js-yaml": "^4.3.1",
66
66
  "mdast-util-to-string": "^4.0.0",
67
67
  "remark": "^15.0.0",
68
68
  "remark-parse": "^11.0.0",