@event4u/agent-config 14.1.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 (120) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +291 -181
  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 +3773 -1731
  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 +22 -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/skill_catalogue_series.ts +91 -0
  75. package/src/scripts/_lib/stdin.ts +35 -1
  76. package/src/scripts/_lib/turn_end_refusals.ts +12 -4
  77. package/src/scripts/_lib/unattended_guard.ts +371 -0
  78. package/src/scripts/ai_council/config.ts +27 -1
  79. package/src/scripts/ai_council/events_log.ts +12 -1
  80. package/src/scripts/ai_council/fallback_config.ts +114 -0
  81. package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
  82. package/src/scripts/ai_council/orchestrator.ts +251 -292
  83. package/src/scripts/ai_council/py_parity.ts +139 -0
  84. package/src/scripts/ai_council/response_render.ts +99 -0
  85. package/src/scripts/ai_council/spend_gate.ts +123 -0
  86. package/src/scripts/ai_council/transport_resolver.ts +81 -13
  87. package/src/scripts/bench_hook_injection.ts +100 -6
  88. package/src/scripts/bench_hook_latency.ts +442 -16
  89. package/src/scripts/check_estate_count.ts +924 -0
  90. package/src/scripts/compile_hook_manifest.ts +67 -0
  91. package/src/scripts/council_cli.ts +100 -125
  92. package/src/scripts/decision_memo.ts +325 -0
  93. package/src/scripts/dispatch_r2_reviewer.ts +169 -15
  94. package/src/scripts/hook_manifest.json +1 -0
  95. package/src/scripts/hook_manifest.yaml +172 -25
  96. package/src/scripts/hooks/concern_registry.ts +6 -0
  97. package/src/scripts/hooks/dispatch_hook.ts +183 -10
  98. package/src/scripts/hooks/dispatch_issues.ts +26 -0
  99. package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
  100. package/src/scripts/hooks/hook_stdin.ts +111 -5
  101. package/src/scripts/hooks/injection_budget.ts +595 -0
  102. package/src/scripts/hooks/payload_stub.ts +446 -0
  103. package/src/scripts/hooks/run_continuation_hook.ts +627 -0
  104. package/src/scripts/hooks/session_eol_hook.ts +52 -0
  105. package/src/scripts/hooks/skill_route_hook.ts +172 -11
  106. package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
  107. package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
  108. package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
  109. package/src/scripts/hooks_doctor.ts +16 -2
  110. package/src/scripts/install-hooks.sh +26 -0
  111. package/src/scripts/interruption_report.ts +330 -13
  112. package/src/scripts/lint_hook_manifest.ts +167 -0
  113. package/src/scripts/roadmap_progress_hook.ts +254 -46
  114. package/src/scripts/run_supervise.ts +623 -0
  115. package/src/scripts/session_register_hook.ts +108 -8
  116. package/src/scripts/sessions_cli.ts +18 -6
  117. package/src/scripts/telemetry_disclosure_hook.ts +244 -0
  118. package/src/server/io/yamlIO.ts +167 -3
  119. package/src/shared/settingsConsent.ts +49 -2
  120. package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
@@ -41,6 +41,31 @@ function _sleepSync(ms: number): void {
41
41
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
42
42
  }
43
43
 
44
+ /** Options for {@link readStdinText}. */
45
+ export interface ReadStdinOptions {
46
+ /**
47
+ * Give up and return `''` when the FIRST byte has not arrived within this
48
+ * many milliseconds. Undefined (the default) keeps the historical behaviour:
49
+ * the full {@link MAX_RETRIES} budget applies from the first read, which is
50
+ * what a `gh pr diff` pipe needs — its first byte can legitimately take a
51
+ * while, and reporting that as empty input is the defect this module exists
52
+ * to prevent.
53
+ *
54
+ * A HOOK is the opposite case, and it cost a CI hang to learn the difference.
55
+ * The host writes the payload synchronously on spawn, so a byte that has not
56
+ * arrived promptly is not a slow writer — it is an fd 0 nobody will ever
57
+ * write to, which is what a child inherits when a test spawns it without
58
+ * piping stdin. With no cap the dispatcher then span the full ~10 s budget on
59
+ * EVERY such invocation and returned "" anyway; measured 12.4 s per call
60
+ * against an immediate return before the retry loop existed. Hundreds of
61
+ * those hung three Node-Tests shards for over an hour.
62
+ *
63
+ * Once ANY byte has arrived the cap no longer applies: a writer demonstrably
64
+ * exists, so the full budget is right and the large-payload fix is intact.
65
+ */
66
+ firstByteTimeoutMs?: number;
67
+ }
68
+
44
69
  /**
45
70
  * Read all of fd 0 as UTF-8.
46
71
  *
@@ -48,10 +73,13 @@ function _sleepSync(ms: number): void {
48
73
  * {@link MAX_RETRIES} consecutive EAGAINs — a caller must not be able to
49
74
  * mistake a failed read for empty input.
50
75
  */
51
- export function readStdinText(fd = 0): string {
76
+ export function readStdinText(fd = 0, opts: ReadStdinOptions = {}): string {
52
77
  const chunks: Buffer[] = [];
53
78
  const buf = Buffer.alloc(CHUNK);
54
79
  let retries = 0;
80
+ let got_any = false;
81
+ const first_byte_deadline =
82
+ opts.firstByteTimeoutMs === undefined ? null : Date.now() + opts.firstByteTimeoutMs;
55
83
  for (;;) {
56
84
  let read: number;
57
85
  try {
@@ -60,6 +88,11 @@ export function readStdinText(fd = 0): string {
60
88
  } catch (exc) {
61
89
  const code = (exc as NodeJS.ErrnoException).code;
62
90
  if (code === 'EAGAIN') {
91
+ // An idle fd 0 is "no input", not a slow writer — but only
92
+ // before the first byte. See ReadStdinOptions.firstByteTimeoutMs.
93
+ if (!got_any && first_byte_deadline !== null && Date.now() >= first_byte_deadline) {
94
+ return '';
95
+ }
63
96
  retries += 1;
64
97
  if (retries > MAX_RETRIES) {
65
98
  throw new Error(
@@ -76,6 +109,7 @@ export function readStdinText(fd = 0): string {
76
109
  throw exc;
77
110
  }
78
111
  if (read === 0) break;
112
+ got_any = true;
79
113
  chunks.push(Buffer.from(buf.subarray(0, read)));
80
114
  }
81
115
  return Buffer.concat(chunks).toString('utf-8');
@@ -45,10 +45,18 @@ import { read_lockfile } from './installed_lock.js';
45
45
  *
46
46
  * The roadmap's § 0 names three (A promissory, B language, C verification).
47
47
  * That was true of the draft and is not true of the tree: detector D
48
- * (`completion`) landed under round 7 § Phase 1 and is in the same
49
- * unconditional list as the other three. Counting three would silently drop a
50
- * detector's refusals, so the set is read off `DetectorId` in the gate rather
51
- * than off the prose.
48
+ * (`completion`) landed under round 7 § Phase 1 and runs in the same detector
49
+ * list as the other three. Counting three would silently drop a detector's
50
+ * refusals, so the set is read off `DetectorId` in the gate rather than off the
51
+ * prose.
52
+ *
53
+ * This said "the same UNCONDITIONAL list" until 2026-08-18, which was wrong and
54
+ * is worth naming rather than quietly rewording: `main()` runs A and D only when
55
+ * no subagent dispatch is open. It does not change this set — every detector
56
+ * that CAN fire needs a counter — but it does change what a reader may conclude
57
+ * from a zero here: A's and D's counts are censored by that third allow path,
58
+ * and `docs/contracts/turn-end-detector-demotion.md` is where that consequence
59
+ * is carried.
52
60
  */
53
61
  export const DETECTOR_IDS = [
54
62
  'promissory',
@@ -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
+ }