@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.
Files changed (118) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +160 -333
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/evals/roadmap.json +8 -0
  5. package/dist/agent-src/commands/fix/ci.md +39 -1
  6. package/dist/agent-src/commands/pr/create.md +65 -4
  7. package/dist/agent-src/commands/roadmap/next.md +28 -5
  8. package/dist/agent-src/commands/work.md +16 -0
  9. package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
  10. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
  11. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
  12. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
  13. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
  14. package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
  15. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
  16. package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
  17. package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
  18. package/dist/agent-src/rules/ui-audit-gate.md +25 -8
  19. package/dist/agent-src/scripts/gate_execute.ts +395 -0
  20. package/dist/agent-src/scripts/resume_probe.ts +422 -0
  21. package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
  22. package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
  23. package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
  24. package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
  25. package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
  26. package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
  27. package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
  28. package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
  29. package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
  30. package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
  31. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  32. package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
  33. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  34. package/dist/agent-src/templates/roadmaps.md +43 -3
  35. package/dist/cli/registry.js +1 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
  38. package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
  39. package/dist/cli-delegate/cmd_conformance.js +224 -104
  40. package/dist/cli-delegate/cmd_doctor.js +2 -2
  41. package/dist/cli-delegate/cmd_handoff.js +1 -1
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +40 -18
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +6 -5
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +5 -4
  51. package/dist/hooks/dispatch.js +2018 -1320
  52. package/dist/mcp/registry-manifest.json +3 -3
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +1652 -892
  55. package/dist/router.json +1 -1
  56. package/dist/scripts/condense.js +1 -1
  57. package/dist/scripts/condense.js.map +1 -1
  58. package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
  59. package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/package.json +1 -1
  62. package/src/agent-src/scripts/gate_execute.ts +395 -0
  63. package/src/agent-src/scripts/resume_probe.ts +422 -0
  64. package/src/agent-src/scripts/roadmap_gates.ts +138 -4
  65. package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
  66. package/src/config/ci-local-parity.yml +13 -0
  67. package/src/config/evaluator-budgets.json +4 -4
  68. package/src/config/gate-coverage.yml +111 -5
  69. package/src/config/gate-violation-baselines.json +2 -2
  70. package/src/config/rule-activation-census.json +41 -0
  71. package/src/config/rule-stub-ceilings.json +52 -0
  72. package/src/scripts/_dispatch.bash +4 -0
  73. package/src/scripts/_lib/evaluator_contract.ts +159 -0
  74. package/src/scripts/_lib/gh_transient.ts +80 -0
  75. package/src/scripts/_lib/release_material.ts +16 -5
  76. package/src/scripts/_lib/review_independence.ts +111 -0
  77. package/src/scripts/_lib/session_register.ts +14 -0
  78. package/src/scripts/_lib/skill_catalogue.ts +20 -0
  79. package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
  80. package/src/scripts/_lib/turn_end_refusals.ts +482 -0
  81. package/src/scripts/ai_council/_py_json.ts +96 -0
  82. package/src/scripts/ai_council/cli_call_budget.ts +357 -0
  83. package/src/scripts/ai_council/clients.ts +80 -143
  84. package/src/scripts/ai_council/config.ts +25 -11
  85. package/src/scripts/ai_council/probe_store.ts +196 -0
  86. package/src/scripts/ai_council/qualification.ts +425 -0
  87. package/src/scripts/ai_council/qualification_wiring.ts +243 -0
  88. package/src/scripts/ai_council/quorum_wiring.ts +211 -0
  89. package/src/scripts/ai_team/team_dispatch.ts +40 -6
  90. package/src/scripts/capture_skill_catalogue.ts +264 -4
  91. package/src/scripts/check_evaluator_schema.ts +249 -0
  92. package/src/scripts/check_pr_ci_current.ts +409 -0
  93. package/src/scripts/check_review_schema.ts +195 -0
  94. package/src/scripts/check_rule_activation_census.ts +451 -0
  95. package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
  96. package/src/scripts/condense.ts +1 -1
  97. package/src/scripts/council_cli.ts +141 -191
  98. package/src/scripts/dispatch_r2_reviewer.ts +8 -1
  99. package/src/scripts/hook_manifest.yaml +72 -5
  100. package/src/scripts/hooks/concern_registry.ts +4 -0
  101. package/src/scripts/hooks/dispatch_hook.ts +43 -1
  102. package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
  103. package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
  104. package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
  105. package/src/scripts/hooks_doctor.ts +112 -0
  106. package/src/scripts/interruption_report.ts +443 -0
  107. package/src/scripts/lint_evidence_artifacts.ts +359 -0
  108. package/src/scripts/lint_roadmap_blockers.ts +65 -0
  109. package/src/scripts/lint_roadmap_complexity.ts +7 -1
  110. package/src/scripts/release.ts +38 -53
  111. package/src/scripts/rule_activation_census.ts +292 -0
  112. package/src/scripts/schemas/evaluator-output.schema.json +60 -0
  113. package/src/scripts/schemas/review-findings.schema.json +31 -0
  114. package/src/scripts/self_review_gate.ts +6 -0
  115. package/src/scripts/session_register_hook.ts +30 -1
  116. package/src/scripts/sync_pr_branch.ts +271 -0
  117. package/src/scripts/test_council_qualification.ts +213 -0
  118. 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
- /** Increment today's call count for `provider`. Returns new total. */
1060
- export function record_cli_call(provider: string, p: string | null = null): number {
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
- const counts = load_cli_call_counts(target);
1063
- counts[provider] = (counts[provider] ?? 0) + 1;
1064
- fs.mkdirSync(path.dirname(target), { recursive: true });
1065
- fs.writeFileSync(
1066
- target,
1067
- _jsonDumpsIndent2({ date: _today_utc_iso(), counts }),
1068
- { encoding: 'utf-8' },
1069
- );
1070
- return counts[provider];
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
- let counts = load_cli_call_counts(target);
1086
- if (provider === null) {
1087
- counts = {};
1088
- } else {
1089
- delete counts[provider];
1090
- }
1091
- fs.mkdirSync(path.dirname(target), { recursive: true });
1092
- fs.writeFileSync(
1093
- target,
1094
- _jsonDumpsIndent2({ date: _today_utc_iso(), counts }),
1095
- { encoding: 'utf-8' },
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
- const cliCallsPath = opts.cli_calls_path ?? null;
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