session-orchestrator 5.11.0 → 5.13.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 (394) hide show
  1. package/.claude-plugin/marketplace.json +12 -1
  2. package/.claude-plugin/plugin.json +4 -1
  3. package/.codex-plugin/agents/explorer.toml +1 -1
  4. package/.codex-plugin/agents/session-reviewer.toml +2 -2
  5. package/.codex-plugin/agents/wave-worker.toml +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/.orchestrator/policy/blocked-commands.json +8 -0
  9. package/.orchestrator/policy/public-export.json +30 -0
  10. package/CHANGELOG.md +180 -3
  11. package/README.md +126 -117
  12. package/agents/analyst.md +7 -2
  13. package/agents/architect-reviewer.md +7 -2
  14. package/agents/code-implementer.md +4 -2
  15. package/agents/db-specialist.md +9 -2
  16. package/agents/dialectic-deriver.md +9 -3
  17. package/agents/docs-writer.md +7 -2
  18. package/agents/eval-judge.md +7 -1
  19. package/agents/qa-strategist.md +7 -2
  20. package/agents/security-reviewer.md +9 -2
  21. package/agents/session-reviewer.md +18 -2
  22. package/agents/skill-applied-judge.md +7 -1
  23. package/agents/test-writer.md +9 -2
  24. package/agents/ui-developer.md +9 -2
  25. package/agents/ux-evaluator.md +6 -1
  26. package/commands/session.md +4 -2
  27. package/commands/templates-ack.md +89 -18
  28. package/docs/agent-authoring.md +30 -11
  29. package/docs/ci-setup.md +38 -2
  30. package/docs/codex-setup.md +33 -9
  31. package/docs/components.md +13 -2
  32. package/docs/cursor-setup.md +3 -3
  33. package/docs/events-schema.md +5 -4
  34. package/docs/evidence-candidates.md +63 -0
  35. package/docs/fleet-checkin-audit.md +7 -4
  36. package/docs/github-mirror-protection.md +150 -3
  37. package/docs/install.md +102 -25
  38. package/docs/instruction-delivery.md +1 -1
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/mods.md +118 -0
  41. package/docs/owner-config-schema.md +1 -0
  42. package/docs/plugin-architecture-v3.md +1 -1
  43. package/docs/pm-skills-marketplace.md +4 -3
  44. package/docs/rule-authoring.md +24 -13
  45. package/docs/scope-collision-guard.md +2 -1
  46. package/docs/session-config-reference.md +55 -21
  47. package/docs/session-config-template.md +32 -24
  48. package/docs/sessions-ledger-repair.md +137 -0
  49. package/docs/vault-docs-architecture.md +1 -1
  50. package/hooks/_lib/hook-import-set.json +406 -73
  51. package/hooks/_lib/hook-root.mjs +301 -0
  52. package/hooks/_lib/lock-bootstrap.mjs +434 -4
  53. package/hooks/_lib/orchestrator-ignore.mjs +3 -0
  54. package/hooks/_lib/subagent-transcript.mjs +2 -2
  55. package/hooks/_lib/vcs-create-matcher.mjs +13 -17
  56. package/hooks/cwd-change-restore.mjs +5 -1
  57. package/hooks/enforce-commands.mjs +4 -3
  58. package/hooks/enforce-scope.mjs +11 -1
  59. package/hooks/hooks-codex.json +48 -2
  60. package/hooks/hooks.json +31 -31
  61. package/hooks/on-session-end.mjs +38 -15
  62. package/hooks/on-session-start.mjs +158 -59
  63. package/hooks/on-stop.mjs +11 -6
  64. package/hooks/post-bash-issue-budget-refund.mjs +6 -1
  65. package/hooks/post-bash-write-verify.mjs +8 -4
  66. package/hooks/post-edit-validate.mjs +45 -52
  67. package/hooks/post-subagent-discovery-validator.mjs +2 -2
  68. package/hooks/post-tool-batch-wave-signal.mjs +8 -4
  69. package/hooks/post-tool-failure-corrective-context.mjs +5 -1
  70. package/hooks/pre-bash-destructive-guard.mjs +220 -8
  71. package/hooks/pre-bash-issue-budget.mjs +6 -1
  72. package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
  73. package/hooks/pre-bash-staging-fence.mjs +258 -14
  74. package/hooks/pre-bash-templates-first.mjs +28 -27
  75. package/hooks/run-node.sh +1 -1
  76. package/hooks/skill-invocation-telemetry.mjs +15 -8
  77. package/hooks/subagent-telemetry.mjs +77 -14
  78. package/hooks/wave-scope-commit-guard.mjs +143 -14
  79. package/mods/so-hud/.claude-plugin/plugin.json +11 -0
  80. package/mods/so-hud/README.md +57 -0
  81. package/mods/so-hud/hooks/beacon-io.mjs +101 -0
  82. package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
  83. package/mods/so-hud/hooks/hooks.json +1 -0
  84. package/mods/so-hud/hooks/logic.ts +182 -0
  85. package/mods/so-hud/hooks/register.tsx +670 -0
  86. package/mods/so-hud/hooks/rules.ts +2998 -0
  87. package/mods/so-hud/tsconfig.json +8 -0
  88. package/mods/so-hud/types/index.d.ts +36 -0
  89. package/package.json +4 -3
  90. package/rules/_index.md +1 -1
  91. package/rules/always-on/ask-via-tool.md +1 -1
  92. package/rules/always-on/loop-and-monitor.md +17 -194
  93. package/rules/always-on/parallel-sessions.md +3 -1
  94. package/rules/opt-in-stack/backend-data.md +1 -1
  95. package/rules/opt-in-stack/backend.md +1 -1
  96. package/rules/opt-in-stack/security-web.md +5 -1
  97. package/scripts/agent-usage-census.mjs +79 -0
  98. package/scripts/apply-session-learnings.mjs +34 -6
  99. package/scripts/archive-closed-prds.mjs +21 -5
  100. package/scripts/autopilot.mjs +11 -5
  101. package/scripts/backfill-abandoned-sessions.mjs +13 -3
  102. package/scripts/backfill-evidence-digest.mjs +2 -1
  103. package/scripts/backfill-learnings-from-vault.mjs +61 -6
  104. package/scripts/backfill-sessions.mjs +16 -2
  105. package/scripts/check-package-manager.mjs +26 -8
  106. package/scripts/check-sessions-integrity.mjs +4 -1
  107. package/scripts/ci/assert-vitest-green.mjs +3 -0
  108. package/scripts/ci/windows-smoke.mjs +201 -0
  109. package/scripts/close-precheck.mjs +55 -0
  110. package/scripts/dialectic-deriver.mjs +20 -12
  111. package/scripts/emit-event.mjs +2 -2
  112. package/scripts/emit-session.mjs +239 -20
  113. package/scripts/eval-session.mjs +17 -3
  114. package/scripts/export-hw-learnings.mjs +29 -2
  115. package/scripts/fleet-checkin-audit.mjs +8 -4
  116. package/scripts/fleet-checkin.mjs +73 -3
  117. package/scripts/gc-stale-worktrees.mjs +6 -1
  118. package/scripts/github-export.mjs +128 -0
  119. package/scripts/github-protection-audit.mjs +3 -1
  120. package/scripts/handover-queue-check.mjs +209 -0
  121. package/scripts/harness-audit.mjs +6 -4
  122. package/scripts/lib/agent-frontmatter.mjs +57 -10
  123. package/scripts/lib/agent-status.mjs +125 -7
  124. package/scripts/lib/agent-usage-census.mjs +394 -0
  125. package/scripts/lib/auto-dialectic.mjs +17 -10
  126. package/scripts/lib/auto-dream.mjs +22 -14
  127. package/scripts/lib/autopilot/loop.mjs +3 -1
  128. package/scripts/lib/autopilot/recent-runs.mjs +4 -2
  129. package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
  130. package/scripts/lib/autopilot/telemetry.mjs +5 -1
  131. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  132. package/scripts/lib/baseline-archetypes.mjs +10 -2
  133. package/scripts/lib/blocked-commands-policy.mjs +4 -3
  134. package/scripts/lib/build-live-signals.mjs +13 -9
  135. package/scripts/lib/ci-status-banner.mjs +329 -24
  136. package/scripts/lib/codex/plugin-contract.mjs +8 -0
  137. package/scripts/lib/cold-start-detector.mjs +5 -2
  138. package/scripts/lib/command-blocker.mjs +603 -43
  139. package/scripts/lib/common.mjs +3 -2
  140. package/scripts/lib/config/cross-repo.mjs +74 -7
  141. package/scripts/lib/config/host-paths.mjs +6 -2
  142. package/scripts/lib/config/remote-hosts.mjs +2 -2
  143. package/scripts/lib/config/section-extractor.mjs +65 -11
  144. package/scripts/lib/config/template-keys.mjs +153 -0
  145. package/scripts/lib/config/vault-integration.mjs +1 -0
  146. package/scripts/lib/config.mjs +3 -3
  147. package/scripts/lib/coordinator-snapshot.mjs +1 -1
  148. package/scripts/lib/discovery/triage-state.mjs +2 -1
  149. package/scripts/lib/dispatcher/rank.mjs +5 -7
  150. package/scripts/lib/ecosystem-health.mjs +2 -2
  151. package/scripts/lib/eval/engine.mjs +3 -2
  152. package/scripts/lib/eval/session-resolve.mjs +6 -2
  153. package/scripts/lib/eval/sink.mjs +4 -3
  154. package/scripts/lib/events-retention-banner.mjs +3 -2
  155. package/scripts/lib/events-schema.mjs +10 -0
  156. package/scripts/lib/events.mjs +23 -12
  157. package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
  158. package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
  159. package/scripts/lib/fleet-protocol.mjs +21 -0
  160. package/scripts/lib/gates/gate-full.mjs +172 -11
  161. package/scripts/lib/gates/gate-helpers.mjs +236 -23
  162. package/scripts/lib/git-config-drift.mjs +141 -7
  163. package/scripts/lib/github-export.mjs +447 -0
  164. package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
  165. package/scripts/lib/handover-queue-marks.mjs +159 -0
  166. package/scripts/lib/hardware-pattern-detector.mjs +103 -13
  167. package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
  168. package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
  169. package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
  170. package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
  171. package/scripts/lib/host-identity.mjs +9 -5
  172. package/scripts/lib/http-request.mjs +64 -0
  173. package/scripts/lib/instruction-budget-guard.mjs +30 -22
  174. package/scripts/lib/io.mjs +6 -3
  175. package/scripts/lib/issue-budget-reconcile.mjs +3 -2
  176. package/scripts/lib/issue-budget.mjs +36 -7
  177. package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
  178. package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
  179. package/scripts/lib/learnings/io.mjs +289 -15
  180. package/scripts/lib/learnings/select.mjs +53 -4
  181. package/scripts/lib/learnings/store-role.mjs +209 -0
  182. package/scripts/lib/learnings/surface.mjs +46 -7
  183. package/scripts/lib/ledger-path.mjs +113 -0
  184. package/scripts/lib/maintenance-due-banner.mjs +63 -12
  185. package/scripts/lib/memory-banner.mjs +5 -6
  186. package/scripts/lib/memory-proposals/collector.mjs +5 -2
  187. package/scripts/lib/memory-proposals/sink.mjs +7 -4
  188. package/scripts/lib/memory-proposals/store.mjs +22 -5
  189. package/scripts/lib/mode-selector-accuracy.mjs +2 -1
  190. package/scripts/lib/no-follow-open.mjs +57 -0
  191. package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
  192. package/scripts/lib/own-lock-root.mjs +95 -0
  193. package/scripts/lib/owner-interview.mjs +2 -1
  194. package/scripts/lib/owner-yaml.mjs +70 -7
  195. package/scripts/lib/platform.mjs +161 -9
  196. package/scripts/lib/plugin-update-banner.mjs +15 -11
  197. package/scripts/lib/pre-push-local-partial.mjs +117 -0
  198. package/scripts/lib/pre-push-remote-gate.mjs +151 -25
  199. package/scripts/lib/process-group.mjs +270 -34
  200. package/scripts/lib/project-hygiene.mjs +28 -0
  201. package/scripts/lib/quality-gate.mjs +33 -9
  202. package/scripts/lib/reconcile/backlog.mjs +29 -15
  203. package/scripts/lib/reconcile/engine.mjs +26 -2
  204. package/scripts/lib/reconcile/idempotency.mjs +65 -3
  205. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
  206. package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
  207. package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
  208. package/scripts/lib/resource-probe/evaluate.mjs +32 -9
  209. package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
  210. package/scripts/lib/rule-loader.mjs +20 -10
  211. package/scripts/lib/scope-baseline.mjs +123 -8
  212. package/scripts/lib/scope-echo.mjs +5 -2
  213. package/scripts/lib/scope-gate.mjs +55 -20
  214. package/scripts/lib/session-close-backfill.mjs +75 -7
  215. package/scripts/lib/session-end/close-precheck.mjs +151 -0
  216. package/scripts/lib/session-end/phase-skip.mjs +8 -7
  217. package/scripts/lib/session-end/tail-runner.mjs +7 -2
  218. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  219. package/scripts/lib/session-id.mjs +9 -1
  220. package/scripts/lib/session-identity/own-session.mjs +63 -0
  221. package/scripts/lib/session-invocation.mjs +14 -3
  222. package/scripts/lib/session-lock.mjs +114 -0
  223. package/scripts/lib/session-record-repair.mjs +245 -35
  224. package/scripts/lib/session-registry.mjs +85 -12
  225. package/scripts/lib/session-schema/constants.mjs +9 -0
  226. package/scripts/lib/session-schema/filters.mjs +56 -1
  227. package/scripts/lib/session-schema/validator.mjs +15 -5
  228. package/scripts/lib/session-start-probes.mjs +149 -22
  229. package/scripts/lib/session-token-rollup.mjs +2 -1
  230. package/scripts/lib/session-work-trees.mjs +166 -0
  231. package/scripts/lib/sessions-canonical.mjs +61 -3
  232. package/scripts/lib/sessions-integrity-banner.mjs +17 -39
  233. package/scripts/lib/sessions-staleness-banner.mjs +8 -5
  234. package/scripts/lib/sh-quote.mjs +1 -0
  235. package/scripts/lib/skill-evidence-window.mjs +5 -1
  236. package/scripts/lib/skill-health/join.mjs +10 -9
  237. package/scripts/lib/skill-invocations-schema.mjs +13 -0
  238. package/scripts/lib/skill-judgments-schema.mjs +6 -2
  239. package/scripts/lib/snapshot-probe.mjs +414 -0
  240. package/scripts/lib/so-hud/collect.mjs +174 -0
  241. package/scripts/lib/so-hud/runtime.mjs +87 -0
  242. package/scripts/lib/spiral-carryover.mjs +8 -1
  243. package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
  244. package/scripts/lib/state-md/idle-reset.mjs +248 -0
  245. package/scripts/lib/state-md/mission-status.mjs +5 -1
  246. package/scripts/lib/state-md/recommendations.mjs +26 -4
  247. package/scripts/lib/state-md-peer-guard.mjs +2 -0
  248. package/scripts/lib/state-md.mjs +31 -8
  249. package/scripts/lib/sunset/walker.mjs +4 -3
  250. package/scripts/lib/telemetry/consent.mjs +4 -2
  251. package/scripts/lib/telemetry/queue.mjs +3 -3
  252. package/scripts/lib/telemetry/sync.mjs +45 -9
  253. package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
  254. package/scripts/lib/tmux-layout/layouts.mjs +20 -3
  255. package/scripts/lib/ux-grill/paths.mjs +5 -1
  256. package/scripts/lib/validate/check-agents.mjs +37 -6
  257. package/scripts/lib/validate/check-component-paths.mjs +25 -1
  258. package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
  259. package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
  260. package/scripts/lib/validate/check-json-files.mjs +3 -1
  261. package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
  262. package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
  263. package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
  264. package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
  265. package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
  266. package/scripts/lib/validate/check-unwired-features.mjs +4 -2
  267. package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
  268. package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
  269. package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
  270. package/scripts/lib/validate/confidential-names.mjs +3 -1
  271. package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
  272. package/scripts/lib/vault-mirror/namespace.mjs +22 -9
  273. package/scripts/lib/vault-mirror/process.mjs +197 -65
  274. package/scripts/lib/vault-mirror/prune.mjs +5 -2
  275. package/scripts/lib/vault-mirror/render-learnings.mjs +8 -7
  276. package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
  277. package/scripts/lib/vault-mirror/retention.mjs +2 -2
  278. package/scripts/lib/vault-mirror/utils.mjs +106 -13
  279. package/scripts/lib/vault-staleness-banner.mjs +4 -7
  280. package/scripts/lib/vault-status/board-writer.mjs +34 -5
  281. package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
  282. package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
  283. package/scripts/lib/win-process-tree.mjs +137 -0
  284. package/scripts/lib/workspace.mjs +1 -1
  285. package/scripts/lib/worktree/constants.mjs +1 -1
  286. package/scripts/lib/worktree/lifecycle.mjs +1 -1
  287. package/scripts/lib/worktree/listing.mjs +1 -1
  288. package/scripts/lib/zx.mjs +50 -0
  289. package/scripts/mcp-server.mjs +16 -7
  290. package/scripts/mcp-server.sh +13 -83
  291. package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
  292. package/scripts/memory-propose.mjs +10 -3
  293. package/scripts/migrate-cold-start-seed.mjs +7 -0
  294. package/scripts/migrate-legacy-learnings.sh +22 -0
  295. package/scripts/migrate-sessions-jsonl.mjs +16 -2
  296. package/scripts/migrate-vault-paths.mjs +29 -13
  297. package/scripts/parse-config.mjs +71 -14
  298. package/scripts/print-learnings-index.mjs +25 -2
  299. package/scripts/release-session-lock.mjs +94 -7
  300. package/scripts/release.mjs +451 -24
  301. package/scripts/relocate-vault-corpus.mjs +11 -7
  302. package/scripts/repair-invalid-sessions.mjs +68 -3
  303. package/scripts/resolve-session-invocation.mjs +57 -8
  304. package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
  305. package/scripts/run-quality-gate.mjs +33 -7
  306. package/scripts/self-update.mjs +56 -7
  307. package/scripts/session-commit-files.mjs +133 -0
  308. package/scripts/site-numbers.mjs +52 -9
  309. package/scripts/snapshot-probe.mjs +156 -0
  310. package/scripts/so-hud.mjs +68 -0
  311. package/scripts/state-md-idle-reset.mjs +82 -0
  312. package/scripts/sweep-expired-learnings.mjs +54 -6
  313. package/scripts/sweep-expired-rules.mjs +4 -1
  314. package/scripts/validate-plugin.mjs +22 -0
  315. package/scripts/vault-integration-watcher.mjs +54 -6
  316. package/scripts/vault-mirror.mjs +9 -0
  317. package/scripts/vault-orphans.mjs +171 -0
  318. package/skills/_shared/fleet-protocol.md +166 -3
  319. package/skills/_shared/instruction-file-resolution.md +1 -1
  320. package/skills/_shared/monitor-patterns.md +44 -25
  321. package/skills/_shared/platform-tools.md +7 -0
  322. package/skills/_shared/state-ownership.md +5 -4
  323. package/skills/architecture/references/domain-model.md +1 -1
  324. package/skills/autopilot/SKILL.md +3 -2
  325. package/skills/bootstrap/SKILL.md +3 -1
  326. package/skills/bootstrap/_shared-template.md +4 -4
  327. package/skills/bootstrap/fast-template.md +1 -1
  328. package/skills/bootstrap/private-contract.md +1 -1
  329. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
  330. package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
  331. package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
  332. package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
  333. package/skills/claude-md-drift-check/SKILL.md +3 -3
  334. package/skills/claude-md-drift-check/checker.mjs +61 -101
  335. package/skills/close/SKILL.md +20 -7
  336. package/skills/discovery/probes/docs-staleness.mjs +7 -5
  337. package/skills/discovery/probes/frontend-slop.mjs +7 -3
  338. package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
  339. package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
  340. package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
  341. package/skills/discovery/probes/vault-staleness.mjs +5 -3
  342. package/skills/discovery/probes-session.md +3 -0
  343. package/skills/discovery/probes-vault.md +2 -0
  344. package/skills/dispatcher/SKILL.md +1 -1
  345. package/skills/eval/SKILL.md +3 -1
  346. package/skills/evolve/SKILL.md +2 -0
  347. package/skills/evolve/references/evolve-analyze-mode.md +3 -3
  348. package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
  349. package/skills/gitlab-ops/SKILL.md +7 -6
  350. package/skills/gitlab-portfolio/SKILL.md +2 -2
  351. package/skills/go/SKILL.md +13 -1
  352. package/skills/npm-publish/SKILL.md +5 -2
  353. package/skills/plan/SKILL.md +23 -10
  354. package/skills/plan/mode-feature.md +13 -8
  355. package/skills/plan/mode-new.md +6 -14
  356. package/skills/plan/prd-feature-template.md +19 -0
  357. package/skills/plan/prd-full-template.md +12 -5
  358. package/skills/plan/prd-premortem-panel.md +48 -0
  359. package/skills/plan/prd-reviewer-prompt.md +11 -3
  360. package/skills/plan/soul.md +1 -1
  361. package/skills/portfolio/SKILL.md +5 -6
  362. package/skills/quality-gates/SKILL.md +2 -4
  363. package/skills/reconcile/SKILL.md +27 -4
  364. package/skills/release/SKILL.md +16 -8
  365. package/skills/session-end/SKILL.md +97 -13
  366. package/skills/session-end/phase-3-2-docs-verification.md +1 -0
  367. package/skills/session-end/phase-3-6-tail.md +11 -7
  368. package/skills/session-end/phase-3-7a-recommendations.md +9 -2
  369. package/skills/session-end/plan-verification.md +16 -1
  370. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  371. package/skills/session-end/references/phase-3-documentation-updates.md +25 -13
  372. package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
  373. package/skills/session-end/references/session-summary-template.md +8 -0
  374. package/skills/session-end/session-metrics-write.md +7 -4
  375. package/skills/session-plan/SKILL.md +5 -3
  376. package/skills/session-start/SKILL.md +10 -4
  377. package/skills/session-start/phase-4-5-resource-health.md +11 -5
  378. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  379. package/skills/session-start/phase-8-5-express-path.md +4 -3
  380. package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
  381. package/skills/session-start/references/phase-1-5-session-continuity.md +13 -5
  382. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  383. package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
  384. package/skills/tmux-layout/SKILL.md +2 -2
  385. package/skills/vault-sync/validator.mjs +3 -2
  386. package/skills/wave-executor/SKILL.md +5 -4
  387. package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
  388. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
  389. package/skills/wave-executor/references/wave-loop-review.md +13 -5
  390. package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
  391. package/AGENTS.md +0 -207
  392. package/assets/og-card.png +0 -0
  393. package/assets/og-card.svg +0 -47
  394. package/assets/wave-lifecycle.svg +0 -98
@@ -15,7 +15,7 @@
15
15
  * via `.passthrough()`, and it is NOT rewritten retroactively by this change.
16
16
  */
17
17
 
18
- import { toDate, buildTag, slugifyIdSafe } from './utils.mjs';
18
+ import { toDate, buildTag, slugifyIdSafe, noteIdFor, yamlNoteId } from './utils.mjs';
19
19
  import { stableHostname } from '../host-identity.mjs';
20
20
  import { sourceRecordValue, sourceRecordLine } from './render-learnings.mjs';
21
21
  import { sessionNarrativeText } from './retention.mjs';
@@ -118,38 +118,17 @@ function waveCount(value) {
118
118
  }
119
119
 
120
120
  /**
121
- * ── RENDERABLE ⊋ SCHEMA-VALID (#964) ───────────────────────────────────────
122
- *
123
- * Each generator gates on its own required-field list. Before #964 all three
124
- * lists were function-local `const`s with no stated relationship to
125
- * `REQUIRED_FIELDS` (scripts/lib/session-schema/constants.mjs) — the write-path
126
- * schema — which made five independent notions of "a valid session record" in
127
- * one codebase, drifting silently. They are lifted here, exported, and pinned
128
- * against `REQUIRED_FIELDS` by a mechanical superset test in
129
- * tests/lib/vault-mirror/render-sessions.test.mjs.
130
- *
131
- * The relationship is deliberately asymmetric, not a duplicate:
132
- *
133
- * `REQUIRED_FIELDS` — "this record is well-formed". The writer
134
- * (scripts/emit-session.mjs) REFUSES a record that fails it.
135
- * these lists — "this record renders into a note a human can read".
136
- * vault-mirror SKIPS a record that fails them; the record stays valid.
137
- *
138
- * Collapsing the two would destroy that distinction. Hence `effectiveness`:
139
- * optional to the writer (a record lacking it is fine), required by the v1
140
- * renderer (a note reading `planned=n/a, completed=n/a, carryover=n/a,
141
- * rate=n/a` is worse than no note — indistinguishable from a real session whose
142
- * metrics happened to be absent).
143
- *
144
- * The predicate here is VALUE presence (`null`/`undefined` both fail); the
145
- * validator's is KEY presence. See `_validateRequiredFields` for why.
121
+ * ── RENDERABLE AND SCHEMA-VALID (#1575) ───────────────────────────────────
122
+ *
123
+ * The generators retain route-specific required-field lists. V1 requires the
124
+ * canonical REQUIRED_FIELDS; legacy V2/V3 derive aggregates from other shapes
125
+ * and cannot be emitted by the canonical writer. Effectiveness is optional
126
+ * and nullable in every route, matching the writer. Unknown metrics render
127
+ * n/a; a measured zero remains zero. Malformed non-object metrics still fail.
128
+ * Required fields use VALUE presence; the validator separately checks types.
146
129
  */
147
130
 
148
- /**
149
- * v1 — the only generator reachable from a write-path-valid record (measured:
150
- * 205/205 live ledger records at HEAD 730ee9d route v1). Its list is
151
- * `REQUIRED_FIELDS` plus `effectiveness`, i.e. a strict superset.
152
- */
131
+ /** V1 uses the canonical required-field set without requiring optional metrics. */
153
132
  export const RENDERABLE_SESSION_FIELDS_V1 = Object.freeze([
154
133
  'session_id',
155
134
  'session_type',
@@ -160,7 +139,6 @@ export const RENDERABLE_SESSION_FIELDS_V1 = Object.freeze([
160
139
  'total_files_changed',
161
140
  'agent_summary',
162
141
  'waves',
163
- 'effectiveness',
164
142
  ]);
165
143
 
166
144
  /**
@@ -183,7 +161,6 @@ export const RENDERABLE_SESSION_FIELDS_V2 = Object.freeze([
183
161
  'completed_at',
184
162
  'waves',
185
163
  'files_changed',
186
- 'effectiveness',
187
164
  ]);
188
165
 
189
166
  /**
@@ -200,9 +177,18 @@ export const RENDERABLE_SESSION_FIELDS_V3 = Object.freeze([
200
177
  'started_at',
201
178
  'completed_at',
202
179
  'waves',
203
- 'effectiveness',
204
180
  ]);
205
181
 
182
+ /** Optional metrics share the writer's object-or-null contract in all routes. */
183
+ function sessionEffectiveness(entry) {
184
+ const value = entry.effectiveness;
185
+ if (value === undefined || value === null) return {};
186
+ if (typeof value !== 'object' || Array.isArray(value)) {
187
+ throw new Error(`vault-mirror: session entry missing nested field 'effectiveness' (session_id=${entry.session_id})`);
188
+ }
189
+ return value;
190
+ }
191
+
206
192
  /**
207
193
  * Session-ledger status → vault-frontmatter status mapping (#909).
208
194
  *
@@ -422,12 +408,9 @@ export function generateSessionNote(entry, options = {}) {
422
408
  total_files_changed,
423
409
  agent_summary,
424
410
  waves,
425
- effectiveness,
426
411
  } = entry;
427
412
 
428
- if (typeof effectiveness !== 'object' || effectiveness === null) {
429
- throw new Error(`vault-mirror: session entry missing nested field 'effectiveness' (session_id=${session_id})`);
430
- }
413
+ const effectiveness = sessionEffectiveness(entry);
431
414
  if (typeof agent_summary !== 'object' || agent_summary === null) {
432
415
  throw new Error(`vault-mirror: session entry missing nested field 'agent_summary' (session_id=${session_id})`);
433
416
  }
@@ -463,7 +446,7 @@ export function generateSessionNote(entry, options = {}) {
463
446
  // and cap the combined tag at 64 chars so the frontmatter passes the vault
464
447
  // tagPathRegex. The `id` is likewise slugified to a kebab slug below —
465
448
  // entry.session_id may carry an ISO-timestamp uppercase `T`/`:`/`.`/`Z`.
466
- const noteId = slugifyIdSafe(session_id) ?? session_id;
449
+ const noteId = noteIdFor(options.repoNs, slugifyIdSafe(session_id) ?? session_id);
467
450
  // #909: the real ledger status, mapped into the vault enum — never hard-coded.
468
451
  const vaultStatus = vaultStatusForSession(entry);
469
452
  const tags = `[${buildTag(['session', session_type])}, ${buildTag(['status', vaultStatus])}]`;
@@ -554,7 +537,7 @@ export function generateSessionNote(entry, options = {}) {
554
537
  const { repoNs } = options;
555
538
 
556
539
  return `---
557
- id: ${noteId}
540
+ id: ${yamlNoteId(noteId)}
558
541
  type: session
559
542
  title: ${title}
560
543
  status: ${vaultStatus}
@@ -592,11 +575,9 @@ export function generateSessionNoteV2(entry, options = {}) {
592
575
  if (!Array.isArray(entry.waves)) {
593
576
  throw new Error(`vault-mirror: session entry 'waves' must be an array (session_id=${entry.session_id})`);
594
577
  }
595
- if (typeof entry.effectiveness !== 'object' || entry.effectiveness === null) {
596
- throw new Error(`vault-mirror: session entry missing nested field 'effectiveness' (session_id=${entry.session_id})`);
597
- }
578
+ const effectiveness = sessionEffectiveness(entry);
598
579
 
599
- const { session_id, session_type, started_at, completed_at, duration_seconds, branch, planned_issues, waves, files_changed, issues_closed, issues_created, effectiveness } = entry;
580
+ const { session_id, session_type, started_at, completed_at, duration_seconds, branch, planned_issues, waves, files_changed, issues_closed, issues_created } = entry;
600
581
 
601
582
  const created = toDate(started_at);
602
583
  const updated = toDate(completed_at);
@@ -620,7 +601,7 @@ export function generateSessionNoteV2(entry, options = {}) {
620
601
  // and cap the combined tag at 64 chars so the frontmatter passes the vault
621
602
  // tagPathRegex. The `id` is likewise slugified to a kebab slug below —
622
603
  // entry.session_id may carry an ISO-timestamp uppercase `T`/`:`/`.`/`Z`.
623
- const noteId = slugifyIdSafe(session_id) ?? session_id;
604
+ const noteId = noteIdFor(options.repoNs, slugifyIdSafe(session_id) ?? session_id);
624
605
  // #909: the real ledger status, mapped into the vault enum — never hard-coded.
625
606
  const vaultStatus = vaultStatusForSession(entry);
626
607
  const tags = `[${buildTag(['session', session_type])}, ${buildTag(['status', vaultStatus])}]`;
@@ -641,7 +622,7 @@ export function generateSessionNoteV2(entry, options = {}) {
641
622
  const { repoNs } = options;
642
623
 
643
624
  return `---
644
- id: ${noteId}
625
+ id: ${yamlNoteId(noteId)}
645
626
  type: session
646
627
  title: ${title}
647
628
  status: ${vaultStatus}
@@ -690,14 +671,12 @@ export function generateSessionNoteV3(entry, options = {}) {
690
671
  if (typeof entry.waves !== 'number') {
691
672
  throw new Error(`vault-mirror: session entry 'waves' must be a number (session_id=${entry.session_id})`);
692
673
  }
693
- if (typeof entry.effectiveness !== 'object' || entry.effectiveness === null) {
694
- throw new Error(`vault-mirror: session entry missing nested field 'effectiveness' (session_id=${entry.session_id})`);
695
- }
674
+ const effectiveness = sessionEffectiveness(entry);
696
675
 
697
676
  const {
698
677
  session_id, session_type, platform, branch, started_at, completed_at,
699
678
  duration_minutes, duration_seconds, waves, agents_dispatched, agent_summary,
700
- planned_issues, effectiveness, commits, issues_closed, issues_created,
679
+ planned_issues, commits, issues_closed, issues_created,
701
680
  follow_ups_filed, tests_total_pre, tests_total_post, tests_added,
702
681
  } = entry;
703
682
 
@@ -727,8 +706,12 @@ export function generateSessionNoteV3(entry, options = {}) {
727
706
  const failed = as.failed ?? MISSING_VALUE;
728
707
  const spiral = as.spiral ?? MISSING_VALUE;
729
708
 
709
+ // A qualified cross-repo reference (`group/repo#n`, #1603) already carries
710
+ // its own `#`; only a bare number gets the prefix.
730
711
  const fmtIssues = (list) =>
731
- Array.isArray(list) && list.length ? list.map((i) => `#${i}`).join(', ') : '—';
712
+ Array.isArray(list) && list.length
713
+ ? list.map((i) => (typeof i === 'number' ? `#${i}` : String(i))).join(', ')
714
+ : '—';
732
715
  const closedList = fmtIssues(issues_closed);
733
716
  const createdList = fmtIssues(issues_created);
734
717
  const followList = fmtIssues(follow_ups_filed);
@@ -752,7 +735,7 @@ export function generateSessionNoteV3(entry, options = {}) {
752
735
  // and cap the combined tag at 64 chars so the frontmatter passes the vault
753
736
  // tagPathRegex. The `id` is likewise slugified to a kebab slug below —
754
737
  // entry.session_id may carry an ISO-timestamp uppercase `T`/`:`/`.`/`Z`.
755
- const noteId = slugifyIdSafe(session_id) ?? session_id;
738
+ const noteId = noteIdFor(options.repoNs, slugifyIdSafe(session_id) ?? session_id);
756
739
  // #909: the real ledger status, mapped into the vault enum — never hard-coded.
757
740
  const vaultStatus = vaultStatusForSession(entry);
758
741
  const tags = `[${buildTag(['session', session_type])}, ${buildTag(['status', vaultStatus])}]`;
@@ -768,7 +751,7 @@ export function generateSessionNoteV3(entry, options = {}) {
768
751
  const { repoNs } = options;
769
752
 
770
753
  return `---
771
- id: ${noteId}
754
+ id: ${yamlNoteId(noteId)}
772
755
  type: session
773
756
  title: ${title}
774
757
  status: ${vaultStatus}
@@ -34,7 +34,7 @@
34
34
  import { readdirSync } from 'node:fs';
35
35
  import { join } from 'node:path';
36
36
 
37
- import { subjectToSlug } from './utils.mjs';
37
+ import { subjectToSlug, noteIdFor, yamlNoteId } from './utils.mjs';
38
38
 
39
39
  /** The generator marker every mirror-written note carries. */
40
40
  export const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
@@ -367,7 +367,7 @@ export function renderRollup(existing, rows, { ns, month }) {
367
367
  .map((r) => `| ${r.date} | \`${r.id}\` | ${r.type} | ${r.waves} | ${r.agents} | ${r.files} |`)
368
368
  .join('\n');
369
369
  return `---
370
- id: ${idSlug}
370
+ id: ${yamlNoteId(noteIdFor(null, idSlug))}
371
371
  type: session
372
372
  title: "Sessions ${month} (${label}, metrics only)"
373
373
  status: verified
@@ -10,32 +10,43 @@
10
10
  import { parseSessionId } from '../session-id.mjs';
11
11
  import { digestSha256Short } from '../crypto-digest-utils.mjs';
12
12
 
13
+ /** Normalize letters for new note slugs. */
14
+ function normalizeSlugLetters(value) {
15
+ const german = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' };
16
+ return value.normalize('NFC').toLowerCase()
17
+ .replace(/[äöüß]/g, (letter) => german[letter])
18
+ .normalize('NFKD').replace(/\p{M}/gu, '');
19
+ }
20
+
21
+ /** Historical ASCII deletion, frozen for record identity and old note lookup. */
22
+ export function legacySubjectToSlug(subject) {
23
+ return slugSubject(subject, (value) => value.toLowerCase());
24
+ }
25
+
13
26
  /**
14
27
  * Convert a subject string to a kebab slug.
15
28
  * - If subject contains slashes, collapse to last path segment.
29
+ * - Transliterate German letters and decompose accents.
16
30
  * - Replace dots and underscores with hyphens.
17
31
  * - Strip all non-[a-z0-9-] chars.
18
32
  * - Collapse consecutive hyphens.
19
33
  * - Trim leading/trailing hyphens.
20
34
  */
21
35
  export function subjectToSlug(subject) {
22
- let s = subject;
36
+ return slugSubject(subject, normalizeSlugLetters);
37
+ }
23
38
 
24
- // Collapse slash paths to last segment
39
+ function slugSubject(subject, normalize) {
40
+ let s = subject;
25
41
  if (s.includes('/')) {
26
42
  const parts = s.split('/').filter(Boolean);
27
43
  s = parts[parts.length - 1];
28
44
  }
29
-
30
- // Normalise: lowercase, dots/underscores → hyphens, strip invalid chars
31
- s = s
32
- .toLowerCase()
45
+ return normalize(s)
33
46
  .replace(/[._]/g, '-')
34
47
  .replace(/[^a-z0-9-]/g, '')
35
48
  .replace(/-+/g, '-')
36
49
  .replace(/^-+|-+$/g, '');
37
-
38
- return s;
39
50
  }
40
51
 
41
52
  const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -80,8 +91,8 @@ export const TAG_MAX_LENGTH = 64;
80
91
  * @returns {string}
81
92
  */
82
93
  export function slugifyTagSegment(input) {
83
- const slug = String(input ?? '')
84
- .toLowerCase()
94
+ // Tags are persistent identifiers, so retain historical ASCII deletion.
95
+ const slug = String(input ?? '').toLowerCase()
85
96
  .replace(/[\s._]+/g, '-')
86
97
  .replace(/[^a-z0-9-]/g, '')
87
98
  .replace(/-+/g, '-')
@@ -123,14 +134,95 @@ export function buildTag(segments) {
123
134
  */
124
135
  export function slugifyIdSafe(input) {
125
136
  // Normalise whitespace to hyphens first (so "My ID" → "my-id" rather than
126
- // "myid"), then delegate to subjectToSlug for the rest of the kebab contract.
137
+ // "myid"), then preserve the historical ASCII kebab contract.
127
138
  const normalised = String(input ?? '').replace(/\s+/g, '-');
128
- const slug = subjectToSlug(normalised);
139
+ // Session identity retains historical deletion; transliteration is for new
140
+ // learning names, never an incidental Session-ID migration.
141
+ const slug = legacySubjectToSlug(normalised);
129
142
  if (!slug) return null;
130
143
  // Schema requires min length 2; pad a single-char slug deterministically.
131
144
  return slug.length >= 2 ? slug : `${slug}-x`;
132
145
  }
133
146
 
147
+ /** The vault-sync schema's `id` cap (`skills/vault-sync/validator.mjs`, `.max(128)`). */
148
+ export const NOTE_ID_MAX_LENGTH = 128;
149
+
150
+ /**
151
+ * The frontmatter `id` of a generated note (#1601, #1602). The FILENAME slug
152
+ * stays the note's identity on disk (paths never change); the `id` adds the
153
+ * repo namespace so it is unique across the vault's per-repo folders — a
154
+ * session id or learning subject recurs across repos — and is capped at
155
+ * {@link NOTE_ID_MAX_LENGTH}. An over-long id keeps a 119-char prefix plus an
156
+ * 8-hex SHA-256 suffix of the FULL id, so two long subjects sharing a prefix
157
+ * stay distinct. Deterministic: the same `(repoNs, slug)` always yields the
158
+ * same id. Without a namespace (vault-less callers, unit tests) the slug is
159
+ * only capped.
160
+ *
161
+ * BV-004 ceiling: the separator is a single `-` (the schema slug forbids
162
+ * `--`), so namespace `a` + slug `b-c` and namespace `a-b` + slug `c` render
163
+ * the same id. Revisit-Trigger: the vault validator reports a duplicate id
164
+ * whose two notes sit in different namespace folders.
165
+ *
166
+ * @param {string|null|undefined} repoNs - the resolved repo namespace folder.
167
+ * @param {string} slug - the note's filename slug (or rendered session slug).
168
+ * @returns {string}
169
+ */
170
+ export function noteIdFor(repoNs, slug) {
171
+ const base = String(slug ?? '');
172
+ const ns = typeof repoNs === 'string' && repoNs.length > 0 ? (isValidSlug(repoNs) ? repoNs : legacySubjectToSlug(repoNs)) : '';
173
+ const full = ns ? `${ns}-${base}` : base;
174
+ if (full.length <= NOTE_ID_MAX_LENGTH) return full;
175
+ const suffix = digestSha256Short(full);
176
+ return `${full.slice(0, NOTE_ID_MAX_LENGTH - suffix.length - 1).replace(/-+$/, '')}-${suffix}`;
177
+ }
178
+
179
+ /**
180
+ * Does a note's frontmatter `id` name the note `slug`? Accepts both the
181
+ * pre-#1601 id (the bare slug — a numeric one parses as the same string here,
182
+ * `parseFrontmatter` keeps scalars verbatim) and the current {@link noteIdFor}
183
+ * form, so notes written before the id change keep their ownership evidence.
184
+ *
185
+ * @param {unknown} fmId
186
+ * @param {string} slug
187
+ * @param {string|null|undefined} repoNs
188
+ * @returns {boolean}
189
+ */
190
+ export function noteIdMatches(fmId, slug, repoNs) {
191
+ return typeof fmId === 'string' && (fmId === slug || fmId === noteIdFor(repoNs, slug));
192
+ }
193
+
194
+ /**
195
+ * The `id:` frontmatter value, always a double-quoted YAML string (#1601): an
196
+ * unquoted all-digit id (`id: 433`) parses as a NUMBER under a real YAML parser
197
+ * and fails the schema's `z.string()`.
198
+ *
199
+ * @param {string} id
200
+ * @returns {string}
201
+ */
202
+ export function yamlNoteId(id) {
203
+ return JSON.stringify(String(id));
204
+ }
205
+
206
+ /**
207
+ * Replace the frontmatter `id:` line of a note — used when a displaced note
208
+ * moves to its disambiguated name (#1506.2), whose `id` must match the file,
209
+ * and by the id migration (#1601, `migrate-ids.mjs`). Only the `id:` line
210
+ * INSIDE the leading frontmatter block changes; every other byte — body,
211
+ * other fields, `[REDACTED]` spans (#1025) — is kept verbatim. A note without
212
+ * a frontmatter `id:` line is returned unchanged.
213
+ * @param {string} content
214
+ * @param {string} id
215
+ * @returns {string}
216
+ */
217
+ export function withNoteId(content, id) {
218
+ if (!content.startsWith('---')) return content;
219
+ const end = content.indexOf('\n---', 3);
220
+ if (end === -1) return content;
221
+ const head = content.slice(0, end);
222
+ const nextHead = head.replace(/^id:.*$/m, `id: ${yamlNoteId(id)}`);
223
+ return nextHead === head ? content : nextHead + content.slice(end);
224
+ }
225
+
134
226
  /** Derive the first 8 chars of a UUID (strip hyphens, take first 8 hex chars). */
135
227
  export function uuidPrefix8(id) {
136
228
  return id.replace(/-/g, '').slice(0, 8);
@@ -198,7 +290,8 @@ export function resolveSourceSessionLink(source_session, opts = {}) {
198
290
  if (!raw || raw === 'unknown') return { isLink: false, target: raw || 'unknown' };
199
291
  // Provenance tags like "agent-proposed@wave-1" are never session notes.
200
292
  if (/@/.test(raw)) return { isLink: false, target: raw };
201
- const slug = subjectToSlug(raw) || raw;
293
+ // Session targets retain the same historical identity as their note filenames.
294
+ const slug = legacySubjectToSlug(raw) || raw;
202
295
  if (typeof opts.noteExists === 'function') {
203
296
  // EXISTENCE-based (authoritative when a predicate is supplied).
204
297
  return opts.noteExists(slug) ? { isLink: true, target: slug } : { isLink: false, target: raw };
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { existsSync, readFileSync } from 'node:fs';
26
- import path from 'node:path';
26
+ import { resolveLedgerPath } from './ledger-path.mjs';
27
27
 
28
28
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
29
29
 
@@ -95,12 +95,9 @@ export function checkVaultStaleness({ repoRoot, now = Date.now() } = {}) {
95
95
  try {
96
96
  if (!repoRoot || typeof repoRoot !== 'string') return null;
97
97
 
98
- const jsonlPath = path.join(
99
- repoRoot,
100
- '.orchestrator',
101
- 'metrics',
102
- 'vault-staleness.jsonl',
103
- );
98
+ // #1722: the ledger the probe writes (skills/discovery/probes/vault-staleness.mjs)
99
+ // — the main checkout's when repoRoot is a linked worktree that ignores it.
100
+ const jsonlPath = resolveLedgerPath('.orchestrator/metrics/vault-staleness.jsonl', repoRoot);
104
101
 
105
102
  if (!existsSync(jsonlPath)) return null;
106
103
 
@@ -48,7 +48,7 @@
48
48
  * canonical-vault refusal (#1450). Plain Node ESM. No external deps.
49
49
  */
50
50
 
51
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
51
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
52
52
  import os from 'node:os';
53
53
  import path from 'node:path';
54
54
 
@@ -56,6 +56,7 @@ import { emitEvent, sessionAttribution } from '../events.mjs';
56
56
  import { isLockLive, readLock, DEFAULT_TTL_HOURS } from '../session-lock.mjs';
57
57
  import { stableHostname } from '../host-identity.mjs';
58
58
  import { readRegistry, repoPathHash, isRegistryEntryFresh } from '../session-registry.mjs';
59
+ import { digestSha256 } from '../crypto-digest-utils.mjs';
59
60
  import { parseFrontmatter } from '../vault-mirror/utils.mjs';
60
61
  import { readConfigFile, parseSessionConfig } from '../config.mjs';
61
62
  import { validatePathInsideProject } from '../path-utils.mjs';
@@ -134,10 +135,29 @@ const CASE_INSENSITIVE_FS = process.platform === 'darwin' || process.platform ==
134
135
  * @returns {string|null} `KEY_LENGTH`-char hex prefix, or null for an unusable path.
135
136
  */
136
137
  export function boardKey(repoRoot) {
138
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
139
+ try {
140
+ let resolved = path.resolve(repoRoot);
141
+ try { resolved = realpathSync.native(resolved); } catch { /* Keep unavailable paths' legacy identity. */ }
142
+ // Fold AFTER realpath, then hash directly: repoPathHash would restore
143
+ // on-disk casing and change existing case-folded board keys on macOS.
144
+ return digestSha256(CASE_INSENSITIVE_FS ? resolved.toLowerCase() : resolved).slice(0, KEY_LENGTH);
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Pre-#1557 lexical board key, used to migrate rows when alias paths now
152
+ * resolve to a canonical key. BV-004 revisit/deletion trigger: remove once a
153
+ * host-wide board audit confirms no rows written before the #1557 canonical-key
154
+ * rollout remain for 30 consecutive days; age alone does not prove migration.
155
+ */
156
+ function legacyBoardKey(repoRoot) {
137
157
  if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
138
158
  try {
139
159
  const resolved = path.resolve(repoRoot);
140
- return repoPathHash(CASE_INSENSITIVE_FS ? resolved.toLowerCase() : resolved).slice(0, KEY_LENGTH);
160
+ return digestSha256(CASE_INSENSITIVE_FS ? resolved.toLowerCase() : resolved).slice(0, KEY_LENGTH);
141
161
  } catch {
142
162
  return null;
143
163
  }
@@ -435,6 +455,7 @@ export async function collectRows({ repos, now = new Date(), registry, priorStat
435
455
  rows.push({
436
456
  repo: repoName,
437
457
  key,
458
+ legacyKey: legacyBoardKey(repo.repoRoot), // Internal migration identity; never rendered.
438
459
  status,
439
460
  session: status === STATUS_FREI ? null : session,
440
461
  branch: status === STATUS_FREI ? null : branch,
@@ -1098,8 +1119,8 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
1098
1119
  // skips `frei` candidates and the TTL pass only rewrites `status`, never
1099
1120
  // removes a row), and the board would grow a permanent duplicate per repo.
1100
1121
  // So a fresh row first claims its hash slot; if that slot is new, it ADOPTS
1101
- // the legacy name slot for the same folded name — one board write converts
1102
- // the row, and the migration is complete for that repo.
1122
+ // the legacy name slot for the same folded name and the pre-#1557 lexical
1123
+ // key slot — one board write converts the row for that repo.
1103
1124
  const merged = new Map(staleRederivedRows);
1104
1125
  for (const row of rows) {
1105
1126
  const slot = row.key ? hashSlot(row.key) : nameSlot(row.repo);
@@ -1107,6 +1128,13 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
1107
1128
  // First keyed write for this repo — take over its legacy row rather than
1108
1129
  // rendering a second one beside it.
1109
1130
  merged.delete(nameSlot(row.repo));
1131
+ if (row.legacyKey && row.legacyKey !== row.key) {
1132
+ // An 8-char key can collide: adopt the legacy-key row only when it
1133
+ // names this repo, never another repo's row that shares the prefix.
1134
+ const legacySlot = hashSlot(row.legacyKey);
1135
+ const legacyRow = merged.get(legacySlot);
1136
+ if (legacyRow && foldKey(legacyRow.repo) === foldKey(row.repo)) merged.delete(legacySlot);
1137
+ }
1110
1138
  }
1111
1139
  merged.set(slot, row);
1112
1140
  }
@@ -1311,7 +1339,8 @@ export function buildSweepRepos(candidates, { thisRepoRoot } = {}) {
1311
1339
  * @param {object} [opts]
1312
1340
  * @param {string} opts.repoRoot — the calling repo (always included in the sweep).
1313
1341
  * @param {string} [opts.startDir] — enumeration root; omitted in production so
1314
- * {@link enumerateCandidates} defaults to `getConfinementRoot()` (~/Projects).
1342
+ * {@link enumerateCandidates} defaults to `getConfinementRoot()` (~/Projects
1343
+ * unless the host sets `projects-root`, #1562).
1315
1344
  * Test seam only — do NOT compute `path.dirname(repoRoot)` here.
1316
1345
  * @param {Date|number} [opts.now] — clock seam. A `Date` is used as-is; a finite
1317
1346
  * number is treated as epoch-ms (matching {@link collectRows}'s own
@@ -31,12 +31,15 @@ import { readFile } from 'node:fs/promises';
31
31
  import path from 'node:path';
32
32
 
33
33
  import { emitEvent, sessionAttribution } from '../events.mjs';
34
- import { parseStateMd, parseMissionStatus, resolveStateMdPath } from '../state-md.mjs';
34
+ import { parseStateMd, parseMissionStatus } from '../state-md.mjs';
35
+ import { resolveStateMdWriteTarget, warnForeignStateMd } from '../state-md/frontmatter-mutators.mjs';
35
36
  import {
36
37
  parseFrontmatter,
37
38
  toDate,
38
39
  yamlQuoteIfNeeded,
39
40
  subjectToSlug,
41
+ noteIdFor,
42
+ yamlNoteId,
40
43
  } from '../vault-mirror/utils.mjs';
41
44
  import { matchesModuloRedaction } from '../vault-mirror/process.mjs';
42
45
  import { readConfigFile, parseSessionConfig } from '../config.mjs';
@@ -317,7 +320,7 @@ export function renderNarrative(opts) {
317
320
 
318
321
  // Frontmatter — `_generator` LAST (markdown-writer / render-sessions convention).
319
322
  lines.push('---');
320
- lines.push(`id: ${noteId}`);
323
+ lines.push(`id: ${yamlNoteId(noteIdFor(null, noteId))}`);
321
324
  lines.push('type: session');
322
325
  lines.push(`title: ${title}`);
323
326
  lines.push(`created: ${createdValue}`);
@@ -380,6 +383,31 @@ export function resolveNarrativePath(vaultDir, repoSlug) {
380
383
  return path.join(vaultDir, '01-projects', repoSlug, '_session-narrative.md');
381
384
  }
382
385
 
386
+ /**
387
+ * The `created` date of the narrative already at `outputPath` when THIS generator
388
+ * wrote it (#1602; same rule as board-writer.mjs). A hand-authored or foreign
389
+ * file, a missing one, or a malformed date yields `undefined` — the render then
390
+ * stamps today, exactly as on a first write. A read failure is not fatal here:
391
+ * `writeNarrative` reads the file again and owns that error contract.
392
+ *
393
+ * @param {string} outputPath
394
+ * @param {{ readFileSync?: Function, existsSync?: Function }} [injectedFs]
395
+ * @returns {string|undefined} `YYYY-MM-DD`
396
+ */
397
+ function existingNarrativeCreated(outputPath, injectedFs) {
398
+ const fsExists = injectedFs?.existsSync ?? existsSync;
399
+ const fsReadFile = injectedFs?.readFileSync ?? readFileSync;
400
+ try {
401
+ if (!fsExists(outputPath)) return undefined;
402
+ const fm = parseFrontmatter(fsReadFile(outputPath, 'utf8'));
403
+ if (fm?.['_generator'] !== GENERATOR_MARKER) return undefined;
404
+ const created = fm['created'];
405
+ return typeof created === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(created) ? created : undefined;
406
+ } catch {
407
+ return undefined;
408
+ }
409
+ }
410
+
383
411
  // ── Idempotent write ─────────────────────────────────────────────────────────────
384
412
 
385
413
  /**
@@ -393,6 +421,26 @@ function normalizeUpdated(content) {
393
421
  return content.replace(/^(updated:\s*)(.+)$/m, `$1${UPDATED_PLACEHOLDER}`);
394
422
  }
395
423
 
424
+ /**
425
+ * The comparison form of a narrative for the noop check: {@link normalizeUpdated}
426
+ * plus the `id:` line dropped to a bare `id:` (#1601, release-review HOCH 2).
427
+ * The id differs between generations of the renderer in more than one way: the
428
+ * render now quotes it (`id: "x"`) where older files carry `id: x`, and
429
+ * `noteIdFor` caps it, so a pre-cap file can carry a longer value. Any such
430
+ * difference made the whole document "changed", which skipped the `[REDACTED]`
431
+ * comparison below and rewrote the file — with the raw secret whenever it was
432
+ * missing from the current env (#1025). The id is derived from the note's
433
+ * path, not from its content, so no id difference may ever trigger a rewrite;
434
+ * a schema-breaking id on disk is the job of `vault-mirror/migrate-ids.mjs`,
435
+ * which changes that one line and nothing else.
436
+ *
437
+ * @param {string} content
438
+ * @returns {string}
439
+ */
440
+ function normalizeForNoopCompare(content) {
441
+ return normalizeUpdated(content).replace(/^id:.*$/m, 'id:');
442
+ }
443
+
396
444
  /**
397
445
  * Write the narrative file with idempotency guards (mirrors gitlab-portfolio
398
446
  * markdown-writer.mjs contract).
@@ -501,8 +549,9 @@ export function writeNarrative(opts) {
501
549
  // #1025 — identical once every `[REDACTED]` span already on disk is read
502
550
  // as a wildcard. See the guard-order note above for why the second arm is
503
551
  // load-bearing rather than a nicety.
504
- const existingNormalized = normalizeUpdated(existingContent);
505
- const candidateNormalized = normalizeUpdated(content);
552
+ // #1601: the id line is left out of the compare — see normalizeForNoopCompare.
553
+ const existingNormalized = normalizeForNoopCompare(existingContent);
554
+ const candidateNormalized = normalizeForNoopCompare(content);
506
555
  if (
507
556
  existingNormalized === candidateNormalized ||
508
557
  matchesModuloRedaction(existingNormalized, candidateNormalized)
@@ -963,8 +1012,15 @@ async function runNarrativeMirror(opts) {
963
1012
  return { result: { action: 'skipped-invalid-path', path: outputPath }, needleCount: masker.needleCount, hits: 0, dryRun };
964
1013
  }
965
1014
 
966
- // Read STATE.md (best-effort; absent STATE.md → nothing to mirror).
967
- const stateMdPath = resolveStateMdPath(repoRoot);
1015
+ // Read STATE.md (best-effort; absent STATE.md → nothing to mirror). #1622:
1016
+ // another platform's STATE.md not proven to be this process's counts as absent.
1017
+ // #1640: `rejected` is also set when the target ITSELF is foreign
1018
+ // (`blocked`) — never read and mirror that file either.
1019
+ const { path: stateMdPath, rejected } = resolveStateMdWriteTarget(repoRoot);
1020
+ if (rejected) {
1021
+ warnForeignStateMd('mirrorNarrative', rejected, stateMdPath);
1022
+ return { result: { action: 'skipped-no-statemd', path: outputPath }, needleCount: masker.needleCount, hits: 0, dryRun };
1023
+ }
968
1024
  let stateContents;
969
1025
  try {
970
1026
  stateContents = await readFile(stateMdPath, 'utf8');
@@ -976,7 +1032,10 @@ async function runNarrativeMirror(opts) {
976
1032
  // parse (so `[REDACTED]` can never break it) and before the render, the
977
1033
  // idempotency comparison and the write. See maskNarrative above.
978
1034
  const { narrative, hits } = maskNarrative(extractNarrative(stateContents), masker);
979
- const content = renderNarrative({ repo: repoName, narrative, now });
1035
+ // #1602: `created` is the day the narrative was FIRST written — carried over
1036
+ // from the file on disk, so only `updated` moves between runs.
1037
+ const createdIso = existingNarrativeCreated(outputPath, injectedFs);
1038
+ const content = renderNarrative({ repo: repoName, narrative, now, createdIso });
980
1039
 
981
1040
  // `chars` measures the document THIS call rendered — so it is present on
982
1041
  // `skipped-noop` and `dry-run` too, where the render happened but nothing