session-orchestrator 5.11.0 → 5.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (394) hide show
  1. package/.claude-plugin/marketplace.json +12 -1
  2. package/.claude-plugin/plugin.json +4 -1
  3. package/.codex-plugin/agents/explorer.toml +1 -1
  4. package/.codex-plugin/agents/session-reviewer.toml +2 -2
  5. package/.codex-plugin/agents/wave-worker.toml +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/.orchestrator/policy/blocked-commands.json +8 -0
  9. package/.orchestrator/policy/public-export.json +30 -0
  10. package/CHANGELOG.md +180 -3
  11. package/README.md +126 -117
  12. package/agents/analyst.md +7 -2
  13. package/agents/architect-reviewer.md +7 -2
  14. package/agents/code-implementer.md +4 -2
  15. package/agents/db-specialist.md +9 -2
  16. package/agents/dialectic-deriver.md +9 -3
  17. package/agents/docs-writer.md +7 -2
  18. package/agents/eval-judge.md +7 -1
  19. package/agents/qa-strategist.md +7 -2
  20. package/agents/security-reviewer.md +9 -2
  21. package/agents/session-reviewer.md +18 -2
  22. package/agents/skill-applied-judge.md +7 -1
  23. package/agents/test-writer.md +9 -2
  24. package/agents/ui-developer.md +9 -2
  25. package/agents/ux-evaluator.md +6 -1
  26. package/commands/session.md +4 -2
  27. package/commands/templates-ack.md +89 -18
  28. package/docs/agent-authoring.md +30 -11
  29. package/docs/ci-setup.md +38 -2
  30. package/docs/codex-setup.md +33 -9
  31. package/docs/components.md +13 -2
  32. package/docs/cursor-setup.md +3 -3
  33. package/docs/events-schema.md +5 -4
  34. package/docs/evidence-candidates.md +63 -0
  35. package/docs/fleet-checkin-audit.md +7 -4
  36. package/docs/github-mirror-protection.md +150 -3
  37. package/docs/install.md +102 -25
  38. package/docs/instruction-delivery.md +1 -1
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/mods.md +118 -0
  41. package/docs/owner-config-schema.md +1 -0
  42. package/docs/plugin-architecture-v3.md +1 -1
  43. package/docs/pm-skills-marketplace.md +4 -3
  44. package/docs/rule-authoring.md +24 -13
  45. package/docs/scope-collision-guard.md +2 -1
  46. package/docs/session-config-reference.md +55 -21
  47. package/docs/session-config-template.md +32 -24
  48. package/docs/sessions-ledger-repair.md +137 -0
  49. package/docs/vault-docs-architecture.md +1 -1
  50. package/hooks/_lib/hook-import-set.json +406 -73
  51. package/hooks/_lib/hook-root.mjs +301 -0
  52. package/hooks/_lib/lock-bootstrap.mjs +434 -4
  53. package/hooks/_lib/orchestrator-ignore.mjs +3 -0
  54. package/hooks/_lib/subagent-transcript.mjs +2 -2
  55. package/hooks/_lib/vcs-create-matcher.mjs +13 -17
  56. package/hooks/cwd-change-restore.mjs +5 -1
  57. package/hooks/enforce-commands.mjs +4 -3
  58. package/hooks/enforce-scope.mjs +11 -1
  59. package/hooks/hooks-codex.json +48 -2
  60. package/hooks/hooks.json +31 -31
  61. package/hooks/on-session-end.mjs +38 -15
  62. package/hooks/on-session-start.mjs +158 -59
  63. package/hooks/on-stop.mjs +11 -6
  64. package/hooks/post-bash-issue-budget-refund.mjs +6 -1
  65. package/hooks/post-bash-write-verify.mjs +8 -4
  66. package/hooks/post-edit-validate.mjs +45 -52
  67. package/hooks/post-subagent-discovery-validator.mjs +2 -2
  68. package/hooks/post-tool-batch-wave-signal.mjs +8 -4
  69. package/hooks/post-tool-failure-corrective-context.mjs +5 -1
  70. package/hooks/pre-bash-destructive-guard.mjs +220 -8
  71. package/hooks/pre-bash-issue-budget.mjs +6 -1
  72. package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
  73. package/hooks/pre-bash-staging-fence.mjs +258 -14
  74. package/hooks/pre-bash-templates-first.mjs +28 -27
  75. package/hooks/run-node.sh +1 -1
  76. package/hooks/skill-invocation-telemetry.mjs +15 -8
  77. package/hooks/subagent-telemetry.mjs +77 -14
  78. package/hooks/wave-scope-commit-guard.mjs +143 -14
  79. package/mods/so-hud/.claude-plugin/plugin.json +11 -0
  80. package/mods/so-hud/README.md +57 -0
  81. package/mods/so-hud/hooks/beacon-io.mjs +101 -0
  82. package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
  83. package/mods/so-hud/hooks/hooks.json +1 -0
  84. package/mods/so-hud/hooks/logic.ts +182 -0
  85. package/mods/so-hud/hooks/register.tsx +670 -0
  86. package/mods/so-hud/hooks/rules.ts +2998 -0
  87. package/mods/so-hud/tsconfig.json +8 -0
  88. package/mods/so-hud/types/index.d.ts +36 -0
  89. package/package.json +4 -3
  90. package/rules/_index.md +1 -1
  91. package/rules/always-on/ask-via-tool.md +1 -1
  92. package/rules/always-on/loop-and-monitor.md +17 -194
  93. package/rules/always-on/parallel-sessions.md +3 -1
  94. package/rules/opt-in-stack/backend-data.md +1 -1
  95. package/rules/opt-in-stack/backend.md +1 -1
  96. package/rules/opt-in-stack/security-web.md +5 -1
  97. package/scripts/agent-usage-census.mjs +79 -0
  98. package/scripts/apply-session-learnings.mjs +34 -6
  99. package/scripts/archive-closed-prds.mjs +21 -5
  100. package/scripts/autopilot.mjs +11 -5
  101. package/scripts/backfill-abandoned-sessions.mjs +13 -3
  102. package/scripts/backfill-evidence-digest.mjs +2 -1
  103. package/scripts/backfill-learnings-from-vault.mjs +61 -6
  104. package/scripts/backfill-sessions.mjs +16 -2
  105. package/scripts/check-package-manager.mjs +26 -8
  106. package/scripts/check-sessions-integrity.mjs +4 -1
  107. package/scripts/ci/assert-vitest-green.mjs +3 -0
  108. package/scripts/ci/windows-smoke.mjs +201 -0
  109. package/scripts/close-precheck.mjs +55 -0
  110. package/scripts/dialectic-deriver.mjs +20 -12
  111. package/scripts/emit-event.mjs +2 -2
  112. package/scripts/emit-session.mjs +239 -20
  113. package/scripts/eval-session.mjs +17 -3
  114. package/scripts/export-hw-learnings.mjs +29 -2
  115. package/scripts/fleet-checkin-audit.mjs +8 -4
  116. package/scripts/fleet-checkin.mjs +73 -3
  117. package/scripts/gc-stale-worktrees.mjs +6 -1
  118. package/scripts/github-export.mjs +128 -0
  119. package/scripts/github-protection-audit.mjs +3 -1
  120. package/scripts/handover-queue-check.mjs +209 -0
  121. package/scripts/harness-audit.mjs +6 -4
  122. package/scripts/lib/agent-frontmatter.mjs +57 -10
  123. package/scripts/lib/agent-status.mjs +125 -7
  124. package/scripts/lib/agent-usage-census.mjs +394 -0
  125. package/scripts/lib/auto-dialectic.mjs +17 -10
  126. package/scripts/lib/auto-dream.mjs +22 -14
  127. package/scripts/lib/autopilot/loop.mjs +3 -1
  128. package/scripts/lib/autopilot/recent-runs.mjs +4 -2
  129. package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
  130. package/scripts/lib/autopilot/telemetry.mjs +5 -1
  131. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  132. package/scripts/lib/baseline-archetypes.mjs +10 -2
  133. package/scripts/lib/blocked-commands-policy.mjs +4 -3
  134. package/scripts/lib/build-live-signals.mjs +13 -9
  135. package/scripts/lib/ci-status-banner.mjs +329 -24
  136. package/scripts/lib/codex/plugin-contract.mjs +8 -0
  137. package/scripts/lib/cold-start-detector.mjs +5 -2
  138. package/scripts/lib/command-blocker.mjs +603 -43
  139. package/scripts/lib/common.mjs +3 -2
  140. package/scripts/lib/config/cross-repo.mjs +74 -7
  141. package/scripts/lib/config/host-paths.mjs +6 -2
  142. package/scripts/lib/config/remote-hosts.mjs +2 -2
  143. package/scripts/lib/config/section-extractor.mjs +65 -11
  144. package/scripts/lib/config/template-keys.mjs +153 -0
  145. package/scripts/lib/config/vault-integration.mjs +1 -0
  146. package/scripts/lib/config.mjs +3 -3
  147. package/scripts/lib/coordinator-snapshot.mjs +1 -1
  148. package/scripts/lib/discovery/triage-state.mjs +2 -1
  149. package/scripts/lib/dispatcher/rank.mjs +5 -7
  150. package/scripts/lib/ecosystem-health.mjs +2 -2
  151. package/scripts/lib/eval/engine.mjs +3 -2
  152. package/scripts/lib/eval/session-resolve.mjs +6 -2
  153. package/scripts/lib/eval/sink.mjs +4 -3
  154. package/scripts/lib/events-retention-banner.mjs +3 -2
  155. package/scripts/lib/events-schema.mjs +10 -0
  156. package/scripts/lib/events.mjs +23 -12
  157. package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
  158. package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
  159. package/scripts/lib/fleet-protocol.mjs +21 -0
  160. package/scripts/lib/gates/gate-full.mjs +172 -11
  161. package/scripts/lib/gates/gate-helpers.mjs +236 -23
  162. package/scripts/lib/git-config-drift.mjs +141 -7
  163. package/scripts/lib/github-export.mjs +447 -0
  164. package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
  165. package/scripts/lib/handover-queue-marks.mjs +159 -0
  166. package/scripts/lib/hardware-pattern-detector.mjs +103 -13
  167. package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
  168. package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
  169. package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
  170. package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
  171. package/scripts/lib/host-identity.mjs +9 -5
  172. package/scripts/lib/http-request.mjs +64 -0
  173. package/scripts/lib/instruction-budget-guard.mjs +30 -22
  174. package/scripts/lib/io.mjs +6 -3
  175. package/scripts/lib/issue-budget-reconcile.mjs +3 -2
  176. package/scripts/lib/issue-budget.mjs +36 -7
  177. package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
  178. package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
  179. package/scripts/lib/learnings/io.mjs +289 -15
  180. package/scripts/lib/learnings/select.mjs +53 -4
  181. package/scripts/lib/learnings/store-role.mjs +209 -0
  182. package/scripts/lib/learnings/surface.mjs +46 -7
  183. package/scripts/lib/ledger-path.mjs +113 -0
  184. package/scripts/lib/maintenance-due-banner.mjs +63 -12
  185. package/scripts/lib/memory-banner.mjs +5 -6
  186. package/scripts/lib/memory-proposals/collector.mjs +5 -2
  187. package/scripts/lib/memory-proposals/sink.mjs +7 -4
  188. package/scripts/lib/memory-proposals/store.mjs +22 -5
  189. package/scripts/lib/mode-selector-accuracy.mjs +2 -1
  190. package/scripts/lib/no-follow-open.mjs +57 -0
  191. package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
  192. package/scripts/lib/own-lock-root.mjs +95 -0
  193. package/scripts/lib/owner-interview.mjs +2 -1
  194. package/scripts/lib/owner-yaml.mjs +70 -7
  195. package/scripts/lib/platform.mjs +161 -9
  196. package/scripts/lib/plugin-update-banner.mjs +15 -11
  197. package/scripts/lib/pre-push-local-partial.mjs +117 -0
  198. package/scripts/lib/pre-push-remote-gate.mjs +151 -25
  199. package/scripts/lib/process-group.mjs +270 -34
  200. package/scripts/lib/project-hygiene.mjs +28 -0
  201. package/scripts/lib/quality-gate.mjs +33 -9
  202. package/scripts/lib/reconcile/backlog.mjs +29 -15
  203. package/scripts/lib/reconcile/engine.mjs +26 -2
  204. package/scripts/lib/reconcile/idempotency.mjs +65 -3
  205. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
  206. package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
  207. package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
  208. package/scripts/lib/resource-probe/evaluate.mjs +32 -9
  209. package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
  210. package/scripts/lib/rule-loader.mjs +20 -10
  211. package/scripts/lib/scope-baseline.mjs +123 -8
  212. package/scripts/lib/scope-echo.mjs +5 -2
  213. package/scripts/lib/scope-gate.mjs +55 -20
  214. package/scripts/lib/session-close-backfill.mjs +75 -7
  215. package/scripts/lib/session-end/close-precheck.mjs +151 -0
  216. package/scripts/lib/session-end/phase-skip.mjs +8 -7
  217. package/scripts/lib/session-end/tail-runner.mjs +7 -2
  218. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  219. package/scripts/lib/session-id.mjs +9 -1
  220. package/scripts/lib/session-identity/own-session.mjs +63 -0
  221. package/scripts/lib/session-invocation.mjs +14 -3
  222. package/scripts/lib/session-lock.mjs +114 -0
  223. package/scripts/lib/session-record-repair.mjs +245 -35
  224. package/scripts/lib/session-registry.mjs +85 -12
  225. package/scripts/lib/session-schema/constants.mjs +9 -0
  226. package/scripts/lib/session-schema/filters.mjs +56 -1
  227. package/scripts/lib/session-schema/validator.mjs +15 -5
  228. package/scripts/lib/session-start-probes.mjs +149 -22
  229. package/scripts/lib/session-token-rollup.mjs +2 -1
  230. package/scripts/lib/session-work-trees.mjs +166 -0
  231. package/scripts/lib/sessions-canonical.mjs +61 -3
  232. package/scripts/lib/sessions-integrity-banner.mjs +17 -39
  233. package/scripts/lib/sessions-staleness-banner.mjs +8 -5
  234. package/scripts/lib/sh-quote.mjs +1 -0
  235. package/scripts/lib/skill-evidence-window.mjs +5 -1
  236. package/scripts/lib/skill-health/join.mjs +10 -9
  237. package/scripts/lib/skill-invocations-schema.mjs +13 -0
  238. package/scripts/lib/skill-judgments-schema.mjs +6 -2
  239. package/scripts/lib/snapshot-probe.mjs +414 -0
  240. package/scripts/lib/so-hud/collect.mjs +174 -0
  241. package/scripts/lib/so-hud/runtime.mjs +87 -0
  242. package/scripts/lib/spiral-carryover.mjs +8 -1
  243. package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
  244. package/scripts/lib/state-md/idle-reset.mjs +248 -0
  245. package/scripts/lib/state-md/mission-status.mjs +5 -1
  246. package/scripts/lib/state-md/recommendations.mjs +26 -4
  247. package/scripts/lib/state-md-peer-guard.mjs +2 -0
  248. package/scripts/lib/state-md.mjs +31 -8
  249. package/scripts/lib/sunset/walker.mjs +4 -3
  250. package/scripts/lib/telemetry/consent.mjs +4 -2
  251. package/scripts/lib/telemetry/queue.mjs +3 -3
  252. package/scripts/lib/telemetry/sync.mjs +45 -9
  253. package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
  254. package/scripts/lib/tmux-layout/layouts.mjs +20 -3
  255. package/scripts/lib/ux-grill/paths.mjs +5 -1
  256. package/scripts/lib/validate/check-agents.mjs +37 -6
  257. package/scripts/lib/validate/check-component-paths.mjs +25 -1
  258. package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
  259. package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
  260. package/scripts/lib/validate/check-json-files.mjs +3 -1
  261. package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
  262. package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
  263. package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
  264. package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
  265. package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
  266. package/scripts/lib/validate/check-unwired-features.mjs +4 -2
  267. package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
  268. package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
  269. package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
  270. package/scripts/lib/validate/confidential-names.mjs +3 -1
  271. package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
  272. package/scripts/lib/vault-mirror/namespace.mjs +22 -9
  273. package/scripts/lib/vault-mirror/process.mjs +197 -65
  274. package/scripts/lib/vault-mirror/prune.mjs +5 -2
  275. package/scripts/lib/vault-mirror/render-learnings.mjs +8 -7
  276. package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
  277. package/scripts/lib/vault-mirror/retention.mjs +2 -2
  278. package/scripts/lib/vault-mirror/utils.mjs +106 -13
  279. package/scripts/lib/vault-staleness-banner.mjs +4 -7
  280. package/scripts/lib/vault-status/board-writer.mjs +34 -5
  281. package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
  282. package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
  283. package/scripts/lib/win-process-tree.mjs +137 -0
  284. package/scripts/lib/workspace.mjs +1 -1
  285. package/scripts/lib/worktree/constants.mjs +1 -1
  286. package/scripts/lib/worktree/lifecycle.mjs +1 -1
  287. package/scripts/lib/worktree/listing.mjs +1 -1
  288. package/scripts/lib/zx.mjs +50 -0
  289. package/scripts/mcp-server.mjs +16 -7
  290. package/scripts/mcp-server.sh +13 -83
  291. package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
  292. package/scripts/memory-propose.mjs +10 -3
  293. package/scripts/migrate-cold-start-seed.mjs +7 -0
  294. package/scripts/migrate-legacy-learnings.sh +22 -0
  295. package/scripts/migrate-sessions-jsonl.mjs +16 -2
  296. package/scripts/migrate-vault-paths.mjs +29 -13
  297. package/scripts/parse-config.mjs +71 -14
  298. package/scripts/print-learnings-index.mjs +25 -2
  299. package/scripts/release-session-lock.mjs +94 -7
  300. package/scripts/release.mjs +451 -24
  301. package/scripts/relocate-vault-corpus.mjs +11 -7
  302. package/scripts/repair-invalid-sessions.mjs +68 -3
  303. package/scripts/resolve-session-invocation.mjs +57 -8
  304. package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
  305. package/scripts/run-quality-gate.mjs +33 -7
  306. package/scripts/self-update.mjs +56 -7
  307. package/scripts/session-commit-files.mjs +133 -0
  308. package/scripts/site-numbers.mjs +52 -9
  309. package/scripts/snapshot-probe.mjs +156 -0
  310. package/scripts/so-hud.mjs +68 -0
  311. package/scripts/state-md-idle-reset.mjs +82 -0
  312. package/scripts/sweep-expired-learnings.mjs +54 -6
  313. package/scripts/sweep-expired-rules.mjs +4 -1
  314. package/scripts/validate-plugin.mjs +22 -0
  315. package/scripts/vault-integration-watcher.mjs +54 -6
  316. package/scripts/vault-mirror.mjs +9 -0
  317. package/scripts/vault-orphans.mjs +171 -0
  318. package/skills/_shared/fleet-protocol.md +166 -3
  319. package/skills/_shared/instruction-file-resolution.md +1 -1
  320. package/skills/_shared/monitor-patterns.md +44 -25
  321. package/skills/_shared/platform-tools.md +7 -0
  322. package/skills/_shared/state-ownership.md +5 -4
  323. package/skills/architecture/references/domain-model.md +1 -1
  324. package/skills/autopilot/SKILL.md +3 -2
  325. package/skills/bootstrap/SKILL.md +3 -1
  326. package/skills/bootstrap/_shared-template.md +4 -4
  327. package/skills/bootstrap/fast-template.md +1 -1
  328. package/skills/bootstrap/private-contract.md +1 -1
  329. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
  330. package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
  331. package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
  332. package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
  333. package/skills/claude-md-drift-check/SKILL.md +3 -3
  334. package/skills/claude-md-drift-check/checker.mjs +61 -101
  335. package/skills/close/SKILL.md +20 -7
  336. package/skills/discovery/probes/docs-staleness.mjs +7 -5
  337. package/skills/discovery/probes/frontend-slop.mjs +7 -3
  338. package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
  339. package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
  340. package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
  341. package/skills/discovery/probes/vault-staleness.mjs +5 -3
  342. package/skills/discovery/probes-session.md +3 -0
  343. package/skills/discovery/probes-vault.md +2 -0
  344. package/skills/dispatcher/SKILL.md +1 -1
  345. package/skills/eval/SKILL.md +3 -1
  346. package/skills/evolve/SKILL.md +2 -0
  347. package/skills/evolve/references/evolve-analyze-mode.md +3 -3
  348. package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
  349. package/skills/gitlab-ops/SKILL.md +7 -6
  350. package/skills/gitlab-portfolio/SKILL.md +2 -2
  351. package/skills/go/SKILL.md +13 -1
  352. package/skills/npm-publish/SKILL.md +5 -2
  353. package/skills/plan/SKILL.md +23 -10
  354. package/skills/plan/mode-feature.md +13 -8
  355. package/skills/plan/mode-new.md +6 -14
  356. package/skills/plan/prd-feature-template.md +19 -0
  357. package/skills/plan/prd-full-template.md +12 -5
  358. package/skills/plan/prd-premortem-panel.md +48 -0
  359. package/skills/plan/prd-reviewer-prompt.md +11 -3
  360. package/skills/plan/soul.md +1 -1
  361. package/skills/portfolio/SKILL.md +5 -6
  362. package/skills/quality-gates/SKILL.md +2 -4
  363. package/skills/reconcile/SKILL.md +27 -4
  364. package/skills/release/SKILL.md +16 -8
  365. package/skills/session-end/SKILL.md +97 -13
  366. package/skills/session-end/phase-3-2-docs-verification.md +1 -0
  367. package/skills/session-end/phase-3-6-tail.md +11 -7
  368. package/skills/session-end/phase-3-7a-recommendations.md +9 -2
  369. package/skills/session-end/plan-verification.md +16 -1
  370. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  371. package/skills/session-end/references/phase-3-documentation-updates.md +25 -13
  372. package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
  373. package/skills/session-end/references/session-summary-template.md +8 -0
  374. package/skills/session-end/session-metrics-write.md +7 -4
  375. package/skills/session-plan/SKILL.md +5 -3
  376. package/skills/session-start/SKILL.md +10 -4
  377. package/skills/session-start/phase-4-5-resource-health.md +11 -5
  378. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  379. package/skills/session-start/phase-8-5-express-path.md +4 -3
  380. package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
  381. package/skills/session-start/references/phase-1-5-session-continuity.md +13 -5
  382. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  383. package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
  384. package/skills/tmux-layout/SKILL.md +2 -2
  385. package/skills/vault-sync/validator.mjs +3 -2
  386. package/skills/wave-executor/SKILL.md +5 -4
  387. package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
  388. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
  389. package/skills/wave-executor/references/wave-loop-review.md +13 -5
  390. package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
  391. package/AGENTS.md +0 -207
  392. package/assets/og-card.png +0 -0
  393. package/assets/og-card.svg +0 -47
  394. package/assets/wave-lifecycle.svg +0 -98
@@ -47,6 +47,7 @@ import { isLockShape } from './session-lock-shape.mjs';
47
47
  import { isPidAliveOnHost } from './file-lock.mjs';
48
48
  import { writeJsonAtomicSync } from './io.mjs';
49
49
  import { hostnamesMatch, lockHostCandidate, recordHostAlias, stableHostname } from './host-identity.mjs';
50
+ import { isSafeSessionId } from './fleet-protocol.mjs';
50
51
 
51
52
  // isPidAliveOnHost moved into file-lock.mjs in #630 (the file-lock primitive
52
53
  // owns it so the dependency edge points file-lock → io, never the reverse).
@@ -133,6 +134,13 @@ export const LOCK_LEFTOVER_MIN_AGE_MS = 60 * 60 * 1000;
133
134
  */
134
135
  export const OWNER_PROOF_RELPATH = '.orchestrator/runtime/lock-owner-proof.json';
135
136
 
137
+ /**
138
+ * Directory of the "released on purpose" markers (#1651), relative to the repo
139
+ * root — one `<raw session_id>.json` per session that gave its lock back via
140
+ * `scripts/release-session-lock.mjs`. Gitignored like the owner proof beside it.
141
+ */
142
+ export const RELEASE_MARKER_RELDIR = '.orchestrator/runtime/lock-released';
143
+
136
144
  // ---------------------------------------------------------------------------
137
145
  // Internal helpers
138
146
  // ---------------------------------------------------------------------------
@@ -875,6 +883,10 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
875
883
  function withOwnerProof(result, repoRoot) {
876
884
  const w = writeOwnerProof({ repoRoot, lock: result.lock });
877
885
  result.ownerProof = w.ok ? { ok: true } : { ok: false, reason: w.reason };
886
+ // #1651: the session holds a lock again, so its earlier "released on
887
+ // purpose" marker no longer describes it. This is the one junction every
888
+ // ok:true write of acquire()/forceAcquire() passes. Never throws.
889
+ clearReleaseMarker({ repoRoot, sessionId: result.lock?.session_id });
878
890
  return result;
879
891
  }
880
892
 
@@ -1253,6 +1265,108 @@ export function loadOwnerProof({ repoRoot } = {}) {
1253
1265
  }
1254
1266
  }
1255
1267
 
1268
+ /**
1269
+ * Absolute path of the release marker for `sessionId`, or null when the id is
1270
+ * not a safe single file-name component (#1651). The id comes from a lock file
1271
+ * or a CLI flag, so it is validated before it reaches a path: no separator, no
1272
+ * `.`/`..`, at most 128 chars of `[A-Za-z0-9._-]`.
1273
+ *
1274
+ * @param {string|undefined} repoRoot
1275
+ * @param {unknown} sessionId
1276
+ * @returns {string|null}
1277
+ */
1278
+ function releaseMarkerPath(repoRoot, sessionId) {
1279
+ if (!isSafeSessionId(sessionId)) return null;
1280
+ return path.join(repoRoot ?? process.cwd(), RELEASE_MARKER_RELDIR, `${sessionId}.json`);
1281
+ }
1282
+
1283
+ /**
1284
+ * Record that `sessionId` deliberately released its own lock (#1651), so the
1285
+ * SessionStart bootstrap does not re-create it on a `compact` or `resume` of
1286
+ * that same raw session — a headless run may be using the slot. Atomic
1287
+ * (tmp+rename). Never throws.
1288
+ *
1289
+ * Ceiling (BV-004): markers are never pruned — one small file per released
1290
+ * session id. Revisit if `.orchestrator/runtime/lock-released/` grows past a
1291
+ * few hundred entries.
1292
+ *
1293
+ * `reason` (optional, #1747) records why the lock was given back; the
1294
+ * SessionStart bootstrap writes `'lead-yield'` and honours such a marker only
1295
+ * while a fleet lead's lease is active. Absent = an explicit release.
1296
+ *
1297
+ * @param {{ repoRoot?: string, sessionId: string, reason?: string }} args
1298
+ * @returns {{ ok: true, path: string } | { ok: false, reason: string, error?: string }}
1299
+ */
1300
+ export function writeReleaseMarker({ repoRoot, sessionId, reason } = {}) {
1301
+ try {
1302
+ const file = releaseMarkerPath(repoRoot, sessionId);
1303
+ if (file === null) return { ok: false, reason: 'unsafe-session-id' };
1304
+ const w = writeJsonAtomicSync(
1305
+ file,
1306
+ {
1307
+ schema_version: 1,
1308
+ session_id: sessionId,
1309
+ released_at: nowIso(),
1310
+ host: stableHostname(),
1311
+ ...(typeof reason === 'string' ? { reason } : {}),
1312
+ },
1313
+ { tmpPrefix: '.lock-released.tmp' },
1314
+ );
1315
+ if (!w.ok) return { ok: false, reason: 'fs-error', error: w.error };
1316
+ return { ok: true, path: file };
1317
+ } catch (err) {
1318
+ return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
1319
+ }
1320
+ }
1321
+
1322
+ /**
1323
+ * True when a release marker for `sessionId` exists (#1651). Never throws; an
1324
+ * unsafe id or any fs error reads as "no marker", i.e. the pre-#1651 behaviour.
1325
+ *
1326
+ * @param {{ repoRoot?: string, sessionId: string }} args
1327
+ * @returns {boolean}
1328
+ */
1329
+ export function hasReleaseMarker({ repoRoot, sessionId } = {}) {
1330
+ try {
1331
+ const file = releaseMarkerPath(repoRoot, sessionId);
1332
+ return file !== null && fs.lstatSync(file).isFile();
1333
+ } catch {
1334
+ return false;
1335
+ }
1336
+ }
1337
+
1338
+ /**
1339
+ * The release marker body for `sessionId`, or null when it is absent, unsafe,
1340
+ * not a regular file or not a JSON object (#1747). Never throws. Existence
1341
+ * alone is {@link hasReleaseMarker}; this reader only adds the `reason`.
1342
+ *
1343
+ * @param {{ repoRoot?: string, sessionId: string }} args
1344
+ * @returns {{ reason?: unknown } & Record<string, unknown> | null}
1345
+ */
1346
+ export function readReleaseMarker({ repoRoot, sessionId } = {}) {
1347
+ try {
1348
+ const file = releaseMarkerPath(repoRoot, sessionId);
1349
+ if (file === null || !fs.lstatSync(file).isFile()) return null;
1350
+ const body = JSON.parse(fs.readFileSync(file, 'utf8'));
1351
+ return body !== null && typeof body === 'object' && !Array.isArray(body) ? body : null;
1352
+ } catch {
1353
+ return null;
1354
+ }
1355
+ }
1356
+
1357
+ /**
1358
+ * Remove the release marker for `sessionId` (#1651). Best-effort, never throws.
1359
+ *
1360
+ * @param {{ repoRoot?: string, sessionId: string }} args
1361
+ * @returns {void}
1362
+ */
1363
+ export function clearReleaseMarker({ repoRoot, sessionId } = {}) {
1364
+ try {
1365
+ const file = releaseMarkerPath(repoRoot, sessionId);
1366
+ if (file !== null) fs.rmSync(file, { force: true });
1367
+ } catch { /* best-effort */ }
1368
+ }
1369
+
1256
1370
  /**
1257
1371
  * Release the lock IFF it belongs to the given session_id.
1258
1372
  * Silent no-op when the lock belongs to a different session or does not exist.
@@ -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
+ }