@event4u/agent-config 14.8.0 → 14.10.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 +214 -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/async-python-patterns/SKILL.md +1 -1
- package/dist/agent-src/skills/code-refactoring/SKILL.md +1 -1
- package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
- package/dist/agent-src/skills/condense-memory/SKILL.md +1 -1
- package/dist/agent-src/skills/conventional-commits-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/design-review/SKILL.md +1 -1
- package/dist/agent-src/skills/discovery-interview/SKILL.md +1 -1
- package/dist/agent-src/skills/doc-coauthoring/SKILL.md +1 -1
- package/dist/agent-src/skills/docx-authoring/SKILL.md +1 -1
- package/dist/agent-src/skills/editorial-calendar/SKILL.md +1 -1
- package/dist/agent-src/skills/github-ci/SKILL.md +1 -1
- 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-artifact-completeness/SKILL.md +1 -1
- package/dist/agent-src/skills/judge-injection-defense/SKILL.md +1 -1
- 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/laravel-validation/SKILL.md +1 -1
- package/dist/agent-src/skills/legal-intake-triage/SKILL.md +1 -1
- package/dist/agent-src/skills/legal-practice-profile/SKILL.md +1 -1
- package/dist/agent-src/skills/messaging-architecture/SKILL.md +1 -1
- package/dist/agent-src/skills/migration-architect/SKILL.md +1 -1
- package/dist/agent-src/skills/module-detect-on-the-fly/SKILL.md +1 -1
- package/dist/agent-src/skills/module-management/SKILL.md +1 -1
- package/dist/agent-src/skills/motion-choreographer/SKILL.md +1 -1
- package/dist/agent-src/skills/onboarding-program/SKILL.md +1 -1
- package/dist/agent-src/skills/perf-feedback-craft/SKILL.md +1 -1
- package/dist/agent-src/skills/prompt-optimizer/SKILL.md +1 -1
- package/dist/agent-src/skills/receiving-code-review/SKILL.md +1 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
- package/dist/agent-src/skills/scene-expander/SKILL.md +1 -1
- package/dist/agent-src/skills/secrets-management/SKILL.md +1 -1
- package/dist/agent-src/skills/using-git-worktrees/SKILL.md +1 -1
- 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 +64 -42
- 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 +267 -9
- package/src/config/gate-violation-baselines.json +72 -72
- package/src/config/preamble-payload-budget.json +71 -67
- 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/seating.ts +211 -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_passport_reconciliation.ts +274 -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/generate_pack_manifests.ts +78 -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/schemas/pack.schema.json +62 -4
- package/src/scripts/schemas/skill.schema.json +2 -2
- package/src/scripts/sync_pr_branch.ts +14 -7
- package/src/scripts/tighten_skill_descriptions.ts +264 -0
- 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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Materialise one subtree of a git ref into a scratch directory, so a gate can
|
|
3
|
+
* MEASURE the base side with the same function it measures HEAD with.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
* ---------------
|
|
7
|
+
* A ratchet needs a "before" number the change under review cannot rewrite. The
|
|
8
|
+
* cheapest way to get one is to commit it — and that is what
|
|
9
|
+
* `src/config/estate-count-budget.json` did until ADR-243. A committed number is
|
|
10
|
+
* also a file every branch edits, which made it the most-conflicted non-generated
|
|
11
|
+
* path in this repository: 7 of 7 `CONFLICTING` open PRs on 2026-08-21, and 39 of
|
|
12
|
+
* 43 non-merge commits in a 60-day window moving it.
|
|
13
|
+
*
|
|
14
|
+
* The number does not have to be stored. Where the metric is a function of the
|
|
15
|
+
* tree, the base ref's own tree IS the "before" side — it is committed, and the
|
|
16
|
+
* change under review cannot rewrite it either. What is missing is a way to run
|
|
17
|
+
* a filesystem parser over a ref without checking it out.
|
|
18
|
+
*
|
|
19
|
+
* WHY NOT `git archive`
|
|
20
|
+
* ---------------------
|
|
21
|
+
* Measured, not assumed: `git archive origin/main agents/roadmaps | tar -x`
|
|
22
|
+
* produces an EMPTY archive in this repository, because `.gitattributes` carries
|
|
23
|
+
* `/agents export-ignore` (line 15) and `git archive` honours it. That is correct
|
|
24
|
+
* for the npm tarball it was written for and fatal here, and it fails silently —
|
|
25
|
+
* an empty extraction reads as "the estate measured zero", which is the shape of
|
|
26
|
+
* a floor of 0 that passes everything.
|
|
27
|
+
*
|
|
28
|
+
* `git ls-tree` + `git cat-file --batch` reads the object store directly and
|
|
29
|
+
* ignores export-ignore entirely. Cost, measured on 703 files: 0.22 s, two
|
|
30
|
+
* spawns total.
|
|
31
|
+
*
|
|
32
|
+
* WHY NOT `git worktree add`
|
|
33
|
+
* --------------------------
|
|
34
|
+
* It checks out the whole tree — 9177 files here — for a subtree of 703, and it
|
|
35
|
+
* mutates `.git/worktrees`, so a crashed gate leaves administrative state behind.
|
|
36
|
+
*
|
|
37
|
+
* THE EMPTY CASE IS AN ERROR, NEVER A MEASUREMENT
|
|
38
|
+
* -----------------------------------------------
|
|
39
|
+
* `materialiseSubtree` returns the file count and callers MUST treat 0 as "the
|
|
40
|
+
* base side could not be established", never as a measurement. An absent subtree,
|
|
41
|
+
* a typo in the path, a ref that predates the directory and an export-ignore
|
|
42
|
+
* silently eating the content are indistinguishable from each other and all
|
|
43
|
+
* produce zero — and for a shrink-only gate, a floor of zero is a green light for
|
|
44
|
+
* unbounded growth. This is stated here because the caller cannot see which of
|
|
45
|
+
* those happened either, and the safe reading of all four is the same.
|
|
46
|
+
*/
|
|
47
|
+
import { spawnSync } from 'node:child_process';
|
|
48
|
+
import * as fs from 'node:fs';
|
|
49
|
+
import * as os from 'node:os';
|
|
50
|
+
import * as path from 'node:path';
|
|
51
|
+
|
|
52
|
+
/** Ceiling on the blob stream. The roadmap corpus is ~2 MB; this is headroom. */
|
|
53
|
+
const BATCH_MAX_BUFFER = 256 * 1024 * 1024;
|
|
54
|
+
|
|
55
|
+
export interface MaterialisedTree {
|
|
56
|
+
/** Absolute path to a directory that now holds `<subtree>/...` from `ref`. */
|
|
57
|
+
root: string;
|
|
58
|
+
/** How many blobs were written. `0` means the base side is UNAVAILABLE. */
|
|
59
|
+
files: number;
|
|
60
|
+
/** Set when the read failed outright; `files` is then 0. */
|
|
61
|
+
error: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One entry of `git ls-tree -r -z`: `<mode> SP <type> SP <sha> TAB <path>`. */
|
|
65
|
+
interface TreeEntry {
|
|
66
|
+
sha: string;
|
|
67
|
+
rel: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse `git ls-tree -r -z` output.
|
|
72
|
+
*
|
|
73
|
+
* The `-z` form is used rather than `--format`, which needs git ≥ 2.36: this runs
|
|
74
|
+
* on contributor machines and on runners, and a gate that fails on an older git
|
|
75
|
+
* is a gate that gets turned off.
|
|
76
|
+
*/
|
|
77
|
+
function parseLsTree(stdout: string): TreeEntry[] {
|
|
78
|
+
const out: TreeEntry[] = [];
|
|
79
|
+
for (const record of stdout.split('\0')) {
|
|
80
|
+
if (record === '') continue;
|
|
81
|
+
const tab = record.indexOf('\t');
|
|
82
|
+
if (tab === -1) continue;
|
|
83
|
+
const meta = record.slice(0, tab).split(' ');
|
|
84
|
+
const sha = meta[2];
|
|
85
|
+
const type = meta[1];
|
|
86
|
+
const rel = record.slice(tab + 1);
|
|
87
|
+
// Blobs only. A submodule entry is a `commit` and has no content here;
|
|
88
|
+
// writing its sha as a file would be a silent corruption of the measured
|
|
89
|
+
// tree rather than a missing file the caller could notice.
|
|
90
|
+
if (type !== 'blob' || sha === undefined || sha === '' || rel === '') continue;
|
|
91
|
+
out.push({ sha, rel });
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Walk a `git cat-file --batch` stream, invoking `sink` per object.
|
|
98
|
+
*
|
|
99
|
+
* Wire format per object: `<sha> SP <type> SP <size> LF`, then exactly `size`
|
|
100
|
+
* bytes, then one LF. Sizes are read from the header rather than inferred from
|
|
101
|
+
* the next header, so a blob containing a line that looks like a header cannot
|
|
102
|
+
* desynchronise the parse — the failure mode that makes a line-oriented reading
|
|
103
|
+
* of this stream wrong.
|
|
104
|
+
*/
|
|
105
|
+
function walkBatch(buf: Buffer, sink: (sha: string, body: Buffer) => void): void {
|
|
106
|
+
let pos = 0;
|
|
107
|
+
while (pos < buf.length) {
|
|
108
|
+
const nl = buf.indexOf('\n', pos);
|
|
109
|
+
if (nl === -1) return;
|
|
110
|
+
const header = buf.subarray(pos, nl).toString('utf-8');
|
|
111
|
+
const parts = header.split(' ');
|
|
112
|
+
if (parts.length < 3) return; // `<sha> missing` — a bad ref; stop cleanly.
|
|
113
|
+
const size = Number.parseInt(parts[2] ?? '', 10);
|
|
114
|
+
if (!Number.isFinite(size) || size < 0) return;
|
|
115
|
+
const start = nl + 1;
|
|
116
|
+
sink(parts[0] ?? '', buf.subarray(start, start + size));
|
|
117
|
+
pos = start + size + 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Write `<subtree>` as it exists at `ref` into a fresh temp directory.
|
|
123
|
+
*
|
|
124
|
+
* The returned `root` is the caller's to remove — callers hold it in a `finally`.
|
|
125
|
+
* `subtree` is a repo-relative POSIX path (`agents/roadmaps`), because that is
|
|
126
|
+
* what git pathspecs take; a `path.sep`-joined value works on POSIX and silently
|
|
127
|
+
* matches nothing on Windows, so callers pass the POSIX form explicitly.
|
|
128
|
+
*/
|
|
129
|
+
export function materialiseSubtree(repoRoot: string, ref: string, subtree: string): MaterialisedTree {
|
|
130
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'base-tree-'));
|
|
131
|
+
const list = spawnSync('git', ['ls-tree', '-r', '-z', ref, '--', subtree], {
|
|
132
|
+
cwd: repoRoot,
|
|
133
|
+
encoding: 'utf-8',
|
|
134
|
+
maxBuffer: BATCH_MAX_BUFFER,
|
|
135
|
+
});
|
|
136
|
+
if (list.status !== 0) {
|
|
137
|
+
return { root, files: 0, error: `git ls-tree ${ref} -- ${subtree} failed: ${(list.stderr ?? '').trim()}` };
|
|
138
|
+
}
|
|
139
|
+
const entries = parseLsTree(list.stdout ?? '');
|
|
140
|
+
if (entries.length === 0) {
|
|
141
|
+
return { root, files: 0, error: `${subtree} holds no blobs at ${ref}` };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const batch = spawnSync('git', ['cat-file', '--batch'], {
|
|
145
|
+
cwd: repoRoot,
|
|
146
|
+
input: `${entries.map((e) => e.sha).join('\n')}\n`,
|
|
147
|
+
maxBuffer: BATCH_MAX_BUFFER,
|
|
148
|
+
});
|
|
149
|
+
if (batch.status !== 0 || batch.stdout === null) {
|
|
150
|
+
return { root, files: 0, error: `git cat-file --batch failed: ${String(batch.stderr ?? '').trim()}` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// One sha may appear under several paths (identical file content), so the
|
|
154
|
+
// stream is keyed by sha and every path carrying that sha is written. Keying
|
|
155
|
+
// by position would break on exactly that case, and it is not hypothetical:
|
|
156
|
+
// roadmap stubs share boilerplate.
|
|
157
|
+
const bySha = new Map<string, Buffer>();
|
|
158
|
+
walkBatch(batch.stdout, (sha, body) => {
|
|
159
|
+
if (!bySha.has(sha)) bySha.set(sha, body);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
let files = 0;
|
|
163
|
+
for (const entry of entries) {
|
|
164
|
+
const body = bySha.get(entry.sha);
|
|
165
|
+
if (body === undefined) continue;
|
|
166
|
+
const dest = path.join(root, ...entry.rel.split('/'));
|
|
167
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
168
|
+
fs.writeFileSync(dest, body);
|
|
169
|
+
files += 1;
|
|
170
|
+
}
|
|
171
|
+
if (files !== entries.length) {
|
|
172
|
+
return { root, files: 0, error: `wrote ${String(files)} of ${String(entries.length)} blobs from ${ref}` };
|
|
173
|
+
}
|
|
174
|
+
return { root, files, error: null };
|
|
175
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* billing_grant — the run-scoped answer to "plan quota is gone, may I spend?".
|
|
3
|
+
*
|
|
4
|
+
* The problem this closes is a billing-class switch nobody chose at the moment
|
|
5
|
+
* it happened. A council seat on a vendor CLI under a subscription login is
|
|
6
|
+
* unmetered; its api twin is metered USD. Until `api_on_quota: 'ask'` existed
|
|
7
|
+
* that switch was decided once, at configuration time, and then applied
|
|
8
|
+
* silently mid-round — see `transport_resolver.ts` § `FallbackPolicy`.
|
|
9
|
+
*
|
|
10
|
+
* A grant is the human's yes, and it is scoped to ONE run for two reasons.
|
|
11
|
+
*
|
|
12
|
+
* The first is that a run is the unit the question is answerable in. "May I
|
|
13
|
+
* spend money finishing this?" needs the remaining scope in view; a standing
|
|
14
|
+
* setting has no scope to show. The second is the failure a wall-clock TTL
|
|
15
|
+
* produces: the git-guard grant expired after 30 minutes, so a drain outliving
|
|
16
|
+
* its own grant re-asked mid-run — which is the interruption this whole
|
|
17
|
+
* mechanism exists to remove. So the TTL here is the RUN, and `revoke` is
|
|
18
|
+
* called from the path that already clears run-continuation state. A wall
|
|
19
|
+
* clock appears only as a backstop for a run that died without cleaning up.
|
|
20
|
+
*
|
|
21
|
+
* Transport is an env var (`AC_BILLING_GRANT`) plus a file under
|
|
22
|
+
* `agents/runtime/`. Both are needed and neither is redundant: the env var is
|
|
23
|
+
* what a spawned subagent can read without knowing the repo root, and the file
|
|
24
|
+
* is what makes the grant survive a subprocess boundary in the other
|
|
25
|
+
* direction and gives `revoke` something to delete. `hardenedSpawnEnv()`
|
|
26
|
+
* passes the `AC_` family through — verified, and pinned by a test in
|
|
27
|
+
* `tests/scripts/_lib/spawn_env.test.ts`, because a future deny-family entry
|
|
28
|
+
* would otherwise break the chain silently.
|
|
29
|
+
*
|
|
30
|
+
* PII-exclusion-by-construction: a grant record holds a run id, an ISO
|
|
31
|
+
* timestamp, and nothing else. There is no field capable of carrying a prompt,
|
|
32
|
+
* a path, or a credential, so there is no scrubbing pass here that could fail.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import * as fs from 'node:fs';
|
|
36
|
+
import * as path from 'node:path';
|
|
37
|
+
|
|
38
|
+
/** Env var the main session sets and every spawned worker reads. */
|
|
39
|
+
export const BILLING_GRANT_ENV = 'AC_BILLING_GRANT';
|
|
40
|
+
|
|
41
|
+
/** Directory, relative to the repo root, holding one file per live grant. */
|
|
42
|
+
export const BILLING_GRANT_DIR_REL = path.join('agents', 'runtime', 'billing-grants');
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Backstop only. A run that exits through its normal path calls `revoke`; this
|
|
46
|
+
* bounds the damage of one that does not, and is deliberately far longer than
|
|
47
|
+
* any run so it can never be the thing that ends a live grant.
|
|
48
|
+
*/
|
|
49
|
+
export const BILLING_GRANT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
export interface BillingGrant {
|
|
52
|
+
readonly run_id: string;
|
|
53
|
+
readonly issued_at: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Filesystem-safe filename for a run id. Mirrors `run_checkpoint.checkpointFile`. */
|
|
57
|
+
export function grantFile(repoRoot: string, runId: string): string {
|
|
58
|
+
return path.join(
|
|
59
|
+
repoRoot,
|
|
60
|
+
BILLING_GRANT_DIR_REL,
|
|
61
|
+
`${runId.replace(/[^A-Za-z0-9_-]/g, '_')}.json`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Record the human's yes for `runId` and return the env pair to hand to
|
|
67
|
+
* subagents. Idempotent: re-issuing keeps the original `issued_at`, so a
|
|
68
|
+
* second yes inside one run cannot extend the backstop window.
|
|
69
|
+
*/
|
|
70
|
+
export function issueBillingGrant(
|
|
71
|
+
repoRoot: string,
|
|
72
|
+
runId: string,
|
|
73
|
+
now: () => Date = () => new Date(),
|
|
74
|
+
): BillingGrant {
|
|
75
|
+
const file = grantFile(repoRoot, runId);
|
|
76
|
+
const existing = _read(file);
|
|
77
|
+
if (existing !== null && existing.run_id === runId) return existing;
|
|
78
|
+
const grant: BillingGrant = { run_id: runId, issued_at: now().toISOString() };
|
|
79
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
80
|
+
fs.writeFileSync(file, `${JSON.stringify(grant, null, 2)}\n`, 'utf8');
|
|
81
|
+
return grant;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Is a grant in force for `runId`?
|
|
86
|
+
*
|
|
87
|
+
* Both halves must agree, and the AND is the security property rather than
|
|
88
|
+
* belt-and-braces: an env var alone is settable by anything in the process
|
|
89
|
+
* tree, and a file alone would let a grant from a previous run authorise this
|
|
90
|
+
* one. Requiring the env var to NAME the run whose file exists is what makes
|
|
91
|
+
* a different run's grant unusable.
|
|
92
|
+
*/
|
|
93
|
+
export function isBillingGrantActive(
|
|
94
|
+
repoRoot: string,
|
|
95
|
+
runId: string,
|
|
96
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
97
|
+
now: () => Date = () => new Date(),
|
|
98
|
+
): boolean {
|
|
99
|
+
if ((env[BILLING_GRANT_ENV] ?? '').trim() !== runId) return false;
|
|
100
|
+
const grant = _read(grantFile(repoRoot, runId));
|
|
101
|
+
if (grant === null || grant.run_id !== runId) return false;
|
|
102
|
+
const age = now().getTime() - Date.parse(grant.issued_at);
|
|
103
|
+
return Number.isFinite(age) && age >= 0 && age <= BILLING_GRANT_MAX_AGE_MS;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ambient check for a caller that does not carry a run id — the council CLI
|
|
108
|
+
* reading config, for instance. The env var names the run, so the file it
|
|
109
|
+
* names is the one that must exist; there is no "any grant will do" branch.
|
|
110
|
+
*/
|
|
111
|
+
export function hasBillingGrant(
|
|
112
|
+
repoRoot: string,
|
|
113
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
114
|
+
now: () => Date = () => new Date(),
|
|
115
|
+
): boolean {
|
|
116
|
+
const runId = (env[BILLING_GRANT_ENV] ?? '').trim();
|
|
117
|
+
if (runId === '') return false;
|
|
118
|
+
return isBillingGrantActive(repoRoot, runId, env, now);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Remove the grant for `runId`. Safe to call when none exists.
|
|
123
|
+
*
|
|
124
|
+
* Called from `run_continuation_hook.clearRunState` — the one place a run is
|
|
125
|
+
* declared over, and therefore the grant's real TTL. `clearRunState` is
|
|
126
|
+
* exported for that reason: reaching it through the concern chain would mean
|
|
127
|
+
* standing up the whole dispatch harness to assert one unlink.
|
|
128
|
+
*/
|
|
129
|
+
export function revokeBillingGrant(repoRoot: string, runId: string): void {
|
|
130
|
+
try {
|
|
131
|
+
fs.unlinkSync(grantFile(repoRoot, runId));
|
|
132
|
+
} catch {
|
|
133
|
+
// Already gone is the success case, not an error worth surfacing.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function _read(file: string): BillingGrant | null {
|
|
138
|
+
try {
|
|
139
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8')) as unknown;
|
|
140
|
+
if (raw === null || typeof raw !== 'object') return null;
|
|
141
|
+
const o = raw as Record<string, unknown>;
|
|
142
|
+
if (typeof o.run_id !== 'string' || typeof o.issued_at !== 'string') return null;
|
|
143
|
+
return { run_id: o.run_id, issued_at: o.issued_at };
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The id of the run a grant would be scoped to.
|
|
151
|
+
*
|
|
152
|
+
* Resolution order, first non-empty wins: `AC_BILLING_GRANT` (a grant already
|
|
153
|
+
* in force names its own run), then `AC_RUN_ID` (what an orchestrating session
|
|
154
|
+
* sets), then the host's session id. Returns `null` when none resolves — and
|
|
155
|
+
* `null` is rendered as a literal placeholder rather than a fabricated id,
|
|
156
|
+
* because a grant command carrying an invented run id would be a command that
|
|
157
|
+
* silently authorises nothing.
|
|
158
|
+
*/
|
|
159
|
+
export function currentRunId(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
160
|
+
for (const key of [BILLING_GRANT_ENV, 'AC_RUN_ID', 'CLAUDE_CODE_SESSION_ID']) {
|
|
161
|
+
const v = (env[key] ?? '').trim();
|
|
162
|
+
if (v !== '') return v;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `council:{grant-billing,revoke-billing}` verbs, and the round's closing
|
|
3
|
+
* Human Gate line.
|
|
4
|
+
*
|
|
5
|
+
* Lives here rather than in `council_cli.ts` for the reason that file's other
|
|
6
|
+
* extractions give: it is ~2,500 lines over the source ceiling and the
|
|
7
|
+
* documented remedy is extraction, not a raised baseline
|
|
8
|
+
* (`gate-violation-baselines.json` § check_source_size_budget).
|
|
9
|
+
*
|
|
10
|
+
* IO is injected rather than imported so this module stays testable without a
|
|
11
|
+
* process: `council_cli.ts` owns the repo root and the stdout/stderr pair.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
BILLING_GRANT_ENV,
|
|
16
|
+
currentRunId,
|
|
17
|
+
hasBillingGrant,
|
|
18
|
+
issueBillingGrant,
|
|
19
|
+
revokeBillingGrant,
|
|
20
|
+
} from './billing_grant.js';
|
|
21
|
+
import { renderBillingGateLines } from './council_fallback_posture.js';
|
|
22
|
+
import { writeQuotaParked } from './quota_parked.js';
|
|
23
|
+
|
|
24
|
+
export interface BillingCliDeps {
|
|
25
|
+
readonly repoRoot: string;
|
|
26
|
+
readonly stdout: (s: string) => void;
|
|
27
|
+
readonly stderr: (s: string) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The verbs this module owns, spread into `council_cli`'s subcommand list. */
|
|
31
|
+
export const BILLING_SUBCOMMANDS = ['grant-billing', 'revoke-billing'] as const;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The arg spec for a billing verb, or `null` when `cmd` is not one.
|
|
35
|
+
*
|
|
36
|
+
* Consulted from `council_cli`'s `default:` arm rather than added as two more
|
|
37
|
+
* `case` labels there: the verb list already lives in this module, and a
|
|
38
|
+
* second copy of it in a switch is a copy that can drift.
|
|
39
|
+
*/
|
|
40
|
+
export function billingArgSpec(
|
|
41
|
+
cmd: string,
|
|
42
|
+
): { positionals: string[]; opts: never[]; requiredOpts: never[] } | null {
|
|
43
|
+
return (BILLING_SUBCOMMANDS as readonly string[]).includes(cmd)
|
|
44
|
+
? { positionals: ['run_id'], opts: [], requiredOpts: [] }
|
|
45
|
+
: null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Dispatch a billing verb, or return `null` when `cmd` is not one.
|
|
50
|
+
*
|
|
51
|
+
* A `null` return rather than a thrown or a boolean out-param: the caller's
|
|
52
|
+
* dispatch chain is a run of `if (cmd === x) return f()`, and this has to slot
|
|
53
|
+
* into it as two lines without teaching the caller which verbs exist.
|
|
54
|
+
*/
|
|
55
|
+
export function handleBillingCommand(
|
|
56
|
+
cmd: string,
|
|
57
|
+
runId: unknown,
|
|
58
|
+
d: BillingCliDeps,
|
|
59
|
+
): number | null {
|
|
60
|
+
if (cmd === 'grant-billing') return grantBilling(String(runId ?? ''), d);
|
|
61
|
+
if (cmd === 'revoke-billing') return revokeBilling(String(runId ?? ''), d);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Record the human's yes for one run.
|
|
67
|
+
*
|
|
68
|
+
* Writes the grant file and then prints the export line, because the file
|
|
69
|
+
* alone is not the grant: a grant is in force only when `AC_BILLING_GRANT`
|
|
70
|
+
* NAMES the run whose file exists (see `isBillingGrantActive`), and that AND
|
|
71
|
+
* is what stops a previous run's grant from authorising this one. Printing the
|
|
72
|
+
* export rather than setting it is not a limitation being apologised for — a
|
|
73
|
+
* process cannot set its parent's environment, and a command claiming to would
|
|
74
|
+
* be lying.
|
|
75
|
+
*/
|
|
76
|
+
export function grantBilling(runId: string, d: BillingCliDeps): number {
|
|
77
|
+
if (runId.trim() === '') {
|
|
78
|
+
d.stderr('❌ council:grant-billing: a run id is required.\n');
|
|
79
|
+
return 2;
|
|
80
|
+
}
|
|
81
|
+
const g = issueBillingGrant(d.repoRoot, runId.trim());
|
|
82
|
+
d.stdout(`✅ billing grant recorded for run ${g.run_id} (issued ${g.issued_at})\n`);
|
|
83
|
+
d.stdout(` export ${BILLING_GRANT_ENV}=${g.run_id}\n`);
|
|
84
|
+
const live = hasBillingGrant(d.repoRoot) ? 'yes' : 'not until the export above is set';
|
|
85
|
+
d.stdout(` in force: ${live}\n`);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* End the grant. Exit 0 on an absent one: "there is no grant for this run" is
|
|
91
|
+
* the state the caller asked for, and failing on it would make the run-end
|
|
92
|
+
* cleanup path noisy on exactly the runs that never needed a grant.
|
|
93
|
+
*/
|
|
94
|
+
export function revokeBilling(runId: string, d: BillingCliDeps): number {
|
|
95
|
+
if (runId.trim() === '') {
|
|
96
|
+
d.stderr('❌ council:revoke-billing: a run id is required.\n');
|
|
97
|
+
return 2;
|
|
98
|
+
}
|
|
99
|
+
revokeBillingGrant(d.repoRoot, runId.trim());
|
|
100
|
+
d.stdout(`✅ billing grant revoked for run ${runId.trim()}\n`);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Print the round's closing question when `api_on_quota: 'ask'` parked seats.
|
|
106
|
+
* Silent when nothing parked, so the caller needs no branch.
|
|
107
|
+
*/
|
|
108
|
+
export function printBillingGate(parked: readonly string[], d: BillingCliDeps): void {
|
|
109
|
+
const runId = currentRunId();
|
|
110
|
+
// Record before rendering. A parked round is the one moment this repository
|
|
111
|
+
// KNOWS plan quota is exhausted without needing a host signal, and the
|
|
112
|
+
// printed line reaches whoever is watching the terminal — which for an
|
|
113
|
+
// autonomous drain is nobody. The marker is what survives to the next
|
|
114
|
+
// `run:supervise` report. Skipped when no run id resolves: a marker with a
|
|
115
|
+
// fabricated key is worse than none, because nothing would ever match it.
|
|
116
|
+
if (parked.length > 0 && runId !== null) writeQuotaParked(d.repoRoot, runId, parked);
|
|
117
|
+
for (const line of renderBillingGateLines(parked, {
|
|
118
|
+
runId: runId ?? '<run-id>',
|
|
119
|
+
estimatedUsd: null,
|
|
120
|
+
})) {
|
|
121
|
+
d.stdout(`${line}\n`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* `council:status` is the one command that must stay free of side effects.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import type { ApiOnQuota } from '../ai_council/transport_resolver.js';
|
|
17
|
+
|
|
16
18
|
export type FallbackPosture = 'api' | 'none' | 'n/a';
|
|
17
19
|
|
|
18
20
|
export interface PostureInput {
|
|
@@ -59,7 +61,7 @@ export function fallbackPostureFor(input: PostureInput): FallbackPosture {
|
|
|
59
61
|
*/
|
|
60
62
|
export function renderPostureLines(
|
|
61
63
|
postures: ReadonlyArray<readonly [string, FallbackPosture]>,
|
|
62
|
-
apiOnQuota:
|
|
64
|
+
apiOnQuota: ApiOnQuota,
|
|
63
65
|
): string[] {
|
|
64
66
|
const live = postures.filter(([, p]) => p !== 'n/a');
|
|
65
67
|
if (live.length === 0) return [];
|
|
@@ -72,10 +74,55 @@ export function renderPostureLines(
|
|
|
72
74
|
);
|
|
73
75
|
// Separate from the per-seat lines: not a per-seat property but the one
|
|
74
76
|
// billing-class decision, governing every seat at once.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
// Three postures, rendered by name rather than by truthiness. `'ask'` is
|
|
78
|
+
// truthy, so an `apiOnQuota ? 'on' : 'off'` reading would print `on` for
|
|
79
|
+
// it — telling the operator that quota fall-through happens silently when
|
|
80
|
+
// the whole point of the value is that it does not.
|
|
81
|
+
const word = apiOnQuota === 'ask' ? 'ask' : apiOnQuota ? 'on' : 'off';
|
|
82
|
+
const consequence =
|
|
83
|
+
apiOnQuota === 'ask'
|
|
84
|
+
? ' — an exhausted cli quota PARKS the seat and the round closes with a question,' +
|
|
85
|
+
' unless a run-scoped billing grant is already in force'
|
|
86
|
+
: apiOnQuota
|
|
87
|
+
? ' — an exhausted cli quota MAY retry on the metered rung'
|
|
88
|
+
: ' — an exhausted cli quota may not retry on the metered rung';
|
|
89
|
+
lines.push(` fallback quota api_on_quota: ${word}${consequence}`);
|
|
80
90
|
return lines;
|
|
81
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The Human Gate a round closes with when `api_on_quota: 'ask'` parked seats.
|
|
95
|
+
*
|
|
96
|
+
* A correction to this roadmap step, recorded rather than quietly absorbed:
|
|
97
|
+
* the step said to render this "through the existing `renderPostureLines`
|
|
98
|
+
* path". That path is `council:status`, which runs before any round and can
|
|
99
|
+
* therefore never know which seats a round parked. So this is a sibling in the
|
|
100
|
+
* same module rather than a branch inside that function — same place, same
|
|
101
|
+
* vocabulary, different moment.
|
|
102
|
+
*
|
|
103
|
+
* Returns `[]` when nothing parked, so a caller may render unconditionally
|
|
104
|
+
* without a heading over an empty list.
|
|
105
|
+
*/
|
|
106
|
+
export function renderBillingGateLines(
|
|
107
|
+
parked: readonly string[],
|
|
108
|
+
opts: { readonly runId: string; readonly estimatedUsd: number | null },
|
|
109
|
+
): string[] {
|
|
110
|
+
if (parked.length === 0) return [];
|
|
111
|
+
const seats = [...new Set(parked)].sort();
|
|
112
|
+
const cost =
|
|
113
|
+
opts.estimatedUsd === null
|
|
114
|
+
? 'unknown — no priced estimate for the retry'
|
|
115
|
+
: `~$${opts.estimatedUsd.toFixed(2)}`;
|
|
116
|
+
return [
|
|
117
|
+
'',
|
|
118
|
+
'HUMAN GATE — plan quota exhausted, metered spend not authorised',
|
|
119
|
+
` parked seats ${seats.join(', ')}`,
|
|
120
|
+
` retry cost ${cost}`,
|
|
121
|
+
// The command is printed rather than described because the whole
|
|
122
|
+
// mechanism fails if answering it takes a lookup: an operator who has
|
|
123
|
+
// to go find the syntax is an operator who says yes tomorrow, on a run
|
|
124
|
+
// that has already ended.
|
|
125
|
+
` to authorise agent-config council:grant-billing ${opts.runId}`,
|
|
126
|
+
' to decline do nothing — the grant is never implied by silence',
|
|
127
|
+
];
|
|
128
|
+
}
|
|
@@ -13,13 +13,18 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { CliFallbackOptions, FallbackEvent } from '../ai_council/mid_flight_fallback.js';
|
|
16
|
+
import type { ApiOnQuota } from '../ai_council/transport_resolver.js';
|
|
17
|
+
import { parseApiOnQuota } from '../ai_council/transport_resolver.js';
|
|
18
|
+
import { hasBillingGrant } from './billing_grant.js';
|
|
16
19
|
import { CouncilConfigError } from '../ai_council/config.js';
|
|
17
20
|
|
|
18
21
|
type Dict = Record<string, unknown>;
|
|
19
22
|
|
|
20
23
|
/** Anything the twin factory needs that `council_cli.ts` owns. */
|
|
21
24
|
export interface FallbackWiringDeps {
|
|
22
|
-
readonly apiOnQuota:
|
|
25
|
+
readonly apiOnQuota: ApiOnQuota;
|
|
26
|
+
/** Run-scoped billing grant in force. Read only under `'ask'`. */
|
|
27
|
+
readonly billingGrant?: boolean;
|
|
23
28
|
/** Does this provider have an api constructor at all? */
|
|
24
29
|
readonly hasApiRung: (provider: string) => boolean;
|
|
25
30
|
/** This provider's `members.<name>` block, or `{}`. */
|
|
@@ -38,6 +43,7 @@ export interface FallbackWiringDeps {
|
|
|
38
43
|
) => unknown;
|
|
39
44
|
/** The events-log appender. */
|
|
40
45
|
readonly emit: (record: Dict) => unknown;
|
|
46
|
+
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/**
|
|
@@ -77,8 +83,15 @@ function isRefusal(exc: unknown): boolean {
|
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
export function buildFallbackOptions(deps: FallbackWiringDeps): CliFallbackOptions {
|
|
86
|
+
// Owned here, not passed in. `establishTwin` emits `awaiting_grant` and
|
|
87
|
+
// this is the nearest thing to it that outlives one call, so a caller
|
|
88
|
+
// wanting the closing question reads `options.parked` instead of
|
|
89
|
+
// threading a collector through the factory.
|
|
90
|
+
const parked: string[] = [];
|
|
80
91
|
return {
|
|
81
92
|
api_on_quota: deps.apiOnQuota,
|
|
93
|
+
billing_grant: deps.billingGrant === true,
|
|
94
|
+
parked,
|
|
82
95
|
// The orchestrator is a pure library and holds no sink; this is it.
|
|
83
96
|
// One line per establishing escalation, so attendance analysis can
|
|
84
97
|
// tell a seat SAVED by the fallback from one that was natively api.
|
|
@@ -93,6 +106,7 @@ export function buildFallbackOptions(deps: FallbackWiringDeps): CliFallbackOptio
|
|
|
93
106
|
// during a fallback kills the whole round, and the mechanism whose
|
|
94
107
|
// entire purpose is to SAVE a seat becomes the thing that loses
|
|
95
108
|
// every seat. Losing the log line is the strictly smaller failure.
|
|
109
|
+
if (e.outcome === 'awaiting_grant') parked.push(e.provider);
|
|
96
110
|
try {
|
|
97
111
|
deps.emit({
|
|
98
112
|
action: 'transport_fallback',
|
|
@@ -124,3 +138,49 @@ export function buildFallbackOptions(deps: FallbackWiringDeps): CliFallbackOptio
|
|
|
124
138
|
},
|
|
125
139
|
};
|
|
126
140
|
}
|
|
141
|
+
|
|
142
|
+
/** Everything `wireCouncilFallback` needs that `council_cli.ts` owns. */
|
|
143
|
+
export interface CallSiteDeps {
|
|
144
|
+
readonly repoRoot: string;
|
|
145
|
+
readonly isDict: (v: unknown) => v is Dict;
|
|
146
|
+
readonly membersCfg: Dict;
|
|
147
|
+
readonly overrides: Dict;
|
|
148
|
+
readonly hasApiRung: (provider: string) => boolean;
|
|
149
|
+
readonly constructApi: FallbackWiringDeps['constructApi'];
|
|
150
|
+
readonly emit: (record: Dict) => unknown;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Build the fallback factory from the raw `ai_council` block and hand it back
|
|
155
|
+
* through the out-param `build_members` threads.
|
|
156
|
+
*
|
|
157
|
+
* Extracted from `council_cli.ts` when the billing-grant read joined the two
|
|
158
|
+
* config reads already here: that file is ~2,500 lines over the source ceiling
|
|
159
|
+
* and the documented remedy is extraction rather than a raised baseline
|
|
160
|
+
* (`gate-violation-baselines.json` § check_source_size_budget). Keeping the
|
|
161
|
+
* derivation next to the factory it feeds is the better shape anyway — the
|
|
162
|
+
* caller had to know that an absent `fallback` block means `false` and that
|
|
163
|
+
* the grant is read per-run, neither of which is the caller's business.
|
|
164
|
+
*/
|
|
165
|
+
export function wireCouncilFallback(
|
|
166
|
+
out: { options: CliFallbackOptions | null } | null,
|
|
167
|
+
ai: Dict,
|
|
168
|
+
d: CallSiteDeps,
|
|
169
|
+
): void {
|
|
170
|
+
// The null check lives here, not at the call site: `fallback_out` is an
|
|
171
|
+
// optional out-param and "the caller passed no ref" is this function's
|
|
172
|
+
// business, not a branch every caller has to remember.
|
|
173
|
+
if (out === null) return;
|
|
174
|
+
// Absent/malformed block means `false`, never a throw. Why quota
|
|
175
|
+
// fall-through is opt-in: `FallbackPolicy.apiOnQuota`.
|
|
176
|
+
const cfg = d.isDict(ai['fallback']) ? ai['fallback'] : {};
|
|
177
|
+
out.options = buildFallbackOptions({
|
|
178
|
+
apiOnQuota: parseApiOnQuota(cfg['api_on_quota']),
|
|
179
|
+
billingGrant: hasBillingGrant(d.repoRoot),
|
|
180
|
+
hasApiRung: d.hasApiRung,
|
|
181
|
+
memberConfig: (p) => ((d.membersCfg[p] as Dict) || {}) as Dict,
|
|
182
|
+
modelOverride: (p) => (d.overrides[p] as string | undefined) ?? null,
|
|
183
|
+
constructApi: d.constructApi,
|
|
184
|
+
emit: d.emit,
|
|
185
|
+
});
|
|
186
|
+
}
|