session-orchestrator 5.12.0 → 5.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (385) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +4 -1
  3. package/.codex-plugin/agents/explorer.toml +1 -1
  4. package/.codex-plugin/agents/session-reviewer.toml +2 -2
  5. package/.codex-plugin/agents/wave-worker.toml +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/.orchestrator/policy/blocked-commands.json +8 -0
  9. package/.orchestrator/policy/public-export.json +30 -0
  10. package/CHANGELOG.md +162 -4
  11. package/README.md +126 -127
  12. package/agents/analyst.md +7 -2
  13. package/agents/architect-reviewer.md +7 -2
  14. package/agents/code-implementer.md +4 -2
  15. package/agents/db-specialist.md +9 -2
  16. package/agents/dialectic-deriver.md +9 -3
  17. package/agents/docs-writer.md +7 -2
  18. package/agents/eval-judge.md +7 -1
  19. package/agents/qa-strategist.md +7 -2
  20. package/agents/security-reviewer.md +9 -2
  21. package/agents/session-reviewer.md +18 -2
  22. package/agents/skill-applied-judge.md +7 -1
  23. package/agents/test-writer.md +9 -2
  24. package/agents/ui-developer.md +9 -2
  25. package/agents/ux-evaluator.md +6 -1
  26. package/commands/session.md +1 -1
  27. package/commands/templates-ack.md +89 -18
  28. package/docs/agent-authoring.md +30 -11
  29. package/docs/ci-setup.md +38 -2
  30. package/docs/codex-setup.md +33 -9
  31. package/docs/components.md +13 -2
  32. package/docs/cursor-setup.md +3 -3
  33. package/docs/events-schema.md +5 -4
  34. package/docs/evidence-candidates.md +63 -0
  35. package/docs/fleet-checkin-audit.md +7 -4
  36. package/docs/github-mirror-protection.md +150 -3
  37. package/docs/install.md +90 -17
  38. package/docs/instruction-delivery.md +1 -1
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/mods.md +118 -0
  41. package/docs/owner-config-schema.md +1 -0
  42. package/docs/plugin-architecture-v3.md +1 -1
  43. package/docs/pm-skills-marketplace.md +4 -3
  44. package/docs/rule-authoring.md +24 -13
  45. package/docs/scope-collision-guard.md +2 -1
  46. package/docs/session-config-reference.md +49 -15
  47. package/docs/session-config-template.md +30 -22
  48. package/docs/sessions-ledger-repair.md +137 -0
  49. package/docs/vault-docs-architecture.md +1 -1
  50. package/hooks/_lib/hook-import-set.json +406 -73
  51. package/hooks/_lib/hook-root.mjs +301 -0
  52. package/hooks/_lib/lock-bootstrap.mjs +434 -4
  53. package/hooks/_lib/orchestrator-ignore.mjs +3 -0
  54. package/hooks/_lib/subagent-transcript.mjs +2 -2
  55. package/hooks/_lib/vcs-create-matcher.mjs +13 -17
  56. package/hooks/cwd-change-restore.mjs +5 -1
  57. package/hooks/enforce-commands.mjs +4 -3
  58. package/hooks/enforce-scope.mjs +11 -1
  59. package/hooks/hooks-codex.json +48 -2
  60. package/hooks/hooks.json +31 -31
  61. package/hooks/on-session-end.mjs +38 -15
  62. package/hooks/on-session-start.mjs +139 -49
  63. package/hooks/on-stop.mjs +11 -6
  64. package/hooks/post-bash-issue-budget-refund.mjs +6 -1
  65. package/hooks/post-bash-write-verify.mjs +8 -4
  66. package/hooks/post-edit-validate.mjs +45 -52
  67. package/hooks/post-subagent-discovery-validator.mjs +2 -2
  68. package/hooks/post-tool-batch-wave-signal.mjs +8 -4
  69. package/hooks/post-tool-failure-corrective-context.mjs +5 -1
  70. package/hooks/pre-bash-destructive-guard.mjs +220 -8
  71. package/hooks/pre-bash-issue-budget.mjs +6 -1
  72. package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
  73. package/hooks/pre-bash-staging-fence.mjs +258 -14
  74. package/hooks/pre-bash-templates-first.mjs +28 -27
  75. package/hooks/run-node.sh +1 -1
  76. package/hooks/skill-invocation-telemetry.mjs +15 -8
  77. package/hooks/subagent-telemetry.mjs +77 -14
  78. package/hooks/wave-scope-commit-guard.mjs +143 -14
  79. package/mods/so-hud/.claude-plugin/plugin.json +11 -0
  80. package/mods/so-hud/README.md +57 -0
  81. package/mods/so-hud/hooks/beacon-io.mjs +101 -0
  82. package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
  83. package/mods/so-hud/hooks/hooks.json +1 -0
  84. package/mods/so-hud/hooks/logic.ts +182 -0
  85. package/mods/so-hud/hooks/register.tsx +670 -0
  86. package/mods/so-hud/hooks/rules.ts +2998 -0
  87. package/mods/so-hud/tsconfig.json +8 -0
  88. package/mods/so-hud/types/index.d.ts +36 -0
  89. package/package.json +4 -2
  90. package/rules/_index.md +1 -1
  91. package/rules/always-on/ask-via-tool.md +1 -1
  92. package/rules/always-on/loop-and-monitor.md +17 -194
  93. package/rules/always-on/parallel-sessions.md +3 -1
  94. package/rules/opt-in-stack/backend-data.md +1 -1
  95. package/rules/opt-in-stack/backend.md +1 -1
  96. package/rules/opt-in-stack/security-web.md +5 -1
  97. package/scripts/agent-usage-census.mjs +79 -0
  98. package/scripts/apply-session-learnings.mjs +34 -6
  99. package/scripts/archive-closed-prds.mjs +21 -5
  100. package/scripts/autopilot.mjs +11 -5
  101. package/scripts/backfill-abandoned-sessions.mjs +13 -3
  102. package/scripts/backfill-evidence-digest.mjs +2 -1
  103. package/scripts/backfill-learnings-from-vault.mjs +61 -6
  104. package/scripts/backfill-sessions.mjs +16 -2
  105. package/scripts/check-package-manager.mjs +26 -8
  106. package/scripts/check-sessions-integrity.mjs +4 -1
  107. package/scripts/ci/assert-vitest-green.mjs +3 -0
  108. package/scripts/ci/windows-smoke.mjs +201 -0
  109. package/scripts/close-precheck.mjs +55 -0
  110. package/scripts/dialectic-deriver.mjs +20 -12
  111. package/scripts/emit-event.mjs +2 -2
  112. package/scripts/emit-session.mjs +239 -20
  113. package/scripts/eval-session.mjs +17 -3
  114. package/scripts/export-hw-learnings.mjs +29 -2
  115. package/scripts/fleet-checkin-audit.mjs +8 -4
  116. package/scripts/fleet-checkin.mjs +39 -27
  117. package/scripts/gc-stale-worktrees.mjs +6 -1
  118. package/scripts/github-export.mjs +128 -0
  119. package/scripts/github-protection-audit.mjs +3 -1
  120. package/scripts/handover-queue-check.mjs +209 -0
  121. package/scripts/harness-audit.mjs +6 -4
  122. package/scripts/lib/agent-frontmatter.mjs +57 -10
  123. package/scripts/lib/agent-status.mjs +125 -7
  124. package/scripts/lib/agent-usage-census.mjs +394 -0
  125. package/scripts/lib/auto-dialectic.mjs +17 -10
  126. package/scripts/lib/auto-dream.mjs +22 -14
  127. package/scripts/lib/autopilot/loop.mjs +3 -1
  128. package/scripts/lib/autopilot/recent-runs.mjs +4 -2
  129. package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
  130. package/scripts/lib/autopilot/telemetry.mjs +5 -1
  131. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  132. package/scripts/lib/baseline-archetypes.mjs +10 -2
  133. package/scripts/lib/blocked-commands-policy.mjs +4 -3
  134. package/scripts/lib/build-live-signals.mjs +13 -9
  135. package/scripts/lib/ci-status-banner.mjs +329 -24
  136. package/scripts/lib/codex/plugin-contract.mjs +8 -0
  137. package/scripts/lib/cold-start-detector.mjs +5 -2
  138. package/scripts/lib/command-blocker.mjs +603 -43
  139. package/scripts/lib/common.mjs +3 -2
  140. package/scripts/lib/config/cross-repo.mjs +74 -7
  141. package/scripts/lib/config/host-paths.mjs +6 -2
  142. package/scripts/lib/config/section-extractor.mjs +65 -11
  143. package/scripts/lib/config/template-keys.mjs +153 -0
  144. package/scripts/lib/config/vault-integration.mjs +1 -0
  145. package/scripts/lib/config.mjs +2 -2
  146. package/scripts/lib/coordinator-snapshot.mjs +1 -1
  147. package/scripts/lib/discovery/triage-state.mjs +2 -1
  148. package/scripts/lib/dispatcher/rank.mjs +5 -7
  149. package/scripts/lib/ecosystem-health.mjs +2 -2
  150. package/scripts/lib/eval/engine.mjs +3 -2
  151. package/scripts/lib/eval/session-resolve.mjs +6 -2
  152. package/scripts/lib/eval/sink.mjs +4 -3
  153. package/scripts/lib/events-retention-banner.mjs +3 -2
  154. package/scripts/lib/events-schema.mjs +10 -0
  155. package/scripts/lib/events.mjs +23 -12
  156. package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
  157. package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
  158. package/scripts/lib/fleet-protocol.mjs +4 -0
  159. package/scripts/lib/gates/gate-full.mjs +172 -11
  160. package/scripts/lib/gates/gate-helpers.mjs +236 -23
  161. package/scripts/lib/git-config-drift.mjs +141 -7
  162. package/scripts/lib/github-export.mjs +447 -0
  163. package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
  164. package/scripts/lib/handover-queue-marks.mjs +159 -0
  165. package/scripts/lib/hardware-pattern-detector.mjs +103 -13
  166. package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
  167. package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
  168. package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
  169. package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
  170. package/scripts/lib/host-identity.mjs +9 -5
  171. package/scripts/lib/http-request.mjs +64 -0
  172. package/scripts/lib/instruction-budget-guard.mjs +30 -22
  173. package/scripts/lib/io.mjs +6 -3
  174. package/scripts/lib/issue-budget-reconcile.mjs +3 -2
  175. package/scripts/lib/issue-budget.mjs +36 -7
  176. package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
  177. package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
  178. package/scripts/lib/learnings/io.mjs +289 -15
  179. package/scripts/lib/learnings/select.mjs +53 -4
  180. package/scripts/lib/learnings/store-role.mjs +209 -0
  181. package/scripts/lib/learnings/surface.mjs +46 -7
  182. package/scripts/lib/ledger-path.mjs +113 -0
  183. package/scripts/lib/maintenance-due-banner.mjs +63 -12
  184. package/scripts/lib/memory-banner.mjs +5 -6
  185. package/scripts/lib/memory-proposals/collector.mjs +5 -2
  186. package/scripts/lib/memory-proposals/sink.mjs +7 -4
  187. package/scripts/lib/memory-proposals/store.mjs +22 -5
  188. package/scripts/lib/mode-selector-accuracy.mjs +2 -1
  189. package/scripts/lib/no-follow-open.mjs +57 -0
  190. package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
  191. package/scripts/lib/own-lock-root.mjs +95 -0
  192. package/scripts/lib/owner-interview.mjs +2 -1
  193. package/scripts/lib/owner-yaml.mjs +70 -7
  194. package/scripts/lib/platform.mjs +161 -9
  195. package/scripts/lib/plugin-update-banner.mjs +15 -11
  196. package/scripts/lib/pre-push-local-partial.mjs +117 -0
  197. package/scripts/lib/pre-push-remote-gate.mjs +151 -25
  198. package/scripts/lib/process-group.mjs +270 -34
  199. package/scripts/lib/project-hygiene.mjs +28 -0
  200. package/scripts/lib/quality-gate.mjs +33 -9
  201. package/scripts/lib/reconcile/backlog.mjs +29 -15
  202. package/scripts/lib/reconcile/engine.mjs +26 -2
  203. package/scripts/lib/reconcile/idempotency.mjs +65 -3
  204. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
  205. package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
  206. package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
  207. package/scripts/lib/resource-probe/evaluate.mjs +32 -9
  208. package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
  209. package/scripts/lib/rule-loader.mjs +20 -10
  210. package/scripts/lib/scope-baseline.mjs +123 -8
  211. package/scripts/lib/scope-echo.mjs +5 -2
  212. package/scripts/lib/scope-gate.mjs +55 -20
  213. package/scripts/lib/session-close-backfill.mjs +75 -7
  214. package/scripts/lib/session-end/close-precheck.mjs +151 -0
  215. package/scripts/lib/session-end/phase-skip.mjs +8 -7
  216. package/scripts/lib/session-end/tail-runner.mjs +7 -2
  217. package/scripts/lib/session-id.mjs +9 -1
  218. package/scripts/lib/session-identity/own-session.mjs +63 -0
  219. package/scripts/lib/session-lock.mjs +114 -0
  220. package/scripts/lib/session-record-repair.mjs +245 -35
  221. package/scripts/lib/session-registry.mjs +72 -9
  222. package/scripts/lib/session-schema/constants.mjs +9 -0
  223. package/scripts/lib/session-schema/filters.mjs +56 -1
  224. package/scripts/lib/session-schema/validator.mjs +15 -5
  225. package/scripts/lib/session-start-probes.mjs +149 -22
  226. package/scripts/lib/session-token-rollup.mjs +2 -1
  227. package/scripts/lib/session-work-trees.mjs +166 -0
  228. package/scripts/lib/sessions-canonical.mjs +61 -3
  229. package/scripts/lib/sessions-integrity-banner.mjs +17 -39
  230. package/scripts/lib/sessions-staleness-banner.mjs +8 -5
  231. package/scripts/lib/sh-quote.mjs +1 -0
  232. package/scripts/lib/skill-evidence-window.mjs +5 -1
  233. package/scripts/lib/skill-health/join.mjs +10 -9
  234. package/scripts/lib/skill-invocations-schema.mjs +13 -0
  235. package/scripts/lib/skill-judgments-schema.mjs +6 -2
  236. package/scripts/lib/snapshot-probe.mjs +414 -0
  237. package/scripts/lib/so-hud/collect.mjs +49 -5
  238. package/scripts/lib/so-hud/runtime.mjs +15 -4
  239. package/scripts/lib/spiral-carryover.mjs +8 -1
  240. package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
  241. package/scripts/lib/state-md/idle-reset.mjs +14 -5
  242. package/scripts/lib/state-md/mission-status.mjs +5 -1
  243. package/scripts/lib/state-md-peer-guard.mjs +2 -0
  244. package/scripts/lib/state-md.mjs +31 -8
  245. package/scripts/lib/sunset/walker.mjs +4 -3
  246. package/scripts/lib/telemetry/consent.mjs +4 -2
  247. package/scripts/lib/telemetry/queue.mjs +3 -3
  248. package/scripts/lib/telemetry/sync.mjs +45 -9
  249. package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
  250. package/scripts/lib/tmux-layout/layouts.mjs +20 -3
  251. package/scripts/lib/ux-grill/paths.mjs +5 -1
  252. package/scripts/lib/validate/check-agents.mjs +37 -6
  253. package/scripts/lib/validate/check-component-paths.mjs +25 -1
  254. package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
  255. package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
  256. package/scripts/lib/validate/check-json-files.mjs +3 -1
  257. package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
  258. package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
  259. package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
  260. package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
  261. package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
  262. package/scripts/lib/validate/check-unwired-features.mjs +4 -2
  263. package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
  264. package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
  265. package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
  266. package/scripts/lib/validate/confidential-names.mjs +3 -1
  267. package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
  268. package/scripts/lib/vault-mirror/namespace.mjs +19 -7
  269. package/scripts/lib/vault-mirror/process.mjs +56 -27
  270. package/scripts/lib/vault-mirror/prune.mjs +5 -2
  271. package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
  272. package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
  273. package/scripts/lib/vault-mirror/retention.mjs +2 -2
  274. package/scripts/lib/vault-mirror/utils.mjs +79 -0
  275. package/scripts/lib/vault-staleness-banner.mjs +4 -7
  276. package/scripts/lib/vault-status/board-writer.mjs +2 -1
  277. package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
  278. package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
  279. package/scripts/lib/win-process-tree.mjs +137 -0
  280. package/scripts/lib/workspace.mjs +1 -1
  281. package/scripts/lib/worktree/constants.mjs +1 -1
  282. package/scripts/lib/worktree/lifecycle.mjs +1 -1
  283. package/scripts/lib/worktree/listing.mjs +1 -1
  284. package/scripts/lib/zx.mjs +50 -0
  285. package/scripts/mcp-server.mjs +16 -7
  286. package/scripts/mcp-server.sh +13 -83
  287. package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
  288. package/scripts/memory-propose.mjs +10 -3
  289. package/scripts/migrate-cold-start-seed.mjs +7 -0
  290. package/scripts/migrate-legacy-learnings.sh +22 -0
  291. package/scripts/migrate-sessions-jsonl.mjs +16 -2
  292. package/scripts/migrate-vault-paths.mjs +29 -13
  293. package/scripts/parse-config.mjs +71 -14
  294. package/scripts/print-learnings-index.mjs +25 -2
  295. package/scripts/release-session-lock.mjs +94 -7
  296. package/scripts/release.mjs +451 -24
  297. package/scripts/relocate-vault-corpus.mjs +11 -7
  298. package/scripts/repair-invalid-sessions.mjs +68 -3
  299. package/scripts/resolve-session-invocation.mjs +10 -17
  300. package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
  301. package/scripts/run-quality-gate.mjs +33 -7
  302. package/scripts/self-update.mjs +56 -7
  303. package/scripts/session-commit-files.mjs +133 -0
  304. package/scripts/site-numbers.mjs +52 -9
  305. package/scripts/snapshot-probe.mjs +156 -0
  306. package/scripts/so-hud.mjs +29 -6
  307. package/scripts/state-md-idle-reset.mjs +2 -1
  308. package/scripts/sweep-expired-learnings.mjs +54 -6
  309. package/scripts/sweep-expired-rules.mjs +4 -1
  310. package/scripts/validate-plugin.mjs +22 -0
  311. package/scripts/vault-integration-watcher.mjs +54 -6
  312. package/scripts/vault-mirror.mjs +9 -0
  313. package/skills/_shared/fleet-protocol.md +142 -1
  314. package/skills/_shared/instruction-file-resolution.md +1 -1
  315. package/skills/_shared/monitor-patterns.md +44 -25
  316. package/skills/_shared/platform-tools.md +7 -0
  317. package/skills/_shared/state-ownership.md +3 -2
  318. package/skills/architecture/references/domain-model.md +1 -1
  319. package/skills/bootstrap/SKILL.md +3 -1
  320. package/skills/bootstrap/_shared-template.md +4 -4
  321. package/skills/bootstrap/fast-template.md +1 -1
  322. package/skills/bootstrap/private-contract.md +1 -1
  323. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
  324. package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
  325. package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
  326. package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
  327. package/skills/claude-md-drift-check/SKILL.md +3 -3
  328. package/skills/claude-md-drift-check/checker.mjs +61 -101
  329. package/skills/close/SKILL.md +20 -7
  330. package/skills/discovery/probes/docs-staleness.mjs +7 -5
  331. package/skills/discovery/probes/frontend-slop.mjs +7 -3
  332. package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
  333. package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
  334. package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
  335. package/skills/discovery/probes/vault-staleness.mjs +5 -3
  336. package/skills/discovery/probes-session.md +3 -0
  337. package/skills/discovery/probes-vault.md +2 -0
  338. package/skills/dispatcher/SKILL.md +1 -1
  339. package/skills/eval/SKILL.md +3 -1
  340. package/skills/evolve/SKILL.md +2 -0
  341. package/skills/evolve/references/evolve-analyze-mode.md +3 -3
  342. package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
  343. package/skills/gitlab-ops/SKILL.md +7 -6
  344. package/skills/gitlab-portfolio/SKILL.md +2 -2
  345. package/skills/go/SKILL.md +13 -1
  346. package/skills/npm-publish/SKILL.md +5 -2
  347. package/skills/plan/SKILL.md +23 -10
  348. package/skills/plan/mode-feature.md +13 -8
  349. package/skills/plan/mode-new.md +6 -14
  350. package/skills/plan/prd-feature-template.md +19 -0
  351. package/skills/plan/prd-full-template.md +12 -5
  352. package/skills/plan/prd-premortem-panel.md +48 -0
  353. package/skills/plan/prd-reviewer-prompt.md +11 -3
  354. package/skills/plan/soul.md +1 -1
  355. package/skills/portfolio/SKILL.md +5 -6
  356. package/skills/quality-gates/SKILL.md +2 -4
  357. package/skills/reconcile/SKILL.md +27 -4
  358. package/skills/release/SKILL.md +16 -8
  359. package/skills/session-end/SKILL.md +94 -11
  360. package/skills/session-end/phase-3-2-docs-verification.md +1 -0
  361. package/skills/session-end/phase-3-6-tail.md +11 -7
  362. package/skills/session-end/phase-3-7a-recommendations.md +9 -2
  363. package/skills/session-end/plan-verification.md +16 -1
  364. package/skills/session-end/references/phase-3-documentation-updates.md +14 -12
  365. package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
  366. package/skills/session-end/references/session-summary-template.md +4 -0
  367. package/skills/session-end/session-metrics-write.md +7 -4
  368. package/skills/session-plan/SKILL.md +5 -3
  369. package/skills/session-start/SKILL.md +9 -3
  370. package/skills/session-start/phase-4-5-resource-health.md +11 -5
  371. package/skills/session-start/phase-8-5-express-path.md +4 -3
  372. package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
  373. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -2
  374. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  375. package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
  376. package/skills/tmux-layout/SKILL.md +2 -2
  377. package/skills/vault-sync/validator.mjs +3 -2
  378. package/skills/wave-executor/SKILL.md +5 -4
  379. package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
  380. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
  381. package/skills/wave-executor/references/wave-loop-review.md +13 -5
  382. package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
  383. package/assets/og-card.png +0 -0
  384. package/assets/og-card.svg +0 -47
  385. package/assets/wave-lifecycle.svg +0 -98
@@ -0,0 +1,151 @@
1
+ /**
2
+ * close-precheck.mjs — the `/close` pre-check as code (#1661).
3
+ *
4
+ * The pre-check used to be prose that read `<state-dir>/STATE.md` and acted on
5
+ * its `status` alone. It never asked whose STATE.md that is. Two failures
6
+ * followed (2026-10-17, three sessions in one afternoon):
7
+ * - After `/clear` the tab runs under a NEW native id while STATE.md still
8
+ * carries the previous one. A completed foreign STATE.md read as "already
9
+ * finalized", the session answered with the wait sentence although its own
10
+ * handover was written, and the inbox kept nudging.
11
+ * - A foreign ACTIVE STATE.md (a peer working in the same checkout) passed
12
+ * condition 3, so session-end would have closed the peer's session.
13
+ *
14
+ * Ownership is decided on the native id only: STATE.md `session-id` (#1368)
15
+ * against `readProcessLocalSessionIds()`. The session lock is no proof here —
16
+ * it records the pid of the short-lived hook process, and in a shared checkout
17
+ * it may name a peer.
18
+ *
19
+ * Unproven ownership (#1718): a STATE.md without `session-id` (written before
20
+ * #1368, in practice a foreign old file) or a process that cannot name its own
21
+ * id never runs session-end on status alone. A would-be session-end verdict
22
+ * becomes `ownership-unproven` with a `confirmToken` bound to the STATE.md path
23
+ * and its raw bytes; only that token (`--confirm-own`) turns it back, and only while the
24
+ * file is unchanged. Field incident 2026-10-21: a legacy STATE.md of an older,
25
+ * already closed plan read `complete-missing-record`.
26
+ *
27
+ * Ceiling (BV-004): on platforms without a native id (Cursor, Pi, Codex
28
+ * without a thread id) that is one operator confirmation per `/close`.
29
+ * Revisit when those platforms expose a native session id.
30
+ */
31
+
32
+ import { createHash } from 'node:crypto';
33
+ import { existsSync, readFileSync } from 'node:fs';
34
+ import path from 'node:path';
35
+
36
+ import { parseStateMd } from '../state-md.mjs';
37
+ import { findRecordedSession } from '../session-close-backfill.mjs';
38
+ import { readLock } from '../session-lock.mjs';
39
+ import { readProcessLocalSessionIds } from '../session-identity/own-session.mjs';
40
+ import { readJsonlFile } from '../io.mjs';
41
+ import { resolveEventsLedgerRoot, resolveStateDir } from '../platform.mjs';
42
+
43
+ /**
44
+ * Confirmation token for an `ownership-unproven` STATE.md (#1718): bound to the
45
+ * STATE.md path and its raw bytes, so a byte-identical file in another checkout
46
+ * yields a different token.
47
+ *
48
+ * @param {string} statePath path of the STATE.md (resolved to absolute)
49
+ * @param {Buffer|string} raw the raw STATE.md bytes
50
+ * @returns {string} 16 lowercase hex characters
51
+ */
52
+ export function computeConfirmToken(statePath, raw) {
53
+ return createHash('sha256').update(path.resolve(statePath)).update('\0').update(raw).digest('hex').slice(0, 16);
54
+ }
55
+
56
+ /** Verdicts that hand off to session-end; every other verdict stops `/close`. */
57
+ export const RUNS_SESSION_END = new Set(['run-session-end', 'complete-missing-record']);
58
+
59
+ /**
60
+ * Classify `<state-dir>/STATE.md` for `/close`.
61
+ *
62
+ * @param {object} opts
63
+ * @param {string} opts.repoRoot project root (the working tree `/close` runs in)
64
+ * @param {string} [opts.stateDir] `.claude` | `.codex` | `.cursor` | `.pi`; default from the platform
65
+ * @param {object} [opts.env=process.env] injectable for tests
66
+ * @param {string} [opts.confirmToken] token from an earlier `ownership-unproven`
67
+ * result; bound to the STATE.md path and its raw bytes, confirms a legacy/unprovable
68
+ * STATE.md while that file is unchanged (#1718)
69
+ * @returns {{
70
+ * verdict: 'no-state'|'foreign'|'run-session-end'|'complete-missing-record'|'already-finalized'|'unexpected-status'|'inconclusive'|'ownership-unproven',
71
+ * ownership: 'own'|'foreign'|'legacy'|'unprovable'|'confirmed'|null,
72
+ * status: string|null, stateSessionId: string|null, ownSessionIds: string[],
73
+ * statePath: string, reason?: string,
74
+ * wouldRun?: 'run-session-end'|'complete-missing-record', confirmToken?: string,
75
+ * session?: string|null, startedAt?: string|null
76
+ * }} the last four only with `ownership-unproven`
77
+ */
78
+ export function classifyCloseState({ repoRoot, stateDir, env = process.env, confirmToken } = {}) {
79
+ const dir = stateDir ?? resolveStateDir();
80
+ const statePath = path.join(repoRoot, dir, 'STATE.md');
81
+ const ownSessionIds = readProcessLocalSessionIds({ env });
82
+ const base = { ownership: null, status: null, stateSessionId: null, ownSessionIds, statePath };
83
+
84
+ if (!existsSync(statePath)) return { ...base, verdict: 'no-state' };
85
+
86
+ const raw = readFileSync(statePath);
87
+ const parsed = parseStateMd(raw.toString('utf8'));
88
+ if (!parsed) return { ...base, verdict: 'inconclusive', reason: 'STATE.md has no readable frontmatter' };
89
+ const fm = parsed.frontmatter;
90
+ const status = typeof fm.status === 'string' ? fm.status : null;
91
+ const rawId = typeof fm['session-id'] === 'string' ? fm['session-id'].trim() : '';
92
+ const stateSessionId = rawId || null;
93
+
94
+ const ownership = !stateSessionId ? 'legacy'
95
+ : ownSessionIds.length === 0 ? 'unprovable'
96
+ : ownSessionIds.includes(stateSessionId) ? 'own' : 'foreign';
97
+ const known = { ...base, ownership, status, stateSessionId };
98
+
99
+ // A foreign STATE.md is never this session's to close, whatever its status:
100
+ // completed belongs to an earlier tab incarnation or a peer, active/paused to a
101
+ // peer (or to this tab before a /clear, which this process cannot prove).
102
+ if (ownership === 'foreign') return { ...known, verdict: 'foreign' };
103
+
104
+ // #1718: legacy/unprovable never reaches session-end without a token bound
105
+ // to this path and the bytes read here; every non-session-end verdict passes unchanged.
106
+ const gate = (wouldRun) => {
107
+ if (ownership === 'own') return { ...known, verdict: wouldRun };
108
+ const token = computeConfirmToken(statePath, raw);
109
+ if (confirmToken === token) return { ...known, ownership: 'confirmed', verdict: wouldRun };
110
+ const unproven = {
111
+ ...known, verdict: 'ownership-unproven', wouldRun, confirmToken: token,
112
+ session: typeof fm.session === 'string' ? fm.session : null,
113
+ startedAt: typeof fm.started_at === 'string' ? fm.started_at : null,
114
+ };
115
+ if (confirmToken === undefined) return unproven;
116
+ return { ...unproven, reason: 'confirm token does not match STATE.md (changed since the pre-check, or the token belongs to another file)' };
117
+ };
118
+
119
+ if (status === 'active' || status === 'paused') return gate('run-session-end');
120
+ if (status !== 'completed') return { ...known, verdict: 'unexpected-status' };
121
+
122
+ // #429: completed alone does not prove the Phase 3.7 writer ran.
123
+ let records;
124
+ try {
125
+ records = readJsonlFile(path.join(resolveEventsLedgerRoot(repoRoot), '.orchestrator', 'metrics', 'sessions.jsonl'));
126
+ } catch (err) {
127
+ return { ...known, verdict: 'inconclusive', reason: `sessions.jsonl unreadable: ${err.message}` };
128
+ }
129
+ // The lock fallback only for STATE.md without session-id (pre-#1368), and
130
+ // only when the lock names THIS process (#1663). A peer's lock UUID would
131
+ // become the native key in findRecordedSession, skip this session's own
132
+ // record (different UUID) before the label match is reached, and send
133
+ // session-end a second time. Foreign or unprovable lock → no fallback, the
134
+ // label + started_at join decides.
135
+ let lockId = null;
136
+ if (!stateSessionId) {
137
+ let lockRaw;
138
+ try { lockRaw = readLock({ repoRoot })?.session_id ?? null; } catch { lockRaw = null; }
139
+ const trimmed = typeof lockRaw === 'string' ? lockRaw.trim() : '';
140
+ if (trimmed && ownSessionIds.includes(trimmed)) lockId = trimmed;
141
+ }
142
+ const record = findRecordedSession(records, {
143
+ sessionId: stateSessionId ?? lockId,
144
+ semanticSessionId: fm.session ?? null,
145
+ startedAt: fm.started_at ?? null,
146
+ });
147
+ // An abandoned backfill stub is no completion; a legacy record without
148
+ // `status` (written before the field existed) is authoritative.
149
+ const finalized = record && (record.status === undefined || record.status === 'completed');
150
+ return finalized ? { ...known, verdict: 'already-finalized' } : gate('complete-missing-record');
151
+ }
@@ -61,11 +61,11 @@
61
61
  */
62
62
 
63
63
  import { existsSync } from 'node:fs';
64
- import path from 'node:path';
65
64
 
66
65
  import { collectProposals } from '../memory-proposals/collector.mjs';
67
66
  import { sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
68
- import { readSkillInvocations } from '../skill-invocations-schema.mjs';
67
+ import { readSkillInvocations, defaultSkillInvocationsPath } from '../skill-invocations-schema.mjs';
68
+ import { resolveLedgerPath } from '../ledger-path.mjs';
69
69
  import { runReconcileFromPhaseSkip, resolveEffectiveTargets } from '../reconcile/engine.mjs';
70
70
 
71
71
  // NOTE (2026-09-09): `shouldDispatchAutoDream` / `shouldDispatchAutoDialectic`
@@ -91,10 +91,11 @@ import { runReconcileFromPhaseSkip, resolveEffectiveTargets } from '../reconcile
91
91
  * @returns {{filePath: string, archivePath: string}}
92
92
  */
93
93
  export function resolveLearningsPaths(repoRoot) {
94
- const dir = path.join(repoRoot, '.orchestrator', 'metrics');
94
+ // #1722: each store resolves on its own — a linked worktree of a repo that
95
+ // ignores them maps to the main checkout, a versioned one stays local.
95
96
  return {
96
- filePath: path.join(dir, 'learnings.jsonl'),
97
- archivePath: path.join(dir, 'learnings-archive.jsonl'),
97
+ filePath: resolveLedgerPath('.orchestrator/metrics/learnings.jsonl', repoRoot),
98
+ archivePath: resolveLedgerPath('.orchestrator/metrics/learnings-archive.jsonl', repoRoot),
98
99
  };
99
100
  }
100
101
 
@@ -232,7 +233,7 @@ async function decideSkillJudge({ repoRoot, cfg, sessionId }) {
232
233
  return mkSkip(phase, 'disabled (skill-evolution.judge=false)', 'config-gate');
233
234
  }
234
235
  if (isPersistenceOff(cfg)) return mkSkip(phase, 'persistence=false', 'config-gate');
235
- const invPath = path.join(repoRoot, '.orchestrator', 'metrics', 'skill-invocations.jsonl');
236
+ const invPath = defaultSkillInvocationsPath(repoRoot);
236
237
  const entries = await readSkillInvocations(invPath);
237
238
  const noSessionFilter = sessionId === null || sessionId === undefined;
238
239
  const judged = new Set(
@@ -272,7 +273,7 @@ async function decideReconcile({ repoRoot, cfg }) {
272
273
  if (cfg?.reconcile?.enabled !== true) {
273
274
  return mkSkip(phase, 'disabled (reconcile.enabled=false)', 'config-gate');
274
275
  }
275
- const learningsPath = path.join(repoRoot, '.orchestrator', 'metrics', 'learnings.jsonl');
276
+ const learningsPath = resolveLearningsPaths(repoRoot).filePath;
276
277
  if (!existsSync(learningsPath)) {
277
278
  return mkSkip(phase, 'learnings.jsonl absent', 'learnings.jsonl');
278
279
  }
@@ -41,7 +41,8 @@
41
41
  *
42
42
  * @typedef {Object} SweepResult
43
43
  * @property {boolean} ran - true only when the store was actually rewritten.
44
- * @property {string} [reason] - why it did not run ('plan-skip' | 'no-plan' | 'error').
44
+ * @property {string} [reason] - why it did not run ('plan-skip' | 'no-plan' |
45
+ * 'store-role-inbox' | 'error').
45
46
  * @property {string} [error] - error message, `reason === 'error'` only.
46
47
  * @property {number} [scanned] - entries read from the active store (ran only).
47
48
  * @property {number} [archived] - entries moved to the archive sidecar (ran only).
@@ -50,7 +51,7 @@
50
51
  * @property {string} [archivePath] - the archive sidecar written to (ran only).
51
52
  */
52
53
 
53
- import { sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
54
+ import { SKIPPED_STORE_ROLE_INBOX, sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
54
55
  import { resolveLearningsPaths } from './phase-skip.mjs';
55
56
 
56
57
  /** Event name — a plain string literal so the events-schema census can see it. */
@@ -151,6 +152,10 @@ export async function runExpiredSweep({ repoRoot, plan, now, graceDays, emit = t
151
152
  ...(graceDays === undefined ? {} : { graceDays }),
152
153
  });
153
154
 
155
+ // Inbox role (#1516): the sweep wrote nothing, so this is not a run and
156
+ // emits no sweep_applied — that record's presence claims a rewrite.
157
+ if (res?.skipped === SKIPPED_STORE_ROLE_INBOX) return { ran: false, reason: SKIPPED_STORE_ROLE_INBOX };
158
+
154
159
  const scanned = res?.scanned ?? 0;
155
160
  const archived = res?.archived ?? 0;
156
161
  // The sweep result omits `malformed` when the read found none, so absent IS
@@ -43,6 +43,7 @@ import { readFile } from 'node:fs/promises';
43
43
  import path from 'node:path';
44
44
 
45
45
  import { eventsFilePath } from './events.mjs';
46
+ import { resolveEventsLedgerRoot } from './platform.mjs';
46
47
  import { readCanonicalSessions } from './sessions-canonical.mjs';
47
48
  import { readTailWindow } from './tail-window.mjs';
48
49
  import { withStateMdLock } from './session-lock.mjs';
@@ -144,7 +145,11 @@ function isValidMode(mode) {
144
145
  * only across DIFFERENT ids — never the same id twice, see above).
145
146
  */
146
147
  async function readSessionIdsFromHistory(repoRoot) {
147
- const filePath = path.join(repoRoot, '.orchestrator', 'metrics', 'sessions.jsonl');
148
+ // #1592: the repo's ONE sessions ledger (main checkout of a linked worktree,
149
+ // where every close writes since #1584). Aggregation is a monotone max, so the
150
+ // wider ledger can only raise n — the events source does not always cover a
151
+ // closed session (rotated ledger, no lock event), the worktree read was empty.
152
+ const filePath = path.join(resolveEventsLedgerRoot(repoRoot), '.orchestrator', 'metrics', 'sessions.jsonl');
148
153
  return readCanonicalSessions({ filePath }).map((rec) => rec.session_id);
149
154
  }
150
155
 
@@ -266,6 +271,9 @@ async function readSessionIdsFromEvents(repoRoot) {
266
271
  * @returns {Promise<string|null>} The session_id string, or null when absent.
267
272
  */
268
273
  async function readSessionIdFromStateMd(repoRoot) {
274
+ // #1636 decision: deliberately the READ resolver, foreign STATE.md included —
275
+ // this source only collects ids already TAKEN, so a foreign one can only raise
276
+ // n (max over a union) and ignoring it could re-mint a peer's id.
269
277
  const filePath = resolveStateMdPath(repoRoot);
270
278
  let raw;
271
279
  try {
@@ -27,6 +27,8 @@
27
27
  */
28
28
 
29
29
  import { readFileSync } from 'node:fs';
30
+ import { execFileSync } from 'node:child_process';
31
+ import { homedir } from 'node:os';
30
32
  import path from 'node:path';
31
33
  import { isLockShape } from '../session-lock-shape.mjs';
32
34
 
@@ -228,6 +230,67 @@ export function readProcessLocalSessionIds({ env = process.env, hookInput = null
228
230
  return ids;
229
231
  }
230
232
 
233
+ /**
234
+ * Read only process parent/name metadata, never command arguments or env.
235
+ * @param {number} pid
236
+ * @returns {{ ppid: number, comm: string } | null}
237
+ */
238
+ function readProcessParent(pid) {
239
+ const output = execFileSync('ps', ['-p', String(pid), '-o', 'ppid=,comm='], {
240
+ encoding: 'utf8', timeout: 1000, maxBuffer: 2048,
241
+ stdio: ['ignore', 'pipe', 'pipe'],
242
+ });
243
+ const match = /^\s*(\d+)\s+(\S+)\s*$/.exec(output);
244
+ return match ? { ppid: Number(match[1]), comm: match[2] } : null;
245
+ }
246
+
247
+ /**
248
+ * Prove an acknowledgement's own invocation using the nearest Claude process.
249
+ * Unlike the general identity union, inherited env and shared repo files are
250
+ * never proof here. Stop at the nearest Claude even when its session is unreadable.
251
+ * Missing, contradictory or unmeasurable evidence returns null; never throws.
252
+ * Reader injection permits deterministic fixtures when native ps is unavailable.
253
+ *
254
+ * @param {{ env?: object, pid?: number, homeDir?: string,
255
+ * readProcess?: (pid: number) => { ppid: number, comm: string } | null,
256
+ * readSession?: (pid: number) => unknown }} [opts]
257
+ * @returns {string|null}
258
+ */
259
+ export function readInvocationSessionId({
260
+ env = process.env, pid = process.pid, homeDir = homedir(),
261
+ readProcess = readProcessParent,
262
+ readSession = (ancestorPid) => JSON.parse(readFileSync(
263
+ path.join(homeDir, '.claude', 'sessions', `${ancestorPid}.json`), 'utf8',
264
+ )),
265
+ } = {}) {
266
+ const seen = new Set();
267
+ try {
268
+ for (let depth = 0; depth < 64; depth++) {
269
+ if (!Number.isSafeInteger(pid) || pid <= 0 || seen.has(pid)) return null;
270
+ seen.add(pid);
271
+ const processInfo = readProcess(pid);
272
+ if (!processInfo || !Number.isSafeInteger(processInfo.ppid) || processInfo.ppid < 0 ||
273
+ seen.has(processInfo.ppid) || typeof processInfo.comm !== 'string' || !processInfo.comm.trim()) return null;
274
+ // macOS reports comm as argv[0]; a Claude started by absolute path must still count.
275
+ if (path.basename(processInfo.comm) === 'claude') {
276
+ const session = readSession(pid);
277
+ const sessionId = typeof session?.sessionId === 'string' ? session.sessionId.trim() : '';
278
+ if (!sessionId) return null;
279
+ // Every present native ID must agree; platform selection cannot hide
280
+ // a contradictory inherited ID from a nested harness.
281
+ for (const key of ['CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID']) {
282
+ const value = env?.[key];
283
+ if (value !== undefined && value !== null &&
284
+ (typeof value !== 'string' || (value.trim() && value.trim() !== sessionId))) return null;
285
+ }
286
+ return sessionId;
287
+ }
288
+ pid = processInfo.ppid;
289
+ }
290
+ } catch { /* Missing process/session evidence must fail closed. */ }
291
+ return null;
292
+ }
293
+
231
294
  /**
232
295
  * Decide whether a wave-scope manifest belongs to THIS session.
233
296
  *
@@ -47,6 +47,7 @@ import { isLockShape } from './session-lock-shape.mjs';
47
47
  import { isPidAliveOnHost } from './file-lock.mjs';
48
48
  import { writeJsonAtomicSync } from './io.mjs';
49
49
  import { hostnamesMatch, lockHostCandidate, recordHostAlias, stableHostname } from './host-identity.mjs';
50
+ import { isSafeSessionId } from './fleet-protocol.mjs';
50
51
 
51
52
  // isPidAliveOnHost moved into file-lock.mjs in #630 (the file-lock primitive
52
53
  // owns it so the dependency edge points file-lock → io, never the reverse).
@@ -133,6 +134,13 @@ export const LOCK_LEFTOVER_MIN_AGE_MS = 60 * 60 * 1000;
133
134
  */
134
135
  export const OWNER_PROOF_RELPATH = '.orchestrator/runtime/lock-owner-proof.json';
135
136
 
137
+ /**
138
+ * Directory of the "released on purpose" markers (#1651), relative to the repo
139
+ * root — one `<raw session_id>.json` per session that gave its lock back via
140
+ * `scripts/release-session-lock.mjs`. Gitignored like the owner proof beside it.
141
+ */
142
+ export const RELEASE_MARKER_RELDIR = '.orchestrator/runtime/lock-released';
143
+
136
144
  // ---------------------------------------------------------------------------
137
145
  // Internal helpers
138
146
  // ---------------------------------------------------------------------------
@@ -875,6 +883,10 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
875
883
  function withOwnerProof(result, repoRoot) {
876
884
  const w = writeOwnerProof({ repoRoot, lock: result.lock });
877
885
  result.ownerProof = w.ok ? { ok: true } : { ok: false, reason: w.reason };
886
+ // #1651: the session holds a lock again, so its earlier "released on
887
+ // purpose" marker no longer describes it. This is the one junction every
888
+ // ok:true write of acquire()/forceAcquire() passes. Never throws.
889
+ clearReleaseMarker({ repoRoot, sessionId: result.lock?.session_id });
878
890
  return result;
879
891
  }
880
892
 
@@ -1253,6 +1265,108 @@ export function loadOwnerProof({ repoRoot } = {}) {
1253
1265
  }
1254
1266
  }
1255
1267
 
1268
+ /**
1269
+ * Absolute path of the release marker for `sessionId`, or null when the id is
1270
+ * not a safe single file-name component (#1651). The id comes from a lock file
1271
+ * or a CLI flag, so it is validated before it reaches a path: no separator, no
1272
+ * `.`/`..`, at most 128 chars of `[A-Za-z0-9._-]`.
1273
+ *
1274
+ * @param {string|undefined} repoRoot
1275
+ * @param {unknown} sessionId
1276
+ * @returns {string|null}
1277
+ */
1278
+ function releaseMarkerPath(repoRoot, sessionId) {
1279
+ if (!isSafeSessionId(sessionId)) return null;
1280
+ return path.join(repoRoot ?? process.cwd(), RELEASE_MARKER_RELDIR, `${sessionId}.json`);
1281
+ }
1282
+
1283
+ /**
1284
+ * Record that `sessionId` deliberately released its own lock (#1651), so the
1285
+ * SessionStart bootstrap does not re-create it on a `compact` or `resume` of
1286
+ * that same raw session — a headless run may be using the slot. Atomic
1287
+ * (tmp+rename). Never throws.
1288
+ *
1289
+ * Ceiling (BV-004): markers are never pruned — one small file per released
1290
+ * session id. Revisit if `.orchestrator/runtime/lock-released/` grows past a
1291
+ * few hundred entries.
1292
+ *
1293
+ * `reason` (optional, #1747) records why the lock was given back; the
1294
+ * SessionStart bootstrap writes `'lead-yield'` and honours such a marker only
1295
+ * while a fleet lead's lease is active. Absent = an explicit release.
1296
+ *
1297
+ * @param {{ repoRoot?: string, sessionId: string, reason?: string }} args
1298
+ * @returns {{ ok: true, path: string } | { ok: false, reason: string, error?: string }}
1299
+ */
1300
+ export function writeReleaseMarker({ repoRoot, sessionId, reason } = {}) {
1301
+ try {
1302
+ const file = releaseMarkerPath(repoRoot, sessionId);
1303
+ if (file === null) return { ok: false, reason: 'unsafe-session-id' };
1304
+ const w = writeJsonAtomicSync(
1305
+ file,
1306
+ {
1307
+ schema_version: 1,
1308
+ session_id: sessionId,
1309
+ released_at: nowIso(),
1310
+ host: stableHostname(),
1311
+ ...(typeof reason === 'string' ? { reason } : {}),
1312
+ },
1313
+ { tmpPrefix: '.lock-released.tmp' },
1314
+ );
1315
+ if (!w.ok) return { ok: false, reason: 'fs-error', error: w.error };
1316
+ return { ok: true, path: file };
1317
+ } catch (err) {
1318
+ return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
1319
+ }
1320
+ }
1321
+
1322
+ /**
1323
+ * True when a release marker for `sessionId` exists (#1651). Never throws; an
1324
+ * unsafe id or any fs error reads as "no marker", i.e. the pre-#1651 behaviour.
1325
+ *
1326
+ * @param {{ repoRoot?: string, sessionId: string }} args
1327
+ * @returns {boolean}
1328
+ */
1329
+ export function hasReleaseMarker({ repoRoot, sessionId } = {}) {
1330
+ try {
1331
+ const file = releaseMarkerPath(repoRoot, sessionId);
1332
+ return file !== null && fs.lstatSync(file).isFile();
1333
+ } catch {
1334
+ return false;
1335
+ }
1336
+ }
1337
+
1338
+ /**
1339
+ * The release marker body for `sessionId`, or null when it is absent, unsafe,
1340
+ * not a regular file or not a JSON object (#1747). Never throws. Existence
1341
+ * alone is {@link hasReleaseMarker}; this reader only adds the `reason`.
1342
+ *
1343
+ * @param {{ repoRoot?: string, sessionId: string }} args
1344
+ * @returns {{ reason?: unknown } & Record<string, unknown> | null}
1345
+ */
1346
+ export function readReleaseMarker({ repoRoot, sessionId } = {}) {
1347
+ try {
1348
+ const file = releaseMarkerPath(repoRoot, sessionId);
1349
+ if (file === null || !fs.lstatSync(file).isFile()) return null;
1350
+ const body = JSON.parse(fs.readFileSync(file, 'utf8'));
1351
+ return body !== null && typeof body === 'object' && !Array.isArray(body) ? body : null;
1352
+ } catch {
1353
+ return null;
1354
+ }
1355
+ }
1356
+
1357
+ /**
1358
+ * Remove the release marker for `sessionId` (#1651). Best-effort, never throws.
1359
+ *
1360
+ * @param {{ repoRoot?: string, sessionId: string }} args
1361
+ * @returns {void}
1362
+ */
1363
+ export function clearReleaseMarker({ repoRoot, sessionId } = {}) {
1364
+ try {
1365
+ const file = releaseMarkerPath(repoRoot, sessionId);
1366
+ if (file !== null) fs.rmSync(file, { force: true });
1367
+ } catch { /* best-effort */ }
1368
+ }
1369
+
1256
1370
  /**
1257
1371
  * Release the lock IFF it belongs to the given session_id.
1258
1372
  * Silent no-op when the lock belongs to a different session or does not exist.