session-orchestrator 3.23.0 → 4.0.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 (393) hide show
  1. package/.agents/skills/architecture/SKILL.md +18 -0
  2. package/.agents/skills/autopilot/SKILL.md +17 -0
  3. package/.agents/skills/bootstrap/SKILL.md +20 -0
  4. package/.agents/skills/brainstorm/SKILL.md +22 -0
  5. package/.agents/skills/claude-md-drift-check/SKILL.md +15 -0
  6. package/.agents/skills/convergence-monitoring/SKILL.md +22 -0
  7. package/.agents/skills/debug/SKILL.md +22 -0
  8. package/.agents/skills/discovery/SKILL.md +20 -0
  9. package/.agents/skills/dispatcher/SKILL.md +15 -0
  10. package/.agents/skills/docs-orchestrator/SKILL.md +18 -0
  11. package/.agents/skills/ecosystem-health/SKILL.md +20 -0
  12. package/.agents/skills/eli5/SKILL.md +20 -0
  13. package/.agents/skills/eval/SKILL.md +21 -0
  14. package/.agents/skills/evolve/SKILL.md +21 -0
  15. package/.agents/skills/frontmatter-guard/SKILL.md +15 -0
  16. package/.agents/skills/gitlab-ops/SKILL.md +20 -0
  17. package/.agents/skills/gitlab-portfolio/SKILL.md +15 -0
  18. package/.agents/skills/grill/SKILL.md +22 -0
  19. package/.agents/skills/hook-development/SKILL.md +15 -0
  20. package/.agents/skills/mcp-builder/SKILL.md +15 -0
  21. package/.agents/skills/memory-cleanup/SKILL.md +21 -0
  22. package/.agents/skills/mode-selector/SKILL.md +17 -0
  23. package/.agents/skills/npm-publish/SKILL.md +16 -0
  24. package/.agents/skills/peekaboo-driver/SKILL.md +18 -0
  25. package/.agents/skills/persona-panel/SKILL.md +17 -0
  26. package/.agents/skills/plan/SKILL.md +20 -0
  27. package/.agents/skills/playwright-driver/SKILL.md +20 -0
  28. package/.agents/skills/quality-gates/SKILL.md +20 -0
  29. package/.agents/skills/reconcile/SKILL.md +21 -0
  30. package/.agents/skills/remote-offload/SKILL.md +20 -0
  31. package/.agents/skills/repo-audit/SKILL.md +16 -0
  32. package/.agents/skills/session-end/SKILL.md +20 -0
  33. package/.agents/skills/session-plan/SKILL.md +20 -0
  34. package/.agents/skills/session-start/SKILL.md +20 -0
  35. package/.agents/skills/spinout/SKILL.md +16 -0
  36. package/.agents/skills/sunset-review/SKILL.md +16 -0
  37. package/.agents/skills/test-runner/SKILL.md +20 -0
  38. package/.agents/skills/tmux-layout/SKILL.md +21 -0
  39. package/.agents/skills/using-orchestrator/SKILL.md +17 -0
  40. package/.agents/skills/vault-mirror/SKILL.md +15 -0
  41. package/.agents/skills/vault-sync/SKILL.md +15 -0
  42. package/.agents/skills/wave-executor/SKILL.md +20 -0
  43. package/.agents/skills/write-executable-plan/SKILL.md +22 -0
  44. package/.claude-plugin/marketplace.json +1 -1
  45. package/.claude-plugin/plugin.json +1 -1
  46. package/.codex-plugin/plugin.json +1 -1
  47. package/.cursor/commands/autopilot.md +2 -2
  48. package/.cursor/commands/bootstrap.md +1 -1
  49. package/.cursor/commands/brainstorm.md +1 -1
  50. package/.cursor/commands/debug.md +1 -1
  51. package/.cursor/commands/discovery.md +1 -1
  52. package/.cursor/commands/dispatcher.md +2 -2
  53. package/.cursor/commands/eli5.md +2 -2
  54. package/.cursor/commands/eval.md +2 -2
  55. package/.cursor/commands/evolve.md +1 -1
  56. package/.cursor/commands/go.md +1 -1
  57. package/.cursor/commands/grill.md +2 -2
  58. package/.cursor/commands/memory-cleanup.md +2 -2
  59. package/.cursor/commands/persona-panel.md +1 -1
  60. package/.cursor/commands/plan.md +1 -1
  61. package/.cursor/commands/portfolio.md +1 -1
  62. package/.cursor/commands/reconcile.md +2 -2
  63. package/.cursor/commands/release.md +2 -2
  64. package/.cursor/commands/session.md +2 -2
  65. package/.cursor/commands/spinout.md +2 -2
  66. package/.cursor/commands/sunset-review.md +2 -2
  67. package/.cursor/commands/templates-ack.md +2 -2
  68. package/.cursor/commands/test.md +2 -2
  69. package/.cursor/skills/brainstorm/SKILL.md +1 -1
  70. package/.cursor/skills/eval/SKILL.md +1 -1
  71. package/.cursor/skills/quality-gates/SKILL.md +1 -1
  72. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  73. package/.orchestrator/policy/blocked-commands.json +121 -0
  74. package/.orchestrator/policy/ecosystem.schema.json +66 -0
  75. package/.orchestrator/policy/quality-gates.example.json +16 -0
  76. package/.orchestrator/policy/quality-gates.schema.json +38 -0
  77. package/.orchestrator/policy/templates-policy.json +27 -0
  78. package/.orchestrator/policy/test-profiles.json +47 -0
  79. package/AGENTS.md +225 -0
  80. package/CHANGELOG.md +1401 -0
  81. package/NOTICE +11 -6
  82. package/README.md +127 -92
  83. package/agents/db-specialist.md +0 -1
  84. package/agents/eval-judge.md +1 -1
  85. package/agents/skill-applied-judge.md +1 -1
  86. package/assets/wave-lifecycle.svg +98 -0
  87. package/commands/release.md +6 -3
  88. package/commands/session.md +18 -3
  89. package/docs/README.md +4 -0
  90. package/{agents/AGENTS.md → docs/agent-authoring.md} +19 -26
  91. package/docs/baseline.md +67 -0
  92. package/docs/ci-setup.md +249 -48
  93. package/docs/codex-setup.md +66 -22
  94. package/docs/components.md +37 -16
  95. package/docs/cursor-setup.md +6 -2
  96. package/docs/events-schema.md +51 -10
  97. package/docs/instruction-delivery.md +62 -0
  98. package/{agents/memory-proposal-collector.md → docs/memory-proposal-flow.md} +1 -8
  99. package/docs/migration-v4.md +341 -0
  100. package/docs/pi-setup.md +6 -1
  101. package/docs/plugin-architecture-v3.md +1 -1
  102. package/docs/rule-authoring.md +85 -19
  103. package/docs/scope-collision-guard.md +8 -8
  104. package/docs/session-config-reference.md +120 -61
  105. package/docs/session-config-template.md +40 -33
  106. package/docs/telemetry/telemetry-claims.md +11 -10
  107. package/docs/telemetry.md +187 -4
  108. package/docs/vault-docs-architecture.md +50 -11
  109. package/hooks/_lib/atomic-json.mjs +111 -0
  110. package/hooks/_lib/hook-import-set.json +1487 -0
  111. package/hooks/_lib/subagent-paths.mjs +143 -0
  112. package/hooks/_lib/subagent-transcript.mjs +562 -0
  113. package/hooks/config-protection.mjs +2 -2
  114. package/hooks/cwd-change-restore.mjs +11 -31
  115. package/hooks/enforce-commands.mjs +69 -0
  116. package/hooks/enforce-scope.mjs +35 -6
  117. package/hooks/hooks-codex.json +1 -1
  118. package/hooks/hooks-cursor.json +10 -0
  119. package/hooks/hooks-pi.json +5 -0
  120. package/hooks/hooks.json +6 -1
  121. package/hooks/loop-guard.mjs +3 -3
  122. package/hooks/on-session-end.mjs +280 -14
  123. package/hooks/on-session-start.mjs +153 -4
  124. package/hooks/on-stop.mjs +371 -17
  125. package/hooks/operator-steer.mjs +2 -2
  126. package/hooks/post-bash-write-verify.mjs +189 -4
  127. package/hooks/post-edit-import-probe.mjs +344 -0
  128. package/hooks/post-subagent-discovery-validator.mjs +278 -392
  129. package/hooks/post-tool-batch-wave-signal.mjs +272 -44
  130. package/hooks/post-tool-failure-corrective-context.mjs +11 -34
  131. package/hooks/post-tooluse-frontend-slop.mjs +3 -3
  132. package/hooks/pre-bash-destructive-guard.mjs +39 -13
  133. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  134. package/hooks/skill-invocation-telemetry.mjs +17 -5
  135. package/hooks/subagent-telemetry.mjs +24 -30
  136. package/monitors/monitors.json +3 -3
  137. package/package.json +9 -1
  138. package/pi/prompts/session.md +2 -2
  139. package/plugin.json +27 -0
  140. package/scripts/autopilot.mjs +26 -12
  141. package/scripts/backfill-abandoned-sessions.mjs +130 -15
  142. package/scripts/backfill-learnings-from-vault.mjs +9 -3
  143. package/scripts/dialectic-deriver.mjs +73 -8
  144. package/scripts/emit-event.mjs +10 -2
  145. package/scripts/export-hw-learnings.mjs +113 -1
  146. package/scripts/generate-agents-skills.mjs +378 -0
  147. package/scripts/generate-cursor-adapter.mjs +45 -8
  148. package/scripts/generate-hook-import-set.mjs +249 -0
  149. package/scripts/lib/agent-status.mjs +13 -2
  150. package/scripts/lib/auq/parse.mjs +5 -29
  151. package/scripts/lib/auto-dialectic.mjs +68 -0
  152. package/scripts/lib/auto-dream.mjs +38 -36
  153. package/scripts/lib/autonomy/suitability.mjs +6 -0
  154. package/scripts/lib/autopilot/loop.mjs +2 -2
  155. package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
  156. package/scripts/lib/build-live-signals.mjs +25 -22
  157. package/scripts/lib/ci-status-banner.mjs +220 -75
  158. package/scripts/lib/codex/plugin-contract.mjs +82 -6
  159. package/scripts/lib/cold-start-detector.mjs +23 -14
  160. package/scripts/lib/config/auto-dream.mjs +2 -1
  161. package/scripts/lib/config/block-header.mjs +63 -0
  162. package/scripts/lib/config/block-preprocess.mjs +177 -0
  163. package/scripts/lib/config/broken-window.mjs +2 -1
  164. package/scripts/lib/config/cold-start.mjs +2 -1
  165. package/scripts/lib/config/config-protection.mjs +22 -2
  166. package/scripts/lib/config/context-coverage.mjs +2 -1
  167. package/scripts/lib/config/cross-repo.mjs +2 -1
  168. package/scripts/lib/config/custom-phases.mjs +2 -1
  169. package/scripts/lib/config/dialectic.mjs +2 -1
  170. package/scripts/lib/config/discovery-validator.mjs +9 -3
  171. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +24 -1
  172. package/scripts/lib/config/dispatcher-autonomy.mjs +2 -1
  173. package/scripts/lib/config/docs-orchestrator.mjs +2 -1
  174. package/scripts/lib/config/docs-staleness.mjs +2 -1
  175. package/scripts/lib/config/drift-check.mjs +2 -1
  176. package/scripts/lib/config/eval.mjs +2 -1
  177. package/scripts/lib/config/events-rotation.mjs +2 -1
  178. package/scripts/lib/config/evolve.mjs +8 -2
  179. package/scripts/lib/config/frontend-slop-hook.mjs +7 -3
  180. package/scripts/lib/config/gitlab-portfolio.mjs +2 -1
  181. package/scripts/lib/config/handover-gate.mjs +2 -1
  182. package/scripts/lib/config/health-endpoints.mjs +388 -0
  183. package/scripts/lib/config/issue-budget.mjs +2 -1
  184. package/scripts/lib/config/loop-guard.mjs +2 -1
  185. package/scripts/lib/config/memory.mjs +2 -1
  186. package/scripts/lib/config/moc-staleness.mjs +2 -1
  187. package/scripts/lib/config/persona-gate-wave.mjs +2 -1
  188. package/scripts/lib/config/private-config-dir.mjs +67 -0
  189. package/scripts/lib/config/reconcile.mjs +2 -1
  190. package/scripts/lib/config/remote-hosts.mjs +234 -0
  191. package/scripts/lib/config/section-extractor.mjs +7 -1
  192. package/scripts/lib/config/skill-evolution.mjs +2 -1
  193. package/scripts/lib/config/slopcheck.mjs +2 -1
  194. package/scripts/lib/config/state-md-lock.mjs +2 -1
  195. package/scripts/lib/config/templates-first.mjs +2 -1
  196. package/scripts/lib/config/test.mjs +2 -1
  197. package/scripts/lib/config/vault-integration.mjs +7 -1
  198. package/scripts/lib/config/vault-mirror-quality.mjs +2 -1
  199. package/scripts/lib/config/vault-staleness.mjs +2 -1
  200. package/scripts/lib/config/vault-sync.mjs +2 -1
  201. package/scripts/lib/config/verification-auto-fix.mjs +2 -1
  202. package/scripts/lib/config/wave-reviewers.mjs +2 -1
  203. package/scripts/lib/config/worktree-orphans.mjs +2 -1
  204. package/scripts/lib/config.mjs +31 -3
  205. package/scripts/lib/convergence-monitor.mjs +82 -16
  206. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  207. package/scripts/lib/dispatcher/rank.mjs +124 -48
  208. package/scripts/lib/ecosystem-health.mjs +16 -2
  209. package/scripts/lib/eval/engine.mjs +9 -1
  210. package/scripts/lib/eval/session-resolve.mjs +23 -4
  211. package/scripts/lib/events-schema.mjs +48 -0
  212. package/scripts/lib/events.mjs +256 -7
  213. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  214. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  215. package/scripts/lib/frontmatter-guard.mjs +131 -13
  216. package/scripts/lib/gates/gate-full.mjs +26 -0
  217. package/scripts/lib/gates/gate-helpers.mjs +76 -0
  218. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  219. package/scripts/lib/hardware-pattern-detector.mjs +18 -1
  220. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  221. package/scripts/lib/harness-audit/categories/category4.mjs +31 -11
  222. package/scripts/lib/host-identity.mjs +50 -11
  223. package/scripts/lib/instruction-budget-guard.mjs +171 -5
  224. package/scripts/lib/learnings/evolve-telemetry.mjs +178 -0
  225. package/scripts/lib/learnings/io.mjs +60 -6
  226. package/scripts/lib/memory-banner.mjs +20 -8
  227. package/scripts/lib/memory-proposals/store.mjs +30 -22
  228. package/scripts/lib/owner-config-banner.mjs +43 -6
  229. package/scripts/lib/owner-config-loader.mjs +21 -10
  230. package/scripts/lib/owner-interview.mjs +3 -3
  231. package/scripts/lib/owner-yaml.mjs +207 -14
  232. package/scripts/lib/peer-discovery.mjs +20 -2
  233. package/scripts/lib/platform.mjs +108 -15
  234. package/scripts/lib/plugin-update-banner.mjs +406 -0
  235. package/scripts/lib/project-hygiene.mjs +38 -2
  236. package/scripts/lib/qg-command-drift-banner.mjs +50 -12
  237. package/scripts/lib/quality-gate.mjs +133 -44
  238. package/scripts/lib/reconcile/emitter.mjs +68 -6
  239. package/scripts/lib/reconcile/engine.mjs +249 -9
  240. package/scripts/lib/reconcile/idempotency.mjs +37 -4
  241. package/scripts/lib/reconcile/writer.mjs +40 -18
  242. package/scripts/lib/scope-gate.mjs +36 -0
  243. package/scripts/lib/session-close-backfill.mjs +125 -18
  244. package/scripts/lib/session-discovery.mjs +57 -3
  245. package/scripts/lib/session-end/phase-skip.mjs +2 -2
  246. package/scripts/lib/session-id.mjs +12 -23
  247. package/scripts/lib/session-identity/own-session.mjs +187 -11
  248. package/scripts/lib/session-lock-shape.mjs +43 -0
  249. package/scripts/lib/session-lock.mjs +5 -10
  250. package/scripts/lib/session-registry.mjs +25 -9
  251. package/scripts/lib/session-schema/constants.mjs +36 -2
  252. package/scripts/lib/session-schema/validator.mjs +38 -4
  253. package/scripts/lib/session-start-probes.mjs +18 -1
  254. package/scripts/lib/session-transition.mjs +1 -1
  255. package/scripts/lib/sessions-canonical.mjs +446 -0
  256. package/scripts/lib/sessions-staleness-banner.mjs +18 -11
  257. package/scripts/lib/skill-health/join.mjs +17 -4
  258. package/scripts/lib/state-md.mjs +78 -0
  259. package/scripts/lib/sunset/walker.mjs +6 -0
  260. package/scripts/lib/telemetry/schema.mjs +255 -17
  261. package/scripts/lib/telemetry/sync.mjs +417 -24
  262. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  263. package/scripts/lib/validate/check-agents-skills.mjs +327 -0
  264. package/scripts/lib/validate/check-agents.mjs +3 -3
  265. package/scripts/lib/validate/check-cursor-adapter.mjs +234 -72
  266. package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
  267. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  268. package/scripts/lib/validate/check-hooks-symmetry.mjs +45 -16
  269. package/scripts/lib/validate/check-owner-leakage.mjs +281 -20
  270. package/scripts/lib/validate/check-skill-links.mjs +163 -0
  271. package/scripts/lib/validate/check-skill-script-paths.mjs +455 -0
  272. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  273. package/scripts/lib/validate/check-unwired-features.mjs +0 -9
  274. package/scripts/lib/validate/check-validator-registration.mjs +254 -0
  275. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  276. package/scripts/lib/validate/enumerate-repo-files.mjs +317 -0
  277. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  278. package/scripts/lib/vault-backfill/template.mjs +63 -6
  279. package/scripts/lib/vault-mirror/process.mjs +165 -42
  280. package/scripts/lib/vault-mirror/telemetry.mjs +2 -2
  281. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  282. package/scripts/lib/vault-status/board-writer.mjs +174 -135
  283. package/scripts/lib/vault-status/narrative-mirror.mjs +129 -37
  284. package/scripts/lib/wave-executor/dispatch-common.mjs +164 -0
  285. package/scripts/lib/wave-executor/foreign-dispatch.mjs +7 -142
  286. package/scripts/lib/wave-executor/remote-dispatch.mjs +502 -0
  287. package/scripts/lib/wave-resource-gate.mjs +133 -7
  288. package/scripts/lib/wave-sizing.mjs +4 -1
  289. package/scripts/lib/wave-transcript-tail.mjs +142 -8
  290. package/scripts/materialize-wave-scope.mjs +32 -9
  291. package/scripts/memory-propose.mjs +146 -8
  292. package/scripts/migrate-cold-start-seed.mjs +4 -1
  293. package/scripts/parse-config.mjs +60 -3
  294. package/scripts/promote-vault-strict.mjs +4 -15
  295. package/scripts/release.mjs +337 -29
  296. package/scripts/repair-invalid-sessions.mjs +3 -3
  297. package/scripts/run-quality-gate.mjs +128 -11
  298. package/scripts/site-numbers.mjs +36 -4
  299. package/scripts/sweep-expired-learnings.mjs +90 -0
  300. package/scripts/sync-vault-schema.mjs +3 -1
  301. package/scripts/telemetry.mjs +2 -2
  302. package/scripts/validate-plugin.mjs +187 -0
  303. package/scripts/validate-wave-scope.mjs +28 -8
  304. package/scripts/vault-consolidate.mjs +3 -11
  305. package/scripts/vault-integration-watcher.mjs +2 -4
  306. package/scripts/vault-mirror.mjs +111 -26
  307. package/scripts/wave-scope-binding.mjs +215 -0
  308. package/skills/_shared/instruction-file-resolution.md +10 -0
  309. package/skills/_shared/parallel-aware-auq.md +31 -2
  310. package/skills/_shared/parallel-aware-preamble.md +18 -4
  311. package/skills/_shared/platform-tools.md +1 -1
  312. package/skills/_shared/state-ownership.md +1 -1
  313. package/skills/architecture/SKILL.md +7 -5
  314. package/skills/{domain-model/SKILL.md → architecture/references/domain-model.md} +9 -9
  315. package/skills/autopilot/SKILL.md +4 -18
  316. package/skills/claude-md-drift-check/SKILL.md +5 -1
  317. package/skills/claude-md-drift-check/checker.mjs +62 -2
  318. package/skills/convergence-monitoring/SIGNALS.md +55 -0
  319. package/skills/discovery/probes/vault-staleness.mjs +37 -13
  320. package/skills/discovery/probes-arch.md +20 -18
  321. package/skills/dispatcher/SKILL.md +3 -2
  322. package/skills/ecosystem-health/SKILL.md +4 -1
  323. package/skills/ecosystem-health/wizard.md +5 -0
  324. package/skills/evolve/SKILL.md +87 -11
  325. package/skills/frontmatter-guard/SKILL.md +11 -5
  326. package/skills/npm-publish/SKILL.md +1 -1
  327. package/skills/reconcile/SKILL.md +38 -2
  328. package/skills/remote-offload/SKILL.md +89 -0
  329. package/skills/session-end/SKILL.md +18 -905
  330. package/skills/session-end/phase-3-6-tail.md +19 -9
  331. package/skills/session-end/plan-verification.md +221 -155
  332. package/skills/session-end/references/phase-2-quality-gate.md +93 -0
  333. package/skills/session-end/references/phase-3-documentation-updates.md +229 -0
  334. package/skills/session-end/references/phase-4a-worktree-cleanup.md +120 -0
  335. package/skills/session-end/references/phase-4b-worktree-orphan-sweep.md +58 -0
  336. package/skills/session-end/references/phase-5-issue-cleanup.md +104 -0
  337. package/skills/session-end/references/session-summary-template.md +62 -0
  338. package/skills/session-plan/SKILL.md +49 -0
  339. package/skills/session-start/SKILL.md +41 -900
  340. package/skills/session-start/phase-8-5-express-path.md +1 -1
  341. package/skills/session-start/references/phase-1-1-dispatcher-autonomy-capture.md +55 -0
  342. package/skills/session-start/references/phase-1-2-session-lock.md +140 -0
  343. package/skills/session-start/references/phase-1-5-session-continuity.md +254 -0
  344. package/skills/session-start/references/phase-1-7-vault-status-board.md +53 -0
  345. package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +75 -0
  346. package/skills/session-start/references/phase-4-ssot-environment-check.md +155 -0
  347. package/skills/session-start/references/phase-6-5-forced-reads.md +75 -0
  348. package/skills/session-start/references/phase-6-6-project-intelligence.md +81 -0
  349. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +103 -0
  350. package/skills/vault-sync/validator.mjs +21 -27
  351. package/skills/wave-executor/SKILL.md +16 -2
  352. package/skills/wave-executor/references/wave-loop-dispatch.md +612 -0
  353. package/skills/wave-executor/references/wave-loop-review.md +570 -0
  354. package/skills/wave-executor/references/wave-loop-scope-manifest.md +162 -0
  355. package/skills/wave-executor/wave-loop.md +14 -1271
  356. package/templates/_shared/journey-manifest.md +10 -6
  357. package/.cursor/commands/autopilot-multi.md +0 -14
  358. package/.cursor/commands/contract-version-bump.md +0 -14
  359. package/.cursor/commands/journey-audit.md +0 -14
  360. package/.cursor/skills/contract-version-bump/SKILL.md +0 -12
  361. package/.cursor/skills/daily/SKILL.md +0 -12
  362. package/.cursor/skills/domain-model/SKILL.md +0 -13
  363. package/.cursor/skills/journey-audit/SKILL.md +0 -13
  364. package/.cursor/skills/skill-creator/SKILL.md +0 -13
  365. package/.cursor/skills/ubiquitous-language/SKILL.md +0 -13
  366. package/commands/autopilot-multi.md +0 -74
  367. package/commands/contract-version-bump.md +0 -28
  368. package/commands/journey-audit.md +0 -43
  369. package/pi/prompts/autopilot-multi.md +0 -12
  370. package/pi/prompts/contract-version-bump.md +0 -12
  371. package/pi/prompts/journey-audit.md +0 -12
  372. package/scripts/autopilot-multi.mjs +0 -885
  373. package/scripts/backfill-learnings-expires.mjs +0 -196
  374. package/scripts/backfill-learnings.mjs +0 -203
  375. package/scripts/fleet-instruction-scan.mjs +0 -141
  376. package/scripts/lib/autopilot/dep-graph.mjs +0 -417
  377. package/scripts/lib/autopilot/multi-killswitch.mjs +0 -184
  378. package/scripts/lib/webhook-url.mjs +0 -105
  379. package/scripts/lifecycle-sim-v6.mjs +0 -347
  380. package/scripts/migrate-learnings-jsonl.mjs +0 -189
  381. package/scripts/migrate-subagents-jsonl.mjs +0 -196
  382. package/scripts/upload-social-preview.mjs +0 -316
  383. package/skills/_shared/model-selection.md +0 -64
  384. package/skills/contract-version-bump/SKILL.md +0 -219
  385. package/skills/daily/SKILL.md +0 -222
  386. package/skills/daily/generate.sh +0 -92
  387. package/skills/daily/templates/daily.md.tpl +0 -36
  388. package/skills/journey-audit/SKILL.md +0 -269
  389. package/skills/skill-creator/SKILL.md +0 -168
  390. package/skills/ubiquitous-language/SKILL.md +0 -97
  391. package/skills/vault-sync/package-lock.json +0 -40
  392. /package/skills/{domain-model → architecture/references}/ADR-FORMAT.md +0 -0
  393. /package/skills/{domain-model → architecture/references}/CONTEXT-FORMAT.md +0 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * board-lock.mjs — cross-repo mutex for the vault live-status board (issue #1180).
3
+ *
4
+ * The board at `<vault-dir>/01-projects/_active-sessions.md` is ONE file shared
5
+ * by every repo on the host: `sweepBoard()` runs at every session-start and
6
+ * session-end of EVERY repo, and each run performs a read-modify-write
7
+ * (`mirrorBoardInner` reads the existing board to seed `preservedRows` /
8
+ * `priorStatusByRepo`, merges the freshly-derived rows over it, then writes).
9
+ * `atomicWriteWithBackup`'s tmp+rename protects READERS from a half-written
10
+ * file; it does NOT protect the merge, so two sessions that read the same base
11
+ * concurrently both write a board missing the other's row — last writer wins.
12
+ *
13
+ * Why not `withStateMdLock`: that lock lives at `<repoRoot>/.orchestrator/state.lock`
14
+ * and is PER-REPO. Two different repos racing on the one shared board never
15
+ * contend on it — wrong domain.
16
+ *
17
+ * Why `staleCheck: 'mtime'` and not `'pid'`: the vault directory can be shared
18
+ * across hosts (Obsidian sync / a network volume), so the recorded pid is not
19
+ * probeable here. `mtime` ages the lock out after `staleMs` regardless of who
20
+ * wrote it. (`tryAcquireFileLock` never auto-overrides a CROSS-HOST body — see
21
+ * `file-lock.mjs` `isExistingStale` — so a foreign-host lock is waited out and
22
+ * then handled by the fail-open path below, never stolen.)
23
+ *
24
+ * FAIL-OPEN by contract: a board update is best-effort telemetry and must never
25
+ * abort a session phase (same posture as `writeBoard`'s `skipped-write-failed`
26
+ * return). On acquire timeout or fs-error we emit exactly ONE stderr WARN and
27
+ * run `fn` unlocked.
28
+ *
29
+ * Return shape follows {@link import('../locks/state-md-lock.mjs').withStateMdLock}:
30
+ * the value of `fn` is returned verbatim, so call sites need no branching. The
31
+ * lock outcome — which is diagnostic, not part of the board result — is exposed
32
+ * through the optional `onLockOutcome` callback instead of widening the return
33
+ * type for every caller. That is the simpler of the two shapes the issue offered:
34
+ * `mirrorBoardInner` already returns a `{ result, rows }` envelope of its own and
35
+ * would have had to unwrap a second one on every path.
36
+ *
37
+ * No external deps — Node stdlib + `file-lock.mjs`.
38
+ */
39
+
40
+ import path from 'node:path';
41
+ import crypto from 'node:crypto';
42
+
43
+ import { withFileLock } from '../file-lock.mjs';
44
+ import { expandTilde } from '../common.mjs';
45
+
46
+ /** Default acquire budget: short — the critical section is a read + a rename. */
47
+ const DEFAULT_TIMEOUT_MS = 5000;
48
+ /** Default poll cadence while another writer holds the board. */
49
+ const DEFAULT_POLL_MS = 50;
50
+ /**
51
+ * Default mtime staleness TTL — a board write that took a minute is dead.
52
+ *
53
+ * CEILING (BV-004): 60 s bounds the WHOLE critical section, and that section is
54
+ * not just the rename — `mirrorBoardInner` runs `collectRows` over every repo
55
+ * registered on this host. A sweep that ever exceeds 60 s makes a LIVE writer
56
+ * look stale, and the second writer overrides its lock and re-opens the exact
57
+ * lost-update race this mutex exists to close. It is a constant rather than a
58
+ * measurement because the sweep is fast today and a self-tuning TTL would be a
59
+ * second thing to be wrong.
60
+ * REVISIT when a board sweep is measured above 30 s (half the TTL — the point
61
+ * at which a slow host crosses it), or when an `onLockOutcome` carrying
62
+ * `staleOverride` is observed in the events ledger on a host that had no crash.
63
+ */
64
+ const DEFAULT_STALE_MS = 60_000;
65
+
66
+ /**
67
+ * Resolve the board lock path for a vault directory.
68
+ *
69
+ * `<vaultDir>/.orchestrator/board.lock` — deliberately NOT beside the board in
70
+ * `01-projects/`: the vault's own `.gitignore` already ignores
71
+ * `.orchestrator/*.lock`, and `vault-sync` walks `.md` files only, so the lock
72
+ * is invisible to both the vault's VCS and its validator.
73
+ *
74
+ * Home-expansion is `expandTilde` from `../common.mjs` — the consolidation the
75
+ * comment above once deferred (issue #1182): 8 inline `expandHome` copies in
76
+ * 3 non-equivalent shapes, one of them (`gitlab-portfolio/cli.mjs`) actively
77
+ * wrong on `~user/x`. All 8 call sites now import the shared helper.
78
+ *
79
+ * @param {string} vaultDir — absolute or `~`-prefixed vault root.
80
+ * @returns {string} absolute lock path.
81
+ */
82
+ export function boardLockPathFor(vaultDir) {
83
+ return path.join(expandTilde(vaultDir), '.orchestrator', 'board.lock');
84
+ }
85
+
86
+ /**
87
+ * Run `fn` while holding the board mutex for `vaultDir`.
88
+ *
89
+ * Acquire polls until `timeoutMs`; the containing `.orchestrator/` directory is
90
+ * created on demand (`tryAcquireFileLock` → `createExclusive` does a
91
+ * `mkdirSync(dir, { recursive: true })` before linking). The lock is always
92
+ * released in a `finally`, including when `fn` throws — a throw propagates
93
+ * unchanged and is NEVER converted into the fail-open path (an unlocked retry
94
+ * of a throwing merge would run it twice).
95
+ *
96
+ * @param {string} vaultDir
97
+ * @param {() => (T | Promise<T>)} fn
98
+ * @param {object} [opts]
99
+ * @param {number} [opts.timeoutMs=5000]
100
+ * @param {number} [opts.pollMs=50]
101
+ * @param {number} [opts.staleMs=60000] — mtime age after which a lock is overridden.
102
+ * @param {string} [opts.holder] — holder label recorded in the lock body.
103
+ * @param {(outcome: { locked: boolean, lockPath: string, reason?: string, staleOverride?: string }) => void} [opts.onLockOutcome]
104
+ * — diagnostic sink, called exactly once before `fn` runs. `staleOverride`
105
+ * is present only when this acquire OVERRODE an aged lock, and carries
106
+ * `file-lock.mjs`'s own reason token — the observable behind the
107
+ * DEFAULT_STALE_MS revisit trigger.
108
+ * @param {(lockPath: string, fn: Function, opts: object) => Promise<object>} [opts.lockImpl]
109
+ * — test seam; defaults to {@link withFileLock}. Must honour the same
110
+ * `{ ok: true, value } | { ok: false, reason }` contract.
111
+ * @param {(msg: string) => void} [opts.warn] — WARN sink (default: stderr).
112
+ * @returns {Promise<T>} whatever `fn` returned.
113
+ * @template T
114
+ */
115
+ export async function withBoardLock(vaultDir, fn, opts = {}) {
116
+ if (typeof fn !== 'function') {
117
+ throw new TypeError('withBoardLock: fn must be a function');
118
+ }
119
+
120
+ const {
121
+ timeoutMs = DEFAULT_TIMEOUT_MS,
122
+ pollMs = DEFAULT_POLL_MS,
123
+ staleMs = DEFAULT_STALE_MS,
124
+ holder: holderOpt,
125
+ onLockOutcome,
126
+ lockImpl = withFileLock,
127
+ warn = (msg) => process.stderr.write(msg),
128
+ } = opts;
129
+
130
+ const lockPath = boardLockPathFor(vaultDir);
131
+ const holder = typeof holderOpt === 'string' && holderOpt.length > 0
132
+ ? holderOpt
133
+ : `board-writer-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
134
+
135
+ let outcomeReported = false;
136
+ // A stale-override is the one event that can silently break the mutex's
137
+ // guarantee (see DEFAULT_STALE_MS § CEILING), and `withFileLock` announces it
138
+ // ONLY through its `warn` sink — where it is prose nobody can aggregate. Lift
139
+ // it onto the diagnostic outcome so the revisit trigger is observable rather
140
+ // than anecdotal. It rides the SAME single `onLockOutcome` call (the override
141
+ // happens during acquire, i.e. strictly before `fn`), which keeps the
142
+ // documented "called exactly once" contract intact.
143
+ let staleOverride = null;
144
+ const warnAndWatch = (msg) => {
145
+ const hit = /overriding stale lock \(([^)]*)\)/.exec(msg);
146
+ if (hit) staleOverride = hit[1];
147
+ warn(`${msg}\n`);
148
+ };
149
+
150
+ const result = await lockImpl(
151
+ lockPath,
152
+ async () => {
153
+ outcomeReported = true;
154
+ onLockOutcome?.({
155
+ locked: true,
156
+ lockPath,
157
+ ...(staleOverride ? { staleOverride } : {}),
158
+ });
159
+ return await fn();
160
+ },
161
+ {
162
+ timeoutMs,
163
+ pollMs,
164
+ staleCheck: 'mtime',
165
+ staleMs,
166
+ holder,
167
+ indent: 2,
168
+ tmpPrefix: '.board.lock',
169
+ warn: warnAndWatch,
170
+ },
171
+ );
172
+
173
+ if (result?.ok) return result.value;
174
+
175
+ // Fail-open: never let a contended or broken lock abort a session phase.
176
+ // `outcomeReported` guards the (impossible-by-contract, but cheap to pin)
177
+ // case of an impl that both ran `fn` and reported failure.
178
+ if (!outcomeReported) {
179
+ const reason = result?.reason ?? 'unknown';
180
+ warn(`⚠ withBoardLock: ${reason} acquiring ${lockPath} — writing board WITHOUT the lock (best-effort)\n`);
181
+ onLockOutcome?.({ locked: false, lockPath, reason });
182
+ return await fn();
183
+ }
184
+ return result?.value;
185
+ }
@@ -58,6 +58,8 @@ import { readConfigFile, parseSessionConfig } from '../config.mjs';
58
58
  import { validatePathInsideProject } from '../path-utils.mjs';
59
59
  import { enumerateCandidates } from '../dispatcher/enumerate.mjs';
60
60
  import { atomicWriteWithBackup } from '../io.mjs';
61
+ import { withBoardLock } from './board-lock.mjs';
62
+ import { expandTilde } from '../common.mjs';
61
63
 
62
64
  /** Frontmatter sentinel that identifies generator-owned board files. */
63
65
  export const GENERATOR_MARKER = 'session-orchestrator-active-sessions@1';
@@ -180,22 +182,6 @@ const nameSlot = (repo) => `n:${foldKey(repo)}`;
180
182
 
181
183
  // ── Path helpers ────────────────────────────────────────────────────────────────
182
184
 
183
- /**
184
- * Expand a leading `~` to the current user's home directory. Inlined here on
185
- * purpose — the shared helper is private elsewhere, and a shared
186
- * `vault-write-guard.mjs` extraction is deferred to a later epic (W2 forbids a
187
- * new shared file in this slice).
188
- *
189
- * @param {string} p
190
- * @returns {string}
191
- */
192
- function expandHome(p) {
193
- if (typeof p !== 'string' || p.length === 0) return p;
194
- if (p === '~') return os.homedir();
195
- if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
196
- return p;
197
- }
198
-
199
185
  /**
200
186
  * Resolve the board file path from a vault directory.
201
187
  *
@@ -203,7 +189,7 @@ function expandHome(p) {
203
189
  * @returns {string} `<vaultDir>/01-projects/_active-sessions.md`
204
190
  */
205
191
  export function resolveBoardPath(vaultDir) {
206
- return path.join(expandHome(vaultDir), '01-projects', '_active-sessions.md');
192
+ return path.join(expandTilde(vaultDir), '01-projects', '_active-sessions.md');
207
193
  }
208
194
 
209
195
  // ── Formatting helpers ───────────────────────────────────────────────────────────
@@ -729,6 +715,10 @@ export const BOARD_EVENT = 'orchestrator.vault.board_written';
729
715
  * @param {number} [opts.reposSwept] — candidates {@link enumerateCandidates}
730
716
  * returned, on the {@link sweepBoard} path only.
731
717
  * @param {number} [opts.durationMs]
718
+ * @param {{ locked: boolean, reason?: string, stale_override?: string, waited_ms: number }} [opts.lock]
719
+ * — board-lock outcome, present only when the lock was actually attempted
720
+ * (i.e. not on the early no-op guards, not on dry-run). Never carries the lock
721
+ * PATH — that is a `$HOME`-rooted string, the CP1 shape this payload keeps out.
732
722
  * @returns {Promise<void>}
733
723
  */
734
724
  /**
@@ -765,7 +755,7 @@ function telemetrySafePath(outputPath) {
765
755
  return base.length > 0 ? base : undefined;
766
756
  }
767
757
 
768
- async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows, reposSwept, durationMs }) {
758
+ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows, reposSwept, durationMs, lock }) {
769
759
  // Refuse the SO_PROJECT_DIR fallback instead of guessing a destination.
770
760
  // Without an explicit repoRoot, `emitEvent` resolves `eventsFilePath(undefined)`
771
761
  // and writes into whatever tree the ambient env points at — so `mirrorBoard()`
@@ -798,6 +788,12 @@ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows
798
788
  ...(Number.isFinite(rows) ? { rows } : {}),
799
789
  ...(Number.isFinite(reposSwept) ? { repos_swept: reposSwept } : {}),
800
790
  ...(Number.isFinite(durationMs) ? { duration_ms: durationMs } : {}),
791
+ // Lock diagnostics (absent on every path that never took the lock: the
792
+ // early no-op guards and dry-run). `lock.locked === false` marks a
793
+ // fail-open unlocked write; `lock.stale_override` marks an acquire that
794
+ // aged out someone else's lock — the observable behind board-lock's
795
+ // DEFAULT_STALE_MS revisit trigger.
796
+ ...(lock && typeof lock === 'object' ? { lock } : {}),
801
797
  // #1147: join key parity with the sibling `narrative_mirrored` event,
802
798
  // which has carried attribution since #1073. Without it a board record
803
799
  // cannot be joined to the session that wrote it. Both keys are OMITTED
@@ -851,7 +847,10 @@ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows
851
847
  * `owner.yaml`, whose `paths.vault-dir` override (if set) wins over the fixture value
852
848
  * and bleeds into the assertion (issue #783). Production callers omit this — the
853
849
  * default (real owner.yaml resolution) is the correct host-local behavior there.
854
- * @returns {Promise<{ result: { action: string, path?: string }, rows?: number }>}
850
+ * @returns {Promise<{ result: { action: string, path?: string }, rows?: number,
851
+ * lock?: { locked: boolean, reason?: string, stale_override?: string, waited_ms: number } }>}
852
+ * `lock` is present only on the locked path (absent on the early no-op guards
853
+ * and on dry-run, which deliberately takes no lock).
855
854
  * `rows` is present only once the render was reached — see
856
855
  * {@link emitBoardEvent}'s `rows` contract. The public {@link mirrorBoard}
857
856
  * wrapper unwraps `result` so the caller-visible return shape is unchanged.
@@ -880,7 +879,7 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
880
879
  }
881
880
 
882
881
  // Safety: the resolved vault dir must live under $HOME.
883
- const expandedVault = expandHome(vaultDir);
882
+ const expandedVault = expandTilde(vaultDir);
884
883
  const home = os.homedir();
885
884
  const inHome = validatePathInsideProject(expandedVault, home);
886
885
  if (!inHome.ok) {
@@ -934,132 +933,171 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
934
933
 
935
934
  const outputPath = resolveBoardPath(vaultDir);
936
935
 
937
- // Read the EXISTING generator-owned board (if any) to:
938
- // 1. preserve its `created:` otherwise every render differs on `created:`
939
- // and the noop-skip in writeBoard would never fire.
940
- // 2. recover the prior per-repo status drives the `closed` derivation for
941
- // repos NOT in this update (idempotent merge: their rows are re-derived).
942
- // Both maps are keyed by {@link foldKey}(repo) case-insensitively folded
943
- // (issue #719) so two prior rows differing only by case (e.g.
944
- // `some-repo` vs `Some-Repo`, the same physical directory on a
945
- // case-insensitive-preserving filesystem like APFS) collapse to ONE entry
946
- // instead of coexisting as duplicates. The row OBJECTS keep their original
947
- // `repo` string untouched, so `renderBoard` still displays true casing.
948
- const fsReadFile = fs?.readFileSync ?? readFileSync;
949
- const fsExists = fs?.existsSync ?? existsSync;
950
- let createdIso;
951
- const priorStatusByRepo = new Map(); // LEGACY rows only see collectRows contract
952
- const priorStatusByKey = new Map(); // boardKey status (authoritative since #871)
953
- const preservedRows = new Map(); // merge slot (see hashSlot/nameSlot) prior row
954
- if (fsExists(outputPath)) {
955
- let existing;
956
- try {
957
- existing = fsReadFile(outputPath, 'utf8');
958
- } catch {
959
- existing = null;
960
- }
961
- if (existing) {
962
- const fm = parseFrontmatter(existing);
963
- if (fm && fm['_generator'] === GENERATOR_MARKER) {
964
- if (fm['created']) createdIso = fm['created'];
965
- for (const prior of parseBoardRows(existing)) {
966
- // Dual-key slotting (#871): a keyed row owns its own hash slot; a
967
- // legacy (6-column) row falls back to its folded display name. Two
968
- // keyed rows can only collide when they resolve to the SAME path, so
969
- // the heartbeat-preference resolution below is now reached almost
970
- // exclusively by legacy rows which is precisely the case it was
971
- // written for (#719).
972
- const key = prior.key ? hashSlot(prior.key) : nameSlot(prior.repo);
973
- const collidingPrior = preservedRows.get(key);
974
- if (collidingPrior) {
975
- // Collision WITHIN parseBoardRows output two prior rows fold to
976
- // the same key with no fresh row in play yet (that upsert happens
977
- // below). Prefer the row with the most-recent `heartbeat` rather
978
- // than silently last-in-file-order. Guard: if either heartbeat is
979
- // unparsable, fall through to last-written-wins (the pre-#719
980
- // default) by NOT skipping the overwrite below.
981
- const collidingTs = Date.parse(collidingPrior.heartbeat ?? '');
982
- const priorTs = Date.parse(prior.heartbeat ?? '');
983
- if (Number.isFinite(collidingTs) && Number.isFinite(priorTs) && collidingTs > priorTs) {
984
- // The already-preserved row is strictly newer keep it, skip
985
- // this older colliding row entirely.
986
- continue;
936
+ // Everything below — the two reads of the existing board, the merge, and the
937
+ // write is ONE read-modify-write over a file shared by every repo on the
938
+ // host (issue #1180). Serialise it on the vault-scoped board lock so a
939
+ // concurrent sweepBoard() from another repo cannot compute its merge from a
940
+ // base we are about to replace. Fail-open: withBoardLock runs the closure
941
+ // unlocked (with a WARN) rather than let a contended lock abort the phase.
942
+ const mergeAndWrite = async () => {
943
+ // Read the EXISTING generator-owned board (if any) to:
944
+ // 1. preserve its `created:` otherwise every render differs on `created:`
945
+ // and the noop-skip in writeBoard would never fire.
946
+ // 2. recover the prior per-repo status drives the `closed` derivation for
947
+ // repos NOT in this update (idempotent merge: their rows are re-derived).
948
+ // Both maps are keyed by {@link foldKey}(repo) — case-insensitively folded
949
+ // (issue #719) — so two prior rows differing only by case (e.g.
950
+ // `some-repo` vs `Some-Repo`, the same physical directory on a
951
+ // case-insensitive-preserving filesystem like APFS) collapse to ONE entry
952
+ // instead of coexisting as duplicates. The row OBJECTS keep their original
953
+ // `repo` string untouched, so `renderBoard` still displays true casing.
954
+ const fsReadFile = fs?.readFileSync ?? readFileSync;
955
+ const fsExists = fs?.existsSync ?? existsSync;
956
+ let createdIso;
957
+ const priorStatusByRepo = new Map(); // LEGACY rows only — see collectRows contract
958
+ const priorStatusByKey = new Map(); // boardKey → status (authoritative since #871)
959
+ const preservedRows = new Map(); // merge slot (see hashSlot/nameSlot) → prior row
960
+ if (fsExists(outputPath)) {
961
+ let existing;
962
+ try {
963
+ existing = fsReadFile(outputPath, 'utf8');
964
+ } catch {
965
+ existing = null;
966
+ }
967
+ if (existing) {
968
+ const fm = parseFrontmatter(existing);
969
+ if (fm && fm['_generator'] === GENERATOR_MARKER) {
970
+ if (fm['created']) createdIso = fm['created'];
971
+ for (const prior of parseBoardRows(existing)) {
972
+ // Dual-key slotting (#871): a keyed row owns its own hash slot; a
973
+ // legacy (6-column) row falls back to its folded display name. Two
974
+ // keyed rows can only collide when they resolve to the SAME path, so
975
+ // the heartbeat-preference resolution below is now reached almost
976
+ // exclusively by legacy rows which is precisely the case it was
977
+ // written for (#719).
978
+ const key = prior.key ? hashSlot(prior.key) : nameSlot(prior.repo);
979
+ const collidingPrior = preservedRows.get(key);
980
+ if (collidingPrior) {
981
+ // Collision WITHIN parseBoardRows output — two prior rows fold to
982
+ // the same key with no fresh row in play yet (that upsert happens
983
+ // below). Prefer the row with the most-recent `heartbeat` rather
984
+ // than silently last-in-file-order. Guard: if either heartbeat is
985
+ // unparsable, fall through to last-written-wins (the pre-#719
986
+ // default) by NOT skipping the overwrite below.
987
+ const collidingTs = Date.parse(collidingPrior.heartbeat ?? '');
988
+ const priorTs = Date.parse(prior.heartbeat ?? '');
989
+ if (Number.isFinite(collidingTs) && Number.isFinite(priorTs) && collidingTs > priorTs) {
990
+ // The already-preserved row is strictly newer — keep it, skip
991
+ // this older colliding row entirely.
992
+ continue;
993
+ }
987
994
  }
995
+ if (prior.key) {
996
+ priorStatusByKey.set(prior.key, prior.status);
997
+ } else {
998
+ // LEGACY rows only. Seeding this map from keyed rows too would let
999
+ // repo B (never seen, same basename) inherit repo A's terminal
1000
+ // status through the name fallback in collectRows — reintroducing
1001
+ // the identity collision #871 exists to remove, one layer down.
1002
+ priorStatusByRepo.set(foldKey(prior.repo), prior.status);
1003
+ }
1004
+ preservedRows.set(key, prior);
988
1005
  }
989
- if (prior.key) {
990
- priorStatusByKey.set(prior.key, prior.status);
991
- } else {
992
- // LEGACY rows only. Seeding this map from keyed rows too would let
993
- // repo B (never seen, same basename) inherit repo A's terminal
994
- // status through the name fallback in collectRows — reintroducing
995
- // the identity collision #871 exists to remove, one layer down.
996
- priorStatusByRepo.set(foldKey(prior.repo), prior.status);
997
- }
998
- preservedRows.set(key, prior);
999
1006
  }
1000
1007
  }
1001
1008
  }
1002
- }
1003
1009
 
1004
- const rows = await collectRows({ repos: repoList, now, priorStatusByRepo, priorStatusByKey });
1005
-
1006
- // TTL-staleness re-derivation for PRESERVED rows (issue #829 Finding 2).
1007
- // Without this pass, a preserved `in-progress` row (a repo NOT in this
1008
- // update) is copied forward FOREVER — a crashed/never-closed session's row
1009
- // never flips even after its heartbeat has aged well past the lock's TTL,
1010
- // because `collectRows` only re-derives status for repos actually IN
1011
- // `repoList`. Re-derive staleness for every preserved row here, BEFORE the
1012
- // freshly-derived `rows` are upserted over it below (fresh data always
1013
- // wins regardless of this pass — a live lock or an explicit-closed update
1014
- // always takes precedence over the TTL flip).
1015
- //
1016
- // Board rows carry only a raw `heartbeat` string, never the lock's own
1017
- // `ttl_hours` (that field is not part of the rendered board) — so this
1018
- // reuses the shared {@link DEFAULT_TTL_HOURS} constant rather than the
1019
- // per-lock TTL {@link isLockLive} uses when a live lock object is in hand.
1020
- // Rows with an unparseable/absent heartbeat are left UNCHANGED (fail-open,
1021
- // never crash on a malformed prior board).
1022
- const nowMs = now instanceof Date ? now.getTime() : Date.now();
1023
- const ttlMs = DEFAULT_TTL_HOURS * 3600 * 1000;
1024
- const staleRederivedRows = new Map();
1025
- for (const [key, row] of preservedRows) {
1026
- if (row.status === STATUS_IN_PROGRESS) {
1027
- const heartbeatMs = Date.parse(row.heartbeat ?? '');
1028
- if (Number.isFinite(heartbeatMs) && (nowMs - heartbeatMs) >= ttlMs) {
1029
- staleRederivedRows.set(key, { ...row, status: STATUS_FORCE_CLOSED });
1030
- continue;
1010
+ const rows = await collectRows({ repos: repoList, now, priorStatusByRepo, priorStatusByKey });
1011
+
1012
+ // TTL-staleness re-derivation for PRESERVED rows (issue #829 Finding 2).
1013
+ // Without this pass, a preserved `in-progress` row (a repo NOT in this
1014
+ // update) is copied forward FOREVER — a crashed/never-closed session's row
1015
+ // never flips even after its heartbeat has aged well past the lock's TTL,
1016
+ // because `collectRows` only re-derives status for repos actually IN
1017
+ // `repoList`. Re-derive staleness for every preserved row here, BEFORE the
1018
+ // freshly-derived `rows` are upserted over it below (fresh data always
1019
+ // wins regardless of this pass — a live lock or an explicit-closed update
1020
+ // always takes precedence over the TTL flip).
1021
+ //
1022
+ // Board rows carry only a raw `heartbeat` string, never the lock's own
1023
+ // `ttl_hours` (that field is not part of the rendered board) — so this
1024
+ // reuses the shared {@link DEFAULT_TTL_HOURS} constant rather than the
1025
+ // per-lock TTL {@link isLockLive} uses when a live lock object is in hand.
1026
+ // Rows with an unparseable/absent heartbeat are left UNCHANGED (fail-open,
1027
+ // never crash on a malformed prior board).
1028
+ const nowMs = now instanceof Date ? now.getTime() : Date.now();
1029
+ const ttlMs = DEFAULT_TTL_HOURS * 3600 * 1000;
1030
+ const staleRederivedRows = new Map();
1031
+ for (const [key, row] of preservedRows) {
1032
+ if (row.status === STATUS_IN_PROGRESS) {
1033
+ const heartbeatMs = Date.parse(row.heartbeat ?? '');
1034
+ if (Number.isFinite(heartbeatMs) && (nowMs - heartbeatMs) >= ttlMs) {
1035
+ staleRederivedRows.set(key, { ...row, status: STATUS_FORCE_CLOSED });
1036
+ continue;
1037
+ }
1031
1038
  }
1039
+ staleRederivedRows.set(key, row);
1032
1040
  }
1033
- staleRederivedRows.set(key, row);
1034
- }
1035
1041
 
1036
- // Idempotent merge: keep prior (TTL-rederived) rows for repos NOT in this
1037
- // update, then upsert the freshly-derived rows over them so repeated writes
1038
- // stay stable. A freshly-derived row ALWAYS wins over a preserved row in the
1039
- // same slot — that is what collapses a live row over a stale preserved one.
1040
- //
1041
- // Dual-key upsert (#871). A naive switch from the folded name to the path
1042
- // key would make the two key spaces DISJOINT: the fresh row would never
1043
- // overwrite the legacy row, the legacy row would become immortal (the sweep
1044
- // skips `frei` candidates and the TTL pass only rewrites `status`, never
1045
- // removes a row), and the board would grow a permanent duplicate per repo.
1046
- // So a fresh row first claims its hash slot; if that slot is new, it ADOPTS
1047
- // the legacy name slot for the same folded name — one board write converts
1048
- // the row, and the migration is complete for that repo.
1049
- const merged = new Map(staleRederivedRows);
1050
- for (const row of rows) {
1051
- const slot = row.key ? hashSlot(row.key) : nameSlot(row.repo);
1052
- if (row.key && !merged.has(slot)) {
1053
- // First keyed write for this repo — take over its legacy row rather than
1054
- // rendering a second one beside it.
1055
- merged.delete(nameSlot(row.repo));
1042
+ // Idempotent merge: keep prior (TTL-rederived) rows for repos NOT in this
1043
+ // update, then upsert the freshly-derived rows over them so repeated writes
1044
+ // stay stable. A freshly-derived row ALWAYS wins over a preserved row in the
1045
+ // same slot — that is what collapses a live row over a stale preserved one.
1046
+ //
1047
+ // Dual-key upsert (#871). A naive switch from the folded name to the path
1048
+ // key would make the two key spaces DISJOINT: the fresh row would never
1049
+ // overwrite the legacy row, the legacy row would become immortal (the sweep
1050
+ // skips `frei` candidates and the TTL pass only rewrites `status`, never
1051
+ // removes a row), and the board would grow a permanent duplicate per repo.
1052
+ // So a fresh row first claims its hash slot; if that slot is new, it ADOPTS
1053
+ // the legacy name slot for the same folded name — one board write converts
1054
+ // the row, and the migration is complete for that repo.
1055
+ const merged = new Map(staleRederivedRows);
1056
+ for (const row of rows) {
1057
+ const slot = row.key ? hashSlot(row.key) : nameSlot(row.repo);
1058
+ if (row.key && !merged.has(slot)) {
1059
+ // First keyed write for this repo — take over its legacy row rather than
1060
+ // rendering a second one beside it.
1061
+ merged.delete(nameSlot(row.repo));
1062
+ }
1063
+ merged.set(slot, row);
1056
1064
  }
1057
- merged.set(slot, row);
1058
- }
1059
1065
 
1060
- const content = renderBoard([...merged.values()], { now, createdIso });
1066
+ const content = renderBoard([...merged.values()], { now, createdIso });
1067
+
1068
+ return { result: writeBoard({ outputPath, content, dryRun, fs }), rows: merged.size };
1069
+ };
1070
+
1071
+ // dry-run never touches disk (writeBoard guard 1) — so it must not create a
1072
+ // lock file in the operator's vault either. Nothing to serialise.
1073
+ if (dryRun) return await mergeAndWrite();
1074
+
1075
+ // The lock outcome is diagnostic, and until now it went nowhere: `withBoardLock`
1076
+ // has exposed `onLockOutcome` since #1180, and NO production caller passed one
1077
+ // (measured 2026-09-02: `grep -rn onLockOutcome scripts hooks` → board-lock.mjs
1078
+ // and its test, nothing else). So the two states that silently weaken the mutex —
1079
+ // a fail-open unlocked write, and a stale-override that can override a LIVE
1080
+ // writer (see board-lock's DEFAULT_STALE_MS § CEILING) — were unobservable in
1081
+ // aggregate, which is exactly what the revisit trigger needs. Capture it here and
1082
+ // ride it out on the ONE board_written event rather than adding a second event.
1083
+ let lockOutcome;
1084
+ const acquireStartedAt = Date.now();
1085
+ const inner = await withBoardLock(expandedVault, mergeAndWrite, {
1086
+ onLockOutcome: (o) => {
1087
+ // Called exactly once, strictly BEFORE `fn` — so the elapsed time is the
1088
+ // acquire wait, not the merge. `lockPath` is deliberately DROPPED: it is
1089
+ // `<vault>/.orchestrator/board.lock` under $HOME, i.e. the CP1 (OS username)
1090
+ // shape `telemetrySafePath` exists to keep out of the payload.
1091
+ lockOutcome = {
1092
+ locked: o?.locked === true,
1093
+ ...(typeof o?.reason === 'string' ? { reason: o.reason } : {}),
1094
+ ...(typeof o?.staleOverride === 'string' ? { stale_override: o.staleOverride } : {}),
1095
+ waited_ms: Date.now() - acquireStartedAt,
1096
+ };
1097
+ },
1098
+ });
1061
1099
 
1062
- return { result: writeBoard({ outputPath, content, dryRun, fs }), rows: merged.size };
1100
+ return lockOutcome === undefined ? inner : { ...inner, lock: lockOutcome };
1063
1101
  }
1064
1102
 
1065
1103
  /**
@@ -1095,7 +1133,7 @@ export async function mirrorBoard(opts = {}) {
1095
1133
  // throws exactly as it did before this wrapper existed.
1096
1134
  const { repoRoot, caller = 'mirrorBoard', reposSwept } = opts;
1097
1135
 
1098
- const { result, rows } = await mirrorBoardInner(opts);
1136
+ const { result, rows, lock } = await mirrorBoardInner(opts);
1099
1137
 
1100
1138
  await emitBoardEvent({
1101
1139
  repoRoot,
@@ -1105,6 +1143,7 @@ export async function mirrorBoard(opts = {}) {
1105
1143
  rows,
1106
1144
  reposSwept,
1107
1145
  durationMs: Date.now() - startedAt,
1146
+ lock,
1108
1147
  });
1109
1148
 
1110
1149
  return result;