@event4u/agent-config 14.8.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 (136) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +165 -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/code-review/evals/evals.json +87 -46
  13. package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
  14. package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
  15. package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
  16. package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
  17. package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
  18. package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
  19. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  20. package/dist/agent-src/templates/roadmaps.md +8 -0
  21. package/dist/cli/registry.js +2 -0
  22. package/dist/cli/registry.js.map +1 -1
  23. package/dist/cli-delegate/{chunk-MMSGVZKV.js → chunk-77GR3VD4.js} +65 -40
  24. package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
  25. package/dist/cli-delegate/{chunk-EEJUMCDZ.js → chunk-PLSG6UL5.js} +1 -1
  26. package/dist/cli-delegate/cmd_conformance.js +89 -13
  27. package/dist/cli-delegate/cmd_doctor.js +3 -3
  28. package/dist/cli-delegate/cmd_handoff.js +2 -2
  29. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  30. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  31. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  32. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  33. package/dist/discovery/deprecation-report.md +1 -1
  34. package/dist/discovery/discovery-manifest.json +37 -15
  35. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  36. package/dist/discovery/discovery-manifest.summary.md +3 -3
  37. package/dist/discovery/dormancy-report.md +1 -1
  38. package/dist/discovery/orphan-report.md +1 -1
  39. package/dist/discovery/packs.json +6 -5
  40. package/dist/discovery/trust-report.md +2 -2
  41. package/dist/discovery/workspaces.json +5 -4
  42. package/dist/hooks/dispatch.js +562 -204
  43. package/dist/mcp/registry-manifest.json +3 -3
  44. package/dist/mcp/server.json +2 -2
  45. package/dist/mcp/server.mjs +808 -331
  46. package/dist/scripts/_lib/claude_plugin.js +162 -0
  47. package/dist/scripts/_lib/claude_plugin.js.map +1 -0
  48. package/dist/scripts/_lib/environment_detector.js +397 -0
  49. package/dist/scripts/_lib/environment_detector.js.map +1 -0
  50. package/dist/scripts/_lib/spawn_env.js +129 -0
  51. package/dist/scripts/_lib/spawn_env.js.map +1 -0
  52. package/dist/scripts/ai_council/modes.js +199 -0
  53. package/dist/scripts/ai_council/modes.js.map +1 -0
  54. package/dist/scripts/ai_council/transport_resolver.js +418 -0
  55. package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
  56. package/dist/server/routes/wizard.js +3 -2
  57. package/dist/server/routes/wizard.js.map +1 -1
  58. package/dist/ui/assets/index-Cxgy63kz.js +26 -0
  59. package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
  62. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
  63. package/package.json +7 -3
  64. package/src/config/estate-count-budget.json +13 -744
  65. package/src/config/evaluator-budgets.json +4 -4
  66. package/src/config/gate-coverage.yml +247 -9
  67. package/src/config/gate-violation-baselines.json +72 -72
  68. package/src/config/publish-surface.json +60 -0
  69. package/src/config/requirements-trace-budget.json +32 -0
  70. package/src/config/rule-stub-ceilings.json +8 -1
  71. package/src/scripts/_dispatch.bash +7 -0
  72. package/src/scripts/_lib/base_tree.ts +175 -0
  73. package/src/scripts/_lib/billing_grant.ts +165 -0
  74. package/src/scripts/_lib/billing_grant_cli.ts +123 -0
  75. package/src/scripts/_lib/council_fallback_posture.ts +53 -6
  76. package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
  77. package/src/scripts/_lib/host_capability.ts +59 -5
  78. package/src/scripts/_lib/quota_parked.ts +133 -0
  79. package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
  80. package/src/scripts/_lib/review_telemetry.ts +196 -0
  81. package/src/scripts/_lib/subagent_response.ts +92 -0
  82. package/src/scripts/adr/regenerate_index.ts +14 -7
  83. package/src/scripts/ai_council/clients.ts +12 -1
  84. package/src/scripts/ai_council/config.ts +72 -115
  85. package/src/scripts/ai_council/fallback_config.ts +19 -7
  86. package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
  87. package/src/scripts/ai_council/orchestrator.ts +2 -2
  88. package/src/scripts/ai_council/py_format.ts +124 -0
  89. package/src/scripts/ai_council/transport_resolver.ts +65 -2
  90. package/src/scripts/audit_adr_coverage.ts +33 -6
  91. package/src/scripts/check_council_pin_staleness.ts +300 -0
  92. package/src/scripts/check_estate_count.ts +420 -293
  93. package/src/scripts/check_gate_coverage.ts +91 -0
  94. package/src/scripts/check_no_stub_inventory_table.ts +292 -0
  95. package/src/scripts/check_pack_size.ts +234 -1
  96. package/src/scripts/check_publish_surface.ts +297 -0
  97. package/src/scripts/check_requirements_trace.ts +378 -0
  98. package/src/scripts/check_secret_leak.ts +166 -12
  99. package/src/scripts/code_graph/cli.ts +44 -5
  100. package/src/scripts/code_graph/extract.ts +87 -0
  101. package/src/scripts/council_cli.ts +12 -16
  102. package/src/scripts/detect_ai_tells.ts +87 -0
  103. package/src/scripts/dispatch_r2_reviewer.ts +42 -0
  104. package/src/scripts/generate_capabilities_index.ts +41 -0
  105. package/src/scripts/git_authorization_hook.ts +54 -4
  106. package/src/scripts/hooks/block_unauthorized_git.ts +19 -1
  107. package/src/scripts/hooks/run_continuation_hook.ts +3 -1
  108. package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
  109. package/src/scripts/injection_scan_hook.ts +120 -10
  110. package/src/scripts/lint_adr_sweep_routing.ts +365 -0
  111. package/src/scripts/lint_workflow_security.ts +53 -10
  112. package/src/scripts/lint_workflow_security_allowlist.json +2 -18
  113. package/src/scripts/report_envelope_rate.ts +166 -0
  114. package/src/scripts/review_axis_report.ts +218 -0
  115. package/src/scripts/run_supervise.ts +46 -1
  116. package/src/scripts/schemas/evals.schema.json +5 -0
  117. package/src/scripts/sync_pr_branch.ts +14 -7
  118. package/dist/cli/commands/commands.candidates.test.js +0 -257
  119. package/dist/cli/commands/commands.candidates.test.js.map +0 -1
  120. package/dist/cli/commands/profiles.test.js +0 -88
  121. package/dist/cli/commands/profiles.test.js.map +0 -1
  122. package/dist/cli/commands/recordTriggerEval.test.js +0 -113
  123. package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
  124. package/dist/cli/configRoot.test.js +0 -97
  125. package/dist/cli/configRoot.test.js.map +0 -1
  126. package/dist/cli/mcp/content.test.js +0 -54
  127. package/dist/cli/mcp/content.test.js.map +0 -1
  128. package/dist/cli/mcp/dispatch.test.js +0 -108
  129. package/dist/cli/mcp/dispatch.test.js.map +0 -1
  130. package/dist/server/writeRoot.test.js +0 -91
  131. package/dist/server/writeRoot.test.js.map +0 -1
  132. package/dist/shared/capabilities.test.js +0 -39
  133. package/dist/shared/capabilities.test.js.map +0 -1
  134. package/dist/ui/assets/index-C1pBRm1f.js +0 -26
  135. package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
  136. /package/dist/cli-delegate/{chunk-N7CDDGI3.js → chunk-CYH5SP6M.js} +0 -0
@@ -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
  /**
@@ -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`);