@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
@@ -70,70 +70,60 @@ import {
70
70
  } from './prompts.js';
71
71
  import { count_dissenters, dissent_quota_met, is_near_duplicate } from './debate_gates.js';
72
72
  import { parse_stance_line, render_vote_tally, tally_stances } from './stance_tally.js';
73
+ import { MidFlightFallback } from './transport_resolver.js';
74
+ import {
75
+ emitOutcome,
76
+ escalateUnmetered,
77
+ refuseUnmeteredEscalation,
78
+ establishTwin,
79
+ isFailedCliCall,
80
+ runGatedRetry,
81
+ stampFallback,
82
+ type CliFallbackOptions,
83
+ type EstablishedTwin,
84
+ type TwinMap,
85
+ } from './mid_flight_fallback.js';
86
+
87
+ export type { CliFallbackOptions, EstablishedTwin } from './mid_flight_fallback.js';
88
+
89
+ // The spend gate, extracted whole. Re-exported so every existing importer of
90
+ // `CostBudget` / `OverrunEvent` from this module keeps working unchanged.
91
+ export {
92
+ CostBudget,
93
+ OverrunEvent,
94
+ _breach,
95
+ _total_usd,
96
+ type BreachKind,
97
+ type Spent,
98
+ } from './spend_gate.js';
99
+ import {
100
+ CostBudget,
101
+ OverrunEvent,
102
+ _breach,
103
+ _total_usd,
104
+ type Spent,
105
+ } from './spend_gate.js';
106
+ import { callMember, renderResponseMeta } from './response_render.js';
107
+ import {
108
+ _excTag,
109
+ _metaGet,
110
+ _pyFixed,
111
+ _pyInt,
112
+ _pyLStrip,
113
+ _pyLen,
114
+ _pyReprInt,
115
+ _pyRStrip,
116
+ _pySlice,
117
+ _pySplitWhitespace,
118
+ _pyStrip,
119
+ _setdefault,
120
+ } from './py_parity.js';
121
+
73
122
  import { CHAIRMAN_FIELDS_ADDENDUM, render_deanonymization_block } from './blind_review.js';
74
123
  import type { AbsentReason } from './transport_resolver.js';
75
124
  import { isSoloConcluded, type QuorumResult } from './quorum.js';
76
125
  import { isEmptyHandoff, type HandoffEnvelope } from './handoff.js';
77
126
 
78
- // ── Python-format / stdlib parity helpers ────────────────────────────────
79
- //
80
- // The orchestrator formats USD / scores / strengths via Python f-string
81
- // specs (`:.4f`, `:.2f`, `:.1f`) which round half-to-even on the decimal
82
- // representation. JS `toFixed` rounds half away from zero, so the spec
83
- // formatting is reimplemented to stay byte-exact with the retired Python implementation.
84
-
85
- /**
86
- * Format `x` to `ndigits` decimals using round-half-to-even, matching
87
- * CPython's `format(x, ".<ndigits>f")`.
88
- */
89
- function _pyFixed(x: number, ndigits: number): string {
90
- if (!Number.isFinite(x)) {
91
- return String(x);
92
- }
93
- const neg = x < 0 || Object.is(x, -0);
94
- const abs = Math.abs(x);
95
- const factor = Math.pow(10, ndigits);
96
- const scaled = abs * factor;
97
- const floor = Math.floor(scaled);
98
- const frac = scaled - floor;
99
- const tol = Math.max(Math.abs(scaled), 1) * 2 ** -40;
100
- let rounded: number;
101
- if (Math.abs(frac - 0.5) <= tol) {
102
- rounded = floor % 2 === 0 ? floor : floor + 1;
103
- } else {
104
- rounded = Math.round(scaled);
105
- }
106
- let intStr = String(rounded);
107
- let result: string;
108
- if (ndigits === 0) {
109
- result = intStr;
110
- } else {
111
- if (intStr.length <= ndigits) {
112
- intStr = '0'.repeat(ndigits - intStr.length + 1) + intStr;
113
- }
114
- const whole = intStr.slice(0, intStr.length - ndigits);
115
- const dec = intStr.slice(intStr.length - ndigits);
116
- result = `${whole}.${dec}`;
117
- }
118
- return neg ? `-${result}` : result;
119
- }
120
-
121
- /** Mirror Python `str.strip()` (no-arg). Sibling-twin convention uses trim(). */
122
- function _pyStrip(s: string): string {
123
- return s.trim();
124
- }
125
-
126
- /**
127
- * Mirror Python `str.split()` (no separator) — split on runs of whitespace,
128
- * dropping leading / trailing whitespace (no empty tokens).
129
- */
130
- function _pySplitWhitespace(s: string): string[] {
131
- const trimmed = s.trim();
132
- if (trimmed === '') {
133
- return [];
134
- }
135
- return trimmed.split(/\s+/);
136
- }
137
127
 
138
128
  /**
139
129
  * Mirror Python `getattr(obj, name, fallback)` for the duck-typed member
@@ -160,30 +150,6 @@ function _label(idx: number): string {
160
150
 
161
151
  // ── dataclasses ───────────────────────────────────────────────────────
162
152
 
163
- export class CostBudget {
164
- max_input_tokens: number;
165
- max_output_tokens: number;
166
- max_calls: number;
167
- max_total_usd: number; // 0 = USD ceiling disabled (token caps still apply)
168
- daily_limit_usd: number; // 0 = rolling 24h cap disabled (D3)
169
-
170
- constructor(
171
- args: {
172
- max_input_tokens?: number;
173
- max_output_tokens?: number;
174
- max_calls?: number;
175
- max_total_usd?: number;
176
- daily_limit_usd?: number;
177
- } = {},
178
- ) {
179
- this.max_input_tokens = args.max_input_tokens ?? 50_000;
180
- this.max_output_tokens = args.max_output_tokens ?? 20_000;
181
- this.max_calls = args.max_calls ?? 10;
182
- this.max_total_usd = args.max_total_usd ?? 0.0;
183
- this.daily_limit_usd = args.daily_limit_usd ?? 0.0;
184
- }
185
- }
186
-
187
153
  export class CouncilQuestion {
188
154
  mode: string; // one of: prompt, roadmap, diff, files
189
155
  user_prompt: string; // bundled artefact text
@@ -196,44 +162,6 @@ export class CouncilQuestion {
196
162
  }
197
163
  }
198
164
 
199
- /** Passed to `on_overrun` when projected spend exceeds the budget. */
200
- export class OverrunEvent {
201
- member_index: number;
202
- member: ExternalAIClient;
203
- next_estimate: CostEstimate; // this member's projected cost
204
- spent_input_tokens: number; // already-billed totals BEFORE this member
205
- spent_output_tokens: number;
206
- spent_usd: number;
207
- projected_total_usd: number; // spent_usd + next_estimate.total_usd
208
- daily_spent_usd: number; // rolling 24h spend BEFORE this member (D3)
209
- daily_limit_usd: number; // the configured daily cap (0 = disabled)
210
- breach_kind: string; // "session" | "daily" | "tokens"
211
-
212
- constructor(args: {
213
- member_index: number;
214
- member: ExternalAIClient;
215
- next_estimate: CostEstimate;
216
- spent_input_tokens: number;
217
- spent_output_tokens: number;
218
- spent_usd: number;
219
- projected_total_usd: number;
220
- daily_spent_usd?: number;
221
- daily_limit_usd?: number;
222
- breach_kind?: string;
223
- }) {
224
- this.member_index = args.member_index;
225
- this.member = args.member;
226
- this.next_estimate = args.next_estimate;
227
- this.spent_input_tokens = args.spent_input_tokens;
228
- this.spent_output_tokens = args.spent_output_tokens;
229
- this.spent_usd = args.spent_usd;
230
- this.projected_total_usd = args.projected_total_usd;
231
- this.daily_spent_usd = args.daily_spent_usd ?? 0.0;
232
- this.daily_limit_usd = args.daily_limit_usd ?? 0.0;
233
- this.breach_kind = args.breach_kind ?? 'session';
234
- }
235
- }
236
-
237
165
  // Callback signature: receive event → return True (proceed) or False (skip + tag error).
238
166
  export type OnOverrunCallback = (event: OverrunEvent) => boolean;
239
167
 
@@ -360,10 +288,6 @@ export function estimate_debate_cost(
360
288
  });
361
289
  }
362
290
 
363
- /** Mirror the Python `CostEstimate.total_usd` property. */
364
- function _total_usd(e: CostEstimate): number {
365
- return e.input_usd + e.output_usd;
366
- }
367
291
 
368
292
  /**
369
293
  * Return a pre-call cost estimate per member, in input order.
@@ -449,6 +373,8 @@ export interface ConsultOptions {
449
373
  * context). Default `null` → byte-identical to today.
450
374
  */
451
375
  no_project_context_members?: ReadonlySet<string> | null;
376
+ /** Mid-flight cli→api fallback; absent → no retry. See `mid_flight_fallback`. */
377
+ cli_fallback?: CliFallbackOptions | null;
452
378
  }
453
379
 
454
380
  /**
@@ -504,6 +430,11 @@ export function consult(
504
430
  }
505
431
 
506
432
  const spent: Spent = { input: 0, output: 0, usd: 0.0 };
433
+ // One ledger + one twin map per invocation — the double-spend unit.
434
+ const cli_fallback = opts.cli_fallback ?? null;
435
+ const fallback_ledger = cli_fallback !== null ? new MidFlightFallback() : null;
436
+ const fallback_twins =
437
+ cli_fallback !== null ? new Map<string, EstablishedTwin>() : null;
507
438
  let last_results: CouncilResponse[] = [];
508
439
  // A3 read unlock: the stable prefix is ALWAYS the original user prompt;
509
440
  // per-round critiques + the STANCE contract ride in a volatile suffix so
@@ -535,6 +466,9 @@ export function consult(
535
466
  project,
536
467
  original_ask,
537
468
  advisor_plans,
469
+ cli_fallback,
470
+ fallback_ledger,
471
+ fallback_twins,
538
472
  member_prompt_suffix: opts.member_prompt_suffix ?? null,
539
473
  no_project_context_members: opts.no_project_context_members ?? null,
540
474
  split: suffix_for_round
@@ -572,12 +506,19 @@ export function consult(
572
506
  `Reply with ONLY that single line for your final position.\n\n${STANCE_LINE_CONTRACT}`,
573
507
  max_tokens: question.max_tokens,
574
508
  });
509
+ // Stance repair shares the invocation-wide ledger: transport
510
+ // health and structural validity are INDEPENDENT failure modes,
511
+ // so refusing the retry would discard salvageable work for an
512
+ // unrelated reason. Bounded by the retry's own spend gate.
575
513
  const rr = _run_round([member], repairQ, resolvedBudget, spent, {
576
514
  table,
577
515
  on_overrun,
578
516
  project,
579
517
  original_ask,
580
518
  advisor_plans,
519
+ cli_fallback,
520
+ fallback_ledger,
521
+ fallback_twins,
581
522
  });
582
523
  const rep = rr[0];
583
524
  if (rep !== undefined) {
@@ -592,12 +533,8 @@ export function consult(
592
533
  return last_results;
593
534
  }
594
535
 
595
- interface Spent {
596
- input: number;
597
- output: number;
598
- usd: number;
599
- }
600
536
 
537
+ /** Which cap a projected call breaches, or `null` when it fits. */
601
538
  interface RunRoundOptions {
602
539
  table: PriceTable | null;
603
540
  on_overrun: OnOverrunCallback | null;
@@ -616,6 +553,11 @@ interface RunRoundOptions {
616
553
  member_prompt_suffix?: Map<string, string> | null;
617
554
  /** Ü2 — see `ConsultOptions.no_project_context_members`. */
618
555
  no_project_context_members?: ReadonlySet<string> | null;
556
+ /** See `ConsultOptions.cli_fallback`. Both set together, or neither. */
557
+ cli_fallback?: CliFallbackOptions | null;
558
+ fallback_ledger?: MidFlightFallback | null;
559
+ /** Invocation-wide substitution map — see `mid_flight_fallback.TwinMap`. */
560
+ fallback_twins?: TwinMap | null;
619
561
  }
620
562
 
621
563
  /** Run a single round; mutate `spent` with cumulative totals. */
@@ -668,77 +610,111 @@ function _run_round(
668
610
  })
669
611
  : null;
670
612
 
613
+ const twins = opts.fallback_twins ?? null;
671
614
  for (let idx = 0; idx < members.length; idx++) {
672
- const member = members[idx] as ExternalAIClient;
615
+ const declared = members[idx] as ExternalAIClient;
616
+ // Sticky substitution: a provider that fell back earlier is
617
+ // replaced BEFORE the call, so the dead binary is spawned once.
618
+ let established = twins?.get(declared.name) ?? null;
619
+ const substituted_on_entry = established !== null;
620
+ let member = established !== null ? established.client : declared;
621
+ // Set only when THIS iteration escalated an unmetered seat; read by
622
+ // the breach handler below so a refusal degrades that one seat instead
623
+ // of the round. See the `unmetered_original !== null` branch there.
624
+ let unmetered_original: CouncilResponse | null = null;
625
+ let unmetered_twin: EstablishedTwin | null = null;
673
626
  // ── non-billable members skip the cost gate entirely ─────────
674
627
  // ManualClient (and future PlaywrightClient) cost us $0; their
675
628
  // token counts are still tracked from the response below for
676
629
  // observability, but no projection / budget breach can apply.
677
630
  if (!(_getattr(member, 'billable', true) as boolean)) {
678
- let response: CouncilResponse;
679
- try {
680
- response = opts.split
681
- ? member.ask_split(
682
- _system_prompt_for_member(member),
683
- opts.split.stable,
684
- opts.split.suffix,
685
- question.max_tokens,
686
- )
687
- : member.ask(
688
- _system_prompt_for_member(member),
689
- question.user_prompt,
690
- question.max_tokens,
691
- );
692
- } catch (exc) {
693
- response = new CouncilResponse({
694
- provider: member.name,
695
- model: member.model,
696
- text: '',
697
- error: _excTag(exc),
698
- });
631
+ let response = callMember(member, question, opts, _system_prompt_for_member, _excTag);
632
+ // ── the fallback's REAL entry point ──────────────────────
633
+ // On an escalation the loop deliberately does NOT `continue`: it
634
+ // falls through to the metered path with `member` swapped for the
635
+ // twin. Why this branch and not the retry block below:
636
+ // `mid_flight_fallback.escalateUnmetered`.
637
+ const nb_twin = escalateUnmetered(
638
+ member,
639
+ response,
640
+ _getattr(member, 'transport', 'api') as string,
641
+ opts.cli_fallback ?? null,
642
+ opts.fallback_ledger ?? null,
643
+ );
644
+ const escalated = nb_twin !== null;
645
+ if (nb_twin !== null) {
646
+ established = nb_twin;
647
+ twins?.set(member.name, nb_twin);
648
+ member = nb_twin.client;
649
+ // Kept so a budget refusal below can return the ORIGINAL cli
650
+ // failure rather than aborting the seat as its twin.
651
+ unmetered_original = response;
652
+ unmetered_twin = nb_twin;
653
+ }
654
+ if (!escalated) {
655
+ _stamp_transport_metadata(response, member);
656
+ results.push(response);
657
+ spent.input += response.input_tokens;
658
+ spent.output += response.output_tokens;
659
+ continue;
699
660
  }
700
- _stamp_transport_metadata(response, member);
701
- results.push(response);
702
- spent.input += response.input_tokens;
703
- spent.output += response.output_tokens;
704
- continue;
705
661
  }
706
662
 
707
663
  // ── projected spend check ────────────────────────────────────
708
- const est = estimates ? (estimates[idx] as CostEstimate) : null;
709
- const proj_input = spent.input + (est ? est.input_tokens : 0);
710
- const proj_output = spent.output + (est ? est.output_tokens : 0);
711
- const proj_usd = spent.usd + (est ? _total_usd(est) : 0.0);
712
- const next_call_usd = est ? _total_usd(est) : 0.0;
713
-
714
- const breaches_tokens =
715
- proj_input > budget.max_input_tokens ||
716
- proj_output > budget.max_output_tokens;
717
- const breaches_usd =
718
- budget.max_total_usd > 0 && proj_usd > budget.max_total_usd;
719
- const breaches_daily =
720
- budget.daily_limit_usd > 0 &&
721
- _would_exceed_daily(budget.daily_limit_usd, next_call_usd);
722
-
723
- if (breaches_tokens || breaches_usd || breaches_daily) {
724
- const breach_kind = breaches_tokens
725
- ? 'tokens'
726
- : breaches_daily
727
- ? 'daily'
728
- : 'session';
664
+ // A substituted twin is priced as ITSELF — the pre-loop estimate row
665
+ // belongs to an unmetered seat and would gate a metered call at $0.
666
+ const est =
667
+ established !== null && table !== null
668
+ ? (estimate(question, [member], table, {
669
+ project,
670
+ original_ask,
671
+ advisor_plans: opts.advisor_plans ?? null,
672
+ })[0] as CostEstimate)
673
+ : estimates
674
+ ? (estimates[idx] as CostEstimate)
675
+ : null;
676
+ const breach = _breach(est, spent, budget);
677
+ if (breach !== null) {
678
+ const breach_kind = breach;
729
679
  const error_tag =
730
680
  breach_kind === 'daily'
731
681
  ? 'daily_budget_exceeded'
732
682
  : 'cost_budget_exceeded';
683
+ // A seat that arrived here by escalating from an UNMETERED cli
684
+ // call degrades to its own original failure — never to an abort,
685
+ // and never to the round-wide short-circuit below. The reasoning,
686
+ // and the rollback of both the twin map and the ledger claim, live
687
+ // in `refuseUnmeteredEscalation`.
688
+ if (unmetered_original !== null && unmetered_twin !== null) {
689
+ const refused = refuseUnmeteredEscalation({
690
+ original: unmetered_original,
691
+ declared,
692
+ twin: unmetered_twin,
693
+ fallback: opts.cli_fallback ?? null,
694
+ ledger: opts.fallback_ledger ?? null,
695
+ twins,
696
+ });
697
+ _stamp_transport_metadata(refused, declared);
698
+ results.push(refused);
699
+ spent.input += refused.input_tokens;
700
+ spent.output += refused.output_tokens;
701
+ continue;
702
+ }
733
703
  if (on_overrun !== null && estimates !== null) {
734
704
  const event = new OverrunEvent({
735
705
  member_index: idx,
736
706
  member,
737
- next_estimate: estimates[idx] as CostEstimate,
707
+ // `est`, not `estimates[idx]`. The breach decision two
708
+ // lines up was already re-priced for a substituted twin;
709
+ // the event that ASKS the operator to approve it showed
710
+ // the pre-loop row for the unmetered DECLARED seat, i.e. a
711
+ // ~$0 next-call estimate for the very call the gate just
712
+ // refused as over budget. R2 review, finding 8.
713
+ next_estimate: (est ?? estimates[idx]) as CostEstimate,
738
714
  spent_input_tokens: _pyInt(spent.input),
739
715
  spent_output_tokens: _pyInt(spent.output),
740
716
  spent_usd: spent.usd,
741
- projected_total_usd: proj_usd,
717
+ projected_total_usd: spent.usd + (est ? _total_usd(est) : 0.0),
742
718
  daily_spent_usd:
743
719
  budget.daily_limit_usd > 0 ? _today_spend_usd() : 0.0,
744
720
  daily_limit_usd: budget.daily_limit_usd,
@@ -757,29 +733,62 @@ function _run_round(
757
733
  }
758
734
  }
759
735
 
736
+ // An escalated unmetered seat clears the gate: NOW the escalation is
737
+ // real, so `retried` is emitted here rather than at establishment.
738
+ // `escalateUnmetered` used to emit it the moment the twin was built,
739
+ // which put a "seat saved" line in the log for escalations the budget
740
+ // then refused — R2 round 2, finding 4.
741
+ if (unmetered_twin !== null) {
742
+ const fb = opts.cli_fallback ?? null;
743
+ if (fb !== null) emitOutcome(fb, declared.name, unmetered_twin, 'retried');
744
+ }
745
+
760
746
  // ── actual call ──────────────────────────────────────────────
761
- let response: CouncilResponse;
762
- try {
763
- response = opts.split
764
- ? member.ask_split(
765
- _system_prompt_for_member(member),
766
- opts.split.stable,
767
- opts.split.suffix,
768
- question.max_tokens,
769
- )
770
- : member.ask(
771
- _system_prompt_for_member(member),
772
- question.user_prompt,
773
- question.max_tokens,
774
- );
775
- } catch (exc) {
776
- response = new CouncilResponse({
777
- provider: member.name,
778
- model: member.model,
779
- text: '',
780
- error: _excTag(exc),
747
+ let response = callMember(member, question, opts, _system_prompt_for_member, _excTag);
748
+
749
+ // ── mid-flight cli→api fallback, billable-cli path ───────────
750
+ // The community CLI wrappers reach the fallback here; the
751
+ // vendor-official ones took the non-billable branch above. Realized
752
+ // cost and the transport stamp below run on `effective`.
753
+ let effective: ExternalAIClient = member;
754
+ const fallback = opts.cli_fallback ?? null;
755
+ const ledger = opts.fallback_ledger ?? null;
756
+ if (
757
+ fallback !== null &&
758
+ ledger !== null &&
759
+ isFailedCliCall(member, response.error, _getattr(member, 'transport', 'api') as string)
760
+ ) {
761
+ const twin = establishTwin({
762
+ member,
763
+ error: String(response.error),
764
+ fallback,
765
+ ledger,
781
766
  });
767
+ if (twin !== null) {
768
+ const out = runGatedRetry(response, member, twin, fallback, {
769
+ gate: (client) =>
770
+ table === null ||
771
+ _breach(
772
+ estimate(question, [client], table, {
773
+ project,
774
+ original_ask,
775
+ advisor_plans: opts.advisor_plans ?? null,
776
+ })[0] as CostEstimate,
777
+ spent,
778
+ budget,
779
+ ) === null,
780
+ call: (client) => callMember(client, question, opts, _system_prompt_for_member, _excTag),
781
+ }, ledger);
782
+ response = out.response;
783
+ effective = out.effective;
784
+ // Established for the rest of the invocation, so later rounds
785
+ // do not re-spawn the dead binary.
786
+ if (out.retried) twins?.set(member.name, twin);
787
+ }
782
788
  }
789
+ // No round is silent about which transport answered; `sticky`
790
+ // marks the reuses (see `stampFallback`).
791
+ if (established !== null) stampFallback(response, established, substituted_on_entry);
783
792
  results.push(response);
784
793
  spent.input += response.input_tokens;
785
794
  spent.output += response.output_tokens;
@@ -792,8 +801,8 @@ function _run_round(
792
801
  // ttl is '5m': the request builder emits `cache_control:
793
802
  // {type:'ephemeral'}` with no explicit ttl, i.e. the 5-min default.
794
803
  const actual = reprice_with_cache(
795
- member.name,
796
- member.model,
804
+ effective.name,
805
+ effective.model,
797
806
  {
798
807
  input_tokens: response.input_tokens,
799
808
  cache_read_input_tokens: response.cache_read_input_tokens,
@@ -808,14 +817,14 @@ function _run_round(
808
817
  // Persist to the rolling 24h ledger when the daily cap is
809
818
  // active. Errors are swallowed inside record_spend.
810
819
  if (budget.daily_limit_usd > 0 && !response.error) {
811
- _record_daily_spend(_total_usd(actual), member.name, member.model, {
820
+ _record_daily_spend(_total_usd(actual), effective.name, effective.model, {
812
821
  cache_read_input_tokens: response.cache_read_input_tokens,
813
822
  cache_creation_input_tokens: response.cache_creation_input_tokens,
814
823
  cache_ttl: '5m',
815
824
  });
816
825
  }
817
826
  }
818
- _stamp_transport_metadata(response, member, actual_usd);
827
+ _stamp_transport_metadata(response, effective, actual_usd);
819
828
  }
820
829
 
821
830
  return results;
@@ -1067,6 +1076,10 @@ function _run_debate_gate_repairs(
1067
1076
  project: ProjectContext | null;
1068
1077
  original_ask: string;
1069
1078
  advisor_plans: Map<string, AdvisorPlan> | null;
1079
+ /** Threaded from `run_debate`; ledger and twins are invocation-wide. */
1080
+ cli_fallback?: CliFallbackOptions | null;
1081
+ fallback_ledger?: MidFlightFallback | null;
1082
+ fallback_twins?: Map<string, EstablishedTwin> | null;
1070
1083
  },
1071
1084
  on_repair: ((member: string, reason: string) => boolean) | null,
1072
1085
  ): CouncilResponse[] {
@@ -1182,6 +1195,18 @@ export interface RunDebateOptions {
1182
1195
  restate?: boolean;
1183
1196
  /** Receives the restate responses (rendering, divergence flags, cost). */
1184
1197
  on_restate?: ((responses: CouncilResponse[]) => void) | null;
1198
+ /**
1199
+ * Mid-flight cli→api fallback for every call this debate makes — the
1200
+ * restate pass, each round, and the gate-repair re-prompts. Absent →
1201
+ * byte-identical to today.
1202
+ *
1203
+ * ONE ledger and one twin map span the whole invocation, never one per
1204
+ * round: the eligible classes are overwhelmingly DURABLE, so a per-round
1205
+ * ledger would re-spawn and re-fail the dead CLI once per round. Why that
1206
+ * scope needs the twin map to be coherent at all, and what it costs a
1207
+ * transient failure: `mid_flight_fallback.TwinMap`.
1208
+ */
1209
+ cli_fallback?: CliFallbackOptions | null;
1185
1210
  }
1186
1211
 
1187
1212
  /**
@@ -1241,6 +1266,11 @@ export function run_debate(
1241
1266
  }
1242
1267
 
1243
1268
  const spent: Spent = { input: 0, output: 0, usd: 0.0 };
1269
+ // One per invocation: restate pass, rounds, and gate repairs share it.
1270
+ const cli_fallback = opts.cli_fallback ?? null;
1271
+ const fallback_ledger = cli_fallback !== null ? new MidFlightFallback() : null;
1272
+ const fallback_twins =
1273
+ cli_fallback !== null ? new Map<string, EstablishedTwin>() : null;
1244
1274
  const all_rounds: CouncilResponse[][] = [];
1245
1275
  // A3 read unlock (debate twin): stable prefix = the original prompt; the
1246
1276
  // per-round positions block rides in a volatile suffix (see consult loop).
@@ -1267,6 +1297,9 @@ export function run_debate(
1267
1297
  project,
1268
1298
  original_ask,
1269
1299
  advisor_plans,
1300
+ cli_fallback,
1301
+ fallback_ledger,
1302
+ fallback_twins,
1270
1303
  });
1271
1304
  opts.on_restate?.(restated);
1272
1305
  }
@@ -1296,6 +1329,9 @@ export function run_debate(
1296
1329
  project,
1297
1330
  original_ask,
1298
1331
  advisor_plans,
1332
+ cli_fallback,
1333
+ fallback_ledger,
1334
+ fallback_twins,
1299
1335
  split: current_suffix
1300
1336
  ? { stable: question.user_prompt, suffix: current_suffix }
1301
1337
  : null,
@@ -1315,7 +1351,16 @@ export function run_debate(
1315
1351
  }),
1316
1352
  budget,
1317
1353
  spent,
1318
- { table, on_overrun, project, original_ask, advisor_plans },
1354
+ {
1355
+ table,
1356
+ on_overrun,
1357
+ project,
1358
+ original_ask,
1359
+ advisor_plans,
1360
+ cli_fallback,
1361
+ fallback_ledger,
1362
+ fallback_twins,
1363
+ },
1319
1364
  opts.on_repair ?? null,
1320
1365
  );
1321
1366
  }
@@ -1731,28 +1776,6 @@ export function run_consensus_scoring(
1731
1776
  * Tokens marked `estimated=True` get a `~` prefix so the audit
1732
1777
  * trail flags heuristic counts.
1733
1778
  */
1734
- function _render_response_meta(r: CouncilResponse): string {
1735
- const meta_dict: Record<string, unknown> = r.metadata ?? {};
1736
- const billable = Boolean(_metaGet(meta_dict, 'billable', true));
1737
- const estimated = Boolean(_metaGet(meta_dict, 'tokens_estimated', false));
1738
- const parts: string[] = [];
1739
- if (!billable) {
1740
- const label = (_metaGet(meta_dict, 'subscription_label', null) ||
1741
- 'flat-rate') as string;
1742
- parts.push(`cost: subscription (${label})`);
1743
- } else {
1744
- const cost_usd = _metaGet(meta_dict, 'cost_usd', undefined);
1745
- if (typeof cost_usd === 'number' && !(typeof cost_usd === 'boolean')) {
1746
- parts.push(`cost: $${_pyFixed(cost_usd, 4)}`);
1747
- }
1748
- const prefix = estimated ? '~' : '';
1749
- parts.push(
1750
- `tokens: ${prefix}${r.input_tokens} in / ${prefix}${r.output_tokens} out`,
1751
- );
1752
- }
1753
- parts.push(`${r.latency_ms} ms`);
1754
- return `*${parts.join(' · ')}*`;
1755
- }
1756
1779
 
1757
1780
  // Lens defaults for the Phase 9 confidence-explanation badge. The PR
1758
1781
  // lens stays terse so the existing "Must-fix / Nice-to-have" structure
@@ -1906,7 +1929,7 @@ export function render(responses: CouncilResponse[], opts: RenderOptions = {}):
1906
1929
  blocks.push(`${header}\n\n*ERROR:* \`${r.error}\``);
1907
1930
  continue;
1908
1931
  }
1909
- const meta = _render_response_meta(r);
1932
+ const meta = renderResponseMeta(r, { pyFixed: _pyFixed });
1910
1933
  blocks.push(`${header}\n\n${meta}\n\n${r.text}`);
1911
1934
  }
1912
1935
  if (peer_review !== null && peer_review.responses.length > 0) {
@@ -2205,67 +2228,3 @@ function _render_bucket(
2205
2228
  }
2206
2229
  return lines.join('\n');
2207
2230
  }
2208
-
2209
- // ── small stdlib parity helpers ───────────────────────────────────────
2210
-
2211
- /** Mirror Python `int(x)` truncation-toward-zero on a float total. */
2212
- function _pyInt(x: number): number {
2213
- return Math.trunc(x);
2214
- }
2215
-
2216
- /** Mirror Python `repr(int)` for the error message in estimate_debate_cost. */
2217
- function _pyReprInt(x: number): string {
2218
- return String(x);
2219
- }
2220
-
2221
- /** Mirror `type(exc).__name__ + ": " + str(exc)`. */
2222
- function _excTag(exc: unknown): string {
2223
- if (exc instanceof Error) {
2224
- return `${exc.name}: ${exc.message}`;
2225
- }
2226
- return `Error: ${String(exc)}`;
2227
- }
2228
-
2229
- /** dict.setdefault(key, value) — only set when key absent. */
2230
- function _setdefault(
2231
- obj: Record<string, unknown>,
2232
- key: string,
2233
- value: unknown,
2234
- ): void {
2235
- if (!(key in obj)) {
2236
- obj[key] = value;
2237
- }
2238
- }
2239
-
2240
- /** dict.get(key, default). */
2241
- function _metaGet(
2242
- obj: Record<string, unknown>,
2243
- key: string,
2244
- fallback: unknown,
2245
- ): unknown {
2246
- return key in obj ? obj[key] : fallback;
2247
- }
2248
-
2249
- /** Python `str.lstrip()` (no-arg) — strip leading whitespace. */
2250
- function _pyLStrip(s: string): string {
2251
- return s.replace(/^\s+/, '');
2252
- }
2253
-
2254
- /** Python `str.rstrip()` (no-arg) — strip trailing whitespace. */
2255
- function _pyRStrip(s: string): string {
2256
- return s.replace(/\s+$/, '');
2257
- }
2258
-
2259
- /** Python `len(str)` — code-point count, not UTF-16 unit count. */
2260
- function _pyLen(s: string): number {
2261
- let n = 0;
2262
- for (const _ of s) {
2263
- n += 1;
2264
- }
2265
- return n;
2266
- }
2267
-
2268
- /** Python `s[start:end]` — code-point slicing. */
2269
- function _pySlice(s: string, start: number, end: number): string {
2270
- return [...s].slice(start, end).join('');
2271
- }