agentxchain 2.61.0 → 2.63.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.
@@ -86,6 +86,8 @@ import {
86
86
  } from '../src/commands/plugin.js';
87
87
  import { templateSetCommand } from '../src/commands/template-set.js';
88
88
  import { templateListCommand } from '../src/commands/template-list.js';
89
+ import { roleCommand } from '../src/commands/role.js';
90
+ import { turnShowCommand } from '../src/commands/turn.js';
89
91
  import { templateValidateCommand } from '../src/commands/template-validate.js';
90
92
  import {
91
93
  multiInitCommand,
@@ -192,6 +194,7 @@ program
192
194
  .description('View or edit project configuration')
193
195
  .option('--add-agent', 'Add a new agent interactively')
194
196
  .option('--remove-agent <id>', 'Remove an agent by ID')
197
+ .option('--get <path>', 'Read a config value (e.g. --get project.goal)')
195
198
  .option('--set <path_and_value...>', 'Set a config value (e.g. --set project.goal "Build a governed CLI")')
196
199
  .option('-j, --json', 'Output config as JSON')
197
200
  .action(configCommand);
@@ -484,6 +487,33 @@ templateCmd
484
487
  .option('-j, --json', 'Output as JSON')
485
488
  .action(templateValidateCommand);
486
489
 
490
+ const roleCmd = program
491
+ .command('role')
492
+ .description('Inspect governed role definitions');
493
+
494
+ roleCmd
495
+ .command('list')
496
+ .description('List all defined roles with title, authority, and runtime')
497
+ .option('-j, --json', 'Output as JSON')
498
+ .action((opts) => roleCommand('list', null, opts));
499
+
500
+ roleCmd
501
+ .command('show <role_id>')
502
+ .description('Show detailed information for a single role')
503
+ .option('-j, --json', 'Output as JSON')
504
+ .action((roleId, opts) => roleCommand('show', roleId, opts));
505
+
506
+ const turnCmd = program
507
+ .command('turn')
508
+ .description('Inspect active governed turn dispatch bundles');
509
+
510
+ turnCmd
511
+ .command('show [turn_id]')
512
+ .description('Show a selected active turn and its dispatch artifacts')
513
+ .option('--artifact <name>', 'Print one artifact: assignment, prompt, context, or manifest')
514
+ .option('-j, --json', 'Output as JSON')
515
+ .action(turnShowCommand);
516
+
487
517
  const multiCmd = program
488
518
  .command('multi')
489
519
  .description('Multi-repo coordinator orchestration');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.61.0",
3
+ "version": "2.63.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -198,21 +198,53 @@ echo " OK: all 9 governed version surfaces reference ${TARGET_VERSION}"
198
198
 
199
199
  # 5. Auto-align Homebrew mirror to target version
200
200
  # The formula URL and README version/tarball are updated automatically.
201
- # The SHA256 is carried from the previous version — it is inherently a
201
+ # The SHA256 is carried from the previous committed formula — it is inherently a
202
202
  # post-publish artifact (npm registry tarballs are not byte-identical to
203
- # local npm-pack output). sync-homebrew.sh corrects the SHA after publish.
203
+ # local npm-pack output). Any working-tree SHA edit is overwritten here.
204
+ # sync-homebrew.sh corrects the SHA after publish.
204
205
  echo "[5/9] Auto-aligning Homebrew mirror to ${TARGET_VERSION}..."
205
206
  HOMEBREW_MIRROR="${REPO_ROOT}/cli/homebrew/agentxchain.rb"
206
207
  HOMEBREW_MIRROR_README="${REPO_ROOT}/cli/homebrew/README.md"
207
208
  TARBALL_URL="https://registry.npmjs.org/agentxchain/-/agentxchain-${TARGET_VERSION}.tgz"
208
209
  HOMEBREW_ALIGNED=false
210
+ COMMITTED_HOMEBREW_SHA=""
211
+
212
+ extract_formula_sha() {
213
+ sed -nE 's|^[[:space:]]*sha256 "([a-f0-9]{64})".*|\1|p' "$1" | head -n 1
214
+ }
209
215
 
210
216
  if [[ -f "$HOMEBREW_MIRROR" ]]; then
217
+ COMMITTED_FORMULA_TMP="$(mktemp "${TMPDIR:-/tmp}/agentxchain-homebrew-head.XXXXXX")"
218
+ if ! git -C "$REPO_ROOT" show "HEAD:cli/homebrew/agentxchain.rb" >"$COMMITTED_FORMULA_TMP" 2>/dev/null; then
219
+ rm -f "$COMMITTED_FORMULA_TMP"
220
+ echo "FAIL: could not load HEAD:cli/homebrew/agentxchain.rb to carry the pre-publish SHA" >&2
221
+ exit 1
222
+ fi
223
+ COMMITTED_HOMEBREW_SHA="$(extract_formula_sha "$COMMITTED_FORMULA_TMP")"
224
+ rm -f "$COMMITTED_FORMULA_TMP"
225
+ if [[ -z "$COMMITTED_HOMEBREW_SHA" ]]; then
226
+ echo "FAIL: HEAD:cli/homebrew/agentxchain.rb does not contain a parseable sha256" >&2
227
+ exit 1
228
+ fi
229
+
230
+ WORKTREE_HOMEBREW_SHA="$(extract_formula_sha "$HOMEBREW_MIRROR")"
231
+ if [[ -z "$WORKTREE_HOMEBREW_SHA" ]]; then
232
+ echo "FAIL: cli/homebrew/agentxchain.rb does not contain a parseable sha256" >&2
233
+ exit 1
234
+ fi
235
+
211
236
  ESCAPED_URL="$(printf '%s' "$TARBALL_URL" | sed 's/[&/\]/\\&/g')"
237
+ ESCAPED_SHA="$(printf '%s' "$COMMITTED_HOMEBREW_SHA" | sed 's/[&/\]/\\&/g')"
212
238
  sed -i.bak -E "s|^([[:space:]]*url \").*(\")|\1${ESCAPED_URL}\2|" "$HOMEBREW_MIRROR"
239
+ sed -i.bak -E "s|^([[:space:]]*sha256 \").*(\")|\1${ESCAPED_SHA}\2|" "$HOMEBREW_MIRROR"
213
240
  rm -f "${HOMEBREW_MIRROR}.bak"
214
241
  HOMEBREW_ALIGNED=true
215
242
  echo " OK: formula URL -> ${TARBALL_URL}"
243
+ if [[ "$WORKTREE_HOMEBREW_SHA" != "$COMMITTED_HOMEBREW_SHA" ]]; then
244
+ echo " OK: formula SHA normalized back to committed pre-publish SHA ${COMMITTED_HOMEBREW_SHA}"
245
+ else
246
+ echo " OK: formula SHA carried from committed pre-publish SHA ${COMMITTED_HOMEBREW_SHA}"
247
+ fi
216
248
  fi
217
249
 
218
250
  if [[ -f "$HOMEBREW_MIRROR_README" ]]; then
@@ -223,7 +255,7 @@ if [[ -f "$HOMEBREW_MIRROR_README" ]]; then
223
255
  fi
224
256
 
225
257
  if $HOMEBREW_ALIGNED; then
226
- echo " Note: SHA carried from previous version; sync-homebrew.sh will set the real registry SHA post-publish"
258
+ echo " Note: local npm pack output is not canonical release truth; sync-homebrew.sh will set the real registry SHA post-publish"
227
259
  else
228
260
  echo " Skipped: no Homebrew mirror files found"
229
261
  fi
@@ -346,4 +378,7 @@ if [[ "$SKIP_PREFLIGHT" -eq 1 ]]; then
346
378
  echo " npm run preflight:release:strict -- --target-version ${TARGET_VERSION}"
347
379
  fi
348
380
  echo ""
381
+ echo "Homebrew mirror is in Phase 1 (stale SHA from previous version)."
382
+ echo "After npm publish completes, run sync-homebrew.sh to reach Phase 3."
383
+ echo ""
349
384
  echo "Next: git push origin main --follow-tags"
@@ -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
@@ -0,0 +1,106 @@
1
+ import chalk from 'chalk';
2
+ import { loadProjectContext } from '../lib/config.js';
3
+
4
+ export function roleCommand(subcommand, roleId, opts) {
5
+ const context = loadProjectContext();
6
+ if (!context) {
7
+ console.log(chalk.red(' No agentxchain.json found. Run `agentxchain init` first.'));
8
+ process.exit(1);
9
+ }
10
+
11
+ const { rawConfig, version } = context;
12
+
13
+ if (version !== 4) {
14
+ console.log(chalk.red(' Not a governed AgentXchain project (requires v4 config).'));
15
+ process.exit(1);
16
+ }
17
+
18
+ const roles = rawConfig.roles || {};
19
+ const roleIds = Object.keys(roles);
20
+
21
+ if (subcommand === 'show') {
22
+ return showRole(roleId, roles, roleIds, opts);
23
+ }
24
+
25
+ // Default: list
26
+ return listRoles(roles, roleIds, opts);
27
+ }
28
+
29
+ function listRoles(roles, roleIds, opts) {
30
+ if (roleIds.length === 0) {
31
+ if (opts.json) {
32
+ console.log('[]');
33
+ } else {
34
+ console.log(' No roles defined.');
35
+ }
36
+ return;
37
+ }
38
+
39
+ if (opts.json) {
40
+ const output = roleIds.map((id) => ({
41
+ id,
42
+ title: roles[id].title,
43
+ mandate: roles[id].mandate,
44
+ write_authority: roles[id].write_authority,
45
+ runtime: roles[id].runtime,
46
+ }));
47
+ console.log(JSON.stringify(output, null, 2));
48
+ return;
49
+ }
50
+
51
+ console.log(chalk.bold(`\n Roles (${roleIds.length}):\n`));
52
+ for (const id of roleIds) {
53
+ const r = roles[id];
54
+ const authority = r.write_authority === 'authoritative'
55
+ ? chalk.green(r.write_authority)
56
+ : r.write_authority === 'proposed'
57
+ ? chalk.yellow(r.write_authority)
58
+ : chalk.dim(r.write_authority);
59
+ console.log(` ${chalk.cyan(id)} — ${r.title} [${authority}] → ${chalk.dim(r.runtime)}`);
60
+ }
61
+ console.log('');
62
+ console.log(chalk.dim(' Usage: agentxchain role show <role_id>\n'));
63
+ }
64
+
65
+ function showRole(roleId, roles, roleIds, opts) {
66
+ if (!roleId) {
67
+ console.log(chalk.red(' Missing role ID.'));
68
+ console.log(chalk.dim(` Usage: agentxchain role show <role_id>`));
69
+ if (roleIds.length > 0) {
70
+ console.log(chalk.dim(` Available: ${roleIds.join(', ')}`));
71
+ }
72
+ process.exit(1);
73
+ }
74
+
75
+ if (!roles[roleId]) {
76
+ console.log(chalk.red(` Unknown role: ${roleId}`));
77
+ console.log(chalk.dim(` Available: ${roleIds.join(', ')}`));
78
+ process.exit(1);
79
+ }
80
+
81
+ const r = roles[roleId];
82
+
83
+ if (opts.json) {
84
+ console.log(JSON.stringify({
85
+ id: roleId,
86
+ title: r.title,
87
+ mandate: r.mandate,
88
+ write_authority: r.write_authority,
89
+ runtime: r.runtime,
90
+ }, null, 2));
91
+ return;
92
+ }
93
+
94
+ const authority = r.write_authority === 'authoritative'
95
+ ? chalk.green(r.write_authority)
96
+ : r.write_authority === 'proposed'
97
+ ? chalk.yellow(r.write_authority)
98
+ : chalk.dim(r.write_authority);
99
+
100
+ console.log(chalk.bold(`\n Role: ${chalk.cyan(roleId)}\n`));
101
+ console.log(` Title: ${r.title}`);
102
+ console.log(` Mandate: ${r.mandate}`);
103
+ console.log(` Authority: ${authority}`);
104
+ console.log(` Runtime: ${chalk.dim(r.runtime)}`);
105
+ console.log('');
106
+ }
@@ -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
@@ -0,0 +1,210 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import chalk from 'chalk';
4
+ import { loadProjectContext, loadProjectState } from '../lib/config.js';
5
+ import { getActiveTurnCount, getActiveTurns } from '../lib/governed-state.js';
6
+ import {
7
+ getDispatchAssignmentPath,
8
+ getDispatchContextPath,
9
+ getDispatchManifestPath,
10
+ getDispatchPromptPath,
11
+ getDispatchTurnDir,
12
+ } from '../lib/turn-paths.js';
13
+
14
+ const ARTIFACTS = {
15
+ assignment: {
16
+ label: 'Assignment',
17
+ pathFor: getDispatchAssignmentPath,
18
+ parse: 'json',
19
+ },
20
+ prompt: {
21
+ label: 'Prompt',
22
+ pathFor: getDispatchPromptPath,
23
+ parse: 'text',
24
+ },
25
+ context: {
26
+ label: 'Context',
27
+ pathFor: getDispatchContextPath,
28
+ parse: 'text',
29
+ },
30
+ manifest: {
31
+ label: 'Manifest',
32
+ pathFor: getDispatchManifestPath,
33
+ parse: 'json',
34
+ },
35
+ };
36
+
37
+ export function turnShowCommand(turnId, opts) {
38
+ const context = requireGovernedContext();
39
+ const state = loadProjectState(context.root, context.config);
40
+
41
+ if (!state) {
42
+ console.log(chalk.red(' Governed state is missing or invalid.'));
43
+ process.exit(1);
44
+ }
45
+
46
+ const activeTurns = getActiveTurns(state);
47
+ const selectedTurnId = resolveTurnId(turnId, activeTurns);
48
+ const turn = activeTurns[selectedTurnId];
49
+ const artifacts = buildArtifactIndex(context.root, selectedTurnId);
50
+ const assignment = readJsonArtifact(artifacts.assignment.absPath);
51
+
52
+ if (opts.artifact) {
53
+ return printArtifact(selectedTurnId, turn, artifacts, opts.artifact, opts.json);
54
+ }
55
+
56
+ if (opts.json) {
57
+ console.log(JSON.stringify(buildTurnPayload(selectedTurnId, turn, state, artifacts, assignment), null, 2));
58
+ return;
59
+ }
60
+
61
+ printTurnSummary(selectedTurnId, turn, state, artifacts, assignment);
62
+ }
63
+
64
+ function requireGovernedContext() {
65
+ const context = loadProjectContext();
66
+ if (!context) {
67
+ console.log(chalk.red(' No agentxchain.json found. Run `agentxchain init` first.'));
68
+ process.exit(1);
69
+ }
70
+ if (context.config.protocol_mode !== 'governed') {
71
+ console.log(chalk.red(' The turn command is only available for governed projects.'));
72
+ process.exit(1);
73
+ }
74
+ return context;
75
+ }
76
+
77
+ function resolveTurnId(requestedTurnId, activeTurns) {
78
+ const turnIds = Object.keys(activeTurns);
79
+ if (requestedTurnId) {
80
+ if (!activeTurns[requestedTurnId]) {
81
+ console.log(chalk.red(` Unknown active turn: ${requestedTurnId}`));
82
+ if (turnIds.length > 0) {
83
+ console.log(chalk.dim(` Available: ${turnIds.join(', ')}`));
84
+ }
85
+ process.exit(1);
86
+ }
87
+ return requestedTurnId;
88
+ }
89
+
90
+ if (turnIds.length === 0) {
91
+ console.log(chalk.red(' No active turn found.'));
92
+ process.exit(1);
93
+ }
94
+
95
+ if (turnIds.length > 1) {
96
+ console.log(chalk.red(' Multiple active turns are present. Re-run with `agentxchain turn show <turn_id>`.'));
97
+ console.log(chalk.dim(` Available: ${turnIds.join(', ')}`));
98
+ process.exit(1);
99
+ }
100
+
101
+ return turnIds[0];
102
+ }
103
+
104
+ function buildArtifactIndex(root, turnId) {
105
+ return Object.fromEntries(
106
+ Object.entries(ARTIFACTS).map(([id, artifact]) => {
107
+ const relPath = artifact.pathFor(turnId);
108
+ const absPath = join(root, relPath);
109
+ return [id, {
110
+ id,
111
+ label: artifact.label,
112
+ relPath,
113
+ absPath,
114
+ exists: existsSync(absPath),
115
+ parse: artifact.parse,
116
+ }];
117
+ }),
118
+ );
119
+ }
120
+
121
+ function buildTurnPayload(turnId, turn, state, artifacts, assignment) {
122
+ return {
123
+ turn_id: turnId,
124
+ run_id: state.run_id || assignment?.run_id || null,
125
+ phase: state.phase || assignment?.phase || null,
126
+ role: turn.assigned_role,
127
+ runtime: turn.runtime_id,
128
+ status: turn.status,
129
+ attempt: turn.attempt,
130
+ dispatch_dir: getDispatchTurnDir(turnId),
131
+ staging_result_path: assignment?.staging_result_path || null,
132
+ active_turn_count: getActiveTurnCount(state),
133
+ artifacts: Object.fromEntries(
134
+ Object.entries(artifacts).map(([id, artifact]) => [id, {
135
+ path: artifact.relPath,
136
+ exists: artifact.exists,
137
+ }]),
138
+ ),
139
+ };
140
+ }
141
+
142
+ function printTurnSummary(turnId, turn, state, artifacts, assignment) {
143
+ console.log('');
144
+ console.log(chalk.bold(` Turn: ${chalk.cyan(turnId)}`));
145
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
146
+ console.log(` ${chalk.dim('Run:')} ${state.run_id || assignment?.run_id || chalk.dim('unknown')}`);
147
+ console.log(` ${chalk.dim('Phase:')} ${state.phase || assignment?.phase || chalk.dim('unknown')}`);
148
+ console.log(` ${chalk.dim('Role:')} ${chalk.bold(turn.assigned_role)}`);
149
+ console.log(` ${chalk.dim('Runtime:')} ${turn.runtime_id}`);
150
+ console.log(` ${chalk.dim('Status:')} ${turn.status}`);
151
+ console.log(` ${chalk.dim('Attempt:')} ${turn.attempt}`);
152
+ console.log(` ${chalk.dim('Dispatch:')} ${getDispatchTurnDir(turnId)}`);
153
+ if (assignment?.staging_result_path) {
154
+ console.log(` ${chalk.dim('Staging:')} ${assignment.staging_result_path}`);
155
+ }
156
+ console.log('');
157
+ console.log(` ${chalk.dim('Artifacts:')}`);
158
+ for (const artifact of Object.values(artifacts)) {
159
+ const marker = artifact.exists ? chalk.green('exists') : chalk.red('missing');
160
+ console.log(` ${artifact.label.padEnd(10)} ${marker} ${artifact.relPath}`);
161
+ }
162
+ console.log('');
163
+ console.log(chalk.dim(` View one: agentxchain turn show ${turnId} --artifact prompt`));
164
+ console.log('');
165
+ }
166
+
167
+ function printArtifact(turnId, turn, artifacts, artifactId, jsonMode) {
168
+ const artifact = artifacts[artifactId];
169
+ if (!artifact) {
170
+ console.log(chalk.red(` Unknown artifact: ${artifactId}`));
171
+ console.log(chalk.dim(` Allowed: ${Object.keys(ARTIFACTS).join(', ')}`));
172
+ process.exit(1);
173
+ }
174
+ if (!artifact.exists) {
175
+ console.log(chalk.red(` Missing artifact: ${artifact.relPath}`));
176
+ process.exit(1);
177
+ }
178
+
179
+ const raw = readFileSync(artifact.absPath, 'utf8');
180
+ if (!jsonMode) {
181
+ process.stdout.write(raw.endsWith('\n') ? raw : `${raw}\n`);
182
+ return;
183
+ }
184
+
185
+ const content = artifact.parse === 'json'
186
+ ? JSON.parse(raw)
187
+ : raw;
188
+
189
+ console.log(JSON.stringify({
190
+ turn_id: turnId,
191
+ role: turn.assigned_role,
192
+ runtime: turn.runtime_id,
193
+ artifact: {
194
+ id: artifact.id,
195
+ path: artifact.relPath,
196
+ content,
197
+ },
198
+ }, null, 2));
199
+ }
200
+
201
+ function readJsonArtifact(absPath) {
202
+ if (!existsSync(absPath)) {
203
+ return null;
204
+ }
205
+ try {
206
+ return JSON.parse(readFileSync(absPath, 'utf8'));
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
@@ -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 budget with agentxchain config --set budget.per_run_max_usd <usd>, 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({
@@ -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,7 +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'];
64
+ const VALID_BUDGET_ON_EXCEED = ['pause_and_escalate', 'warn'];
65
65
 
66
66
  function validateMcpRuntime(runtimeId, runtime, errors) {
67
67
  const transport = typeof runtime?.transport === 'string' && runtime.transport.trim()
@@ -583,7 +583,7 @@ export function validateBudgetConfig(budget) {
583
583
 
584
584
  if (budget.on_exceed !== undefined) {
585
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(', ')} (warn is not implemented)`);
586
+ errors.push(`budget.on_exceed must be one of: ${VALID_BUDGET_ON_EXCEED.join(', ')}`);
587
587
  }
588
588
  }
589
589
 
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
  /**