agentxchain 2.22.0 → 2.23.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.
package/README.md CHANGED
@@ -214,7 +214,7 @@ The first-party governed workflow kit includes `.planning/SYSTEM_SPEC.md` alongs
214
214
  - `manual`: implemented
215
215
  - `local_cli`: implemented
216
216
  - `mcp`: implemented for stdio and streamable HTTP tool-contract dispatch
217
- - `api_proxy`: implemented for synchronous review-only turns and stages a provider-backed result during `step`
217
+ - `api_proxy`: implemented for synchronous `review_only` and `proposed` write-authority turns; stages a provider-backed result during `step`
218
218
 
219
219
  ## Legacy IDE Mode
220
220
 
@@ -68,6 +68,7 @@ import { resumeCommand } from '../src/commands/resume.js';
68
68
  import { escalateCommand } from '../src/commands/escalate.js';
69
69
  import { acceptTurnCommand } from '../src/commands/accept-turn.js';
70
70
  import { rejectTurnCommand } from '../src/commands/reject-turn.js';
71
+ import { proposalListCommand, proposalDiffCommand, proposalApplyCommand, proposalRejectCommand } from '../src/commands/proposal.js';
71
72
  import { stepCommand } from '../src/commands/step.js';
72
73
  import { runCommand } from '../src/commands/run.js';
73
74
  import { approveTransitionCommand } from '../src/commands/approve-transition.js';
@@ -535,4 +536,35 @@ intakeCmd
535
536
  .option('-j, --json', 'Output as JSON')
536
537
  .action(intakeStatusCommand);
537
538
 
539
+ // --- Proposal operations ----------------------------------------------------
540
+
541
+ const proposalCmd = program
542
+ .command('proposal')
543
+ .description('Manage proposed changes from api_proxy agents');
544
+
545
+ proposalCmd
546
+ .command('list')
547
+ .description('List all proposals and their status')
548
+ .action(proposalListCommand);
549
+
550
+ proposalCmd
551
+ .command('diff <turn_id>')
552
+ .description('Show diff between proposed files and current workspace')
553
+ .option('--file <path>', 'Show diff for a single file only')
554
+ .action(proposalDiffCommand);
555
+
556
+ proposalCmd
557
+ .command('apply <turn_id>')
558
+ .description('Apply proposed changes to the workspace')
559
+ .option('--file <path>', 'Apply only a specific file')
560
+ .option('--dry-run', 'Show what would change without writing')
561
+ .option('--force', 'Override proposal conflicts or unverifiable legacy proposals')
562
+ .action(proposalApplyCommand);
563
+
564
+ proposalCmd
565
+ .command('reject <turn_id>')
566
+ .description('Reject a proposal without applying changes')
567
+ .option('--reason <reason>', 'Reason for rejection (required)')
568
+ .action(proposalRejectCommand);
569
+
538
570
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.22.0",
3
+ "version": "2.23.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,6 +6,7 @@ set -euo pipefail
6
6
 
7
7
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
8
  CLI_DIR="${SCRIPT_DIR}/.."
9
+ REPO_ROOT="$(cd "${CLI_DIR}/.." && pwd)"
9
10
  cd "$CLI_DIR"
10
11
 
11
12
  TARGET_VERSION=""
@@ -46,13 +47,56 @@ fi
46
47
  echo "AgentXchain Release Identity: ${TARGET_VERSION}"
47
48
  echo "============================================="
48
49
 
49
- # 1. Assert clean tree
50
- echo "[1/7] Checking git tree cleanliness..."
51
- if ! git diff --quiet HEAD 2>/dev/null || [ -n "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
52
- echo "FAIL: Working tree is not clean. Commit or stash changes before creating release identity." >&2
50
+ TARGET_RELEASE_DOC="website-v2/docs/releases/v${TARGET_VERSION//./-}.mdx"
51
+ ALLOWED_RELEASE_PATHS=(
52
+ "cli/CHANGELOG.md"
53
+ "${TARGET_RELEASE_DOC}"
54
+ "website-v2/sidebars.ts"
55
+ "website-v2/src/pages/index.tsx"
56
+ ".agentxchain-conformance/capabilities.json"
57
+ "website-v2/docs/protocol-implementor-guide.mdx"
58
+ ".planning/LAUNCH_EVIDENCE_REPORT.md"
59
+ )
60
+
61
+ is_allowed_release_path() {
62
+ local candidate="$1"
63
+ local allowed
64
+ for allowed in "${ALLOWED_RELEASE_PATHS[@]}"; do
65
+ if [[ "$candidate" == "$allowed" ]]; then
66
+ return 0
67
+ fi
68
+ done
69
+ return 1
70
+ }
71
+
72
+ stage_if_present() {
73
+ local rel_path="$1"
74
+ if [[ -e "${REPO_ROOT}/${rel_path}" ]]; then
75
+ git -C "$REPO_ROOT" add -- "$rel_path"
76
+ return 0
77
+ fi
78
+ if git -C "$REPO_ROOT" ls-files --error-unmatch "$rel_path" >/dev/null 2>&1; then
79
+ git -C "$REPO_ROOT" add -- "$rel_path"
80
+ fi
81
+ }
82
+
83
+ # 1. Assert only allowed release-surface dirt is present
84
+ echo "[1/7] Checking release-prep tree state..."
85
+ DISALLOWED_DIRTY=()
86
+ while IFS= read -r status_line; do
87
+ [[ -z "$status_line" ]] && continue
88
+ path="${status_line#?? }"
89
+ if ! is_allowed_release_path "$path"; then
90
+ DISALLOWED_DIRTY+=("$path")
91
+ fi
92
+ done < <(git -C "$REPO_ROOT" status --porcelain)
93
+
94
+ if [[ "${#DISALLOWED_DIRTY[@]}" -gt 0 ]]; then
95
+ echo "FAIL: Working tree contains changes outside the allowed release surfaces:" >&2
96
+ printf ' - %s\n' "${DISALLOWED_DIRTY[@]}" >&2
53
97
  exit 1
54
98
  fi
55
- echo " OK: tree is clean"
99
+ echo " OK: tree contains only allowed release-prep changes"
56
100
 
57
101
  # 2. Assert not already at target version
58
102
  echo "[2/7] Checking current version..."
@@ -78,11 +122,14 @@ echo " OK: package.json updated to ${TARGET_VERSION}"
78
122
 
79
123
  # 5. Stage version files
80
124
  echo "[5/7] Staging version files..."
81
- git add package.json
125
+ git add -- package.json
82
126
  if [[ -f package-lock.json ]]; then
83
- git add package-lock.json
127
+ git add -- package-lock.json
84
128
  fi
85
- echo " OK: version files staged"
129
+ for rel_path in "${ALLOWED_RELEASE_PATHS[@]}"; do
130
+ stage_if_present "$rel_path"
131
+ done
132
+ echo " OK: version files and allowed release surfaces staged"
86
133
 
87
134
  # 6. Create release commit
88
135
  echo "[6/7] Creating release commit..."
@@ -0,0 +1,144 @@
1
+ import chalk from 'chalk';
2
+ import { loadProjectContext } from '../lib/config.js';
3
+ import { listProposals, diffProposal, applyProposal, rejectProposal } from '../lib/proposal-ops.js';
4
+
5
+ export async function proposalListCommand() {
6
+ const context = requireGovernedContext();
7
+ const result = listProposals(context.root);
8
+ if (!result.ok) {
9
+ console.log(chalk.red(result.error));
10
+ process.exit(1);
11
+ }
12
+
13
+ if (result.proposals.length === 0) {
14
+ console.log(chalk.dim(' No proposals found.'));
15
+ return;
16
+ }
17
+
18
+ console.log('');
19
+ console.log(chalk.bold(' Proposals'));
20
+ console.log(chalk.dim(' ' + '─'.repeat(60)));
21
+ for (const p of result.proposals) {
22
+ const statusColor = p.status === 'applied' ? chalk.green : p.status === 'rejected' ? chalk.red : chalk.yellow;
23
+ console.log(` ${chalk.dim(p.turn_id)} ${p.role} ${p.file_count} files ${statusColor(p.status)}`);
24
+ }
25
+ console.log('');
26
+ }
27
+
28
+ export async function proposalDiffCommand(turnId, opts) {
29
+ const context = requireGovernedContext();
30
+ if (!turnId) {
31
+ console.log(chalk.red('Usage: agentxchain proposal diff <turn_id>'));
32
+ process.exit(1);
33
+ }
34
+
35
+ const result = diffProposal(context.root, turnId, opts.file);
36
+ if (!result.ok) {
37
+ console.log(chalk.red(result.error));
38
+ process.exit(1);
39
+ }
40
+
41
+ for (const d of result.diffs) {
42
+ console.log('');
43
+ console.log(chalk.bold(` ${d.path}`) + chalk.dim(` (${d.action})`));
44
+ console.log(chalk.dim(' ' + '─'.repeat(50)));
45
+ for (const line of d.preview.split('\n')) {
46
+ if (line.startsWith('+')) console.log(chalk.green(` ${line}`));
47
+ else if (line.startsWith('-')) console.log(chalk.red(` ${line}`));
48
+ else console.log(chalk.dim(` ${line}`));
49
+ }
50
+ }
51
+ console.log('');
52
+ }
53
+
54
+ export async function proposalApplyCommand(turnId, opts) {
55
+ const context = requireGovernedContext();
56
+ if (!turnId) {
57
+ console.log(chalk.red('Usage: agentxchain proposal apply <turn_id>'));
58
+ process.exit(1);
59
+ }
60
+
61
+ const result = applyProposal(context.root, turnId, {
62
+ file: opts.file,
63
+ dryRun: opts.dryRun,
64
+ force: opts.force,
65
+ });
66
+
67
+ if (!result.ok) {
68
+ if (Array.isArray(result.conflicts) && result.conflicts.length > 0) {
69
+ console.log(chalk.red(` ${result.error}`));
70
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
71
+ for (const conflict of result.conflicts) {
72
+ console.log(` ${chalk.red('•')} ${conflict.path} ${chalk.dim(`(${conflict.reason})`)}`);
73
+ }
74
+ console.log('');
75
+ process.exit(1);
76
+ }
77
+ console.log(chalk.red(result.error));
78
+ process.exit(1);
79
+ }
80
+
81
+ console.log('');
82
+ if (result.dry_run) {
83
+ console.log(chalk.yellow(' Dry Run — No Changes Written'));
84
+ } else if (result.forced) {
85
+ console.log(chalk.yellow(' Proposal Applied With Force'));
86
+ } else {
87
+ console.log(chalk.green(' Proposal Applied'));
88
+ }
89
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
90
+ console.log(` ${chalk.dim('Turn:')} ${turnId}`);
91
+ console.log(` ${chalk.dim('Applied:')} ${result.applied_files.length} files`);
92
+ if (result.applied_files.length > 0) {
93
+ for (const f of result.applied_files) {
94
+ console.log(` ${chalk.dim('•')} ${f}`);
95
+ }
96
+ }
97
+ if (result.skipped_files.length > 0) {
98
+ console.log(` ${chalk.dim('Skipped:')} ${result.skipped_files.length} files`);
99
+ for (const f of result.skipped_files) {
100
+ console.log(` ${chalk.dim('•')} ${f}`);
101
+ }
102
+ }
103
+ if (result.forced && result.overridden_conflicts?.length > 0) {
104
+ console.log(` ${chalk.dim('Forced:')} ${result.overridden_conflicts.length} conflicts overridden`);
105
+ for (const conflict of result.overridden_conflicts) {
106
+ console.log(` ${chalk.dim('•')} ${conflict.path}`);
107
+ }
108
+ }
109
+ console.log('');
110
+ }
111
+
112
+ export async function proposalRejectCommand(turnId, opts) {
113
+ const context = requireGovernedContext();
114
+ if (!turnId) {
115
+ console.log(chalk.red('Usage: agentxchain proposal reject <turn_id> --reason "..."'));
116
+ process.exit(1);
117
+ }
118
+
119
+ const result = rejectProposal(context.root, turnId, opts.reason);
120
+ if (!result.ok) {
121
+ console.log(chalk.red(result.error));
122
+ process.exit(1);
123
+ }
124
+
125
+ console.log('');
126
+ console.log(chalk.yellow(' Proposal Rejected'));
127
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
128
+ console.log(` ${chalk.dim('Turn:')} ${turnId}`);
129
+ console.log(` ${chalk.dim('Reason:')} ${opts.reason}`);
130
+ console.log('');
131
+ }
132
+
133
+ function requireGovernedContext() {
134
+ const context = loadProjectContext();
135
+ if (!context) {
136
+ console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
137
+ process.exit(1);
138
+ }
139
+ if (context.config.protocol_mode !== 'governed') {
140
+ console.log(chalk.red('The proposal command is only available for governed projects.'));
141
+ process.exit(1);
142
+ }
143
+ return context;
144
+ }
@@ -21,6 +21,7 @@ import { loadProjectContext, loadProjectState } from '../lib/config.js';
21
21
  import {
22
22
  initializeGovernedRun,
23
23
  assignGovernedTurn,
24
+ deriveAfterDispatchHookRecoveryAction,
24
25
  markRunBlocked,
25
26
  getActiveTurns,
26
27
  getActiveTurnCount,
@@ -358,13 +359,17 @@ function runAfterDispatchHooks(root, hooksConfig, state, turn, config) {
358
359
  || `after_dispatch hook blocked dispatch for turn ${turnId}`;
359
360
  const errorCode = afterDispatchHooks.tamper?.error_code || 'hook_blocked';
360
361
 
362
+ const recoveryAction = deriveAfterDispatchHookRecoveryAction(state, config, {
363
+ turnRetained: true,
364
+ turnId,
365
+ });
361
366
  markRunBlocked(root, {
362
367
  blockedOn: `hook:after_dispatch:${hookName}`,
363
368
  category: 'dispatch_error',
364
369
  recovery: {
365
370
  typed_reason: afterDispatchHooks.tamper ? 'hook_tamper' : 'hook_block',
366
371
  owner: 'human',
367
- recovery_action: `Fix or reconfigure the hook, then rerun agentxchain resume`,
372
+ recovery_action: recoveryAction,
368
373
  turn_retained: true,
369
374
  detail,
370
375
  },
@@ -378,6 +383,7 @@ function runAfterDispatchHooks(root, hooksConfig, state, turn, config) {
378
383
  hookName,
379
384
  error: detail,
380
385
  errorCode,
386
+ recoveryAction,
381
387
  hookResults: afterDispatchHooks,
382
388
  });
383
389
 
@@ -387,7 +393,7 @@ function runAfterDispatchHooks(root, hooksConfig, state, turn, config) {
387
393
  return { ok: true };
388
394
  }
389
395
 
390
- function printDispatchHookFailure({ turnId, roleId, hookName, error, hookResults }) {
396
+ function printDispatchHookFailure({ turnId, roleId, hookName, error, hookResults, recoveryAction }) {
391
397
  const isTamper = hookResults?.tamper;
392
398
  console.log('');
393
399
  console.log(chalk.yellow(' Dispatch Blocked By Hook'));
@@ -399,7 +405,7 @@ function printDispatchHookFailure({ turnId, roleId, hookName, error, hookResults
399
405
  console.log(` ${chalk.dim('Error:')} ${error}`);
400
406
  console.log(` ${chalk.dim('Reason:')} ${isTamper ? 'hook_tamper' : 'hook_block'}`);
401
407
  console.log(` ${chalk.dim('Owner:')} human`);
402
- console.log(` ${chalk.dim('Action:')} Fix or reconfigure the hook, then rerun agentxchain resume`);
408
+ console.log(` ${chalk.dim('Action:')} ${recoveryAction}`);
403
409
  console.log('');
404
410
  }
405
411
 
@@ -28,6 +28,7 @@ import {
28
28
  initializeGovernedRun,
29
29
  assignGovernedTurn,
30
30
  acceptGovernedTurn,
31
+ deriveAfterDispatchHookRecoveryAction,
31
32
  rejectGovernedTurn,
32
33
  markRunBlocked,
33
34
  getActiveTurnCount,
@@ -338,7 +339,7 @@ export async function stepCommand(opts) {
338
339
  });
339
340
 
340
341
  if (!afterDispatchHooks.ok) {
341
- const blocked = blockStepForHookIssue(root, turn, {
342
+ const blocked = blockStepForHookIssue(root, state, turn, {
342
343
  hookResults: afterDispatchHooks,
343
344
  phase: 'after_dispatch',
344
345
  defaultDetail: `after_dispatch hook blocked dispatch for turn ${turn.turn_id}`,
@@ -654,7 +655,7 @@ export async function stepCommand(opts) {
654
655
  });
655
656
 
656
657
  if (!beforeValidationHooks.ok) {
657
- const blocked = blockStepForHookIssue(root, turn, {
658
+ const blocked = blockStepForHookIssue(root, state, turn, {
658
659
  hookResults: beforeValidationHooks,
659
660
  phase: 'before_validation',
660
661
  defaultDetail: `before_validation hook blocked validation for turn ${turn.turn_id}`,
@@ -686,7 +687,7 @@ export async function stepCommand(opts) {
686
687
  });
687
688
 
688
689
  if (!afterValidationHooks.ok) {
689
- const blocked = blockStepForHookIssue(root, turn, {
690
+ const blocked = blockStepForHookIssue(root, state, turn, {
690
691
  hookResults: afterValidationHooks,
691
692
  phase: 'after_validation',
692
693
  defaultDetail: `after_validation hook blocked acceptance for turn ${turn.turn_id}`,
@@ -775,7 +776,7 @@ function loadHookStagedTurn(root, stagingRel) {
775
776
  }
776
777
  }
777
778
 
778
- function blockStepForHookIssue(root, turn, { hookResults, phase, defaultDetail, config }) {
779
+ function blockStepForHookIssue(root, state, turn, { hookResults, phase, defaultDetail, config }) {
779
780
  const hookName = hookResults.blocker?.hook_name
780
781
  || hookResults.results?.find((entry) => entry.hook_name)?.hook_name
781
782
  || 'unknown';
@@ -783,13 +784,17 @@ function blockStepForHookIssue(root, turn, { hookResults, phase, defaultDetail,
783
784
  || hookResults.tamper?.message
784
785
  || defaultDetail;
785
786
  const errorCode = hookResults.tamper?.error_code || 'hook_blocked';
787
+ const recoveryAction = deriveAfterDispatchHookRecoveryAction(state, config, {
788
+ turnRetained: true,
789
+ turnId: turn.turn_id,
790
+ });
786
791
  const blocked = markRunBlocked(root, {
787
792
  blockedOn: `hook:${phase}:${hookName}`,
788
793
  category: phase === 'after_dispatch' ? 'dispatch_error' : 'validation_error',
789
794
  recovery: {
790
795
  typed_reason: hookResults.tamper ? 'hook_tamper' : 'hook_block',
791
796
  owner: 'human',
792
- recovery_action: 'Fix or reconfigure the hook, then run agentxchain step --resume',
797
+ recovery_action: recoveryAction,
793
798
  turn_retained: true,
794
799
  detail,
795
800
  },
@@ -1,10 +1,14 @@
1
1
  /**
2
- * API proxy adapter — review-only synchronous provider calls.
2
+ * API proxy adapter — synchronous provider calls for review and proposed authoring.
3
3
  *
4
- * v1 scope (Session #19 freeze):
5
- * - review_only roles only
4
+ * Supported write authorities:
5
+ * - review_only: single request/response, structured review JSON
6
+ * - proposed: single request/response, structured JSON with proposed_changes[]
7
+ * (orchestrator materializes proposals to .agentxchain/proposed/<turn_id>/)
8
+ *
9
+ * Constraints:
6
10
  * - single request / single response (synchronous within `step`)
7
- * - no tool use, no patch application, no repo writes
11
+ * - no tool use, no direct repo writes
8
12
  * - turn result must arrive as structured JSON
9
13
  * - provider telemetry is authoritative for cost
10
14
  *
@@ -46,13 +50,15 @@ const PROVIDER_ENDPOINTS = {
46
50
  openai: 'https://api.openai.com/v1/chat/completions',
47
51
  };
48
52
 
49
- // Cost rates per million tokens (USD)
50
- const COST_RATES = {
51
- // Anthropic
53
+ // Bundled cost rates per million tokens (USD).
54
+ // These are convenience defaults — operators can override via budget.cost_rates in agentxchain.json.
55
+ // Verified: 2026-04-07 (Anthropic via docs.anthropic.com; OpenAI from training knowledge)
56
+ const BUNDLED_COST_RATES = {
57
+ // Anthropic — verified 2026-04-07
52
58
  'claude-sonnet-4-6': { input_per_1m: 3.00, output_per_1m: 15.00 },
53
- 'claude-opus-4-6': { input_per_1m: 15.00, output_per_1m: 75.00 },
54
- 'claude-haiku-4-5-20251001': { input_per_1m: 0.80, output_per_1m: 4.00 },
55
- // OpenAI
59
+ 'claude-opus-4-6': { input_per_1m: 5.00, output_per_1m: 25.00 },
60
+ 'claude-haiku-4-5-20251001': { input_per_1m: 1.00, output_per_1m: 5.00 },
61
+ // OpenAI — verified 2026-04-07 (training knowledge, could not live-verify openai.com)
56
62
  'gpt-4o': { input_per_1m: 2.50, output_per_1m: 10.00 },
57
63
  'gpt-4o-mini': { input_per_1m: 0.15, output_per_1m: 0.60 },
58
64
  'gpt-4.1': { input_per_1m: 2.00, output_per_1m: 8.00 },
@@ -63,6 +69,18 @@ const COST_RATES = {
63
69
  'o4-mini': { input_per_1m: 1.10, output_per_1m: 4.40 },
64
70
  };
65
71
 
72
+ // Resolve cost rates: operator-supplied cost_rates override bundled defaults
73
+ function getCostRates(model, config) {
74
+ const operatorRates = config?.budget?.cost_rates;
75
+ if (operatorRates && typeof operatorRates === 'object' && operatorRates[model]) {
76
+ const r = operatorRates[model];
77
+ if (Number.isFinite(r.input_per_1m) && Number.isFinite(r.output_per_1m)) {
78
+ return r;
79
+ }
80
+ }
81
+ return BUNDLED_COST_RATES[model] || null;
82
+ }
83
+
66
84
  const RETRYABLE_ERROR_CLASSES = [
67
85
  'rate_limited',
68
86
  'network_failure',
@@ -415,7 +433,7 @@ function emptyUsageTotals() {
415
433
  };
416
434
  }
417
435
 
418
- function usageFromTelemetry(provider, model, usage) {
436
+ function usageFromTelemetry(provider, model, usage, config) {
419
437
  if (!usage || typeof usage !== 'object') return null;
420
438
 
421
439
  let inputTokens = 0;
@@ -429,7 +447,7 @@ function usageFromTelemetry(provider, model, usage) {
429
447
  outputTokens = Number.isFinite(usage.output_tokens) ? usage.output_tokens : 0;
430
448
  }
431
449
 
432
- const rates = COST_RATES[model];
450
+ const rates = getCostRates(model, config);
433
451
  const usd = rates
434
452
  ? (inputTokens / 1_000_000) * rates.input_per_1m + (outputTokens / 1_000_000) * rates.output_per_1m
435
453
  : 0;
@@ -576,6 +594,7 @@ async function executeApiCall({
576
594
  requestBody,
577
595
  timeoutSeconds,
578
596
  signal,
597
+ config,
579
598
  }) {
580
599
  const timeoutMs = timeoutSeconds * 1000;
581
600
  const controller = new AbortController();
@@ -672,7 +691,7 @@ async function executeApiCall({
672
691
  };
673
692
  }
674
693
 
675
- const usage = usageFromTelemetry(provider, model, responseData.usage);
694
+ const usage = usageFromTelemetry(provider, model, responseData.usage, config);
676
695
  const extraction = extractTurnResult(responseData, provider);
677
696
 
678
697
  if (!extraction.ok) {
@@ -740,9 +759,9 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
740
759
  return { ok: false, error: `Runtime "${runtimeId}" is not an api_proxy runtime` };
741
760
  }
742
761
 
743
- // Enforce v1 restriction: review_only only
744
- if (role?.write_authority !== 'review_only') {
745
- return { ok: false, error: `v1 api_proxy only supports review_only roles (got "${role?.write_authority}")` };
762
+ // Enforce api_proxy restriction: review_only or proposed only
763
+ if (role?.write_authority !== 'review_only' && role?.write_authority !== 'proposed') {
764
+ return { ok: false, error: `api_proxy only supports review_only and proposed roles (got "${role?.write_authority}")` };
746
765
  }
747
766
 
748
767
  // Read dispatch bundle
@@ -879,6 +898,7 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
879
898
  requestBody,
880
899
  timeoutSeconds,
881
900
  signal,
901
+ config,
882
902
  });
883
903
 
884
904
  const attemptCompletedAt = new Date().toISOString();
@@ -1180,5 +1200,7 @@ export {
1180
1200
  buildOpenAiRequest,
1181
1201
  classifyError,
1182
1202
  classifyHttpError,
1183
- COST_RATES,
1203
+ BUNDLED_COST_RATES,
1204
+ BUNDLED_COST_RATES as COST_RATES, // backward compat alias
1205
+ getCostRates,
1184
1206
  };
@@ -1,27 +1,63 @@
1
- import { deriveEscalationRecoveryAction, getActiveTurnCount } from './governed-state.js';
1
+ import {
2
+ deriveConflictLoopRecoveryAction,
3
+ deriveDispatchRecoveryAction,
4
+ deriveEscalationRecoveryAction,
5
+ deriveHookTamperRecoveryAction,
6
+ deriveNeedsHumanRecoveryAction,
7
+ getActiveTurnCount,
8
+ } from './governed-state.js';
2
9
 
3
10
  function isLegacyEscalationRecoveryAction(action) {
4
11
  return action === 'Resolve the escalation, then run agentxchain step --resume'
5
12
  || action === 'Resolve the escalation, then run agentxchain step';
6
13
  }
7
14
 
8
- function maybeRefreshEscalationAction(state, config, persistedRecovery, turnRetained) {
15
+ function isLegacyNeedsHumanRecoveryAction(action) {
16
+ return action === 'Resolve the stated issue, then run agentxchain step --resume';
17
+ }
18
+
19
+ function isLegacyHookTamperRecoveryAction(action) {
20
+ return action === 'Disable or fix the hook, verify protected files, then run agentxchain step --resume';
21
+ }
22
+
23
+ function isLegacyConflictLoopRecoveryAction(action) {
24
+ return typeof action === 'string' && action.startsWith('Serialize the conflicting work, then run agentxchain step --resume');
25
+ }
26
+
27
+ function maybeRefreshRecoveryAction(state, config, persistedRecovery, turnRetained) {
9
28
  if (!config || !persistedRecovery || typeof persistedRecovery !== 'object') {
10
29
  return null;
11
30
  }
12
31
 
13
32
  const typedReason = persistedRecovery.typed_reason;
14
33
  const currentAction = persistedRecovery.recovery_action || null;
15
- const shouldRefresh = typedReason === 'retries_exhausted'
16
- || ((typedReason === 'operator_escalation') && isLegacyEscalationRecoveryAction(currentAction));
17
- if (!shouldRefresh) {
18
- return null;
34
+ const turnId = state?.blocked_reason?.turn_id ?? state?.escalation?.from_turn_id ?? null;
35
+ if (typedReason === 'retries_exhausted' || ((typedReason === 'operator_escalation') && isLegacyEscalationRecoveryAction(currentAction))) {
36
+ return deriveEscalationRecoveryAction(state, config, {
37
+ turnRetained,
38
+ turnId,
39
+ });
40
+ }
41
+
42
+ if (typedReason === 'needs_human' && isLegacyNeedsHumanRecoveryAction(currentAction)) {
43
+ return deriveNeedsHumanRecoveryAction(state, config, {
44
+ turnRetained,
45
+ turnId,
46
+ });
47
+ }
48
+
49
+ if (typedReason === 'hook_tamper' && isLegacyHookTamperRecoveryAction(currentAction)) {
50
+ return deriveHookTamperRecoveryAction(state, config, {
51
+ turnRetained,
52
+ turnId,
53
+ });
54
+ }
55
+
56
+ if (typedReason === 'conflict_loop' && isLegacyConflictLoopRecoveryAction(currentAction)) {
57
+ return deriveConflictLoopRecoveryAction(turnId);
19
58
  }
20
59
 
21
- return deriveEscalationRecoveryAction(state, config, {
22
- turnRetained,
23
- turnId: state?.blocked_reason?.turn_id ?? state?.escalation?.from_turn_id ?? null,
24
- });
60
+ return null;
25
61
  }
26
62
 
27
63
  export function deriveRecoveryDescriptor(state, config = null) {
@@ -53,11 +89,11 @@ export function deriveRecoveryDescriptor(state, config = null) {
53
89
 
54
90
  const persistedRecovery = state.blocked_reason?.recovery;
55
91
  if (persistedRecovery && typeof persistedRecovery === 'object') {
56
- const refreshedEscalationAction = maybeRefreshEscalationAction(state, config, persistedRecovery, turnRetained);
92
+ const refreshedRecoveryAction = maybeRefreshRecoveryAction(state, config, persistedRecovery, turnRetained);
57
93
  return {
58
94
  typed_reason: persistedRecovery.typed_reason || 'unknown_block',
59
95
  owner: persistedRecovery.owner || 'human',
60
- recovery_action: refreshedEscalationAction
96
+ recovery_action: refreshedRecoveryAction
61
97
  || persistedRecovery.recovery_action
62
98
  || 'Inspect state.json and resolve manually before rerunning agentxchain step',
63
99
  turn_retained: typeof persistedRecovery.turn_retained === 'boolean'
@@ -75,7 +111,10 @@ export function deriveRecoveryDescriptor(state, config = null) {
75
111
  return {
76
112
  typed_reason: 'needs_human',
77
113
  owner: 'human',
78
- recovery_action: 'Resolve the stated issue, then run agentxchain step --resume',
114
+ recovery_action: deriveNeedsHumanRecoveryAction(state, config, {
115
+ turnRetained,
116
+ turnId: state.blocked_reason?.turn_id ?? null,
117
+ }),
79
118
  turn_retained: turnRetained,
80
119
  detail: state.blocked_on.slice('human:'.length) || null,
81
120
  };
@@ -110,7 +149,10 @@ export function deriveRecoveryDescriptor(state, config = null) {
110
149
  return {
111
150
  typed_reason: 'dispatch_error',
112
151
  owner: 'human',
113
- recovery_action: 'Resolve the dispatch issue, then run agentxchain step --resume',
152
+ recovery_action: deriveDispatchRecoveryAction(state, config, {
153
+ turnRetained,
154
+ turnId: state.blocked_reason?.turn_id ?? null,
155
+ }),
114
156
  turn_retained: turnRetained,
115
157
  detail: state.blocked_on.slice('dispatch:'.length) || state.blocked_on,
116
158
  };
package/src/lib/config.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  normalizeGovernedStateShape,
8
8
  getActiveTurn,
9
9
  reconcileBudgetStatusWithConfig,
10
- reconcileEscalationRecoveryWithConfig,
10
+ reconcileRecoveryActionsWithConfig,
11
11
  } from './governed-state.js';
12
12
 
13
13
  function attachLegacyCurrentTurnAlias(state) {
@@ -155,7 +155,7 @@ export function loadProjectState(root, config) {
155
155
  stateData = normalized.state;
156
156
  const reconciledBudget = reconcileBudgetStatusWithConfig(stateData, config);
157
157
  stateData = reconciledBudget.state;
158
- const reconciledRecovery = reconcileEscalationRecoveryWithConfig(stateData, config);
158
+ const reconciledRecovery = reconcileRecoveryActionsWithConfig(stateData, config);
159
159
  stateData = reconciledRecovery.state;
160
160
  if (normalized.changed || reconciledBudget.changed || reconciledRecovery.changed) {
161
161
  safeWriteJson(filePath, stateData);