agentxchain 2.22.0 → 2.24.1

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.24.1",
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..."
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env bash
2
2
  # Release postflight — run this after publish succeeds.
3
3
  # Verifies: release tag exists, npm registry serves the version, metadata is present,
4
- # the published package can execute its CLI entrypoint, and runner package exports
5
- # are importable in a clean consumer project.
4
+ # the published package resolves through npx, the published package can execute its
5
+ # CLI entrypoint from the tarball, and runner package exports are importable in a
6
+ # clean consumer project.
6
7
  # Usage: bash scripts/release-postflight.sh --target-version <semver> [--tag vX.Y.Z]
7
8
  set -uo pipefail
8
9
 
@@ -139,6 +140,33 @@ run_install_smoke() {
139
140
  return "$version_status"
140
141
  }
141
142
 
143
+ run_npx_smoke() {
144
+ local smoke_root
145
+ local smoke_npmrc
146
+ local npx_output
147
+ local npx_status
148
+
149
+ smoke_root="$(mktemp -d "${TMPDIR:-/tmp}/agentxchain-npx-postflight.XXXXXX")"
150
+ mkdir -p "${smoke_root}/home" "${smoke_root}/cache" "${smoke_root}/npm-cache"
151
+
152
+ smoke_npmrc="${smoke_root}/.npmrc"
153
+ echo "registry=https://registry.npmjs.org/" > "$smoke_npmrc"
154
+
155
+ npx_output="$(
156
+ env -u NODE_AUTH_TOKEN \
157
+ HOME="${smoke_root}/home" \
158
+ XDG_CACHE_HOME="${smoke_root}/cache" \
159
+ NPM_CONFIG_CACHE="${smoke_root}/npm-cache" \
160
+ NPM_CONFIG_USERCONFIG="$smoke_npmrc" \
161
+ npx --yes "${PACKAGE_NAME}@${TARGET_VERSION}" --version 2>&1
162
+ )"
163
+ npx_status=$?
164
+
165
+ printf '%s\n' "$npx_output"
166
+ rm -rf "$smoke_root"
167
+ return "$npx_status"
168
+ }
169
+
142
170
  run_runner_export_smoke() {
143
171
  if [[ -z "$TARBALL_URL" ]]; then
144
172
  echo "registry tarball metadata unavailable for runner export smoke" >&2
@@ -261,17 +289,17 @@ run_with_retry() {
261
289
 
262
290
  echo "AgentXchain v${TARGET_VERSION} Release Postflight"
263
291
  echo "====================================="
264
- echo "Checks release truth after publish: tag, registry visibility, metadata, CLI install smoke, and package export smoke."
292
+ echo "Checks release truth after publish: tag, registry visibility, metadata, npx smoke, CLI install smoke, and package export smoke."
265
293
  echo ""
266
294
 
267
- echo "[1/6] Git tag"
295
+ echo "[1/7] Git tag"
268
296
  if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null 2>&1; then
269
297
  pass "Git tag ${TAG} exists locally"
270
298
  else
271
299
  fail "Git tag ${TAG} is missing locally"
272
300
  fi
273
301
 
274
- echo "[2/6] Registry version"
302
+ echo "[2/7] Registry version"
275
303
  if run_with_retry VERSION_OUTPUT "registry version" equals "${TARGET_VERSION}" npm view "${PACKAGE_NAME}@${TARGET_VERSION}" version; then
276
304
  PUBLISHED_VERSION="$(trim_last_line "$VERSION_OUTPUT")"
277
305
  if [[ "$PUBLISHED_VERSION" == "$TARGET_VERSION" ]]; then
@@ -284,7 +312,7 @@ else
284
312
  printf '%s\n' "$VERSION_OUTPUT" | tail -20
285
313
  fi
286
314
 
287
- echo "[3/6] Registry tarball metadata"
315
+ echo "[3/7] Registry tarball metadata"
288
316
  if run_with_retry TARBALL_OUTPUT "registry tarball metadata" nonempty "" npm view "${PACKAGE_NAME}@${TARGET_VERSION}" dist.tarball; then
289
317
  TARBALL_URL="$(trim_last_line "$TARBALL_OUTPUT")"
290
318
  if [[ -n "$TARBALL_URL" ]]; then
@@ -297,7 +325,7 @@ else
297
325
  printf '%s\n' "$TARBALL_OUTPUT" | tail -20
298
326
  fi
299
327
 
300
- echo "[4/6] Registry checksum metadata"
328
+ echo "[4/7] Registry checksum metadata"
301
329
  if run_with_retry INTEGRITY_OUTPUT "registry checksum metadata" nonempty "" npm view "${PACKAGE_NAME}@${TARGET_VERSION}" dist.integrity; then
302
330
  REGISTRY_CHECKSUM="$(trim_last_line "$INTEGRITY_OUTPUT")"
303
331
  fi
@@ -312,7 +340,20 @@ else
312
340
  fail "registry did not return checksum metadata"
313
341
  fi
314
342
 
315
- echo "[5/6] Install smoke"
343
+ echo "[5/7] npx smoke"
344
+ if run_with_retry NPX_OUTPUT "npx smoke" nonempty "" run_npx_smoke; then
345
+ NPX_VERSION="$(trim_last_line "$NPX_OUTPUT")"
346
+ if [[ "$NPX_VERSION" == "$TARGET_VERSION" ]]; then
347
+ pass "published npx command resolves and reports ${TARGET_VERSION}"
348
+ else
349
+ fail "published npx command reported '${NPX_VERSION}', expected '${TARGET_VERSION}'"
350
+ fi
351
+ else
352
+ fail "published npx smoke failed"
353
+ printf '%s\n' "$NPX_OUTPUT" | tail -20
354
+ fi
355
+
356
+ echo "[6/7] Install smoke"
316
357
  if run_with_retry EXEC_OUTPUT "install smoke" nonempty "" run_install_smoke; then
317
358
  EXEC_VERSION="$(trim_last_line "$EXEC_OUTPUT")"
318
359
  if [[ "$EXEC_VERSION" == "$TARGET_VERSION" ]]; then
@@ -325,7 +366,7 @@ else
325
366
  printf '%s\n' "$EXEC_OUTPUT" | tail -20
326
367
  fi
327
368
 
328
- echo "[6/6] Package export smoke"
369
+ echo "[7/7] Package export smoke"
329
370
  if run_with_retry RUNNER_EXPORT_OUTPUT "runner export smoke" nonempty "" run_runner_export_smoke; then
330
371
  RUNNER_EXPORT_JSON="$(trim_last_line "$RUNNER_EXPORT_OUTPUT")"
331
372
  RUNNER_EXPORT_VERSION="$(printf '%s' "$RUNNER_EXPORT_JSON" | node --input-type=module -e "process.stdin.setEncoding('utf8'); let raw=''; process.stdin.on('data', (chunk) => raw += chunk); process.stdin.on('end', () => { const parsed = JSON.parse(raw); console.log(parsed.runner_interface_version || ''); });")"
@@ -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
  };