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
@@ -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,8 @@ import { readFileSync, existsSync } from 'node:fs';
11
11
  import { resolve, sep } from 'node:path';
12
12
  import { spawnSync } from 'node:child_process';
13
13
 
14
+ import { redactHomeDir } from '../common.mjs';
15
+
14
16
  const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
15
17
  const MIRROR_DIRS = ['40-learnings', '50-sessions'];
16
18
 
@@ -79,7 +81,7 @@ export function autoCommitVaultMirror(vaultDirPath, sessionId, repo) {
79
81
 
80
82
  const addResult = git(vaultDirPath, ['add', '--', ...stagingTargets]);
81
83
  if (addResult.status !== 0) {
82
- process.stderr.write(`vault-mirror: auto-commit git-add failed: ${addResult.stderr}\n`);
84
+ process.stderr.write(`vault-mirror: auto-commit git-add failed: ${redactHomeDir(addResult.stderr)}\n`);
83
85
  process.stdout.write(
84
86
  JSON.stringify({ action: 'auto-commit-skipped', reason: 'git-add-failed' }) + '\n',
85
87
  );
@@ -88,7 +90,7 @@ export function autoCommitVaultMirror(vaultDirPath, sessionId, repo) {
88
90
 
89
91
  const diff = git(vaultDirPath, ['diff', '--cached', '--name-only', '--', ...stagingTargets]);
90
92
  if (diff.status !== 0) {
91
- process.stderr.write(`vault-mirror: auto-commit git-diff failed: ${diff.stderr}\n`);
93
+ process.stderr.write(`vault-mirror: auto-commit git-diff failed: ${redactHomeDir(diff.stderr)}\n`);
92
94
  process.stdout.write(
93
95
  JSON.stringify({ action: 'auto-commit-skipped', reason: 'git-diff-failed' }) + '\n',
94
96
  );
@@ -182,7 +184,7 @@ export function autoCommitVaultMirror(vaultDirPath, sessionId, repo) {
182
184
  // committed content is already validated. See SKILL.md § "Pre-commit hook bypass (--no-verify)".
183
185
  const commit = git(vaultDirPath, ['commit', '-m', subject, '--no-verify']);
184
186
  if (commit.status !== 0) {
185
- process.stderr.write(`vault-mirror: auto-commit git-commit failed: ${commit.stderr}\n`);
187
+ process.stderr.write(`vault-mirror: auto-commit git-commit failed: ${redactHomeDir(commit.stderr)}\n`);
186
188
  process.stdout.write(
187
189
  JSON.stringify({ action: 'auto-commit-skipped', reason: 'git-commit-failed' }) + '\n',
188
190
  );
@@ -10,13 +10,18 @@
10
10
  * - Pure + deterministic (given the same vaultName, the same `.vault.yaml`
11
11
  * under `repoRoot`, the same git remote, and the same host-local pseudonym
12
12
  * map).
13
- * - `repoRoot` (#1389) selects the directory whose `.vault.yaml` is read;
14
- * it defaults to `process.cwd()`. Named limit: it scopes ONLY that lookup —
15
- * the {@link deriveRepo} fallback still resolves from the process cwd and
16
- * is cached per process, so a `repoRoot` that differs from the cwd AND has
17
- * no declared slug falls back to the cwd's git identity. Every production
18
- * caller passes its own cwd today; revisit if a caller ever mirrors a repo
19
- * other than the one it runs in.
13
+ * - `repoRoot` (#1389, #1503) selects the repo whose identity is resolved —
14
+ * BOTH the `.vault.yaml` lookup and the {@link deriveRepo} fallback (its
15
+ * preferred git remote, else its directory name). It defaults to
16
+ * `process.cwd()`. Until #1503 only the `.vault.yaml` half honoured it: the
17
+ * git fallback still read the process cwd, cached once per process, so a
18
+ * caller mirroring a repo other than the one it ran in got the CWD's
19
+ * namespace — which is how one repo's ledger overwrote another repo's vault
20
+ * notes. The revisit trigger this comment used to name ("a caller mirrors a
21
+ * repo other than the one it runs in") fired; the cache is now keyed per
22
+ * root. Remaining ceiling: a caller that passes NO `repoRoot` still gets the
23
+ * cwd's identity — `scripts/vault-mirror.mjs` derives a root from
24
+ * `--source` precisely so that it never relies on that default.
20
25
  * - Returns a lowercase kebab slug safe for use as a filesystem path segment.
21
26
  * - Host-local pseudonym mapping (Epic #725 D5): consulted ONLY at the redaction
22
27
  * site (only when a segment is owner-leaky). If such a repo (raw or sanitised)
@@ -48,7 +53,7 @@
48
53
  * (itself a leaf — `node:child_process` only) — never the pipeline.
49
54
  */
50
55
 
51
- import { basename } from 'node:path';
56
+ import { basename, resolve } from 'node:path';
52
57
 
53
58
  import { subjectToSlug } from './utils.mjs';
54
59
  import { isOwnerLeakySegment } from '../../lib/validate/check-owner-leakage.mjs';
@@ -97,7 +102,13 @@ function currentMapPath() {
97
102
 
98
103
  // ── Repo identity (issue #343; moved here from process.mjs for #734b) ────────
99
104
 
100
- let _cachedRepo = null;
105
+ /**
106
+ * Resolved repo root → derived identity. Keyed per root (#1503): a single
107
+ * process-wide slot answered every later caller with the FIRST root's identity,
108
+ * whichever root that caller asked about.
109
+ * @type {Map<string, string>}
110
+ */
111
+ const _cachedRepos = new Map();
101
112
 
102
113
  /** scp-like SSH remote: `git@host:org/name.git` (no `://`, an `@` before any `/`). */
103
114
  const SCP_LIKE_REMOTE_RE = /^[^@/\s]+@[^:/\s]+:(.+)$/;
@@ -177,7 +188,7 @@ function repoIdentifierFromRemoteUrl(url) {
177
188
  * {@link resolveRepoNamespace} returned `'unknown-repo'`. Measured
178
189
  * 2026-08-19; it turned a namespace assertion red and blocked a push.
179
190
  *
180
- * Fallback: `path.basename(process.cwd())`, as before — but the two reasons for
191
+ * Fallback: `path.basename(<root>)` — but the two reasons for
181
192
  * reaching it are no longer indistinguishable. A QUERY FAILURE (not a git repo,
182
193
  * git not on PATH, git errored — {@link isQueryFailure}) emits a stderr WARN,
183
194
  * because the identity under which vault notes are written was GUESSED. A real
@@ -186,8 +197,11 @@ function repoIdentifierFromRemoteUrl(url) {
186
197
  * An `ok` resolution whose URL yields no usable identifier also falls back
187
198
  * silently — the query succeeded and the answer was simply unusable.
188
199
  *
189
- * Cached per-process — repo identity does not change mid-run, and the cache also
190
- * keeps the WARN to at most one line per process.
200
+ * `<root>` is `repoRoot` when given, else `process.cwd()` (#1503: the remote is
201
+ * read with `git -C <root>`, so the identity is the root's, never the cwd's).
202
+ *
203
+ * Cached per resolved root — repo identity does not change mid-run, and the
204
+ * cache also keeps the WARN to at most one line per root.
191
205
  *
192
206
  * NOTE — this is the RAW identifier and is NOT leak-guarded. Never write its
193
207
  * output to the vault directly; route it through {@link resolveRepoNamespace}
@@ -198,18 +212,25 @@ function repoIdentifierFromRemoteUrl(url) {
198
212
  * until the #734b cycle break, and the module-level cache means there must remain
199
213
  * exactly ONE definition.
200
214
  *
215
+ * @param {object} [opts]
216
+ * @param {string|null} [opts.repoRoot] - Repo whose identity to derive;
217
+ * `undefined`/`null` → `process.cwd()`.
201
218
  * @returns {string} e.g. 'Kanevry/session-orchestrator' or a bare directory name.
202
219
  */
203
- export function deriveRepo() {
204
- if (_cachedRepo !== null) return _cachedRepo;
220
+ export function deriveRepo({ repoRoot = null } = {}) {
221
+ const root = resolve(repoRoot ?? process.cwd());
222
+ const cached = _cachedRepos.get(root);
223
+ if (cached !== undefined) return cached;
224
+
225
+ const remember = (identity) => {
226
+ _cachedRepos.set(root, identity);
227
+ return identity;
228
+ };
205
229
 
206
- const resolved = resolvePreferredRemote({});
230
+ const resolved = resolvePreferredRemote({ repoRoot: root });
207
231
  if (resolved.ok) {
208
232
  const identifier = repoIdentifierFromRemoteUrl(resolved.url);
209
- if (identifier !== '') {
210
- _cachedRepo = identifier;
211
- return _cachedRepo;
212
- }
233
+ if (identifier !== '') return remember(identifier);
213
234
  } else if (isQueryFailure(resolved.reason)) {
214
235
  process.stderr.write(
215
236
  `WARN vault-mirror/namespace: could not query git remotes (${resolved.reason}); ` +
@@ -218,8 +239,7 @@ export function deriveRepo() {
218
239
  );
219
240
  }
220
241
 
221
- _cachedRepo = basename(process.cwd());
222
- return _cachedRepo;
242
+ return remember(basename(root));
223
243
  }
224
244
 
225
245
  /**
@@ -249,9 +269,9 @@ function lookupPseudonym(base, seg) {
249
269
  * When non-empty and non-whitespace, used in place of the declared/git-derived
250
270
  * repo name. When absent, the base is the repo's declared `.vault.yaml`
251
271
  * `metadata.slug` (#1131), else the git origin via deriveRepo().
252
- * @param {string|null} [opts.repoRoot] - Directory whose `.vault.yaml` supplies
253
- * the declared slug (#1389). `undefined`/`null` → `process.cwd()`. Does NOT
254
- * redirect the deriveRepo() fallback (see the module header's named limit).
272
+ * @param {string|null} [opts.repoRoot] - Repo whose identity is resolved: its
273
+ * `.vault.yaml` supplies the declared slug (#1389), and its git remote feeds
274
+ * the deriveRepo() fallback (#1503). `undefined`/`null` → `process.cwd()`.
255
275
  * @returns {string} A single kebab-slug path segment, e.g. 'session-orchestrator'.
256
276
  * Special returns:
257
277
  * - 'unknown-repo' — slug derivation produced an empty string.
@@ -284,10 +304,11 @@ export function resolveRepoNamespace({ vaultName = null, repoRoot = null } = {})
284
304
  // guard below deliberately — a declared slug is operator data flowing into a
285
305
  // written path and is checked by CP1/CP6/CP10 and the pseudonym map exactly
286
306
  // like any git-derived identifier.
287
- const declaredSlug = readVaultSlug(repoRoot ?? process.cwd());
307
+ const root = repoRoot ?? process.cwd();
308
+ const declaredSlug = readVaultSlug(root);
288
309
  const base = (vaultName && typeof vaultName === 'string' && vaultName.trim())
289
310
  ? vaultName.trim()
290
- : (declaredSlug ?? deriveRepo());
311
+ : (declaredSlug ?? deriveRepo({ repoRoot: root }));
291
312
 
292
313
  // Sanitise: collapse to last path segment, lowercase, strip non-[a-z0-9-].
293
314
  const seg = subjectToSlug(base);