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
@@ -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, 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';
@@ -19,13 +19,13 @@ import { emitMirrorEvent } from './telemetry.mjs';
19
19
  import {
20
20
  ARCHIVE_ROOT,
21
21
  canonicalNamespace,
22
- legacyConcatSlug,
23
22
  noteNarrativeChars,
24
23
  markArchived,
25
24
  noteInsightKey,
26
25
  renderRollup,
27
26
  rollupRelPath,
28
27
  rollupRowFromNote,
28
+ section,
29
29
  sessionNarrativeChars,
30
30
  } from './retention.mjs';
31
31
 
@@ -269,6 +269,23 @@ export function matchesModuloRedaction(existingVal, renderedVal) {
269
269
  return new RegExp(`^${pattern}$`).test(renderedVal);
270
270
  }
271
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
+
272
289
  /**
273
290
  * Return true when the existing vault note content and the freshly-rendered
274
291
  * candidate share identical canonical fields (i.e. no meaningful update needed).
@@ -450,17 +467,6 @@ function disambigSlugFor(dir, slug, { legacyId, recordKey }) {
450
467
  return { slug: hashed, exists: null };
451
468
  }
452
469
 
453
- /**
454
- * Replace the frontmatter `id:` line of a note — used when a displaced note
455
- * moves to its disambiguated name (#1506.2), whose `id` must match the file.
456
- * @param {string} content
457
- * @param {string} id
458
- * @returns {string}
459
- */
460
- function withNoteId(content, id) {
461
- return content.replace(/^id: .*$/m, `id: ${id}`);
462
- }
463
-
464
470
  /**
465
471
  * Emit `skipped-foreign-owner` for a note the writer must not touch: one stderr
466
472
  * `SKIP` line (vault-relative path, like every other SKIP line) and the stdout
@@ -904,7 +910,7 @@ function maskEntrySecrets(entry) {
904
910
  * @param {'v1'|'v2'} schema
905
911
  * @returns {string}
906
912
  */
907
- function learningSlug(entry, schema) {
913
+ function learningSlug(entry, schema, slugger = learningNameSlugger(entry)) {
908
914
  const entryId = entry.id;
909
915
  let slugSource;
910
916
  if (schema === 'v2') {
@@ -916,7 +922,7 @@ function learningSlug(entry, schema) {
916
922
  }
917
923
  let slug;
918
924
  if (typeof slugSource === 'string' && slugSource.length > 0) {
919
- slug = subjectToSlug(slugSource);
925
+ slug = slugger(slugSource);
920
926
  } else {
921
927
  slug = '';
922
928
  }
@@ -936,6 +942,62 @@ function learningSlug(entry, schema) {
936
942
  }
937
943
 
938
944
 
945
+ /** A record without representable provenance (v1 or v2) keeps its historical name. */
946
+ function learningNameSlugger(entry) {
947
+ return learningSourceRecord(entry) === null ? legacySubjectToSlug : subjectToSlug;
948
+ }
949
+
950
+ /** Compare BEFORE filename capping so a changed concatenated name is protected. */
951
+ function hasLearningSlugTransition(entry, schema) {
952
+ if (learningNameSlugger(entry) === legacySubjectToSlug) return false;
953
+ const source = schema === 'v2' ? entry.id : entry.subject;
954
+ if (typeof source !== 'string' || source.length === 0) return false;
955
+ const normalized = schema === 'v1' ? source.trim().replace(/\s+/g, '-') : source;
956
+ return subjectToSlug(normalized) !== legacySubjectToSlug(normalized) ||
957
+ (schema === 'v1' && subjectToSlug(source) !== legacySubjectToSlug(source));
958
+ }
959
+
960
+ function historicalConcatSlug(subject) {
961
+ if (typeof subject !== 'string') return '';
962
+ return legacySubjectToSlug(subject);
963
+ }
964
+
965
+ /** All historical names, including both generations of disambiguation suffix. */
966
+ function learningSlugVariants(entry, schema) {
967
+ const bases = new Set([learningSlug(entry, schema), learningSlug(entry, schema, legacySubjectToSlug)]);
968
+ if (schema === 'v1') {
969
+ const concat = historicalConcatSlug(entry.subject);
970
+ if (isValidSlug(concat)) {
971
+ bases.add(concat);
972
+ if (concat.length > 240) bases.add(concat.slice(0, 240).replace(/-+$/, ''));
973
+ }
974
+ }
975
+ const names = new Set(bases);
976
+ const id = String(entry.id);
977
+ for (const base of bases) {
978
+ names.add(`${base}-${uuidPrefix8(id)}`);
979
+ names.add(`${base}-${recordKeySuffix8(learningSourceRecord(entry) ?? id)}`);
980
+ }
981
+ return [...names];
982
+ }
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
+
994
+ /** Only explicit, exact provenance permits assigning an old name to a record. */
995
+ function positiveLearningOwner(fm, name, repoNs, recordKey) {
996
+ return recordKey !== null && notOurGenerator(fm) === null &&
997
+ fmNamesSlug(fm, name, repoNs) && fm['source-record'] === recordKey && fm['source-repo'] === repoNs;
998
+ }
999
+
1000
+
939
1001
  export async function processLearning(rawEntry, _lineNum, ctx) {
940
1002
  const {
941
1003
  vaultDir,
@@ -1023,10 +1085,8 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1023
1085
  // #1513 review M2: a hyphen alias (`gotzendorferv2`) resolves to the
1024
1086
  // canonical folder already in the vault (`gotzendorfer-v2`), so the mirror
1025
1087
  // and the pruner agree on where a repo's notes live.
1026
- const repoNs = canonicalNamespace(
1027
- vaultDir,
1028
- resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
1029
- );
1088
+ const stableNs = resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null });
1089
+ const repoNs = canonicalNamespace(vaultDir, stableNs);
1030
1090
  // #725 D2: thread the resolved repo namespace into the learning frontmatter as
1031
1091
  // `source-repo` for cross-repo attribution. repoNs is already sanitised +
1032
1092
  // leak-guarded by resolveRepoNamespace, so it is safe to interpolate as-is. The
@@ -1075,18 +1135,56 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1075
1135
  return null;
1076
1136
  };
1077
1137
  const targetDir = join(resolve(vaultDir), '40-learnings', repoNs);
1078
- if (!dryRun) mkdirSync(targetDir, { recursive: true });
1079
1138
 
1080
1139
  let targetPath = join(targetDir, `${slug}.md`);
1081
1140
 
1141
+ // #1548: inspect EVERY candidate before mutation when normalization changes
1142
+ // a name. Missing/foreign/ambiguous provenance is not permission to create a
1143
+ // second note, including under --force. Keep the actual flat or namespace path.
1144
+ const slugTransition = hasLearningSlugTransition(entry, schema);
1145
+ if (slugTransition) {
1146
+ const matches = [];
1147
+ for (const dir of [targetDir, join(resolve(vaultDir), '40-learnings')]) {
1148
+ for (const name of learningSlugVariants(entry, schema)) {
1149
+ const path = join(dir, `${name}.md`);
1150
+ if (!existsSync(path)) continue;
1151
+ const fm = parseFrontmatter(readFileSync(path, 'utf8'));
1152
+ matches.push({ path, name, valid: positiveLearningOwner(fm, name, repoNs, writer.recordKey) });
1153
+ }
1154
+ }
1155
+ if (matches.length > 1 || matches.some((match) => !match.valid)) {
1156
+ return emitEntryAction(_lineNum, ctx, {
1157
+ action: 'skipped-foreign-owner', path: null, id: entryId,
1158
+ meta: { reason: 'slug transition: ambiguous or missing/contradictory provenance' },
1159
+ });
1160
+ }
1161
+ if (matches.length === 1) {
1162
+ slug = matches[0].name;
1163
+ targetPath = matches[0].path;
1164
+ }
1165
+ }
1166
+
1167
+ // A provenance-less flat v2 note cannot be assigned to this namespace.
1168
+ // Keep it untouched rather than creating a second copy on an advanced date.
1169
+ if (schema === 'v2' && writer.recordKey === null &&
1170
+ learningSlugVariants(entry, schema).some((name) =>
1171
+ existsSync(join(resolve(vaultDir), '40-learnings', name + '.md')))) {
1172
+ return emitEntryAction(_lineNum, ctx, {
1173
+ action: 'skipped-foreign-owner', path: null, id: entryId,
1174
+ meta: { reason: 'flat note: missing representable source provenance' },
1175
+ });
1176
+ }
1177
+
1178
+ if (!dryRun) mkdirSync(targetDir, { recursive: true });
1179
+
1082
1180
  // #1513 LEGACY SLUG: until #725 D1 a v1 subject was slugged WITHOUT the
1083
1181
  // whitespace → hyphen pre-map ("3parallelimplagents…"); the canonical form
1084
1182
  // hyphenates ("3-parallel-impl-agents…"). When only the legacy variant of
1085
1183
  // THIS record's note exists, it IS the note: it is updated in place under
1086
1184
  // its own name instead of a second file being created beside it (the vault
1087
1185
  // carried 364 such twins). New notes always get the canonical name.
1088
- if (schema === 'v1') {
1089
- const legacySlug = legacyConcatSlug(entry.subject);
1186
+ if (!slugTransition && schema === 'v1') {
1187
+ const legacySlug = historicalConcatSlug(entry.subject);
1090
1188
  const flatDir = join(resolve(vaultDir), '40-learnings');
1091
1189
  if (
1092
1190
  isValidSlug(legacySlug) &&
@@ -1100,11 +1198,13 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1100
1198
  const legacyFm = parseFrontmatter(readFileSync(legacyPath, 'utf8'));
1101
1199
  if (
1102
1200
  notOurGenerator(legacyFm) === null &&
1103
- legacyFm['id'] === legacySlug &&
1201
+ fmNamesSlug(legacyFm, legacySlug, repoNs) &&
1104
1202
  foreignOwnerReason(legacyFm, writer) === null
1105
1203
  ) {
1106
1204
  slug = legacySlug;
1107
- targetPath = join(targetDir, `${legacySlug}.md`);
1205
+ targetPath = positiveLearningOwner(legacyFm, legacySlug, repoNs, writer.recordKey)
1206
+ ? legacyPath
1207
+ : join(targetDir, `${legacySlug}.md`);
1108
1208
  break;
1109
1209
  }
1110
1210
  }
@@ -1114,8 +1214,8 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1114
1214
  // #660 IDEMPOTENCY DUAL-PROBE: before treating the namespaced path as absent,
1115
1215
  // also check the legacy flat path. If a note with the same slug already exists
1116
1216
  // in the flat layout (pre-namespace migration), treat it as existing to avoid
1117
- // duplicating the note. The deferred-migration decision means we only skip;
1118
- // we do NOT move the flat note into the namespaced dir here.
1217
+ // duplicating the note. Positive provenance also permits an update at the
1218
+ // actual flat path; notes without that evidence keep their historical rules.
1119
1219
  const legacyFlatPath = join(resolve(vaultDir), '40-learnings', `${slug}.md`);
1120
1220
  // Set when a still-leaking legacy flat note was healed in place below, so the
1121
1221
  // action emitted for THIS entry can say so (see the write sites at the end).
@@ -1137,12 +1237,16 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1137
1237
  // neither a duplicate to skip on nor a file to rewrite with THIS record's
1138
1238
  // content; this entry proceeds to its own namespaced path (a different
1139
1239
  // file). A leak in it is masked in place, keeping its owner and content.
1140
- const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && legacyFm['id'] === slug;
1240
+ const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && fmNamesSlug(legacyFm, slug, repoNs);
1141
1241
  const legacyForeign = legacyMarked && foreignOwnerReason(legacyFm, writer) !== null;
1142
1242
  if (legacyForeign && legacyStillLeaks) {
1143
1243
  healedForeignLeak = maskForeignNoteInPlace(legacyFlatPath, legacyContent, ctx);
1144
1244
  }
1145
- if (legacyMarked && !legacyForeign) {
1245
+ if (positiveLearningOwner(legacyFm, slug, repoNs, writer.recordKey)) {
1246
+ // A proven flat note is the update target. Let the common existing-note
1247
+ // branch below compare/render/heal it ONCE; never pre-write a healed copy.
1248
+ targetPath = legacyFlatPath;
1249
+ } else if (legacyMarked && !legacyForeign) {
1146
1250
  const entryUpdated = toDate(dateSource);
1147
1251
  if (!force && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
1148
1252
  // Date has not advanced — but content may have changed (confidence, insight, etc.).
@@ -1152,6 +1256,9 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1152
1256
  // #1028 residue 1: the five-field compare cannot see a raw secret
1153
1257
  // sitting in a non-canonical field (e.g. `evidence`) — the
1154
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.
1155
1262
  return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: slug });
1156
1263
  }
1157
1264
  // Content differs, or the on-disk note still leaks under the current
@@ -1220,7 +1327,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1220
1327
  const entryDay = toDate(dateSource);
1221
1328
  if (
1222
1329
  routedByRecord &&
1223
- fm['id'] === slug &&
1330
+ fmNamesSlug(fm, slug, repoNs) &&
1224
1331
  !expired &&
1225
1332
  /^\d{4}-\d{2}-\d{2}$/.test(entryDay) &&
1226
1333
  typeof fm['created'] === 'string' &&
@@ -1233,7 +1340,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1233
1340
  if (takenOver !== null) return takenOver;
1234
1341
  }
1235
1342
 
1236
- if (fm['id'] !== slug || routedByRecord) {
1343
+ if (!fmNamesSlug(fm, slug, repoNs) || routedByRecord) {
1237
1344
  // Different id (or different record) → collision: disambiguate
1238
1345
  const picked = disambigSlugFor(targetDir, slug, {
1239
1346
  legacyId: String(entryId),
@@ -1313,8 +1420,8 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1313
1420
 
1314
1421
  // #1506.2: the main note is this record's — a live disambiguation copy of
1315
1422
  // the same record (left by an interrupted takeover) is superseded.
1316
- const stale = fm['source-record'] === writer.recordKey
1317
- ? staleOwnDisambig(targetDir, slug, entryId, writer)
1423
+ const stale = !slugTransition && fm['source-record'] === writer.recordKey
1424
+ ? staleOwnDisambig(targetDir, slug, entryId, writer, repoNs)
1318
1425
  : null;
1319
1426
 
1320
1427
  // Same id: check if updated would advance (unless --force overrides).
@@ -1327,6 +1434,9 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1327
1434
  if (learningContentMatches(existingContent, candidateContent)) {
1328
1435
  // #1028 residue 1: probe the on-disk content against the CURRENT masker
1329
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.
1330
1440
  mainUnchanged = !maskerWouldChange(existingContent);
1331
1441
  }
1332
1442
  // Content differs, or the on-disk note still leaks under the current env —
@@ -1351,14 +1461,19 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1351
1461
  return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: slug });
1352
1462
  }
1353
1463
 
1354
- // Overwrite with advanced updated date (or forced re-render)
1464
+ // Overwrite with advanced updated date (or forced re-render). A retained
1465
+ // flat note, including an old Unicode slug, is healed by this single write.
1355
1466
  const content = generator(entry, slug, generatorOpts);
1467
+ healedLegacyFlat = !dryRun && targetPath === legacyFlatPath && maskerWouldChange(existingContent);
1356
1468
  if (!dryRun) writeFileSync(targetPath, content, 'utf8');
1357
1469
  return emitEntryAction(_lineNum, ctx, {
1358
1470
  action: 'updated',
1359
1471
  path: targetPath,
1360
1472
  id: slug,
1361
- ...(stale !== null ? { meta: { superseded_id: basename(stale.path, '.md') } } : {}),
1473
+ ...((stale !== null || healedLegacyFlat) ? { meta: {
1474
+ ...(stale !== null ? { superseded_id: basename(stale.path, '.md') } : {}),
1475
+ ...(healedLegacyFlat ? { healed_legacy_flat: true } : {}),
1476
+ } } : {}),
1362
1477
  });
1363
1478
  }
1364
1479
 
@@ -1388,14 +1503,14 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1388
1503
  *
1389
1504
  * @returns {{ path: string, next: string }|null} the rewrite, or null.
1390
1505
  */
1391
- function staleOwnDisambig(targetDir, slug, entryId, writer) {
1506
+ function staleOwnDisambig(targetDir, slug, entryId, writer, repoNs) {
1392
1507
  if (typeof writer.recordKey !== 'string') return null;
1393
1508
  const own = disambigSlugFor(targetDir, slug, { legacyId: String(entryId), recordKey: writer.recordKey });
1394
1509
  const path = join(targetDir, `${own.slug}.md`);
1395
1510
  if (!(own.exists ?? existsSync(path))) return null;
1396
1511
  const content = readFileSync(path, 'utf8');
1397
1512
  const fm = parseFrontmatter(content);
1398
- 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;
1399
1514
  const next = markArchived(content, 'superseded');
1400
1515
  return next === content ? null : { path, next };
1401
1516
  }
@@ -1425,14 +1540,14 @@ function takeOverMainNote({
1425
1540
  if (notOurGenerator(dfm) !== null || dfm['source-record'] !== displacedKey) return null;
1426
1541
  }
1427
1542
 
1428
- const ownStale = staleOwnDisambig(targetDir, slug, entryId, writer);
1543
+ const ownStale = staleOwnDisambig(targetDir, slug, entryId, writer, generatorOpts.repoNs);
1429
1544
 
1430
1545
  if (!dryRun) {
1431
1546
  // Re-read: an in-place leak heal above may have rewritten the note.
1432
1547
  const current = readFileSync(targetPath, 'utf8');
1433
1548
  // Rewritten even when it exists (and is ours, checked above): a stale copy
1434
1549
  // there may carry `status: archived` from an earlier archive pass.
1435
- writeFileSync(displacedPath, withNoteId(current, displaced.slug), 'utf8');
1550
+ writeFileSync(displacedPath, withNoteId(current, noteIdFor(generatorOpts.repoNs, displaced.slug)), 'utf8');
1436
1551
  // Before the main note: a crash between the two writes must leave the
1437
1552
  // takeover re-runnable (the main note still foreign), never a main note of
1438
1553
  // ours beside a stale live disambiguation note of the same record.
@@ -1465,17 +1580,7 @@ function takeOverMainNote({
1465
1580
  export function learningNoteSlugs(rawEntry) {
1466
1581
  const entry = normalizeLearningEntry(rawEntry);
1467
1582
  if (!entry || entry.id === null || entry.id === undefined) return [];
1468
- const schema = detectLearningSchema(entry);
1469
- const slugs = [learningSlug(entry, schema)];
1470
- if (schema === 'v1') {
1471
- const legacy = legacyConcatSlug(entry.subject);
1472
- if (isValidSlug(legacy) && !slugs.includes(legacy)) slugs.push(legacy);
1473
- }
1474
- const id = String(entry.id);
1475
- for (const name of [`${slugs[0]}-${uuidPrefix8(id)}`, `${slugs[0]}-${recordKeySuffix8(learningSourceRecord(entry) ?? id)}`]) {
1476
- if (!slugs.includes(name)) slugs.push(name);
1477
- }
1478
- return slugs;
1583
+ return learningSlugVariants(entry, detectLearningSchema(entry));
1479
1584
  }
1480
1585
 
1481
1586
  /**
@@ -1515,20 +1620,43 @@ export async function processArchivedLearning(rawEntry, _lineNum, ctx) {
1515
1620
  meta: { reason: 'archived record: a live record owns this note' },
1516
1621
  });
1517
1622
  if (names.length === 0) return liveOwned();
1518
- const repoNs = canonicalNamespace(
1519
- vaultDir,
1520
- resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
1521
- );
1623
+ const stableNs = resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null });
1624
+ const repoNs = canonicalNamespace(vaultDir, stableNs);
1522
1625
  const nsDir = join(resolve(vaultDir), '40-learnings', repoNs);
1523
1626
  const flatDir = join(resolve(vaultDir), '40-learnings');
1524
1627
  const writer = { recordKey: learningSourceRecord(entry) };
1628
+ const schema = detectLearningSchema(entry);
1629
+ if (schema === 'v2' && writer.recordKey === null &&
1630
+ allNames.some((name) => existsSync(join(flatDir, name + '.md')))) {
1631
+ return emitEntryAction(_lineNum, ctx, {
1632
+ action: 'skipped-foreign-owner', path: null, id: entry.id,
1633
+ meta: { reason: 'flat note: missing representable source provenance' },
1634
+ });
1635
+ }
1636
+ if (hasLearningSlugTransition(entry, schema)) {
1637
+ const matches = [];
1638
+ for (const dir of [nsDir, flatDir]) {
1639
+ for (const name of allNames) {
1640
+ const path = join(dir, `${name}.md`);
1641
+ if (!existsSync(path)) continue;
1642
+ const fm = parseFrontmatter(readFileSync(path, 'utf8'));
1643
+ matches.push(positiveLearningOwner(fm, name, repoNs, writer.recordKey));
1644
+ }
1645
+ }
1646
+ if (matches.length > 1 || matches.some((valid) => !valid)) {
1647
+ return emitEntryAction(_lineNum, ctx, {
1648
+ action: 'skipped-foreign-owner', path: null, id: entry.id,
1649
+ meta: { reason: 'slug transition: ambiguous or missing/contradictory provenance' },
1650
+ });
1651
+ }
1652
+ }
1525
1653
  for (const dir of [nsDir, flatDir]) {
1526
1654
  for (const name of names) {
1527
1655
  const path = join(dir, `${name}.md`);
1528
1656
  if (!existsSync(path)) continue;
1529
1657
  const content = readFileSync(path, 'utf8');
1530
1658
  const fm = parseFrontmatter(content);
1531
- if (notOurGenerator(fm) !== null || fm['id'] !== name) continue;
1659
+ if (notOurGenerator(fm) !== null || !fmNamesSlug(fm, name, repoNs)) continue;
1532
1660
  if (foreignOwnerReason(fm, writer) !== null) continue;
1533
1661
  const next = markArchived(content, reason);
1534
1662
  if (next === content) {
@@ -1615,8 +1743,8 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1615
1743
  const generator =
1616
1744
  schema === 'v3' ? generateSessionNoteV3 : schema === 'v2' ? generateSessionNoteV2 : generateSessionNote;
1617
1745
 
1618
- // Validate session_id as a filesystem-safe slug; fall back via subjectToSlug
1619
- // (which collapses slashes to last segment + strips invalid chars) before
1746
+ // Validate session_id as a filesystem-safe slug; preserve old Session identity
1747
+ // with the historical sanitizer (collapse slash paths + strip invalid chars), before
1620
1748
  // resorting to a uuid-derived slug. Without subjectToSlug, raw slashes in
1621
1749
  // rawSessionId (e.g. "feat/opus-4-7-...") would survive uuidPrefix8 and
1622
1750
  // produce a path with directory separators in the basename.
@@ -1624,7 +1752,7 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1624
1752
  if (isValidSlug(rawSessionId)) {
1625
1753
  session_id = rawSessionId;
1626
1754
  } else if (typeof rawSessionId === 'string' && rawSessionId.length > 0) {
1627
- const sanitised = subjectToSlug(rawSessionId);
1755
+ const sanitised = legacySubjectToSlug(rawSessionId);
1628
1756
  session_id = isValidSlug(sanitised) ? sanitised : `session-${uuidPrefix8(rawSessionId)}`;
1629
1757
  } else {
1630
1758
  session_id = `session-${uuidPrefix8(randomUUID())}`;
@@ -1671,10 +1799,8 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1671
1799
  // owner-leaky repo's real name reached the vault through the session-note
1672
1800
  // frontmatter even though the directory AND the learning `source-repo` field
1673
1801
  // were already pseudonym-mapped/redacted (#732 leak-guard bypass).
1674
- const repoNs = canonicalNamespace(
1675
- vaultDir,
1676
- resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
1677
- );
1802
+ const stableNs = resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null });
1803
+ const repoNs = canonicalNamespace(vaultDir, stableNs);
1678
1804
 
1679
1805
  // Narrative gate (#1513, replaces the PRD F1.2 rendered-length gate). A
1680
1806
  // session gets its own note only when its FREE-TEXT fields (`notes`,
@@ -1714,6 +1840,12 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1714
1840
  // (`subjectToSlug`), caps at 240 chars, or falls back to a uuid slug. A note
1715
1841
  // carrying either id is this record's own note.
1716
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);
1717
1849
 
1718
1850
  // Canonical filename pattern (issue #343): `<session_id>.md` where session_id
1719
1851
  // follows `<branch>-<YYYY-MM-DD>-<HHmm>-<slug>` per the session-id schema.
@@ -1740,14 +1872,14 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1740
1872
  const legacyStillLeaks = legacyFm !== null && maskerWouldChange(legacyContent);
1741
1873
  // #1503: a flat note from a foreign record is not ours — see processLearning's
1742
1874
  // dual-probe. A leak in it is masked in place, keeping its owner and content.
1743
- const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && legacyFm['id'] === session_id;
1875
+ const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && isOwnSessionId(legacyFm);
1744
1876
  const legacyForeign = legacyMarked && foreignOwnerReason(legacyFm, writer) !== null;
1745
1877
  if (legacyForeign && legacyStillLeaks) {
1746
1878
  healedForeignLeak = maskForeignNoteInPlace(legacyFlatPath, legacyContent, ctx);
1747
1879
  }
1748
1880
  if (legacyMarked && !legacyForeign) {
1749
1881
  const entryUpdated = toDate(entry.completed_at);
1750
- const legacyNarrativeStale = noteNarrativeChars(legacyContent) !== noteNarrativeChars(renderedBody);
1882
+ const legacyNarrativeStale = sessionNotesStale(legacyContent, renderedBody);
1751
1883
  if (!force && !legacyStillLeaks && !legacyNarrativeStale && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
1752
1884
  return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: session_id });
1753
1885
  }
@@ -1786,7 +1918,7 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1786
1918
  // no disambiguation scheme. Checked before the date comparison; `--force`
1787
1919
  // does not bypass it. A leak in a foreign note is masked in place, never
1788
1920
  // rewritten from this record.
1789
- const sameSession = fm['id'] === session_id || fm['id'] === renderedId;
1921
+ const sameSession = isOwnSessionId(fm);
1790
1922
  const foreign = foreignOwnerReason(fm, writer) ?? (sameSession ? null : 'id mismatch');
1791
1923
  if (foreign !== null) {
1792
1924
  const healed = existingLeaks && maskForeignNoteInPlace(targetPath, existingContent, ctx);
@@ -1797,7 +1929,7 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1797
1929
  // #1513 review H2: a note rendered before narrative/summary were printed
1798
1930
  // carries less free text than the record — heal it once, or the pruner
1799
1931
  // archives it as metrics-only and the mirror re-creates it every run.
1800
- const narrativeStale = noteNarrativeChars(existingContent) !== noteNarrativeChars(renderedBody);
1932
+ const narrativeStale = sessionNotesStale(existingContent, renderedBody);
1801
1933
  if (!force && !existingLeaks && !narrativeStale && fm['updated'] && fm['updated'] >= entryUpdated) {
1802
1934
  return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: session_id });
1803
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, subjectToSlug, 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
 
@@ -118,9 +118,10 @@ export function normalizeLearningEntry(entry) {
118
118
  e.evidence = joinList(e.evidence_sessions) ?? joinList(e.files) ?? joinList(e.sessions) ?? '(none recorded)';
119
119
  }
120
120
  if (e.id === null || e.id === undefined) {
121
- // subjectToSlug strips whitespace without hyphenating (designed for
121
+ // Keep historical ASCII deletion for record identity; only note names
122
+ // transliterate. The old slugger strips whitespace (designed for
122
123
  // kebab-ish inputs) — pre-map whitespace to hyphens for prose subjects.
123
- const derived = firstString(e.subject) ? subjectToSlug(e.subject.trim().replace(/\s+/g, '-')) : '';
124
+ const derived = firstString(e.subject) ? legacySubjectToSlug(e.subject.trim().replace(/\s+/g, '-')) : '';
124
125
  if (isValidSlug(derived)) e.id = derived;
125
126
  }
126
127
  if (e.source_session === null || e.source_session === undefined) {
@@ -157,7 +158,7 @@ export function generateLearningNote(entry, slug, opts = {}) {
157
158
  // through buildTag so each is kebab-slugified and capped at 64 chars. The
158
159
  // existing source/<session> slug is preserved (buildTag is idempotent for an
159
160
  // already-kebab value and adds the length cap).
160
- const sourceTag = subjectToSlug(String(source_session)) || 'unknown';
161
+ const sourceTag = legacySubjectToSlug(String(source_session)) || 'unknown';
161
162
  const tags = `[${buildTag(['learning', type])}, ${buildTag(['status', status])}, ${buildTag(['source', sourceTag])}]`;
162
163
 
163
164
  // Check if expires has a value; it's optional in schema
@@ -185,7 +186,7 @@ export function generateLearningNote(entry, slug, opts = {}) {
185
186
  const sourceSessionBodyLink = _srcIsLink ? `[[${_srcTarget}]]` : _srcTarget;
186
187
 
187
188
  return `---
188
- id: ${slug}
189
+ id: ${yamlNoteId(noteIdFor(opts.repoNs, slug))}
189
190
  type: learning
190
191
  title: ${title}
191
192
  status: ${status}
@@ -230,7 +231,7 @@ export function generateLearningNoteV2(entry, slug, opts = {}) {
230
231
  const title = yamlQuoteIfNeeded(titleRaw);
231
232
 
232
233
  // #602: type/status interpolated raw upstream — slugify + cap each segment.
233
- const scopeTag = subjectToSlug(scope) || 'unscoped';
234
+ const scopeTag = legacySubjectToSlug(scope) || 'unscoped';
234
235
  const tags = `[${buildTag(['learning', type])}, ${buildTag(['status', status])}, ${buildTag(['scope', scopeTag])}]`;
235
236
 
236
237
  // #725 D2: emit `source-repo` for cross-repo attribution when the caller threads
@@ -241,7 +242,7 @@ export function generateLearningNoteV2(entry, slug, opts = {}) {
241
242
  const recordLine = sourceRecordLine(learningSourceRecord(entry));
242
243
 
243
244
  return `---
244
- id: ${slug}
245
+ id: ${yamlNoteId(noteIdFor(opts.repoNs, slug))}
245
246
  type: learning
246
247
  title: ${title}
247
248
  status: ${status}