@event4u/agent-config 14.2.0 → 14.4.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 (116) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +286 -218
  3. package/agents/templates/.ai-council.yml.example +35 -0
  4. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
  5. package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
  6. package/dist/agent-src/scripts/resume_probe.ts +154 -9
  7. package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
  8. package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
  9. package/dist/agent-src/templates/agent-settings.md +70 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  12. package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  13. package/dist/cli/python/workspace_drive.js +8 -3
  14. package/dist/cli/python/workspace_drive.js.map +1 -1
  15. package/dist/cli/python/workspace_hosts.js +16 -12
  16. package/dist/cli/python/workspace_hosts.js.map +1 -1
  17. package/dist/cli/registry.js +2 -0
  18. package/dist/cli/registry.js.map +1 -1
  19. package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
  20. package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
  21. package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
  22. package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
  23. package/dist/cli-delegate/cmd_conformance.js +26 -11
  24. package/dist/cli-delegate/cmd_doctor.js +2 -2
  25. package/dist/cli-delegate/cmd_handoff.js +3 -3
  26. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  27. package/dist/cli-delegate/cmd_settings_set.js +17 -3
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +4 -4
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/dispatch.js +3684 -1719
  38. package/dist/mcp/registry-manifest.json +2 -2
  39. package/dist/mcp/server.json +2 -2
  40. package/dist/mcp/server.mjs +1070 -408
  41. package/dist/scripts/_lib/host_launch.js +66 -0
  42. package/dist/scripts/_lib/host_launch.js.map +1 -0
  43. package/dist/server/io/yamlIO.js +175 -3
  44. package/dist/server/io/yamlIO.js.map +1 -1
  45. package/dist/server/routes/wizard.js +20 -1
  46. package/dist/server/routes/wizard.js.map +1 -1
  47. package/dist/shared/settingsConsent.js +25 -0
  48. package/dist/shared/settingsConsent.js.map +1 -1
  49. package/docs/contracts/settings-classes.md +11 -0
  50. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
  51. package/package.json +1 -1
  52. package/src/agent-src/scripts/resume_probe.ts +154 -9
  53. package/src/agent-src/scripts/roadmap_gates.ts +210 -6
  54. package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  55. package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  56. package/src/config/estate-count-budget.json +120 -0
  57. package/src/config/evaluator-budgets.json +4 -4
  58. package/src/config/gate-coverage.yml +32 -4
  59. package/src/config/gate-violation-baselines.json +63 -63
  60. package/src/config/gitignore-block.txt +5 -0
  61. package/src/config/hook-latency-budget.json +40 -1
  62. package/src/config/hook-token-budget.json +16 -0
  63. package/src/config/rule-stub-ceilings.json +11 -3
  64. package/src/scripts/_dispatch.bash +42 -1
  65. package/src/scripts/_lib/council_fallback_posture.ts +81 -0
  66. package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
  67. package/src/scripts/_lib/council_settings_block.ts +152 -0
  68. package/src/scripts/_lib/headless_invocation.ts +285 -0
  69. package/src/scripts/_lib/host_launch.ts +72 -0
  70. package/src/scripts/_lib/map_to_object.ts +16 -0
  71. package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
  72. package/src/scripts/_lib/run_checkpoint.ts +409 -0
  73. package/src/scripts/_lib/session_register.ts +32 -1
  74. package/src/scripts/_lib/stdin.ts +35 -1
  75. package/src/scripts/_lib/turn_end_refusals.ts +12 -4
  76. package/src/scripts/_lib/unattended_guard.ts +371 -0
  77. package/src/scripts/ai_council/config.ts +27 -1
  78. package/src/scripts/ai_council/events_log.ts +12 -1
  79. package/src/scripts/ai_council/fallback_config.ts +114 -0
  80. package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
  81. package/src/scripts/ai_council/orchestrator.ts +251 -292
  82. package/src/scripts/ai_council/py_parity.ts +139 -0
  83. package/src/scripts/ai_council/response_render.ts +99 -0
  84. package/src/scripts/ai_council/spend_gate.ts +123 -0
  85. package/src/scripts/ai_council/transport_resolver.ts +81 -13
  86. package/src/scripts/bench_hook_injection.ts +100 -6
  87. package/src/scripts/bench_hook_latency.ts +442 -16
  88. package/src/scripts/check_estate_count.ts +924 -0
  89. package/src/scripts/compile_hook_manifest.ts +67 -0
  90. package/src/scripts/council_cli.ts +100 -125
  91. package/src/scripts/decision_memo.ts +325 -0
  92. package/src/scripts/hook_manifest.json +1 -0
  93. package/src/scripts/hook_manifest.yaml +172 -25
  94. package/src/scripts/hooks/concern_registry.ts +6 -0
  95. package/src/scripts/hooks/dispatch_hook.ts +183 -10
  96. package/src/scripts/hooks/dispatch_issues.ts +26 -0
  97. package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
  98. package/src/scripts/hooks/hook_stdin.ts +111 -5
  99. package/src/scripts/hooks/injection_budget.ts +595 -0
  100. package/src/scripts/hooks/payload_stub.ts +446 -0
  101. package/src/scripts/hooks/run_continuation_hook.ts +627 -0
  102. package/src/scripts/hooks/session_eol_hook.ts +52 -0
  103. package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
  104. package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
  105. package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
  106. package/src/scripts/hooks_doctor.ts +16 -2
  107. package/src/scripts/interruption_report.ts +330 -13
  108. package/src/scripts/lint_hook_manifest.ts +167 -0
  109. package/src/scripts/roadmap_progress_hook.ts +254 -46
  110. package/src/scripts/run_supervise.ts +623 -0
  111. package/src/scripts/session_register_hook.ts +108 -8
  112. package/src/scripts/sessions_cli.ts +18 -6
  113. package/src/scripts/telemetry_disclosure_hook.ts +244 -0
  114. package/src/server/io/yamlIO.ts +167 -3
  115. package/src/shared/settingsConsent.ts +49 -2
  116. package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
@@ -0,0 +1,371 @@
1
+ /**
2
+ * unattended_guard — the three preconditions an unattended run must clear
3
+ * before anything spawns, and the ledger that bounds it once it has.
4
+ *
5
+ * road-to-long-horizon-execution Phase 4.0 (sequencing UOTL Phase 7.1).
6
+ *
7
+ * ## What this is, and what it deliberately is not
8
+ *
9
+ * It is NOT the spawner. There is no `spawnHeadlessAgent` here and that is a
10
+ * decision, not an omission — see § "Why the spawn is not in this file".
11
+ *
12
+ * It IS the layer that has to be right BEFORE a spawn is safe, and every part
13
+ * of it is testable offline with no spend:
14
+ *
15
+ * 1. **Remote safety.** A worktree an unattended agent works in must carry
16
+ * no production remote. UOTL 7.1 says so; the failure it prevents is an
17
+ * unattended push reaching a real remote because a worktree inherited
18
+ * the parent's `origin`.
19
+ * 2. **A budget with a ledger.** A cap nobody decrements is a comment. The
20
+ * ledger is a file, spend is booked against it, and an exhausted budget
21
+ * refuses — before the call, never after.
22
+ * 3. **Job dedup.** Two schedulers, or one scheduler and one human, must
23
+ * not run the same roadmap at the same head twice. The key is derived
24
+ * from the work, not from a random id, so the second caller recognises
25
+ * the first caller's job.
26
+ *
27
+ * ## Why the spawn is not in this file
28
+ *
29
+ * Writing a spawner means choosing an invocation shape, an auth path and a
30
+ * sandbox posture for a process that spends money with nobody watching. Each
31
+ * of those is a decision with a blast radius, and none of them is testable
32
+ * without spending. Shipping an untested one behind a flag that reads as
33
+ * finished is how a capability nobody validated ends up in a scheduler.
34
+ *
35
+ * The guard lands first on purpose: it is the half that can be verified, and
36
+ * it is the half whose absence makes the other half unsafe. Phase 4.2's
37
+ * pre-registered demotion gate has to exist before a first unattended run
38
+ * either way, so the ordering costs nothing.
39
+ */
40
+
41
+ import { spawnSync } from 'node:child_process';
42
+ import * as fs from 'node:fs';
43
+ import * as path from 'node:path';
44
+
45
+ export const BUDGET_REL = path.join('agents', 'runtime', 'state', 'unattended-budget.json');
46
+ export const JOBS_REL = path.join('agents', 'runtime', 'state', 'unattended-jobs.json');
47
+
48
+ /**
49
+ * Remote URL fragments that mark a remote as PRODUCTION-reachable.
50
+ *
51
+ * Deliberately a shape test rather than a host allowlist: the question is not
52
+ * "which forge" but "can a push from here reach something real". A local path
53
+ * remote or a remote pointing inside this checkout cannot; anything that
54
+ * resolves over the network can.
55
+ */
56
+ export function isProductionRemote(url: string): boolean {
57
+ const u = url.trim();
58
+ if (u === '') return false;
59
+ // ONLY a recognisably-local remote is non-production. Everything else is
60
+ // treated as reachable, including a shape this function does not know.
61
+ //
62
+ // R2 round 4, finding 6. The test used to be positive — enumerate the
63
+ // network shapes and return false otherwise — which meant an unrecognised
64
+ // URL cleared the "no production remote" precondition. An SSH-config alias
65
+ // is the concrete case: `gh:org/repo.git` matches no prefix here and fails
66
+ // the scp-style pattern (which requires `user@host:`), yet a push through
67
+ // it reaches GitHub exactly as `git@github.com:` does. The whole module
68
+ // fails CLOSED on an unreadable git config for precisely this reason, and
69
+ // this one function was reading "I do not recognise it" as "it is safe".
70
+ //
71
+ // A filesystem path — a sibling worktree, a bare mirror on disk. A push
72
+ // there is recoverable and reaches nobody.
73
+ if (u.startsWith('/') || u.startsWith('.') || u.startsWith('~') || u.startsWith('file://')) {
74
+ return false;
75
+ }
76
+ return true;
77
+ }
78
+
79
+ export interface RemoteVerdict {
80
+ readonly safe: boolean;
81
+ /** Every remote found, so a refusal can name the one that failed. */
82
+ readonly remotes: ReadonlyArray<{ name: string; url: string; production: boolean }>;
83
+ readonly reason: string;
84
+ }
85
+
86
+ /**
87
+ * Is this worktree safe for an unattended agent to work in?
88
+ *
89
+ * Fails CLOSED on an unreadable git config: "I could not tell" and "there is
90
+ * no production remote" must not resolve to the same answer when the
91
+ * consequence of being wrong is an unattended push to a real remote.
92
+ */
93
+ export function checkRemotes(worktree: string): RemoteVerdict {
94
+ const r = spawnSync('git', ['remote', '-v'], { cwd: worktree, encoding: 'utf-8' });
95
+ if (r.status !== 0) {
96
+ return {
97
+ safe: false,
98
+ remotes: [],
99
+ reason:
100
+ `could not read remotes in ${worktree} (git exited ${String(r.status)}) — ` +
101
+ `refusing, because "unreadable" and "none" must not mean the same thing here`,
102
+ };
103
+ }
104
+ // BOTH rows, and the push row decides. R2 round 6, finding 5: parsing only
105
+ // `(fetch)` judged a remote by the URL it reads from, while the thing this
106
+ // precondition guards against is a PUSH. `git remote -v` emits a separate
107
+ // `(push)` row whenever `pushurl` diverges, so a worktree with
108
+ // `url = /srv/mirrors/repo.git` and `pushurl = git@github.com:org/repo.git`
109
+ // read as local and cleared the gate — fail-open, in the one module whose
110
+ // stated posture is fail-closed.
111
+ //
112
+ // A remote is production if EITHER of its URLs is, because either one
113
+ // being reachable is enough for the harm this check exists to prevent.
114
+ const urls = new Map<string, Set<string>>();
115
+ for (const line of (r.stdout ?? '').split('\n')) {
116
+ const m = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(line.trim());
117
+ if (m === null) continue;
118
+ const name = m[1] as string;
119
+ const set = urls.get(name) ?? new Set<string>();
120
+ set.add(m[2] as string);
121
+ urls.set(name, set);
122
+ }
123
+ const remotes = [...urls.entries()].map(([name, set]) => {
124
+ const all = [...set];
125
+ const prod = all.filter((u) => isProductionRemote(u));
126
+ return {
127
+ name,
128
+ // The production URL is the one worth naming in a refusal.
129
+ url: (prod[0] ?? all[0]) as string,
130
+ production: prod.length > 0,
131
+ };
132
+ });
133
+ const bad = remotes.filter((x) => x.production);
134
+ if (bad.length > 0) {
135
+ return {
136
+ safe: false,
137
+ remotes,
138
+ reason:
139
+ `production-reachable remote(s): ${bad.map((b) => `${b.name} → ${b.url}`).join(', ')} — ` +
140
+ `an unattended run must not be able to push to a real remote (UOTL 7.1)`,
141
+ };
142
+ }
143
+ return { safe: true, remotes, reason: 'no production-reachable remote' };
144
+ }
145
+
146
+ /** The cap and what has been spent against it. */
147
+ export interface UnattendedBudget {
148
+ schema_version: 1;
149
+ /** Hard ceiling in USD. `0` disables unattended runs entirely. */
150
+ max_usd: number;
151
+ /** Hard ceiling in tokens across the window. `0` disables. */
152
+ max_tokens: number;
153
+ spent_usd: number;
154
+ spent_tokens: number;
155
+ /** ISO date the window opened; a new UTC day resets both counters. */
156
+ window_opened: string;
157
+ }
158
+
159
+ export function emptyBudget(day: string): UnattendedBudget {
160
+ // Both ceilings default to ZERO, which disables unattended runs. An
161
+ // absent budget file must not read as "unlimited" — that is the one
162
+ // default that turns a missing config into unbounded spend.
163
+ return {
164
+ schema_version: 1,
165
+ max_usd: 0,
166
+ max_tokens: 0,
167
+ spent_usd: 0,
168
+ spent_tokens: 0,
169
+ window_opened: day,
170
+ };
171
+ }
172
+
173
+ export function utcDay(now: Date = new Date()): string {
174
+ return now.toISOString().slice(0, 10);
175
+ }
176
+
177
+ export function readBudget(repoRoot: string, now: Date = new Date()): UnattendedBudget {
178
+ const day = utcDay(now);
179
+ let raw: unknown;
180
+ try {
181
+ raw = JSON.parse(fs.readFileSync(path.join(repoRoot, BUDGET_REL), 'utf-8'));
182
+ } catch {
183
+ return emptyBudget(day);
184
+ }
185
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return emptyBudget(day);
186
+ const o = raw as Record<string, unknown>;
187
+ const num = (k: string): number => {
188
+ const v = o[k];
189
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0;
190
+ };
191
+ const opened = typeof o['window_opened'] === 'string' ? o['window_opened'] : day;
192
+ // A new UTC day rolls the window: the caps persist, the spend resets.
193
+ const rolled = opened !== day;
194
+ return {
195
+ schema_version: 1,
196
+ max_usd: num('max_usd'),
197
+ max_tokens: num('max_tokens'),
198
+ spent_usd: rolled ? 0 : num('spent_usd'),
199
+ spent_tokens: rolled ? 0 : num('spent_tokens'),
200
+ window_opened: rolled ? day : opened,
201
+ };
202
+ }
203
+
204
+ export function writeBudget(repoRoot: string, b: UnattendedBudget): void {
205
+ const file = path.join(repoRoot, BUDGET_REL);
206
+ fs.mkdirSync(path.dirname(file), { recursive: true });
207
+ fs.writeFileSync(file, `${JSON.stringify(b, null, 2)}\n`, 'utf-8');
208
+ }
209
+
210
+ export interface BudgetVerdict {
211
+ readonly allowed: boolean;
212
+ readonly reason: string;
213
+ }
214
+
215
+ /**
216
+ * May a run costing `usd` / `tokens` proceed?
217
+ *
218
+ * Checked BEFORE the call, never after: a cap enforced on the way out has
219
+ * already been exceeded by the time it fires.
220
+ */
221
+ export function checkBudget(b: UnattendedBudget, usd: number, tokens: number): BudgetVerdict {
222
+ if (b.max_usd <= 0 && b.max_tokens <= 0) {
223
+ return {
224
+ allowed: false,
225
+ reason:
226
+ 'no unattended budget configured (both ceilings are 0) — an absent budget ' +
227
+ 'disables unattended runs rather than permitting unbounded ones',
228
+ };
229
+ }
230
+ // Each ceiling is checked INDEPENDENTLY, and a 0 on either dimension
231
+ // refuses any consumption of it rather than skipping the check.
232
+ //
233
+ // R2 review, finding 5. The guarded form was `max_tokens > 0 && …`, so an
234
+ // operator writing `{max_usd: 5, max_tokens: 0}` — following the field's
235
+ // own "`0` disables" — got an UNBOUNDED token allowance, and `digest()`
236
+ // then printed `N/0 tokens`, which reads as an exhausted cap while runs
237
+ // were being permitted. The whole point of this module's zero-default is
238
+ // that a missing number never means unlimited; a zero written on purpose
239
+ // must mean the same thing as a zero left unset.
240
+ if (b.max_usd <= 0 && usd > 0) {
241
+ return {
242
+ allowed: false,
243
+ reason: `USD ceiling is 0 (disabled) and ${usd} was projected`,
244
+ };
245
+ }
246
+ if (b.max_usd > 0 && b.spent_usd + usd > b.max_usd) {
247
+ return {
248
+ allowed: false,
249
+ reason: `USD ceiling: ${b.spent_usd} spent + ${usd} projected > ${b.max_usd}`,
250
+ };
251
+ }
252
+ if (b.max_tokens <= 0 && tokens > 0) {
253
+ return {
254
+ allowed: false,
255
+ reason: `token ceiling is 0 (disabled) and ${tokens} was projected`,
256
+ };
257
+ }
258
+ if (b.max_tokens > 0 && b.spent_tokens + tokens > b.max_tokens) {
259
+ return {
260
+ allowed: false,
261
+ reason: `token ceiling: ${b.spent_tokens} spent + ${tokens} projected > ${b.max_tokens}`,
262
+ };
263
+ }
264
+ return { allowed: true, reason: 'within budget' };
265
+ }
266
+
267
+ export function bookSpend(
268
+ b: UnattendedBudget,
269
+ usd: number,
270
+ tokens: number,
271
+ ): UnattendedBudget {
272
+ return { ...b, spent_usd: b.spent_usd + usd, spent_tokens: b.spent_tokens + tokens };
273
+ }
274
+
275
+ /**
276
+ * The dedup key for one unit of unattended work.
277
+ *
278
+ * Derived from WHAT is being done — roadmap plus the commit it starts from —
279
+ * not from a random id, so a second caller with the same work recognises the
280
+ * first caller's job. A random id would make every duplicate look new, which
281
+ * is exactly the collision this exists to catch.
282
+ */
283
+ export function jobKey(roadmapSlug: string, head: string): string {
284
+ return `${roadmapSlug}@${head.slice(0, 12)}`;
285
+ }
286
+
287
+ export type JobLedger = Record<string, { started_at: string }>;
288
+
289
+ export function readJobs(repoRoot: string): JobLedger {
290
+ try {
291
+ const raw: unknown = JSON.parse(fs.readFileSync(path.join(repoRoot, JOBS_REL), 'utf-8'));
292
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return {};
293
+ const out: JobLedger = {};
294
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
295
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
296
+ const started = (v as Record<string, unknown>)['started_at'];
297
+ if (typeof started === 'string') out[k] = { started_at: started };
298
+ }
299
+ }
300
+ return out;
301
+ } catch {
302
+ return {};
303
+ }
304
+ }
305
+
306
+ export function writeJobs(repoRoot: string, jobs: JobLedger): void {
307
+ const file = path.join(repoRoot, JOBS_REL);
308
+ fs.mkdirSync(path.dirname(file), { recursive: true });
309
+ fs.writeFileSync(file, `${JSON.stringify(jobs, null, 2)}\n`, 'utf-8');
310
+ }
311
+
312
+ export interface PreflightInput {
313
+ readonly repoRoot: string;
314
+ readonly worktree: string;
315
+ readonly roadmapSlug: string;
316
+ readonly head: string;
317
+ readonly projectedUsd: number;
318
+ readonly projectedTokens: number;
319
+ readonly now?: Date;
320
+ }
321
+
322
+ export interface PreflightVerdict {
323
+ readonly ok: boolean;
324
+ /** Every failed precondition, not just the first — one run, one full answer. */
325
+ readonly refusals: readonly string[];
326
+ readonly jobKey: string;
327
+ }
328
+
329
+ /**
330
+ * All three preconditions, reported together.
331
+ *
332
+ * Returns every refusal rather than short-circuiting: an operator fixing one
333
+ * precondition at a time round-trips N times, and the whole point of a
334
+ * preflight is that it answers in one pass.
335
+ *
336
+ * THE DEDUP PRECONDITION CANNOT FIRE TODAY, and that is stated here rather
337
+ * than implied by a passing read. It queries the job ledger correctly, but
338
+ * nothing in the tree WRITES that ledger: `writeJobs`, `bookSpend` and
339
+ * `writeBudget` have no production caller, so two callers racing the same
340
+ * `roadmap@head` both receive `ok: true`. R2 review, finding 13 — the module
341
+ * previously listed dedup among its three preconditions with no note, which
342
+ * describes a control that is not there.
343
+ *
344
+ * The write half belongs to the spawn, which Phase 4.0 deliberately does not
345
+ * build. The claim-and-release lifecycle is the spawn's to own: a claim
346
+ * written by a checker that never learns whether the job started would strand
347
+ * the key on every crash and turn the dedup into a permanent refusal. Until
348
+ * the spawn exists, treat this as a read that is ready for a writer, never as
349
+ * a live guarantee.
350
+ */
351
+ export function preflight(input: PreflightInput): PreflightVerdict {
352
+ const refusals: string[] = [];
353
+ const key = jobKey(input.roadmapSlug, input.head);
354
+
355
+ const remotes = checkRemotes(input.worktree);
356
+ if (!remotes.safe) refusals.push(`remote: ${remotes.reason}`);
357
+
358
+ const budget = readBudget(input.repoRoot, input.now ?? new Date());
359
+ const verdict = checkBudget(budget, input.projectedUsd, input.projectedTokens);
360
+ if (!verdict.allowed) refusals.push(`budget: ${verdict.reason}`);
361
+
362
+ const jobs = readJobs(input.repoRoot);
363
+ if (jobs[key] !== undefined) {
364
+ refusals.push(
365
+ `dedup: job '${key}' already started at ${jobs[key]?.started_at ?? 'unknown'} — ` +
366
+ `the same roadmap at the same head is already in flight`,
367
+ );
368
+ }
369
+
370
+ return { ok: refusals.length === 0, refusals, jobKey: key };
371
+ }
@@ -94,6 +94,16 @@ const _VALID_PROVIDERS: ReadonlySet<string> = new Set([
94
94
  ]);
95
95
  const _VALID_MODES: ReadonlySet<string> = new Set(['api', 'manual', 'cli', 'auto']);
96
96
 
97
+ // The fallback/second-model surfaces live in `fallback_config.ts` — this file
98
+ // is far over the source ceiling and the documented fix is extraction.
99
+ const _FALLBACK_DEPS = {
100
+ isDict: _isDict as (v: unknown) => v is Dict,
101
+ isBool: _isBool as (v: unknown) => v is boolean,
102
+ isStr: _isStr as (v: unknown) => v is string,
103
+ repr: _pyRepr, typeName: _pyTypeName, sortedListRepr: _sortedListRepr,
104
+ error: (msg: string): Error => new CouncilConfigError(msg),
105
+ };
106
+
97
107
  /**
98
108
  * Prefixes that signal "this is a raw API key" so we refuse it loudly
99
109
  * even when the user accidentally inlined it in `api_key_ref`.
@@ -537,6 +547,12 @@ const _VALID_CHAIRMAN_MODES: ReadonlySet<string> = new Set(['host', 'member', 'a
537
547
  export interface DecisionResolutionEntry {
538
548
  readonly mode: string;
539
549
  readonly confidence_threshold: number;
550
+ /**
551
+ * OPTIONAL local second-model rung — UOTL Phase 4.1. Absent (`null`) →
552
+ * the ladder is unchanged. Provider set, quota binding and the locked-class
553
+ * refusal live in `fallback_config.ts::buildSecondModel`.
554
+ */
555
+ readonly second_model: string | null;
540
556
  }
541
557
 
542
558
  /** Opt-in fuzzy matching for the corpus-aware classifier (step-9 P5). */
@@ -617,6 +633,9 @@ export type QuorumSetting = 'majority' | number;
617
633
  */
618
634
  export type QuorumMinPresent = number;
619
635
 
636
+ export type { FallbackConfig } from './fallback_config.js';
637
+ import { buildFallback, buildSecondModel, type FallbackConfig as _FallbackConfig } from './fallback_config.js';
638
+
620
639
  export interface CouncilConfig {
621
640
  readonly enabled: boolean;
622
641
  readonly defaults: DefaultsConfig;
@@ -627,6 +646,7 @@ export interface CouncilConfig {
627
646
  readonly cli_call_budget: CliCallBudgetConfig;
628
647
  readonly quorum: QuorumSetting;
629
648
  readonly quorum_min_present: QuorumMinPresent;
649
+ readonly fallback: _FallbackConfig;
630
650
  readonly necessity_classifier: NecessityClassifierConfig;
631
651
  readonly model_downgrade: ModelDowngradeConfig;
632
652
  readonly debate: DebateConfig;
@@ -881,6 +901,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
881
901
  const quorum_min_present = _build_quorum_min_present(
882
902
  _get(raw, 'quorum_min_present', SOLO_FLOOR_MIN_PRESENT),
883
903
  );
904
+ const fallback = buildFallback(_get(raw, 'fallback', {}), _FALLBACK_DEPS);
884
905
  const necessity_classifier = _build_necessity_classifier(
885
906
  _asDict(_getOr(raw, 'necessity_classifier', {})),
886
907
  );
@@ -931,6 +952,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
931
952
  cli_call_budget,
932
953
  quorum,
933
954
  quorum_min_present,
955
+ fallback,
934
956
  necessity_classifier,
935
957
  model_downgrade,
936
958
  debate,
@@ -1309,7 +1331,11 @@ function _build_decision_resolution(d: Dict): DecisionResolutionConfig {
1309
1331
  `must be in [0.0, 1.0] (got ${_pyRepr(_f(threshold))}).`,
1310
1332
  );
1311
1333
  }
1312
- classes.set(cls, { mode, confidence_threshold: threshold });
1334
+ // UOTL Phase 4.1 — the optional local rung, REFUSED on a locked
1335
+ // class rather than ignored: a dropped key reads as configured.
1336
+ const second_model = buildSecondModel(
1337
+ entry_raw as Record<string, unknown>, cls, _LOCKED_IMPACT_CLASSES, _FALLBACK_DEPS);
1338
+ classes.set(cls, { mode, confidence_threshold: threshold, second_model });
1313
1339
  }
1314
1340
  const fast_path_raw = _getOr(d, 'fast_path', {});
1315
1341
  if (!_isDict(fast_path_raw)) {
@@ -66,13 +66,24 @@ import type { AbsentReason } from './transport_resolver.js';
66
66
  */
67
67
  export const SCHEMA_VERSION = 4;
68
68
 
69
- export type EventAction = 'proceed' | 'skip_necessity' | 'block_quota' | 'quorum_result';
69
+ export type EventAction =
70
+ | 'proceed'
71
+ | 'skip_necessity'
72
+ | 'block_quota'
73
+ | 'quorum_result'
74
+ | 'transport_fallback';
70
75
 
71
76
  const _VALID_ACTIONS: ReadonlySet<string> = new Set([
72
77
  'proceed',
73
78
  'skip_necessity',
74
79
  'block_quota',
75
80
  'quorum_result',
81
+ // A seat lost its cli transport mid-pass and was retried on the metered
82
+ // api rung. Without this action, attendance analysis cannot tell a seat
83
+ // SAVED by the fallback from a seat that was natively api all along — the
84
+ // two are indistinguishable in the response set, and only one of them
85
+ // costs money that the operator did not plan.
86
+ 'transport_fallback',
76
87
  ]);
77
88
 
78
89
  /**
@@ -0,0 +1,114 @@
1
+ /**
2
+ * fallback_config — the two config surfaces the question ladder added, kept
3
+ * out of `config.ts` because that file is 800 lines over the source ceiling
4
+ * and the documented fix is extraction rather than a raised baseline
5
+ * (`gate-violation-baselines.json` § check_source_size_budget).
6
+ *
7
+ * Behavioural contract: `docs/contracts/ai-council-config.md` — § "Mid-flight
8
+ * fallback is failure-class-gated" for `fallback.api_on_quota`, § "Decision
9
+ * classes" for `second_model`. The reasons a reader needs AT the code are
10
+ * here; the rest is there rather than duplicated.
11
+ */
12
+
13
+ /**
14
+ * `fallback.*` — the mid-flight cli→api retry's one configurable knob.
15
+ *
16
+ * Modelled rather than read leniently off the raw dict for a mechanical
17
+ * reason: `council_cli.ts::load_settings` does not hand callers the file, it
18
+ * hands them a block SYNTHESIZED from `CouncilConfig`. A key this type does
19
+ * not carry cannot reach `build_members`, whatever the operator wrote — the
20
+ * same defect `quorum` and `quorum_min_present` each shipped once already.
21
+ */
22
+ export interface FallbackConfig {
23
+ /**
24
+ * May an exhausted CLI quota retry on the metered api rung? Default
25
+ * `false`. The no-double-charge classes are always eligible and never
26
+ * consult this; `timeout` / 5xx are never eligible under any value.
27
+ */
28
+ readonly api_on_quota: boolean;
29
+ }
30
+
31
+ /**
32
+ * Providers a `second_model` rung may name — a SUBSET of the five
33
+ * `members:` accepts, and of `council_cli.ts::_CLI_PROVIDERS`.
34
+ *
35
+ * `xai` and `perplexity` ship COMMUNITY CLI wrappers that consume an API key
36
+ * and set `billable = true`, so routing a resolution there would spend USD on
37
+ * the rung whose entire purpose is not to. The discriminator is
38
+ * `billable === false`, not "has a cli subclass" — the wider set would have
39
+ * been the easy read and the wrong one.
40
+ */
41
+ export const SECOND_MODEL_PROVIDERS: ReadonlySet<string> = new Set([
42
+ 'anthropic',
43
+ 'openai',
44
+ 'gemini',
45
+ ]);
46
+
47
+ export interface FallbackBuilderDeps {
48
+ readonly isDict: (v: unknown) => v is Record<string, unknown>;
49
+ readonly isBool: (v: unknown) => v is boolean;
50
+ readonly isStr: (v: unknown) => v is string;
51
+ readonly repr: (v: unknown) => string;
52
+ readonly typeName: (v: unknown) => string;
53
+ readonly sortedListRepr: (s: ReadonlySet<string>) => string;
54
+ readonly error: (msg: string) => Error;
55
+ }
56
+
57
+ /**
58
+ * Validate the `fallback` block.
59
+ *
60
+ * Asymmetric on purpose. A malformed BLOCK is tolerated as absent — the key's
61
+ * whole job is to WITHHOLD spend, so a garbled container degrading to "off" is
62
+ * safe, and a config that fails to load is worse. A malformed `api_on_quota`
63
+ * VALUE is refused: `"yes"` is an operator trying to authorise spend, and
64
+ * silently reading that as `false` would be as wrong as reading it as `true`.
65
+ */
66
+ export function buildFallback(raw: unknown, d: FallbackBuilderDeps): FallbackConfig {
67
+ if (!d.isDict(raw)) return { api_on_quota: false };
68
+ const v = raw['api_on_quota'];
69
+ 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 };
76
+ }
77
+
78
+ /**
79
+ * Validate one decision class's optional `second_model` rung.
80
+ *
81
+ * Absent → `null`. Present on a LOCKED class → hard error, even for an
82
+ * explicit `null`: those classes do not have this dimension, and accepting the
83
+ * key at any value teaches its author that they do. Present with a provider
84
+ * outside the USD-neutral set → hard error naming the set, because the failure
85
+ * this rung exists to prevent is a halt and silently disabling it would
86
+ * reintroduce that halt at the moment it is needed.
87
+ */
88
+ export function buildSecondModel(
89
+ entry: Record<string, unknown>,
90
+ cls: string,
91
+ locked: ReadonlySet<string>,
92
+ d: FallbackBuilderDeps,
93
+ ): string | null {
94
+ if (!('second_model' in entry)) return null;
95
+ if (locked.has(cls)) {
96
+ throw d.error(
97
+ `decision_resolution.classes.${cls}.second_model=${d.repr(entry['second_model'])}: ` +
98
+ `a second-model rung is not configurable for high-impact / user-required ` +
99
+ `decisions — those classes are LOCKED to \`user\` (Iron Law) and never resolve ` +
100
+ `on a model of any kind.`,
101
+ );
102
+ }
103
+ const raw = entry['second_model'];
104
+ if (raw === null) return null;
105
+ if (!(d.isStr(raw) && SECOND_MODEL_PROVIDERS.has(raw))) {
106
+ throw d.error(
107
+ `decision_resolution.classes.${cls}.second_model=${d.repr(raw)} not in ` +
108
+ `${d.sortedListRepr(SECOND_MODEL_PROVIDERS)} — the rung must be a provider whose ` +
109
+ `local CLI runs under subscription auth, so a resolution can never become ` +
110
+ `metered USD spend.`,
111
+ );
112
+ }
113
+ return raw;
114
+ }