session-orchestrator 5.11.0 → 5.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (394) hide show
  1. package/.claude-plugin/marketplace.json +12 -1
  2. package/.claude-plugin/plugin.json +4 -1
  3. package/.codex-plugin/agents/explorer.toml +1 -1
  4. package/.codex-plugin/agents/session-reviewer.toml +2 -2
  5. package/.codex-plugin/agents/wave-worker.toml +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/.orchestrator/policy/blocked-commands.json +8 -0
  9. package/.orchestrator/policy/public-export.json +30 -0
  10. package/CHANGELOG.md +180 -3
  11. package/README.md +126 -117
  12. package/agents/analyst.md +7 -2
  13. package/agents/architect-reviewer.md +7 -2
  14. package/agents/code-implementer.md +4 -2
  15. package/agents/db-specialist.md +9 -2
  16. package/agents/dialectic-deriver.md +9 -3
  17. package/agents/docs-writer.md +7 -2
  18. package/agents/eval-judge.md +7 -1
  19. package/agents/qa-strategist.md +7 -2
  20. package/agents/security-reviewer.md +9 -2
  21. package/agents/session-reviewer.md +18 -2
  22. package/agents/skill-applied-judge.md +7 -1
  23. package/agents/test-writer.md +9 -2
  24. package/agents/ui-developer.md +9 -2
  25. package/agents/ux-evaluator.md +6 -1
  26. package/commands/session.md +4 -2
  27. package/commands/templates-ack.md +89 -18
  28. package/docs/agent-authoring.md +30 -11
  29. package/docs/ci-setup.md +38 -2
  30. package/docs/codex-setup.md +33 -9
  31. package/docs/components.md +13 -2
  32. package/docs/cursor-setup.md +3 -3
  33. package/docs/events-schema.md +5 -4
  34. package/docs/evidence-candidates.md +63 -0
  35. package/docs/fleet-checkin-audit.md +7 -4
  36. package/docs/github-mirror-protection.md +150 -3
  37. package/docs/install.md +102 -25
  38. package/docs/instruction-delivery.md +1 -1
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/mods.md +118 -0
  41. package/docs/owner-config-schema.md +1 -0
  42. package/docs/plugin-architecture-v3.md +1 -1
  43. package/docs/pm-skills-marketplace.md +4 -3
  44. package/docs/rule-authoring.md +24 -13
  45. package/docs/scope-collision-guard.md +2 -1
  46. package/docs/session-config-reference.md +55 -21
  47. package/docs/session-config-template.md +32 -24
  48. package/docs/sessions-ledger-repair.md +137 -0
  49. package/docs/vault-docs-architecture.md +1 -1
  50. package/hooks/_lib/hook-import-set.json +406 -73
  51. package/hooks/_lib/hook-root.mjs +301 -0
  52. package/hooks/_lib/lock-bootstrap.mjs +434 -4
  53. package/hooks/_lib/orchestrator-ignore.mjs +3 -0
  54. package/hooks/_lib/subagent-transcript.mjs +2 -2
  55. package/hooks/_lib/vcs-create-matcher.mjs +13 -17
  56. package/hooks/cwd-change-restore.mjs +5 -1
  57. package/hooks/enforce-commands.mjs +4 -3
  58. package/hooks/enforce-scope.mjs +11 -1
  59. package/hooks/hooks-codex.json +48 -2
  60. package/hooks/hooks.json +31 -31
  61. package/hooks/on-session-end.mjs +38 -15
  62. package/hooks/on-session-start.mjs +158 -59
  63. package/hooks/on-stop.mjs +11 -6
  64. package/hooks/post-bash-issue-budget-refund.mjs +6 -1
  65. package/hooks/post-bash-write-verify.mjs +8 -4
  66. package/hooks/post-edit-validate.mjs +45 -52
  67. package/hooks/post-subagent-discovery-validator.mjs +2 -2
  68. package/hooks/post-tool-batch-wave-signal.mjs +8 -4
  69. package/hooks/post-tool-failure-corrective-context.mjs +5 -1
  70. package/hooks/pre-bash-destructive-guard.mjs +220 -8
  71. package/hooks/pre-bash-issue-budget.mjs +6 -1
  72. package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
  73. package/hooks/pre-bash-staging-fence.mjs +258 -14
  74. package/hooks/pre-bash-templates-first.mjs +28 -27
  75. package/hooks/run-node.sh +1 -1
  76. package/hooks/skill-invocation-telemetry.mjs +15 -8
  77. package/hooks/subagent-telemetry.mjs +77 -14
  78. package/hooks/wave-scope-commit-guard.mjs +143 -14
  79. package/mods/so-hud/.claude-plugin/plugin.json +11 -0
  80. package/mods/so-hud/README.md +57 -0
  81. package/mods/so-hud/hooks/beacon-io.mjs +101 -0
  82. package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
  83. package/mods/so-hud/hooks/hooks.json +1 -0
  84. package/mods/so-hud/hooks/logic.ts +182 -0
  85. package/mods/so-hud/hooks/register.tsx +670 -0
  86. package/mods/so-hud/hooks/rules.ts +2998 -0
  87. package/mods/so-hud/tsconfig.json +8 -0
  88. package/mods/so-hud/types/index.d.ts +36 -0
  89. package/package.json +4 -3
  90. package/rules/_index.md +1 -1
  91. package/rules/always-on/ask-via-tool.md +1 -1
  92. package/rules/always-on/loop-and-monitor.md +17 -194
  93. package/rules/always-on/parallel-sessions.md +3 -1
  94. package/rules/opt-in-stack/backend-data.md +1 -1
  95. package/rules/opt-in-stack/backend.md +1 -1
  96. package/rules/opt-in-stack/security-web.md +5 -1
  97. package/scripts/agent-usage-census.mjs +79 -0
  98. package/scripts/apply-session-learnings.mjs +34 -6
  99. package/scripts/archive-closed-prds.mjs +21 -5
  100. package/scripts/autopilot.mjs +11 -5
  101. package/scripts/backfill-abandoned-sessions.mjs +13 -3
  102. package/scripts/backfill-evidence-digest.mjs +2 -1
  103. package/scripts/backfill-learnings-from-vault.mjs +61 -6
  104. package/scripts/backfill-sessions.mjs +16 -2
  105. package/scripts/check-package-manager.mjs +26 -8
  106. package/scripts/check-sessions-integrity.mjs +4 -1
  107. package/scripts/ci/assert-vitest-green.mjs +3 -0
  108. package/scripts/ci/windows-smoke.mjs +201 -0
  109. package/scripts/close-precheck.mjs +55 -0
  110. package/scripts/dialectic-deriver.mjs +20 -12
  111. package/scripts/emit-event.mjs +2 -2
  112. package/scripts/emit-session.mjs +239 -20
  113. package/scripts/eval-session.mjs +17 -3
  114. package/scripts/export-hw-learnings.mjs +29 -2
  115. package/scripts/fleet-checkin-audit.mjs +8 -4
  116. package/scripts/fleet-checkin.mjs +73 -3
  117. package/scripts/gc-stale-worktrees.mjs +6 -1
  118. package/scripts/github-export.mjs +128 -0
  119. package/scripts/github-protection-audit.mjs +3 -1
  120. package/scripts/handover-queue-check.mjs +209 -0
  121. package/scripts/harness-audit.mjs +6 -4
  122. package/scripts/lib/agent-frontmatter.mjs +57 -10
  123. package/scripts/lib/agent-status.mjs +125 -7
  124. package/scripts/lib/agent-usage-census.mjs +394 -0
  125. package/scripts/lib/auto-dialectic.mjs +17 -10
  126. package/scripts/lib/auto-dream.mjs +22 -14
  127. package/scripts/lib/autopilot/loop.mjs +3 -1
  128. package/scripts/lib/autopilot/recent-runs.mjs +4 -2
  129. package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
  130. package/scripts/lib/autopilot/telemetry.mjs +5 -1
  131. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  132. package/scripts/lib/baseline-archetypes.mjs +10 -2
  133. package/scripts/lib/blocked-commands-policy.mjs +4 -3
  134. package/scripts/lib/build-live-signals.mjs +13 -9
  135. package/scripts/lib/ci-status-banner.mjs +329 -24
  136. package/scripts/lib/codex/plugin-contract.mjs +8 -0
  137. package/scripts/lib/cold-start-detector.mjs +5 -2
  138. package/scripts/lib/command-blocker.mjs +603 -43
  139. package/scripts/lib/common.mjs +3 -2
  140. package/scripts/lib/config/cross-repo.mjs +74 -7
  141. package/scripts/lib/config/host-paths.mjs +6 -2
  142. package/scripts/lib/config/remote-hosts.mjs +2 -2
  143. package/scripts/lib/config/section-extractor.mjs +65 -11
  144. package/scripts/lib/config/template-keys.mjs +153 -0
  145. package/scripts/lib/config/vault-integration.mjs +1 -0
  146. package/scripts/lib/config.mjs +3 -3
  147. package/scripts/lib/coordinator-snapshot.mjs +1 -1
  148. package/scripts/lib/discovery/triage-state.mjs +2 -1
  149. package/scripts/lib/dispatcher/rank.mjs +5 -7
  150. package/scripts/lib/ecosystem-health.mjs +2 -2
  151. package/scripts/lib/eval/engine.mjs +3 -2
  152. package/scripts/lib/eval/session-resolve.mjs +6 -2
  153. package/scripts/lib/eval/sink.mjs +4 -3
  154. package/scripts/lib/events-retention-banner.mjs +3 -2
  155. package/scripts/lib/events-schema.mjs +10 -0
  156. package/scripts/lib/events.mjs +23 -12
  157. package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
  158. package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
  159. package/scripts/lib/fleet-protocol.mjs +21 -0
  160. package/scripts/lib/gates/gate-full.mjs +172 -11
  161. package/scripts/lib/gates/gate-helpers.mjs +236 -23
  162. package/scripts/lib/git-config-drift.mjs +141 -7
  163. package/scripts/lib/github-export.mjs +447 -0
  164. package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
  165. package/scripts/lib/handover-queue-marks.mjs +159 -0
  166. package/scripts/lib/hardware-pattern-detector.mjs +103 -13
  167. package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
  168. package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
  169. package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
  170. package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
  171. package/scripts/lib/host-identity.mjs +9 -5
  172. package/scripts/lib/http-request.mjs +64 -0
  173. package/scripts/lib/instruction-budget-guard.mjs +30 -22
  174. package/scripts/lib/io.mjs +6 -3
  175. package/scripts/lib/issue-budget-reconcile.mjs +3 -2
  176. package/scripts/lib/issue-budget.mjs +36 -7
  177. package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
  178. package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
  179. package/scripts/lib/learnings/io.mjs +289 -15
  180. package/scripts/lib/learnings/select.mjs +53 -4
  181. package/scripts/lib/learnings/store-role.mjs +209 -0
  182. package/scripts/lib/learnings/surface.mjs +46 -7
  183. package/scripts/lib/ledger-path.mjs +113 -0
  184. package/scripts/lib/maintenance-due-banner.mjs +63 -12
  185. package/scripts/lib/memory-banner.mjs +5 -6
  186. package/scripts/lib/memory-proposals/collector.mjs +5 -2
  187. package/scripts/lib/memory-proposals/sink.mjs +7 -4
  188. package/scripts/lib/memory-proposals/store.mjs +22 -5
  189. package/scripts/lib/mode-selector-accuracy.mjs +2 -1
  190. package/scripts/lib/no-follow-open.mjs +57 -0
  191. package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
  192. package/scripts/lib/own-lock-root.mjs +95 -0
  193. package/scripts/lib/owner-interview.mjs +2 -1
  194. package/scripts/lib/owner-yaml.mjs +70 -7
  195. package/scripts/lib/platform.mjs +161 -9
  196. package/scripts/lib/plugin-update-banner.mjs +15 -11
  197. package/scripts/lib/pre-push-local-partial.mjs +117 -0
  198. package/scripts/lib/pre-push-remote-gate.mjs +151 -25
  199. package/scripts/lib/process-group.mjs +270 -34
  200. package/scripts/lib/project-hygiene.mjs +28 -0
  201. package/scripts/lib/quality-gate.mjs +33 -9
  202. package/scripts/lib/reconcile/backlog.mjs +29 -15
  203. package/scripts/lib/reconcile/engine.mjs +26 -2
  204. package/scripts/lib/reconcile/idempotency.mjs +65 -3
  205. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
  206. package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
  207. package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
  208. package/scripts/lib/resource-probe/evaluate.mjs +32 -9
  209. package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
  210. package/scripts/lib/rule-loader.mjs +20 -10
  211. package/scripts/lib/scope-baseline.mjs +123 -8
  212. package/scripts/lib/scope-echo.mjs +5 -2
  213. package/scripts/lib/scope-gate.mjs +55 -20
  214. package/scripts/lib/session-close-backfill.mjs +75 -7
  215. package/scripts/lib/session-end/close-precheck.mjs +151 -0
  216. package/scripts/lib/session-end/phase-skip.mjs +8 -7
  217. package/scripts/lib/session-end/tail-runner.mjs +7 -2
  218. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  219. package/scripts/lib/session-id.mjs +9 -1
  220. package/scripts/lib/session-identity/own-session.mjs +63 -0
  221. package/scripts/lib/session-invocation.mjs +14 -3
  222. package/scripts/lib/session-lock.mjs +114 -0
  223. package/scripts/lib/session-record-repair.mjs +245 -35
  224. package/scripts/lib/session-registry.mjs +85 -12
  225. package/scripts/lib/session-schema/constants.mjs +9 -0
  226. package/scripts/lib/session-schema/filters.mjs +56 -1
  227. package/scripts/lib/session-schema/validator.mjs +15 -5
  228. package/scripts/lib/session-start-probes.mjs +149 -22
  229. package/scripts/lib/session-token-rollup.mjs +2 -1
  230. package/scripts/lib/session-work-trees.mjs +166 -0
  231. package/scripts/lib/sessions-canonical.mjs +61 -3
  232. package/scripts/lib/sessions-integrity-banner.mjs +17 -39
  233. package/scripts/lib/sessions-staleness-banner.mjs +8 -5
  234. package/scripts/lib/sh-quote.mjs +1 -0
  235. package/scripts/lib/skill-evidence-window.mjs +5 -1
  236. package/scripts/lib/skill-health/join.mjs +10 -9
  237. package/scripts/lib/skill-invocations-schema.mjs +13 -0
  238. package/scripts/lib/skill-judgments-schema.mjs +6 -2
  239. package/scripts/lib/snapshot-probe.mjs +414 -0
  240. package/scripts/lib/so-hud/collect.mjs +174 -0
  241. package/scripts/lib/so-hud/runtime.mjs +87 -0
  242. package/scripts/lib/spiral-carryover.mjs +8 -1
  243. package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
  244. package/scripts/lib/state-md/idle-reset.mjs +248 -0
  245. package/scripts/lib/state-md/mission-status.mjs +5 -1
  246. package/scripts/lib/state-md/recommendations.mjs +26 -4
  247. package/scripts/lib/state-md-peer-guard.mjs +2 -0
  248. package/scripts/lib/state-md.mjs +31 -8
  249. package/scripts/lib/sunset/walker.mjs +4 -3
  250. package/scripts/lib/telemetry/consent.mjs +4 -2
  251. package/scripts/lib/telemetry/queue.mjs +3 -3
  252. package/scripts/lib/telemetry/sync.mjs +45 -9
  253. package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
  254. package/scripts/lib/tmux-layout/layouts.mjs +20 -3
  255. package/scripts/lib/ux-grill/paths.mjs +5 -1
  256. package/scripts/lib/validate/check-agents.mjs +37 -6
  257. package/scripts/lib/validate/check-component-paths.mjs +25 -1
  258. package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
  259. package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
  260. package/scripts/lib/validate/check-json-files.mjs +3 -1
  261. package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
  262. package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
  263. package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
  264. package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
  265. package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
  266. package/scripts/lib/validate/check-unwired-features.mjs +4 -2
  267. package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
  268. package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
  269. package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
  270. package/scripts/lib/validate/confidential-names.mjs +3 -1
  271. package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
  272. package/scripts/lib/vault-mirror/namespace.mjs +22 -9
  273. package/scripts/lib/vault-mirror/process.mjs +197 -65
  274. package/scripts/lib/vault-mirror/prune.mjs +5 -2
  275. package/scripts/lib/vault-mirror/render-learnings.mjs +8 -7
  276. package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
  277. package/scripts/lib/vault-mirror/retention.mjs +2 -2
  278. package/scripts/lib/vault-mirror/utils.mjs +106 -13
  279. package/scripts/lib/vault-staleness-banner.mjs +4 -7
  280. package/scripts/lib/vault-status/board-writer.mjs +34 -5
  281. package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
  282. package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
  283. package/scripts/lib/win-process-tree.mjs +137 -0
  284. package/scripts/lib/workspace.mjs +1 -1
  285. package/scripts/lib/worktree/constants.mjs +1 -1
  286. package/scripts/lib/worktree/lifecycle.mjs +1 -1
  287. package/scripts/lib/worktree/listing.mjs +1 -1
  288. package/scripts/lib/zx.mjs +50 -0
  289. package/scripts/mcp-server.mjs +16 -7
  290. package/scripts/mcp-server.sh +13 -83
  291. package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
  292. package/scripts/memory-propose.mjs +10 -3
  293. package/scripts/migrate-cold-start-seed.mjs +7 -0
  294. package/scripts/migrate-legacy-learnings.sh +22 -0
  295. package/scripts/migrate-sessions-jsonl.mjs +16 -2
  296. package/scripts/migrate-vault-paths.mjs +29 -13
  297. package/scripts/parse-config.mjs +71 -14
  298. package/scripts/print-learnings-index.mjs +25 -2
  299. package/scripts/release-session-lock.mjs +94 -7
  300. package/scripts/release.mjs +451 -24
  301. package/scripts/relocate-vault-corpus.mjs +11 -7
  302. package/scripts/repair-invalid-sessions.mjs +68 -3
  303. package/scripts/resolve-session-invocation.mjs +57 -8
  304. package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
  305. package/scripts/run-quality-gate.mjs +33 -7
  306. package/scripts/self-update.mjs +56 -7
  307. package/scripts/session-commit-files.mjs +133 -0
  308. package/scripts/site-numbers.mjs +52 -9
  309. package/scripts/snapshot-probe.mjs +156 -0
  310. package/scripts/so-hud.mjs +68 -0
  311. package/scripts/state-md-idle-reset.mjs +82 -0
  312. package/scripts/sweep-expired-learnings.mjs +54 -6
  313. package/scripts/sweep-expired-rules.mjs +4 -1
  314. package/scripts/validate-plugin.mjs +22 -0
  315. package/scripts/vault-integration-watcher.mjs +54 -6
  316. package/scripts/vault-mirror.mjs +9 -0
  317. package/scripts/vault-orphans.mjs +171 -0
  318. package/skills/_shared/fleet-protocol.md +166 -3
  319. package/skills/_shared/instruction-file-resolution.md +1 -1
  320. package/skills/_shared/monitor-patterns.md +44 -25
  321. package/skills/_shared/platform-tools.md +7 -0
  322. package/skills/_shared/state-ownership.md +5 -4
  323. package/skills/architecture/references/domain-model.md +1 -1
  324. package/skills/autopilot/SKILL.md +3 -2
  325. package/skills/bootstrap/SKILL.md +3 -1
  326. package/skills/bootstrap/_shared-template.md +4 -4
  327. package/skills/bootstrap/fast-template.md +1 -1
  328. package/skills/bootstrap/private-contract.md +1 -1
  329. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
  330. package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
  331. package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
  332. package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
  333. package/skills/claude-md-drift-check/SKILL.md +3 -3
  334. package/skills/claude-md-drift-check/checker.mjs +61 -101
  335. package/skills/close/SKILL.md +20 -7
  336. package/skills/discovery/probes/docs-staleness.mjs +7 -5
  337. package/skills/discovery/probes/frontend-slop.mjs +7 -3
  338. package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
  339. package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
  340. package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
  341. package/skills/discovery/probes/vault-staleness.mjs +5 -3
  342. package/skills/discovery/probes-session.md +3 -0
  343. package/skills/discovery/probes-vault.md +2 -0
  344. package/skills/dispatcher/SKILL.md +1 -1
  345. package/skills/eval/SKILL.md +3 -1
  346. package/skills/evolve/SKILL.md +2 -0
  347. package/skills/evolve/references/evolve-analyze-mode.md +3 -3
  348. package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
  349. package/skills/gitlab-ops/SKILL.md +7 -6
  350. package/skills/gitlab-portfolio/SKILL.md +2 -2
  351. package/skills/go/SKILL.md +13 -1
  352. package/skills/npm-publish/SKILL.md +5 -2
  353. package/skills/plan/SKILL.md +23 -10
  354. package/skills/plan/mode-feature.md +13 -8
  355. package/skills/plan/mode-new.md +6 -14
  356. package/skills/plan/prd-feature-template.md +19 -0
  357. package/skills/plan/prd-full-template.md +12 -5
  358. package/skills/plan/prd-premortem-panel.md +48 -0
  359. package/skills/plan/prd-reviewer-prompt.md +11 -3
  360. package/skills/plan/soul.md +1 -1
  361. package/skills/portfolio/SKILL.md +5 -6
  362. package/skills/quality-gates/SKILL.md +2 -4
  363. package/skills/reconcile/SKILL.md +27 -4
  364. package/skills/release/SKILL.md +16 -8
  365. package/skills/session-end/SKILL.md +97 -13
  366. package/skills/session-end/phase-3-2-docs-verification.md +1 -0
  367. package/skills/session-end/phase-3-6-tail.md +11 -7
  368. package/skills/session-end/phase-3-7a-recommendations.md +9 -2
  369. package/skills/session-end/plan-verification.md +16 -1
  370. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  371. package/skills/session-end/references/phase-3-documentation-updates.md +25 -13
  372. package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
  373. package/skills/session-end/references/session-summary-template.md +8 -0
  374. package/skills/session-end/session-metrics-write.md +7 -4
  375. package/skills/session-plan/SKILL.md +5 -3
  376. package/skills/session-start/SKILL.md +10 -4
  377. package/skills/session-start/phase-4-5-resource-health.md +11 -5
  378. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  379. package/skills/session-start/phase-8-5-express-path.md +4 -3
  380. package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
  381. package/skills/session-start/references/phase-1-5-session-continuity.md +13 -5
  382. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  383. package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
  384. package/skills/tmux-layout/SKILL.md +2 -2
  385. package/skills/vault-sync/validator.mjs +3 -2
  386. package/skills/wave-executor/SKILL.md +5 -4
  387. package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
  388. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
  389. package/skills/wave-executor/references/wave-loop-review.md +13 -5
  390. package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
  391. package/AGENTS.md +0 -207
  392. package/assets/og-card.png +0 -0
  393. package/assets/og-card.svg +0 -47
  394. package/assets/wave-lifecycle.svg +0 -98
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { discoverActiveSessions } from '../session-discovery.mjs';
6
+ import { readRegistry, activeDir } from '../session-registry.mjs';
7
+ import { readCurrentStatus } from '../agent-status.mjs';
8
+ import { readLockDetailed, isLockLive } from '../session-lock.mjs';
9
+ import { readTailWindow } from '../tail-window.mjs';
10
+ import { parseSessionConfig } from '../config.mjs';
11
+ import { resolveStateMdPath } from '../state-md.mjs';
12
+ import { resolveOwnLockRootFor } from '../own-lock-root.mjs';
13
+ import { collectSnapshot } from './collect.mjs';
14
+ import { resolveEventsLedgerRoot } from '../platform.mjs';
15
+ const exec = promisify(execFile);
16
+ export const MAX_EVENT_BYTES = 512 * 1024;
17
+
18
+ export function readEventTail(repoRoot) {
19
+ try {
20
+ const tail = readTailWindow(path.join(resolveEventsLedgerRoot(repoRoot), '.orchestrator/metrics/events.jsonl'), MAX_EVENT_BYTES);
21
+ return { text: tail.cut ? tail.text.slice(tail.text.indexOf('\n') + 1) : tail.text, degraded: tail.cut ? ['events:tail-only'] : [] };
22
+ } catch (e) { return { text: '', degraded: [e.code === 'ENOENT' ? 'events:absent' : 'events:unreadable'] }; }
23
+ }
24
+
25
+ async function registryWithHealth() {
26
+ // The canonical reader intentionally swallows failures; distinguish its directory first.
27
+ try { await fs.readdir(activeDir()); }
28
+ catch (e) { if (e.code === 'ENOENT') return []; throw e; }
29
+ return readRegistry();
30
+ }
31
+
32
+ /** Capture fallback at the injected boundaries, without intercepting global stderr. */
33
+ export async function discover(repoRoot, { run = exec, registry = registryWithHealth, discovery = discoverActiveSessions, now = Date.now() } = {}) {
34
+ const degraded = [];
35
+ let registryEntries = [];
36
+ const sessions = await discovery(repoRoot, {
37
+ now, timeoutMs: 1000,
38
+ listWorktreesImpl: async () => {
39
+ try {
40
+ const { stdout } = await run('git', ['-C', repoRoot, 'worktree', 'list', '--porcelain'], { timeout: 700, maxBuffer: 512 * 1024 });
41
+ return stdout.trim().split('\n\n').filter(Boolean).map(block => {
42
+ const lines = block.split('\n');
43
+ return { path: lines.find(l => l.startsWith('worktree '))?.slice(9), branch: lines.find(l => l.startsWith('branch '))?.slice(7).replace(/^refs\/heads\//, '') ?? '' };
44
+ }).filter(w => w.path);
45
+ } catch (e) {
46
+ degraded.push(e.killed || e.code === 'ETIMEDOUT' ? 'discovery-timeout' : 'discovery-fallback');
47
+ return [{ path: repoRoot, branch: '' }];
48
+ }
49
+ },
50
+ registryReader: async () => {
51
+ try { registryEntries = await registry(); return registryEntries; } catch { degraded.push('registry-unreadable'); return []; }
52
+ },
53
+ });
54
+ if (sessions.some(s => s.registryOnly)) degraded.push('discovery:registry-fallback');
55
+ return { sessions: sessions.map(s => ({ ...s, last_heartbeat: registryEntries.find(r => r.session_id === s.sessionId)?.last_heartbeat ?? null })), degraded };
56
+ }
57
+
58
+ async function config(repoRoot) {
59
+ for (const name of ['CLAUDE.md', 'AGENTS.md']) {
60
+ try {
61
+ const source = await fs.readFile(path.join(repoRoot, name), 'utf8');
62
+ if (!/^vault-integration:/m.test(source)) continue;
63
+ // No host-local owner.yaml reads and no vault path resolution outside this repo.
64
+ const parsed = parseSessionConfig(source, { hostPaths: { env: {}, ownerConfig: {} } });
65
+ const value = parsed['vault-integration'];
66
+ return { enabled: value?.enabled ?? null, mode: value?.mode ?? null };
67
+ } catch (e) { if (e.code !== 'ENOENT') return { degraded: ['config:unreadable'] }; }
68
+ }
69
+ return { enabled: null, mode: null };
70
+ }
71
+ /**
72
+ * @param {string} repoRoot
73
+ * @param {{ sessionId?: string|null }} [opts] `sessionId` = the mod's raw session id (#1730). The agent-status
74
+ * writer (`hooks/subagent-telemetry.mjs`) writes beside the session's own lock, which is not always `repoRoot`:
75
+ * a session working in a worktree by `cd` keeps its lock in the main checkout and the other way round. With an
76
+ * id, agents and the lock that dates them are read there. Without one, everything is read at `repoRoot`.
77
+ */
78
+ export async function measure(repoRoot, { sessionId = null } = {}) {
79
+ const ownRoot = sessionId ? resolveOwnLockRootFor({ sessionId, cwd: repoRoot }).root : null;
80
+ return collectSnapshot(repoRoot, {
81
+ now: Date.now, readText: file => fs.readFile(file, 'utf8'), lock: root => readLockDetailed({ repoRoot: root }),
82
+ isLockLive, agents: root => readCurrentStatus({ repoRoot: ownRoot ?? root }), discover, events: readEventTail, config,
83
+ ...(ownRoot ? { agentLock: () => readLockDetailed({ repoRoot: ownRoot }) } : {}),
84
+ statePath: path.relative(repoRoot, resolveStateMdPath(repoRoot)),
85
+ sessionId: sessionId ?? process.env.CLAUDE_SESSION_ID ?? process.env.SO_SESSION_ID ?? null,
86
+ });
87
+ }
@@ -44,6 +44,7 @@ import {
44
44
  resolveIssueBudgetSessionId,
45
45
  } from './issue-budget.mjs';
46
46
  import { resolveProjectDir } from './platform.mjs';
47
+ import { resolveOwnLockRoot } from '../../hooks/_lib/hook-root.mjs';
47
48
  import { resolveRepoSpec } from './vcs-repo-spec.mjs';
48
49
 
49
50
  /**
@@ -167,8 +168,14 @@ function runCli(cmd, args, repoRoot) {
167
168
  let currentSession = null;
168
169
  if (typeof nativeRawId === 'string' && nativeRawId.length > 0) {
169
170
  try {
171
+ // #1605: after /clear in an entered worktree the session's lock and
172
+ // current-session.json live in that worktree, not at the launch root.
173
+ // Read the record beside the own lock — the same lookup
174
+ // `hooks/pre-bash-issue-budget.mjs` uses — so both producers charge
175
+ // the ledger (which stays at `budgetRoot`) under the same session key.
176
+ const ownRoot = resolveOwnLockRoot({ session_id: nativeRawId, cwd: process.cwd() }, budgetRoot);
170
177
  const parsed = JSON.parse(
171
- readFileSync(path.join(budgetRoot, '.orchestrator', 'current-session.json'), 'utf8'),
178
+ readFileSync(path.join(ownRoot, '.orchestrator', 'current-session.json'), 'utf8'),
172
179
  );
173
180
  // SESSION BINDING (#1058). `current-session.json` is repo-global —
174
181
  // every session sharing this working copy writes the same path — so
@@ -15,6 +15,9 @@ import { dirname, resolve as resolvePath } from 'node:path';
15
15
  import { parseStateMd, serializeStateMd } from './yaml-parser.mjs';
16
16
  import { withStateMdLock } from '../session-lock.mjs';
17
17
  import { resolveStateDir } from '../platform.mjs';
18
+ // Hook-closure note: own-session.mjs imports only node:* and the import-free
19
+ // session-lock-shape.mjs, both already in the hook import set.
20
+ import { readProcessLocalSessionIds } from '../session-identity/own-session.mjs';
18
21
 
19
22
  /**
20
23
  * Sets frontmatter.updated to the given ISO 8601 timestamp and returns the
@@ -85,20 +88,84 @@ const STATE_DIR_CANDIDATES = ['.claude', '.codex', '.cursor', '.pi'];
85
88
  * @returns {string} Absolute artifact path.
86
89
  */
87
90
  export function resolveStateArtifactPath(repoRoot, filename) {
91
+ const { candidates } = stateArtifactCandidates(repoRoot, filename);
92
+ return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
93
+ }
94
+
95
+ /**
96
+ * Shared candidate list: SO_STATE_DIR override (if set), the active harness's
97
+ * state dir, then the legacy harness dirs. `preferredCount` is how many of the
98
+ * leading entries are override/active (as opposed to legacy).
99
+ *
100
+ * @param {string|undefined} repoRoot
101
+ * @param {string} filename
102
+ * @returns {{ candidates: string[], preferredCount: number }}
103
+ */
104
+ function stateArtifactCandidates(repoRoot, filename) {
88
105
  const root = repoRoot ?? process.cwd();
89
106
  const override = (process.env.SO_STATE_DIR ?? '').trim();
90
107
  const active = resolveStateDir();
91
- const directories = [...new Set([override || active, active, ...STATE_DIR_CANDIDATES])];
92
- const candidates = directories.map((directory) => resolvePath(root, directory, filename));
93
- return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
108
+ const preferred = [...new Set([override || active, active])];
109
+ const directories = [...new Set([...preferred, ...STATE_DIR_CANDIDATES])];
110
+ return {
111
+ candidates: directories.map((directory) => resolvePath(root, directory, filename)),
112
+ preferredCount: preferred.length,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * May THIS process write into the existing STATE.md at `candidate`?
118
+ * Always yes for a placeholder (no frontmatter or no `session-id`), a
119
+ * `status: idle` record, or a `session-id` naming this process
120
+ * (`readProcessLocalSessionIds()`). An unreadable file is never a target.
121
+ *
122
+ * Otherwise the answer depends on where the candidate sits:
123
+ * - Legacy (other-platform) dir: no — #1618, unchanged.
124
+ * - SO_STATE_DIR / active dir (`preferred`): refused ONLY when it is
125
+ * `status: completed` AND this process has a measurable own id (#1640 —
126
+ * the measured damage: session-end Phase 3.7a wrote into a FINISHED
127
+ * foreign session's record). A non-completed record keeps the pre-#1640
128
+ * behaviour: one checkout has one session.lock owner, and after `/clear`
129
+ * the process carries a NEW raw id while its own active STATE.md still
130
+ * names the old one — refusing that file made `/close` unable to finish.
131
+ * `completedHandoff` (Idle Reset) may take a completed record too: that
132
+ * rewrite IS the hand-over to the next session.
133
+ *
134
+ * CEILING (BV-004): a `/clear`ed session's active file is still identified by
135
+ * its OLD id until close, where {@link markStateMdCompletedOnDisk} re-stamps
136
+ * it; a live parallel session in the same checkout is PSA territory, not
137
+ * this guard's. Revisit when the raw id survives `/clear` (resume linkage).
138
+ *
139
+ * @param {string} candidate
140
+ * @param {string[]} ownIds
141
+ * @param {{ preferred: boolean, completedHandoff?: boolean }} opts
142
+ * @returns {boolean}
143
+ */
144
+ function isWritableStateMd(candidate, ownIds, { preferred, completedHandoff = false }) {
145
+ let frontmatter;
146
+ try {
147
+ frontmatter = parseStateMd(readFileSync(candidate, 'utf8'))?.frontmatter ?? null;
148
+ } catch {
149
+ return false;
150
+ }
151
+ const raw = frontmatter?.['session-id'];
152
+ const sessionId = typeof raw === 'string' ? raw.trim() : '';
153
+ if (!sessionId) return true;
154
+ const status = frontmatter.status;
155
+ if (status === 'idle') return true;
156
+ if (ownIds.includes(sessionId)) return true;
157
+ if (!preferred) return false;
158
+ return status !== 'completed' || completedHandoff || ownIds.length === 0;
94
159
  }
95
160
 
96
161
  /**
97
- * Resolve the active STATE.md path under a given repo root.
162
+ * Resolve the active STATE.md path under a given repo root — READ side.
98
163
  *
99
164
  * Returns the first existing candidate, checking the active platform first.
100
165
  * Falls back to the active platform's state dir when none exist (matches the
101
- * create-on-first-write semantics expected by callers).
166
+ * create-on-first-write semantics expected by callers). The legacy fallback to
167
+ * another platform's STATE.md is kept for readers (#890); writers must use
168
+ * {@link resolveStateMdWritePath}.
102
169
  *
103
170
  * @param {string|undefined} repoRoot
104
171
  * @returns {string} Absolute path to STATE.md.
@@ -107,6 +174,83 @@ export function resolveStateMdPath(repoRoot) {
107
174
  return resolveStateArtifactPath(repoRoot, 'STATE.md');
108
175
  }
109
176
 
177
+ /**
178
+ * Resolve the STATE.md path a WRITE may target (#1618, #1640).
179
+ *
180
+ * Every existing candidate — SO_STATE_DIR, the active platform's dir, the
181
+ * legacy dirs — must pass the ownership rule ({@link isWritableStateMd}); the
182
+ * first one that does wins. Before #1640 the override/active candidates were
183
+ * taken unchecked, so session-end Phase 3.7a wrote its recommendation fields
184
+ * into a FOREIGN completed `.claude/STATE.md` while this session's own record
185
+ * lived elsewhere. Without a qualifying candidate the preferred path is
186
+ * returned for create-on-first-write — unless that path itself holds a foreign
187
+ * file, in which case `blocked` is true and writers refuse.
188
+ *
189
+ * @param {string|undefined} repoRoot
190
+ * @returns {string} Absolute path to STATE.md.
191
+ */
192
+ export function resolveStateMdWritePath(repoRoot) {
193
+ return resolveStateMdWriteTarget(repoRoot).path;
194
+ }
195
+
196
+ /**
197
+ * Like {@link resolveStateMdWritePath}, but also names the first STATE.md
198
+ * that was REJECTED on the way (#1618 review): `rejected` is the absolute path
199
+ * of an existing STATE.md not proven to be this process's, set only when the
200
+ * returned `path` is NOT a qualifying existing file (absent → create, or
201
+ * `blocked`). Callers use it to say why nothing was written instead of
202
+ * returning a silent no-op. `blocked` means `path` exists and is foreign:
203
+ * nothing may be written there (#1640).
204
+ *
205
+ * `opts.statePath` (#1640) pins the target explicitly — session-end passes the
206
+ * same path to Phase 3.7a and Phase 3.4 so both land in one file — and is
207
+ * subject to the same ownership rule (treated as a preferred candidate).
208
+ * `opts.completedHandoff` lets the Idle Reset take a preferred
209
+ * `status: completed` record (see {@link isWritableStateMd}).
210
+ *
211
+ * @param {string|undefined} repoRoot
212
+ * @param {{ statePath?: string, completedHandoff?: boolean }} [opts]
213
+ * @returns {{ path: string, rejected: string|null, blocked: boolean }}
214
+ */
215
+ export function resolveStateMdWriteTarget(repoRoot, opts = {}) {
216
+ const { statePath, completedHandoff = false } = opts;
217
+ const ownIds = readProcessLocalSessionIds();
218
+ const explicit = typeof statePath === 'string' && statePath.trim() !== '';
219
+ const { candidates, preferredCount } = explicit
220
+ ? { candidates: [resolvePath(repoRoot ?? process.cwd(), statePath)], preferredCount: 1 }
221
+ : stateArtifactCandidates(repoRoot, 'STATE.md');
222
+ let rejected = null;
223
+ for (const [index, candidate] of candidates.entries()) {
224
+ if (!existsSync(candidate)) continue;
225
+ if (isWritableStateMd(candidate, ownIds, { preferred: index < preferredCount, completedHandoff })) {
226
+ return { path: candidate, rejected: null, blocked: false };
227
+ }
228
+ rejected ??= candidate;
229
+ }
230
+ const path = candidates[0];
231
+ return { path, rejected, blocked: existsSync(path) };
232
+ }
233
+
234
+ /**
235
+ * One stderr line naming a rejected foreign STATE.md, so a fail-closed
236
+ * write target never degrades into a silent no-op (#1618 review). When the
237
+ * rejected file IS the target (#1640 `blocked`), the line says nothing is
238
+ * written instead of naming a different target.
239
+ *
240
+ * @param {string} caller
241
+ * @param {string} rejected
242
+ * @param {string} target
243
+ */
244
+ export function warnForeignStateMd(caller, rejected, target) {
245
+ const outcome = rejected === target
246
+ ? 'nothing written — it is no session-own write target'
247
+ : `write target is ${target}`;
248
+ process.stderr.write(
249
+ `⚠ ${caller}: ignoring ${rejected} — its session-id is not this process's ` +
250
+ `(no matching process-local session id); ${outcome} (#1618/#1640)\n`
251
+ );
252
+ }
253
+
110
254
  // ---------------------------------------------------------------------------
111
255
  // Size-ceiling guard (issue #739)
112
256
  // ---------------------------------------------------------------------------
@@ -351,27 +495,40 @@ function writeFileAtomic(filePath, contents) {
351
495
  * frontmatter-unsafe breach throws an `Error`
352
496
  * (`.code === 'STATE_MD_FRONTMATTER_UNSAFE'`) instead of returning a no-op
353
497
  * result. Default `false`.
498
+ * @param {string} [opts.statePath] — explicit STATE.md target (#1640), absolute
499
+ * or repo-relative; still ownership-checked. A foreign existing target is
500
+ * refused with `reason: 'foreign-state-md'`, never overwritten.
501
+ * @param {boolean} [opts.completedHandoff] — Idle Reset only: a preferred
502
+ * `status: completed` record qualifies as write target (#1640).
354
503
  * @returns {Promise<{ written: boolean, path: string, contents: string|null, reason?: string }>}
355
504
  */
356
505
  export async function writeStateMd(repoRoot, transformer, opts = {}) {
357
506
  if (typeof transformer !== 'function') {
358
507
  throw new TypeError('writeStateMd: transformer must be a function');
359
508
  }
360
- const statePath = resolveStateMdPath(repoRoot);
509
+ const { path: statePath, rejected, blocked } = resolveStateMdWriteTarget(repoRoot, {
510
+ statePath: opts.statePath,
511
+ completedHandoff: opts.completedHandoff,
512
+ });
513
+ if (rejected) warnForeignStateMd('writeStateMd', rejected, statePath);
514
+ const foreign = rejected ? { reason: 'foreign-state-md' } : {};
515
+ // #1640: the only existing file at the target belongs to another session —
516
+ // never overwrite it, never call the transformer on its contents.
517
+ if (blocked) return { written: false, path: statePath, contents: null, ...foreign };
361
518
  return withStateMdLock(
362
519
  repoRoot,
363
520
  async () => {
364
521
  const before = existsSync(statePath) ? readFileSync(statePath, 'utf8') : '';
365
522
  const after = await transformer(before);
366
523
  if (after === null || after === undefined) {
367
- return { written: false, path: statePath, contents: null };
524
+ return { written: false, path: statePath, contents: null, ...foreign };
368
525
  }
369
526
  if (typeof after !== 'string') {
370
527
  throw new TypeError('writeStateMd: transformer must return a string or null');
371
528
  }
372
529
  if (after === before) {
373
530
  // No textual change — skip the write to keep mtime stable.
374
- return { written: false, path: statePath, contents: before };
531
+ return { written: false, path: statePath, contents: before, ...foreign };
375
532
  }
376
533
 
377
534
  const ceilingBytes = typeof opts._ceilingBytes === 'number' && opts._ceilingBytes > 0
@@ -456,6 +613,107 @@ export async function updateFrontmatterFieldsOnDisk(repoRoot, fields, opts = {})
456
613
  return writeStateMd(repoRoot, (contents) => updateFrontmatterFields(contents, fields), opts);
457
614
  }
458
615
 
616
+ /**
617
+ * Why the STATE.md `session-id` `sessionId` may NOT be taken over by this
618
+ * process (#1640 review MED-1), or `null` when it may. A non-completed record
619
+ * passes the write rule even with a foreign id (a `/clear`ed session's own
620
+ * file still names its pre-`/clear` id), so before re-stamping the id and
621
+ * completing the file we ask the host session registry whether that id is
622
+ * still REGISTERED at all (`readRegistry()`; the caller already excludes our
623
+ * own id). A registered session owns the file: completing it would lock that
624
+ * session out of its own record. No freshness window: the heartbeat is only
625
+ * written at turn end (hooks/on-stop.mjs), and 92 of 954 consecutive
626
+ * `orchestrator.turn.stopped` gaps (48 of 64 sessions) exceeded 15 min —
627
+ * an idle-but-live peer would look dead. Measured 2026-10-15: 13 of 13
628
+ * sessions that ended with SessionEnd `reason: clear` were already
629
+ * deregistered (on-session-end.mjs deregisters on every reason), so a
630
+ * `/clear` predecessor does not block its successor's close.
631
+ *
632
+ * Fail-closed: a registry directory that exists but cannot be read refuses.
633
+ * An absent registry (ENOENT) means no live peers.
634
+ *
635
+ * CEILING (BV-004): the registry is host-wide, not per checkout, and the
636
+ * check runs inside the STATE.md lock but not under the registry's. A session
637
+ * that crashed without SessionEnd (or whose deregistration failed) stays
638
+ * registered until `sweepZombies()` removes it after 60 min of inactivity at
639
+ * the next SessionStart of ANY session — until then no other session can
640
+ * complete its STATE.md (fail-closed direction, refused loudly). Revisit if
641
+ * that refusal shows up in close transcripts.
642
+ *
643
+ * Lazy import: this module is in the hook import closure; the registry is
644
+ * only needed on this async session-end path.
645
+ *
646
+ * @param {string} sessionId
647
+ * @param {string|undefined} ownId
648
+ * @returns {Promise<string|null>}
649
+ */
650
+ async function liveForeignOwner(sessionId) {
651
+ try {
652
+ const { activeDir, readRegistry } = await import('../session-registry.mjs');
653
+ const { readdir } = await import('node:fs/promises');
654
+ try {
655
+ await readdir(activeDir());
656
+ } catch (err) {
657
+ if (err?.code === 'ENOENT') return null;
658
+ return `session registry unreadable (${err?.code ?? 'error'})`;
659
+ }
660
+ const entries = await readRegistry();
661
+ return entries.some((entry) => entry.session_id === sessionId)
662
+ ? 'its session-id belongs to a session still registered on this host'
663
+ : null;
664
+ } catch (err) {
665
+ return `session registry unreadable (${err?.code ?? err?.message ?? 'error'})`;
666
+ }
667
+ }
668
+
669
+ /**
670
+ * session-end Phase 3.4 (#1640): finalize THIS session's STATE.md in one
671
+ * locked write — `status: completed`, `updated`, optionally `current-wave`,
672
+ * and `session-id` re-stamped to the closing process's own raw id when one is
673
+ * measurable. The re-stamp keeps a `/clear`ed session's record its own after
674
+ * 3.4: the writers that run LATER in the close — Phase 5's
675
+ * `markOpenQuestionAnsweredOnDisk` — would otherwise find a completed record
676
+ * naming the pre-`/clear` id and be refused as foreign. Without a measurable
677
+ * id the existing `session-id` is left as is.
678
+ *
679
+ * Refused (`reason: 'foreign-state-md'`, one stderr line, file byte-identical)
680
+ * when the record's `session-id` is neither this process's nor free to take
681
+ * over — see {@link liveForeignOwner}. Placeholder (no `session-id`), idle and
682
+ * own records are never checked against the registry.
683
+ *
684
+ * @param {string} repoRoot — absolute path to the repository root (required)
685
+ * @param {object} [opts] — `writeStateMd` options; pass the SAME `statePath` as Phase 3.7a.
686
+ * @param {number} [opts.currentWave] — final wave count, written as `current-wave`.
687
+ * @returns {Promise<{ written: boolean, path: string, contents: string|null, reason?: string }>}
688
+ * @throws {Error} when repoRoot is undefined, null, or empty
689
+ */
690
+ export async function markStateMdCompletedOnDisk(repoRoot, opts = {}) {
691
+ requireRepoRoot(repoRoot, 'markStateMdCompletedOnDisk');
692
+ const { currentWave, ...writeOpts } = opts;
693
+ const fields = { status: 'completed', updated: new Date().toISOString() };
694
+ if (Number.isInteger(currentWave)) fields['current-wave'] = currentWave;
695
+ const [ownId] = readProcessLocalSessionIds();
696
+ if (ownId) fields['session-id'] = ownId;
697
+ let refusal = null;
698
+ const result = await writeStateMd(
699
+ repoRoot,
700
+ async (contents) => {
701
+ const frontmatter = parseStateMd(contents)?.frontmatter;
702
+ const raw = frontmatter?.['session-id'];
703
+ const sessionId = typeof raw === 'string' ? raw.trim() : '';
704
+ if (sessionId && sessionId !== ownId && frontmatter.status !== 'idle') {
705
+ refusal = await liveForeignOwner(sessionId);
706
+ if (refusal) return null;
707
+ }
708
+ return updateFrontmatterFields(contents, fields);
709
+ },
710
+ writeOpts,
711
+ );
712
+ if (!refusal) return result;
713
+ process.stderr.write(`⚠ markStateMdCompletedOnDisk: refusing ${result.path} — ${refusal}; nothing written (#1640)\n`);
714
+ return { ...result, written: false, reason: 'foreign-state-md' };
715
+ }
716
+
459
717
  /**
460
718
  * Convenience wrapper: apply `touchUpdatedField` under the state-lock.
461
719
  *