session-orchestrator 3.22.0 → 3.24.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 (316) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +446 -0
  91. package/README.md +22 -17
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -2
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/session-reviewer.md +7 -1
  98. package/agents/test-writer.md +2 -1
  99. package/agents/ui-developer.md +2 -1
  100. package/commands/bootstrap.md +2 -2
  101. package/commands/close.md +3 -1
  102. package/commands/go.md +1 -1
  103. package/commands/journey-audit.md +43 -0
  104. package/docs/USER-GUIDE.md +2 -2
  105. package/docs/ci-setup.md +194 -25
  106. package/docs/codex-setup.md +64 -0
  107. package/docs/components.md +7 -7
  108. package/docs/cursor-setup.md +26 -47
  109. package/docs/events-schema.md +120 -10
  110. package/docs/github-mirror-protection.md +197 -0
  111. package/docs/pi-setup.md +2 -0
  112. package/docs/rule-authoring.md +3 -1
  113. package/docs/scope-collision-guard.md +49 -2
  114. package/docs/session-config-reference.md +89 -9
  115. package/docs/session-config-template.md +38 -7
  116. package/docs/telemetry/telemetry-claims.md +11 -10
  117. package/docs/telemetry.md +52 -1
  118. package/hooks/_lib/atomic-json.mjs +111 -0
  119. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  120. package/hooks/_lib/subagent-paths.mjs +143 -0
  121. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  122. package/hooks/cwd-change-restore.mjs +9 -29
  123. package/hooks/enforce-scope.mjs +93 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +2 -2
  128. package/hooks/on-session-end.mjs +486 -19
  129. package/hooks/on-session-start.mjs +263 -12
  130. package/hooks/on-stop.mjs +392 -24
  131. package/hooks/post-bash-write-verify.mjs +104 -4
  132. package/hooks/post-subagent-discovery-validator.mjs +182 -21
  133. package/hooks/post-tool-batch-wave-signal.mjs +165 -42
  134. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  135. package/hooks/pre-bash-issue-budget.mjs +117 -4
  136. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  138. package/hooks/pre-bash-staging-fence.mjs +4 -0
  139. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  140. package/hooks/skill-invocation-telemetry.mjs +21 -10
  141. package/hooks/subagent-telemetry.mjs +11 -26
  142. package/monitors/monitors.json +6 -0
  143. package/package.json +1 -1
  144. package/pi/prompts/journey-audit.md +12 -0
  145. package/rules/_index.md +9 -1
  146. package/rules/always-on/ask-via-tool.md +62 -0
  147. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  148. package/rules/always-on/build-value.md +47 -0
  149. package/rules/always-on/cross-session-messaging.md +59 -0
  150. package/rules/always-on/loop-and-monitor.md +221 -0
  151. package/rules/always-on/parallel-sessions.md +142 -12
  152. package/rules/always-on/receiving-review.md +108 -0
  153. package/rules/always-on/test-value.md +40 -0
  154. package/rules/always-on/verification-before-completion.md +77 -0
  155. package/scripts/archive-closed-prds.mjs +258 -18
  156. package/scripts/autopilot.mjs +31 -12
  157. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  158. package/scripts/backfill-evidence-digest.mjs +376 -0
  159. package/scripts/cursor-install.mjs +89 -48
  160. package/scripts/emit-event.mjs +10 -2
  161. package/scripts/export-hw-learnings.mjs +143 -2
  162. package/scripts/express-path.mjs +299 -0
  163. package/scripts/generate-cursor-adapter.mjs +253 -0
  164. package/scripts/github-protection-audit.mjs +358 -0
  165. package/scripts/lib/auq/parse.mjs +5 -29
  166. package/scripts/lib/auto-dialectic.mjs +68 -0
  167. package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
  168. package/scripts/lib/build-live-signals.mjs +49 -27
  169. package/scripts/lib/ci-status-banner.mjs +158 -11
  170. package/scripts/lib/cold-start-detector.mjs +23 -14
  171. package/scripts/lib/command-blocker.mjs +70 -0
  172. package/scripts/lib/config/block-header.mjs +55 -0
  173. package/scripts/lib/config/discovery-validator.mjs +7 -2
  174. package/scripts/lib/config/health-endpoints.mjs +383 -0
  175. package/scripts/lib/config/reconcile.mjs +79 -4
  176. package/scripts/lib/config/remote-hosts.mjs +233 -0
  177. package/scripts/lib/config/section-extractor.mjs +235 -36
  178. package/scripts/lib/config-schema.mjs +9 -1
  179. package/scripts/lib/config.mjs +87 -8
  180. package/scripts/lib/convergence-monitor.mjs +13 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  182. package/scripts/lib/dispatcher/cli.mjs +2 -2
  183. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  184. package/scripts/lib/events-schema.mjs +48 -0
  185. package/scripts/lib/events.mjs +238 -5
  186. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  187. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  188. package/scripts/lib/express-path.mjs +327 -0
  189. package/scripts/lib/file-lock.mjs +22 -4
  190. package/scripts/lib/gates/gate-full.mjs +81 -8
  191. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  192. package/scripts/lib/git-config-drift.mjs +134 -5
  193. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  194. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  195. package/scripts/lib/host-identity.mjs +247 -2
  196. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  197. package/scripts/lib/issue-budget.mjs +229 -30
  198. package/scripts/lib/learnings/io.mjs +55 -10
  199. package/scripts/lib/learnings/schema.mjs +95 -28
  200. package/scripts/lib/lock-reaper.mjs +7 -1
  201. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  202. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  203. package/scripts/lib/memory-banner.mjs +25 -10
  204. package/scripts/lib/memory-paths.mjs +15 -6
  205. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  206. package/scripts/lib/peer-discovery.mjs +20 -2
  207. package/scripts/lib/platform.mjs +72 -9
  208. package/scripts/lib/plugin-root.mjs +143 -19
  209. package/scripts/lib/project-hygiene.mjs +43 -3
  210. package/scripts/lib/quality-gate.mjs +271 -13
  211. package/scripts/lib/reconcile/emitter.mjs +87 -19
  212. package/scripts/lib/reconcile/engine.mjs +517 -18
  213. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  214. package/scripts/lib/reconcile/renderer.mjs +148 -3
  215. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  216. package/scripts/lib/reconcile/writer.mjs +415 -84
  217. package/scripts/lib/rule-loader.mjs +37 -2
  218. package/scripts/lib/rules-sync.mjs +51 -8
  219. package/scripts/lib/scope-gate.mjs +126 -0
  220. package/scripts/lib/session-close-backfill.mjs +427 -37
  221. package/scripts/lib/session-discovery.mjs +69 -5
  222. package/scripts/lib/session-end/phase-skip.mjs +38 -5
  223. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  224. package/scripts/lib/session-id.mjs +30 -14
  225. package/scripts/lib/session-identity/own-session.mjs +220 -0
  226. package/scripts/lib/session-lock.mjs +85 -30
  227. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  228. package/scripts/lib/session-schema/validator.mjs +40 -0
  229. package/scripts/lib/session-start-probes.mjs +608 -0
  230. package/scripts/lib/session-transition.mjs +277 -0
  231. package/scripts/lib/sessions-canonical.mjs +446 -0
  232. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  233. package/scripts/lib/spiral-carryover.mjs +90 -9
  234. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  235. package/scripts/lib/state-md/mission-status.mjs +350 -52
  236. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  237. package/scripts/lib/state-md.mjs +12 -2
  238. package/scripts/lib/telemetry/schema.mjs +74 -8
  239. package/scripts/lib/telemetry/sync.mjs +91 -16
  240. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  241. package/scripts/lib/validate/check-agents.mjs +66 -0
  242. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  243. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  244. package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
  245. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  246. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  247. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  248. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  249. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  250. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  251. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  252. package/scripts/lib/validate/check-unwired-features.mjs +333 -32
  253. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  254. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  255. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  256. package/scripts/lib/validate/repo-files.mjs +275 -0
  257. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  258. package/scripts/lib/vault-mirror/process.mjs +99 -43
  259. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  260. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  261. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  262. package/scripts/lib/vault-status/board-writer.mjs +381 -141
  263. package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
  264. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  265. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  266. package/scripts/lib/wave-resource-gate.mjs +127 -7
  267. package/scripts/lib/wave-transcript-tail.mjs +889 -0
  268. package/scripts/materialize-wave-scope.mjs +228 -15
  269. package/scripts/mcp-server.sh +11 -2
  270. package/scripts/memory-propose.mjs +132 -8
  271. package/scripts/parse-config.mjs +65 -0
  272. package/scripts/promote-vault-strict.mjs +4 -15
  273. package/scripts/site-numbers.mjs +36 -4
  274. package/scripts/token-audit.sh +9 -2
  275. package/scripts/validate-plugin.mjs +29 -0
  276. package/scripts/validate-wave-scope.mjs +67 -0
  277. package/scripts/vault-consolidate.mjs +3 -11
  278. package/scripts/vault-integration-watcher.mjs +2 -4
  279. package/scripts/vault-mirror.mjs +305 -51
  280. package/skills/_shared/monitor-patterns.md +31 -5
  281. package/skills/_shared/parallel-aware-auq.md +31 -2
  282. package/skills/_shared/parallel-aware-preamble.md +19 -4
  283. package/skills/_shared/platform-tools.md +11 -5
  284. package/skills/_shared/state-ownership.md +29 -2
  285. package/skills/autopilot/SKILL.md +5 -1
  286. package/skills/bootstrap/SKILL.md +3 -3
  287. package/skills/bootstrap/_shared-template.md +18 -10
  288. package/skills/bootstrap/deep-template.md +10 -6
  289. package/skills/bootstrap/fast-template.md +15 -8
  290. package/skills/bootstrap/standard-template.md +10 -6
  291. package/skills/claude-md-drift-check/checker.mjs +39 -11
  292. package/skills/contract-version-bump/SKILL.md +1 -1
  293. package/skills/dispatcher/SKILL.md +1 -1
  294. package/skills/ecosystem-health/SKILL.md +4 -1
  295. package/skills/ecosystem-health/wizard.md +5 -0
  296. package/skills/evolve/SKILL.md +38 -1
  297. package/skills/journey-audit/SKILL.md +270 -0
  298. package/skills/peekaboo-driver/SKILL.md +15 -3
  299. package/skills/persona-panel/SKILL.md +1 -1
  300. package/skills/reconcile/SKILL.md +46 -3
  301. package/skills/remote-offload/SKILL.md +89 -0
  302. package/skills/session-end/SKILL.md +17 -4
  303. package/skills/session-end/metrics-collection.md +7 -4
  304. package/skills/session-end/phase-3-6-tail.md +20 -9
  305. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  306. package/skills/session-plan/SKILL.md +6 -1
  307. package/skills/session-plan/wave-template.md +1 -0
  308. package/skills/session-start/SKILL.md +54 -17
  309. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  310. package/skills/session-start/phase-8-5-express-path.md +77 -12
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +5 -3
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +143 -22
  315. package/templates/_shared/journey-manifest.md +110 -0
  316. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -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
+ }