@event4u/agent-config 14.8.0 → 14.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +214 -237
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +34 -4
- package/dist/agent-src/commands/pr/merge.md +9 -5
- package/dist/agent-src/commands/review/changes.md +95 -14
- package/dist/agent-src/commands/roadmap/process-full.md +2 -2
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +36 -0
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
- package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
- package/dist/agent-src/skills/async-python-patterns/SKILL.md +1 -1
- package/dist/agent-src/skills/code-refactoring/SKILL.md +1 -1
- package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
- package/dist/agent-src/skills/condense-memory/SKILL.md +1 -1
- package/dist/agent-src/skills/conventional-commits-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/design-review/SKILL.md +1 -1
- package/dist/agent-src/skills/discovery-interview/SKILL.md +1 -1
- package/dist/agent-src/skills/doc-coauthoring/SKILL.md +1 -1
- package/dist/agent-src/skills/docx-authoring/SKILL.md +1 -1
- package/dist/agent-src/skills/editorial-calendar/SKILL.md +1 -1
- package/dist/agent-src/skills/github-ci/SKILL.md +1 -1
- package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
- package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
- package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
- package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +1 -1
- package/dist/agent-src/skills/judge-injection-defense/SKILL.md +1 -1
- package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
- package/dist/agent-src/skills/laravel-validation/SKILL.md +1 -1
- package/dist/agent-src/skills/legal-intake-triage/SKILL.md +1 -1
- package/dist/agent-src/skills/legal-practice-profile/SKILL.md +1 -1
- package/dist/agent-src/skills/messaging-architecture/SKILL.md +1 -1
- package/dist/agent-src/skills/migration-architect/SKILL.md +1 -1
- package/dist/agent-src/skills/module-detect-on-the-fly/SKILL.md +1 -1
- package/dist/agent-src/skills/module-management/SKILL.md +1 -1
- package/dist/agent-src/skills/motion-choreographer/SKILL.md +1 -1
- package/dist/agent-src/skills/onboarding-program/SKILL.md +1 -1
- package/dist/agent-src/skills/perf-feedback-craft/SKILL.md +1 -1
- package/dist/agent-src/skills/prompt-optimizer/SKILL.md +1 -1
- package/dist/agent-src/skills/receiving-code-review/SKILL.md +1 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
- package/dist/agent-src/skills/scene-expander/SKILL.md +1 -1
- package/dist/agent-src/skills/secrets-management/SKILL.md +1 -1
- package/dist/agent-src/skills/using-git-worktrees/SKILL.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +8 -0
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-MMSGVZKV.js → chunk-77GR3VD4.js} +65 -40
- package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
- package/dist/cli-delegate/{chunk-EEJUMCDZ.js → chunk-PLSG6UL5.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +89 -13
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +64 -42
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +562 -204
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +808 -331
- package/dist/scripts/_lib/claude_plugin.js +162 -0
- package/dist/scripts/_lib/claude_plugin.js.map +1 -0
- package/dist/scripts/_lib/environment_detector.js +397 -0
- package/dist/scripts/_lib/environment_detector.js.map +1 -0
- package/dist/scripts/_lib/spawn_env.js +129 -0
- package/dist/scripts/_lib/spawn_env.js.map +1 -0
- package/dist/scripts/ai_council/modes.js +199 -0
- package/dist/scripts/ai_council/modes.js.map +1 -0
- package/dist/scripts/ai_council/transport_resolver.js +418 -0
- package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
- package/dist/server/routes/wizard.js +3 -2
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/ui/assets/index-Cxgy63kz.js +26 -0
- package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
- package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
- package/package.json +7 -3
- package/src/config/estate-count-budget.json +13 -744
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +267 -9
- package/src/config/gate-violation-baselines.json +72 -72
- package/src/config/preamble-payload-budget.json +71 -67
- package/src/config/publish-surface.json +60 -0
- package/src/config/requirements-trace-budget.json +32 -0
- package/src/config/rule-stub-ceilings.json +8 -1
- package/src/scripts/_dispatch.bash +7 -0
- package/src/scripts/_lib/base_tree.ts +175 -0
- package/src/scripts/_lib/billing_grant.ts +165 -0
- package/src/scripts/_lib/billing_grant_cli.ts +123 -0
- package/src/scripts/_lib/council_fallback_posture.ts +53 -6
- package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
- package/src/scripts/_lib/host_capability.ts +59 -5
- package/src/scripts/_lib/quota_parked.ts +133 -0
- package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
- package/src/scripts/_lib/review_telemetry.ts +196 -0
- package/src/scripts/_lib/subagent_response.ts +92 -0
- package/src/scripts/adr/regenerate_index.ts +14 -7
- package/src/scripts/ai_council/clients.ts +12 -1
- package/src/scripts/ai_council/config.ts +72 -115
- package/src/scripts/ai_council/fallback_config.ts +19 -7
- package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
- package/src/scripts/ai_council/orchestrator.ts +2 -2
- package/src/scripts/ai_council/py_format.ts +124 -0
- package/src/scripts/ai_council/seating.ts +211 -0
- package/src/scripts/ai_council/transport_resolver.ts +65 -2
- package/src/scripts/audit_adr_coverage.ts +33 -6
- package/src/scripts/check_council_pin_staleness.ts +300 -0
- package/src/scripts/check_estate_count.ts +420 -293
- package/src/scripts/check_gate_coverage.ts +91 -0
- package/src/scripts/check_no_stub_inventory_table.ts +292 -0
- package/src/scripts/check_pack_passport_reconciliation.ts +274 -0
- package/src/scripts/check_pack_size.ts +234 -1
- package/src/scripts/check_publish_surface.ts +297 -0
- package/src/scripts/check_requirements_trace.ts +378 -0
- package/src/scripts/check_secret_leak.ts +166 -12
- package/src/scripts/code_graph/cli.ts +44 -5
- package/src/scripts/code_graph/extract.ts +87 -0
- package/src/scripts/council_cli.ts +12 -16
- package/src/scripts/detect_ai_tells.ts +87 -0
- package/src/scripts/dispatch_r2_reviewer.ts +42 -0
- package/src/scripts/generate_capabilities_index.ts +41 -0
- package/src/scripts/generate_pack_manifests.ts +78 -0
- package/src/scripts/git_authorization_hook.ts +54 -4
- package/src/scripts/hooks/block_unauthorized_git.ts +19 -1
- package/src/scripts/hooks/run_continuation_hook.ts +3 -1
- package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
- package/src/scripts/injection_scan_hook.ts +120 -10
- package/src/scripts/lint_adr_sweep_routing.ts +365 -0
- package/src/scripts/lint_workflow_security.ts +53 -10
- package/src/scripts/lint_workflow_security_allowlist.json +2 -18
- package/src/scripts/report_envelope_rate.ts +166 -0
- package/src/scripts/review_axis_report.ts +218 -0
- package/src/scripts/run_supervise.ts +46 -1
- package/src/scripts/schemas/evals.schema.json +5 -0
- package/src/scripts/schemas/pack.schema.json +62 -4
- package/src/scripts/schemas/skill.schema.json +2 -2
- package/src/scripts/sync_pr_branch.ts +14 -7
- package/src/scripts/tighten_skill_descriptions.ts +264 -0
- package/dist/cli/commands/commands.candidates.test.js +0 -257
- package/dist/cli/commands/commands.candidates.test.js.map +0 -1
- package/dist/cli/commands/profiles.test.js +0 -88
- package/dist/cli/commands/profiles.test.js.map +0 -1
- package/dist/cli/commands/recordTriggerEval.test.js +0 -113
- package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
- package/dist/cli/configRoot.test.js +0 -97
- package/dist/cli/configRoot.test.js.map +0 -1
- package/dist/cli/mcp/content.test.js +0 -54
- package/dist/cli/mcp/content.test.js.map +0 -1
- package/dist/cli/mcp/dispatch.test.js +0 -108
- package/dist/cli/mcp/dispatch.test.js.map +0 -1
- package/dist/server/writeRoot.test.js +0 -91
- package/dist/server/writeRoot.test.js.map +0 -1
- package/dist/shared/capabilities.test.js +0 -39
- package/dist/shared/capabilities.test.js.map +0 -1
- package/dist/ui/assets/index-C1pBRm1f.js +0 -26
- package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
- /package/dist/cli-delegate/{chunk-N7CDDGI3.js → chunk-CYH5SP6M.js} +0 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is seated for a run, decided once and frozen.
|
|
3
|
+
*
|
|
4
|
+
* Selection today is config-static ask-all: every enabled member, every
|
|
5
|
+
* question, no per-question seating. This adds an OPTIONAL declaration of what
|
|
6
|
+
* a run wants from its seats, and computes the answer once.
|
|
7
|
+
*
|
|
8
|
+
* FAMILY DIVERSITY ONLY — no band ordering. `blocker: b-ladder-order-benchmark-spend`
|
|
9
|
+
* was resolved (b) on 2026-08-22: nothing in this tree establishes that a higher
|
|
10
|
+
* capability band reviews better, the archived benchmark substrate would have to
|
|
11
|
+
* be re-run to support it, and the council that would have decided otherwise had
|
|
12
|
+
* no quota. So `tier` keeps its single documented meaning — a capability rank
|
|
13
|
+
* read only as a chairman-selection input — and nothing here reads it. A seat
|
|
14
|
+
* declaration carries MISSION, never RANK.
|
|
15
|
+
*
|
|
16
|
+
* "Family" is the PROVIDER, not a capability band. For a council, diversity means
|
|
17
|
+
* independent providers: two Claude bands share a vendor, a training pipeline and
|
|
18
|
+
* a failure mode, so seating `opus` beside `sonnet` buys none of what a second
|
|
19
|
+
* opinion is for. (`MODEL_FAMILIES` in the orchestration-record hook enumerates
|
|
20
|
+
* Claude bands for a different purpose — token attribution — and is deliberately
|
|
21
|
+
* not reused here.)
|
|
22
|
+
*
|
|
23
|
+
* Absent declaration ⇒ today's behaviour, byte-identical. That is asserted by a
|
|
24
|
+
* test rather than intended.
|
|
25
|
+
*/
|
|
26
|
+
import type { MemberConfig } from './config.js';
|
|
27
|
+
|
|
28
|
+
/** A member's provider family. The map key IS the provider in this config shape. */
|
|
29
|
+
export function familyOf(member: MemberConfig): string {
|
|
30
|
+
return member.name;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read the optional `seat_constraints:` block out of a raw council config.
|
|
35
|
+
*
|
|
36
|
+
* DELIBERATELY NOT A FIELD ON `CouncilConfig`, and the reason is a ratchet
|
|
37
|
+
* rather than a design preference. `config.ts` is 2,205 lines — 705 over the
|
|
38
|
+
* 1,500-line source-size line — and `check_source_size_budget` counts only the
|
|
39
|
+
* excess above that line, so the 39 lines this feature first added there were a
|
|
40
|
+
* straight +39 against a shrink-only baseline. The gate's own message is
|
|
41
|
+
* explicit that raising the baseline is "a defect, not a fix". Extraction is the
|
|
42
|
+
* sanctioned repair, and this file is 169 lines, so the same code costs nothing
|
|
43
|
+
* here.
|
|
44
|
+
*
|
|
45
|
+
* THE TRADE-OFF, stated rather than buried: validation moves from LOAD time to
|
|
46
|
+
* READ time. A malformed `min_families` no longer fails the moment the config
|
|
47
|
+
* is parsed; it fails when a caller asks for the constraints. Today that
|
|
48
|
+
* difference is unobservable — Phase 2 ships the DECLARATION and no caller
|
|
49
|
+
* reads it yet — but it is a real change in when a bad config is caught, and
|
|
50
|
+
* whoever wires the first caller should call this at run start so the failure
|
|
51
|
+
* lands before any seat is used.
|
|
52
|
+
*
|
|
53
|
+
* Fails CLOSED on a malformed value rather than falling back to absent: a run
|
|
54
|
+
* that declared a diversity floor and silently got none is the exact silent
|
|
55
|
+
* fallback the degradation line exists to prevent.
|
|
56
|
+
*/
|
|
57
|
+
export function readSeatConstraints(raw: unknown, label = 'seat_constraints'): SeatConstraints {
|
|
58
|
+
if (raw === null || raw === undefined) return {};
|
|
59
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
60
|
+
throw new TypeError(`${label} must be a mapping (got ${typeof raw}).`);
|
|
61
|
+
}
|
|
62
|
+
const block = (raw as Record<string, unknown>)['seat_constraints'] ?? raw;
|
|
63
|
+
if (block === null || block === undefined) return {};
|
|
64
|
+
if (typeof block !== 'object' || Array.isArray(block)) {
|
|
65
|
+
throw new TypeError(`${label} must be a mapping (got ${typeof block}).`);
|
|
66
|
+
}
|
|
67
|
+
const mf = (block as Record<string, unknown>)['min_families'];
|
|
68
|
+
if (mf === null || mf === undefined) return {};
|
|
69
|
+
if (typeof mf !== 'number' || !Number.isInteger(mf) || mf < 1) {
|
|
70
|
+
throw new TypeError(`${label}.min_families must be an integer >= 1 when set (got ${String(mf)}).`);
|
|
71
|
+
}
|
|
72
|
+
return { min_families: mf };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SeatConstraints {
|
|
76
|
+
/**
|
|
77
|
+
* Minimum distinct provider families the run wants seated.
|
|
78
|
+
*
|
|
79
|
+
* Absent (or 1) ⇒ no constraint and no degradation line: ask-all, unchanged.
|
|
80
|
+
*/
|
|
81
|
+
readonly min_families?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface Seating {
|
|
85
|
+
/** The seated members, in deterministic config order. */
|
|
86
|
+
readonly seats: readonly MemberConfig[];
|
|
87
|
+
/** Distinct families actually seated. */
|
|
88
|
+
readonly families: readonly string[];
|
|
89
|
+
/**
|
|
90
|
+
* Exactly ONE line when the constraint could not be met, `null` otherwise.
|
|
91
|
+
*
|
|
92
|
+
* One line, not a wall: a degradation nobody reads is the same as a silent
|
|
93
|
+
* fallback, and N warnings is how a reader learns to skip the header.
|
|
94
|
+
*/
|
|
95
|
+
readonly degraded: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Compute the seating once.
|
|
100
|
+
*
|
|
101
|
+
* Deterministic and side-effect-free: the same members and constraints always
|
|
102
|
+
* produce the same seats, which is what makes freezing it meaningful. Seating
|
|
103
|
+
* that changed between round 1 and round 3 would silently invalidate the
|
|
104
|
+
* anonymised-peer-reply contract the council skill depends on — a "reviewer B"
|
|
105
|
+
* in round 3 that is not the "reviewer B" of round 1 makes every cross-reference
|
|
106
|
+
* in the synthesis wrong while looking fine.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveSeating(
|
|
109
|
+
members: readonly MemberConfig[],
|
|
110
|
+
constraints: SeatConstraints = {},
|
|
111
|
+
): Seating {
|
|
112
|
+
const enabled = members.filter((m) => m.enabled);
|
|
113
|
+
const families = [...new Set(enabled.map(familyOf))];
|
|
114
|
+
const want = constraints.min_families ?? 1;
|
|
115
|
+
|
|
116
|
+
if (want <= 1 || families.length >= want) {
|
|
117
|
+
return { seats: enabled, families, degraded: null };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
seats: enabled,
|
|
121
|
+
families,
|
|
122
|
+
degraded:
|
|
123
|
+
`tier-degraded: asked for ${String(want)} model families, seated ${String(families.length)} ` +
|
|
124
|
+
`(${families.join(', ')}). The run proceeds with what is enabled — a council that refused to ` +
|
|
125
|
+
`convene on a diversity shortfall would fail closed on the common single-provider install, ` +
|
|
126
|
+
`which is worse than a stated shortfall.`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Freeze a seating so a later config mutation cannot change it.
|
|
132
|
+
*
|
|
133
|
+
* Returns a structurally frozen snapshot. This is the mechanism behind "resolve
|
|
134
|
+
* once per run": the caller resolves at run start, freezes, and passes the
|
|
135
|
+
* snapshot down — so there is no path by which round 3 re-reads the config.
|
|
136
|
+
*/
|
|
137
|
+
export function freezeSeating(s: Seating): Seating {
|
|
138
|
+
return Object.freeze({
|
|
139
|
+
seats: Object.freeze([...s.seats]),
|
|
140
|
+
families: Object.freeze([...s.families]),
|
|
141
|
+
degraded: s.degraded,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Vendor sentinels and "latest in band" aliases — an id that cannot go stale.
|
|
147
|
+
*
|
|
148
|
+
* Read from the providers' own CLI surfaces on 2026-08-22, never from recall:
|
|
149
|
+
* `claude --help` documents 'fable', 'opus' and 'sonnet' as aliases for the
|
|
150
|
+
* latest model in their band, and `codex-default` is the openai transport's
|
|
151
|
+
* documented "let the CLI choose".
|
|
152
|
+
*/
|
|
153
|
+
export const ADMISSIBLE_SENTINELS: ReadonlySet<string> = new Set([
|
|
154
|
+
'codex-default',
|
|
155
|
+
'fable',
|
|
156
|
+
'opus',
|
|
157
|
+
'sonnet',
|
|
158
|
+
'haiku',
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
export interface AdmissibilityResult {
|
|
162
|
+
readonly admissible: boolean;
|
|
163
|
+
readonly reason: string | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Is this member's `model:` id from an admissible SOURCE?
|
|
168
|
+
*
|
|
169
|
+
* THE CRITERION AS WRITTEN COULD NOT BE IMPLEMENTED, and the deviation is
|
|
170
|
+
* recorded here rather than papered over. The roadmap step says a declared seat
|
|
171
|
+
* "resolves through the shipped `model_tier` bands; a model id that no tier maps
|
|
172
|
+
* to is rejected at config load". But `TIER_TO_CLAUDE_MODEL`
|
|
173
|
+
* (`_lib/model_tier.ts:36-41`) maps tiers onto CLAUDE aliases only — there is no
|
|
174
|
+
* entry any `gemini-*`, `grok-*` or `sonar-*` id could resolve through. Taken
|
|
175
|
+
* literally, that rule rejects every non-anthropic member in the shipped starter
|
|
176
|
+
* config, i.e. it fails closed on a valid config.
|
|
177
|
+
*
|
|
178
|
+
* What the step is actually protecting is stated in its own title: "including
|
|
179
|
+
* the host's own recall". The failure mode is a plausible-sounding model id
|
|
180
|
+
* TYPED FROM MEMORY, which is indistinguishable from a real one at review time
|
|
181
|
+
* and would silently undo the pin refresh. So admissibility is checked against
|
|
182
|
+
* the SOURCE of the id, over the two things this tree can actually verify:
|
|
183
|
+
*
|
|
184
|
+
* 1. a vendor sentinel or documented "latest in band" alias — cannot go stale,
|
|
185
|
+
* and read from the provider's own surface; or
|
|
186
|
+
* 2. a dated pin carrying a `verified_at` stamp — which records that a human
|
|
187
|
+
* looked at the provider's surface on a named date, and which
|
|
188
|
+
* `check_council_pin_staleness` ages out.
|
|
189
|
+
*
|
|
190
|
+
* An id that is neither is exactly the recall case: a bare dated id nobody
|
|
191
|
+
* recorded checking.
|
|
192
|
+
*/
|
|
193
|
+
export function checkModelAdmissibility(member: MemberConfig): AdmissibilityResult {
|
|
194
|
+
const id = member.model.trim().toLowerCase();
|
|
195
|
+
if (ADMISSIBLE_SENTINELS.has(id)) {
|
|
196
|
+
return { admissible: true, reason: null };
|
|
197
|
+
}
|
|
198
|
+
if (member.verified_at !== null && member.verified_at !== undefined) {
|
|
199
|
+
return { admissible: true, reason: null };
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
admissible: false,
|
|
203
|
+
reason:
|
|
204
|
+
`members.${member.name}.model is '${member.model}' — a dated id with no verified_at stamp. ` +
|
|
205
|
+
`An id must come from a source this tree can check: a vendor sentinel or documented ` +
|
|
206
|
+
`"latest in band" alias (${[...ADMISSIBLE_SENTINELS].join(', ')} — see src/scripts/_lib/model_tier.ts ` +
|
|
207
|
+
`for the tier vocabulary these bands come from), or a pin carrying verified_at: "YYYY-MM-DD" ` +
|
|
208
|
+
`recorded from the provider's own surface. A model id remembered rather than read is not an ` +
|
|
209
|
+
`admissible source, and is indistinguishable from a real one at review time.`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -403,13 +403,76 @@ const FALLBACK_ELIGIBLE: ReadonlySet<CliFailureClass> = new Set([
|
|
|
403
403
|
* `auto` may infer — the USD spend itself remains guarded by the ordinary
|
|
404
404
|
* `cost_budget` gates the orchestrator runs on the retry.
|
|
405
405
|
*/
|
|
406
|
+
/**
|
|
407
|
+
* The three postures of `ai_council.fallback.api_on_quota`.
|
|
408
|
+
*
|
|
409
|
+
* `false` and `true` mean what they always meant. `'ask'` is the third: the
|
|
410
|
+
* billing-class decision is deferred out of configuration time and into the
|
|
411
|
+
* round that would actually cross the cliff, where the remaining scope is
|
|
412
|
+
* known. Under `'ask'` the seat is eligible only when a run-scoped billing
|
|
413
|
+
* grant is present — a human said yes for THIS run — and is parked otherwise.
|
|
414
|
+
*/
|
|
415
|
+
export type ApiOnQuota = boolean | 'ask';
|
|
416
|
+
|
|
406
417
|
export interface FallbackPolicy {
|
|
407
|
-
readonly apiOnQuota:
|
|
418
|
+
readonly apiOnQuota: ApiOnQuota;
|
|
419
|
+
/**
|
|
420
|
+
* True when a run-scoped billing grant is in force. Only consulted under
|
|
421
|
+
* `apiOnQuota: 'ask'`; absent is never read as granted, which is why this
|
|
422
|
+
* is optional rather than defaulted anywhere.
|
|
423
|
+
*/
|
|
424
|
+
readonly billingGrant?: boolean;
|
|
408
425
|
}
|
|
409
426
|
|
|
410
427
|
/** The base policy: exactly the three-plus-one no-double-charge classes. */
|
|
411
428
|
export const DEFAULT_FALLBACK_POLICY: FallbackPolicy = { apiOnQuota: false };
|
|
412
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Collapse the tri-state plus the grant into the boolean the eligibility rule
|
|
432
|
+
* needs. `'ask'` without a grant is `false` — never `true` by omission, which
|
|
433
|
+
* is the one direction that would turn a configured question into silent
|
|
434
|
+
* metered spend.
|
|
435
|
+
*/
|
|
436
|
+
export function effectiveApiOnQuota(policy: FallbackPolicy): boolean {
|
|
437
|
+
if (policy.apiOnQuota === 'ask') return policy.billingGrant === true;
|
|
438
|
+
return policy.apiOnQuota === true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Parse a config value into the tri-state, or throw.
|
|
443
|
+
*
|
|
444
|
+
* Rejecting is the point. A parser that mapped an unknown value onto a truthy
|
|
445
|
+
* default would turn a configured `off` into metered spend — the exact failure
|
|
446
|
+
* the `'ask'` posture exists to prevent, introduced by the code that adds it.
|
|
447
|
+
* Accepts the Python-ish spellings the council config has always allowed on
|
|
448
|
+
* the boolean side, plus `ask` on the new one.
|
|
449
|
+
*/
|
|
450
|
+
export function apiOnQuotaView(raw: unknown): ApiOnQuota {
|
|
451
|
+
// Lenient by design, unlike `parseApiOnQuota`: this feeds a DISPLAY
|
|
452
|
+
// surface, where throwing on a malformed file would blank the wizard
|
|
453
|
+
// instead of showing the operator the setting they need to fix. Reading
|
|
454
|
+
// by value rather than by truthiness is the half that still matters —
|
|
455
|
+
// `=== true` alone renders `'ask'` as `false`, and the next save
|
|
456
|
+
// downgrades a configured question to a configured no.
|
|
457
|
+
return raw === 'ask' ? 'ask' : raw === true;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function parseApiOnQuota(raw: unknown): ApiOnQuota {
|
|
461
|
+
if (typeof raw === 'boolean') return raw;
|
|
462
|
+
if (raw === undefined || raw === null) return false;
|
|
463
|
+
if (typeof raw === 'string') {
|
|
464
|
+
const s = raw.trim().toLowerCase();
|
|
465
|
+
if (s === 'ask') return 'ask';
|
|
466
|
+
if (s === 'true' || s === 'yes' || s === 'on' || s === '1') return true;
|
|
467
|
+
if (s === '' || s === 'false' || s === 'no' || s === 'off' || s === '0') {
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
throw new Error(
|
|
472
|
+
`ai_council.fallback.api_on_quota: expected false | true | 'ask', got ${JSON.stringify(raw)}`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
413
476
|
/** Normalise a raw client error / skip reason into a `CliFailureClass`. */
|
|
414
477
|
export function classifyCliFailure(raw: string): CliFailureClass {
|
|
415
478
|
const s = raw.trim().toLowerCase();
|
|
@@ -464,7 +527,7 @@ export function isFallbackEligibleUnder(
|
|
|
464
527
|
policy: FallbackPolicy,
|
|
465
528
|
): boolean {
|
|
466
529
|
if (FALLBACK_ELIGIBLE.has(failure)) return true;
|
|
467
|
-
return policy
|
|
530
|
+
return effectiveApiOnQuota(policy) && failure === 'quota_exhausted';
|
|
468
531
|
}
|
|
469
532
|
|
|
470
533
|
/**
|
|
@@ -28,7 +28,7 @@ import * as path from 'node:path';
|
|
|
28
28
|
import process from 'node:process';
|
|
29
29
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
30
30
|
|
|
31
|
-
import { readAdrFrontmatterScalars } from './_lib/adr_frontmatter.js';
|
|
31
|
+
import { readAdrAxisCells, readAdrFrontmatterScalars } from './_lib/adr_frontmatter.js';
|
|
32
32
|
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
33
33
|
|
|
34
34
|
const _HERE = fileURLToPath(import.meta.url);
|
|
@@ -279,8 +279,13 @@ export function scan_area(area: string): [AdrEntry[], string[]] {
|
|
|
279
279
|
errs.push(`${area}/${base}: filename does not match NNNN-<slug>.md`);
|
|
280
280
|
continue;
|
|
281
281
|
}
|
|
282
|
-
const
|
|
283
|
-
|
|
282
|
+
const text = fs.readFileSync(p, 'utf-8');
|
|
283
|
+
const fm = read_adr_meta(text);
|
|
284
|
+
// Read apart from `fm`, for the same reason the index does: the two
|
|
285
|
+
// axes are nested, so the scalar reader keeps them out of its map and
|
|
286
|
+
// spreading it could never produce these cells.
|
|
287
|
+
const axes = readAdrAxisCells(text);
|
|
288
|
+
adrs.push({ num: m[1] as string, slug: m[2] as string, path: base, ...fm, ...axes });
|
|
284
289
|
}
|
|
285
290
|
// Gap check.
|
|
286
291
|
const nums = adrs.map((a) => parseInt(a.num, 10));
|
|
@@ -322,7 +327,11 @@ export function render_area_readme(area: string, meta: AreaMeta, adrs: AdrEntry[
|
|
|
322
327
|
} else {
|
|
323
328
|
lines.push(`Contract: _not yet published_ (\`${repoRel}\`).`);
|
|
324
329
|
}
|
|
325
|
-
lines.push(
|
|
330
|
+
lines.push(
|
|
331
|
+
'',
|
|
332
|
+
'| # | Title | Status | Date | Provenance | Evidence | Supersedes |',
|
|
333
|
+
'|---|---|---|---|---|---|---|',
|
|
334
|
+
);
|
|
326
335
|
for (const a of adrs) {
|
|
327
336
|
// a.get("decision", a["slug"]).replace("-", " ").title()
|
|
328
337
|
const decision = a['decision'] !== undefined ? a['decision'] : a.slug;
|
|
@@ -330,11 +339,12 @@ export function render_area_readme(area: string, meta: AreaMeta, adrs: AdrEntry[
|
|
|
330
339
|
lines.push(
|
|
331
340
|
`| [${a.num}](${a.path}) | ${title} | ` +
|
|
332
341
|
`${a['status'] ?? '—'} | ${a['date'] ?? '—'} | ` +
|
|
342
|
+
`${a['provenance_kind'] ?? '—'} | ${a['evidence_grade'] ?? '—'} | ` +
|
|
333
343
|
`${a['supersedes'] ?? '—'} |`,
|
|
334
344
|
);
|
|
335
345
|
}
|
|
336
346
|
if (adrs.length === 0) {
|
|
337
|
-
lines.push('| _none yet_ | — | — | — | — |');
|
|
347
|
+
lines.push('| _none yet_ | — | — | — | — | — | — |');
|
|
338
348
|
}
|
|
339
349
|
return lines.join('\n') + '\n';
|
|
340
350
|
}
|
|
@@ -411,9 +421,26 @@ export function cmd_check(): number {
|
|
|
411
421
|
process.stderr.write(`❌ ${e}\n`);
|
|
412
422
|
hard += 1;
|
|
413
423
|
}
|
|
414
|
-
|
|
424
|
+
const readmePath = path.join(ADR_ROOT, area, 'README.md');
|
|
425
|
+
if (adrs.length > 0 && !_exists(readmePath)) {
|
|
415
426
|
process.stderr.write(`❌ ${area}/: README.md missing\n`);
|
|
416
427
|
hard += 1;
|
|
428
|
+
} else if (_exists(readmePath)) {
|
|
429
|
+
// Existence was the whole check until 2026-08-22, and the six
|
|
430
|
+
// committed READMEs proved what that costs: every one of them had
|
|
431
|
+
// been generated before the shared frontmatter reader landed, so
|
|
432
|
+
// they rendered `—` for `status` and `date` on ADRs that carry
|
|
433
|
+
// both — stale for months against a gate reporting zero failures.
|
|
434
|
+
// A generated file with no freshness gate is a file nobody
|
|
435
|
+
// regenerates.
|
|
436
|
+
const rendered = render_area_readme(area, meta, adrs);
|
|
437
|
+
if (fs.readFileSync(readmePath, 'utf-8') !== rendered) {
|
|
438
|
+
process.stderr.write(
|
|
439
|
+
`❌ ${area}/README.md is stale — regenerate with ` +
|
|
440
|
+
`\`audit_adr_coverage --regen-area-readme ${area}\`\n`,
|
|
441
|
+
);
|
|
442
|
+
hard += 1;
|
|
443
|
+
}
|
|
417
444
|
}
|
|
418
445
|
if (adrs.length === 0) {
|
|
419
446
|
process.stderr.write(`⚠️ ${area}/: no bootstrap ADR yet (contract: ${meta.contract})\n`);
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* A council model pin cannot notice its own age. This gate is what notices.
|
|
4
|
+
*
|
|
5
|
+
* THE DEFECT IT EXISTS FOR. The starter council config shipped a dated,
|
|
6
|
+
* several-generation-old model id on an **enabled** member — the seat a quality
|
|
7
|
+
* council actually reviews with — and nothing in the file could flag it: a grep
|
|
8
|
+
* for `verified_at` or `stale` over the template returned two prose comments and
|
|
9
|
+
* no key. The pin was unversioned and unstamped, so it rotted silently for as
|
|
10
|
+
* long as nobody happened to re-read a template.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT DOES NOT DO, and this is the honest half: it cannot tell whether a
|
|
13
|
+
* pinned id is current. That needs the provider's own listing, which lives
|
|
14
|
+
* outside this tree and changes on the provider's schedule. It reports how long
|
|
15
|
+
* ago a **human** last said they looked. A stamp is a claim about attention, not
|
|
16
|
+
* about correctness, and treating it as the second would be worse than no stamp.
|
|
17
|
+
*
|
|
18
|
+
* SENTINELS ARE EXEMPT, BY DESIGN. A member on `codex-default`, or on a vendor
|
|
19
|
+
* alias the provider documents as "the latest model" in a band (`claude --help`:
|
|
20
|
+
* 'fable', 'opus', 'sonnet'), delegates the version decision to the vendor CLI
|
|
21
|
+
* and cannot go stale. Demanding a date there would be asking for a refresh
|
|
22
|
+
* nobody would ever perform, which is how a gate teaches its reader to ignore
|
|
23
|
+
* it.
|
|
24
|
+
*
|
|
25
|
+
* Mirrors `check_corpus_staleness` rather than inventing a second idiom:
|
|
26
|
+
* offline, deterministic, and the clock is INJECTABLE via `--today YYYY-MM-DD`
|
|
27
|
+
* so a fixture cannot flip verdict with the calendar. A future date is its own
|
|
28
|
+
* violation class — a stamp ahead of today is not "very fresh", it is a typo or
|
|
29
|
+
* a fabrication, and collapsing it into the fresh path would hide exactly the
|
|
30
|
+
* case worth catching.
|
|
31
|
+
*
|
|
32
|
+
* Exit: 0 clean or advisory-only, 1 on a violation, 2 on a usage error
|
|
33
|
+
* (unparsable config, bad --today, or a dead scan scope).
|
|
34
|
+
*/
|
|
35
|
+
import * as fs from 'node:fs';
|
|
36
|
+
import * as os from 'node:os';
|
|
37
|
+
import * as path from 'node:path';
|
|
38
|
+
|
|
39
|
+
import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
|
|
40
|
+
import { runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
|
|
41
|
+
|
|
42
|
+
/** Days before a stamped pin is called stale. */
|
|
43
|
+
export const CADENCE_DAYS = 100;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Vendor sentinels and "latest in band" aliases — a member on one of these
|
|
47
|
+
* cannot go stale, so it needs no stamp.
|
|
48
|
+
*
|
|
49
|
+
* The alias set is READ FROM the provider's own CLI surface (`claude --help`
|
|
50
|
+
* documents 'fable', 'opus' and 'sonnet' as aliases for the latest model), not
|
|
51
|
+
* from the agent's recall. That distinction is the one the roadmap behind this
|
|
52
|
+
* gate names as its top risk: a plausible-sounding id typed from memory is
|
|
53
|
+
* indistinguishable from a real one at review time.
|
|
54
|
+
*/
|
|
55
|
+
export const SENTINEL_MODELS: ReadonlySet<string> = new Set([
|
|
56
|
+
'codex-default',
|
|
57
|
+
'fable',
|
|
58
|
+
'opus',
|
|
59
|
+
'sonnet',
|
|
60
|
+
'haiku',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
export type Verdict = 'fresh' | 'sentinel' | 'unstamped' | 'stale' | 'future-date' | 'malformed';
|
|
64
|
+
|
|
65
|
+
export interface PinRow {
|
|
66
|
+
member: string;
|
|
67
|
+
model: string;
|
|
68
|
+
verified_at: string | null;
|
|
69
|
+
enabled: boolean;
|
|
70
|
+
verdict: Verdict;
|
|
71
|
+
age_days: number | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
75
|
+
|
|
76
|
+
/** Parse a `YYYY-MM-DD` into a UTC epoch-day, or null when it is not a real date. */
|
|
77
|
+
export function epochDay(s: string): number | null {
|
|
78
|
+
const m = DATE_RE.exec(s);
|
|
79
|
+
if (m === null) return null;
|
|
80
|
+
const y = Number(m[1]);
|
|
81
|
+
const mo = Number(m[2]);
|
|
82
|
+
const d = Number(m[3]);
|
|
83
|
+
const t = Date.UTC(y, mo - 1, d);
|
|
84
|
+
const probe = new Date(t);
|
|
85
|
+
if (probe.getUTCFullYear() !== y || probe.getUTCMonth() + 1 !== mo || probe.getUTCDate() !== d) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return Math.floor(t / 86400000);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Classify one member. Pure — the unit under `--self-test`. */
|
|
92
|
+
export function classify(
|
|
93
|
+
member: string,
|
|
94
|
+
model: string,
|
|
95
|
+
verified_at: string | null,
|
|
96
|
+
enabled: boolean,
|
|
97
|
+
today: string,
|
|
98
|
+
): PinRow {
|
|
99
|
+
const base: Omit<PinRow, 'verdict' | 'age_days'> = { member, model, verified_at, enabled };
|
|
100
|
+
if (SENTINEL_MODELS.has(model.trim().toLowerCase())) {
|
|
101
|
+
return { ...base, verdict: 'sentinel', age_days: null };
|
|
102
|
+
}
|
|
103
|
+
if (verified_at === null || verified_at.trim() === '') {
|
|
104
|
+
return { ...base, verdict: 'unstamped', age_days: null };
|
|
105
|
+
}
|
|
106
|
+
const stamp = epochDay(verified_at);
|
|
107
|
+
if (stamp === null) return { ...base, verdict: 'malformed', age_days: null };
|
|
108
|
+
const now = epochDay(today);
|
|
109
|
+
if (now === null) throw new Error(`--today must be a real YYYY-MM-DD date (got ${today})`);
|
|
110
|
+
const age = now - stamp;
|
|
111
|
+
if (age < 0) return { ...base, verdict: 'future-date', age_days: age };
|
|
112
|
+
return { ...base, verdict: age > CADENCE_DAYS ? 'stale' : 'fresh', age_days: age };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read the members out of a council YAML without a YAML dependency.
|
|
117
|
+
*
|
|
118
|
+
* Deliberately line-based and deliberately narrow: it reads `members:` two
|
|
119
|
+
* levels deep, which is the whole shape this gate needs. A full parser would
|
|
120
|
+
* pull a dependency into a gate whose entire job is to be runnable offline in
|
|
121
|
+
* any checkout.
|
|
122
|
+
*/
|
|
123
|
+
export function parseMembers(text: string): { member: string; model: string; verified_at: string | null; enabled: boolean }[] {
|
|
124
|
+
const lines = text.split('\n');
|
|
125
|
+
const out: { member: string; model: string; verified_at: string | null; enabled: boolean }[] = [];
|
|
126
|
+
let inMembers = false;
|
|
127
|
+
let cur: { member: string; model: string; verified_at: string | null; enabled: boolean } | null = null;
|
|
128
|
+
for (const raw of lines) {
|
|
129
|
+
const line = raw.replace(/\t/g, ' ');
|
|
130
|
+
if (/^members:\s*$/.test(line)) {
|
|
131
|
+
inMembers = true;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (!inMembers) continue;
|
|
135
|
+
// A non-indented, non-empty, non-comment line ends the block.
|
|
136
|
+
if (line.trim() !== '' && !line.startsWith(' ') && !line.trimStart().startsWith('#')) break;
|
|
137
|
+
const head = /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line);
|
|
138
|
+
if (head !== null) {
|
|
139
|
+
if (cur !== null) out.push(cur);
|
|
140
|
+
cur = { member: String(head[1]), model: '', verified_at: null, enabled: false };
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (cur === null) continue;
|
|
144
|
+
const kv = /^ {4}([a-z_]+):\s*(.+?)\s*$/.exec(line);
|
|
145
|
+
if (kv === null) continue;
|
|
146
|
+
const k = String(kv[1]);
|
|
147
|
+
const v = String(kv[2]).replace(/^['"]|['"]$/g, '');
|
|
148
|
+
if (k === 'model') cur.model = v;
|
|
149
|
+
else if (k === 'verified_at') cur.verified_at = v;
|
|
150
|
+
else if (k === 'enabled') cur.enabled = v === 'true';
|
|
151
|
+
}
|
|
152
|
+
if (cur !== null) out.push(cur);
|
|
153
|
+
return out.filter((m) => m.model !== '');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function selfTest(): number {
|
|
157
|
+
const T = '2026-08-22';
|
|
158
|
+
let seq = 0;
|
|
159
|
+
// Each case drives the REAL gate over a real fixture, so what is asserted is
|
|
160
|
+
// the exit code rather than an internal verdict. Reject == the gate refuses.
|
|
161
|
+
const probe = (body: string): number => {
|
|
162
|
+
seq += 1;
|
|
163
|
+
const file = path.join(os.tmpdir(), `pin-staleness-selftest-${String(process.pid)}-${String(seq)}.yml`);
|
|
164
|
+
fs.writeFileSync(file, body, 'utf-8');
|
|
165
|
+
try {
|
|
166
|
+
return main(['--file', file, '--today', T]);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// Mirror the entrypoint: a dead scan scope exits 2 there, so a case
|
|
169
|
+
// asserting "must not pass" has to observe the same code rather than
|
|
170
|
+
// an exception the harness would read as a broken test.
|
|
171
|
+
if (err instanceof DeadScopeError) return 2;
|
|
172
|
+
throw err;
|
|
173
|
+
} finally {
|
|
174
|
+
fs.rmSync(file, { force: true });
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const member = (model: string, stamp: string | null): string =>
|
|
178
|
+
`members:\n m:\n enabled: true\n model: ${model}\n` +
|
|
179
|
+
(stamp === null ? '' : ` verified_at: "${stamp}"\n`);
|
|
180
|
+
|
|
181
|
+
const cases: SelfTestCase[] = [
|
|
182
|
+
{ name: 'a sentinel needs no stamp', expect: 'accept', run: () => probe(member('codex-default', null)) },
|
|
183
|
+
{ name: 'a vendor alias needs no stamp', expect: 'accept', run: () => probe(member('sonnet', null)) },
|
|
184
|
+
{
|
|
185
|
+
name: 'a dated pin with no stamp is advisory, not a failure',
|
|
186
|
+
expect: 'accept',
|
|
187
|
+
run: () => probe(member('gemini-2.5-pro', null)),
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: 'a stamp inside the cadence is fresh',
|
|
191
|
+
expect: 'accept',
|
|
192
|
+
run: () => probe(member('gemini-2.5-pro', '2026-08-01')),
|
|
193
|
+
},
|
|
194
|
+
// 100 exactly is fresh; 101 is not. A cadence met on the day it comes
|
|
195
|
+
// due is met — the same boundary `check_corpus_staleness` uses.
|
|
196
|
+
{
|
|
197
|
+
name: 'the cadence boundary is inclusive',
|
|
198
|
+
expect: 'accept',
|
|
199
|
+
run: () => probe(member('gemini-2.5-pro', '2026-05-14')),
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: 'a stamp past the cadence is refused',
|
|
203
|
+
expect: 'reject',
|
|
204
|
+
run: () => probe(member('gemini-2.5-pro', '2026-01-01')),
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: 'a future stamp is refused, not read as very fresh',
|
|
208
|
+
expect: 'reject',
|
|
209
|
+
run: () => probe(member('gemini-2.5-pro', '2027-01-01')),
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: 'an impossible date is refused, not treated as stale',
|
|
213
|
+
expect: 'reject',
|
|
214
|
+
run: () => probe(member('gemini-2.5-pro', '2026-02-30')),
|
|
215
|
+
},
|
|
216
|
+
{ name: 'a non-date is refused', expect: 'reject', run: () => probe(member('gemini-2.5-pro', 'soon')) },
|
|
217
|
+
{
|
|
218
|
+
name: 'a member with no model: leaves an empty scan and must not pass',
|
|
219
|
+
expect: 'reject',
|
|
220
|
+
run: () => probe('members:\n m:\n enabled: true\n'),
|
|
221
|
+
},
|
|
222
|
+
];
|
|
223
|
+
return runSelfTest({
|
|
224
|
+
gate: 'check_council_pin_staleness',
|
|
225
|
+
cases,
|
|
226
|
+
minCases: 10,
|
|
227
|
+
minRejectCases: 5,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function main(argv: string[]): number {
|
|
232
|
+
if (argv.includes('--self-test')) return selfTest();
|
|
233
|
+
const ti = argv.indexOf('--today');
|
|
234
|
+
const today =
|
|
235
|
+
ti >= 0 && argv[ti + 1] !== undefined
|
|
236
|
+
? String(argv[ti + 1])
|
|
237
|
+
: new Date().toISOString().slice(0, 10);
|
|
238
|
+
if (epochDay(today) === null) {
|
|
239
|
+
process.stderr.write(`--today must be a real YYYY-MM-DD date (got ${today})\n`);
|
|
240
|
+
return 2;
|
|
241
|
+
}
|
|
242
|
+
const fi = argv.indexOf('--file');
|
|
243
|
+
const file =
|
|
244
|
+
fi >= 0 && argv[fi + 1] !== undefined
|
|
245
|
+
? String(argv[fi + 1])
|
|
246
|
+
: path.join(process.cwd(), 'agents/templates/.ai-council.yml.example');
|
|
247
|
+
if (!fs.existsSync(file)) {
|
|
248
|
+
process.stderr.write(`no council config at ${file}\n`);
|
|
249
|
+
return 2;
|
|
250
|
+
}
|
|
251
|
+
const members = parseMembers(fs.readFileSync(file, 'utf-8'));
|
|
252
|
+
const rows = members.map((m) => classify(m.member, m.model, m.verified_at, m.enabled, today));
|
|
253
|
+
const bad = rows.filter((r) => r.verdict === 'stale' || r.verdict === 'future-date' || r.verdict === 'malformed');
|
|
254
|
+
const unstamped = rows.filter((r) => r.verdict === 'unstamped');
|
|
255
|
+
|
|
256
|
+
for (const r of rows) {
|
|
257
|
+
const age = r.age_days === null ? '—' : `${r.age_days}d`;
|
|
258
|
+
process.stdout.write(
|
|
259
|
+
` ${r.verdict.padEnd(11)} ${r.member.padEnd(12)} ${r.model.padEnd(20)} ${age}${r.enabled ? '' : ' (disabled)'}\n`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
// Assert the scope and publish the count in one call: a dead scan scope is a
|
|
263
|
+
// usage error, never a pass, and "0 stale pins" over 0 members read is the
|
|
264
|
+
// failure this separates. reportScanned emits the number the assertion just
|
|
265
|
+
// accepted, so the published count cannot drift from the validated one.
|
|
266
|
+
reportScanned({
|
|
267
|
+
gate: 'check_council_pin_staleness',
|
|
268
|
+
scanned: rows.length,
|
|
269
|
+
units: 'council member(s) with a model:',
|
|
270
|
+
roots: [file],
|
|
271
|
+
});
|
|
272
|
+
if (bad.length > 0) {
|
|
273
|
+
process.stdout.write(
|
|
274
|
+
`❌ ${bad.length} pin(s) need attention (cadence ${CADENCE_DAYS}d). A stamp records when a human last\n` +
|
|
275
|
+
` checked the pin against the provider's own surface — refresh the pin or the date, and prefer a\n` +
|
|
276
|
+
` vendor sentinel where one exists: a sentinel cannot go stale, so it never needs a stamp.\n`,
|
|
277
|
+
);
|
|
278
|
+
return 1;
|
|
279
|
+
}
|
|
280
|
+
if (unstamped.length > 0) {
|
|
281
|
+
process.stdout.write(
|
|
282
|
+
`⚠️ ${unstamped.length} dated pin(s) carry no verified_at — advisory. Add a stamp, or move the member to a\n` +
|
|
283
|
+
` vendor sentinel and it drops out of this gate entirely.\n`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
process.stdout.write('✅ council model pins within cadence.\n');
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (process.argv[1] !== undefined && process.argv[1].includes('check_council_pin_staleness')) {
|
|
291
|
+
try {
|
|
292
|
+
process.exit(main(process.argv.slice(2)));
|
|
293
|
+
} catch (err) {
|
|
294
|
+
if (err instanceof DeadScopeError) {
|
|
295
|
+
process.stderr.write(`${err.message}\n`);
|
|
296
|
+
process.exit(2);
|
|
297
|
+
}
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
}
|