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.
- package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- 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 {
|
|
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
|
|
214
|
-
* `gate-
|
|
215
|
-
*
|
|
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
|
|
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
|
|
326
|
-
*
|
|
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
|
|
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
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const configuredGateTimeoutMs = (() => {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
})();
|
|
427
|
-
const operatorTimeoutOverride = (process.env.SO_GATE_TIMEOUT_MS || '').trim() !== '';
|
|
428
|
-
const commandTimeoutMs = operatorTimeoutOverride || configuredGateTimeoutMs === null
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
const env = {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
|
|
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);
|