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
@@ -10,6 +10,8 @@
10
10
  * makes the shell a process-group LEADER (`child.pid === pgid`, setsid), so
11
11
  * `process.kill(-pgid, sig)` reaches every descendant that did not setsid away.
12
12
  *
13
+ * Windows uses a non-detached hidden child and taskkill /T with CIM readback.
14
+ *
13
15
  * Measured on this host (Darwin 25.6.0, 2026-09-21) and load-bearing here:
14
16
  * - `process.kill(-pgid, 'SIGTERM')` does NOT terminate a grandchild that
15
17
  * installs `trap "" TERM`; SIGKILL to the group does. Hence the ladder.
@@ -23,6 +25,7 @@
23
25
  */
24
26
 
25
27
  import { spawn } from 'node:child_process';
28
+ import { StringDecoder } from 'node:string_decoder';
26
29
  import { createHash } from 'node:crypto';
27
30
  import {
28
31
  existsSync,
@@ -36,6 +39,7 @@ import path from 'node:path';
36
39
 
37
40
  import { NOT_A_REGULAR_FILE, appendNoFollow } from './orphan-reaper/no-follow-append.mjs';
38
41
  import { readTailWindow } from './tail-window.mjs';
42
+ import { killWindowsProcessTree } from './win-process-tree.mjs';
39
43
 
40
44
  /**
41
45
  * Grace period between SIGTERM and SIGKILL, in ms — the repo's one definition,
@@ -106,12 +110,20 @@ const EXIT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
106
110
  * one the caller passed, which is why the exit handler never reaches
107
111
  * `process.kill` for a group a test spawned through an injected seam.
108
112
  *
109
- * @type {Map<number, {killFn: (target: number, signal: string) => unknown}>}
113
+ * @type {Map<number, {killFn: (target: number, signal: string) => unknown, platform: string, spawnSyncFn?: Function, snapshotFn?: Function, child: object, spawnedAtMs: number, verifyTree: boolean}>}
110
114
  */
111
115
  const LIVE_GROUPS = new Map();
112
116
 
113
117
  let exitHandlerInstalled = false;
114
118
 
119
+ /**
120
+ * Synchronous cleanup callbacks registered via `registerExitCleanup()`, run on
121
+ * the exit path after the live groups are killed.
122
+ *
123
+ * @type {Set<() => void>}
124
+ */
125
+ const EXIT_CLEANUPS = new Set();
126
+
115
127
  /**
116
128
  * Default liveness probe: signal 0 tells us whether a PID exists without
117
129
  * touching it. `ESRCH` = gone. `EPERM` = alive but not ours — reported as ALIVE
@@ -142,6 +154,28 @@ function defaultKill(target, signal) {
142
154
  return process.kill(target, signal);
143
155
  }
144
156
 
157
+ /**
158
+ * Default GROUP liveness probe: signal 0 to `-pgid` asks whether ANY member of
159
+ * the group still exists — unlike {@link defaultIsAlive}, which probes the
160
+ * leader pid and so reads a group whose leader already exited as dead.
161
+ * `ESRCH` = no member left. `EPERM` = a member exists that we may not signal:
162
+ * reported as ALIVE, for the same reason as {@link defaultIsAlive} — the ladder
163
+ * that follows then stops at EPERM and reports the group as a survivor instead
164
+ * of the probe booking it as gone.
165
+ *
166
+ * @param {number} pgid
167
+ * @returns {boolean}
168
+ */
169
+ function defaultGroupAlive(pgid) {
170
+ if (!isSignalablePgid(pgid)) return false;
171
+ try {
172
+ process.kill(-pgid, 0);
173
+ return true;
174
+ } catch (err) {
175
+ return err?.code === 'EPERM';
176
+ }
177
+ }
178
+
145
179
  /**
146
180
  * Is `pgid` a value that may be NEGATED and handed to `kill(2)`?
147
181
  *
@@ -197,12 +231,12 @@ function isValidLedgerRecord(parsed) {
197
231
  * same binary with different arguments without copying a command line — which
198
232
  * may carry paths or tokens — into a ledger.
199
233
  *
200
- * @param {string} cmd Full command line.
234
+ * @param {string|string[]} cmd Full command line or argv.
201
235
  * @returns {string} e.g. `npm:6f1c4e0a9b2d7e35`. Empty input yields `:<hash of "">`.
202
236
  */
203
237
  export function buildCommandSignature(cmd) {
204
- const full = String(cmd ?? '');
205
- const firstToken = full.trim().split(/\s+/)[0] ?? '';
238
+ const full = Array.isArray(cmd) ? JSON.stringify(cmd) : String(cmd ?? '');
239
+ const firstToken = Array.isArray(cmd) ? (cmd[0] ?? '') : (full.trim().split(/\s+/)[0] ?? '');
206
240
  const hash = createHash('sha256').update(full, 'utf8').digest('hex').slice(0, 16);
207
241
  return `${firstToken}:${hash}`;
208
242
  }
@@ -242,12 +276,19 @@ export function installExitHandler() {
242
276
 
243
277
  process.once('exit', () => {
244
278
  killAllLiveGroupsSync();
279
+ runExitCleanupsSync();
245
280
  });
246
281
 
247
282
  for (const signal of EXIT_SIGNALS) {
248
283
  const handler = () => {
249
284
  killAllLiveGroupsSync();
250
285
  if (process.listenerCount(signal) === 0) {
286
+ // SIGKILL is sent, not awaited (#1723): a child can still write for a
287
+ // few milliseconds while the callbacks remove its files, so a callback
288
+ // that deletes a tree retries (snapshot-probe: removeTreeOnExit).
289
+ // Only on this branch: with a foreign listener the process may live on,
290
+ // and its own `exit` (if any) still runs the callbacks then.
291
+ runExitCleanupsSync();
251
292
  // We were the only listener: restore the default disposition so the
252
293
  // process still dies from the signal it was sent.
253
294
  process.kill(process.pid, signal);
@@ -257,6 +298,52 @@ export function installExitHandler() {
257
298
  }
258
299
  }
259
300
 
301
+ /**
302
+ * Register a synchronous callback that runs once when the process leaves via
303
+ * `exit` or via a SIGINT/SIGTERM/SIGHUP this module handles — after SIGKILL was
304
+ * sent to every live group (sent, not awaited: a child may still be writing
305
+ * for a few milliseconds, #1723), before the signal is re-raised. Installs the exit handler
306
+ * itself, so a process that never spawned a group is covered too.
307
+ *
308
+ * Use it for resources the caller created and would otherwise remove only in a
309
+ * `finally` that a signal skips (e.g. its own `mkdtemp` directory, #1715).
310
+ * Errors a callback throws are swallowed: the exit path must not throw.
311
+ *
312
+ * Named ceiling (BV-004): SIGKILL or any other uncatchable death runs no
313
+ * callback, and an async callback's continuation never runs (the process is
314
+ * already exiting). Revisit if a caller needs async teardown.
315
+ *
316
+ * @param {() => void} fn synchronous cleanup
317
+ * @returns {() => void} unregister — call it once the caller cleaned up itself
318
+ */
319
+ export function registerExitCleanup(fn) {
320
+ if (typeof fn !== 'function') throw new TypeError('registerExitCleanup: fn must be a function');
321
+ installExitHandler();
322
+ // A wrapper per registration: the same fn registered twice stays two entries.
323
+ const entry = () => fn();
324
+ EXIT_CLEANUPS.add(entry);
325
+ return () => {
326
+ EXIT_CLEANUPS.delete(entry);
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Run and drop every registered cleanup, each at most once, swallowing errors.
332
+ *
333
+ * @returns {void}
334
+ */
335
+ function runExitCleanupsSync() {
336
+ const entries = [...EXIT_CLEANUPS];
337
+ EXIT_CLEANUPS.clear();
338
+ for (const entry of entries) {
339
+ try {
340
+ entry();
341
+ } catch {
342
+ /* the exit path must never throw */
343
+ }
344
+ }
345
+ }
346
+
260
347
  /**
261
348
  * SIGKILL every registered group, synchronously, swallowing every error.
262
349
  * Deliberately not exported: it is the exit path, not part of the interface.
@@ -267,7 +354,10 @@ function killAllLiveGroupsSync() {
267
354
  for (const [pgid, entry] of LIVE_GROUPS) {
268
355
  if (!isSignalablePgid(pgid)) continue;
269
356
  try {
270
- entry.killFn(-pgid, 'SIGKILL');
357
+ if (entry.platform === 'win32') {
358
+ const result = killWindowsProcessTree(pgid, entry);
359
+ if (!result.ok) process.stderr.write(`process-group: Windows exit cleanup: ${result.error ?? `survivors ${result.survivors}`}\n`);
360
+ } else entry.killFn(-pgid, 'SIGKILL');
271
361
  } catch {
272
362
  /* already gone, or not ours — nothing to do at exit time */
273
363
  }
@@ -316,14 +406,20 @@ function killAllLiveGroupsSync() {
316
406
  * @param {number} pgid Process-group id (positive; the negation happens here).
317
407
  * @param {object} [opts]
318
408
  * @param {(target: number, signal: string) => unknown} [opts.killFn] Signal seam. Tests ALWAYS inject.
409
+ * @param {string} [opts.platform]
410
+ * @param {Function} [opts.spawnSyncFn]
411
+ * @param {Function} [opts.snapshotFn]
412
+ * @param {boolean} [opts.verifyTree] - Skip Windows CIM measurement for short-budget hooks.
413
+ * @param {object} [opts.child] - Owned ChildProcess (Windows identity).
414
+ * @param {number} [opts.spawnedAtMs] - Wall-clock timestamp immediately before spawn.
319
415
  * @param {number} [opts.killGraceMs]
320
416
  * @param {number} [opts.verifyWaitMs]
321
417
  * @param {(pid: number) => boolean} [opts.isAliveFn] Liveness probe on the group leader.
322
418
  * @param {(ms: number) => Promise<void>} [opts.sleepFn]
323
419
  * @param {((signal: string) => boolean|Promise<boolean>)|null} [opts.beforeSignal] Consulted
324
420
  * immediately before EVERY signal. Anything but `true` aborts the rest of the ladder.
325
- * @returns {Promise<{ok: boolean, signalsSent: string[], survivors: number[],
326
- * error: 'ESRCH'|'EPERM'|'invalid-pgid'|null, aborted: string|null}>}
421
+ * @returns {Promise<{ok: boolean, signalsSent: string[], survivors: number[]|null,
422
+ * error: string|null, aborted: string|null}>}
327
423
  * `signalsSent` records ATTEMPTS in order, including one that threw.
328
424
  */
329
425
  export async function killProcessGroup(pgid, {
@@ -333,6 +429,12 @@ export async function killProcessGroup(pgid, {
333
429
  isAliveFn = defaultIsAlive,
334
430
  sleepFn = defaultSleep,
335
431
  beforeSignal = null,
432
+ platform = process.platform,
433
+ spawnSyncFn,
434
+ snapshotFn,
435
+ verifyTree = true,
436
+ child,
437
+ spawnedAtMs,
336
438
  } = {}) {
337
439
  /** @type {string[]} */
338
440
  const signalsSent = [];
@@ -359,6 +461,11 @@ export async function killProcessGroup(pgid, {
359
461
  ok: false, signalsSent, survivors: [], error: null, aborted: signal,
360
462
  });
361
463
 
464
+ if (platform === 'win32') {
465
+ if (!(await permitted('taskkill'))) return aborted('taskkill');
466
+ return killWindowsProcessTree(pgid, { spawnSyncFn, snapshotFn, child, spawnedAtMs, verifyTree });
467
+ }
468
+
362
469
  if (!(await permitted('SIGTERM'))) return aborted('SIGTERM');
363
470
 
364
471
  signalsSent.push('SIGTERM');
@@ -438,14 +545,17 @@ export async function killProcessGroup(pgid, {
438
545
  * @param {object} [opts]
439
546
  * @param {{pid?: number, ppid?: number, rssKb?: number, etimeSeconds?: number|null, cpuPct?: number, args?: string|null}|null} [opts.snapshotLine]
440
547
  * @param {number} [opts.nowMs]
548
+ * @param {string} [opts.platform]
441
549
  * @param {number} [opts.toleranceMs=2000]
442
- * @returns {{match: boolean, reason: 'ok'|'gone'|'start-time-mismatch'|'signature-mismatch', observed: {etimeSeconds: number|null, args: string|null}}}
550
+ * @returns {{match: boolean, reason: string, observed: {etimeSeconds: number|null, args: string|null}}}
443
551
  */
444
552
  export function verifyProcessIdentity(pid, expected, {
445
553
  snapshotLine = null,
446
554
  nowMs = Date.now(),
447
555
  toleranceMs = 2000,
556
+ platform = process.platform,
448
557
  } = {}) {
558
+ if (platform === 'win32') return { match: false, reason: 'nicht messbar auf win32', observed: { etimeSeconds: null, args: null } };
449
559
  const gone = { match: false, reason: /** @type {const} */ ('gone'), observed: { etimeSeconds: null, args: null } };
450
560
  if (!snapshotLine || typeof snapshotLine !== 'object') return gone;
451
561
  // A row for a different PID describes a different process, not this one.
@@ -529,12 +639,20 @@ function readLedgerNoFollow(target) {
529
639
  * @param {string} repoRoot
530
640
  * @param {GateProcessRecord} record
531
641
  * @param {object} [opts]
642
+ * @param {string} [opts.platform]
532
643
  * @param {(filePath: string, line: string) => void} [opts.appendFn] Sink seam. When
533
644
  * supplied, the parent directory is NOT created — the caller owns its sink, and
534
645
  * mkdir on a synthetic repoRoot would materialise a directory in a test.
535
646
  * @returns {void}
536
647
  */
537
- export function recordGateProcess(repoRoot, record, { appendFn } = {}) {
648
+ export function recordGateProcess(repoRoot, record, { appendFn, platform = process.platform } = {}) {
649
+ // win32: no ledger. Its only reader is the orphan reaper, which is POSIX-only
650
+ // (identity checks need `ps`), and the no-follow append has no Windows
651
+ // equivalent (O_NOFOLLOW is undefined there). A warning here fired on EVERY
652
+ // gate run on Windows (measured 2026-10-14, #1612) — noise, not a signal.
653
+ // Readers still report `state: 'unreadable'` on win32, so nothing reads this
654
+ // absence as an empty ledger. Revisit when a Windows reaper exists.
655
+ if (platform === 'win32') return;
538
656
  const target = ledgerPathFor(repoRoot);
539
657
  try {
540
658
  if (!appendFn) mkdirSync(path.dirname(target), { recursive: true });
@@ -572,9 +690,10 @@ export function recordGateProcess(repoRoot, record, { appendFn } = {}) {
572
690
  * @param {(filePath: string) => string} [opts.readFn] Reader seam; must throw or
573
691
  * return '' for a missing file.
574
692
  * @param {number} [opts.nowMs]
693
+ * @param {string} [opts.platform]
575
694
  * @param {number} [opts.maxAgeMs]
576
695
  * @returns {{records: GateProcessRecord[], malformedLines: number, expired: number,
577
- * state: 'read'|'absent'|'unreadable'}}
696
+ * state: 'read'|'absent'|'unreadable', error?: string}}
578
697
  * `records` are the fresh, parseable entries in source order; empty unless
579
698
  * `state` is `read`.
580
699
  */
@@ -582,7 +701,9 @@ export function readGateProcessLedger(repoRoot, {
582
701
  readFn,
583
702
  nowMs = Date.now(),
584
703
  maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
704
+ platform = process.platform,
585
705
  } = {}) {
706
+ if (platform === 'win32') return { records: [], malformedLines: 0, expired: 0, state: 'unreadable', error: 'nicht messbar auf win32' };
586
707
  const target = ledgerPathFor(repoRoot);
587
708
  let raw;
588
709
  try {
@@ -691,6 +812,7 @@ export function replaceRegularFile(target, body, io = { lstatSync, writeFileSync
691
812
  * @param {string} repoRoot
692
813
  * @param {object} [opts]
693
814
  * @param {number} [opts.nowMs]
815
+ * @param {string} [opts.platform]
694
816
  * @param {number} [opts.maxAgeMs]
695
817
  * @param {{existsSync: Function, readFileSync: Function, writeFileSync: Function,
696
818
  * lstatSync: Function, renameSync: Function, unlinkSync: Function}} [opts.fs] fs seam;
@@ -701,8 +823,11 @@ export function replaceRegularFile(target, body, io = { lstatSync, writeFileSync
701
823
  export function pruneGateProcessLedger(repoRoot, {
702
824
  nowMs = Date.now(),
703
825
  maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
826
+ platform = process.platform,
704
827
  fs: fsSeam,
705
828
  } = {}) {
829
+ // win32 keeps no ledger (see recordGateProcess), so there is nothing to prune.
830
+ if (platform === 'win32') return 0;
706
831
  const io = fsSeam ?? {
707
832
  existsSync,
708
833
  readFileSync: readLedgerNoFollow,
@@ -778,12 +903,28 @@ export function pruneGateProcessLedger(repoRoot, {
778
903
  * later, at a pgid `finish()` already deregistered — the exact late signal at a
779
904
  * possibly-recycled group id this module exists to prevent.
780
905
  *
781
- * @param {string} cmd Full shell command. Executable configuration, not data —
906
+ * Normal-exit path (POSIX, #1704): `close` proves only that every member closed
907
+ * the PIPE — a member that redirected its stdio away (`sleep 30 >/dev/null 2>&1 &`)
908
+ * outlives a command that exited 0. On a normal close the GROUP is probed once
909
+ * (`kill(-pgid, 0)`); gone → resolve at once, no added latency. Still alive →
910
+ * the same ladder runs against it and the promise resolves only after it, so the
911
+ * report is accurate: added latency at most `killGraceMs + verifyWaitMs + 1000`
912
+ * (the same hard deadline). The finding goes into the additive `leftoverGroup`
913
+ * field — never into `survivors`/`killSignals`, whose consumers read them as
914
+ * "the run's own ladder fired / something outlived SIGKILL" (pre-push
915
+ * `cleanRerun`, the gate envelopes) — and pass/fail is NOT changed:
916
+ * measure first (HR-105), making it red is a later decision.
917
+ *
918
+ * @param {string|string[]} cmd Shell command or [executable, ...args]. Executable configuration, not data —
782
919
  * see `.claude/rules/security.md` § Session Config Command Trust.
783
920
  * @param {object} [opts]
784
921
  * @param {string} [opts.cwd]
785
922
  * @param {NodeJS.ProcessEnv} [opts.env]
786
923
  * @param {number|null} [opts.timeoutMs] null disables the clock (the byte cap still applies).
924
+ * @param {string} [opts.platform]
925
+ * @param {Function} [opts.spawnSyncFn]
926
+ * @param {Function} [opts.snapshotFn]
927
+ * @param {boolean} [opts.verifyTree] - Skip Windows CIM measurement for short-budget hooks.
787
928
  * @param {number} [opts.killGraceMs]
788
929
  * @param {number} [opts.verifyWaitMs]
789
930
  * @param {number} [opts.maxOutputBytes]
@@ -791,6 +932,10 @@ export function pruneGateProcessLedger(repoRoot, {
791
932
  * @param {(target: number, signal: string) => unknown} [opts.killFn] Tests ALWAYS inject.
792
933
  * @param {() => number} [opts.now]
793
934
  * @param {(pid: number) => boolean} [opts.isAliveFn]
935
+ * @param {((pgid: number) => boolean)|null} [opts.groupAliveFn] Group probe for the
936
+ * normal-exit path. Defaults to {@link defaultGroupAlive} ONLY while `killFn` is
937
+ * the real one; with an injected `killFn` and no injected probe the normal-exit
938
+ * probe is skipped, so a test's fake pid never reaches the real kernel.
794
939
  * @param {(ms: number) => Promise<void>} [opts.sleepFn]
795
940
  * @param {(record: GateProcessRecord) => void} [opts.onRegister] Defaults to
796
941
  * {@link recordGateProcess} when — and only when — `repoRoot` is given.
@@ -798,11 +943,14 @@ export function pruneGateProcessLedger(repoRoot, {
798
943
  * @param {string} [opts.commandSignature] Defaults to {@link buildCommandSignature}.
799
944
  * @param {string|null} [opts.sessionId]
800
945
  * @returns {Promise<{exitCode: number, output: string, fullOutput: string, timedOut: boolean,
801
- * overflow: boolean, killSignals: string[], survivors: number[], pid: number, pgid: number,
802
- * durationMs: number}>}
946
+ * overflow: boolean, killSignals: string[], survivors: number[]|null, pid: number, pgid: number,
947
+ * durationMs: number, ok?: boolean, error?: string|null,
948
+ * leftoverGroup: {pgid: number, signals: string[], survivors: number[]}|null}>}
803
949
  * `exitCode` is 124 on timeout, 1 on overflow or spawn failure, else the child's own code.
804
950
  * `output` is the last ~50 lines of `fullOutput`. `survivors` are PIDs still alive after
805
951
  * SIGKILL + `verifyWaitMs` — a non-empty list is a REPORTED failure, never a silent one.
952
+ * `leftoverGroup` is null unless the group was still alive after a NORMAL close; then it
953
+ * carries the signals sent to it and what survived them.
806
954
  */
807
955
  export function spawnInGroup(cmd, {
808
956
  cwd,
@@ -815,13 +963,19 @@ export function spawnInGroup(cmd, {
815
963
  killFn = defaultKill,
816
964
  now = Date.now,
817
965
  isAliveFn = defaultIsAlive,
966
+ groupAliveFn,
818
967
  sleepFn = defaultSleep,
819
968
  onRegister,
820
969
  repoRoot,
821
970
  commandSignature,
822
971
  sessionId = null,
972
+ platform = process.platform,
973
+ spawnSyncFn,
974
+ snapshotFn,
975
+ verifyTree = true,
823
976
  } = {}) {
824
977
  installExitHandler();
978
+ const groupAlive = groupAliveFn ?? (killFn === defaultKill ? defaultGroupAlive : null);
825
979
 
826
980
  return new Promise((resolve) => {
827
981
  const startedAt = now();
@@ -832,13 +986,22 @@ export function spawnInGroup(cmd, {
832
986
  /** @type {string[]} */
833
987
  const killSignals = [];
834
988
  let fullOutput = '';
989
+ const stdoutDecoder = new StringDecoder('utf8');
990
+ const stderrDecoder = new StringDecoder('utf8');
991
+ let cleanupError = null;
835
992
  let capturedBytes = 0;
836
993
  let timedOut = false;
837
994
  let overflow = false;
838
995
  let settled = false;
839
996
  let ladderStarted = false;
840
- /** @type {number[]} */
997
+ // The signal that ended the child, from `close` (#1673): `exitCode` maps a
998
+ // signal end to 1, so without this field it reads like a normal failure.
999
+ /** @type {string|null} */
1000
+ let exitSignal = null;
1001
+ /** @type {number[]|null} */
841
1002
  let survivors = [];
1003
+ /** @type {{pgid: number, signals: string[], survivors: number[]}|null} */
1004
+ let leftoverGroup = null;
842
1005
  /** @type {ReturnType<typeof setTimeout>|null} */
843
1006
  let timeoutTimer = null;
844
1007
  /** @type {ReturnType<typeof setTimeout>|null} */
@@ -847,6 +1010,7 @@ export function spawnInGroup(cmd, {
847
1010
  const finish = (childExitCode) => {
848
1011
  if (settled) return;
849
1012
  settled = true;
1013
+ fullOutput += stdoutDecoder.end() + stderrDecoder.end();
850
1014
  if (timeoutTimer) clearTimeout(timeoutTimer);
851
1015
  if (deadlineTimer) clearTimeout(deadlineTimer);
852
1016
  if (pgid !== null) LIVE_GROUPS.delete(pgid);
@@ -862,25 +1026,28 @@ export function spawnInGroup(cmd, {
862
1026
  timedOut,
863
1027
  overflow,
864
1028
  killSignals,
1029
+ exitSignal,
865
1030
  survivors,
1031
+ leftoverGroup,
866
1032
  pid: pid ?? -1,
867
1033
  pgid: pgid ?? -1,
868
1034
  durationMs: now() - startedAt,
1035
+ ...(platform === 'win32' && ladderStarted ? { ok: cleanupError === null && survivors?.length === 0, error: cleanupError } : {}),
869
1036
  });
870
1037
  };
871
1038
 
872
1039
  let child;
1040
+ let spawnedAtMs;
873
1041
  try {
874
- // The command is trusted configuration; `shell: true` is intentional.
875
- // `detached: true` is what makes this a GROUP leader — the whole point.
876
- // nosemgrep: unsafe-shell-spawn
877
- child = spawnFn(cmd, {
878
- cwd,
879
- env,
880
- shell: true,
881
- detached: true,
1042
+ // Configured strings keep shell semantics; internal argv bypasses quoting.
1043
+ const options = {
1044
+ cwd, env, shell: !Array.isArray(cmd),
1045
+ ...(platform === 'win32' ? { windowsHide: true } : { detached: true }),
882
1046
  stdio: ['ignore', 'pipe', 'pipe'],
883
- });
1047
+ };
1048
+ spawnedAtMs = Date.now();
1049
+ // nosemgrep: unsafe-shell-spawn
1050
+ child = Array.isArray(cmd) ? spawnFn(cmd[0], cmd.slice(1), options) : spawnFn(cmd, options);
884
1051
  } catch (err) {
885
1052
  fullOutput = `process-group: failed to spawn "${cmd}": ${err?.message ?? String(err)}`;
886
1053
  finish(1);
@@ -893,7 +1060,7 @@ export function spawnInGroup(cmd, {
893
1060
  pgid = pid;
894
1061
 
895
1062
  if (pgid !== null) {
896
- LIVE_GROUPS.set(pgid, { killFn });
1063
+ LIVE_GROUPS.set(pgid, { killFn, platform, spawnSyncFn, snapshotFn, child, spawnedAtMs, verifyTree });
897
1064
  /** @type {GateProcessRecord} */
898
1065
  const record = {
899
1066
  pid,
@@ -903,7 +1070,7 @@ export function spawnInGroup(cmd, {
903
1070
  sessionId: sessionId ?? null,
904
1071
  recordedAt: new Date(startedAt).toISOString(),
905
1072
  };
906
- const register = onRegister ?? (repoRoot ? (r) => recordGateProcess(repoRoot, r) : null);
1073
+ const register = onRegister ?? (repoRoot ? (r) => recordGateProcess(repoRoot, r, { platform }) : null);
907
1074
  if (register) {
908
1075
  try {
909
1076
  register(record);
@@ -921,7 +1088,7 @@ export function spawnInGroup(cmd, {
921
1088
  // for.
922
1089
  if (!onRegister && repoRoot) {
923
1090
  try {
924
- pruneGateProcessLedger(repoRoot);
1091
+ pruneGateProcessLedger(repoRoot, { platform });
925
1092
  } catch {
926
1093
  /* housekeeping, never a gate precondition */
927
1094
  }
@@ -943,13 +1110,14 @@ export function spawnInGroup(cmd, {
943
1110
  // Hard deadline: `close` waits for EVERY group member to close the shared
944
1111
  // pipe, so one survivor would hang this promise forever.
945
1112
  deadlineTimer = setTimeout(() => {
946
- if (survivors.length === 0 && pgid !== null) survivors = [pgid];
1113
+ if (survivors?.length === 0 && pgid !== null) survivors = [pgid];
947
1114
  finish(null);
948
1115
  }, killGraceMs + verifyWaitMs + 1000);
949
1116
  deadlineTimer.unref?.();
950
1117
 
951
1118
  killProcessGroup(pgid, {
952
1119
  killFn: trackingKill,
1120
+ platform, spawnSyncFn, snapshotFn, child, spawnedAtMs, verifyTree,
953
1121
  killGraceMs,
954
1122
  verifyWaitMs,
955
1123
  isAliveFn,
@@ -962,17 +1130,67 @@ export function spawnInGroup(cmd, {
962
1130
  })
963
1131
  .then((res) => {
964
1132
  survivors = res.survivors;
1133
+ if (platform === 'win32') {
1134
+ killSignals.push(...res.signalsSent);
1135
+ cleanupError = res.error ?? (res.aborted ? `aborted: ${res.aborted}` : null);
1136
+ if (cleanupError) fullOutput += `\nprocess-group: ${cleanupError}\n`;
1137
+ finish(null);
1138
+ }
965
1139
  if (!res.ok) finish(null);
966
1140
  })
967
- .catch(() => {
1141
+ .catch((err) => {
1142
+ if (platform === 'win32') {
1143
+ survivors = null;
1144
+ cleanupError = `nicht messbar auf win32: ${err}`;
1145
+ fullOutput += `\nprocess-group: ${cleanupError}\n`;
1146
+ }
968
1147
  finish(null);
969
1148
  });
970
1149
  };
971
1150
 
972
- const onChunk = (chunk) => {
1151
+ // Normal-exit leftover (#1704): the leader closed, but the group still has a
1152
+ // member. Same ladder, same `beforeSignal` gate and hard deadline as
1153
+ // `runLadder`, but its own record: the run itself stays a normal exit.
1154
+ const reapLeftoverGroup = (childExitCode) => {
1155
+ // Marks the group as being reaped, so a timeout firing during the grace
1156
+ // window cannot start a second ladder (or flip the run to 124).
1157
+ ladderStarted = true;
1158
+ if (timeoutTimer) clearTimeout(timeoutTimer);
1159
+ const record = { pgid, signals: [], survivors: [] };
1160
+ leftoverGroup = record;
1161
+ deadlineTimer = setTimeout(() => {
1162
+ if (record.survivors.length === 0) record.survivors = [pgid];
1163
+ finish(childExitCode);
1164
+ }, killGraceMs + verifyWaitMs + 1000);
1165
+ deadlineTimer.unref?.();
1166
+
1167
+ killProcessGroup(pgid, {
1168
+ killFn: (target, signal) => {
1169
+ record.signals.push(signal);
1170
+ return killFn(target, signal);
1171
+ },
1172
+ platform, killGraceMs, verifyWaitMs, sleepFn,
1173
+ // The leader is already reaped, so the leader-pid probe would read the
1174
+ // group as dead; verify on the GROUP instead.
1175
+ isAliveFn: groupAlive,
1176
+ // Settled only via the hard deadline here; after that the pgid is
1177
+ // deregistered and may be recycled, so no further signal (see runLadder).
1178
+ beforeSignal: () => !settled,
1179
+ })
1180
+ .then((res) => {
1181
+ if (!settled) record.survivors = Array.isArray(res.survivors) ? [...res.survivors] : [pgid];
1182
+ })
1183
+ .catch(() => {
1184
+ if (!settled) record.survivors = [pgid];
1185
+ })
1186
+ .finally(() => finish(childExitCode));
1187
+ };
1188
+
1189
+ const onChunk = (decoder, chunk) => {
973
1190
  if (overflow) return;
974
- const text = chunk.toString();
975
- capturedBytes += Buffer.byteLength(text, 'utf8');
1191
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1192
+ const text = decoder.write(bytes);
1193
+ capturedBytes += bytes.length;
976
1194
  fullOutput += text;
977
1195
  if (capturedBytes > maxOutputBytes) {
978
1196
  overflow = true;
@@ -980,8 +1198,8 @@ export function spawnInGroup(cmd, {
980
1198
  runLadder();
981
1199
  }
982
1200
  };
983
- child.stdout?.on('data', onChunk);
984
- child.stderr?.on('data', onChunk);
1201
+ child.stdout?.on('data', (chunk) => onChunk(stdoutDecoder, chunk));
1202
+ child.stderr?.on('data', (chunk) => onChunk(stderrDecoder, chunk));
985
1203
 
986
1204
  if (typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0) {
987
1205
  timeoutTimer = setTimeout(() => {
@@ -995,7 +1213,25 @@ export function spawnInGroup(cmd, {
995
1213
  fullOutput += `\nprocess-group: child error: ${err?.message ?? String(err)}`;
996
1214
  finish(1);
997
1215
  });
998
- child.on('close', (code) => finish(typeof code === 'number' ? code : null));
1216
+ child.on('close', (code, signal) => {
1217
+ exitSignal = typeof signal === 'string' ? signal : null;
1218
+ // Windows cleanup must finish readback even if taskkill already closed pipes.
1219
+ if (platform === 'win32' && ladderStarted) return;
1220
+ const childExitCode = typeof code === 'number' ? code : null;
1221
+ // POSIX only: Windows has no process group to probe (its tree cleanup is
1222
+ // a separate path) and keeps its behaviour. One synchronous probe — when
1223
+ // the group is gone, the caller pays nothing extra.
1224
+ // Named ceiling (BV-004): the probe runs right after the leader was
1225
+ // reaped; a pgid recycled onto a foreign session leader inside that gap
1226
+ // would be probed and laddered. Needs the pid space to wrap within
1227
+ // milliseconds; revisit with an identity check (verifyProcessIdentity on
1228
+ // a group member) if a leftover is ever reported for a foreign command.
1229
+ if (!settled && !ladderStarted && platform !== 'win32' && pgid !== null && groupAlive?.(pgid) === true) {
1230
+ reapLeftoverGroup(childExitCode);
1231
+ return;
1232
+ }
1233
+ finish(childExitCode);
1234
+ });
999
1235
  });
1000
1236
  }
1001
1237
 
@@ -796,6 +796,31 @@ export function checkEnvDocumentation(repoRoot) {
796
796
  };
797
797
  }
798
798
 
799
+ /** The session ledger the orchestrator appends to on every close. */
800
+ const SESSIONS_LEDGER_REL = '.orchestrator/metrics/sessions.jsonl';
801
+
802
+ /**
803
+ * H6 — Tracked session ledger (#1666).
804
+ *
805
+ * A repo that TRACKS `.orchestrator/metrics/sessions.jsonl` gets one commit-worthy
806
+ * diff per session close, and every linked worktree checks out its own copy —
807
+ * where foreign records landed in #1660. The hint is text only; nothing here
808
+ * runs it. `fixable: false`: untracking changes what the repo shares with
809
+ * every clone, an owner decision, not batch work.
810
+ *
811
+ * @param {string} repoRoot
812
+ * @returns {object|null}
813
+ */
814
+ export function checkTrackedSessionsLedger(repoRoot) {
815
+ // `--error-unmatch` exits non-zero (→ null) when the path is not tracked.
816
+ if (git(['ls-files', '--error-unmatch', '--', SESSIONS_LEDGER_REL], repoRoot) === null) return null;
817
+ return {
818
+ check: 'tracked-sessions-ledger',
819
+ fixable: false,
820
+ message: `${SESSIONS_LEDGER_REL} is tracked in git — every session close dirties the tree and each worktree carries its own copy; untrack it: \`git rm --cached ${SESSIONS_LEDGER_REL}\` and add it to .gitignore`,
821
+ };
822
+ }
823
+
799
824
  /**
800
825
  * Aggregate the project-hygiene family into one banner result.
801
826
  *
@@ -831,6 +856,9 @@ export function checkProjectHygiene({ repoRoot, now = Date.now(), thresholds = {
831
856
 
832
857
  const env = checkEnvDocumentation(repoRoot);
833
858
  if (env) findings.push(env);
859
+
860
+ const ledger = checkTrackedSessionsLedger(repoRoot);
861
+ if (ledger) findings.push(ledger);
834
862
  } catch {
835
863
  // Probe family is advisory — a partial result beats a crashed session-start.
836
864
  if (findings.length === 0) return null;