agentxchain 2.60.0 → 2.62.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.
@@ -192,6 +192,7 @@ program
192
192
  .description('View or edit project configuration')
193
193
  .option('--add-agent', 'Add a new agent interactively')
194
194
  .option('--remove-agent <id>', 'Remove an agent by ID')
195
+ .option('--get <path>', 'Read a config value (e.g. --get project.goal)')
195
196
  .option('--set <path_and_value...>', 'Set a config value (e.g. --set project.goal "Build a governed CLI")')
196
197
  .option('-j, --json', 'Output config as JSON')
197
198
  .action(configCommand);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.60.0",
3
+ "version": "2.62.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -166,6 +166,9 @@ export async function acceptTurnCommand(opts = {}) {
166
166
  if (accepted?.cost?.usd != null) {
167
167
  console.log(` ${chalk.dim('Cost:')} $${formatUsd(accepted.cost.usd)}`);
168
168
  }
169
+ if (result.budget_warning) {
170
+ console.log(` ${chalk.yellow('Budget warning:')} ${result.budget_warning}`);
171
+ }
169
172
  console.log('');
170
173
 
171
174
  const recovery = deriveRecoveryDescriptor(result.state);
@@ -17,6 +17,12 @@ export async function configCommand(opts) {
17
17
  const config = rawConfig;
18
18
  const configPath = join(root, CONFIG_FILE);
19
19
 
20
+ if (opts.get && opts.set) {
21
+ console.log(chalk.red(' --get and --set are mutually exclusive.'));
22
+ console.log(chalk.dim(' Inspect a value with `agentxchain config --get <path>` or change it with `agentxchain config --set <path> <value>`.'));
23
+ process.exit(1);
24
+ }
25
+
20
26
  if (version === 4 && opts.addAgent) {
21
27
  printLegacyOnlyMutationError('--add-agent');
22
28
  return;
@@ -37,6 +43,11 @@ export async function configCommand(opts) {
37
43
  return;
38
44
  }
39
45
 
46
+ if (opts.get) {
47
+ getSetting(config, opts.get, { json: opts.json });
48
+ return;
49
+ }
50
+
40
51
  if (opts.set) {
41
52
  setSetting(config, configPath, opts.set, { version, root });
42
53
  return;
@@ -81,6 +92,7 @@ function printLegacyConfig(config) {
81
92
  console.log(chalk.dim(' Commands:'));
82
93
  console.log(` ${chalk.bold('agentxchain config --add-agent')} Add a new agent`);
83
94
  console.log(` ${chalk.bold('agentxchain config --remove-agent <id>')} Remove an agent`);
95
+ console.log(` ${chalk.bold('agentxchain config --get <key>')} Read one config value`);
84
96
  console.log(` ${chalk.bold('agentxchain config --set <key> <val>')} Update a setting`);
85
97
  console.log(` ${chalk.bold('agentxchain config --json')} Output as JSON`);
86
98
  console.log('');
@@ -99,6 +111,7 @@ function printGovernedConfig(config) {
99
111
  console.log(` ${chalk.dim('Runtimes:')} ${Object.keys(config.runtimes || {}).length}`);
100
112
  console.log('');
101
113
  console.log(chalk.dim(' Commands:'));
114
+ console.log(` ${chalk.bold('agentxchain config --get project.goal')} Read one config value without opening JSON`);
102
115
  console.log(` ${chalk.bold('agentxchain config --set project.goal "Build a ..."')} Set mission context without hand-editing JSON`);
103
116
  console.log(` ${chalk.bold('agentxchain config --set roles.qa.runtime manual-qa')} Switch a governed role runtime`);
104
117
  console.log(` ${chalk.bold('agentxchain config --json')} Output raw config`);
@@ -169,10 +182,8 @@ function setSetting(config, configPath, keyValPair, context) {
169
182
  }
170
183
 
171
184
  const { key, rawVal } = parsed;
172
- const segments = key.split('.');
173
- const forbiddenKeys = new Set(['__proto__', 'prototype', 'constructor']);
174
-
175
- if (segments.some(segment => forbiddenKeys.has(segment))) {
185
+ const segments = parseKeyPath(key);
186
+ if (!segments) {
176
187
  console.log(chalk.red(' Refusing to write reserved object path.'));
177
188
  process.exit(1);
178
189
  }
@@ -211,6 +222,35 @@ function setSetting(config, configPath, keyValPair, context) {
211
222
  console.log('');
212
223
  }
213
224
 
225
+ function getSetting(config, key, opts = {}) {
226
+ const segments = parseKeyPath(key);
227
+ if (!segments) {
228
+ console.log(chalk.red(' Refusing to read reserved object path.'));
229
+ process.exit(1);
230
+ }
231
+
232
+ let value = config;
233
+ for (const segment of segments) {
234
+ if (value === null || typeof value !== 'object' || !(segment in value)) {
235
+ console.log(chalk.red(` Config path not found: ${key}`));
236
+ process.exit(1);
237
+ }
238
+ value = value[segment];
239
+ }
240
+
241
+ if (opts.json) {
242
+ console.log(JSON.stringify(value, null, 2));
243
+ return;
244
+ }
245
+
246
+ if (value !== null && typeof value === 'object') {
247
+ console.log(JSON.stringify(value, null, 2));
248
+ return;
249
+ }
250
+
251
+ console.log(String(value));
252
+ }
253
+
214
254
  function parseSetInput(input) {
215
255
  if (Array.isArray(input)) {
216
256
  if (input.length >= 2) {
@@ -235,6 +275,19 @@ function parseSetInput(input) {
235
275
  return null;
236
276
  }
237
277
 
278
+ function parseKeyPath(input) {
279
+ if (typeof input !== 'string' || input.trim() === '') {
280
+ return null;
281
+ }
282
+
283
+ const segments = input.split('.');
284
+ const forbiddenKeys = new Set(['__proto__', 'prototype', 'constructor']);
285
+ if (segments.some(segment => segment === '' || forbiddenKeys.has(segment))) {
286
+ return null;
287
+ }
288
+ return segments;
289
+ }
290
+
238
291
  function validateEditedConfig(config, context) {
239
292
  if (context.version === 4) {
240
293
  return validateV4Config(config, context.root);
@@ -297,6 +297,9 @@ export async function restartCommand(opts) {
297
297
  console.log(chalk.red(`Failed to assign turn: ${assignment.error}`));
298
298
  process.exit(1);
299
299
  }
300
+ for (const warning of assignment.warnings || []) {
301
+ console.log(chalk.yellow(`Warning: ${warning}`));
302
+ }
300
303
 
301
304
  // assignGovernedTurn already writes a checkpoint at turn_assigned
302
305
 
@@ -259,6 +259,7 @@ export async function resumeCommand(opts) {
259
259
  console.log(chalk.red(`Failed to assign turn: ${assignResult.error}`));
260
260
  process.exit(1);
261
261
  }
262
+ printAssignmentWarnings(assignResult);
262
263
  state = assignResult.state;
263
264
 
264
265
  // Write dispatch bundle
@@ -415,6 +416,12 @@ function printDispatchBundleWarnings(bundleResult) {
415
416
  }
416
417
  }
417
418
 
419
+ function printAssignmentWarnings(assignResult) {
420
+ for (const warning of assignResult.warnings || []) {
421
+ console.log(chalk.yellow(`Warning: ${warning}`));
422
+ }
423
+ }
424
+
418
425
  function printAssignmentHookFailure(result, roleId) {
419
426
  const recovery = deriveRecoveryDescriptor(result.state);
420
427
  const hookName = result.hookResults?.blocker?.hook_name
@@ -277,7 +277,10 @@ function renderGovernedStatus(context, opts) {
277
277
 
278
278
  if (state?.budget_status) {
279
279
  console.log('');
280
- console.log(` ${chalk.dim('Budget:')} spent $${formatUsd(state.budget_status.spent_usd)} / remaining $${formatUsd(state.budget_status.remaining_usd)}`);
280
+ const budgetLabel = state.budget_status.warn_mode
281
+ ? `spent $${formatUsd(state.budget_status.spent_usd)} / remaining $${formatUsd(state.budget_status.remaining_usd)} ${chalk.yellow('[OVER BUDGET]')}`
282
+ : `spent $${formatUsd(state.budget_status.spent_usd)} / remaining $${formatUsd(state.budget_status.remaining_usd)}`;
283
+ console.log(` ${chalk.dim('Budget:')} ${budgetLabel}`);
281
284
  }
282
285
 
283
286
  console.log('');
@@ -293,6 +293,7 @@ export async function stepCommand(opts) {
293
293
  console.log(chalk.red(`Failed to assign turn: ${assignResult.error}`));
294
294
  process.exit(1);
295
295
  }
296
+ printAssignmentWarnings(assignResult);
296
297
  state = assignResult.state;
297
298
 
298
299
  const bundleResult = writeDispatchBundle(root, state, config);
@@ -948,6 +949,12 @@ function printDispatchBundleWarnings(bundleResult) {
948
949
  }
949
950
  }
950
951
 
952
+ function printAssignmentWarnings(assignResult) {
953
+ for (const warning of assignResult.warnings || []) {
954
+ console.log(chalk.yellow(`Warning: ${warning}`));
955
+ }
956
+ }
957
+
951
958
  function printAssignmentHookFailure(result, roleId) {
952
959
  const recovery = deriveRecoveryDescriptor(result.state);
953
960
  const hookName = result.hookResults?.blocker?.hook_name
@@ -575,7 +575,7 @@ function attachLegacyCurrentTurnAlias(state) {
575
575
  function formatBudgetRecoveryAction(isReadyToResume) {
576
576
  return isReadyToResume
577
577
  ? 'Run agentxchain resume to assign the next turn'
578
- : 'Increase per_run_max_usd in agentxchain.json, then run agentxchain resume';
578
+ : 'Increase budget with agentxchain config --set budget.per_run_max_usd <usd>, then run agentxchain resume';
579
579
  }
580
580
 
581
581
  function formatBudgetRecoveryDetail(spentUsd, limitUsd, remainingUsd, isReadyToResume) {
@@ -1936,13 +1936,19 @@ export function assignGovernedTurn(root, config, roleId) {
1936
1936
  return { ok: false, error: `Cannot assign turn: ${activeCount} active turn(s) already at capacity (max_concurrent_turns = ${maxConcurrent})` };
1937
1937
  }
1938
1938
 
1939
- // DEC-BUDGET-ENFORCE-001: Pre-assignment budget exhaustion guard
1940
- if (state.budget_status?.remaining_usd != null && state.budget_status.remaining_usd <= 0) {
1941
- return { ok: false, error: `Cannot assign turn: run budget exhausted (spent $${(state.budget_status.spent_usd || 0).toFixed(2)} of $${((state.budget_status.spent_usd || 0) + state.budget_status.remaining_usd).toFixed(2)} limit). Increase per_run_max_usd in agentxchain.json, then run agentxchain resume` };
1942
- }
1943
-
1944
1939
  // DEC-PARALLEL-011: Budget reservation
1945
1940
  const warnings = [];
1941
+
1942
+ // DEC-BUDGET-ENFORCE-001 + DEC-BUDGET-WARN-001: Pre-assignment budget exhaustion guard
1943
+ if (state.budget_status?.remaining_usd != null && state.budget_status.remaining_usd <= 0) {
1944
+ const onExceed = config.budget?.on_exceed || 'pause_and_escalate';
1945
+ if (onExceed === 'warn') {
1946
+ // Allow assignment but add a warning
1947
+ warnings.push(`Budget exhausted (spent $${(state.budget_status.spent_usd || 0).toFixed(2)} of $${((state.budget_status.spent_usd || 0) + state.budget_status.remaining_usd).toFixed(2)} limit). Run continues in warn mode per on_exceed policy.`);
1948
+ } else {
1949
+ return { ok: false, error: `Cannot assign turn: run budget exhausted (spent $${(state.budget_status.spent_usd || 0).toFixed(2)} of $${((state.budget_status.spent_usd || 0) + state.budget_status.remaining_usd).toFixed(2)} limit). Increase budget with agentxchain config --set budget.per_run_max_usd <usd>, then run agentxchain resume` };
1950
+ }
1951
+ }
1946
1952
  const reservations = { ...(state.budget_reservations || {}) };
1947
1953
  const turnId = generateId('turn');
1948
1954
  const estimatedCost = estimateTurnBudget(config, roleId);
@@ -1950,7 +1956,8 @@ export function assignGovernedTurn(root, config, roleId) {
1950
1956
  if (estimatedCost > 0 && state.budget_status?.remaining_usd != null) {
1951
1957
  const alreadyReserved = Object.values(reservations).reduce((sum, r) => sum + (r.reserved_usd || 0), 0);
1952
1958
  const available = state.budget_status.remaining_usd - alreadyReserved;
1953
- if (estimatedCost > available) {
1959
+ const onExceedReserve = config.budget?.on_exceed || 'pause_and_escalate';
1960
+ if (estimatedCost > available && onExceedReserve !== 'warn') {
1954
1961
  return { ok: false, error: `Cannot assign turn: estimated cost $${estimatedCost.toFixed(2)} exceeds available budget $${available.toFixed(2)} (after reservations)` };
1955
1962
  }
1956
1963
  reservations[turnId] = {
@@ -2617,6 +2624,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2617
2624
  accepted_integration_ref: derivedRef,
2618
2625
  next_recommended_role: deriveNextRecommendedRole(turnResult, state, config),
2619
2626
  budget_status: {
2627
+ ...(state.budget_status || {}),
2620
2628
  spent_usd: (state.budget_status?.spent_usd || 0) + costUsd,
2621
2629
  remaining_usd: state.budget_status?.remaining_usd != null
2622
2630
  ? state.budget_status.remaining_usd - costUsd
@@ -2657,7 +2665,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2657
2665
  if (turnReservation && costUsd > turnReservation.reserved_usd) {
2658
2666
  budgetWarning = `Actual cost $${costUsd.toFixed(2)} exceeded reservation $${turnReservation.reserved_usd.toFixed(2)} for this turn`;
2659
2667
  }
2660
- // Budget exhaustion enforcement
2668
+ // Budget exhaustion enforcement (DEC-BUDGET-ENFORCE-001 + DEC-BUDGET-WARN-001)
2661
2669
  if (
2662
2670
  updatedState.budget_status.remaining_usd != null &&
2663
2671
  updatedState.budget_status.remaining_usd <= 0 &&
@@ -2665,9 +2673,9 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2665
2673
  updatedState.status !== 'completed'
2666
2674
  ) {
2667
2675
  const onExceed = config.budget?.on_exceed || 'pause_and_escalate';
2676
+ const limit = (updatedState.budget_status.spent_usd + updatedState.budget_status.remaining_usd);
2677
+ const overBy = Math.abs(updatedState.budget_status.remaining_usd);
2668
2678
  if (onExceed === 'pause_and_escalate') {
2669
- const limit = (updatedState.budget_status.spent_usd + updatedState.budget_status.remaining_usd);
2670
- const overBy = Math.abs(updatedState.budget_status.remaining_usd);
2671
2679
  updatedState.status = 'blocked';
2672
2680
  updatedState.blocked_on = 'budget:exhausted';
2673
2681
  updatedState.blocked_reason = buildBlockedReason({
@@ -2675,7 +2683,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2675
2683
  recovery: {
2676
2684
  typed_reason: 'budget_exhausted',
2677
2685
  owner: 'human',
2678
- recovery_action: 'Increase per_run_max_usd in agentxchain.json, then run agentxchain resume',
2686
+ recovery_action: 'Increase budget with agentxchain config --set budget.per_run_max_usd <usd>, then run agentxchain resume',
2679
2687
  turn_retained: false,
2680
2688
  detail: `Run budget exhausted: spent $${updatedState.budget_status.spent_usd.toFixed(2)} of $${limit.toFixed(2)} limit ($${overBy.toFixed(2)} over)`,
2681
2689
  },
@@ -2685,6 +2693,15 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2685
2693
  updatedState.budget_status.exhausted = true;
2686
2694
  updatedState.budget_status.exhausted_at = now;
2687
2695
  updatedState.budget_status.exhausted_after_turn = currentTurn.turn_id;
2696
+ } else if (onExceed === 'warn') {
2697
+ // DEC-BUDGET-WARN-001: Do not block — mark exhaustion and emit warning
2698
+ if (!updatedState.budget_status.exhausted) {
2699
+ updatedState.budget_status.exhausted = true;
2700
+ updatedState.budget_status.exhausted_at = now;
2701
+ updatedState.budget_status.exhausted_after_turn = currentTurn.turn_id;
2702
+ }
2703
+ updatedState.budget_status.warn_mode = true;
2704
+ budgetWarning = `Budget exhausted: spent $${updatedState.budget_status.spent_usd.toFixed(2)} of $${limit.toFixed(2)} limit ($${overBy.toFixed(2)} over). Run continues in warn mode.`;
2688
2705
  }
2689
2706
  }
2690
2707
 
@@ -3081,6 +3098,22 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3081
3098
  });
3082
3099
  }
3083
3100
 
3101
+ // DEC-BUDGET-WARN-001: Emit budget_exceeded_warn event when warn mode triggers
3102
+ if (updatedState.budget_status?.warn_mode && budgetWarning) {
3103
+ emitRunEvent(root, 'budget_exceeded_warn', {
3104
+ run_id: updatedState.run_id,
3105
+ phase: updatedState.phase,
3106
+ status: updatedState.status,
3107
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3108
+ payload: {
3109
+ spent_usd: updatedState.budget_status.spent_usd,
3110
+ limit_usd: updatedState.budget_status.spent_usd + updatedState.budget_status.remaining_usd,
3111
+ remaining_usd: updatedState.budget_status.remaining_usd,
3112
+ warning: budgetWarning,
3113
+ },
3114
+ });
3115
+ }
3116
+
3084
3117
  if (updatedState.pending_phase_transition) {
3085
3118
  emitPendingLifecycleNotification(root, config, updatedState, 'phase_transition_pending', {
3086
3119
  from: updatedState.pending_phase_transition.from,
@@ -61,6 +61,7 @@ const VALID_API_PROXY_PREFLIGHT_FIELDS = [
61
61
  'tokenizer',
62
62
  'safety_margin_tokens',
63
63
  ];
64
+ const VALID_BUDGET_ON_EXCEED = ['pause_and_escalate', 'warn'];
64
65
 
65
66
  function validateMcpRuntime(runtimeId, runtime, errors) {
66
67
  const transport = typeof runtime?.transport === 'string' && runtime.transport.trim()
@@ -537,6 +538,12 @@ export function validateV4Config(data, projectRoot) {
537
538
  errors.push(...policyValidation.errors);
538
539
  }
539
540
 
541
+ // Budget (optional but validated if present)
542
+ if (data.budget !== undefined) {
543
+ const budgetValidation = validateBudgetConfig(data.budget);
544
+ errors.push(...budgetValidation.errors);
545
+ }
546
+
540
547
  // Approval Policy (optional but validated if present)
541
548
  if (data.approval_policy !== undefined) {
542
549
  errors.push(...validateApprovalPolicy(data.approval_policy, data.routing));
@@ -551,6 +558,80 @@ export function validateV4Config(data, projectRoot) {
551
558
  return { ok: errors.length === 0, errors };
552
559
  }
553
560
 
561
+ export function validateBudgetConfig(budget) {
562
+ const errors = [];
563
+
564
+ if (budget === null) {
565
+ return { ok: true, errors };
566
+ }
567
+
568
+ if (!budget || typeof budget !== 'object' || Array.isArray(budget)) {
569
+ errors.push('budget must be an object');
570
+ return { ok: false, errors };
571
+ }
572
+
573
+ validateBudgetUsdLimit('budget.per_turn_max_usd', budget.per_turn_max_usd, errors);
574
+ validateBudgetUsdLimit('budget.per_run_max_usd', budget.per_run_max_usd, errors);
575
+
576
+ if (
577
+ Number.isFinite(budget.per_turn_max_usd) &&
578
+ Number.isFinite(budget.per_run_max_usd) &&
579
+ budget.per_turn_max_usd > budget.per_run_max_usd
580
+ ) {
581
+ errors.push('budget.per_turn_max_usd must be less than or equal to budget.per_run_max_usd when both are set');
582
+ }
583
+
584
+ if (budget.on_exceed !== undefined) {
585
+ if (typeof budget.on_exceed !== 'string' || !VALID_BUDGET_ON_EXCEED.includes(budget.on_exceed)) {
586
+ errors.push(`budget.on_exceed must be one of: ${VALID_BUDGET_ON_EXCEED.join(', ')}`);
587
+ }
588
+ }
589
+
590
+ if (budget.cost_rates !== undefined) {
591
+ if (!budget.cost_rates || typeof budget.cost_rates !== 'object' || Array.isArray(budget.cost_rates)) {
592
+ errors.push('budget.cost_rates must be an object');
593
+ } else {
594
+ for (const [model, rates] of Object.entries(budget.cost_rates)) {
595
+ if (typeof model !== 'string' || !model.trim()) {
596
+ errors.push('budget.cost_rates model keys must be non-empty strings');
597
+ continue;
598
+ }
599
+ if (!rates || typeof rates !== 'object' || Array.isArray(rates)) {
600
+ errors.push(`budget.cost_rates.${model} must be an object`);
601
+ continue;
602
+ }
603
+ validateBudgetCostRate(`budget.cost_rates.${model}.input_per_1m`, rates.input_per_1m, errors);
604
+ validateBudgetCostRate(`budget.cost_rates.${model}.output_per_1m`, rates.output_per_1m, errors);
605
+ }
606
+ }
607
+ }
608
+
609
+ return { ok: errors.length === 0, errors };
610
+ }
611
+
612
+ function validateBudgetUsdLimit(path, value, errors) {
613
+ if (value === undefined || value === null) {
614
+ return;
615
+ }
616
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
617
+ errors.push(`${path} must be a finite number when provided`);
618
+ return;
619
+ }
620
+ if (value <= 0) {
621
+ errors.push(`${path} must be greater than 0 when provided`);
622
+ }
623
+ }
624
+
625
+ function validateBudgetCostRate(path, value, errors) {
626
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
627
+ errors.push(`${path} must be a finite number`);
628
+ return;
629
+ }
630
+ if (value < 0) {
631
+ errors.push(`${path} must be greater than or equal to 0`);
632
+ }
633
+ }
634
+
554
635
  export function validateSchedulesConfig(schedules, roles) {
555
636
  const errors = [];
556
637
 
package/src/lib/report.js CHANGED
@@ -45,6 +45,19 @@ function normalizeBudgetStatus(budgetStatus) {
45
45
  if (Number.isFinite(budgetStatus.remaining_usd)) {
46
46
  normalized.remaining_usd = budgetStatus.remaining_usd;
47
47
  }
48
+ // DEC-BUDGET-WARN-004: preserve warn-mode and exhaustion fields
49
+ if (budgetStatus.warn_mode === true) {
50
+ normalized.warn_mode = true;
51
+ }
52
+ if (budgetStatus.exhausted === true) {
53
+ normalized.exhausted = true;
54
+ }
55
+ if (budgetStatus.exhausted_at) {
56
+ normalized.exhausted_at = budgetStatus.exhausted_at;
57
+ }
58
+ if (budgetStatus.exhausted_after_turn) {
59
+ normalized.exhausted_after_turn = budgetStatus.exhausted_after_turn;
60
+ }
48
61
 
49
62
  return Object.keys(normalized).length > 0 ? normalized : null;
50
63
  }
@@ -890,8 +903,9 @@ export function formatGovernanceReportText(report) {
890
903
  ];
891
904
 
892
905
  if (run.budget_status) {
906
+ const warnTag = run.budget_status.warn_mode ? ' [OVER BUDGET]' : '';
893
907
  lines.push(
894
- `Budget: spent ${formatUsd(run.budget_status.spent_usd)}, remaining ${formatUsd(run.budget_status.remaining_usd)}`,
908
+ `Budget: spent ${formatUsd(run.budget_status.spent_usd)}, remaining ${formatUsd(run.budget_status.remaining_usd)}${warnTag}`,
895
909
  );
896
910
  }
897
911
 
@@ -1300,7 +1314,8 @@ export function formatGovernanceReportMarkdown(report) {
1300
1314
  ];
1301
1315
 
1302
1316
  if (run.budget_status) {
1303
- lines.push(`- Budget: spent ${formatUsd(run.budget_status.spent_usd)}, remaining ${formatUsd(run.budget_status.remaining_usd)}`);
1317
+ const warnTag = run.budget_status.warn_mode ? ' **[OVER BUDGET]**' : '';
1318
+ lines.push(`- Budget: spent ${formatUsd(run.budget_status.spent_usd)}, remaining ${formatUsd(run.budget_status.remaining_usd)}${warnTag}`);
1304
1319
  }
1305
1320
 
1306
1321
  if (run.created_at) {
@@ -23,6 +23,7 @@ export const VALID_RUN_EVENTS = [
23
23
  'escalation_resolved',
24
24
  'gate_pending',
25
25
  'gate_approved',
26
+ 'budget_exceeded_warn',
26
27
  ];
27
28
 
28
29
  /**