session-orchestrator 5.3.0 → 5.4.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 (206) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/navigator/SKILL.md +22 -0
  3. package/.agents/skills/navigator/agents/openai.yaml +5 -0
  4. package/.agents/skills/test-audit/SKILL.md +21 -0
  5. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  6. package/.claude-plugin/marketplace.json +1 -1
  7. package/.claude-plugin/plugin.json +1 -1
  8. package/.codex-plugin/plugin.json +1 -1
  9. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  10. package/.codex-plugin/skills/navigator/SKILL.md +22 -0
  11. package/.codex-plugin/skills/navigator/agents/openai.yaml +5 -0
  12. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  13. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  14. package/.cursor/commands/navigator.md +15 -0
  15. package/.cursor/commands/test-audit.md +14 -0
  16. package/.cursor/rules/060-evolve.mdc +27 -12
  17. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  18. package/.cursor/skills/navigator/SKILL.md +13 -0
  19. package/.cursor/skills/test-audit/SKILL.md +12 -0
  20. package/.cursor-plugin/plugin.json +1 -1
  21. package/.orchestrator/policy/blocked-commands.json +1 -1
  22. package/AGENTS.md +9 -31
  23. package/CHANGELOG.md +70 -0
  24. package/README.md +14 -10
  25. package/docs/agent-authoring.md +2 -2
  26. package/docs/baseline.md +10 -1
  27. package/docs/ci-setup.md +57 -4
  28. package/docs/components.md +12 -12
  29. package/docs/events-schema.md +11 -8
  30. package/docs/persona-panel.md +5 -4
  31. package/docs/rule-authoring.md +27 -12
  32. package/docs/scope-collision-guard.md +26 -4
  33. package/docs/session-config-reference.md +55 -6
  34. package/docs/session-config-template.md +11 -2
  35. package/hooks/_lib/hook-import-set.json +27 -23
  36. package/hooks/hooks-codex.json +1 -1
  37. package/hooks/hooks-cursor.json +5 -0
  38. package/hooks/hooks-pi.json +5 -0
  39. package/hooks/hooks.json +6 -1
  40. package/hooks/on-session-start.mjs +53 -0
  41. package/hooks/on-stop.mjs +3 -1
  42. package/hooks/post-tool-batch-wave-signal.mjs +3 -1
  43. package/hooks/pre-bash-memory-propose-audit.mjs +52 -3
  44. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  45. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  46. package/hooks/pre-task-scope-disjoint.mjs +559 -72
  47. package/hooks/subagent-telemetry.mjs +96 -19
  48. package/package.json +3 -3
  49. package/pi/prompts/navigator.md +12 -0
  50. package/pi/prompts/test-audit.md +12 -0
  51. package/rules/README.md +6 -1
  52. package/rules/_index.md +3 -3
  53. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  54. package/rules/opt-in-domain/prompt-caching.md +5 -1
  55. package/rules/opt-in-stack/backend-data.md +9 -1
  56. package/rules/opt-in-stack/backend.md +7 -1
  57. package/rules/opt-in-stack/frontend.md +6 -1
  58. package/rules/opt-in-stack/security-web.md +7 -1
  59. package/rules/opt-in-stack/swift.md +5 -1
  60. package/scripts/apply-session-learnings.mjs +492 -0
  61. package/scripts/archive-closed-prds.mjs +2 -1
  62. package/scripts/autopilot.mjs +49 -53
  63. package/scripts/backfill-abandoned-sessions.mjs +1 -1
  64. package/scripts/baseline-archetypes.mjs +1 -1
  65. package/scripts/check-package-manager.mjs +62 -5
  66. package/scripts/cursor-install.mjs +1 -4
  67. package/scripts/emit-session.mjs +296 -2
  68. package/scripts/export-hw-learnings.mjs +22 -3
  69. package/scripts/fleet-checkin.mjs +147 -0
  70. package/scripts/lib/agent-frontmatter.mjs +3 -3
  71. package/scripts/lib/auto-dream.mjs +7 -6
  72. package/scripts/lib/autopilot/flags.mjs +6 -1
  73. package/scripts/lib/autopilot/loop.mjs +10 -6
  74. package/scripts/lib/baseline-archetypes.mjs +41 -22
  75. package/scripts/lib/config/host-paths.mjs +79 -0
  76. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  77. package/scripts/lib/config/reaper.mjs +7 -6
  78. package/scripts/lib/config-schema.mjs +13 -0
  79. package/scripts/lib/config.mjs +71 -10
  80. package/scripts/lib/events.mjs +21 -2
  81. package/scripts/lib/fleet-protocol.mjs +270 -0
  82. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  83. package/scripts/lib/gates/gate-full.mjs +7 -2
  84. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  85. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  86. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  87. package/scripts/lib/instruction-budget-guard.mjs +69 -11
  88. package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
  89. package/scripts/lib/learnings/io.mjs +154 -26
  90. package/scripts/lib/learnings/schema.mjs +163 -15
  91. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  92. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  93. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  94. package/scripts/lib/named-vault-resolver.mjs +104 -5
  95. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  96. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  97. package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
  98. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  99. package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
  100. package/scripts/lib/orphan-reaper/scan.mjs +506 -0
  101. package/scripts/lib/orphan-reaper.mjs +43 -1315
  102. package/scripts/lib/owner-yaml.mjs +104 -6
  103. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  104. package/scripts/lib/process-group.mjs +62 -11
  105. package/scripts/lib/project-hygiene.mjs +21 -1
  106. package/scripts/lib/quality-gate.mjs +45 -5
  107. package/scripts/lib/resource-probe.mjs +1 -1
  108. package/scripts/lib/rule-loader.mjs +100 -62
  109. package/scripts/lib/rules-sync.mjs +27 -12
  110. package/scripts/lib/scope-baseline.mjs +3 -4
  111. package/scripts/lib/session-record-repair.mjs +183 -26
  112. package/scripts/lib/session-registry.mjs +27 -2
  113. package/scripts/lib/session-schema/constants.mjs +30 -4
  114. package/scripts/lib/session-schema/validator.mjs +28 -1
  115. package/scripts/lib/session-token-rollup.mjs +245 -52
  116. package/scripts/lib/sessions-canonical.mjs +100 -9
  117. package/scripts/lib/sh-quote.mjs +29 -0
  118. package/scripts/lib/subagents-schema.mjs +30 -3
  119. package/scripts/lib/telemetry/pricing.mjs +118 -71
  120. package/scripts/lib/tests-src-ratio.mjs +140 -23
  121. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  122. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  123. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  124. package/scripts/lib/validate/check-agents.mjs +1 -1
  125. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  126. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  127. package/scripts/lib/validate/check-test-value-bans.mjs +87 -14
  128. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  129. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  130. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  131. package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
  132. package/scripts/lib/vault-status/board-writer.mjs +48 -11
  133. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  134. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  135. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  136. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  137. package/scripts/lib/wave-resource-gate.mjs +34 -8
  138. package/scripts/memory-propose.mjs +18 -5
  139. package/scripts/release-session-lock.mjs +2 -1
  140. package/scripts/release.mjs +10 -3
  141. package/scripts/repair-invalid-sessions.mjs +37 -4
  142. package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
  143. package/scripts/run-quality-gate.mjs +323 -302
  144. package/scripts/site-numbers.mjs +163 -5
  145. package/scripts/sweep-expired-learnings.mjs +56 -5
  146. package/scripts/sweep-expired-rules.mjs +2 -1
  147. package/scripts/validate-plugin.mjs +7 -0
  148. package/scripts/vault-integration-watcher.mjs +2 -1
  149. package/scripts/vault-mirror.mjs +72 -46
  150. package/scripts/wave-scope-binding.mjs +2 -1
  151. package/skills/_shared/config-reading.md +3 -3
  152. package/skills/_shared/fleet-protocol.md +131 -0
  153. package/skills/_shared/state-ownership.md +5 -0
  154. package/skills/autopilot/SKILL.md +25 -11
  155. package/skills/bootstrap/SKILL.md +1 -1
  156. package/skills/bootstrap/_shared-template.md +1 -1
  157. package/skills/bootstrap/fast-template.md +1 -1
  158. package/skills/bootstrap/private-contract.md +2 -1
  159. package/skills/bootstrap/public-fallback.md +6 -3
  160. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  161. package/skills/claude-md-drift-check/SKILL.md +10 -10
  162. package/skills/claude-md-drift-check/checker.mjs +26 -18
  163. package/skills/evolve/SKILL.md +2 -2
  164. package/skills/evolve/references/evolve-analyze-mode.md +10 -6
  165. package/skills/memory-cleanup/SKILL.md +1 -1
  166. package/skills/navigator/SKILL.md +193 -0
  167. package/skills/navigator/references/account-check.sh +87 -0
  168. package/skills/navigator/references/fleet-decisions.mjs +231 -0
  169. package/skills/navigator/references/slot-check.sh +114 -0
  170. package/skills/persona-panel/SKILL.md +2 -2
  171. package/skills/persona-panel/persona-format.md +2 -2
  172. package/skills/persona-panel/presets/designer-lens.md +1 -1
  173. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  174. package/skills/persona-panel/presets/pm-lens.md +1 -1
  175. package/skills/plan/mode-retro.md +19 -4
  176. package/skills/quality-gates/SKILL.md +6 -4
  177. package/skills/remote-offload/SKILL.md +4 -2
  178. package/skills/session-end/learning-patterns.md +22 -12
  179. package/skills/session-end/metrics-collection.md +5 -6
  180. package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
  181. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  182. package/skills/session-end/references/session-summary-template.md +1 -1
  183. package/skills/session-end/session-metrics-write.md +22 -60
  184. package/skills/session-plan/SKILL.md +0 -3
  185. package/skills/session-start/SKILL.md +6 -0
  186. package/skills/session-start/references/operations-contract.md +4 -0
  187. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  188. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -2
  189. package/skills/test-audit/SKILL.md +152 -0
  190. package/skills/test-audit/references/campaign.md +111 -0
  191. package/skills/test-audit/references/mutate.sh +50 -0
  192. package/skills/wave-executor/SKILL.md +1 -1
  193. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  194. package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
  195. package/skills/wave-executor/references/wave-loop-review.md +3 -5
  196. package/templates/personas/README.md +5 -5
  197. package/templates/personas/accounting-compliance.v1.md +1 -1
  198. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  199. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  200. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  201. package/templates/personas/buyer-p3-build.v1.md +1 -1
  202. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  203. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  204. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  205. package/templates/personas/klima-ai-expert.v1.md +1 -1
  206. 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
58
  import { readProcessLocalSessionIds } from './lib/session-identity/own-session.mjs';
58
59
  import { findScopeFile } 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
  *
@@ -350,255 +299,327 @@ function resolveWaveNumber(projectDir) {
350
299
  }
351
300
  }
352
301
 
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`);
302
+ // ---------------------------------------------------------------------------
303
+ // Main — the CLI body. Guarded below so a bare `import()` runs none of it.
304
+ // ---------------------------------------------------------------------------
305
+
306
+ async function main() {
307
+ // Argument parsing
308
+ const argv = process.argv.slice(2);
309
+
310
+ if (argv.includes('-h') || argv.includes('--help')) {
311
+ process.stdout.write(
312
+ 'Usage: run-quality-gate.mjs --variant <variant> [--config <json-or-file>] ' +
313
+ '[--files <file1,file2,...>] [--session-start-ref <ref>] [--ledger-root <path>]\n\n' +
314
+ 'Variants: baseline, incremental, full-gate, per-file\n\n' +
315
+ 'Exit codes:\n' +
316
+ ' 0 — pass (non-blocking variants always exit 0)\n' +
317
+ ' 1 — script error (bad arguments, missing dependencies)\n' +
318
+ ' 2 — gate failed (full-gate only)\n' +
319
+ ' 124 — gate timed out; its process group was killed (SIGTERM→SIGKILL)\n',
320
+ );
321
+ process.exit(0);
322
+ }
323
+
324
+ let variant = '';
325
+ let config = '';
326
+ let files = '';
327
+ let sessionStartRef = '';
328
+ let ledgerRootArg = '';
329
+
330
+ for (let i = 0; i < argv.length; i++) {
331
+ const arg = argv[i];
332
+ switch (arg) {
333
+ case '--variant':
334
+ if (i + 1 >= argv.length) die('Missing value for --variant');
335
+ variant = argv[++i];
336
+ break;
337
+ case '--config':
338
+ if (i + 1 >= argv.length) die('Missing value for --config');
339
+ config = argv[++i];
340
+ break;
341
+ case '--files':
342
+ if (i + 1 >= argv.length) die('Missing value for --files');
343
+ files = argv[++i];
344
+ break;
345
+ case '--session-start-ref':
346
+ if (i + 1 >= argv.length) die('Missing value for --session-start-ref');
347
+ sessionStartRef = argv[++i];
348
+ break;
349
+ case '--ledger-root':
350
+ if (i + 1 >= argv.length) die('Missing value for --ledger-root');
351
+ ledgerRootArg = argv[++i];
352
+ break;
353
+ default:
354
+ die(`Unknown argument: ${arg}`);
365
355
  }
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');
356
+ }
357
+
358
+ if (!variant) die('Missing required argument: --variant');
359
+ if (!VALID_VARIANTS.includes(variant)) {
360
+ die(`Invalid variant: '${variant}' (allowed: ${VALID_VARIANTS.join(', ')})`);
361
+ }
362
+
363
+ // Load policy file (never throws)
364
+ const repoRoot = process.cwd();
365
+ const policy = loadQualityGatesPolicy(repoRoot);
366
+
367
+ // Parse --config (JSON string or file path)
368
+ let configJson = null;
369
+ if (config) {
370
+ if (existsSync(config)) {
371
+ try {
372
+ configJson = JSON.parse(readFileSync(config, 'utf8'));
373
+ } catch (err) {
374
+ warn(`Could not parse config file '${config}': ${err.message}; using defaults`);
375
+ }
376
+ } else {
377
+ try {
378
+ configJson = JSON.parse(config);
379
+ } catch {
380
+ warn('Config is neither a valid file path nor valid JSON; using defaults');
381
+ }
371
382
  }
372
383
  }
373
- }
374
384
 
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);
385
+ const TYPECHECK_CMD = extractCommand(policy, 'typecheck', 'typecheck-command', configJson, DEFAULT_TYPECHECK_CMD);
386
+ const TEST_CMD = extractCommand(policy, 'test', 'test-command', configJson, DEFAULT_TEST_CMD);
387
+ const LINT_CMD = extractCommand(policy, 'lint', 'lint-command', configJson, DEFAULT_LINT_CMD);
378
388
 
379
- // ---------------------------------------------------------------------------
380
- // Gate dispatch — shell-out to existing gate-*.mjs sub-scripts
381
- // ---------------------------------------------------------------------------
389
+ // ---------------------------------------------------------------------------
390
+ // Gate dispatch — shell-out to existing gate-*.mjs sub-scripts
391
+ // ---------------------------------------------------------------------------
382
392
 
383
- const gatePath = GATE_SCRIPT[variant];
393
+ const gatePath = GATE_SCRIPT[variant];
384
394
 
385
- if (!existsSync(gatePath)) {
386
- die(`Gate script not found: ${gatePath}`);
387
- }
395
+ if (!existsSync(gatePath)) {
396
+ die(`Gate script not found: ${gatePath}`);
397
+ }
388
398
 
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
- };
399
+ // Resolved BEFORE the spawn (it used to sit beside the telemetry block below):
400
+ // the gate-process ledger the spawn writes is pinned to the same root as the
401
+ // event, for the same reason — see the spawn's `repoRoot` comment.
402
+ const ledgerRoot = resolveLedgerRoot(ledgerRootArg);
403
+
404
+ // `npm_config_loglevel` is INHERITED by every descendant, and the pre-push hook
405
+ // invokes this script as `npm run --silent quality-gate` — which sets it to
406
+ // `silent`. That level then reached the gate's own children: `npm pack
407
+ // --dry-run` emitted ZERO `npm notice` lines instead of 818 (measured
408
+ // 2026-08-22), so the release leakage test saw an empty listing, and several
409
+ // validate-plugin/e2e tests that shell out to npm went red the same way. Every
410
+ // one of them passes under a bare `npm test` and fails only INSIDE the gate,
411
+ // which is the hardest shape to diagnose and cost an hour of chasing phantoms.
412
+ //
413
+ // Pinned rather than deleted: an explicit level makes the gate's children
414
+ // independent of how the gate itself was invoked. `--silent` still does its real
415
+ // job — keeping THIS process's stdout to the single JSON envelope — because the
416
+ // children's output is captured by `runCheck`, never streamed.
417
+ /**
418
+ * Per-command wall-clock ceiling for PATH B (#1425 A3 / #1432).
419
+ *
420
+ * Precedence, highest first:
421
+ * 1. `SO_GATE_TIMEOUT_MS` — the OPERATOR override. It is inherited by every
422
+ * descendant, so a gate child that re-derives the ceiling gets the same
423
+ * answer; `resolveGateTimeoutMs()` already reads it.
424
+ * 2. Session Config `gate.timeout-path-b-ms` — the COMMITTED default.
425
+ * 3. `DEFAULT_GATE_TIMEOUT_MS` (900 000), via `resolveGateTimeoutMs()`.
426
+ *
427
+ * Published to the gate sub-script as `GATE_TIMEOUT_MS` — a DIFFERENT name from
428
+ * the operator override on purpose: writing the resolved value back into
429
+ * `SO_GATE_TIMEOUT_MS` would make a committed default indistinguishable from an
430
+ * operator decision for every process further down the tree.
431
+ */
432
+ const configuredGateTimeoutMs = (() => {
433
+ const block = configJson !== null && typeof configJson === 'object' ? configJson.gate : null;
434
+ const raw = block && typeof block === 'object' ? block['timeout-path-b-ms'] : undefined;
435
+ return Number.isFinite(raw) && raw > 0 ? raw : null;
436
+ })();
437
+ const operatorTimeoutOverride = (process.env.SO_GATE_TIMEOUT_MS || '').trim() !== '';
438
+ const commandTimeoutMs = operatorTimeoutOverride || configuredGateTimeoutMs === null
439
+ ? resolveGateTimeoutMs()
440
+ : configuredGateTimeoutMs;
441
+
442
+ // Gate-process REGISTER root — the same precedence the telemetry destination
443
+ // uses below (`--ledger-root` > project-dir env > cwd), so a sandboxed test that
444
+ // only sets CLAUDE_PROJECT_DIR never writes register lines into the checkout.
445
+ const gateLedgerRoot =
446
+ ledgerRoot ?? process.env.CLAUDE_PROJECT_DIR ?? process.env.CODEX_PROJECT_DIR ?? repoRoot;
447
+
448
+ const env = {
449
+ ...process.env,
450
+ npm_config_loglevel: 'notice',
451
+ TYPECHECK_CMD,
452
+ TEST_CMD,
453
+ LINT_CMD,
454
+ GATE_TIMEOUT_MS: String(commandTimeoutMs),
455
+ // Same resolution the wrapper uses for its OWN spawn (`--ledger-root` > repo
456
+ // root): the gate sub-scripts pass it to runCheck() so every register line of
457
+ // one gate run lands in ONE ledger (#1425 A4, W5 fix-pass).
458
+ GATE_LEDGER_ROOT: gateLedgerRoot,
459
+ FILES: files,
460
+ SESSION_START_REF: sessionStartRef,
461
+ };
462
+
463
+ // The gate sub-script runs as the LEADER OF ITS OWN PROCESS GROUP under a
464
+ // wall-clock ceiling (Epic #1425 A3). Before this, `spawnSync('node', [gatePath])`
465
+ // had NO timeout at all and no group semantics: a wedged `tsgo` or vitest worker
466
+ // two levels down was reparented to PPID 1 and kept its memory (2026-09-20: four
467
+ // such orphans, up to 8.0 GB RSS each, host at 13 % free).
468
+ //
469
+ // stdout is PIPED (not inherited) so the suite counts the gate already computed
470
+ // can be lifted straight off its JSON envelope into telemetry (#954) instead of
471
+ // travelling as prose through the STATE.md header. The envelope is re-emitted
472
+ // verbatim below, so the stdout contract is unchanged — a gate sub-script writes
473
+ // exactly one JSON line at the very end (its own child commands are captured by
474
+ // `runCheck`), so nothing streamed before and nothing streams now.
475
+ //
476
+ // stderr stays INHERITED, and that is why `spawnFn` is overridden here rather
477
+ // than left at its default: `spawnInGroup` merges stdout and stderr into one
478
+ // capture, which would interleave the gate's failure disclosure (hundreds of
479
+ // lines, #1149) into the single JSON document every consumer parses off stdout.
480
+ // Handing it a child with `stderr: 'inherit'` leaves `child.stderr` null, the
481
+ // module's own `child.stderr?.on(…)` a no-op, and the gate's warnings live on
482
+ // the operator's terminal exactly as before.
483
+ // Quoted: the gate path derives from `import.meta.url`, so a space in the
484
+ // checkout path must not split the command.
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()}`);
524
+ }
452
525
 
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, "'\\''")}'`;
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
+ // `timed_out` / `survivors` / `kill_signals`: the OUTER ladder (the gate
589
+ // sub-script as a whole, #1439 — same builder as the library emitter)
590
+ // OR-joined with the INNER per-command ladders the envelope publishes
591
+ // (#1457). Keys omitted when the inner ladder was not measured and the
592
+ // outer one did not fire — see `joinGateKillFields`.
593
+ const killFields = joinGateKillFields({ outer: gateKillFields(result), gateStdout });
594
+ // A killed gate is `failed` in every variant (#1459, owner decision
595
+ // 2026-10-01). The reporting variants (baseline / incremental / per-file)
596
+ // exit 0 even when a command was killed, so the exit code alone would
597
+ // record that run as `passed`. Their exit code stays 0 on purpose: it has
598
+ // never carried the check verdict there (a failing check exits 0 too).
599
+ const failed = exitCode !== 0 || killFields.timed_out === true;
600
+ await emitEvent(
601
+ `orchestrator.quality_gate.${failed ? 'failed' : 'passed'}`,
602
+ {
603
+ variant,
604
+ exit_code: exitCode,
605
+ ...killFields,
606
+ ...(counts ? { counts } : {}),
607
+ ...(failedFiles ? { failed_files: failedFiles } : {}),
608
+ ...(waveNumber !== null ? { wave_number: waveNumber } : {}),
609
+ ...sessionAttribution(ledgerRoot ?? repoRoot),
610
+ },
611
+ // `{}` is byte-identical to omitting the argument (`opts.repoRoot ??
612
+ // getProjectDir()`), so the default path is untouched.
613
+ ledgerRoot ? { repoRoot: ledgerRoot } : {},
614
+ );
615
+ } catch { /* best-effort telemetry — gate result is authoritative */ }
616
+
617
+ process.exit(exitCode);
463
618
  }
464
619
 
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()}`);
620
+ // Entry guard (#1440 C1): run only as the node script `npm run quality-gate` /
621
+ // the pre-push hook exec — a bare `import()` must parse no argv, spawn no gate
622
+ // and never `process.exit` the importing process.
623
+ if (isMainModule(import.meta.url)) {
624
+ await main();
524
625
  }
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);