session-orchestrator 5.3.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -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","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,22 +65,28 @@
48
65
  * Part of session-orchestrator vault-mirror (Issue #14).
49
66
  */
50
67
 
51
- import { spawnSync } from 'node:child_process';
52
- import { existsSync } from 'node:fs';
53
- import { resolve } from 'node:path';
68
+ import { existsSync, realpathSync } from 'node:fs';
69
+ import { basename, dirname, resolve } from 'node:path';
54
70
  import { createInterface } from 'node:readline';
55
71
  import { createReadStream } from 'node:fs';
56
72
 
57
- import { processLearning, processSession, getMaskerStats } from './lib/vault-mirror/process.mjs';
73
+ import {
74
+ processLearning,
75
+ processSession,
76
+ getMaskerStats,
77
+ emitAction,
78
+ } from './lib/vault-mirror/process.mjs';
58
79
  import { emitMirrorEvent, emitMirrorRunEvent } from './lib/vault-mirror/telemetry.mjs';
59
80
  import { emitEvent } from './lib/events.mjs';
60
81
  import { autoCommitVaultMirror } from './lib/vault-mirror/auto-commit.mjs';
61
82
  import { parseColumnFlags, CliFlagError } from './lib/cli-flags.mjs';
62
83
  import { resolveRepoNamespace } from './lib/vault-mirror/namespace.mjs';
63
- import { resolveCanonicalSuffixes } 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';
64
86
  import { loadOwnerConfig } from './lib/owner-yaml.mjs';
65
87
  import { canonicalizeSessions } from './lib/sessions-canonical.mjs';
66
88
  import { isMainModule } from './lib/is-main-module.mjs';
89
+ import { expandTilde, redactHomeDir } from './lib/common.mjs';
67
90
 
68
91
  // ── Canonical-vault helpers (#600 D2 / #607 D2) ────────────────────────────────
69
92
  // These are module-level (above the CLI bootstrap) so the module is import-safe
@@ -85,7 +108,7 @@ export function _resolveCanonicalSuffix(envValue) {
85
108
  return envValue && envValue.trim() ? envValue.trim() : '/agents/vault';
86
109
  }
87
110
 
88
- // Kept for documentation; the guard now uses resolveCanonicalSuffixes() which
111
+ // Kept for documentation; the guard now uses checkCanonicalVault() which
89
112
  // generalises this to N suffixes. The `_` prefix satisfies the no-unused-vars rule.
90
113
  const _CANONICAL_VAULT_SUFFIX = _resolveCanonicalSuffix(
91
114
  process.env.VAULT_MIRROR_CANONICAL_SUFFIX,
@@ -93,18 +116,14 @@ const _CANONICAL_VAULT_SUFFIX = _resolveCanonicalSuffix(
93
116
 
94
117
  /**
95
118
  * Normalize a git remote URL to a host/path tail for canonical-suffix matching
96
- * (#607 D2 — exported for unit tests). Strips `.git`, the `git@host:` / scheme
97
- * prefixes, and trailing slashes.
119
+ * (#607 D2 — exported for unit tests). Since #1450 the ONE implementation is
120
+ * `normalizeRemote` in lib/named-vault-resolver.mjs, shared with the board
121
+ * writer's guard; this name is kept for the existing test imports.
98
122
  * @param {string} url
99
123
  * @returns {string}
100
124
  */
101
125
  export function _normalizeRemote(url) {
102
- return String(url ?? '')
103
- .trim()
104
- .replace(/\.git$/, '')
105
- .replace(/^git@([^:]+):/, '$1/')
106
- .replace(/^[a-z]+:\/\//, '')
107
- .replace(/\/+$/, '');
126
+ return normalizeRemote(url);
108
127
  }
109
128
 
110
129
  // ── Mirror telemetry (#1116) ──────────────────────────────────────────────────
@@ -130,9 +149,10 @@ export function _normalizeRemote(url) {
130
149
  // is always measured.
131
150
  // - Same ledger as the masker emit: `emitEvent` is called 2-arg so both events
132
151
  // from one run resolve the SAME destination (`SO_PROJECT_DIR`, i.e.
133
- // `CLAUDE_PROJECT_DIR` or the CWD walk-up). This CLI has no repo-root flag
134
- // and deriving one from `--source` would split a single run's telemetry
135
- // 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.
136
156
  //
137
157
  // #1147 moved both emitters into `scripts/lib/vault-mirror/telemetry.mjs` and
138
158
  // widened the coverage from "the two skipped-invalid branches" to "every entry,
@@ -185,13 +205,14 @@ if (_isDirectInvocation) {
185
205
  kind: null,
186
206
  'session-id': null,
187
207
  'vault-name': null,
208
+ 'repo-root': null,
188
209
  'quality-min-narrative-chars': null,
189
210
  'quality-min-confidence': null,
190
211
  },
191
212
  });
192
213
  } catch (err) {
193
214
  if (err instanceof CliFlagError) {
194
- process.stderr.write(`vault-mirror: ${err.message}\n`);
215
+ process.stderr.write(`vault-mirror: ${redactHomeDir(err.message)}\n`);
195
216
  process.exit(1);
196
217
  }
197
218
  throw err;
@@ -205,7 +226,7 @@ if (flagValues.help === true) {
205
226
  [
206
227
  'Usage: node vault-mirror.mjs --vault-dir <path> --source <jsonl-path> --kind <learning|session>',
207
228
  ' [--dry-run] [--strict-schema] [--no-commit] [--force]',
208
- ' [--session-id <id>]',
229
+ ' [--session-id <id>] [--repo-root <path>] [--vault-name <name>]',
209
230
  ' [--quality-min-narrative-chars <int>]',
210
231
  ' [--quality-min-confidence <float>]',
211
232
  '',
@@ -223,6 +244,11 @@ if (flagValues.help === true) {
223
244
  ' When set, mirrors write under <vault-name>/ instead of the',
224
245
  ' git-derived repo identifier. Maps to vault-integration.vault-name',
225
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.',
226
252
  ' --quality-min-narrative-chars <int> Sessions: minimum rendered-narrative length (default 400).',
227
253
  ' Entries below the threshold emit "skipped-quality-low".',
228
254
  ' --quality-min-confidence <float> Learnings: minimum confidence threshold (default 0.5).',
@@ -235,7 +261,11 @@ if (flagValues.help === true) {
235
261
  process.exit(0);
236
262
  }
237
263
 
238
- const vaultDir = flagValues['vault-dir'];
264
+ // Expand a leading `~` before anything touches the path: owner.yaml `vault-dirs:` /
265
+ // `paths.vault-dir` and the committed Session Config all accept `~/…`, and a quoted
266
+ // `--vault-dir "~/…"` reaches us literally. `resolve('~/x')` would yield `<cwd>/~/x`
267
+ // and the existsSync check below would abort with `missing-vault-dir` (agents/vault#319).
268
+ const vaultDir = expandTilde(flagValues['vault-dir']);
239
269
  const source = flagValues.source;
240
270
  const kind = flagValues.kind;
241
271
  const dryRun = flagValues['dry-run'] === true;
@@ -244,6 +274,7 @@ const noCommit = flagValues['no-commit'] === true;
244
274
  const force = flagValues.force === true;
245
275
  const sessionIdArg = flagValues['session-id'];
246
276
  const vaultName = flagValues['vault-name'] ?? null;
277
+ const repoRootArg = flagValues['repo-root'] ?? null;
247
278
 
248
279
  // Quality-gate thresholds (PRD F1.2). Parse as numbers; reject malformed input
249
280
  // loudly so CI cannot accidentally pass a string ("400px") and silently fall
@@ -284,7 +315,7 @@ const qualityMinConfidence = parseFloatFlag(
284
315
 
285
316
  if (!vaultDir || !source || !kind) {
286
317
  process.stderr.write(
287
- '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',
288
319
  );
289
320
  process.exit(1);
290
321
  }
@@ -323,16 +354,62 @@ if (kind !== 'learning' && kind !== 'session') {
323
354
  // imports) so they are import-safe and unit-testable; see _resolveCanonicalSuffix
324
355
  // / _normalizeRemote there (#607 D2).
325
356
 
357
+ /**
358
+ * Render a CLI path flag for a stderr line as its basename only (#1479).
359
+ *
360
+ * This CLI's stderr lands in CI logs and session transcripts, and an absolute
361
+ * `--vault-dir` / `--source` carries the operator's home directory — the
362
+ * owner-leakage class `.claude/rules/security.md` § Owner-Privacy guards. The
363
+ * basename still says WHICH value was wrong; the caller passed the full path
364
+ * and already knows it.
365
+ * @param {string} p
366
+ * @returns {string}
367
+ */
368
+ function pathForLog(p) {
369
+ return `.../${basename(String(p ?? ''))}`;
370
+ }
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
+
326
402
  // ── Run-level accounting + run close-out (#1147) ──────────────────────────────
327
403
  //
328
404
  // Deliberately OUTSIDE main(): the run event's whole contract is that it is
329
405
  // written ONCE PER RUN and that its ABSENCE is the broken-emitter signal
330
- // (HR-105). Six exits bypass main's normal tail — the three PRE-LOOP aborts at
331
- // the top of main (missing vault-dir, non-canonical vault, missing source), the
332
- // malformed-JSON abort and the filesystem-error abort inside the loop (all five
333
- // `process.exit`, which no `finally` and no `catch` can intercept), and the
334
- // top-level `main().catch`, which runs in a scope where main's locals no longer
335
- // 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
336
413
  // close the run out through ONE function instead of each re-deriving the
337
414
  // payload.
338
415
  const runState = {
@@ -361,10 +438,10 @@ const tally = (action) => {
361
438
  * counted were the ones that vanished from the ledger, in the one shape
362
439
  * ("no record") that the docstring reserves for a broken emitter.
363
440
  *
364
- * @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]
365
442
  * Omitted on a complete run. When present it LABELS the counters as partial:
366
443
  * every line after the abort was never attempted, so the classes no longer
367
- * partition `total`. On the three PRE-LOOP values (#1151) nothing was
444
+ * partition `total`. On the PRE-LOOP values (#1151, #1503) nothing was
368
445
  * attempted at all — `total` is a measured 0 and the label is what separates
369
446
  * "never started" from "ran over an empty source".
370
447
  * @returns {Promise<void>}
@@ -422,7 +499,7 @@ async function finishRun(aborted) {
422
499
  // ── Main ──────────────────────────────────────────────────────────────────────
423
500
 
424
501
  async function main() {
425
- // 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
426
503
  // `finishRun` every other exit uses. They are the runs that never reached
427
504
  // their first entry — a bad vault-dir, a wrong vault, a missing source — and
428
505
  // until now they were the only outcomes that left NO record at all, which is
@@ -430,36 +507,107 @@ async function main() {
430
507
  // are all `0`, and `aborted` is what makes that zero readable as "never
431
508
  // started" rather than "ran over an empty source".
432
509
  if (!existsSync(resolve(vaultDir))) {
433
- process.stderr.write(`vault-mirror: vault-dir not found: ${vaultDir}\n`);
510
+ process.stderr.write(`vault-mirror: vault-dir not found: ${pathForLog(vaultDir)}\n`);
434
511
  await finishRun('missing-vault-dir');
435
512
  process.exit(2);
436
513
  }
437
514
 
438
- if (process.env.VAULT_MIRROR_SKIP_CANONICAL_CHECK !== '1') {
439
- const res = spawnSync('git', ['-C', resolve(vaultDir), 'remote', 'get-url', 'origin'], {
440
- encoding: 'utf8',
441
- });
442
- const canonicalSuffixes = resolveCanonicalSuffixes({
443
- ownerConfig: loadOwnerConfig().config,
444
- env: process.env,
445
- });
446
- const ok = res.status === 0 && canonicalSuffixes.some((s) => _normalizeRemote(res.stdout).endsWith(s));
447
- if (!ok) {
448
- const got = res.status === 0 ? res.stdout.trim() : 'no git origin';
449
- process.stderr.write(
450
- `vault-mirror: refusing to mirror — "${vaultDir}" is not the canonical Meta-Vault (expected git origin ending in one of: ${canonicalSuffixes.join(', ')}; got ${got})\n`,
451
- );
452
- await finishRun('vault-not-canonical');
453
- process.exit(2);
454
- }
515
+ // The check itself — origin probe, suffix match, the SKIP_CANONICAL_CHECK
516
+ // escape hatch — is the shared `checkCanonicalVault` (#1450), the same one the
517
+ // board writer applies, so the two vault writers cannot drift apart again.
518
+ const canonical = checkCanonicalVault({
519
+ vaultDir: resolve(vaultDir),
520
+ ownerConfig: loadOwnerConfig().config,
521
+ env: process.env,
522
+ });
523
+ if (!canonical.ok) {
524
+ process.stderr.write(
525
+ `vault-mirror: refusing to mirror — vault is not the canonical Meta-Vault (expected git origin ending in one of: ${canonical.expected.join(', ')}; got ${describeOriginForLog(canonical.got) || 'no git origin'})\n`,
526
+ );
527
+ await finishRun('vault-not-canonical');
528
+ process.exit(2);
455
529
  }
456
530
 
457
531
  if (!existsSync(resolve(source))) {
458
- process.stderr.write(`vault-mirror: source file not found: ${source}\n`);
532
+ process.stderr.write(`vault-mirror: source file not found: ${pathForLog(source)}\n`);
459
533
  await finishRun('missing-source');
460
534
  process.exit(2);
461
535
  }
462
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
+
463
611
  const rl = createInterface({
464
612
  input: createReadStream(resolve(source), 'utf8'),
465
613
  crlfDelay: Infinity,
@@ -485,10 +633,14 @@ async function main() {
485
633
  kind,
486
634
  force,
487
635
  vaultName,
488
- // #1389: the repo whose `.vault.yaml` declares the namespace slug. This CLI
489
- // has no repo-root flag, so it is the invocation cwd — bound ONCE here and
490
- // shared by every processor call and the auto-commit below.
491
- 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(),
492
644
  qualityMinNarrativeChars,
493
645
  qualityMinConfidence,
494
646
  };
@@ -524,7 +676,7 @@ async function main() {
524
676
  } catch (err) {
525
677
  // Validation errors (missing required fields) → per-entry skip, not a global failure
526
678
  if (err.message.startsWith('vault-mirror:')) {
527
- process.stderr.write(`${err.message}\n`);
679
+ process.stderr.write(`${redactHomeDir(err.message)}\n`);
528
680
  const entryId = entry?.id ?? entry?.session_id ?? null;
529
681
  process.stdout.write(
530
682
  JSON.stringify({ action: 'skipped-invalid', path: null, kind, id: entryId }) + '\n',
@@ -537,7 +689,7 @@ async function main() {
537
689
  line: entryLineNum,
538
690
  recordId: entryId,
539
691
  skipClass: 'validation',
540
- reason: err.message,
692
+ reason: redactHomeDir(err.message),
541
693
  dryRun,
542
694
  });
543
695
  return;
@@ -556,7 +708,7 @@ async function main() {
556
708
  (typeof err.code === 'string' && err.code.length > 0) || Boolean(err.syscall);
557
709
  if (!isSystemError) {
558
710
  process.stderr.write(
559
- `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`,
560
712
  );
561
713
  const entryId = entry?.id ?? entry?.session_id ?? null;
562
714
  process.stdout.write(
@@ -576,13 +728,13 @@ async function main() {
576
728
  line: entryLineNum,
577
729
  recordId: entryId,
578
730
  skipClass: 'mapper-crash',
579
- reason: err.message,
731
+ reason: redactHomeDir(err.message),
580
732
  dryRun,
581
733
  });
582
734
  return;
583
735
  }
584
736
  // Unexpected filesystem errors → fatal
585
- 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`);
586
738
  await finishRun('filesystem-error');
587
739
  process.exit(2);
588
740
  }
@@ -608,7 +760,7 @@ async function main() {
608
760
  try {
609
761
  entry = JSON.parse(trimmed);
610
762
  } catch (err) {
611
- 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`);
612
764
  // Close the run out BEFORE exiting: `process.exit` runs no `finally`, so
613
765
  // without this the abort is the one outcome that leaves no run record —
614
766
  // exactly the shape reserved for a broken emitter.
@@ -618,6 +770,8 @@ async function main() {
618
770
 
619
771
  if (kind === 'session') {
620
772
  sessionEntries.push(entry);
773
+ const recordKey = sessionSourceRecord(entry);
774
+ if (recordKey !== null) ctx.sourceRecordKeys.add(recordKey);
621
775
  sessionLineNums.push(lineNum);
622
776
  continue;
623
777
  }
@@ -654,18 +808,27 @@ async function main() {
654
808
  if (isIdentifiable(entry) && !survivors.has(entry)) {
655
809
  // A losing duplicate: an earlier line whose `session_id` a LATER line
656
810
  // in this same batch supersedes or overwrites (crash-recovery
657
- // re-append, #1068 stub/supersede pair). No dispatch, no stdout line,
658
- // no tally for it — the winning occurrence (dispatched below, at its
659
- // own position) already produces the ONE note this physical session
660
- // gets. BV-004 ceiling: `runState.total` still counts this raw line,
661
- // so `created+updated+skipped+failed` no longer partitions `total`
662
- // for a `--kind session` run that collapsed at least one duplicate —
663
- // no test pins that invariant for session kind (only for `learning`,
664
- // where duplicates are not collapsed), and a partially-written vault
665
- // from a batch that could not be fully deduped is the worse failure
666
- // mode. Revisit with a dedicated telemetry action if an operator ever
667
- // needs to name WHICH lines were collapsed, not just how many notes
668
- // were written.
811
+ // re-append, #1068 stub/supersede pair). Never dispatched — the winning
812
+ // occurrence (dispatched below, at its own position) produces the ONE
813
+ // note this physical session gets. It is still ACCOUNTED for (#1291):
814
+ // `skipped-duplicate-session` goes through the same per-entry path as
815
+ // every other skip (stdout line + per-entry ledger record, no `path`
816
+ // because no target file exists for it) and into the tally, so
817
+ // `created+updated+skipped+failed === total` partitions a `--kind
818
+ // session` run that collapsed duplicates, and the ledger names WHICH
819
+ // lines were collapsed, not just how many notes were written.
820
+ tally(
821
+ await emitAction({
822
+ action: 'skipped-duplicate-session',
823
+ path: null,
824
+ kind,
825
+ id: entry.session_id,
826
+ vaultDir: ctx.vaultDir,
827
+ meta: { line: sessionLineNums[i] },
828
+ line: sessionLineNums[i],
829
+ dryRun,
830
+ }),
831
+ );
669
832
  continue;
670
833
  }
671
834
  await dispatchEntry(entry, sessionLineNums[i]);
@@ -706,7 +869,7 @@ async function main() {
706
869
  }
707
870
 
708
871
  main().catch(async (err) => {
709
- process.stderr.write(`vault-mirror: unexpected error: ${err.message}\n`);
872
+ process.stderr.write(`vault-mirror: unexpected error: ${redactHomeDir(err.message)}\n`);
710
873
  // Same reason as the two in-loop aborts: an unexpected throw is a run that
711
874
  // ENDED, and the ledger has to say so. `finishRun` never throws, so this
712
875
  // cannot turn a diagnosable crash into a silent one.
@@ -37,6 +37,7 @@
37
37
  */
38
38
 
39
39
  import { parseArgs } from 'node:util';
40
+ import path from 'node:path';
40
41
  import { attributionForRecord, emitEvent } from './lib/events.mjs';
41
42
  import { MANIFEST_SESSION_KEYS } from './lib/session-identity/own-session.mjs';
42
43
  import { isMainModule } from './lib/is-main-module.mjs';
@@ -109,7 +110,7 @@ async function main(argv) {
109
110
  return;
110
111
  }
111
112
 
112
- const repoRoot = parsed.values['repo-root'] ?? process.cwd();
113
+ const repoRoot = path.resolve(parsed.values['repo-root'] ?? process.cwd());
113
114
  const binding = resolveBinding(repoRoot);
114
115
 
115
116
  if (Object.keys(binding).length === 0) {
@@ -133,14 +133,14 @@ Learnings live exclusively in `.orchestrator/metrics/learnings.jsonl`. The pre-`
133
133
 
134
134
  The learning lifecycle states are:
135
135
 
136
- - **Created**: `confidence: 0.5`, `expires_at`: current date + `learning-expiry-days` (default: 30)
137
- - **Confirmed** (same type+subject seen again): `confidence += 0.15` (cap 1.0), `expires_at` reset
136
+ - **Created**: `confidence: 0.5`, `expires_at` = `created_at` + a per-TYPE TTL — `deriveExpiresAt(created_at, type)` with `LEARNING_TTL_DAYS[type] ?? LEARNING_TTL_DAYS.default` (`scripts/lib/learnings/schema.mjs`; 30–90 days per type, 60 for an unknown type). A caller-supplied `expires_at` is kept. `learning-expiry-days` does NOT set it: `scripts/lib/config.mjs` still parses that key (default 30), but no script or hook reads the parsed value — it survives only in skill prose (the `/evolve` Review-mode *Extend* action).
137
+ - **Confirmed** (same type+subject seen again): `confidence += 0.15` (cap 1.0), `expires_at` reset to `deriveExpiresAt(now, type)`
138
138
  - **Contradicted** (evidence against): `confidence -= 0.2` — do NOT reset `expires_at` (let the learning decay naturally if contradicted)
139
139
  - **Decayed** (untouched this session): `confidence -= learning-decay-rate` (from Session Config, default `0.05`). Applied at session-end after touched-set update, before prune. Clamped to 0.0. Does NOT reset `expires_at`.
140
140
  - **Expired**: `expires_at < current date` — removed on next write
141
141
  - **Dead**: `confidence <= 0.0` — removed on next write
142
142
 
143
- **Expiration check semantics:** Compare `expires_at` by date portion only (ignore time-of-day) to avoid intra-day jitter. When writing `expires_at`, set it to `<current_date>T00:00:00Z + learning-expiry-days` (midnight UTC).
143
+ **Expiration check semantics:** The code compares full timestamps, not dates: `pruneLearnings()` (`scripts/lib/learnings/expiry-sweep.mjs`) archives when `expires_at < now`; the time-driven sweep archives only after a 14-day grace window past `expires_at`. When writing `expires_at`, derive it with `deriveExpiresAt()` (full ISO timestamp, no midnight rounding) — never from `learning-expiry-days`.
144
144
 
145
145
  **Confidence bounds enforcement:** After EVERY increment or decrement, clamp confidence to [0.0, 1.0]. A learning at 0.95 confirmed becomes 1.0 (not 1.10). A learning at 0.1 contradicted becomes 0.0 and is pruned.
146
146