session-orchestrator 5.3.0 → 5.4.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 (206) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/navigator/SKILL.md +22 -0
  3. package/.agents/skills/navigator/agents/openai.yaml +5 -0
  4. package/.agents/skills/test-audit/SKILL.md +21 -0
  5. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  6. package/.claude-plugin/marketplace.json +1 -1
  7. package/.claude-plugin/plugin.json +1 -1
  8. package/.codex-plugin/plugin.json +1 -1
  9. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  10. package/.codex-plugin/skills/navigator/SKILL.md +22 -0
  11. package/.codex-plugin/skills/navigator/agents/openai.yaml +5 -0
  12. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  13. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  14. package/.cursor/commands/navigator.md +15 -0
  15. package/.cursor/commands/test-audit.md +14 -0
  16. package/.cursor/rules/060-evolve.mdc +27 -12
  17. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  18. package/.cursor/skills/navigator/SKILL.md +13 -0
  19. package/.cursor/skills/test-audit/SKILL.md +12 -0
  20. package/.cursor-plugin/plugin.json +1 -1
  21. package/.orchestrator/policy/blocked-commands.json +1 -1
  22. package/AGENTS.md +9 -31
  23. package/CHANGELOG.md +70 -0
  24. package/README.md +14 -10
  25. package/docs/agent-authoring.md +2 -2
  26. package/docs/baseline.md +10 -1
  27. package/docs/ci-setup.md +57 -4
  28. package/docs/components.md +12 -12
  29. package/docs/events-schema.md +11 -8
  30. package/docs/persona-panel.md +5 -4
  31. package/docs/rule-authoring.md +27 -12
  32. package/docs/scope-collision-guard.md +26 -4
  33. package/docs/session-config-reference.md +55 -6
  34. package/docs/session-config-template.md +11 -2
  35. package/hooks/_lib/hook-import-set.json +27 -23
  36. package/hooks/hooks-codex.json +1 -1
  37. package/hooks/hooks-cursor.json +5 -0
  38. package/hooks/hooks-pi.json +5 -0
  39. package/hooks/hooks.json +6 -1
  40. package/hooks/on-session-start.mjs +53 -0
  41. package/hooks/on-stop.mjs +3 -1
  42. package/hooks/post-tool-batch-wave-signal.mjs +3 -1
  43. package/hooks/pre-bash-memory-propose-audit.mjs +52 -3
  44. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  45. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  46. package/hooks/pre-task-scope-disjoint.mjs +559 -72
  47. package/hooks/subagent-telemetry.mjs +96 -19
  48. package/package.json +3 -3
  49. package/pi/prompts/navigator.md +12 -0
  50. package/pi/prompts/test-audit.md +12 -0
  51. package/rules/README.md +6 -1
  52. package/rules/_index.md +3 -3
  53. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  54. package/rules/opt-in-domain/prompt-caching.md +5 -1
  55. package/rules/opt-in-stack/backend-data.md +9 -1
  56. package/rules/opt-in-stack/backend.md +7 -1
  57. package/rules/opt-in-stack/frontend.md +6 -1
  58. package/rules/opt-in-stack/security-web.md +7 -1
  59. package/rules/opt-in-stack/swift.md +5 -1
  60. package/scripts/apply-session-learnings.mjs +492 -0
  61. package/scripts/archive-closed-prds.mjs +2 -1
  62. package/scripts/autopilot.mjs +49 -53
  63. package/scripts/backfill-abandoned-sessions.mjs +1 -1
  64. package/scripts/baseline-archetypes.mjs +1 -1
  65. package/scripts/check-package-manager.mjs +62 -5
  66. package/scripts/cursor-install.mjs +1 -4
  67. package/scripts/emit-session.mjs +296 -2
  68. package/scripts/export-hw-learnings.mjs +22 -3
  69. package/scripts/fleet-checkin.mjs +147 -0
  70. package/scripts/lib/agent-frontmatter.mjs +3 -3
  71. package/scripts/lib/auto-dream.mjs +7 -6
  72. package/scripts/lib/autopilot/flags.mjs +6 -1
  73. package/scripts/lib/autopilot/loop.mjs +10 -6
  74. package/scripts/lib/baseline-archetypes.mjs +41 -22
  75. package/scripts/lib/config/host-paths.mjs +79 -0
  76. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  77. package/scripts/lib/config/reaper.mjs +7 -6
  78. package/scripts/lib/config-schema.mjs +13 -0
  79. package/scripts/lib/config.mjs +71 -10
  80. package/scripts/lib/events.mjs +21 -2
  81. package/scripts/lib/fleet-protocol.mjs +270 -0
  82. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  83. package/scripts/lib/gates/gate-full.mjs +7 -2
  84. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  85. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  86. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  87. package/scripts/lib/instruction-budget-guard.mjs +69 -11
  88. package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
  89. package/scripts/lib/learnings/io.mjs +154 -26
  90. package/scripts/lib/learnings/schema.mjs +163 -15
  91. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  92. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  93. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  94. package/scripts/lib/named-vault-resolver.mjs +104 -5
  95. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  96. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  97. package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
  98. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  99. package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
  100. package/scripts/lib/orphan-reaper/scan.mjs +506 -0
  101. package/scripts/lib/orphan-reaper.mjs +43 -1315
  102. package/scripts/lib/owner-yaml.mjs +104 -6
  103. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  104. package/scripts/lib/process-group.mjs +62 -11
  105. package/scripts/lib/project-hygiene.mjs +21 -1
  106. package/scripts/lib/quality-gate.mjs +45 -5
  107. package/scripts/lib/resource-probe.mjs +1 -1
  108. package/scripts/lib/rule-loader.mjs +100 -62
  109. package/scripts/lib/rules-sync.mjs +27 -12
  110. package/scripts/lib/scope-baseline.mjs +3 -4
  111. package/scripts/lib/session-record-repair.mjs +183 -26
  112. package/scripts/lib/session-registry.mjs +27 -2
  113. package/scripts/lib/session-schema/constants.mjs +30 -4
  114. package/scripts/lib/session-schema/validator.mjs +28 -1
  115. package/scripts/lib/session-token-rollup.mjs +245 -52
  116. package/scripts/lib/sessions-canonical.mjs +100 -9
  117. package/scripts/lib/sh-quote.mjs +29 -0
  118. package/scripts/lib/subagents-schema.mjs +30 -3
  119. package/scripts/lib/telemetry/pricing.mjs +118 -71
  120. package/scripts/lib/tests-src-ratio.mjs +140 -23
  121. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  122. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  123. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  124. package/scripts/lib/validate/check-agents.mjs +1 -1
  125. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  126. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  127. package/scripts/lib/validate/check-test-value-bans.mjs +87 -14
  128. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  129. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  130. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  131. package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
  132. package/scripts/lib/vault-status/board-writer.mjs +48 -11
  133. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  134. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  135. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  136. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  137. package/scripts/lib/wave-resource-gate.mjs +34 -8
  138. package/scripts/memory-propose.mjs +18 -5
  139. package/scripts/release-session-lock.mjs +2 -1
  140. package/scripts/release.mjs +10 -3
  141. package/scripts/repair-invalid-sessions.mjs +37 -4
  142. package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
  143. package/scripts/run-quality-gate.mjs +323 -302
  144. package/scripts/site-numbers.mjs +163 -5
  145. package/scripts/sweep-expired-learnings.mjs +56 -5
  146. package/scripts/sweep-expired-rules.mjs +2 -1
  147. package/scripts/validate-plugin.mjs +7 -0
  148. package/scripts/vault-integration-watcher.mjs +2 -1
  149. package/scripts/vault-mirror.mjs +72 -46
  150. package/scripts/wave-scope-binding.mjs +2 -1
  151. package/skills/_shared/config-reading.md +3 -3
  152. package/skills/_shared/fleet-protocol.md +131 -0
  153. package/skills/_shared/state-ownership.md +5 -0
  154. package/skills/autopilot/SKILL.md +25 -11
  155. package/skills/bootstrap/SKILL.md +1 -1
  156. package/skills/bootstrap/_shared-template.md +1 -1
  157. package/skills/bootstrap/fast-template.md +1 -1
  158. package/skills/bootstrap/private-contract.md +2 -1
  159. package/skills/bootstrap/public-fallback.md +6 -3
  160. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  161. package/skills/claude-md-drift-check/SKILL.md +10 -10
  162. package/skills/claude-md-drift-check/checker.mjs +26 -18
  163. package/skills/evolve/SKILL.md +2 -2
  164. package/skills/evolve/references/evolve-analyze-mode.md +10 -6
  165. package/skills/memory-cleanup/SKILL.md +1 -1
  166. package/skills/navigator/SKILL.md +193 -0
  167. package/skills/navigator/references/account-check.sh +87 -0
  168. package/skills/navigator/references/fleet-decisions.mjs +231 -0
  169. package/skills/navigator/references/slot-check.sh +114 -0
  170. package/skills/persona-panel/SKILL.md +2 -2
  171. package/skills/persona-panel/persona-format.md +2 -2
  172. package/skills/persona-panel/presets/designer-lens.md +1 -1
  173. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  174. package/skills/persona-panel/presets/pm-lens.md +1 -1
  175. package/skills/plan/mode-retro.md +19 -4
  176. package/skills/quality-gates/SKILL.md +6 -4
  177. package/skills/remote-offload/SKILL.md +4 -2
  178. package/skills/session-end/learning-patterns.md +22 -12
  179. package/skills/session-end/metrics-collection.md +5 -6
  180. package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
  181. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  182. package/skills/session-end/references/session-summary-template.md +1 -1
  183. package/skills/session-end/session-metrics-write.md +22 -60
  184. package/skills/session-plan/SKILL.md +0 -3
  185. package/skills/session-start/SKILL.md +6 -0
  186. package/skills/session-start/references/operations-contract.md +4 -0
  187. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  188. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -2
  189. package/skills/test-audit/SKILL.md +152 -0
  190. package/skills/test-audit/references/campaign.md +111 -0
  191. package/skills/test-audit/references/mutate.sh +50 -0
  192. package/skills/wave-executor/SKILL.md +1 -1
  193. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  194. package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
  195. package/skills/wave-executor/references/wave-loop-review.md +3 -5
  196. package/templates/personas/README.md +5 -5
  197. package/templates/personas/accounting-compliance.v1.md +1 -1
  198. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  199. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  200. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  201. package/templates/personas/buyer-p3-build.v1.md +1 -1
  202. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  203. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  204. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  205. package/templates/personas/klima-ai-expert.v1.md +1 -1
  206. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -6,17 +6,21 @@
6
6
  * Catches vendoring bugs that are invisible at the source-of-truth layer but
7
7
  * become live footguns once synced into a target repo:
8
8
  *
9
- * - `paths:` frontmatter in a `rules/` LIBRARY SOURCE — a vendoring-
10
- * CONVENTION gate, not a loader-compatibility one. Since #795
11
- * `rule-loader.mjs` accepts `paths:` as an alias for `globs:`, so such a
12
- * rule IS glob-scoped; `globs:` stays the canonical form for a rule
13
- * vendored out through the library (#742). See § Scope below — this probe
14
- * never judges a repo's own consolidated `.claude/rules/` files.
9
+ * - `frontmatter-not-at-top` — a `---` block preceded by anything (the
10
+ * provenance comment, a blank line). Claude Code reads frontmatter only
11
+ * from line 1 and reads only its `paths:` key, so such a rule silently
12
+ * loads ALWAYS-ON in every consumer repo (#1449).
13
+ * - `globs-paths-mismatch` — a library rule's frontmatter must carry
14
+ * `globs:` AND `paths:` with the identical list (order-insensitive), or
15
+ * neither. `paths:` is what Claude Code reads; `globs:` stays because
16
+ * `rule-loader.mjs` prefers it (#742/#795). One key alone, or two
17
+ * different lists, scopes the rule differently per loader.
15
18
  * - Missing provenance header — `rules-sync.mjs` detects "plugin-owned vs.
16
- * local override" purely by checking whether the first line starts with
17
- * `PLUGIN_HEADER_PREFIX`. A source file missing that header gets
18
- * mis-detected as a local override on the NEXT re-sync (it looks
19
- * hand-authored), so the plugin can never update it again.
19
+ * local override" via `isPluginOwnedContent()`: the header line starting
20
+ * with `PLUGIN_HEADER_PREFIX` on line 1 (a rule without frontmatter) or as
21
+ * the first non-blank line after a line-1 frontmatter block. A source file
22
+ * missing it gets mis-detected as a local override on the NEXT re-sync (it
23
+ * looks hand-authored), so the plugin can never update it again.
20
24
  * - Unfilled placeholder tokens (`{{PROJECT_NAME}}`-style, `## TODO:
21
25
  * Customize` headings, `<!-- TODO:` comments) leaking from an
22
26
  * un-filled-in skeleton into a synced rule.
@@ -30,9 +34,10 @@
30
34
  * Stdlib-only ESM, with the same picomatch-with-fallback resolution pattern
31
35
  * as `rule-loader.mjs` (duplicated here rather than imported because those
32
36
  * helpers are module-private there — the *approach* is reused, not
33
- * reinvented). Frontmatter `globs:` extraction is NOT duplicated — this
34
- * module imports the already-exported `parseGlobsFrontmatter` from
35
- * `rule-loader.mjs` directly.
37
+ * reinvented). The globs-derived probes use the already-exported
38
+ * `parseGlobsFrontmatter` from `rule-loader.mjs` directly; only
39
+ * `globs-paths-mismatch` reads the two keys itself (`readScopeKeys`), because
40
+ * the loader merges them and cannot say which key carried which list.
36
41
  *
37
42
  * ## Scope: the `rules/` fleet library, never a repo's own `.claude/rules/`
38
43
  *
@@ -43,15 +48,14 @@
43
48
  * written target is never read back through the gate. The CLI's `--dir` is
44
49
  * operator-supplied and means that same library.
45
50
  *
46
- * This is load-bearing for `paths-frontmatter`: a repo's own CONSOLIDATED rules
47
- * under `.claude/rules/` are `paths:`-canonical by design, because Claude
48
- * Code's native loader reads ONLY `paths:` and treats a rule without it as
49
- * always-on (`validate/check-rules.mjs` #1108; `docs/rule-authoring.md`
50
- * § Consolidated rules point 3). Measured 2026-09-16 in this repo: 10
51
- * path-scoped rule files there, 9 of them `paths:`-only. Pointing `--dir` at
52
- * such a tree would emit findings that, if obeyed, UNDO that consolidation —
53
- * which is also why `check-rules.mjs` deliberately does not duplicate this
54
- * probe for `.claude/rules/`.
51
+ * This matters for `globs-paths-mismatch` and the provenance probe: they encode
52
+ * the LIBRARY convention (both scope keys, a provenance header). A repo's own
53
+ * CONSOLIDATED rules under `.claude/rules/` are `paths:`-only and header-free
54
+ * by design, because Claude Code's native loader reads ONLY `paths:`
55
+ * (`validate/check-rules.mjs` #1108; `docs/rule-authoring.md` § Consolidated
56
+ * rules point 3). Measured 2026-09-16 in this repo: 10 path-scoped rule files
57
+ * there, 9 of them `paths:`-only. Pointing `--dir` at such a tree reports
58
+ * findings about a convention those files were never meant to follow.
55
59
  *
56
60
  * @module validate-vendored-rules
57
61
  */
@@ -61,7 +65,7 @@ import { join, resolve, relative, basename } from 'node:path';
61
65
  import { fileURLToPath } from 'node:url';
62
66
  import { execFileSync } from 'node:child_process';
63
67
  import { createRequire } from 'node:module';
64
- import { parseGlobsFrontmatter } from './rule-loader.mjs';
68
+ import { parseGlobsFrontmatter, FRONTMATTER_NOT_AT_TOP } from './rule-loader.mjs';
65
69
  import { isMainModule } from './is-main-module.mjs';
66
70
 
67
71
  // Mirrors rules-sync.mjs's exported PLUGIN_HEADER_PREFIX (rules-sync.mjs
@@ -137,21 +141,26 @@ function matchGlob(filePath, globPattern) {
137
141
  // Frontmatter helpers
138
142
  // ---------------------------------------------------------------------------
139
143
 
144
+ // A line that may precede a MISPLACED frontmatter opener: blank, or one
145
+ // complete single-line HTML comment (the provenance header's shape).
140
146
  const HEADER_LINE_RE = /^[ \t]*(?:<!--.*-->)?[ \t]*$/;
141
147
 
142
148
  /**
143
- * Finds a YAML frontmatter block delimited by `---` lines.
149
+ * Locates a YAML frontmatter block delimited by `---` lines.
144
150
  *
145
- * Mirrors rule-loader.mjs's header-tolerant frontmatter shape for the `paths:`
146
- * authoring guard: a leading run of blank lines and/or single-line HTML
147
- * comments may precede the opening `---`.
151
+ * Deliberately tolerant of a leading run of blank lines and/or single-line
152
+ * HTML comments before the opener — NOT because such a block is valid (Claude
153
+ * Code reads frontmatter only when `---` is line 1, and `rule-loader.mjs`
154
+ * no longer tolerates the prefix either), but so `frontmatter-not-at-top` can
155
+ * SEE the misplaced block and report it. Callers that judge what a loader
156
+ * reads must check `startLine === 0` first.
148
157
  *
149
- * @param {string} content
150
- * @returns {{ body: string, startLine: number } | null} startLine is the
151
- * 0-based file-line index of the opening `---`.
158
+ * @param {string[]} lines - file content split on `\r?\n`
159
+ * @returns {{ bodyLines: string[], startLine: number, endLine: number } | null}
160
+ * startLine / endLine are the 0-based file-line indices of the opening and
161
+ * closing `---`.
152
162
  */
153
- function extractFrontmatter(content) {
154
- const lines = content.split(/\r?\n/);
163
+ function locateFrontmatter(lines) {
155
164
  let startLine = 0;
156
165
  while (startLine < lines.length && HEADER_LINE_RE.test(lines[startLine])) {
157
166
  startLine++;
@@ -159,22 +168,93 @@ function extractFrontmatter(content) {
159
168
  if (lines[startLine] !== '---') return null;
160
169
  for (let i = startLine + 1; i < lines.length; i++) {
161
170
  if (lines[i] === '---') {
162
- return { body: lines.slice(startLine + 1, i).join('\n'), startLine };
171
+ return { bodyLines: lines.slice(startLine + 1, i), startLine, endLine: i };
163
172
  }
164
173
  }
165
174
  return null;
166
175
  }
167
176
 
168
177
  /**
169
- * Line number (1-based, absolute within the file) of a match at `bodyIndex`
170
- * inside the frontmatter body.
171
- * @param {number} startLine - 0-based file-line index of the opening `---`
172
- * @param {string} body - frontmatter body text
173
- * @param {number} bodyIndex - character offset within body
174
- * @returns {number}
178
+ * True when `content` carries the plugin provenance header in one of the two
179
+ * positions the vendoring convention allows (#1449):
180
+ *
181
+ * (a) line 1 — a rule WITHOUT frontmatter, and also every vendored copy
182
+ * written before #1449 (header on line 1, frontmatter after it);
183
+ * (b) the first non-blank line after the closing `---` of a frontmatter
184
+ * block that opens on line 1 — the current format for a rule WITH
185
+ * frontmatter, because Claude Code reads `paths:` only from a line-1
186
+ * block.
187
+ *
188
+ * This is the ownership predicate `rules-sync.mjs` uses to tell a
189
+ * plugin-owned vendored copy (rewrite it) from a local rule (preserve it), and
190
+ * the `provenance-header` probe's acceptance test. Accepting (a) with a
191
+ * frontmatter after it keeps old-format consumer copies plugin-owned, so the
192
+ * next sync REWRITES them into the new format instead of preserving them
193
+ * forever. Lives here rather than in rules-sync.mjs because rules-sync.mjs
194
+ * imports from this module; it re-exports this function.
195
+ *
196
+ * @param {string} content - raw rule file content
197
+ * @returns {boolean}
175
198
  */
176
- function lineWithinFrontmatter(startLine, body, bodyIndex) {
177
- return startLine + 1 + body.slice(0, bodyIndex).split('\n').length;
199
+ export function isPluginOwnedContent(content) {
200
+ const lines = content.split(/\r?\n/);
201
+ if ((lines[0] ?? '').startsWith(PLUGIN_HEADER_PREFIX)) return true;
202
+ const fm = locateFrontmatter(lines);
203
+ if (!fm || fm.startLine !== 0) return false;
204
+ const next = lines.slice(fm.endLine + 1).find((l) => l.trim() !== '');
205
+ return next !== undefined && next.startsWith(PLUGIN_HEADER_PREFIX);
206
+ }
207
+
208
+ /**
209
+ * Strips one pair of surrounding quotes, as rule-loader.mjs does for list items.
210
+ * @param {string} s
211
+ * @returns {string}
212
+ */
213
+ function unquoteItem(s) {
214
+ return s.trim().replace(/^["']|["']$/g, '');
215
+ }
216
+
217
+ /**
218
+ * Reads the `globs:` and `paths:` sequences of a frontmatter body EACH ON ITS
219
+ * OWN. `parseGlobsFrontmatter()` cannot answer this: it merges the two keys by
220
+ * design (`globs:` wins, `paths:` is discarded), so it cannot say which key
221
+ * carried which list. Accepts the three value shapes rule-loader.mjs accepts —
222
+ * indented block list, flow list `[a, b]`, single inline value.
223
+ *
224
+ * @param {string[]} bodyLines - frontmatter lines between the `---` markers
225
+ * @returns {{ globs?: { values: string[], index: number }, paths?: { values: string[], index: number } }}
226
+ * `index` is the 0-based body line of the key.
227
+ */
228
+ function readScopeKeys(bodyLines) {
229
+ /** @type {{ globs?: { values: string[], index: number }, paths?: { values: string[], index: number } }} */
230
+ const found = {};
231
+ let active = null;
232
+ bodyLines.forEach((raw, index) => {
233
+ const line = raw.replace(/\s+$/, '');
234
+ if (line === '' || /^\s*#/.test(line)) return;
235
+ if (active) {
236
+ const item = /^\s+-\s+(.*)$/.exec(line);
237
+ if (item) {
238
+ found[active].values.push(unquoteItem(item[1]));
239
+ return;
240
+ }
241
+ active = null;
242
+ }
243
+ const key = /^(globs|paths):\s*(.*)$/.exec(line);
244
+ if (!key) return;
245
+ const [, name, value] = key;
246
+ let values = [];
247
+ if (value === '') {
248
+ active = name;
249
+ } else if (value.startsWith('[') && value.endsWith(']')) {
250
+ const inner = value.slice(1, -1).trim();
251
+ values = inner === '' ? [] : inner.split(',').map(unquoteItem);
252
+ } else {
253
+ values = [unquoteItem(value)];
254
+ }
255
+ found[name] = { values, index };
256
+ });
257
+ return found;
178
258
  }
179
259
 
180
260
  /**
@@ -463,7 +543,7 @@ const FOREIGN_TOKEN_RE = /[A-Z][a-z]+[A-Z]/;
463
543
 
464
544
  /**
465
545
  * @typedef {object} RuleViolation
466
- * @property {string} rule - check id (paths-frontmatter | provenance-header | placeholder | zero-match-globs | foreign-glob)
546
+ * @property {string} rule - check id (frontmatter-not-at-top | globs-paths-mismatch | provenance-header | placeholder | zero-match-globs | foreign-glob)
467
547
  * @property {'error'|'warn'} severity
468
548
  * @property {string} message
469
549
  * @property {number} [line] - 1-based line number, when known
@@ -486,52 +566,94 @@ export function validateRuleContent({ content, relPath, targetRoot = null, requi
486
566
  /** @type {RuleViolation[]} */
487
567
  const violations = [];
488
568
 
489
- // ---- paths-frontmatter (error) ----
490
- const fm = extractFrontmatter(content);
491
- if (fm) {
492
- const pathsMatch = /^paths:/m.exec(fm.body);
493
- if (pathsMatch) {
569
+ const lines = content.split(/\r?\n/);
570
+ const fm = locateFrontmatter(lines);
571
+
572
+ // ---- frontmatter-not-at-top (error) ----
573
+ // Claude Code reads frontmatter only when `---` is line 1. Anything before
574
+ // it (the provenance comment, a blank line) makes it see NO frontmatter, so
575
+ // a path-scoped rule loads ALWAYS-ON in every consumer repo, with no error.
576
+ // Two detectors, one verdict (BV-003): `locateFrontmatter` sees a block
577
+ // behind blank lines / single-line comments (the provenance shape);
578
+ // `parseGlobsFrontmatter` (the loader — the same code the wave injector
579
+ // runs) additionally sees one behind ANY text, e.g. a heading. Either
580
+ // signal is the defect; the finding is reported once.
581
+ const loaderDefect = parseGlobsFrontmatter(content).defect === FRONTMATTER_NOT_AT_TOP;
582
+ if ((fm && fm.startLine !== 0) || (!(fm && fm.startLine === 0) && loaderDefect)) {
583
+ const openerLine = fm ? fm.startLine + 1 : lines.findIndex((l) => l === '---') + 1;
584
+ violations.push({
585
+ rule: 'frontmatter-not-at-top',
586
+ severity: 'error',
587
+ message:
588
+ `${relPath}: the frontmatter block opens on line ${openerLine}, not line 1. ` +
589
+ `Claude Code reads frontmatter only when '---' is the very first line, so it sees none ` +
590
+ `here and loads this rule always-on, ignoring its 'paths:' scope. Move the block to the ` +
591
+ `top; the provenance header goes on the first line after the closing '---'.`,
592
+ line: 1,
593
+ });
594
+ }
595
+
596
+ // ---- globs-paths-mismatch (error) ----
597
+ // Judged only on a line-1 block: a misplaced block is read by no loader,
598
+ // and frontmatter-not-at-top already reports it.
599
+ if (fm && fm.startLine === 0) {
600
+ const { globs, paths } = readScopeKeys(fm.bodyLines);
601
+ // body index i sits on 1-based file line i + 2 when the opener is line 1.
602
+ const keyLine = (k) => k.index + 2;
603
+ let detail = null;
604
+ let line;
605
+ if (globs && !paths) {
606
+ detail =
607
+ `declares 'globs:' but no 'paths:'. Claude Code reads only 'paths:', so it loads this ` +
608
+ `rule always-on. Add 'paths:' carrying the identical list.`;
609
+ line = keyLine(globs);
610
+ } else if (paths && !globs) {
611
+ detail =
612
+ `declares 'paths:' but no 'globs:'. rule-loader.mjs prefers 'globs:' (#742/#795); a ` +
613
+ `rule vendored out through the plugin's rules/ library carries both keys. Add 'globs:' ` +
614
+ `carrying the identical list. This does NOT apply to a repo's own consolidated ` +
615
+ `.claude/rules/ files — those are paths:-only by design and this gate never scans them.`;
616
+ line = keyLine(paths);
617
+ } else if (globs && paths) {
618
+ const g = new Set(globs.values);
619
+ const p = new Set(paths.values);
620
+ const onlyGlobs = [...g].filter((x) => !p.has(x));
621
+ const onlyPaths = [...p].filter((x) => !g.has(x));
622
+ if (onlyGlobs.length > 0 || onlyPaths.length > 0) {
623
+ detail =
624
+ `'globs:' and 'paths:' carry different lists (only in globs: ${JSON.stringify(onlyGlobs)}; ` +
625
+ `only in paths: ${JSON.stringify(onlyPaths)}). Claude Code scopes by 'paths:', ` +
626
+ `rule-loader.mjs by 'globs:', so the two would activate this rule on different files. ` +
627
+ `Make both lists identical.`;
628
+ line = keyLine(globs.index <= paths.index ? globs : paths);
629
+ }
630
+ }
631
+ if (detail) {
494
632
  violations.push({
495
- rule: 'paths-frontmatter',
633
+ rule: 'globs-paths-mismatch',
496
634
  severity: 'error',
497
- // NOTE (#795 / corrected 2026-07-25): the previous wording claimed rule-loader.mjs
498
- // does not recognize `paths:` and that such a rule loads always-on. Both are false
499
- // since #795 — `rule-loader.mjs:275` accepts `paths` alongside `globs`, and `:313`
500
- // treats it as a fallback alias (`globs:` wins silently when both are present), so a
501
- // `paths:`-only rule IS glob-scoped. The probe itself stays: it enforces the canonical
502
- // vendoring form, which is a convention gate, not a loader-compatibility gate. That
503
- // intent survives #795 and is the subject of the #742 fleet canonicalisation sweep.
504
- // SCOPE (2026-09-16): this fires only over `rules/` library sources — the module doc's
505
- // § Scope section names every caller. Do NOT re-point it at a repo's own
506
- // `.claude/rules/`: those consolidated files are `paths:`-canonical and obeying this
507
- // remedy there would undo the consolidation (docs/rule-authoring.md point 3).
508
- message:
509
- `${relPath}: frontmatter declares a top-level 'paths:' key. It is a recognized alias ` +
510
- `for 'globs:' (issue #795), so the rule does load glob-scoped — but 'globs:' is the ` +
511
- `canonical form for a rule VENDORED OUT through the plugin's rules/ library, which is ` +
512
- `this probe's only population. Migrate to 'globs:' (see issue #742). This does NOT ` +
513
- `apply to a repo's own consolidated .claude/rules/ files — those are paths:-canonical ` +
514
- `(Claude Code's native loader reads only 'paths:') and this gate never scans them.`,
515
- line: lineWithinFrontmatter(fm.startLine, fm.body, pathsMatch.index),
635
+ message: `${relPath}: frontmatter ${detail}`,
636
+ line,
516
637
  });
517
638
  }
518
639
  }
519
640
 
520
641
  // ---- provenance-header (error, opt-in) ----
521
- if (requireProvenance) {
522
- const firstLine = content.split('\n')[0] ?? '';
523
- if (!firstLine.startsWith(PLUGIN_HEADER_PREFIX)) {
524
- violations.push({
525
- rule: 'provenance-header',
526
- severity: 'error',
527
- message:
528
- `${relPath}: missing provenance header — the first line must start with ` +
529
- `${JSON.stringify(PLUGIN_HEADER_PREFIX)} (see rules-sync.mjs PLUGIN_HEADER_PREFIX). ` +
530
- `Without it, rules-sync.mjs mis-detects this file as a local override on the next re-sync ` +
531
- `and will never update it again.`,
532
- line: 1,
533
- });
534
- }
642
+ // A header on line 1 followed by a frontmatter is accepted HERE (the file
643
+ // has a header, and isPluginOwnedContent must keep old vendored copies
644
+ // plugin-owned); frontmatter-not-at-top reports that shape, once.
645
+ if (requireProvenance && !isPluginOwnedContent(content)) {
646
+ violations.push({
647
+ rule: 'provenance-header',
648
+ severity: 'error',
649
+ message:
650
+ `${relPath}: missing provenance header — a line starting with ` +
651
+ `${JSON.stringify(PLUGIN_HEADER_PREFIX)} (see rules-sync.mjs PLUGIN_HEADER_PREFIX) must ` +
652
+ `stand either on line 1 (a rule without frontmatter) or on the first non-blank line after ` +
653
+ `the closing '---' of a frontmatter that opens on line 1. Without it, rules-sync.mjs ` +
654
+ `mis-detects this file as a local override on the next re-sync and will never update it again.`,
655
+ line: 1,
656
+ });
535
657
  }
536
658
 
537
659
  // ---- placeholder (error) — skip matches inside fenced code blocks ----
@@ -569,6 +691,9 @@ export function validateRuleContent({ content, relPath, targetRoot = null, requi
569
691
  }
570
692
 
571
693
  // ---- globs: derived checks (zero-match-globs, foreign-glob) ----
694
+ // Driven by the loader's own parse, so these judge exactly the list
695
+ // rule-loader.mjs will scope by (`globs:`, else its `paths:` alias). On a
696
+ // misplaced block that yields no globs — frontmatter-not-at-top covers it.
572
697
  let globs;
573
698
  try {
574
699
  ({ globs } = parseGlobsFrontmatter(content));
@@ -11,6 +11,10 @@
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.
14
18
  * - `orchestrator.vault.mirror_run_completed` — ONE record per CLI run,
15
19
  * carrying the DENOMINATOR (`total` plus the per-class counts).
16
20
  *
@@ -162,7 +166,10 @@ export async function emitMirrorEvent({
162
166
  * @param {number} opts.updated — entries whose action was `updated`.
163
167
  * @param {number} opts.skipped — entries skipped for a NON-failure reason
164
168
  * (`skipped-noop`, `skipped-handwritten`, `skipped-quality-low`,
165
- * `skipped-collision-resolved`, `skipped-abandoned`).
169
+ * `skipped-collision-resolved`, `skipped-abandoned`,
170
+ * `skipped-duplicate-session`). The last one (#1291) is what keeps
171
+ * `created + updated + skipped + failed === total` true for a `--kind
172
+ * session` run whose dedup pass collapsed a duplicate `session_id` line.
166
173
  * @param {number} opts.failed — entries that produced `skipped-invalid`
167
174
  * (validation error or mapper crash). Split out from `skipped` because these
168
175
  * are the runs where a session silently ends up WITHOUT its vault note.
@@ -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';
@@ -825,6 +829,8 @@ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows
825
829
  * Vault path assertion (Epic #673 safety): the resolved vault dir MUST live
826
830
  * under $HOME — a vault outside the home tree is refused as `skipped-vault-disabled`
827
831
  * so a misconfigured path can never drive a write into an arbitrary location.
832
+ * It must also be a canonical Meta-Vault ({@link checkCanonicalVault}, #1450):
833
+ * otherwise `skipped-vault-not-canonical`, nothing written, no lock, dry-run included.
828
834
  *
829
835
  * @param {object} opts
830
836
  * @param {string} opts.repoRoot — the repo whose row is being updated.
@@ -840,11 +846,14 @@ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows
840
846
  * @param {Date} [opts.now]
841
847
  * @param {boolean} [opts.dryRun]
842
848
  * @param {object} [opts.fs] — injectable fs for tests.
843
- * @param {{ env?: Record<string, string|undefined>, ownerConfig?: object }} [opts.hostPaths]
849
+ * @param {{ env?: Record<string, string|undefined>, ownerConfig?: object,
850
+ * readOriginUrl?: (vaultDir: string) => string }} [opts.hostPaths]
844
851
  * — forwarded verbatim to {@link parseSessionConfig}'s `hostPaths` DI seam (issue #653).
852
+ * The same ctx feeds {@link checkCanonicalVault}; `readOriginUrl` replaces its
853
+ * `git remote get-url origin` read (tests only — tmp vaults have no origin).
845
854
  * Tests MUST pass a hermetic ctx (e.g. `{ env: {}, ownerConfig: undefined }`) when
846
855
  * 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
856
+ * `owner.yaml`, whose `vault-dirs:` cwd match or `paths.vault-dir` override (if set) wins over the fixture value
848
857
  * and bleeds into the assertion (issue #783). Production callers omit this — the
849
858
  * default (real owner.yaml resolution) is the correct host-local behavior there.
850
859
  * @returns {Promise<{ result: { action: string, path?: string }, rows?: number,
@@ -861,10 +870,19 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
861
870
  }
862
871
 
863
872
  // Read + parse Session Config. Any failure → silent no-op.
873
+ // The host context is resolved ONCE here and reused by the canonical-vault
874
+ // guard below: loadOwnerConfig() has no cache and its section-drop WARNs are
875
+ // not rate-limited, so a second load would print every WARN of a broken
876
+ // owner.yaml twice per board write (#1450 follow-up). The config file is read
877
+ // BEFORE owner.yaml — the order narrative-mirror.mjs shares (#1479); vault-dir
878
+ // precedence is applied in parseSessionConfig, so the order changes only
879
+ // whether a repo without a config file reads owner.yaml at all.
864
880
  let config;
881
+ let hostCtx;
865
882
  try {
866
883
  const text = await readConfigFile(repoRoot);
867
- config = parseSessionConfig(text, { hostPaths });
884
+ hostCtx = hostPaths ?? loadHostPaths();
885
+ config = parseSessionConfig(text, { hostPaths: hostCtx });
868
886
  } catch {
869
887
  return { result: { action: 'skipped-vault-disabled' } };
870
888
  }
@@ -886,6 +904,24 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
886
904
  return { result: { action: 'skipped-vault-disabled' } };
887
905
  }
888
906
 
907
+ // Safety: the vault must be a canonical Meta-Vault (#1450) — the same guard
908
+ // vault-mirror applies, so a vault-dir that resolved to a FOREIGN vault can
909
+ // never receive the board. Reuses the `hostCtx` the config parse used, so a
910
+ // hermetic ctx never reads the real owner.yaml and owner.yaml is loaded at
911
+ // most once. Runs before the dry-run branch and the lock: fail-closed.
912
+ const canonical = checkCanonicalVault({
913
+ vaultDir: expandedVault,
914
+ ownerConfig: hostCtx.ownerConfig,
915
+ env: hostCtx.env ?? process.env,
916
+ readOriginUrl: hostCtx.readOriginUrl,
917
+ });
918
+ if (!canonical.ok) {
919
+ process.stderr.write(
920
+ `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`,
921
+ );
922
+ return { result: { action: 'skipped-vault-not-canonical' } };
923
+ }
924
+
889
925
  // `vault-name` (#660) overrides the git-derived repo slug for per-project
890
926
  // vault namespacing. Without it `collectRows` falls back to
891
927
  // `path.basename(repoRoot)`, so a repo whose directory name differs from its
@@ -1112,10 +1148,10 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
1112
1148
  * Public {@link mirrorBoardInner} wrapper that emits exactly ONE
1113
1149
  * {@link BOARD_EVENT} per call — on EVERY path, no-ops included.
1114
1150
  *
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
1151
+ * The wrapper exists so the emit cannot be forgotten: the seven return points
1152
+ * inside {@link mirrorBoardInner} (five `skipped-vault-disabled` guards, the
1153
+ * `skipped-vault-not-canonical` guard, plus whatever {@link writeBoard}
1154
+ * decides) all funnel through here, and so does the eighth someone adds next. Emitting per-return instead would leave each new
1119
1155
  * early return silent by default — which is the exact defect being fixed.
1120
1156
  *
1121
1157
  * A THROW from the inner function is deliberately NOT converted into an event:
@@ -1276,7 +1312,8 @@ export function buildSweepRepos(candidates, { thisRepoRoot } = {}) {
1276
1312
  * @param {object} [opts.deps] — injectable deps for {@link enumerateCandidates}
1277
1313
  * (test seam: `readdirSync`, `existsSync`, `readLock`, `isLockLive`,
1278
1314
  * `getCrossRepoProjects`, `validatePathInsideProject`, `now`).
1279
- * @param {{ env?: Record<string, string|undefined>, ownerConfig?: object }} [opts.hostPaths]
1315
+ * @param {{ env?: Record<string, string|undefined>, ownerConfig?: object,
1316
+ * readOriginUrl?: (vaultDir: string) => string }} [opts.hostPaths]
1280
1317
  * — forwarded verbatim to every {@link mirrorBoard} call below (both the happy-path
1281
1318
  * and the enumeration-failure-fallback path), which in turn forwards it to
1282
1319
  * {@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