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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/060-evolve.mdc +5 -5
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +115 -3
- package/README.md +30 -15
- package/docs/ci-setup.md +120 -23
- package/docs/components.md +7 -7
- package/docs/events-schema.md +17 -14
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/scope-collision-guard.md +19 -15
- package/docs/session-config-reference.md +7 -7
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +17 -8
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +4 -2
- package/hooks/on-stop.mjs +77 -171
- package/hooks/post-bash-write-verify.mjs +219 -24
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +41 -156
- package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +603 -241
- package/hooks/wave-scope-commit-guard.mjs +235 -73
- package/package.json +9 -8
- package/scripts/apply-session-learnings.mjs +35 -14
- package/scripts/archive-closed-prds.mjs +38 -11
- package/scripts/autopilot.mjs +8 -1
- package/scripts/backfill-abandoned-sessions.mjs +80 -5
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +46 -2
- package/scripts/emit-session.mjs +5 -3
- package/scripts/export-hw-learnings.mjs +18 -7
- package/scripts/fleet-checkin.mjs +26 -3
- package/scripts/lib/autopilot/loop.mjs +25 -10
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +51 -3
- package/scripts/lib/fleet-protocol.mjs +20 -2
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +8 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
- package/scripts/lib/learnings/io.mjs +125 -16
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/named-vault-resolver.mjs +48 -5
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
- package/scripts/lib/orphan-reaper/scan.mjs +4 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +5 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +83 -22
- package/scripts/lib/scope-baseline.mjs +116 -25
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +201 -27
- package/scripts/lib/session-close-backfill.mjs +202 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-registry.mjs +41 -16
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +39 -34
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +5 -2
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +77 -2
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
- package/scripts/lib/vault-status/board-writer.mjs +5 -1
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/release.mjs +1 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
- package/scripts/run-quality-gate.mjs +8 -5
- package/scripts/self-update.mjs +229 -0
- package/scripts/sweep-expired-learnings.mjs +560 -28
- package/scripts/validate-wave-scope.mjs +60 -6
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-mirror.mjs +170 -33
- package/skills/_shared/fleet-protocol.md +74 -71
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/state-ownership.md +38 -14
- package/skills/autopilot/SKILL.md +1 -1
- package/skills/bootstrap/SKILL.md +5 -3
- package/skills/bootstrap/_shared-template.md +96 -1
- package/skills/bootstrap/fast-template.md +3 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +11 -2
- package/skills/evolve/references/evolve-analyze-mode.md +37 -17
- package/skills/session-end/learning-patterns.md +3 -3
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/session-metrics-write.md +45 -10
- package/skills/session-start/SKILL.md +3 -3
- package/skills/session-start/references/operations-contract.md +5 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +2 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
- package/.agents/skills/navigator/SKILL.md +0 -22
- package/.agents/skills/navigator/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/navigator/SKILL.md +0 -22
- package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
- package/.cursor/commands/navigator.md +0 -15
- package/.cursor/skills/navigator/SKILL.md +0 -13
- package/pi/prompts/navigator.md +0 -12
- package/skills/navigator/SKILL.md +0 -193
- package/skills/navigator/references/account-check.sh +0 -87
- package/skills/navigator/references/fleet-decisions.mjs +0 -231
- 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
|
-
* `
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
/**
|
|
357
|
-
|
|
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
|
|
362
|
-
* CONTROL_CLOCK_ARG, so a block that proves a seam is never
|
|
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
|
-
/**
|
|
383
|
-
|
|
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
|
-
|
|
619
|
-
|
|
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 =
|
|
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 (
|
|
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
|
|
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) =>
|
|
705
|
-
|
|
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.
|
|
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 --
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* caller
|
|
19
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
190
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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] -
|
|
253
|
-
* the declared slug (#1389)
|
|
254
|
-
*
|
|
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
|
|
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);
|