session-orchestrator 3.22.0 → 3.24.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 (316) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +446 -0
  91. package/README.md +22 -17
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -2
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/session-reviewer.md +7 -1
  98. package/agents/test-writer.md +2 -1
  99. package/agents/ui-developer.md +2 -1
  100. package/commands/bootstrap.md +2 -2
  101. package/commands/close.md +3 -1
  102. package/commands/go.md +1 -1
  103. package/commands/journey-audit.md +43 -0
  104. package/docs/USER-GUIDE.md +2 -2
  105. package/docs/ci-setup.md +194 -25
  106. package/docs/codex-setup.md +64 -0
  107. package/docs/components.md +7 -7
  108. package/docs/cursor-setup.md +26 -47
  109. package/docs/events-schema.md +120 -10
  110. package/docs/github-mirror-protection.md +197 -0
  111. package/docs/pi-setup.md +2 -0
  112. package/docs/rule-authoring.md +3 -1
  113. package/docs/scope-collision-guard.md +49 -2
  114. package/docs/session-config-reference.md +89 -9
  115. package/docs/session-config-template.md +38 -7
  116. package/docs/telemetry/telemetry-claims.md +11 -10
  117. package/docs/telemetry.md +52 -1
  118. package/hooks/_lib/atomic-json.mjs +111 -0
  119. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  120. package/hooks/_lib/subagent-paths.mjs +143 -0
  121. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  122. package/hooks/cwd-change-restore.mjs +9 -29
  123. package/hooks/enforce-scope.mjs +93 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +2 -2
  128. package/hooks/on-session-end.mjs +486 -19
  129. package/hooks/on-session-start.mjs +263 -12
  130. package/hooks/on-stop.mjs +392 -24
  131. package/hooks/post-bash-write-verify.mjs +104 -4
  132. package/hooks/post-subagent-discovery-validator.mjs +182 -21
  133. package/hooks/post-tool-batch-wave-signal.mjs +165 -42
  134. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  135. package/hooks/pre-bash-issue-budget.mjs +117 -4
  136. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  138. package/hooks/pre-bash-staging-fence.mjs +4 -0
  139. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  140. package/hooks/skill-invocation-telemetry.mjs +21 -10
  141. package/hooks/subagent-telemetry.mjs +11 -26
  142. package/monitors/monitors.json +6 -0
  143. package/package.json +1 -1
  144. package/pi/prompts/journey-audit.md +12 -0
  145. package/rules/_index.md +9 -1
  146. package/rules/always-on/ask-via-tool.md +62 -0
  147. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  148. package/rules/always-on/build-value.md +47 -0
  149. package/rules/always-on/cross-session-messaging.md +59 -0
  150. package/rules/always-on/loop-and-monitor.md +221 -0
  151. package/rules/always-on/parallel-sessions.md +142 -12
  152. package/rules/always-on/receiving-review.md +108 -0
  153. package/rules/always-on/test-value.md +40 -0
  154. package/rules/always-on/verification-before-completion.md +77 -0
  155. package/scripts/archive-closed-prds.mjs +258 -18
  156. package/scripts/autopilot.mjs +31 -12
  157. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  158. package/scripts/backfill-evidence-digest.mjs +376 -0
  159. package/scripts/cursor-install.mjs +89 -48
  160. package/scripts/emit-event.mjs +10 -2
  161. package/scripts/export-hw-learnings.mjs +143 -2
  162. package/scripts/express-path.mjs +299 -0
  163. package/scripts/generate-cursor-adapter.mjs +253 -0
  164. package/scripts/github-protection-audit.mjs +358 -0
  165. package/scripts/lib/auq/parse.mjs +5 -29
  166. package/scripts/lib/auto-dialectic.mjs +68 -0
  167. package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
  168. package/scripts/lib/build-live-signals.mjs +49 -27
  169. package/scripts/lib/ci-status-banner.mjs +158 -11
  170. package/scripts/lib/cold-start-detector.mjs +23 -14
  171. package/scripts/lib/command-blocker.mjs +70 -0
  172. package/scripts/lib/config/block-header.mjs +55 -0
  173. package/scripts/lib/config/discovery-validator.mjs +7 -2
  174. package/scripts/lib/config/health-endpoints.mjs +383 -0
  175. package/scripts/lib/config/reconcile.mjs +79 -4
  176. package/scripts/lib/config/remote-hosts.mjs +233 -0
  177. package/scripts/lib/config/section-extractor.mjs +235 -36
  178. package/scripts/lib/config-schema.mjs +9 -1
  179. package/scripts/lib/config.mjs +87 -8
  180. package/scripts/lib/convergence-monitor.mjs +13 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  182. package/scripts/lib/dispatcher/cli.mjs +2 -2
  183. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  184. package/scripts/lib/events-schema.mjs +48 -0
  185. package/scripts/lib/events.mjs +238 -5
  186. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  187. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  188. package/scripts/lib/express-path.mjs +327 -0
  189. package/scripts/lib/file-lock.mjs +22 -4
  190. package/scripts/lib/gates/gate-full.mjs +81 -8
  191. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  192. package/scripts/lib/git-config-drift.mjs +134 -5
  193. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  194. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  195. package/scripts/lib/host-identity.mjs +247 -2
  196. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  197. package/scripts/lib/issue-budget.mjs +229 -30
  198. package/scripts/lib/learnings/io.mjs +55 -10
  199. package/scripts/lib/learnings/schema.mjs +95 -28
  200. package/scripts/lib/lock-reaper.mjs +7 -1
  201. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  202. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  203. package/scripts/lib/memory-banner.mjs +25 -10
  204. package/scripts/lib/memory-paths.mjs +15 -6
  205. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  206. package/scripts/lib/peer-discovery.mjs +20 -2
  207. package/scripts/lib/platform.mjs +72 -9
  208. package/scripts/lib/plugin-root.mjs +143 -19
  209. package/scripts/lib/project-hygiene.mjs +43 -3
  210. package/scripts/lib/quality-gate.mjs +271 -13
  211. package/scripts/lib/reconcile/emitter.mjs +87 -19
  212. package/scripts/lib/reconcile/engine.mjs +517 -18
  213. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  214. package/scripts/lib/reconcile/renderer.mjs +148 -3
  215. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  216. package/scripts/lib/reconcile/writer.mjs +415 -84
  217. package/scripts/lib/rule-loader.mjs +37 -2
  218. package/scripts/lib/rules-sync.mjs +51 -8
  219. package/scripts/lib/scope-gate.mjs +126 -0
  220. package/scripts/lib/session-close-backfill.mjs +427 -37
  221. package/scripts/lib/session-discovery.mjs +69 -5
  222. package/scripts/lib/session-end/phase-skip.mjs +38 -5
  223. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  224. package/scripts/lib/session-id.mjs +30 -14
  225. package/scripts/lib/session-identity/own-session.mjs +220 -0
  226. package/scripts/lib/session-lock.mjs +85 -30
  227. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  228. package/scripts/lib/session-schema/validator.mjs +40 -0
  229. package/scripts/lib/session-start-probes.mjs +608 -0
  230. package/scripts/lib/session-transition.mjs +277 -0
  231. package/scripts/lib/sessions-canonical.mjs +446 -0
  232. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  233. package/scripts/lib/spiral-carryover.mjs +90 -9
  234. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  235. package/scripts/lib/state-md/mission-status.mjs +350 -52
  236. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  237. package/scripts/lib/state-md.mjs +12 -2
  238. package/scripts/lib/telemetry/schema.mjs +74 -8
  239. package/scripts/lib/telemetry/sync.mjs +91 -16
  240. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  241. package/scripts/lib/validate/check-agents.mjs +66 -0
  242. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  243. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  244. package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
  245. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  246. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  247. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  248. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  249. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  250. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  251. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  252. package/scripts/lib/validate/check-unwired-features.mjs +333 -32
  253. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  254. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  255. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  256. package/scripts/lib/validate/repo-files.mjs +275 -0
  257. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  258. package/scripts/lib/vault-mirror/process.mjs +99 -43
  259. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  260. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  261. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  262. package/scripts/lib/vault-status/board-writer.mjs +381 -141
  263. package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
  264. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  265. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  266. package/scripts/lib/wave-resource-gate.mjs +127 -7
  267. package/scripts/lib/wave-transcript-tail.mjs +889 -0
  268. package/scripts/materialize-wave-scope.mjs +228 -15
  269. package/scripts/mcp-server.sh +11 -2
  270. package/scripts/memory-propose.mjs +132 -8
  271. package/scripts/parse-config.mjs +65 -0
  272. package/scripts/promote-vault-strict.mjs +4 -15
  273. package/scripts/site-numbers.mjs +36 -4
  274. package/scripts/token-audit.sh +9 -2
  275. package/scripts/validate-plugin.mjs +29 -0
  276. package/scripts/validate-wave-scope.mjs +67 -0
  277. package/scripts/vault-consolidate.mjs +3 -11
  278. package/scripts/vault-integration-watcher.mjs +2 -4
  279. package/scripts/vault-mirror.mjs +305 -51
  280. package/skills/_shared/monitor-patterns.md +31 -5
  281. package/skills/_shared/parallel-aware-auq.md +31 -2
  282. package/skills/_shared/parallel-aware-preamble.md +19 -4
  283. package/skills/_shared/platform-tools.md +11 -5
  284. package/skills/_shared/state-ownership.md +29 -2
  285. package/skills/autopilot/SKILL.md +5 -1
  286. package/skills/bootstrap/SKILL.md +3 -3
  287. package/skills/bootstrap/_shared-template.md +18 -10
  288. package/skills/bootstrap/deep-template.md +10 -6
  289. package/skills/bootstrap/fast-template.md +15 -8
  290. package/skills/bootstrap/standard-template.md +10 -6
  291. package/skills/claude-md-drift-check/checker.mjs +39 -11
  292. package/skills/contract-version-bump/SKILL.md +1 -1
  293. package/skills/dispatcher/SKILL.md +1 -1
  294. package/skills/ecosystem-health/SKILL.md +4 -1
  295. package/skills/ecosystem-health/wizard.md +5 -0
  296. package/skills/evolve/SKILL.md +38 -1
  297. package/skills/journey-audit/SKILL.md +270 -0
  298. package/skills/peekaboo-driver/SKILL.md +15 -3
  299. package/skills/persona-panel/SKILL.md +1 -1
  300. package/skills/reconcile/SKILL.md +46 -3
  301. package/skills/remote-offload/SKILL.md +89 -0
  302. package/skills/session-end/SKILL.md +17 -4
  303. package/skills/session-end/metrics-collection.md +7 -4
  304. package/skills/session-end/phase-3-6-tail.md +20 -9
  305. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  306. package/skills/session-plan/SKILL.md +6 -1
  307. package/skills/session-plan/wave-template.md +1 -0
  308. package/skills/session-start/SKILL.md +54 -17
  309. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  310. package/skills/session-start/phase-8-5-express-path.md +77 -12
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +5 -3
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +143 -22
  315. package/templates/_shared/journey-manifest.md +110 -0
  316. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -0,0 +1,327 @@
1
+ /**
2
+ * express-path.mjs — the Express Path activation DECISION plus its record (#214, #1119).
3
+ *
4
+ * ## Why this module exists at all
5
+ *
6
+ * The Express Path (session-start Phase 8.5) was built as prose and nothing else.
7
+ * Two measurements on 2026-08-23 pinned what that cost:
8
+ *
9
+ * grep -c "express" scripts/lib/config.mjs → 0 (506 lines)
10
+ * grep -c "express" .orchestrator/metrics/events.jsonl → 0 (whole history)
11
+ *
12
+ * The second number is the real defect. `express-path.enabled` had no parser, so
13
+ * the documented default was prose rather than a value — but even a wired key only
14
+ * answers "what was configured". Nothing anywhere answered "did it fire?", and
15
+ * 22 of the last 30 sessions ran wave-less housekeeping without a single record
16
+ * saying whether the path applied to any of them.
17
+ *
18
+ * So the decision lives HERE, in code, and records itself as it is made. The prose
19
+ * in `skills/session-start/phase-8-5-express-path.md` points at this function; it
20
+ * does not re-implement the rule and it does not emit the event. A telemetry
21
+ * emitter that fires only when a coordinator reads the right paragraph is not
22
+ * wired — that is the same class of defect wave 2 of this session repaired for 18
23
+ * discovery probes.
24
+ *
25
+ * ## Where the rule comes from
26
+ *
27
+ * The three activation conditions are quoted verbatim from
28
+ * `skills/session-start/phase-8-5-express-path.md:9-11` and cross-checked against
29
+ * `docs/session-config-reference.md:1532-1536`. Both sources agree. The condition
30
+ * matrix in the same two files (spec `:47-53`, reference `:1557-1563`) supplies the
31
+ * fourth input, `parallelAgentsRequired`, which condition 3 carries as its second
32
+ * clause ("AND no parallel agents are required").
33
+ *
34
+ * ## Module placement — a deviation, and its cause
35
+ *
36
+ * Every other top-level Session Config block parser lives at
37
+ * `scripts/lib/config/<key>.mjs` (42 files). `_parseExpressPath` sits here instead
38
+ * because this agent's declared file scope for #1119 named `scripts/lib/express-path.mjs`
39
+ * and did not include `scripts/lib/config/`. The parser is otherwise a byte-for-byte
40
+ * sibling of `config/state-md-lock.mjs` — same block walk, same `matchBlockHeader`
41
+ * adoption (#830), same tolerant fallbacks. It imports only `./config/block-header.mjs`,
42
+ * which has zero imports by design, so no cycle is possible in either direction.
43
+ * Revisit trigger: if a later session is free to touch `scripts/lib/config/`, move
44
+ * `_parseExpressPath` to `scripts/lib/config/express-path.mjs` and leave only
45
+ * `evaluateExpressPath` here.
46
+ */
47
+
48
+ import { matchBlockHeader } from './config/block-header.mjs';
49
+
50
+ /**
51
+ * The one event name this module emits. Named, not inlined, so a consumer that
52
+ * queries the ledger and the producer that writes it cannot drift apart.
53
+ * Matches `ORCHESTRATOR_EVENT_RE` in `events-schema.mjs`.
54
+ */
55
+ export const EXPRESS_PATH_EVENT = 'orchestrator.express_path.evaluated';
56
+
57
+ /**
58
+ * Condition 3, first clause: "Agreed issue scope is ≤ 3 issues"
59
+ * (`skills/session-start/phase-8-5-express-path.md:11`).
60
+ */
61
+ export const EXPRESS_PATH_MAX_TASKS = 3;
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Config parsing
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Parse the top-level `express-path:` YAML block from markdown content.
69
+ * Independent of the `## Session Config` section boundary, exactly like the
70
+ * sibling block parsers under `scripts/lib/config/`.
71
+ *
72
+ * Defaults:
73
+ * enabled: true (documented in docs/session-config-reference.md:1528 —
74
+ * "opt-in by default, opt-out via express-path.enabled: false")
75
+ *
76
+ * Because the default is `true`, only a literal `false` flips it — mirroring
77
+ * `config/state-md-lock.mjs`, whose `enabled` default is also `true`. (The
78
+ * `=== 'true'` form used by `config/discovery-validator.mjs` is the opposite
79
+ * convention and belongs to a default-`false` key.)
80
+ *
81
+ * Tolerant parser: malformed values silently fall back to the default.
82
+ *
83
+ * @param {string} content — full file contents
84
+ * @returns {{ enabled: boolean }}
85
+ */
86
+ export function _parseExpressPath(content) {
87
+ const defaults = {
88
+ enabled: true,
89
+ };
90
+
91
+ const lines = content.split(/\r?\n/);
92
+ let inBlock = false;
93
+ const blockLines = [];
94
+
95
+ for (const rawLine of lines) {
96
+ const line = rawLine.replace(/\r$/, '');
97
+ if (!inBlock) {
98
+ if (matchBlockHeader(line, 'express-path')) inBlock = true;
99
+ continue;
100
+ }
101
+ if (line.length > 0 && !/^\s/.test(line)) break;
102
+ blockLines.push(line);
103
+ }
104
+
105
+ if (blockLines.length === 0) return defaults;
106
+
107
+ let epEnabled = true;
108
+
109
+ for (const rawLine of blockLines) {
110
+ const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
111
+ if (!clean.trim()) continue;
112
+
113
+ const kvMatch = clean.match(/^\s+([a-zA-Z_-]+):\s*(.*)/);
114
+ if (!kvMatch) continue;
115
+
116
+ const k = kvMatch[1];
117
+ let v = kvMatch[2].trim();
118
+ if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
119
+ else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
120
+
121
+ switch (k) {
122
+ case 'enabled':
123
+ // Default is true → only flip to false on explicit "false"
124
+ epEnabled = v.toLowerCase() !== 'false';
125
+ break;
126
+ }
127
+ }
128
+
129
+ return {
130
+ enabled: epEnabled,
131
+ };
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // The decision
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /**
139
+ * Read `express-path.enabled` out of a `parseSessionConfig()` result.
140
+ *
141
+ * Returns `undefined` — NOT `true` — when the flag was never measured, so the
142
+ * caller can distinguish "the operator set true" from "nobody looked". The
143
+ * documented default is applied to the DECISION; the absent measurement is kept
144
+ * out of the RECORD.
145
+ *
146
+ * @param {unknown} config — a `parseSessionConfig()` result, or anything else
147
+ * @returns {boolean|undefined}
148
+ */
149
+ function _readEnabledFlag(config) {
150
+ if (config === null || typeof config !== 'object') return undefined;
151
+ const block = /** @type {Record<string, unknown>} */ (config)['express-path'];
152
+ if (block === null || typeof block !== 'object') return undefined;
153
+ const value = /** @type {Record<string, unknown>} */ (block).enabled;
154
+ return typeof value === 'boolean' ? value : undefined;
155
+ }
156
+
157
+ /**
158
+ * Build the event payload. Every optional field is spread conditionally:
159
+ * a value that was not measured is OMITTED, never encoded as `0`/`false`.
160
+ * An absent key is the only honest encoding of "not measured" — the same
161
+ * contract `sessionAttribution()` in `events.mjs` follows for session ids.
162
+ *
163
+ * @param {object} ctx
164
+ * @returns {Record<string, unknown>}
165
+ */
166
+ function _buildPayload(ctx) {
167
+ const { activated, reasons, enabledMeasured, sessionType, taskCount, parallelAgentsRequired } = ctx;
168
+ const sessionTypeMeasured = typeof sessionType === 'string' && sessionType.trim() !== '';
169
+ const taskCountMeasured = Number.isInteger(taskCount) && taskCount >= 0;
170
+
171
+ return {
172
+ activated,
173
+ reasons,
174
+ ...(enabledMeasured === undefined ? {} : { enabled: enabledMeasured }),
175
+ ...(sessionTypeMeasured ? { session_type: sessionType.trim() } : {}),
176
+ ...(taskCountMeasured ? { task_count: taskCount } : {}),
177
+ ...(typeof parallelAgentsRequired === 'boolean'
178
+ ? { parallel_agents_required: parallelAgentsRequired }
179
+ : {}),
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Append the evaluation record. Best-effort in the strict sense: it can never
185
+ * change, delay past its own await, or throw into the verdict.
186
+ *
187
+ * `events.mjs` is imported LAZILY on purpose, and the reason is measured rather
188
+ * than stylistic. `scripts/lib/config.mjs` imports this module for its parser,
189
+ * and its static import graph is 48 files today with no telemetry or platform
190
+ * modules in it. A static `import './events.mjs'` would add `platform.mjs`,
191
+ * which runs filesystem walk-ups at MODULE LOAD (`SO_PROJECT_DIR =
192
+ * resolveProjectDir(...)`) — paid by every hook and script that merely reads
193
+ * config. The lazy form also puts the telemetry module's own load inside the
194
+ * try/catch, so a broken events stack cannot stop the decision from being made.
195
+ *
196
+ * @param {object} ctx — see `_buildPayload`, plus `repoRoot`
197
+ * @returns {Promise<void>}
198
+ */
199
+ async function _emitEvaluated(ctx) {
200
+ const { repoRoot } = ctx;
201
+
202
+ // Refuse the SO_PROJECT_DIR fallback rather than guess a destination. Without
203
+ // an explicit repoRoot, `emitEvent` writes to whatever tree the ambient env
204
+ // resolves to — which in wave 1 of this session put a synthetic record into
205
+ // the operator's real fleet ledger. A skipped record is recoverable; a record
206
+ // in the wrong ledger is not. Diagnostics on stderr, never stdout.
207
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
208
+ process.stderr.write(
209
+ `express-path: skipped ${EXPRESS_PATH_EVENT} — no repoRoot given; ` +
210
+ 'refusing the ambient SO_PROJECT_DIR destination (#941).\n',
211
+ );
212
+ return;
213
+ }
214
+
215
+ try {
216
+ const { emitEvent, sessionAttribution } = await import('./events.mjs');
217
+ await emitEvent(
218
+ EXPRESS_PATH_EVENT,
219
+ { ..._buildPayload(ctx), ...sessionAttribution(repoRoot) },
220
+ { repoRoot },
221
+ );
222
+ } catch {
223
+ // Best-effort telemetry — the verdict below is authoritative and is
224
+ // returned whether or not the ledger accepted the record.
225
+ }
226
+ }
227
+
228
+ /**
229
+ * @typedef {object} ExpressPathVerdict
230
+ * @property {boolean} activated — true only when every condition below holds.
231
+ * @property {string[]} reasons — when NOT activated, the blocking condition
232
+ * codes and nothing else: the answer to "why didn't it fire". When activated,
233
+ * the satisfied codes, since all of them decided jointly. Stable, greppable
234
+ * slugs — they are the query key for the ledger.
235
+ */
236
+
237
+ /**
238
+ * Evaluate the Express Path activation conditions and record the evaluation.
239
+ *
240
+ * The three conditions, quoted from `skills/session-start/phase-8-5-express-path.md:9-11`:
241
+ *
242
+ * 1. "`express-path.enabled` is `true` in Session Config (default: `true` —
243
+ * opt-in by default, opt-out via `express-path.enabled: false`)."
244
+ * 2. "Session type is `housekeeping` (the user confirmed `housekeeping` in Phase 8)."
245
+ * 3. "Agreed issue scope is ≤ 3 issues AND no parallel agents are required
246
+ * (i.e., tasks are sequential, no wave decomposition needed)."
247
+ *
248
+ * Every condition is evaluated — none short-circuits — because a run blocked by
249
+ * two conditions is a different fact from one blocked by a single condition, and
250
+ * the ledger is the only place that difference can ever be read.
251
+ *
252
+ * Fail-closed on an unmeasured input: an unknown session type or an unknown task
253
+ * count BLOCKS activation (`session-type-unknown` / `task-count-unknown`) rather
254
+ * than defaulting into a fast path that skips every quality gate. `enabled` is
255
+ * the one exception — its documented default is `true`, so an absent config
256
+ * applies that default to the decision while omitting `enabled` from the record.
257
+ *
258
+ * @param {object} [opts]
259
+ * @param {string} [opts.repoRoot] — repo whose `.orchestrator/metrics/events.jsonl`
260
+ * receives the record. REQUIRED for the record to be written; when absent the
261
+ * verdict is still returned and a WARN goes to stderr (see `_emitEvaluated`).
262
+ * @param {object} [opts.config] — a `parseSessionConfig()` result. Read for
263
+ * `config['express-path'].enabled` only.
264
+ * @param {string} [opts.sessionType] — the session type confirmed in Phase 8.
265
+ * @param {number} [opts.taskCount] — agreed issue/task scope (non-negative integer).
266
+ * @param {boolean} [opts.parallelAgentsRequired] — whether the agreed tasks need
267
+ * parallel agents. Omitting it asserts nothing: the condition is treated as
268
+ * satisfied (`parallel-agents-not-asserted`) and the field is left out of the
269
+ * record, because "nobody said parallel agents are needed" is not the same
270
+ * claim as "it was measured that none are needed".
271
+ * @returns {Promise<ExpressPathVerdict>}
272
+ */
273
+ export async function evaluateExpressPath(opts = {}) {
274
+ const { repoRoot, config, sessionType, taskCount, parallelAgentsRequired } = opts;
275
+
276
+ const enabledMeasured = _readEnabledFlag(config);
277
+ const enabled = enabledMeasured === undefined ? true : enabledMeasured;
278
+
279
+ /** @type {string[]} */ const satisfied = [];
280
+ /** @type {string[]} */ const blocking = [];
281
+
282
+ // Condition 1 — express-path.enabled
283
+ if (enabled) satisfied.push('enabled');
284
+ else blocking.push('disabled-by-config');
285
+
286
+ // Condition 2 — session type is housekeeping
287
+ if (typeof sessionType !== 'string' || sessionType.trim() === '') {
288
+ blocking.push('session-type-unknown');
289
+ } else if (sessionType.trim() === 'housekeeping') {
290
+ satisfied.push('session-type-housekeeping');
291
+ } else {
292
+ blocking.push('session-type-not-housekeeping');
293
+ }
294
+
295
+ // Condition 3a — scope ≤ EXPRESS_PATH_MAX_TASKS issues
296
+ if (!Number.isInteger(taskCount) || taskCount < 0) {
297
+ blocking.push('task-count-unknown');
298
+ } else if (taskCount > EXPRESS_PATH_MAX_TASKS) {
299
+ blocking.push('scope-exceeds-limit');
300
+ } else {
301
+ satisfied.push('scope-within-limit');
302
+ }
303
+
304
+ // Condition 3b — no parallel agents required
305
+ if (parallelAgentsRequired === true) {
306
+ blocking.push('parallel-agents-required');
307
+ } else if (parallelAgentsRequired === false) {
308
+ satisfied.push('no-parallel-agents');
309
+ } else {
310
+ satisfied.push('parallel-agents-not-asserted');
311
+ }
312
+
313
+ const activated = blocking.length === 0;
314
+ const reasons = activated ? satisfied : blocking;
315
+
316
+ await _emitEvaluated({
317
+ repoRoot,
318
+ activated,
319
+ reasons,
320
+ enabledMeasured,
321
+ sessionType,
322
+ taskCount,
323
+ parallelAgentsRequired,
324
+ });
325
+
326
+ return { activated, reasons };
327
+ }
@@ -12,8 +12,10 @@
12
12
  * 4. live holder OR cross-host → poll until a deadline;
13
13
  * 5. owner-guarded release.
14
14
  *
15
- * This module is the single home for that skeleton. It is a PURE primitive:
16
- * it imports ONLY scripts/lib/io.mjs (for writeJsonAtomicSync) and Node stdlib.
15
+ * This module is the single home for that skeleton. It is a near-PURE primitive:
16
+ * it imports ONLY scripts/lib/io.mjs (for writeJsonAtomicSync), the two host
17
+ * identity helpers from scripts/lib/host-identity.mjs (#1072 — `os.hostname()`
18
+ * is not stable on a single machine), and Node stdlib.
17
19
  * It does NOT import session-lock.mjs — instead `isPidAliveOnHost` is MOVED here
18
20
  * and re-exported from session-lock.mjs, so the dependency edge points
19
21
  * file-lock → io, never the reverse (no import cycle).
@@ -35,6 +37,7 @@ import path from 'node:path';
35
37
  import crypto from 'node:crypto';
36
38
 
37
39
  import { writeJsonAtomicSync } from './io.mjs';
40
+ import { hostnamesMatch, lockHostCandidate, stableHostname } from './host-identity.mjs';
38
41
 
39
42
  // ---------------------------------------------------------------------------
40
43
  // PID liveness (moved here from session-lock.mjs in #630)
@@ -139,7 +142,17 @@ function parseBody(raw) {
139
142
  * @returns {{ stale: boolean, reason: string }}
140
143
  */
141
144
  function isExistingStale(existing, staleCheck, staleMs, lockPath) {
142
- const sameHost = existing.host === os.hostname();
145
+ // #1072: compare on the normalised/alias-aware identity, never on a raw
146
+ // `os.hostname()` string. A raw comparison reports `cross-host` for THIS
147
+ // machine's own lock whenever the hostname has flipped spelling since it was
148
+ // written, and `cross-host` disables stale detection entirely (below) — the
149
+ // lock then survives its holder for the full timeout. `host_id` is preferred
150
+ // when present because it is already normalised; `host` is the pre-#1072
151
+ // fallback, so old lock files need no migration. `lockHostCandidate` owns
152
+ // that choice for all six lock-family call sites — and falls back on an
153
+ // EMPTY `host_id` too, which a bare `??` would have short-circuited into a
154
+ // false `cross-host` verdict against this machine's own lock.
155
+ const sameHost = hostnamesMatch(lockHostCandidate(existing), os.hostname());
143
156
  if (!sameHost) return { stale: false, reason: 'cross-host' };
144
157
 
145
158
  if (staleCheck === 'none') return { stale: false, reason: 'live' };
@@ -294,7 +307,10 @@ export function tryAcquireFileLock(lockPath, opts = {}) {
294
307
 
295
308
  const body = {
296
309
  pid: process.pid,
310
+ // `host` stays the RAW hostname (on-the-wire field other readers pin);
311
+ // `host_id` is the additive normalised twin the comparisons above use.
297
312
  host: os.hostname(),
313
+ host_id: stableHostname(),
298
314
  acquiredAt: new Date().toISOString(),
299
315
  ...(meta && typeof meta === 'object' ? meta : {}),
300
316
  };
@@ -407,7 +423,9 @@ export function releaseFileLock(lockPath, opts = {}) {
407
423
  const expectedHolder = typeof holder === 'string' && holder.length > 0 ? holder : null;
408
424
  const ownerMatch = expectedHolder !== null
409
425
  ? lock.holder === expectedHolder
410
- : lock.pid === process.pid && lock.host === os.hostname();
426
+ // #1072: a raw hostname comparison here makes a process unable to release
427
+ // its OWN lock after the hostname flips spelling mid-session.
428
+ : lock.pid === process.pid && hostnamesMatch(lockHostCandidate(lock), os.hostname());
411
429
 
412
430
  if (!ownerMatch) return { ok: false, reason: 'not-owner' };
413
431
 
@@ -40,14 +40,42 @@ const tcErrorCount =
40
40
  : 0;
41
41
 
42
42
  // --- Test ---
43
- // NOTE: `testFailed`, NOT `failed` — a local `failed` is already bound near the
44
- // bottom of this file and drives `process.exit(failed ? 2 : 0)`. Shadowing it
45
- // would corrupt the gate's exit code.
43
+ // NOTE: `testCounts`, NOT `failed` — a local `failed` is already bound near
44
+ // the bottom of this file and drives `process.exit(failed ? 2 : 0)`.
45
+ // Shadowing it would corrupt the gate's exit code.
46
46
  const testResult = runCheck(testCmd);
47
- const { passed: testPassed, failed: testFailed, total: testTotal } =
47
+ const testCounts =
48
48
  testResult.status !== 'skip'
49
49
  ? extractTestCounts(testResult.fullOutput ?? testResult.output)
50
- : { passed: 0, failed: 0, total: 0 };
50
+ : { passed: 0, failed: 0, total: 0, files: null };
51
+
52
+ // A suite that dies at import time (a bad import, a syntax error in a test
53
+ // file) is counted on vitest's `Test Files` line, not its `Tests` line —
54
+ // vitest omits `N failed` from `Tests` when zero individual test CASES ran.
55
+ // That leaves `status: 'fail'` sitting beside `failed: 0`, which reads as "no
56
+ // failures" even though the gate is about to block. `suite_died` names the
57
+ // contradiction explicitly, in the JSON itself, rather than leaving it
58
+ // inferable only by cross-referencing three separate fields (#1149).
59
+ //
60
+ // All FOUR of these fields are file-measurement-derived, so all four are
61
+ // published together or not at all. Only vitest prints a `Test Files` line; a
62
+ // non-vitest runner or a terse reporter prints none, and the previous
63
+ // zero-filled `files_*: 0` was an UNMEASURED zero the envelope could not tell
64
+ // from a measured one — with `suite_died: false` derived from it, stating a
65
+ // verdict nobody had checked. Absent, not zero: the same contract this
66
+ // envelope's `counts` field already keeps (`admitSuiteCounts`).
67
+ const fileFields = testCounts.files
68
+ ? {
69
+ files_total: testCounts.files.total,
70
+ files_passed: testCounts.files.passed,
71
+ files_failed: testCounts.files.failed,
72
+ // Self-diagnosing: true exactly when `status: 'fail'` sits beside a
73
+ // test-case `failed: 0` that a file-level failure explains. Greppable —
74
+ // a consumer no longer has to recompute the contradiction by hand.
75
+ suite_died:
76
+ testResult.status === 'fail' && testCounts.failed === 0 && testCounts.files.failed > 0,
77
+ }
78
+ : {};
51
79
 
52
80
  // --- Lint ---
53
81
  const lintResult = runCheck(lintCmd);
@@ -79,9 +107,15 @@ const output = {
79
107
  // `passed + failed === total` as a real producer/consumer drift guard.
80
108
  test: {
81
109
  status: testResult.status,
82
- total: testTotal,
83
- passed: testPassed,
84
- failed: testFailed,
110
+ total: testCounts.total,
111
+ passed: testCounts.passed,
112
+ failed: testCounts.failed,
113
+ // File-level counts + `suite_died` (#1149) — present only when a
114
+ // `Test Files` line was actually parsed; see `fileFields` above.
115
+ // Additive keys only: `admitSuiteCounts` / `suiteCountsFromGateStdout`
116
+ // read just passed/failed/total, so the stdout JSON contract stays one
117
+ // document and the test-case triple keeps `total === passed + failed`.
118
+ ...fileFields,
85
119
  },
86
120
  lint: { status: lintResult.status, warnings: lintWarnings },
87
121
  debug_artifacts: debugArtifacts,
@@ -94,4 +128,43 @@ process.stdout.write(JSON.stringify(output) + '\n');
94
128
  const failed = [tcResult, testResult, lintResult].some(
95
129
  (r) => r.status === 'fail',
96
130
  );
131
+
132
+ // --- Failure disclosure ---
133
+ // A gate that BLOCKS and says nothing is not concise, it is unusable. Measured
134
+ // 2026-08-23: a pre-push run emitted exactly
135
+ // {"test":{"status":"fail","total":14671,"passed":14671,"failed":0}, …}
136
+ // and nothing else. `failed: 0` beside `status: fail` was not a contradiction
137
+ // in the data — it was the SHAPE of the report: `extractTestCounts` reads
138
+ // vitest's `Tests …` summary line, and vitest OMITS the `N failed` segment
139
+ // when no individual test case failed. A suite that dies at import time is
140
+ // counted on the `Test Files …` line instead, which this payload had no field
141
+ // for. So a file-level failure was, by construction, reported as zero
142
+ // failures. The JSON envelope now carries `files_failed` and `suite_died`
143
+ // (#1149) so that contradiction is published rather than only inferable —
144
+ // but the envelope still does not name WHICH file died.
145
+ //
146
+ // Reconstructing which file died then cost a full manual re-materialisation of
147
+ // the tracked tree. That is the cost this block removes: on failure the raw
148
+ // output of every failing gate goes to STDERR, where the JSON contract on
149
+ // STDOUT is untouched and every existing consumer keeps parsing one line.
150
+ //
151
+ // stderr, not stdout, and only on failure, for three reasons that are all
152
+ // contract-preserving: `scripts/run-quality-gate.mjs` and the pre-push hook
153
+ // parse stdout as ONE JSON document; a green run stays silent, so the "~2 min
154
+ // of silence" the hook promises still holds; and the operator gets the failure
155
+ // at the moment of the block instead of a second run to find it.
156
+ if (failed) {
157
+ for (const [name, result] of [
158
+ ['typecheck', tcResult],
159
+ ['test', testResult],
160
+ ['lint', lintResult],
161
+ ]) {
162
+ if (result.status !== 'fail') continue;
163
+ const raw = result.fullOutput ?? result.output ?? '';
164
+ process.stderr.write(`\n──── ${name} FAILED — raw output ────\n`);
165
+ process.stderr.write(raw.length > 0 ? raw : '(the gate captured no output)\n');
166
+ process.stderr.write(`──── end ${name} ────\n`);
167
+ }
168
+ }
169
+
97
170
  process.exit(failed ? 2 : 0);
@@ -154,7 +154,35 @@ export function extractCount(output, pattern) {
154
154
  const TEST_SUMMARY_LINE = /^\s*Tests\b/;
155
155
 
156
156
  /**
157
- * Parse test-runner output for pass/fail/total TEST-CASE counts.
157
+ * Matches vitest's FILE-level summary line (`Test Files 8 failed | 595 passed (603)`).
158
+ *
159
+ * Distinct from {@link TEST_SUMMARY_LINE} (the TEST-CASE line, `Tests ...`): a
160
+ * suite that dies at import time is counted HERE, and the `Tests` line then
161
+ * carries no `N failed` segment at all (#1149) — vitest omits it whenever zero
162
+ * individual test cases ran. Last matching line wins (the final summary after
163
+ * any rerun), mirroring `TEST_SUMMARY_LINE`'s own convention.
164
+ */
165
+ const TEST_FILES_SUMMARY_LINE = /^\s*Test Files\b/;
166
+
167
+ /**
168
+ * Parse a `<N> passed` / `<N> failed` pair out of one summary line/scope.
169
+ * `total` is their sum (skipped excluded) — the same rule the top-level
170
+ * test-case triple in {@link extractTestCounts} publishes.
171
+ *
172
+ * @param {string} scope
173
+ * @returns {{ passed: number, failed: number, total: number }}
174
+ */
175
+ function parseCountTriple(scope) {
176
+ const passMatch = scope.match(/(\d+)\s+passed/);
177
+ const failMatch = scope.match(/(\d+)\s+failed/);
178
+ const passed = passMatch ? parseInt(passMatch[1], 10) : 0;
179
+ const failed = failMatch ? parseInt(failMatch[1], 10) : 0;
180
+ return { passed, failed, total: passed + failed };
181
+ }
182
+
183
+ /**
184
+ * Parse test-runner output for pass/fail/total TEST-CASE counts, plus the
185
+ * vitest FILE-level triple when a `Test Files` summary line is present.
158
186
  *
159
187
  * ## Which line is parsed
160
188
  *
@@ -168,10 +196,38 @@ const TEST_SUMMARY_LINE = /^\s*Tests\b/;
168
196
  * A naive whole-output scan hits the FILE count (550) and publishes it as the
169
197
  * test count — the number then rides `gate-full.mjs`'s `test.passed` into the
170
198
  * `orchestrator.quality_gate.*` event stream looking authoritative. So: when a
171
- * `Tests`-anchored summary line exists, ONLY that line is parsed (the LAST one,
172
- * which is the final summary after any rerun). Terse or non-vitest output with
173
- * no such line falls back to scanning the whole string, which preserves the
174
- * bare `"42 passed"` / `"10 passed, 5 failed"` forms.
199
+ * `Tests`-anchored summary line exists, ONLY that line is parsed for the
200
+ * test-case triple (the LAST one, which is the final summary after any
201
+ * rerun). Terse or non-vitest output with no such line falls back to scanning
202
+ * the whole string, which preserves the bare `"42 passed"` / `"10 passed, 5
203
+ * failed"` forms.
204
+ *
205
+ * The `Test Files` line is parsed the same way, into a SEPARATE nested `files`
206
+ * triple — never folded into `failed`/`total`. `total === passed + failed` is
207
+ * load-bearing for the test-case triple (`admitSuiteCounts`, and historically
208
+ * `failed = total - passed` in `run-quality-gate.mjs`); mixing file counts in
209
+ * would either break that invariant or invent phantom test-case failures. A
210
+ * suite that dies at import is invisible on the `Tests` line (vitest omits
211
+ * `N failed` there when zero test cases ran) and visible only on `Test
212
+ * Files` — #1149, measured as
213
+ * `{"test":{"status":"fail","total":14671,"passed":14671,"failed":0}}`, with
214
+ * no field anywhere naming the 8 files that actually died.
215
+ * `gate-full.mjs` derives a `suite_died` boolean from `files.failed` for
216
+ * exactly this shape.
217
+ *
218
+ * ## `files` is NULL when no `Test Files` line was seen — never a zero triple
219
+ *
220
+ * Only vitest prints that line. A non-vitest runner, a terse reporter, or a
221
+ * fail-fast crash produces output with no file-level summary at all, and the
222
+ * function used to publish `files_total/passed/failed: 0` there — an UNMEASURED
223
+ * zero, byte-identical in the envelope to a measured one, from which
224
+ * `gate-full.mjs` then derived `suite_died: false` as if it had checked. That is
225
+ * the same "absent is not zero" defect `admitSuiteCounts` exists to prevent one
226
+ * field over, so it uses the same channel: `null` means "no file-level
227
+ * measurement", and the caller OMITS the keys rather than zero-filling them.
228
+ * The test-case triple keeps its zeros — those are the caller's fallback, not a
229
+ * parse result, and `total: 0` is already the "no counts" signal
230
+ * `admitSuiteCounts` rejects.
175
231
  *
176
232
  * ## What `total` means — passed + failed, NOT vitest's parenthesised number
177
233
  *
@@ -189,22 +245,27 @@ const TEST_SUMMARY_LINE = /^\s*Tests\b/;
189
245
  * see. Add it together with a consumer, never ahead of one.
190
246
  *
191
247
  * @param {string} output - Captured test-runner stdout/stderr (or a tail of it).
192
- * @returns {{ passed: number, failed: number, total: number }} `total === passed + failed`.
248
+ * @returns {{ passed: number, failed: number, total: number, files: {passed: number, failed: number, total: number}|null }}
249
+ * Test-case `total === passed + failed`. `files` is `null` when the output
250
+ * carried no `Test Files` summary line; when present,
251
+ * `files.total === files.passed + files.failed`.
193
252
  */
194
253
  export function extractTestCounts(output) {
195
- if (!output) return { passed: 0, failed: 0, total: 0 };
254
+ const EMPTY = { passed: 0, failed: 0, total: 0, files: null };
255
+ if (!output) return EMPTY;
196
256
 
197
- const summaryLines = output.split('\n').filter((line) => TEST_SUMMARY_LINE.test(line));
257
+ const lines = output.split('\n');
258
+ const summaryLines = lines.filter((line) => TEST_SUMMARY_LINE.test(line));
198
259
  const scope = summaryLines.length > 0 ? summaryLines[summaryLines.length - 1] : output;
260
+ const { passed, failed, total } = parseCountTriple(scope);
199
261
 
200
- const passMatch = scope.match(/(\d+)\s+passed/);
201
- const failMatch = scope.match(/(\d+)\s+failed/);
262
+ const fileSummaryLines = lines.filter((line) => TEST_FILES_SUMMARY_LINE.test(line));
263
+ const fileScope = fileSummaryLines.length > 0 ? fileSummaryLines[fileSummaryLines.length - 1] : '';
264
+ // No `Test Files` line → NOT MEASURED. See the docstring section above: a zero
265
+ // triple here would be indistinguishable from a real all-passed-zero-files run.
266
+ const files = fileScope ? parseCountTriple(fileScope) : null;
202
267
 
203
- const passed = passMatch ? parseInt(passMatch[1], 10) : 0;
204
- const failed = failMatch ? parseInt(failMatch[1], 10) : 0;
205
- const total = passed + failed;
206
-
207
- return { passed, failed, total };
268
+ return { passed, failed, total, files };
208
269
  }
209
270
 
210
271
  /**