@event4u/agent-config 17.0.0 → 17.1.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 (119) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +104 -485
  5. package/README.md +2 -1
  6. package/dist/agent-src/contexts/execution/host-capability-manifest.md +5 -3
  7. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  8. package/dist/agent-src/templates/roadmaps.md +5 -1
  9. package/dist/cli-delegate/{chunk-IROAQ77A.js → chunk-3PK3VZHB.js} +107 -9
  10. package/dist/cli-delegate/{chunk-2SFOOFJJ.js → chunk-BZJ4Y3CI.js} +1 -1
  11. package/dist/cli-delegate/{chunk-CAL76ADI.js → chunk-CMZEKSNN.js} +52 -10
  12. package/dist/cli-delegate/{chunk-ESUNQ5XR.js → chunk-E3CFULNX.js} +22 -4
  13. package/dist/cli-delegate/{chunk-GOMILC3O.js → chunk-QBHEI5N4.js} +4 -3
  14. package/dist/cli-delegate/{chunk-SIOMDAKL.js → chunk-VYZGJLCA.js} +29 -39
  15. package/dist/cli-delegate/chunk-W5JXMF2T.js +77 -0
  16. package/dist/cli-delegate/{chunk-4SHNERBL.js → chunk-XIAPY44N.js} +1 -1
  17. package/dist/cli-delegate/{chunk-JHMMWNJ2.js → chunk-YHAPONWV.js} +1 -1
  18. package/dist/cli-delegate/cmd_conformance.js +9 -8
  19. package/dist/cli-delegate/cmd_doctor.js +4 -4
  20. package/dist/cli-delegate/cmd_doctor_neighbours.js +2 -2
  21. package/dist/cli-delegate/cmd_explain.js +1 -1
  22. package/dist/cli-delegate/cmd_export.js +6 -6
  23. package/dist/cli-delegate/cmd_fleet.js +3 -3
  24. package/dist/cli-delegate/cmd_preflight.js +3 -3
  25. package/dist/cli-delegate/cmd_prune.js +6 -6
  26. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  27. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  28. package/dist/cli-delegate/cmd_sync.js +6 -6
  29. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  30. package/dist/cli-delegate/cmd_update.js +9 -13
  31. package/dist/cli-delegate/cmd_validate.js +6 -6
  32. package/dist/discovery/deprecation-report.md +1 -1
  33. package/dist/discovery/discovery-manifest.json +4 -4
  34. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  35. package/dist/discovery/discovery-manifest.summary.md +1 -1
  36. package/dist/discovery/dormancy-report.md +1 -1
  37. package/dist/discovery/orphan-report.md +1 -1
  38. package/dist/discovery/packs.json +2 -2
  39. package/dist/discovery/trust-report.md +1 -1
  40. package/dist/discovery/workspaces.json +2 -2
  41. package/dist/hooks/dispatch.js +98 -74
  42. package/dist/hooks/dispatch.sha256 +1 -1
  43. package/dist/install/claudeRuleRewrite.js +10 -3
  44. package/dist/install/conflict.js +24 -2
  45. package/dist/install/conflictTracker.js +14 -3
  46. package/dist/install/install.mjs +222 -117
  47. package/dist/install/installThinLayer.js +1 -0
  48. package/dist/install/loggedWrites.js +142 -0
  49. package/dist/install/paths.js +12 -2
  50. package/dist/install/preserve.js +25 -4
  51. package/dist/install/txlog.js +10 -6
  52. package/dist/mcp/registry-manifest.json +2 -2
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +845 -294
  55. package/dist/scripts/_lib/egress_rules.js +4 -1
  56. package/dist/scripts/_lib/identifier_redaction.js +35 -2
  57. package/dist/scripts/ai_council/seat_policy.js +7 -1
  58. package/dist/server/schemas/settings.js +1 -1
  59. package/package.json +1 -1
  60. package/src/config/ci-local-parity.yml +1 -1
  61. package/src/config/gate-coverage.yml +27 -7
  62. package/src/config/gate-violation-baselines.json +8 -2
  63. package/src/config/hook-bundle-budget.json +2 -2
  64. package/src/config/production-entries.json +18 -0
  65. package/src/config/release-gate-locality.yml +0 -7
  66. package/src/install/claudeRuleRewrite.ts +10 -1
  67. package/src/install/conflict.ts +24 -1
  68. package/src/install/conflictTracker.ts +18 -2
  69. package/src/install/installThinLayer.ts +6 -0
  70. package/src/install/loggedWrites.ts +148 -0
  71. package/src/install/paths.ts +11 -2
  72. package/src/install/preserve.ts +40 -4
  73. package/src/install/txlog.ts +10 -6
  74. package/src/scripts/_cli/cmd_conformance.ts +3 -2
  75. package/src/scripts/_cli/cmd_update.ts +15 -17
  76. package/src/scripts/_lib/council_settings_block.ts +9 -2
  77. package/src/scripts/_lib/egress_rules.ts +4 -1
  78. package/src/scripts/_lib/host_capability.ts +10 -5
  79. package/src/scripts/_lib/identifier_redaction.ts +38 -2
  80. package/src/scripts/_lib/price_freshness.ts +112 -0
  81. package/src/scripts/_lib/production_reach.ts +194 -0
  82. package/src/scripts/_lib/quota_parked.ts +17 -28
  83. package/src/scripts/_lib/release_findings_ingest.ts +13 -335
  84. package/src/scripts/_lib/tier_downshift_verdict.ts +81 -35
  85. package/src/scripts/ai_council/chairman.ts +5 -2
  86. package/src/scripts/ai_council/clients.ts +4 -3
  87. package/src/scripts/ai_council/free_hand_shadow.ts +24 -12
  88. package/src/scripts/ai_council/free_route_catalog.ts +142 -5
  89. package/src/scripts/ai_council/free_route_client.ts +4 -0
  90. package/src/scripts/ai_council/hand_payload.ts +115 -7
  91. package/src/scripts/ai_council/orchestrator.ts +7 -10
  92. package/src/scripts/ai_council/route_chooser.ts +1 -1
  93. package/src/scripts/ai_council/route_member.ts +66 -4
  94. package/src/scripts/ai_council/seat_policy.ts +8 -1
  95. package/src/scripts/ai_council/self_hosted_probe.ts +327 -0
  96. package/src/scripts/ai_council/status_surface.ts +2 -0
  97. package/src/scripts/ask_transport.ts +137 -15
  98. package/src/scripts/check_finding_dispositions.ts +25 -71
  99. package/src/scripts/check_release_pr_shape.ts +3 -3
  100. package/src/scripts/check_step_production_reach.ts +257 -0
  101. package/src/scripts/council_cli.ts +10 -8
  102. package/src/scripts/hook_manifest.json +1 -1
  103. package/src/scripts/hook_manifest.yaml +3 -0
  104. package/src/scripts/hooks/dispatch_hook.ts +2 -2
  105. package/src/scripts/hooks/payload_stub.ts +27 -0
  106. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +29 -70
  107. package/src/scripts/hooks/subagent_ledger_hook.ts +80 -0
  108. package/src/scripts/install.ts +2 -2
  109. package/src/scripts/lint_decision_classes.ts +34 -0
  110. package/src/scripts/lint_hook_manifest.ts +24 -0
  111. package/src/scripts/lint_roadmap_blockers.ts +52 -3
  112. package/src/scripts/release.ts +5 -9
  113. package/src/scripts/release_drill.ts +11 -165
  114. package/src/scripts/release_publication.ts +0 -9
  115. package/src/scripts/report_production_reach.ts +35 -0
  116. package/src/scripts/worker_reuse_shadow_report.ts +399 -1
  117. package/src/server/schemas/settings.ts +1 -1
  118. package/dist/cli-delegate/chunk-A4QJ6ENT.js +0 -50
  119. /package/dist/cli-delegate/{chunk-E6XNBVE4.js → chunk-KR5ZIFPO.js} +0 -0
@@ -9,6 +9,7 @@
9
9
  * Bounds, each failing toward not calling the free side:
10
10
  * - the free side must be unpaid (`free-quota` or `self-hosted`); a metered
11
11
  * "free" route would spend, so it is never shadowed;
12
+ * - the paid side must not be a `base_url` route, which counts as paid without being one;
12
13
  * - the payload must be `public-artifact`, the ceiling every free route sits at;
13
14
  * - at most `SHADOW_CALL_CAP` rows (decision A5); past it the paid side still
14
15
  * answers and nothing else is called.
@@ -26,7 +27,11 @@ import { pathToFileURL } from 'node:url';
26
27
 
27
28
  import type { ExternalAIClient } from './clients.js';
28
29
  import { admitsContent, type ContentClass } from './content_ceiling.js';
29
- import { handSystemPrompt, parseHandReturn } from './hand_schemas.js';
30
+ import { councilEgress, type CouncilEgress } from './council_egress.js';
31
+ import type { Exposure, ExposureLedger } from './exposure_ledger.js';
32
+ import { FreeRouteClient } from './free_route_client.js';
33
+ import { buildHandPayload, type HandPayload, type HandSlice } from './hand_payload.js';
34
+ import { parseHandReturn } from './hand_schemas.js';
30
35
  import { routeCandidateFromMember } from './route_chooser.js';
31
36
  import { classifyRouteOutcome, recordRouteOutcome, type RouteOutcome } from './route_liveness.js';
32
37
  import { isUnpaid, type HandKind, type RouteBilling, type VerifyVerdict } from '../_lib/hand_kinds.js';
@@ -63,7 +68,6 @@ export type HeadVerify = (
63
68
  ) => { verdict: VerifyVerdict; tokens: number } | Promise<{ verdict: VerifyVerdict; tokens: number }>;
64
69
 
65
70
  export interface ShadowAskOptions {
66
- readonly kind: HandKind;
67
71
  readonly contentClass: ContentClass;
68
72
  readonly paid: ExternalAIClient;
69
73
  readonly free: ExternalAIClient;
@@ -71,9 +75,13 @@ export interface ShadowAskOptions {
71
75
  /** Root of the gitignored state the ledger and route liveness live in. */
72
76
  readonly stateRoot: string;
73
77
  readonly now?: () => Date;
78
+ /** The run's placeholder scope; default: the council egress state. */
79
+ readonly egress?: CouncilEgress;
80
+ /** Recorded against the free route when, and only when, it is asked. */
81
+ readonly exposure?: { readonly ledger: ExposureLedger; readonly exposes: Exposure };
74
82
  }
75
83
 
76
- export type ShadowSkip = 'free-side-metered' | 'paid-side-unpaid' | 'content-ceiling' | 'cap-reached';
84
+ export type ShadowSkip = 'free-side-metered' | 'paid-side-unpaid' | 'paid-side-route' | 'content-ceiling' | 'cap-reached';
77
85
 
78
86
  export interface ShadowAnswer {
79
87
  readonly answer: string;
@@ -94,9 +102,9 @@ interface Called {
94
102
  readonly tokens: number;
95
103
  }
96
104
 
97
- async function _call(member: ExternalAIClient, kind: HandKind, prompt: string): Promise<Called> {
105
+ async function _call(member: ExternalAIClient, kind: HandKind, payload: HandPayload): Promise<Called> {
98
106
  try {
99
- const raw = member.ask(handSystemPrompt(kind), prompt);
107
+ const raw = member.ask(payload.system, payload.user);
100
108
  const r = raw instanceof Promise ? await raw : raw;
101
109
  const outcome = classifyRouteOutcome(r?.error ?? null);
102
110
  const text = (r?.text ?? '').trim();
@@ -147,26 +155,30 @@ function _routeModel(member: ExternalAIClient): string | null {
147
155
  return typeof m['base_url'] === 'string' && typeof m['model'] === 'string' && m['model'] !== '' ? m['model'] : null;
148
156
  }
149
157
 
150
- export async function shadowAsk(prompt: string, opts: ShadowAskOptions): Promise<ShadowResult> {
158
+ export async function shadowAsk(slice: HandSlice, opts: ShadowAskOptions): Promise<ShadowResult> {
159
+ const kind = slice.kind;
151
160
  const paidRoute = routeCandidateFromMember(opts.paid);
152
161
  const freeRoute = routeCandidateFromMember(opts.free);
162
+ if (isUnpaid(paidRoute.billing)) return { answer: null, row: null, skipped: 'paid-side-unpaid' };
163
+ if (opts.paid instanceof FreeRouteClient) return { answer: null, row: null, skipped: 'paid-side-route' };
164
+ const payload = buildHandPayload(slice, opts.egress ?? councilEgress());
153
165
  const usePaid = async (skipped: ShadowSkip): Promise<ShadowResult> => {
154
- const c = await _call(opts.paid, opts.kind, prompt);
166
+ const c = await _call(opts.paid, kind, payload);
155
167
  return { answer: c.parsed.ok ? { answer: c.text, parsed: c.parsed.value } : null, row: null, skipped };
156
168
  };
157
- if (isUnpaid(paidRoute.billing)) return { answer: null, row: null, skipped: 'paid-side-unpaid' };
158
169
  if (!isUnpaid(freeRoute.billing)) return usePaid('free-side-metered');
159
170
  if (!admitsContent(freeRoute.ceiling, opts.contentClass).admitted) return usePaid('content-ceiling');
160
171
  if (_rowCount(opts.stateRoot) >= SHADOW_CALL_CAP) return usePaid('cap-reached');
161
172
 
162
- const [p, f] = await Promise.all([_call(opts.paid, opts.kind, prompt), _call(opts.free, opts.kind, prompt)]);
173
+ opts.exposure?.ledger.record(freeRoute.name, opts.exposure.exposes);
174
+ const [p, f] = await Promise.all([_call(opts.paid, kind, payload), _call(opts.free, kind, payload)]);
163
175
  const model = _routeModel(opts.free);
164
176
  if (model !== null) recordRouteOutcome(opts.stateRoot, freeRoute.name, model, f.outcome);
165
177
  const row: ShadowRow = {
166
178
  ts: (opts.now ?? (() => new Date()))().toISOString(),
167
- kind: opts.kind,
168
- paid: await _side(opts.paid, p, opts.kind, 'paid', opts.verify),
169
- free: await _side(opts.free, f, opts.kind, 'free', opts.verify),
179
+ kind,
180
+ paid: await _side(opts.paid, p, kind, 'paid', opts.verify),
181
+ free: await _side(opts.free, f, kind, 'free', opts.verify),
170
182
  };
171
183
  _append(opts.stateRoot, row);
172
184
  return { answer: p.parsed.ok ? { answer: p.text, parsed: p.parsed.value } : null, row };
@@ -16,7 +16,9 @@
16
16
 
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
+ import { pathToFileURL } from 'node:url';
19
20
 
21
+ import { resolve_project_root } from '../_lib/agent_settings.js';
20
22
  import { isRealDate } from './free_route.js';
21
23
 
22
24
  export interface RouteAttestation {
@@ -24,8 +26,14 @@ export interface RouteAttestation {
24
26
  readonly checked: string;
25
27
  readonly source_url: string;
26
28
  readonly trains_on_prompts: boolean;
27
- /** Whether the terms permit use inside a client distributed to users in the EEA, Switzerland and the UK. */
28
- readonly client_distribution_eea_ch_uk?: 'permitted' | 'forbidden';
29
+ /** What the terms say about keeping prompts and outputs, in their words' substance. */
30
+ readonly retention?: string;
31
+ /**
32
+ * Whether the terms permit use inside a client distributed to users in the
33
+ * EEA, Switzerland and the UK. `unknown` records that the terms were read
34
+ * and say neither, which keeps the route maintainer-only like an absent field.
35
+ */
36
+ readonly client_distribution_eea_ch_uk?: 'permitted' | 'forbidden' | 'unknown';
29
37
  }
30
38
 
31
39
  export interface CatalogRoute {
@@ -34,13 +42,36 @@ export interface CatalogRoute {
34
42
  readonly keyless: boolean;
35
43
  /** `null` until someone reads the terms and dates what they say. */
36
44
  readonly attestation: RouteAttestation | null;
45
+ /**
46
+ * The route's own model list marking each entry `community: true|false`.
47
+ * When set, discovery admits only first-party ids and fails closed when the
48
+ * list cannot be read, because the attestation speaks for first-party
49
+ * models only.
50
+ */
51
+ readonly first_party_list?: string;
37
52
  }
38
53
 
39
54
  /**
40
- * Base URLs only. No row carries an attestation yet: none has been read and
41
- * dated, so every row is maintainer-only until one is.
55
+ * Base URLs only. One row carries an attestation (council 2026-10-22,
56
+ * decision 2): its terms were read from the provider's published policy, and
57
+ * they settle neither way whether a distributed client may serve EEA, CH or UK
58
+ * users, so it stays maintainer-only.
42
59
  */
43
60
  export const ROUTE_CATALOG: readonly CatalogRoute[] = [
61
+ {
62
+ id: 'pollinations',
63
+ base_url: 'https://text.pollinations.ai/openai',
64
+ keyless: true,
65
+ first_party_list: 'https://text.pollinations.ai/models',
66
+ attestation: {
67
+ checked: '2026-10-22',
68
+ source_url: 'https://pollinations.ai/privacy',
69
+ trains_on_prompts: false,
70
+ retention:
71
+ 'prompts and outputs processed transiently, with short-lived response caches; the selected model\'s inference host sets its own retention',
72
+ client_distribution_eea_ch_uk: 'unknown',
73
+ },
74
+ },
44
75
  { id: 'groq', base_url: 'https://api.groq.com/openai/v1', keyless: false, attestation: null },
45
76
  { id: 'cerebras', base_url: 'https://api.cerebras.ai/v1', keyless: false, attestation: null },
46
77
  { id: 'openrouter', base_url: 'https://openrouter.ai/api/v1', keyless: false, attestation: null },
@@ -64,7 +95,7 @@ export function consumerDefaultVerdict(route: CatalogRoute, now: Date = new Date
64
95
  if (ageDays > ATTESTATION_MAX_AGE_DAYS) {
65
96
  return { admissible: false, reason: `${route.id}: attestation from ${a.checked} is older than ${ATTESTATION_MAX_AGE_DAYS} days` };
66
97
  }
67
- if (a.client_distribution_eea_ch_uk === undefined) {
98
+ if (a.client_distribution_eea_ch_uk === undefined || a.client_distribution_eea_ch_uk === 'unknown') {
68
99
  return { admissible: false, reason: `${route.id}: attestation records no EEA/CH/UK distribution term — maintainer runs only` };
69
100
  }
70
101
  if (a.client_distribution_eea_ch_uk === 'forbidden') {
@@ -137,6 +168,52 @@ export interface DiscoverOptions {
137
168
  readonly fetchImpl?: typeof fetch;
138
169
  readonly now?: Date;
139
170
  readonly timeoutMs?: number;
171
+ /** A route's `first_party_list`; when set, only ids it marks `community: false` are kept. */
172
+ readonly firstPartyListUrl?: string;
173
+ }
174
+
175
+ /** First-party names and aliases, each mapped to the full alias set of its entry. */
176
+ async function firstPartyIds(url: string, opts: DiscoverOptions): Promise<Map<string, ReadonlySet<string>>> {
177
+ const res = await (opts.fetchImpl ?? fetch)(url, {
178
+ headers: { accept: 'application/json' },
179
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
180
+ });
181
+ if (!res.ok) throw new Error(`first-party list HTTP ${res.status}`);
182
+ const body = (await res.json()) as unknown;
183
+ if (!Array.isArray(body)) throw new Error('first-party list is not a list');
184
+ const ids = new Map<string, ReadonlySet<string>>();
185
+ for (const m of body) {
186
+ if (m === null || typeof m !== 'object') continue;
187
+ const e = m as Record<string, unknown>;
188
+ if (e['community'] !== false) continue;
189
+ const names = [e['name'], ...(Array.isArray(e['aliases']) ? e['aliases'] : [])].filter((n): n is string => typeof n === 'string');
190
+ const entry = new Set(names);
191
+ for (const n of names) ids.set(n, entry);
192
+ }
193
+ return ids;
194
+ }
195
+
196
+ /**
197
+ * The id a route answers under when asked for `model`, from one minimal call; '' when the call
198
+ * gives no answer. A route may list one id and serve under another, and the client keeps an
199
+ * answer only on an exact match, so the roster must hold the id the route actually serves.
200
+ */
201
+ async function servedIdOf(baseUrl: string, model: string, opts: DiscoverOptions): Promise<string> {
202
+ try {
203
+ const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json' };
204
+ if (opts.apiKey) headers['authorization'] = `Bearer ${opts.apiKey}`;
205
+ const res = await (opts.fetchImpl ?? fetch)(`${baseUrl.replace(/\/+$/, '')}/chat/completions`, {
206
+ method: 'POST',
207
+ headers,
208
+ body: JSON.stringify({ model, max_tokens: 16, messages: [{ role: 'user', content: 'Reply with: ok' }] }),
209
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
210
+ });
211
+ if (!res.ok) return '';
212
+ const served = ((await res.json()) as { model?: unknown }).model;
213
+ return typeof served === 'string' ? served : '';
214
+ } catch {
215
+ return '';
216
+ }
140
217
  }
141
218
 
142
219
  /**
@@ -159,6 +236,18 @@ export async function discoverRouteModels(routeKey: string, baseUrl: string, opt
159
236
  models = body.data
160
237
  .map((m) => (m !== null && typeof m === 'object' ? (m as Record<string, unknown>)['id'] : undefined))
161
238
  .filter((id): id is string => typeof id === 'string' && id !== '');
239
+ if (opts.firstPartyListUrl) {
240
+ const fp = await firstPartyIds(opts.firstPartyListUrl, opts);
241
+ const resolved: string[] = [];
242
+ for (const id of models.filter((m) => fp.has(m))) {
243
+ const aliases = fp.get(id) as ReadonlySet<string>;
244
+ const served = aliases.size > 1 ? await servedIdOf(baseUrl, id, opts) : '';
245
+ // No answer keeps the listed id; an id outside the entry's declared aliases is a substitution.
246
+ if (served === '' || served === id) resolved.push(id);
247
+ else if (aliases.has(served)) resolved.push(served);
248
+ }
249
+ models = [...new Set(resolved)];
250
+ }
162
251
  } catch (exc) {
163
252
  if (routeKey in store) {
164
253
  delete store[routeKey];
@@ -171,3 +260,51 @@ export async function discoverRouteModels(routeKey: string, baseUrl: string, opt
171
260
  _writeStore(opts.repoRoot, store);
172
261
  return { status: 'known', models, observed_at };
173
262
  }
263
+
264
+ export interface CatalogMainDeps {
265
+ readonly fetchImpl?: typeof fetch;
266
+ readonly stdout?: (line: string) => void;
267
+ readonly now?: Date;
268
+ }
269
+
270
+ const USAGE = 'usage: free_route_catalog discover [--root <dir>]';
271
+
272
+ /**
273
+ * `discover [--root <dir>]` — the production path that writes the first-party
274
+ * roster `route_member.ts` gates on. Every catalogued row with a
275
+ * `first_party_list` is discovered under its catalogue id, beneath the same
276
+ * repository root the gate resolves. Exit 1 when any roster stays unknown.
277
+ */
278
+ export async function main(argv: readonly string[], deps: CatalogMainDeps = {}): Promise<number> {
279
+ const out = deps.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
280
+ const [cmd, ...rest] = argv;
281
+ const rootAt = rest.indexOf('--root');
282
+ const rootArg = rootAt === -1 ? null : (rest[rootAt + 1] ?? '');
283
+ if (cmd !== 'discover' || rootArg === '' || rest.length !== (rootAt === -1 ? 0 : 2)) {
284
+ out(USAGE);
285
+ return 2;
286
+ }
287
+ const [repoRoot] = resolve_project_root(rootArg);
288
+ let unknown = 0;
289
+ for (const row of ROUTE_CATALOG) {
290
+ if (row.first_party_list === undefined) continue;
291
+ const roster = await discoverRouteModels(row.id, row.base_url, {
292
+ repoRoot,
293
+ firstPartyListUrl: row.first_party_list,
294
+ ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
295
+ ...(deps.now ? { now: deps.now } : {}),
296
+ });
297
+ if (roster.status === 'known') {
298
+ out(`${row.id}: known (${roster.models.length} first-party models, observed ${roster.observed_at})`);
299
+ } else {
300
+ unknown += 1;
301
+ out(`${row.id}: unknown (${roster.reason})`);
302
+ }
303
+ }
304
+ out(`roster: ${path.join(repoRoot, ROUTE_MODELS_RELPATH)}`);
305
+ return unknown === 0 ? 0 : 1;
306
+ }
307
+
308
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
309
+ void main(process.argv.slice(2)).then((code) => process.exit(code));
310
+ }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { curlJsonPost, type JsonPost } from './api_transport.js';
8
8
  import { CouncilResponse, ExternalAIClient } from './clients.js';
9
+ import type { ContentClass } from './content_ceiling.js';
9
10
  import { familyOf, servedMatches, type RouteBilling } from './free_route.js';
10
11
  import { policyExclusionReason, type PolicyExclusion } from './seat_policy.js';
11
12
 
@@ -15,6 +16,7 @@ export interface FreeRouteClientOptions {
15
16
  readonly base_url: string;
16
17
  readonly api_key: string | null;
17
18
  readonly billing: RouteBilling;
19
+ readonly content_ceiling: ContentClass;
18
20
  readonly post?: JsonPost;
19
21
  }
20
22
 
@@ -27,6 +29,7 @@ export interface FreeRouteClientOptions {
27
29
  export class FreeRouteClient extends ExternalAIClient {
28
30
  readonly base_url: string;
29
31
  readonly billing: RouteBilling;
32
+ readonly content_ceiling: ContentClass;
30
33
  private readonly _apiKey: string | null;
31
34
  private readonly _post: JsonPost;
32
35
 
@@ -36,6 +39,7 @@ export class FreeRouteClient extends ExternalAIClient {
36
39
  this.model = opts.model;
37
40
  this.base_url = opts.base_url.replace(/\/+$/, '');
38
41
  this.billing = opts.billing;
42
+ this.content_ceiling = opts.content_ceiling;
39
43
  this.billable = opts.billing === 'per-token';
40
44
  this._apiKey = opts.api_key;
41
45
  this._post = opts.post ?? curlJsonPost;
@@ -98,17 +98,121 @@ function pythonHeaderEnd(src: string): number {
98
98
  return -1;
99
99
  }
100
100
 
101
+ /** `//` and `/* *\/` comments blanked, strings kept. */
102
+ function stripComments(src: string): string {
103
+ let out = '';
104
+ for (let i = 0; i < src.length; i++) {
105
+ const c = src[i] as string;
106
+ const next = src[i + 1];
107
+ if (c === '/' && (next === '/' || next === '*')) {
108
+ const close = next === '/' ? src.indexOf('\n', i) : src.indexOf('*/', i + 2);
109
+ i = close === -1 ? src.length : next === '/' ? close - 1 : close + 1;
110
+ out += ' ';
111
+ continue;
112
+ }
113
+ if (c === '"' || c === "'" || c === '`') {
114
+ const from = i;
115
+ for (i++; i < src.length && src[i] !== c; i++) if (src[i] === '\\') i++;
116
+ out += src.slice(from, i + 1);
117
+ continue;
118
+ }
119
+ out += c;
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /** Imports, namespaces and file pragmas: statements around a declaration, never one. */
125
+ const PREAMBLE = [
126
+ /^<\?php\b/,
127
+ /^(?:import|export)\s+(?:type\s+)?(?:[^;'"`]*?\s+from\s+)?(['"])[^'"\n]*\1\s*;?/,
128
+ /^(?:const|let|var)\s+[\w{}\s,]+=\s*require\((['"])[^'"\n]*\1\)\s*;?/,
129
+ /^(?:namespace|use)\s+[\w\\:{},\s]+?(?:\s+as\s+\w+)?\s*;/,
130
+ /^declare\s*\([^)]*\)\s*;/,
131
+ /^(['"])use strict\1\s*;?/,
132
+ ];
133
+
134
+ /** Top-level statements of a brace-language source, preamble dropped. */
135
+ function braceStatements(source: string): string[] {
136
+ let src = stripComments(source).trim();
137
+ for (let hit = true; hit; ) {
138
+ hit = false;
139
+ for (const re of PREAMBLE) {
140
+ const m = re.exec(src);
141
+ if (m !== null) {
142
+ src = src.slice(m[0].length).trimStart();
143
+ hit = true;
144
+ }
145
+ }
146
+ }
147
+ const out: string[] = [];
148
+ let depth = 0;
149
+ let from = 0;
150
+ const cut = (to: number): void => {
151
+ const stmt = src.slice(from, to).trim();
152
+ if (stmt !== '' && stmt !== ';') out.push(stmt);
153
+ from = to;
154
+ };
155
+ for (let i = 0; i < src.length; i++) {
156
+ const c = src[i];
157
+ if (c === '"' || c === "'" || c === '`') {
158
+ for (i++; i < src.length && src[i] !== c; i++) if (src[i] === '\\') i++;
159
+ } else if (c === '(' || c === '[' || c === '{') depth++;
160
+ else if (c === ')' || c === ']' || c === '}') {
161
+ depth--;
162
+ if (c === '}' && depth === 0) {
163
+ const rest = /^(\s*)(\S?)/.exec(src.slice(i + 1)) as RegExpExecArray;
164
+ const n = rest[2] as string;
165
+ if (n === ';') cut(i + 1 + (rest[1] as string).length + 1);
166
+ else if (n === '' || ((rest[1] as string).includes('\n') && !/[.{)=|&,?:]/.test(n))) cut(i + 1);
167
+ }
168
+ } else if (c === ';' && depth === 0) cut(i + 1);
169
+ }
170
+ cut(src.length);
171
+ return out;
172
+ }
173
+
174
+ /** The one Python `def` of a source, imports and decorators dropped. */
175
+ function pythonDeclaration(source: string): string {
176
+ const lines = source.split('\n');
177
+ const starts: number[] = [];
178
+ let depth = 0;
179
+ lines.forEach((line, n) => {
180
+ const atTop = depth === 0 && /^\S/.test(line) && !line.startsWith('#');
181
+ for (const c of line) {
182
+ if (c === '(' || c === '[' || c === '{') depth++;
183
+ else if (c === ')' || c === ']' || c === '}') depth--;
184
+ }
185
+ if (atTop && !/^(?:import\s|from\s+\S+\s+import\s|@)/.test(line)) starts.push(n);
186
+ });
187
+ const first = starts[0];
188
+ if (starts.length !== 1 || first === undefined || !/^(?:async\s+)?def\s/.test(lines[first] as string)) {
189
+ throw new HandPayloadError('a callee must hold exactly one top-level function; send that declaration alone');
190
+ }
191
+ return lines.slice(first).join('\n');
192
+ }
193
+
194
+ const STATEMENT_IN_BLOCK = /\breturn\b|\bthrow\b|\bif\s*\(|(?<![=!<>])=(?![=>])/;
195
+
101
196
  /**
102
- * A function's signature without its body. Brace languages lose the final
103
- * top-level block (a return-type literal before it survives); an expression
104
- * arrow loses everything from its arrow; a Python `def` keeps its header line.
197
+ * A function's signature without its body. A callee must hold exactly one
198
+ * top-level declaration once imports, namespaces and comments are dropped,
199
+ * else it is refused. Brace languages lose the final top-level block (a
200
+ * return-type literal before it survives); an expression arrow loses
201
+ * everything from its arrow; a Python `def` keeps its header line.
105
202
  */
106
203
  export function signatureOf(source: string): string {
107
- const src = source.trim();
108
- if (/^(?:async\s+)?def\s/.test(src)) {
204
+ if (/^(?:async\s+)?def\s+\w+\s*\(/m.test(source)) {
205
+ const src = pythonDeclaration(source);
109
206
  const end = pythonHeaderEnd(src);
110
- return end === -1 ? src : src.slice(0, end + 1);
207
+ const sig = end === -1 ? src.trim() : src.slice(0, end + 1);
208
+ if (sig.includes('\n') && /\breturn\b/.test(sig)) throw new HandPayloadError('a body survived signature extraction');
209
+ return sig;
111
210
  }
211
+ const stmts = braceStatements(source);
212
+ if (stmts.length !== 1) {
213
+ throw new HandPayloadError('a callee must hold exactly one top-level declaration; send that declaration alone');
214
+ }
215
+ const src = stmts[0] as string;
112
216
  const { blocks, arrows } = scanTopLevel(src);
113
217
  const tailEnd = src.replace(/[\s;]+$/u, '').length - 1;
114
218
  const last = blocks[blocks.length - 1];
@@ -119,7 +223,11 @@ export function signatureOf(source: string): string {
119
223
  } else if (arrows[0] !== undefined) {
120
224
  head = src.slice(0, arrows[0]).trimEnd();
121
225
  }
122
- return `${head.replace(/[\s;]+$/u, '')};`;
226
+ const sig = `${head.replace(/[\s;]+$/u, '')};`;
227
+ for (const b of scanTopLevel(sig).blocks) {
228
+ if (STATEMENT_IN_BLOCK.test(sig.slice(b.start + 1, b.end))) throw new HandPayloadError('a body survived signature extraction');
229
+ }
230
+ return sig;
123
231
  }
124
232
 
125
233
  function escapeRegExp(s: string): string {
@@ -502,16 +502,13 @@ export function consult(
502
502
  // Non-blind: the seat is asked about its own reply, so it continues the session that wrote it.
503
503
  const seat = member as Partial<Pick<CliClient, 'lastSession' | 'resumeNext'>>;
504
504
  if (seat.lastSession !== undefined) seat.resumeNext = seat.lastSession;
505
- const rr = _run_round([member], repairQ, resolvedBudget, spent, {
506
- table,
507
- on_overrun,
508
- project,
509
- original_ask,
510
- advisor_plans,
511
- cli_fallback,
512
- fallback_ledger,
513
- fallback_twins,
514
- });
505
+ let rr: CouncilResponse[];
506
+ try {
507
+ const round = { table, on_overrun, project, original_ask, advisor_plans, cli_fallback, fallback_ledger, fallback_twins };
508
+ rr = _run_round([member], repairQ, resolvedBudget, spent, round);
509
+ } finally {
510
+ if (seat.resumeNext !== undefined) seat.resumeNext = null; // a quota gate or refusal returns before the seat clears it
511
+ }
515
512
  const rep = rr[0];
516
513
  if (rep !== undefined) {
517
514
  opts.on_stance_repair_result?.(rep);
@@ -149,7 +149,7 @@ export function routeCandidateFromMember(member: ExternalAIClient): RouteCandida
149
149
  const ceiling: ContentClass =
150
150
  billing === 'free-quota'
151
151
  ? 'public-artifact'
152
- : classifyContentClass(m['content_ceiling'], isUnpaid(billing) ? 'public-artifact' : DEFAULT_SEAT_CEILING);
152
+ : classifyContentClass(m['content_ceiling'], isUnpaid(billing) || typeof m['base_url'] === 'string' ? 'public-artifact' : DEFAULT_SEAT_CEILING);
153
153
  const quota = m['remaining_quota'];
154
154
  return {
155
155
  name,
@@ -5,17 +5,22 @@
5
5
  * therefore must not import the loader back.
6
6
  */
7
7
 
8
+ import { resolve_project_root } from '../_lib/agent_settings.js';
8
9
  import { PROVIDER_TABLE } from '../_lib/environment_detector.js';
9
10
  import { resolve_api_key } from './config.js';
10
- import type { ContentClass } from './content_ceiling.js';
11
+ import { classifyContentClass, DEFAULT_SEAT_CEILING, type ContentClass } from './content_ceiling.js';
12
+ import type { ExternalAIClient } from './clients.js';
11
13
  import { FreeRouteClient } from './free_route_client.js';
14
+ import { readRouteRoster, ROUTE_CATALOG } from './free_route_catalog.js';
12
15
  import {
13
16
  parseRouteFields,
14
17
  resolveRouteTransport,
15
- UNPROVEN_PROBE,
18
+ routeCeiling,
19
+ type SelfHostedProbeResult,
16
20
  type RouteBilling,
17
21
  type SelfHostedProbe,
18
22
  } from './free_route.js';
23
+ import { SELF_HOSTED_PROBE, type ProbeRoute } from './self_hosted_probe.js';
19
24
  import { keyResolves } from './status_surface.js';
20
25
 
21
26
  export type RouteMemberResult =
@@ -28,15 +33,26 @@ export function routeMemberFor(
28
33
  cfg: Record<string, unknown>,
29
34
  contentClass: ContentClass,
30
35
  modelOverride?: string,
31
- probe: SelfHostedProbe = UNPROVEN_PROBE,
36
+ probe: SelfHostedProbe = SELF_HOSTED_PROBE,
37
+ stateRoot?: string,
38
+ now: Date = new Date(),
32
39
  ): RouteMemberResult | null {
33
40
  const parsed = parseRouteFields(name, cfg, Object.hasOwn(PROVIDER_TABLE, name));
34
41
  if ('error' in parsed) return { absent: { member: name, reason: 'policy_unknown', detail: parsed.error } };
35
42
  const route = parsed.route;
36
43
  if (route === null) return null;
37
44
  const model = modelOverride || (typeof cfg['model'] === 'string' ? cfg['model'] : '');
45
+ // The root discovery writes under, so a run from a subdirectory reads the same roster.
46
+ const gate = firstPartyGate(route.base_url, model, stateRoot ?? resolve_project_root(null)[0], now);
47
+ if (gate !== null) return { absent: { member: name, reason: 'policy_model_mismatch', detail: gate } };
38
48
  const ref = typeof cfg['api_key_ref'] === 'string' ? cfg['api_key_ref'] : null;
39
- const t = resolveRouteTransport({ name, route, model, contentClass, keyPresent: keyResolves(ref), probe });
49
+ // A keyed loopback route can only be proven with its key on the probe call.
50
+ let probed: SelfHostedProbeResult | undefined;
51
+ const keyedProbe: SelfHostedProbe = (r, m) => {
52
+ const withKey: ProbeRoute = { ...r, api_key_ref: ref };
53
+ return (probed = probe(withKey, m));
54
+ };
55
+ const t = resolveRouteTransport({ name, route, model, contentClass, keyPresent: keyResolves(ref), probe: keyedProbe });
40
56
  if (!t.available) {
41
57
  return { absent: { member: name, reason: t.absentReason ?? 'unavailable', detail: t.reason ?? 'route unavailable' } };
42
58
  }
@@ -47,6 +63,52 @@ export function routeMemberFor(
47
63
  base_url: route.base_url,
48
64
  api_key: ref === null ? null : resolve_api_key(ref, `ai_council.members.${name}`),
49
65
  billing: t.billing as RouteBilling,
66
+ content_ceiling: routeCeiling(route, probed),
50
67
  }),
51
68
  };
52
69
  }
70
+
71
+ /** A built seat carries the ceiling its config declares; a route client keeps its own. */
72
+ export function stampSeatCeilings(members: readonly ExternalAIClient[], membersCfg: Record<string, unknown>): void {
73
+ for (const m of members) {
74
+ const seat = m as unknown as Record<string, unknown>;
75
+ if (m instanceof FreeRouteClient || seat['content_ceiling'] !== undefined) continue;
76
+ const cfg = membersCfg[m.name];
77
+ const declared = typeof cfg === 'object' && cfg !== null ? (cfg as Record<string, unknown>)['content_ceiling'] : undefined;
78
+ seat['content_ceiling'] = classifyContentClass(declared, DEFAULT_SEAT_CEILING);
79
+ }
80
+ }
81
+
82
+ /** A first-party roster older than this no longer says which models are first-party. */
83
+ export const FIRST_PARTY_ROSTER_MAX_AGE_MS = 24 * 60 * 60 * 1000;
84
+
85
+ /**
86
+ * On a catalogued route whose attestation covers first-party models only, the
87
+ * model must be in a fresh discovered first-party roster; anything else is a
88
+ * reason to be absent, so an unknown provenance never reaches the route.
89
+ */
90
+ function firstPartyGate(baseUrl: string, model: string, stateRoot: string, now: Date): string | null {
91
+ const key = routeIdentity(baseUrl);
92
+ const row = ROUTE_CATALOG.find((r) => r.first_party_list !== undefined && routeIdentity(r.base_url) === key);
93
+ if (row === undefined) return null;
94
+ const roster = readRouteRoster(stateRoot, row.id);
95
+ if (roster.status !== 'known') return `${row.id}: no discovered first-party roster (${roster.reason})`;
96
+ if (now.getTime() - Date.parse(roster.observed_at) > FIRST_PARTY_ROSTER_MAX_AGE_MS) {
97
+ return `${row.id}: first-party roster from ${roster.observed_at} is older than a day`;
98
+ }
99
+ if (!roster.models.includes(model)) return `${row.id}: ${model} is not in the route's first-party roster`;
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Host and path, scheme dropped: an `http` or mixed-case spelling of a
105
+ * catalogued route is the same route to the gate, so it cannot step around it.
106
+ */
107
+ export function routeIdentity(url: string): string {
108
+ try {
109
+ const u = new URL(url);
110
+ return `${u.host}${u.pathname.replace(/\/+$/, '')}`;
111
+ } catch {
112
+ return url.trim().toLowerCase().replace(/\/+$/, '');
113
+ }
114
+ }
@@ -160,7 +160,7 @@ export function isUnpaidBilling(billing: string | null | undefined): boolean {
160
160
  }
161
161
 
162
162
  export interface RoleRefusal {
163
- readonly reason: 'unpaid_head_role';
163
+ readonly reason: 'unpaid_head_role' | 'route_head_role';
164
164
  readonly message: string;
165
165
  }
166
166
 
@@ -168,7 +168,14 @@ export function seatRoleRefusal(
168
168
  role: CouncilRole,
169
169
  billing: string | null | undefined,
170
170
  seat = 'this seat',
171
+ route = false,
171
172
  ): RoleRefusal | null {
173
+ if (HEAD_ROLES.has(role) && route) {
174
+ return {
175
+ reason: 'route_head_role',
176
+ message: `${seat} is a base_url route and may not hold the ${role} role, whatever billing it reports (ADR-257)`,
177
+ };
178
+ }
172
179
  if (!HEAD_ROLES.has(role) || !isUnpaidBilling(billing)) return null;
173
180
  return {
174
181
  reason: 'unpaid_head_role',