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
@@ -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
  *
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { parseStateMd, serializeStateMd } from './yaml-parser.mjs';
20
20
  import { existsSync } from 'node:fs';
21
- import { resolveStateMdPath, updateFrontmatterFields, writeStateMd } from './frontmatter-mutators.mjs';
21
+ import { resolveStateMdWriteTarget, updateFrontmatterFields, warnForeignStateMd, writeStateMd } from './frontmatter-mutators.mjs';
22
22
  import { parseRecommendations } from './recommendations.mjs';
23
23
 
24
24
  /** Rule 7: the drift-baseline keys that must not survive into session N+1. */
@@ -201,7 +201,9 @@ export function resetStateMdToIdle(contents) {
201
201
 
202
202
  /**
203
203
  * Applies `resetStateMdToIdle` to the repo's STATE.md under the STATE.md lock
204
- * (`writeStateMd`). A missing or empty STATE.md is a no-op with reason `missing`.
204
+ * (`writeStateMd`). A missing or empty STATE.md is a no-op with reason `missing`;
205
+ * when the only STATE.md belongs to another platform's foreign session it is
206
+ * `foreign-state-md` (#1618), with one stderr WARN naming that file.
205
207
  * A write the size-ceiling / frontmatter-safe guards refuse comes back as
206
208
  * `changed: false` with that guard's reason.
207
209
  *
@@ -216,8 +218,15 @@ export async function resetStateMdToIdleOnDisk(repoRoot, opts = {}) {
216
218
  }
217
219
  // No STATE.md: answer before the lock, which would otherwise create
218
220
  // <repoRoot>/.orchestrator/ in whatever directory a mistyped root names.
219
- const statePath = resolveStateMdPath(repoRoot);
220
- if (!existsSync(statePath)) return { changed: false, reason: 'missing', path: statePath };
221
+ // Probe the same path writeStateMd will target (#1618), never a foreign one.
222
+ // The reset is the hand-over that turns the PREVIOUS session's completed
223
+ // record into idle, so a preferred `status: completed` file qualifies (#1640).
224
+ const writeOpts = { ...opts, completedHandoff: true };
225
+ const { path: statePath, rejected } = resolveStateMdWriteTarget(repoRoot, writeOpts);
226
+ if (!existsSync(statePath)) {
227
+ if (rejected) warnForeignStateMd('resetStateMdToIdleOnDisk', rejected, statePath);
228
+ return { changed: false, reason: rejected ? 'foreign-state-md' : 'missing', path: statePath };
229
+ }
221
230
  let reason;
222
231
  const result = await writeStateMd(
223
232
  repoRoot,
@@ -230,7 +239,7 @@ export async function resetStateMdToIdleOnDisk(repoRoot, opts = {}) {
230
239
  reason = reset.reason;
231
240
  return reset.changed ? reset.contents : null;
232
241
  },
233
- opts,
242
+ writeOpts,
234
243
  );
235
244
  const out = { changed: result.written, path: result.path };
236
245
  const why = result.reason ?? reason;
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { parseStateMd, serializeStateMd } from './yaml-parser.mjs';
26
- import { updateFrontmatterFields, writeStateMd } from './frontmatter-mutators.mjs';
26
+ import { resolveStateMdWriteTarget, updateFrontmatterFields, writeStateMd } from './frontmatter-mutators.mjs';
27
27
 
28
28
  const MISSION_STATUS_HEADING_RE = /^##\s+Mission Status\s*$/;
29
29
  const WRITER_TIMESTAMP_SOURCE = '\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z';
@@ -677,9 +677,13 @@ const REFUSAL_REASONS = Object.freeze({
677
677
  */
678
678
  export async function setMissionStatusOnDisk(repoRoot, taskId, status, opts = {}) {
679
679
  let refused;
680
+ // A fail-closed write target (#1618) hands an empty buffer; writeStateMd
681
+ // already names the rejected foreign STATE.md, so do not blame its content.
682
+ const foreign = resolveStateMdWriteTarget(repoRoot, opts).rejected !== null;
680
683
  const result = await writeStateMd(
681
684
  repoRoot,
682
685
  (contents) => {
686
+ if (foreign && contents === '') return contents;
683
687
  const outcome = setMissionStatusDetailed(contents, taskId, status);
684
688
  refused = outcome.refused;
685
689
  if (outcome.refused) {
@@ -55,6 +55,8 @@ function orderedStateMdCandidates() {
55
55
  * @returns {string} Absolute path (may not exist on disk).
56
56
  */
57
57
  function _resolveStateMdPath(repoRoot) {
58
+ // #1636 decision: no ownership filter here — this guard exists to SEE another
59
+ // session's STATE.md, so a foreign file is its input, not noise to drop.
58
60
  const candidates = orderedStateMdCandidates();
59
61
  for (const candidate of candidates) {
60
62
  const abs = join(repoRoot, candidate);
@@ -19,6 +19,7 @@
19
19
  import { parseStateMd as _parseStateMd } from './state-md/yaml-parser.mjs';
20
20
  import { updateFrontmatterFields as _updateFrontmatterFields } from './state-md/frontmatter-mutators.mjs';
21
21
  import { readLock as _readLock } from './session-lock.mjs';
22
+ import { readProcessLocalSessionIds as _readProcessLocalSessionIds } from './session-identity/own-session.mjs';
22
23
 
23
24
  export { parseStateMd, serializeStateMd } from './state-md/yaml-parser.mjs';
24
25
 
@@ -195,27 +196,49 @@ export function resolveSessionStartedAt({ repoRoot } = {}) {
195
196
  /**
196
197
  * The session ids a STATE.md writer may stamp into frontmatter: `session-id`
197
198
  * (the RAW/native harness id) and `session` (the semantic attribution label),
198
- * both read from the live lock.
199
+ * both read from the live lock — and `session-id` only when the lock is
200
+ * provably THIS process's own (#1663).
199
201
  *
200
- * Both fields are `null` when no usable lock exists — ABSENCE IS NEVER
201
- * COERCED, and a writer must then OMIT the key rather than write a
202
- * placeholder. Neither id grants ownership of anything (see
202
+ * The lock is repo-global: a session that lost the acquire race, or a
203
+ * pre-#1641 lock without `claude_pid` that survives until its TTL, names a
204
+ * PEER. Since #1661 `/close` decides ownership by comparing STATE.md
205
+ * `session-id` against `readProcessLocalSessionIds()`, so stamping the peer's
206
+ * id would make this session's own STATE.md read `foreign` and stop its own
207
+ * close. Hence:
208
+ * - `session_id` is the lock's raw id only when it is contained in
209
+ * `readProcessLocalSessionIds({ env })`; otherwise `null`. A process that
210
+ * cannot name its own id (Cursor, Pi, Codex without a thread id) gets
211
+ * `null` too — unprovable is not own.
212
+ * - `semantic_session_id` is `null` when the lock is PROVABLY foreign (this
213
+ * process has an id and the lock names another). Ceiling (BV-004): when
214
+ * the process has no id at all, the label is still taken from the lock,
215
+ * as before #1663, so the #429 label join keeps working on those
216
+ * harnesses — it may then name a peer. Revisit when every harness
217
+ * exposes a native id.
218
+ *
219
+ * ABSENCE IS NEVER COERCED: a writer must OMIT a `null` key rather than write
220
+ * a placeholder. Neither id grants ownership of anything (see
203
221
  * `skills/_shared/state-ownership.md`); `session-id` exists so `/close`'s #429
204
222
  * pre-check can join STATE.md to sessions.jsonl on a native identity instead
205
223
  * of falling through to the label + timestamp path.
206
224
  *
207
225
  * Never throws.
208
226
  *
209
- * @param {{ repoRoot?: string }} [opts]
227
+ * @param {{ repoRoot?: string, env?: object }} [opts] `env` defaults to
228
+ * `process.env`; injectable for tests.
210
229
  * @returns {{ session_id: string|null, semantic_session_id: string|null }}
211
230
  */
212
- export function resolveSessionIds({ repoRoot } = {}) {
231
+ export function resolveSessionIds({ repoRoot, env = process.env } = {}) {
213
232
  const pick = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : null);
214
233
  try {
215
234
  const lock = _readLock({ repoRoot });
235
+ const lockId = pick(lock?.session_id);
236
+ const ownIds = _readProcessLocalSessionIds({ env });
237
+ const own = lockId !== null && ownIds.includes(lockId);
238
+ const provablyForeign = lockId !== null && ownIds.length > 0 && !own;
216
239
  return {
217
- session_id: pick(lock?.session_id),
218
- semantic_session_id: pick(lock?.semantic_session_id),
240
+ session_id: own ? lockId : null,
241
+ semantic_session_id: provablyForeign ? null : pick(lock?.semantic_session_id),
219
242
  };
220
243
  } catch {
221
244
  return { session_id: null, semantic_session_id: null };
@@ -42,6 +42,7 @@ import path from 'node:path';
42
42
  import { normalizeSkillInvocation } from '../skill-invocations-schema.mjs';
43
43
  import { normalizeSkillJudgment } from '../skill-judgments-schema.mjs';
44
44
  import { isMainModule } from '../is-main-module.mjs';
45
+ import { resolveLedgerPath } from '../ledger-path.mjs';
45
46
  import { userInvocableSkills } from '../user-invocable-skills.mjs';
46
47
 
47
48
  // ---------------------------------------------------------------------------
@@ -948,15 +949,15 @@ export function runSunsetWalk(repoRoot, opts = {}) {
948
949
 
949
950
  const subagentsPath =
950
951
  opts.subagentsPath ??
951
- path.join(repoRoot, '.orchestrator', 'metrics', 'subagents.jsonl');
952
+ resolveLedgerPath('.orchestrator/metrics/subagents.jsonl', repoRoot);
952
953
 
953
954
  const invocationsPath =
954
955
  opts.invocationsPath ??
955
- path.join(repoRoot, '.orchestrator', 'metrics', 'skill-invocations.jsonl');
956
+ resolveLedgerPath('.orchestrator/metrics/skill-invocations.jsonl', repoRoot);
956
957
 
957
958
  const judgmentsPath =
958
959
  opts.judgmentsPath ??
959
- path.join(repoRoot, '.orchestrator', 'metrics', 'skill-judgments.jsonl');
960
+ resolveLedgerPath('.orchestrator/metrics/skill-judgments.jsonl', repoRoot);
960
961
 
961
962
  const surface = enumerateSurface(repoRoot);
962
963
  const dispatch = readDispatchCounts(subagentsPath, { windowDays, now: nowMs });
@@ -159,11 +159,13 @@ export function readTelemetryState({ path } = {}) {
159
159
  export function writeTelemetryState(record, { path } = {}) {
160
160
  const target = path || TELEMETRY_JSON_PATH;
161
161
  try {
162
- mkdirSync(dirname(target), { recursive: true });
162
+ // 0o700: the default target is the private config dir, which must not come
163
+ // out 0777 under umask 000 (#1629).
164
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
163
165
  } catch (err) {
164
166
  return { ok: false, error: err?.message ?? String(err) };
165
167
  }
166
- const res = writeJsonAtomicSync(target, record);
168
+ const res = writeJsonAtomicSync(target, record, { mode: 0o600 }); // consent + anon_id are host-private (#1632)
167
169
  return res.ok ? { ok: true } : { ok: false, error: res.error };
168
170
  }
169
171
 
@@ -91,7 +91,7 @@ function _serializedByteLength(entries) {
91
91
  * via tmp-file + `renameSync` in the SAME directory (same-filesystem rename
92
92
  * is atomic on POSIX — mirrors `writeJsonAtomicSync` in `scripts/lib/io.mjs`,
93
93
  * which has no NDJSON-array variant). Creates the parent directory with
94
- * `mkdirSync(dir, { recursive: true })` first.
94
+ * `mkdirSync(dir, { recursive: true, mode: 0o700 })` first.
95
95
  *
96
96
  * @param {string} filePath
97
97
  * @param {Array<object>} entries
@@ -101,10 +101,10 @@ function _serializedByteLength(entries) {
101
101
  */
102
102
  function _writeEntriesAtomicSync(filePath, entries) {
103
103
  const dir = dirname(filePath);
104
- mkdirSync(dir, { recursive: true });
104
+ mkdirSync(dir, { recursive: true, mode: 0o700 }); // private config dir; 0777 under umask 000 otherwise (#1629)
105
105
  const tmpSuffix = randomBytes(6).toString('hex');
106
106
  const tmpFile = join(dir, `.tmp.${tmpSuffix}`);
107
- writeFileSync(tmpFile, _toNdjson(entries), 'utf8');
107
+ writeFileSync(tmpFile, _toNdjson(entries), { encoding: 'utf8', mode: 0o600 }); // rename keeps this mode (#1632)
108
108
  renameSync(tmpFile, filePath);
109
109
  }
110
110