session-orchestrator 5.12.0 → 5.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (385) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +4 -1
  3. package/.codex-plugin/agents/explorer.toml +1 -1
  4. package/.codex-plugin/agents/session-reviewer.toml +2 -2
  5. package/.codex-plugin/agents/wave-worker.toml +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/.orchestrator/policy/blocked-commands.json +8 -0
  9. package/.orchestrator/policy/public-export.json +30 -0
  10. package/CHANGELOG.md +162 -4
  11. package/README.md +126 -127
  12. package/agents/analyst.md +7 -2
  13. package/agents/architect-reviewer.md +7 -2
  14. package/agents/code-implementer.md +4 -2
  15. package/agents/db-specialist.md +9 -2
  16. package/agents/dialectic-deriver.md +9 -3
  17. package/agents/docs-writer.md +7 -2
  18. package/agents/eval-judge.md +7 -1
  19. package/agents/qa-strategist.md +7 -2
  20. package/agents/security-reviewer.md +9 -2
  21. package/agents/session-reviewer.md +18 -2
  22. package/agents/skill-applied-judge.md +7 -1
  23. package/agents/test-writer.md +9 -2
  24. package/agents/ui-developer.md +9 -2
  25. package/agents/ux-evaluator.md +6 -1
  26. package/commands/session.md +1 -1
  27. package/commands/templates-ack.md +89 -18
  28. package/docs/agent-authoring.md +30 -11
  29. package/docs/ci-setup.md +38 -2
  30. package/docs/codex-setup.md +33 -9
  31. package/docs/components.md +13 -2
  32. package/docs/cursor-setup.md +3 -3
  33. package/docs/events-schema.md +5 -4
  34. package/docs/evidence-candidates.md +63 -0
  35. package/docs/fleet-checkin-audit.md +7 -4
  36. package/docs/github-mirror-protection.md +150 -3
  37. package/docs/install.md +90 -17
  38. package/docs/instruction-delivery.md +1 -1
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/mods.md +118 -0
  41. package/docs/owner-config-schema.md +1 -0
  42. package/docs/plugin-architecture-v3.md +1 -1
  43. package/docs/pm-skills-marketplace.md +4 -3
  44. package/docs/rule-authoring.md +24 -13
  45. package/docs/scope-collision-guard.md +2 -1
  46. package/docs/session-config-reference.md +49 -15
  47. package/docs/session-config-template.md +30 -22
  48. package/docs/sessions-ledger-repair.md +137 -0
  49. package/docs/vault-docs-architecture.md +1 -1
  50. package/hooks/_lib/hook-import-set.json +406 -73
  51. package/hooks/_lib/hook-root.mjs +301 -0
  52. package/hooks/_lib/lock-bootstrap.mjs +434 -4
  53. package/hooks/_lib/orchestrator-ignore.mjs +3 -0
  54. package/hooks/_lib/subagent-transcript.mjs +2 -2
  55. package/hooks/_lib/vcs-create-matcher.mjs +13 -17
  56. package/hooks/cwd-change-restore.mjs +5 -1
  57. package/hooks/enforce-commands.mjs +4 -3
  58. package/hooks/enforce-scope.mjs +11 -1
  59. package/hooks/hooks-codex.json +48 -2
  60. package/hooks/hooks.json +31 -31
  61. package/hooks/on-session-end.mjs +38 -15
  62. package/hooks/on-session-start.mjs +139 -49
  63. package/hooks/on-stop.mjs +11 -6
  64. package/hooks/post-bash-issue-budget-refund.mjs +6 -1
  65. package/hooks/post-bash-write-verify.mjs +8 -4
  66. package/hooks/post-edit-validate.mjs +45 -52
  67. package/hooks/post-subagent-discovery-validator.mjs +2 -2
  68. package/hooks/post-tool-batch-wave-signal.mjs +8 -4
  69. package/hooks/post-tool-failure-corrective-context.mjs +5 -1
  70. package/hooks/pre-bash-destructive-guard.mjs +220 -8
  71. package/hooks/pre-bash-issue-budget.mjs +6 -1
  72. package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
  73. package/hooks/pre-bash-staging-fence.mjs +258 -14
  74. package/hooks/pre-bash-templates-first.mjs +28 -27
  75. package/hooks/run-node.sh +1 -1
  76. package/hooks/skill-invocation-telemetry.mjs +15 -8
  77. package/hooks/subagent-telemetry.mjs +77 -14
  78. package/hooks/wave-scope-commit-guard.mjs +143 -14
  79. package/mods/so-hud/.claude-plugin/plugin.json +11 -0
  80. package/mods/so-hud/README.md +57 -0
  81. package/mods/so-hud/hooks/beacon-io.mjs +101 -0
  82. package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
  83. package/mods/so-hud/hooks/hooks.json +1 -0
  84. package/mods/so-hud/hooks/logic.ts +182 -0
  85. package/mods/so-hud/hooks/register.tsx +670 -0
  86. package/mods/so-hud/hooks/rules.ts +2998 -0
  87. package/mods/so-hud/tsconfig.json +8 -0
  88. package/mods/so-hud/types/index.d.ts +36 -0
  89. package/package.json +4 -2
  90. package/rules/_index.md +1 -1
  91. package/rules/always-on/ask-via-tool.md +1 -1
  92. package/rules/always-on/loop-and-monitor.md +17 -194
  93. package/rules/always-on/parallel-sessions.md +3 -1
  94. package/rules/opt-in-stack/backend-data.md +1 -1
  95. package/rules/opt-in-stack/backend.md +1 -1
  96. package/rules/opt-in-stack/security-web.md +5 -1
  97. package/scripts/agent-usage-census.mjs +79 -0
  98. package/scripts/apply-session-learnings.mjs +34 -6
  99. package/scripts/archive-closed-prds.mjs +21 -5
  100. package/scripts/autopilot.mjs +11 -5
  101. package/scripts/backfill-abandoned-sessions.mjs +13 -3
  102. package/scripts/backfill-evidence-digest.mjs +2 -1
  103. package/scripts/backfill-learnings-from-vault.mjs +61 -6
  104. package/scripts/backfill-sessions.mjs +16 -2
  105. package/scripts/check-package-manager.mjs +26 -8
  106. package/scripts/check-sessions-integrity.mjs +4 -1
  107. package/scripts/ci/assert-vitest-green.mjs +3 -0
  108. package/scripts/ci/windows-smoke.mjs +201 -0
  109. package/scripts/close-precheck.mjs +55 -0
  110. package/scripts/dialectic-deriver.mjs +20 -12
  111. package/scripts/emit-event.mjs +2 -2
  112. package/scripts/emit-session.mjs +239 -20
  113. package/scripts/eval-session.mjs +17 -3
  114. package/scripts/export-hw-learnings.mjs +29 -2
  115. package/scripts/fleet-checkin-audit.mjs +8 -4
  116. package/scripts/fleet-checkin.mjs +39 -27
  117. package/scripts/gc-stale-worktrees.mjs +6 -1
  118. package/scripts/github-export.mjs +128 -0
  119. package/scripts/github-protection-audit.mjs +3 -1
  120. package/scripts/handover-queue-check.mjs +209 -0
  121. package/scripts/harness-audit.mjs +6 -4
  122. package/scripts/lib/agent-frontmatter.mjs +57 -10
  123. package/scripts/lib/agent-status.mjs +125 -7
  124. package/scripts/lib/agent-usage-census.mjs +394 -0
  125. package/scripts/lib/auto-dialectic.mjs +17 -10
  126. package/scripts/lib/auto-dream.mjs +22 -14
  127. package/scripts/lib/autopilot/loop.mjs +3 -1
  128. package/scripts/lib/autopilot/recent-runs.mjs +4 -2
  129. package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
  130. package/scripts/lib/autopilot/telemetry.mjs +5 -1
  131. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  132. package/scripts/lib/baseline-archetypes.mjs +10 -2
  133. package/scripts/lib/blocked-commands-policy.mjs +4 -3
  134. package/scripts/lib/build-live-signals.mjs +13 -9
  135. package/scripts/lib/ci-status-banner.mjs +329 -24
  136. package/scripts/lib/codex/plugin-contract.mjs +8 -0
  137. package/scripts/lib/cold-start-detector.mjs +5 -2
  138. package/scripts/lib/command-blocker.mjs +603 -43
  139. package/scripts/lib/common.mjs +3 -2
  140. package/scripts/lib/config/cross-repo.mjs +74 -7
  141. package/scripts/lib/config/host-paths.mjs +6 -2
  142. package/scripts/lib/config/section-extractor.mjs +65 -11
  143. package/scripts/lib/config/template-keys.mjs +153 -0
  144. package/scripts/lib/config/vault-integration.mjs +1 -0
  145. package/scripts/lib/config.mjs +2 -2
  146. package/scripts/lib/coordinator-snapshot.mjs +1 -1
  147. package/scripts/lib/discovery/triage-state.mjs +2 -1
  148. package/scripts/lib/dispatcher/rank.mjs +5 -7
  149. package/scripts/lib/ecosystem-health.mjs +2 -2
  150. package/scripts/lib/eval/engine.mjs +3 -2
  151. package/scripts/lib/eval/session-resolve.mjs +6 -2
  152. package/scripts/lib/eval/sink.mjs +4 -3
  153. package/scripts/lib/events-retention-banner.mjs +3 -2
  154. package/scripts/lib/events-schema.mjs +10 -0
  155. package/scripts/lib/events.mjs +23 -12
  156. package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
  157. package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
  158. package/scripts/lib/fleet-protocol.mjs +4 -0
  159. package/scripts/lib/gates/gate-full.mjs +172 -11
  160. package/scripts/lib/gates/gate-helpers.mjs +236 -23
  161. package/scripts/lib/git-config-drift.mjs +141 -7
  162. package/scripts/lib/github-export.mjs +447 -0
  163. package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
  164. package/scripts/lib/handover-queue-marks.mjs +159 -0
  165. package/scripts/lib/hardware-pattern-detector.mjs +103 -13
  166. package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
  167. package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
  168. package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
  169. package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
  170. package/scripts/lib/host-identity.mjs +9 -5
  171. package/scripts/lib/http-request.mjs +64 -0
  172. package/scripts/lib/instruction-budget-guard.mjs +30 -22
  173. package/scripts/lib/io.mjs +6 -3
  174. package/scripts/lib/issue-budget-reconcile.mjs +3 -2
  175. package/scripts/lib/issue-budget.mjs +36 -7
  176. package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
  177. package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
  178. package/scripts/lib/learnings/io.mjs +289 -15
  179. package/scripts/lib/learnings/select.mjs +53 -4
  180. package/scripts/lib/learnings/store-role.mjs +209 -0
  181. package/scripts/lib/learnings/surface.mjs +46 -7
  182. package/scripts/lib/ledger-path.mjs +113 -0
  183. package/scripts/lib/maintenance-due-banner.mjs +63 -12
  184. package/scripts/lib/memory-banner.mjs +5 -6
  185. package/scripts/lib/memory-proposals/collector.mjs +5 -2
  186. package/scripts/lib/memory-proposals/sink.mjs +7 -4
  187. package/scripts/lib/memory-proposals/store.mjs +22 -5
  188. package/scripts/lib/mode-selector-accuracy.mjs +2 -1
  189. package/scripts/lib/no-follow-open.mjs +57 -0
  190. package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
  191. package/scripts/lib/own-lock-root.mjs +95 -0
  192. package/scripts/lib/owner-interview.mjs +2 -1
  193. package/scripts/lib/owner-yaml.mjs +70 -7
  194. package/scripts/lib/platform.mjs +161 -9
  195. package/scripts/lib/plugin-update-banner.mjs +15 -11
  196. package/scripts/lib/pre-push-local-partial.mjs +117 -0
  197. package/scripts/lib/pre-push-remote-gate.mjs +151 -25
  198. package/scripts/lib/process-group.mjs +270 -34
  199. package/scripts/lib/project-hygiene.mjs +28 -0
  200. package/scripts/lib/quality-gate.mjs +33 -9
  201. package/scripts/lib/reconcile/backlog.mjs +29 -15
  202. package/scripts/lib/reconcile/engine.mjs +26 -2
  203. package/scripts/lib/reconcile/idempotency.mjs +65 -3
  204. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
  205. package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
  206. package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
  207. package/scripts/lib/resource-probe/evaluate.mjs +32 -9
  208. package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
  209. package/scripts/lib/rule-loader.mjs +20 -10
  210. package/scripts/lib/scope-baseline.mjs +123 -8
  211. package/scripts/lib/scope-echo.mjs +5 -2
  212. package/scripts/lib/scope-gate.mjs +55 -20
  213. package/scripts/lib/session-close-backfill.mjs +75 -7
  214. package/scripts/lib/session-end/close-precheck.mjs +151 -0
  215. package/scripts/lib/session-end/phase-skip.mjs +8 -7
  216. package/scripts/lib/session-end/tail-runner.mjs +7 -2
  217. package/scripts/lib/session-id.mjs +9 -1
  218. package/scripts/lib/session-identity/own-session.mjs +63 -0
  219. package/scripts/lib/session-lock.mjs +114 -0
  220. package/scripts/lib/session-record-repair.mjs +245 -35
  221. package/scripts/lib/session-registry.mjs +72 -9
  222. package/scripts/lib/session-schema/constants.mjs +9 -0
  223. package/scripts/lib/session-schema/filters.mjs +56 -1
  224. package/scripts/lib/session-schema/validator.mjs +15 -5
  225. package/scripts/lib/session-start-probes.mjs +149 -22
  226. package/scripts/lib/session-token-rollup.mjs +2 -1
  227. package/scripts/lib/session-work-trees.mjs +166 -0
  228. package/scripts/lib/sessions-canonical.mjs +61 -3
  229. package/scripts/lib/sessions-integrity-banner.mjs +17 -39
  230. package/scripts/lib/sessions-staleness-banner.mjs +8 -5
  231. package/scripts/lib/sh-quote.mjs +1 -0
  232. package/scripts/lib/skill-evidence-window.mjs +5 -1
  233. package/scripts/lib/skill-health/join.mjs +10 -9
  234. package/scripts/lib/skill-invocations-schema.mjs +13 -0
  235. package/scripts/lib/skill-judgments-schema.mjs +6 -2
  236. package/scripts/lib/snapshot-probe.mjs +414 -0
  237. package/scripts/lib/so-hud/collect.mjs +49 -5
  238. package/scripts/lib/so-hud/runtime.mjs +15 -4
  239. package/scripts/lib/spiral-carryover.mjs +8 -1
  240. package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
  241. package/scripts/lib/state-md/idle-reset.mjs +14 -5
  242. package/scripts/lib/state-md/mission-status.mjs +5 -1
  243. package/scripts/lib/state-md-peer-guard.mjs +2 -0
  244. package/scripts/lib/state-md.mjs +31 -8
  245. package/scripts/lib/sunset/walker.mjs +4 -3
  246. package/scripts/lib/telemetry/consent.mjs +4 -2
  247. package/scripts/lib/telemetry/queue.mjs +3 -3
  248. package/scripts/lib/telemetry/sync.mjs +45 -9
  249. package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
  250. package/scripts/lib/tmux-layout/layouts.mjs +20 -3
  251. package/scripts/lib/ux-grill/paths.mjs +5 -1
  252. package/scripts/lib/validate/check-agents.mjs +37 -6
  253. package/scripts/lib/validate/check-component-paths.mjs +25 -1
  254. package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
  255. package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
  256. package/scripts/lib/validate/check-json-files.mjs +3 -1
  257. package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
  258. package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
  259. package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
  260. package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
  261. package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
  262. package/scripts/lib/validate/check-unwired-features.mjs +4 -2
  263. package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
  264. package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
  265. package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
  266. package/scripts/lib/validate/confidential-names.mjs +3 -1
  267. package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
  268. package/scripts/lib/vault-mirror/namespace.mjs +19 -7
  269. package/scripts/lib/vault-mirror/process.mjs +56 -27
  270. package/scripts/lib/vault-mirror/prune.mjs +5 -2
  271. package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
  272. package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
  273. package/scripts/lib/vault-mirror/retention.mjs +2 -2
  274. package/scripts/lib/vault-mirror/utils.mjs +79 -0
  275. package/scripts/lib/vault-staleness-banner.mjs +4 -7
  276. package/scripts/lib/vault-status/board-writer.mjs +2 -1
  277. package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
  278. package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
  279. package/scripts/lib/win-process-tree.mjs +137 -0
  280. package/scripts/lib/workspace.mjs +1 -1
  281. package/scripts/lib/worktree/constants.mjs +1 -1
  282. package/scripts/lib/worktree/lifecycle.mjs +1 -1
  283. package/scripts/lib/worktree/listing.mjs +1 -1
  284. package/scripts/lib/zx.mjs +50 -0
  285. package/scripts/mcp-server.mjs +16 -7
  286. package/scripts/mcp-server.sh +13 -83
  287. package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
  288. package/scripts/memory-propose.mjs +10 -3
  289. package/scripts/migrate-cold-start-seed.mjs +7 -0
  290. package/scripts/migrate-legacy-learnings.sh +22 -0
  291. package/scripts/migrate-sessions-jsonl.mjs +16 -2
  292. package/scripts/migrate-vault-paths.mjs +29 -13
  293. package/scripts/parse-config.mjs +71 -14
  294. package/scripts/print-learnings-index.mjs +25 -2
  295. package/scripts/release-session-lock.mjs +94 -7
  296. package/scripts/release.mjs +451 -24
  297. package/scripts/relocate-vault-corpus.mjs +11 -7
  298. package/scripts/repair-invalid-sessions.mjs +68 -3
  299. package/scripts/resolve-session-invocation.mjs +10 -17
  300. package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
  301. package/scripts/run-quality-gate.mjs +33 -7
  302. package/scripts/self-update.mjs +56 -7
  303. package/scripts/session-commit-files.mjs +133 -0
  304. package/scripts/site-numbers.mjs +52 -9
  305. package/scripts/snapshot-probe.mjs +156 -0
  306. package/scripts/so-hud.mjs +29 -6
  307. package/scripts/state-md-idle-reset.mjs +2 -1
  308. package/scripts/sweep-expired-learnings.mjs +54 -6
  309. package/scripts/sweep-expired-rules.mjs +4 -1
  310. package/scripts/validate-plugin.mjs +22 -0
  311. package/scripts/vault-integration-watcher.mjs +54 -6
  312. package/scripts/vault-mirror.mjs +9 -0
  313. package/skills/_shared/fleet-protocol.md +142 -1
  314. package/skills/_shared/instruction-file-resolution.md +1 -1
  315. package/skills/_shared/monitor-patterns.md +44 -25
  316. package/skills/_shared/platform-tools.md +7 -0
  317. package/skills/_shared/state-ownership.md +3 -2
  318. package/skills/architecture/references/domain-model.md +1 -1
  319. package/skills/bootstrap/SKILL.md +3 -1
  320. package/skills/bootstrap/_shared-template.md +4 -4
  321. package/skills/bootstrap/fast-template.md +1 -1
  322. package/skills/bootstrap/private-contract.md +1 -1
  323. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
  324. package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
  325. package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
  326. package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
  327. package/skills/claude-md-drift-check/SKILL.md +3 -3
  328. package/skills/claude-md-drift-check/checker.mjs +61 -101
  329. package/skills/close/SKILL.md +20 -7
  330. package/skills/discovery/probes/docs-staleness.mjs +7 -5
  331. package/skills/discovery/probes/frontend-slop.mjs +7 -3
  332. package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
  333. package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
  334. package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
  335. package/skills/discovery/probes/vault-staleness.mjs +5 -3
  336. package/skills/discovery/probes-session.md +3 -0
  337. package/skills/discovery/probes-vault.md +2 -0
  338. package/skills/dispatcher/SKILL.md +1 -1
  339. package/skills/eval/SKILL.md +3 -1
  340. package/skills/evolve/SKILL.md +2 -0
  341. package/skills/evolve/references/evolve-analyze-mode.md +3 -3
  342. package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
  343. package/skills/gitlab-ops/SKILL.md +7 -6
  344. package/skills/gitlab-portfolio/SKILL.md +2 -2
  345. package/skills/go/SKILL.md +13 -1
  346. package/skills/npm-publish/SKILL.md +5 -2
  347. package/skills/plan/SKILL.md +23 -10
  348. package/skills/plan/mode-feature.md +13 -8
  349. package/skills/plan/mode-new.md +6 -14
  350. package/skills/plan/prd-feature-template.md +19 -0
  351. package/skills/plan/prd-full-template.md +12 -5
  352. package/skills/plan/prd-premortem-panel.md +48 -0
  353. package/skills/plan/prd-reviewer-prompt.md +11 -3
  354. package/skills/plan/soul.md +1 -1
  355. package/skills/portfolio/SKILL.md +5 -6
  356. package/skills/quality-gates/SKILL.md +2 -4
  357. package/skills/reconcile/SKILL.md +27 -4
  358. package/skills/release/SKILL.md +16 -8
  359. package/skills/session-end/SKILL.md +94 -11
  360. package/skills/session-end/phase-3-2-docs-verification.md +1 -0
  361. package/skills/session-end/phase-3-6-tail.md +11 -7
  362. package/skills/session-end/phase-3-7a-recommendations.md +9 -2
  363. package/skills/session-end/plan-verification.md +16 -1
  364. package/skills/session-end/references/phase-3-documentation-updates.md +14 -12
  365. package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
  366. package/skills/session-end/references/session-summary-template.md +4 -0
  367. package/skills/session-end/session-metrics-write.md +7 -4
  368. package/skills/session-plan/SKILL.md +5 -3
  369. package/skills/session-start/SKILL.md +9 -3
  370. package/skills/session-start/phase-4-5-resource-health.md +11 -5
  371. package/skills/session-start/phase-8-5-express-path.md +4 -3
  372. package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
  373. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -2
  374. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  375. package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
  376. package/skills/tmux-layout/SKILL.md +2 -2
  377. package/skills/vault-sync/validator.mjs +3 -2
  378. package/skills/wave-executor/SKILL.md +5 -4
  379. package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
  380. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
  381. package/skills/wave-executor/references/wave-loop-review.md +13 -5
  382. package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
  383. package/assets/og-card.png +0 -0
  384. package/assets/og-card.svg +0 -47
  385. package/assets/wave-lifecycle.svg +0 -98
@@ -80,15 +80,45 @@ function tailOf(text) {
80
80
  * SIGKILL (PRD B6: a sent signal proves nothing).
81
81
  *
82
82
  * @param {number} timeoutMs
83
- * @param {{pgid: number, killSignals: string[], survivors: number[]}} run
83
+ * @param {{pgid: number, killSignals: string[], survivors: number[]|null, error?: string}} run
84
84
  * @returns {string}
85
85
  */
86
86
  function timeoutLine(timeoutMs, run) {
87
87
  const ladder = (run.killSignals ?? []).join('\u2192') || 'no signal sent';
88
88
  return `gate: TIMEOUT after ${timeoutMs} ms \u2014 process group ${run.pgid} ${ladder}, `
89
- + `survivors: [${(run.survivors ?? []).join(', ')}]`;
89
+ + (run.survivors === null
90
+ ? `Überlebende nicht messbar: ${run.error}`
91
+ : `survivors: [${(run.survivors ?? []).join(', ')}]`);
90
92
  }
91
93
 
94
+ const LEFTOVER_GROUP_PREFIX = 'gate: reaped process group ';
95
+ const LEFTOVER_GROUP_INFIX = ' left running after exit: ';
96
+ const LEFTOVER_NO_SIGNAL = 'no signal sent';
97
+ /**
98
+ * The line that names a process group still alive after a NORMAL exit and
99
+ * reaped by {@link spawnInGroup} (#1704, its `leftoverGroup` field). Reported,
100
+ * never a status change: pass/fail stays the command's own (HR-105).
101
+ *
102
+ * @param {{pgid: number, signals: string[], survivors: number[]}} leftover
103
+ * @returns {string}
104
+ */
105
+ export function leftoverGroupLine(leftover) {
106
+ const ladder = (leftover.signals ?? []).join('→') || LEFTOVER_NO_SIGNAL;
107
+ const survivors = leftover.survivors ?? [];
108
+ return `${LEFTOVER_GROUP_PREFIX}${leftover.pgid}${LEFTOVER_GROUP_INFIX}${ladder}`
109
+ + (survivors.length > 0 ? `, survivors: [${survivors.join(', ')}]` : '');
110
+ }
111
+ /**
112
+ * Exactly the line {@link leftoverGroupLine} produces, anchored, built from the
113
+ * same constants so the two cannot drift. The timeout-only parser admits it
114
+ * after the FAIL blocks: it is the gate's own report, not a vitest error.
115
+ */
116
+ const LEFTOVER_GROUP_LINE_RE = new RegExp(
117
+ `^${LEFTOVER_GROUP_PREFIX}\\d+${LEFTOVER_GROUP_INFIX}`
118
+ + `(?:${LEFTOVER_NO_SIGNAL}|SIG[A-Z0-9]+(?:→SIG[A-Z0-9]+)*)`
119
+ + String.raw`(?:, survivors: \[\d+(?:, \d+)*\])?$`,
120
+ );
121
+
92
122
  /**
93
123
  * The stdout envelope `scripts/run-quality-gate.mjs` publishes when the GATE
94
124
  * SUB-SCRIPT itself was killed on the wall-clock ceiling.
@@ -106,10 +136,10 @@ function timeoutLine(timeoutMs, run) {
106
136
  * @param {object} args
107
137
  * @param {string} args.variant The `--variant` value the run was started with.
108
138
  * @param {number} args.timeoutMs Ceiling that fired.
109
- * @param {{pgid: number, durationMs: number, killSignals: string[], survivors: number[]}} args.run
139
+ * @param {{pgid: number, durationMs: number, killSignals: string[], survivors: number[]|null, error?: string}} args.run
110
140
  * The {@link spawnInGroup} result.
111
141
  * @returns {{variant: string, error: 'gate-timeout', timeout_ms: number, duration_ms: number,
112
- * pgid: number, kill_signals: string[], survivors: number[]}}
142
+ * pgid: number, kill_signals: string[], survivors: number[]|null, cleanup_error?: string, ok?: boolean}}
113
143
  */
114
144
  export function gateTimeoutEnvelope({ variant, timeoutMs, run }) {
115
145
  return {
@@ -119,7 +149,8 @@ export function gateTimeoutEnvelope({ variant, timeoutMs, run }) {
119
149
  duration_ms: run?.durationMs ?? 0,
120
150
  pgid: run?.pgid ?? -1,
121
151
  kill_signals: run?.killSignals ?? [],
122
- survivors: run?.survivors ?? [],
152
+ survivors: run?.survivors === null ? null : (run?.survivors ?? []),
153
+ ...(run?.survivors === null ? { cleanup_error: run.error, ok: false } : {}),
123
154
  };
124
155
  }
125
156
 
@@ -136,9 +167,10 @@ export function gateTimeoutEnvelope({ variant, timeoutMs, run }) {
136
167
  * Absent is not zero: a result that spawned nothing (skip, stub) carries no
137
168
  * `timedOut` boolean, and gets NO keys — never `false` / `[]`.
138
169
  *
139
- * @param {{timedOut?: boolean, killSignals?: string[], survivors?: number[]}} result
170
+ * @param {{timedOut?: boolean, killSignals?: string[], survivors?: number[]|null, error?: string}} result
140
171
  * A {@link runCheck} result.
141
- * @returns {{timed_out: boolean, kill_signals: string[], survivors: number[]}|{}}
172
+ * @returns {{timed_out: boolean, kill_signals: string[], survivors: number[]|null, error?: string,
173
+ * leftover_group?: {pgid: number, signals: string[], survivors: number[]}}|{}}
142
174
  * `survivors` are the pids, same as {@link gateTimeoutEnvelope}.
143
175
  */
144
176
  export function commandKillFields(result) {
@@ -146,7 +178,14 @@ export function commandKillFields(result) {
146
178
  return {
147
179
  timed_out: result.timedOut,
148
180
  kill_signals: Array.isArray(result.killSignals) ? [...result.killSignals] : [],
149
- survivors: Array.isArray(result.survivors) ? [...result.survivors] : [],
181
+ survivors: result.survivors === null ? null : (Array.isArray(result.survivors) ? [...result.survivors] : []),
182
+ ...(result.survivors === null ? { error: result.error, ok: false } : {}),
183
+ // #1704: additive, only when a group was reaped after a normal exit.
184
+ ...(result.leftoverGroup ? { leftover_group: {
185
+ pgid: result.leftoverGroup.pgid,
186
+ signals: [...(result.leftoverGroup.signals ?? [])],
187
+ survivors: [...(result.leftoverGroup.survivors ?? [])],
188
+ } } : {}),
150
189
  };
151
190
  }
152
191
 
@@ -163,7 +202,12 @@ const KILL_FIELD_COMMANDS = ['typecheck', 'test', 'lint'];
163
202
  * a boolean `timed_out` or provably spawned nothing — `status: 'skip'` or
164
203
  * listed in `stubbed`): `timed_out` = outer OR any inner; `kill_signals` =
165
204
  * outer followed by each command's, in execution order; `survivors` = outer
166
- * count plus every inner pid count.
205
+ * count plus every inner pid count. Any explicit null makes the joined
206
+ * count null and retains the measurement error. A command whose group was
207
+ * reaped after a normal exit adds `{command, pgid, signals, survivors}` to
208
+ * `leftover_groups` (#1708); the key is absent when no command had one.
209
+ * The joined `survivors` count stays the kill-ladder count and does NOT
210
+ * include `leftover_groups[].survivors` (#1704 keeps the two apart).
167
211
  * - inner NOT measured (a variant whose envelope does not publish it, an
168
212
  * unparseable envelope, or the outer ceiling killed the sub-script before it
169
213
  * wrote one): the outer ladder alone when it fired — a kill is a kill — and
@@ -173,10 +217,11 @@ const KILL_FIELD_COMMANDS = ['typecheck', 'test', 'lint'];
173
217
  * Never throws.
174
218
  *
175
219
  * @param {object} args
176
- * @param {{timed_out: boolean, survivors: number, kill_signals: string[]}} args.outer
220
+ * @param {{timed_out: boolean, survivors: number|null, error?: string, kill_signals: string[]}} args.outer
177
221
  * `gateKillFields()` of the sub-script's `spawnInGroup` result.
178
222
  * @param {string} args.gateStdout The sub-script's stdout envelope (`''` on an outer kill).
179
- * @returns {{timed_out: boolean, survivors: number, kill_signals: string[]}|{}}
223
+ * @returns {{timed_out: boolean, survivors: number|null, error?: string, kill_signals: string[],
224
+ * leftover_groups?: Array<{command: string, pgid: number, signals: string[], survivors: number[]}>}|{}}
180
225
  */
181
226
  export function joinGateKillFields({ outer, gateStdout }) {
182
227
  let parsed = null;
@@ -199,12 +244,22 @@ export function joinGateKillFields({ outer, gateStdout }) {
199
244
  || parsed[name].status === 'skip'
200
245
  || Boolean(stubbed[name]));
201
246
 
202
- if (!innerKnown) return outer?.timed_out === true ? { ...outer } : {};
247
+ if (!innerKnown) return outer?.timed_out === true || outer?.survivors === null ? { ...outer } : {};
248
+
249
+ const unmeasurable = [outer, ...inner].filter((c) => c?.survivors === null);
250
+ // #1708 point 1: a group reaped after a NORMAL exit (#1704) reached the
251
+ // envelope via commandKillFields but was dropped here, so the event could
252
+ // not show how often it happens before one turns a gate red (HR-105).
253
+ const leftoverGroups = names
254
+ .filter((name) => isObj(parsed[name].leftover_group))
255
+ .map((name) => ({ command: name, ...parsed[name].leftover_group }));
203
256
 
204
257
  return {
258
+ ...(leftoverGroups.length > 0 ? { leftover_groups: leftoverGroups } : {}),
205
259
  timed_out: outer?.timed_out === true || inner.some((c) => c.timed_out),
206
- survivors: (outer?.survivors ?? 0)
260
+ survivors: unmeasurable.length > 0 ? null : (outer?.survivors ?? 0)
207
261
  + inner.reduce((n, c) => n + (Array.isArray(c.survivors) ? c.survivors.length : 0), 0),
262
+ ...(unmeasurable.length > 0 ? { error: unmeasurable.map((c) => c.error).join('; '), ok: false } : {}),
208
263
  kill_signals: [
209
264
  ...(outer?.kill_signals ?? []),
210
265
  ...inner.flatMap((c) => (Array.isArray(c.kill_signals) ? c.kill_signals : [])),
@@ -237,7 +292,7 @@ export function joinGateKillFields({ outer, gateStdout }) {
237
292
  *
238
293
  * @param {object} args
239
294
  * @param {{fullOutput?: string, exitCode?: number, timedOut?: boolean, pgid?: number,
240
- * durationMs?: number, killSignals?: string[], survivors?: number[]}} args.result
295
+ * durationMs?: number, killSignals?: string[], survivors?: number[]|null, error?: string}} args.result
241
296
  * The {@link spawnInGroup} result for the gate sub-script.
242
297
  * @param {string} args.variant The `--variant` value the run was started with.
243
298
  * @param {number} args.timeoutMs The OUTER ceiling that applied to the sub-script.
@@ -254,14 +309,16 @@ export function publishGateOutcome({ result, variant, timeoutMs }) {
254
309
  return { stdout: capture, stderr: '', exitCode, warnings: [] };
255
310
  }
256
311
 
257
- const survivors = result.survivors ?? [];
312
+ const survivors = result.survivors === null ? null : (result.survivors ?? []);
258
313
  return {
259
314
  stdout: `${JSON.stringify(gateTimeoutEnvelope({ variant, timeoutMs, run: result }))}\n`,
260
315
  stderr: capture.trim()
261
316
  ? `\n\u2500\u2500\u2500\u2500 gate TIMED OUT \u2014 captured output before the kill \u2500\u2500\u2500\u2500\n${capture}\n\u2500\u2500\u2500\u2500 end \u2500\u2500\u2500\u2500\n`
262
317
  : '',
263
318
  exitCode,
264
- warnings: survivors.length > 0
319
+ warnings: survivors === null
320
+ ? [`Überlebende nicht messbar: ${result.error}`]
321
+ : survivors.length > 0
265
322
  ? [`gate process group ${result.pgid} left survivors after SIGKILL: ${survivors.join(', ')}`]
266
323
  : [],
267
324
  };
@@ -342,10 +399,12 @@ function isTestFile(filePath) {
342
399
  * @param {number|null} [opts.timeoutMs] - Wall-clock ceiling; defaults to
343
400
  * {@link resolveGateTimeoutMs} (`SO_GATE_TIMEOUT_MS` or 900_000). `null`
344
401
  * disables the clock — the byte cap still applies.
402
+ * @param {string} [opts.platform] - Host platform for missing-command classification.
345
403
  * @param {string} [opts.repoRoot] - Root whose gate-process ledger the spawn is
346
404
  * recorded in; defaults to `process.cwd()`, which is the tree under test.
347
405
  * @returns {Promise<{ status: 'pass'|'fail'|'skip', output: string, fullOutput: string,
348
- * exitCode: number, timedOut?: boolean, killSignals?: string[], survivors?: number[],
406
+ * exitCode: number, timedOut?: boolean, killSignals?: string[], survivors?: number[]|null, error?: string,
407
+ * leftoverGroup?: {pgid: number, signals: string[], survivors: number[]},
349
408
  * stubbed?: { kind: 'echo'|'noop' } }>}
350
409
  */
351
410
  export async function runCheck(cmd, opts = {}) {
@@ -364,12 +423,30 @@ export async function runCheck(cmd, opts = {}) {
364
423
  repoRoot: process.cwd(),
365
424
  commandSignature: buildCommandSignature(cmd),
366
425
  ...opts,
426
+ // #1675: the CLI gate path (`.husky/pre-push` → `npm run quality-gate` →
427
+ // gate-full → here) must bound Vitest's workers exactly like `runGate()` in
428
+ // scripts/lib/quality-gate.mjs; without it the pre-push gate ran 18 workers
429
+ // on an 18-core host and timed out under load (#1673). Applied AFTER the
430
+ // `opts` spread so a caller-supplied `env` keeps its keys but cannot drop
431
+ // the bound. typecheck/lint ignore the flag, so it is set unconditionally.
432
+ // NAMED CEILING (BV-004): the policy is duplicated in exactly two spawn
433
+ // sites (this one and `runGate`); revisit by extracting one shared helper
434
+ // if a third gate entrypoint appears or the bound needs a value other
435
+ // than the flag `vitest.config.mjs` reads.
436
+ env: { ...(opts.env ?? process.env), SO_BOUNDED_WORKERS: '1' },
367
437
  timeoutMs,
368
438
  });
369
439
 
370
440
  // Reported in every returned shape below, so a consumer never has to ask a
371
441
  // second question to learn whether the group is actually gone.
372
- const groupFields = { killSignals: run.killSignals, survivors: run.survivors };
442
+ const groupFields = { killSignals: run.killSignals, survivors: run.survivors,
443
+ ...(run.survivors === null ? { error: run.error, ok: false } : {}),
444
+ ...(run.leftoverGroup ? { leftoverGroup: run.leftoverGroup } : {}) };
445
+ // #1704: a group reaped after a NORMAL exit is named in the output of the
446
+ // non-timeout shapes (a timeout run never carries one); status is unchanged.
447
+ const runFullOutput = run.leftoverGroup
448
+ ? `${run.fullOutput}\n${leftoverGroupLine(run.leftoverGroup)}\n`
449
+ : run.fullOutput;
373
450
 
374
451
  if (run.timedOut) {
375
452
  const fullOutput = `${run.fullOutput}\n${timeoutLine(timeoutMs, run)}\n`;
@@ -383,17 +460,25 @@ export async function runCheck(cmd, opts = {}) {
383
460
  };
384
461
  }
385
462
 
386
- // Exit code 127 means command not found — treat as skip.
387
- if (run.exitCode === 127) {
388
- return { status: 'skip', output: 'command not found', fullOutput: '', exitCode: 127, timedOut: false, ...groupFields };
463
+ // A missing configured command is a failure, never an implicit opt-out.
464
+ // cmd.exe sets ERRORLEVEL 9009 for an unknown command, but `cmd /d /s /c`
465
+ // (Node's shell on win32) exits 1 for it (measured 2026-10-14, #1612), so a
466
+ // missing command there is an ordinary `fail` without this hint; 9009
467
+ // only surfaces when a batch file propagates it with `exit /b`.
468
+ if (run.exitCode === 127 || ((opts.platform ?? process.platform) === 'win32' && run.exitCode === 9009)) {
469
+ const message = `command not found (exit ${run.exitCode}); correct the command or explicitly set it to skip in the Session Config.`;
470
+ const fullOutput = `${runFullOutput}\n${message}`.trim();
471
+ return { status: 'fail', output: tailOf(fullOutput), fullOutput, exitCode: run.exitCode, timedOut: false, ...groupFields };
389
472
  }
390
473
 
391
474
  return {
392
475
  status: run.exitCode === 0 ? 'pass' : 'fail',
393
- output: tailOf(run.fullOutput),
394
- fullOutput: run.fullOutput,
476
+ output: tailOf(runFullOutput),
477
+ fullOutput: runFullOutput,
395
478
  exitCode: run.exitCode,
396
479
  timedOut: false,
480
+ // #1673: an end by signal, which `exitCode` alone reports as 1.
481
+ exitSignal: run.exitSignal ?? null,
397
482
  ...groupFields,
398
483
  };
399
484
  }
@@ -657,6 +742,134 @@ export function extractFailedTestFiles(output, cwd = process.cwd()) {
657
742
  return [...seen];
658
743
  }
659
744
 
745
+ /**
746
+ * vitest's own wording for a test or hook that ran past its timeout (vitest
747
+ * 4.1.11, `makeTimeoutError` in @vitest/runner): `Test timed out in 5000ms.`,
748
+ * with the `If this is a long-running …` hint after a newline, so it is the
749
+ * NEXT line ({@link TIMEOUT_BLOCK_LINE}). Anchored at the end: any further text
750
+ * on the error line is not a plain timeout.
751
+ */
752
+ const TIMEOUT_ERROR_LINE = /^(?:Error:\s+)?(?:Test|Hook) timed out in \d+ms\.?$/;
753
+ /**
754
+ * `⎯⎯⎯[k/N]⎯` — closes every numbered error block. N is the run's error total
755
+ * counted BEFORE vitest merges identical errors (`printErrorsSummary`, vitest
756
+ * 4.1.11), so it equals the number of FAIL headers, not of blocks.
757
+ */
758
+ const ERROR_BLOCK_COUNTER = /^\s*⎯+\[(\d+)\/(\d+)\]⎯*\s*$/;
759
+ /**
760
+ * The only lines a timeout block may carry after its error line: vitest's
761
+ * hint, a stack frame (` ❯ …`) and the code frame (` 2| …`, ` | ^`).
762
+ * Anything else — `Caused by:`, a serialized error, a second error — makes the
763
+ * block something other than a plain timeout.
764
+ */
765
+ const TIMEOUT_BLOCK_LINE = /^(?:If this is a long-running (?:test|hook),|\s*❯\s|\s+\d+\|(?: |$)|\s+\|\s*\^)/;
766
+ /**
767
+ * Once the first FAIL block opened, the only lines vitest prints OUTSIDE a
768
+ * block are the section rules and the closing summary, each in its exact
769
+ * numeric shape (vitest 4.1.11, measured). Any other line there — a package
770
+ * manager's own error line included — is an error the block list does not hold
771
+ * (Codex review round 3, H1: prefix-only matches admitted `npm error
772
+ * AssertionError …` and `⎯⎯ AssertionError … ⎯⎯`).
773
+ */
774
+ const AFTER_FAILURES_LINE = new RegExp(
775
+ [
776
+ String.raw`^\s*⎯+ Failed (?:Tests|Suites) \d+ ⎯+\s*$`,
777
+ String.raw`^\s*Test Files\s+\d+ (?:failed|passed)(?: \| \d+ (?:passed|skipped|todo))* \(\d+\)\s*$`,
778
+ String.raw`^\s*Tests\s+\d+ (?:failed|passed)(?: \| \d+ (?:passed|skipped|todo))* \(\d+\)\s*$`,
779
+ String.raw`^\s*Start at\s+\d{2}:\d{2}:\d{2}\s*$`,
780
+ String.raw`^\s*Duration\s+\d+(?:\.\d+)?m?s(?: \([^()]*\))?\s*$`,
781
+ ].join('|'),
782
+ );
783
+ /**
784
+ * Errors vitest reports OUTSIDE the numbered FAIL blocks: the `Unhandled
785
+ * Errors` section and its `Errors N error(s)` summary line. Either one means a
786
+ * failure the block list does not hold (measured, vitest 4.1.11).
787
+ */
788
+ const UNCOUNTED_ERRORS_LINE = /^(?:⎯+\s*Unhandled\b|\s*Errors\s+\d+\s+errors?\b)/;
789
+
790
+ /**
791
+ * Which failing files failed ONLY by timeout — the set the full gate may run a
792
+ * second time, serially (#1673).
793
+ *
794
+ * Under host load the parallel suite loses tests to their timeout while the
795
+ * code is fine; four pre-push runs on m5 blocked with four different red sets
796
+ * (11, 11, 2, 7 tests), none touching the pushed diff. A timeout is the one
797
+ * failure that load alone produces. An assertion failure is NOT eligible, even
798
+ * one that load provoked: re-running it would let a genuine flake through the
799
+ * gate (Lead 32, 2026-10-20).
800
+ *
801
+ * Shape (measured, vitest 4.1.11): every error block starts with one or more
802
+ * `FAIL <file> …` headers, then the error line, and ends in `⎯[k/N]⎯`.
803
+ * Several headers share one block when vitest's `printTaskErrors` merges their
804
+ * errors: same `stack` AND same `diff`, same project, and (for tests) deep-equal
805
+ * annotations. A timeout's stack is the place the test or hook was DECLARED, so
806
+ * one `beforeEach` that times out for every test of a file, or the cases of one
807
+ * `it.each`, print as one block under several headers. Each header is one
808
+ * (task, error) pair, and N counts those pairs before the merge, so the
809
+ * completeness check compares N with the HEADERS read, not with the blocks.
810
+ *
811
+ * Fail-closed: `eligible` is true only when EVERY counted error was parsed and
812
+ * EVERY block is a timeout. A header count that differs from N (an unhandled
813
+ * error, an unknown layout), no counter at all, or one non-timeout block makes
814
+ * the whole run ineligible.
815
+ *
816
+ * @param {string} output - Captured test-runner stdout/stderr.
817
+ * @param {string} [cwd=process.cwd()] - Root to relativise absolute paths against.
818
+ * @returns {{ eligible: boolean, files: string[], reason: string }}
819
+ */
820
+ export function extractTimeoutOnlyFailedFiles(output, cwd = process.cwd()) {
821
+ if (!output || typeof output !== 'string') return { eligible: false, files: [], reason: 'no-output' };
822
+
823
+ const prefix = cwd.endsWith('/') ? cwd : `${cwd}/`;
824
+ const files = new Set();
825
+ let pending = [];
826
+ let inBlock = false;
827
+ let sawFail = false;
828
+ let blocks = 0;
829
+ let errors = 0;
830
+ let declaredTotal = null;
831
+ let allTimeouts = true;
832
+
833
+ for (const rawLine of output.split('\n')) {
834
+ const line = rawLine.replace(ANSI_ESCAPE, '');
835
+ if (UNCOUNTED_ERRORS_LINE.test(line)) return { eligible: false, files: [], reason: 'uncounted-errors' };
836
+ const counter = ERROR_BLOCK_COUNTER.exec(line);
837
+ if (counter) {
838
+ declaredTotal = Number(counter[2]);
839
+ inBlock = false;
840
+ continue;
841
+ }
842
+ const header = FAIL_LINE.exec(line);
843
+ if (header) {
844
+ pending.push(header[1].startsWith(prefix) ? header[1].slice(prefix.length) : header[1]);
845
+ inBlock = false;
846
+ sawFail = true;
847
+ continue;
848
+ }
849
+ if (line.trim() === '') continue;
850
+ if (inBlock) {
851
+ if (!TIMEOUT_BLOCK_LINE.test(line)) allTimeouts = false;
852
+ continue;
853
+ }
854
+ if (pending.length === 0) {
855
+ if (sawFail && !AFTER_FAILURES_LINE.test(line) && !LEFTOVER_GROUP_LINE_RE.test(line)) return { eligible: false, files: [], reason: 'line-outside-blocks' };
856
+ continue;
857
+ }
858
+ blocks += 1;
859
+ errors += pending.length;
860
+ if (!TIMEOUT_ERROR_LINE.test(line.trim())) allTimeouts = false;
861
+ for (const file of pending) files.add(file);
862
+ pending = [];
863
+ inBlock = true;
864
+ }
865
+
866
+ if (pending.length > 0) return { eligible: false, files: [], reason: 'header-without-error' };
867
+ if (declaredTotal === null || blocks === 0) return { eligible: false, files: [], reason: 'no-error-blocks' };
868
+ if (errors !== declaredTotal) return { eligible: false, files: [], reason: `parsed-${errors}-of-${declaredTotal}` };
869
+ if (!allTimeouts) return { eligible: false, files: [], reason: 'non-timeout-failure' };
870
+ return { eligible: true, files: [...files], reason: 'timeouts-only' };
871
+ }
872
+
660
873
  /**
661
874
  * Admit a suite-count triple, or refuse to claim a measurement.
662
875
  *
@@ -101,7 +101,9 @@
101
101
  */
102
102
 
103
103
  import { spawnSync } from 'node:child_process';
104
- import { isAbsolute, relative } from 'node:path';
104
+ import { accessSync, constants, existsSync } from 'node:fs';
105
+ import { detectPackageManager } from './package-manager.mjs';
106
+ import { isAbsolute, join, relative } from 'node:path';
105
107
 
106
108
  /** Default timeout in ms for each `git config` invocation. */
107
109
  export const DEFAULT_TIMEOUT_MS = 5000;
@@ -140,8 +142,17 @@ export const DEGRADED_REASONS = Object.freeze([
140
142
  * - `hooks-path` — a local `core.hooksPath` not pointing at `.husky/_`
141
143
  * and not DECLARED by the repo — no tracked file directly under it is a
142
144
  * real git hook name; see `hooksPathIsTracked` / `GIT_HOOK_NAMES`.
145
+ * - `hooks-path-missing` — `core.hooksPath` points at `.husky/_`, but the
146
+ * directory git resolves it to in THIS working tree lacks an executable
147
+ * `pre-push` or the `h` helper (#1688).
148
+ * `.husky/_` is gitignored and written by `husky`; under `ignore-scripts=true`
149
+ * a fresh linked worktree never gets it, so commit and push run ungated and
150
+ * silent. Repair with the canonical PM (SEC-020): `pnpm exec husky`
151
+ * (pnpm), `npm exec --no -- husky` (npm); never fetch a missing package.
152
+ * - `hooks-path-unset` — `core.hooksPath` is set in no scope although the repo
153
+ * tracks `.husky/<hook>` files, so git never runs them (#1692). Same repair.
143
154
  *
144
- * @type {readonly ['ambient-git-env','local-identity','local-gpgsign','fixture-remote','hooks-path']}
155
+ * @type {readonly ['ambient-git-env','local-identity','local-gpgsign','fixture-remote','hooks-path','hooks-path-missing','hooks-path-unset']}
145
156
  */
146
157
  export const FINDING_KINDS = Object.freeze([
147
158
  'ambient-git-env',
@@ -149,6 +160,8 @@ export const FINDING_KINDS = Object.freeze([
149
160
  'local-gpgsign',
150
161
  'fixture-remote',
151
162
  'hooks-path',
163
+ 'hooks-path-missing',
164
+ 'hooks-path-unset',
152
165
  ]);
153
166
 
154
167
  /**
@@ -267,7 +280,8 @@ function filteredGitEnv(sourceEnv) {
267
280
  * Run one `git config` query.
268
281
  *
269
282
  * @param {string[]} args argv after the leading `git`
270
- * @param {{repoRoot: string, env: Record<string,string|undefined>, timeoutMs: number, spawn: Function}} ctx
283
+ * @param {{repoRoot: string, env: Record<string,string|undefined>, timeoutMs: number, spawn: Function, repoConfirmed?: boolean}} ctx
284
+ * `repoConfirmed` is true once an earlier `--local` read of this probe succeeded
271
285
  * @returns {{ok: true, stdout: string} | {ok: false, reason: 'git-unavailable'|'not-a-git-repo'|'config-unreadable'|'timeout'|'git-error', status: number|null}}
272
286
  */
273
287
  function runGitConfig(args, ctx) {
@@ -297,7 +311,9 @@ function runGitConfig(args, ctx) {
297
311
  if (stderr.includes('bad config') || stderr.includes('bad numeric config')) {
298
312
  return { ok: false, reason: 'config-unreadable', status };
299
313
  }
300
- return { ok: false, reason: 'not-a-git-repo', status };
314
+ // Once the `--local` read has succeeded the repo demonstrably exists, so a
315
+ // later 128 is a config git can no longer read, not a missing repo (#1694).
316
+ return { ok: false, reason: ctx.repoConfirmed ? 'config-unreadable' : 'not-a-git-repo', status };
301
317
  }
302
318
  return { ok: false, reason: 'git-error', status };
303
319
  }
@@ -437,6 +453,102 @@ function hooksPathIsTracked(value, ctx) {
437
453
  return lines.some((line) => isDeclaredHookFile(pathspec, line));
438
454
  }
439
455
 
456
+ /**
457
+ * @param {{repoRoot: string, env: Record<string,string|undefined>, timeoutMs: number, spawn: Function}} ctx
458
+ * @returns {string[]} each missing runtime requirement, empty when healthy.
459
+ * Uses git's own per-worktree path resolution. A failed query is reported,
460
+ * never read as present.
461
+ */
462
+ function hooksDirMissingReasons(ctx) {
463
+ const res = runGitConfig(['rev-parse', '--path-format=absolute', '--git-path', 'hooks'], ctx);
464
+ if (!res.ok) return [`der Hook-Pfad ließ sich nicht auflösen (${res.reason})`];
465
+ const dir = res.stdout.trim();
466
+ if (dir === '') return ['der Hook-Pfad ließ sich nicht auflösen (leere Ausgabe)'];
467
+ const reasons = [];
468
+ const prePush = join(dir, 'pre-push');
469
+ if (!existsSync(prePush)) {
470
+ reasons.push(`${prePush} fehlt`);
471
+ } else {
472
+ try {
473
+ accessSync(prePush, constants.X_OK);
474
+ } catch {
475
+ reasons.push(`${prePush} ist nicht ausführbar`);
476
+ }
477
+ }
478
+ if (!existsSync(join(dir, 'h'))) reasons.push(`${dir}/h fehlt`);
479
+ return reasons;
480
+ }
481
+
482
+ /**
483
+ * `core.hooksPath` unset in EVERY scope (local, global, system, includes) while
484
+ * the repo tracks `.husky/<hook>` files: git then falls back to
485
+ * `<common-dir>/hooks`, which husky never writes, so commit and push run
486
+ * ungated and silent (#1692 — measured on a host clone with three ungated
487
+ * MR pushes).
488
+ *
489
+ * Ceiling: a `core.hooksPath` set ONLY globally/system-wide is not judged
490
+ * here — the local loop covers local values; revisit if a fleet host sets it
491
+ * globally. Likewise a value git finds only through config-location env vars
492
+ * (`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_COUNT`, a non-default `XDG_CONFIG_HOME`)
493
+ * is invisible behind {@link GIT_ENV_ALLOWLIST} and reads as unset; revisit
494
+ * if a host relies on that.
495
+ *
496
+ * @param {{repoRoot: string, env: Record<string,string|undefined>, timeoutMs: number, spawn: Function}} ctx
497
+ * @returns {{kind:'hooks-path-unset', key:string, value:string, detail:string} | null}
498
+ * null when a hooksPath is set somewhere or the repo tracks no husky hook.
499
+ * A failed query is a finding ("unknown"), never read as healthy.
500
+ */
501
+ function hooksPathUnsetFinding(ctx) {
502
+ const unknown = (what, reason) => ({
503
+ kind: /** @type {const} */ ('hooks-path-unset'),
504
+ key: 'core.hooksPath',
505
+ value: '',
506
+ detail: `${what} (${reason}) — ob die Husky-Hooks laufen, ist unbekannt.`,
507
+ });
508
+
509
+ // `git config --get` exits 1 for "key not set in any scope".
510
+ const effective = runGitConfig(['config', '--get', 'core.hooksPath'], ctx);
511
+ if (effective.ok) return null;
512
+ if (effective.status !== 1) return unknown('core.hooksPath ließ sich nicht abfragen', effective.reason);
513
+
514
+ // `ls-files` also exits 128 for a corrupt index, which is no config fault:
515
+ // the repo-confirmed 128 is named for what it is, a git error (#1694 review).
516
+ const tracked = runGitConfig(['ls-files', '--', '.husky'], { ...ctx, repoConfirmed: false });
517
+ if (!tracked.ok) {
518
+ const reason = tracked.reason === 'not-a-git-repo' ? 'git-error' : tracked.reason;
519
+ return unknown('die Husky-Hooks ließen sich nicht auflisten', reason);
520
+ }
521
+ const hooks = tracked.stdout
522
+ .split('\n')
523
+ .filter((line) => isDeclaredHookFile('.husky', line));
524
+ if (hooks.length === 0) return null;
525
+
526
+ return {
527
+ kind: 'hooks-path-unset',
528
+ key: 'core.hooksPath',
529
+ value: '',
530
+ detail:
531
+ `core.hooksPath ist nicht gesetzt, das Repo versioniert aber ${hooks.join(', ')} — ` +
532
+ `git führt diese Hooks nicht aus, Commits und Pushes laufen ungeprüft. ` +
533
+ huskyRepairHint(ctx.repoRoot),
534
+ };
535
+ }
536
+
537
+ /** @param {string} repoRoot */
538
+ function huskyRepairHint(repoRoot) {
539
+ // SEC-020: respect the TARGET repo's canonical PM, not the plugin's npm.
540
+ const commands = {
541
+ pnpm: 'pnpm exec husky',
542
+ npm: 'npm exec --no -- husky',
543
+ yarn: 'yarn exec husky',
544
+ bun: 'bun run husky',
545
+ };
546
+ const pm = detectPackageManager(repoRoot);
547
+ return pm
548
+ ? `Reparatur: \`${commands[pm]}\`.`
549
+ : 'Reparatur: Husky mit dem canonical Package Manager des Repos ausführen (SEC-020).';
550
+ }
551
+
440
552
  /**
441
553
  * Build the degraded result. Distinct from `null` on purpose — see the header.
442
554
  *
@@ -479,7 +591,7 @@ export function checkGitConfigDrift(opts = {}, deps = {}) {
479
591
 
480
592
  const activeEnv = env && typeof env === 'object' ? env : process.env;
481
593
  const spawn = deps?.spawn ?? spawnSync;
482
- const ctx = { repoRoot, env: activeEnv, timeoutMs, spawn };
594
+ const ctx = { repoRoot, env: activeEnv, timeoutMs, spawn, repoConfirmed: false };
483
595
 
484
596
  /** @type {Array<{kind:string, key:string, value:string, detail:string}>} */
485
597
  const findings = [];
@@ -503,6 +615,7 @@ export function checkGitConfigDrift(opts = {}, deps = {}) {
503
615
 
504
616
  const local = runGitConfig(['config', '--local', '--list', '-z'], ctx);
505
617
  if (!local.ok) return degradedResult(local.reason);
618
+ ctx.repoConfirmed = true;
506
619
 
507
620
  // Best-effort global read, for the identity/gpgsign comparison only. Its
508
621
  // failure never degrades the result — the local query, which IS the
@@ -516,7 +629,8 @@ export function checkGitConfigDrift(opts = {}, deps = {}) {
516
629
  }
517
630
  }
518
631
 
519
- for (const { key, value } of parseNulConfigList(local.stdout)) {
632
+ const localEntries = parseNulConfigList(local.stdout);
633
+ for (const { key, value } of localEntries) {
520
634
  const lower = key.toLowerCase();
521
635
 
522
636
  if (lower === 'user.email' || lower === 'user.name') {
@@ -566,7 +680,22 @@ export function checkGitConfigDrift(opts = {}, deps = {}) {
566
680
  continue;
567
681
  }
568
682
 
569
- if (lower === 'core.hookspath' && !hooksPathIsExpected(value) && !hooksPathIsTracked(value, ctx)) {
683
+ if (lower === 'core.hookspath' && hooksPathIsExpected(value)) {
684
+ const missing = hooksDirMissingReasons(ctx);
685
+ for (const reason of missing) {
686
+ findings.push({
687
+ kind: 'hooks-path-missing',
688
+ key,
689
+ value,
690
+ detail:
691
+ `core.hooksPath=${value}, aber ${reason} — die Husky-Installation in diesem ` +
692
+ `Arbeitsbaum ist unvollständig. ${huskyRepairHint(repoRoot)}`,
693
+ });
694
+ }
695
+ continue;
696
+ }
697
+
698
+ if (lower === 'core.hookspath' && !hooksPathIsTracked(value, ctx)) {
570
699
  findings.push({
571
700
  kind: 'hooks-path',
572
701
  key,
@@ -578,6 +707,11 @@ export function checkGitConfigDrift(opts = {}, deps = {}) {
578
707
  }
579
708
  }
580
709
 
710
+ if (!localEntries.some(({ key }) => key.toLowerCase() === 'core.hookspath')) {
711
+ const unset = hooksPathUnsetFinding(ctx);
712
+ if (unset) findings.push(unset);
713
+ }
714
+
581
715
  if (findings.length === 0) return null;
582
716
 
583
717
  const plural = findings.length === 1 ? 'Eintrag' : 'Einträge';