@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.
Files changed (170) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +214 -237
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +34 -4
  5. package/dist/agent-src/commands/pr/merge.md +9 -5
  6. package/dist/agent-src/commands/review/changes.md +95 -14
  7. package/dist/agent-src/commands/roadmap/process-full.md +2 -2
  8. package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
  9. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +36 -0
  10. package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
  11. package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
  12. package/dist/agent-src/skills/async-python-patterns/SKILL.md +1 -1
  13. package/dist/agent-src/skills/code-refactoring/SKILL.md +1 -1
  14. package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
  15. package/dist/agent-src/skills/condense-memory/SKILL.md +1 -1
  16. package/dist/agent-src/skills/conventional-commits-writing/SKILL.md +1 -1
  17. package/dist/agent-src/skills/design-review/SKILL.md +1 -1
  18. package/dist/agent-src/skills/discovery-interview/SKILL.md +1 -1
  19. package/dist/agent-src/skills/doc-coauthoring/SKILL.md +1 -1
  20. package/dist/agent-src/skills/docx-authoring/SKILL.md +1 -1
  21. package/dist/agent-src/skills/editorial-calendar/SKILL.md +1 -1
  22. package/dist/agent-src/skills/github-ci/SKILL.md +1 -1
  23. package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
  24. package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
  25. package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
  26. package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +1 -1
  27. package/dist/agent-src/skills/judge-injection-defense/SKILL.md +1 -1
  28. package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
  29. package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
  30. package/dist/agent-src/skills/laravel-validation/SKILL.md +1 -1
  31. package/dist/agent-src/skills/legal-intake-triage/SKILL.md +1 -1
  32. package/dist/agent-src/skills/legal-practice-profile/SKILL.md +1 -1
  33. package/dist/agent-src/skills/messaging-architecture/SKILL.md +1 -1
  34. package/dist/agent-src/skills/migration-architect/SKILL.md +1 -1
  35. package/dist/agent-src/skills/module-detect-on-the-fly/SKILL.md +1 -1
  36. package/dist/agent-src/skills/module-management/SKILL.md +1 -1
  37. package/dist/agent-src/skills/motion-choreographer/SKILL.md +1 -1
  38. package/dist/agent-src/skills/onboarding-program/SKILL.md +1 -1
  39. package/dist/agent-src/skills/perf-feedback-craft/SKILL.md +1 -1
  40. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +1 -1
  41. package/dist/agent-src/skills/receiving-code-review/SKILL.md +1 -1
  42. package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
  43. package/dist/agent-src/skills/scene-expander/SKILL.md +1 -1
  44. package/dist/agent-src/skills/secrets-management/SKILL.md +1 -1
  45. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +1 -1
  46. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  47. package/dist/agent-src/templates/roadmaps.md +8 -0
  48. package/dist/cli/registry.js +2 -0
  49. package/dist/cli/registry.js.map +1 -1
  50. package/dist/cli-delegate/{chunk-MMSGVZKV.js → chunk-77GR3VD4.js} +65 -40
  51. package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
  52. package/dist/cli-delegate/{chunk-EEJUMCDZ.js → chunk-PLSG6UL5.js} +1 -1
  53. package/dist/cli-delegate/cmd_conformance.js +89 -13
  54. package/dist/cli-delegate/cmd_doctor.js +3 -3
  55. package/dist/cli-delegate/cmd_handoff.js +2 -2
  56. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  57. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  58. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  59. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  60. package/dist/discovery/deprecation-report.md +1 -1
  61. package/dist/discovery/discovery-manifest.json +64 -42
  62. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  63. package/dist/discovery/discovery-manifest.summary.md +3 -3
  64. package/dist/discovery/dormancy-report.md +1 -1
  65. package/dist/discovery/orphan-report.md +1 -1
  66. package/dist/discovery/packs.json +6 -5
  67. package/dist/discovery/trust-report.md +2 -2
  68. package/dist/discovery/workspaces.json +5 -4
  69. package/dist/hooks/dispatch.js +562 -204
  70. package/dist/mcp/registry-manifest.json +3 -3
  71. package/dist/mcp/server.json +2 -2
  72. package/dist/mcp/server.mjs +808 -331
  73. package/dist/scripts/_lib/claude_plugin.js +162 -0
  74. package/dist/scripts/_lib/claude_plugin.js.map +1 -0
  75. package/dist/scripts/_lib/environment_detector.js +397 -0
  76. package/dist/scripts/_lib/environment_detector.js.map +1 -0
  77. package/dist/scripts/_lib/spawn_env.js +129 -0
  78. package/dist/scripts/_lib/spawn_env.js.map +1 -0
  79. package/dist/scripts/ai_council/modes.js +199 -0
  80. package/dist/scripts/ai_council/modes.js.map +1 -0
  81. package/dist/scripts/ai_council/transport_resolver.js +418 -0
  82. package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
  83. package/dist/server/routes/wizard.js +3 -2
  84. package/dist/server/routes/wizard.js.map +1 -1
  85. package/dist/ui/assets/index-Cxgy63kz.js +26 -0
  86. package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
  87. package/dist/ui/index.html +1 -1
  88. package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
  89. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
  90. package/package.json +7 -3
  91. package/src/config/estate-count-budget.json +13 -744
  92. package/src/config/evaluator-budgets.json +4 -4
  93. package/src/config/gate-coverage.yml +267 -9
  94. package/src/config/gate-violation-baselines.json +72 -72
  95. package/src/config/preamble-payload-budget.json +71 -67
  96. package/src/config/publish-surface.json +60 -0
  97. package/src/config/requirements-trace-budget.json +32 -0
  98. package/src/config/rule-stub-ceilings.json +8 -1
  99. package/src/scripts/_dispatch.bash +7 -0
  100. package/src/scripts/_lib/base_tree.ts +175 -0
  101. package/src/scripts/_lib/billing_grant.ts +165 -0
  102. package/src/scripts/_lib/billing_grant_cli.ts +123 -0
  103. package/src/scripts/_lib/council_fallback_posture.ts +53 -6
  104. package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
  105. package/src/scripts/_lib/host_capability.ts +59 -5
  106. package/src/scripts/_lib/quota_parked.ts +133 -0
  107. package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
  108. package/src/scripts/_lib/review_telemetry.ts +196 -0
  109. package/src/scripts/_lib/subagent_response.ts +92 -0
  110. package/src/scripts/adr/regenerate_index.ts +14 -7
  111. package/src/scripts/ai_council/clients.ts +12 -1
  112. package/src/scripts/ai_council/config.ts +72 -115
  113. package/src/scripts/ai_council/fallback_config.ts +19 -7
  114. package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
  115. package/src/scripts/ai_council/orchestrator.ts +2 -2
  116. package/src/scripts/ai_council/py_format.ts +124 -0
  117. package/src/scripts/ai_council/seating.ts +211 -0
  118. package/src/scripts/ai_council/transport_resolver.ts +65 -2
  119. package/src/scripts/audit_adr_coverage.ts +33 -6
  120. package/src/scripts/check_council_pin_staleness.ts +300 -0
  121. package/src/scripts/check_estate_count.ts +420 -293
  122. package/src/scripts/check_gate_coverage.ts +91 -0
  123. package/src/scripts/check_no_stub_inventory_table.ts +292 -0
  124. package/src/scripts/check_pack_passport_reconciliation.ts +274 -0
  125. package/src/scripts/check_pack_size.ts +234 -1
  126. package/src/scripts/check_publish_surface.ts +297 -0
  127. package/src/scripts/check_requirements_trace.ts +378 -0
  128. package/src/scripts/check_secret_leak.ts +166 -12
  129. package/src/scripts/code_graph/cli.ts +44 -5
  130. package/src/scripts/code_graph/extract.ts +87 -0
  131. package/src/scripts/council_cli.ts +12 -16
  132. package/src/scripts/detect_ai_tells.ts +87 -0
  133. package/src/scripts/dispatch_r2_reviewer.ts +42 -0
  134. package/src/scripts/generate_capabilities_index.ts +41 -0
  135. package/src/scripts/generate_pack_manifests.ts +78 -0
  136. package/src/scripts/git_authorization_hook.ts +54 -4
  137. package/src/scripts/hooks/block_unauthorized_git.ts +19 -1
  138. package/src/scripts/hooks/run_continuation_hook.ts +3 -1
  139. package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
  140. package/src/scripts/injection_scan_hook.ts +120 -10
  141. package/src/scripts/lint_adr_sweep_routing.ts +365 -0
  142. package/src/scripts/lint_workflow_security.ts +53 -10
  143. package/src/scripts/lint_workflow_security_allowlist.json +2 -18
  144. package/src/scripts/report_envelope_rate.ts +166 -0
  145. package/src/scripts/review_axis_report.ts +218 -0
  146. package/src/scripts/run_supervise.ts +46 -1
  147. package/src/scripts/schemas/evals.schema.json +5 -0
  148. package/src/scripts/schemas/pack.schema.json +62 -4
  149. package/src/scripts/schemas/skill.schema.json +2 -2
  150. package/src/scripts/sync_pr_branch.ts +14 -7
  151. package/src/scripts/tighten_skill_descriptions.ts +264 -0
  152. package/dist/cli/commands/commands.candidates.test.js +0 -257
  153. package/dist/cli/commands/commands.candidates.test.js.map +0 -1
  154. package/dist/cli/commands/profiles.test.js +0 -88
  155. package/dist/cli/commands/profiles.test.js.map +0 -1
  156. package/dist/cli/commands/recordTriggerEval.test.js +0 -113
  157. package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
  158. package/dist/cli/configRoot.test.js +0 -97
  159. package/dist/cli/configRoot.test.js.map +0 -1
  160. package/dist/cli/mcp/content.test.js +0 -54
  161. package/dist/cli/mcp/content.test.js.map +0 -1
  162. package/dist/cli/mcp/dispatch.test.js +0 -108
  163. package/dist/cli/mcp/dispatch.test.js.map +0 -1
  164. package/dist/server/writeRoot.test.js +0 -91
  165. package/dist/server/writeRoot.test.js.map +0 -1
  166. package/dist/shared/capabilities.test.js +0 -39
  167. package/dist/shared/capabilities.test.js.map +0 -1
  168. package/dist/ui/assets/index-C1pBRm1f.js +0 -26
  169. package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
  170. /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: boolean;
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.apiOnQuota && failure === 'quota_exhausted';
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 fm = read_adr_meta(fs.readFileSync(p, 'utf-8'));
283
- adrs.push({ num: m[1] as string, slug: m[2] as string, path: base, ...fm });
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('', '| # | Title | Status | Date | Supersedes |', '|---|---|---|---|---|');
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
- if (adrs.length > 0 && !_exists(path.join(ADR_ROOT, area, 'README.md'))) {
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
+ }