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
@@ -72,6 +72,7 @@ import { atomicWriteWithBackup, envelopeToError } from './io.mjs';
72
72
  import { validateSession as defaultValidateSession } from './session-schema/validator.mjs';
73
73
  import { serializeSessionLineChecked as defaultSerialize } from './session-schema.mjs';
74
74
  import { checkSessionsIntegrity as defaultCheckIntegrity } from './sessions-integrity-banner.mjs';
75
+ import { resolveEventsLedgerRoot } from './platform.mjs';
75
76
 
76
77
  // ---------------------------------------------------------------------------
77
78
  // Constants
@@ -263,6 +264,34 @@ function preserveRaw(out, key, rawValue, rescued) {
263
264
  rescued.add(sidecar);
264
265
  }
265
266
 
267
+ /**
268
+ * True when `flags` lists `completed_at` as incomplete although `rec.completed_at`
269
+ * IS a measured end: the record's own valid, monotonic `ended_at` (verbatim or
270
+ * its millisecond copy). Records repaired before #1699 item 12 carry exactly
271
+ * this stale flag (#1705).
272
+ *
273
+ * The ONE predicate behind both the heal in {@link repairRecord} and the
274
+ * routing of an otherwise schema-valid line in {@link repairLine} — two copies
275
+ * would drift, and a routing that is wider than the heal re-serializes a line
276
+ * for nothing, one that is narrower leaves the heal unreachable.
277
+ *
278
+ * @param {unknown} flags — the record's `_backfill_incomplete_fields`
279
+ * @param {object} rec — the record whose timestamps are judged
280
+ * @returns {boolean}
281
+ */
282
+ function hasStaleCompletedAtFlag(flags, rec) {
283
+ if (!Array.isArray(flags) || !flags.includes('completed_at')) return false;
284
+ if (typeof rec.completed_at !== 'string' || typeof rec.ended_at !== 'string' ||
285
+ typeof rec.started_at !== 'string') return false;
286
+ const endedNorm = normalizeTimestamp(rec.ended_at);
287
+ const endedMs = Date.parse(rec.ended_at);
288
+ const startedMs = Date.parse(rec.started_at);
289
+ const endedValid = Number.isFinite(endedMs) && !(endedNorm !== null && 'invalid' in endedNorm);
290
+ const isCopy = rec.completed_at === rec.ended_at ||
291
+ (endedNorm !== null && rec.completed_at === endedNorm.value);
292
+ return endedValid && Number.isFinite(startedMs) && endedMs >= startedMs && isCopy;
293
+ }
294
+
266
295
  /** Compact ISO stamp for backup filenames: `20260805T091500Z`. */
267
296
  export function backupStamp(date = new Date()) {
268
297
  return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
@@ -342,9 +371,17 @@ export function repairRecord(record) {
342
371
  // -- total_waves ----------------------------------------------------------
343
372
  if (!isCount(out.total_waves)) {
344
373
  preserveRaw(out, 'total_waves', record.total_waves, rescued);
345
- out.total_waves = wavesNumber !== null ? wavesNumber : out.waves.length;
346
374
  defects.push('total_waves_missing');
347
- incomplete.add('total_waves');
375
+ // A numeric `waves` IS the wave count (see above): measured, not
376
+ // defaulted, so NOT listed — the same rule as the `agents_total` /
377
+ // `files_changed` aliases below (#1699 item 12). Only the fallback to the
378
+ // (possibly just-defaulted) array length is an unverified default.
379
+ if (wavesNumber !== null) {
380
+ out.total_waves = wavesNumber;
381
+ } else {
382
+ out.total_waves = out.waves.length;
383
+ incomplete.add('total_waves');
384
+ }
348
385
  }
349
386
 
350
387
  // -- agent_summary --------------------------------------------------------
@@ -377,27 +414,58 @@ export function repairRecord(record) {
377
414
  // -- total_agents ---------------------------------------------------------
378
415
  if (!isCount(out.total_agents)) {
379
416
  preserveRaw(out, 'total_agents', record.total_agents, rescued);
380
- // Prefer the record's own evidence: an agent_summary PRESENT in the
417
+ // Only the record's own evidence counts: an agent_summary PRESENT in the
381
418
  // original sums to the real agent count (live line 71 sums to 30 where
382
- // waves.length is 5 — W2/A4 review finding). Fall back to waves.length
383
- // only when the summary was absent or sums to 0 (no signal).
419
+ // waves.length is 5 — W2/A4 review finding); else the legacy alias
420
+ // `agents_total` carries the same fact under an older name (#1698). Never
421
+ // waves.length: a wave is not an agent (a record with `agents_total: 19`
422
+ // and five waves was repaired to 5). No evidence → transparently 0.
384
423
  const summarySum = isPlainObject(record.agent_summary)
385
424
  ? AGENT_SUMMARY_FIELDS.reduce(
386
425
  (n, f) => n + (isCount(record.agent_summary[f]) ? record.agent_summary[f] : 0),
387
426
  0
388
427
  )
389
428
  : 0;
390
- out.total_agents = summarySum > 0 ? summarySum : out.waves.length;
391
429
  defects.push('total_agents_missing');
392
- incomplete.add('total_agents');
430
+ if (summarySum > 0) {
431
+ // A sum over counters that may themselves be partial: an inference, so
432
+ // it stays listed as incomplete (unchanged since W2/A4).
433
+ out.total_agents = summarySum;
434
+ incomplete.add('total_agents');
435
+ // A measured `agents_total` that disagrees with the sum is a conflict
436
+ // between two of the record's own sources; the sum still wins, but the
437
+ // record must say so (#1699 item 10). Same forensics tag shape as
438
+ // `_completed_at_conflict` (session-schema/aliases.mjs); the alias key
439
+ // stays on the record, so both values remain readable.
440
+ if (isCount(record.agents_total) && record.agents_total !== summarySum) {
441
+ out._total_agents_conflict = true;
442
+ defects.push('total_agents_conflict');
443
+ }
444
+ } else if (isCount(record.agents_total)) {
445
+ // The same fact under its old name: measured, not defaulted — so NOT
446
+ // listed as incomplete (the list is the set of defaulted fields, see the
447
+ // module docblock). The alias key itself stays on the record untouched,
448
+ // like legacy `ended_at` below.
449
+ out.total_agents = record.agents_total;
450
+ } else {
451
+ out.total_agents = 0;
452
+ incomplete.add('total_agents');
453
+ }
393
454
  }
394
455
 
395
456
  // -- total_files_changed --------------------------------------------------
396
457
  if (!isCount(out.total_files_changed)) {
397
458
  preserveRaw(out, 'total_files_changed', record.total_files_changed, rescued);
398
- out.total_files_changed = 0;
399
459
  defects.push('total_files_changed_missing');
400
- incomplete.add('total_files_changed');
460
+ // Legacy alias `files_changed` is the same count under an older name
461
+ // (#1698: records with `files_changed: 44` were repaired to 0). A non-count
462
+ // alias (e.g. a path list) is no proof → 0, flagged as defaulted.
463
+ if (isCount(record.files_changed)) {
464
+ out.total_files_changed = record.files_changed;
465
+ } else {
466
+ out.total_files_changed = 0;
467
+ incomplete.add('total_files_changed');
468
+ }
401
469
  }
402
470
 
403
471
  // -- timestamp precision (#1442) -------------------------------------------
@@ -424,6 +492,7 @@ export function repairRecord(record) {
424
492
  // the ledger is not chronologically ordered (see the module docblock).
425
493
  if (typeof out.completed_at !== 'string' && typeof out.started_at === 'string') {
426
494
  preserveRaw(out, 'completed_at', record.completed_at, rescued);
495
+ let measuredEnd = false;
427
496
  const endedMs = typeof out.ended_at === 'string' ? Date.parse(out.ended_at) : NaN;
428
497
  const startedMs = Date.parse(out.started_at);
429
498
  const endedNorm = normalizeTimestamp(out.ended_at);
@@ -440,11 +509,36 @@ export function repairRecord(record) {
440
509
  offsetNormalized ||= endedNorm.offset;
441
510
  }
442
511
  out.completed_at = endedNorm?.value ?? out.ended_at;
512
+ // The same measurement under its legacy name (aliasLegacyEndedAt flags
513
+ // nothing either): NOT listed, or `sessions-staleness-banner.mjs` would
514
+ // discard a real end time as an anchor (#1699 item 12).
515
+ measuredEnd = true;
443
516
  } else {
444
517
  out.completed_at = out.started_at;
445
518
  }
446
519
  defects.push('completed_at_missing');
447
- incomplete.add('completed_at');
520
+ if (!measuredEnd) incomplete.add('completed_at');
521
+ }
522
+
523
+ // -- stale completed_at flag (#1705) ----------------------------------------
524
+ // Records repaired before #1699 item 12 copied a valid, monotonic `ended_at`
525
+ // into `completed_at` AND listed it as incomplete. The branch above only runs
526
+ // when `completed_at` is missing, so those records never healed and the
527
+ // staleness banner kept discarding a real end time. Same predicate as the
528
+ // `measuredEnd` branch: `completed_at` IS that ended_at (verbatim or its
529
+ // millisecond copy), parseable, not before `started_at`.
530
+ // Measured 2026-10-21: 0 ledger records carry the sibling `total_waves`
531
+ // flag beside a numeric `waves`, so that path needs no heal.
532
+ const priorFlags = Array.isArray(record._backfill_incomplete_fields)
533
+ ? record._backfill_incomplete_fields
534
+ : [];
535
+ let healedFlags = null;
536
+ if (hasStaleCompletedAtFlag(priorFlags, out)) {
537
+ // The flag list is the record's own provenance; keep the prior list
538
+ // exactly once in its sidecar before rewriting it (process-contracts.md).
539
+ preserveRaw(out, 'backfill_incomplete_fields', record._backfill_incomplete_fields, rescued);
540
+ healedFlags = priorFlags.filter((f) => f !== 'completed_at');
541
+ defects.push('completed_at_flag_stale');
448
542
  }
449
543
  if (precisionCut) defects.push('timestamp_precision');
450
544
  if (offsetNormalized) defects.push('timestamp_offset');
@@ -454,9 +548,7 @@ export function repairRecord(record) {
454
548
  }
455
549
 
456
550
  // -- provenance (additive, already schema-accepted) ------------------------
457
- const prior = Array.isArray(record._backfill_incomplete_fields)
458
- ? record._backfill_incomplete_fields.filter((f) => typeof f === 'string')
459
- : [];
551
+ const prior = (healedFlags ?? priorFlags).filter((f) => typeof f === 'string');
460
552
  const merged = orderIncompleteFields(incomplete);
461
553
  // The repair is recorded in its own field; `_backfill_source` keeps the
462
554
  // record's ORIGIN. Overwriting a synthesised origin ('state-md-completed',
@@ -483,17 +575,25 @@ export function repairRecord(record) {
483
575
  * - `unparseable` — `JSON.parse` threw; emitted unchanged. File corruption is
484
576
  * a different problem and the integrity banner skips these
485
577
  * too; silently "fixing" bytes we cannot read is worse.
486
- * - `valid` — passed `validateSession`; emitted BYTE-FOR-BYTE.
487
- * - `repaired` — defects fixed, gated, re-serialized.
578
+ * - `valid` — passed `validateSession`; emitted BYTE-FOR-BYTE. The ONE
579
+ * exception is below.
580
+ * - `repaired` — defects fixed, gated, re-serialized. `healedValid` is
581
+ * true when the line was ALREADY schema-valid and only
582
+ * carried a stale `completed_at` flag (#1705, see
583
+ * `hasStaleCompletedAtFlag`): such a line is routed through
584
+ * `repairRecord` too, otherwise that heal is unreachable from
585
+ * the ledger path. Callers counting INVALID lines must not
586
+ * count these. Should the healed record fail the gate, the
587
+ * line stays `valid` and byte-identical — it was valid before.
488
588
  * - `error` — still invalid after repair; ORIGINAL emitted unchanged.
489
589
  *
490
590
  * @param {string} line
491
591
  * @param {{validateSession?: Function, serialize?: Function}} [deps]
492
- * @returns {{line: string, status: string, sessionId: string|null, defects: string[], incompleteFields: string[], error: string|null, record: object|null}}
592
+ * @returns {{line: string, status: string, sessionId: string|null, defects: string[], incompleteFields: string[], error: string|null, record: object|null, healedValid: boolean}}
493
593
  */
494
594
  export function repairLine(line, deps = {}) {
495
595
  const { validateSession = defaultValidateSession, serialize = defaultSerialize } = deps;
496
- const base = { line, status: 'valid', sessionId: null, defects: [], incompleteFields: [], error: null, record: null };
596
+ const base = { line, status: 'valid', sessionId: null, defects: [], incompleteFields: [], error: null, record: null, healedValid: false };
497
597
 
498
598
  if (line.trim().length === 0) return { ...base, status: 'blank' };
499
599
 
@@ -509,16 +609,22 @@ export function repairLine(line, deps = {}) {
509
609
 
510
610
  const sessionId = typeof record.session_id === 'string' ? record.session_id : null;
511
611
 
612
+ let validBefore = false;
512
613
  try {
513
614
  validateSession(record);
514
- // Already valid — emit the ORIGINAL bytes. Re-serializing a passing record
515
- // would rewrite key order for no benefit and bury the real repairs in noise.
516
- return { ...base, status: 'valid', sessionId, record };
615
+ validBefore = true;
517
616
  } catch {
518
617
  /* fall through to repair */
519
618
  }
619
+ // Already valid — emit the ORIGINAL bytes. Re-serializing a passing record
620
+ // would rewrite key order for no benefit and bury the real repairs in noise.
621
+ // Sole exception: the stale completed_at flag no schema check sees (#1705).
622
+ if (validBefore && !hasStaleCompletedAtFlag(record._backfill_incomplete_fields, record)) {
623
+ return { ...base, status: 'valid', sessionId, record };
624
+ }
520
625
 
521
- const { record: repaired, defects, incompleteFields } = repairRecord(record);
626
+ const { record: repaired, defects, incompleteFields, changed } = repairRecord(record);
627
+ if (validBefore && !changed) return { ...base, status: 'valid', sessionId, record };
522
628
  try {
523
629
  // GATE ONLY — the return value is deliberately discarded. `validateSession`
524
630
  // stamps `schema_version: 2` on its output, and live line 152 has no
@@ -534,8 +640,10 @@ export function repairLine(line, deps = {}) {
534
640
  incompleteFields,
535
641
  error: null,
536
642
  record: repaired,
643
+ healedValid: validBefore,
537
644
  };
538
645
  } catch (err) {
646
+ if (validBefore) return { ...base, status: 'valid', sessionId, record };
539
647
  return {
540
648
  ...base,
541
649
  status: 'error',
@@ -568,6 +676,9 @@ export function repairText(raw, deps = {}) {
568
676
  let unparseable = 0;
569
677
  let invalidBefore = 0;
570
678
  let repaired = 0;
679
+ // Schema-valid lines whose stale completed_at flag was healed (#1705). They
680
+ // count under `repaired` (the line IS rewritten) but never as invalid.
681
+ let healedValid = 0;
571
682
 
572
683
  for (let i = 0; i < results.length; i++) {
573
684
  const r = results[i];
@@ -579,6 +690,12 @@ export function repairText(raw, deps = {}) {
579
690
  continue;
580
691
  }
581
692
  if (r.status === 'valid') continue;
693
+ if (r.healedValid) {
694
+ healedValid += 1;
695
+ repaired += 1;
696
+ for (const d of r.defects) defectsByClass[d] = (defectsByClass[d] ?? 0) + 1;
697
+ continue;
698
+ }
582
699
  invalidBefore += 1;
583
700
  for (const d of r.defects) defectsByClass[d] = (defectsByClass[d] ?? 0) + 1;
584
701
  if (r.status === 'repaired') {
@@ -601,8 +718,10 @@ export function repairText(raw, deps = {}) {
601
718
  unparseable,
602
719
  invalid_before: invalidBefore,
603
720
  repaired,
721
+ // Subset of `repaired`: lines that were already schema-valid (#1705).
722
+ healed_valid: healedValid,
604
723
  // Projected: every line that was invalid and did NOT get repaired.
605
- invalid_after: invalidBefore - repaired,
724
+ invalid_after: invalidBefore - (repaired - healedValid),
606
725
  // Lines with no defensible repair (`repairLine` status `error`). They are
607
726
  // emitted byte-identically and are EXPECTED to stay invalid after a
608
727
  // write — `verifyWritten` tolerates exactly these line numbers (#1442).
@@ -623,8 +742,8 @@ export function repairText(raw, deps = {}) {
623
742
  * `checkSessionsIntegrity` additionally exercises the REAL vault-mirror render
624
743
  * path — a record can pass the first and still be dropped by the second.
625
744
  *
626
- * The integrity probe reads `<repoRoot>/.orchestrator/metrics/sessions.jsonl`
627
- * by construction, so it is only meaningful when `file` IS that path; against
745
+ * The integrity probe reads `<ledger root of repoRoot>/.orchestrator/metrics/sessions.jsonl`
746
+ * (the main checkout's for a linked worktree, #1592) by construction, so it is only meaningful when `file` IS that path; against
628
747
  * any other target it is reported as skipped rather than quietly measuring the
629
748
  * wrong file.
630
749
  *
@@ -706,10 +825,14 @@ export function verifyWritten({ file, repoRoot, deps = {}, expected = {} }) {
706
825
  };
707
826
  }
708
827
 
709
- /** True when `file` IS `<repoRoot>/.orchestrator/metrics/sessions.jsonl` — the only file `checkIntegrity` reads. */
828
+ /**
829
+ * True when `file` IS the ledger `checkIntegrity` reads for `repoRoot` —
830
+ * resolved through the same main-checkout mapping (#1592), so a stale
831
+ * worktree-local ledger is never judged by the main ledger's integrity.
832
+ */
710
833
  function isCanonicalLedger(file, repoRoot) {
711
834
  if (typeof repoRoot !== 'string' || repoRoot.length === 0) return false;
712
- return path.resolve(file) === path.resolve(path.join(repoRoot, CANONICAL_LEDGER_REL));
835
+ return path.resolve(file) === path.resolve(path.join(resolveEventsLedgerRoot(repoRoot), CANONICAL_LEDGER_REL));
713
836
  }
714
837
 
715
838
  /**
@@ -736,14 +859,17 @@ function integrityWorsened(before, after) {
736
859
  * write. Apply copies the original to `<file>.bak-<stamp>` FIRST, writes
737
860
  * `<file>.tmp-<pid>` in the same directory and renames it over the target
738
861
  * (atomic within a filesystem), then re-verifies the written file. A failed
739
- * verification restores the backup byte-identically and reports `ok: false` —
862
+ * verification restores the backup byte-identically (plus any line appended
863
+ * after the rename, #1670) and reports `ok: false` —
740
864
  * the caller maps that to exit 3. Lines with no defensible repair are kept
741
865
  * byte-identical and counted in `summary.unrepairable`; they are NOT a
742
866
  * verification failure (see `verifyWritten` § WHAT "OK" MEANS) — the caller
743
867
  * maps an ok run with an unrepairable remainder to exit 4.
744
868
  *
745
869
  * Never throws for a defective RECORD (those land in `summary.errors`); a
746
- * genuine I/O failure DOES throw and is the caller's exit-2 case.
870
+ * genuine I/O failure DOES throw and is the caller's exit-2 case — as does a
871
+ * ledger that changed between read and rename (`err.code === 'ELEDGERCHANGED'`,
872
+ * #1665): the tmp is removed and the original, with its new line, stays as-is.
747
873
  *
748
874
  * @param {object} args
749
875
  * @param {string} args.file ledger path
@@ -761,6 +887,7 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
761
887
  renameSync = fs.renameSync,
762
888
  copyFileSync = fs.copyFileSync,
763
889
  unlinkSync = fs.unlinkSync,
890
+ appendFileSync = fs.appendFileSync,
764
891
  } = deps;
765
892
 
766
893
  const { checkIntegrity = defaultCheckIntegrity } = deps;
@@ -798,9 +925,34 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
798
925
  // The helper unlinks its tmp on failure; the backup above keeps its own
799
926
  // `.bak-<compact stamp>` schema, so the helper takes none. The injected fs
800
927
  // seams (`deps`) are threaded through so a test fake still sees the write.
928
+ //
929
+ // Lost-update guard (#1665): appenders (`emit-session.mjs` → `appendJsonl`)
930
+ // take NO lock — they rely on O_APPEND — so a repair-side lock would exclude
931
+ // nobody. Instead the ledger is re-read right before the rename and the swap
932
+ // is refused when its bytes differ from what was repaired: an append between
933
+ // read and rename would otherwise be overwritten silently. Throwing inside the
934
+ // rename seam makes the helper unlink its tmp and leave the target untouched;
935
+ // the caller re-raises it (CLI exit 2) and a re-run picks up the new line.
936
+ // CEILING: the window between this check and the rename itself stays open
937
+ // (microseconds). Revisit trigger: if appenders ever take a shared lock,
938
+ // replace this check with that lock held across read→rename.
939
+ // The exact bytes renamed into place — the restore below carries over only
940
+ // what lies BEYOND them (#1670).
941
+ let renamedBytes = null;
942
+ const guardedRename = (from, to) => {
943
+ if (readFileSync(file, 'utf8') !== raw) {
944
+ const err = new Error(
945
+ `${file} changed during the repair (concurrent append?) — nothing was replaced; re-run the repair`
946
+ );
947
+ err.code = 'ELEDGERCHANGED';
948
+ throw err;
949
+ }
950
+ renamedBytes = readFileSync(from);
951
+ renameSync(from, to);
952
+ };
801
953
  const res = atomicWriteWithBackup(file, text, {
802
954
  tmpPrefix: `.${path.basename(file)}.tmp`,
803
- fs: { writeFileSync, renameSync, unlinkSync },
955
+ fs: { writeFileSync, renameSync: guardedRename, unlinkSync },
804
956
  });
805
957
  if (!res.ok) throw envelopeToError(res);
806
958
 
@@ -825,15 +977,73 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
825
977
  // emitted byte-identically and keep the ledger exactly as broken as it was.
826
978
  // The backup is preferred (it is the on-disk artefact an operator can
827
979
  // inspect); `raw` is the in-memory fallback for `--no-backup`, and both are
828
- // the same bytes.
829
- if (backupPath) {
830
- copyFileSync(backupPath, file);
831
- } else {
832
- writeFileSync(file, raw, 'utf8');
833
- }
980
+ // the same bytes. Lines appended AFTER the rename are carried over (#1670).
981
+ summary.restore_appended_bytes = restoreOriginal({
982
+ file, backupPath, raw, renamed: renamedBytes,
983
+ fs: { readFileSync, writeFileSync, renameSync, copyFileSync, unlinkSync, appendFileSync },
984
+ });
834
985
  summary.ok = false;
835
986
  summary.restored = true;
836
987
  }
837
988
 
838
989
  return summary;
839
990
  }
991
+
992
+ /** How often the restore re-reads a ledger that keeps moving before it gives up. */
993
+ const RESTORE_ATTEMPTS = 3;
994
+
995
+ /**
996
+ * Put the original bytes back after a failed post-verification WITHOUT losing a
997
+ * line another process appended after the repair's rename (#1670).
998
+ *
999
+ * Appenders take no lock (O_APPEND, see the lost-update guard in
1000
+ * {@link repairLedger}), so between the rename and this restore a session
1001
+ * record can land at the end of the repaired file. A plain copy of the backup
1002
+ * over the target dropped it. The restore therefore re-reads the target: the
1003
+ * bytes beyond the renamed content are appended to the original, and the result
1004
+ * is swapped in through a tmp file + rename that is refused when the target
1005
+ * moved again since that read (bounded retry). When the target no longer starts
1006
+ * with the renamed bytes, someone rewrote it — restoring would destroy that
1007
+ * write, so nothing is touched and ELEDGERCHANGED is raised (the backup stays).
1008
+ *
1009
+ * CEILING: as in the repair swap, the window between the last re-read and the
1010
+ * rename itself stays open (microseconds). Revisit trigger: appenders taking a
1011
+ * shared lock — hold it across read→rename instead.
1012
+ *
1013
+ * @returns {number} bytes carried over from concurrent appends
1014
+ */
1015
+ function restoreOriginal({ file, backupPath, raw, renamed, fs: io }) {
1016
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.restore-${process.pid}`);
1017
+ for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) {
1018
+ const current = io.readFileSync(file);
1019
+ if (current.length < renamed.length || !current.subarray(0, renamed.length).equals(renamed)) {
1020
+ const err = new Error(
1021
+ `${file} was rewritten after the repair's rename — post-verification failed, but restoring ` +
1022
+ `would overwrite that write; nothing was restored. Original bytes: ${backupPath ?? '(no backup taken)'}`
1023
+ );
1024
+ err.code = 'ELEDGERCHANGED';
1025
+ throw err;
1026
+ }
1027
+ const tail = current.subarray(renamed.length);
1028
+ try {
1029
+ if (backupPath) io.copyFileSync(backupPath, tmp);
1030
+ else io.writeFileSync(tmp, raw, 'utf8');
1031
+ if (tail.length > 0) io.appendFileSync(tmp, tail);
1032
+ if (!io.readFileSync(file).equals(current)) {
1033
+ io.unlinkSync(tmp);
1034
+ continue;
1035
+ }
1036
+ io.renameSync(tmp, file);
1037
+ return tail.length;
1038
+ } catch (err) {
1039
+ try { io.unlinkSync(tmp); } catch { /* already gone */ }
1040
+ throw err;
1041
+ }
1042
+ }
1043
+ const err = new Error(
1044
+ `${file} kept changing during the restore (concurrent appends?) — post-verification failed and ` +
1045
+ `nothing was restored. Original bytes: ${backupPath ?? '(no backup taken)'}`
1046
+ );
1047
+ err.code = 'ELEDGERCHANGED';
1048
+ throw err;
1049
+ }
@@ -19,9 +19,18 @@
19
19
  * started_at, last_heartbeat, status ('active'|'wave'|'idle'),
20
20
  * current_wave, host_class, mode (Epic #583 W2-I3),
21
21
  * role ('navigator'|null, optional, #1462 — display only, never ownership;
22
- * set by heartbeat(patch.role), NOT by registerSelf)
22
+ * set by heartbeat(patch.role), NOT by registerSelf),
23
+ * session_root (absolute realpath|null, optional, #1605 — where THIS
24
+ * session's `.orchestrator/session.lock` and `current-session.json`
25
+ * live; set by registerSelf),
26
+ * work_root (absolute realpath|null, optional, #1605 — the working copy the
27
+ * session works in; set by registerSelf, refreshed by
28
+ * heartbeat(patch.workRoot) at every turn end)
23
29
  * }
24
30
  *
31
+ * `session_root` / `work_root` are additive: readers that predate #1605 ignore
32
+ * them, and `_validEntry` does not require them.
33
+ *
25
34
  * Schema v2 (Epic #583, W2-I3): `mode` field added so the exclusivity-matrix
26
35
  * can classify cross-repo registry entries correctly. Without it, every
27
36
  * registry-sourced peer was bucketed as `mode='session'` → classifyMode threw
@@ -38,7 +47,7 @@ import crypto from 'node:crypto';
38
47
  import { promises as fs } from 'node:fs';
39
48
  import { digestSha256 } from './crypto-digest-utils.mjs';
40
49
 
41
- import { appendFileSync, mkdirSync, realpathSync } from 'node:fs';
50
+ import { appendFileSync, chmodSync, mkdirSync, realpathSync } from 'node:fs';
42
51
 
43
52
  import { utcTimestamp, appendJsonl } from './common.mjs';
44
53
  import { resolvePrivateConfigDir } from './config/private-config-dir.mjs';
@@ -109,6 +118,23 @@ function resolveRepoPath(repoPath) {
109
118
  }
110
119
  }
111
120
 
121
+ /**
122
+ * Canonical spelling of an optional absolute root for `session_root` /
123
+ * `work_root` (#1605): `null` when absent, the realpath otherwise.
124
+ *
125
+ * @param {unknown} value
126
+ * @param {string} name option name for the error message
127
+ * @returns {string|null}
128
+ * @throws {TypeError} when `value` is neither nullish nor an absolute path string
129
+ */
130
+ function _optionalRoot(value, name) {
131
+ if (value === undefined || value === null) return null;
132
+ if (typeof value !== 'string' || !path.isAbsolute(value)) {
133
+ throw new TypeError(`${name} must be an absolute path when provided`);
134
+ }
135
+ return resolveRepoPath(value);
136
+ }
137
+
112
138
  function _assertSessionId(sessionId) {
113
139
  if (typeof sessionId !== 'string' || sessionId.length === 0) {
114
140
  throw new TypeError('session_id must be a non-empty string');
@@ -120,9 +146,11 @@ function _assertSessionId(sessionId) {
120
146
  }
121
147
 
122
148
  async function _writeJsonAtomic(filePath, data, { mode = 0o600 } = {}) {
123
- await fs.mkdir(path.dirname(filePath), { recursive: true });
149
+ // 0o700: the registry sits in the private config dir; a bare mkdir leaves it
150
+ // 0777 under umask 000 and breaks the owner-policy check beside it (#1629).
151
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
124
152
  const tmp = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
125
- await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
153
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode }); // no 0666 window (#1632)
126
154
  await fs.rename(tmp, filePath);
127
155
  try { await fs.chmod(filePath, mode); } catch { /* best effort */ }
128
156
  }
@@ -218,8 +246,10 @@ export function logSweepEvent({ event, session_id, error }) {
218
246
  }) + '\n';
219
247
  try {
220
248
  const logPath = sweepLogPath();
221
- try { mkdirSync(path.dirname(logPath), { recursive: true }); } catch { /* best effort */ }
222
- appendFileSync(logPath, line, 'utf8');
249
+ try { mkdirSync(path.dirname(logPath), { recursive: true, mode: 0o700 }); } catch { /* best effort */ } // #1629
250
+ appendFileSync(logPath, line, { encoding: 'utf8', mode: 0o600 }); // #1632
251
+ // `mode` applies on creation only: tighten a loose pre-existing log too (#1636).
252
+ try { chmodSync(logPath, 0o600); } catch { /* best effort */ }
223
253
  } catch { /* last-resort silent no-op — never let log-write cascade */ }
224
254
  }
225
255
 
@@ -235,6 +265,10 @@ export function logSweepEvent({ event, session_id, error }) {
235
265
  * @param {number} [opts.pid] — defaults to process.pid
236
266
  * @param {string} [opts.status] — 'active' | 'wave' | 'idle' (default 'active')
237
267
  * @param {number} [opts.currentWave] — default 0
268
+ * @param {string} [opts.sessionRoot] — absolute root holding this session's
269
+ * `session.lock` / `current-session.json` (#1605); stored as `session_root`.
270
+ * @param {string} [opts.workRoot] — absolute working copy the session works in
271
+ * (#1605); stored as `work_root`.
238
272
  * @param {string} [opts.mode] — session mode (e.g., 'deep', 'feature', 'housekeeping').
239
273
  * Schema v2 (Epic #583, W2-I3): when present, propagated into the entry so
240
274
  * discovery + exclusivity-matrix classification work for cross-repo peers.
@@ -258,6 +292,8 @@ export async function registerSelf(opts) {
258
292
  if (opts.semanticSessionId !== undefined && opts.semanticSessionId !== null && typeof opts.semanticSessionId !== 'string') {
259
293
  throw new TypeError('registerSelf: semanticSessionId must be a string when provided');
260
294
  }
295
+ const sessionRoot = _optionalRoot(opts.sessionRoot, 'registerSelf: sessionRoot');
296
+ const workRoot = _optionalRoot(opts.workRoot, 'registerSelf: workRoot');
261
297
  const now = utcTimestamp();
262
298
  const entry = {
263
299
  session_id: sessionId,
@@ -273,6 +309,8 @@ export async function registerSelf(opts) {
273
309
  current_wave: opts.currentWave ?? 0,
274
310
  host_class: opts.hostClass ?? null,
275
311
  mode: opts.mode ?? null,
312
+ session_root: sessionRoot,
313
+ work_root: workRoot,
276
314
  };
277
315
  await _writeJsonAtomic(entryPath(sessionId), entry);
278
316
  return entry;
@@ -287,16 +325,20 @@ export const REGISTRY_ROLES = Object.freeze(['navigator']);
287
325
  *
288
326
  * `patch.role`: `undefined` keeps the stored role, `null` clears it, a member
289
327
  * of `REGISTRY_ROLES` sets it. Anything else throws before the file is touched.
328
+ * `patch.workRoot` (#1605): `undefined` keeps the stored `work_root`, an
329
+ * absolute path replaces it (stored as its realpath).
290
330
  *
291
331
  * @param {string} sessionId
292
- * @param {{ status?: string, currentWave?: number, role?: string|null }} [patch]
332
+ * @param {{ status?: string, currentWave?: number, role?: string|null, workRoot?: string }} [patch]
293
333
  * @returns {Promise<object|null>} the updated entry, or null if none exists.
294
- * @throws {TypeError} when `patch.role` is neither undefined, null, nor a REGISTRY_ROLES member.
334
+ * @throws {TypeError} when `patch.role` is neither undefined, null, nor a REGISTRY_ROLES
335
+ * member, or `patch.workRoot` is neither undefined nor an absolute path.
295
336
  */
296
337
  export async function heartbeat(sessionId, patch = {}) {
297
338
  if (patch.role !== undefined && patch.role !== null && !REGISTRY_ROLES.includes(patch.role)) {
298
339
  throw new TypeError('heartbeat: role must be "navigator" or null');
299
340
  }
341
+ const workRoot = patch.workRoot === undefined ? undefined : _optionalRoot(patch.workRoot, 'heartbeat: workRoot');
300
342
  const file = entryPath(sessionId);
301
343
  const existing = await _readJsonSafe(file);
302
344
  if (!_validEntry(existing)) return null;
@@ -306,6 +348,7 @@ export async function heartbeat(sessionId, patch = {}) {
306
348
  ...(patch.status !== undefined ? { status: patch.status } : {}),
307
349
  ...(patch.currentWave !== undefined ? { current_wave: patch.currentWave } : {}),
308
350
  ...(patch.role !== undefined ? { role: patch.role } : {}),
351
+ ...(workRoot !== undefined && workRoot !== null ? { work_root: workRoot } : {}),
309
352
  };
310
353
  await _writeJsonAtomic(file, updated);
311
354
  return updated;
@@ -363,6 +406,26 @@ export async function detectPeers({ sessionId, freshnessMin = 15 } = {}) {
363
406
  });
364
407
  }
365
408
 
409
+ /**
410
+ * Count registry entries whose `started_at` lies within the last `windowMin`
411
+ * minutes — the start-wave signal (#1650). Pure: pass the entries you already
412
+ * hold. Passing detectPeers() output is complete as long as `windowMin` is at
413
+ * most detectPeers' `freshnessMin` (15): registerSelf() writes `started_at`
414
+ * and `last_heartbeat` with the same stamp, so every entry started inside the
415
+ * window is still heartbeat-fresh. Self must already be excluded by the caller.
416
+ * Unparseable or future `started_at` never counts (`_ageMinutes` → Infinity).
417
+ *
418
+ * @param {object[]} entries Registry entries (e.g. detectPeers() result)
419
+ * @param {object} [opts]
420
+ * @param {number} [opts.windowMin=5]
421
+ * @param {number} [opts.now] ms-since-epoch (test seam)
422
+ * @returns {number}
423
+ */
424
+ export function countRecentStarts(entries, { windowMin = 5, now = Date.now() } = {}) {
425
+ if (!Array.isArray(entries)) return 0;
426
+ return entries.filter((e) => e && _ageMinutes(e.started_at, now) <= windowMin).length;
427
+ }
428
+
366
429
  /**
367
430
  * Remove heartbeat entries older than `thresholdMin` minutes. Appends one
368
431
  * JSONL line per removal to sweepLogPath() for observability.
@@ -412,7 +475,7 @@ export async function sweepZombies({ thresholdMin = 60, now = Date.now() } = {})
412
475
  file: name,
413
476
  age_minutes: Number.isFinite(age) ? Math.round(age) : null,
414
477
  reason: valid ? 'stale-heartbeat' : 'malformed-entry',
415
- });
478
+ }, { mode: 0o600 }); // #1632
416
479
  logged += 1;
417
480
  } catch { /* log best-effort */ }
418
481
  } catch { /* file might have been removed by another sweep */ }
@@ -45,6 +45,8 @@ export const SESSION_KEY_ALIASES = Object.freeze({
45
45
  issues_filed: 'issues_created',
46
46
  issues_planned: 'planned_issues',
47
47
  files_changed: 'total_files_changed',
48
+ // No `agents_total` alias (#1699): session-record-repair.mjs lets the agent_summary sum win over it
49
+ // and flags a disagreement; an alias here would make normalize/migrate prefer agents_total instead.
48
50
  snapshots: 'snapshots_created',
49
51
  learnings: 'learnings_added',
50
52
  waves_total: 'total_waves',
@@ -252,6 +254,13 @@ export const OPTIONAL_FIELDS = Object.freeze([
252
254
  // 2026-10-01), so the counters say which of the two it is.
253
255
  'cost_records_priced',
254
256
  'cost_records_total',
257
+ // #1659 — `[{ tree, start_ref }]`: the work trees this session committed in,
258
+ // from its own `orchestrator.session.work_tree` events. Written by
259
+ // scripts/emit-session.mjs only when it adds something beyond
260
+ // `session_start_ref` (≥2 trees, or one tree that is not the own checkout).
261
+ // Shape (absolute `tree`, full hex `start_ref`) is enforced at that writer —
262
+ // malformed entries are dropped with a WARN — not in validateSession().
263
+ 'session_work_trees',
255
264
  // #1470 Pkt 4/6 — what `total_cost_usd` is and which price table produced
256
265
  // the cost fields. Merged by scripts/emit-session.mjs from
257
266
  // scripts/lib/telemetry/pricing.mjs (`COST_BASIS`, `PRICING_TABLE_DATE`):