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
@@ -14,7 +14,7 @@
14
14
  import { existsSync, readFileSync } from 'node:fs';
15
15
  import path from 'node:path';
16
16
 
17
- import { tokenizeCommand } from './command-blocker.mjs';
17
+ import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
18
18
 
19
19
  /**
20
20
  * Find the wave-scope.json file for the given project root.
@@ -573,6 +573,25 @@ export function assertTestSiblingCoverage(fileScope, allowedPaths, opts = {}) {
573
573
  * emits no token at all — an improvement: `tee out.txt <<EOF` used to report
574
574
  * the literal `EOF` as a second write target.)
575
575
  *
576
+ * ## Segment-first, wrapper-aware verb resolution (#996.2)
577
+ *
578
+ * The interpretation is SEGMENT-first: the token stream is split on shell chain
579
+ * operators / newlines by {@link splitChainSegments} BEFORE it is interpreted,
580
+ * and each segment's real command verb is resolved through the shared
581
+ * {@link resolveSegmentVerb} wrapper table. A lone `expectCommand` flag used to
582
+ * read the FIRST word of a segment as the command head, so a transparent wrapper
583
+ * (`sudo`, `env FOO=1`, `timeout 5`, `nice -n 10`, `command`, `/usr/bin/time`)
584
+ * consumed the head slot and the REAL verb (`tee`/`sed`/`dd`) landed in argument
585
+ * position where it was never matched — measured `sudo tee src/x.ts` → `[]`, a
586
+ * DETECTION LOSS. This is the same fail-open class #991 closed in the ledger
587
+ * guard, in a warn-only (#800) consumer. `resolveSegmentVerb` unwraps the
588
+ * wrappers and returns a BASENAME-normalized verb, so an absolute `/usr/bin/tee`
589
+ * resolves to `tee` for free. It ALSO reports wrapper-written file operands
590
+ * (`writesFile: true`, the #992 discriminator) — `/usr/bin/time -o FILE npm test`
591
+ * truncates FILE while the verb is `npm`, a write with no redirect operator and
592
+ * no tee/sed/dd head; those are harvested exactly as {@link extractRedirectTargets}
593
+ * does.
594
+ *
576
595
  * Hook-safe: pure, deterministic, no I/O. Never throws — a non-string / empty
577
596
  * input returns `[]`.
578
597
  *
@@ -582,8 +601,6 @@ export function assertTestSiblingCoverage(fileScope, allowedPaths, opts = {}) {
582
601
  export function extractBashWriteTargets(command) {
583
602
  if (typeof command !== 'string' || command.length === 0) return [];
584
603
 
585
- const tokens = classifyShellTokens(tokenizeCommand(command));
586
-
587
604
  const out = [];
588
605
  const seen = new Set();
589
606
  const add = (value) => {
@@ -594,21 +611,104 @@ export function extractBashWriteTargets(command) {
594
611
  out.push(v);
595
612
  };
596
613
 
597
- // Second pass: interpret the token stream. `mode` tracks a command-head that
598
- // owns following args (tee/sed/dd); `pendingRedirect` marks that the NEXT word
599
- // token is a redirect target.
600
- let mode = null; // null | 'tee' | 'sed' | 'dd'
601
- let pendingRedirect = false;
602
- // A here-doc was opened: `tokenizeCommand` will emit its BODY as an ordinary
603
- // quoted word token, and a single-word body (`hello`) would otherwise be read
604
- // as a `tee`/`sed`/`dd` file argument. Explicit `>` redirect targets stay
605
- // accepted — those carry their own operator and cannot be body text.
606
- let heredocOpen = false;
607
- let expectCommand = true; // next word is the command head of this segment
608
- let sedArgs = []; // { value } collected for a `sed` head
609
- let sedInPlace = false;
610
-
611
- const flushSed = () => {
614
+ // Segment-first (#996.2): split on shell chain operators / newlines BEFORE
615
+ // interpreting, then resolve each segment's REAL verb through the shared
616
+ // wrapper table. `out`/`seen`/`add` stay outside the loop so de-duplication
617
+ // and first-seen ordering hold across segments (`echo a > x; echo b >> x`).
618
+ for (const segment of splitChainSegments(tokenizeCommand(command))) {
619
+ // #996.2 namespace fix: resolve the verb in the SAME (paren-peeled,
620
+ // redirect-target-excluded) namespace the interpretation loop's `wordsSeen`
621
+ // counter walks below — see {@link verbResolutionSegment}. Passing the RAW
622
+ // segment mis-resolved a LEADING redirect (`> a.txt tee b.ts` → verb `>`) or a
623
+ // LEADING subshell paren (`(tee inner.ts)` → verb `(tee`): mode never became
624
+ // `tee`/`sed`/`dd` and the `index + 1` head-skip then swallowed the real
625
+ // write target. For A5's 10 wrapper forms (no leading redirect/paren) the view
626
+ // is a no-op, so verb/index/wrapperArgs — incl. the #992 `writesFile` marks —
627
+ // are byte-identical to the raw call.
628
+ const { verb, index, wrapperArgs } = resolveSegmentVerb(verbResolutionSegment(segment));
629
+ const tokens = classifyShellTokens(segment);
630
+
631
+ // A wrapper can WRITE a file through its OWN option, with no redirect and no
632
+ // tee/sed/dd verb: `/usr/bin/time -o src/report.txt npm test` truncates
633
+ // src/report.txt while the resolved verb is `npm`. `resolveSegmentVerb` marks
634
+ // exactly those operands `writesFile: true` (the #992 discriminator that
635
+ // keeps `nice -n 10` / `sudo -u root` operands OUT). Harvest them exactly as
636
+ // collectRedirectTargets does — the write is otherwise invisible to this pass.
637
+ for (const wa of wrapperArgs) {
638
+ if (wa.writesFile === true && typeof wa.value === 'string') add(wa.value);
639
+ }
640
+
641
+ // `mode` tracks a command-head that owns following args (tee/sed/dd). It is
642
+ // set ONCE per segment from the resolved verb, which `resolveSegmentVerb`
643
+ // returns basename-normalized — so an absolute `/usr/bin/tee` resolves to
644
+ // `tee` for free. `pendingRedirect` marks that the NEXT word is a redirect
645
+ // target; `heredocOpen` that a here-doc BODY word (DATA) follows.
646
+ const mode = verb === 'tee' || verb === 'sed' || verb === 'dd' ? verb : null;
647
+ let pendingRedirect = false;
648
+ let heredocOpen = false;
649
+ const sedArgs = []; // classified word tokens collected for a `sed -i` head
650
+ let sedInPlace = false;
651
+
652
+ // THE ONE CARE POINT of the #996.2 refactor: the first `index + 1` WORD
653
+ // tokens that REACH the `wordsSeen` counter are the wrapper chain + the verb
654
+ // itself, never file arguments. `index` now comes from
655
+ // {@link verbResolutionSegment}, which resolves the verb in the SAME namespace
656
+ // this loop walks — paren-peeled words with redirect targets removed — so
657
+ // `index` is 1:1 with the `word` tokens that reach the counter below (a
658
+ // leading redirect or subshell paren no longer shifts it out of alignment).
659
+ // `peelSubshellParens` emits AT MOST one `word` per raw token.
660
+ const headWordsToSkip = index + 1;
661
+ let wordsSeen = 0;
662
+
663
+ for (const tk of tokens) {
664
+ if (tk.type === 'redirect') {
665
+ pendingRedirect = true;
666
+ continue;
667
+ }
668
+ if (tk.type === 'heredoc') {
669
+ // here-doc: the delimiter emits no token; the BODY arrives later as data
670
+ pendingRedirect = false;
671
+ heredocOpen = true;
672
+ continue;
673
+ }
674
+ if (tk.type === 'in') {
675
+ // input redirect / here-string — not a write target
676
+ pendingRedirect = false;
677
+ continue;
678
+ }
679
+ if (tk.type === 'sep') {
680
+ // Within a segment the ONLY seps are peeled subshell parens — chain
681
+ // operators were already consumed by splitChainSegments. Reset only the
682
+ // pending redirect so `> >(cat)` stays inert (without it the peeled `cat`
683
+ // reads as the redirect target); mode/heredoc reset live at segment end.
684
+ pendingRedirect = false;
685
+ continue;
686
+ }
687
+ // word token
688
+ if (pendingRedirect) {
689
+ add(tk.value);
690
+ pendingRedirect = false;
691
+ continue;
692
+ }
693
+ if (wordsSeen < headWordsToSkip) {
694
+ wordsSeen++; // wrapper chain + verb head — not a file argument
695
+ continue;
696
+ }
697
+ // subsequent argument words, interpreted per active command-head mode
698
+ if (heredocOpen) continue; // DATA, not a file argument
699
+ if (mode === 'tee') {
700
+ if (!isShellFlag(tk.value)) add(tk.value);
701
+ } else if (mode === 'sed') {
702
+ if (/^-i/.test(tk.value)) sedInPlace = true;
703
+ sedArgs.push(tk);
704
+ } else if (mode === 'dd') {
705
+ if (tk.value.startsWith('of=')) add(tk.value.slice(3));
706
+ }
707
+ }
708
+
709
+ // Segment end: flush a pending `sed -i` file argument (last non-flag arg).
710
+ // Only reached once per segment now that each segment carries a single verb,
711
+ // so the former sep-branch / defensive flushes are unnecessary.
612
712
  if (mode === 'sed' && sedInPlace) {
613
713
  for (let i = sedArgs.length - 1; i >= 0; i--) {
614
714
  if (!isShellFlag(sedArgs[i].value)) {
@@ -617,61 +717,7 @@ export function extractBashWriteTargets(command) {
617
717
  }
618
718
  }
619
719
  }
620
- sedArgs = [];
621
- sedInPlace = false;
622
- };
623
-
624
- for (const tk of tokens) {
625
- if (tk.type === 'redirect') {
626
- pendingRedirect = true;
627
- continue;
628
- }
629
- if (tk.type === 'heredoc') {
630
- // here-doc: the delimiter emits no token; the BODY arrives later as data
631
- pendingRedirect = false;
632
- heredocOpen = true;
633
- continue;
634
- }
635
- if (tk.type === 'in') {
636
- // input redirect / here-string — not a write target
637
- pendingRedirect = false;
638
- continue;
639
- }
640
- if (tk.type === 'sep') {
641
- flushSed();
642
- mode = null;
643
- pendingRedirect = false;
644
- heredocOpen = false;
645
- expectCommand = true;
646
- continue;
647
- }
648
- // word token
649
- if (pendingRedirect) {
650
- add(tk.value);
651
- pendingRedirect = false;
652
- continue;
653
- }
654
- if (expectCommand) {
655
- expectCommand = false;
656
- flushSed(); // flush any prior sed segment defensively
657
- if (tk.value === 'tee') { mode = 'tee'; continue; }
658
- if (tk.value === 'sed') { mode = 'sed'; continue; }
659
- if (tk.value === 'dd') { mode = 'dd'; continue; }
660
- mode = null;
661
- continue;
662
- }
663
- // subsequent argument words, interpreted per active command-head mode
664
- if (heredocOpen) continue; // DATA, not a file argument
665
- if (mode === 'tee') {
666
- if (!isShellFlag(tk.value)) add(tk.value);
667
- } else if (mode === 'sed') {
668
- if (/^-i/.test(tk.value)) sedInPlace = true;
669
- sedArgs.push(tk);
670
- } else if (mode === 'dd') {
671
- if (tk.value.startsWith('of=')) add(tk.value.slice(3));
672
- }
673
720
  }
674
- flushSed();
675
721
 
676
722
  return out;
677
723
  }
@@ -776,6 +822,82 @@ function classifyShellTokens(tokens) {
776
822
  return out;
777
823
  }
778
824
 
825
+ /**
826
+ * Project a raw chain segment into the token list used for VERB resolution,
827
+ * in the SAME namespace {@link extractBashWriteTargets}'s interpretation loop
828
+ * walks with its `wordsSeen` counter (#996.2 namespace fix).
829
+ *
830
+ * {@link resolveSegmentVerb} resolves argv[0] from RAW tokens. Two segment shapes
831
+ * put a NON-verb token in raw position 0 and so mis-resolve the command head,
832
+ * losing the write target the pre-#996.2 pass had detected:
833
+ * - a LEADING write-redirect — `> a.txt tee b.ts` resolves the `>` operator as
834
+ * the "verb" (mode never becomes `tee`), and the loop's `index + 1` head-skip
835
+ * then swallows `tee`, dropping `b.ts`.
836
+ * - a LEADING subshell paren — `(tee inner.ts)` keeps the raw token `(tee`
837
+ * (`.replace(/^.*\//,'')` leaves it untouched, no `/`), so the wrapper table
838
+ * never matches: verb is the literal `(tee`, again mode-less. The classified
839
+ * stream peels the paren AFTER, so the raw verb index and the peeled word
840
+ * stream diverge.
841
+ *
842
+ * The projection makes both namespaces agree: peel leading/trailing parens off
843
+ * each unquoted token (matching {@link peelSubshellParens}' WORD output) and drop
844
+ * every write-redirect operator together with its target word — exactly the
845
+ * tokens the interpretation loop keeps OUT of `wordsSeen` (redirect operators are
846
+ * not `word`s; their targets are consumed by the `pendingRedirect` branch before
847
+ * the counter). The resulting segment's verb INDEX is then 1:1 with `wordsSeen`,
848
+ * so `index + 1` skips the wrapper chain + verb and no more.
849
+ *
850
+ * A5's 10 transparent-wrapper forms carry neither a leading redirect nor a paren,
851
+ * so this is a NO-OP for them: `resolveSegmentVerb`'s verb/index/wrapperArgs (incl.
852
+ * the #992 `writesFile` operand marks) are byte-identical to the raw-segment call.
853
+ *
854
+ * @param {Array<{ text: string, quoted: boolean }>} segment
855
+ * @returns {Array<{ text: string, quoted: boolean }>}
856
+ */
857
+ function verbResolutionSegment(segment) {
858
+ const out = [];
859
+ let pendingRedirectTarget = false;
860
+ for (const tok of segment) {
861
+ if (!tok.quoted) {
862
+ // A write redirect consumes the NEXT token as its target — neither the
863
+ // operator nor its target reaches `wordsSeen`, so both must be absent here
864
+ // to keep the verb index aligned.
865
+ if (WRITE_REDIRECT_OP_RE.test(tok.text)) {
866
+ pendingRedirectTarget = true;
867
+ continue;
868
+ }
869
+ // heredoc / input-redirect operators carry no immediate target token that
870
+ // reaches `wordsSeen` (the here-doc delimiter emits nothing; an input target
871
+ // DOES reach the counter and so is kept) — drop only the operator itself.
872
+ if (HEREDOC_OP_RE.test(tok.text) || INPUT_REDIRECT_OP_RE.test(tok.text)) {
873
+ pendingRedirectTarget = false;
874
+ continue;
875
+ }
876
+ // Within a segment the only separator that can appear is an fd-dup `&`
877
+ // (e.g. `2>&1`), which classifyShellTokens also treats as a `sep` that
878
+ // resets the pending redirect — mirror that so the target after it stays.
879
+ if (SHELL_SEPARATOR_OPS.has(tok.text)) {
880
+ pendingRedirectTarget = false;
881
+ continue;
882
+ }
883
+ }
884
+ if (pendingRedirectTarget) {
885
+ pendingRedirectTarget = false;
886
+ continue; // this token is the dropped write-redirect target
887
+ }
888
+ if (tok.quoted) {
889
+ out.push(tok);
890
+ continue;
891
+ }
892
+ let text = tok.text;
893
+ while (text.startsWith('(')) text = text.slice(1);
894
+ while (text.endsWith(')')) text = text.slice(0, -1);
895
+ if (text.length === 0) continue; // pure paren = a separator, not a word
896
+ out.push({ text, quoted: false });
897
+ }
898
+ return out;
899
+ }
900
+
779
901
  /**
780
902
  * Build an actionable suggestion string for a scope violation.
781
903
  *
@@ -42,7 +42,7 @@ import crypto from 'node:crypto';
42
42
  import { appendJsonl as defaultAppendJsonl } from './common.mjs';
43
43
  import { readLock as defaultReadLock, isLockLive as defaultIsLockLive, DEFAULT_TTL_HOURS } from './session-lock.mjs';
44
44
  import { validateSession as defaultValidateSession } from './session-schema/validator.mjs';
45
- import { serializeSessionLineChecked as defaultSerialize } from '../emit-session.mjs';
45
+ import { serializeSessionLineChecked as defaultSerialize } from './session-schema.mjs';
46
46
 
47
47
  // ---------------------------------------------------------------------------
48
48
  // Constants
@@ -512,7 +512,7 @@ export async function backfillAbandonedSession({
512
512
  let validated;
513
513
  try {
514
514
  validated = validateSession(record);
515
- serializeSessionLineChecked(validated);
515
+ serializeSessionLineChecked(record);
516
516
  } catch (err) {
517
517
  return { action: 'error', error: `validation: ${err?.message ?? String(err)}`, sessionId: recordId };
518
518
  }