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
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * render-learnings.mjs — Learning markdown generators for vault-mirror (Issue #283 split).
3
3
  *
4
- * Exports: detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2
4
+ * Exports: detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2,
5
+ * sourceRecordValue, learningSourceRecord, sourceRecordLine (#1503)
5
6
  */
6
7
 
7
8
  import { toDate, truncateAtWord, yamlQuoteIfNeeded, subjectToSlug, isValidSlug, buildTag, resolveSourceSessionLink } from './utils.mjs';
@@ -18,6 +19,63 @@ export function detectLearningSchema(entry) {
18
19
  return entry && typeof entry.text === 'string' ? 'v2' : 'v1';
19
20
  }
20
21
 
22
+ /**
23
+ * Characters a `source-record:` value may carry (#1503). The value is always
24
+ * written double-quoted, and BOTH readers of it — the vault-sync YAML loader and
25
+ * the line-based `parseFrontmatter` in utils.mjs, which only strips the
26
+ * surrounding quotes and never unescapes — must read back exactly the string
27
+ * that was rendered. Excluding `"`, `\`, whitespace and anything non-ASCII is
28
+ * what makes that round trip lossless without an escaping scheme. Record uuids,
29
+ * kebab ids, session uuids and ISO timestamps all fit.
30
+ */
31
+ const SOURCE_RECORD_RE = /^[A-Za-z0-9][A-Za-z0-9._:+@/-]{0,199}$/;
32
+
33
+ /**
34
+ * Normalise one candidate `source-record` value (#1503): the identity of the
35
+ * JSONL record a vault note was rendered from. Returns `null` for anything that
36
+ * cannot round-trip through the frontmatter (see {@link SOURCE_RECORD_RE}) — the
37
+ * line is then omitted and the record-level ownership check is skipped for that
38
+ * entry, which is the same "absent = allow" reading an old note gets.
39
+ *
40
+ * Shared by both renderers so the rendered value and the value process.mjs
41
+ * compares against come from ONE derivation.
42
+ *
43
+ * @param {unknown} value
44
+ * @returns {string|null}
45
+ */
46
+ export function sourceRecordValue(value) {
47
+ if (typeof value !== 'string') return null;
48
+ const trimmed = value.trim();
49
+ return SOURCE_RECORD_RE.test(trimmed) ? trimmed : null;
50
+ }
51
+
52
+ /**
53
+ * The `source-record` of a learning note: the record's own `id` (a uuid for v1
54
+ * producers, the kebab id for v2 and for alias producers whose id was derived
55
+ * from the subject by {@link normalizeLearningEntry}).
56
+ *
57
+ * Named ceiling: a derived id IS the subject slug, so two repos holding the same
58
+ * subject under derived ids render the same `source-record` and this field
59
+ * cannot tell them apart — `source-repo` and the CLI's repo-root resolution are
60
+ * what separate those.
61
+ *
62
+ * @param {object} entry - normalized learning entry.
63
+ * @returns {string|null}
64
+ */
65
+ export function learningSourceRecord(entry) {
66
+ return sourceRecordValue(entry?.id);
67
+ }
68
+
69
+ /**
70
+ * Render the `source-record:` frontmatter line, or `''` when the entry has no
71
+ * representable record key.
72
+ * @param {string|null} value - output of {@link sourceRecordValue}.
73
+ * @returns {string}
74
+ */
75
+ export function sourceRecordLine(value) {
76
+ return value === null ? '' : `source-record: "${value}"\n`;
77
+ }
78
+
21
79
  const firstString = (...vals) => vals.find((v) => typeof v === 'string' && v.length > 0);
22
80
 
23
81
  const joinList = (v) =>
@@ -112,6 +170,9 @@ export function generateLearningNote(entry, slug, opts = {}) {
112
170
  // vault-sync schema declares source-repo OPTIONAL).
113
171
  const sourceRepoLine =
114
172
  typeof opts.repoNs === 'string' && opts.repoNs.length > 0 ? `source-repo: ${opts.repoNs}\n` : '';
173
+ // #1503: the record this note was rendered from — process.mjs refuses to
174
+ // overwrite a note whose `source-record` names a different record.
175
+ const recordLine = sourceRecordLine(learningSourceRecord(entry));
115
176
 
116
177
  // source_session is emitted as an Obsidian wikilink ONLY when source_session
117
178
  // resolves to a real, mirror-able session id (semantic or UUID-v4). Anything
@@ -132,7 +193,7 @@ created: ${created}
132
193
  updated: ${updated}
133
194
  tags: ${tags}
134
195
  source_session: ${sourceSessionLink}
135
- ${expiresLine}${sourceRepoLine}_generator: ${GENERATOR_MARKER}
196
+ ${expiresLine}${sourceRepoLine}${recordLine}_generator: ${GENERATOR_MARKER}
136
197
  ---
137
198
 
138
199
  # ${titleRaw}
@@ -176,6 +237,8 @@ export function generateLearningNoteV2(entry, slug, opts = {}) {
176
237
  // opts.repoNs (see generateLearningNote for the full contract). Backward-compatible.
177
238
  const sourceRepoLine =
178
239
  typeof opts.repoNs === 'string' && opts.repoNs.length > 0 ? `source-repo: ${opts.repoNs}\n` : '';
240
+ // #1503: see generateLearningNote.
241
+ const recordLine = sourceRecordLine(learningSourceRecord(entry));
179
242
 
180
243
  return `---
181
244
  id: ${slug}
@@ -185,7 +248,7 @@ status: ${status}
185
248
  created: ${created}
186
249
  updated: ${updated}
187
250
  tags: ${tags}
188
- ${sourceRepoLine}_generator: ${GENERATOR_MARKER}
251
+ ${sourceRepoLine}${recordLine}_generator: ${GENERATOR_MARKER}
189
252
  ---
190
253
 
191
254
  # ${titleRaw}
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * render-sessions.mjs — Session markdown generators for vault-mirror (Issue #283 split).
3
3
  *
4
- * Exports: detectSessionSchema, normalizeSessionEntry, generateSessionNote, generateSessionNoteV2, generateSessionNoteV3
4
+ * Exports: detectSessionSchema, normalizeSessionEntry, generateSessionNote, generateSessionNoteV2, generateSessionNoteV3,
5
+ * sessionSourceRecord (#1503)
5
6
  *
6
7
  * #732: all three generators accept `options.repoNs` (the leak-guarded /
7
8
  * pseudonym-mapped namespace segment from resolveRepoNamespace(), threaded
@@ -15,6 +16,7 @@
15
16
  */
16
17
 
17
18
  import { toDate, buildTag, slugifyIdSafe } from './utils.mjs';
19
+ import { sourceRecordValue, sourceRecordLine } from './render-learnings.mjs';
18
20
 
19
21
  const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
20
22
 
@@ -282,6 +284,28 @@ function fmLine(key, value) {
282
284
  return `${key}: ${value}\n`;
283
285
  }
284
286
 
287
+ /**
288
+ * The `source-record` of a session note (#1503): the physical session the note
289
+ * was rendered from — `raw_session_id` (the harness's session uuid) when the
290
+ * record carries one, else `started_at`. `session_id` itself cannot serve: it is
291
+ * the FILENAME, and semantic ids such as `main-2026-10-02-session-1` recur across
292
+ * repos by construction, which is exactly the collision this field exists to
293
+ * detect. All three generators require `started_at`, so every renderable record
294
+ * has a key.
295
+ *
296
+ * Named ceiling: a record whose key changes between runs — a later backfill
297
+ * adding `raw_session_id` to a record first mirrored under its `started_at` —
298
+ * reads as a different record, and process.mjs then refuses the rewrite
299
+ * (`skipped-foreign-owner`, visible on stdout). Revisit if that is ever observed
300
+ * on a same-repo record.
301
+ *
302
+ * @param {object} entry - normalized session entry.
303
+ * @returns {string|null}
304
+ */
305
+ export function sessionSourceRecord(entry) {
306
+ return sourceRecordValue(entry?.raw_session_id) ?? sourceRecordValue(entry?.started_at);
307
+ }
308
+
285
309
  /**
286
310
  * Session JSONL has three producer schemas in production:
287
311
  * v1 (legacy): total_waves, total_agents, total_files_changed, agent_summary, waves[{agent_count, files_changed, quality}]
@@ -521,7 +545,7 @@ status: ${vaultStatus}
521
545
  created: ${created}
522
546
  updated: ${updated}
523
547
  tags: ${tags}
524
- ${fmLine('source-repo', repoNs)}_generator: ${GENERATOR_MARKER}
548
+ ${fmLine('source-repo', repoNs)}${sourceRecordLine(sessionSourceRecord(entry))}_generator: ${GENERATOR_MARKER}
525
549
  ---
526
550
 
527
551
  # Session ${session_id}
@@ -606,7 +630,7 @@ status: ${vaultStatus}
606
630
  created: ${created}
607
631
  updated: ${updated}
608
632
  tags: ${tags}
609
- ${fmLine('source-repo', repoNs)}_generator: ${GENERATOR_MARKER}
633
+ ${fmLine('source-repo', repoNs)}${sourceRecordLine(sessionSourceRecord(entry))}_generator: ${GENERATOR_MARKER}
610
634
  ---
611
635
 
612
636
  # Session ${session_id}
@@ -731,7 +755,7 @@ status: ${vaultStatus}
731
755
  created: ${created}
732
756
  updated: ${updated}
733
757
  tags: ${tags}
734
- ${fmLine('source-repo', repoNs)}_generator: ${GENERATOR_MARKER}
758
+ ${fmLine('source-repo', repoNs)}${sourceRecordLine(sessionSourceRecord(entry))}_generator: ${GENERATOR_MARKER}
735
759
  ---
736
760
 
737
761
  # Session ${session_id}
@@ -11,6 +11,16 @@
11
11
  * records that between them said nothing happened (#1151). Its count is
12
12
  * carried by the run event's `skipped` total and `action_breakdown`, so the
13
13
  * class stays measured — only its per-line locators are gone.
14
+ * `skipped-duplicate-session` (#1291) is one of these per-entry records:
15
+ * a `--kind session` line whose `session_id` a later line in the same
16
+ * source superseded, so it was never dispatched. It carries `record_id`
17
+ * and `line` and never `path` — no target file exists for it.
18
+ * `skipped-foreign-owner` (#1503) is another: the generator note at the
19
+ * target path names a different owner (a `source-record` the writer's
20
+ * ledger does not carry, or a different session id), so it was left
21
+ * untouched — unless it still leaks a secret, which is healed over the
22
+ * guard. It carries the refused note's `path` and a constant `reason`
23
+ * (`source-record mismatch` / `id mismatch`).
14
24
  * - `orchestrator.vault.mirror_run_completed` — ONE record per CLI run,
15
25
  * carrying the DENOMINATOR (`total` plus the per-class counts).
16
26
  *
@@ -21,8 +31,8 @@
21
31
  * "a rule you cannot falsify is not a rule"). The run event is emitted
22
32
  * unconditionally — including on every abort path, where it carries an
23
33
  * `aborted` discriminator (`missing-vault-dir` | `vault-not-canonical` |
24
- * `missing-source` | `malformed-json` | `filesystem-error` |
25
- * `unexpected-error`). So `total: 0` is a MEASURED zero, a partial count is a
34
+ * `missing-source` | `source-repo-mismatch` | `malformed-json` |
35
+ * `filesystem-error` | `unexpected-error`). So `total: 0` is a MEASURED zero, a partial count is a
26
36
  * LABELLED partial rather than a silent one, and the record's absence — and
27
37
  * nothing else — is the broken-emitter signal.
28
38
  *
@@ -32,9 +42,10 @@
32
42
  *
33
43
  * Ledger destination: `emitEvent` is called 2-arg, so every event from one run
34
44
  * resolves the SAME destination (`SO_PROJECT_DIR`, i.e. `CLAUDE_PROJECT_DIR` or
35
- * the CWD walk-up) as the masker emit. This CLI has no repo-root flag and
36
- * deriving one from `--source` would split a single run's telemetry across two
37
- * ledgers.
45
+ * the CWD walk-up) as the masker emit. The repo root that decides the vault
46
+ * NAMESPACE (#1503: `--repo-root`, else the git repo containing `--source`) is
47
+ * deliberately NOT used for the ledger — routing telemetry by it would split a
48
+ * single run's records across two ledgers.
38
49
  */
39
50
 
40
51
  import { emitEvent, sessionAttribution } from '../events.mjs';
@@ -162,20 +173,23 @@ export async function emitMirrorEvent({
162
173
  * @param {number} opts.updated — entries whose action was `updated`.
163
174
  * @param {number} opts.skipped — entries skipped for a NON-failure reason
164
175
  * (`skipped-noop`, `skipped-handwritten`, `skipped-quality-low`,
165
- * `skipped-collision-resolved`, `skipped-abandoned`).
176
+ * `skipped-collision-resolved`, `skipped-foreign-owner`, `skipped-abandoned`,
177
+ * `skipped-duplicate-session`). The last one (#1291) is what keeps
178
+ * `created + updated + skipped + failed === total` true for a `--kind
179
+ * session` run whose dedup pass collapsed a duplicate `session_id` line.
166
180
  * @param {number} opts.failed — entries that produced `skipped-invalid`
167
181
  * (validation error or mapper crash). Split out from `skipped` because these
168
182
  * are the runs where a session silently ends up WITHOUT its vault note.
169
183
  * @param {Record<string, number>} [opts.actionBreakdown] — per-action counts;
170
184
  * only actions observed at least once appear.
171
185
  * @param {boolean} opts.dryRun — whether this run wrote anything at all.
172
- * @param {'missing-vault-dir'|'vault-not-canonical'|'missing-source'|'malformed-json'|'filesystem-error'|'unexpected-error'} [opts.aborted]
186
+ * @param {'missing-vault-dir'|'vault-not-canonical'|'missing-source'|'source-repo-mismatch'|'malformed-json'|'filesystem-error'|'unexpected-error'} [opts.aborted]
173
187
  * Present ONLY when the run exited before its normal tail. OMITTED on a
174
188
  * complete run — absent means "ran to the end", never "unknown". The first
175
- * three values are PRE-LOOP aborts (#1151): the run never reached its first
176
- * entry, so all five counters are `0` and that zero is the point — without a
177
- * record, a mirror that never started is indistinguishable from one that was
178
- * never invoked.
189
+ * four values are PRE-LOOP aborts (#1151, #1503): the run never reached its
190
+ * first entry, so all five counters are `0` and that zero is the point —
191
+ * without a record, a mirror that never started is indistinguishable from one
192
+ * that was never invoked.
179
193
  * @returns {Promise<void>}
180
194
  */
181
195
  export async function emitMirrorRunEvent({
@@ -12,8 +12,9 @@
12
12
  *
13
13
  * Telemetry: every {@link mirrorBoard} call — and therefore every
14
14
  * {@link sweepBoard} call — emits exactly ONE {@link BOARD_EVENT} record,
15
- * including the no-op paths (`skipped-vault-disabled`, `skipped-handwritten`,
16
- * `skipped-noop`, `skipped-write-failed`). Those are the states that previously
15
+ * including the no-op paths (`skipped-vault-disabled`,
16
+ * `skipped-vault-not-canonical`, `skipped-handwritten`, `skipped-noop`,
17
+ * `skipped-write-failed`). Those are the states that previously
17
18
  * looked identical to a healthy write from outside the process. Emission is
18
19
  * best-effort and can never fail a board write.
19
20
  *
@@ -43,7 +44,8 @@
43
44
  * 2. Defense-in-depth: writeBoard hard-refuses to touch `_overview.md`
44
45
  * (sven-owned, must NEVER be written by this generator).
45
46
  *
46
- * No console noise — library code. Plain Node ESM. No external deps.
47
+ * No console noise — library code — except the one stderr line on a
48
+ * canonical-vault refusal (#1450). Plain Node ESM. No external deps.
47
49
  */
48
50
 
49
51
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
@@ -60,6 +62,8 @@ import { enumerateCandidates } from '../dispatcher/enumerate.mjs';
60
62
  import { atomicWriteWithBackup } from '../io.mjs';
61
63
  import { withBoardLock } from './board-lock.mjs';
62
64
  import { expandTilde } from '../common.mjs';
65
+ import { checkCanonicalVault, describeOriginForLog } from '../named-vault-resolver.mjs';
66
+ import { loadHostPaths } from '../config/host-paths.mjs';
63
67
 
64
68
  /** Frontmatter sentinel that identifies generator-owned board files. */
65
69
  export const GENERATOR_MARKER = 'session-orchestrator-active-sessions@1';
@@ -356,8 +360,12 @@ export async function collectRows({ repos, now = new Date(), registry, priorStat
356
360
  ? lock.mode
357
361
  : (registryEntry && registryEntry.mode ? registryEntry.mode : null);
358
362
 
363
+ // The "Last heartbeat" column shows a heartbeat or nothing: a lock without
364
+ // `last_heartbeat` renders '—', never its `started_at` under that label
365
+ // (#595 sunset, 2026-10-02 — `isLockLive()` already calls it not live, so
366
+ // the status derivation below treats it as a dead lease).
359
367
  const heartbeat = lock
360
- ? (lock.last_heartbeat ?? lock.started_at ?? null)
368
+ ? (lock.last_heartbeat ?? null)
361
369
  : (registryEntry ? (registryEntry.last_heartbeat ?? null) : null);
362
370
 
363
371
  // ── Status derivation ──
@@ -825,6 +833,8 @@ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows
825
833
  * Vault path assertion (Epic #673 safety): the resolved vault dir MUST live
826
834
  * under $HOME — a vault outside the home tree is refused as `skipped-vault-disabled`
827
835
  * so a misconfigured path can never drive a write into an arbitrary location.
836
+ * It must also be a canonical Meta-Vault ({@link checkCanonicalVault}, #1450):
837
+ * otherwise `skipped-vault-not-canonical`, nothing written, no lock, dry-run included.
828
838
  *
829
839
  * @param {object} opts
830
840
  * @param {string} opts.repoRoot — the repo whose row is being updated.
@@ -840,11 +850,14 @@ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows
840
850
  * @param {Date} [opts.now]
841
851
  * @param {boolean} [opts.dryRun]
842
852
  * @param {object} [opts.fs] — injectable fs for tests.
843
- * @param {{ env?: Record<string, string|undefined>, ownerConfig?: object }} [opts.hostPaths]
853
+ * @param {{ env?: Record<string, string|undefined>, ownerConfig?: object,
854
+ * readOriginUrl?: (vaultDir: string) => string }} [opts.hostPaths]
844
855
  * — forwarded verbatim to {@link parseSessionConfig}'s `hostPaths` DI seam (issue #653).
856
+ * The same ctx feeds {@link checkCanonicalVault}; `readOriginUrl` replaces its
857
+ * `git remote get-url origin` read (tests only — tmp vaults have no origin).
845
858
  * Tests MUST pass a hermetic ctx (e.g. `{ env: {}, ownerConfig: undefined }`) when
846
859
  * asserting a fixture's committed `vault-dir` — omitting it reads the REAL host
847
- * `owner.yaml`, whose `paths.vault-dir` override (if set) wins over the fixture value
860
+ * `owner.yaml`, whose `vault-dirs:` cwd match or `paths.vault-dir` override (if set) wins over the fixture value
848
861
  * and bleeds into the assertion (issue #783). Production callers omit this — the
849
862
  * default (real owner.yaml resolution) is the correct host-local behavior there.
850
863
  * @returns {Promise<{ result: { action: string, path?: string }, rows?: number,
@@ -861,10 +874,19 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
861
874
  }
862
875
 
863
876
  // Read + parse Session Config. Any failure → silent no-op.
877
+ // The host context is resolved ONCE here and reused by the canonical-vault
878
+ // guard below: loadOwnerConfig() has no cache and its section-drop WARNs are
879
+ // not rate-limited, so a second load would print every WARN of a broken
880
+ // owner.yaml twice per board write (#1450 follow-up). The config file is read
881
+ // BEFORE owner.yaml — the order narrative-mirror.mjs shares (#1479); vault-dir
882
+ // precedence is applied in parseSessionConfig, so the order changes only
883
+ // whether a repo without a config file reads owner.yaml at all.
864
884
  let config;
885
+ let hostCtx;
865
886
  try {
866
887
  const text = await readConfigFile(repoRoot);
867
- config = parseSessionConfig(text, { hostPaths });
888
+ hostCtx = hostPaths ?? loadHostPaths();
889
+ config = parseSessionConfig(text, { hostPaths: hostCtx });
868
890
  } catch {
869
891
  return { result: { action: 'skipped-vault-disabled' } };
870
892
  }
@@ -886,6 +908,24 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
886
908
  return { result: { action: 'skipped-vault-disabled' } };
887
909
  }
888
910
 
911
+ // Safety: the vault must be a canonical Meta-Vault (#1450) — the same guard
912
+ // vault-mirror applies, so a vault-dir that resolved to a FOREIGN vault can
913
+ // never receive the board. Reuses the `hostCtx` the config parse used, so a
914
+ // hermetic ctx never reads the real owner.yaml and owner.yaml is loaded at
915
+ // most once. Runs before the dry-run branch and the lock: fail-closed.
916
+ const canonical = checkCanonicalVault({
917
+ vaultDir: expandedVault,
918
+ ownerConfig: hostCtx.ownerConfig,
919
+ env: hostCtx.env ?? process.env,
920
+ readOriginUrl: hostCtx.readOriginUrl,
921
+ });
922
+ if (!canonical.ok) {
923
+ process.stderr.write(
924
+ `vault-status board: refusing to write — 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`,
925
+ );
926
+ return { result: { action: 'skipped-vault-not-canonical' } };
927
+ }
928
+
889
929
  // `vault-name` (#660) overrides the git-derived repo slug for per-project
890
930
  // vault namespacing. Without it `collectRows` falls back to
891
931
  // `path.basename(repoRoot)`, so a repo whose directory name differs from its
@@ -1112,10 +1152,10 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
1112
1152
  * Public {@link mirrorBoardInner} wrapper that emits exactly ONE
1113
1153
  * {@link BOARD_EVENT} per call — on EVERY path, no-ops included.
1114
1154
  *
1115
- * The wrapper exists so the emit cannot be forgotten: the six return points
1116
- * inside {@link mirrorBoardInner} (five `skipped-vault-disabled` guards plus
1117
- * whatever {@link writeBoard} decides) all funnel through here, and so does the
1118
- * seventh someone adds next. Emitting per-return instead would leave each new
1155
+ * The wrapper exists so the emit cannot be forgotten: the seven return points
1156
+ * inside {@link mirrorBoardInner} (five `skipped-vault-disabled` guards, the
1157
+ * `skipped-vault-not-canonical` guard, plus whatever {@link writeBoard}
1158
+ * decides) all funnel through here, and so does the eighth someone adds next. Emitting per-return instead would leave each new
1119
1159
  * early return silent by default — which is the exact defect being fixed.
1120
1160
  *
1121
1161
  * A THROW from the inner function is deliberately NOT converted into an event:
@@ -1276,7 +1316,8 @@ export function buildSweepRepos(candidates, { thisRepoRoot } = {}) {
1276
1316
  * @param {object} [opts.deps] — injectable deps for {@link enumerateCandidates}
1277
1317
  * (test seam: `readdirSync`, `existsSync`, `readLock`, `isLockLive`,
1278
1318
  * `getCrossRepoProjects`, `validatePathInsideProject`, `now`).
1279
- * @param {{ env?: Record<string, string|undefined>, ownerConfig?: object }} [opts.hostPaths]
1319
+ * @param {{ env?: Record<string, string|undefined>, ownerConfig?: object,
1320
+ * readOriginUrl?: (vaultDir: string) => string }} [opts.hostPaths]
1280
1321
  * — forwarded verbatim to every {@link mirrorBoard} call below (both the happy-path
1281
1322
  * and the enumeration-failure-fallback path), which in turn forwards it to
1282
1323
  * {@link parseSessionConfig}'s `hostPaths` DI seam (issue #653/#783). See
@@ -44,6 +44,8 @@ import { validatePathInsideProject } from '../path-utils.mjs';
44
44
  import { createSecretValueMasker } from '../secret-masker.mjs';
45
45
  import { expandTilde } from '../common.mjs';
46
46
  import { readVaultSlug } from '../vault-yaml.mjs';
47
+ import { checkCanonicalVault, describeOriginForLog } from '../named-vault-resolver.mjs';
48
+ import { loadHostPaths } from '../config/host-paths.mjs';
47
49
 
48
50
  /** Frontmatter sentinel that identifies generator-owned narrative files. */
49
51
  export const GENERATOR_MARKER = 'session-orchestrator-vault-status-narrative@1';
@@ -763,9 +765,10 @@ async function emitMaskerEvent({ repoRoot, needleCount, hits, dryRun }) {
763
765
  * before any write.
764
766
  *
765
767
  * THIS FUNCTION WRITES TO THE OPERATOR'S REAL VAULT UNLESS YOU STOP IT. `vault-dir`
766
- * resolves HOST-LOCALLY — `SO_VAULT_DIR` > `owner.yaml` `paths.vault-dir` >
767
- * committed Session Config (`scripts/lib/config/host-paths.mjs`) — so the literal
768
- * value in a CLAUDE.md you just wrote into a tmp dir LOSES to both overrides. A
768
+ * resolves HOST-LOCALLY — `SO_VAULT_DIR` > `owner.yaml` `vault-dirs:` cwd match >
769
+ * `owner.yaml` `paths.vault-dir` > committed Session Config (`resolveVaultDir` in
770
+ * `scripts/lib/config.mjs`) — so the literal value in a CLAUDE.md you just wrote
771
+ * into a tmp dir LOSES to every override. A
769
772
  * probe that passes a synthetic `repoRoot` and nothing else has already written
770
773
  * into the live vault once (#1025 review). Any caller that must not touch the real
771
774
  * vault passes `hostPaths: { env: {}, ownerConfig: undefined }` (see below),
@@ -777,13 +780,16 @@ async function emitMaskerEvent({ repoRoot, needleCount, hits, dryRun }) {
777
780
  * now?: Date,
778
781
  * dryRun?: boolean,
779
782
  * fs?: object,
780
- * hostPaths?: { env?: Record<string, string|undefined>, ownerConfig?: object },
783
+ * hostPaths?: { env?: Record<string, string|undefined>, ownerConfig?: object,
784
+ * readOriginUrl?: (vaultDir: string) => string },
781
785
  * }} opts
782
786
  * `hostPaths` is forwarded verbatim to {@link parseSessionConfig}'s `hostPaths` DI
783
- * seam (issue #653). Tests MUST pass a hermetic ctx (e.g. `{ env: {}, ownerConfig:
787
+ * seam (issue #653). The same ctx feeds {@link checkCanonicalVault} (#1450);
788
+ * `readOriginUrl` replaces its `git remote get-url origin` read (tests only —
789
+ * tmp vaults have no origin). Tests MUST pass a hermetic ctx (e.g. `{ env: {}, ownerConfig:
784
790
  * undefined }`) when asserting a fixture's committed `vault-dir` — omitting it reads
785
- * the REAL host `owner.yaml`, whose `paths.vault-dir` override (if set) wins over the
786
- * fixture value and bleeds into the assertion (issue #783). Production callers omit
791
+ * the REAL host `owner.yaml`, whose `vault-dirs:` cwd match or `paths.vault-dir`
792
+ * override (if set) wins over the fixture value and bleeds into the assertion (issue #783). Production callers omit
787
793
  * this — the default (real owner.yaml resolution) is the correct host-local behavior.
788
794
  * @returns {Promise<{ action: string, path?: string }>}
789
795
  */
@@ -829,7 +835,7 @@ export async function mirrorNarrative(opts) {
829
835
  * Split out so that the two emit sites in `mirrorNarrative` (the narrative
830
836
  * event AND, since #1028, the masker event) each cover EVERY outcome from
831
837
  * ONE call: a future early return added inside this function is telemetered
832
- * by construction, whereas hand-placing an emit beside each of the seven
838
+ * by construction, whereas hand-placing an emit beside each of the eight
833
839
  * `return`s makes "forgot the new one" the default failure. The `chars` companion travels beside the result
834
840
  * rather than inside it because the returned object is a PUBLIC shape that
835
841
  * callers (and tests) compare with `toEqual` — adding a key there would be an
@@ -855,10 +861,19 @@ async function runNarrativeMirror(opts) {
855
861
  const masker = createSecretValueMasker(process.env);
856
862
 
857
863
  // Read Session Config (CLAUDE.md / AGENTS.md) and resolve vault settings.
864
+ // The host context is loaded ONCE and shared by the Session Config parse and
865
+ // the canonical-vault guard below, so owner.yaml is read a single time per run.
866
+ // Order (#1479, same as board-writer.mjs): config file first, owner.yaml
867
+ // second. The `vault-dir` precedence (SO_VAULT_DIR > owner.yaml > committed)
868
+ // is applied inside parseSessionConfig, after both are in hand, so the order
869
+ // cannot change the resolved dir; it only spares a repo without a config file
870
+ // the owner.yaml read and its section-drop WARNs on a run that skips anyway.
858
871
  let config;
872
+ let hostCtx;
859
873
  try {
860
874
  const configText = await readConfigFile(repoRoot);
861
- config = parseSessionConfig(configText, { hostPaths });
875
+ hostCtx = hostPaths ?? loadHostPaths();
876
+ config = parseSessionConfig(configText, { hostPaths: hostCtx });
862
877
  } catch {
863
878
  return { result: { action: 'skipped-vault-disabled' }, needleCount: masker.needleCount, hits: 0, dryRun };
864
879
  }
@@ -900,6 +915,22 @@ async function runNarrativeMirror(opts) {
900
915
  }
901
916
 
902
917
  const vaultDir = path.resolve(expandTilde(rawVaultDir));
918
+
919
+ // #1450: same shared guard as board-writer.mjs and scripts/vault-mirror.mjs —
920
+ // refuse a vault-dir whose git origin is not a canonical Meta-Vault. The
921
+ // stderr line names the expected suffixes and the origin, never the vault
922
+ // path itself.
923
+ const canonical = checkCanonicalVault({
924
+ vaultDir,
925
+ ownerConfig: hostCtx.ownerConfig,
926
+ env: hostCtx.env ?? process.env,
927
+ readOriginUrl: hostCtx.readOriginUrl,
928
+ });
929
+ if (!canonical.ok) {
930
+ process.stderr.write(`narrative-mirror: refusing to write — 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`);
931
+ return { result: { action: 'skipped-vault-not-canonical' }, needleCount: masker.needleCount, hits: 0, dryRun };
932
+ }
933
+
903
934
  const candidateSlug = subjectToSlug(repoName) || 'unknown';
904
935
  // A declared `.vault.yaml` slug needs no healing — it IS the canonical folder
905
936
  // name, so resolveLooseSlug is SKIPPED for it. Running the healer over it
@@ -160,6 +160,14 @@ export function stripUrlCredentials(url) {
160
160
  * `--repo <spec>` override path, which bypasses the source-level strip in
161
161
  * {@link resolveRawRemoteUrl}. Never throws; a non-string returns unchanged.
162
162
  *
163
+ * Scope is CREDENTIALS only, as {@link userinfoIsCredential} classifies them.
164
+ * A bare login on a non-HTTP scheme (`ssh://alice@host/…`) is left verbatim,
165
+ * the URL path is never touched (`ssh://***@host/home/alice/vault` keeps the
166
+ * remote home dir), and the scp-like `user@host:path` form is not matched at
167
+ * all. So this is NOT the scp-consistent origin renderer: `describeOriginForLog`
168
+ * (`named-vault-resolver.mjs`) redacts every non-`git` login in both forms and
169
+ * hides a home-dir path (#1490) — use that for a git origin in a log line.
170
+ *
163
171
  * @param {string} text
164
172
  * @returns {string}
165
173
  */
@@ -21,6 +21,14 @@
21
21
  * exists to be.
22
22
  */
23
23
 
24
+ /**
25
+ * Re-exported, not re-declared: the value lives in `process-group.mjs` since
26
+ * 773a994b, but this module exported it in 5.3.x and the package ships
27
+ * `scripts/` with no `exports` map — so a deep importer still links against
28
+ * this name. One definition, two import paths.
29
+ */
30
+ export { DEFAULT_KILL_GRACE_MS } from '../process-group.mjs';
31
+
24
32
  /**
25
33
  * Roles that may NEVER run on a foreign model. Copied verbatim from
26
34
  * account-switch `tools/routing/routing.yaml` § never_foreign (read 2026-08-25).
@@ -44,22 +52,6 @@ export const NEVER_FOREIGN_ROLES = Object.freeze([
44
52
  * manufactures timeouts and reads as model failure. */
45
53
  export const DEFAULT_TIMEOUT_SEC = 900;
46
54
 
47
- /**
48
- * Grace period between SIGTERM and SIGKILL, in ms.
49
- *
50
- * `child.kill('SIGTERM')` is a REQUEST: a child that installs a handler and
51
- * ignores it never emits `close`, and this module's only resolve paths are
52
- * `close` and `error` — so without escalation a wedged `cursor-agent` hangs
53
- * `dispatchForeign` forever and takes the whole wave with it. SIGKILL is not
54
- * catchable, so the escalation always terminates.
55
- *
56
- * Named ceiling (BV-004): 10 s is enough for a cooperative child to flush its
57
- * stream-json tail and exit. Revisit if a well-behaved `cursor-agent` is ever
58
- * measured needing longer than that to shut down — raise the constant, never
59
- * drop the escalation.
60
- */
61
- export const DEFAULT_KILL_GRACE_MS = 10_000;
62
-
63
55
  /** A runId names a directory and a log file. Anything outside this alphabet —
64
56
  * or the two relative-path literals the alphabet happens to admit — can escape
65
57
  * the parent it is joined to. */
@@ -90,8 +82,8 @@ export function isNeverForeignRole(role) {
90
82
  *
91
83
  * The escalation is the difference between a bounded dispatch and a hung wave:
92
84
  * this function's only resolve paths are the child's `close` and `error`
93
- * events, so a child that ignores SIGTERM never lets the promise settle. See
94
- * {@link DEFAULT_KILL_GRACE_MS}.
85
+ * events, so a child that ignores SIGTERM never lets the promise settle. The
86
+ * callers pass `DEFAULT_KILL_GRACE_MS` from `process-group.mjs`.
95
87
  *
96
88
  * @returns {Promise<{capture: string, exitCode: number|null, signal: string|null,
97
89
  * timerFired: boolean, killSignals: string[], errorCode: string|null}>}
@@ -54,8 +54,8 @@ import path from 'node:path';
54
54
 
55
55
  import { emitEvent, sessionAttribution } from '../events.mjs';
56
56
  import { isPathInside } from '../path-utils.mjs';
57
+ import { DEFAULT_KILL_GRACE_MS } from '../process-group.mjs';
57
58
  import {
58
- DEFAULT_KILL_GRACE_MS,
59
59
  DEFAULT_TIMEOUT_SEC,
60
60
  isNeverForeignRole,
61
61
  isSafeRunId,
@@ -34,8 +34,8 @@ import path from 'node:path';
34
34
 
35
35
  import { emitEvent, sessionAttribution } from '../events.mjs';
36
36
  import { isPathInside } from '../path-utils.mjs';
37
+ import { DEFAULT_KILL_GRACE_MS } from '../process-group.mjs';
37
38
  import {
38
- DEFAULT_KILL_GRACE_MS,
39
39
  DEFAULT_TIMEOUT_SEC,
40
40
  isNeverForeignRole,
41
41
  isSafeRunId,