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 +1 -1
- package/bin/agentxchain.js +32 -0
- package/package.json +1 -1
- package/scripts/release-bump.sh +55 -8
- package/src/commands/proposal.js +144 -0
- package/src/commands/resume.js +9 -3
- package/src/commands/step.js +10 -5
- package/src/lib/adapters/api-proxy-adapter.js +39 -17
- package/src/lib/blocked-state.js +56 -14
- package/src/lib/config.js +2 -2
- package/src/lib/dispatch-bundle.js +176 -1
- package/src/lib/governed-state.js +215 -20
- package/src/lib/normalized-config.js +3 -3
- package/src/lib/proposal-ops.js +451 -0
- package/src/lib/repo-observer.js +1 -0
- package/src/lib/schemas/turn-result.schema.json +28 -0
- package/src/lib/turn-result-validator.js +35 -0
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
|
|
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
|
|
package/bin/agentxchain.js
CHANGED
|
@@ -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
package/scripts/release-bump.sh
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/resume.js
CHANGED
|
@@ -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:
|
|
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:')}
|
|
408
|
+
console.log(` ${chalk.dim('Action:')} ${recoveryAction}`);
|
|
403
409
|
console.log('');
|
|
404
410
|
}
|
|
405
411
|
|
package/src/commands/step.js
CHANGED
|
@@ -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:
|
|
797
|
+
recovery_action: recoveryAction,
|
|
793
798
|
turn_retained: true,
|
|
794
799
|
detail,
|
|
795
800
|
},
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* API proxy adapter —
|
|
2
|
+
* API proxy adapter — synchronous provider calls for review and proposed authoring.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - review_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
|
|
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
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
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:
|
|
54
|
-
'claude-haiku-4-5-20251001': { input_per_1m:
|
|
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 =
|
|
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
|
|
744
|
-
if (role?.write_authority !== 'review_only') {
|
|
745
|
-
return { ok: false, error: `
|
|
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
|
-
|
|
1203
|
+
BUNDLED_COST_RATES,
|
|
1204
|
+
BUNDLED_COST_RATES as COST_RATES, // backward compat alias
|
|
1205
|
+
getCostRates,
|
|
1184
1206
|
};
|
package/src/lib/blocked-state.js
CHANGED
|
@@ -1,27 +1,63 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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);
|