session-orchestrator 5.4.0 → 5.5.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 (150) 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 +95 -3
  7. package/README.md +11 -11
  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 +15 -13
  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 +70 -170
  26. package/hooks/post-bash-write-verify.mjs +98 -17
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +32 -154
  29. package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +584 -207
  32. package/hooks/wave-scope-commit-guard.mjs +168 -72
  33. package/package.json +2 -6
  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 +194 -27
  75. package/scripts/lib/session-close-backfill.mjs +129 -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 +21 -2
  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/state-ownership.md +38 -14
  110. package/skills/autopilot/SKILL.md +1 -1
  111. package/skills/bootstrap/SKILL.md +5 -3
  112. package/skills/bootstrap/_shared-template.md +96 -1
  113. package/skills/bootstrap/fast-template.md +3 -0
  114. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  115. package/skills/bootstrap/standard-template.md +2 -0
  116. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  117. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  118. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  119. package/skills/docs-orchestrator/SKILL.md +12 -5
  120. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  121. package/skills/evolve/SKILL.md +11 -2
  122. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  123. package/skills/session-end/learning-patterns.md +3 -3
  124. package/skills/session-end/metrics-collection.md +1 -1
  125. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  126. package/skills/session-end/plan-verification.md +25 -9
  127. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  128. package/skills/session-end/session-metrics-write.md +45 -10
  129. package/skills/session-start/SKILL.md +3 -3
  130. package/skills/session-start/references/operations-contract.md +5 -3
  131. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  132. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  133. package/skills/vault-mirror/SKILL.md +21 -11
  134. package/skills/wave-executor/SKILL.md +1 -1
  135. package/skills/wave-executor/circuit-breaker.md +1 -1
  136. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  137. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  138. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  139. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  140. package/.agents/skills/navigator/SKILL.md +0 -22
  141. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  142. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  143. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  144. package/.cursor/commands/navigator.md +0 -15
  145. package/.cursor/skills/navigator/SKILL.md +0 -13
  146. package/pi/prompts/navigator.md +0 -12
  147. package/skills/navigator/SKILL.md +0 -193
  148. package/skills/navigator/references/account-check.sh +0 -87
  149. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  150. package/skills/navigator/references/slot-check.sh +0 -114
@@ -35,6 +35,9 @@
35
35
  * `git ls-files` — spawned HERE, in the CLI layer,
36
36
  * because scripts/lib/scope-gate.mjs is hook-safe and
37
37
  * must not spawn a process (see its module header).
38
+ * Runs even when the manifest fails schema validation
39
+ * (#1026.4): it reads only the sidecar, so schema
40
+ * errors and collisions are reported together (exit 1).
38
41
  * --union <path> #1020. QUERY MODE. Read the same sidecar, compute
39
42
  * `expandTestSiblings(unionFileScopes(scopes), {role})`
40
43
  * using the MANIFEST'S OWN `role`, and print the
@@ -638,11 +641,24 @@ function knownRepoFiles() {
638
641
  * @param {string} sidecarPath
639
642
  */
640
643
  function assertDisjointOrDie(sidecarPath) {
644
+ if (!reportScopeCollisions(sidecarPath)) process.exit(1);
645
+ }
646
+
647
+ /**
648
+ * Run the #1020 collision check and write one stderr line per finding.
649
+ * Reads ONLY the sidecar, never the manifest — which is why {@link validate}
650
+ * can still run it when the manifest fails schema validation (#1026.4).
651
+ * Sidecar I/O and shape defects exit via {@link readAgentScopesOrDie}.
652
+ *
653
+ * @param {string} sidecarPath
654
+ * @returns {boolean} true when the wave is disjoint
655
+ */
656
+ function reportScopeCollisions(sidecarPath) {
641
657
  const agentScopes = readAgentScopesOrDie(sidecarPath, '--assert-disjoint');
642
658
  const { ok, collisions, duplicateIds } = findScopeCollisions(agentScopes, {
643
659
  knownFiles: knownRepoFiles(),
644
660
  });
645
- if (ok) return;
661
+ if (ok) return true;
646
662
 
647
663
  // Duplicate ids FIRST: they are a malformed plan, and a reader who fixes them
648
664
  // may well change which collisions remain.
@@ -659,7 +675,7 @@ function assertDisjointOrDie(sidecarPath) {
659
675
  process.stderr.write(
660
676
  `ERROR: ${collisions.length} scope collision(s), ${duplicateIds.length} duplicate id(s) — every file must belong to exactly ONE agent per wave (#1020; .claude/rules/parallel-sessions.md § Decision Tree)\n`,
661
677
  );
662
- process.exit(1);
678
+ return false;
663
679
  }
664
680
 
665
681
  /**
@@ -709,6 +725,9 @@ function validate(
709
725
  for (const e of errors) {
710
726
  process.stderr.write(`ERROR: ${e}\n`);
711
727
  }
728
+ // #1026.4 — the collision check reads only the sidecar, so a schema error
729
+ // must not hide its findings: report both, exit 1 either way.
730
+ if (assertDisjointPath) reportScopeCollisions(assertDisjointPath);
712
731
  process.exit(1);
713
732
  }
714
733
 
@@ -38,6 +38,10 @@ import {
38
38
  } from './lib/vault-backfill/template.mjs';
39
39
 
40
40
  import { validateManifest, SLUG_RE } from './lib/vault-backfill/manifest.mjs';
41
+ import { _parseVaultIntegration } from './lib/config/vault-integration.mjs';
42
+ import { loadHostPaths, resolveVaultIntegrationHost } from './lib/config/host-paths.mjs';
43
+ import { resolveVaultDir } from './lib/config.mjs';
44
+ import { expandTilde } from './lib/common.mjs';
41
45
 
42
46
  // ── CLI argument parsing ──────────────────────────────────────────────────────
43
47
 
@@ -85,9 +89,29 @@ let hadWriteError = false;
85
89
 
86
90
  if (flagVerbose) setGlabVerbose(true);
87
91
 
88
- // ── Session Config reader (inline) ───────────────────────────────────────────
92
+ // ── Session Config reader ─────────────────────────────────────────────────────
89
93
 
90
- /** Read vault-integration.{gitlab-groups,vault-dir} from CLAUDE.md / AGENTS.md in CWD. */
94
+ /**
95
+ * Read vault-integration.{gitlab-groups,vault-dir} from CLAUDE.md / AGENTS.md in
96
+ * CWD through the canonical Session Config parser (#1094), so every form it
97
+ * accepts (block, inline object, dash/bold bullet) reaches this CLI too.
98
+ *
99
+ * `vault-dir` resolves HOST-LOCALLY like every other vault writer:
100
+ * SO_VAULT_DIR > owner.yaml `vault-dirs:` cwd match > owner.yaml
101
+ * `paths.vault-dir` > the committed value (`resolveVaultDir`). The committed
102
+ * value alone would put `--apply` stubs into whatever vault the committed file
103
+ * names, even on a host whose owner.yaml points elsewhere. Not
104
+ * `parseSessionConfig`: it throws on an invalid value in any unrelated block.
105
+ *
106
+ * `enabled` is the switch the other vault writers obey (board-writer,
107
+ * narrative-mirror: `enabled !== true` → skip), after the same host-local
108
+ * lowering `parseSessionConfig` applies — env `SO_VAULT_INTEGRATION` >
109
+ * owner.yaml `vault-integration:` > committed (#1496: stubs were created with
110
+ * the integration off, from an owner.yaml `paths.vault-dir` alone).
111
+ *
112
+ * @returns {{'gitlab-groups': string[]|null, 'vault-dir': string|null, enabled: boolean,
113
+ * 'host-override': string|null}}
114
+ */
91
115
  function readVaultIntegrationConfig() {
92
116
  const candidates = [join(process.cwd(), 'CLAUDE.md'), join(process.cwd(), 'AGENTS.md')];
93
117
 
@@ -98,45 +122,24 @@ function readVaultIntegrationConfig() {
98
122
  }
99
123
  }
100
124
 
101
- if (!content) return { 'gitlab-groups': null, 'vault-dir': null };
102
-
103
- let inBlock = false;
104
- let gitlabGroups = null;
105
- let vaultDir = null;
106
-
107
- for (const rawLine of content.split(/\r?\n/)) {
108
- const line = rawLine.replace(/\r$/, '');
109
-
110
- if (!inBlock && /^vault-integration:\s*$/.test(line)) { inBlock = true; continue; }
111
- if (inBlock) {
112
- if (line.length > 0 && !/^\s/.test(line)) break;
113
-
114
- const m = line.match(/^\s+([a-zA-Z_-]+):\s*(.*)/);
115
- if (!m) continue;
116
-
117
- const k = m[1].trim();
118
- let v = m[2].trim().replace(/\s*#.*$/, '').trim();
119
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
120
- v = v.slice(1, -1);
121
- }
122
-
123
- if (k === 'gitlab-groups' && v) {
124
- gitlabGroups = v.replace(/^\[/, '').replace(/\]$/, '').trim() || null;
125
- }
126
- if (k === 'vault-dir' && v && v !== 'none' && v !== 'null') {
127
- vaultDir = v;
128
- }
129
- }
130
- }
131
-
132
- return { 'gitlab-groups': gitlabGroups, 'vault-dir': vaultDir };
125
+ const hostCtx = loadHostPaths();
126
+ const vi = resolveVaultIntegrationHost(_parseVaultIntegration(content ?? ''), hostCtx);
127
+ const vaultDir = resolveVaultDir(vi['vault-dir'], hostCtx).value ?? null;
128
+ return {
129
+ 'gitlab-groups': vi['gitlab-groups'],
130
+ 'vault-dir': vaultDir,
131
+ enabled: vi.enabled === true,
132
+ 'host-override': vi['host-override'],
133
+ };
133
134
  }
134
135
 
135
136
  // ── Vault-dir folder stub ─────────────────────────────────────────────────────
136
137
 
137
138
  function createVaultFolderStub(vaultDir, slug) {
138
139
  if (!vaultDir) return;
139
- const stubDir = join(resolve(vaultDir), '01-projects', slug);
140
+ // `~/…` is the documented vault-dir shape; resolve() alone does not expand it
141
+ // and would put the stub under `<cwd>/~/…`.
142
+ const stubDir = join(resolve(expandTilde(vaultDir)), '01-projects', slug);
140
143
  try {
141
144
  if (!existsSync(stubDir)) {
142
145
  if (applyWrites) {
@@ -399,10 +402,20 @@ async function main() {
399
402
  if (flagGroups) {
400
403
  groups = flagGroups.split(',').map((g) => g.trim()).filter(Boolean);
401
404
  } else if (cfgConfig['gitlab-groups']) {
402
- groups = cfgConfig['gitlab-groups'].split(',').map((g) => g.trim()).filter(Boolean);
405
+ groups = cfgConfig['gitlab-groups'];
403
406
  }
404
407
 
405
- const vaultDir = flagVaultDir ?? cfgConfig['vault-dir'] ?? null;
408
+ // The configured vault-dir counts only while vault-integration is on; an
409
+ // explicit --vault-dir is this run's own request and still wins.
410
+ let vaultDir = flagVaultDir ?? null;
411
+ if (vaultDir === null && cfgConfig['vault-dir'] !== null) {
412
+ if (cfgConfig.enabled) {
413
+ vaultDir = cfgConfig['vault-dir'];
414
+ } else {
415
+ const why = cfgConfig['host-override'] ? `switched off by ${cfgConfig['host-override']}` : 'not enabled';
416
+ log(`vault-integration is ${why} — no vault folder stubs (pass --vault-dir to create them anyway)`);
417
+ }
418
+ }
406
419
 
407
420
  if (!flagYes && (!groups || groups.length === 0)) {
408
421
  die(1, 'no GitLab groups specified — pass --groups <CSV> or set vault-integration.gitlab-groups in CLAUDE.md');
@@ -8,23 +8,40 @@
8
8
  * CLI usage:
9
9
  * node vault-mirror.mjs --vault-dir <path> --source <jsonl-path> --kind <learning|session>
10
10
  * [--dry-run] [--strict-schema] [--no-commit] [--force]
11
- * [--session-id <id>]
11
+ * [--session-id <id>] [--repo-root <path>] [--vault-name <name>]
12
12
  * [--quality-min-narrative-chars <int>] (sessions only; default 400)
13
13
  * [--quality-min-confidence <float>] (learnings only; default 0.5)
14
14
  *
15
15
  * Exit codes:
16
16
  * 0 — success (including idempotent no-op)
17
17
  * 1 — validation error (malformed JSON line, bad slug, etc.)
18
- * 2 — filesystem error
18
+ * 2 — filesystem error, or a pre-loop refusal (missing vault-dir / source,
19
+ * non-canonical vault, `--source` belonging to a different repo than
20
+ * `--repo-root` — #1503)
19
21
  *
20
22
  * Output: one JSON line per action on stdout:
21
- * {"action":"created|updated|skipped-noop|skipped-handwritten|skipped-collision-resolved|skipped-invalid|skipped-quality-low|skipped-abandoned|skipped-duplicate-session","path":"...","kind":"...","id":"..."}
23
+ * {"action":"created|updated|skipped-noop|skipped-handwritten|skipped-collision-resolved|skipped-foreign-owner|skipped-invalid|skipped-quality-low|skipped-abandoned|skipped-duplicate-session","path":"...","kind":"...","id":"..."}
22
24
  *
23
25
  * Idempotency rules:
24
26
  * 1. File does not exist → create.
25
27
  * 2. File exists, has _generator marker, id matches → overwrite only if updated would advance; else skipped-noop.
26
28
  * 3. File exists, lacks _generator → skip (hand-written). Log to stderr.
27
29
  * 4. File exists, has _generator, id differs → collision-disambiguate by appending -<first8 of uuid>.
30
+ * (Sessions have no disambiguation: a different-id note is skipped-foreign-owner.)
31
+ * 5. File exists, has _generator, but its `source-record` names a different record
32
+ * than this write (#1503): learnings → collision-disambiguate as in rule 4;
33
+ * sessions → skipped-foreign-owner unless the key occurs in this --source ledger.
34
+ * A leak in such a foreign note is masked IN PLACE (owner, content and
35
+ * source-record kept; `healed_leak: true` on the action) — never rewritten
36
+ * from this record. If the masked frontmatter would not parse, it is left
37
+ * unchanged with a stderr WARN.
38
+ *
39
+ * Repo root (#1503): the namespace is the identity of the repo the --source
40
+ * ledger belongs to — `--repo-root` when given, else the project holding
41
+ * --source (`<root>/.orchestrator/metrics/`), else the git repo containing it,
42
+ * else the cwd (a loose source outside any git repo). It is never the cwd
43
+ * when the source sits in a repo: running from another repo's checkout used to
44
+ * write one project's records over another project's notes.
28
45
  *
29
46
  * Quality gate (PRD F1.2):
30
47
  * Learnings with confidence < --quality-min-confidence emit `skipped-quality-low`.
@@ -48,8 +65,8 @@
48
65
  * Part of session-orchestrator vault-mirror (Issue #14).
49
66
  */
50
67
 
51
- import { existsSync } from 'node:fs';
52
- import { basename, resolve } from 'node:path';
68
+ import { existsSync, realpathSync } from 'node:fs';
69
+ import { basename, dirname, resolve } from 'node:path';
53
70
  import { createInterface } from 'node:readline';
54
71
  import { createReadStream } from 'node:fs';
55
72
 
@@ -64,11 +81,12 @@ import { emitEvent } from './lib/events.mjs';
64
81
  import { autoCommitVaultMirror } from './lib/vault-mirror/auto-commit.mjs';
65
82
  import { parseColumnFlags, CliFlagError } from './lib/cli-flags.mjs';
66
83
  import { resolveRepoNamespace } from './lib/vault-mirror/namespace.mjs';
67
- import { checkCanonicalVault, describeOriginForLog, normalizeRemote } from './lib/named-vault-resolver.mjs';
84
+ import { sessionSourceRecord } from './lib/vault-mirror/render-sessions.mjs';
85
+ import { checkCanonicalVault, describeOriginForLog, findRepoRoot, normalizeRemote } from './lib/named-vault-resolver.mjs';
68
86
  import { loadOwnerConfig } from './lib/owner-yaml.mjs';
69
87
  import { canonicalizeSessions } from './lib/sessions-canonical.mjs';
70
88
  import { isMainModule } from './lib/is-main-module.mjs';
71
- import { expandTilde } from './lib/common.mjs';
89
+ import { expandTilde, redactHomeDir } from './lib/common.mjs';
72
90
 
73
91
  // ── Canonical-vault helpers (#600 D2 / #607 D2) ────────────────────────────────
74
92
  // These are module-level (above the CLI bootstrap) so the module is import-safe
@@ -131,9 +149,10 @@ export function _normalizeRemote(url) {
131
149
  // is always measured.
132
150
  // - Same ledger as the masker emit: `emitEvent` is called 2-arg so both events
133
151
  // from one run resolve the SAME destination (`SO_PROJECT_DIR`, i.e.
134
- // `CLAUDE_PROJECT_DIR` or the CWD walk-up). This CLI has no repo-root flag
135
- // and deriving one from `--source` would split a single run's telemetry
136
- // across two ledgers.
152
+ // `CLAUDE_PROJECT_DIR` or the CWD walk-up). The repo root that decides the
153
+ // vault NAMESPACE (#1503: `--repo-root`, else derived from `--source`) is
154
+ // deliberately NOT used for the ledger — routing telemetry by it would split
155
+ // a single run's records across two ledgers.
137
156
  //
138
157
  // #1147 moved both emitters into `scripts/lib/vault-mirror/telemetry.mjs` and
139
158
  // widened the coverage from "the two skipped-invalid branches" to "every entry,
@@ -186,13 +205,14 @@ if (_isDirectInvocation) {
186
205
  kind: null,
187
206
  'session-id': null,
188
207
  'vault-name': null,
208
+ 'repo-root': null,
189
209
  'quality-min-narrative-chars': null,
190
210
  'quality-min-confidence': null,
191
211
  },
192
212
  });
193
213
  } catch (err) {
194
214
  if (err instanceof CliFlagError) {
195
- process.stderr.write(`vault-mirror: ${err.message}\n`);
215
+ process.stderr.write(`vault-mirror: ${redactHomeDir(err.message)}\n`);
196
216
  process.exit(1);
197
217
  }
198
218
  throw err;
@@ -206,7 +226,7 @@ if (flagValues.help === true) {
206
226
  [
207
227
  'Usage: node vault-mirror.mjs --vault-dir <path> --source <jsonl-path> --kind <learning|session>',
208
228
  ' [--dry-run] [--strict-schema] [--no-commit] [--force]',
209
- ' [--session-id <id>]',
229
+ ' [--session-id <id>] [--repo-root <path>] [--vault-name <name>]',
210
230
  ' [--quality-min-narrative-chars <int>]',
211
231
  ' [--quality-min-confidence <float>]',
212
232
  '',
@@ -224,6 +244,11 @@ if (flagValues.help === true) {
224
244
  ' When set, mirrors write under <vault-name>/ instead of the',
225
245
  ' git-derived repo identifier. Maps to vault-integration.vault-name',
226
246
  ' in Session Config. Sanitised to a lowercase kebab slug.',
247
+ ' --repo-root <path> The repo whose ledger --source is (#1503). Its .vault.yaml',
248
+ ' slug / git remote decides the vault namespace. Default: the',
249
+ ' project holding --source (<root>/.orchestrator/metrics/), else',
250
+ ' the git repo containing it, else the cwd. Refused (exit 2,',
251
+ ' "source-repo-mismatch") when --source lies in a different repo.',
227
252
  ' --quality-min-narrative-chars <int> Sessions: minimum rendered-narrative length (default 400).',
228
253
  ' Entries below the threshold emit "skipped-quality-low".',
229
254
  ' --quality-min-confidence <float> Learnings: minimum confidence threshold (default 0.5).',
@@ -249,6 +274,7 @@ const noCommit = flagValues['no-commit'] === true;
249
274
  const force = flagValues.force === true;
250
275
  const sessionIdArg = flagValues['session-id'];
251
276
  const vaultName = flagValues['vault-name'] ?? null;
277
+ const repoRootArg = flagValues['repo-root'] ?? null;
252
278
 
253
279
  // Quality-gate thresholds (PRD F1.2). Parse as numbers; reject malformed input
254
280
  // loudly so CI cannot accidentally pass a string ("400px") and silently fall
@@ -289,7 +315,7 @@ const qualityMinConfidence = parseFloatFlag(
289
315
 
290
316
  if (!vaultDir || !source || !kind) {
291
317
  process.stderr.write(
292
- 'Usage: node vault-mirror.mjs --vault-dir <path> --source <jsonl-path> --kind <learning|session> [--dry-run] [--strict-schema] [--no-commit] [--force] [--session-id <id>] [--quality-min-narrative-chars <int>] [--quality-min-confidence <float>]\n',
318
+ 'Usage: node vault-mirror.mjs --vault-dir <path> --source <jsonl-path> --kind <learning|session> [--dry-run] [--strict-schema] [--no-commit] [--force] [--session-id <id>] [--repo-root <path>] [--vault-name <name>] [--quality-min-narrative-chars <int>] [--quality-min-confidence <float>]\n',
293
319
  );
294
320
  process.exit(1);
295
321
  }
@@ -343,16 +369,47 @@ function pathForLog(p) {
343
369
  return `.../${basename(String(p ?? ''))}`;
344
370
  }
345
371
 
372
+ /**
373
+ * The project a ledger belongs to (#1503): the directory that holds it under
374
+ * the `.orchestrator/metrics/` convention, else the git repo containing it,
375
+ * else `null`.
376
+ *
377
+ * The convention comes FIRST because the git toplevel is the wrong answer for
378
+ * a project nested inside a repo — a monorepo package with its own
379
+ * `.orchestrator/` and `.vault.yaml` (measured: `--repo-root <package>` was
380
+ * refused against the monorepo's namespace, and without the flag the package's
381
+ * notes moved to the monorepo's folder). Every producer writes the ledgers to
382
+ * `<project>/.orchestrator/metrics/`, which is also exactly the directory whose
383
+ * `.vault.yaml` named the namespace before #1503 (callers ran from it).
384
+ *
385
+ * Named ceiling: a ledger stored under some project's `.orchestrator/metrics/`
386
+ * is attributed to that project whoever wrote its records — a foreign ledger
387
+ * COPIED there is not detected here; the session `source-record` guard in
388
+ * process.mjs is the remaining defence for that shape.
389
+ *
390
+ * @param {string} realSource - the source path after `realpathSync`.
391
+ * @returns {string|null}
392
+ */
393
+ function ledgerOwnerRoot(realSource) {
394
+ const metricsDir = dirname(realSource);
395
+ const orchestratorDir = dirname(metricsDir);
396
+ if (basename(metricsDir) === 'metrics' && basename(orchestratorDir) === '.orchestrator') {
397
+ return dirname(orchestratorDir);
398
+ }
399
+ return findRepoRoot(dirname(realSource));
400
+ }
401
+
346
402
  // ── Run-level accounting + run close-out (#1147) ──────────────────────────────
347
403
  //
348
404
  // Deliberately OUTSIDE main(): the run event's whole contract is that it is
349
405
  // written ONCE PER RUN and that its ABSENCE is the broken-emitter signal
350
- // (HR-105). Six exits bypass main's normal tail — the three PRE-LOOP aborts at
351
- // the top of main (missing vault-dir, non-canonical vault, missing source), the
352
- // malformed-JSON abort and the filesystem-error abort inside the loop (all five
353
- // `process.exit`, which no `finally` and no `catch` can intercept), and the
354
- // top-level `main().catch`, which runs in a scope where main's locals no longer
355
- // exist. Keeping the counters and the emitter out here is what lets all six
406
+ // (HR-105). Seven exits bypass main's normal tail — the four PRE-LOOP aborts at
407
+ // the top of main (missing vault-dir, non-canonical vault, missing source,
408
+ // source/repo-root mismatch), the malformed-JSON abort and the filesystem-error
409
+ // abort inside the loop (all six `process.exit`, which no `finally` and no
410
+ // `catch` can intercept), and the top-level `main().catch`, which runs in a
411
+ // scope where main's locals no longer exist. Keeping the counters and the
412
+ // emitter out here is what lets all seven
356
413
  // close the run out through ONE function instead of each re-deriving the
357
414
  // payload.
358
415
  const runState = {
@@ -381,10 +438,10 @@ const tally = (action) => {
381
438
  * counted were the ones that vanished from the ledger, in the one shape
382
439
  * ("no record") that the docstring reserves for a broken emitter.
383
440
  *
384
- * @param {'missing-vault-dir'|'vault-not-canonical'|'missing-source'|'malformed-json'|'filesystem-error'|'unexpected-error'} [aborted]
441
+ * @param {'missing-vault-dir'|'vault-not-canonical'|'missing-source'|'source-repo-mismatch'|'malformed-json'|'filesystem-error'|'unexpected-error'} [aborted]
385
442
  * Omitted on a complete run. When present it LABELS the counters as partial:
386
443
  * every line after the abort was never attempted, so the classes no longer
387
- * partition `total`. On the three PRE-LOOP values (#1151) nothing was
444
+ * partition `total`. On the PRE-LOOP values (#1151, #1503) nothing was
388
445
  * attempted at all — `total` is a measured 0 and the label is what separates
389
446
  * "never started" from "ran over an empty source".
390
447
  * @returns {Promise<void>}
@@ -442,7 +499,7 @@ async function finishRun(aborted) {
442
499
  // ── Main ──────────────────────────────────────────────────────────────────────
443
500
 
444
501
  async function main() {
445
- // The three PRE-LOOP aborts below (#1151) close the run out through the same
502
+ // The PRE-LOOP aborts below (#1151, #1503) close the run out through the same
446
503
  // `finishRun` every other exit uses. They are the runs that never reached
447
504
  // their first entry — a bad vault-dir, a wrong vault, a missing source — and
448
505
  // until now they were the only outcomes that left NO record at all, which is
@@ -477,6 +534,80 @@ async function main() {
477
534
  process.exit(2);
478
535
  }
479
536
 
537
+ // ── Repo root + source/repo-root agreement (#1503) ──────────────────────────
538
+ //
539
+ // The namespace every note is written under is the identity of `repoRoot`.
540
+ // Until #1503 that was hard-wired to the invocation cwd, while `--source` was
541
+ // free to name ANY ledger: a session that ran this CLI from another repo's
542
+ // checkout with `--source <ThisRepo>/.orchestrator/metrics/sessions.jsonl`
543
+ // wrote 130 of this repo's records under the OTHER repo's namespace and
544
+ // overwrote 3 of its notes — slug and session-id collisions are routine across
545
+ // repos. The fix is to stop asking the cwd:
546
+ // 1. `--repo-root` when given — the caller states which repo it mirrors;
547
+ // 2. else the ledger's own project — see {@link ledgerOwnerRoot};
548
+ // 3. else the cwd — only for a source that is neither a project ledger nor
549
+ // inside a git repo (a copied ledger, the test suites' mkdtemp sources),
550
+ // where nothing better is knowable.
551
+ // Step 2's answer is also what an explicit `--repo-root` is checked against.
552
+ //
553
+ // The ledger's REAL path decides, not the path it was handed under: a symlink
554
+ // inside repo A that points at repo B's ledger is B's ledger. `realpathSync`
555
+ // falls back to the lexical path only when it throws (the file was just
556
+ // proven to exist, so that is a race or a permission edge, not a normal case).
557
+ // The same holds for `--repo-root`: a remote-less, slug-less repo is named by
558
+ // its directory, so a root reached through a symlink would otherwise carry the
559
+ // LINK's name and be refused against its own ledger.
560
+ const realOrLexical = (p) => {
561
+ try {
562
+ return realpathSync(p);
563
+ } catch {
564
+ return p;
565
+ }
566
+ };
567
+ const sourceRealPath = realOrLexical(resolve(source));
568
+ const sourceRepoRoot = ledgerOwnerRoot(sourceRealPath);
569
+ const repoRoot = repoRootArg
570
+ ? realOrLexical(resolve(expandTilde(repoRootArg)))
571
+ : (sourceRepoRoot ?? process.cwd());
572
+
573
+ // A source with no owning project cannot be checked against anything: the
574
+ // namespace then rests on the cwd or on an unverifiable `--repo-root`, and a
575
+ // ledger copied out of ANOTHER repo would be written over this repo's notes
576
+ // without a refusal. Say so once — the run proceeds, because a loose ledger
577
+ // (a copy, a test fixture) is a legitimate input.
578
+ if (sourceRepoRoot === null) {
579
+ const ns = resolveRepoNamespace({ vaultName, repoRoot });
580
+ process.stderr.write(
581
+ repoRootArg
582
+ ? `WARN vault-mirror: --source ${pathForLog(source)} is neither a project ledger (.orchestrator/metrics/) nor inside a git repo, so --repo-root cannot be checked against it; writing under namespace '${ns}'\n`
583
+ : `WARN vault-mirror: --source ${pathForLog(source)} is neither a project ledger (.orchestrator/metrics/) nor inside a git repo; the namespace '${ns}' comes from the cwd — pass --repo-root to state which repo this ledger belongs to\n`,
584
+ );
585
+ }
586
+
587
+ // Fail closed when the two disagree: an explicit `--repo-root` whose identity
588
+ // differs from the repo the ledger lives in is exactly the incident shape
589
+ // (callers pass `--repo-root "$PWD"`, so a run from the wrong checkout lands
590
+ // here instead of in another project's notes). NAMESPACES are compared, not
591
+ // paths — a worktree and its main checkout are different directories that
592
+ // share one `.vault.yaml` slug and one remote. `vaultName` is left out on
593
+ // purpose: it overrides the FOLDER, and the question here is whether the
594
+ // ledger belongs to the repo being mirrored. Named ceiling: two owner-leaky
595
+ // repos without a pseudonym-map entry both resolve to `redacted-repo` and pass
596
+ // this check — they already share that one folder (#725 D5), so the refusal
597
+ // could not keep them apart anyway.
598
+ if (repoRootArg && sourceRepoRoot !== null) {
599
+ const repoNs = resolveRepoNamespace({ repoRoot });
600
+ const sourceNs = resolveRepoNamespace({ repoRoot: sourceRepoRoot });
601
+ if (repoNs !== sourceNs) {
602
+ // Both values are leak-guarded namespaces — safe for logs, unlike the paths.
603
+ process.stderr.write(
604
+ `vault-mirror: refusing to mirror — --source ${pathForLog(source)} belongs to repo namespace '${sourceNs}', but --repo-root resolves to '${repoNs}'. Run with the repo root the ledger belongs to.\n`,
605
+ );
606
+ await finishRun('source-repo-mismatch');
607
+ process.exit(2);
608
+ }
609
+ }
610
+
480
611
  const rl = createInterface({
481
612
  input: createReadStream(resolve(source), 'utf8'),
482
613
  crlfDelay: Infinity,
@@ -502,10 +633,14 @@ async function main() {
502
633
  kind,
503
634
  force,
504
635
  vaultName,
505
- // #1389: the repo whose `.vault.yaml` declares the namespace slug. This CLI
506
- // has no repo-root flag, so it is the invocation cwd — bound ONCE here and
507
- // shared by every processor call and the auto-commit below.
508
- repoRoot: process.cwd(),
636
+ // #1389/#1503: the repo whose identity (`.vault.yaml` slug, else git remote)
637
+ // is the namespace — resolved ONCE above from `--repo-root` / `--source`,
638
+ // and shared by every processor call and the auto-commit below.
639
+ repoRoot,
640
+ // #1503: every `source-record` key the `--kind session` source carries,
641
+ // superseded duplicates included — filled while buffering below, read by
642
+ // processSession's owner guard (a key from this ledger is never foreign).
643
+ sourceRecordKeys: new Set(),
509
644
  qualityMinNarrativeChars,
510
645
  qualityMinConfidence,
511
646
  };
@@ -541,7 +676,7 @@ async function main() {
541
676
  } catch (err) {
542
677
  // Validation errors (missing required fields) → per-entry skip, not a global failure
543
678
  if (err.message.startsWith('vault-mirror:')) {
544
- process.stderr.write(`${err.message}\n`);
679
+ process.stderr.write(`${redactHomeDir(err.message)}\n`);
545
680
  const entryId = entry?.id ?? entry?.session_id ?? null;
546
681
  process.stdout.write(
547
682
  JSON.stringify({ action: 'skipped-invalid', path: null, kind, id: entryId }) + '\n',
@@ -554,7 +689,7 @@ async function main() {
554
689
  line: entryLineNum,
555
690
  recordId: entryId,
556
691
  skipClass: 'validation',
557
- reason: err.message,
692
+ reason: redactHomeDir(err.message),
558
693
  dryRun,
559
694
  });
560
695
  return;
@@ -573,7 +708,7 @@ async function main() {
573
708
  (typeof err.code === 'string' && err.code.length > 0) || Boolean(err.syscall);
574
709
  if (!isSystemError) {
575
710
  process.stderr.write(
576
- `vault-mirror: mapper crash on line ${entryLineNum} (${err.message}) — record skipped\n`,
711
+ `vault-mirror: mapper crash on line ${entryLineNum} (${redactHomeDir(err.message)}) — record skipped\n`,
577
712
  );
578
713
  const entryId = entry?.id ?? entry?.session_id ?? null;
579
714
  process.stdout.write(
@@ -593,13 +728,13 @@ async function main() {
593
728
  line: entryLineNum,
594
729
  recordId: entryId,
595
730
  skipClass: 'mapper-crash',
596
- reason: err.message,
731
+ reason: redactHomeDir(err.message),
597
732
  dryRun,
598
733
  });
599
734
  return;
600
735
  }
601
736
  // Unexpected filesystem errors → fatal
602
- process.stderr.write(`vault-mirror: filesystem error on line ${entryLineNum}: ${err.message}\n`);
737
+ process.stderr.write(`vault-mirror: filesystem error on line ${entryLineNum}: ${redactHomeDir(err.message)}\n`);
603
738
  await finishRun('filesystem-error');
604
739
  process.exit(2);
605
740
  }
@@ -625,7 +760,7 @@ async function main() {
625
760
  try {
626
761
  entry = JSON.parse(trimmed);
627
762
  } catch (err) {
628
- process.stderr.write(`vault-mirror: malformed JSON on line ${lineNum}: ${err.message}\n`);
763
+ process.stderr.write(`vault-mirror: malformed JSON on line ${lineNum}: ${redactHomeDir(err.message)}\n`);
629
764
  // Close the run out BEFORE exiting: `process.exit` runs no `finally`, so
630
765
  // without this the abort is the one outcome that leaves no run record —
631
766
  // exactly the shape reserved for a broken emitter.
@@ -635,6 +770,8 @@ async function main() {
635
770
 
636
771
  if (kind === 'session') {
637
772
  sessionEntries.push(entry);
773
+ const recordKey = sessionSourceRecord(entry);
774
+ if (recordKey !== null) ctx.sourceRecordKeys.add(recordKey);
638
775
  sessionLineNums.push(lineNum);
639
776
  continue;
640
777
  }
@@ -732,7 +869,7 @@ async function main() {
732
869
  }
733
870
 
734
871
  main().catch(async (err) => {
735
- process.stderr.write(`vault-mirror: unexpected error: ${err.message}\n`);
872
+ process.stderr.write(`vault-mirror: unexpected error: ${redactHomeDir(err.message)}\n`);
736
873
  // Same reason as the two in-loop aborts: an unexpected throw is a run that
737
874
  // ENDED, and the ledger has to say so. `finishRun` never throws, so this
738
875
  // cannot turn a diagnosable crash into a silent one.