session-orchestrator 5.2.0 → 5.3.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/architecture/SKILL.md +3 -1
- package/.agents/skills/autopilot/SKILL.md +5 -1
- package/.agents/skills/autopilot/agents/openai.yaml +5 -0
- package/.agents/skills/bootstrap/SKILL.md +5 -1
- package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
- package/.agents/skills/brainstorm/SKILL.md +5 -1
- package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
- package/.agents/skills/close/SKILL.md +5 -1
- package/.agents/skills/close/agents/openai.yaml +5 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
- package/.agents/skills/debug/SKILL.md +5 -1
- package/.agents/skills/debug/agents/openai.yaml +5 -0
- package/.agents/skills/discovery/SKILL.md +5 -1
- package/.agents/skills/discovery/agents/openai.yaml +5 -0
- package/.agents/skills/dispatcher/SKILL.md +5 -1
- package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ecosystem-health/SKILL.md +3 -1
- package/.agents/skills/eli5/SKILL.md +5 -1
- package/.agents/skills/eli5/agents/openai.yaml +5 -0
- package/.agents/skills/eval/SKILL.md +6 -2
- package/.agents/skills/eval/agents/openai.yaml +5 -0
- package/.agents/skills/evolve/SKILL.md +6 -2
- package/.agents/skills/evolve/agents/openai.yaml +5 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
- package/.agents/skills/gitlab-ops/SKILL.md +3 -1
- package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
- package/.agents/skills/go/SKILL.md +5 -1
- package/.agents/skills/go/agents/openai.yaml +5 -0
- package/.agents/skills/grill/SKILL.md +5 -1
- package/.agents/skills/grill/agents/openai.yaml +5 -0
- package/.agents/skills/harness-audit/SKILL.md +5 -1
- package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
- package/.agents/skills/hook-development/SKILL.md +3 -1
- package/.agents/skills/mcp-builder/SKILL.md +3 -1
- package/.agents/skills/memory-cleanup/SKILL.md +5 -1
- package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.agents/skills/mode-selector/SKILL.md +3 -1
- package/.agents/skills/npm-publish/SKILL.md +4 -2
- package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
- package/.agents/skills/persona-panel/SKILL.md +5 -1
- package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
- package/.agents/skills/plan/SKILL.md +5 -1
- package/.agents/skills/plan/agents/openai.yaml +5 -0
- package/.agents/skills/playwright-driver/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +5 -1
- package/.agents/skills/portfolio/agents/openai.yaml +5 -0
- package/.agents/skills/quality-gates/SKILL.md +3 -1
- package/.agents/skills/reconcile/SKILL.md +5 -1
- package/.agents/skills/reconcile/agents/openai.yaml +5 -0
- package/.agents/skills/release/SKILL.md +5 -1
- package/.agents/skills/release/agents/openai.yaml +5 -0
- package/.agents/skills/remote-offload/SKILL.md +3 -1
- package/.agents/skills/repo-audit/SKILL.md +5 -1
- package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
- package/.agents/skills/session/SKILL.md +21 -0
- package/.agents/skills/session/agents/openai.yaml +5 -0
- package/.agents/skills/session-end/SKILL.md +3 -1
- package/.agents/skills/session-plan/SKILL.md +3 -1
- package/.agents/skills/session-start/SKILL.md +3 -1
- package/.agents/skills/spinout/SKILL.md +5 -1
- package/.agents/skills/spinout/agents/openai.yaml +5 -0
- package/.agents/skills/sunset-review/SKILL.md +5 -1
- package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
- package/.agents/skills/templates-ack/SKILL.md +21 -0
- package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
- package/.agents/skills/test/SKILL.md +5 -1
- package/.agents/skills/test/agents/openai.yaml +5 -0
- package/.agents/skills/test-runner/SKILL.md +3 -1
- package/.agents/skills/tmux-layout/SKILL.md +3 -1
- package/.agents/skills/using-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ux-grill/SKILL.md +5 -1
- package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +3 -1
- package/.agents/skills/vault-sync/SKILL.md +3 -1
- package/.agents/skills/wave-executor/SKILL.md +3 -1
- package/.agents/skills/write-executable-plan/SKILL.md +3 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/evolve/SKILL.md +1 -1
- package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
- package/.codex-plugin/skills/session/SKILL.md +1 -1
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/session.md +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +0 -2
- package/.cursor/rules/050-plan.mdc +1 -1
- package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/npm-publish/SKILL.md +1 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +12 -3
- package/AGENTS.md +3 -2
- package/CHANGELOG.md +136 -0
- package/README.md +9 -9
- package/SECURITY.md +12 -0
- package/agents/dialectic-deriver.md +13 -10
- package/agents/eval-judge.md +67 -45
- package/agents/skill-applied-judge.md +34 -19
- package/commands/session.md +7 -3
- package/docs/baseline.md +12 -6
- package/docs/codex-setup.md +14 -2
- package/docs/components.md +7 -5
- package/docs/events-schema.md +56 -9
- package/docs/rule-authoring.md +58 -6
- package/docs/session-config-reference.md +100 -7
- package/docs/session-config-template.md +31 -2
- package/docs/telemetry.md +2 -0
- package/hooks/_lib/hook-import-set.json +85 -8
- package/hooks/_lib/subagent-transcript.mjs +582 -31
- package/hooks/config-protection.mjs +11 -3
- package/hooks/cwd-change-restore.mjs +11 -3
- package/hooks/enforce-commands.mjs +70 -23
- package/hooks/enforce-scope.mjs +143 -33
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/loop-guard.mjs +11 -3
- package/hooks/on-session-end.mjs +58 -23
- package/hooks/on-session-start.mjs +48 -11
- package/hooks/on-stop.mjs +168 -22
- package/hooks/operator-steer.mjs +11 -3
- package/hooks/post-bash-issue-budget-refund.mjs +18 -8
- package/hooks/post-bash-write-verify.mjs +3 -2
- package/hooks/post-edit-import-probe.mjs +17 -9
- package/hooks/post-edit-validate.mjs +13 -5
- package/hooks/post-subagent-discovery-validator.mjs +98 -13
- package/hooks/post-tool-batch-wave-signal.mjs +200 -38
- package/hooks/post-tool-failure-corrective-context.mjs +11 -5
- package/hooks/post-tooluse-frontend-slop.mjs +10 -4
- package/hooks/pre-auq-clarity.mjs +15 -2
- package/hooks/pre-bash-destructive-guard.mjs +80 -9
- package/hooks/pre-bash-issue-budget.mjs +16 -11
- package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
- package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
- package/hooks/pre-bash-staging-fence.mjs +335 -31
- package/hooks/pre-bash-templates-first.mjs +19 -14
- package/hooks/pre-task-scope-disjoint.mjs +233 -2
- package/hooks/subagent-telemetry.mjs +15 -19
- package/hooks/wave-scope-commit-guard.mjs +197 -100
- package/monitors/monitors.json +1 -1
- package/output-styles/wave-summary.md +1 -1
- package/package.json +1 -1
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/session.md +1 -1
- package/rules/README.md +1 -1
- package/rules/opt-in-domain/prompt-caching.md +1 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +3 -3
- package/rules/opt-in-stack/frontend.md +1 -1
- package/rules/opt-in-stack/security-web.md +3 -3
- package/rules/opt-in-stack/swift.md +1 -1
- package/scripts/autopilot.mjs +23 -2
- package/scripts/backfill-abandoned-sessions.mjs +117 -15
- package/scripts/check-sessions-integrity.mjs +300 -0
- package/scripts/dialectic-deriver.mjs +50 -13
- package/scripts/emit-session.mjs +75 -29
- package/scripts/eval-session.mjs +65 -3
- package/scripts/generate-agents-skills.mjs +102 -29
- package/scripts/generate-cursor-adapter.mjs +61 -16
- package/scripts/lib/agent-status.mjs +2 -31
- package/scripts/lib/auq/clarity.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +12 -31
- package/scripts/lib/auq/schema.mjs +56 -41
- package/scripts/lib/auto-dialectic.mjs +304 -15
- package/scripts/lib/autopilot/flags.mjs +12 -1
- package/scripts/lib/autopilot/kill-switches.mjs +6 -3
- package/scripts/lib/autopilot/loop.mjs +14 -1
- package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
- package/scripts/lib/ci-status-banner.mjs +376 -16
- package/scripts/lib/command-blocker.mjs +275 -28
- package/scripts/lib/config/dialectic.mjs +12 -3
- package/scripts/lib/config/gate.mjs +74 -0
- package/scripts/lib/config/reaper.mjs +162 -0
- package/scripts/lib/config.mjs +14 -0
- package/scripts/lib/convergence-monitor.mjs +74 -11
- package/scripts/lib/ecosystem-health.mjs +11 -0
- package/scripts/lib/eval/engine.mjs +421 -53
- package/scripts/lib/eval/judge.mjs +463 -40
- package/scripts/lib/eval/schema.mjs +10 -1
- package/scripts/lib/events-rotation.mjs +221 -25
- package/scripts/lib/events-schema.mjs +114 -0
- package/scripts/lib/events.mjs +524 -5
- package/scripts/lib/frontmatter-guard.mjs +21 -10
- package/scripts/lib/gates/gate-baseline.mjs +27 -2
- package/scripts/lib/gates/gate-full.mjs +28 -3
- package/scripts/lib/gates/gate-helpers.mjs +243 -21
- package/scripts/lib/gates/gate-incremental.mjs +28 -3
- package/scripts/lib/gates/gate-per-file.mjs +27 -2
- package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
- package/scripts/lib/instruction-budget-guard.mjs +146 -4
- package/scripts/lib/io.mjs +42 -8
- package/scripts/lib/issue-close-strip-labels.mjs +207 -49
- package/scripts/lib/js-mask.mjs +197 -0
- package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
- package/scripts/lib/maintenance-due-banner.mjs +53 -88
- package/scripts/lib/orphan-reaper.mjs +1588 -0
- package/scripts/lib/peer-cards/merger.mjs +48 -10
- package/scripts/lib/peer-cards/reader.mjs +78 -2
- package/scripts/lib/process-group.mjs +899 -0
- package/scripts/lib/quality-gate.mjs +107 -28
- package/scripts/lib/reconcile/backlog.mjs +368 -0
- package/scripts/lib/reconcile/engine.mjs +55 -188
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
- package/scripts/lib/reconcile/sanitize.mjs +69 -3
- package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
- package/scripts/lib/resource-probe/parsers.mjs +31 -0
- package/scripts/lib/rule-loader.mjs +41 -12
- package/scripts/lib/scope-echo.mjs +39 -2
- package/scripts/lib/scope-gate.mjs +605 -1
- package/scripts/lib/session-close-backfill.mjs +33 -6
- package/scripts/lib/session-id.mjs +9 -20
- package/scripts/lib/session-invocation.mjs +20 -0
- package/scripts/lib/session-schema/constants.mjs +30 -2
- package/scripts/lib/session-schema/normalizer.mjs +56 -4
- package/scripts/lib/session-schema.mjs +8 -3
- package/scripts/lib/session-start-probes.mjs +95 -10
- package/scripts/lib/sessions-canonical.mjs +23 -0
- package/scripts/lib/sessions-integrity-banner.mjs +7 -1
- package/scripts/lib/sessions-staleness-banner.mjs +193 -51
- package/scripts/lib/skill-evidence-window.mjs +891 -0
- package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
- package/scripts/lib/skill-evolution/engine.mjs +18 -9
- package/scripts/lib/skill-judge.mjs +45 -3
- package/scripts/lib/tail-window.mjs +56 -0
- package/scripts/lib/telemetry/schema.mjs +30 -0
- package/scripts/lib/telemetry/sync.mjs +61 -6
- package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
- package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
- package/scripts/lib/user-invocable-skills.mjs +23 -3
- package/scripts/lib/ux-grill/reconcile.mjs +48 -22
- package/scripts/lib/validate/check-agents-skills.mjs +26 -15
- package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
- package/scripts/lib/validate/check-entry-guard.mjs +13 -50
- package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
- package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
- package/scripts/lib/validate/check-rules.mjs +7 -5
- package/scripts/lib/validate/check-skill-links.mjs +9 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
- package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
- package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
- package/scripts/lib/validate/check-unwired-features.mjs +130 -27
- package/scripts/lib/validate/check-validator-registration.mjs +34 -10
- package/scripts/lib/validate/confidential-names.mjs +10 -0
- package/scripts/lib/validate-vendored-rules.mjs +4 -3
- package/scripts/lib/vault-mirror/namespace.mjs +46 -8
- package/scripts/lib/vault-mirror/process.mjs +10 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
- package/scripts/lib/vault-yaml.mjs +118 -0
- package/scripts/lib/worktree/lifecycle.mjs +153 -1
- package/scripts/release-session-lock.mjs +305 -0
- package/scripts/release.mjs +30 -5
- package/scripts/resolve-session-invocation.mjs +59 -0
- package/scripts/run-quality-gate.mjs +156 -17
- package/scripts/sweep-expired-rules.mjs +14 -3
- package/scripts/validate-plugin.mjs +12 -0
- package/scripts/validate-wave-scope.mjs +32 -105
- package/scripts/vault-mirror.mjs +9 -1
- package/skills/_shared/platform-tools.md +23 -11
- package/skills/autopilot/SKILL.md +22 -7
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/convergence-monitoring/README.md +8 -1
- package/skills/convergence-monitoring/SIGNALS.md +50 -6
- package/skills/convergence-monitoring/SKILL.md +15 -6
- package/skills/eval/SKILL.md +39 -24
- package/skills/eval/rubric-v1.md +1 -0
- package/skills/eval/rubric-v2.md +457 -0
- package/skills/evolve/SKILL.md +1 -1
- package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
- package/skills/gitlab-ops/SKILL.md +3 -2
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +11 -0
- package/skills/session-end/SKILL.md +13 -16
- package/skills/session-end/discovery-scan.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +55 -9
- package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
- package/skills/session-end/session-metrics-write.md +10 -0
- package/skills/session-plan/SKILL.md +17 -5
- package/skills/session-plan/references/session-plan-task-classification.md +2 -2
- package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
- package/skills/ux-grill/SKILL.md +1 -1
- package/skills/wave-executor/SKILL.md +8 -4
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
- package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
- package/.cursor/commands/convergence-monitoring.md +0 -13
- package/.cursor/commands/npm-publish.md +0 -13
- package/pi/prompts/convergence-monitoring.md +0 -11
- package/pi/prompts/npm-publish.md +0 -11
|
@@ -18,6 +18,31 @@ const testCmd = process.env.TEST_CMD;
|
|
|
18
18
|
const lintCmd = process.env.LINT_CMD;
|
|
19
19
|
const sessionStartRef = process.env.SESSION_START_REF ?? '';
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Per-command wall-clock ceiling, published by `scripts/run-quality-gate.mjs`
|
|
23
|
+
* as `GATE_TIMEOUT_MS` (#1425 A3 / #1432). It carries the ALREADY-RESOLVED
|
|
24
|
+
* value — operator override `SO_GATE_TIMEOUT_MS` > Session Config
|
|
25
|
+
* `gate.timeout-path-b-ms` > 900 000 — so this script only has to read it.
|
|
26
|
+
*
|
|
27
|
+
* Absent or non-numeric (this gate invoked directly, not through the wrapper)
|
|
28
|
+
* → the option is OMITTED, and `runCheck` falls back to `resolveGateTimeoutMs()`
|
|
29
|
+
* exactly as before. An empty object is deliberate: passing `timeoutMs:
|
|
30
|
+
* undefined` would NOT trigger that fallback in every spread order.
|
|
31
|
+
*/
|
|
32
|
+
const CHECK_OPTS = (() => {
|
|
33
|
+
const raw = Number((process.env.GATE_TIMEOUT_MS || '').trim());
|
|
34
|
+
const opts = Number.isFinite(raw) && raw > 0 ? { timeoutMs: raw } : {};
|
|
35
|
+
// GATE_LEDGER_ROOT (#1425 A4, W5 fix-pass): the wrapper publishes the ledger
|
|
36
|
+
// root it resolved (--ledger-root > repo root) so the gate-process register
|
|
37
|
+
// is written where the WRAPPER decided, not where this sub-script happens to
|
|
38
|
+
// run. Without it every runCheck() here defaulted to process.cwd(), and a test
|
|
39
|
+
// that spawned this script from the checkout wrote real lines into the live
|
|
40
|
+
// .orchestrator/runtime/gate-processes.jsonl — the reaper's kill population.
|
|
41
|
+
const ledgerRoot = (process.env.GATE_LEDGER_ROOT || "").trim();
|
|
42
|
+
if (ledgerRoot && ledgerRoot.startsWith("/")) opts.repoRoot = ledgerRoot;
|
|
43
|
+
return opts;
|
|
44
|
+
})();
|
|
45
|
+
|
|
21
46
|
if (!typecheckCmd) {
|
|
22
47
|
process.stderr.write('TYPECHECK_CMD must be set\n');
|
|
23
48
|
process.exit(1);
|
|
@@ -34,7 +59,7 @@ if (!lintCmd) {
|
|
|
34
59
|
const startTime = Date.now();
|
|
35
60
|
|
|
36
61
|
// --- Typecheck ---
|
|
37
|
-
const tcResult = runCheck(typecheckCmd);
|
|
62
|
+
const tcResult = await runCheck(typecheckCmd, CHECK_OPTS);
|
|
38
63
|
const tcErrorCount =
|
|
39
64
|
tcResult.status === 'fail'
|
|
40
65
|
? extractCount(tcResult.fullOutput ?? tcResult.output, /error TS\d+/)
|
|
@@ -44,7 +69,7 @@ const tcErrorCount =
|
|
|
44
69
|
// NOTE: `testCounts`, NOT `failed` — a local `failed` is already bound near
|
|
45
70
|
// the bottom of this file and drives `process.exit(failed ? 2 : 0)`.
|
|
46
71
|
// Shadowing it would corrupt the gate's exit code.
|
|
47
|
-
const testResult = runCheck(testCmd);
|
|
72
|
+
const testResult = await runCheck(testCmd, CHECK_OPTS);
|
|
48
73
|
const testCounts =
|
|
49
74
|
testResult.status !== 'skip'
|
|
50
75
|
? extractTestCounts(testResult.fullOutput ?? testResult.output)
|
|
@@ -98,7 +123,7 @@ const fileFields = testCounts.files
|
|
|
98
123
|
: {};
|
|
99
124
|
|
|
100
125
|
// --- Lint ---
|
|
101
|
-
const lintResult = runCheck(lintCmd);
|
|
126
|
+
const lintResult = await runCheck(lintCmd, CHECK_OPTS);
|
|
102
127
|
const lintWarnings =
|
|
103
128
|
lintResult.status !== 'skip'
|
|
104
129
|
? extractCount(lintResult.fullOutput ?? lintResult.output, /warning/i)
|
|
@@ -8,10 +8,176 @@
|
|
|
8
8
|
|
|
9
9
|
import { execSync } from 'node:child_process';
|
|
10
10
|
import { readFileSync } from 'node:fs';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_GATE_TIMEOUT_MS,
|
|
14
|
+
buildCommandSignature,
|
|
15
|
+
spawnInGroup,
|
|
16
|
+
} from '../process-group.mjs';
|
|
11
17
|
import { detectStubCommand } from './echo-stub-detect.mjs';
|
|
12
18
|
|
|
13
19
|
const RUN_CHECK_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
14
20
|
|
|
21
|
+
/** Lines of captured output kept in the human-facing `output` tail. */
|
|
22
|
+
const OUTPUT_TAIL_LINES = 5;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Name of the per-invocation override for the gate wall-clock ceiling.
|
|
26
|
+
*
|
|
27
|
+
* Read at CALL time, never at module load, so a test (or a caller that sets it
|
|
28
|
+
* for one child) is not defeated by import order.
|
|
29
|
+
*
|
|
30
|
+
* Named ceiling (BV-004): this is an ENV var, and an env var is inherited by
|
|
31
|
+
* every descendant — the exact shape that made `SO_GATE_LEDGER_ROOT` reach
|
|
32
|
+
* every vitest worker on 2026-09-06 (`scripts/run-quality-gate.mjs`, the
|
|
33
|
+
* `--ledger-root` block). It is acceptable here only because the value is a
|
|
34
|
+
* CEILING every descendant should honour anyway. A future Session-Config
|
|
35
|
+
* wiring of `gate.timeout-path-b-ms` should prefer the explicit `timeoutMs`
|
|
36
|
+
* option below — which reaches exactly one call — over exporting this name.
|
|
37
|
+
*/
|
|
38
|
+
export const GATE_TIMEOUT_ENV = 'SO_GATE_TIMEOUT_MS';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The wall-clock ceiling one gate command is allowed, in ms.
|
|
42
|
+
*
|
|
43
|
+
* Precedence: `process.env.SO_GATE_TIMEOUT_MS` when set to a finite positive
|
|
44
|
+
* number, else {@link DEFAULT_GATE_TIMEOUT_MS} (900_000 — the PRD's
|
|
45
|
+
* `gate.timeout-path-b-ms`, deliberately the same 15 min the synchronous
|
|
46
|
+
* path A already had, so both gate paths are allowed exactly as long).
|
|
47
|
+
*
|
|
48
|
+
* A non-numeric or non-positive value is IGNORED rather than honoured: a typo
|
|
49
|
+
* that parsed as 0 would disable the cap, which is the failure this whole
|
|
50
|
+
* change exists to remove.
|
|
51
|
+
*
|
|
52
|
+
* @returns {number} positive milliseconds
|
|
53
|
+
*/
|
|
54
|
+
export function resolveGateTimeoutMs() {
|
|
55
|
+
const raw = (process.env[GATE_TIMEOUT_ENV] || '').trim();
|
|
56
|
+
if (!raw) return DEFAULT_GATE_TIMEOUT_MS;
|
|
57
|
+
const parsed = Number(raw);
|
|
58
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_GATE_TIMEOUT_MS;
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Last {@link OUTPUT_TAIL_LINES} lines of a captured text, trimmed.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} text
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function tailOf(text) {
|
|
69
|
+
return String(text ?? '').split('\n').slice(-OUTPUT_TAIL_LINES).join('\n').trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The one line a timed-out gate command MUST carry.
|
|
74
|
+
*
|
|
75
|
+
* A timeout is a FAILURE with a name, never a silent `fail`: without this line
|
|
76
|
+
* a killed command is indistinguishable in the envelope from a command that
|
|
77
|
+
* exited non-zero on its own, and the operator has nothing to act on. It names
|
|
78
|
+
* the ceiling that fired, the process GROUP that was signalled, the ladder that
|
|
79
|
+
* ran, and — the part an exit code can never carry — anything that SURVIVED
|
|
80
|
+
* SIGKILL (PRD B6: a sent signal proves nothing).
|
|
81
|
+
*
|
|
82
|
+
* @param {number} timeoutMs
|
|
83
|
+
* @param {{pgid: number, killSignals: string[], survivors: number[]}} run
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function timeoutLine(timeoutMs, run) {
|
|
87
|
+
const ladder = (run.killSignals ?? []).join('\u2192') || 'no signal sent';
|
|
88
|
+
return `gate: TIMEOUT after ${timeoutMs} ms \u2014 process group ${run.pgid} ${ladder}, `
|
|
89
|
+
+ `survivors: [${(run.survivors ?? []).join(', ')}]`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The stdout envelope `scripts/run-quality-gate.mjs` publishes when the GATE
|
|
94
|
+
* SUB-SCRIPT itself was killed on the wall-clock ceiling.
|
|
95
|
+
*
|
|
96
|
+
* Lives here, beside the other envelope helpers, because the CLI that consumes
|
|
97
|
+
* it is a top-level script with no exports — a pure function there would be
|
|
98
|
+
* untestable without executing the CLI.
|
|
99
|
+
*
|
|
100
|
+
* Deliberately carries no `test`/`typecheck`/`lint` object: a killed gate
|
|
101
|
+
* measured nothing, and `suiteCountsFromGateStdout` must return `null` for it
|
|
102
|
+
* (absent is not zero). `error: 'gate-timeout'` is the machine-readable
|
|
103
|
+
* discriminator; the exit code is 124, the same value `spawnInGroup` reports
|
|
104
|
+
* and the same one coreutils `timeout(1)` uses.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} args
|
|
107
|
+
* @param {string} args.variant The `--variant` value the run was started with.
|
|
108
|
+
* @param {number} args.timeoutMs Ceiling that fired.
|
|
109
|
+
* @param {{pgid: number, durationMs: number, killSignals: string[], survivors: number[]}} args.run
|
|
110
|
+
* The {@link spawnInGroup} result.
|
|
111
|
+
* @returns {{variant: string, error: 'gate-timeout', timeout_ms: number, duration_ms: number,
|
|
112
|
+
* pgid: number, kill_signals: string[], survivors: number[]}}
|
|
113
|
+
*/
|
|
114
|
+
export function gateTimeoutEnvelope({ variant, timeoutMs, run }) {
|
|
115
|
+
return {
|
|
116
|
+
variant,
|
|
117
|
+
error: 'gate-timeout',
|
|
118
|
+
timeout_ms: timeoutMs,
|
|
119
|
+
duration_ms: run?.durationMs ?? 0,
|
|
120
|
+
pgid: run?.pgid ?? -1,
|
|
121
|
+
kill_signals: run?.killSignals ?? [],
|
|
122
|
+
survivors: run?.survivors ?? [],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Decide WHAT a finished gate sub-script run publishes — stdout, stderr,
|
|
128
|
+
* exit code and operator warnings — without performing any of the writes.
|
|
129
|
+
*
|
|
130
|
+
* ## Why this is a pure function and not four `process.*.write` calls
|
|
131
|
+
*
|
|
132
|
+
* The timeout branch in `scripts/run-quality-gate.mjs` was unreachable by any
|
|
133
|
+
* test: reaching it required a REAL gate sub-script to exceed
|
|
134
|
+
* `resolveGateTimeoutMs() + GATE_OUTER_TIMEOUT_RESERVE_MS` (15 min + 60 s, a
|
|
135
|
+
* hard constant with no injection seam), so the four decisions it makes —
|
|
136
|
+
* suppress the partial capture on stdout, re-publish it on stderr, emit ONE
|
|
137
|
+
* complete `gate-timeout` envelope, warn about survivors — were pinned by
|
|
138
|
+
* nothing. The CLI now decides here and only WRITES there, so each decision is
|
|
139
|
+
* testable against a synthetic {@link spawnInGroup} result.
|
|
140
|
+
*
|
|
141
|
+
* ## The one-document contract on stdout
|
|
142
|
+
*
|
|
143
|
+
* A killed child never wrote its envelope, so its capture is at best a partial
|
|
144
|
+
* JSON document. Publishing that hands every stdout consumer a parse error
|
|
145
|
+
* where a named failure belongs; publishing BOTH the partial text and an
|
|
146
|
+
* envelope breaks the "one JSON document" contract. Hence: capture → stderr,
|
|
147
|
+
* envelope → stdout, and `stdout` carries EXACTLY the envelope line.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} args
|
|
150
|
+
* @param {{fullOutput?: string, exitCode?: number, timedOut?: boolean, pgid?: number,
|
|
151
|
+
* durationMs?: number, killSignals?: string[], survivors?: number[]}} args.result
|
|
152
|
+
* The {@link spawnInGroup} result for the gate sub-script.
|
|
153
|
+
* @param {string} args.variant The `--variant` value the run was started with.
|
|
154
|
+
* @param {number} args.timeoutMs The OUTER ceiling that applied to the sub-script.
|
|
155
|
+
* @returns {{stdout: string, stderr: string, exitCode: number, warnings: string[]}}
|
|
156
|
+
* `stdout`/`stderr` are written verbatim (empty string = write nothing);
|
|
157
|
+
* `warnings` go through the caller's `warn()`; `exitCode` is the gate's own,
|
|
158
|
+
* which `spawnInGroup` reports as 124 on the timeout path.
|
|
159
|
+
*/
|
|
160
|
+
export function publishGateOutcome({ result, variant, timeoutMs }) {
|
|
161
|
+
const capture = String(result?.fullOutput ?? '');
|
|
162
|
+
const exitCode = result?.exitCode ?? 0;
|
|
163
|
+
|
|
164
|
+
if (!result?.timedOut) {
|
|
165
|
+
return { stdout: capture, stderr: '', exitCode, warnings: [] };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const survivors = result.survivors ?? [];
|
|
169
|
+
return {
|
|
170
|
+
stdout: `${JSON.stringify(gateTimeoutEnvelope({ variant, timeoutMs, run: result }))}\n`,
|
|
171
|
+
stderr: capture.trim()
|
|
172
|
+
? `\n\u2500\u2500\u2500\u2500 gate TIMED OUT \u2014 captured output before the kill \u2500\u2500\u2500\u2500\n${capture}\n\u2500\u2500\u2500\u2500 end \u2500\u2500\u2500\u2500\n`
|
|
173
|
+
: '',
|
|
174
|
+
exitCode,
|
|
175
|
+
warnings: survivors.length > 0
|
|
176
|
+
? [`gate process group ${result.pgid} left survivors after SIGKILL: ${survivors.join(', ')}`]
|
|
177
|
+
: [],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
15
181
|
// ---------------------------------------------------------------------------
|
|
16
182
|
// Internal pattern helpers
|
|
17
183
|
// ---------------------------------------------------------------------------
|
|
@@ -39,7 +205,8 @@ function isTestFile(filePath) {
|
|
|
39
205
|
// ---------------------------------------------------------------------------
|
|
40
206
|
|
|
41
207
|
/**
|
|
42
|
-
* Execute a shell command
|
|
208
|
+
* Execute a shell command as the LEADER OF ITS OWN PROCESS GROUP, under a
|
|
209
|
+
* wall-clock ceiling, and return a structured result.
|
|
43
210
|
*
|
|
44
211
|
* `output` is a bounded TAIL for humans. `fullOutput` is the complete captured
|
|
45
212
|
* text and is what every COUNT parse must read.
|
|
@@ -52,10 +219,47 @@ function isTestFile(filePath) {
|
|
|
52
219
|
* "the runner never produced results" and hides WHICH test failed. An hour was
|
|
53
220
|
* spent chasing that phantom before the real cause (one red test) was found.
|
|
54
221
|
*
|
|
222
|
+
* ## Why a process group, and why a timeout (Epic #1425 A3)
|
|
223
|
+
*
|
|
224
|
+
* This used to be `execSync(cmd, { maxBuffer })` — no timeout at all, and the
|
|
225
|
+
* shell as the only signalled process. On 2026-09-20 four `tsgo --noEmit`
|
|
226
|
+
* grandchildren of gate runs outlived their parents at PPID 1 with up to 8.0 GB
|
|
227
|
+
* RSS each and took the host to 13 % free memory. {@link spawnInGroup} spawns
|
|
228
|
+
* `detached`, so `process.kill(-pgid, …)` reaches every descendant, and it runs
|
|
229
|
+
* the SIGTERM→grace→SIGKILL ladder with a read-back verification.
|
|
230
|
+
*
|
|
231
|
+
* ## What changed for callers
|
|
232
|
+
*
|
|
233
|
+
* 1. It is ASYNC. Every call site must `await`.
|
|
234
|
+
* 2. `fullOutput` now interleaves stdout AND stderr on the PASS path too
|
|
235
|
+
* (`execSync` discarded stderr when the command succeeded). A runner that
|
|
236
|
+
* prints its summary to stderr is therefore no longer invisible to
|
|
237
|
+
* {@link extractTestCounts}.
|
|
238
|
+
* 3. Three fields are added — `timedOut`, `killSignals`, `survivors` — and they
|
|
239
|
+
* are present ONLY when a process actually ran. A skipped or stubbed command
|
|
240
|
+
* spawned nothing, so it carries no `timedOut: false`: absent is not a
|
|
241
|
+
* measured false, the same contract `counts` and `files` already keep.
|
|
242
|
+
*
|
|
243
|
+
* A timeout is a REPORTED failure: `status: 'fail'`, `exitCode: 124`, and a
|
|
244
|
+
* `gate: TIMEOUT after …` line appended to `output`/`fullOutput` naming the
|
|
245
|
+
* ceiling, the group, the signal ladder and any survivor. It is never a silent
|
|
246
|
+
* `fail`.
|
|
247
|
+
*
|
|
55
248
|
* @param {string} cmd - Shell command to run, or `"skip"` / empty to skip.
|
|
56
|
-
* @
|
|
249
|
+
* @param {object} [opts] - Forwarded verbatim to {@link spawnInGroup}, and it
|
|
250
|
+
* OVERRIDES the defaults below (`timeoutMs`, `maxOutputBytes`, `repoRoot`,
|
|
251
|
+
* `commandSignature`). This is also the seam tests inject `spawnFn` /
|
|
252
|
+
* `killFn` / `isAliveFn` through.
|
|
253
|
+
* @param {number|null} [opts.timeoutMs] - Wall-clock ceiling; defaults to
|
|
254
|
+
* {@link resolveGateTimeoutMs} (`SO_GATE_TIMEOUT_MS` or 900_000). `null`
|
|
255
|
+
* disables the clock — the byte cap still applies.
|
|
256
|
+
* @param {string} [opts.repoRoot] - Root whose gate-process ledger the spawn is
|
|
257
|
+
* recorded in; defaults to `process.cwd()`, which is the tree under test.
|
|
258
|
+
* @returns {Promise<{ status: 'pass'|'fail'|'skip', output: string, fullOutput: string,
|
|
259
|
+
* exitCode: number, timedOut?: boolean, killSignals?: string[], survivors?: number[],
|
|
260
|
+
* stubbed?: { kind: 'echo'|'noop' } }>}
|
|
57
261
|
*/
|
|
58
|
-
export function runCheck(cmd) {
|
|
262
|
+
export async function runCheck(cmd, opts = {}) {
|
|
59
263
|
if (!cmd || cmd === 'skip') {
|
|
60
264
|
return { status: 'skip', output: '', fullOutput: '', exitCode: 0 };
|
|
61
265
|
}
|
|
@@ -65,26 +269,44 @@ export function runCheck(cmd) {
|
|
|
65
269
|
return { status: 'pass', output: `(stubbed: ${stub.kind})`, fullOutput: `(stubbed: ${stub.kind})`, exitCode: 0, stubbed: { kind: stub.kind } };
|
|
66
270
|
}
|
|
67
271
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
272
|
+
const timeoutMs = opts.timeoutMs === undefined ? resolveGateTimeoutMs() : opts.timeoutMs;
|
|
273
|
+
const run = await spawnInGroup(cmd, {
|
|
274
|
+
maxOutputBytes: RUN_CHECK_MAX_BUFFER_BYTES,
|
|
275
|
+
repoRoot: process.cwd(),
|
|
276
|
+
commandSignature: buildCommandSignature(cmd),
|
|
277
|
+
...opts,
|
|
278
|
+
timeoutMs,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Reported in every returned shape below, so a consumer never has to ask a
|
|
282
|
+
// second question to learn whether the group is actually gone.
|
|
283
|
+
const groupFields = { killSignals: run.killSignals, survivors: run.survivors };
|
|
284
|
+
|
|
285
|
+
if (run.timedOut) {
|
|
286
|
+
const fullOutput = `${run.fullOutput}\n${timeoutLine(timeoutMs, run)}\n`;
|
|
287
|
+
return {
|
|
288
|
+
status: 'fail',
|
|
289
|
+
output: tailOf(fullOutput),
|
|
290
|
+
fullOutput,
|
|
291
|
+
exitCode: run.exitCode,
|
|
292
|
+
timedOut: true,
|
|
293
|
+
...groupFields,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
83
296
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return { status: '
|
|
297
|
+
// Exit code 127 means command not found — treat as skip.
|
|
298
|
+
if (run.exitCode === 127) {
|
|
299
|
+
return { status: 'skip', output: 'command not found', fullOutput: '', exitCode: 127, timedOut: false, ...groupFields };
|
|
87
300
|
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
status: run.exitCode === 0 ? 'pass' : 'fail',
|
|
304
|
+
output: tailOf(run.fullOutput),
|
|
305
|
+
fullOutput: run.fullOutput,
|
|
306
|
+
exitCode: run.exitCode,
|
|
307
|
+
timedOut: false,
|
|
308
|
+
...groupFields,
|
|
309
|
+
};
|
|
88
310
|
}
|
|
89
311
|
|
|
90
312
|
/**
|
|
@@ -13,6 +13,31 @@ const testCmd = process.env.TEST_CMD;
|
|
|
13
13
|
const files = process.env.FILES ?? '';
|
|
14
14
|
const sessionStartRef = process.env.SESSION_START_REF ?? '';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Per-command wall-clock ceiling, published by `scripts/run-quality-gate.mjs`
|
|
18
|
+
* as `GATE_TIMEOUT_MS` (#1425 A3 / #1432). It carries the ALREADY-RESOLVED
|
|
19
|
+
* value — operator override `SO_GATE_TIMEOUT_MS` > Session Config
|
|
20
|
+
* `gate.timeout-path-b-ms` > 900 000 — so this script only has to read it.
|
|
21
|
+
*
|
|
22
|
+
* Absent or non-numeric (this gate invoked directly, not through the wrapper)
|
|
23
|
+
* → the option is OMITTED, and `runCheck` falls back to `resolveGateTimeoutMs()`
|
|
24
|
+
* exactly as before. An empty object is deliberate: passing `timeoutMs:
|
|
25
|
+
* undefined` would NOT trigger that fallback in every spread order.
|
|
26
|
+
*/
|
|
27
|
+
const CHECK_OPTS = (() => {
|
|
28
|
+
const raw = Number((process.env.GATE_TIMEOUT_MS || '').trim());
|
|
29
|
+
const opts = Number.isFinite(raw) && raw > 0 ? { timeoutMs: raw } : {};
|
|
30
|
+
// GATE_LEDGER_ROOT (#1425 A4, W5 fix-pass): the wrapper publishes the ledger
|
|
31
|
+
// root it resolved (--ledger-root > repo root) so the gate-process register
|
|
32
|
+
// is written where the WRAPPER decided, not where this sub-script happens to
|
|
33
|
+
// run. Without it every runCheck() here defaulted to process.cwd(), and a test
|
|
34
|
+
// that spawned this script from the checkout wrote real lines into the live
|
|
35
|
+
// .orchestrator/runtime/gate-processes.jsonl — the reaper's kill population.
|
|
36
|
+
const ledgerRoot = (process.env.GATE_LEDGER_ROOT || "").trim();
|
|
37
|
+
if (ledgerRoot && ledgerRoot.startsWith("/")) opts.repoRoot = ledgerRoot;
|
|
38
|
+
return opts;
|
|
39
|
+
})();
|
|
40
|
+
|
|
16
41
|
if (!typecheckCmd) {
|
|
17
42
|
process.stderr.write('TYPECHECK_CMD must be set\n');
|
|
18
43
|
process.exit(1);
|
|
@@ -28,7 +53,7 @@ let testStatus;
|
|
|
28
53
|
let errors = [];
|
|
29
54
|
|
|
30
55
|
// --- typecheck (always runs unless cmd is "skip") ---
|
|
31
|
-
const tcResult = await runCheck(typecheckCmd);
|
|
56
|
+
const tcResult = await runCheck(typecheckCmd, CHECK_OPTS);
|
|
32
57
|
const tcStatus = tcResult.status;
|
|
33
58
|
if (tcStatus === 'fail') {
|
|
34
59
|
errors = errors.concat(extractErrorLinesJson(tcResult.output, /error TS\d+/));
|
|
@@ -41,7 +66,7 @@ if (testCmd === 'skip') {
|
|
|
41
66
|
const testFiles = await resolveTestFiles(files, sessionStartRef);
|
|
42
67
|
if (testFiles.length > 0) {
|
|
43
68
|
const fileArgs = testFiles.join(' ');
|
|
44
|
-
const testResult = await runCheck(`${testCmd} -- ${fileArgs}
|
|
69
|
+
const testResult = await runCheck(`${testCmd} -- ${fileArgs}`, CHECK_OPTS);
|
|
45
70
|
testStatus = testResult.status;
|
|
46
71
|
if (testStatus === 'fail') {
|
|
47
72
|
const testErrors = extractErrorLinesJson(testResult.output, /(fail|error|FAIL)/i);
|
|
@@ -49,7 +74,7 @@ if (testCmd === 'skip') {
|
|
|
49
74
|
}
|
|
50
75
|
} else if (!files && !sessionStartRef) {
|
|
51
76
|
// No FILES or SESSION_START_REF supplied: run the full test suite
|
|
52
|
-
const testResult = await runCheck(testCmd);
|
|
77
|
+
const testResult = await runCheck(testCmd, CHECK_OPTS);
|
|
53
78
|
testStatus = testResult.status;
|
|
54
79
|
if (testStatus === 'fail') {
|
|
55
80
|
const testErrors = extractErrorLinesJson(testResult.output, /(fail|error|FAIL)/i);
|
|
@@ -14,6 +14,31 @@ import { runCheck, csvToJsonArray } from './gate-helpers.mjs';
|
|
|
14
14
|
const typecheckCmd = process.env.TYPECHECK_CMD;
|
|
15
15
|
const testCmd = process.env.TEST_CMD;
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Per-command wall-clock ceiling, published by `scripts/run-quality-gate.mjs`
|
|
19
|
+
* as `GATE_TIMEOUT_MS` (#1425 A3 / #1432). It carries the ALREADY-RESOLVED
|
|
20
|
+
* value — operator override `SO_GATE_TIMEOUT_MS` > Session Config
|
|
21
|
+
* `gate.timeout-path-b-ms` > 900 000 — so this script only has to read it.
|
|
22
|
+
*
|
|
23
|
+
* Absent or non-numeric (this gate invoked directly, not through the wrapper)
|
|
24
|
+
* → the option is OMITTED, and `runCheck` falls back to `resolveGateTimeoutMs()`
|
|
25
|
+
* exactly as before. An empty object is deliberate: passing `timeoutMs:
|
|
26
|
+
* undefined` would NOT trigger that fallback in every spread order.
|
|
27
|
+
*/
|
|
28
|
+
const CHECK_OPTS = (() => {
|
|
29
|
+
const raw = Number((process.env.GATE_TIMEOUT_MS || '').trim());
|
|
30
|
+
const opts = Number.isFinite(raw) && raw > 0 ? { timeoutMs: raw } : {};
|
|
31
|
+
// GATE_LEDGER_ROOT (#1425 A4, W5 fix-pass): the wrapper publishes the ledger
|
|
32
|
+
// root it resolved (--ledger-root > repo root) so the gate-process register
|
|
33
|
+
// is written where the WRAPPER decided, not where this sub-script happens to
|
|
34
|
+
// run. Without it every runCheck() here defaulted to process.cwd(), and a test
|
|
35
|
+
// that spawned this script from the checkout wrote real lines into the live
|
|
36
|
+
// .orchestrator/runtime/gate-processes.jsonl — the reaper's kill population.
|
|
37
|
+
const ledgerRoot = (process.env.GATE_LEDGER_ROOT || "").trim();
|
|
38
|
+
if (ledgerRoot && ledgerRoot.startsWith("/")) opts.repoRoot = ledgerRoot;
|
|
39
|
+
return opts;
|
|
40
|
+
})();
|
|
41
|
+
|
|
17
42
|
if (!typecheckCmd) {
|
|
18
43
|
process.stderr.write('gate-per-file: TYPECHECK_CMD must be set\n');
|
|
19
44
|
process.exit(1);
|
|
@@ -33,14 +58,14 @@ if (files.length === 0) {
|
|
|
33
58
|
}
|
|
34
59
|
|
|
35
60
|
// Typecheck runs on the whole project, not per-file
|
|
36
|
-
const tcResult = runCheck(typecheckCmd);
|
|
61
|
+
const tcResult = await runCheck(typecheckCmd, CHECK_OPTS);
|
|
37
62
|
const tcStatus = tcResult.status;
|
|
38
63
|
|
|
39
64
|
let testStatus = 'skip';
|
|
40
65
|
|
|
41
66
|
if (testCmd !== 'skip' && files.length > 0) {
|
|
42
67
|
const fileArgs = files.join(' ');
|
|
43
|
-
const testResult = runCheck(`${testCmd} -- ${fileArgs}
|
|
68
|
+
const testResult = await runCheck(`${testCmd} -- ${fileArgs}`, CHECK_OPTS);
|
|
44
69
|
testStatus = testResult.status;
|
|
45
70
|
}
|
|
46
71
|
|
|
@@ -139,7 +139,12 @@ export function renderPortfolio(summaries, opts) {
|
|
|
139
139
|
// Frontmatter
|
|
140
140
|
lines.push('---');
|
|
141
141
|
lines.push(`_generator: ${GENERATOR_MARKER}`);
|
|
142
|
-
|
|
142
|
+
// `id` + `type` satisfy the vault frontmatter schema (skills/vault-sync/validator.mjs).
|
|
143
|
+
// `type: dashboard` was not in its enum and `id` was missing, so the generated file
|
|
144
|
+
// failed the hard gate at /close (#1144). `board` is the enum member added for
|
|
145
|
+
// generated dashboards (#738) — same shape as vault-status/board-writer.mjs.
|
|
146
|
+
lines.push('id: portfolio');
|
|
147
|
+
lines.push('type: board');
|
|
143
148
|
lines.push(`created: ${createdValue}`);
|
|
144
149
|
lines.push(`updated: ${updatedValue}`);
|
|
145
150
|
lines.push('---');
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
|
|
79
79
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
80
80
|
import { basename, join } from 'node:path';
|
|
81
|
+
import { scanEventsBackwards } from './events.mjs';
|
|
81
82
|
import { loadApplicableRules, parseGlobsFrontmatter } from './rule-loader.mjs';
|
|
82
83
|
|
|
83
84
|
/** Default directive ceiling (operator-chosen growth ratchet just above the ~457 baseline). */
|
|
@@ -443,6 +444,35 @@ export const DEFAULT_GENERATED_BYTE_CEILING = 95000;
|
|
|
443
444
|
*/
|
|
444
445
|
export const DEFAULT_PATH_SCOPED_BYTE_CEILING = 124000;
|
|
445
446
|
|
|
447
|
+
/**
|
|
448
|
+
* Share of {@link DEFAULT_PATH_SCOPED_BYTE_CEILING} at which the banner warns
|
|
449
|
+
* BEFORE the writer refuses (#1419).
|
|
450
|
+
*
|
|
451
|
+
* The defect #1419 records is not the ceiling but its TIMING: it becomes
|
|
452
|
+
* visible only inside `writeApprovedRules`, after the operator has already
|
|
453
|
+
* decided. Measured on that case (2026-09-20, /reconcile run): 117,973 of
|
|
454
|
+
* 124,000 B = 95.1 % — ~6 kB of headroom holding back `capped: 91`
|
|
455
|
+
* candidates, and `overPathScopedBudget` (a HARD `bytes > ceiling` test) did
|
|
456
|
+
* not fire, so nothing warned.
|
|
457
|
+
*
|
|
458
|
+
* 90 % is the distance of ~4 materialised rules: 10 % of 124,000 B = 12,400 B
|
|
459
|
+
* against the ~1.5 kB a generated rule file occupied on the #1419 tree — i.e.
|
|
460
|
+
* roughly four more accepted learnings until the hard break. A fixed fraction,
|
|
461
|
+
* deliberately not a per-repo calibration: it cannot know how large THIS
|
|
462
|
+
* repo's next rule is, and a second calibrated number would need its own
|
|
463
|
+
* falsification record.
|
|
464
|
+
*
|
|
465
|
+
* BV-004 ceiling + revisit trigger (HR-105): measure this line's firing rate
|
|
466
|
+
* after ~20 sessions from the probe records in
|
|
467
|
+
* `.orchestrator/metrics/events.jsonl`
|
|
468
|
+
* (`jq -c 'select(.event=="orchestrator.probes.completed")
|
|
469
|
+
* | .probes[] | select(.id=="instruction-budget")' …` — count `ran-warn`
|
|
470
|
+
* against the record total).
|
|
471
|
+
* Above ~10 % the instrument is RE-AIMED per HR-101 — neither lowered nor
|
|
472
|
+
* silenced; below it, leave it alone.
|
|
473
|
+
*/
|
|
474
|
+
export const PATH_SCOPED_NEAR_THRESHOLD = 0.9;
|
|
475
|
+
|
|
446
476
|
/**
|
|
447
477
|
* Read the `instruction-budget:` nested block from the `## Session Config`
|
|
448
478
|
* section of CLAUDE.md (or AGENTS.md) at `repoRoot`. Synchronous + never throws.
|
|
@@ -1244,6 +1274,83 @@ export function computeInstructionBudget(opts = {}) {
|
|
|
1244
1274
|
};
|
|
1245
1275
|
}
|
|
1246
1276
|
|
|
1277
|
+
/** Ledger event the backlog number below is read from (`reconcile/engine.mjs`). */
|
|
1278
|
+
const RECONCILE_EVENT = 'orchestrator.reconcile.completed';
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* Wall-clock budget for the backwards ledger walk. Deliberately small: this
|
|
1282
|
+
* runs inside a session-start probe whose own budget is 2 s, and the number it
|
|
1283
|
+
* fetches is DECORATION on a line that is already actionable without it.
|
|
1284
|
+
*/
|
|
1285
|
+
const RECONCILE_SCAN_BUDGET_MS = 150;
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* `capped` from the most recent `orchestrator.reconcile.completed` record —
|
|
1289
|
+
* how many rule-eligible learnings the last /reconcile run held back.
|
|
1290
|
+
*
|
|
1291
|
+
* Three-state on purpose (`feedback_missing_measurement_looks_like_zero`):
|
|
1292
|
+
* `null` means NOT MEASURED (no ledger, no such record, unreadable source, or
|
|
1293
|
+
* the walk ran out of budget), which the caller must render as ABSENCE — never
|
|
1294
|
+
* as `0`, which is a measured "nothing waiting".
|
|
1295
|
+
*
|
|
1296
|
+
* @param {string|undefined} repoRoot
|
|
1297
|
+
* @returns {number|null}
|
|
1298
|
+
*/
|
|
1299
|
+
function readLastReconcileCapped(repoRoot) {
|
|
1300
|
+
if (typeof repoRoot !== 'string' || repoRoot === '') return null;
|
|
1301
|
+
const filePath = join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
|
|
1302
|
+
let capped = null;
|
|
1303
|
+
let scan;
|
|
1304
|
+
try {
|
|
1305
|
+
scan = scanEventsBackwards({
|
|
1306
|
+
filePath,
|
|
1307
|
+
budgetMs: RECONCILE_SCAN_BUDGET_MS,
|
|
1308
|
+
// Cheap raw-line pre-filter before JSON.parse — same reason as
|
|
1309
|
+
// `maintenance-due-banner.mjs`: parsing every line of a multi-MB ledger
|
|
1310
|
+
// to answer one question is the cost this option exists to avoid.
|
|
1311
|
+
filter: RECONCILE_EVENT,
|
|
1312
|
+
onRecord: (rec) => {
|
|
1313
|
+
if (rec?.event !== RECONCILE_EVENT) return false;
|
|
1314
|
+
capped = Number.isFinite(rec.capped) ? rec.capped : null;
|
|
1315
|
+
return true; // newest record wins; stop the walk
|
|
1316
|
+
},
|
|
1317
|
+
});
|
|
1318
|
+
} catch {
|
|
1319
|
+
return null; // never throw out of the banner wrapper
|
|
1320
|
+
}
|
|
1321
|
+
// A walk that did not finish has not proven anything about the backlog.
|
|
1322
|
+
if (scan.truncated || scan.unreadable.length > 0) return null;
|
|
1323
|
+
return capped;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* The #1419 early-warning line: the path-scoped ceiling is close, not broken.
|
|
1328
|
+
*
|
|
1329
|
+
* Reports the numbers the RULE judged (HR-106) — the same `pathScoped.bytes`
|
|
1330
|
+
* and ceiling `writeApprovedRules` will compare later — plus, when the ledger
|
|
1331
|
+
* carries one, how many learnings the last /reconcile run already held back.
|
|
1332
|
+
* The percentage is formatted locale-free so the same corpus renders the same
|
|
1333
|
+
* string on every host.
|
|
1334
|
+
*
|
|
1335
|
+
* @param {ReturnType<typeof computeInstructionBudget>} budget
|
|
1336
|
+
* @param {string|undefined} repoRoot
|
|
1337
|
+
* @returns {string}
|
|
1338
|
+
*/
|
|
1339
|
+
function formatNearPathScopedLine(budget, repoRoot) {
|
|
1340
|
+
const used = budget.bySurface.pathScoped.bytes;
|
|
1341
|
+
const ceiling = budget.pathScopedByteCeiling;
|
|
1342
|
+
const pct = ((used / ceiling) * 100).toFixed(1);
|
|
1343
|
+
const capped = readLastReconcileCapped(repoRoot);
|
|
1344
|
+
// Only a POSITIVE measured backlog is worth a clause; `0` and `null` both
|
|
1345
|
+
// add nothing the operator can act on, and printing `0` for `null` would be
|
|
1346
|
+
// the false-negative this helper's three-state contract exists to prevent.
|
|
1347
|
+
const backlog = typeof capped === 'number' && capped > 0 ? ` — ${capped} Learnings warten` : '';
|
|
1348
|
+
return (
|
|
1349
|
+
`path-scoped Regel-Decke zu ${pct} % belegt (${used}/${ceiling} B)${backlog} — ` +
|
|
1350
|
+
'/reconcile wird Learnings zurueckhalten'
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1247
1354
|
/**
|
|
1248
1355
|
* Banner wrapper — session-start Phase 4 convention.
|
|
1249
1356
|
*
|
|
@@ -1268,10 +1375,15 @@ export function computeInstructionBudget(opts = {}) {
|
|
|
1268
1375
|
* @param {number} [opts.byteCeiling] explicit byte-ceiling override (wins over config).
|
|
1269
1376
|
* @param {number} [opts.pathScopedByteCeiling] explicit path-scoped-ceiling override.
|
|
1270
1377
|
* @returns {{ severity: 'warn', message: string } | null}
|
|
1271
|
-
* null when disabled / off / every axis
|
|
1272
|
-
* read failure. Since #1316 each of the four axes — directives, bytes,
|
|
1378
|
+
* null when disabled / off / every axis comfortably under its ceiling OR on
|
|
1379
|
+
* any read failure. Since #1316 each of the four axes — directives, bytes,
|
|
1273
1380
|
* generated, path-scoped — raises the banner on its own (see
|
|
1274
|
-
* DEFAULT_PATH_SCOPED_BYTE_CEILING).
|
|
1381
|
+
* DEFAULT_PATH_SCOPED_BYTE_CEILING). Since #1419 the path-scoped axis ALSO
|
|
1382
|
+
* raises it at {@link PATH_SCOPED_NEAR_THRESHOLD} of its ceiling, before the
|
|
1383
|
+
* breach: that band used to be silent, and the ceiling then surfaced only
|
|
1384
|
+
* inside `writeApprovedRules` — after the operator had approved rules it
|
|
1385
|
+
* refused to write. The RETURN SHAPE is unchanged (`{severity:'warn',
|
|
1386
|
+
* message}` | null), so `session-start-probes.mjs` needs no new vocabulary.
|
|
1275
1387
|
*/
|
|
1276
1388
|
export function checkInstructionBudget(opts = {}) {
|
|
1277
1389
|
let cfg;
|
|
@@ -1331,7 +1443,33 @@ export function checkInstructionBudget(opts = {}) {
|
|
|
1331
1443
|
return null; // never throw out of the banner wrapper
|
|
1332
1444
|
}
|
|
1333
1445
|
|
|
1334
|
-
if (!budget
|
|
1446
|
+
if (!budget) return null;
|
|
1447
|
+
|
|
1448
|
+
// #1419 near-threshold stage. Strictly BELOW the hard test (a breach is
|
|
1449
|
+
// reported as a breach, never twice), and computed before the early return
|
|
1450
|
+
// so a corpus at 90–100 % of the path-scoped ceiling stops being silent —
|
|
1451
|
+
// that silence is the defect: the ceiling used to surface only inside
|
|
1452
|
+
// `writeApprovedRules`, after the operator had already approved rules.
|
|
1453
|
+
const nearPathScoped =
|
|
1454
|
+
!budget.overPathScopedBudget &&
|
|
1455
|
+
budget.pathScopedByteCeiling > 0 &&
|
|
1456
|
+
budget.bySurface.pathScoped.bytes >= budget.pathScopedByteCeiling * PATH_SCOPED_NEAR_THRESHOLD;
|
|
1457
|
+
|
|
1458
|
+
if (!budget.overBudget && !nearPathScoped) return null;
|
|
1459
|
+
|
|
1460
|
+
const nearLine = nearPathScoped ? formatNearPathScopedLine(budget, opts.repoRoot) : null;
|
|
1461
|
+
|
|
1462
|
+
// Near-threshold and nothing breached: a one-finding banner, carrying the
|
|
1463
|
+
// same remedy as the breach clause below (consolidate — never raise).
|
|
1464
|
+
if (!budget.overBudget) {
|
|
1465
|
+
return {
|
|
1466
|
+
severity: 'warn',
|
|
1467
|
+
message: [
|
|
1468
|
+
`⚠ ${nearLine}.`,
|
|
1469
|
+
' Consolidate generated rules per docs/rule-authoring.md § Consolidated rules; never raise the ceiling.',
|
|
1470
|
+
].join('\n'),
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1335
1473
|
|
|
1336
1474
|
// Name only the breached axes — listing a healthy axis would pad the line
|
|
1337
1475
|
// without telling the operator anything they must act on.
|
|
@@ -1377,8 +1515,12 @@ export function checkInstructionBudget(opts = {}) {
|
|
|
1377
1515
|
.map((f) => `${f.file} (${f.count} dir, ${f.bytes} B)`)
|
|
1378
1516
|
.join(', ');
|
|
1379
1517
|
|
|
1518
|
+
// The near-threshold line rides ALONGSIDE the breach line rather than inside
|
|
1519
|
+
// `axes`: the path-scoped axis is not over, and folding a healthy-but-close
|
|
1520
|
+
// axis into a sentence that says "over" would misreport which ceiling broke.
|
|
1380
1521
|
const message = [
|
|
1381
1522
|
`⚠ Instruction budget over — ${axes.join(' · ')} across ${budget.perFile.length} always-on rules.`,
|
|
1523
|
+
...(nearLine ? [` ${nearLine}.`] : []),
|
|
1382
1524
|
` Top files: ${top}`,
|
|
1383
1525
|
' See the instruction-budget audit (#687; archived in the private Meta-Vault) for the prune/demote list.',
|
|
1384
1526
|
].join('\n');
|