@event4u/agent-config 14.8.0 → 14.9.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +165 -237
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +34 -4
- package/dist/agent-src/commands/pr/merge.md +9 -5
- package/dist/agent-src/commands/review/changes.md +95 -14
- package/dist/agent-src/commands/roadmap/process-full.md +2 -2
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +36 -0
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
- package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
- package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
- package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
- package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
- package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
- package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +8 -0
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-MMSGVZKV.js → chunk-77GR3VD4.js} +65 -40
- package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
- package/dist/cli-delegate/{chunk-EEJUMCDZ.js → chunk-PLSG6UL5.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +89 -13
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +37 -15
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +562 -204
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +808 -331
- package/dist/scripts/_lib/claude_plugin.js +162 -0
- package/dist/scripts/_lib/claude_plugin.js.map +1 -0
- package/dist/scripts/_lib/environment_detector.js +397 -0
- package/dist/scripts/_lib/environment_detector.js.map +1 -0
- package/dist/scripts/_lib/spawn_env.js +129 -0
- package/dist/scripts/_lib/spawn_env.js.map +1 -0
- package/dist/scripts/ai_council/modes.js +199 -0
- package/dist/scripts/ai_council/modes.js.map +1 -0
- package/dist/scripts/ai_council/transport_resolver.js +418 -0
- package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
- package/dist/server/routes/wizard.js +3 -2
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/ui/assets/index-Cxgy63kz.js +26 -0
- package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
- package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
- package/package.json +7 -3
- package/src/config/estate-count-budget.json +13 -744
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +247 -9
- package/src/config/gate-violation-baselines.json +72 -72
- package/src/config/publish-surface.json +60 -0
- package/src/config/requirements-trace-budget.json +32 -0
- package/src/config/rule-stub-ceilings.json +8 -1
- package/src/scripts/_dispatch.bash +7 -0
- package/src/scripts/_lib/base_tree.ts +175 -0
- package/src/scripts/_lib/billing_grant.ts +165 -0
- package/src/scripts/_lib/billing_grant_cli.ts +123 -0
- package/src/scripts/_lib/council_fallback_posture.ts +53 -6
- package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
- package/src/scripts/_lib/host_capability.ts +59 -5
- package/src/scripts/_lib/quota_parked.ts +133 -0
- package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
- package/src/scripts/_lib/review_telemetry.ts +196 -0
- package/src/scripts/_lib/subagent_response.ts +92 -0
- package/src/scripts/adr/regenerate_index.ts +14 -7
- package/src/scripts/ai_council/clients.ts +12 -1
- package/src/scripts/ai_council/config.ts +72 -115
- package/src/scripts/ai_council/fallback_config.ts +19 -7
- package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
- package/src/scripts/ai_council/orchestrator.ts +2 -2
- package/src/scripts/ai_council/py_format.ts +124 -0
- package/src/scripts/ai_council/transport_resolver.ts +65 -2
- package/src/scripts/audit_adr_coverage.ts +33 -6
- package/src/scripts/check_council_pin_staleness.ts +300 -0
- package/src/scripts/check_estate_count.ts +420 -293
- package/src/scripts/check_gate_coverage.ts +91 -0
- package/src/scripts/check_no_stub_inventory_table.ts +292 -0
- package/src/scripts/check_pack_size.ts +234 -1
- package/src/scripts/check_publish_surface.ts +297 -0
- package/src/scripts/check_requirements_trace.ts +378 -0
- package/src/scripts/check_secret_leak.ts +166 -12
- package/src/scripts/code_graph/cli.ts +44 -5
- package/src/scripts/code_graph/extract.ts +87 -0
- package/src/scripts/council_cli.ts +12 -16
- package/src/scripts/detect_ai_tells.ts +87 -0
- package/src/scripts/dispatch_r2_reviewer.ts +42 -0
- package/src/scripts/generate_capabilities_index.ts +41 -0
- package/src/scripts/git_authorization_hook.ts +54 -4
- package/src/scripts/hooks/block_unauthorized_git.ts +19 -1
- package/src/scripts/hooks/run_continuation_hook.ts +3 -1
- package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
- package/src/scripts/injection_scan_hook.ts +120 -10
- package/src/scripts/lint_adr_sweep_routing.ts +365 -0
- package/src/scripts/lint_workflow_security.ts +53 -10
- package/src/scripts/lint_workflow_security_allowlist.json +2 -18
- package/src/scripts/report_envelope_rate.ts +166 -0
- package/src/scripts/review_axis_report.ts +218 -0
- package/src/scripts/run_supervise.ts +46 -1
- package/src/scripts/schemas/evals.schema.json +5 -0
- package/src/scripts/sync_pr_branch.ts +14 -7
- package/dist/cli/commands/commands.candidates.test.js +0 -257
- package/dist/cli/commands/commands.candidates.test.js.map +0 -1
- package/dist/cli/commands/profiles.test.js +0 -88
- package/dist/cli/commands/profiles.test.js.map +0 -1
- package/dist/cli/commands/recordTriggerEval.test.js +0 -113
- package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
- package/dist/cli/configRoot.test.js +0 -97
- package/dist/cli/configRoot.test.js.map +0 -1
- package/dist/cli/mcp/content.test.js +0 -54
- package/dist/cli/mcp/content.test.js.map +0 -1
- package/dist/cli/mcp/dispatch.test.js +0 -108
- package/dist/cli/mcp/dispatch.test.js.map +0 -1
- package/dist/server/writeRoot.test.js +0 -91
- package/dist/server/writeRoot.test.js.map +0 -1
- package/dist/shared/capabilities.test.js +0 -39
- package/dist/shared/capabilities.test.js.map +0 -1
- package/dist/ui/assets/index-C1pBRm1f.js +0 -26
- package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
- /package/dist/cli-delegate/{chunk-N7CDDGI3.js → chunk-CYH5SP6M.js} +0 -0
|
@@ -17,8 +17,17 @@
|
|
|
17
17
|
* permissions: write-all;
|
|
18
18
|
* npm install / npm ci without --ignore-scripts in a
|
|
19
19
|
* pull_request_target workflow.
|
|
20
|
-
* MEDIUM —
|
|
21
|
-
*
|
|
20
|
+
* MEDIUM — actions pinned by mutable tag instead of full SHA;
|
|
21
|
+
* an actions/checkout step that keeps its credential.
|
|
22
|
+
*
|
|
23
|
+
* The MEDIUM tier's original wording read "third-party actions … (first-party
|
|
24
|
+
* actions/* are skipped)". Corrected 2026-08-22: the first-party exemption is
|
|
25
|
+
* GONE (see the removal note below), so the parenthetical documented an
|
|
26
|
+
* exemption that no longer exists — a false claim in a tracked artefact, which
|
|
27
|
+
* is the defect class the change that removed it was written to repair. The
|
|
28
|
+
* TIERS are untouched; only the description of what MEDIUM covers now matches
|
|
29
|
+
* the code. Re-tiering the locked model is a separate decision, recorded as
|
|
30
|
+
* unresolved in agents/evidence/analysis/workflow-security-net-degraded-decision.md.
|
|
22
31
|
*
|
|
23
32
|
* Script-injection detection (regex-based) is intentionally deferred — it
|
|
24
33
|
* requires an AST-aware pass to avoid false positives on quoted / escaped
|
|
@@ -53,8 +62,23 @@ function _setAllowlistPathForTest(p: string): void {
|
|
|
53
62
|
// Triggers that expose the repository token to untrusted pull-request context
|
|
54
63
|
const DANGEROUS_TRIGGERS: ReadonlySet<string> = new Set(['pull_request_target', 'workflow_run']);
|
|
55
64
|
|
|
56
|
-
//
|
|
57
|
-
|
|
65
|
+
// REMOVED 2026-08-22 (road-to-ci-supply-chain-integrity 1.4). This was
|
|
66
|
+
// `FIRST_PARTY_OWNERS = {'actions', 'github'}` and it exempted first-party
|
|
67
|
+
// owners from the mutable-tag rule. The premise — "mutable tags on these are
|
|
68
|
+
// acceptable" — is the one a supply-chain rule cannot hold: a first-party tag
|
|
69
|
+
// is still a mutable pointer, and `actions/checkout@v7` moving under the repo
|
|
70
|
+
// is the same class of event as any other owner's tag moving.
|
|
71
|
+
//
|
|
72
|
+
// What it cost, measured before removal: 100 of the 112 unpinned references in
|
|
73
|
+
// this tree were first-party, so the gate saw 12 of 112 defects and reported
|
|
74
|
+
// green on the rest. An exemption sized like that is not an exemption, it is
|
|
75
|
+
// the rule being off.
|
|
76
|
+
//
|
|
77
|
+
// The removal ships in the SAME change as the 112 pins, and in that order:
|
|
78
|
+
// pin first, then lift, so the transition is red-then-green inside one
|
|
79
|
+
// reviewable diff. Reverting the pins locally must make this gate exit
|
|
80
|
+
// non-zero — that is the assertion that the gate can still see the defect,
|
|
81
|
+
// which a green-only check cannot make.
|
|
58
82
|
|
|
59
83
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
60
84
|
type JsonObject = { [k: string]: JsonValue };
|
|
@@ -262,13 +286,33 @@ function _mutable_third_party_actions(doc: JsonObject, _workflow_name: string):
|
|
|
262
286
|
if (!uses || !uses.includes('@')) {
|
|
263
287
|
continue;
|
|
264
288
|
}
|
|
289
|
+
// `actions/checkout` leaves the repository token in the runner's git
|
|
290
|
+
// config for the rest of the job unless told not to. Measured before
|
|
291
|
+
// this rule existed: 0 of 50 checkouts in this tree set the flag, so
|
|
292
|
+
// every job carried a usable credential it did not need.
|
|
293
|
+
//
|
|
294
|
+
// `true` is a legitimate answer — two jobs here push — so the rule
|
|
295
|
+
// asks for the key to be PRESENT, not for a particular value. An
|
|
296
|
+
// explicit `true` is a reviewable decision; a missing key is an
|
|
297
|
+
// ambient one, and only the second is a finding.
|
|
298
|
+
if (uses.slice(0, uses.indexOf('@')).toLowerCase() === 'actions/checkout') {
|
|
299
|
+
const withObj = _asObject(stepObj['with'] as JsonValue);
|
|
300
|
+
const hasKey =
|
|
301
|
+
withObj !== null &&
|
|
302
|
+
Object.prototype.hasOwnProperty.call(withObj, 'persist-credentials');
|
|
303
|
+
if (!hasKey) {
|
|
304
|
+
findings.push({
|
|
305
|
+
severity: 'MEDIUM',
|
|
306
|
+
rule: 'persist-credentials',
|
|
307
|
+
detail:
|
|
308
|
+
'actions/checkout without an explicit persist-credentials — ' +
|
|
309
|
+
'the repository token stays in the runner git config for the rest of the job',
|
|
310
|
+
location: `job:${job_name}/step:${i + 1}`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
265
314
|
const at = uses.indexOf('@');
|
|
266
|
-
const action_ref = uses.slice(0, at);
|
|
267
315
|
const pin = uses.slice(at + 1);
|
|
268
|
-
const owner = action_ref.includes('/') ? (action_ref.split('/')[0] as string) : action_ref;
|
|
269
|
-
if (FIRST_PARTY_OWNERS.has(owner.toLowerCase())) {
|
|
270
|
-
continue;
|
|
271
|
-
}
|
|
272
316
|
// A full SHA pin is 40 hex chars; anything else is mutable
|
|
273
317
|
if (pin.length === 40 && [...pin.toLowerCase()].every((c) => '0123456789abcdef'.includes(c))) {
|
|
274
318
|
continue;
|
|
@@ -650,7 +694,6 @@ export {
|
|
|
650
694
|
ALLOWLIST_PATH,
|
|
651
695
|
ALLOWLIST_CAP,
|
|
652
696
|
DANGEROUS_TRIGGERS,
|
|
653
|
-
FIRST_PARTY_OWNERS,
|
|
654
697
|
_setWorkflowsDirForTest,
|
|
655
698
|
_setAllowlistPathForTest,
|
|
656
699
|
load_allowlist,
|
|
@@ -1,20 +1,4 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "Legitimate workflow security findings. Hard-capped at 20 entries (lint_workflow_security.py ALLOWLIST_CAP). Exceeding the cap signals the linter is wrong, not the content
|
|
3
|
-
"findings": [
|
|
4
|
-
{
|
|
5
|
-
"workflow": "bench-drift.yml",
|
|
6
|
-
"rule": "mutable-action-tag",
|
|
7
|
-
"reason": "marocchino/sticky-pull-request-comment@v2 — sticky PR comment for bench drift reporting; no code execution, output-only action. SHA-pinning tracked in road-to-security-hardening P3."
|
|
8
|
-
},
|
|
9
|
-
{
|
|
10
|
-
"workflow": "skill-lint.yml",
|
|
11
|
-
"rule": "mutable-action-tag",
|
|
12
|
-
"reason": "marocchino/sticky-pull-request-comment@v2 — sticky PR comment for skill-lint reporting; same rationale as bench-drift.yml. SHA-pinning tracked in road-to-security-hardening P3."
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
"workflow": "cloud-release.yml",
|
|
16
|
-
"rule": "mutable-action-tag",
|
|
17
|
-
"reason": "softprops/action-gh-release@v2 — creates GitHub releases; runs only on push to main (not on pull_request_target), no untrusted input. SHA-pinning tracked in road-to-security-hardening P3."
|
|
18
|
-
}
|
|
19
|
-
]
|
|
2
|
+
"_comment": "Legitimate workflow security findings. Hard-capped at 20 entries (lint_workflow_security.py ALLOWLIST_CAP). Exceeding the cap signals the linter is wrong, not the content \u2014 tighten the heuristic, do not grow this list (autonomous-execution allowlist-growth antipattern). Each entry MUST cite why the finding is acceptable in `reason`. EMPTIED 2026-08-22 (road-to-ci-supply-chain-integrity 0.2). It held three mutable-action-tag exemptions for marocchino/sticky-pull-request-comment and softprops/action-gh-release. All three are now pinned to full commit SHAs, so the findings they excused no longer exist and the entries matched nothing \u2014 lint_workflow_security reports \"0 allowlisted\". The roadmap step asked to REPAIR their `reason` text, each of which closed by naming an archived roadmap phase as the tracker for SHA-pinning; that phase is a git-discipline hook and its file mentions pinning nowhere, so the pointer dangled. Deleting a dead exemption is the honest repair \u2014 rewriting the reason on an entry that excuses nothing would have kept the allowlist looking load-bearing. The dangling pointer is also WHY the three survived: each one read as deferred rather than open. The full trace lives in the archived roadmap's completion note.",
|
|
3
|
+
"findings": []
|
|
20
4
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Publish `valid_envelope_rate` — three numbers, never one.
|
|
4
|
+
*
|
|
5
|
+
* `road-to-subagent-envelope-adoption` step 1.2: a rate with no denominator is
|
|
6
|
+
* a claim, not a measurement. So every line carries the rate, the window
|
|
7
|
+
* bounds, the stop count, AND the path it read — because the ledger is
|
|
8
|
+
* gitignored and machine-local, so a figure with no path is not reproducible
|
|
9
|
+
* even in principle.
|
|
10
|
+
*
|
|
11
|
+
* Named `report_` and not `check_` deliberately: this publishes a number and
|
|
12
|
+
* gates nothing. `gate_population.matchesGatePattern` classifies on a
|
|
13
|
+
* `lint|check|audit|skill|verify_` prefix, and a reporting script wearing a gate
|
|
14
|
+
* prefix would be required to carry a coverage floor it has no business having.
|
|
15
|
+
*
|
|
16
|
+
* ## Rows this counts, and rows it refuses
|
|
17
|
+
*
|
|
18
|
+
* Only `subagent_stop` rows carrying a POST-SPLIT `envelope_parse` value. The
|
|
19
|
+
* retired `absent` value collapsed `no_message` and `no_envelope` into one
|
|
20
|
+
* bucket, so a rate computed across the boundary would mix two vocabularies and
|
|
21
|
+
* report a number that is not about either. Rows with `absent` are excluded and
|
|
22
|
+
* counted separately, so the exclusion is visible rather than silent.
|
|
23
|
+
*
|
|
24
|
+
* Exit 0 always: a report that fails is a gate, and this is not one.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as fs from 'node:fs';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
32
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
33
|
+
const LEDGER_DIR = path.join(REPO_ROOT, 'agents', 'runtime', 'state', 'subagent-ledger');
|
|
34
|
+
|
|
35
|
+
export interface RateReport {
|
|
36
|
+
ledger_paths: string[];
|
|
37
|
+
stops: number;
|
|
38
|
+
ok: number;
|
|
39
|
+
rate: number;
|
|
40
|
+
window_start: string | null;
|
|
41
|
+
window_end: string | null;
|
|
42
|
+
by_verdict: Record<string, number>;
|
|
43
|
+
excluded_absent: number;
|
|
44
|
+
by_agent_type: Record<string, number>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function computeRate(lines: readonly string[], paths: readonly string[]): RateReport {
|
|
48
|
+
const by: Record<string, number> = {};
|
|
49
|
+
const types: Record<string, number> = {};
|
|
50
|
+
let stops = 0;
|
|
51
|
+
let absent = 0;
|
|
52
|
+
let start: string | null = null;
|
|
53
|
+
let end: string | null = null;
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
let r: Record<string, unknown>;
|
|
56
|
+
try {
|
|
57
|
+
r = JSON.parse(line) as Record<string, unknown>;
|
|
58
|
+
} catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (r['event'] !== 'subagent_stop') continue;
|
|
62
|
+
const verdict = r['envelope_parse'];
|
|
63
|
+
if (typeof verdict !== 'string') continue;
|
|
64
|
+
if (verdict === 'absent') {
|
|
65
|
+
absent += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
stops += 1;
|
|
69
|
+
by[verdict] = (by[verdict] ?? 0) + 1;
|
|
70
|
+
const at = typeof r['agent_type'] === 'string' ? r['agent_type'] : '(null)';
|
|
71
|
+
types[at] = (types[at] ?? 0) + 1;
|
|
72
|
+
const ts = r['ts'];
|
|
73
|
+
if (typeof ts === 'string') {
|
|
74
|
+
if (start === null || ts < start) start = ts;
|
|
75
|
+
if (end === null || ts > end) end = ts;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const ok = by['ok'] ?? 0;
|
|
79
|
+
return {
|
|
80
|
+
ledger_paths: [...paths],
|
|
81
|
+
stops,
|
|
82
|
+
ok,
|
|
83
|
+
// 0/0 is 0, not NaN: an empty ledger has a rate of zero valid envelopes
|
|
84
|
+
// and a denominator that says so on the same line.
|
|
85
|
+
rate: stops === 0 ? 0 : ok / stops,
|
|
86
|
+
window_start: start,
|
|
87
|
+
window_end: end,
|
|
88
|
+
by_verdict: by,
|
|
89
|
+
excluded_absent: absent,
|
|
90
|
+
by_agent_type: types,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readLedger(dir: string): { lines: string[]; paths: string[] } {
|
|
95
|
+
let names: string[] = [];
|
|
96
|
+
try {
|
|
97
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith('.jsonl')).sort();
|
|
98
|
+
} catch {
|
|
99
|
+
return { lines: [], paths: [] };
|
|
100
|
+
}
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
const paths: string[] = [];
|
|
103
|
+
for (const n of names) {
|
|
104
|
+
const abs = path.join(dir, n);
|
|
105
|
+
paths.push(path.relative(REPO_ROOT, abs).split(path.sep).join('/'));
|
|
106
|
+
lines.push(...fs.readFileSync(abs, 'utf-8').split('\n'));
|
|
107
|
+
}
|
|
108
|
+
return { lines, paths };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
112
|
+
// `--dir` so the report is reproducible against a ledger that is not this
|
|
113
|
+
// checkout's. The ledger is gitignored and machine-local, so without it the
|
|
114
|
+
// only readable ledger is the one beside the running copy — and a worktree
|
|
115
|
+
// has none, which would make the figure unobtainable exactly where the work
|
|
116
|
+
// happens.
|
|
117
|
+
const i = argv.indexOf('--dir');
|
|
118
|
+
const dir = i >= 0 && i + 1 < argv.length ? path.resolve(argv[i + 1] as string) : LEDGER_DIR;
|
|
119
|
+
const { lines, paths } = readLedger(dir);
|
|
120
|
+
const r = computeRate(lines, paths);
|
|
121
|
+
if (argv.includes('--json')) {
|
|
122
|
+
process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
if (r.ledger_paths.length === 0) {
|
|
126
|
+
// Not a failure, and not a rate either. The ledger is gitignored, so a
|
|
127
|
+
// clean clone has none — reporting 0 % there would be a number about
|
|
128
|
+
// nothing.
|
|
129
|
+
process.stdout.write(
|
|
130
|
+
`valid_envelope_rate: NO LEDGER at ${dir} — ` +
|
|
131
|
+
'the ledger is gitignored and machine-local, so this is expected in a fresh ' +
|
|
132
|
+
'clone and is not a measurement of zero.\n',
|
|
133
|
+
);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
const pct = (r.rate * 100).toFixed(2);
|
|
137
|
+
process.stdout.write(
|
|
138
|
+
`valid_envelope_rate: ${pct}% — ${String(r.ok)} ok of ${String(r.stops)} stops, ` +
|
|
139
|
+
`window ${r.window_start ?? '?'} → ${r.window_end ?? '?'}, ` +
|
|
140
|
+
`read from ${r.ledger_paths.join(', ')}\n`,
|
|
141
|
+
);
|
|
142
|
+
const verdicts = Object.entries(r.by_verdict)
|
|
143
|
+
.sort((a, b) => b[1] - a[1])
|
|
144
|
+
.map(([k, v]) => `${k} ${String(v)}`)
|
|
145
|
+
.join(' · ');
|
|
146
|
+
process.stdout.write(` verdicts: ${verdicts}\n`);
|
|
147
|
+
process.stdout.write(
|
|
148
|
+
` excluded (retired \`absent\` vocabulary): ${String(r.excluded_absent)}\n`,
|
|
149
|
+
);
|
|
150
|
+
const comp = Object.entries(r.by_agent_type)
|
|
151
|
+
.sort((a, b) => b[1] - a[1])
|
|
152
|
+
.map(([k, v]) => `${k} ${String(v)}`)
|
|
153
|
+
.join(' · ');
|
|
154
|
+
process.stdout.write(` agent-type composition: ${comp}\n`);
|
|
155
|
+
process.stdout.write(
|
|
156
|
+
' CAVEAT: machine-local. This ledger is one machine\'s drain traffic, so the\n' +
|
|
157
|
+
' composition above is the shape of THIS traffic and the rate does not\n' +
|
|
158
|
+
' generalise. Published with the composition rather than alone, so the limit\n' +
|
|
159
|
+
' is readable instead of hidden.\n',
|
|
160
|
+
);
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
165
|
+
process.exit(main());
|
|
166
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Report the spec axis's measured effect on the review surface.
|
|
4
|
+
*
|
|
5
|
+
* The question this answers, and the only one: over the observation window,
|
|
6
|
+
* how often did the spec axis change the recommendation the craft judges would
|
|
7
|
+
* have produced alone? The honest answers include **zero**, and **"nothing was
|
|
8
|
+
* recorded"** — which is a third answer, not a variant of zero. A report that
|
|
9
|
+
* printed `0 changed` over an empty ledger would read as evidence the axis is
|
|
10
|
+
* useless, when it is evidence that nothing has been observed yet; those two
|
|
11
|
+
* states are separated everywhere below.
|
|
12
|
+
*
|
|
13
|
+
* Three populations, never collapsed (the same model `check_pack_size` and
|
|
14
|
+
* `check_requirements_trace` use):
|
|
15
|
+
* observed — lines in the window
|
|
16
|
+
* comparable — lines where the axis ran, so a counterfactual exists
|
|
17
|
+
* changed — comparable lines where it flipped the recommendation
|
|
18
|
+
*
|
|
19
|
+
* `comparable` is the denominator, never `observed`: a run where the axis was
|
|
20
|
+
* unreachable carries `spec_axis_effect: null` and contributes to neither
|
|
21
|
+
* numerator nor denominator. Dividing by `observed` would report a low rate for
|
|
22
|
+
* an axis that was never given the chance to act, which is the mis-attribution
|
|
23
|
+
* this split exists to prevent.
|
|
24
|
+
*
|
|
25
|
+
* Exit code is 0 on every outcome including the empty one. This is a REPORT,
|
|
26
|
+
* not a gate — there is no threshold it could honestly enforce with the window
|
|
27
|
+
* this repo has, and a gate over an unmeasured surface fails for the wrong
|
|
28
|
+
* reason. `--self-test` exercises the pure summariser and exits non-zero on a
|
|
29
|
+
* mismatch.
|
|
30
|
+
*/
|
|
31
|
+
import * as fs from 'node:fs';
|
|
32
|
+
import * as path from 'node:path';
|
|
33
|
+
|
|
34
|
+
import type { ReviewLine } from './_lib/review_telemetry.js';
|
|
35
|
+
|
|
36
|
+
export interface ReviewAxisSummary {
|
|
37
|
+
observed: number;
|
|
38
|
+
/** Lines where the axis ran, i.e. a counterfactual exists. */
|
|
39
|
+
comparable: number;
|
|
40
|
+
changed: number;
|
|
41
|
+
unchanged: number;
|
|
42
|
+
/** Lines where the axis could not run at all. */
|
|
43
|
+
unreachable: number;
|
|
44
|
+
/** Reviews that ran with no criteria supplied — the axis reached, nothing to measure against. */
|
|
45
|
+
no_criteria: number;
|
|
46
|
+
/** Criteria handed over and unreadable — an error state, never folded into no_criteria. */
|
|
47
|
+
unparseable: number;
|
|
48
|
+
/** Earliest / latest `at` seen, or null over an empty window. */
|
|
49
|
+
window_start: string | null;
|
|
50
|
+
window_end: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Pure summariser — the unit under `--self-test`. */
|
|
54
|
+
export function summarise(lines: readonly ReviewLine[]): ReviewAxisSummary {
|
|
55
|
+
const s: ReviewAxisSummary = {
|
|
56
|
+
observed: 0,
|
|
57
|
+
comparable: 0,
|
|
58
|
+
changed: 0,
|
|
59
|
+
unchanged: 0,
|
|
60
|
+
unreachable: 0,
|
|
61
|
+
no_criteria: 0,
|
|
62
|
+
unparseable: 0,
|
|
63
|
+
window_start: null,
|
|
64
|
+
window_end: null,
|
|
65
|
+
};
|
|
66
|
+
for (const l of lines) {
|
|
67
|
+
s.observed += 1;
|
|
68
|
+
if (s.window_start === null || l.at < s.window_start) s.window_start = l.at;
|
|
69
|
+
if (s.window_end === null || l.at > s.window_end) s.window_end = l.at;
|
|
70
|
+
if (l.spec_axis_reach === 'unreachable') s.unreachable += 1;
|
|
71
|
+
if (l.criteria_source === 'not_provided') s.no_criteria += 1;
|
|
72
|
+
if (l.criteria_source === 'supplied_unparseable') s.unparseable += 1;
|
|
73
|
+
if (l.spec_axis_effect === 'changed') {
|
|
74
|
+
s.comparable += 1;
|
|
75
|
+
s.changed += 1;
|
|
76
|
+
} else if (l.spec_axis_effect === 'unchanged') {
|
|
77
|
+
s.comparable += 1;
|
|
78
|
+
s.unchanged += 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return s;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Render the summary as the prose a human reads. Empty window says so in words. */
|
|
85
|
+
export function render(s: ReviewAxisSummary): string {
|
|
86
|
+
const out: string[] = [];
|
|
87
|
+
if (s.observed === 0) {
|
|
88
|
+
out.push('NO WINDOW — 0 reviews recorded, so the spec axis has no measured effect in either direction.');
|
|
89
|
+
out.push('This is not "the axis changed nothing": nothing has been observed yet. Rerun once reviews have run.');
|
|
90
|
+
return out.join('\n');
|
|
91
|
+
}
|
|
92
|
+
out.push(`window: ${String(s.window_start)} … ${String(s.window_end)}`);
|
|
93
|
+
out.push(`observed: ${s.observed} review(s)`);
|
|
94
|
+
if (s.comparable === 0) {
|
|
95
|
+
out.push(
|
|
96
|
+
'comparable: 0 — the axis ran in none of them, so it changed nothing BY CONSTRUCTION, ' +
|
|
97
|
+
'which is not evidence about the axis.',
|
|
98
|
+
);
|
|
99
|
+
} else {
|
|
100
|
+
const pct = ((s.changed / s.comparable) * 100).toFixed(1);
|
|
101
|
+
out.push(`comparable: ${s.comparable} (the axis ran; a counterfactual exists)`);
|
|
102
|
+
out.push(`changed the recommendation: ${s.changed} of ${s.comparable} (${pct}%)`);
|
|
103
|
+
if (s.changed === 0) {
|
|
104
|
+
out.push('The honest reading of 0: the axis ran and never flipped a verdict over this window.');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
out.push(
|
|
108
|
+
`not comparable: ${s.unreachable} unreachable · ${s.no_criteria} ran with no criteria · ` +
|
|
109
|
+
`${s.unparseable} with unreadable criteria (an error state, counted apart)`,
|
|
110
|
+
);
|
|
111
|
+
return out.join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readLedger(dir: string): ReviewLine[] {
|
|
115
|
+
if (!fs.existsSync(dir)) return [];
|
|
116
|
+
const lines: ReviewLine[] = [];
|
|
117
|
+
for (const f of fs.readdirSync(dir).sort()) {
|
|
118
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
119
|
+
for (const raw of fs.readFileSync(path.join(dir, f), 'utf8').split('\n')) {
|
|
120
|
+
const t = raw.trim();
|
|
121
|
+
if (!t) continue;
|
|
122
|
+
try {
|
|
123
|
+
const o = JSON.parse(t) as ReviewLine;
|
|
124
|
+
if (o && o.schema === 'review-axis-v1') lines.push(o);
|
|
125
|
+
} catch {
|
|
126
|
+
// A malformed line is skipped, never guessed at.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return lines;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function selfTest(): number {
|
|
134
|
+
const at = '2026-08-22T00:00:00Z';
|
|
135
|
+
const base = {
|
|
136
|
+
schema: 'review-axis-v1' as const,
|
|
137
|
+
at,
|
|
138
|
+
judges_declared: 6,
|
|
139
|
+
judges_ran: 6,
|
|
140
|
+
criteria_count: 2,
|
|
141
|
+
spec_missing: 0,
|
|
142
|
+
spec_partial: 0,
|
|
143
|
+
recommendation: 'proceed' as const,
|
|
144
|
+
};
|
|
145
|
+
const cases: { name: string; lines: ReviewLine[]; want: Partial<ReviewAxisSummary>; wantText?: string }[] = [
|
|
146
|
+
{ name: 'empty window is not zero-effect', lines: [], want: { observed: 0, comparable: 0 }, wantText: 'NO WINDOW' },
|
|
147
|
+
{
|
|
148
|
+
name: 'unreachable contributes to neither population',
|
|
149
|
+
lines: [
|
|
150
|
+
{
|
|
151
|
+
...base,
|
|
152
|
+
spec_axis_reach: 'unreachable',
|
|
153
|
+
criteria_source: 'not_provided',
|
|
154
|
+
criteria_count: 0,
|
|
155
|
+
spec_axis_effect: null,
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
want: { observed: 1, comparable: 0, changed: 0, unreachable: 1 },
|
|
159
|
+
wantText: 'BY CONSTRUCTION',
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'changed and unchanged both land in comparable',
|
|
163
|
+
lines: [
|
|
164
|
+
{ ...base, spec_axis_reach: 'reachable_with_criteria', criteria_source: 'supplied', spec_axis_effect: 'changed' },
|
|
165
|
+
{ ...base, spec_axis_reach: 'reachable_with_criteria', criteria_source: 'supplied', spec_axis_effect: 'unchanged' },
|
|
166
|
+
],
|
|
167
|
+
want: { observed: 2, comparable: 2, changed: 1, unchanged: 1 },
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: 'unparseable is counted apart from no_criteria',
|
|
171
|
+
lines: [
|
|
172
|
+
{
|
|
173
|
+
...base,
|
|
174
|
+
spec_axis_reach: 'reachable_no_criteria',
|
|
175
|
+
criteria_source: 'supplied_unparseable',
|
|
176
|
+
criteria_count: 0,
|
|
177
|
+
spec_axis_effect: 'unchanged',
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
want: { observed: 1, no_criteria: 0, unparseable: 1, comparable: 1 },
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
let pass = 0;
|
|
184
|
+
let fail = 0;
|
|
185
|
+
for (const c of cases) {
|
|
186
|
+
const got = summarise(c.lines);
|
|
187
|
+
const bad = Object.entries(c.want).filter(([k, v]) => got[k as keyof ReviewAxisSummary] !== v);
|
|
188
|
+
const text = render(got);
|
|
189
|
+
const textBad = c.wantText !== undefined && !text.includes(c.wantText);
|
|
190
|
+
if (bad.length === 0 && !textBad) {
|
|
191
|
+
pass += 1;
|
|
192
|
+
} else {
|
|
193
|
+
fail += 1;
|
|
194
|
+
console.error(`❌ ${c.name}`);
|
|
195
|
+
for (const [k, v] of bad) console.error(` ${k}: want ${String(v)}, got ${String(got[k as keyof ReviewAxisSummary])}`);
|
|
196
|
+
if (textBad) console.error(` prose missing: ${String(c.wantText)}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
process.stdout.write(`review_axis_report --self-test: ${pass} pass, ${fail} fail\n`);
|
|
200
|
+
return fail === 0 ? 0 : 1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function main(argv: string[]): number {
|
|
204
|
+
if (argv.includes('--self-test')) return selfTest();
|
|
205
|
+
const dirIdx = argv.indexOf('--dir');
|
|
206
|
+
const dir =
|
|
207
|
+
dirIdx >= 0 && argv[dirIdx + 1] !== undefined
|
|
208
|
+
? String(argv[dirIdx + 1])
|
|
209
|
+
: path.join(process.cwd(), 'agents', 'runtime', 'state', 'review-axis');
|
|
210
|
+
const lines = readLedger(dir);
|
|
211
|
+
process.stdout.write(render(summarise(lines)) + '\n');
|
|
212
|
+
process.stdout.write(`scanned: ${lines.length}\n`);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (process.argv[1] !== undefined && process.argv[1].includes('review_axis_report')) {
|
|
217
|
+
process.exit(main(process.argv.slice(2)));
|
|
218
|
+
}
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
roadmapPath,
|
|
52
52
|
} from './_lib/run_checkpoint.js';
|
|
53
53
|
import { readBudget } from './_lib/unattended_guard.js';
|
|
54
|
+
import { findQuotaParked, type QuotaParkedMarker } from './_lib/quota_parked.js';
|
|
54
55
|
import {
|
|
55
56
|
LIVE_SPAWN_REFUSAL,
|
|
56
57
|
planResume,
|
|
@@ -80,6 +81,7 @@ export type Disposition =
|
|
|
80
81
|
| 'no-roadmap'
|
|
81
82
|
| 'roadmap-unreadable'
|
|
82
83
|
| 'complete'
|
|
84
|
+
| 'quota-parked'
|
|
83
85
|
| 'budget-exhausted'
|
|
84
86
|
| 'relaunchable';
|
|
85
87
|
|
|
@@ -246,6 +248,21 @@ export function classify(
|
|
|
246
248
|
reason: 'no open steps remain — the run finished, the session just never said so',
|
|
247
249
|
};
|
|
248
250
|
}
|
|
251
|
+
// Before the relaunch cap, and the order is load-bearing in one direction
|
|
252
|
+
// only: a parked run that has also spent its relaunches should still read
|
|
253
|
+
// as parked, because "waiting for a reset" is the cause and "spent three
|
|
254
|
+
// relaunches" is a consequence of it. Reported the other way round, an
|
|
255
|
+
// operator sees a run that failed repeatedly rather than one that keeps
|
|
256
|
+
// meeting the same wall.
|
|
257
|
+
const parked = findQuotaParked(repoRoot, rec.session_id);
|
|
258
|
+
if (parked !== null) {
|
|
259
|
+
return {
|
|
260
|
+
...base,
|
|
261
|
+
disposition: 'quota-parked',
|
|
262
|
+
open_steps: counts.open,
|
|
263
|
+
reason: quotaParkedReason(parked, counts.open),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
249
266
|
if (base.relaunches >= MAX_RELAUNCHES_PER_RUN) {
|
|
250
267
|
return {
|
|
251
268
|
...base,
|
|
@@ -264,6 +281,23 @@ export function classify(
|
|
|
264
281
|
};
|
|
265
282
|
}
|
|
266
283
|
|
|
284
|
+
/**
|
|
285
|
+
* The one sentence a human acts on for a parked run.
|
|
286
|
+
*
|
|
287
|
+
* It states the reset time is unknown rather than omitting it, and rather than
|
|
288
|
+
* guessing an interval. There is no reset-time parser and there will not be one
|
|
289
|
+
* until a verified error string exists to pin a fixture against — see
|
|
290
|
+
* `later/road-to-billing-cliff-detection.md`. An omitted unknown reads as "no
|
|
291
|
+
* wait needed"; a guessed interval reads as a fact.
|
|
292
|
+
*/
|
|
293
|
+
export function quotaParkedReason(m: QuotaParkedMarker, open: number): string {
|
|
294
|
+
return (
|
|
295
|
+
`plan quota exhausted on ${m.providers.join(', ')} since ${m.parked_at} — ` +
|
|
296
|
+
`${open} open step(s) remain; the run is waiting, not broken. ` +
|
|
297
|
+
`Reset time unknown (no parser: no verified error string yet).`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
267
301
|
export interface ScanOptions {
|
|
268
302
|
readonly now?: Date;
|
|
269
303
|
}
|
|
@@ -434,10 +468,21 @@ export function digest(repoRoot: string, candidates: readonly Candidate[], now:
|
|
|
434
468
|
|
|
435
469
|
const dead = candidates.filter((c) => c.disposition !== 'alive');
|
|
436
470
|
const relaunchable = candidates.filter((c) => c.disposition === 'relaunchable');
|
|
471
|
+
const quotaParked = candidates.filter((c) => c.disposition === 'quota-parked');
|
|
437
472
|
lines.push(
|
|
438
473
|
` sessions: ${candidates.length} registered · ${candidates.length - dead.length} alive · ` +
|
|
439
|
-
`${relaunchable.length} relaunchable
|
|
474
|
+
`${relaunchable.length} relaunchable` +
|
|
475
|
+
(quotaParked.length > 0 ? ` · ${quotaParked.length} quota-parked` : ''),
|
|
440
476
|
);
|
|
477
|
+
// Named on its own line, not folded into the relaunchable count, because
|
|
478
|
+
// the two call for opposite responses: a relaunchable run wants a session,
|
|
479
|
+
// a parked one wants time. Suppressed at zero so the ordinary digest does
|
|
480
|
+
// not carry a line about a state nothing is in.
|
|
481
|
+
for (const c of quotaParked) {
|
|
482
|
+
lines.push(
|
|
483
|
+
` parked: ${c.roadmap ?? '-'} — ${c.reason}`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
441
486
|
|
|
442
487
|
const budget = readBudget(repoRoot, now);
|
|
443
488
|
lines.push(
|
|
@@ -48,6 +48,11 @@
|
|
|
48
48
|
"minLength": 1,
|
|
49
49
|
"description": "The input fixture handed to the skill under test."
|
|
50
50
|
},
|
|
51
|
+
"_why": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"minLength": 1,
|
|
54
|
+
"description": "Optional rationale: why this fixture exists at all. Annotation, never input — nothing reads it at run time, and it carries the same underscore prefix as the top-level `_calibration` to say so. Added because the alternative, on scenarios whose rationale IS the negative-control argument for the pair above them, was to delete the argument to satisfy additionalProperties."
|
|
55
|
+
},
|
|
51
56
|
"environment": {
|
|
52
57
|
"type": "string",
|
|
53
58
|
"minLength": 1,
|
|
@@ -105,14 +105,21 @@ const GENERATED = [
|
|
|
105
105
|
* because the result looks measured either way. So this class NAMES the
|
|
106
106
|
* resolution and stops. The human runs it, with the result in front of them.
|
|
107
107
|
*
|
|
108
|
-
*
|
|
109
|
-
* road-to-merge-hotspot-drawdown
|
|
110
|
-
*
|
|
108
|
+
* `gate-violation-baselines.json` conflicted in 7 of the 7 open PRs measured for
|
|
109
|
+
* road-to-merge-hotspot-drawdown, and it is not gitignorable: its counts are not
|
|
110
|
+
* a function of the tree, so the committed number is the only "before" side that
|
|
111
|
+
* exists.
|
|
112
|
+
*
|
|
113
|
+
* `estate-count-budget.json` USED to be the other member and is deliberately not
|
|
114
|
+
* listed any more (ADR-243). Its metrics ARE a function of the tree, so
|
|
115
|
+
* `check_estate_count` measures the floor at the base ref instead of storing it,
|
|
116
|
+
* and the file now carries policy only. A conflict in it is an ordinary AUTHORED
|
|
117
|
+
* one — two humans editing the same policy sentence — and telling the reader to
|
|
118
|
+
* "re-run the measurement" for that would name a resolution the file no longer
|
|
119
|
+
* has. Removing the row is the point of the change, not an oversight: the row
|
|
120
|
+
* described a conflict that no longer occurs.
|
|
111
121
|
*/
|
|
112
|
-
const REMEASURED = [
|
|
113
|
-
'src/config/estate-count-budget.json',
|
|
114
|
-
'src/config/gate-violation-baselines.json',
|
|
115
|
-
];
|
|
122
|
+
const REMEASURED = ['src/config/gate-violation-baselines.json'];
|
|
116
123
|
|
|
117
124
|
function sh(cmd: string, args: readonly string[], cwd: string): { ok: boolean; out: string; err: string } {
|
|
118
125
|
const r = spawnSync(cmd, [...args], {
|