@event4u/agent-config 14.1.0 → 14.4.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 +291 -181
- package/agents/templates/.ai-council.yml.example +35 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
- package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
- package/dist/agent-src/scripts/resume_probe.ts +154 -9
- package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
- package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
- package/dist/agent-src/templates/agent-settings.md +70 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/dist/cli/python/workspace_drive.js +8 -3
- package/dist/cli/python/workspace_drive.js.map +1 -1
- package/dist/cli/python/workspace_hosts.js +16 -12
- package/dist/cli/python/workspace_hosts.js.map +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
- package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
- package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
- package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +26 -11
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +17 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +3773 -1731
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1070 -408
- package/dist/scripts/_lib/host_launch.js +66 -0
- package/dist/scripts/_lib/host_launch.js.map +1 -0
- package/dist/server/io/yamlIO.js +175 -3
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/wizard.js +20 -1
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/shared/settingsConsent.js +25 -0
- package/dist/shared/settingsConsent.js.map +1 -1
- package/docs/contracts/settings-classes.md +11 -0
- package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/resume_probe.ts +154 -9
- package/src/agent-src/scripts/roadmap_gates.ts +210 -6
- package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/src/config/estate-count-budget.json +120 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +32 -4
- package/src/config/gate-violation-baselines.json +63 -63
- package/src/config/gitignore-block.txt +5 -0
- package/src/config/hook-latency-budget.json +40 -1
- package/src/config/hook-token-budget.json +22 -0
- package/src/config/rule-stub-ceilings.json +11 -3
- package/src/scripts/_dispatch.bash +42 -1
- package/src/scripts/_lib/council_fallback_posture.ts +81 -0
- package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
- package/src/scripts/_lib/council_settings_block.ts +152 -0
- package/src/scripts/_lib/headless_invocation.ts +285 -0
- package/src/scripts/_lib/host_launch.ts +72 -0
- package/src/scripts/_lib/map_to_object.ts +16 -0
- package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
- package/src/scripts/_lib/run_checkpoint.ts +409 -0
- package/src/scripts/_lib/session_register.ts +32 -1
- package/src/scripts/_lib/skill_catalogue_series.ts +91 -0
- package/src/scripts/_lib/stdin.ts +35 -1
- package/src/scripts/_lib/turn_end_refusals.ts +12 -4
- package/src/scripts/_lib/unattended_guard.ts +371 -0
- package/src/scripts/ai_council/config.ts +27 -1
- package/src/scripts/ai_council/events_log.ts +12 -1
- package/src/scripts/ai_council/fallback_config.ts +114 -0
- package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
- package/src/scripts/ai_council/orchestrator.ts +251 -292
- package/src/scripts/ai_council/py_parity.ts +139 -0
- package/src/scripts/ai_council/response_render.ts +99 -0
- package/src/scripts/ai_council/spend_gate.ts +123 -0
- package/src/scripts/ai_council/transport_resolver.ts +81 -13
- package/src/scripts/bench_hook_injection.ts +100 -6
- package/src/scripts/bench_hook_latency.ts +442 -16
- package/src/scripts/check_estate_count.ts +924 -0
- package/src/scripts/compile_hook_manifest.ts +67 -0
- package/src/scripts/council_cli.ts +100 -125
- package/src/scripts/decision_memo.ts +325 -0
- package/src/scripts/dispatch_r2_reviewer.ts +169 -15
- package/src/scripts/hook_manifest.json +1 -0
- package/src/scripts/hook_manifest.yaml +172 -25
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/dispatch_hook.ts +183 -10
- package/src/scripts/hooks/dispatch_issues.ts +26 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
- package/src/scripts/hooks/hook_stdin.ts +111 -5
- package/src/scripts/hooks/injection_budget.ts +595 -0
- package/src/scripts/hooks/payload_stub.ts +446 -0
- package/src/scripts/hooks/run_continuation_hook.ts +627 -0
- package/src/scripts/hooks/session_eol_hook.ts +52 -0
- package/src/scripts/hooks/skill_route_hook.ts +172 -11
- package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
- package/src/scripts/hooks_doctor.ts +16 -2
- package/src/scripts/install-hooks.sh +26 -0
- package/src/scripts/interruption_report.ts +330 -13
- package/src/scripts/lint_hook_manifest.ts +167 -0
- package/src/scripts/roadmap_progress_hook.ts +254 -46
- package/src/scripts/run_supervise.ts +623 -0
- package/src/scripts/session_register_hook.ts +108 -8
- package/src/scripts/sessions_cli.ts +18 -6
- package/src/scripts/telemetry_disclosure_hook.ts +244 -0
- package/src/server/io/yamlIO.ts +167 -3
- package/src/shared/settingsConsent.ts +49 -2
- package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
|
@@ -41,6 +41,31 @@ function _sleepSync(ms: number): void {
|
|
|
41
41
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Options for {@link readStdinText}. */
|
|
45
|
+
export interface ReadStdinOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Give up and return `''` when the FIRST byte has not arrived within this
|
|
48
|
+
* many milliseconds. Undefined (the default) keeps the historical behaviour:
|
|
49
|
+
* the full {@link MAX_RETRIES} budget applies from the first read, which is
|
|
50
|
+
* what a `gh pr diff` pipe needs — its first byte can legitimately take a
|
|
51
|
+
* while, and reporting that as empty input is the defect this module exists
|
|
52
|
+
* to prevent.
|
|
53
|
+
*
|
|
54
|
+
* A HOOK is the opposite case, and it cost a CI hang to learn the difference.
|
|
55
|
+
* The host writes the payload synchronously on spawn, so a byte that has not
|
|
56
|
+
* arrived promptly is not a slow writer — it is an fd 0 nobody will ever
|
|
57
|
+
* write to, which is what a child inherits when a test spawns it without
|
|
58
|
+
* piping stdin. With no cap the dispatcher then span the full ~10 s budget on
|
|
59
|
+
* EVERY such invocation and returned "" anyway; measured 12.4 s per call
|
|
60
|
+
* against an immediate return before the retry loop existed. Hundreds of
|
|
61
|
+
* those hung three Node-Tests shards for over an hour.
|
|
62
|
+
*
|
|
63
|
+
* Once ANY byte has arrived the cap no longer applies: a writer demonstrably
|
|
64
|
+
* exists, so the full budget is right and the large-payload fix is intact.
|
|
65
|
+
*/
|
|
66
|
+
firstByteTimeoutMs?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
44
69
|
/**
|
|
45
70
|
* Read all of fd 0 as UTF-8.
|
|
46
71
|
*
|
|
@@ -48,10 +73,13 @@ function _sleepSync(ms: number): void {
|
|
|
48
73
|
* {@link MAX_RETRIES} consecutive EAGAINs — a caller must not be able to
|
|
49
74
|
* mistake a failed read for empty input.
|
|
50
75
|
*/
|
|
51
|
-
export function readStdinText(fd = 0): string {
|
|
76
|
+
export function readStdinText(fd = 0, opts: ReadStdinOptions = {}): string {
|
|
52
77
|
const chunks: Buffer[] = [];
|
|
53
78
|
const buf = Buffer.alloc(CHUNK);
|
|
54
79
|
let retries = 0;
|
|
80
|
+
let got_any = false;
|
|
81
|
+
const first_byte_deadline =
|
|
82
|
+
opts.firstByteTimeoutMs === undefined ? null : Date.now() + opts.firstByteTimeoutMs;
|
|
55
83
|
for (;;) {
|
|
56
84
|
let read: number;
|
|
57
85
|
try {
|
|
@@ -60,6 +88,11 @@ export function readStdinText(fd = 0): string {
|
|
|
60
88
|
} catch (exc) {
|
|
61
89
|
const code = (exc as NodeJS.ErrnoException).code;
|
|
62
90
|
if (code === 'EAGAIN') {
|
|
91
|
+
// An idle fd 0 is "no input", not a slow writer — but only
|
|
92
|
+
// before the first byte. See ReadStdinOptions.firstByteTimeoutMs.
|
|
93
|
+
if (!got_any && first_byte_deadline !== null && Date.now() >= first_byte_deadline) {
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
63
96
|
retries += 1;
|
|
64
97
|
if (retries > MAX_RETRIES) {
|
|
65
98
|
throw new Error(
|
|
@@ -76,6 +109,7 @@ export function readStdinText(fd = 0): string {
|
|
|
76
109
|
throw exc;
|
|
77
110
|
}
|
|
78
111
|
if (read === 0) break;
|
|
112
|
+
got_any = true;
|
|
79
113
|
chunks.push(Buffer.from(buf.subarray(0, read)));
|
|
80
114
|
}
|
|
81
115
|
return Buffer.concat(chunks).toString('utf-8');
|
|
@@ -45,10 +45,18 @@ import { read_lockfile } from './installed_lock.js';
|
|
|
45
45
|
*
|
|
46
46
|
* The roadmap's § 0 names three (A promissory, B language, C verification).
|
|
47
47
|
* That was true of the draft and is not true of the tree: detector D
|
|
48
|
-
* (`completion`) landed under round 7 § Phase 1 and
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
48
|
+
* (`completion`) landed under round 7 § Phase 1 and runs in the same detector
|
|
49
|
+
* list as the other three. Counting three would silently drop a detector's
|
|
50
|
+
* refusals, so the set is read off `DetectorId` in the gate rather than off the
|
|
51
|
+
* prose.
|
|
52
|
+
*
|
|
53
|
+
* This said "the same UNCONDITIONAL list" until 2026-08-18, which was wrong and
|
|
54
|
+
* is worth naming rather than quietly rewording: `main()` runs A and D only when
|
|
55
|
+
* no subagent dispatch is open. It does not change this set — every detector
|
|
56
|
+
* that CAN fire needs a counter — but it does change what a reader may conclude
|
|
57
|
+
* from a zero here: A's and D's counts are censored by that third allow path,
|
|
58
|
+
* and `docs/contracts/turn-end-detector-demotion.md` is where that consequence
|
|
59
|
+
* is carried.
|
|
52
60
|
*/
|
|
53
61
|
export const DETECTOR_IDS = [
|
|
54
62
|
'promissory',
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* unattended_guard — the three preconditions an unattended run must clear
|
|
3
|
+
* before anything spawns, and the ledger that bounds it once it has.
|
|
4
|
+
*
|
|
5
|
+
* road-to-long-horizon-execution Phase 4.0 (sequencing UOTL Phase 7.1).
|
|
6
|
+
*
|
|
7
|
+
* ## What this is, and what it deliberately is not
|
|
8
|
+
*
|
|
9
|
+
* It is NOT the spawner. There is no `spawnHeadlessAgent` here and that is a
|
|
10
|
+
* decision, not an omission — see § "Why the spawn is not in this file".
|
|
11
|
+
*
|
|
12
|
+
* It IS the layer that has to be right BEFORE a spawn is safe, and every part
|
|
13
|
+
* of it is testable offline with no spend:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Remote safety.** A worktree an unattended agent works in must carry
|
|
16
|
+
* no production remote. UOTL 7.1 says so; the failure it prevents is an
|
|
17
|
+
* unattended push reaching a real remote because a worktree inherited
|
|
18
|
+
* the parent's `origin`.
|
|
19
|
+
* 2. **A budget with a ledger.** A cap nobody decrements is a comment. The
|
|
20
|
+
* ledger is a file, spend is booked against it, and an exhausted budget
|
|
21
|
+
* refuses — before the call, never after.
|
|
22
|
+
* 3. **Job dedup.** Two schedulers, or one scheduler and one human, must
|
|
23
|
+
* not run the same roadmap at the same head twice. The key is derived
|
|
24
|
+
* from the work, not from a random id, so the second caller recognises
|
|
25
|
+
* the first caller's job.
|
|
26
|
+
*
|
|
27
|
+
* ## Why the spawn is not in this file
|
|
28
|
+
*
|
|
29
|
+
* Writing a spawner means choosing an invocation shape, an auth path and a
|
|
30
|
+
* sandbox posture for a process that spends money with nobody watching. Each
|
|
31
|
+
* of those is a decision with a blast radius, and none of them is testable
|
|
32
|
+
* without spending. Shipping an untested one behind a flag that reads as
|
|
33
|
+
* finished is how a capability nobody validated ends up in a scheduler.
|
|
34
|
+
*
|
|
35
|
+
* The guard lands first on purpose: it is the half that can be verified, and
|
|
36
|
+
* it is the half whose absence makes the other half unsafe. Phase 4.2's
|
|
37
|
+
* pre-registered demotion gate has to exist before a first unattended run
|
|
38
|
+
* either way, so the ordering costs nothing.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { spawnSync } from 'node:child_process';
|
|
42
|
+
import * as fs from 'node:fs';
|
|
43
|
+
import * as path from 'node:path';
|
|
44
|
+
|
|
45
|
+
export const BUDGET_REL = path.join('agents', 'runtime', 'state', 'unattended-budget.json');
|
|
46
|
+
export const JOBS_REL = path.join('agents', 'runtime', 'state', 'unattended-jobs.json');
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Remote URL fragments that mark a remote as PRODUCTION-reachable.
|
|
50
|
+
*
|
|
51
|
+
* Deliberately a shape test rather than a host allowlist: the question is not
|
|
52
|
+
* "which forge" but "can a push from here reach something real". A local path
|
|
53
|
+
* remote or a remote pointing inside this checkout cannot; anything that
|
|
54
|
+
* resolves over the network can.
|
|
55
|
+
*/
|
|
56
|
+
export function isProductionRemote(url: string): boolean {
|
|
57
|
+
const u = url.trim();
|
|
58
|
+
if (u === '') return false;
|
|
59
|
+
// ONLY a recognisably-local remote is non-production. Everything else is
|
|
60
|
+
// treated as reachable, including a shape this function does not know.
|
|
61
|
+
//
|
|
62
|
+
// R2 round 4, finding 6. The test used to be positive — enumerate the
|
|
63
|
+
// network shapes and return false otherwise — which meant an unrecognised
|
|
64
|
+
// URL cleared the "no production remote" precondition. An SSH-config alias
|
|
65
|
+
// is the concrete case: `gh:org/repo.git` matches no prefix here and fails
|
|
66
|
+
// the scp-style pattern (which requires `user@host:`), yet a push through
|
|
67
|
+
// it reaches GitHub exactly as `git@github.com:` does. The whole module
|
|
68
|
+
// fails CLOSED on an unreadable git config for precisely this reason, and
|
|
69
|
+
// this one function was reading "I do not recognise it" as "it is safe".
|
|
70
|
+
//
|
|
71
|
+
// A filesystem path — a sibling worktree, a bare mirror on disk. A push
|
|
72
|
+
// there is recoverable and reaches nobody.
|
|
73
|
+
if (u.startsWith('/') || u.startsWith('.') || u.startsWith('~') || u.startsWith('file://')) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface RemoteVerdict {
|
|
80
|
+
readonly safe: boolean;
|
|
81
|
+
/** Every remote found, so a refusal can name the one that failed. */
|
|
82
|
+
readonly remotes: ReadonlyArray<{ name: string; url: string; production: boolean }>;
|
|
83
|
+
readonly reason: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Is this worktree safe for an unattended agent to work in?
|
|
88
|
+
*
|
|
89
|
+
* Fails CLOSED on an unreadable git config: "I could not tell" and "there is
|
|
90
|
+
* no production remote" must not resolve to the same answer when the
|
|
91
|
+
* consequence of being wrong is an unattended push to a real remote.
|
|
92
|
+
*/
|
|
93
|
+
export function checkRemotes(worktree: string): RemoteVerdict {
|
|
94
|
+
const r = spawnSync('git', ['remote', '-v'], { cwd: worktree, encoding: 'utf-8' });
|
|
95
|
+
if (r.status !== 0) {
|
|
96
|
+
return {
|
|
97
|
+
safe: false,
|
|
98
|
+
remotes: [],
|
|
99
|
+
reason:
|
|
100
|
+
`could not read remotes in ${worktree} (git exited ${String(r.status)}) — ` +
|
|
101
|
+
`refusing, because "unreadable" and "none" must not mean the same thing here`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// BOTH rows, and the push row decides. R2 round 6, finding 5: parsing only
|
|
105
|
+
// `(fetch)` judged a remote by the URL it reads from, while the thing this
|
|
106
|
+
// precondition guards against is a PUSH. `git remote -v` emits a separate
|
|
107
|
+
// `(push)` row whenever `pushurl` diverges, so a worktree with
|
|
108
|
+
// `url = /srv/mirrors/repo.git` and `pushurl = git@github.com:org/repo.git`
|
|
109
|
+
// read as local and cleared the gate — fail-open, in the one module whose
|
|
110
|
+
// stated posture is fail-closed.
|
|
111
|
+
//
|
|
112
|
+
// A remote is production if EITHER of its URLs is, because either one
|
|
113
|
+
// being reachable is enough for the harm this check exists to prevent.
|
|
114
|
+
const urls = new Map<string, Set<string>>();
|
|
115
|
+
for (const line of (r.stdout ?? '').split('\n')) {
|
|
116
|
+
const m = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(line.trim());
|
|
117
|
+
if (m === null) continue;
|
|
118
|
+
const name = m[1] as string;
|
|
119
|
+
const set = urls.get(name) ?? new Set<string>();
|
|
120
|
+
set.add(m[2] as string);
|
|
121
|
+
urls.set(name, set);
|
|
122
|
+
}
|
|
123
|
+
const remotes = [...urls.entries()].map(([name, set]) => {
|
|
124
|
+
const all = [...set];
|
|
125
|
+
const prod = all.filter((u) => isProductionRemote(u));
|
|
126
|
+
return {
|
|
127
|
+
name,
|
|
128
|
+
// The production URL is the one worth naming in a refusal.
|
|
129
|
+
url: (prod[0] ?? all[0]) as string,
|
|
130
|
+
production: prod.length > 0,
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
const bad = remotes.filter((x) => x.production);
|
|
134
|
+
if (bad.length > 0) {
|
|
135
|
+
return {
|
|
136
|
+
safe: false,
|
|
137
|
+
remotes,
|
|
138
|
+
reason:
|
|
139
|
+
`production-reachable remote(s): ${bad.map((b) => `${b.name} → ${b.url}`).join(', ')} — ` +
|
|
140
|
+
`an unattended run must not be able to push to a real remote (UOTL 7.1)`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return { safe: true, remotes, reason: 'no production-reachable remote' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The cap and what has been spent against it. */
|
|
147
|
+
export interface UnattendedBudget {
|
|
148
|
+
schema_version: 1;
|
|
149
|
+
/** Hard ceiling in USD. `0` disables unattended runs entirely. */
|
|
150
|
+
max_usd: number;
|
|
151
|
+
/** Hard ceiling in tokens across the window. `0` disables. */
|
|
152
|
+
max_tokens: number;
|
|
153
|
+
spent_usd: number;
|
|
154
|
+
spent_tokens: number;
|
|
155
|
+
/** ISO date the window opened; a new UTC day resets both counters. */
|
|
156
|
+
window_opened: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function emptyBudget(day: string): UnattendedBudget {
|
|
160
|
+
// Both ceilings default to ZERO, which disables unattended runs. An
|
|
161
|
+
// absent budget file must not read as "unlimited" — that is the one
|
|
162
|
+
// default that turns a missing config into unbounded spend.
|
|
163
|
+
return {
|
|
164
|
+
schema_version: 1,
|
|
165
|
+
max_usd: 0,
|
|
166
|
+
max_tokens: 0,
|
|
167
|
+
spent_usd: 0,
|
|
168
|
+
spent_tokens: 0,
|
|
169
|
+
window_opened: day,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function utcDay(now: Date = new Date()): string {
|
|
174
|
+
return now.toISOString().slice(0, 10);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function readBudget(repoRoot: string, now: Date = new Date()): UnattendedBudget {
|
|
178
|
+
const day = utcDay(now);
|
|
179
|
+
let raw: unknown;
|
|
180
|
+
try {
|
|
181
|
+
raw = JSON.parse(fs.readFileSync(path.join(repoRoot, BUDGET_REL), 'utf-8'));
|
|
182
|
+
} catch {
|
|
183
|
+
return emptyBudget(day);
|
|
184
|
+
}
|
|
185
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return emptyBudget(day);
|
|
186
|
+
const o = raw as Record<string, unknown>;
|
|
187
|
+
const num = (k: string): number => {
|
|
188
|
+
const v = o[k];
|
|
189
|
+
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0;
|
|
190
|
+
};
|
|
191
|
+
const opened = typeof o['window_opened'] === 'string' ? o['window_opened'] : day;
|
|
192
|
+
// A new UTC day rolls the window: the caps persist, the spend resets.
|
|
193
|
+
const rolled = opened !== day;
|
|
194
|
+
return {
|
|
195
|
+
schema_version: 1,
|
|
196
|
+
max_usd: num('max_usd'),
|
|
197
|
+
max_tokens: num('max_tokens'),
|
|
198
|
+
spent_usd: rolled ? 0 : num('spent_usd'),
|
|
199
|
+
spent_tokens: rolled ? 0 : num('spent_tokens'),
|
|
200
|
+
window_opened: rolled ? day : opened,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function writeBudget(repoRoot: string, b: UnattendedBudget): void {
|
|
205
|
+
const file = path.join(repoRoot, BUDGET_REL);
|
|
206
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
207
|
+
fs.writeFileSync(file, `${JSON.stringify(b, null, 2)}\n`, 'utf-8');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface BudgetVerdict {
|
|
211
|
+
readonly allowed: boolean;
|
|
212
|
+
readonly reason: string;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* May a run costing `usd` / `tokens` proceed?
|
|
217
|
+
*
|
|
218
|
+
* Checked BEFORE the call, never after: a cap enforced on the way out has
|
|
219
|
+
* already been exceeded by the time it fires.
|
|
220
|
+
*/
|
|
221
|
+
export function checkBudget(b: UnattendedBudget, usd: number, tokens: number): BudgetVerdict {
|
|
222
|
+
if (b.max_usd <= 0 && b.max_tokens <= 0) {
|
|
223
|
+
return {
|
|
224
|
+
allowed: false,
|
|
225
|
+
reason:
|
|
226
|
+
'no unattended budget configured (both ceilings are 0) — an absent budget ' +
|
|
227
|
+
'disables unattended runs rather than permitting unbounded ones',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
// Each ceiling is checked INDEPENDENTLY, and a 0 on either dimension
|
|
231
|
+
// refuses any consumption of it rather than skipping the check.
|
|
232
|
+
//
|
|
233
|
+
// R2 review, finding 5. The guarded form was `max_tokens > 0 && …`, so an
|
|
234
|
+
// operator writing `{max_usd: 5, max_tokens: 0}` — following the field's
|
|
235
|
+
// own "`0` disables" — got an UNBOUNDED token allowance, and `digest()`
|
|
236
|
+
// then printed `N/0 tokens`, which reads as an exhausted cap while runs
|
|
237
|
+
// were being permitted. The whole point of this module's zero-default is
|
|
238
|
+
// that a missing number never means unlimited; a zero written on purpose
|
|
239
|
+
// must mean the same thing as a zero left unset.
|
|
240
|
+
if (b.max_usd <= 0 && usd > 0) {
|
|
241
|
+
return {
|
|
242
|
+
allowed: false,
|
|
243
|
+
reason: `USD ceiling is 0 (disabled) and ${usd} was projected`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (b.max_usd > 0 && b.spent_usd + usd > b.max_usd) {
|
|
247
|
+
return {
|
|
248
|
+
allowed: false,
|
|
249
|
+
reason: `USD ceiling: ${b.spent_usd} spent + ${usd} projected > ${b.max_usd}`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (b.max_tokens <= 0 && tokens > 0) {
|
|
253
|
+
return {
|
|
254
|
+
allowed: false,
|
|
255
|
+
reason: `token ceiling is 0 (disabled) and ${tokens} was projected`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (b.max_tokens > 0 && b.spent_tokens + tokens > b.max_tokens) {
|
|
259
|
+
return {
|
|
260
|
+
allowed: false,
|
|
261
|
+
reason: `token ceiling: ${b.spent_tokens} spent + ${tokens} projected > ${b.max_tokens}`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return { allowed: true, reason: 'within budget' };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function bookSpend(
|
|
268
|
+
b: UnattendedBudget,
|
|
269
|
+
usd: number,
|
|
270
|
+
tokens: number,
|
|
271
|
+
): UnattendedBudget {
|
|
272
|
+
return { ...b, spent_usd: b.spent_usd + usd, spent_tokens: b.spent_tokens + tokens };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The dedup key for one unit of unattended work.
|
|
277
|
+
*
|
|
278
|
+
* Derived from WHAT is being done — roadmap plus the commit it starts from —
|
|
279
|
+
* not from a random id, so a second caller with the same work recognises the
|
|
280
|
+
* first caller's job. A random id would make every duplicate look new, which
|
|
281
|
+
* is exactly the collision this exists to catch.
|
|
282
|
+
*/
|
|
283
|
+
export function jobKey(roadmapSlug: string, head: string): string {
|
|
284
|
+
return `${roadmapSlug}@${head.slice(0, 12)}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export type JobLedger = Record<string, { started_at: string }>;
|
|
288
|
+
|
|
289
|
+
export function readJobs(repoRoot: string): JobLedger {
|
|
290
|
+
try {
|
|
291
|
+
const raw: unknown = JSON.parse(fs.readFileSync(path.join(repoRoot, JOBS_REL), 'utf-8'));
|
|
292
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
|
293
|
+
const out: JobLedger = {};
|
|
294
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
295
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
296
|
+
const started = (v as Record<string, unknown>)['started_at'];
|
|
297
|
+
if (typeof started === 'string') out[k] = { started_at: started };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
} catch {
|
|
302
|
+
return {};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function writeJobs(repoRoot: string, jobs: JobLedger): void {
|
|
307
|
+
const file = path.join(repoRoot, JOBS_REL);
|
|
308
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
309
|
+
fs.writeFileSync(file, `${JSON.stringify(jobs, null, 2)}\n`, 'utf-8');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface PreflightInput {
|
|
313
|
+
readonly repoRoot: string;
|
|
314
|
+
readonly worktree: string;
|
|
315
|
+
readonly roadmapSlug: string;
|
|
316
|
+
readonly head: string;
|
|
317
|
+
readonly projectedUsd: number;
|
|
318
|
+
readonly projectedTokens: number;
|
|
319
|
+
readonly now?: Date;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface PreflightVerdict {
|
|
323
|
+
readonly ok: boolean;
|
|
324
|
+
/** Every failed precondition, not just the first — one run, one full answer. */
|
|
325
|
+
readonly refusals: readonly string[];
|
|
326
|
+
readonly jobKey: string;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* All three preconditions, reported together.
|
|
331
|
+
*
|
|
332
|
+
* Returns every refusal rather than short-circuiting: an operator fixing one
|
|
333
|
+
* precondition at a time round-trips N times, and the whole point of a
|
|
334
|
+
* preflight is that it answers in one pass.
|
|
335
|
+
*
|
|
336
|
+
* THE DEDUP PRECONDITION CANNOT FIRE TODAY, and that is stated here rather
|
|
337
|
+
* than implied by a passing read. It queries the job ledger correctly, but
|
|
338
|
+
* nothing in the tree WRITES that ledger: `writeJobs`, `bookSpend` and
|
|
339
|
+
* `writeBudget` have no production caller, so two callers racing the same
|
|
340
|
+
* `roadmap@head` both receive `ok: true`. R2 review, finding 13 — the module
|
|
341
|
+
* previously listed dedup among its three preconditions with no note, which
|
|
342
|
+
* describes a control that is not there.
|
|
343
|
+
*
|
|
344
|
+
* The write half belongs to the spawn, which Phase 4.0 deliberately does not
|
|
345
|
+
* build. The claim-and-release lifecycle is the spawn's to own: a claim
|
|
346
|
+
* written by a checker that never learns whether the job started would strand
|
|
347
|
+
* the key on every crash and turn the dedup into a permanent refusal. Until
|
|
348
|
+
* the spawn exists, treat this as a read that is ready for a writer, never as
|
|
349
|
+
* a live guarantee.
|
|
350
|
+
*/
|
|
351
|
+
export function preflight(input: PreflightInput): PreflightVerdict {
|
|
352
|
+
const refusals: string[] = [];
|
|
353
|
+
const key = jobKey(input.roadmapSlug, input.head);
|
|
354
|
+
|
|
355
|
+
const remotes = checkRemotes(input.worktree);
|
|
356
|
+
if (!remotes.safe) refusals.push(`remote: ${remotes.reason}`);
|
|
357
|
+
|
|
358
|
+
const budget = readBudget(input.repoRoot, input.now ?? new Date());
|
|
359
|
+
const verdict = checkBudget(budget, input.projectedUsd, input.projectedTokens);
|
|
360
|
+
if (!verdict.allowed) refusals.push(`budget: ${verdict.reason}`);
|
|
361
|
+
|
|
362
|
+
const jobs = readJobs(input.repoRoot);
|
|
363
|
+
if (jobs[key] !== undefined) {
|
|
364
|
+
refusals.push(
|
|
365
|
+
`dedup: job '${key}' already started at ${jobs[key]?.started_at ?? 'unknown'} — ` +
|
|
366
|
+
`the same roadmap at the same head is already in flight`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { ok: refusals.length === 0, refusals, jobKey: key };
|
|
371
|
+
}
|
|
@@ -94,6 +94,16 @@ const _VALID_PROVIDERS: ReadonlySet<string> = new Set([
|
|
|
94
94
|
]);
|
|
95
95
|
const _VALID_MODES: ReadonlySet<string> = new Set(['api', 'manual', 'cli', 'auto']);
|
|
96
96
|
|
|
97
|
+
// The fallback/second-model surfaces live in `fallback_config.ts` — this file
|
|
98
|
+
// is far over the source ceiling and the documented fix is extraction.
|
|
99
|
+
const _FALLBACK_DEPS = {
|
|
100
|
+
isDict: _isDict as (v: unknown) => v is Dict,
|
|
101
|
+
isBool: _isBool as (v: unknown) => v is boolean,
|
|
102
|
+
isStr: _isStr as (v: unknown) => v is string,
|
|
103
|
+
repr: _pyRepr, typeName: _pyTypeName, sortedListRepr: _sortedListRepr,
|
|
104
|
+
error: (msg: string): Error => new CouncilConfigError(msg),
|
|
105
|
+
};
|
|
106
|
+
|
|
97
107
|
/**
|
|
98
108
|
* Prefixes that signal "this is a raw API key" so we refuse it loudly
|
|
99
109
|
* even when the user accidentally inlined it in `api_key_ref`.
|
|
@@ -537,6 +547,12 @@ const _VALID_CHAIRMAN_MODES: ReadonlySet<string> = new Set(['host', 'member', 'a
|
|
|
537
547
|
export interface DecisionResolutionEntry {
|
|
538
548
|
readonly mode: string;
|
|
539
549
|
readonly confidence_threshold: number;
|
|
550
|
+
/**
|
|
551
|
+
* OPTIONAL local second-model rung — UOTL Phase 4.1. Absent (`null`) →
|
|
552
|
+
* the ladder is unchanged. Provider set, quota binding and the locked-class
|
|
553
|
+
* refusal live in `fallback_config.ts::buildSecondModel`.
|
|
554
|
+
*/
|
|
555
|
+
readonly second_model: string | null;
|
|
540
556
|
}
|
|
541
557
|
|
|
542
558
|
/** Opt-in fuzzy matching for the corpus-aware classifier (step-9 P5). */
|
|
@@ -617,6 +633,9 @@ export type QuorumSetting = 'majority' | number;
|
|
|
617
633
|
*/
|
|
618
634
|
export type QuorumMinPresent = number;
|
|
619
635
|
|
|
636
|
+
export type { FallbackConfig } from './fallback_config.js';
|
|
637
|
+
import { buildFallback, buildSecondModel, type FallbackConfig as _FallbackConfig } from './fallback_config.js';
|
|
638
|
+
|
|
620
639
|
export interface CouncilConfig {
|
|
621
640
|
readonly enabled: boolean;
|
|
622
641
|
readonly defaults: DefaultsConfig;
|
|
@@ -627,6 +646,7 @@ export interface CouncilConfig {
|
|
|
627
646
|
readonly cli_call_budget: CliCallBudgetConfig;
|
|
628
647
|
readonly quorum: QuorumSetting;
|
|
629
648
|
readonly quorum_min_present: QuorumMinPresent;
|
|
649
|
+
readonly fallback: _FallbackConfig;
|
|
630
650
|
readonly necessity_classifier: NecessityClassifierConfig;
|
|
631
651
|
readonly model_downgrade: ModelDowngradeConfig;
|
|
632
652
|
readonly debate: DebateConfig;
|
|
@@ -881,6 +901,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
|
|
|
881
901
|
const quorum_min_present = _build_quorum_min_present(
|
|
882
902
|
_get(raw, 'quorum_min_present', SOLO_FLOOR_MIN_PRESENT),
|
|
883
903
|
);
|
|
904
|
+
const fallback = buildFallback(_get(raw, 'fallback', {}), _FALLBACK_DEPS);
|
|
884
905
|
const necessity_classifier = _build_necessity_classifier(
|
|
885
906
|
_asDict(_getOr(raw, 'necessity_classifier', {})),
|
|
886
907
|
);
|
|
@@ -931,6 +952,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
|
|
|
931
952
|
cli_call_budget,
|
|
932
953
|
quorum,
|
|
933
954
|
quorum_min_present,
|
|
955
|
+
fallback,
|
|
934
956
|
necessity_classifier,
|
|
935
957
|
model_downgrade,
|
|
936
958
|
debate,
|
|
@@ -1309,7 +1331,11 @@ function _build_decision_resolution(d: Dict): DecisionResolutionConfig {
|
|
|
1309
1331
|
`must be in [0.0, 1.0] (got ${_pyRepr(_f(threshold))}).`,
|
|
1310
1332
|
);
|
|
1311
1333
|
}
|
|
1312
|
-
|
|
1334
|
+
// UOTL Phase 4.1 — the optional local rung, REFUSED on a locked
|
|
1335
|
+
// class rather than ignored: a dropped key reads as configured.
|
|
1336
|
+
const second_model = buildSecondModel(
|
|
1337
|
+
entry_raw as Record<string, unknown>, cls, _LOCKED_IMPACT_CLASSES, _FALLBACK_DEPS);
|
|
1338
|
+
classes.set(cls, { mode, confidence_threshold: threshold, second_model });
|
|
1313
1339
|
}
|
|
1314
1340
|
const fast_path_raw = _getOr(d, 'fast_path', {});
|
|
1315
1341
|
if (!_isDict(fast_path_raw)) {
|
|
@@ -66,13 +66,24 @@ import type { AbsentReason } from './transport_resolver.js';
|
|
|
66
66
|
*/
|
|
67
67
|
export const SCHEMA_VERSION = 4;
|
|
68
68
|
|
|
69
|
-
export type EventAction =
|
|
69
|
+
export type EventAction =
|
|
70
|
+
| 'proceed'
|
|
71
|
+
| 'skip_necessity'
|
|
72
|
+
| 'block_quota'
|
|
73
|
+
| 'quorum_result'
|
|
74
|
+
| 'transport_fallback';
|
|
70
75
|
|
|
71
76
|
const _VALID_ACTIONS: ReadonlySet<string> = new Set([
|
|
72
77
|
'proceed',
|
|
73
78
|
'skip_necessity',
|
|
74
79
|
'block_quota',
|
|
75
80
|
'quorum_result',
|
|
81
|
+
// A seat lost its cli transport mid-pass and was retried on the metered
|
|
82
|
+
// api rung. Without this action, attendance analysis cannot tell a seat
|
|
83
|
+
// SAVED by the fallback from a seat that was natively api all along — the
|
|
84
|
+
// two are indistinguishable in the response set, and only one of them
|
|
85
|
+
// costs money that the operator did not plan.
|
|
86
|
+
'transport_fallback',
|
|
76
87
|
]);
|
|
77
88
|
|
|
78
89
|
/**
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fallback_config — the two config surfaces the question ladder added, kept
|
|
3
|
+
* out of `config.ts` because that file is 800 lines over the source ceiling
|
|
4
|
+
* and the documented fix is extraction rather than a raised baseline
|
|
5
|
+
* (`gate-violation-baselines.json` § check_source_size_budget).
|
|
6
|
+
*
|
|
7
|
+
* Behavioural contract: `docs/contracts/ai-council-config.md` — § "Mid-flight
|
|
8
|
+
* fallback is failure-class-gated" for `fallback.api_on_quota`, § "Decision
|
|
9
|
+
* classes" for `second_model`. The reasons a reader needs AT the code are
|
|
10
|
+
* here; the rest is there rather than duplicated.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `fallback.*` — the mid-flight cli→api retry's one configurable knob.
|
|
15
|
+
*
|
|
16
|
+
* Modelled rather than read leniently off the raw dict for a mechanical
|
|
17
|
+
* reason: `council_cli.ts::load_settings` does not hand callers the file, it
|
|
18
|
+
* hands them a block SYNTHESIZED from `CouncilConfig`. A key this type does
|
|
19
|
+
* not carry cannot reach `build_members`, whatever the operator wrote — the
|
|
20
|
+
* same defect `quorum` and `quorum_min_present` each shipped once already.
|
|
21
|
+
*/
|
|
22
|
+
export interface FallbackConfig {
|
|
23
|
+
/**
|
|
24
|
+
* May an exhausted CLI quota retry on the metered api rung? Default
|
|
25
|
+
* `false`. The no-double-charge classes are always eligible and never
|
|
26
|
+
* consult this; `timeout` / 5xx are never eligible under any value.
|
|
27
|
+
*/
|
|
28
|
+
readonly api_on_quota: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Providers a `second_model` rung may name — a SUBSET of the five
|
|
33
|
+
* `members:` accepts, and of `council_cli.ts::_CLI_PROVIDERS`.
|
|
34
|
+
*
|
|
35
|
+
* `xai` and `perplexity` ship COMMUNITY CLI wrappers that consume an API key
|
|
36
|
+
* and set `billable = true`, so routing a resolution there would spend USD on
|
|
37
|
+
* the rung whose entire purpose is not to. The discriminator is
|
|
38
|
+
* `billable === false`, not "has a cli subclass" — the wider set would have
|
|
39
|
+
* been the easy read and the wrong one.
|
|
40
|
+
*/
|
|
41
|
+
export const SECOND_MODEL_PROVIDERS: ReadonlySet<string> = new Set([
|
|
42
|
+
'anthropic',
|
|
43
|
+
'openai',
|
|
44
|
+
'gemini',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
export interface FallbackBuilderDeps {
|
|
48
|
+
readonly isDict: (v: unknown) => v is Record<string, unknown>;
|
|
49
|
+
readonly isBool: (v: unknown) => v is boolean;
|
|
50
|
+
readonly isStr: (v: unknown) => v is string;
|
|
51
|
+
readonly repr: (v: unknown) => string;
|
|
52
|
+
readonly typeName: (v: unknown) => string;
|
|
53
|
+
readonly sortedListRepr: (s: ReadonlySet<string>) => string;
|
|
54
|
+
readonly error: (msg: string) => Error;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate the `fallback` block.
|
|
59
|
+
*
|
|
60
|
+
* Asymmetric on purpose. A malformed BLOCK is tolerated as absent — the key's
|
|
61
|
+
* whole job is to WITHHOLD spend, so a garbled container degrading to "off" is
|
|
62
|
+
* safe, and a config that fails to load is worse. A malformed `api_on_quota`
|
|
63
|
+
* VALUE is refused: `"yes"` is an operator trying to authorise spend, and
|
|
64
|
+
* silently reading that as `false` would be as wrong as reading it as `true`.
|
|
65
|
+
*/
|
|
66
|
+
export function buildFallback(raw: unknown, d: FallbackBuilderDeps): FallbackConfig {
|
|
67
|
+
if (!d.isDict(raw)) return { api_on_quota: false };
|
|
68
|
+
const v = raw['api_on_quota'];
|
|
69
|
+
if (v === undefined) return { api_on_quota: false };
|
|
70
|
+
if (!d.isBool(v)) {
|
|
71
|
+
throw d.error(
|
|
72
|
+
`\`fallback.api_on_quota\`=${d.repr(v)} must be a boolean (got ${d.typeName(v)}).`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return { api_on_quota: v };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validate one decision class's optional `second_model` rung.
|
|
80
|
+
*
|
|
81
|
+
* Absent → `null`. Present on a LOCKED class → hard error, even for an
|
|
82
|
+
* explicit `null`: those classes do not have this dimension, and accepting the
|
|
83
|
+
* key at any value teaches its author that they do. Present with a provider
|
|
84
|
+
* outside the USD-neutral set → hard error naming the set, because the failure
|
|
85
|
+
* this rung exists to prevent is a halt and silently disabling it would
|
|
86
|
+
* reintroduce that halt at the moment it is needed.
|
|
87
|
+
*/
|
|
88
|
+
export function buildSecondModel(
|
|
89
|
+
entry: Record<string, unknown>,
|
|
90
|
+
cls: string,
|
|
91
|
+
locked: ReadonlySet<string>,
|
|
92
|
+
d: FallbackBuilderDeps,
|
|
93
|
+
): string | null {
|
|
94
|
+
if (!('second_model' in entry)) return null;
|
|
95
|
+
if (locked.has(cls)) {
|
|
96
|
+
throw d.error(
|
|
97
|
+
`decision_resolution.classes.${cls}.second_model=${d.repr(entry['second_model'])}: ` +
|
|
98
|
+
`a second-model rung is not configurable for high-impact / user-required ` +
|
|
99
|
+
`decisions — those classes are LOCKED to \`user\` (Iron Law) and never resolve ` +
|
|
100
|
+
`on a model of any kind.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const raw = entry['second_model'];
|
|
104
|
+
if (raw === null) return null;
|
|
105
|
+
if (!(d.isStr(raw) && SECOND_MODEL_PROVIDERS.has(raw))) {
|
|
106
|
+
throw d.error(
|
|
107
|
+
`decision_resolution.classes.${cls}.second_model=${d.repr(raw)} not in ` +
|
|
108
|
+
`${d.sortedListRepr(SECOND_MODEL_PROVIDERS)} — the rung must be a provider whose ` +
|
|
109
|
+
`local CLI runs under subscription auth, so a resolution can never become ` +
|
|
110
|
+
`metered USD spend.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return raw;
|
|
114
|
+
}
|