session-orchestrator 5.12.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 (385) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  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 +162 -4
  11. package/README.md +126 -127
  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 +1 -1
  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 +90 -17
  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 +49 -15
  47. package/docs/session-config-template.md +30 -22
  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 +139 -49
  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 -2
  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 +39 -27
  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/section-extractor.mjs +65 -11
  143. package/scripts/lib/config/template-keys.mjs +153 -0
  144. package/scripts/lib/config/vault-integration.mjs +1 -0
  145. package/scripts/lib/config.mjs +2 -2
  146. package/scripts/lib/coordinator-snapshot.mjs +1 -1
  147. package/scripts/lib/discovery/triage-state.mjs +2 -1
  148. package/scripts/lib/dispatcher/rank.mjs +5 -7
  149. package/scripts/lib/ecosystem-health.mjs +2 -2
  150. package/scripts/lib/eval/engine.mjs +3 -2
  151. package/scripts/lib/eval/session-resolve.mjs +6 -2
  152. package/scripts/lib/eval/sink.mjs +4 -3
  153. package/scripts/lib/events-retention-banner.mjs +3 -2
  154. package/scripts/lib/events-schema.mjs +10 -0
  155. package/scripts/lib/events.mjs +23 -12
  156. package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
  157. package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
  158. package/scripts/lib/fleet-protocol.mjs +4 -0
  159. package/scripts/lib/gates/gate-full.mjs +172 -11
  160. package/scripts/lib/gates/gate-helpers.mjs +236 -23
  161. package/scripts/lib/git-config-drift.mjs +141 -7
  162. package/scripts/lib/github-export.mjs +447 -0
  163. package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
  164. package/scripts/lib/handover-queue-marks.mjs +159 -0
  165. package/scripts/lib/hardware-pattern-detector.mjs +103 -13
  166. package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
  167. package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
  168. package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
  169. package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
  170. package/scripts/lib/host-identity.mjs +9 -5
  171. package/scripts/lib/http-request.mjs +64 -0
  172. package/scripts/lib/instruction-budget-guard.mjs +30 -22
  173. package/scripts/lib/io.mjs +6 -3
  174. package/scripts/lib/issue-budget-reconcile.mjs +3 -2
  175. package/scripts/lib/issue-budget.mjs +36 -7
  176. package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
  177. package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
  178. package/scripts/lib/learnings/io.mjs +289 -15
  179. package/scripts/lib/learnings/select.mjs +53 -4
  180. package/scripts/lib/learnings/store-role.mjs +209 -0
  181. package/scripts/lib/learnings/surface.mjs +46 -7
  182. package/scripts/lib/ledger-path.mjs +113 -0
  183. package/scripts/lib/maintenance-due-banner.mjs +63 -12
  184. package/scripts/lib/memory-banner.mjs +5 -6
  185. package/scripts/lib/memory-proposals/collector.mjs +5 -2
  186. package/scripts/lib/memory-proposals/sink.mjs +7 -4
  187. package/scripts/lib/memory-proposals/store.mjs +22 -5
  188. package/scripts/lib/mode-selector-accuracy.mjs +2 -1
  189. package/scripts/lib/no-follow-open.mjs +57 -0
  190. package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
  191. package/scripts/lib/own-lock-root.mjs +95 -0
  192. package/scripts/lib/owner-interview.mjs +2 -1
  193. package/scripts/lib/owner-yaml.mjs +70 -7
  194. package/scripts/lib/platform.mjs +161 -9
  195. package/scripts/lib/plugin-update-banner.mjs +15 -11
  196. package/scripts/lib/pre-push-local-partial.mjs +117 -0
  197. package/scripts/lib/pre-push-remote-gate.mjs +151 -25
  198. package/scripts/lib/process-group.mjs +270 -34
  199. package/scripts/lib/project-hygiene.mjs +28 -0
  200. package/scripts/lib/quality-gate.mjs +33 -9
  201. package/scripts/lib/reconcile/backlog.mjs +29 -15
  202. package/scripts/lib/reconcile/engine.mjs +26 -2
  203. package/scripts/lib/reconcile/idempotency.mjs +65 -3
  204. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
  205. package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
  206. package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
  207. package/scripts/lib/resource-probe/evaluate.mjs +32 -9
  208. package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
  209. package/scripts/lib/rule-loader.mjs +20 -10
  210. package/scripts/lib/scope-baseline.mjs +123 -8
  211. package/scripts/lib/scope-echo.mjs +5 -2
  212. package/scripts/lib/scope-gate.mjs +55 -20
  213. package/scripts/lib/session-close-backfill.mjs +75 -7
  214. package/scripts/lib/session-end/close-precheck.mjs +151 -0
  215. package/scripts/lib/session-end/phase-skip.mjs +8 -7
  216. package/scripts/lib/session-end/tail-runner.mjs +7 -2
  217. package/scripts/lib/session-id.mjs +9 -1
  218. package/scripts/lib/session-identity/own-session.mjs +63 -0
  219. package/scripts/lib/session-lock.mjs +114 -0
  220. package/scripts/lib/session-record-repair.mjs +245 -35
  221. package/scripts/lib/session-registry.mjs +72 -9
  222. package/scripts/lib/session-schema/constants.mjs +9 -0
  223. package/scripts/lib/session-schema/filters.mjs +56 -1
  224. package/scripts/lib/session-schema/validator.mjs +15 -5
  225. package/scripts/lib/session-start-probes.mjs +149 -22
  226. package/scripts/lib/session-token-rollup.mjs +2 -1
  227. package/scripts/lib/session-work-trees.mjs +166 -0
  228. package/scripts/lib/sessions-canonical.mjs +61 -3
  229. package/scripts/lib/sessions-integrity-banner.mjs +17 -39
  230. package/scripts/lib/sessions-staleness-banner.mjs +8 -5
  231. package/scripts/lib/sh-quote.mjs +1 -0
  232. package/scripts/lib/skill-evidence-window.mjs +5 -1
  233. package/scripts/lib/skill-health/join.mjs +10 -9
  234. package/scripts/lib/skill-invocations-schema.mjs +13 -0
  235. package/scripts/lib/skill-judgments-schema.mjs +6 -2
  236. package/scripts/lib/snapshot-probe.mjs +414 -0
  237. package/scripts/lib/so-hud/collect.mjs +49 -5
  238. package/scripts/lib/so-hud/runtime.mjs +15 -4
  239. package/scripts/lib/spiral-carryover.mjs +8 -1
  240. package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
  241. package/scripts/lib/state-md/idle-reset.mjs +14 -5
  242. package/scripts/lib/state-md/mission-status.mjs +5 -1
  243. package/scripts/lib/state-md-peer-guard.mjs +2 -0
  244. package/scripts/lib/state-md.mjs +31 -8
  245. package/scripts/lib/sunset/walker.mjs +4 -3
  246. package/scripts/lib/telemetry/consent.mjs +4 -2
  247. package/scripts/lib/telemetry/queue.mjs +3 -3
  248. package/scripts/lib/telemetry/sync.mjs +45 -9
  249. package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
  250. package/scripts/lib/tmux-layout/layouts.mjs +20 -3
  251. package/scripts/lib/ux-grill/paths.mjs +5 -1
  252. package/scripts/lib/validate/check-agents.mjs +37 -6
  253. package/scripts/lib/validate/check-component-paths.mjs +25 -1
  254. package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
  255. package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
  256. package/scripts/lib/validate/check-json-files.mjs +3 -1
  257. package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
  258. package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
  259. package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
  260. package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
  261. package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
  262. package/scripts/lib/validate/check-unwired-features.mjs +4 -2
  263. package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
  264. package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
  265. package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
  266. package/scripts/lib/validate/confidential-names.mjs +3 -1
  267. package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
  268. package/scripts/lib/vault-mirror/namespace.mjs +19 -7
  269. package/scripts/lib/vault-mirror/process.mjs +56 -27
  270. package/scripts/lib/vault-mirror/prune.mjs +5 -2
  271. package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
  272. package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
  273. package/scripts/lib/vault-mirror/retention.mjs +2 -2
  274. package/scripts/lib/vault-mirror/utils.mjs +79 -0
  275. package/scripts/lib/vault-staleness-banner.mjs +4 -7
  276. package/scripts/lib/vault-status/board-writer.mjs +2 -1
  277. package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
  278. package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
  279. package/scripts/lib/win-process-tree.mjs +137 -0
  280. package/scripts/lib/workspace.mjs +1 -1
  281. package/scripts/lib/worktree/constants.mjs +1 -1
  282. package/scripts/lib/worktree/lifecycle.mjs +1 -1
  283. package/scripts/lib/worktree/listing.mjs +1 -1
  284. package/scripts/lib/zx.mjs +50 -0
  285. package/scripts/mcp-server.mjs +16 -7
  286. package/scripts/mcp-server.sh +13 -83
  287. package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
  288. package/scripts/memory-propose.mjs +10 -3
  289. package/scripts/migrate-cold-start-seed.mjs +7 -0
  290. package/scripts/migrate-legacy-learnings.sh +22 -0
  291. package/scripts/migrate-sessions-jsonl.mjs +16 -2
  292. package/scripts/migrate-vault-paths.mjs +29 -13
  293. package/scripts/parse-config.mjs +71 -14
  294. package/scripts/print-learnings-index.mjs +25 -2
  295. package/scripts/release-session-lock.mjs +94 -7
  296. package/scripts/release.mjs +451 -24
  297. package/scripts/relocate-vault-corpus.mjs +11 -7
  298. package/scripts/repair-invalid-sessions.mjs +68 -3
  299. package/scripts/resolve-session-invocation.mjs +10 -17
  300. package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
  301. package/scripts/run-quality-gate.mjs +33 -7
  302. package/scripts/self-update.mjs +56 -7
  303. package/scripts/session-commit-files.mjs +133 -0
  304. package/scripts/site-numbers.mjs +52 -9
  305. package/scripts/snapshot-probe.mjs +156 -0
  306. package/scripts/so-hud.mjs +29 -6
  307. package/scripts/state-md-idle-reset.mjs +2 -1
  308. package/scripts/sweep-expired-learnings.mjs +54 -6
  309. package/scripts/sweep-expired-rules.mjs +4 -1
  310. package/scripts/validate-plugin.mjs +22 -0
  311. package/scripts/vault-integration-watcher.mjs +54 -6
  312. package/scripts/vault-mirror.mjs +9 -0
  313. package/skills/_shared/fleet-protocol.md +142 -1
  314. package/skills/_shared/instruction-file-resolution.md +1 -1
  315. package/skills/_shared/monitor-patterns.md +44 -25
  316. package/skills/_shared/platform-tools.md +7 -0
  317. package/skills/_shared/state-ownership.md +3 -2
  318. package/skills/architecture/references/domain-model.md +1 -1
  319. package/skills/bootstrap/SKILL.md +3 -1
  320. package/skills/bootstrap/_shared-template.md +4 -4
  321. package/skills/bootstrap/fast-template.md +1 -1
  322. package/skills/bootstrap/private-contract.md +1 -1
  323. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
  324. package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
  325. package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
  326. package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
  327. package/skills/claude-md-drift-check/SKILL.md +3 -3
  328. package/skills/claude-md-drift-check/checker.mjs +61 -101
  329. package/skills/close/SKILL.md +20 -7
  330. package/skills/discovery/probes/docs-staleness.mjs +7 -5
  331. package/skills/discovery/probes/frontend-slop.mjs +7 -3
  332. package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
  333. package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
  334. package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
  335. package/skills/discovery/probes/vault-staleness.mjs +5 -3
  336. package/skills/discovery/probes-session.md +3 -0
  337. package/skills/discovery/probes-vault.md +2 -0
  338. package/skills/dispatcher/SKILL.md +1 -1
  339. package/skills/eval/SKILL.md +3 -1
  340. package/skills/evolve/SKILL.md +2 -0
  341. package/skills/evolve/references/evolve-analyze-mode.md +3 -3
  342. package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
  343. package/skills/gitlab-ops/SKILL.md +7 -6
  344. package/skills/gitlab-portfolio/SKILL.md +2 -2
  345. package/skills/go/SKILL.md +13 -1
  346. package/skills/npm-publish/SKILL.md +5 -2
  347. package/skills/plan/SKILL.md +23 -10
  348. package/skills/plan/mode-feature.md +13 -8
  349. package/skills/plan/mode-new.md +6 -14
  350. package/skills/plan/prd-feature-template.md +19 -0
  351. package/skills/plan/prd-full-template.md +12 -5
  352. package/skills/plan/prd-premortem-panel.md +48 -0
  353. package/skills/plan/prd-reviewer-prompt.md +11 -3
  354. package/skills/plan/soul.md +1 -1
  355. package/skills/portfolio/SKILL.md +5 -6
  356. package/skills/quality-gates/SKILL.md +2 -4
  357. package/skills/reconcile/SKILL.md +27 -4
  358. package/skills/release/SKILL.md +16 -8
  359. package/skills/session-end/SKILL.md +94 -11
  360. package/skills/session-end/phase-3-2-docs-verification.md +1 -0
  361. package/skills/session-end/phase-3-6-tail.md +11 -7
  362. package/skills/session-end/phase-3-7a-recommendations.md +9 -2
  363. package/skills/session-end/plan-verification.md +16 -1
  364. package/skills/session-end/references/phase-3-documentation-updates.md +14 -12
  365. package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
  366. package/skills/session-end/references/session-summary-template.md +4 -0
  367. package/skills/session-end/session-metrics-write.md +7 -4
  368. package/skills/session-plan/SKILL.md +5 -3
  369. package/skills/session-start/SKILL.md +9 -3
  370. package/skills/session-start/phase-4-5-resource-health.md +11 -5
  371. package/skills/session-start/phase-8-5-express-path.md +4 -3
  372. package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
  373. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -2
  374. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  375. package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
  376. package/skills/tmux-layout/SKILL.md +2 -2
  377. package/skills/vault-sync/validator.mjs +3 -2
  378. package/skills/wave-executor/SKILL.md +5 -4
  379. package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
  380. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
  381. package/skills/wave-executor/references/wave-loop-review.md +13 -5
  382. package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
  383. package/assets/og-card.png +0 -0
  384. package/assets/og-card.svg +0 -47
  385. package/assets/wave-lifecycle.svg +0 -98
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * migrate-ids.mjs — one-time repair of the frontmatter `id` of notes the vault
4
+ * mirror wrote before #1601/#1602.
5
+ *
6
+ * Why a separate pass: the mirror's hot path (`process.mjs`) never rewrites a
7
+ * note because only its `id` is wrong. Its only rewrite is a full re-render
8
+ * from the ledger record, and that re-render brought back plaintext that an
9
+ * earlier run had masked to `[REDACTED]` (#1025) whenever the secret was
10
+ * missing from the current env. That was the release-review finding HOCH 1 on
11
+ * MR !148. This pass changes ONE line and nothing else.
12
+ *
13
+ * Contract:
14
+ * - Only notes with the vault-mirror `_generator` marker are considered, in
15
+ * `40-learnings/`, `50-sessions/` and their `90-archive/mirror/` copies,
16
+ * flat (`<zone>/<file>`) or namespaced (`<zone>/<ns>/<file>`). Month
17
+ * rollups are skipped (their id is not derived from the filename).
18
+ * - The new id is `noteIdFor(<ns>, <filename slug>)`: the same id the
19
+ * generator renders today. The namespace is the folder; a flat note uses
20
+ * its own `source-repo` (as `fmNamesSlug` in process.mjs does).
21
+ * - `schema` changes: the old id breaks the vault-sync schema (missing, over
22
+ * 128 chars, all digits, not a kebab slug). Always planned.
23
+ * - `namespace` changes: the old id is the bare pre-#1602 slug. Planned only
24
+ * with `includeBare`, because it touches every older generated note once.
25
+ * - Two notes of the same layer (live, or `90-archive/`) that would end up
26
+ * with the same id are both left alone and reported as `conflicts`. A live
27
+ * note and its archive copy never conflict: the vault-sync validator does
28
+ * not check `90-archive/`, so blocking the live repair on its copy would
29
+ * leave the schema error in place.
30
+ * - A note whose frontmatter has no `id:` line is not planned (the one-line
31
+ * rewrite has no line to change); it is reported as `unrepairable`.
32
+ * - Apply replaces only the `id:` line inside the frontmatter
33
+ * (`withNoteId`), checks that nothing else changed, re-reads the file and
34
+ * skips it if it changed since the plan, and writes atomically: a tmp
35
+ * sibling in the same folder, then rename (`atomicWriteWithBackup`).
36
+ * - Apply refuses to write anything when the plan has more changes than
37
+ * `maxChanges`. The CLI requires `--max-changes` with `--apply`, so the
38
+ * count comes from a dry run first.
39
+ *
40
+ * Usage (dry run is the default and writes nothing):
41
+ * node scripts/lib/vault-mirror/migrate-ids.mjs --vault-dir PATH [--include-bare]
42
+ * node scripts/lib/vault-mirror/migrate-ids.mjs --vault-dir PATH [--include-bare] --apply --max-changes N
43
+ *
44
+ * Output: one JSON line per planned change ({path, from, to, reason}), then
45
+ * one summary line. Paths are vault-relative. The `from` values are the
46
+ * record needed to undo a run.
47
+ *
48
+ * Exit codes: 0 success · 1 usage error · 2 refused (over the cap), per-file
49
+ * errors, or `unrepairable` notes left after --apply
50
+ */
51
+
52
+ import { readdirSync, readFileSync } from 'node:fs';
53
+ import { join, resolve } from 'node:path';
54
+ import yaml from 'js-yaml';
55
+
56
+ import { atomicWriteWithBackup } from '../io.mjs';
57
+ import { expandTilde } from '../common.mjs';
58
+ import { isMainModule } from '../is-main-module.mjs';
59
+ import { NOTE_ID_MAX_LENGTH, isValidSlug, noteIdFor, noteIdMatches, parseFrontmatter, slugifyIdSafe, withNoteId } from './utils.mjs';
60
+ import { ARCHIVE_ROOT, GENERATOR_MARKER, ROLLUP_PREFIX } from './retention.mjs';
61
+
62
+ const ZONES = ['40-learnings', '50-sessions'];
63
+ /** The vault-sync schema's id pattern (`skills/vault-sync/validator.mjs` slugRegex). */
64
+ const SCHEMA_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
65
+ const ID_LINE = /^id:(.*)$/m;
66
+
67
+ /** Every `.md` of a zone root: flat files and one folder level below. */
68
+ function listZoneFiles(vaultDir, zoneRel) {
69
+ const out = [];
70
+ let top;
71
+ try {
72
+ top = readdirSync(join(vaultDir, zoneRel), { withFileTypes: true });
73
+ } catch {
74
+ return out;
75
+ }
76
+ for (const ent of top) {
77
+ if (ent.isFile() && ent.name.endsWith('.md')) out.push({ rel: `${zoneRel}/${ent.name}`, dirNs: '', name: ent.name });
78
+ else if (ent.isDirectory()) {
79
+ let inner;
80
+ try {
81
+ inner = readdirSync(join(vaultDir, zoneRel, ent.name), { withFileTypes: true });
82
+ } catch {
83
+ continue;
84
+ }
85
+ for (const f of inner) {
86
+ if (f.isFile() && f.name.endsWith('.md')) out.push({ rel: `${zoneRel}/${ent.name}/${f.name}`, dirNs: ent.name, name: f.name });
87
+ }
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * Does the on-disk id break the vault-sync schema (`z.string().regex(slug).max(128)`)?
95
+ * `cur` is the parseFrontmatter value (quotes stripped, always a string);
96
+ * `raw` is the literal after `id:`. An unquoted `433` is a YAML NUMBER even
97
+ * though parseFrontmatter hands it back as a string — the #1601 shape — so
98
+ * the raw literal goes through a real YAML parse too.
99
+ */
100
+ function idViolatesSchema(cur, raw) {
101
+ if (typeof cur !== 'string' || cur.length > NOTE_ID_MAX_LENGTH || !SCHEMA_SLUG.test(cur)) return true;
102
+ try {
103
+ return typeof yaml.load(`id: ${raw}`)?.id !== 'string';
104
+ } catch {
105
+ return true;
106
+ }
107
+ }
108
+
109
+ /** The leading frontmatter block, or null. */
110
+ function frontmatterHead(content) {
111
+ if (!content.startsWith('---')) return null;
112
+ const end = content.indexOf('\n---', 3);
113
+ return end === -1 ? null : content.slice(0, end);
114
+ }
115
+
116
+ /**
117
+ * Plan the id migration. Read-only.
118
+ *
119
+ * @param {{ vaultDir: string, includeBare?: boolean }} opts
120
+ * @returns {{ changes: Array<{path: string, from: string, to: string, reason: 'schema'|'namespace'}>,
121
+ * conflicts: Array<{path: string, to: string, reason: 'schema'|'namespace'}>, unrepairable: Array<{path: string, reason: 'no-id-line'}>,
122
+ * scanned: number, unrecognised: number, contents: Map<string, string> }}
123
+ * `contents` holds the bytes each change was planned against (apply compares them).
124
+ */
125
+ export function planIdMigration({ vaultDir, includeBare = false }) {
126
+ const candidates = [];
127
+ // `<layer>:<id>` → count of notes that carry or would carry it. Counted per
128
+ // layer: a live note and its archive copy share an id by design.
129
+ const targets = new Map();
130
+ const unrepairable = [];
131
+ let scanned = 0;
132
+ let unrecognised = 0;
133
+ const zoneRoots = [...ZONES, ...ZONES.map((z) => `${ARCHIVE_ROOT}/${z}`)];
134
+ for (const zoneRel of zoneRoots) {
135
+ const layer = zoneRel.startsWith(`${ARCHIVE_ROOT}/`) ? 'archive' : 'live';
136
+ for (const { rel, dirNs, name } of listZoneFiles(vaultDir, zoneRel)) {
137
+ if (name.startsWith(ROLLUP_PREFIX)) continue;
138
+ let content;
139
+ try {
140
+ content = readFileSync(join(vaultDir, rel), 'utf8');
141
+ } catch {
142
+ continue;
143
+ }
144
+ const fm = parseFrontmatter(content);
145
+ if (!fm || fm['_generator'] !== GENERATOR_MARKER) continue;
146
+ const head = frontmatterHead(content);
147
+ if (head === null) continue;
148
+ scanned += 1;
149
+ const stem = name.slice(0, -'.md'.length);
150
+ // The generator's id base: a learning's filename IS its slug; a session
151
+ // file is named by the raw session id, which the renderer slugifies.
152
+ const base = isValidSlug(stem) ? stem : (slugifyIdSafe(stem) ?? stem);
153
+ const ns = dirNs || (typeof fm['source-repo'] === 'string' ? fm['source-repo'] : null);
154
+ const to = noteIdFor(ns, base);
155
+ const cur = typeof fm['id'] === 'string' ? fm['id'] : undefined;
156
+ const rawLine = head.match(ID_LINE);
157
+ if (!rawLine) {
158
+ unrepairable.push({ path: rel, reason: 'no-id-line' });
159
+ continue;
160
+ }
161
+ const from = rawLine[1].trim();
162
+ const violates = idViolatesSchema(cur, from);
163
+ let reason = null;
164
+ if (violates) reason = 'schema';
165
+ else if (cur !== to) {
166
+ const bare = cur === base || cur === stem || noteIdMatches(cur, base, fm['source-repo']);
167
+ if (bare && includeBare) reason = 'namespace';
168
+ else if (!bare) unrecognised += 1;
169
+ }
170
+ const finalId = reason === null ? cur : to;
171
+ if (typeof finalId === 'string') targets.set(`${layer}:${finalId}`, (targets.get(`${layer}:${finalId}`) ?? 0) + 1);
172
+ if (reason !== null) candidates.push({ path: rel, from, to, reason, content, layer });
173
+ }
174
+ }
175
+ const changes = [];
176
+ const conflicts = [];
177
+ const contents = new Map();
178
+ for (const c of candidates) {
179
+ if ((targets.get(`${c.layer}:${c.to}`) ?? 0) > 1) {
180
+ conflicts.push({ path: c.path, to: c.to, reason: c.reason });
181
+ continue;
182
+ }
183
+ changes.push({ path: c.path, from: c.from, to: c.to, reason: c.reason });
184
+ contents.set(c.path, c.content);
185
+ }
186
+ return { changes, conflicts, unrepairable, scanned, unrecognised, contents };
187
+ }
188
+
189
+ /**
190
+ * True when `next` differs from `content` in the frontmatter `id:` line only.
191
+ * A belt against a future change to `withNoteId`, not an independent check:
192
+ * it strips the line with the same `id:` pattern `withNoteId` rewrites, so it
193
+ * only catches a `withNoteId` that starts touching anything else
194
+ * (BV-002: data-loss prevention).
195
+ */
196
+ function onlyIdLineChanged(content, next) {
197
+ const strip = (s) => {
198
+ const head = frontmatterHead(s);
199
+ return head === null ? s : head.replace(ID_LINE, 'id:') + s.slice(head.length);
200
+ };
201
+ return strip(content) === strip(next);
202
+ }
203
+
204
+ /**
205
+ * Apply a plan from {@link planIdMigration}.
206
+ *
207
+ * @param {ReturnType<typeof planIdMigration>} plan
208
+ * @param {{ vaultDir: string, maxChanges: number, fs?: object }} opts
209
+ * `fs` is passed to `atomicWriteWithBackup` (test seam).
210
+ * @returns {{ ok: boolean, refused?: 'over-limit', written: string[],
211
+ * skipped: Array<{path: string, reason: string}>, errors: Array<{path: string, error: string}> }}
212
+ */
213
+ export function applyIdMigration(plan, { vaultDir, maxChanges, fs: injectedFs } = {}) {
214
+ const written = [];
215
+ const skipped = [];
216
+ const errors = [];
217
+ if (!Number.isInteger(maxChanges) || maxChanges < 0 || plan.changes.length > maxChanges) {
218
+ return { ok: false, refused: 'over-limit', written, skipped, errors };
219
+ }
220
+ for (const change of plan.changes) {
221
+ const abs = join(vaultDir, change.path);
222
+ let content;
223
+ try {
224
+ content = readFileSync(abs, 'utf8');
225
+ } catch (err) {
226
+ errors.push({ path: change.path, error: err?.code ?? 'read-failed' });
227
+ continue;
228
+ }
229
+ if (content !== plan.contents.get(change.path)) {
230
+ skipped.push({ path: change.path, reason: 'changed-since-plan' });
231
+ continue;
232
+ }
233
+ const next = withNoteId(content, change.to);
234
+ if (next === content || !onlyIdLineChanged(content, next)) {
235
+ skipped.push({ path: change.path, reason: 'not-a-single-line-change' });
236
+ continue;
237
+ }
238
+ const res = atomicWriteWithBackup(abs, next, { tmpPrefix: '.vault-mirror-migrate-ids.tmp', ...(injectedFs ? { fs: injectedFs } : {}) });
239
+ if (res.ok) written.push(change.path);
240
+ else errors.push({ path: change.path, error: res.code ?? 'write-failed' });
241
+ }
242
+ return { ok: errors.length === 0, written, skipped, errors };
243
+ }
244
+
245
+ const HELP = `Usage: node scripts/lib/vault-mirror/migrate-ids.mjs --vault-dir PATH [--include-bare]
246
+ [--apply --max-changes N]
247
+
248
+ Repairs the frontmatter id of vault-mirror notes (#1601/#1602). Dry run is the
249
+ default: it prints the planned changes and writes nothing. --apply needs
250
+ --max-changes N (take N from the dry run); a plan with more changes is refused.
251
+
252
+ --include-bare Also namespace ids that are valid but carry no repo prefix
253
+ `;
254
+
255
+ /**
256
+ * @param {string[]} argv
257
+ * @returns {{ vaultDir: string|null, apply: boolean, includeBare: boolean, maxChanges: number|null, help: boolean }}
258
+ */
259
+ export function parseArgs(argv) {
260
+ const args = { vaultDir: null, apply: false, includeBare: false, maxChanges: null, help: false };
261
+ for (let i = 0; i < argv.length; i += 1) {
262
+ const a = argv[i];
263
+ if (a === '--help' || a === '-h') args.help = true;
264
+ else if (a === '--apply') args.apply = true;
265
+ else if (a === '--include-bare') args.includeBare = true;
266
+ else if (a === '--vault-dir') args.vaultDir = argv[++i] ?? null;
267
+ else if (a === '--max-changes') {
268
+ const raw = argv[++i];
269
+ const n = Number(raw);
270
+ if (!Number.isInteger(n) || n < 0) throw new Error(`invalid --max-changes "${raw}"`);
271
+ args.maxChanges = n;
272
+ } else throw new Error(`unknown argument "${a}"`);
273
+ }
274
+ return args;
275
+ }
276
+
277
+ if (isMainModule(import.meta.url)) {
278
+ let args;
279
+ try {
280
+ args = parseArgs(process.argv.slice(2));
281
+ } catch (err) {
282
+ process.stderr.write(`migrate-ids: ${err.message}\n`);
283
+ process.exit(1);
284
+ }
285
+ if (args.help) {
286
+ process.stdout.write(HELP);
287
+ process.exit(0);
288
+ }
289
+ if (!args.vaultDir) {
290
+ process.stderr.write('migrate-ids: --vault-dir is required\n');
291
+ process.exit(1);
292
+ }
293
+ if (args.apply && args.maxChanges === null) {
294
+ process.stderr.write('migrate-ids: --apply needs --max-changes N (run without --apply first to see N)\n');
295
+ process.exit(1);
296
+ }
297
+ const vaultDir = resolve(expandTilde(args.vaultDir));
298
+ const plan = planIdMigration({ vaultDir, includeBare: args.includeBare });
299
+ for (const c of plan.changes) process.stdout.write(`${JSON.stringify(c)}\n`);
300
+ const summary = {
301
+ mode: args.apply ? 'apply' : 'dry-run',
302
+ scanned: plan.scanned,
303
+ planned: plan.changes.length,
304
+ conflicts: plan.conflicts,
305
+ unrepairable: plan.unrepairable,
306
+ unrecognised: plan.unrecognised,
307
+ };
308
+ if (plan.unrepairable.length > 0) {
309
+ process.stderr.write(`migrate-ids: ${plan.unrepairable.length} note(s) have no id line and are not repaired; see "unrepairable"\n`);
310
+ }
311
+ if (!args.apply) {
312
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
313
+ process.exit(0);
314
+ }
315
+ const res = applyIdMigration(plan, { vaultDir, maxChanges: args.maxChanges });
316
+ process.stdout.write(`${JSON.stringify({ ...summary, ...res, written: res.written.length })}\n`);
317
+ // A run that leaves schema violations behind (no id line, or a schema repair
318
+ // blocked by a conflict) is not a success, even if every planned write landed.
319
+ const schemaLeft = plan.unrepairable.length + plan.conflicts.filter((c) => c.reason === 'schema').length;
320
+ process.exit(res.ok && schemaLeft === 0 ? 0 : 2);
321
+ }
@@ -56,7 +56,7 @@
56
56
  import { basename, resolve } from 'node:path';
57
57
 
58
58
  import { legacySubjectToSlug } from './utils.mjs';
59
- import { isOwnerLeakySegment } from '../../lib/validate/check-owner-leakage.mjs';
59
+ import { inspectOwnerPatterns, isOwnerLeakySegment } from '../../lib/validate/check-owner-leakage.mjs';
60
60
  import { loadPseudonymMap } from './pseudonym-map.mjs';
61
61
  import { loadHostPaths, resolveHostPath } from '../config/host-paths.mjs';
62
62
  import { isQueryFailure, resolvePreferredRemote } from '../vcs-repo-spec.mjs';
@@ -261,6 +261,22 @@ function lookupPseudonym(base, seg) {
261
261
  return null;
262
262
  }
263
263
 
264
+ /**
265
+ * WARN line for a redaction. An unsafe private config dir (#1629, e.g. 0777 under
266
+ * umask 000) makes isOwnerLeakySegment() fail closed with CP1 for EVERY segment —
267
+ * the redaction stays, but calling that a leak hid the real cause.
268
+ *
269
+ * @param {string} where
270
+ * @param {string} pattern
271
+ * @returns {string}
272
+ */
273
+ function redactionWarning(where, pattern) {
274
+ if (inspectOwnerPatterns().reason === 'unsafe-config-dir') {
275
+ return `WARN vault-mirror/namespace: owner policy unavailable (unsafe config dir); redacting ${where} to 'redacted-repo'\n`;
276
+ }
277
+ return `WARN vault-mirror/namespace: owner-privacy leak detected in ${where} (pattern: ${pattern}); redacting to 'redacted-repo'\n`;
278
+ }
279
+
264
280
  /**
265
281
  * Resolve the sanitised repository namespace segment for vault path scoping.
266
282
  *
@@ -332,9 +348,7 @@ export function resolveRepoNamespace({ vaultName = null, repoRoot = null } = {})
332
348
  if (rawMatch !== null) {
333
349
  const pseudonym = lookupPseudonym(base, seg);
334
350
  if (pseudonym) return pseudonym;
335
- process.stderr.write(
336
- `WARN vault-mirror/namespace: owner-privacy leak detected in repo identifier (pattern: ${rawMatch}); redacting to 'redacted-repo'\n`,
337
- );
351
+ process.stderr.write(redactionWarning('repo identifier', rawMatch));
338
352
  return 'redacted-repo';
339
353
  }
340
354
 
@@ -342,9 +356,7 @@ export function resolveRepoNamespace({ vaultName = null, repoRoot = null } = {})
342
356
  if (segMatch !== null) {
343
357
  const pseudonym = lookupPseudonym(base, seg);
344
358
  if (pseudonym) return pseudonym;
345
- process.stderr.write(
346
- `WARN vault-mirror/namespace: owner-privacy leak detected in sanitised namespace segment (pattern: ${segMatch}); redacting to 'redacted-repo'\n`,
347
- );
359
+ process.stderr.write(redactionWarning('sanitised namespace segment', segMatch));
348
360
  return 'redacted-repo';
349
361
  }
350
362
 
@@ -10,7 +10,7 @@ import { basename, join, resolve, sep } from 'node:path';
10
10
  import { createRequire } from 'node:module';
11
11
  import { randomUUID } from 'node:crypto';
12
12
  import { createSecretValueMasker } from '../secret-masker.mjs';
13
- import { subjectToSlug, legacySubjectToSlug, isValidSlug, uuidPrefix8, recordKeySuffix8, toDate, parseFrontmatter } from './utils.mjs';
13
+ import { subjectToSlug, legacySubjectToSlug, isValidSlug, uuidPrefix8, recordKeySuffix8, toDate, parseFrontmatter, slugifyIdSafe, noteIdFor, noteIdMatches, withNoteId } from './utils.mjs';
14
14
  import { isRealSession } from '../session-schema/filters.mjs';
15
15
  import { resolveRepoNamespace } from './namespace.mjs';
16
16
  import { detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2, learningSourceRecord } from './render-learnings.mjs';
@@ -25,6 +25,7 @@ import {
25
25
  renderRollup,
26
26
  rollupRelPath,
27
27
  rollupRowFromNote,
28
+ section,
28
29
  sessionNarrativeChars,
29
30
  } from './retention.mjs';
30
31
 
@@ -268,6 +269,23 @@ export function matchesModuloRedaction(existingVal, renderedVal) {
268
269
  return new RegExp(`^${pattern}$`).test(renderedVal);
269
270
  }
270
271
 
272
+ /**
273
+ * Does a session note on disk carry LESS free text than the record (#1513
274
+ * review H2), so it must be re-rendered? Not when the only difference is a
275
+ * `[REDACTED]` span (#1025): `[REDACTED]` and the raw value differ in length,
276
+ * so the bare char-count compare reported "stale" whenever the secret was
277
+ * missing from the current env and re-rendered the note WITH the raw value.
278
+ * Found while pinning #1601 (release-review HOCH 1, MR !148).
279
+ *
280
+ * @param {string} existingContent
281
+ * @param {string} renderedBody
282
+ * @returns {boolean}
283
+ */
284
+ function sessionNotesStale(existingContent, renderedBody) {
285
+ if (noteNarrativeChars(existingContent) === noteNarrativeChars(renderedBody)) return false;
286
+ return !matchesModuloRedaction(section(existingContent, 'Notes') ?? '', section(renderedBody, 'Notes') ?? '');
287
+ }
288
+
271
289
  /**
272
290
  * Return true when the existing vault note content and the freshly-rendered
273
291
  * candidate share identical canonical fields (i.e. no meaningful update needed).
@@ -449,17 +467,6 @@ function disambigSlugFor(dir, slug, { legacyId, recordKey }) {
449
467
  return { slug: hashed, exists: null };
450
468
  }
451
469
 
452
- /**
453
- * Replace the frontmatter `id:` line of a note — used when a displaced note
454
- * moves to its disambiguated name (#1506.2), whose `id` must match the file.
455
- * @param {string} content
456
- * @param {string} id
457
- * @returns {string}
458
- */
459
- function withNoteId(content, id) {
460
- return content.replace(/^id: .*$/m, `id: ${id}`);
461
- }
462
-
463
470
  /**
464
471
  * Emit `skipped-foreign-owner` for a note the writer must not touch: one stderr
465
472
  * `SKIP` line (vault-relative path, like every other SKIP line) and the stdout
@@ -974,10 +981,20 @@ function learningSlugVariants(entry, schema) {
974
981
  return [...names];
975
982
  }
976
983
 
984
+ /**
985
+ * Does the note `fm` name `slug`? #1601/#1602: its `id` is the bare slug (a note
986
+ * written before the namespaced id) or the slug namespaced under the writer's
987
+ * folder or under the note's OWN `source-repo` — a moved or relabelled note keeps
988
+ * its old prefix, and the folder, not the label, decides ownership (#1503 F1).
989
+ */
990
+ function fmNamesSlug(fm, slug, repoNs) {
991
+ return Boolean(fm) && (noteIdMatches(fm['id'], slug, repoNs) || noteIdMatches(fm['id'], slug, fm['source-repo']));
992
+ }
993
+
977
994
  /** Only explicit, exact provenance permits assigning an old name to a record. */
978
995
  function positiveLearningOwner(fm, name, repoNs, recordKey) {
979
996
  return recordKey !== null && notOurGenerator(fm) === null &&
980
- fm['id'] === name && fm['source-record'] === recordKey && fm['source-repo'] === repoNs;
997
+ fmNamesSlug(fm, name, repoNs) && fm['source-record'] === recordKey && fm['source-repo'] === repoNs;
981
998
  }
982
999
 
983
1000
 
@@ -1181,7 +1198,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1181
1198
  const legacyFm = parseFrontmatter(readFileSync(legacyPath, 'utf8'));
1182
1199
  if (
1183
1200
  notOurGenerator(legacyFm) === null &&
1184
- legacyFm['id'] === legacySlug &&
1201
+ fmNamesSlug(legacyFm, legacySlug, repoNs) &&
1185
1202
  foreignOwnerReason(legacyFm, writer) === null
1186
1203
  ) {
1187
1204
  slug = legacySlug;
@@ -1220,7 +1237,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1220
1237
  // neither a duplicate to skip on nor a file to rewrite with THIS record's
1221
1238
  // content; this entry proceeds to its own namespaced path (a different
1222
1239
  // file). A leak in it is masked in place, keeping its owner and content.
1223
- const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && legacyFm['id'] === slug;
1240
+ const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && fmNamesSlug(legacyFm, slug, repoNs);
1224
1241
  const legacyForeign = legacyMarked && foreignOwnerReason(legacyFm, writer) !== null;
1225
1242
  if (legacyForeign && legacyStillLeaks) {
1226
1243
  healedForeignLeak = maskForeignNoteInPlace(legacyFlatPath, legacyContent, ctx);
@@ -1239,6 +1256,9 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1239
1256
  // #1028 residue 1: the five-field compare cannot see a raw secret
1240
1257
  // sitting in a non-canonical field (e.g. `evidence`) — the
1241
1258
  // `legacyStillLeaks` probe above is what makes this match trustworthy.
1259
+ // #1601: `id` is not a compared field, and a schema-violating old id
1260
+ // is NOT a reason to rewrite here — re-rendering would bring back
1261
+ // plaintext a past run masked (#1025). `migrate-ids.mjs` repairs it.
1242
1262
  return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: slug });
1243
1263
  }
1244
1264
  // Content differs, or the on-disk note still leaks under the current
@@ -1307,7 +1327,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1307
1327
  const entryDay = toDate(dateSource);
1308
1328
  if (
1309
1329
  routedByRecord &&
1310
- fm['id'] === slug &&
1330
+ fmNamesSlug(fm, slug, repoNs) &&
1311
1331
  !expired &&
1312
1332
  /^\d{4}-\d{2}-\d{2}$/.test(entryDay) &&
1313
1333
  typeof fm['created'] === 'string' &&
@@ -1320,7 +1340,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1320
1340
  if (takenOver !== null) return takenOver;
1321
1341
  }
1322
1342
 
1323
- if (fm['id'] !== slug || routedByRecord) {
1343
+ if (!fmNamesSlug(fm, slug, repoNs) || routedByRecord) {
1324
1344
  // Different id (or different record) → collision: disambiguate
1325
1345
  const picked = disambigSlugFor(targetDir, slug, {
1326
1346
  legacyId: String(entryId),
@@ -1401,7 +1421,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1401
1421
  // #1506.2: the main note is this record's — a live disambiguation copy of
1402
1422
  // the same record (left by an interrupted takeover) is superseded.
1403
1423
  const stale = !slugTransition && fm['source-record'] === writer.recordKey
1404
- ? staleOwnDisambig(targetDir, slug, entryId, writer)
1424
+ ? staleOwnDisambig(targetDir, slug, entryId, writer, repoNs)
1405
1425
  : null;
1406
1426
 
1407
1427
  // Same id: check if updated would advance (unless --force overrides).
@@ -1414,6 +1434,9 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1414
1434
  if (learningContentMatches(existingContent, candidateContent)) {
1415
1435
  // #1028 residue 1: probe the on-disk content against the CURRENT masker
1416
1436
  // before trusting the five-field match — see maskerWouldChange above.
1437
+ // #1601: `id` is deliberately NOT part of this decision — an id-only
1438
+ // difference must never re-render the note (that would bring back
1439
+ // plaintext a past run masked, #1025); `migrate-ids.mjs` repairs it.
1417
1440
  mainUnchanged = !maskerWouldChange(existingContent);
1418
1441
  }
1419
1442
  // Content differs, or the on-disk note still leaks under the current env —
@@ -1480,14 +1503,14 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1480
1503
  *
1481
1504
  * @returns {{ path: string, next: string }|null} the rewrite, or null.
1482
1505
  */
1483
- function staleOwnDisambig(targetDir, slug, entryId, writer) {
1506
+ function staleOwnDisambig(targetDir, slug, entryId, writer, repoNs) {
1484
1507
  if (typeof writer.recordKey !== 'string') return null;
1485
1508
  const own = disambigSlugFor(targetDir, slug, { legacyId: String(entryId), recordKey: writer.recordKey });
1486
1509
  const path = join(targetDir, `${own.slug}.md`);
1487
1510
  if (!(own.exists ?? existsSync(path))) return null;
1488
1511
  const content = readFileSync(path, 'utf8');
1489
1512
  const fm = parseFrontmatter(content);
1490
- if (notOurGenerator(fm) !== null || fm['source-record'] !== writer.recordKey || fm['id'] !== own.slug) return null;
1513
+ if (notOurGenerator(fm) !== null || fm['source-record'] !== writer.recordKey || !fmNamesSlug(fm, own.slug, repoNs)) return null;
1491
1514
  const next = markArchived(content, 'superseded');
1492
1515
  return next === content ? null : { path, next };
1493
1516
  }
@@ -1517,14 +1540,14 @@ function takeOverMainNote({
1517
1540
  if (notOurGenerator(dfm) !== null || dfm['source-record'] !== displacedKey) return null;
1518
1541
  }
1519
1542
 
1520
- const ownStale = staleOwnDisambig(targetDir, slug, entryId, writer);
1543
+ const ownStale = staleOwnDisambig(targetDir, slug, entryId, writer, generatorOpts.repoNs);
1521
1544
 
1522
1545
  if (!dryRun) {
1523
1546
  // Re-read: an in-place leak heal above may have rewritten the note.
1524
1547
  const current = readFileSync(targetPath, 'utf8');
1525
1548
  // Rewritten even when it exists (and is ours, checked above): a stale copy
1526
1549
  // there may carry `status: archived` from an earlier archive pass.
1527
- writeFileSync(displacedPath, withNoteId(current, displaced.slug), 'utf8');
1550
+ writeFileSync(displacedPath, withNoteId(current, noteIdFor(generatorOpts.repoNs, displaced.slug)), 'utf8');
1528
1551
  // Before the main note: a crash between the two writes must leave the
1529
1552
  // takeover re-runnable (the main note still foreign), never a main note of
1530
1553
  // ours beside a stale live disambiguation note of the same record.
@@ -1633,7 +1656,7 @@ export async function processArchivedLearning(rawEntry, _lineNum, ctx) {
1633
1656
  if (!existsSync(path)) continue;
1634
1657
  const content = readFileSync(path, 'utf8');
1635
1658
  const fm = parseFrontmatter(content);
1636
- if (notOurGenerator(fm) !== null || fm['id'] !== name) continue;
1659
+ if (notOurGenerator(fm) !== null || !fmNamesSlug(fm, name, repoNs)) continue;
1637
1660
  if (foreignOwnerReason(fm, writer) !== null) continue;
1638
1661
  const next = markArchived(content, reason);
1639
1662
  if (next === content) {
@@ -1817,6 +1840,12 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1817
1840
  // (`subjectToSlug`), caps at 240 chars, or falls back to a uuid slug. A note
1818
1841
  // carrying either id is this record's own note.
1819
1842
  const renderedId = parseFrontmatter(renderedBody)?.id;
1843
+ // #1601/#1602: a note written before the namespaced id carries the bare slug
1844
+ // (`session_id`, or the renderer's whitespace-hyphenated slug); either form,
1845
+ // with or without the repo prefix, names this session.
1846
+ const legacyRenderedId = slugifyIdSafe(rawSessionId) ?? session_id;
1847
+ const isOwnSessionId = (fm) =>
1848
+ fm['id'] === renderedId || fmNamesSlug(fm, session_id, repoNs) || fmNamesSlug(fm, legacyRenderedId, repoNs);
1820
1849
 
1821
1850
  // Canonical filename pattern (issue #343): `<session_id>.md` where session_id
1822
1851
  // follows `<branch>-<YYYY-MM-DD>-<HHmm>-<slug>` per the session-id schema.
@@ -1843,14 +1872,14 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1843
1872
  const legacyStillLeaks = legacyFm !== null && maskerWouldChange(legacyContent);
1844
1873
  // #1503: a flat note from a foreign record is not ours — see processLearning's
1845
1874
  // dual-probe. A leak in it is masked in place, keeping its owner and content.
1846
- const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && legacyFm['id'] === session_id;
1875
+ const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && isOwnSessionId(legacyFm);
1847
1876
  const legacyForeign = legacyMarked && foreignOwnerReason(legacyFm, writer) !== null;
1848
1877
  if (legacyForeign && legacyStillLeaks) {
1849
1878
  healedForeignLeak = maskForeignNoteInPlace(legacyFlatPath, legacyContent, ctx);
1850
1879
  }
1851
1880
  if (legacyMarked && !legacyForeign) {
1852
1881
  const entryUpdated = toDate(entry.completed_at);
1853
- const legacyNarrativeStale = noteNarrativeChars(legacyContent) !== noteNarrativeChars(renderedBody);
1882
+ const legacyNarrativeStale = sessionNotesStale(legacyContent, renderedBody);
1854
1883
  if (!force && !legacyStillLeaks && !legacyNarrativeStale && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
1855
1884
  return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: session_id });
1856
1885
  }
@@ -1889,7 +1918,7 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1889
1918
  // no disambiguation scheme. Checked before the date comparison; `--force`
1890
1919
  // does not bypass it. A leak in a foreign note is masked in place, never
1891
1920
  // rewritten from this record.
1892
- const sameSession = fm['id'] === session_id || fm['id'] === renderedId;
1921
+ const sameSession = isOwnSessionId(fm);
1893
1922
  const foreign = foreignOwnerReason(fm, writer) ?? (sameSession ? null : 'id mismatch');
1894
1923
  if (foreign !== null) {
1895
1924
  const healed = existingLeaks && maskForeignNoteInPlace(targetPath, existingContent, ctx);
@@ -1900,7 +1929,7 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1900
1929
  // #1513 review H2: a note rendered before narrative/summary were printed
1901
1930
  // carries less free text than the record — heal it once, or the pruner
1902
1931
  // archives it as metrics-only and the mirror re-creates it every run.
1903
- const narrativeStale = noteNarrativeChars(existingContent) !== noteNarrativeChars(renderedBody);
1932
+ const narrativeStale = sessionNotesStale(existingContent, renderedBody);
1904
1933
  if (!force && !existingLeaks && !narrativeStale && fm['updated'] && fm['updated'] >= entryUpdated) {
1905
1934
  return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: session_id });
1906
1935
  }
@@ -54,7 +54,7 @@ import {
54
54
  import { createHash } from 'node:crypto';
55
55
  import { basename, dirname, join, resolve, sep } from 'node:path';
56
56
 
57
- import { parseFrontmatter, subjectToSlug } from './utils.mjs';
57
+ import { parseFrontmatter, subjectToSlug, noteIdMatches } from './utils.mjs';
58
58
  import {
59
59
  GENERATOR_MARKER,
60
60
  ROLLUP_PREFIX,
@@ -287,7 +287,10 @@ export function planPrune({ vaultDir, today, minNarrativeChars = 400, aliases =
287
287
  const target = `${n.zone}/${n.effNs}/${n.name}`;
288
288
  if (claimed.has(target) || existingRel.has(target)) {
289
289
  const twin = notes.find((m) => m.rel === target);
290
- if (twin && twin.fm.id === n.fm.id && !decided.has(twin.rel)) {
290
+ // #1601/#1602: the twin may already carry the namespaced id while the flat
291
+ // note keeps the bare pre-#1601 one — same note, either form.
292
+ const sameId = twin && (twin.fm.id === n.fm.id || noteIdMatches(twin.fm.id, n.fm.id, n.effNs) || noteIdMatches(n.fm.id, twin.fm.id, n.effNs));
293
+ if (sameId && !decided.has(twin.rel)) {
291
294
  actions.push({ path: n.rel, reason: 'duplicate', action: 'archive', target: archivePathFor(n.rel), keep: target });
292
295
  } else {
293
296
  actions.push({ path: n.rel, reason, action: 'keep', note: `relocation target taken: ${target}` });
@@ -5,7 +5,7 @@
5
5
  * sourceRecordValue, learningSourceRecord, sourceRecordLine (#1503)
6
6
  */
7
7
 
8
- import { toDate, truncateAtWord, yamlQuoteIfNeeded, legacySubjectToSlug, isValidSlug, buildTag, resolveSourceSessionLink } from './utils.mjs';
8
+ import { toDate, truncateAtWord, yamlQuoteIfNeeded, legacySubjectToSlug, isValidSlug, buildTag, resolveSourceSessionLink, noteIdFor, yamlNoteId } from './utils.mjs';
9
9
 
10
10
  const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
11
11
 
@@ -186,7 +186,7 @@ export function generateLearningNote(entry, slug, opts = {}) {
186
186
  const sourceSessionBodyLink = _srcIsLink ? `[[${_srcTarget}]]` : _srcTarget;
187
187
 
188
188
  return `---
189
- id: ${slug}
189
+ id: ${yamlNoteId(noteIdFor(opts.repoNs, slug))}
190
190
  type: learning
191
191
  title: ${title}
192
192
  status: ${status}
@@ -242,7 +242,7 @@ export function generateLearningNoteV2(entry, slug, opts = {}) {
242
242
  const recordLine = sourceRecordLine(learningSourceRecord(entry));
243
243
 
244
244
  return `---
245
- id: ${slug}
245
+ id: ${yamlNoteId(noteIdFor(opts.repoNs, slug))}
246
246
  type: learning
247
247
  title: ${title}
248
248
  status: ${status}