session-orchestrator 5.4.0 → 5.6.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 (151) 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/.cursor/rules/060-evolve.mdc +5 -5
  5. package/.cursor-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +115 -3
  7. package/README.md +30 -15
  8. package/docs/ci-setup.md +120 -23
  9. package/docs/components.md +7 -7
  10. package/docs/events-schema.md +17 -14
  11. package/docs/install.md +14 -2
  12. package/docs/migration-v4.md +24 -17
  13. package/docs/plugin-architecture-v3.md +1 -1
  14. package/docs/scope-collision-guard.md +19 -15
  15. package/docs/session-config-reference.md +7 -7
  16. package/docs/vault-docs-architecture.md +8 -4
  17. package/hooks/_lib/hook-import-set.json +17 -8
  18. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  19. package/hooks/config-protection.mjs +1 -1
  20. package/hooks/enforce-commands.mjs +28 -13
  21. package/hooks/enforce-scope.mjs +201 -23
  22. package/hooks/hooks-codex.json +1 -1
  23. package/hooks/hooks.json +1 -1
  24. package/hooks/on-session-start.mjs +4 -2
  25. package/hooks/on-stop.mjs +77 -171
  26. package/hooks/post-bash-write-verify.mjs +219 -24
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +41 -156
  29. package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +603 -241
  32. package/hooks/wave-scope-commit-guard.mjs +235 -73
  33. package/package.json +9 -8
  34. package/scripts/apply-session-learnings.mjs +35 -14
  35. package/scripts/archive-closed-prds.mjs +38 -11
  36. package/scripts/autopilot.mjs +8 -1
  37. package/scripts/backfill-abandoned-sessions.mjs +80 -5
  38. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  39. package/scripts/codex-install.mjs +3 -0
  40. package/scripts/cursor-install.mjs +46 -2
  41. package/scripts/emit-session.mjs +5 -3
  42. package/scripts/export-hw-learnings.mjs +18 -7
  43. package/scripts/fleet-checkin.mjs +26 -3
  44. package/scripts/lib/autopilot/loop.mjs +25 -10
  45. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  46. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  47. package/scripts/lib/common.mjs +41 -0
  48. package/scripts/lib/config/vault-integration.mjs +151 -16
  49. package/scripts/lib/config.mjs +5 -1
  50. package/scripts/lib/eval/engine.mjs +13 -0
  51. package/scripts/lib/events-retention-banner.mjs +393 -0
  52. package/scripts/lib/events-rotation.mjs +21 -6
  53. package/scripts/lib/events.mjs +51 -3
  54. package/scripts/lib/fleet-protocol.mjs +20 -2
  55. package/scripts/lib/hardening.mjs +1 -0
  56. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  57. package/scripts/lib/instruction-budget-guard.mjs +8 -0
  58. package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
  59. package/scripts/lib/learnings/io.mjs +125 -16
  60. package/scripts/lib/lock-reaper.mjs +38 -14
  61. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  62. package/scripts/lib/named-vault-resolver.mjs +48 -5
  63. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  64. package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
  65. package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
  66. package/scripts/lib/orphan-reaper/scan.mjs +4 -0
  67. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  68. package/scripts/lib/orphan-reaper.mjs +5 -1
  69. package/scripts/lib/platform.mjs +206 -2
  70. package/scripts/lib/plugin-update-banner.mjs +3 -1
  71. package/scripts/lib/process-group.mjs +83 -22
  72. package/scripts/lib/scope-baseline.mjs +116 -25
  73. package/scripts/lib/scope-echo.mjs +7 -0
  74. package/scripts/lib/scope-gate.mjs +201 -27
  75. package/scripts/lib/session-close-backfill.mjs +202 -4
  76. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  77. package/scripts/lib/session-lock-shape.mjs +6 -3
  78. package/scripts/lib/session-lock.mjs +320 -67
  79. package/scripts/lib/session-registry.mjs +41 -16
  80. package/scripts/lib/session-start-probes.mjs +242 -68
  81. package/scripts/lib/session-token-rollup.mjs +39 -34
  82. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  83. package/scripts/lib/tail-window.mjs +27 -3
  84. package/scripts/lib/telemetry/pricing.mjs +5 -2
  85. package/scripts/lib/telemetry/sync.mjs +17 -0
  86. package/scripts/lib/tests-src-ratio.mjs +77 -2
  87. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  88. package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
  89. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  90. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  91. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  92. package/scripts/lib/vault-mirror/process.mjs +350 -62
  93. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  94. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  95. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  96. package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
  97. package/scripts/lib/vault-status/board-writer.mjs +5 -1
  98. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  99. package/scripts/materialize-wave-scope.mjs +7 -4
  100. package/scripts/release.mjs +1 -0
  101. package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
  102. package/scripts/run-quality-gate.mjs +8 -5
  103. package/scripts/self-update.mjs +229 -0
  104. package/scripts/sweep-expired-learnings.mjs +560 -28
  105. package/scripts/validate-wave-scope.mjs +60 -6
  106. package/scripts/vault-backfill.mjs +50 -37
  107. package/scripts/vault-mirror.mjs +170 -33
  108. package/skills/_shared/fleet-protocol.md +74 -71
  109. package/skills/_shared/parallel-aware-auq.md +1 -1
  110. package/skills/_shared/state-ownership.md +38 -14
  111. package/skills/autopilot/SKILL.md +1 -1
  112. package/skills/bootstrap/SKILL.md +5 -3
  113. package/skills/bootstrap/_shared-template.md +96 -1
  114. package/skills/bootstrap/fast-template.md +3 -0
  115. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  116. package/skills/bootstrap/standard-template.md +2 -0
  117. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  118. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  119. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  120. package/skills/docs-orchestrator/SKILL.md +12 -5
  121. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  122. package/skills/evolve/SKILL.md +11 -2
  123. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  124. package/skills/session-end/learning-patterns.md +3 -3
  125. package/skills/session-end/metrics-collection.md +1 -1
  126. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  127. package/skills/session-end/plan-verification.md +25 -9
  128. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  129. package/skills/session-end/session-metrics-write.md +45 -10
  130. package/skills/session-start/SKILL.md +3 -3
  131. package/skills/session-start/references/operations-contract.md +5 -3
  132. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  133. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  134. package/skills/vault-mirror/SKILL.md +21 -11
  135. package/skills/wave-executor/SKILL.md +1 -1
  136. package/skills/wave-executor/circuit-breaker.md +1 -1
  137. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  138. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  139. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  140. package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
  141. package/.agents/skills/navigator/SKILL.md +0 -22
  142. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  143. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  144. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  145. package/.cursor/commands/navigator.md +0 -15
  146. package/.cursor/skills/navigator/SKILL.md +0 -13
  147. package/pi/prompts/navigator.md +0 -12
  148. package/skills/navigator/SKILL.md +0 -193
  149. package/skills/navigator/references/account-check.sh +0 -87
  150. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  151. package/skills/navigator/references/slot-check.sh +0 -114
@@ -94,11 +94,26 @@
94
94
  * construction rather than by exception list.
95
95
  *
96
96
  * NOT flagged: blocks that control the clock (`now:`, `{ now }`
97
- * or a positional `now` — wider than the seam proof, #1478 —
98
- * `vi.useFakeTimers` / `vi.setSystemTime`); date literals in
99
- * INPUT position (only `.toBe`/`.toEqual`/`.toStrictEqual`
100
- * expected values are read), which leaves the passthrough
101
- * class (input date === output date) untouched.
97
+ * or a positional `now` — wider than the seam proof, #1478;
98
+ * SCREAMING_CASE `NOW` / `FIXED_NOW` / `NOW_MS` count as
99
+ * clock names too, #1499 item 7 — or a fake timer that pins
100
+ * the DATE: `vi.setSystemTime(<date>)`,
101
+ * `vi.useFakeTimers({ now: <date> })`, also when called in
102
+ * the setup at module level or in a `describe` that
103
+ * encloses the block; a bare `vi.useFakeTimers()` installs
104
+ * at the real clock and pins nothing, #1499 item 8). A
105
+ * handover whose value IS the real clock is no control
106
+ * (#1487): inline (`{ now: Date.now() }`) or through a
107
+ * binding in the same block (`const now = new Date();
108
+ * fn(now)`) or in the shared setup — module level,
109
+ * `describe` body, `beforeEach` (#1489 item 16). A freeze
110
+ * does not exempt a block that hands over a real clock bound
111
+ * at collection time (module level, `describe` body): that
112
+ * value was read before any hook ran (#1499 item 9). Date
113
+ * literals in INPUT position (only `.toBe`/`.toEqual`/
114
+ * `.toStrictEqual` expected values are read), which leaves
115
+ * the passthrough class (input date === output date)
116
+ * untouched.
102
117
  *
103
118
  * Per-file opt-out: `// @test-value-bans-allowed` in the first 5 lines skips
104
119
  * the file entirely (same convention as check-test-fixture-shapes.mjs's
@@ -353,13 +368,25 @@ const keyLiteralRe = (key) => new RegExp(`["'\\\\]+${key}["'\\\\]+\\s*:`);
353
368
  const DATE_EXPECTATION =
354
369
  /\.(?:toBe|toEqual|toStrictEqual)\(\s*(['"`])(\d{4}-\d{2}-\d{2}(?:[T ][^'"`]*)?)\1\s*\)/;
355
370
 
356
- /** A clock-named token not read off an object (`Date.now`, `opts.now`) — shared head of the two below. */
357
- const CLOCK_NAME = String.raw`(?<![.\w$])(?:now|nowMs|nowIso|clock|currentDate)\s*`;
371
+ /**
372
+ * A clock-named token not read off an object (`Date.now`, `opts.now`) — shared
373
+ * head of the patterns below. Group 1 is the name.
374
+ *
375
+ * SCREAMING_CASE module constants are clock names too (#1499 item 7): `NOW`,
376
+ * `NOW_MS`, `NOW_ISO`, and any `<WORD>_`-prefixed form (`FIXED_NOW`,
377
+ * `FROZEN_NOW`). An `i` flag would not reach them — `_` is a `\w`, so the
378
+ * lookbehind and the terminators below never see a `FIXED_NOW` boundary.
379
+ * Measured 2026-10-02 over 722 test files: `NOW` is handed over in 29 files,
380
+ * `FIXED_NOW` in 10, `NOW_MS` in 5, `NOW_ISO` in 2.
381
+ */
382
+ const CLOCK_NAME = String.raw`(?<![.\w$])(now|nowMs|nowIso|clock|currentDate|(?:[A-Z][A-Z0-9]*_)*(?:NOW(?:_MS|_ISO)?|CLOCK|CURRENT_DATE))\s*`;
358
383
 
359
384
  /**
360
385
  * Pass 1, the seam PROOF: a NAMED clock key (`{ now: … }`) — only that shape says
361
- * the callee's API takes a clock. Every line it matches also matches
362
- * CONTROL_CLOCK_ARG, so a block that proves a seam is never flagged itself.
386
+ * the callee's API takes a clock, whatever value it is handed. Every line it
387
+ * matches also matches CONTROL_CLOCK_ARG, so a block that proves a seam is never
388
+ * flagged itself — UNLESS the value it hands over is the real clock (#1487):
389
+ * then the block proves the seam and is a bomb in the same breath.
363
390
  */
364
391
  const SEAM_CLOCK_ARG = new RegExp(`${CLOCK_NAME}:`);
365
392
 
@@ -367,7 +394,10 @@ const SEAM_CLOCK_ARG = new RegExp(`${CLOCK_NAME}:`);
367
394
  * Pass 2, is this block clock-CONTROLLED: any clock handover. Property shorthand
368
395
  * (`{ now }`, `{ repoRoot, now }`) and a positional `now` count too; `Date.now()`
369
396
  * and `const now =` do not. Wider is the safe direction here — it can only
370
- * remove findings.
397
+ * remove findings. Group 2 is the terminator: `:` means the name is a KEY and
398
+ * the handed-over value follows it; any other terminator means the name IS the
399
+ * value. A handover only counts when that value is not the real clock — see
400
+ * {@link handsOverControlledClock}.
371
401
  *
372
402
  * CEILING (#1478): pass 1 reads only the named form, because the wide form also
373
403
  * turns helpers into seams — +20 seamed (file, id) pairs in 15 files
@@ -377,10 +407,25 @@ const SEAM_CLOCK_ARG = new RegExp(`${CLOCK_NAME}:`);
377
407
  * REVISIT on the first B5 finding whose subject is a helper, or the first CI
378
408
  * `test-value-bans` B5 finding the ±days clock-shift probe shows to be false.
379
409
  */
380
- const CONTROL_CLOCK_ARG = new RegExp(String.raw`${CLOCK_NAME}(?::|,|\}|\))`);
410
+ const CONTROL_CLOCK_ARG = new RegExp(String.raw`${CLOCK_NAME}(:|,|\}|\))`, 'g');
411
+
412
+ /** A read of the real wall clock: `Date.now()`, an argument-less `new Date()`, `performance.now()`. */
413
+ const REAL_CLOCK_READ = /\bDate\.now\(\)|\bnew Date\(\s*\)|\bperformance\.now\(\)/;
414
+
415
+ /**
416
+ * `const now = <rhs>` / `now = <rhs>` — a clock-named binding (not `==`, not an
417
+ * arrow `now =>`). Group 1 is the name, group 2 the right-hand side.
418
+ */
419
+ const CLOCK_BINDING = new RegExp(String.raw`${CLOCK_NAME}=(?![=>])(.*)`);
381
420
 
382
- /** Freezing the global clock — equally valid control, but not a seam PROOF. */
383
- const FAKE_TIMER = /\b(?:useFakeTimers|setSystemTime|advanceTimersByTime|runAllTimers)\b/;
421
+ /**
422
+ * Freezing the global clock — equally valid control, but not a seam PROOF. Only
423
+ * the INSTALLERS count: `advanceTimersByTime` / `runAllTimers` move a clock some
424
+ * installer already faked and freeze nothing on their own, so a block that only
425
+ * advances timers is controlled exactly when an installer reaches it. Whether an
426
+ * installer pins the DATE is {@link pinsDate}'s call. Group 1 is the installer.
427
+ */
428
+ const DATE_PIN_CALL = /\b(useFakeTimers|setSystemTime)\s*\(/g;
384
429
 
385
430
  /**
386
431
  * Vitest aliases that resolve to the repo's own modules — source of truth is
@@ -615,16 +660,46 @@ function declaredHookSubjects(lines) {
615
660
  * @returns {Array<{start: number, end: number}>} half-open [start, end) indices
616
661
  */
617
662
  function testBlocks(lines) {
618
- const OPENER = /^(\s*)(?:it|test)(?:\.\w+)*\s*\(/;
619
- const SIBLING = /^\s*(?:it|test|describe)(?:\.\w+)*\s*\(/;
663
+ return callRanges(lines, /^(\s*)(?:it|test)(?:\.\w+)*\s*\(/, /^\s*(?:it|test|describe)(?:\.\w+)*\s*\(/);
664
+ }
665
+
666
+ /**
667
+ * Split a file into `describe(` blocks, segmented like {@link testBlocks} but
668
+ * with no sibling fallback: a `describe` without its own same-indentation `});`
669
+ * runs to the end of the file. For its one consumer (B5's setup fake-timer
670
+ * scope) over-inclusion is NOT safe: a wider `describe` exempts more blocks, so
671
+ * a fake timer in it hides a sibling describe's time bomb — the false negative
672
+ * this scoping exists to remove. Ceiling (BV-004): two shapes still widen —
673
+ * a `describe` closed with options (`}, 10_000);`) runs to end of file, and a
674
+ * tagged-template `describe.each\`…\`` never matches the opener, so its timer
675
+ * reads as module level. Measured 2026-10-02 over 722 test files: 0 of the 13
676
+ * fake-timer files use either shape. Revisit when one appears in a fake-timer
677
+ * file, or when a B5 miss traces to one.
678
+ * @param {string[]} lines
679
+ * @returns {Array<{start: number, end: number}>} half-open [start, end) indices
680
+ */
681
+ function describeBlocks(lines) {
682
+ return callRanges(lines, /^(\s*)describe(?:\.\w+)*\s*\(/, null);
683
+ }
684
+
685
+ /**
686
+ * Shared segmenter: every line matching `opener` (group 1 = its indentation)
687
+ * opens a range that ends after the first later line closing at the SAME
688
+ * indentation (`});`), or before the first `sibling` line, or at end of file.
689
+ * @param {string[]} lines
690
+ * @param {RegExp} opener
691
+ * @param {RegExp|null} sibling
692
+ * @returns {Array<{start: number, end: number}>} half-open [start, end) indices
693
+ */
694
+ function callRanges(lines, opener, sibling) {
620
695
  const out = [];
621
696
  for (let i = 0; i < lines.length; i++) {
622
- const m = OPENER.exec(lines[i]);
697
+ const m = opener.exec(lines[i]);
623
698
  if (!m) continue;
624
699
  const closer = new RegExp(`^${m[1]}\\}\\)`);
625
700
  let end = lines.length;
626
701
  for (let j = i + 1; j < lines.length; j++) {
627
- if (SIBLING.test(lines[j])) {
702
+ if (sibling?.test(lines[j])) {
628
703
  end = j;
629
704
  break;
630
705
  }
@@ -663,6 +738,144 @@ function importedLocalIdentifiers(content) {
663
738
  return [...ids];
664
739
  }
665
740
 
741
+ /**
742
+ * The clock-named bindings in these lines (`const now = Date.now()`,
743
+ * `now = new Date('2026-01-01')`): per name, whether ANY binding reads the REAL
744
+ * clock and whether ANY binding does not. Handing a real-clock binding over is
745
+ * reading the wall clock with an extra step, not controlling it.
746
+ *
747
+ * CEILING (#1487): a real-clock value laundered through a non-clock name
748
+ * (`const t = Date.now(); fn({ now: t })`) still counts as control. REVISIT
749
+ * when a clock-shift run of the suite finds a bomb fed by that route.
750
+ * @param {string[]} live non-comment lines
751
+ * @returns {Map<string, {real: boolean, other: boolean}>}
752
+ */
753
+ function clockBindings(live) {
754
+ /** @type {Map<string, {real: boolean, other: boolean}>} */
755
+ const out = new Map();
756
+ for (const line of live) {
757
+ const m = CLOCK_BINDING.exec(line);
758
+ if (!m) continue;
759
+ const seen = out.get(m[1]) ?? { real: false, other: false };
760
+ if (REAL_CLOCK_READ.test(m[2])) seen.real = true;
761
+ else seen.other = true;
762
+ out.set(m[1], seen);
763
+ }
764
+ return out;
765
+ }
766
+
767
+ /**
768
+ * The clock-named names a block hands over as the REAL clock: its own real-clock
769
+ * bindings, plus the shared setup's (module level, `describe` body,
770
+ * `beforeEach`) for every name the block does not rebind itself (#1489 item 16:
771
+ * read block-wise only, `const now = Date.now()` at module level counted as
772
+ * control and kept a genuine bomb silent).
773
+ *
774
+ * A setup name counts only when EVERY setup binding of it reads the real clock.
775
+ * Setup is file-wide, not per-`describe`, so a name bound to the real clock in
776
+ * one `beforeEach` and to a fixed date in another is ambiguous for any given
777
+ * block — read as control, the scan's precision-first direction.
778
+ * @param {Map<string, {real: boolean, other: boolean}>} own the block's bindings
779
+ * @param {Map<string, {real: boolean, other: boolean}>} setup the shared setup's bindings
780
+ * @returns {Set<string>}
781
+ */
782
+ function realClockNames(own, setup) {
783
+ /** @type {Set<string>} */
784
+ const names = new Set();
785
+ for (const [name, b] of own) if (b.real) names.add(name);
786
+ for (const [name, b] of setup) if (b.real && !b.other && !own.has(name)) names.add(name);
787
+ return names;
788
+ }
789
+
790
+ /**
791
+ * The value expression starting at `from`, up to the first `,` or closing
792
+ * bracket at nesting depth 0 (`now: new Date(x), other` → `new Date(x)`).
793
+ * Brackets inside string literals are not tracked; a cut-short value can only
794
+ * hide a real-clock read, which leaves the block counted as controlled — the
795
+ * pre-#1487 behaviour, never a new finding.
796
+ */
797
+ function valueExpressionAt(line, from) {
798
+ let depth = 0;
799
+ for (let i = from; i < line.length; i++) {
800
+ const c = line[i];
801
+ if (c === '(' || c === '[' || c === '{') depth++;
802
+ else if (c === ')' || c === ']' || c === '}') {
803
+ if (depth === 0) return line.slice(from, i);
804
+ depth--;
805
+ } else if (c === ',' && depth === 0) return line.slice(from, i);
806
+ }
807
+ return line.slice(from);
808
+ }
809
+
810
+ /**
811
+ * True when this line hands over a clock that is NOT the real one: a clock-named
812
+ * handover (CONTROL_CLOCK_ARG) whose value neither reads the wall clock inline
813
+ * (`{ now: Date.now() }`) nor names a real-clock binding in the block's scope
814
+ * (`const now = Date.now(); fn(now)`, see `realClockNames`). #1487: before this,
815
+ * both shapes counted as control and kept a genuine time bomb silent.
816
+ * @param {string} line
817
+ * @param {Set<string>} realClock names bound to the real clock in this block's scope
818
+ */
819
+ function handsOverControlledClock(line, realClock) {
820
+ return handoverValues(line).some((v) => !REAL_CLOCK_READ.test(v) && !namesAny(v, realClock));
821
+ }
822
+
823
+ /**
824
+ * The values this line hands over as a clock (CONTROL_CLOCK_ARG): after a
825
+ * `name:` key the expression that follows it, otherwise the name itself.
826
+ * @param {string} line
827
+ * @returns {string[]}
828
+ */
829
+ function handoverValues(line) {
830
+ return [...line.matchAll(CONTROL_CLOCK_ARG)].map((m) =>
831
+ m[2] === ':' ? valueExpressionAt(line, m.index + m[0].length) : m[1],
832
+ );
833
+ }
834
+
835
+ /**
836
+ * True when `value` names one of `names` as a whole identifier.
837
+ * @param {string} value
838
+ * @param {Iterable<string>} names
839
+ */
840
+ function namesAny(value, names) {
841
+ return [...names].some((n) => new RegExp(String.raw`(?<![.\w$])${n}(?![\w$])`).test(value));
842
+ }
843
+
844
+ /**
845
+ * True when this line freezes the calendar DATE, not just the timers (#1499
846
+ * item 8). vitest 4.1 installs `useFakeTimers()` at `this._fakingDate ||
847
+ * Date.now()` — the real clock unless a date was set first — so only
848
+ * `setSystemTime(<date>)` or a `useFakeTimers` config naming `now` pins a day;
849
+ * `{ toFake: ['setTimeout'] }` leaves Date real. A pin whose value reads the wall
850
+ * clock pins nothing: inline (`setSystemTime(new Date())`, `{ now: Date.now() }`)
851
+ * or through a clock-named binding the scanner knows reads it
852
+ * (`const now = new Date(); vi.setSystemTime(now)`, `useFakeTimers({ now })` —
853
+ * review of ac3ded47). An argument the line cannot read — a variable, a config
854
+ * spread over lines, a quoted key — counts as a pin: precision first, as
855
+ * everywhere in B5. CEILING: a pin value laundered through a NON-clock name
856
+ * (`const t = new Date(); vi.setSystemTime(t)`) still counts — the
857
+ * {@link clockBindings} ceiling.
858
+ * @param {string} line
859
+ * @param {Set<string>} realClock names bound to the real clock in this line's scope
860
+ */
861
+ function pinsDate(line, realClock) {
862
+ for (const m of line.matchAll(DATE_PIN_CALL)) {
863
+ const arg = valueExpressionAt(line, m.index + m[0].length).trim();
864
+ let value = arg;
865
+ if (m[1] === 'useFakeTimers') {
866
+ if (arg === '') continue;
867
+ if (!arg.startsWith('{') || !arg.endsWith('}')) return true;
868
+ const key = /(?<![\w$])now(?![\w$])\s*([:,}])?/.exec(arg);
869
+ if (!key) continue;
870
+ if (!key[1]) return true;
871
+ // `now:` hands over the expression after it; shorthand `{ now }` hands over the binding `now`.
872
+ value = key[1] === ':' ? valueExpressionAt(arg, key.index + key[0].length) : 'now';
873
+ }
874
+ if (!REAL_CLOCK_READ.test(value) && !namesAny(value, realClock)) return true;
875
+ }
876
+ return false;
877
+ }
878
+
666
879
  /** True when `id` is invoked anywhere in these lines. */
667
880
  function callsIdentifier(lines, id) {
668
881
  const re = new RegExp(`\\b${id}\\s*\\(`);
@@ -694,15 +907,101 @@ function scanClockBombs(relPath, content, lines) {
694
907
  const sutIds = importedLocalIdentifiers(content);
695
908
  if (sutIds.length === 0) return findings;
696
909
 
697
- const blocks = testBlocks(lines).map(({ start, end }) => {
910
+ const ranges = testBlocks(lines);
911
+
912
+ // The setup every block shares (helpers, constants, `describe` bodies,
913
+ // beforeEach): where an echo may come from, and where a clock may be bound or
914
+ // frozen for the blocks below it. Never a sibling test — its assertions,
915
+ // titles and clock literals say nothing about this block's inputs.
916
+ // CEILING (BV-004): a block without its own same-indentation `});` (a
917
+ // `}, 10_000);` close, an expression-bodied arrow) runs on to the next sibling
918
+ // and takes the setup lines in between out of sharedSetup. Both directions
919
+ // follow: a swallowed `now = Date.now()` hides a finding, a swallowed fake
920
+ // timer adds one. Measured 2026-10-02 @ 46e6695b over 722 test files: 221 of
921
+ // 15,644 blocks lack the closer, 1 of them swallows a beforeAll/beforeEach,
922
+ // none a fake-timer install; 0 blocks are one-line its. REVISIT when a
923
+ // swallowed setup line binds or freezes a clock — then end such blocks on
924
+ // their own close instead of the sibling (testBlocks() also feeds B2/B3).
925
+ /** @type {Set<number>} */
926
+ const inBlock = new Set();
927
+ for (const { start, end } of ranges) for (let q = start; q < end; q++) inBlock.add(q);
928
+ const sharedSetup = lines.filter((_, q) => !inBlock.has(q));
929
+ const setupLive = sharedSetup.filter((l) => !isCommentLine(l));
930
+ const setupBindings = clockBindings(setupLive);
931
+ // A clock frozen in setup (`beforeEach(() => vi.setSystemTime(…))`) controls
932
+ // every block it reaches, including a `now = Date.now()` bound after it — and
933
+ // it reaches only the blocks vitest runs it for: all of them from module level,
934
+ // only its own `describe`'s from inside one. A fake timer in a SIBLING
935
+ // `describe` exempted every block in the file until 2026-10-02 and kept a
936
+ // genuine bomb silent (review of 3206ab24). `null` = module level.
937
+ const describes = describeBlocks(lines);
938
+ /**
939
+ * Innermost `describe` holding line q — ranges nest, so the latest start that
940
+ * still holds it — or `null` at module level.
941
+ * @param {number} q
942
+ */
943
+ const innermostDescribe = (q) => {
944
+ const holders = describes.filter((d) => d.start <= q && q < d.end);
945
+ return holders.length > 0 ? holders[holders.length - 1] : null;
946
+ };
947
+ const setupRealClock = realClockNames(new Map(), setupBindings);
948
+ /** @type {Array<{start: number, end: number}|null>} */
949
+ const setupTimerScopes = [];
950
+ lines.forEach((l, q) => {
951
+ if (inBlock.has(q) || isCommentLine(l) || !pinsDate(l, setupRealClock)) return;
952
+ setupTimerScopes.push(innermostDescribe(q));
953
+ });
954
+ /** @param {number} start */
955
+ const setupFreezes = (start) =>
956
+ setupTimerScopes.some((d) => d === null || (d.start < start && start < d.end));
957
+ // A real-clock name bound by a statement of the module itself or of a
958
+ // `describe` body is read at COLLECTION time, before vitest runs any hook
959
+ // (#1499 item 9). No freeze, in the setup or in the block, reaches that value,
960
+ // so a block handing it over is not exempted by one. Every other setup binding
961
+ // is DEFERRED — a hook callback, or a helper function's body (`validRecord()`,
962
+ // `writeLock()`), which runs when called, after any freeze: 12 of the 13 setup
963
+ // real-clock bindings outside a hook were such helper locals (measured
964
+ // 2026-10-02 over 722 test files, review of ac3ded47). A name qualifies only
965
+ // with no deferred binding at all — anything else reads as control, the
966
+ // precision-first direction. "Statement of" is read from indentation: column 0
967
+ // at module level, the enclosing `describe`'s column + 2 inside one (the
968
+ // prettier indent of this repo). A misread only moves names OUT of the set: a
969
+ // deeper or other indent style counts as deferred, and a hook range that
970
+ // over-extends (a one-line `beforeEach(…);`) does the same. CEILINGS: a
971
+ // one-line helper (`function f() { const now = Date.now(); … }`) at
972
+ // `describe`-body indent counts as collection time; a freeze that itself runs
973
+ // at collection time (a module-level `vi.setSystemTime` above the binding) is
974
+ // not read. Measured 2026-10-02 over 722 test files: 1 collection-time binding
975
+ // (tests/hooks/lock-reconcile.test.mjs:24), 0 installers outside a hook or test
976
+ // block. REVISIT on the first B5 finding traced to either shape.
977
+ const hooks = callRanges(lines, /^(\s*)(?:before|after)(?:Each|All)\s*\(/, null);
978
+ const indentOf = (/** @type {string} */ l) => /^\s*/.exec(l)[0].length;
979
+ /** @param {number} q */
980
+ const atCollection = (q) => {
981
+ if (hooks.some((h) => h.start <= q && q < h.end)) return false;
982
+ const d = innermostDescribe(q);
983
+ return indentOf(lines[q]) === (d ? indentOf(lines[d.start]) + 2 : 0);
984
+ };
985
+ const deferredBindings = clockBindings(
986
+ lines.filter((l, q) => !inBlock.has(q) && !isCommentLine(l) && !atCollection(q)),
987
+ );
988
+ const collectionClock = [...setupBindings]
989
+ .filter(([name, b]) => b.real && !b.other && !deferredBindings.has(name))
990
+ .map(([name]) => name);
991
+
992
+ const blocks = ranges.map(({ start, end }) => {
698
993
  const body = lines.slice(start, end);
699
994
  const live = body.filter((l) => !isCommentLine(l));
995
+ const own = clockBindings(live);
996
+ const realClock = realClockNames(own, setupBindings);
997
+ const unfrozen = collectionClock.filter((name) => !own.has(name));
998
+ const handsOverUnfrozen = live.some((l) => handoverValues(l).some((v) => namesAny(v, unfrozen)));
700
999
  return {
701
1000
  start,
702
1001
  body,
703
1002
  provesSeam: live.some((l) => SEAM_CLOCK_ARG.test(l)),
704
- hasClockArg: live.some((l) => CONTROL_CLOCK_ARG.test(l)),
705
- hasFakeTimer: live.some((l) => FAKE_TIMER.test(l)),
1003
+ hasClockArg: live.some((l) => handsOverControlledClock(l, realClock)),
1004
+ freezesDate: !handsOverUnfrozen && (setupFreezes(start) || live.some((l) => pinsDate(l, realClock))),
706
1005
  };
707
1006
  });
708
1007
 
@@ -714,19 +1013,8 @@ function scanClockBombs(relPath, content, lines) {
714
1013
  }
715
1014
  if (seamed.size === 0) return findings;
716
1015
 
717
- // Where an echo may come from: the finding's own block, or the setup every
718
- // block shares (helpers, constants, beforeEach). Never a sibling test — its
719
- // assertions, titles and clock literals say nothing about this block's inputs.
720
- // A block without its own `});` (a one-line it) runs on to the next sibling
721
- // and takes the setup lines in between out of sharedSetup — that direction can
722
- // only add a finding, never hide one (1.4% of blocks, measured 2026-09-30).
723
- /** @type {Set<number>} */
724
- const inBlock = new Set();
725
- for (const b of blocks) for (let q = b.start; q < b.start + b.body.length; q++) inBlock.add(q);
726
- const sharedSetup = lines.filter((_, q) => !inBlock.has(q));
727
-
728
1016
  for (const b of blocks) {
729
- if (b.hasClockArg || b.hasFakeTimer) continue;
1017
+ if (b.hasClockArg || b.freezesDate) continue;
730
1018
  const subject = [...seamed].find((id) => callsIdentifier(b.body, id));
731
1019
  if (!subject) continue;
732
1020
  const echoSources = [...b.body, ...sharedSetup].filter(
@@ -41,7 +41,7 @@
41
41
  * a string literal counts only when the command STARTS the literal (after
42
42
  * optional whitespace) AND the literal carries no `${` interpolation. That
43
43
  * second half is what separates
44
- * `' glab ci status --pipeline-id LATEST'` (a real command handed to a
44
+ * `' glab ci status --branch main'` (a real command handed to a
45
45
  * shell) from `` `glab note add spawn error: ${err}` `` (a log message).
46
46
  * 4. **The first token after the CLI must be a REAL top-level subcommand** of
47
47
  * that CLI, measured from `gh --help` / `glab --help` (gh 2.86.0,
@@ -11,6 +11,8 @@ import { readFileSync, existsSync } from 'node:fs';
11
11
  import { resolve, sep } from 'node:path';
12
12
  import { spawnSync } from 'node:child_process';
13
13
 
14
+ import { redactHomeDir } from '../common.mjs';
15
+
14
16
  const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
15
17
  const MIRROR_DIRS = ['40-learnings', '50-sessions'];
16
18
 
@@ -79,7 +81,7 @@ export function autoCommitVaultMirror(vaultDirPath, sessionId, repo) {
79
81
 
80
82
  const addResult = git(vaultDirPath, ['add', '--', ...stagingTargets]);
81
83
  if (addResult.status !== 0) {
82
- process.stderr.write(`vault-mirror: auto-commit git-add failed: ${addResult.stderr}\n`);
84
+ process.stderr.write(`vault-mirror: auto-commit git-add failed: ${redactHomeDir(addResult.stderr)}\n`);
83
85
  process.stdout.write(
84
86
  JSON.stringify({ action: 'auto-commit-skipped', reason: 'git-add-failed' }) + '\n',
85
87
  );
@@ -88,7 +90,7 @@ export function autoCommitVaultMirror(vaultDirPath, sessionId, repo) {
88
90
 
89
91
  const diff = git(vaultDirPath, ['diff', '--cached', '--name-only', '--', ...stagingTargets]);
90
92
  if (diff.status !== 0) {
91
- process.stderr.write(`vault-mirror: auto-commit git-diff failed: ${diff.stderr}\n`);
93
+ process.stderr.write(`vault-mirror: auto-commit git-diff failed: ${redactHomeDir(diff.stderr)}\n`);
92
94
  process.stdout.write(
93
95
  JSON.stringify({ action: 'auto-commit-skipped', reason: 'git-diff-failed' }) + '\n',
94
96
  );
@@ -182,7 +184,7 @@ export function autoCommitVaultMirror(vaultDirPath, sessionId, repo) {
182
184
  // committed content is already validated. See SKILL.md § "Pre-commit hook bypass (--no-verify)".
183
185
  const commit = git(vaultDirPath, ['commit', '-m', subject, '--no-verify']);
184
186
  if (commit.status !== 0) {
185
- process.stderr.write(`vault-mirror: auto-commit git-commit failed: ${commit.stderr}\n`);
187
+ process.stderr.write(`vault-mirror: auto-commit git-commit failed: ${redactHomeDir(commit.stderr)}\n`);
186
188
  process.stdout.write(
187
189
  JSON.stringify({ action: 'auto-commit-skipped', reason: 'git-commit-failed' }) + '\n',
188
190
  );
@@ -10,13 +10,18 @@
10
10
  * - Pure + deterministic (given the same vaultName, the same `.vault.yaml`
11
11
  * under `repoRoot`, the same git remote, and the same host-local pseudonym
12
12
  * map).
13
- * - `repoRoot` (#1389) selects the directory whose `.vault.yaml` is read;
14
- * it defaults to `process.cwd()`. Named limit: it scopes ONLY that lookup —
15
- * the {@link deriveRepo} fallback still resolves from the process cwd and
16
- * is cached per process, so a `repoRoot` that differs from the cwd AND has
17
- * no declared slug falls back to the cwd's git identity. Every production
18
- * caller passes its own cwd today; revisit if a caller ever mirrors a repo
19
- * other than the one it runs in.
13
+ * - `repoRoot` (#1389, #1503) selects the repo whose identity is resolved —
14
+ * BOTH the `.vault.yaml` lookup and the {@link deriveRepo} fallback (its
15
+ * preferred git remote, else its directory name). It defaults to
16
+ * `process.cwd()`. Until #1503 only the `.vault.yaml` half honoured it: the
17
+ * git fallback still read the process cwd, cached once per process, so a
18
+ * caller mirroring a repo other than the one it ran in got the CWD's
19
+ * namespace — which is how one repo's ledger overwrote another repo's vault
20
+ * notes. The revisit trigger this comment used to name ("a caller mirrors a
21
+ * repo other than the one it runs in") fired; the cache is now keyed per
22
+ * root. Remaining ceiling: a caller that passes NO `repoRoot` still gets the
23
+ * cwd's identity — `scripts/vault-mirror.mjs` derives a root from
24
+ * `--source` precisely so that it never relies on that default.
20
25
  * - Returns a lowercase kebab slug safe for use as a filesystem path segment.
21
26
  * - Host-local pseudonym mapping (Epic #725 D5): consulted ONLY at the redaction
22
27
  * site (only when a segment is owner-leaky). If such a repo (raw or sanitised)
@@ -48,7 +53,7 @@
48
53
  * (itself a leaf — `node:child_process` only) — never the pipeline.
49
54
  */
50
55
 
51
- import { basename } from 'node:path';
56
+ import { basename, resolve } from 'node:path';
52
57
 
53
58
  import { subjectToSlug } from './utils.mjs';
54
59
  import { isOwnerLeakySegment } from '../../lib/validate/check-owner-leakage.mjs';
@@ -97,7 +102,13 @@ function currentMapPath() {
97
102
 
98
103
  // ── Repo identity (issue #343; moved here from process.mjs for #734b) ────────
99
104
 
100
- let _cachedRepo = null;
105
+ /**
106
+ * Resolved repo root → derived identity. Keyed per root (#1503): a single
107
+ * process-wide slot answered every later caller with the FIRST root's identity,
108
+ * whichever root that caller asked about.
109
+ * @type {Map<string, string>}
110
+ */
111
+ const _cachedRepos = new Map();
101
112
 
102
113
  /** scp-like SSH remote: `git@host:org/name.git` (no `://`, an `@` before any `/`). */
103
114
  const SCP_LIKE_REMOTE_RE = /^[^@/\s]+@[^:/\s]+:(.+)$/;
@@ -177,7 +188,7 @@ function repoIdentifierFromRemoteUrl(url) {
177
188
  * {@link resolveRepoNamespace} returned `'unknown-repo'`. Measured
178
189
  * 2026-08-19; it turned a namespace assertion red and blocked a push.
179
190
  *
180
- * Fallback: `path.basename(process.cwd())`, as before — but the two reasons for
191
+ * Fallback: `path.basename(<root>)` — but the two reasons for
181
192
  * reaching it are no longer indistinguishable. A QUERY FAILURE (not a git repo,
182
193
  * git not on PATH, git errored — {@link isQueryFailure}) emits a stderr WARN,
183
194
  * because the identity under which vault notes are written was GUESSED. A real
@@ -186,8 +197,11 @@ function repoIdentifierFromRemoteUrl(url) {
186
197
  * An `ok` resolution whose URL yields no usable identifier also falls back
187
198
  * silently — the query succeeded and the answer was simply unusable.
188
199
  *
189
- * Cached per-process — repo identity does not change mid-run, and the cache also
190
- * keeps the WARN to at most one line per process.
200
+ * `<root>` is `repoRoot` when given, else `process.cwd()` (#1503: the remote is
201
+ * read with `git -C <root>`, so the identity is the root's, never the cwd's).
202
+ *
203
+ * Cached per resolved root — repo identity does not change mid-run, and the
204
+ * cache also keeps the WARN to at most one line per root.
191
205
  *
192
206
  * NOTE — this is the RAW identifier and is NOT leak-guarded. Never write its
193
207
  * output to the vault directly; route it through {@link resolveRepoNamespace}
@@ -198,18 +212,25 @@ function repoIdentifierFromRemoteUrl(url) {
198
212
  * until the #734b cycle break, and the module-level cache means there must remain
199
213
  * exactly ONE definition.
200
214
  *
215
+ * @param {object} [opts]
216
+ * @param {string|null} [opts.repoRoot] - Repo whose identity to derive;
217
+ * `undefined`/`null` → `process.cwd()`.
201
218
  * @returns {string} e.g. 'Kanevry/session-orchestrator' or a bare directory name.
202
219
  */
203
- export function deriveRepo() {
204
- if (_cachedRepo !== null) return _cachedRepo;
220
+ export function deriveRepo({ repoRoot = null } = {}) {
221
+ const root = resolve(repoRoot ?? process.cwd());
222
+ const cached = _cachedRepos.get(root);
223
+ if (cached !== undefined) return cached;
224
+
225
+ const remember = (identity) => {
226
+ _cachedRepos.set(root, identity);
227
+ return identity;
228
+ };
205
229
 
206
- const resolved = resolvePreferredRemote({});
230
+ const resolved = resolvePreferredRemote({ repoRoot: root });
207
231
  if (resolved.ok) {
208
232
  const identifier = repoIdentifierFromRemoteUrl(resolved.url);
209
- if (identifier !== '') {
210
- _cachedRepo = identifier;
211
- return _cachedRepo;
212
- }
233
+ if (identifier !== '') return remember(identifier);
213
234
  } else if (isQueryFailure(resolved.reason)) {
214
235
  process.stderr.write(
215
236
  `WARN vault-mirror/namespace: could not query git remotes (${resolved.reason}); ` +
@@ -218,8 +239,7 @@ export function deriveRepo() {
218
239
  );
219
240
  }
220
241
 
221
- _cachedRepo = basename(process.cwd());
222
- return _cachedRepo;
242
+ return remember(basename(root));
223
243
  }
224
244
 
225
245
  /**
@@ -249,9 +269,9 @@ function lookupPseudonym(base, seg) {
249
269
  * When non-empty and non-whitespace, used in place of the declared/git-derived
250
270
  * repo name. When absent, the base is the repo's declared `.vault.yaml`
251
271
  * `metadata.slug` (#1131), else the git origin via deriveRepo().
252
- * @param {string|null} [opts.repoRoot] - Directory whose `.vault.yaml` supplies
253
- * the declared slug (#1389). `undefined`/`null` → `process.cwd()`. Does NOT
254
- * redirect the deriveRepo() fallback (see the module header's named limit).
272
+ * @param {string|null} [opts.repoRoot] - Repo whose identity is resolved: its
273
+ * `.vault.yaml` supplies the declared slug (#1389), and its git remote feeds
274
+ * the deriveRepo() fallback (#1503). `undefined`/`null` → `process.cwd()`.
255
275
  * @returns {string} A single kebab-slug path segment, e.g. 'session-orchestrator'.
256
276
  * Special returns:
257
277
  * - 'unknown-repo' — slug derivation produced an empty string.
@@ -284,10 +304,11 @@ export function resolveRepoNamespace({ vaultName = null, repoRoot = null } = {})
284
304
  // guard below deliberately — a declared slug is operator data flowing into a
285
305
  // written path and is checked by CP1/CP6/CP10 and the pseudonym map exactly
286
306
  // like any git-derived identifier.
287
- const declaredSlug = readVaultSlug(repoRoot ?? process.cwd());
307
+ const root = repoRoot ?? process.cwd();
308
+ const declaredSlug = readVaultSlug(root);
288
309
  const base = (vaultName && typeof vaultName === 'string' && vaultName.trim())
289
310
  ? vaultName.trim()
290
- : (declaredSlug ?? deriveRepo());
311
+ : (declaredSlug ?? deriveRepo({ repoRoot: root }));
291
312
 
292
313
  // Sanitise: collapse to last path segment, lowercase, strip non-[a-z0-9-].
293
314
  const seg = subjectToSlug(base);