@event4u/agent-config 14.7.0 → 14.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +175 -390
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +34 -4
- package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
- package/dist/agent-src/commands/evals/roadmap.json +17 -1
- package/dist/agent-src/commands/pr/create.md +14 -2
- package/dist/agent-src/commands/pr/merge.md +383 -0
- package/dist/agent-src/commands/review/changes.md +95 -14
- package/dist/agent-src/commands/roadmap/process-full.md +157 -3
- package/dist/agent-src/commands/roadmap.md +1 -1
- package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +100 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
- package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
- package/dist/agent-src/rules/recurring-criticism.md +1 -1
- package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
- package/dist/agent-src/rules/source-confidentiality.md +1 -1
- package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
- package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
- package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
- package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
- package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
- package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
- package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
- package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
- package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
- package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
- package/dist/agent-src/skills/security/SKILL.md +8 -0
- package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +28 -2
- package/dist/cli/registry.js +3 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-77GR3VD4.js} +68 -43
- package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
- package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-CYH5SP6M.js} +4 -23
- package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
- package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
- package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
- package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
- package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-PLSG6UL5.js} +14 -14
- package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
- package/dist/cli-delegate/cmd_conformance.js +101 -25
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +7 -7
- package/dist/cli-delegate/cmd_export.js +6 -6
- package/dist/cli-delegate/cmd_handoff.js +7 -6
- package/dist/cli-delegate/cmd_memory_get.js +2 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_preflight.js +5 -5
- package/dist/cli-delegate/cmd_prune.js +6 -6
- package/dist/cli-delegate/cmd_route_audit.js +6 -5
- package/dist/cli-delegate/cmd_route_explain.js +3 -3
- package/dist/cli-delegate/cmd_session_recycle.js +3 -3
- package/dist/cli-delegate/cmd_settings_get.js +4 -4
- package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +6 -6
- package/dist/cli-delegate/cmd_uninstall.js +6 -6
- package/dist/cli-delegate/cmd_update.js +3 -3
- package/dist/cli-delegate/cmd_upgrade.js +3 -3
- package/dist/cli-delegate/cmd_validate.js +6 -6
- package/dist/cli-delegate/cmd_versions.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +83 -30
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +11 -9
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +9 -7
- package/dist/hooks/dispatch.js +1295 -812
- package/dist/install/globalRuleLayers.js +169 -0
- package/dist/install/globalRuleLayers.js.map +1 -0
- package/dist/install/partitionEligibility.js +156 -0
- package/dist/install/partitionEligibility.js.map +1 -1
- package/dist/install/ruleLayerPartition.js +157 -0
- package/dist/install/ruleLayerPartition.js.map +1 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1358 -805
- 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/prune_empty_dirs.js +85 -0
- package/dist/scripts/_lib/prune_empty_dirs.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/scripts/condense.js +52 -89
- package/dist/scripts/condense.js.map +1 -1
- 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/artifact-drafting-protocol-mechanics.md +45 -1
- package/docs/guidelines/agent-infra/false-green.md +47 -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/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
- package/src/agent-src/scripts/dashboard_mode.ts +212 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
- package/src/config/ci-local-parity.yml +21 -0
- package/src/config/estate-count-budget.json +13 -471
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +458 -12
- package/src/config/gate-violation-baselines.json +18 -8
- package/src/config/gitignore-block.txt +15 -2
- package/src/config/hook-latency-budget.json +5 -0
- package/src/config/publish-surface.json +60 -0
- package/src/config/requirements-trace-budget.json +32 -0
- package/src/config/rule-stub-ceilings.json +25 -4
- package/src/install/globalRuleLayers.ts +197 -0
- package/src/install/partitionEligibility.ts +177 -0
- package/src/install/ruleLayerPartition.ts +212 -0
- package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
- package/src/scripts/_dispatch.bash +18 -0
- package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
- package/src/scripts/_lib/adr_frontmatter.ts +459 -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/envelope_grounding.ts +11 -1
- package/src/scripts/_lib/host_capability.ts +59 -5
- package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
- package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
- 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/evidence_census.ts +899 -0
- package/src/scripts/adr/regenerate_index.ts +25 -38
- package/src/scripts/adr_cite_check.ts +540 -39
- package/src/scripts/ai_council/clients.ts +12 -1
- package/src/scripts/ai_council/config.ts +72 -115
- package/src/scripts/ai_council/fallback_config.ts +19 -7
- package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
- package/src/scripts/ai_council/orchestrator.ts +2 -2
- package/src/scripts/ai_council/py_format.ts +124 -0
- package/src/scripts/ai_council/transport_resolver.ts +65 -2
- package/src/scripts/audit_adr_coverage.ts +134 -21
- package/src/scripts/bench_hook_latency.ts +204 -0
- package/src/scripts/build_archive_index.ts +34 -0
- package/src/scripts/check_adr_frontmatter.ts +574 -39
- 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_hook_bundle_content.ts +219 -0
- package/src/scripts/check_new_adr_evidence.ts +529 -0
- package/src/scripts/check_no_stub_inventory_table.ts +292 -0
- package/src/scripts/check_pack_size.ts +234 -1
- package/src/scripts/check_publish_surface.ts +297 -0
- package/src/scripts/check_references.ts +18 -0
- package/src/scripts/check_requirements_trace.ts +378 -0
- package/src/scripts/check_rule_layer_partition.ts +531 -0
- package/src/scripts/check_secret_leak.ts +166 -12
- package/src/scripts/check_single_delivery.ts +137 -4
- package/src/scripts/check_standing_rule_delivery.ts +8 -2
- package/src/scripts/code_graph/cli.ts +44 -5
- package/src/scripts/code_graph/extract.ts +87 -0
- package/src/scripts/condense.ts +61 -91
- 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/external_sources_denylist.json +4 -1
- package/src/scripts/generate_capabilities_index.ts +41 -0
- package/src/scripts/git_authorization_hook.ts +54 -4
- package/src/scripts/hooks/block_unauthorized_git.ts +41 -3
- package/src/scripts/hooks/dispatch_hook.ts +154 -187
- package/src/scripts/hooks/dispatch_issues.ts +38 -19
- package/src/scripts/hooks/fallback_yaml.ts +66 -0
- package/src/scripts/hooks/host_semantics.ts +21 -0
- package/src/scripts/hooks/injection_budget.ts +13 -3
- package/src/scripts/hooks/py_json_dumps.ts +103 -0
- package/src/scripts/hooks/run_continuation_hook.ts +3 -1
- package/src/scripts/hooks/state_io.ts +88 -1
- package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
- package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
- package/src/scripts/injection_scan_hook.ts +257 -15
- package/src/scripts/lint_adr_sweep_routing.ts +365 -0
- package/src/scripts/lint_hook_manifest.ts +49 -0
- package/src/scripts/lint_provenance_vocabulary.ts +472 -4
- 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/routing_doctor.ts +5 -1
- package/src/scripts/run_supervise.ts +46 -1
- package/src/scripts/schemas/evals.schema.json +5 -0
- package/src/scripts/sync_pr_branch.ts +170 -20
- 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-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
- package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
- package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
|
@@ -148,7 +148,18 @@ export const CODEX_MEASURED_UNSERVABLE: ReadonlyMap<string, string> = new Map([
|
|
|
148
148
|
]);
|
|
149
149
|
|
|
150
150
|
export const DEFAULT_OPENAI_CLI_MODEL = OPENAI_CLI_VENDOR_DEFAULT;
|
|
151
|
-
|
|
151
|
+
// A vendor ALIAS, not a version. `claude --help` documents 'fable', 'opus' and
|
|
152
|
+
// 'sonnet' as "an alias for the latest model" in their band, so an alias cannot
|
|
153
|
+
// go stale the way a dated id can — which is the whole point of the change that
|
|
154
|
+
// put it here.
|
|
155
|
+
//
|
|
156
|
+
// It was `'claude-sonnet-4-5'`, and this line is the half the roadmap that
|
|
157
|
+
// found the defect did not know about: refreshing only the template's pin would
|
|
158
|
+
// have left every member that omits `model:` falling back to the SAME stale id
|
|
159
|
+
// from code. Read from the provider's own CLI surface on 2026-08-22, never from
|
|
160
|
+
// the agent's recall — the source that road-to-council-seat-selection's own
|
|
161
|
+
// Risk 1 declares inadmissible.
|
|
162
|
+
export const DEFAULT_ANTHROPIC_CLI_MODEL = 'sonnet';
|
|
152
163
|
export const DEFAULT_GEMINI_CLI_MODEL = 'gemini-2.5-pro';
|
|
153
164
|
|
|
154
165
|
// OpenAI-API-compatible endpoints. xAI and Perplexity both expose the
|
|
@@ -144,121 +144,6 @@ export class CouncilConfigError extends Error {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
// ── Python-format helpers (byte-faithful error messages) ───────────
|
|
148
|
-
|
|
149
|
-
/** Python `repr()` for a string scalar (single-quoted, escaped). */
|
|
150
|
-
function _pyReprStr(s: string): string {
|
|
151
|
-
const hasSingle = s.includes("'");
|
|
152
|
-
const hasDouble = s.includes('"');
|
|
153
|
-
const quote = hasSingle && !hasDouble ? '"' : "'";
|
|
154
|
-
let body = s
|
|
155
|
-
.replace(/\\/g, '\\\\')
|
|
156
|
-
.replace(/\n/g, '\\n')
|
|
157
|
-
.replace(/\r/g, '\\r')
|
|
158
|
-
.replace(/\t/g, '\\t');
|
|
159
|
-
if (quote === "'") {
|
|
160
|
-
body = body.replace(/'/g, "\\'");
|
|
161
|
-
} else {
|
|
162
|
-
body = body.replace(/"/g, '\\"');
|
|
163
|
-
}
|
|
164
|
-
return `${quote}${body}${quote}`;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Python `repr()` for a float value (shortest round-trip, `N.0` for ints). */
|
|
168
|
-
function _pyReprFloat(value: number): string {
|
|
169
|
-
if (Number.isInteger(value) && Number.isFinite(value)) {
|
|
170
|
-
return `${value}.0`;
|
|
171
|
-
}
|
|
172
|
-
if (value === Infinity) {
|
|
173
|
-
return 'inf';
|
|
174
|
-
}
|
|
175
|
-
if (value === -Infinity) {
|
|
176
|
-
return '-inf';
|
|
177
|
-
}
|
|
178
|
-
if (Number.isNaN(value)) {
|
|
179
|
-
return 'nan';
|
|
180
|
-
}
|
|
181
|
-
return String(value);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Python `repr()` for an arbitrary parsed value. Floats are tracked via
|
|
186
|
-
* `_FLOAT` so int-valued floats render `N.0`; bare numbers render as
|
|
187
|
-
* Python ints (no decimal). Mirrors `{value!r}` formatting.
|
|
188
|
-
*/
|
|
189
|
-
function _pyRepr(value: unknown): string {
|
|
190
|
-
if (value instanceof _Float) {
|
|
191
|
-
return _pyReprFloat(value.value);
|
|
192
|
-
}
|
|
193
|
-
if (value === null || value === undefined) {
|
|
194
|
-
return 'None';
|
|
195
|
-
}
|
|
196
|
-
if (typeof value === 'boolean') {
|
|
197
|
-
return value ? 'True' : 'False';
|
|
198
|
-
}
|
|
199
|
-
if (typeof value === 'string') {
|
|
200
|
-
return _pyReprStr(value);
|
|
201
|
-
}
|
|
202
|
-
if (typeof value === 'number') {
|
|
203
|
-
return String(value);
|
|
204
|
-
}
|
|
205
|
-
if (Array.isArray(value)) {
|
|
206
|
-
return `[${value.map((v) => _pyRepr(v)).join(', ')}]`;
|
|
207
|
-
}
|
|
208
|
-
if (typeof value === 'object') {
|
|
209
|
-
const parts: string[] = [];
|
|
210
|
-
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
211
|
-
parts.push(`${_pyReprStr(k)}: ${_pyRepr(v)}`);
|
|
212
|
-
}
|
|
213
|
-
return `{${parts.join(', ')}}`;
|
|
214
|
-
}
|
|
215
|
-
return String(value);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Wrapper marking a number that should `repr()` as a Python float. */
|
|
219
|
-
class _Float {
|
|
220
|
-
constructor(readonly value: number) {}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Mark `n` so `_pyRepr` renders it with a Python float repr (`N.0`). */
|
|
224
|
-
function _f(n: number): _Float {
|
|
225
|
-
return new _Float(n);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** Python `type(value).__name__`. */
|
|
229
|
-
function _pyTypeName(value: unknown): string {
|
|
230
|
-
if (value === null || value === undefined) {
|
|
231
|
-
return 'NoneType';
|
|
232
|
-
}
|
|
233
|
-
if (typeof value === 'boolean') {
|
|
234
|
-
return 'bool';
|
|
235
|
-
}
|
|
236
|
-
if (typeof value === 'number') {
|
|
237
|
-
return Number.isInteger(value) ? 'int' : 'float';
|
|
238
|
-
}
|
|
239
|
-
if (typeof value === 'string') {
|
|
240
|
-
return 'str';
|
|
241
|
-
}
|
|
242
|
-
if (Array.isArray(value)) {
|
|
243
|
-
return 'list';
|
|
244
|
-
}
|
|
245
|
-
if (typeof value === 'object') {
|
|
246
|
-
return 'dict';
|
|
247
|
-
}
|
|
248
|
-
return typeof value;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** Python `sorted(set_of_strings)` rendered as a list repr `['a', 'b']`. */
|
|
252
|
-
function _sortedListRepr(items: Iterable<string>): string {
|
|
253
|
-
const sorted = [...items].sort();
|
|
254
|
-
return `[${sorted.map((s) => _pyReprStr(s)).join(', ')}]`;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** Python `oct(mode)` → `0o600`-shaped string. */
|
|
258
|
-
function _pyOct(mode: number): string {
|
|
259
|
-
return `0o${mode.toString(8)}`;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
147
|
// ── Python int()/float() coercion + isinstance helpers ─────────────
|
|
263
148
|
|
|
264
149
|
/**
|
|
@@ -432,6 +317,19 @@ export interface MemberConfig {
|
|
|
432
317
|
* the falsification condition that gates ever enabling `'1h'`.
|
|
433
318
|
*/
|
|
434
319
|
readonly prompt_cache_ttl: '5m' | '1h';
|
|
320
|
+
/**
|
|
321
|
+
* Optional `YYYY-MM-DD` stamp: the date this member's `model:` pin was last
|
|
322
|
+
* checked against the provider's own surface. `null` when unset.
|
|
323
|
+
*
|
|
324
|
+
* Exists because a hard pin cannot notice its own age. The starter template
|
|
325
|
+
* shipped `claude-sonnet-4-5` on an enabled member with nothing in the file
|
|
326
|
+
* able to flag it — and `check_council_pin_staleness` reads exactly this
|
|
327
|
+
* field. A member on a vendor sentinel (`codex-default`, or an alias like
|
|
328
|
+
* `sonnet` that the provider documents as "the latest model") needs no
|
|
329
|
+
* stamp: it cannot go stale, which is why the gate exempts it rather than
|
|
330
|
+
* demanding a date nobody would refresh.
|
|
331
|
+
*/
|
|
332
|
+
readonly verified_at: string | null;
|
|
435
333
|
}
|
|
436
334
|
|
|
437
335
|
/**
|
|
@@ -635,6 +533,15 @@ export type QuorumMinPresent = number;
|
|
|
635
533
|
|
|
636
534
|
export type { FallbackConfig } from './fallback_config.js';
|
|
637
535
|
import { buildFallback, buildSecondModel, type FallbackConfig as _FallbackConfig } from './fallback_config.js';
|
|
536
|
+
import {
|
|
537
|
+
_f,
|
|
538
|
+
_pyOct,
|
|
539
|
+
_pyRepr,
|
|
540
|
+
_pyReprFloat,
|
|
541
|
+
_pyReprStr,
|
|
542
|
+
_pyTypeName,
|
|
543
|
+
_sortedListRepr,
|
|
544
|
+
} from './py_format.js';
|
|
638
545
|
|
|
639
546
|
export interface CouncilConfig {
|
|
640
547
|
readonly enabled: boolean;
|
|
@@ -1916,6 +1823,55 @@ function _build_member(
|
|
|
1916
1823
|
// untouched here) OR as a mapping carrying `ttl`. '5m' is the
|
|
1917
1824
|
// permanent default; see docs/contracts/ai-council-config.md
|
|
1918
1825
|
// § Prompt cache TTL for the falsification condition on '1h'.
|
|
1826
|
+
// A malformed date fails CLOSED. A stamp is a claim about when a human
|
|
1827
|
+
// last looked; accepting `2026-13-45` or `soon` would make the staleness
|
|
1828
|
+
// gate report on a string it cannot compare, which is worse than no stamp.
|
|
1829
|
+
const verified_at_raw = _get(cfg, 'verified_at', null);
|
|
1830
|
+
let verified_at: string | null = null;
|
|
1831
|
+
if (verified_at_raw !== null && verified_at_raw !== undefined) {
|
|
1832
|
+
// The stamp must be a QUOTED string, and the reason is a measured
|
|
1833
|
+
// fail-open rather than a style preference.
|
|
1834
|
+
//
|
|
1835
|
+
// YAML 1.1 resolves an unquoted `2026-08-22` to a Date. Accepting that
|
|
1836
|
+
// and normalising it back with `toISOString()` was the first
|
|
1837
|
+
// implementation, and it LAUNDERED impossible dates: the loader silently
|
|
1838
|
+
// rolls `2026-13-45` over to 2027-02-14, so the calendar check below saw
|
|
1839
|
+
// a valid date and the malformed input passed. Verified against a real
|
|
1840
|
+
// config, not reasoned about.
|
|
1841
|
+
//
|
|
1842
|
+
// A Date is therefore rejected with the fix in the message. That keeps
|
|
1843
|
+
// the impossible-date case failing CLOSED, which is the only direction
|
|
1844
|
+
// worth defending here: a stamp is a claim about attention, and a
|
|
1845
|
+
// silently corrected claim is worse than a rejected one.
|
|
1846
|
+
if (verified_at_raw instanceof Date) {
|
|
1847
|
+
throw new CouncilConfigError(
|
|
1848
|
+
`members.${name}.verified_at must be QUOTED — write ` +
|
|
1849
|
+
`verified_at: "YYYY-MM-DD". Unquoted, YAML parses it as a date and ` +
|
|
1850
|
+
`silently rolls impossible values over (2026-13-45 becomes 2027-02-14), ` +
|
|
1851
|
+
`which would let a malformed stamp through.`,
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
const coerced = verified_at_raw;
|
|
1855
|
+
if (typeof coerced !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(coerced)) {
|
|
1856
|
+
throw new CouncilConfigError(
|
|
1857
|
+
`members.${name}.verified_at must be a 'YYYY-MM-DD' string when set ` +
|
|
1858
|
+
`(got ${_pyRepr(verified_at_raw)}).`,
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
const [y, m, d] = coerced.split('-').map((x) => Number(x));
|
|
1862
|
+
const probe = new Date(Date.UTC(y as number, (m as number) - 1, d as number));
|
|
1863
|
+
if (
|
|
1864
|
+
probe.getUTCFullYear() !== y ||
|
|
1865
|
+
probe.getUTCMonth() + 1 !== m ||
|
|
1866
|
+
probe.getUTCDate() !== d
|
|
1867
|
+
) {
|
|
1868
|
+
throw new CouncilConfigError(
|
|
1869
|
+
`members.${name}.verified_at is not a real calendar date ` +
|
|
1870
|
+
`(got ${_pyRepr(verified_at_raw)}).`,
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
verified_at = coerced;
|
|
1874
|
+
}
|
|
1919
1875
|
const prompt_cache_raw = _get(cfg, 'prompt_cache', null);
|
|
1920
1876
|
let prompt_cache_ttl: '5m' | '1h' = '5m';
|
|
1921
1877
|
if (prompt_cache_raw !== null && prompt_cache_raw !== undefined && !_isBool(prompt_cache_raw)) {
|
|
@@ -1947,6 +1903,7 @@ function _build_member(
|
|
|
1947
1903
|
participate_low_impact: participate_raw,
|
|
1948
1904
|
tier,
|
|
1949
1905
|
prompt_cache_ttl,
|
|
1906
|
+
verified_at,
|
|
1950
1907
|
};
|
|
1951
1908
|
}
|
|
1952
1909
|
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* here; the rest is there rather than duplicated.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import type { ApiOnQuota } from './transport_resolver.js';
|
|
14
|
+
|
|
13
15
|
/**
|
|
14
16
|
* `fallback.*` — the mid-flight cli→api retry's one configurable knob.
|
|
15
17
|
*
|
|
@@ -24,8 +26,13 @@ export interface FallbackConfig {
|
|
|
24
26
|
* May an exhausted CLI quota retry on the metered api rung? Default
|
|
25
27
|
* `false`. The no-double-charge classes are always eligible and never
|
|
26
28
|
* consult this; `timeout` / 5xx are never eligible under any value.
|
|
29
|
+
*
|
|
30
|
+
* Three values, not two. `'ask'` defers the answer out of configuration
|
|
31
|
+
* time and into the round that would cross the cliff: the seat is PARKED
|
|
32
|
+
* unless a run-scoped billing grant says a human already agreed for this
|
|
33
|
+
* run. See `transport_resolver.ts` § `ApiOnQuota` and `_lib/billing_grant`.
|
|
27
34
|
*/
|
|
28
|
-
readonly api_on_quota:
|
|
35
|
+
readonly api_on_quota: ApiOnQuota;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
/**
|
|
@@ -67,12 +74,17 @@ export function buildFallback(raw: unknown, d: FallbackBuilderDeps): FallbackCon
|
|
|
67
74
|
if (!d.isDict(raw)) return { api_on_quota: false };
|
|
68
75
|
const v = raw['api_on_quota'];
|
|
69
76
|
if (v === undefined) return { api_on_quota: false };
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
if (d.isBool(v)) return { api_on_quota: v };
|
|
78
|
+
// `'ask'` is the ONLY string this accepts, and the narrowness is the
|
|
79
|
+
// point: the paragraph above refuses `"yes"` because an operator writing
|
|
80
|
+
// it is trying to authorise spend, and widening to "any truthy-looking
|
|
81
|
+
// string" to let `ask` through would readmit exactly that. A third named
|
|
82
|
+
// value is not a lenient parse.
|
|
83
|
+
if (d.isStr(v) && v === 'ask') return { api_on_quota: 'ask' };
|
|
84
|
+
throw d.error(
|
|
85
|
+
`\`fallback.api_on_quota\`=${d.repr(v)} must be \`false\`, \`true\` or \`'ask'\` ` +
|
|
86
|
+
`(got ${d.typeName(v)}).`,
|
|
87
|
+
);
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
/**
|
|
@@ -19,19 +19,28 @@
|
|
|
19
19
|
import type { ExternalAIClient, CouncilResponse } from './clients.js';
|
|
20
20
|
import {
|
|
21
21
|
classifyCliFailure,
|
|
22
|
+
effectiveApiOnQuota,
|
|
22
23
|
isFallbackEligibleUnder,
|
|
24
|
+
type ApiOnQuota,
|
|
23
25
|
type FallbackPolicy,
|
|
24
26
|
type MidFlightFallback,
|
|
25
27
|
} from './transport_resolver.js';
|
|
26
28
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
+
/**
|
|
30
|
+
* One escalation's outcome, for the event sink.
|
|
31
|
+
*
|
|
32
|
+
* `awaiting_grant` is the posture `'ask'` adds and it is neither of the two
|
|
33
|
+
* that existed: the seat was NOT switched to metered billing and it was NOT
|
|
34
|
+
* lost. It is held, and the round closes with a question instead of a silent
|
|
35
|
+
* charge or a silent absence.
|
|
36
|
+
*/
|
|
37
|
+
export type FallbackOutcome = 'retried' | 'no_twin' | 'cost_budget' | 'awaiting_grant';
|
|
29
38
|
|
|
30
39
|
export interface FallbackEvent {
|
|
31
40
|
readonly provider: string;
|
|
32
41
|
readonly failure: string;
|
|
33
42
|
readonly outcome: FallbackOutcome;
|
|
34
|
-
readonly api_on_quota:
|
|
43
|
+
readonly api_on_quota: ApiOnQuota;
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
export interface CliFallbackOptions {
|
|
@@ -42,7 +51,21 @@ export interface CliFallbackOptions {
|
|
|
42
51
|
*/
|
|
43
52
|
readonly construct: (provider: string) => ExternalAIClient | null;
|
|
44
53
|
/** `ai_council.fallback.api_on_quota` — see `FallbackPolicy.apiOnQuota`. */
|
|
45
|
-
readonly api_on_quota:
|
|
54
|
+
readonly api_on_quota: ApiOnQuota;
|
|
55
|
+
/**
|
|
56
|
+
* True when a run-scoped billing grant is in force for THIS run. Read only
|
|
57
|
+
* under `api_on_quota: 'ask'`; absence means park, never proceed.
|
|
58
|
+
*/
|
|
59
|
+
readonly billing_grant?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Providers parked under `'ask'`, appended during the round.
|
|
62
|
+
*
|
|
63
|
+
* It lives on the options object rather than in the CLI because the CLI
|
|
64
|
+
* has nothing to do with it: the factory that decides a seat is parked is
|
|
65
|
+
* the only thing that can populate it, and a caller threading an array
|
|
66
|
+
* down to that factory and back is plumbing with no reader in between.
|
|
67
|
+
*/
|
|
68
|
+
readonly parked?: string[];
|
|
46
69
|
/**
|
|
47
70
|
* Called once per ESTABLISHING escalation, never per substituted call.
|
|
48
71
|
*
|
|
@@ -60,6 +83,42 @@ export interface EstablishedTwin {
|
|
|
60
83
|
readonly original_error: string;
|
|
61
84
|
}
|
|
62
85
|
|
|
86
|
+
/**
|
|
87
|
+
* A seat held back because the policy is `'ask'` and no grant is in force.
|
|
88
|
+
*
|
|
89
|
+
* The third state the boolean could not express. Before `'ask'` existed a
|
|
90
|
+
* quota-hit seat had exactly two futures — a twin (metered, silent) or `null`
|
|
91
|
+
* (lost for the round). Neither is answerable afterwards: by the time a human
|
|
92
|
+
* reads the output, the spend has happened or the seat is gone. A parked seat
|
|
93
|
+
* is still in the round's seat map, so the question the round closes with is
|
|
94
|
+
* about work that can still be done.
|
|
95
|
+
*/
|
|
96
|
+
export interface ParkedSeat {
|
|
97
|
+
readonly parked: true;
|
|
98
|
+
readonly provider: string;
|
|
99
|
+
readonly reason: string;
|
|
100
|
+
readonly original_error: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `establishTwin`'s three outcomes: a twin, a parked seat, or nothing. */
|
|
104
|
+
export type EstablishOutcome = EstablishedTwin | ParkedSeat | null;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Narrow an `EstablishOutcome` to the twin branch.
|
|
108
|
+
*
|
|
109
|
+
* Every caller must use this rather than a truthiness test: a `ParkedSeat` is
|
|
110
|
+
* an object, so `if (twin)` retries the parked marker as though it were a
|
|
111
|
+
* client — the one bug the park branch exists to make impossible.
|
|
112
|
+
*/
|
|
113
|
+
export function isEstablishedTwin(o: EstablishOutcome): o is EstablishedTwin {
|
|
114
|
+
return o !== null && !('parked' in o);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Narrow an `EstablishOutcome` to the parked branch. */
|
|
118
|
+
export function isParkedSeat(o: EstablishOutcome): o is ParkedSeat {
|
|
119
|
+
return o !== null && 'parked' in o;
|
|
120
|
+
}
|
|
121
|
+
|
|
63
122
|
/**
|
|
64
123
|
* Why the twin map exists alongside the ledger.
|
|
65
124
|
*
|
|
@@ -99,10 +158,32 @@ export interface EstablishInput {
|
|
|
99
158
|
* budget then refused. An ineligible class emits nothing at all: there was no
|
|
100
159
|
* escalation to report.
|
|
101
160
|
*/
|
|
102
|
-
export function establishTwin(input: EstablishInput):
|
|
161
|
+
export function establishTwin(input: EstablishInput): EstablishOutcome {
|
|
103
162
|
const { member, error, fallback, ledger } = input;
|
|
104
163
|
const failure = classifyCliFailure(error);
|
|
105
|
-
const policy: FallbackPolicy = {
|
|
164
|
+
const policy: FallbackPolicy = {
|
|
165
|
+
apiOnQuota: fallback.api_on_quota,
|
|
166
|
+
billingGrant: fallback.billing_grant === true,
|
|
167
|
+
};
|
|
168
|
+
// The park branch runs BEFORE the eligibility test, and the order is the
|
|
169
|
+
// whole mechanism. `effectiveApiOnQuota` collapses an ungranted `'ask'`
|
|
170
|
+
// to `false`, so an eligibility-first reading would return `null` here —
|
|
171
|
+
// the seat lost, exactly as under `api_on_quota: false`, and the operator
|
|
172
|
+
// never asked. The ledger is deliberately NOT consulted: parking spends no
|
|
173
|
+
// fallback attempt, so the seat is still escalatable on the grant.
|
|
174
|
+
if (
|
|
175
|
+
fallback.api_on_quota === 'ask' &&
|
|
176
|
+
failure === 'quota_exhausted' &&
|
|
177
|
+
!effectiveApiOnQuota(policy)
|
|
178
|
+
) {
|
|
179
|
+
fallback.on_event?.({
|
|
180
|
+
provider: member.name,
|
|
181
|
+
failure,
|
|
182
|
+
outcome: 'awaiting_grant',
|
|
183
|
+
api_on_quota: fallback.api_on_quota,
|
|
184
|
+
});
|
|
185
|
+
return { parked: true, provider: member.name, reason: failure, original_error: error };
|
|
186
|
+
}
|
|
106
187
|
if (!isFallbackEligibleUnder(failure, policy)) return null;
|
|
107
188
|
if (ledger.attempt(member.name, failure, policy) !== 'api') return null;
|
|
108
189
|
|
|
@@ -241,7 +322,7 @@ export function escalateUnmetered(
|
|
|
241
322
|
fallback,
|
|
242
323
|
ledger,
|
|
243
324
|
});
|
|
244
|
-
if (twin
|
|
325
|
+
if (!isEstablishedTwin(twin)) return null;
|
|
245
326
|
// NOTHING is emitted here, and the previous comment justifying an emit at
|
|
246
327
|
// establishment is deleted rather than reworded because it stopped being
|
|
247
328
|
// true. It read: "on this path the twin goes through the round's ORDINARY
|
|
@@ -75,7 +75,7 @@ import {
|
|
|
75
75
|
emitOutcome,
|
|
76
76
|
escalateUnmetered,
|
|
77
77
|
refuseUnmeteredEscalation,
|
|
78
|
-
establishTwin,
|
|
78
|
+
establishTwin, isEstablishedTwin,
|
|
79
79
|
isFailedCliCall,
|
|
80
80
|
runGatedRetry,
|
|
81
81
|
stampFallback,
|
|
@@ -764,7 +764,7 @@ function _run_round(
|
|
|
764
764
|
fallback,
|
|
765
765
|
ledger,
|
|
766
766
|
});
|
|
767
|
-
if (twin
|
|
767
|
+
if (isEstablishedTwin(twin)) {
|
|
768
768
|
const out = runGatedRetry(response, member, twin, fallback, {
|
|
769
769
|
gate: (client) =>
|
|
770
770
|
table === null ||
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python-format helpers, extracted from `config.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Every error string this package emits is byte-faithful to the Python original
|
|
5
|
+
* it replaced, which is why `repr()`, `type().__name__` and `oct()` are
|
|
6
|
+
* reimplemented here instead of being approximated with template literals.
|
|
7
|
+
*
|
|
8
|
+
* Pure and self-contained: these functions call only each other, which is what
|
|
9
|
+
* made the section safe to move out whole. Extracted because config.ts is
|
|
10
|
+
* hundreds of lines above the 1500-line source ceiling and a change adding to it
|
|
11
|
+
* has to pay for the lines somewhere.
|
|
12
|
+
*/
|
|
13
|
+
/** Python `repr()` for a string scalar (single-quoted, escaped). */
|
|
14
|
+
export function _pyReprStr(s: string): string {
|
|
15
|
+
const hasSingle = s.includes("'");
|
|
16
|
+
const hasDouble = s.includes('"');
|
|
17
|
+
const quote = hasSingle && !hasDouble ? '"' : "'";
|
|
18
|
+
let body = s
|
|
19
|
+
.replace(/\\/g, '\\\\')
|
|
20
|
+
.replace(/\n/g, '\\n')
|
|
21
|
+
.replace(/\r/g, '\\r')
|
|
22
|
+
.replace(/\t/g, '\\t');
|
|
23
|
+
if (quote === "'") {
|
|
24
|
+
body = body.replace(/'/g, "\\'");
|
|
25
|
+
} else {
|
|
26
|
+
body = body.replace(/"/g, '\\"');
|
|
27
|
+
}
|
|
28
|
+
return `${quote}${body}${quote}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Python `repr()` for a float value (shortest round-trip, `N.0` for ints). */
|
|
32
|
+
export function _pyReprFloat(value: number): string {
|
|
33
|
+
if (Number.isInteger(value) && Number.isFinite(value)) {
|
|
34
|
+
return `${value}.0`;
|
|
35
|
+
}
|
|
36
|
+
if (value === Infinity) {
|
|
37
|
+
return 'inf';
|
|
38
|
+
}
|
|
39
|
+
if (value === -Infinity) {
|
|
40
|
+
return '-inf';
|
|
41
|
+
}
|
|
42
|
+
if (Number.isNaN(value)) {
|
|
43
|
+
return 'nan';
|
|
44
|
+
}
|
|
45
|
+
return String(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Python `repr()` for an arbitrary parsed value. Floats are tracked via
|
|
50
|
+
* `_FLOAT` so int-valued floats render `N.0`; bare numbers render as
|
|
51
|
+
* Python ints (no decimal). Mirrors `{value!r}` formatting.
|
|
52
|
+
*/
|
|
53
|
+
export function _pyRepr(value: unknown): string {
|
|
54
|
+
if (value instanceof _Float) {
|
|
55
|
+
return _pyReprFloat(value.value);
|
|
56
|
+
}
|
|
57
|
+
if (value === null || value === undefined) {
|
|
58
|
+
return 'None';
|
|
59
|
+
}
|
|
60
|
+
if (typeof value === 'boolean') {
|
|
61
|
+
return value ? 'True' : 'False';
|
|
62
|
+
}
|
|
63
|
+
if (typeof value === 'string') {
|
|
64
|
+
return _pyReprStr(value);
|
|
65
|
+
}
|
|
66
|
+
if (typeof value === 'number') {
|
|
67
|
+
return String(value);
|
|
68
|
+
}
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
return `[${value.map((v) => _pyRepr(v)).join(', ')}]`;
|
|
71
|
+
}
|
|
72
|
+
if (typeof value === 'object') {
|
|
73
|
+
const parts: string[] = [];
|
|
74
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
75
|
+
parts.push(`${_pyReprStr(k)}: ${_pyRepr(v)}`);
|
|
76
|
+
}
|
|
77
|
+
return `{${parts.join(', ')}}`;
|
|
78
|
+
}
|
|
79
|
+
return String(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Wrapper marking a number that should `repr()` as a Python float. */
|
|
83
|
+
export class _Float {
|
|
84
|
+
constructor(readonly value: number) {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Mark `n` so `_pyRepr` renders it with a Python float repr (`N.0`). */
|
|
88
|
+
export function _f(n: number): _Float {
|
|
89
|
+
return new _Float(n);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Python `type(value).__name__`. */
|
|
93
|
+
export function _pyTypeName(value: unknown): string {
|
|
94
|
+
if (value === null || value === undefined) {
|
|
95
|
+
return 'NoneType';
|
|
96
|
+
}
|
|
97
|
+
if (typeof value === 'boolean') {
|
|
98
|
+
return 'bool';
|
|
99
|
+
}
|
|
100
|
+
if (typeof value === 'number') {
|
|
101
|
+
return Number.isInteger(value) ? 'int' : 'float';
|
|
102
|
+
}
|
|
103
|
+
if (typeof value === 'string') {
|
|
104
|
+
return 'str';
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(value)) {
|
|
107
|
+
return 'list';
|
|
108
|
+
}
|
|
109
|
+
if (typeof value === 'object') {
|
|
110
|
+
return 'dict';
|
|
111
|
+
}
|
|
112
|
+
return typeof value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Python `sorted(set_of_strings)` rendered as a list repr `['a', 'b']`. */
|
|
116
|
+
export function _sortedListRepr(items: Iterable<string>): string {
|
|
117
|
+
const sorted = [...items].sort();
|
|
118
|
+
return `[${sorted.map((s) => _pyReprStr(s)).join(', ')}]`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Python `oct(mode)` → `0o600`-shaped string. */
|
|
122
|
+
export function _pyOct(mode: number): string {
|
|
123
|
+
return `0o${mode.toString(8)}`;
|
|
124
|
+
}
|
|
@@ -403,13 +403,76 @@ const FALLBACK_ELIGIBLE: ReadonlySet<CliFailureClass> = new Set([
|
|
|
403
403
|
* `auto` may infer — the USD spend itself remains guarded by the ordinary
|
|
404
404
|
* `cost_budget` gates the orchestrator runs on the retry.
|
|
405
405
|
*/
|
|
406
|
+
/**
|
|
407
|
+
* The three postures of `ai_council.fallback.api_on_quota`.
|
|
408
|
+
*
|
|
409
|
+
* `false` and `true` mean what they always meant. `'ask'` is the third: the
|
|
410
|
+
* billing-class decision is deferred out of configuration time and into the
|
|
411
|
+
* round that would actually cross the cliff, where the remaining scope is
|
|
412
|
+
* known. Under `'ask'` the seat is eligible only when a run-scoped billing
|
|
413
|
+
* grant is present — a human said yes for THIS run — and is parked otherwise.
|
|
414
|
+
*/
|
|
415
|
+
export type ApiOnQuota = boolean | 'ask';
|
|
416
|
+
|
|
406
417
|
export interface FallbackPolicy {
|
|
407
|
-
readonly apiOnQuota:
|
|
418
|
+
readonly apiOnQuota: ApiOnQuota;
|
|
419
|
+
/**
|
|
420
|
+
* True when a run-scoped billing grant is in force. Only consulted under
|
|
421
|
+
* `apiOnQuota: 'ask'`; absent is never read as granted, which is why this
|
|
422
|
+
* is optional rather than defaulted anywhere.
|
|
423
|
+
*/
|
|
424
|
+
readonly billingGrant?: boolean;
|
|
408
425
|
}
|
|
409
426
|
|
|
410
427
|
/** The base policy: exactly the three-plus-one no-double-charge classes. */
|
|
411
428
|
export const DEFAULT_FALLBACK_POLICY: FallbackPolicy = { apiOnQuota: false };
|
|
412
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Collapse the tri-state plus the grant into the boolean the eligibility rule
|
|
432
|
+
* needs. `'ask'` without a grant is `false` — never `true` by omission, which
|
|
433
|
+
* is the one direction that would turn a configured question into silent
|
|
434
|
+
* metered spend.
|
|
435
|
+
*/
|
|
436
|
+
export function effectiveApiOnQuota(policy: FallbackPolicy): boolean {
|
|
437
|
+
if (policy.apiOnQuota === 'ask') return policy.billingGrant === true;
|
|
438
|
+
return policy.apiOnQuota === true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Parse a config value into the tri-state, or throw.
|
|
443
|
+
*
|
|
444
|
+
* Rejecting is the point. A parser that mapped an unknown value onto a truthy
|
|
445
|
+
* default would turn a configured `off` into metered spend — the exact failure
|
|
446
|
+
* the `'ask'` posture exists to prevent, introduced by the code that adds it.
|
|
447
|
+
* Accepts the Python-ish spellings the council config has always allowed on
|
|
448
|
+
* the boolean side, plus `ask` on the new one.
|
|
449
|
+
*/
|
|
450
|
+
export function apiOnQuotaView(raw: unknown): ApiOnQuota {
|
|
451
|
+
// Lenient by design, unlike `parseApiOnQuota`: this feeds a DISPLAY
|
|
452
|
+
// surface, where throwing on a malformed file would blank the wizard
|
|
453
|
+
// instead of showing the operator the setting they need to fix. Reading
|
|
454
|
+
// by value rather than by truthiness is the half that still matters —
|
|
455
|
+
// `=== true` alone renders `'ask'` as `false`, and the next save
|
|
456
|
+
// downgrades a configured question to a configured no.
|
|
457
|
+
return raw === 'ask' ? 'ask' : raw === true;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function parseApiOnQuota(raw: unknown): ApiOnQuota {
|
|
461
|
+
if (typeof raw === 'boolean') return raw;
|
|
462
|
+
if (raw === undefined || raw === null) return false;
|
|
463
|
+
if (typeof raw === 'string') {
|
|
464
|
+
const s = raw.trim().toLowerCase();
|
|
465
|
+
if (s === 'ask') return 'ask';
|
|
466
|
+
if (s === 'true' || s === 'yes' || s === 'on' || s === '1') return true;
|
|
467
|
+
if (s === '' || s === 'false' || s === 'no' || s === 'off' || s === '0') {
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
throw new Error(
|
|
472
|
+
`ai_council.fallback.api_on_quota: expected false | true | 'ask', got ${JSON.stringify(raw)}`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
413
476
|
/** Normalise a raw client error / skip reason into a `CliFailureClass`. */
|
|
414
477
|
export function classifyCliFailure(raw: string): CliFailureClass {
|
|
415
478
|
const s = raw.trim().toLowerCase();
|
|
@@ -464,7 +527,7 @@ export function isFallbackEligibleUnder(
|
|
|
464
527
|
policy: FallbackPolicy,
|
|
465
528
|
): boolean {
|
|
466
529
|
if (FALLBACK_ELIGIBLE.has(failure)) return true;
|
|
467
|
-
return policy
|
|
530
|
+
return effectiveApiOnQuota(policy) && failure === 'quota_exhausted';
|
|
468
531
|
}
|
|
469
532
|
|
|
470
533
|
/**
|