session-orchestrator 5.2.0 → 5.3.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 (295) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -1
  2. package/.agents/skills/autopilot/SKILL.md +5 -1
  3. package/.agents/skills/autopilot/agents/openai.yaml +5 -0
  4. package/.agents/skills/bootstrap/SKILL.md +5 -1
  5. package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
  6. package/.agents/skills/brainstorm/SKILL.md +5 -1
  7. package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
  8. package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
  9. package/.agents/skills/close/SKILL.md +5 -1
  10. package/.agents/skills/close/agents/openai.yaml +5 -0
  11. package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
  12. package/.agents/skills/debug/SKILL.md +5 -1
  13. package/.agents/skills/debug/agents/openai.yaml +5 -0
  14. package/.agents/skills/discovery/SKILL.md +5 -1
  15. package/.agents/skills/discovery/agents/openai.yaml +5 -0
  16. package/.agents/skills/dispatcher/SKILL.md +5 -1
  17. package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
  18. package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
  19. package/.agents/skills/ecosystem-health/SKILL.md +3 -1
  20. package/.agents/skills/eli5/SKILL.md +5 -1
  21. package/.agents/skills/eli5/agents/openai.yaml +5 -0
  22. package/.agents/skills/eval/SKILL.md +6 -2
  23. package/.agents/skills/eval/agents/openai.yaml +5 -0
  24. package/.agents/skills/evolve/SKILL.md +6 -2
  25. package/.agents/skills/evolve/agents/openai.yaml +5 -0
  26. package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
  27. package/.agents/skills/gitlab-ops/SKILL.md +3 -1
  28. package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
  29. package/.agents/skills/go/SKILL.md +5 -1
  30. package/.agents/skills/go/agents/openai.yaml +5 -0
  31. package/.agents/skills/grill/SKILL.md +5 -1
  32. package/.agents/skills/grill/agents/openai.yaml +5 -0
  33. package/.agents/skills/harness-audit/SKILL.md +5 -1
  34. package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
  35. package/.agents/skills/hook-development/SKILL.md +3 -1
  36. package/.agents/skills/mcp-builder/SKILL.md +3 -1
  37. package/.agents/skills/memory-cleanup/SKILL.md +5 -1
  38. package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
  39. package/.agents/skills/mode-selector/SKILL.md +3 -1
  40. package/.agents/skills/npm-publish/SKILL.md +4 -2
  41. package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
  42. package/.agents/skills/persona-panel/SKILL.md +5 -1
  43. package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
  44. package/.agents/skills/plan/SKILL.md +5 -1
  45. package/.agents/skills/plan/agents/openai.yaml +5 -0
  46. package/.agents/skills/playwright-driver/SKILL.md +3 -1
  47. package/.agents/skills/portfolio/SKILL.md +5 -1
  48. package/.agents/skills/portfolio/agents/openai.yaml +5 -0
  49. package/.agents/skills/quality-gates/SKILL.md +3 -1
  50. package/.agents/skills/reconcile/SKILL.md +5 -1
  51. package/.agents/skills/reconcile/agents/openai.yaml +5 -0
  52. package/.agents/skills/release/SKILL.md +5 -1
  53. package/.agents/skills/release/agents/openai.yaml +5 -0
  54. package/.agents/skills/remote-offload/SKILL.md +3 -1
  55. package/.agents/skills/repo-audit/SKILL.md +5 -1
  56. package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
  57. package/.agents/skills/session/SKILL.md +21 -0
  58. package/.agents/skills/session/agents/openai.yaml +5 -0
  59. package/.agents/skills/session-end/SKILL.md +3 -1
  60. package/.agents/skills/session-plan/SKILL.md +3 -1
  61. package/.agents/skills/session-start/SKILL.md +3 -1
  62. package/.agents/skills/spinout/SKILL.md +5 -1
  63. package/.agents/skills/spinout/agents/openai.yaml +5 -0
  64. package/.agents/skills/sunset-review/SKILL.md +5 -1
  65. package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
  66. package/.agents/skills/templates-ack/SKILL.md +21 -0
  67. package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
  68. package/.agents/skills/test/SKILL.md +5 -1
  69. package/.agents/skills/test/agents/openai.yaml +5 -0
  70. package/.agents/skills/test-runner/SKILL.md +3 -1
  71. package/.agents/skills/tmux-layout/SKILL.md +3 -1
  72. package/.agents/skills/using-orchestrator/SKILL.md +3 -1
  73. package/.agents/skills/ux-grill/SKILL.md +5 -1
  74. package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
  75. package/.agents/skills/vault-mirror/SKILL.md +3 -1
  76. package/.agents/skills/vault-sync/SKILL.md +3 -1
  77. package/.agents/skills/wave-executor/SKILL.md +3 -1
  78. package/.agents/skills/write-executable-plan/SKILL.md +3 -1
  79. package/.claude-plugin/marketplace.json +1 -1
  80. package/.claude-plugin/plugin.json +1 -1
  81. package/.codex-plugin/plugin.json +4 -4
  82. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
  83. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  84. package/.codex-plugin/skills/evolve/SKILL.md +1 -1
  85. package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
  86. package/.codex-plugin/skills/session/SKILL.md +1 -1
  87. package/.cursor/commands/eval.md +1 -1
  88. package/.cursor/commands/session.md +1 -1
  89. package/.cursor/rules/000-session-orchestrator.mdc +0 -2
  90. package/.cursor/rules/050-plan.mdc +1 -1
  91. package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
  92. package/.cursor/skills/eval/SKILL.md +1 -1
  93. package/.cursor/skills/npm-publish/SKILL.md +1 -0
  94. package/.cursor-plugin/plugin.json +1 -1
  95. package/.orchestrator/policy/blocked-commands.json +12 -3
  96. package/AGENTS.md +3 -2
  97. package/CHANGELOG.md +136 -0
  98. package/README.md +9 -9
  99. package/SECURITY.md +12 -0
  100. package/agents/dialectic-deriver.md +13 -10
  101. package/agents/eval-judge.md +67 -45
  102. package/agents/skill-applied-judge.md +34 -19
  103. package/commands/session.md +7 -3
  104. package/docs/baseline.md +12 -6
  105. package/docs/codex-setup.md +14 -2
  106. package/docs/components.md +7 -5
  107. package/docs/events-schema.md +56 -9
  108. package/docs/rule-authoring.md +58 -6
  109. package/docs/session-config-reference.md +100 -7
  110. package/docs/session-config-template.md +31 -2
  111. package/docs/telemetry.md +2 -0
  112. package/hooks/_lib/hook-import-set.json +85 -8
  113. package/hooks/_lib/subagent-transcript.mjs +582 -31
  114. package/hooks/config-protection.mjs +11 -3
  115. package/hooks/cwd-change-restore.mjs +11 -3
  116. package/hooks/enforce-commands.mjs +70 -23
  117. package/hooks/enforce-scope.mjs +143 -33
  118. package/hooks/hooks-codex.json +1 -1
  119. package/hooks/hooks.json +1 -1
  120. package/hooks/loop-guard.mjs +11 -3
  121. package/hooks/on-session-end.mjs +58 -23
  122. package/hooks/on-session-start.mjs +48 -11
  123. package/hooks/on-stop.mjs +168 -22
  124. package/hooks/operator-steer.mjs +11 -3
  125. package/hooks/post-bash-issue-budget-refund.mjs +18 -8
  126. package/hooks/post-bash-write-verify.mjs +3 -2
  127. package/hooks/post-edit-import-probe.mjs +17 -9
  128. package/hooks/post-edit-validate.mjs +13 -5
  129. package/hooks/post-subagent-discovery-validator.mjs +98 -13
  130. package/hooks/post-tool-batch-wave-signal.mjs +200 -38
  131. package/hooks/post-tool-failure-corrective-context.mjs +11 -5
  132. package/hooks/post-tooluse-frontend-slop.mjs +10 -4
  133. package/hooks/pre-auq-clarity.mjs +15 -2
  134. package/hooks/pre-bash-destructive-guard.mjs +80 -9
  135. package/hooks/pre-bash-issue-budget.mjs +16 -11
  136. package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
  138. package/hooks/pre-bash-staging-fence.mjs +335 -31
  139. package/hooks/pre-bash-templates-first.mjs +19 -14
  140. package/hooks/pre-task-scope-disjoint.mjs +233 -2
  141. package/hooks/subagent-telemetry.mjs +15 -19
  142. package/hooks/wave-scope-commit-guard.mjs +197 -100
  143. package/monitors/monitors.json +1 -1
  144. package/output-styles/wave-summary.md +1 -1
  145. package/package.json +1 -1
  146. package/pi/prompts/eval.md +1 -1
  147. package/pi/prompts/session.md +1 -1
  148. package/rules/README.md +1 -1
  149. package/rules/opt-in-domain/prompt-caching.md +1 -1
  150. package/rules/opt-in-stack/backend-data.md +1 -1
  151. package/rules/opt-in-stack/backend.md +3 -3
  152. package/rules/opt-in-stack/frontend.md +1 -1
  153. package/rules/opt-in-stack/security-web.md +3 -3
  154. package/rules/opt-in-stack/swift.md +1 -1
  155. package/scripts/autopilot.mjs +23 -2
  156. package/scripts/backfill-abandoned-sessions.mjs +117 -15
  157. package/scripts/check-sessions-integrity.mjs +300 -0
  158. package/scripts/dialectic-deriver.mjs +50 -13
  159. package/scripts/emit-session.mjs +75 -29
  160. package/scripts/eval-session.mjs +65 -3
  161. package/scripts/generate-agents-skills.mjs +102 -29
  162. package/scripts/generate-cursor-adapter.mjs +61 -16
  163. package/scripts/lib/agent-status.mjs +2 -31
  164. package/scripts/lib/auq/clarity.mjs +10 -2
  165. package/scripts/lib/auq/parse.mjs +12 -31
  166. package/scripts/lib/auq/schema.mjs +56 -41
  167. package/scripts/lib/auto-dialectic.mjs +304 -15
  168. package/scripts/lib/autopilot/flags.mjs +12 -1
  169. package/scripts/lib/autopilot/kill-switches.mjs +6 -3
  170. package/scripts/lib/autopilot/loop.mjs +14 -1
  171. package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
  172. package/scripts/lib/ci-status-banner.mjs +376 -16
  173. package/scripts/lib/command-blocker.mjs +275 -28
  174. package/scripts/lib/config/dialectic.mjs +12 -3
  175. package/scripts/lib/config/gate.mjs +74 -0
  176. package/scripts/lib/config/reaper.mjs +162 -0
  177. package/scripts/lib/config.mjs +14 -0
  178. package/scripts/lib/convergence-monitor.mjs +74 -11
  179. package/scripts/lib/ecosystem-health.mjs +11 -0
  180. package/scripts/lib/eval/engine.mjs +421 -53
  181. package/scripts/lib/eval/judge.mjs +463 -40
  182. package/scripts/lib/eval/schema.mjs +10 -1
  183. package/scripts/lib/events-rotation.mjs +221 -25
  184. package/scripts/lib/events-schema.mjs +114 -0
  185. package/scripts/lib/events.mjs +524 -5
  186. package/scripts/lib/frontmatter-guard.mjs +21 -10
  187. package/scripts/lib/gates/gate-baseline.mjs +27 -2
  188. package/scripts/lib/gates/gate-full.mjs +28 -3
  189. package/scripts/lib/gates/gate-helpers.mjs +243 -21
  190. package/scripts/lib/gates/gate-incremental.mjs +28 -3
  191. package/scripts/lib/gates/gate-per-file.mjs +27 -2
  192. package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
  193. package/scripts/lib/instruction-budget-guard.mjs +146 -4
  194. package/scripts/lib/io.mjs +42 -8
  195. package/scripts/lib/issue-close-strip-labels.mjs +207 -49
  196. package/scripts/lib/js-mask.mjs +197 -0
  197. package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
  198. package/scripts/lib/maintenance-due-banner.mjs +53 -88
  199. package/scripts/lib/orphan-reaper.mjs +1588 -0
  200. package/scripts/lib/peer-cards/merger.mjs +48 -10
  201. package/scripts/lib/peer-cards/reader.mjs +78 -2
  202. package/scripts/lib/process-group.mjs +899 -0
  203. package/scripts/lib/quality-gate.mjs +107 -28
  204. package/scripts/lib/reconcile/backlog.mjs +368 -0
  205. package/scripts/lib/reconcile/engine.mjs +55 -188
  206. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
  207. package/scripts/lib/reconcile/sanitize.mjs +69 -3
  208. package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
  209. package/scripts/lib/resource-probe/parsers.mjs +31 -0
  210. package/scripts/lib/rule-loader.mjs +41 -12
  211. package/scripts/lib/scope-echo.mjs +39 -2
  212. package/scripts/lib/scope-gate.mjs +605 -1
  213. package/scripts/lib/session-close-backfill.mjs +33 -6
  214. package/scripts/lib/session-id.mjs +9 -20
  215. package/scripts/lib/session-invocation.mjs +20 -0
  216. package/scripts/lib/session-schema/constants.mjs +30 -2
  217. package/scripts/lib/session-schema/normalizer.mjs +56 -4
  218. package/scripts/lib/session-schema.mjs +8 -3
  219. package/scripts/lib/session-start-probes.mjs +95 -10
  220. package/scripts/lib/sessions-canonical.mjs +23 -0
  221. package/scripts/lib/sessions-integrity-banner.mjs +7 -1
  222. package/scripts/lib/sessions-staleness-banner.mjs +193 -51
  223. package/scripts/lib/skill-evidence-window.mjs +891 -0
  224. package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
  225. package/scripts/lib/skill-evolution/engine.mjs +18 -9
  226. package/scripts/lib/skill-judge.mjs +45 -3
  227. package/scripts/lib/tail-window.mjs +56 -0
  228. package/scripts/lib/telemetry/schema.mjs +30 -0
  229. package/scripts/lib/telemetry/sync.mjs +61 -6
  230. package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
  231. package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
  232. package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
  233. package/scripts/lib/user-invocable-skills.mjs +23 -3
  234. package/scripts/lib/ux-grill/reconcile.mjs +48 -22
  235. package/scripts/lib/validate/check-agents-skills.mjs +26 -15
  236. package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
  237. package/scripts/lib/validate/check-entry-guard.mjs +13 -50
  238. package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
  239. package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
  240. package/scripts/lib/validate/check-rules.mjs +7 -5
  241. package/scripts/lib/validate/check-skill-links.mjs +9 -1
  242. package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
  243. package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
  244. package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
  245. package/scripts/lib/validate/check-unwired-features.mjs +130 -27
  246. package/scripts/lib/validate/check-validator-registration.mjs +34 -10
  247. package/scripts/lib/validate/confidential-names.mjs +10 -0
  248. package/scripts/lib/validate-vendored-rules.mjs +4 -3
  249. package/scripts/lib/vault-mirror/namespace.mjs +46 -8
  250. package/scripts/lib/vault-mirror/process.mjs +10 -3
  251. package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
  252. package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
  253. package/scripts/lib/vault-yaml.mjs +118 -0
  254. package/scripts/lib/worktree/lifecycle.mjs +153 -1
  255. package/scripts/release-session-lock.mjs +305 -0
  256. package/scripts/release.mjs +30 -5
  257. package/scripts/resolve-session-invocation.mjs +59 -0
  258. package/scripts/run-quality-gate.mjs +156 -17
  259. package/scripts/sweep-expired-rules.mjs +14 -3
  260. package/scripts/validate-plugin.mjs +12 -0
  261. package/scripts/validate-wave-scope.mjs +32 -105
  262. package/scripts/vault-mirror.mjs +9 -1
  263. package/skills/_shared/platform-tools.md +23 -11
  264. package/skills/autopilot/SKILL.md +22 -7
  265. package/skills/claude-md-drift-check/SKILL.md +1 -1
  266. package/skills/convergence-monitoring/README.md +8 -1
  267. package/skills/convergence-monitoring/SIGNALS.md +50 -6
  268. package/skills/convergence-monitoring/SKILL.md +15 -6
  269. package/skills/eval/SKILL.md +39 -24
  270. package/skills/eval/rubric-v1.md +1 -0
  271. package/skills/eval/rubric-v2.md +457 -0
  272. package/skills/evolve/SKILL.md +1 -1
  273. package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
  274. package/skills/gitlab-ops/SKILL.md +3 -2
  275. package/skills/npm-publish/SKILL.md +1 -1
  276. package/skills/reconcile/SKILL.md +11 -0
  277. package/skills/session-end/SKILL.md +13 -16
  278. package/skills/session-end/discovery-scan.md +1 -1
  279. package/skills/session-end/phase-3-6-tail.md +55 -9
  280. package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
  281. package/skills/session-end/session-metrics-write.md +10 -0
  282. package/skills/session-plan/SKILL.md +17 -5
  283. package/skills/session-plan/references/session-plan-task-classification.md +2 -2
  284. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
  285. package/skills/ux-grill/SKILL.md +1 -1
  286. package/skills/wave-executor/SKILL.md +8 -4
  287. package/skills/wave-executor/circuit-breaker.md +2 -0
  288. package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
  289. package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
  290. package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
  291. package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
  292. package/.cursor/commands/convergence-monitoring.md +0 -13
  293. package/.cursor/commands/npm-publish.md +0 -13
  294. package/pi/prompts/convergence-monitoring.md +0 -11
  295. package/pi/prompts/npm-publish.md +0 -11
@@ -53,6 +53,30 @@
53
53
  * a rule that only cites live instances loses its explanation the moment it works.
54
54
  * If you need a CURRENT S2 hit, run the checker; do not assume this one.
55
55
  *
56
+ * S6 `parser-only-config-key` — the key IS read, but the ONLY file that
57
+ * reads it is its own block parser
58
+ * `scripts/lib/config/<root>.mjs`.
59
+ *
60
+ * S6 is the mirror image of S2, and it exists because S1 counts the PARSER as a
61
+ * consumer: `CONSUMER_DIRS` is `scripts/` + `hooks/`, and every block parser
62
+ * lives under `scripts/lib/config/`. A key its parser resolves and nothing else
63
+ * reads is `compact-nudge` wearing a value object. Measured 2026-09-22 on this
64
+ * tree: `reaper.max-hook-latency-ms` and `reaper.false-alarm-window` both passed
65
+ * S1 on `scripts/lib/config/reaper.mjs` alone while `scripts/lib/orphan-reaper.mjs`
66
+ * used same-named HARDCODED defaults and never saw the parsed value.
67
+ *
68
+ * Why the signal is this narrow — one read file, and it is the key's OWN parser.
69
+ * The blunt rule ("the parser layer never counts") was measured first and
70
+ * REJECTED: it flips 102 of 263 declared keys to unwired, because the ordinary
71
+ * shape here is a consumer that reads the camelCase FIELD (`cfg.driftCheck.mode`)
72
+ * while this census matches the kebab TOKEN. 102 of 263 is the broken instrument
73
+ * `.claude/rules/host-resources.md` HR-101 forbids. The narrow rule reports 32 —
74
+ * a backlog, printed as an aggregate like S4 and enumerated under `--list`.
75
+ *
76
+ * S6 carries no camelCase fallback ON PURPOSE: adding one re-hides both reaper
77
+ * keys, whose camelCase names exist in `orphan-reaper.mjs` as that module's own
78
+ * defaults. A name is not a read.
79
+ *
56
80
  * S2 applies to TOP-LEVEL keys only — a nested key reaches code through its
57
81
  * parent — and its premise is structural: every Session Config key has to pass
58
82
  * through the parser layer to become a value. Measured 2026-08-08: 84 of 89
@@ -393,6 +417,29 @@ const PARSER_PATHS = Object.freeze([
393
417
  path.join('scripts', 'parse-config.mjs'),
394
418
  ]);
395
419
 
420
+ /**
421
+ * The per-block parser directory: `scripts/lib/config/<block>.mjs` turns ONE
422
+ * Session Config block's YAML into a value object and does nothing else.
423
+ *
424
+ * Signal S6 below is about this directory specifically, which is why it is not
425
+ * folded into {@link PARSER_PATHS}.
426
+ */
427
+ const BLOCK_PARSER_DIR = path.join('scripts', 'lib', 'config');
428
+
429
+ /**
430
+ * The key's OWN block parser, by convention `scripts/lib/config/<root>.mjs`.
431
+ *
432
+ * Deliberately an exact name match and not "anything under the parser dir": a
433
+ * sibling block's parser reading the key is still a second file, and S6 only
434
+ * fires when there is exactly ONE reader and it is the key's own.
435
+ *
436
+ * @param {string} root top-level key of the block
437
+ * @returns {string} plugin-root-relative path
438
+ */
439
+ function ownBlockParserPath(root) {
440
+ return path.join(BLOCK_PARSER_DIR, `${root}.mjs`);
441
+ }
442
+
396
443
  /**
397
444
  * Declared-but-unread keys accepted on purpose. Key = full dotted path (S1/S2)
398
445
  * or module path relative to the plugin root (S4), value = REASON naming the
@@ -420,7 +467,8 @@ const ALLOWLIST = Object.freeze({
420
467
 
421
468
  /**
422
469
  * @typedef {{
423
- * kind: 'unwired-config-key' | 'parser-orphan-config-key' | 'allowlist-missing-reason'
470
+ * kind: 'unwired-config-key' | 'parser-orphan-config-key' | 'parser-only-config-key'
471
+ * | 'allowlist-missing-reason'
424
472
  * | 'allowlist-stale' | 'orphaned-prose-module' | 'unreachable-library-module'
425
473
  * | 'coordinator-invoked-module' | 'hand-keyed-learning-subject'
426
474
  * | 'tool-error',
@@ -883,7 +931,9 @@ function mentionedModuleTokens(lines) {
883
931
  * switched off. Revisit if a real module-resolver (import-specifier resolution
884
932
  * relative to the importing file) becomes cheap, or if a collided basename is
885
933
  * ever confirmed to mask a true positive. The `coordinator-invoked-module`
886
- * DOWNGRADE is exempt, and since #1293 so is the CLUSTER-ROOT filter: in both
934
+ * DOWNGRADE is exempt, and since #1293 so is the CLUSTER-ROOT filter (since
935
+ * #1298 its predicate also drives the "drags N" count, which had collapsed
936
+ * two dragged `index.mjs` into one): in both
887
937
  * a colliding basename must be named with its `dirname/base` suffix, because
888
938
  * those matches move a module OUT of the reportable class and would otherwise
889
939
  * hide a true unreachable sibling. Measured cost of leaving the root filter
@@ -1014,24 +1064,41 @@ export function collectUnreachableLibraryModules(pluginRoot) {
1014
1064
  const basenameCount = new Map();
1015
1065
  for (const module of modules) basenameCount.set(module.base, (basenameCount.get(module.base) ?? 0) + 1);
1016
1066
 
1017
- const roots = unreachable.filter((module) => {
1018
- // A bare-basename mention only suppresses when the basename is UNIQUE
1019
- // (see the census above). When it collides, the mentioning module must name
1020
- // the `dirname/base` form in its body — otherwise ONE unreachable module
1021
- // mentioning bare `index.mjs` masks EVERY differently-pathed `index.mjs`
1022
- // root at once (#1293: `locks/index.mjs` + `worktree/index.mjs` were masked
1023
- // by a third unreachable module until that module was deleted for an
1024
- // unrelated reason).
1025
- const ambiguous = (basenameCount.get(module.base) ?? 0) > 1;
1026
- const qualified = module.relative.split(path.sep).slice(-2).join('/');
1027
- const qualifiedRe = ambiguous ? tokenMatcher(qualified) : null;
1028
- return !unreachable.some(
1029
- (other) =>
1030
- other.relative !== module.relative &&
1031
- other.mentions.has(module.base) &&
1032
- (qualifiedRe === null || qualifiedRe.test(other.rawBody)),
1033
- );
1034
- });
1067
+ // One `dirname/base` matcher per unreachable module whose basename collides,
1068
+ // built once: `references` below runs per (mentioner, target) pair.
1069
+ /** @type {Map<string, RegExp>} */
1070
+ const qualifiedMatchers = new Map();
1071
+ for (const module of unreachable) {
1072
+ if ((basenameCount.get(module.base) ?? 0) < 2) continue;
1073
+ qualifiedMatchers.set(module.relative, tokenMatcher(module.relative.split(path.sep).slice(-2).join('/')));
1074
+ }
1075
+ /**
1076
+ * Does `mentioner` reference the unreachable module `target`? A bare-basename
1077
+ * mention only counts when the basename is UNIQUE (see the census above).
1078
+ * When it collides, the mentioning module must name the `dirname/base` form
1079
+ * in its body — otherwise ONE unreachable module mentioning bare `index.mjs`
1080
+ * references EVERY differently-pathed `index.mjs` at once (#1293:
1081
+ * `locks/index.mjs` + `worktree/index.mjs` were masked as roots by a third
1082
+ * unreachable module until that module was deleted for an unrelated reason).
1083
+ *
1084
+ * The ONE predicate for both consumers — the cluster-root filter and the drag
1085
+ * count of the finding message — so the two cannot disagree about who
1086
+ * references whom. They did (#1298): the drag count compared bare basenames,
1087
+ * so a root importing `a/index.mjs` AND `b/index.mjs` read "drags 1".
1088
+ *
1089
+ * @param {(typeof modules)[number]} mentioner
1090
+ * @param {(typeof modules)[number]} target an `unreachable` module
1091
+ * @returns {boolean}
1092
+ */
1093
+ const references = (mentioner, target) => {
1094
+ if (!mentioner.mentions.has(target.base)) return false;
1095
+ const qualifiedRe = qualifiedMatchers.get(target.relative);
1096
+ return qualifiedRe === undefined || qualifiedRe.test(mentioner.rawBody);
1097
+ };
1098
+
1099
+ const roots = unreachable.filter(
1100
+ (module) => !unreachable.some((other) => other.relative !== module.relative && references(other, module)),
1101
+ );
1035
1102
 
1036
1103
  // A pure `export *` barrel enters the population above so it can HEAD its own
1037
1104
  // cluster, but reporting it needs one more condition. S3 already exempts this
@@ -1106,9 +1173,10 @@ export function collectUnreachableLibraryModules(pluginRoot) {
1106
1173
  'architecture. Re-check only if that instruction is ever removed',
1107
1174
  });
1108
1175
  }
1109
- const dragged = [...module.mentions].filter(
1110
- (token) => token !== module.base && [...unreachableSet].some((rel) => path.basename(rel) === token),
1111
- );
1176
+ // Counted per MODULE through the root filter's own predicate, never per
1177
+ // basename token: `mentions` is a Set of basenames, so two dragged
1178
+ // `index.mjs` files would collapse into one (#1298).
1179
+ const dragged = unreachable.filter((other) => other.relative !== module.relative && references(module, other));
1112
1180
  const tail = dragged.length > 0 ? `, and drags ${dragged.length} further unreachable module(s)` : '';
1113
1181
  // A pure barrel reports its re-export surface; naming "0 symbol(s)" there
1114
1182
  // would read as a checker bug rather than as the barrel it is.
@@ -1259,6 +1327,7 @@ export function inspectUnwiredFeatures(pluginRoot) {
1259
1327
  orphanedModules: 0,
1260
1328
  unreachableModules: 0,
1261
1329
  coordinatorInvokedModules: 0,
1330
+ parserOnly: 0,
1262
1331
  handKeyedSubjects: 0,
1263
1332
  judgedSubjects: 0,
1264
1333
  },
@@ -1318,7 +1387,7 @@ export function inspectUnwiredFeatures(pluginRoot) {
1318
1387
 
1319
1388
  for (const key of [...declared.keys.keys()].sort()) {
1320
1389
  const meta = /** @type {DeclaredKey} */ (declared.keys.get(key));
1321
- const { code, comment } = countReadSites(meta, corpus);
1390
+ const { code, comment, files: readFiles } = countReadSites(meta, corpus);
1322
1391
 
1323
1392
  /** @type {Finding | null} */
1324
1393
  let issue = null;
@@ -1331,6 +1400,21 @@ export function inspectUnwiredFeatures(pluginRoot) {
1331
1400
  `declared in ${meta.sources.join(' + ')} but no .mjs under ${CONSUMER_DIRS.join('/ or ')}/ ` +
1332
1401
  `reads it${commentNote} — wire it, delete it, or allowlist it with a reason`,
1333
1402
  };
1403
+ } else if (readFiles.length === 1 && readFiles[0] === ownBlockParserPath(meta.root)) {
1404
+ // S6 — the ONLY file that reads this key is the parser that produced it.
1405
+ // A parser is not a consumer: it turns YAML into a field nobody then
1406
+ // reads, which is `compact-nudge` wearing a value object. Measured
1407
+ // 2026-09-22: `reaper.max-hook-latency-ms` and `reaper.false-alarm-window`
1408
+ // both passed S1 on the strength of `scripts/lib/config/reaper.mjs`
1409
+ // alone, while `orphan-reaper.mjs` used its OWN hardcoded defaults of the
1410
+ // same name and never received the parsed value.
1411
+ issue = {
1412
+ kind: 'parser-only-config-key',
1413
+ key,
1414
+ message:
1415
+ `read only by its own block parser (${readFiles[0]}) — a parser is not a consumer; ` +
1416
+ 'nothing outside the config layer reads the parsed value',
1417
+ };
1334
1418
  } else if (key === meta.root && !tokenMatcher(key).test(parserBody)) {
1335
1419
  issue = {
1336
1420
  kind: 'parser-orphan-config-key',
@@ -1356,7 +1440,8 @@ export function inspectUnwiredFeatures(pluginRoot) {
1356
1440
  continue;
1357
1441
  }
1358
1442
 
1359
- result.summary.unwired += 1;
1443
+ if (issue.kind === 'parser-only-config-key') result.summary.parserOnly += 1;
1444
+ else result.summary.unwired += 1;
1360
1445
  findings.push(issue);
1361
1446
  }
1362
1447
 
@@ -1426,6 +1511,7 @@ export function runCheckUnwiredFeatures(pluginRoot, { list = false } = {}) {
1426
1511
  consumerFiles,
1427
1512
  unwired,
1428
1513
  allowlisted,
1514
+ parserOnly,
1429
1515
  orphanedModules,
1430
1516
  unreachableModules,
1431
1517
  coordinatorInvokedModules,
@@ -1445,8 +1531,16 @@ export function runCheckUnwiredFeatures(pluginRoot, { list = false } = {}) {
1445
1531
  // architecture — an instruction surface tells an LLM to call the module.
1446
1532
  // Measured 2026-09-07: 46 of the 52 findings the single S4 class carried.
1447
1533
  // Printing 46 WARN lines for the design is the broken instrument HR-101 forbids.
1448
- const DEFERRED = Object.freeze(['unreachable-library-module', 'coordinator-invoked-module']);
1534
+ //
1535
+ // `parser-only-config-key` (S6) is deferred on the S4 terms too: 32 findings
1536
+ // on the live tree is a backlog to work down, not a per-run alarm.
1537
+ const DEFERRED = Object.freeze([
1538
+ 'unreachable-library-module',
1539
+ 'coordinator-invoked-module',
1540
+ 'parser-only-config-key',
1541
+ ]);
1449
1542
  const s4 = inspection.findings.filter((item) => item.kind === 'unreachable-library-module');
1543
+ const s6 = inspection.findings.filter((item) => item.kind === 'parser-only-config-key');
1450
1544
  for (const item of inspection.findings) {
1451
1545
  if (!list && DEFERRED.includes(item.kind)) continue;
1452
1546
  console.log(` WARN: [${item.kind}] ${item.key} — ${item.message}`);
@@ -1465,10 +1559,19 @@ export function runCheckUnwiredFeatures(pluginRoot, { list = false } = {}) {
1465
1559
  );
1466
1560
  }
1467
1561
 
1562
+ if (!list && s6.length > 0) {
1563
+ console.log(
1564
+ ` WARN: [parser-only-config-key] ${s6.length} declared key(s) whose only reader is their own ` +
1565
+ `block parser — e.g. ${s6.slice(0, 3).map((item) => item.key).join(', ')}. ` +
1566
+ 'Re-run with --list for the full census.',
1567
+ );
1568
+ }
1569
+
1468
1570
  console.log(
1469
1571
  ` PASS: censused ${declaredKeys} declared key(s) from ${inspection.sourcesScanned.join(' + ') || '(no source)'} ` +
1470
1572
  `against ${consumerFiles} consumer file(s) — ${unwired} unwired, ${allowlisted} allowlisted, ` +
1471
- `${orphanedModules} prose-orphaned module(s), ${unreachableModules} unreachable module(s), ` +
1573
+ `${parserOnly} parser-only, ${orphanedModules} prose-orphaned module(s), ` +
1574
+ `${unreachableModules} unreachable module(s), ` +
1472
1575
  `${coordinatorInvokedModules} coordinator-invoked module(s), ${handKeyedSubjects} hand-keyed ` +
1473
1576
  `learning subject(s) of ${judgedSubjects} judged`,
1474
1577
  );
@@ -51,15 +51,21 @@
51
51
  * skill body) and nowhere in the three RUN surfaces above still reports
52
52
  * UNREGISTERED — being documented is not being run. REVISIT if a fourth run
53
53
  * surface (a new CI job file, a different git hook) is ever added: extend
54
- * `RUN_SURFACES`, do not special-case it here. The comment stripper's own
55
- * quote-tracking is a single flat state — an escaped quote (`\"`) inside a
56
- * double-quoted string is not honoured, and a template-literal's `${...}`
57
- * interpolation is not walked separately. Both failure directions lean
58
- * toward treating MORE text as "inside a string" than a real parser would,
59
- * which can only make the stripper MISS a comment (false "still
60
- * registered"), never manufacture a false UNREGISTERED the direction this
61
- * checker's own false-positive history (the paragraph above) already
62
- * measured as the live hazard.
54
+ * `RUN_SURFACES`, do not special-case it here.
55
+ *
56
+ * THE "NEVER A FALSE UNREGISTERED" CLAIM WAS FALSE (#1388, measured
57
+ * 2026-09-18 @ 20a4cbff). This header used to argue that the hand-rolled
58
+ * stripper could only ever MISS a comment, never manufacture a false
59
+ * UNREGISTERED. It had no regex-literal branch, so
60
+ * `const re = /\/*$/;` in `scripts/validate-plugin.mjs` opened a BLOCK
61
+ * COMMENT at the regex's `/*` and swallowed every `runCheck(...)` line below
62
+ * it precisely a false UNREGISTERED, for every checker registered after
63
+ * that point. The `.mjs` surface is therefore lexed by the shared
64
+ * `maskSource` (`scripts/lib/js-mask.mjs`), which recognises regex literals;
65
+ * only the `#`-comment shell/YAML surfaces (`.husky/pre-commit`,
66
+ * `.gitlab-ci.yml`) still use the small local walker, whose flat
67
+ * quote-tracking ceiling (no `\"` escape handling, no `${...}` walk) is
68
+ * real but bounded to those two files.
63
69
  *
64
70
  * Usage: check-validator-registration.mjs <repo-root>
65
71
  * Output: ` PASS: …` / ` FAIL: …` lines (two leading spaces), then
@@ -74,6 +80,7 @@ import { existsSync, readFileSync } from 'node:fs';
74
80
  import path from 'node:path';
75
81
  import { enumerateRepoFiles } from './enumerate-repo-files.mjs';
76
82
  import { isMainModule } from '../is-main-module.mjs';
83
+ import { maskSource } from '../js-mask.mjs';
77
84
 
78
85
  /** Marker line inside a checker's own header — declares deliberate CLI-only status. */
79
86
  export const STANDALONE_MARKER = /^\s*\/\/\s*registration:\s*standalone\b(?:\s+(.*))?$/m;
@@ -113,11 +120,28 @@ function commentStyleForSurface(rel) {
113
120
  * truncating the line early. See the header NAMED CEILING for what this
114
121
  * quote-tracking deliberately does not attempt.
115
122
  *
123
+ * The JS style (`//` + `/* *\/`) is delegated to the shared lexer
124
+ * {@link maskSource} in `keepLiterals` mode — it is the only one of the three
125
+ * RUN_SURFACES that can contain a REGEX LITERAL, and a stripper without a
126
+ * regex branch reads `/\/*$/` as a block-comment opener and swallows every
127
+ * registration below it (#1388, see the header). `maskSource` blanks comment
128
+ * bytes to SPACES rather than deleting them, which is strictly safer for the
129
+ * substring match below: deletion could splice two fragments into a basename
130
+ * that was never written. The `#` style stays local — `maskSource` models
131
+ * JavaScript, not sh/YAML.
132
+ *
116
133
  * @param {string} text
117
134
  * @param {{lineComment: string, blockComment: boolean, quoteChars: string[]}} style
118
- * @returns {string}
135
+ * @returns {string} TWO comment models behind one signature, and they differ in
136
+ * shape: the JS style (`//` + `/* *\/`, delegated to {@link maskSource})
137
+ * returns a string of the SAME LENGTH as `text`, with comment bytes blanked to
138
+ * spaces; the `#` style (sh/YAML, the local walker) returns a SHORTER string,
139
+ * with the comment text deleted to end-of-line (the newline itself is kept).
140
+ * Only the substring match below is common to both — do not rely on offsets
141
+ * surviving the `#` style.
119
142
  */
120
143
  export function stripComments(text, { lineComment, blockComment, quoteChars }) {
144
+ if (lineComment === '//' && blockComment) return maskSource(text, { keepLiterals: true });
121
145
  let out = '';
122
146
  let i = 0;
123
147
  let inQuote = null;
@@ -177,6 +177,16 @@ export function inspectConfidentialNames({ namesPath, deps = {} } = {}) {
177
177
  // Unconfigured → no list, no noise. This is the normal case for public repos
178
178
  // and for any host that has not opted into confidential-name scanning.
179
179
  if (typeof namesPath !== 'string' || namesPath.trim() === '') {
180
+ // UNREACHABLE from the one production caller today (#1273 P1):
181
+ // `check-owner-leakage.mjs` resolves `namesPath` via `resolveHostPath()`
182
+ // and returns its own early `disabledReason` on an empty/non-string path
183
+ // BEFORE ever calling `inspectConfidentialNames` — so in production this
184
+ // branch never runs. It is reachable only for a direct caller (or a test)
185
+ // that skips that pre-filter. Kept regardless: `status` is part of the
186
+ // documented public contract every deep importer may switch on, and
187
+ // 'unconfigured' is the correct answer for exactly this input shape.
188
+ // REVISIT TRIGGER (BV-004): the production caller stops pre-filtering an
189
+ // empty/non-string `namesPath` before calling `inspectConfidentialNames`.
180
190
  return { status: 'unconfigured', names: [] };
181
191
  }
182
192
 
@@ -617,8 +617,9 @@ export function validateRuleContent({ content, relPath, targetRoot = null, requi
617
617
  }
618
618
 
619
619
  /**
620
- * Validates every `*.md` rule file under `dir`, recursively (skips dotfiles
621
- * and `_index.md`).
620
+ * Validates every `*.md` rule file under `dir`, recursively (skips dotfiles,
621
+ * `_index.md` and `README.md` — neither is a vendored rule: `rules-sync.mjs`
622
+ * vendors only the entries listed in `_index.md`, #1164).
622
623
  *
623
624
  * When `pluginRoot` is given, every scanned file is additionally passed through
624
625
  * `scanVendoringLeaks()` and the findings are collected into the additive
@@ -642,7 +643,7 @@ export function validateRulesDir({ dir, targetRoot = null, requireProvenance = f
642
643
  const absPath = join(absDir, entry.name);
643
644
  if (entry.isDirectory()) {
644
645
  files.push(...collectRuleFiles(absPath));
645
- } else if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== '_index.md') {
646
+ } else if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== '_index.md' && entry.name !== 'README.md') {
646
647
  files.push(relative(dir, absPath).replace(/\\/g, '/'));
647
648
  }
648
649
  }
@@ -5,10 +5,18 @@
5
5
  * vault writes under `40-learnings/<repoNs>/` and `50-sessions/<repoNs>/`.
6
6
  *
7
7
  * Contract:
8
- * resolveRepoNamespace({ vaultName?, cwd? }) → string
8
+ * resolveRepoNamespace({ vaultName?, repoRoot? }) → string
9
9
  *
10
- * - Pure + deterministic (given the same git remote / cwd / vaultName input,
11
- * and the same host-local pseudonym map).
10
+ * - Pure + deterministic (given the same vaultName, the same `.vault.yaml`
11
+ * under `repoRoot`, the same git remote, and the same host-local pseudonym
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.
12
20
  * - Returns a lowercase kebab slug safe for use as a filesystem path segment.
13
21
  * - Host-local pseudonym mapping (Epic #725 D5): consulted ONLY at the redaction
14
22
  * site (only when a segment is owner-leaky). If such a repo (raw or sanitised)
@@ -47,6 +55,7 @@ import { isOwnerLeakySegment } from '../../lib/validate/check-owner-leakage.mjs'
47
55
  import { loadPseudonymMap } from './pseudonym-map.mjs';
48
56
  import { loadHostPaths, resolveHostPath } from '../config/host-paths.mjs';
49
57
  import { isQueryFailure, resolvePreferredRemote } from '../vcs-repo-spec.mjs';
58
+ import { readVaultSlug } from '../vault-yaml.mjs';
50
59
 
51
60
  // ── Lazy pseudonym-map path resolution (Epic #725 D5) ────────────────────────
52
61
  // The map path comes from env SO_NAMESPACE_MAP > owner.yaml paths.namespace-map-path
@@ -237,19 +246,48 @@ function lookupPseudonym(base, seg) {
237
246
  *
238
247
  * @param {object} [opts]
239
248
  * @param {string|null} [opts.vaultName] - Optional override for the repo identifier.
240
- * When non-empty and non-whitespace, used in place of the git-derived repo name.
241
- * When absent, the namespace is derived from the git origin via deriveRepo().
249
+ * When non-empty and non-whitespace, used in place of the declared/git-derived
250
+ * repo name. When absent, the base is the repo's declared `.vault.yaml`
251
+ * `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).
242
255
  * @returns {string} A single kebab-slug path segment, e.g. 'session-orchestrator'.
243
256
  * Special returns:
244
257
  * - 'unknown-repo' — slug derivation produced an empty string.
245
258
  * - 'redacted-repo' — the raw or slugified value matched an owner-leakage pattern
246
259
  * (CP1 personal home path / CP6 private slug / CP10 personal name in Projects path).
247
260
  */
248
- export function resolveRepoNamespace({ vaultName = null } = {}) {
249
- // Choose the base identifier: explicit override first, then git-derived.
261
+ export function resolveRepoNamespace({ vaultName = null, repoRoot = null } = {}) {
262
+ // Choose the base identifier. Precedence: explicit `vaultName` override >
263
+ // the repo's declared `.vault.yaml` `metadata.slug` (#1131) > git-derived.
264
+ //
265
+ // WHY the slug sits HERE and not in deriveRepo(): deriveRepo() is the RAW
266
+ // repo IDENTITY (`org/repo`) and has consumers that want exactly that and
267
+ // nothing else — `vault-relocation-rules.mjs`, `relocate-vault-corpus.mjs`,
268
+ // `named-vault-resolver.mjs` (which documents the `org/repo-name` format).
269
+ // #1131 concerns the vault FOLDER namespace, which is what THIS function
270
+ // decides, so the lookup belongs at this candidate-selection point.
271
+ //
272
+ // Note what this placement does NOT buy: the `source-repo:` frontmatter is
273
+ // this function's output, not deriveRepo()'s (#732; process.mjs deliberately
274
+ // shares ONE value between the write path and the rendered field), so a repo
275
+ // whose declared slug differs from its derived name gets a new `source-repo`
276
+ // either way. That is inherent to moving the folder, not a side effect of
277
+ // where the lookup lives.
278
+ //
279
+ // It sits BELOW `vaultName` (an explicit per-call/operator override must stay
280
+ // honourable) and ABOVE derivation (a declared registration beats a guess from
281
+ // the remote URL or the checkout directory: the remote answers "where does
282
+ // this code live?", the slug answers "which vault project IS this?", and a
283
+ // host-side rename legitimately diverges the two). It sits ABOVE the leak
284
+ // guard below deliberately — a declared slug is operator data flowing into a
285
+ // written path and is checked by CP1/CP6/CP10 and the pseudonym map exactly
286
+ // like any git-derived identifier.
287
+ const declaredSlug = readVaultSlug(repoRoot ?? process.cwd());
250
288
  const base = (vaultName && typeof vaultName === 'string' && vaultName.trim())
251
289
  ? vaultName.trim()
252
- : deriveRepo();
290
+ : (declaredSlug ?? deriveRepo());
253
291
 
254
292
  // Sanitise: collapse to last path segment, lowercase, strip non-[a-z0-9-].
255
293
  const seg = subjectToSlug(base);
@@ -666,8 +666,12 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
666
666
  });
667
667
  }
668
668
 
669
- // #660: namespace new writes under a per-repo subdirectory.
670
- const repoNs = resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null });
669
+ // #660: namespace new writes under a per-repo subdirectory. #1389: `ctx.repoRoot`
670
+ // is optional — absent, resolveRepoNamespace falls back to process.cwd().
671
+ const repoNs = resolveRepoNamespace({
672
+ vaultName: ctx?.vaultName ?? null,
673
+ repoRoot: ctx?.repoRoot ?? null,
674
+ });
671
675
  // #725 D2: thread the resolved repo namespace into the learning frontmatter as
672
676
  // `source-repo` for cross-repo attribution. repoNs is already sanitised +
673
677
  // leak-guarded by resolveRepoNamespace, so it is safe to interpolate as-is. The
@@ -898,7 +902,10 @@ export async function processSession(rawEntry, _lineNum, ctx) {
898
902
  // owner-leaky repo's real name reached the vault through the session-note
899
903
  // frontmatter even though the directory AND the learning `source-repo` field
900
904
  // were already pseudonym-mapped/redacted (#732 leak-guard bypass).
901
- const repoNs = resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null });
905
+ const repoNs = resolveRepoNamespace({
906
+ vaultName: ctx?.vaultName ?? null,
907
+ repoRoot: ctx?.repoRoot ?? null,
908
+ });
902
909
 
903
910
  // Quality gate (PRD F1.2): skip sessions whose rendered narrative is too short.
904
911
  // Measure on the rendered markdown body so the check is schema-agnostic across
@@ -476,14 +476,24 @@ export function generateSessionNote(entry, options = {}) {
476
476
  // #1276: lifecycle-only records omit every older count alias. Started
477
477
  // measures participation; completed/planned-only counts retain their
478
478
  // labels so a plan or a completion count never claims dispatch coverage.
479
- const completedAgents = waveCount(w.agent_count_completed);
480
- const plannedAgents = waveCount(w.agent_count_planned);
479
+ // The `agents_*` triple is the same lifecycle measurement under the
480
+ // de-underscored producer spelling: `metrics-collection.md` documents
481
+ // `agent_count_{planned,started,completed}`, but the live ledger carries
482
+ // one session (`main-2026-09-18-session-1`, 5 waves, measured 2026-09-18
483
+ // over `.orchestrator/metrics/sessions.jsonl`) whose waves emit only
484
+ // `agents_planned` / `agents_started` / `agents_completed` — every one of
485
+ // its 15 counts rendered `?` beside a real number, exactly the #1074 bug
486
+ // class. Each alias sits at the precedence of its documented twin, so no
487
+ // cell that already rendered a value can change.
488
+ const completedAgents = waveCount(w.agent_count_completed) ?? waveCount(w.agents_completed);
489
+ const plannedAgents = waveCount(w.agent_count_planned) ?? waveCount(w.agents_planned);
481
490
  const agentsCell =
482
491
  waveCount(w.agent_count) ??
483
492
  waveCount(w.agents) ??
484
493
  waveCount(w.agents_dispatched) ??
485
494
  waveCount(w.dispatched) ??
486
495
  waveCount(w.agent_count_started) ??
496
+ waveCount(w.agents_started) ??
487
497
  (completedAgents === undefined ? undefined : `${completedAgents} completed`) ??
488
498
  (plannedAgents === undefined ? MISSING_CELL : `${plannedAgents} planned`);
489
499
  const filesCell = waveCount(w.files_changed) ?? waveCount(w.files) ?? MISSING_CELL;
@@ -43,6 +43,7 @@ import { readConfigFile, parseSessionConfig } from '../config.mjs';
43
43
  import { validatePathInsideProject } from '../path-utils.mjs';
44
44
  import { createSecretValueMasker } from '../secret-masker.mjs';
45
45
  import { expandTilde } from '../common.mjs';
46
+ import { readVaultSlug } from '../vault-yaml.mjs';
46
47
 
47
48
  /** Frontmatter sentinel that identifies generator-owned narrative files. */
48
49
  export const GENERATOR_MARKER = 'session-orchestrator-vault-status-narrative@1';
@@ -869,15 +870,29 @@ async function runNarrativeMirror(opts) {
869
870
 
870
871
  // Defense-in-depth: when the caller omits (or passes an empty) `repo`, derive
871
872
  // it from the operator-configured `vault-name` override (#660/#832) when set,
872
- // else the repoRoot basename never silently mis-file under 'unknown' (#675
873
- // review). Precedence: explicit `repo` opt > `vault-name` > basename.
873
+ // else the `.vault.yaml` slug (#1131), else the repoRoot basename never
874
+ // silently mis-file under 'unknown' (#675 review).
874
875
  const vaultNameOverride =
875
876
  typeof vaultIntegration['vault-name'] === 'string' && vaultIntegration['vault-name'].trim()
876
877
  ? vaultIntegration['vault-name'].trim()
877
878
  : null;
878
- const repoName = (typeof repo === 'string' && repo.trim().length > 0)
879
+ const explicitName = (typeof repo === 'string' && repo.trim().length > 0)
879
880
  ? repo
880
- : vaultNameOverride ?? path.basename(path.resolve(repoRoot));
881
+ : vaultNameOverride;
882
+
883
+ // #1131: with NO explicit override, the repo's own `.vault.yaml`
884
+ // `metadata.slug` — the CANONICAL vault registration — outranks the
885
+ // directory basename. Precedence: explicit `repo` opt > `vault-name` >
886
+ // `.vault.yaml` slug > basename.
887
+ //
888
+ // WHY the two overrides still win: both are things a CALLER/operator states
889
+ // for THIS call (`repo` is only ever passed to override the derived name —
890
+ // the sole production caller, `skills/session-end/session-metrics-write.md`,
891
+ // passes `repoRoot` alone), so honouring the file over them would make an
892
+ // explicit override unhonourable. The file wins over the basename, which is
893
+ // a guess nobody stated.
894
+ const vaultYamlSlug = explicitName ? null : readVaultSlug(repoRoot);
895
+ const repoName = explicitName ?? vaultYamlSlug ?? path.basename(path.resolve(repoRoot));
881
896
 
882
897
  const rawVaultDir = vaultIntegration['vault-dir'];
883
898
  if (!rawVaultDir || typeof rawVaultDir !== 'string') {
@@ -886,10 +901,19 @@ async function runNarrativeMirror(opts) {
886
901
 
887
902
  const vaultDir = path.resolve(expandTilde(rawVaultDir));
888
903
  const candidateSlug = subjectToSlug(repoName) || 'unknown';
889
- // Loose-match against existing 01-projects/ folders before minting a new
890
- // slug (issue #829 Finding 3) see resolveLooseSlug for the ambiguity
904
+ // A declared `.vault.yaml` slug needs no healing it IS the canonical folder
905
+ // name, so resolveLooseSlug is SKIPPED for it. Running the healer over it
906
+ // would re-open the bug this fixes: a vault that also carries an unhealed
907
+ // legacy folder (e.g. `foobarapp` beside the declared `foo-bar-app`) makes
908
+ // the loose match ambiguous, and ambiguity falls back to the candidate —
909
+ // which is how a duplicate pair, once created, keeps being written to.
910
+ //
911
+ // Otherwise: loose-match against existing 01-projects/ folders before minting
912
+ // a new slug (issue #829 Finding 3) — see resolveLooseSlug for the ambiguity
891
913
  // rules. Falls through to `candidateSlug` unchanged on any read failure.
892
- const repoSlug = resolveLooseSlug(vaultDir, candidateSlug, { readdirSync: injectedFs?.readdirSync });
914
+ const repoSlug = vaultYamlSlug
915
+ ? candidateSlug
916
+ : resolveLooseSlug(vaultDir, candidateSlug, { readdirSync: injectedFs?.readdirSync });
893
917
  const outputPath = resolveNarrativePath(vaultDir, repoSlug);
894
918
 
895
919
  // Defense-in-depth: ensure the resolved file stays inside the vault root.