@event4u/agent-config 14.7.0 → 14.9.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 (230) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +175 -390
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +34 -4
  5. package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
  6. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  7. package/dist/agent-src/commands/pr/create.md +14 -2
  8. package/dist/agent-src/commands/pr/merge.md +383 -0
  9. package/dist/agent-src/commands/review/changes.md +95 -14
  10. package/dist/agent-src/commands/roadmap/process-full.md +157 -3
  11. package/dist/agent-src/commands/roadmap.md +1 -1
  12. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
  13. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
  14. package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
  15. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +100 -2
  16. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
  17. package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
  18. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  19. package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
  20. package/dist/agent-src/rules/recurring-criticism.md +1 -1
  21. package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
  22. package/dist/agent-src/rules/source-confidentiality.md +1 -1
  23. package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
  24. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  25. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  26. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
  27. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  28. package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
  29. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  30. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  31. package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
  32. package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
  33. package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
  34. package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
  35. package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
  36. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  37. package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
  38. package/dist/agent-src/skills/security/SKILL.md +8 -0
  39. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  40. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  41. package/dist/agent-src/templates/roadmaps.md +28 -2
  42. package/dist/cli/registry.js +3 -0
  43. package/dist/cli/registry.js.map +1 -1
  44. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-77GR3VD4.js} +68 -43
  45. package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
  46. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-CYH5SP6M.js} +4 -23
  47. package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
  48. package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
  49. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  50. package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
  51. package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-PLSG6UL5.js} +14 -14
  52. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  53. package/dist/cli-delegate/cmd_conformance.js +101 -25
  54. package/dist/cli-delegate/cmd_converge.js +3 -3
  55. package/dist/cli-delegate/cmd_doctor.js +7 -7
  56. package/dist/cli-delegate/cmd_export.js +6 -6
  57. package/dist/cli-delegate/cmd_handoff.js +7 -6
  58. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  59. package/dist/cli-delegate/cmd_migrate.js +2 -2
  60. package/dist/cli-delegate/cmd_preflight.js +5 -5
  61. package/dist/cli-delegate/cmd_prune.js +6 -6
  62. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  63. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  64. package/dist/cli-delegate/cmd_session_recycle.js +3 -3
  65. package/dist/cli-delegate/cmd_settings_get.js +4 -4
  66. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  67. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  68. package/dist/cli-delegate/cmd_sync.js +6 -6
  69. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  70. package/dist/cli-delegate/cmd_update.js +3 -3
  71. package/dist/cli-delegate/cmd_upgrade.js +3 -3
  72. package/dist/cli-delegate/cmd_validate.js +6 -6
  73. package/dist/cli-delegate/cmd_versions.js +3 -3
  74. package/dist/discovery/deprecation-report.md +1 -1
  75. package/dist/discovery/discovery-manifest.json +83 -30
  76. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  77. package/dist/discovery/discovery-manifest.summary.md +4 -4
  78. package/dist/discovery/dormancy-report.md +1 -1
  79. package/dist/discovery/orphan-report.md +1 -1
  80. package/dist/discovery/packs.json +11 -9
  81. package/dist/discovery/trust-report.md +2 -2
  82. package/dist/discovery/workspaces.json +9 -7
  83. package/dist/hooks/dispatch.js +1295 -812
  84. package/dist/install/globalRuleLayers.js +169 -0
  85. package/dist/install/globalRuleLayers.js.map +1 -0
  86. package/dist/install/partitionEligibility.js +156 -0
  87. package/dist/install/partitionEligibility.js.map +1 -1
  88. package/dist/install/ruleLayerPartition.js +157 -0
  89. package/dist/install/ruleLayerPartition.js.map +1 -0
  90. package/dist/mcp/registry-manifest.json +3 -3
  91. package/dist/mcp/server.json +2 -2
  92. package/dist/mcp/server.mjs +1358 -805
  93. package/dist/scripts/_lib/claude_plugin.js +162 -0
  94. package/dist/scripts/_lib/claude_plugin.js.map +1 -0
  95. package/dist/scripts/_lib/environment_detector.js +397 -0
  96. package/dist/scripts/_lib/environment_detector.js.map +1 -0
  97. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  98. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  99. package/dist/scripts/_lib/spawn_env.js +129 -0
  100. package/dist/scripts/_lib/spawn_env.js.map +1 -0
  101. package/dist/scripts/ai_council/modes.js +199 -0
  102. package/dist/scripts/ai_council/modes.js.map +1 -0
  103. package/dist/scripts/ai_council/transport_resolver.js +418 -0
  104. package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
  105. package/dist/scripts/condense.js +52 -89
  106. package/dist/scripts/condense.js.map +1 -1
  107. package/dist/server/routes/wizard.js +3 -2
  108. package/dist/server/routes/wizard.js.map +1 -1
  109. package/dist/ui/assets/index-Cxgy63kz.js +26 -0
  110. package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
  111. package/dist/ui/index.html +1 -1
  112. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  113. package/docs/guidelines/agent-infra/false-green.md +47 -1
  114. package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
  115. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
  116. package/package.json +7 -3
  117. package/src/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  118. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  119. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
  120. package/src/config/ci-local-parity.yml +21 -0
  121. package/src/config/estate-count-budget.json +13 -471
  122. package/src/config/evaluator-budgets.json +4 -4
  123. package/src/config/gate-coverage.yml +458 -12
  124. package/src/config/gate-violation-baselines.json +18 -8
  125. package/src/config/gitignore-block.txt +15 -2
  126. package/src/config/hook-latency-budget.json +5 -0
  127. package/src/config/publish-surface.json +60 -0
  128. package/src/config/requirements-trace-budget.json +32 -0
  129. package/src/config/rule-stub-ceilings.json +25 -4
  130. package/src/install/globalRuleLayers.ts +197 -0
  131. package/src/install/partitionEligibility.ts +177 -0
  132. package/src/install/ruleLayerPartition.ts +212 -0
  133. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  134. package/src/scripts/_dispatch.bash +18 -0
  135. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  136. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  137. package/src/scripts/_lib/base_tree.ts +175 -0
  138. package/src/scripts/_lib/billing_grant.ts +165 -0
  139. package/src/scripts/_lib/billing_grant_cli.ts +123 -0
  140. package/src/scripts/_lib/council_fallback_posture.ts +53 -6
  141. package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
  142. package/src/scripts/_lib/envelope_grounding.ts +11 -1
  143. package/src/scripts/_lib/host_capability.ts +59 -5
  144. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  145. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  146. package/src/scripts/_lib/quota_parked.ts +133 -0
  147. package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
  148. package/src/scripts/_lib/review_telemetry.ts +196 -0
  149. package/src/scripts/_lib/subagent_response.ts +92 -0
  150. package/src/scripts/adr/evidence_census.ts +899 -0
  151. package/src/scripts/adr/regenerate_index.ts +25 -38
  152. package/src/scripts/adr_cite_check.ts +540 -39
  153. package/src/scripts/ai_council/clients.ts +12 -1
  154. package/src/scripts/ai_council/config.ts +72 -115
  155. package/src/scripts/ai_council/fallback_config.ts +19 -7
  156. package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
  157. package/src/scripts/ai_council/orchestrator.ts +2 -2
  158. package/src/scripts/ai_council/py_format.ts +124 -0
  159. package/src/scripts/ai_council/transport_resolver.ts +65 -2
  160. package/src/scripts/audit_adr_coverage.ts +134 -21
  161. package/src/scripts/bench_hook_latency.ts +204 -0
  162. package/src/scripts/build_archive_index.ts +34 -0
  163. package/src/scripts/check_adr_frontmatter.ts +574 -39
  164. package/src/scripts/check_council_pin_staleness.ts +300 -0
  165. package/src/scripts/check_estate_count.ts +420 -293
  166. package/src/scripts/check_gate_coverage.ts +91 -0
  167. package/src/scripts/check_hook_bundle_content.ts +219 -0
  168. package/src/scripts/check_new_adr_evidence.ts +529 -0
  169. package/src/scripts/check_no_stub_inventory_table.ts +292 -0
  170. package/src/scripts/check_pack_size.ts +234 -1
  171. package/src/scripts/check_publish_surface.ts +297 -0
  172. package/src/scripts/check_references.ts +18 -0
  173. package/src/scripts/check_requirements_trace.ts +378 -0
  174. package/src/scripts/check_rule_layer_partition.ts +531 -0
  175. package/src/scripts/check_secret_leak.ts +166 -12
  176. package/src/scripts/check_single_delivery.ts +137 -4
  177. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  178. package/src/scripts/code_graph/cli.ts +44 -5
  179. package/src/scripts/code_graph/extract.ts +87 -0
  180. package/src/scripts/condense.ts +61 -91
  181. package/src/scripts/council_cli.ts +12 -16
  182. package/src/scripts/detect_ai_tells.ts +87 -0
  183. package/src/scripts/dispatch_r2_reviewer.ts +42 -0
  184. package/src/scripts/external_sources_denylist.json +4 -1
  185. package/src/scripts/generate_capabilities_index.ts +41 -0
  186. package/src/scripts/git_authorization_hook.ts +54 -4
  187. package/src/scripts/hooks/block_unauthorized_git.ts +41 -3
  188. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  189. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  190. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  191. package/src/scripts/hooks/host_semantics.ts +21 -0
  192. package/src/scripts/hooks/injection_budget.ts +13 -3
  193. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  194. package/src/scripts/hooks/run_continuation_hook.ts +3 -1
  195. package/src/scripts/hooks/state_io.ts +88 -1
  196. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  197. package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
  198. package/src/scripts/injection_scan_hook.ts +257 -15
  199. package/src/scripts/lint_adr_sweep_routing.ts +365 -0
  200. package/src/scripts/lint_hook_manifest.ts +49 -0
  201. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  202. package/src/scripts/lint_workflow_security.ts +53 -10
  203. package/src/scripts/lint_workflow_security_allowlist.json +2 -18
  204. package/src/scripts/report_envelope_rate.ts +166 -0
  205. package/src/scripts/review_axis_report.ts +218 -0
  206. package/src/scripts/routing_doctor.ts +5 -1
  207. package/src/scripts/run_supervise.ts +46 -1
  208. package/src/scripts/schemas/evals.schema.json +5 -0
  209. package/src/scripts/sync_pr_branch.ts +170 -20
  210. package/dist/cli/commands/commands.candidates.test.js +0 -257
  211. package/dist/cli/commands/commands.candidates.test.js.map +0 -1
  212. package/dist/cli/commands/profiles.test.js +0 -88
  213. package/dist/cli/commands/profiles.test.js.map +0 -1
  214. package/dist/cli/commands/recordTriggerEval.test.js +0 -113
  215. package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
  216. package/dist/cli/configRoot.test.js +0 -97
  217. package/dist/cli/configRoot.test.js.map +0 -1
  218. package/dist/cli/mcp/content.test.js +0 -54
  219. package/dist/cli/mcp/content.test.js.map +0 -1
  220. package/dist/cli/mcp/dispatch.test.js +0 -108
  221. package/dist/cli/mcp/dispatch.test.js.map +0 -1
  222. package/dist/server/writeRoot.test.js +0 -91
  223. package/dist/server/writeRoot.test.js.map +0 -1
  224. package/dist/shared/capabilities.test.js +0 -39
  225. package/dist/shared/capabilities.test.js.map +0 -1
  226. package/dist/ui/assets/index-C1pBRm1f.js +0 -26
  227. package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
  228. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  229. package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
  230. package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
@@ -148,7 +148,18 @@ export const CODEX_MEASURED_UNSERVABLE: ReadonlyMap<string, string> = new Map([
148
148
  ]);
149
149
 
150
150
  export const DEFAULT_OPENAI_CLI_MODEL = OPENAI_CLI_VENDOR_DEFAULT;
151
- export const DEFAULT_ANTHROPIC_CLI_MODEL = 'claude-sonnet-4-5';
151
+ // A vendor ALIAS, not a version. `claude --help` documents 'fable', 'opus' and
152
+ // 'sonnet' as "an alias for the latest model" in their band, so an alias cannot
153
+ // go stale the way a dated id can — which is the whole point of the change that
154
+ // put it here.
155
+ //
156
+ // It was `'claude-sonnet-4-5'`, and this line is the half the roadmap that
157
+ // found the defect did not know about: refreshing only the template's pin would
158
+ // have left every member that omits `model:` falling back to the SAME stale id
159
+ // from code. Read from the provider's own CLI surface on 2026-08-22, never from
160
+ // the agent's recall — the source that road-to-council-seat-selection's own
161
+ // Risk 1 declares inadmissible.
162
+ export const DEFAULT_ANTHROPIC_CLI_MODEL = 'sonnet';
152
163
  export const DEFAULT_GEMINI_CLI_MODEL = 'gemini-2.5-pro';
153
164
 
154
165
  // OpenAI-API-compatible endpoints. xAI and Perplexity both expose the
@@ -144,121 +144,6 @@ export class CouncilConfigError extends Error {
144
144
  }
145
145
  }
146
146
 
147
- // ── Python-format helpers (byte-faithful error messages) ───────────
148
-
149
- /** Python `repr()` for a string scalar (single-quoted, escaped). */
150
- function _pyReprStr(s: string): string {
151
- const hasSingle = s.includes("'");
152
- const hasDouble = s.includes('"');
153
- const quote = hasSingle && !hasDouble ? '"' : "'";
154
- let body = s
155
- .replace(/\\/g, '\\\\')
156
- .replace(/\n/g, '\\n')
157
- .replace(/\r/g, '\\r')
158
- .replace(/\t/g, '\\t');
159
- if (quote === "'") {
160
- body = body.replace(/'/g, "\\'");
161
- } else {
162
- body = body.replace(/"/g, '\\"');
163
- }
164
- return `${quote}${body}${quote}`;
165
- }
166
-
167
- /** Python `repr()` for a float value (shortest round-trip, `N.0` for ints). */
168
- function _pyReprFloat(value: number): string {
169
- if (Number.isInteger(value) && Number.isFinite(value)) {
170
- return `${value}.0`;
171
- }
172
- if (value === Infinity) {
173
- return 'inf';
174
- }
175
- if (value === -Infinity) {
176
- return '-inf';
177
- }
178
- if (Number.isNaN(value)) {
179
- return 'nan';
180
- }
181
- return String(value);
182
- }
183
-
184
- /**
185
- * Python `repr()` for an arbitrary parsed value. Floats are tracked via
186
- * `_FLOAT` so int-valued floats render `N.0`; bare numbers render as
187
- * Python ints (no decimal). Mirrors `{value!r}` formatting.
188
- */
189
- function _pyRepr(value: unknown): string {
190
- if (value instanceof _Float) {
191
- return _pyReprFloat(value.value);
192
- }
193
- if (value === null || value === undefined) {
194
- return 'None';
195
- }
196
- if (typeof value === 'boolean') {
197
- return value ? 'True' : 'False';
198
- }
199
- if (typeof value === 'string') {
200
- return _pyReprStr(value);
201
- }
202
- if (typeof value === 'number') {
203
- return String(value);
204
- }
205
- if (Array.isArray(value)) {
206
- return `[${value.map((v) => _pyRepr(v)).join(', ')}]`;
207
- }
208
- if (typeof value === 'object') {
209
- const parts: string[] = [];
210
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
211
- parts.push(`${_pyReprStr(k)}: ${_pyRepr(v)}`);
212
- }
213
- return `{${parts.join(', ')}}`;
214
- }
215
- return String(value);
216
- }
217
-
218
- /** Wrapper marking a number that should `repr()` as a Python float. */
219
- class _Float {
220
- constructor(readonly value: number) {}
221
- }
222
-
223
- /** Mark `n` so `_pyRepr` renders it with a Python float repr (`N.0`). */
224
- function _f(n: number): _Float {
225
- return new _Float(n);
226
- }
227
-
228
- /** Python `type(value).__name__`. */
229
- function _pyTypeName(value: unknown): string {
230
- if (value === null || value === undefined) {
231
- return 'NoneType';
232
- }
233
- if (typeof value === 'boolean') {
234
- return 'bool';
235
- }
236
- if (typeof value === 'number') {
237
- return Number.isInteger(value) ? 'int' : 'float';
238
- }
239
- if (typeof value === 'string') {
240
- return 'str';
241
- }
242
- if (Array.isArray(value)) {
243
- return 'list';
244
- }
245
- if (typeof value === 'object') {
246
- return 'dict';
247
- }
248
- return typeof value;
249
- }
250
-
251
- /** Python `sorted(set_of_strings)` rendered as a list repr `['a', 'b']`. */
252
- function _sortedListRepr(items: Iterable<string>): string {
253
- const sorted = [...items].sort();
254
- return `[${sorted.map((s) => _pyReprStr(s)).join(', ')}]`;
255
- }
256
-
257
- /** Python `oct(mode)` → `0o600`-shaped string. */
258
- function _pyOct(mode: number): string {
259
- return `0o${mode.toString(8)}`;
260
- }
261
-
262
147
  // ── Python int()/float() coercion + isinstance helpers ─────────────
263
148
 
264
149
  /**
@@ -432,6 +317,19 @@ export interface MemberConfig {
432
317
  * the falsification condition that gates ever enabling `'1h'`.
433
318
  */
434
319
  readonly prompt_cache_ttl: '5m' | '1h';
320
+ /**
321
+ * Optional `YYYY-MM-DD` stamp: the date this member's `model:` pin was last
322
+ * checked against the provider's own surface. `null` when unset.
323
+ *
324
+ * Exists because a hard pin cannot notice its own age. The starter template
325
+ * shipped `claude-sonnet-4-5` on an enabled member with nothing in the file
326
+ * able to flag it — and `check_council_pin_staleness` reads exactly this
327
+ * field. A member on a vendor sentinel (`codex-default`, or an alias like
328
+ * `sonnet` that the provider documents as "the latest model") needs no
329
+ * stamp: it cannot go stale, which is why the gate exempts it rather than
330
+ * demanding a date nobody would refresh.
331
+ */
332
+ readonly verified_at: string | null;
435
333
  }
436
334
 
437
335
  /**
@@ -635,6 +533,15 @@ export type QuorumMinPresent = number;
635
533
 
636
534
  export type { FallbackConfig } from './fallback_config.js';
637
535
  import { buildFallback, buildSecondModel, type FallbackConfig as _FallbackConfig } from './fallback_config.js';
536
+ import {
537
+ _f,
538
+ _pyOct,
539
+ _pyRepr,
540
+ _pyReprFloat,
541
+ _pyReprStr,
542
+ _pyTypeName,
543
+ _sortedListRepr,
544
+ } from './py_format.js';
638
545
 
639
546
  export interface CouncilConfig {
640
547
  readonly enabled: boolean;
@@ -1916,6 +1823,55 @@ function _build_member(
1916
1823
  // untouched here) OR as a mapping carrying `ttl`. '5m' is the
1917
1824
  // permanent default; see docs/contracts/ai-council-config.md
1918
1825
  // § Prompt cache TTL for the falsification condition on '1h'.
1826
+ // A malformed date fails CLOSED. A stamp is a claim about when a human
1827
+ // last looked; accepting `2026-13-45` or `soon` would make the staleness
1828
+ // gate report on a string it cannot compare, which is worse than no stamp.
1829
+ const verified_at_raw = _get(cfg, 'verified_at', null);
1830
+ let verified_at: string | null = null;
1831
+ if (verified_at_raw !== null && verified_at_raw !== undefined) {
1832
+ // The stamp must be a QUOTED string, and the reason is a measured
1833
+ // fail-open rather than a style preference.
1834
+ //
1835
+ // YAML 1.1 resolves an unquoted `2026-08-22` to a Date. Accepting that
1836
+ // and normalising it back with `toISOString()` was the first
1837
+ // implementation, and it LAUNDERED impossible dates: the loader silently
1838
+ // rolls `2026-13-45` over to 2027-02-14, so the calendar check below saw
1839
+ // a valid date and the malformed input passed. Verified against a real
1840
+ // config, not reasoned about.
1841
+ //
1842
+ // A Date is therefore rejected with the fix in the message. That keeps
1843
+ // the impossible-date case failing CLOSED, which is the only direction
1844
+ // worth defending here: a stamp is a claim about attention, and a
1845
+ // silently corrected claim is worse than a rejected one.
1846
+ if (verified_at_raw instanceof Date) {
1847
+ throw new CouncilConfigError(
1848
+ `members.${name}.verified_at must be QUOTED — write ` +
1849
+ `verified_at: "YYYY-MM-DD". Unquoted, YAML parses it as a date and ` +
1850
+ `silently rolls impossible values over (2026-13-45 becomes 2027-02-14), ` +
1851
+ `which would let a malformed stamp through.`,
1852
+ );
1853
+ }
1854
+ const coerced = verified_at_raw;
1855
+ if (typeof coerced !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(coerced)) {
1856
+ throw new CouncilConfigError(
1857
+ `members.${name}.verified_at must be a 'YYYY-MM-DD' string when set ` +
1858
+ `(got ${_pyRepr(verified_at_raw)}).`,
1859
+ );
1860
+ }
1861
+ const [y, m, d] = coerced.split('-').map((x) => Number(x));
1862
+ const probe = new Date(Date.UTC(y as number, (m as number) - 1, d as number));
1863
+ if (
1864
+ probe.getUTCFullYear() !== y ||
1865
+ probe.getUTCMonth() + 1 !== m ||
1866
+ probe.getUTCDate() !== d
1867
+ ) {
1868
+ throw new CouncilConfigError(
1869
+ `members.${name}.verified_at is not a real calendar date ` +
1870
+ `(got ${_pyRepr(verified_at_raw)}).`,
1871
+ );
1872
+ }
1873
+ verified_at = coerced;
1874
+ }
1919
1875
  const prompt_cache_raw = _get(cfg, 'prompt_cache', null);
1920
1876
  let prompt_cache_ttl: '5m' | '1h' = '5m';
1921
1877
  if (prompt_cache_raw !== null && prompt_cache_raw !== undefined && !_isBool(prompt_cache_raw)) {
@@ -1947,6 +1903,7 @@ function _build_member(
1947
1903
  participate_low_impact: participate_raw,
1948
1904
  tier,
1949
1905
  prompt_cache_ttl,
1906
+ verified_at,
1950
1907
  };
1951
1908
  }
1952
1909
 
@@ -10,6 +10,8 @@
10
10
  * here; the rest is there rather than duplicated.
11
11
  */
12
12
 
13
+ import type { ApiOnQuota } from './transport_resolver.js';
14
+
13
15
  /**
14
16
  * `fallback.*` — the mid-flight cli→api retry's one configurable knob.
15
17
  *
@@ -24,8 +26,13 @@ export interface FallbackConfig {
24
26
  * May an exhausted CLI quota retry on the metered api rung? Default
25
27
  * `false`. The no-double-charge classes are always eligible and never
26
28
  * consult this; `timeout` / 5xx are never eligible under any value.
29
+ *
30
+ * Three values, not two. `'ask'` defers the answer out of configuration
31
+ * time and into the round that would cross the cliff: the seat is PARKED
32
+ * unless a run-scoped billing grant says a human already agreed for this
33
+ * run. See `transport_resolver.ts` § `ApiOnQuota` and `_lib/billing_grant`.
27
34
  */
28
- readonly api_on_quota: boolean;
35
+ readonly api_on_quota: ApiOnQuota;
29
36
  }
30
37
 
31
38
  /**
@@ -67,12 +74,17 @@ export function buildFallback(raw: unknown, d: FallbackBuilderDeps): FallbackCon
67
74
  if (!d.isDict(raw)) return { api_on_quota: false };
68
75
  const v = raw['api_on_quota'];
69
76
  if (v === undefined) return { api_on_quota: false };
70
- if (!d.isBool(v)) {
71
- throw d.error(
72
- `\`fallback.api_on_quota\`=${d.repr(v)} must be a boolean (got ${d.typeName(v)}).`,
73
- );
74
- }
75
- return { api_on_quota: v };
77
+ if (d.isBool(v)) return { api_on_quota: v };
78
+ // `'ask'` is the ONLY string this accepts, and the narrowness is the
79
+ // point: the paragraph above refuses `"yes"` because an operator writing
80
+ // it is trying to authorise spend, and widening to "any truthy-looking
81
+ // string" to let `ask` through would readmit exactly that. A third named
82
+ // value is not a lenient parse.
83
+ if (d.isStr(v) && v === 'ask') return { api_on_quota: 'ask' };
84
+ throw d.error(
85
+ `\`fallback.api_on_quota\`=${d.repr(v)} must be \`false\`, \`true\` or \`'ask'\` ` +
86
+ `(got ${d.typeName(v)}).`,
87
+ );
76
88
  }
77
89
 
78
90
  /**
@@ -19,19 +19,28 @@
19
19
  import type { ExternalAIClient, CouncilResponse } from './clients.js';
20
20
  import {
21
21
  classifyCliFailure,
22
+ effectiveApiOnQuota,
22
23
  isFallbackEligibleUnder,
24
+ type ApiOnQuota,
23
25
  type FallbackPolicy,
24
26
  type MidFlightFallback,
25
27
  } from './transport_resolver.js';
26
28
 
27
- /** One escalation's outcome, for the event sink. */
28
- export type FallbackOutcome = 'retried' | 'no_twin' | 'cost_budget';
29
+ /**
30
+ * One escalation's outcome, for the event sink.
31
+ *
32
+ * `awaiting_grant` is the posture `'ask'` adds and it is neither of the two
33
+ * that existed: the seat was NOT switched to metered billing and it was NOT
34
+ * lost. It is held, and the round closes with a question instead of a silent
35
+ * charge or a silent absence.
36
+ */
37
+ export type FallbackOutcome = 'retried' | 'no_twin' | 'cost_budget' | 'awaiting_grant';
29
38
 
30
39
  export interface FallbackEvent {
31
40
  readonly provider: string;
32
41
  readonly failure: string;
33
42
  readonly outcome: FallbackOutcome;
34
- readonly api_on_quota: boolean;
43
+ readonly api_on_quota: ApiOnQuota;
35
44
  }
36
45
 
37
46
  export interface CliFallbackOptions {
@@ -42,7 +51,21 @@ export interface CliFallbackOptions {
42
51
  */
43
52
  readonly construct: (provider: string) => ExternalAIClient | null;
44
53
  /** `ai_council.fallback.api_on_quota` — see `FallbackPolicy.apiOnQuota`. */
45
- readonly api_on_quota: boolean;
54
+ readonly api_on_quota: ApiOnQuota;
55
+ /**
56
+ * True when a run-scoped billing grant is in force for THIS run. Read only
57
+ * under `api_on_quota: 'ask'`; absence means park, never proceed.
58
+ */
59
+ readonly billing_grant?: boolean;
60
+ /**
61
+ * Providers parked under `'ask'`, appended during the round.
62
+ *
63
+ * It lives on the options object rather than in the CLI because the CLI
64
+ * has nothing to do with it: the factory that decides a seat is parked is
65
+ * the only thing that can populate it, and a caller threading an array
66
+ * down to that factory and back is plumbing with no reader in between.
67
+ */
68
+ readonly parked?: string[];
46
69
  /**
47
70
  * Called once per ESTABLISHING escalation, never per substituted call.
48
71
  *
@@ -60,6 +83,42 @@ export interface EstablishedTwin {
60
83
  readonly original_error: string;
61
84
  }
62
85
 
86
+ /**
87
+ * A seat held back because the policy is `'ask'` and no grant is in force.
88
+ *
89
+ * The third state the boolean could not express. Before `'ask'` existed a
90
+ * quota-hit seat had exactly two futures — a twin (metered, silent) or `null`
91
+ * (lost for the round). Neither is answerable afterwards: by the time a human
92
+ * reads the output, the spend has happened or the seat is gone. A parked seat
93
+ * is still in the round's seat map, so the question the round closes with is
94
+ * about work that can still be done.
95
+ */
96
+ export interface ParkedSeat {
97
+ readonly parked: true;
98
+ readonly provider: string;
99
+ readonly reason: string;
100
+ readonly original_error: string;
101
+ }
102
+
103
+ /** `establishTwin`'s three outcomes: a twin, a parked seat, or nothing. */
104
+ export type EstablishOutcome = EstablishedTwin | ParkedSeat | null;
105
+
106
+ /**
107
+ * Narrow an `EstablishOutcome` to the twin branch.
108
+ *
109
+ * Every caller must use this rather than a truthiness test: a `ParkedSeat` is
110
+ * an object, so `if (twin)` retries the parked marker as though it were a
111
+ * client — the one bug the park branch exists to make impossible.
112
+ */
113
+ export function isEstablishedTwin(o: EstablishOutcome): o is EstablishedTwin {
114
+ return o !== null && !('parked' in o);
115
+ }
116
+
117
+ /** Narrow an `EstablishOutcome` to the parked branch. */
118
+ export function isParkedSeat(o: EstablishOutcome): o is ParkedSeat {
119
+ return o !== null && 'parked' in o;
120
+ }
121
+
63
122
  /**
64
123
  * Why the twin map exists alongside the ledger.
65
124
  *
@@ -99,10 +158,32 @@ export interface EstablishInput {
99
158
  * budget then refused. An ineligible class emits nothing at all: there was no
100
159
  * escalation to report.
101
160
  */
102
- export function establishTwin(input: EstablishInput): EstablishedTwin | null {
161
+ export function establishTwin(input: EstablishInput): EstablishOutcome {
103
162
  const { member, error, fallback, ledger } = input;
104
163
  const failure = classifyCliFailure(error);
105
- const policy: FallbackPolicy = { apiOnQuota: fallback.api_on_quota };
164
+ const policy: FallbackPolicy = {
165
+ apiOnQuota: fallback.api_on_quota,
166
+ billingGrant: fallback.billing_grant === true,
167
+ };
168
+ // The park branch runs BEFORE the eligibility test, and the order is the
169
+ // whole mechanism. `effectiveApiOnQuota` collapses an ungranted `'ask'`
170
+ // to `false`, so an eligibility-first reading would return `null` here —
171
+ // the seat lost, exactly as under `api_on_quota: false`, and the operator
172
+ // never asked. The ledger is deliberately NOT consulted: parking spends no
173
+ // fallback attempt, so the seat is still escalatable on the grant.
174
+ if (
175
+ fallback.api_on_quota === 'ask' &&
176
+ failure === 'quota_exhausted' &&
177
+ !effectiveApiOnQuota(policy)
178
+ ) {
179
+ fallback.on_event?.({
180
+ provider: member.name,
181
+ failure,
182
+ outcome: 'awaiting_grant',
183
+ api_on_quota: fallback.api_on_quota,
184
+ });
185
+ return { parked: true, provider: member.name, reason: failure, original_error: error };
186
+ }
106
187
  if (!isFallbackEligibleUnder(failure, policy)) return null;
107
188
  if (ledger.attempt(member.name, failure, policy) !== 'api') return null;
108
189
 
@@ -241,7 +322,7 @@ export function escalateUnmetered(
241
322
  fallback,
242
323
  ledger,
243
324
  });
244
- if (twin === null) return null;
325
+ if (!isEstablishedTwin(twin)) return null;
245
326
  // NOTHING is emitted here, and the previous comment justifying an emit at
246
327
  // establishment is deleted rather than reworded because it stopped being
247
328
  // true. It read: "on this path the twin goes through the round's ORDINARY
@@ -75,7 +75,7 @@ import {
75
75
  emitOutcome,
76
76
  escalateUnmetered,
77
77
  refuseUnmeteredEscalation,
78
- establishTwin,
78
+ establishTwin, isEstablishedTwin,
79
79
  isFailedCliCall,
80
80
  runGatedRetry,
81
81
  stampFallback,
@@ -764,7 +764,7 @@ function _run_round(
764
764
  fallback,
765
765
  ledger,
766
766
  });
767
- if (twin !== null) {
767
+ if (isEstablishedTwin(twin)) {
768
768
  const out = runGatedRetry(response, member, twin, fallback, {
769
769
  gate: (client) =>
770
770
  table === null ||
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Python-format helpers, extracted from `config.ts`.
3
+ *
4
+ * Every error string this package emits is byte-faithful to the Python original
5
+ * it replaced, which is why `repr()`, `type().__name__` and `oct()` are
6
+ * reimplemented here instead of being approximated with template literals.
7
+ *
8
+ * Pure and self-contained: these functions call only each other, which is what
9
+ * made the section safe to move out whole. Extracted because config.ts is
10
+ * hundreds of lines above the 1500-line source ceiling and a change adding to it
11
+ * has to pay for the lines somewhere.
12
+ */
13
+ /** Python `repr()` for a string scalar (single-quoted, escaped). */
14
+ export function _pyReprStr(s: string): string {
15
+ const hasSingle = s.includes("'");
16
+ const hasDouble = s.includes('"');
17
+ const quote = hasSingle && !hasDouble ? '"' : "'";
18
+ let body = s
19
+ .replace(/\\/g, '\\\\')
20
+ .replace(/\n/g, '\\n')
21
+ .replace(/\r/g, '\\r')
22
+ .replace(/\t/g, '\\t');
23
+ if (quote === "'") {
24
+ body = body.replace(/'/g, "\\'");
25
+ } else {
26
+ body = body.replace(/"/g, '\\"');
27
+ }
28
+ return `${quote}${body}${quote}`;
29
+ }
30
+
31
+ /** Python `repr()` for a float value (shortest round-trip, `N.0` for ints). */
32
+ export function _pyReprFloat(value: number): string {
33
+ if (Number.isInteger(value) && Number.isFinite(value)) {
34
+ return `${value}.0`;
35
+ }
36
+ if (value === Infinity) {
37
+ return 'inf';
38
+ }
39
+ if (value === -Infinity) {
40
+ return '-inf';
41
+ }
42
+ if (Number.isNaN(value)) {
43
+ return 'nan';
44
+ }
45
+ return String(value);
46
+ }
47
+
48
+ /**
49
+ * Python `repr()` for an arbitrary parsed value. Floats are tracked via
50
+ * `_FLOAT` so int-valued floats render `N.0`; bare numbers render as
51
+ * Python ints (no decimal). Mirrors `{value!r}` formatting.
52
+ */
53
+ export function _pyRepr(value: unknown): string {
54
+ if (value instanceof _Float) {
55
+ return _pyReprFloat(value.value);
56
+ }
57
+ if (value === null || value === undefined) {
58
+ return 'None';
59
+ }
60
+ if (typeof value === 'boolean') {
61
+ return value ? 'True' : 'False';
62
+ }
63
+ if (typeof value === 'string') {
64
+ return _pyReprStr(value);
65
+ }
66
+ if (typeof value === 'number') {
67
+ return String(value);
68
+ }
69
+ if (Array.isArray(value)) {
70
+ return `[${value.map((v) => _pyRepr(v)).join(', ')}]`;
71
+ }
72
+ if (typeof value === 'object') {
73
+ const parts: string[] = [];
74
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
75
+ parts.push(`${_pyReprStr(k)}: ${_pyRepr(v)}`);
76
+ }
77
+ return `{${parts.join(', ')}}`;
78
+ }
79
+ return String(value);
80
+ }
81
+
82
+ /** Wrapper marking a number that should `repr()` as a Python float. */
83
+ export class _Float {
84
+ constructor(readonly value: number) {}
85
+ }
86
+
87
+ /** Mark `n` so `_pyRepr` renders it with a Python float repr (`N.0`). */
88
+ export function _f(n: number): _Float {
89
+ return new _Float(n);
90
+ }
91
+
92
+ /** Python `type(value).__name__`. */
93
+ export function _pyTypeName(value: unknown): string {
94
+ if (value === null || value === undefined) {
95
+ return 'NoneType';
96
+ }
97
+ if (typeof value === 'boolean') {
98
+ return 'bool';
99
+ }
100
+ if (typeof value === 'number') {
101
+ return Number.isInteger(value) ? 'int' : 'float';
102
+ }
103
+ if (typeof value === 'string') {
104
+ return 'str';
105
+ }
106
+ if (Array.isArray(value)) {
107
+ return 'list';
108
+ }
109
+ if (typeof value === 'object') {
110
+ return 'dict';
111
+ }
112
+ return typeof value;
113
+ }
114
+
115
+ /** Python `sorted(set_of_strings)` rendered as a list repr `['a', 'b']`. */
116
+ export function _sortedListRepr(items: Iterable<string>): string {
117
+ const sorted = [...items].sort();
118
+ return `[${sorted.map((s) => _pyReprStr(s)).join(', ')}]`;
119
+ }
120
+
121
+ /** Python `oct(mode)` → `0o600`-shaped string. */
122
+ export function _pyOct(mode: number): string {
123
+ return `0o${mode.toString(8)}`;
124
+ }
@@ -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
  /**