@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.
Files changed (136) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +165 -237
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +34 -4
  5. package/dist/agent-src/commands/pr/merge.md +9 -5
  6. package/dist/agent-src/commands/review/changes.md +95 -14
  7. package/dist/agent-src/commands/roadmap/process-full.md +2 -2
  8. package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
  9. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +36 -0
  10. package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
  11. package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
  12. package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
  13. package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
  14. package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
  15. package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
  16. package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
  17. package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
  18. package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
  19. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  20. package/dist/agent-src/templates/roadmaps.md +8 -0
  21. package/dist/cli/registry.js +2 -0
  22. package/dist/cli/registry.js.map +1 -1
  23. package/dist/cli-delegate/{chunk-MMSGVZKV.js → chunk-77GR3VD4.js} +65 -40
  24. package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
  25. package/dist/cli-delegate/{chunk-EEJUMCDZ.js → chunk-PLSG6UL5.js} +1 -1
  26. package/dist/cli-delegate/cmd_conformance.js +89 -13
  27. package/dist/cli-delegate/cmd_doctor.js +3 -3
  28. package/dist/cli-delegate/cmd_handoff.js +2 -2
  29. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  30. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  31. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  32. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  33. package/dist/discovery/deprecation-report.md +1 -1
  34. package/dist/discovery/discovery-manifest.json +37 -15
  35. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  36. package/dist/discovery/discovery-manifest.summary.md +3 -3
  37. package/dist/discovery/dormancy-report.md +1 -1
  38. package/dist/discovery/orphan-report.md +1 -1
  39. package/dist/discovery/packs.json +6 -5
  40. package/dist/discovery/trust-report.md +2 -2
  41. package/dist/discovery/workspaces.json +5 -4
  42. package/dist/hooks/dispatch.js +562 -204
  43. package/dist/mcp/registry-manifest.json +3 -3
  44. package/dist/mcp/server.json +2 -2
  45. package/dist/mcp/server.mjs +808 -331
  46. package/dist/scripts/_lib/claude_plugin.js +162 -0
  47. package/dist/scripts/_lib/claude_plugin.js.map +1 -0
  48. package/dist/scripts/_lib/environment_detector.js +397 -0
  49. package/dist/scripts/_lib/environment_detector.js.map +1 -0
  50. package/dist/scripts/_lib/spawn_env.js +129 -0
  51. package/dist/scripts/_lib/spawn_env.js.map +1 -0
  52. package/dist/scripts/ai_council/modes.js +199 -0
  53. package/dist/scripts/ai_council/modes.js.map +1 -0
  54. package/dist/scripts/ai_council/transport_resolver.js +418 -0
  55. package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
  56. package/dist/server/routes/wizard.js +3 -2
  57. package/dist/server/routes/wizard.js.map +1 -1
  58. package/dist/ui/assets/index-Cxgy63kz.js +26 -0
  59. package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
  62. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
  63. package/package.json +7 -3
  64. package/src/config/estate-count-budget.json +13 -744
  65. package/src/config/evaluator-budgets.json +4 -4
  66. package/src/config/gate-coverage.yml +247 -9
  67. package/src/config/gate-violation-baselines.json +72 -72
  68. package/src/config/publish-surface.json +60 -0
  69. package/src/config/requirements-trace-budget.json +32 -0
  70. package/src/config/rule-stub-ceilings.json +8 -1
  71. package/src/scripts/_dispatch.bash +7 -0
  72. package/src/scripts/_lib/base_tree.ts +175 -0
  73. package/src/scripts/_lib/billing_grant.ts +165 -0
  74. package/src/scripts/_lib/billing_grant_cli.ts +123 -0
  75. package/src/scripts/_lib/council_fallback_posture.ts +53 -6
  76. package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
  77. package/src/scripts/_lib/host_capability.ts +59 -5
  78. package/src/scripts/_lib/quota_parked.ts +133 -0
  79. package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
  80. package/src/scripts/_lib/review_telemetry.ts +196 -0
  81. package/src/scripts/_lib/subagent_response.ts +92 -0
  82. package/src/scripts/adr/regenerate_index.ts +14 -7
  83. package/src/scripts/ai_council/clients.ts +12 -1
  84. package/src/scripts/ai_council/config.ts +72 -115
  85. package/src/scripts/ai_council/fallback_config.ts +19 -7
  86. package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
  87. package/src/scripts/ai_council/orchestrator.ts +2 -2
  88. package/src/scripts/ai_council/py_format.ts +124 -0
  89. package/src/scripts/ai_council/transport_resolver.ts +65 -2
  90. package/src/scripts/audit_adr_coverage.ts +33 -6
  91. package/src/scripts/check_council_pin_staleness.ts +300 -0
  92. package/src/scripts/check_estate_count.ts +420 -293
  93. package/src/scripts/check_gate_coverage.ts +91 -0
  94. package/src/scripts/check_no_stub_inventory_table.ts +292 -0
  95. package/src/scripts/check_pack_size.ts +234 -1
  96. package/src/scripts/check_publish_surface.ts +297 -0
  97. package/src/scripts/check_requirements_trace.ts +378 -0
  98. package/src/scripts/check_secret_leak.ts +166 -12
  99. package/src/scripts/code_graph/cli.ts +44 -5
  100. package/src/scripts/code_graph/extract.ts +87 -0
  101. package/src/scripts/council_cli.ts +12 -16
  102. package/src/scripts/detect_ai_tells.ts +87 -0
  103. package/src/scripts/dispatch_r2_reviewer.ts +42 -0
  104. package/src/scripts/generate_capabilities_index.ts +41 -0
  105. package/src/scripts/git_authorization_hook.ts +54 -4
  106. package/src/scripts/hooks/block_unauthorized_git.ts +19 -1
  107. package/src/scripts/hooks/run_continuation_hook.ts +3 -1
  108. package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
  109. package/src/scripts/injection_scan_hook.ts +120 -10
  110. package/src/scripts/lint_adr_sweep_routing.ts +365 -0
  111. package/src/scripts/lint_workflow_security.ts +53 -10
  112. package/src/scripts/lint_workflow_security_allowlist.json +2 -18
  113. package/src/scripts/report_envelope_rate.ts +166 -0
  114. package/src/scripts/review_axis_report.ts +218 -0
  115. package/src/scripts/run_supervise.ts +46 -1
  116. package/src/scripts/schemas/evals.schema.json +5 -0
  117. package/src/scripts/sync_pr_branch.ts +14 -7
  118. package/dist/cli/commands/commands.candidates.test.js +0 -257
  119. package/dist/cli/commands/commands.candidates.test.js.map +0 -1
  120. package/dist/cli/commands/profiles.test.js +0 -88
  121. package/dist/cli/commands/profiles.test.js.map +0 -1
  122. package/dist/cli/commands/recordTriggerEval.test.js +0 -113
  123. package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
  124. package/dist/cli/configRoot.test.js +0 -97
  125. package/dist/cli/configRoot.test.js.map +0 -1
  126. package/dist/cli/mcp/content.test.js +0 -54
  127. package/dist/cli/mcp/content.test.js.map +0 -1
  128. package/dist/cli/mcp/dispatch.test.js +0 -108
  129. package/dist/cli/mcp/dispatch.test.js.map +0 -1
  130. package/dist/server/writeRoot.test.js +0 -91
  131. package/dist/server/writeRoot.test.js.map +0 -1
  132. package/dist/shared/capabilities.test.js +0 -39
  133. package/dist/shared/capabilities.test.js.map +0 -1
  134. package/dist/ui/assets/index-C1pBRm1f.js +0 -26
  135. package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
  136. /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 — third-party actions pinned by mutable tag instead of full SHA
21
- * (first-party actions/* are skipped).
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
- // First-party action owners — mutable tags on these are acceptable
57
- const FIRST_PARTY_OWNERS: ReadonlySet<string> = new Set(['actions', 'github']);
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 — tighten the heuristic, do not grow this list (autonomous-execution allowlist-growth antipattern). Each entry MUST cite why the finding is acceptable in `reason`.",
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
- * Both members conflict in 7 of the 7 open PRs measured for
109
- * road-to-merge-hotspot-drawdown. Neither is gitignorable: an untracked baseline
110
- * is a baseline no PR diff can be compared against, which deletes the ratchet.
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], {