session-orchestrator 5.3.0 → 5.5.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -50,12 +50,16 @@ import { loadQualityGatesPolicy, resolveCommand } from './lib/quality-gates-poli
50
50
  import { emitEvent, sessionAttribution } from './lib/events.mjs';
51
51
  import {
52
52
  admitSuiteCounts,
53
+ joinGateKillFields,
53
54
  publishGateOutcome,
54
55
  resolveGateTimeoutMs,
55
56
  } from './lib/gates/gate-helpers.mjs';
56
57
  import { buildCommandSignature, spawnInGroup } from './lib/process-group.mjs';
57
- import { readProcessLocalSessionIds } from './lib/session-identity/own-session.mjs';
58
- import { findScopeFile } from './lib/scope-gate.mjs';
58
+ import { classifyManifestSession, readProcessLocalSessionIds } from './lib/session-identity/own-session.mjs';
59
+ import { findOwnScopeFile } from './lib/scope-gate.mjs';
60
+ import { gateKillFields } from './lib/quality-gate.mjs';
61
+ import { isMainModule } from './lib/is-main-module.mjs';
62
+ import { shellQuote } from './lib/sh-quote.mjs';
59
63
 
60
64
  // ---------------------------------------------------------------------------
61
65
  // Constants
@@ -104,64 +108,6 @@ const GATE_SCRIPT = {
104
108
  'per-file': join(GATES_DIR, 'gate-per-file.mjs'),
105
109
  };
106
110
 
107
- // ---------------------------------------------------------------------------
108
- // Argument parsing
109
- // ---------------------------------------------------------------------------
110
-
111
- const argv = process.argv.slice(2);
112
-
113
- if (argv.includes('-h') || argv.includes('--help')) {
114
- process.stdout.write(
115
- 'Usage: run-quality-gate.mjs --variant <variant> [--config <json-or-file>] ' +
116
- '[--files <file1,file2,...>] [--session-start-ref <ref>] [--ledger-root <path>]\n\n' +
117
- 'Variants: baseline, incremental, full-gate, per-file\n\n' +
118
- 'Exit codes:\n' +
119
- ' 0 — pass (non-blocking variants always exit 0)\n' +
120
- ' 1 — script error (bad arguments, missing dependencies)\n' +
121
- ' 2 — gate failed (full-gate only)\n' +
122
- ' 124 — gate timed out; its process group was killed (SIGTERM→SIGKILL)\n',
123
- );
124
- process.exit(0);
125
- }
126
-
127
- let variant = '';
128
- let config = '';
129
- let files = '';
130
- let sessionStartRef = '';
131
- let ledgerRootArg = '';
132
-
133
- for (let i = 0; i < argv.length; i++) {
134
- const arg = argv[i];
135
- switch (arg) {
136
- case '--variant':
137
- if (i + 1 >= argv.length) die('Missing value for --variant');
138
- variant = argv[++i];
139
- break;
140
- case '--config':
141
- if (i + 1 >= argv.length) die('Missing value for --config');
142
- config = argv[++i];
143
- break;
144
- case '--files':
145
- if (i + 1 >= argv.length) die('Missing value for --files');
146
- files = argv[++i];
147
- break;
148
- case '--session-start-ref':
149
- if (i + 1 >= argv.length) die('Missing value for --session-start-ref');
150
- sessionStartRef = argv[++i];
151
- break;
152
- case '--ledger-root':
153
- if (i + 1 >= argv.length) die('Missing value for --ledger-root');
154
- ledgerRootArg = argv[++i];
155
- break;
156
- default:
157
- die(`Unknown argument: ${arg}`);
158
- }
159
- }
160
-
161
- if (!variant) die('Missing required argument: --variant');
162
- if (!VALID_VARIANTS.includes(variant)) {
163
- die(`Invalid variant: '${variant}' (allowed: ${VALID_VARIANTS.join(', ')})`);
164
- }
165
111
 
166
112
  // ---------------------------------------------------------------------------
167
113
  // Command resolution — policy-file-first (#183), then config, then defaults
@@ -210,9 +156,11 @@ function extractCommand(policy, policyKey, configKey, configJson, defaultCmd) {
210
156
  * The four rejections that stay here are envelope-shaped, not numeric:
211
157
  *
212
158
  * 1. stdout is absent or not parseable JSON;
213
- * 2. `test` is not the object form — only `gate-full.mjs` reports numbers;
214
- * `gate-{baseline,incremental,per-file}.mjs` emit a bare status STRING, so
215
- * a non-full-gate variant structurally cannot carry counts;
159
+ * 2. `test` is not an object. Every variant emits `test` as an object since
160
+ * #1457 pkt 12, but only `gate-full.mjs` puts counts in it —
161
+ * `gate-{baseline,incremental,per-file}.mjs` publish `{ status, …kill
162
+ * ladder }` only, which {@link admitSuiteCounts} refuses for lack of a
163
+ * finite `passed` / `total`;
216
164
  * 3. the test gate was skipped (`status` neither `pass` nor `fail`);
217
165
  * 4. the test COMMAND was detected as a stub (`echo …` / no-op) — a stub's
218
166
  * output parses to 0/0, which would be a fabricated zero.
@@ -256,7 +204,8 @@ function suiteCountsFromGateStdout(stdout) {
256
204
  * the key instead of publishing an empty array that reads as "no file failed".
257
205
  *
258
206
  * Only `gate-full.mjs` publishes the key, and only when the runner printed a
259
- * file-level summary; every other variant emits a bare status string.
207
+ * file-level summary; every other variant's `test` object carries only
208
+ * `status` and the kill ladder.
260
209
  *
261
210
  * Never throws.
262
211
  *
@@ -322,8 +271,10 @@ function resolveLedgerRoot(value) {
322
271
  *
323
272
  * Mirrors `resolveWave()` in `hooks/pre-bash-memory-propose-audit.mjs` — the
324
273
  * same `.{pi,cursor,codex,claude}/wave-scope.json` precedence via
325
- * {@link findScopeFile} — with ONE deliberate difference: the hook returns `0`
326
- * for "no wave-scope file", this returns `null`.
274
+ * {@link findOwnScopeFile}, skipping a PEER session's manifest in the same
275
+ * working copy (#1504: a peer-only result is no wave of ours, never the peer's
276
+ * number) — with ONE deliberate difference: the hook returns `0` for "no
277
+ * wave-scope file", this returns `null`.
327
278
  *
328
279
  * Absent is not zero. A human running `npm run quality-gate` from a `git push`
329
280
  * has no wave at all, and that is the common case; publishing `wave_number: 0`
@@ -340,7 +291,8 @@ function resolveLedgerRoot(value) {
340
291
  */
341
292
  function resolveWaveNumber(projectDir) {
342
293
  try {
343
- const waveFile = findScopeFile(projectDir);
294
+ const ownIds = new Set(readProcessLocalSessionIds({ env: process.env, hookInput: null }));
295
+ const { path: waveFile } = findOwnScopeFile(projectDir, ownIds, classifyManifestSession);
344
296
  if (!waveFile || !existsSync(waveFile)) return null;
345
297
  const wave = JSON.parse(readFileSync(waveFile, 'utf8'))?.wave;
346
298
  if (typeof wave !== 'number' || !Number.isFinite(wave) || wave <= 0) return null;
@@ -350,255 +302,327 @@ function resolveWaveNumber(projectDir) {
350
302
  }
351
303
  }
352
304
 
353
- // Load policy file (never throws)
354
- const repoRoot = process.cwd();
355
- const policy = loadQualityGatesPolicy(repoRoot);
356
-
357
- // Parse --config (JSON string or file path)
358
- let configJson = null;
359
- if (config) {
360
- if (existsSync(config)) {
361
- try {
362
- configJson = JSON.parse(readFileSync(config, 'utf8'));
363
- } catch (err) {
364
- warn(`Could not parse config file '${config}': ${err.message}; using defaults`);
305
+ // ---------------------------------------------------------------------------
306
+ // Main — the CLI body. Guarded below so a bare `import()` runs none of it.
307
+ // ---------------------------------------------------------------------------
308
+
309
+ async function main() {
310
+ // Argument parsing
311
+ const argv = process.argv.slice(2);
312
+
313
+ if (argv.includes('-h') || argv.includes('--help')) {
314
+ process.stdout.write(
315
+ 'Usage: run-quality-gate.mjs --variant <variant> [--config <json-or-file>] ' +
316
+ '[--files <file1,file2,...>] [--session-start-ref <ref>] [--ledger-root <path>]\n\n' +
317
+ 'Variants: baseline, incremental, full-gate, per-file\n\n' +
318
+ 'Exit codes:\n' +
319
+ ' 0 — pass (non-blocking variants always exit 0)\n' +
320
+ ' 1 — script error (bad arguments, missing dependencies)\n' +
321
+ ' 2 — gate failed (full-gate only)\n' +
322
+ ' 124 — gate timed out; its process group was killed (SIGTERM→SIGKILL)\n',
323
+ );
324
+ process.exit(0);
325
+ }
326
+
327
+ let variant = '';
328
+ let config = '';
329
+ let files = '';
330
+ let sessionStartRef = '';
331
+ let ledgerRootArg = '';
332
+
333
+ for (let i = 0; i < argv.length; i++) {
334
+ const arg = argv[i];
335
+ switch (arg) {
336
+ case '--variant':
337
+ if (i + 1 >= argv.length) die('Missing value for --variant');
338
+ variant = argv[++i];
339
+ break;
340
+ case '--config':
341
+ if (i + 1 >= argv.length) die('Missing value for --config');
342
+ config = argv[++i];
343
+ break;
344
+ case '--files':
345
+ if (i + 1 >= argv.length) die('Missing value for --files');
346
+ files = argv[++i];
347
+ break;
348
+ case '--session-start-ref':
349
+ if (i + 1 >= argv.length) die('Missing value for --session-start-ref');
350
+ sessionStartRef = argv[++i];
351
+ break;
352
+ case '--ledger-root':
353
+ if (i + 1 >= argv.length) die('Missing value for --ledger-root');
354
+ ledgerRootArg = argv[++i];
355
+ break;
356
+ default:
357
+ die(`Unknown argument: ${arg}`);
365
358
  }
366
- } else {
367
- try {
368
- configJson = JSON.parse(config);
369
- } catch {
370
- warn('Config is neither a valid file path nor valid JSON; using defaults');
359
+ }
360
+
361
+ if (!variant) die('Missing required argument: --variant');
362
+ if (!VALID_VARIANTS.includes(variant)) {
363
+ die(`Invalid variant: '${variant}' (allowed: ${VALID_VARIANTS.join(', ')})`);
364
+ }
365
+
366
+ // Load policy file (never throws)
367
+ const repoRoot = process.cwd();
368
+ const policy = loadQualityGatesPolicy(repoRoot);
369
+
370
+ // Parse --config (JSON string or file path)
371
+ let configJson = null;
372
+ if (config) {
373
+ if (existsSync(config)) {
374
+ try {
375
+ configJson = JSON.parse(readFileSync(config, 'utf8'));
376
+ } catch (err) {
377
+ warn(`Could not parse config file '${config}': ${err.message}; using defaults`);
378
+ }
379
+ } else {
380
+ try {
381
+ configJson = JSON.parse(config);
382
+ } catch {
383
+ warn('Config is neither a valid file path nor valid JSON; using defaults');
384
+ }
371
385
  }
372
386
  }
373
- }
374
387
 
375
- const TYPECHECK_CMD = extractCommand(policy, 'typecheck', 'typecheck-command', configJson, DEFAULT_TYPECHECK_CMD);
376
- const TEST_CMD = extractCommand(policy, 'test', 'test-command', configJson, DEFAULT_TEST_CMD);
377
- const LINT_CMD = extractCommand(policy, 'lint', 'lint-command', configJson, DEFAULT_LINT_CMD);
388
+ const TYPECHECK_CMD = extractCommand(policy, 'typecheck', 'typecheck-command', configJson, DEFAULT_TYPECHECK_CMD);
389
+ const TEST_CMD = extractCommand(policy, 'test', 'test-command', configJson, DEFAULT_TEST_CMD);
390
+ const LINT_CMD = extractCommand(policy, 'lint', 'lint-command', configJson, DEFAULT_LINT_CMD);
378
391
 
379
- // ---------------------------------------------------------------------------
380
- // Gate dispatch — shell-out to existing gate-*.mjs sub-scripts
381
- // ---------------------------------------------------------------------------
392
+ // ---------------------------------------------------------------------------
393
+ // Gate dispatch — shell-out to existing gate-*.mjs sub-scripts
394
+ // ---------------------------------------------------------------------------
382
395
 
383
- const gatePath = GATE_SCRIPT[variant];
396
+ const gatePath = GATE_SCRIPT[variant];
384
397
 
385
- if (!existsSync(gatePath)) {
386
- die(`Gate script not found: ${gatePath}`);
387
- }
398
+ if (!existsSync(gatePath)) {
399
+ die(`Gate script not found: ${gatePath}`);
400
+ }
388
401
 
389
- // Resolved BEFORE the spawn (it used to sit beside the telemetry block below):
390
- // the gate-process ledger the spawn writes is pinned to the same root as the
391
- // event, for the same reason — see the spawn's `repoRoot` comment.
392
- const ledgerRoot = resolveLedgerRoot(ledgerRootArg);
393
-
394
- // `npm_config_loglevel` is INHERITED by every descendant, and the pre-push hook
395
- // invokes this script as `npm run --silent quality-gate` — which sets it to
396
- // `silent`. That level then reached the gate's own children: `npm pack
397
- // --dry-run` emitted ZERO `npm notice` lines instead of 818 (measured
398
- // 2026-08-22), so the release leakage test saw an empty listing, and several
399
- // validate-plugin/e2e tests that shell out to npm went red the same way. Every
400
- // one of them passes under a bare `npm test` and fails only INSIDE the gate,
401
- // which is the hardest shape to diagnose and cost an hour of chasing phantoms.
402
- //
403
- // Pinned rather than deleted: an explicit level makes the gate's children
404
- // independent of how the gate itself was invoked. `--silent` still does its real
405
- // job — keeping THIS process's stdout to the single JSON envelope — because the
406
- // children's output is captured by `runCheck`, never streamed.
407
- /**
408
- * Per-command wall-clock ceiling for PATH B (#1425 A3 / #1432).
409
- *
410
- * Precedence, highest first:
411
- * 1. `SO_GATE_TIMEOUT_MS` — the OPERATOR override. It is inherited by every
412
- * descendant, so a gate child that re-derives the ceiling gets the same
413
- * answer; `resolveGateTimeoutMs()` already reads it.
414
- * 2. Session Config `gate.timeout-path-b-ms` — the COMMITTED default.
415
- * 3. `DEFAULT_GATE_TIMEOUT_MS` (900 000), via `resolveGateTimeoutMs()`.
416
- *
417
- * Published to the gate sub-script as `GATE_TIMEOUT_MS` — a DIFFERENT name from
418
- * the operator override on purpose: writing the resolved value back into
419
- * `SO_GATE_TIMEOUT_MS` would make a committed default indistinguishable from an
420
- * operator decision for every process further down the tree.
421
- */
422
- const configuredGateTimeoutMs = (() => {
423
- const block = configJson !== null && typeof configJson === 'object' ? configJson.gate : null;
424
- const raw = block && typeof block === 'object' ? block['timeout-path-b-ms'] : undefined;
425
- return Number.isFinite(raw) && raw > 0 ? raw : null;
426
- })();
427
- const operatorTimeoutOverride = (process.env.SO_GATE_TIMEOUT_MS || '').trim() !== '';
428
- const commandTimeoutMs = operatorTimeoutOverride || configuredGateTimeoutMs === null
429
- ? resolveGateTimeoutMs()
430
- : configuredGateTimeoutMs;
431
-
432
- // Gate-process REGISTER root — the same precedence the telemetry destination
433
- // uses below (`--ledger-root` > project-dir env > cwd), so a sandboxed test that
434
- // only sets CLAUDE_PROJECT_DIR never writes register lines into the checkout.
435
- const gateLedgerRoot =
436
- ledgerRoot ?? process.env.CLAUDE_PROJECT_DIR ?? process.env.CODEX_PROJECT_DIR ?? repoRoot;
437
-
438
- const env = {
439
- ...process.env,
440
- npm_config_loglevel: 'notice',
441
- TYPECHECK_CMD,
442
- TEST_CMD,
443
- LINT_CMD,
444
- GATE_TIMEOUT_MS: String(commandTimeoutMs),
445
- // Same resolution the wrapper uses for its OWN spawn (`--ledger-root` > repo
446
- // root): the gate sub-scripts pass it to runCheck() so every register line of
447
- // one gate run lands in ONE ledger (#1425 A4, W5 fix-pass).
448
- GATE_LEDGER_ROOT: gateLedgerRoot,
449
- FILES: files,
450
- SESSION_START_REF: sessionStartRef,
451
- };
402
+ // Resolved BEFORE the spawn (it used to sit beside the telemetry block below):
403
+ // the gate-process ledger the spawn writes is pinned to the same root as the
404
+ // event, for the same reason — see the spawn's `repoRoot` comment.
405
+ const ledgerRoot = resolveLedgerRoot(ledgerRootArg);
406
+
407
+ // `npm_config_loglevel` is INHERITED by every descendant, and the pre-push hook
408
+ // invokes this script as `npm run --silent quality-gate` — which sets it to
409
+ // `silent`. That level then reached the gate's own children: `npm pack
410
+ // --dry-run` emitted ZERO `npm notice` lines instead of 818 (measured
411
+ // 2026-08-22), so the release leakage test saw an empty listing, and several
412
+ // validate-plugin/e2e tests that shell out to npm went red the same way. Every
413
+ // one of them passes under a bare `npm test` and fails only INSIDE the gate,
414
+ // which is the hardest shape to diagnose and cost an hour of chasing phantoms.
415
+ //
416
+ // Pinned rather than deleted: an explicit level makes the gate's children
417
+ // independent of how the gate itself was invoked. `--silent` still does its real
418
+ // job — keeping THIS process's stdout to the single JSON envelope — because the
419
+ // children's output is captured by `runCheck`, never streamed.
420
+ /**
421
+ * Per-command wall-clock ceiling for PATH B (#1425 A3 / #1432).
422
+ *
423
+ * Precedence, highest first:
424
+ * 1. `SO_GATE_TIMEOUT_MS` — the OPERATOR override. It is inherited by every
425
+ * descendant, so a gate child that re-derives the ceiling gets the same
426
+ * answer; `resolveGateTimeoutMs()` already reads it.
427
+ * 2. Session Config `gate.timeout-path-b-ms` — the COMMITTED default.
428
+ * 3. `DEFAULT_GATE_TIMEOUT_MS` (900 000), via `resolveGateTimeoutMs()`.
429
+ *
430
+ * Published to the gate sub-script as `GATE_TIMEOUT_MS` — a DIFFERENT name from
431
+ * the operator override on purpose: writing the resolved value back into
432
+ * `SO_GATE_TIMEOUT_MS` would make a committed default indistinguishable from an
433
+ * operator decision for every process further down the tree.
434
+ */
435
+ const configuredGateTimeoutMs = (() => {
436
+ const block = configJson !== null && typeof configJson === 'object' ? configJson.gate : null;
437
+ const raw = block && typeof block === 'object' ? block['timeout-path-b-ms'] : undefined;
438
+ return Number.isFinite(raw) && raw > 0 ? raw : null;
439
+ })();
440
+ const operatorTimeoutOverride = (process.env.SO_GATE_TIMEOUT_MS || '').trim() !== '';
441
+ const commandTimeoutMs = operatorTimeoutOverride || configuredGateTimeoutMs === null
442
+ ? resolveGateTimeoutMs()
443
+ : configuredGateTimeoutMs;
444
+
445
+ // Gate-process REGISTER root — the same precedence the telemetry destination
446
+ // uses below (`--ledger-root` > project-dir env > cwd), so a sandboxed test that
447
+ // only sets CLAUDE_PROJECT_DIR never writes register lines into the checkout.
448
+ const gateLedgerRoot =
449
+ ledgerRoot ?? process.env.CLAUDE_PROJECT_DIR ?? process.env.CODEX_PROJECT_DIR ?? repoRoot;
450
+
451
+ const env = {
452
+ ...process.env,
453
+ npm_config_loglevel: 'notice',
454
+ TYPECHECK_CMD,
455
+ TEST_CMD,
456
+ LINT_CMD,
457
+ GATE_TIMEOUT_MS: String(commandTimeoutMs),
458
+ // Same resolution the wrapper uses for its OWN spawn (`--ledger-root` > repo
459
+ // root): the gate sub-scripts pass it to runCheck() so every register line of
460
+ // one gate run lands in ONE ledger (#1425 A4, W5 fix-pass).
461
+ GATE_LEDGER_ROOT: gateLedgerRoot,
462
+ FILES: files,
463
+ SESSION_START_REF: sessionStartRef,
464
+ };
465
+
466
+ // The gate sub-script runs as the LEADER OF ITS OWN PROCESS GROUP under a
467
+ // wall-clock ceiling (Epic #1425 A3). Before this, `spawnSync('node', [gatePath])`
468
+ // had NO timeout at all and no group semantics: a wedged `tsgo` or vitest worker
469
+ // two levels down was reparented to PPID 1 and kept its memory (2026-09-20: four
470
+ // such orphans, up to 8.0 GB RSS each, host at 13 % free).
471
+ //
472
+ // stdout is PIPED (not inherited) so the suite counts the gate already computed
473
+ // can be lifted straight off its JSON envelope into telemetry (#954) instead of
474
+ // travelling as prose through the STATE.md header. The envelope is re-emitted
475
+ // verbatim below, so the stdout contract is unchanged — a gate sub-script writes
476
+ // exactly one JSON line at the very end (its own child commands are captured by
477
+ // `runCheck`), so nothing streamed before and nothing streams now.
478
+ //
479
+ // stderr stays INHERITED, and that is why `spawnFn` is overridden here rather
480
+ // than left at its default: `spawnInGroup` merges stdout and stderr into one
481
+ // capture, which would interleave the gate's failure disclosure (hundreds of
482
+ // lines, #1149) into the single JSON document every consumer parses off stdout.
483
+ // Handing it a child with `stderr: 'inherit'` leaves `child.stderr` null, the
484
+ // module's own `child.stderr?.on(…)` a no-op, and the gate's warnings live on
485
+ // the operator's terminal exactly as before.
486
+ // Quoted: the gate path derives from `import.meta.url`, so a space in the
487
+ // checkout path must not split the command.
488
+ const gateCommand = `node ${shellQuote(gatePath)}`;
489
+ // Derived from the SAME resolved per-command ceiling published as
490
+ // `GATE_TIMEOUT_MS` above — not from `resolveGateTimeoutMs()` a second time.
491
+ // The outer cap must stay strictly ABOVE the inner one so the inner kill fires
492
+ // first and the gate can still print its envelope; re-deriving here would leave
493
+ // the outer cap at the 900 000 default while a committed
494
+ // `gate.timeout-path-b-ms` above it made the inner cap the later of the two.
495
+ const gateTimeoutMs = commandTimeoutMs + GATE_OUTER_TIMEOUT_RESERVE_MS;
496
+ const result = await spawnInGroup(gateCommand, {
497
+ cwd: repoRoot,
498
+ env,
499
+ timeoutMs: gateTimeoutMs,
500
+ maxOutputBytes: GATE_STDOUT_MAX_BUFFER_BYTES,
501
+ // The ledger is what the orphan reaper (#1425 B) reads to tell its OWN gate
502
+ // processes from every other `node` on the host, so it is pinned to the same
503
+ // root the telemetry is — under the pre-push hook the tree the gate runs in
504
+ // is deleted seconds later, and the record with it.
505
+ repoRoot: gateLedgerRoot,
506
+ commandSignature: buildCommandSignature(gateCommand),
507
+ // The OWNER the ledger records, from the PROCESS-LOCAL witness only — the same
508
+ // rule `scripts/lib/quality-gate.mjs` follows for the gate commands it spawns
509
+ // (`.claude/rules/identity-and-locks.md`: rank witnesses, never union them).
510
+ // `.orchestrator/session.lock` is deliberately NOT consulted: it is a
511
+ // repo-GLOBAL artefact any session in this working copy may hold, so reading
512
+ // it would stamp a PEER's id onto our own gate process and point the orphan
513
+ // reaper (#1425 B) at the wrong owner. No witness → `null`, which the ledger
514
+ // records as "owner unknown" — the state every record carried before this.
515
+ sessionId: [...readProcessLocalSessionIds({ env: process.env, hookInput: null })][0] ?? null,
516
+ // stdin is IGNORED, not inherited. A gate sub-script reads no stdin, but a
517
+ // DETACHED child is in its own process group and is therefore not the
518
+ // terminal's foreground group: the first read from an inherited TTY earns it
519
+ // SIGTTIN, which stops the whole group until the outer ceiling kills it. An
520
+ // ignored stdin turns that hang into an immediate EOF.
521
+ spawnFn: (command, options) => spawn(command, { ...options, stdio: ['ignore', 'pipe', 'inherit'] }),
522
+ });
523
+
524
+ // `pid: -1` is `spawnInGroup`'s spawn-failure channel (it never rejects).
525
+ if (result.pid === -1) {
526
+ die(`Failed to run gate script: ${result.fullOutput.trim()}`);
527
+ }
452
528
 
453
- /**
454
- * POSIX single-quote one argument for the shell `spawnInGroup` runs the command
455
- * through. The gate path is derived from `import.meta.url`, so it carries
456
- * whatever the checkout path carries — a space in it must not split the command.
457
- *
458
- * @param {string} value
459
- * @returns {string}
460
- */
461
- function shellQuote(value) {
462
- return `'${String(value).replace(/'/g, "'\\''")}'`;
529
+ // On timeout the child was KILLED before it could write its envelope, so its
530
+ // capture is at best a partial JSON document. Publishing that would hand every
531
+ // stdout consumer a parse error where a named failure belongs; publishing the
532
+ // partial text AND an envelope would break the one-document contract. So the
533
+ // capture goes to stderr, where the operator can still read it, and stdout
534
+ // carries a complete `gate-timeout` envelope instead.
535
+ //
536
+ // The DECISION lives in `publishGateOutcome` (gate-helpers.mjs) and only the
537
+ // WRITES live here: this branch needs a real 16-minute gate to reach, so while
538
+ // the decision was inline it was pinned by nothing.
539
+ const outcome = publishGateOutcome({ result, variant, timeoutMs: gateTimeoutMs });
540
+ const gateStdout = result.timedOut ? '' : result.fullOutput;
541
+ if (outcome.stderr) process.stderr.write(outcome.stderr);
542
+ if (outcome.stdout) process.stdout.write(outcome.stdout);
543
+ for (const line of outcome.warnings) warn(line);
544
+
545
+ // Quality-gate telemetry — one canonical event per gate run via emitEvent
546
+ // (single emission path). `sessionAttribution` is the shared helper in
547
+ // events.mjs (#941); this CLI wrapper runs against the CWD `repoRoot`, so the
548
+ // bare emitEvent destination (SO_PROJECT_DIR default) is correct here.
549
+ //
550
+ // EXCEPT under the pre-push hook, which is the one caller that runs the gate in
551
+ // a tree that is about to be DELETED. `.husky/pre-push` materialises the tracked
552
+ // tree into a temp dir and deliberately scrubs every `*PROJECT_DIR` name before
553
+ // invoking the gate there, so `getProjectDir()` resolves to that temp tree (it
554
+ // carries both a CLAUDE.md (or AGENTS.md) and a .git) and the record lands in
555
+ // `<tmp>/.orchestrator/metrics/events.jsonl`, which the hook's EXIT trap then
556
+ // removes. Measured 2026-09-06: a pre-push run that BLOCKED a push left no
557
+ // `orchestrator.quality_gate.failed` line in this repo's ledger at all — the
558
+ // gate failure was, by construction, the one event that could never be recorded.
559
+ //
560
+ // `--ledger-root` is that hook's channel for handing back the root it already
561
+ // knows (`git rev-parse --show-toplevel`, read BEFORE it cds). It pins ONLY the
562
+ // telemetry destination and the attribution root — every other path the gate
563
+ // resolves stays inside the tree actually under test, which is the whole point
564
+ // of the materialisation. Absent (every other caller) → unchanged behaviour:
565
+ // `emitEvent`'s own default resolution.
566
+ //
567
+ // It is an ARGV FLAG and not an env var, and that is load-bearing. Measured
568
+ // 2026-09-06 with the env-var form: `SO_GATE_LEDGER_ROOT=$tmp npx vitest run
569
+ // tests/scripts/run-quality-gate.test.mjs -t "telemetry emission"` → `8 failed |
570
+ // 1 passed`. The chain was: hook exports the var → `npm run quality-gate` →
571
+ // `gate-full.mjs` spawns `npm test` → every vitest worker inherits it → the
572
+ // suite's own gate spawns spread `...process.env`, so the pinned root outranked
573
+ // their per-test project dir and the gate's telemetry tests wrote to the hook's
574
+ // root. The gate that releases 4.0.0 would have blocked on itself. An env var is
575
+ // inherited by every descendant; a flag reaches exactly one process.
576
+ //
577
+ // Best-effort: a telemetry failure must NEVER alter the gate's authoritative
578
+ // exit code — which is why the counts parse also lives inside this try.
579
+ const exitCode = outcome.exitCode;
580
+ try {
581
+ const counts = suiteCountsFromGateStdout(gateStdout);
582
+ // The names behind `counts.failed`. Absent, never `[]` — see
583
+ // `failedFilesFromGateStdout`.
584
+ const failedFiles = failedFilesFromGateStdout(gateStdout);
585
+ // Wave-scope sidecar is read from the SAME project dir the event lands in
586
+ // (emitEvent's own destination precedence), so a tmp-scoped run cannot pick
587
+ // up the host repo's live wave. Mirrors the hook's projectDir resolution.
588
+ const waveNumber = resolveWaveNumber(
589
+ ledgerRoot ?? process.env.CLAUDE_PROJECT_DIR ?? process.env.CODEX_PROJECT_DIR ?? repoRoot,
590
+ );
591
+ // `timed_out` / `survivors` / `kill_signals`: the OUTER ladder (the gate
592
+ // sub-script as a whole, #1439 — same builder as the library emitter)
593
+ // OR-joined with the INNER per-command ladders the envelope publishes
594
+ // (#1457). Keys omitted when the inner ladder was not measured and the
595
+ // outer one did not fire — see `joinGateKillFields`.
596
+ const killFields = joinGateKillFields({ outer: gateKillFields(result), gateStdout });
597
+ // A killed gate is `failed` in every variant (#1459, owner decision
598
+ // 2026-10-01). The reporting variants (baseline / incremental / per-file)
599
+ // exit 0 even when a command was killed, so the exit code alone would
600
+ // record that run as `passed`. Their exit code stays 0 on purpose: it has
601
+ // never carried the check verdict there (a failing check exits 0 too).
602
+ const failed = exitCode !== 0 || killFields.timed_out === true;
603
+ await emitEvent(
604
+ `orchestrator.quality_gate.${failed ? 'failed' : 'passed'}`,
605
+ {
606
+ variant,
607
+ exit_code: exitCode,
608
+ ...killFields,
609
+ ...(counts ? { counts } : {}),
610
+ ...(failedFiles ? { failed_files: failedFiles } : {}),
611
+ ...(waveNumber !== null ? { wave_number: waveNumber } : {}),
612
+ ...sessionAttribution(ledgerRoot ?? repoRoot),
613
+ },
614
+ // `{}` is byte-identical to omitting the argument (`opts.repoRoot ??
615
+ // getProjectDir()`), so the default path is untouched.
616
+ ledgerRoot ? { repoRoot: ledgerRoot } : {},
617
+ );
618
+ } catch { /* best-effort telemetry — gate result is authoritative */ }
619
+
620
+ process.exit(exitCode);
463
621
  }
464
622
 
465
- // The gate sub-script runs as the LEADER OF ITS OWN PROCESS GROUP under a
466
- // wall-clock ceiling (Epic #1425 A3). Before this, `spawnSync('node', [gatePath])`
467
- // had NO timeout at all and no group semantics: a wedged `tsgo` or vitest worker
468
- // two levels down was reparented to PPID 1 and kept its memory (2026-09-20: four
469
- // such orphans, up to 8.0 GB RSS each, host at 13 % free).
470
- //
471
- // stdout is PIPED (not inherited) so the suite counts the gate already computed
472
- // can be lifted straight off its JSON envelope into telemetry (#954) instead of
473
- // travelling as prose through the STATE.md header. The envelope is re-emitted
474
- // verbatim below, so the stdout contract is unchanged — a gate sub-script writes
475
- // exactly one JSON line at the very end (its own child commands are captured by
476
- // `runCheck`), so nothing streamed before and nothing streams now.
477
- //
478
- // stderr stays INHERITED, and that is why `spawnFn` is overridden here rather
479
- // than left at its default: `spawnInGroup` merges stdout and stderr into one
480
- // capture, which would interleave the gate's failure disclosure (hundreds of
481
- // lines, #1149) into the single JSON document every consumer parses off stdout.
482
- // Handing it a child with `stderr: 'inherit'` leaves `child.stderr` null, the
483
- // module's own `child.stderr?.on(…)` a no-op, and the gate's warnings live on
484
- // the operator's terminal exactly as before.
485
- const gateCommand = `node ${shellQuote(gatePath)}`;
486
- // Derived from the SAME resolved per-command ceiling published as
487
- // `GATE_TIMEOUT_MS` above — not from `resolveGateTimeoutMs()` a second time.
488
- // The outer cap must stay strictly ABOVE the inner one so the inner kill fires
489
- // first and the gate can still print its envelope; re-deriving here would leave
490
- // the outer cap at the 900 000 default while a committed
491
- // `gate.timeout-path-b-ms` above it made the inner cap the later of the two.
492
- const gateTimeoutMs = commandTimeoutMs + GATE_OUTER_TIMEOUT_RESERVE_MS;
493
- const result = await spawnInGroup(gateCommand, {
494
- cwd: repoRoot,
495
- env,
496
- timeoutMs: gateTimeoutMs,
497
- maxOutputBytes: GATE_STDOUT_MAX_BUFFER_BYTES,
498
- // The ledger is what the orphan reaper (#1425 B) reads to tell its OWN gate
499
- // processes from every other `node` on the host, so it is pinned to the same
500
- // root the telemetry is — under the pre-push hook the tree the gate runs in
501
- // is deleted seconds later, and the record with it.
502
- repoRoot: gateLedgerRoot,
503
- commandSignature: buildCommandSignature(gateCommand),
504
- // The OWNER the ledger records, from the PROCESS-LOCAL witness only — the same
505
- // rule `scripts/lib/quality-gate.mjs` follows for the gate commands it spawns
506
- // (`.claude/rules/identity-and-locks.md`: rank witnesses, never union them).
507
- // `.orchestrator/session.lock` is deliberately NOT consulted: it is a
508
- // repo-GLOBAL artefact any session in this working copy may hold, so reading
509
- // it would stamp a PEER's id onto our own gate process and point the orphan
510
- // reaper (#1425 B) at the wrong owner. No witness → `null`, which the ledger
511
- // records as "owner unknown" — the state every record carried before this.
512
- sessionId: [...readProcessLocalSessionIds({ env: process.env, hookInput: null })][0] ?? null,
513
- // stdin is IGNORED, not inherited. A gate sub-script reads no stdin, but a
514
- // DETACHED child is in its own process group and is therefore not the
515
- // terminal's foreground group: the first read from an inherited TTY earns it
516
- // SIGTTIN, which stops the whole group until the outer ceiling kills it. An
517
- // ignored stdin turns that hang into an immediate EOF.
518
- spawnFn: (command, options) => spawn(command, { ...options, stdio: ['ignore', 'pipe', 'inherit'] }),
519
- });
520
-
521
- // `pid: -1` is `spawnInGroup`'s spawn-failure channel (it never rejects).
522
- if (result.pid === -1) {
523
- die(`Failed to run gate script: ${result.fullOutput.trim()}`);
623
+ // Entry guard (#1440 C1): run only as the node script `npm run quality-gate` /
624
+ // the pre-push hook exec — a bare `import()` must parse no argv, spawn no gate
625
+ // and never `process.exit` the importing process.
626
+ if (isMainModule(import.meta.url)) {
627
+ await main();
524
628
  }
525
-
526
- // On timeout the child was KILLED before it could write its envelope, so its
527
- // capture is at best a partial JSON document. Publishing that would hand every
528
- // stdout consumer a parse error where a named failure belongs; publishing the
529
- // partial text AND an envelope would break the one-document contract. So the
530
- // capture goes to stderr, where the operator can still read it, and stdout
531
- // carries a complete `gate-timeout` envelope instead.
532
- //
533
- // The DECISION lives in `publishGateOutcome` (gate-helpers.mjs) and only the
534
- // WRITES live here: this branch needs a real 16-minute gate to reach, so while
535
- // the decision was inline it was pinned by nothing.
536
- const outcome = publishGateOutcome({ result, variant, timeoutMs: gateTimeoutMs });
537
- const gateStdout = result.timedOut ? '' : result.fullOutput;
538
- if (outcome.stderr) process.stderr.write(outcome.stderr);
539
- if (outcome.stdout) process.stdout.write(outcome.stdout);
540
- for (const line of outcome.warnings) warn(line);
541
-
542
- // Quality-gate telemetry — one canonical event per gate run via emitEvent
543
- // (single emission path). `sessionAttribution` is the shared helper in
544
- // events.mjs (#941); this CLI wrapper runs against the CWD `repoRoot`, so the
545
- // bare emitEvent destination (SO_PROJECT_DIR default) is correct here.
546
- //
547
- // EXCEPT under the pre-push hook, which is the one caller that runs the gate in
548
- // a tree that is about to be DELETED. `.husky/pre-push` materialises the tracked
549
- // tree into a temp dir and deliberately scrubs every `*PROJECT_DIR` name before
550
- // invoking the gate there, so `getProjectDir()` resolves to that temp tree (it
551
- // carries both a CLAUDE.md (or AGENTS.md) and a .git) and the record lands in
552
- // `<tmp>/.orchestrator/metrics/events.jsonl`, which the hook's EXIT trap then
553
- // removes. Measured 2026-09-06: a pre-push run that BLOCKED a push left no
554
- // `orchestrator.quality_gate.failed` line in this repo's ledger at all — the
555
- // gate failure was, by construction, the one event that could never be recorded.
556
- //
557
- // `--ledger-root` is that hook's channel for handing back the root it already
558
- // knows (`git rev-parse --show-toplevel`, read BEFORE it cds). It pins ONLY the
559
- // telemetry destination and the attribution root — every other path the gate
560
- // resolves stays inside the tree actually under test, which is the whole point
561
- // of the materialisation. Absent (every other caller) → unchanged behaviour:
562
- // `emitEvent`'s own default resolution.
563
- //
564
- // It is an ARGV FLAG and not an env var, and that is load-bearing. Measured
565
- // 2026-09-06 with the env-var form: `SO_GATE_LEDGER_ROOT=$tmp npx vitest run
566
- // tests/scripts/run-quality-gate.test.mjs -t "telemetry emission"` → `8 failed |
567
- // 1 passed`. The chain was: hook exports the var → `npm run quality-gate` →
568
- // `gate-full.mjs` spawns `npm test` → every vitest worker inherits it → the
569
- // suite's own gate spawns spread `...process.env`, so the pinned root outranked
570
- // their per-test project dir and the gate's telemetry tests wrote to the hook's
571
- // root. The gate that releases 4.0.0 would have blocked on itself. An env var is
572
- // inherited by every descendant; a flag reaches exactly one process.
573
- //
574
- // Best-effort: a telemetry failure must NEVER alter the gate's authoritative
575
- // exit code — which is why the counts parse also lives inside this try.
576
- const exitCode = outcome.exitCode;
577
- try {
578
- const counts = suiteCountsFromGateStdout(gateStdout);
579
- // The names behind `counts.failed`. Absent, never `[]` — see
580
- // `failedFilesFromGateStdout`.
581
- const failedFiles = failedFilesFromGateStdout(gateStdout);
582
- // Wave-scope sidecar is read from the SAME project dir the event lands in
583
- // (emitEvent's own destination precedence), so a tmp-scoped run cannot pick
584
- // up the host repo's live wave. Mirrors the hook's projectDir resolution.
585
- const waveNumber = resolveWaveNumber(
586
- ledgerRoot ?? process.env.CLAUDE_PROJECT_DIR ?? process.env.CODEX_PROJECT_DIR ?? repoRoot,
587
- );
588
- await emitEvent(
589
- `orchestrator.quality_gate.${exitCode === 0 ? 'passed' : 'failed'}`,
590
- {
591
- variant,
592
- exit_code: exitCode,
593
- ...(counts ? { counts } : {}),
594
- ...(failedFiles ? { failed_files: failedFiles } : {}),
595
- ...(waveNumber !== null ? { wave_number: waveNumber } : {}),
596
- ...sessionAttribution(ledgerRoot ?? repoRoot),
597
- },
598
- // `{}` is byte-identical to omitting the argument (`opts.repoRoot ??
599
- // getProjectDir()`), so the default path is untouched.
600
- ledgerRoot ? { repoRoot: ledgerRoot } : {},
601
- );
602
- } catch { /* best-effort telemetry — gate result is authoritative */ }
603
-
604
- process.exit(exitCode);