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
@@ -73,6 +73,7 @@ if (!shouldRunHook('pre-bash-destructive-guard')) process.exit(0);
73
73
  /** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
74
74
  /** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
75
75
  /** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
76
+ /** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
76
77
  let resolveProjectDir;
77
78
  let resolvePluginRoot;
78
79
  /**
@@ -81,10 +82,10 @@ let resolvePluginRoot;
81
82
  * primitives).
82
83
  *
83
84
  * Held as ONE object rather than destructured into six bindings on purpose: the
84
- * required-export list then exists in exactly one place —
85
- * `COMMAND_BLOCKER_EXPORTS` in `_lib/guard-source-loader.mjs`, which validates
86
- * both the working-tree and the HEAD copy against it. The previous split (six
87
- * names destructured here, two of them checked there) is what let a HEAD copy
85
+ * required-export list then exists in exactly one place — the `requires` array
86
+ * on the `blocker` spec passed to `armGuard` (#993), which validates both the
87
+ * working-tree and the HEAD copy against it. The previous split (six names
88
+ * destructured here, two of them checked in the loader) is what let a HEAD copy
88
89
  * missing four exports banner "DEGRADED — still armed" and then fail open on
89
90
  * every command.
90
91
  *
@@ -96,8 +97,38 @@ let loadEffectivePolicy;
96
97
  let isSessionConfigHeading;
97
98
  let emitEvent;
98
99
 
100
+ /**
101
+ * Labels of guard modules that loaded from HEAD rather than the working tree
102
+ * (#993/#995). Populated by `bootstrap()` from `armGuard`'s return; read by
103
+ * `main()`, which pushes a visible-channel DEGRADED notice when it is non-empty —
104
+ * the stderr degradation banner alone is invisible under the exit-0 protocol.
105
+ *
106
+ * @type {string[]}
107
+ */
108
+ let degradedLabels = [];
109
+
99
110
  const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
100
111
 
112
+ /** This hook's name — threaded into both guard banners (#993: no hard-wired literal). */
113
+ const HOOK_NAME = 'pre-bash-destructive-guard';
114
+
115
+ /**
116
+ * The two consequence blocks, spliced VERBATIM into the DEGRADED and GUARD
117
+ * INACTIVE banners (#993). Byte-identical to the pre-#993 inline banner text so
118
+ * the #992 banner-visibility tests stay green.
119
+ */
120
+ const GUARD_CONSEQUENCE = {
121
+ degraded: [
122
+ ' Consequence: destructive-command enforcement IS still armed, but it is evaluating the',
123
+ ' COMMITTED (HEAD) command lexer — any uncommitted change to that file is NOT in effect.',
124
+ ],
125
+ inactive: [
126
+ ' Consequence: destructive Bash commands (git reset --hard, rm -rf, git push --force,',
127
+ ' git stash, redirect-truncate of protected artefacts) are NOT being blocked. This is a',
128
+ ' BROKEN GUARD, not a policy decision — do not route around it, repair it.',
129
+ ],
130
+ };
131
+
101
132
  /**
102
133
  * Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
103
134
  * is one of the ones that may have failed to load.
@@ -117,22 +148,76 @@ function bannerProjectDir() {
117
148
  * @returns {Promise<void>}
118
149
  */
119
150
  async function bootstrap() {
120
- ({ readStdin, emitAllow, emitDeny } = await import('../scripts/lib/io.mjs'));
121
- ({ resolveProjectDir, resolvePluginRoot } = await import('../scripts/lib/platform.mjs'));
122
- ({ readConfigFile } = await import('../scripts/lib/config.mjs'));
123
- ({ loadEffectivePolicy } = await import('../scripts/lib/blocked-commands-policy.mjs'));
124
- ({ isSessionConfigHeading } = await import('../scripts/lib/config/section-extractor.mjs'));
125
- ({ emitEvent } = await import('../scripts/lib/events.mjs'));
126
-
127
- const { loadCommandBlocker } = await import('./_lib/guard-source-loader.mjs');
128
- // Throws unless the loaded namespace carries the COMPLETE required export set
129
- // (working-tree copy or HEAD fallback alike) — the caller then banners
130
- // GUARD INACTIVE rather than arming a guard that cannot evaluate.
131
- ({ module: blocker } = await loadCommandBlocker({
132
- specifier: pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'command-blocker.mjs')).href,
133
- repoRoot: PLUGIN_ROOT,
134
- projectDir: bannerProjectDir(),
135
- }));
151
+ const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
152
+
153
+ const { armGuard } = await import('./_lib/guard-source-loader.mjs');
154
+ // #993: one generic loader for every repo dependency. Only `blocker` opts into
155
+ // the `git show HEAD:` fallback (dependency-free, so data:-URL loadable) and
156
+ // carries the COMPLETE required-export set — validated on the working-tree AND
157
+ // the HEAD copy, so a partial namespace banners GUARD INACTIVE rather than
158
+ // arming a guard that fails open per command. A load failure of any entry
159
+ // throws; the caller banners GUARD INACTIVE.
160
+ const { modules, degraded } = await armGuard(
161
+ {
162
+ io: { specifier: lib('io.mjs') },
163
+ platform: { specifier: lib('platform.mjs') },
164
+ config: { specifier: lib('config.mjs') },
165
+ blockedCommandsPolicy: { specifier: lib('blocked-commands-policy.mjs') },
166
+ sectionExtractor: { specifier: lib('config', 'section-extractor.mjs') },
167
+ events: { specifier: lib('events.mjs') },
168
+ blocker: {
169
+ specifier: lib('command-blocker.mjs'),
170
+ headFallback: true,
171
+ requires: [
172
+ 'tokenizeCommand',
173
+ 'commandMatchesBlocked',
174
+ 'extractRedirectTargets',
175
+ 'redirectRuleMatches',
176
+ 'redirectSpanEnd',
177
+ 'resolveSegmentVerb',
178
+ 'splitChainSegments',
179
+ ],
180
+ },
181
+ },
182
+ {
183
+ hookName: HOOK_NAME,
184
+ repoRoot: PLUGIN_ROOT,
185
+ projectDir: bannerProjectDir(),
186
+ consequence: GUARD_CONSEQUENCE,
187
+ }
188
+ );
189
+
190
+ ({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
191
+ ({ resolveProjectDir, resolvePluginRoot } = modules.platform);
192
+ ({ readConfigFile } = modules.config);
193
+ ({ loadEffectivePolicy } = modules.blockedCommandsPolicy);
194
+ ({ isSessionConfigHeading } = modules.sectionExtractor);
195
+ ({ emitEvent } = modules.events);
196
+ blocker = modules.blocker;
197
+ degradedLabels = degraded;
198
+ }
199
+
200
+ /**
201
+ * Flush the aggregated allow-with-notice channel (#995).
202
+ *
203
+ * Warnings raised during rule evaluation accumulate in `notices` and are emitted
204
+ * ONCE, here, on the VISIBLE stdout channel via `emitWarn` (allow-with-notice) —
205
+ * else a plain `emitAllow`. Both exit 0 and never return, so this is always the
206
+ * last statement on an allow path.
207
+ *
208
+ * Why aggregate instead of `emitWarn`-ing inline at each warn site: `emitWarn`
209
+ * is `@returns never`, so an inline call mid-loop would exit BEFORE a later
210
+ * `block` rule was evaluated — flipping a would-be DENY into an ALLOW-with-notice
211
+ * (measured: `bash <33×-c>` overshoots the recursion cap, and a command that
212
+ * both trips the cap AND carries `rm -rf src/` would wave the delete through).
213
+ * A block, when it fires, exits via `emitDeny` and these notices are simply
214
+ * dropped: DENY wins, and the stderr copies of each notice remain for CI/debug.
215
+ *
216
+ * @param {string[]} notices
217
+ * @returns {never}
218
+ */
219
+ function flushNotices(notices) {
220
+ return notices.length > 0 ? emitWarn(notices.join('\n')) : emitAllow();
136
221
  }
137
222
 
138
223
  // Module-level per-path policy cache (issue #250, extended for the #972
@@ -242,29 +327,6 @@ async function isGitStashNonEmpty(projectDir) {
242
327
  }
243
328
  }
244
329
 
245
- /**
246
- * Given a redirect token at `segment[i]`, return the index of the LAST token
247
- * belonging to that redirect (the operand word, when one exists). Redirect
248
- * operators and their operands name IO targets, not command arguments —
249
- * `rm -rf /tmp/ok > out.log` must not collect `>` or `out.log` as rm targets
250
- * (#983 FP fix). `dup` (`2>&1`) carries no operand word.
251
- *
252
- * @param {Array<{ text: string, quoted: boolean, redirect?: object }>} segment
253
- * @param {number} i — index of the redirect token
254
- * @returns {number}
255
- */
256
- function redirectSpanEnd(segment, i) {
257
- const tok = segment[i];
258
- // `dup` (2>&1) carries its target inline; `heredoc` consumes its delimiter in
259
- // the lexer (the body arrives as a separate QUOTED token) — neither owns an
260
- // operand word, and claiming one would swallow the next real token.
261
- const hasOperandWord = tok.redirect.mode !== 'dup' && tok.redirect.mode !== 'heredoc';
262
- if (hasOperandWord && i + 1 < segment.length && !segment[i + 1].redirect) {
263
- return i + 1;
264
- }
265
- return i;
266
- }
267
-
268
330
  /**
269
331
  * Parse ALL non-flag path arguments from every `rm` invocation in a command.
270
332
  *
@@ -314,7 +376,7 @@ function parseRmTargets(command) {
314
376
  for (let i = index + 1; i < segment.length; i++) {
315
377
  const tok = segment[i];
316
378
  if (tok.redirect) {
317
- const end = redirectSpanEnd(segment, i);
379
+ const end = blocker.redirectSpanEnd(segment, i);
318
380
  const mode = tok.redirect.mode;
319
381
  if ((mode === 'truncate' || mode === 'append') && end > i) {
320
382
  const operand = segment[end];
@@ -388,7 +450,7 @@ function commandHasRecursiveForceRm(command) {
388
450
  let seenDashDash = false;
389
451
  for (let i = index + 1; i < segment.length; i++) {
390
452
  const t = segment[i];
391
- if (t.redirect) { i = redirectSpanEnd(segment, i); continue; }
453
+ if (t.redirect) { i = blocker.redirectSpanEnd(segment, i); continue; }
392
454
  if (!seenDashDash && t.text === '--') { seenDashDash = true; continue; }
393
455
  if (!seenDashDash && !t.quoted && t.text.startsWith('-') && t.text !== '-') {
394
456
  if (t.text === '--recursive') recursive = true;
@@ -675,6 +737,22 @@ async function main() {
675
737
  const projectDir = resolveProjectDir();
676
738
  const sessionId = resolveSessionId(input);
677
739
 
740
+ // #995 — aggregated allow-with-notice channel. Warn-severity matches and
741
+ // fail-visible markers accumulate here (stderr copies are kept for parity) and
742
+ // flush ONCE, on the visible stdout channel, only after EVERY rule has been
743
+ // evaluated. See flushNotices for why inline emitWarn would fail open.
744
+ const notices = [];
745
+ // A guard armed from HEAD (working-tree module unparseable) is a visible-channel
746
+ // concern too: the DEGRADED banner rides stderr only, which exit-0 discards. The
747
+ // stderr banner already fired once-per-session inside armGuard; surface it on
748
+ // stdout so a degraded ALLOW is not silently indistinguishable from a healthy one.
749
+ if (degradedLabels.length > 0) {
750
+ notices.push(
751
+ `${HOOK_NAME}: DEGRADED — guard module(s) loaded from HEAD, not your working tree ` +
752
+ `(${degradedLabels.join(', ')}); uncommitted changes to them are NOT in effect. See #992.`
753
+ );
754
+ }
755
+
678
756
  // G3 — bypass: allow-destructive-ops: true in Session Config
679
757
  // Note: parseSessionConfig only returns known fields; allow-destructive-ops is
680
758
  // a new field, so we parse the raw markdown for it directly.
@@ -717,7 +795,10 @@ async function main() {
717
795
  for (const warning of warnings) {
718
796
  process.stderr.write(`⚠ pre-bash-destructive-guard: ${warning}\n`);
719
797
  }
720
- if (!Array.isArray(rules)) return emitAllow();
798
+ // No usable policy anywhere → documented total fail-open. Flush any notice
799
+ // already queued (a DEGRADED head-fallback notice), else a plain allow: the
800
+ // policy-load warnings above stay stderr-only, as they always have.
801
+ if (!Array.isArray(rules)) return flushNotices(notices);
721
802
 
722
803
  // G5 — rule evaluation
723
804
  for (const rule of rules) {
@@ -748,15 +829,19 @@ async function main() {
748
829
  const reasons = [
749
830
  ...new Set(unresolved.map((e) => e.reason ?? 'variable/substitution')),
750
831
  ].join(', ');
751
- process.stderr.write(
752
- `⚠ pre-bash-destructive-guard: unresolved redirect target (${reasons}) not matched (fail-visible)\n`
753
- );
832
+ // #995 — the #988-T2 recursion-cap marker used to reach stderr only, so
833
+ // under exit-0 a budget-exhausted command (`bash <33×-c>` hiding
834
+ // `> CLAUDE.md`) allowed with an INVISIBLE notice. Aggregate onto the
835
+ // visible channel; keep the stderr copy for parity.
836
+ const msg = `pre-bash-destructive-guard: unresolved redirect target (${reasons}) — not matched (fail-visible)`;
837
+ process.stderr.write(`⚠ ${msg}\n`);
838
+ notices.push(msg);
754
839
  }
755
840
  if (!blocker.redirectRuleMatches(rule, command, { repoRoot: projectDir })) continue;
756
841
  if (severity !== 'block') {
757
- process.stderr.write(
758
- `⚠ pre-bash-destructive-guard: redirect target matched (rule: ${id}) — ${rationale}\n`
759
- );
842
+ const msg = `pre-bash-destructive-guard: redirect target matched (rule: ${id}) — ${rationale}`;
843
+ process.stderr.write(`⚠ ${msg}\n`);
844
+ notices.push(msg); // #995 — visible on the allow-with-notice channel
760
845
  continue;
761
846
  }
762
847
  // Reason stays short (stdout-budget): operator + target, never the command.
@@ -782,9 +867,9 @@ async function main() {
782
867
  continue;
783
868
  }
784
869
  }
785
- process.stderr.write(
786
- `⚠ pre-bash-destructive-guard: '${pattern}' (rule: ${id}) — ${rationale}\n`
787
- );
870
+ const msg = `pre-bash-destructive-guard: '${pattern}' (rule: ${id}) — ${rationale}`;
871
+ process.stderr.write(`⚠ ${msg}\n`);
872
+ notices.push(msg); // #995 — visible on the allow-with-notice channel
788
873
  // Best-effort telemetry — must never affect the warn/allow outcome.
789
874
  try {
790
875
  await emitEvent('orchestrator.destructive_guard.warned', {
@@ -832,8 +917,9 @@ async function main() {
832
917
  // Unknown severity → skip (conservative allow for unknown future severities)
833
918
  }
834
919
 
835
- // G6 — no blocking match
836
- return emitAllow();
920
+ // G6 — no blocking match. Flush the aggregated allow-with-notice channel
921
+ // (#995): emitWarn if any notice queued (visible), else a silent allow.
922
+ return flushNotices(notices);
837
923
  }
838
924
 
839
925
  // ---------------------------------------------------------------------------
@@ -857,8 +943,9 @@ try {
857
943
  const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
858
944
  // Unthrottled by design: EVERY call in an unarmed session says so (#992
859
945
  // hardening — the once-per-session marker it used to pass through was
860
- // suppressible by a bare `touch` on a derivable tmp path).
861
- emitGuardInactiveBanner({ error: loadError });
946
+ // suppressible by a bare `touch` on a derivable tmp path). hookName is
947
+ // threaded explicitly (#993 no hard-wired literal in the loader).
948
+ emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
862
949
  } catch {
863
950
  // Last resort: even the banner helper failed to load. Emit unconditionally
864
951
  // (no once-per-session keying) — repeated noise beats a silent disarm.