@event4u/agent-config 13.0.0 → 14.0.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 +160 -333
- package/README.md +1 -1
- package/dist/agent-src/commands/evals/roadmap.json +8 -0
- package/dist/agent-src/commands/fix/ci.md +39 -1
- package/dist/agent-src/commands/pr/create.md +65 -4
- package/dist/agent-src/commands/roadmap/next.md +28 -5
- package/dist/agent-src/commands/work.md +16 -0
- package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
- package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
- package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
- package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
- package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
- package/dist/agent-src/rules/ui-audit-gate.md +25 -8
- package/dist/agent-src/scripts/gate_execute.ts +395 -0
- package/dist/agent-src/scripts/resume_probe.ts +422 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
- package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
- package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
- package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
- package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
- package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
- package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
- package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
- package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
- package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +43 -3
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
- package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +224 -104
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +40 -18
- 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 +2018 -1320
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1652 -892
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +1 -1
- package/dist/scripts/condense.js.map +1 -1
- package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
- package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/gate_execute.ts +395 -0
- package/src/agent-src/scripts/resume_probe.ts +422 -0
- package/src/agent-src/scripts/roadmap_gates.ts +138 -4
- package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
- package/src/config/ci-local-parity.yml +13 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +111 -5
- package/src/config/gate-violation-baselines.json +2 -2
- package/src/config/rule-activation-census.json +41 -0
- package/src/config/rule-stub-ceilings.json +52 -0
- package/src/scripts/_dispatch.bash +4 -0
- package/src/scripts/_lib/evaluator_contract.ts +159 -0
- package/src/scripts/_lib/gh_transient.ts +80 -0
- package/src/scripts/_lib/release_material.ts +16 -5
- package/src/scripts/_lib/review_independence.ts +111 -0
- package/src/scripts/_lib/session_register.ts +14 -0
- package/src/scripts/_lib/skill_catalogue.ts +20 -0
- package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
- package/src/scripts/_lib/turn_end_refusals.ts +482 -0
- package/src/scripts/ai_council/_py_json.ts +96 -0
- package/src/scripts/ai_council/cli_call_budget.ts +357 -0
- package/src/scripts/ai_council/clients.ts +80 -143
- package/src/scripts/ai_council/config.ts +25 -11
- package/src/scripts/ai_council/probe_store.ts +196 -0
- package/src/scripts/ai_council/qualification.ts +425 -0
- package/src/scripts/ai_council/qualification_wiring.ts +243 -0
- package/src/scripts/ai_council/quorum_wiring.ts +211 -0
- package/src/scripts/ai_team/team_dispatch.ts +40 -6
- package/src/scripts/capture_skill_catalogue.ts +264 -4
- package/src/scripts/check_evaluator_schema.ts +249 -0
- package/src/scripts/check_pr_ci_current.ts +409 -0
- package/src/scripts/check_review_schema.ts +195 -0
- package/src/scripts/check_rule_activation_census.ts +451 -0
- package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
- package/src/scripts/condense.ts +1 -1
- package/src/scripts/council_cli.ts +141 -191
- package/src/scripts/dispatch_r2_reviewer.ts +8 -1
- package/src/scripts/hook_manifest.yaml +72 -5
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/dispatch_hook.ts +43 -1
- package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
- package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
- package/src/scripts/hooks_doctor.ts +112 -0
- package/src/scripts/interruption_report.ts +443 -0
- package/src/scripts/lint_evidence_artifacts.ts +359 -0
- package/src/scripts/lint_roadmap_blockers.ts +65 -0
- package/src/scripts/lint_roadmap_complexity.ts +7 -1
- package/src/scripts/release.ts +38 -53
- package/src/scripts/rule_activation_census.ts +292 -0
- package/src/scripts/schemas/evaluator-output.schema.json +60 -0
- package/src/scripts/schemas/review-findings.schema.json +31 -0
- package/src/scripts/self_review_gate.ts +6 -0
- package/src/scripts/session_register_hook.ts +30 -1
- package/src/scripts/sync_pr_branch.ts +271 -0
- package/src/scripts/test_council_qualification.ts +213 -0
- package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// Shared CLI-call budget: the daily counter's durability layer, its attribution
|
|
2
|
+
// sidecar, and the one authority that resolves a per-provider cap.
|
|
3
|
+
//
|
|
4
|
+
// Extracted from `clients.ts` and `config.ts` (road-to-council-quota-accounting-truth)
|
|
5
|
+
// because both are far past the 1500-line source-size ceiling, and a 3200-line
|
|
6
|
+
// client module has no business owning the persistence layer. The archived
|
|
7
|
+
// roadmap carries the full findings; this file states only what the code cannot.
|
|
8
|
+
//
|
|
9
|
+
// Dependency direction is deliberate and load-bearing: nothing here imports from
|
|
10
|
+
// `clients.ts` or `config.ts`. The Python-parity JSON dumper lives in
|
|
11
|
+
// `clients.ts` and a frozen byte-parity golden depends on it, so it is INJECTED
|
|
12
|
+
// as `serialize` rather than imported — importing it would make this module and
|
|
13
|
+
// `clients.ts` mutually dependent, and re-implementing it would fork the golden.
|
|
14
|
+
// Same reason `resolveCliCallCaps` takes the valid-provider set as an argument
|
|
15
|
+
// instead of reading it from `config.ts`.
|
|
16
|
+
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Attribution sidecar suffix, appended to the counter's own state path.
|
|
22
|
+
*
|
|
23
|
+
* A sidecar rather than a new key in `cli-calls.json`, for three reasons in
|
|
24
|
+
* order of weight: the gate reads the counter file, so a diagnostic write must
|
|
25
|
+
* not be able to corrupt gating; a failed attribution write is swallowed where a
|
|
26
|
+
* failed counter write is not; and the counter keeps its on-disk shape, so the
|
|
27
|
+
* frozen byte-parity golden stays meaningful.
|
|
28
|
+
*/
|
|
29
|
+
export const CLI_CALLS_ATTRIBUTION_SUFFIX = '.attribution.json';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Who booked a call. Enumerated because the consumer set is closed: exactly two
|
|
33
|
+
* files in the tree construct a `CliClient`.
|
|
34
|
+
*
|
|
35
|
+
* `unknown` is a FINDING when it appears — it means a third booking path exists
|
|
36
|
+
* that this enumeration does not know about. That is how the test suite was
|
|
37
|
+
* caught spending the operator's live budget.
|
|
38
|
+
*/
|
|
39
|
+
export const CLI_CONSUMER_COUNCIL = 'council';
|
|
40
|
+
export const CLI_CONSUMER_TEAM = 'team';
|
|
41
|
+
export const CLI_CONSUMER_UNKNOWN = 'unknown';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `metadata.quota_source` on a `cli_quota_exhausted` response. One error string,
|
|
45
|
+
* two events with OPPOSITE remedies:
|
|
46
|
+
*
|
|
47
|
+
* - `local_budget` — our counter refused before spawning. Nothing was sent or
|
|
48
|
+
* billed; the fix is ours (raise the cap, reset, or wait for UTC midnight).
|
|
49
|
+
* - `provider` — the vendor CLI refused us. A process ran and the call is booked;
|
|
50
|
+
* their window governs and we do not know it.
|
|
51
|
+
*/
|
|
52
|
+
export const QUOTA_SOURCE_LOCAL_BUDGET = 'local_budget';
|
|
53
|
+
export const QUOTA_SOURCE_PROVIDER = 'provider';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generous per-provider daily guard for `mode: cli` / `mode: auto` members.
|
|
57
|
+
* Sized as a guard against silent quota exhaustion on an always-on pass, not as
|
|
58
|
+
* a brake on normal use.
|
|
59
|
+
*/
|
|
60
|
+
export const DEFAULT_CLI_CALLS_PER_DAY = 50;
|
|
61
|
+
|
|
62
|
+
/** Attribution sidecar path beside a counter-state path. */
|
|
63
|
+
export function attributionPath(target: string): string {
|
|
64
|
+
return `${target}${CLI_CALLS_ATTRIBUTION_SUFFIX}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the per-provider daily cap map — the SINGLE authority for that
|
|
69
|
+
* question, used by both the gate and the report.
|
|
70
|
+
*
|
|
71
|
+
* Seeds every known provider with the default, then applies the caller's
|
|
72
|
+
* overrides. An absent, empty, or malformed mapping therefore yields the
|
|
73
|
+
* DEFAULTS, never "uncapped": omission is not a way to switch the guard off.
|
|
74
|
+
* That was the live defect — a raw `.ai-council.yml` dict with a commented-out
|
|
75
|
+
* `cli_call_budget:` block left the map empty, the per-member lookup fell back to
|
|
76
|
+
* `null`, and `ask()` reads `null` as uncapped while still booking.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately LENIENT where the strict config builder throws: it skips an
|
|
79
|
+
* unknown provider or a bad value instead of raising, because two consumers are
|
|
80
|
+
* reporting paths that must still print something useful for the providers that
|
|
81
|
+
* ARE valid. Validation runs first on every real config load, so a bad entry
|
|
82
|
+
* still fails loudly — it just does not take the quota report down with it.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveCliCallCaps(
|
|
85
|
+
raw: unknown,
|
|
86
|
+
validProviders: Iterable<string>,
|
|
87
|
+
): Record<string, number> {
|
|
88
|
+
const out: Record<string, number> = {};
|
|
89
|
+
for (const provider of validProviders) {
|
|
90
|
+
out[provider] = DEFAULT_CLI_CALLS_PER_DAY;
|
|
91
|
+
}
|
|
92
|
+
if (!_isObject(raw)) {
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
const known = new Set(Object.keys(out));
|
|
96
|
+
for (const [provider, value] of Object.entries(raw)) {
|
|
97
|
+
if (!known.has(provider)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
out[provider] = value;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Write counter state so a concurrent reader can never observe a partial file.
|
|
110
|
+
*
|
|
111
|
+
* The direct `writeFileSync` this replaces had a failure mode worse than the
|
|
112
|
+
* lost update it is usually described as: the counter's reader swallows a
|
|
113
|
+
* `JSON.parse` error and returns an empty map, so a reader landing mid-write sees
|
|
114
|
+
* ZERO calls used and the gate admits everything until the next successful write.
|
|
115
|
+
* One interleaved write could blank the whole budget.
|
|
116
|
+
*
|
|
117
|
+
* The temp file is created in the TARGET's own directory, not a system temp dir:
|
|
118
|
+
* rename is atomic within one filesystem, and staying beside the target is what
|
|
119
|
+
* keeps it there.
|
|
120
|
+
*/
|
|
121
|
+
export function writeStateAtomically(target: string, payload: string): void {
|
|
122
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
123
|
+
const tmp = `${target}.${process.pid}.${_monotonicish()}.tmp`;
|
|
124
|
+
try {
|
|
125
|
+
fs.writeFileSync(tmp, payload, { encoding: 'utf-8' });
|
|
126
|
+
fs.renameSync(tmp, target);
|
|
127
|
+
} catch (exc) {
|
|
128
|
+
try {
|
|
129
|
+
fs.unlinkSync(tmp);
|
|
130
|
+
} catch {
|
|
131
|
+
// Best-effort cleanup; the write error is the real signal.
|
|
132
|
+
}
|
|
133
|
+
throw exc;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Hold a bounded, best-effort cross-process lock around a read-modify-write.
|
|
139
|
+
*
|
|
140
|
+
* `O_EXCL` creation is atomic and is the only mutual-exclusion primitive
|
|
141
|
+
* available without a dependency. Three deliberate bounds:
|
|
142
|
+
*
|
|
143
|
+
* - **Bounded wait.** A council seat must never block on a lock; after the budget
|
|
144
|
+
* expires the update proceeds unlocked. Losing one increment is the
|
|
145
|
+
* pre-existing behaviour and beats stalling a dispatch.
|
|
146
|
+
* - **Stale-lock breaking.** A crashed holder must not wedge the counter forever.
|
|
147
|
+
* - **In-process calls need none of this** — the callers are synchronous and Node
|
|
148
|
+
* is single-threaded, so same-process bookings are already serialised. The
|
|
149
|
+
* window this closes is strictly cross-process: a council invocation and an
|
|
150
|
+
* `ai_team` invocation booking into one shared file.
|
|
151
|
+
*/
|
|
152
|
+
export function withStateLock<T>(target: string, fn: () => T): T {
|
|
153
|
+
const lockPath = `${target}.lock`;
|
|
154
|
+
const waitMs = 1000;
|
|
155
|
+
let held = false;
|
|
156
|
+
const started = _monotonicish();
|
|
157
|
+
while (_monotonicish() - started < waitMs) {
|
|
158
|
+
try {
|
|
159
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
160
|
+
fs.closeSync(fs.openSync(lockPath, 'wx'));
|
|
161
|
+
held = true;
|
|
162
|
+
break;
|
|
163
|
+
} catch {
|
|
164
|
+
try {
|
|
165
|
+
if (_monotonicish() - fs.statSync(lockPath).mtimeMs > waitMs) {
|
|
166
|
+
fs.unlinkSync(lockPath);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// Vanished between the failed create and the stat — retry.
|
|
171
|
+
}
|
|
172
|
+
_spin(20);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
return fn();
|
|
177
|
+
} finally {
|
|
178
|
+
if (held) {
|
|
179
|
+
try {
|
|
180
|
+
fs.unlinkSync(lockPath);
|
|
181
|
+
} catch {
|
|
182
|
+
// A removed lock is the desired end state either way.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Read today's `provider → consumer → count` attribution.
|
|
190
|
+
*
|
|
191
|
+
* Fail-soft by contract: an absent, unreadable, stale, or malformed sidecar reads
|
|
192
|
+
* as `{}`. Attribution that cannot be read is a lost diagnostic, never a gating
|
|
193
|
+
* decision — no caller may branch on it to admit or refuse a call.
|
|
194
|
+
*/
|
|
195
|
+
export function readAttribution(
|
|
196
|
+
target: string,
|
|
197
|
+
todayIso: string,
|
|
198
|
+
): Record<string, Record<string, number>> {
|
|
199
|
+
const p = attributionPath(target);
|
|
200
|
+
if (!fs.existsSync(p)) {
|
|
201
|
+
return {};
|
|
202
|
+
}
|
|
203
|
+
let data: unknown;
|
|
204
|
+
try {
|
|
205
|
+
data = JSON.parse(fs.readFileSync(p, { encoding: 'utf-8' }));
|
|
206
|
+
} catch {
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
if (!_isObject(data) || data['date'] !== todayIso) {
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
const raw = data['by_consumer'];
|
|
213
|
+
if (!_isObject(raw)) {
|
|
214
|
+
return {};
|
|
215
|
+
}
|
|
216
|
+
const out: Record<string, Record<string, number>> = {};
|
|
217
|
+
for (const [provider, perConsumer] of Object.entries(raw)) {
|
|
218
|
+
if (!_isObject(perConsumer)) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const bucket: Record<string, number> = {};
|
|
222
|
+
for (const [consumer, v] of Object.entries(perConsumer)) {
|
|
223
|
+
if (typeof v === 'number' && Number.isInteger(v)) {
|
|
224
|
+
bucket[consumer] = v;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
out[provider] = bucket;
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Record one booking in the attribution sidecar. Best-effort: a booking that
|
|
234
|
+
* cannot be attributed still counts. Call INSIDE the counter lock so the two
|
|
235
|
+
* files cannot interleave against each other.
|
|
236
|
+
*
|
|
237
|
+
* `serialize` is injected rather than imported — see the module header.
|
|
238
|
+
*/
|
|
239
|
+
export function recordAttribution(
|
|
240
|
+
target: string,
|
|
241
|
+
provider: string,
|
|
242
|
+
consumer: string,
|
|
243
|
+
todayIso: string,
|
|
244
|
+
serialize: (value: unknown) => string,
|
|
245
|
+
): void {
|
|
246
|
+
try {
|
|
247
|
+
const by_consumer = readAttribution(target, todayIso);
|
|
248
|
+
const bucket = by_consumer[provider] ?? {};
|
|
249
|
+
bucket[consumer] = (bucket[consumer] ?? 0) + 1;
|
|
250
|
+
by_consumer[provider] = bucket;
|
|
251
|
+
writeStateAtomically(attributionPath(target), serialize({ date: todayIso, by_consumer }));
|
|
252
|
+
} catch {
|
|
253
|
+
// Diagnostic only.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Clear attribution for one provider, or all of it. Follows the counter's own
|
|
259
|
+
* reset: stale attribution would credit spend to a bucket that no longer records
|
|
260
|
+
* it. Best-effort for the same reason as `recordAttribution`.
|
|
261
|
+
*/
|
|
262
|
+
export function resetAttribution(
|
|
263
|
+
target: string,
|
|
264
|
+
provider: string | null,
|
|
265
|
+
todayIso: string,
|
|
266
|
+
serialize: (value: unknown) => string,
|
|
267
|
+
): void {
|
|
268
|
+
try {
|
|
269
|
+
let by_consumer = readAttribution(target, todayIso);
|
|
270
|
+
if (provider === null) {
|
|
271
|
+
by_consumer = {};
|
|
272
|
+
} else {
|
|
273
|
+
delete by_consumer[provider];
|
|
274
|
+
}
|
|
275
|
+
writeStateAtomically(attributionPath(target), serialize({ date: todayIso, by_consumer }));
|
|
276
|
+
} catch {
|
|
277
|
+
// Diagnostic only.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The subset of a CLI client this module reads — structural, so no import. */
|
|
282
|
+
interface QuotaClientView {
|
|
283
|
+
readonly name?: unknown;
|
|
284
|
+
readonly max_calls_per_day?: unknown;
|
|
285
|
+
readonly warn_at?: unknown;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Format the pre-run quota summary from already-read counts.
|
|
290
|
+
*
|
|
291
|
+
* Returns `[summary, warnProviders]`, where `warnProviders` is the subset whose
|
|
292
|
+
* `used / cap` ratio crossed `warn_at`.
|
|
293
|
+
*
|
|
294
|
+
* Two silences that read to an operator as "within budget" are gone: a cap of `0`
|
|
295
|
+
* (the strictest setting available) used to be dropped by a Python-truthy filter,
|
|
296
|
+
* and no caps at all returned the empty string — in exactly the configuration
|
|
297
|
+
* where the shared counter runs unguarded. An omission is indistinguishable from
|
|
298
|
+
* a pass, and the counter reached 72/63/99 while nothing was printed.
|
|
299
|
+
*/
|
|
300
|
+
export function quotaSummaryLine(
|
|
301
|
+
clients: readonly QuotaClientView[],
|
|
302
|
+
counts: Record<string, number>,
|
|
303
|
+
): [string, string[]] {
|
|
304
|
+
// Any number is a cap, zero included; only an absent one is uncapped.
|
|
305
|
+
const capOf = (c: QuotaClientView): number | null =>
|
|
306
|
+
typeof c.max_calls_per_day === 'number' && Number.isFinite(c.max_calls_per_day)
|
|
307
|
+
? c.max_calls_per_day
|
|
308
|
+
: null;
|
|
309
|
+
const nameOf = (c: QuotaClientView): string =>
|
|
310
|
+
typeof c.name === 'string' && c.name !== '' ? c.name : '?';
|
|
311
|
+
const usedOf = (c: QuotaClientView): number => Math.trunc(counts[nameOf(c)] ?? 0);
|
|
312
|
+
|
|
313
|
+
if (clients.length === 0) {
|
|
314
|
+
return ['', []];
|
|
315
|
+
}
|
|
316
|
+
const parts: string[] = [];
|
|
317
|
+
const warn: string[] = [];
|
|
318
|
+
for (const c of clients) {
|
|
319
|
+
const limit = capOf(c);
|
|
320
|
+
const used = usedOf(c);
|
|
321
|
+
if (limit === null) {
|
|
322
|
+
// Named rather than omitted: real consumption, no protection.
|
|
323
|
+
parts.push(`${nameOf(c)} ${used}/uncapped`);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
parts.push(`${nameOf(c)} ${used}/${Math.trunc(limit)}`);
|
|
327
|
+
const warnAt = typeof c.warn_at === 'number' ? c.warn_at : 0.8;
|
|
328
|
+
// Limit 0 admits nothing, so any booked call is already past it — the
|
|
329
|
+
// ratio is undefined there and 0.0 would read as "0 % used".
|
|
330
|
+
if (limit === 0 ? used > 0 : used / limit >= warnAt) {
|
|
331
|
+
warn.push(nameOf(c));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const prefix = warn.length > 0 ? '⚠️ ' : '';
|
|
335
|
+
return [`${prefix}council:quota · ${parts.join(' · ')}`, warn];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function _isObject(x: unknown): x is Record<string, unknown> {
|
|
339
|
+
return typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Monotonic-ish millisecond clock for lock ages and temp-file names. */
|
|
343
|
+
function _monotonicish(): number {
|
|
344
|
+
return performance.now();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Synchronous back-off. Necessary rather than chosen: the whole booking path is
|
|
349
|
+
* synchronous, so making it async to await a sub-second contention window would
|
|
350
|
+
* change every caller.
|
|
351
|
+
*/
|
|
352
|
+
function _spin(ms: number): void {
|
|
353
|
+
const until = _monotonicish() + ms;
|
|
354
|
+
while (_monotonicish() < until) {
|
|
355
|
+
// deliberate busy-wait
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -78,6 +78,8 @@ export function _resetModelCeilingMemo(): void {
|
|
|
78
78
|
_modelCeilingMemo.clear();
|
|
79
79
|
}
|
|
80
80
|
import * as user_global_paths from '../_lib/user_global_paths.js';
|
|
81
|
+
import { jsonDumpsIndent2 as _jsonDumpsIndent2 } from './_py_json.js';
|
|
82
|
+
import * as budget from './cli_call_budget.js';
|
|
81
83
|
import { appendEvent } from './events_log.js';
|
|
82
84
|
|
|
83
85
|
export const ANTHROPIC_KEY_FILENAME = 'anthropic.key';
|
|
@@ -453,6 +455,12 @@ interface CliClientOptions {
|
|
|
453
455
|
max_calls_per_day?: number | null | undefined;
|
|
454
456
|
warn_at?: number | undefined;
|
|
455
457
|
cli_calls_path?: string | null | undefined;
|
|
458
|
+
/**
|
|
459
|
+
* Who is booking these calls — a `CLI_CONSUMER_*` constant, declared by the
|
|
460
|
+
* construction site because the client cannot know its caller. Omitted →
|
|
461
|
+
* `CLI_CONSUMER_UNKNOWN`, which is a finding rather than a default.
|
|
462
|
+
*/
|
|
463
|
+
consumer?: string | undefined;
|
|
456
464
|
}
|
|
457
465
|
|
|
458
466
|
/**
|
|
@@ -987,6 +995,17 @@ export class PerplexityClient extends _OpenAICompatibleClient {
|
|
|
987
995
|
|
|
988
996
|
export const CLI_CALLS_FILENAME = 'cli-calls.json';
|
|
989
997
|
|
|
998
|
+
// Durability, attribution and cap resolution live in `cli_call_budget.ts` — see
|
|
999
|
+
// its header for why the dependency runs one way only.
|
|
1000
|
+
export {
|
|
1001
|
+
CLI_CALLS_ATTRIBUTION_SUFFIX,
|
|
1002
|
+
CLI_CONSUMER_COUNCIL,
|
|
1003
|
+
CLI_CONSUMER_TEAM,
|
|
1004
|
+
CLI_CONSUMER_UNKNOWN,
|
|
1005
|
+
QUOTA_SOURCE_LOCAL_BUDGET,
|
|
1006
|
+
QUOTA_SOURCE_PROVIDER,
|
|
1007
|
+
} from './cli_call_budget.js';
|
|
1008
|
+
|
|
990
1009
|
// Default subprocess timeout (seconds) for a single CLI call. Long enough for
|
|
991
1010
|
// the largest frontier models to think; short enough to surface a hung
|
|
992
1011
|
// subprocess without freezing the council run.
|
|
@@ -1056,18 +1075,39 @@ export function load_cli_call_counts(p: string | null = null): Record<string, nu
|
|
|
1056
1075
|
return out;
|
|
1057
1076
|
}
|
|
1058
1077
|
|
|
1059
|
-
/**
|
|
1060
|
-
|
|
1078
|
+
/**
|
|
1079
|
+
* Return today's `provider → consumer → count` attribution. Fail-soft: an
|
|
1080
|
+
* absent, stale, or malformed sidecar reads as `{}` and never gates a call.
|
|
1081
|
+
*/
|
|
1082
|
+
export function load_cli_call_attribution(
|
|
1083
|
+
p: string | null = null,
|
|
1084
|
+
): Record<string, Record<string, number>> {
|
|
1061
1085
|
const target = p !== null ? p : _cliCallsStatePath();
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1086
|
+
return budget.readAttribution(target, _today_utc_iso());
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Increment today's call count for `provider`. Returns the new total.
|
|
1091
|
+
*
|
|
1092
|
+
* `consumer` names who booked it and lands in the sidecar, never in the counter
|
|
1093
|
+
* the gate reads. It defaults to `CLI_CONSUMER_UNKNOWN` so an undeclared call
|
|
1094
|
+
* path is visible as a finding rather than attributed to whoever happens to be
|
|
1095
|
+
* first in the enumeration.
|
|
1096
|
+
*/
|
|
1097
|
+
export function record_cli_call(
|
|
1098
|
+
provider: string,
|
|
1099
|
+
p: string | null = null,
|
|
1100
|
+
consumer: string = budget.CLI_CONSUMER_UNKNOWN,
|
|
1101
|
+
): number {
|
|
1102
|
+
const target = p !== null ? p : _cliCallsStatePath();
|
|
1103
|
+
const today = _today_utc_iso();
|
|
1104
|
+
return budget.withStateLock(target, () => {
|
|
1105
|
+
const counts = load_cli_call_counts(target);
|
|
1106
|
+
counts[provider] = (counts[provider] ?? 0) + 1;
|
|
1107
|
+
budget.writeStateAtomically(target, _jsonDumpsIndent2({ date: today, counts }));
|
|
1108
|
+
budget.recordAttribution(target, provider, consumer, today, _jsonDumpsIndent2);
|
|
1109
|
+
return counts[provider] as number;
|
|
1110
|
+
});
|
|
1071
1111
|
}
|
|
1072
1112
|
|
|
1073
1113
|
/**
|
|
@@ -1082,57 +1122,29 @@ export function reset_cli_call_counts(
|
|
|
1082
1122
|
p: string | null = null,
|
|
1083
1123
|
): Record<string, number> {
|
|
1084
1124
|
const target = p !== null ? p : _cliCallsStatePath();
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
counts =
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
target,
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
);
|
|
1097
|
-
return counts;
|
|
1125
|
+
const today = _today_utc_iso();
|
|
1126
|
+
return budget.withStateLock(target, () => {
|
|
1127
|
+
let counts = load_cli_call_counts(target);
|
|
1128
|
+
if (provider === null) {
|
|
1129
|
+
counts = {};
|
|
1130
|
+
} else {
|
|
1131
|
+
delete counts[provider];
|
|
1132
|
+
}
|
|
1133
|
+
budget.writeStateAtomically(target, _jsonDumpsIndent2({ date: today, counts }));
|
|
1134
|
+
budget.resetAttribution(target, provider, today, _jsonDumpsIndent2);
|
|
1135
|
+
return counts;
|
|
1136
|
+
});
|
|
1098
1137
|
}
|
|
1099
1138
|
|
|
1100
1139
|
/**
|
|
1101
|
-
* Build the pre-run quota summary line (step-8 P1, D1 + D4).
|
|
1102
|
-
*
|
|
1103
|
-
* Returns `[summary, warn_providers]` where `summary` is the formatted
|
|
1104
|
-
* one-liner (empty string when no CLI member has a configured cap) and
|
|
1105
|
-
* `warn_providers` is the subset whose `used / max_calls_per_day` ratio crossed
|
|
1106
|
-
* `warn_at`. Uncapped providers (`max_calls_per_day is None`) are omitted from
|
|
1107
|
-
* the summary entirely — they cannot exceed a threshold that does not exist.
|
|
1140
|
+
* Build the pre-run quota summary line (step-8 P1, D1 + D4). Formatting lives in
|
|
1141
|
+
* `cli_call_budget.ts`; this reads the counter and delegates.
|
|
1108
1142
|
*/
|
|
1109
1143
|
export function quota_summary_line(
|
|
1110
1144
|
clients: CliClient[],
|
|
1111
1145
|
opts: { cli_calls_path?: string | null } = {},
|
|
1112
1146
|
): [string, string[]] {
|
|
1113
|
-
|
|
1114
|
-
// Python: [c for c in clients if getattr(c, "max_calls_per_day", None)]
|
|
1115
|
-
// → truthy check (None or 0 both falsy).
|
|
1116
|
-
const capped = clients.filter((c) => _pyTruthy(_getattr(c, 'max_calls_per_day', null)));
|
|
1117
|
-
if (capped.length === 0) {
|
|
1118
|
-
return ['', []];
|
|
1119
|
-
}
|
|
1120
|
-
const counts = load_cli_call_counts(cliCallsPath);
|
|
1121
|
-
const parts: string[] = [];
|
|
1122
|
-
const warn: string[] = [];
|
|
1123
|
-
for (const c of capped) {
|
|
1124
|
-
const name = _getattr(c, 'name', '?') as string;
|
|
1125
|
-
const used = _pyInt(counts[name] ?? 0);
|
|
1126
|
-
const limit = _pyInt((c.max_calls_per_day as number));
|
|
1127
|
-
parts.push(`${name} ${used}/${limit}`);
|
|
1128
|
-
const ratio = limit > 0 ? used / limit : 0.0;
|
|
1129
|
-
const warnAt = Number(_getattr(c, 'warn_at', 0.8));
|
|
1130
|
-
if (ratio >= warnAt) {
|
|
1131
|
-
warn.push(name);
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
const prefix = warn.length > 0 ? '⚠️ ' : '';
|
|
1135
|
-
return [`${prefix}council:quota · ${parts.join(' · ')}`, warn];
|
|
1147
|
+
return budget.quotaSummaryLine(clients, load_cli_call_counts(opts.cli_calls_path ?? null));
|
|
1136
1148
|
}
|
|
1137
1149
|
|
|
1138
1150
|
/** Transport-seam result, shaped like a finished `subprocess.run`. */
|
|
@@ -1196,6 +1208,8 @@ export abstract class CliClient extends ExternalAIClient {
|
|
|
1196
1208
|
warn_at: number;
|
|
1197
1209
|
binary!: string;
|
|
1198
1210
|
protected _cli_calls_path: string | null;
|
|
1211
|
+
/** Attribution label for every booking this client makes. */
|
|
1212
|
+
protected _consumer: string;
|
|
1199
1213
|
|
|
1200
1214
|
static _AUTH_FAILURE_PATTERNS: readonly string[] = [
|
|
1201
1215
|
'authentication',
|
|
@@ -1239,6 +1253,7 @@ export abstract class CliClient extends ExternalAIClient {
|
|
|
1239
1253
|
this.max_calls_per_day = opts.max_calls_per_day ?? null;
|
|
1240
1254
|
this.warn_at = opts.warn_at ?? 0.8;
|
|
1241
1255
|
this._cli_calls_path = opts.cli_calls_path ?? null;
|
|
1256
|
+
this._consumer = opts.consumer ?? budget.CLI_CONSUMER_UNKNOWN;
|
|
1242
1257
|
const binary = opts.binary ?? null;
|
|
1243
1258
|
if (binary !== null) {
|
|
1244
1259
|
this.binary = binary;
|
|
@@ -1287,7 +1302,7 @@ export abstract class CliClient extends ExternalAIClient {
|
|
|
1287
1302
|
*/
|
|
1288
1303
|
protected _recordCallQuietly(): void {
|
|
1289
1304
|
try {
|
|
1290
|
-
record_cli_call(this.name, this._cli_calls_path);
|
|
1305
|
+
record_cli_call(this.name, this._cli_calls_path, this._consumer);
|
|
1291
1306
|
} catch {
|
|
1292
1307
|
// state-file write failure is non-fatal here.
|
|
1293
1308
|
}
|
|
@@ -1441,6 +1456,9 @@ export abstract class CliClient extends ExternalAIClient {
|
|
|
1441
1456
|
cli: true,
|
|
1442
1457
|
cli_calls_used: used,
|
|
1443
1458
|
cli_calls_max: this.max_calls_per_day,
|
|
1459
|
+
// Our counter refused before any spawn — nothing sent,
|
|
1460
|
+
// nothing booked for this call.
|
|
1461
|
+
quota_source: budget.QUOTA_SOURCE_LOCAL_BUDGET,
|
|
1444
1462
|
},
|
|
1445
1463
|
});
|
|
1446
1464
|
}
|
|
@@ -1549,6 +1567,13 @@ export abstract class CliClient extends ExternalAIClient {
|
|
|
1549
1567
|
cli: true,
|
|
1550
1568
|
returncode: proc.returncode,
|
|
1551
1569
|
stderr_tail: (proc.stderr || '').slice(-500),
|
|
1570
|
+
// The vendor refused us; the local-budget refusal above
|
|
1571
|
+
// returns before spawning. Opposite remedies, one error
|
|
1572
|
+
// string — so discriminate explicitly rather than leaving it
|
|
1573
|
+
// inferable from which metadata keys happen to be absent.
|
|
1574
|
+
...(code === 'cli_quota_exhausted'
|
|
1575
|
+
? { quota_source: budget.QUOTA_SOURCE_PROVIDER }
|
|
1576
|
+
: {}),
|
|
1552
1577
|
},
|
|
1553
1578
|
});
|
|
1554
1579
|
}
|
|
@@ -2719,94 +2744,6 @@ function _excMessage(exc: unknown): string {
|
|
|
2719
2744
|
return String(exc);
|
|
2720
2745
|
}
|
|
2721
2746
|
|
|
2722
|
-
/** `json.dumps(obj, indent=2)` — default ensure_ascii=True, insertion-order keys. */
|
|
2723
|
-
function _jsonDumpsIndent2(value: unknown): string {
|
|
2724
|
-
return _jsonDumpsIndented(value, 2, 0);
|
|
2725
|
-
}
|
|
2726
|
-
|
|
2727
|
-
function _jsonDumpsIndented(value: unknown, indent: number, level: number): string {
|
|
2728
|
-
if (value === null || value === undefined) {
|
|
2729
|
-
return 'null';
|
|
2730
|
-
}
|
|
2731
|
-
switch (typeof value) {
|
|
2732
|
-
case 'boolean':
|
|
2733
|
-
return value ? 'true' : 'false';
|
|
2734
|
-
case 'number':
|
|
2735
|
-
return _pyJsonNumber(value);
|
|
2736
|
-
case 'string':
|
|
2737
|
-
return _pyJsonStringAscii(value);
|
|
2738
|
-
case 'object':
|
|
2739
|
-
break;
|
|
2740
|
-
default:
|
|
2741
|
-
throw new TypeError(`Object of type ${typeof value} is not JSON serializable`);
|
|
2742
|
-
}
|
|
2743
|
-
const pad = ' '.repeat(indent * (level + 1));
|
|
2744
|
-
const closePad = ' '.repeat(indent * level);
|
|
2745
|
-
if (Array.isArray(value)) {
|
|
2746
|
-
if (value.length === 0) {
|
|
2747
|
-
return '[]';
|
|
2748
|
-
}
|
|
2749
|
-
const items = value.map((v) => pad + _jsonDumpsIndented(v, indent, level + 1));
|
|
2750
|
-
return `[\n${items.join(',\n')}\n${closePad}]`;
|
|
2751
|
-
}
|
|
2752
|
-
const obj = value as Record<string, unknown>;
|
|
2753
|
-
const keys = Object.keys(obj);
|
|
2754
|
-
if (keys.length === 0) {
|
|
2755
|
-
return '{}';
|
|
2756
|
-
}
|
|
2757
|
-
const items = keys.map(
|
|
2758
|
-
(k) => `${pad}${_pyJsonStringAscii(k)}: ${_jsonDumpsIndented(obj[k], indent, level + 1)}`,
|
|
2759
|
-
);
|
|
2760
|
-
return `{\n${items.join(',\n')}\n${closePad}}`;
|
|
2761
|
-
}
|
|
2762
|
-
|
|
2763
|
-
/** Render a number like Python `json.dumps` (int vs float; JS has one type). */
|
|
2764
|
-
function _pyJsonNumber(n: number): string {
|
|
2765
|
-
if (!Number.isFinite(n)) {
|
|
2766
|
-
if (Number.isNaN(n)) {
|
|
2767
|
-
return 'NaN';
|
|
2768
|
-
}
|
|
2769
|
-
return n > 0 ? 'Infinity' : '-Infinity';
|
|
2770
|
-
}
|
|
2771
|
-
return String(n);
|
|
2772
|
-
}
|
|
2773
|
-
|
|
2774
|
-
/** Escape a string like Python `json.dumps(..., ensure_ascii=True)` (default). */
|
|
2775
|
-
function _pyJsonStringAscii(s: string): string {
|
|
2776
|
-
let out = '"';
|
|
2777
|
-
for (const ch of s) {
|
|
2778
|
-
const code = ch.codePointAt(0) ?? 0;
|
|
2779
|
-
if (ch === '"') {
|
|
2780
|
-
out += '\\"';
|
|
2781
|
-
} else if (ch === '\\') {
|
|
2782
|
-
out += '\\\\';
|
|
2783
|
-
} else if (ch === '\b') {
|
|
2784
|
-
out += '\\b';
|
|
2785
|
-
} else if (ch === '\f') {
|
|
2786
|
-
out += '\\f';
|
|
2787
|
-
} else if (ch === '\n') {
|
|
2788
|
-
out += '\\n';
|
|
2789
|
-
} else if (ch === '\r') {
|
|
2790
|
-
out += '\\r';
|
|
2791
|
-
} else if (ch === '\t') {
|
|
2792
|
-
out += '\\t';
|
|
2793
|
-
} else if (code < 0x20) {
|
|
2794
|
-
out += `\\u${code.toString(16).padStart(4, '0')}`;
|
|
2795
|
-
} else if (code < 0x7f) {
|
|
2796
|
-
out += ch;
|
|
2797
|
-
} else if (code <= 0xffff) {
|
|
2798
|
-
out += `\\u${code.toString(16).padStart(4, '0')}`;
|
|
2799
|
-
} else {
|
|
2800
|
-
// Astral plane → surrogate pair, matching Python json.dumps default.
|
|
2801
|
-
const c = code - 0x10000;
|
|
2802
|
-
const hi = 0xd800 + (c >> 10);
|
|
2803
|
-
const lo = 0xdc00 + (c & 0x3ff);
|
|
2804
|
-
out += `\\u${hi.toString(16).padStart(4, '0')}\\u${lo.toString(16).padStart(4, '0')}`;
|
|
2805
|
-
}
|
|
2806
|
-
}
|
|
2807
|
-
return out + '"';
|
|
2808
|
-
}
|
|
2809
|
-
|
|
2810
2747
|
/** Python `shutil.which(name)` — resolve an executable on PATH. */
|
|
2811
2748
|
function _which(name: string): string | null {
|
|
2812
2749
|
// Absolute / relative path with a separator: check directly (Python's which
|