@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +104 -485
- package/README.md +2 -1
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +5 -3
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +5 -1
- package/dist/cli-delegate/{chunk-IROAQ77A.js → chunk-3PK3VZHB.js} +107 -9
- package/dist/cli-delegate/{chunk-2SFOOFJJ.js → chunk-BZJ4Y3CI.js} +1 -1
- package/dist/cli-delegate/{chunk-CAL76ADI.js → chunk-CMZEKSNN.js} +52 -10
- package/dist/cli-delegate/{chunk-ESUNQ5XR.js → chunk-E3CFULNX.js} +22 -4
- package/dist/cli-delegate/{chunk-GOMILC3O.js → chunk-QBHEI5N4.js} +4 -3
- package/dist/cli-delegate/{chunk-SIOMDAKL.js → chunk-VYZGJLCA.js} +29 -39
- package/dist/cli-delegate/chunk-W5JXMF2T.js +77 -0
- package/dist/cli-delegate/{chunk-4SHNERBL.js → chunk-XIAPY44N.js} +1 -1
- package/dist/cli-delegate/{chunk-JHMMWNJ2.js → chunk-YHAPONWV.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +9 -8
- package/dist/cli-delegate/cmd_doctor.js +4 -4
- package/dist/cli-delegate/cmd_doctor_neighbours.js +2 -2
- package/dist/cli-delegate/cmd_explain.js +1 -1
- package/dist/cli-delegate/cmd_export.js +6 -6
- package/dist/cli-delegate/cmd_fleet.js +3 -3
- package/dist/cli-delegate/cmd_preflight.js +3 -3
- package/dist/cli-delegate/cmd_prune.js +6 -6
- package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +6 -6
- package/dist/cli-delegate/cmd_uninstall.js +6 -6
- package/dist/cli-delegate/cmd_update.js +9 -13
- package/dist/cli-delegate/cmd_validate.js +6 -6
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +98 -74
- package/dist/hooks/dispatch.sha256 +1 -1
- package/dist/install/claudeRuleRewrite.js +10 -3
- package/dist/install/conflict.js +24 -2
- package/dist/install/conflictTracker.js +14 -3
- package/dist/install/install.mjs +222 -117
- package/dist/install/installThinLayer.js +1 -0
- package/dist/install/loggedWrites.js +142 -0
- package/dist/install/paths.js +12 -2
- package/dist/install/preserve.js +25 -4
- package/dist/install/txlog.js +10 -6
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +845 -294
- package/dist/scripts/_lib/egress_rules.js +4 -1
- package/dist/scripts/_lib/identifier_redaction.js +35 -2
- package/dist/scripts/ai_council/seat_policy.js +7 -1
- package/dist/server/schemas/settings.js +1 -1
- package/package.json +1 -1
- package/src/config/ci-local-parity.yml +1 -1
- package/src/config/gate-coverage.yml +27 -7
- package/src/config/gate-violation-baselines.json +8 -2
- package/src/config/hook-bundle-budget.json +2 -2
- package/src/config/production-entries.json +18 -0
- package/src/config/release-gate-locality.yml +0 -7
- package/src/install/claudeRuleRewrite.ts +10 -1
- package/src/install/conflict.ts +24 -1
- package/src/install/conflictTracker.ts +18 -2
- package/src/install/installThinLayer.ts +6 -0
- package/src/install/loggedWrites.ts +148 -0
- package/src/install/paths.ts +11 -2
- package/src/install/preserve.ts +40 -4
- package/src/install/txlog.ts +10 -6
- package/src/scripts/_cli/cmd_conformance.ts +3 -2
- package/src/scripts/_cli/cmd_update.ts +15 -17
- package/src/scripts/_lib/council_settings_block.ts +9 -2
- package/src/scripts/_lib/egress_rules.ts +4 -1
- package/src/scripts/_lib/host_capability.ts +10 -5
- package/src/scripts/_lib/identifier_redaction.ts +38 -2
- package/src/scripts/_lib/price_freshness.ts +112 -0
- package/src/scripts/_lib/production_reach.ts +194 -0
- package/src/scripts/_lib/quota_parked.ts +17 -28
- package/src/scripts/_lib/release_findings_ingest.ts +13 -335
- package/src/scripts/_lib/tier_downshift_verdict.ts +81 -35
- package/src/scripts/ai_council/chairman.ts +5 -2
- package/src/scripts/ai_council/clients.ts +4 -3
- package/src/scripts/ai_council/free_hand_shadow.ts +24 -12
- package/src/scripts/ai_council/free_route_catalog.ts +142 -5
- package/src/scripts/ai_council/free_route_client.ts +4 -0
- package/src/scripts/ai_council/hand_payload.ts +115 -7
- package/src/scripts/ai_council/orchestrator.ts +7 -10
- package/src/scripts/ai_council/route_chooser.ts +1 -1
- package/src/scripts/ai_council/route_member.ts +66 -4
- package/src/scripts/ai_council/seat_policy.ts +8 -1
- package/src/scripts/ai_council/self_hosted_probe.ts +327 -0
- package/src/scripts/ai_council/status_surface.ts +2 -0
- package/src/scripts/ask_transport.ts +137 -15
- package/src/scripts/check_finding_dispositions.ts +25 -71
- package/src/scripts/check_release_pr_shape.ts +3 -3
- package/src/scripts/check_step_production_reach.ts +257 -0
- package/src/scripts/council_cli.ts +10 -8
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +3 -0
- package/src/scripts/hooks/dispatch_hook.ts +2 -2
- package/src/scripts/hooks/payload_stub.ts +27 -0
- package/src/scripts/hooks/spawn_guard_shadow_hook.ts +29 -70
- package/src/scripts/hooks/subagent_ledger_hook.ts +80 -0
- package/src/scripts/install.ts +2 -2
- package/src/scripts/lint_decision_classes.ts +34 -0
- package/src/scripts/lint_hook_manifest.ts +24 -0
- package/src/scripts/lint_roadmap_blockers.ts +52 -3
- package/src/scripts/release.ts +5 -9
- package/src/scripts/release_drill.ts +11 -165
- package/src/scripts/release_publication.ts +0 -9
- package/src/scripts/report_production_reach.ts +35 -0
- package/src/scripts/worker_reuse_shadow_report.ts +399 -1
- package/src/server/schemas/settings.ts +1 -1
- package/dist/cli-delegate/chunk-A4QJ6ENT.js +0 -50
- /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 {
|
|
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,
|
|
105
|
+
async function _call(member: ExternalAIClient, kind: HandKind, payload: HandPayload): Promise<Called> {
|
|
98
106
|
try {
|
|
99
|
-
const raw = member.ask(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
168
|
-
paid: await _side(opts.paid, p,
|
|
169
|
-
free: await _side(opts.free, f,
|
|
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
|
-
/**
|
|
28
|
-
readonly
|
|
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.
|
|
41
|
-
*
|
|
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.
|
|
103
|
-
* top-level
|
|
104
|
-
*
|
|
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
|
-
|
|
108
|
-
|
|
204
|
+
if (/^(?:async\s+)?def\s+\w+\s*\(/m.test(source)) {
|
|
205
|
+
const src = pythonDeclaration(source);
|
|
109
206
|
const end = pythonHeaderEnd(src);
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
on_overrun,
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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',
|