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.
@@ -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);
@@ -14,7 +14,7 @@
14
14
  * orchestrator turn loop call it after assignGovernedTurn().
15
15
  */
16
16
 
17
- import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync } from 'fs';
18
18
  import { join } from 'path';
19
19
  import { getActiveTurn, getActiveTurns } from './governed-state.js';
20
20
  import {
@@ -31,6 +31,7 @@ import {
31
31
  const HISTORY_PATH = '.agentxchain/history.jsonl';
32
32
  const FILE_PREVIEW_MAX_FILES = 5;
33
33
  const FILE_PREVIEW_MAX_LINES = 120;
34
+ const PROPOSAL_SUMMARY_MAX_LINES = 80;
34
35
  const GATE_FILE_PREVIEW_MAX_LINES = 60;
35
36
  const DISPATCH_LOG_MAX_LINES = 50;
36
37
  const DISPATCH_LOG_MAX_LINE_BYTES = 8192;
@@ -229,6 +230,21 @@ function renderPrompt(role, roleId, turn, state, config, root) {
229
230
  lines.push('');
230
231
  lines.push('- You may propose changes as patches but cannot directly commit.');
231
232
  lines.push('- Your artifact type should be `patch`.');
233
+ if (runtimeType === 'api_proxy') {
234
+ lines.push('- **This runtime cannot write repo files directly.** When doing work, you MUST return proposed changes as structured JSON.');
235
+ lines.push('- Include a `proposed_changes` array in your turn result with each file change (omit or set to `[]` on completion-only turns):');
236
+ lines.push(' ```json');
237
+ lines.push(' "proposed_changes": [');
238
+ lines.push(' { "path": "src/lib/foo.js", "action": "create", "content": "// full file..." },');
239
+ lines.push(' { "path": "src/lib/bar.js", "action": "modify", "content": "// full new content..." },');
240
+ lines.push(' { "path": "src/old.js", "action": "delete" }');
241
+ lines.push(' ]');
242
+ lines.push(' ```');
243
+ lines.push('- Valid actions: `create` (new file), `modify` (replace content), `delete` (remove file).');
244
+ lines.push('- `content` is required for `create` and `modify` actions.');
245
+ lines.push('- The orchestrator will materialize your proposal to `.agentxchain/proposed/<turn_id>/` for review.');
246
+ lines.push('- List all proposed file paths in `files_changed`.');
247
+ }
232
248
  lines.push('');
233
249
  }
234
250
 
@@ -366,6 +382,22 @@ function renderPrompt(role, roleId, turn, state, config, root) {
366
382
  lines.push(`- **You are in the \`${currentPhase}\` phase (final phase).** When ready to ship, set \`run_completion_request: true\` and \`phase_transition_request: null\`.`);
367
383
  }
368
384
  }
385
+ // Phase-specific guidance for proposed roles
386
+ if (role.write_authority === 'proposed' && phaseNames.length > 0) {
387
+ const currentPhase = state?.phase;
388
+ const phaseIdx = currentPhase ? phaseNames.indexOf(currentPhase) : -1;
389
+ if (phaseIdx >= 0 && phaseIdx < phaseNames.length - 1) {
390
+ const nextPhase = phaseNames[phaseIdx + 1];
391
+ const currentGate = config.routing?.[currentPhase]?.exit_gate;
392
+ const gateClause = currentGate ? ` and the exit gate (\`${currentGate}\`) is satisfied` : '';
393
+ lines.push(`- **You are in the \`${currentPhase}\` phase (not final phase).** When your work is complete${gateClause}, set \`phase_transition_request: "${nextPhase}"\`.`);
394
+ } else if (phaseIdx >= 0 && phaseIdx === phaseNames.length - 1) {
395
+ lines.push(`- **You are in the \`${currentPhase}\` phase (final phase).** When ready to ship, set \`run_completion_request: true\` and \`phase_transition_request: null\`.`);
396
+ if (runtimeType === 'api_proxy') {
397
+ lines.push('- **Completion turns must be no-op:** set `proposed_changes` to `[]` or omit it, set `files_changed` to `[]`, and set `artifact.type` to `"review"`. Do NOT propose file changes on a completion turn.');
398
+ }
399
+ }
400
+ }
369
401
  // Phase-specific guidance for review_only roles (terminal phase ship readiness)
370
402
  if (role.write_authority === 'review_only' && phaseNames.length > 0) {
371
403
  const currentPhase = state?.phase;
@@ -467,6 +499,42 @@ function renderContext(state, config, root, turn, role) {
467
499
  }
468
500
  }
469
501
 
502
+ const proposalPreview = role?.write_authority === 'review_only'
503
+ ? buildProposalArtifactPreview(root, lastTurn)
504
+ : null;
505
+ if (proposalPreview) {
506
+ lines.push('### Proposed Artifact');
507
+ lines.push('');
508
+ lines.push(`- **Artifact ref:** \`${proposalPreview.artifactRef}\``);
509
+ lines.push(`- **Proposed files:** ${proposalPreview.changeCount}`);
510
+ lines.push('');
511
+ lines.push('```');
512
+ lines.push(proposalPreview.summary);
513
+ lines.push('```');
514
+ if (proposalPreview.summaryTruncated) {
515
+ lines.push('');
516
+ lines.push(`_Preview truncated after ${PROPOSAL_SUMMARY_MAX_LINES} lines._`);
517
+ }
518
+ lines.push('');
519
+
520
+ if (proposalPreview.filePreviews.length > 0) {
521
+ lines.push('### Proposed File Previews');
522
+ lines.push('');
523
+ for (const preview of proposalPreview.filePreviews) {
524
+ lines.push(`#### \`${preview.path}\` (${preview.action})`);
525
+ lines.push('');
526
+ lines.push('```');
527
+ lines.push(preview.content);
528
+ lines.push('```');
529
+ if (preview.truncated) {
530
+ lines.push('');
531
+ lines.push(`_Preview truncated after ${FILE_PREVIEW_MAX_LINES} lines._`);
532
+ }
533
+ lines.push('');
534
+ }
535
+ }
536
+ }
537
+
470
538
  // Verification evidence from the previous turn
471
539
  // Use raw verification (has commands, machine_evidence, evidence_summary)
472
540
  // and supplement with normalized_verification status when available
@@ -683,6 +751,113 @@ function buildChangedFilePreviews(root, filesChanged) {
683
751
  return previews;
684
752
  }
685
753
 
754
+ function buildProposalArtifactPreview(root, lastTurn) {
755
+ const artifactRef = lastTurn?.artifact?.ref;
756
+ if (typeof artifactRef !== 'string' || !artifactRef.startsWith('.agentxchain/proposed/')) {
757
+ return null;
758
+ }
759
+
760
+ const absProposalDir = join(root, artifactRef);
761
+ const summaryPath = join(absProposalDir, 'PROPOSAL.md');
762
+
763
+ let summary = '';
764
+ let summaryTruncated = false;
765
+
766
+ if (existsSync(summaryPath)) {
767
+ try {
768
+ const raw = readFileSync(summaryPath, 'utf8');
769
+ const lines = raw.replace(/\r\n/g, '\n').split('\n');
770
+ summaryTruncated = lines.length > PROPOSAL_SUMMARY_MAX_LINES;
771
+ summary = (summaryTruncated ? lines.slice(0, PROPOSAL_SUMMARY_MAX_LINES) : lines)
772
+ .join('\n')
773
+ .trimEnd();
774
+ } catch {
775
+ summary = '';
776
+ }
777
+ }
778
+
779
+ const changeActions = extractProposalActions(summary);
780
+ const materializedFiles = collectProposalFiles(absProposalDir);
781
+
782
+ if (!summary) {
783
+ const fallbackLines = [
784
+ `# Proposed Changes — ${lastTurn.turn_id || '(unknown)'}`,
785
+ '',
786
+ lastTurn.summary || '(no summary)',
787
+ '',
788
+ ];
789
+ for (const filePath of materializedFiles) {
790
+ fallbackLines.push(`- \`${filePath}\` — ${changeActions.get(filePath) || 'create'}`);
791
+ }
792
+ summary = fallbackLines.join('\n');
793
+ }
794
+
795
+ const filePreviews = [];
796
+ for (const relPath of materializedFiles.slice(0, FILE_PREVIEW_MAX_FILES)) {
797
+ const absPath = join(absProposalDir, relPath);
798
+
799
+ let raw;
800
+ try {
801
+ raw = readFileSync(absPath, 'utf8');
802
+ } catch {
803
+ continue;
804
+ }
805
+
806
+ const lines = raw.replace(/\r\n/g, '\n').split('\n');
807
+ const truncated = lines.length > FILE_PREVIEW_MAX_LINES;
808
+ const previewLines = truncated ? lines.slice(0, FILE_PREVIEW_MAX_LINES) : lines;
809
+ filePreviews.push({
810
+ path: relPath,
811
+ action: changeActions.get(relPath) || 'create',
812
+ content: previewLines.join('\n').trimEnd(),
813
+ truncated,
814
+ });
815
+ }
816
+
817
+ return {
818
+ artifactRef,
819
+ summary,
820
+ summaryTruncated,
821
+ filePreviews,
822
+ changeCount: changeActions.size || materializedFiles.length,
823
+ };
824
+ }
825
+
826
+ function extractProposalActions(summary) {
827
+ const actions = new Map();
828
+ if (!summary) {
829
+ return actions;
830
+ }
831
+
832
+ const matches = summary.matchAll(/^- `([^`]+)` — ([a-z]+)/gm);
833
+ for (const match of matches) {
834
+ actions.set(match[1], match[2]);
835
+ }
836
+ return actions;
837
+ }
838
+
839
+ function collectProposalFiles(absProposalDir, relPrefix = '') {
840
+ if (!existsSync(absProposalDir)) {
841
+ return [];
842
+ }
843
+
844
+ const files = [];
845
+ for (const entry of readdirSync(absProposalDir, { withFileTypes: true })) {
846
+ if (entry.name === 'PROPOSAL.md') {
847
+ continue;
848
+ }
849
+
850
+ const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
851
+ const absPath = join(absProposalDir, entry.name);
852
+ if (entry.isDirectory()) {
853
+ files.push(...collectProposalFiles(absPath, relPath));
854
+ } else if (entry.isFile()) {
855
+ files.push(relPath);
856
+ }
857
+ }
858
+ return files.sort();
859
+ }
860
+
686
861
  function buildDispatchLogExcerpt(root, turnId) {
687
862
  const logPath = join(root, getDispatchLogPath(turnId));
688
863
  if (!existsSync(logPath)) {
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, readdirSync, rmSync } from 'fs';
19
19
  import { join, dirname } from 'path';
20
- import { randomBytes } from 'crypto';
20
+ import { randomBytes, createHash } from 'crypto';
21
21
  import { safeWriteJson } from './safe-write.js';
22
22
  import { validateStagedTurnResult } from './turn-result-validator.js';
23
23
  import { evaluatePhaseExit, evaluateRunCompletion } from './gate-evaluator.js';
@@ -155,6 +155,87 @@ function materializeDerivedReviewArtifact(root, turnResult, state, runtimeType,
155
155
  return reviewPath;
156
156
  }
157
157
 
158
+ function materializeDerivedProposalArtifact(root, turnResult, state, runtimeType) {
159
+ if (turnResult?.artifact?.type !== 'patch' || runtimeType !== 'api_proxy') {
160
+ return null;
161
+ }
162
+ if (!Array.isArray(turnResult.proposed_changes) || turnResult.proposed_changes.length === 0) {
163
+ return null;
164
+ }
165
+
166
+ const proposalDir = `.agentxchain/proposed/${turnResult.turn_id}`;
167
+ const absProposalDir = join(root, proposalDir);
168
+ mkdirSync(absProposalDir, { recursive: true });
169
+
170
+ // Write PROPOSAL.md summary
171
+ const summaryLines = [
172
+ `# Proposed Changes — ${turnResult.turn_id}`,
173
+ '',
174
+ `**Role:** ${turnResult.role}`,
175
+ `**Runtime:** ${turnResult.runtime_id}`,
176
+ `**Status:** ${turnResult.status}`,
177
+ '',
178
+ `## Summary`,
179
+ '',
180
+ turnResult.summary || '(no summary)',
181
+ '',
182
+ `## Files`,
183
+ '',
184
+ ];
185
+ for (const change of turnResult.proposed_changes) {
186
+ summaryLines.push(`- \`${change.path}\` — ${change.action}`);
187
+ }
188
+ if (turnResult.decisions?.length > 0) {
189
+ summaryLines.push('', '## Decisions', '');
190
+ for (const dec of turnResult.decisions) {
191
+ summaryLines.push(`- **${dec.id}** (${dec.category}): ${dec.statement}`);
192
+ }
193
+ }
194
+ summaryLines.push('');
195
+ writeFileSync(join(absProposalDir, 'PROPOSAL.md'), summaryLines.join('\n'));
196
+ writeFileSync(
197
+ join(absProposalDir, 'SOURCE_SNAPSHOT.json'),
198
+ JSON.stringify(captureProposalSourceSnapshot(root, turnResult.proposed_changes), null, 2) + '\n',
199
+ );
200
+
201
+ // Materialize each proposed file (create/modify only; delete just listed)
202
+ for (const change of turnResult.proposed_changes) {
203
+ if (change.action === 'delete') continue;
204
+ if (typeof change.content !== 'string') continue;
205
+ const absFilePath = join(absProposalDir, change.path);
206
+ mkdirSync(dirname(absFilePath), { recursive: true });
207
+ writeFileSync(absFilePath, change.content);
208
+ }
209
+
210
+ turnResult.artifact = { ...(turnResult.artifact || {}), ref: proposalDir };
211
+ return proposalDir;
212
+ }
213
+
214
+ function captureProposalSourceSnapshot(root, proposedChanges) {
215
+ return {
216
+ captured_at: new Date().toISOString(),
217
+ files: proposedChanges.map((change) => {
218
+ const absFilePath = join(root, change.path);
219
+ if (!existsSync(absFilePath)) {
220
+ return {
221
+ path: change.path,
222
+ action: change.action,
223
+ existed: false,
224
+ sha256: null,
225
+ };
226
+ }
227
+
228
+ const content = readFileSync(absFilePath);
229
+ return {
230
+ path: change.path,
231
+ action: change.action,
232
+ existed: true,
233
+ sha256: `sha256:${createHash('sha256').update(content).digest('hex')}`,
234
+ };
235
+ }),
236
+ };
237
+ }
238
+
158
239
  function normalizeActiveTurns(activeTurns) {
159
240
  if (!activeTurns || typeof activeTurns !== 'object' || Array.isArray(activeTurns)) {
160
241
  return {};
@@ -229,6 +310,60 @@ export function deriveRetainedTurnRecoveryCommand(state, config, options = {}) {
229
310
  return command;
230
311
  }
231
312
 
313
+ export function deriveBlockedRecoveryCommand(state, config, options = {}) {
314
+ const turnRetained = typeof options.turnRetained === 'boolean'
315
+ ? options.turnRetained
316
+ : getActiveTurnCount(state) > 0;
317
+ if (!turnRetained) {
318
+ return options.clearedCommand || 'agentxchain resume';
319
+ }
320
+
321
+ return deriveRetainedTurnRecoveryCommand(state, config, {
322
+ turnId: options.turnId,
323
+ fallbackCommand: options.fallbackCommand || 'agentxchain step --resume',
324
+ manualCommand: options.manualCommand || 'agentxchain resume',
325
+ automatedCommand: options.automatedCommand || 'agentxchain step --resume',
326
+ });
327
+ }
328
+
329
+ export function deriveNeedsHumanRecoveryAction(state, config, options = {}) {
330
+ const command = deriveBlockedRecoveryCommand(state, config, {
331
+ turnRetained: options.turnRetained,
332
+ turnId: options.turnId,
333
+ });
334
+ return `Resolve the stated issue, then run ${command}`;
335
+ }
336
+
337
+ export function deriveDispatchRecoveryAction(state, config, options = {}) {
338
+ const command = deriveBlockedRecoveryCommand(state, config, {
339
+ turnRetained: options.turnRetained,
340
+ turnId: options.turnId,
341
+ });
342
+ return `Resolve the dispatch issue, then run ${command}`;
343
+ }
344
+
345
+ export function deriveHookTamperRecoveryAction(state, config, options = {}) {
346
+ const command = deriveBlockedRecoveryCommand(state, config, {
347
+ turnRetained: options.turnRetained,
348
+ turnId: options.turnId,
349
+ });
350
+ return `Disable or fix the hook, verify protected files, then run ${command}`;
351
+ }
352
+
353
+ export function deriveAfterDispatchHookRecoveryAction(state, config, options = {}) {
354
+ const command = deriveBlockedRecoveryCommand(state, config, {
355
+ turnRetained: options.turnRetained,
356
+ turnId: options.turnId,
357
+ });
358
+ return `Fix or reconfigure the hook, then rerun ${command}`;
359
+ }
360
+
361
+ export function deriveConflictLoopRecoveryAction(turnId) {
362
+ return turnId
363
+ ? `Serialize the conflicting work, then run agentxchain reject-turn --turn ${turnId} --reassign`
364
+ : 'Serialize the conflicting work, then run agentxchain reject-turn --reassign';
365
+ }
366
+
232
367
  function isLegacyEscalationRecoveryAction(action) {
233
368
  return action === 'Resolve the escalation, then run agentxchain step --resume'
234
369
  || action === 'Resolve the escalation, then run agentxchain step';
@@ -857,7 +992,7 @@ function deriveHookRecovery(state, { phase, hookName, detail, errorCode, turnId,
857
992
  typed_reason: isTamper ? 'hook_tamper' : 'hook_block',
858
993
  owner: 'human',
859
994
  recovery_action: isTamper
860
- ? 'Disable or fix the hook, verify protected files, then run agentxchain step --resume'
995
+ ? deriveHookTamperRecoveryAction(state, null, { turnRetained, turnId })
861
996
  : `Fix or reconfigure hook "${hookName}", then rerun agentxchain accept-turn${turnId ? ` --turn ${turnId}` : ''}`,
862
997
  turn_retained: Boolean(turnRetained),
863
998
  detail: detail || hookName || phase,
@@ -936,30 +1071,80 @@ function normalizeRecoveryDescriptor(recovery, turnRetained, detail) {
936
1071
  };
937
1072
  }
938
1073
 
939
- export function reconcileEscalationRecoveryWithConfig(state, config) {
1074
+ function isLegacyNeedsHumanRecoveryAction(action) {
1075
+ return action === 'Resolve the stated issue, then run agentxchain step --resume';
1076
+ }
1077
+
1078
+ function isLegacyHookTamperRecoveryAction(action) {
1079
+ return action === 'Disable or fix the hook, verify protected files, then run agentxchain step --resume';
1080
+ }
1081
+
1082
+ function isLegacyAfterDispatchHookBlockRecoveryAction(action) {
1083
+ return action === 'Fix or reconfigure the hook, then rerun agentxchain resume';
1084
+ }
1085
+
1086
+ function isLegacyConflictLoopRecoveryAction(action) {
1087
+ return typeof action === 'string' && action.startsWith('Serialize the conflicting work, then run agentxchain step --resume');
1088
+ }
1089
+
1090
+ export function reconcileRecoveryActionsWithConfig(state, config) {
940
1091
  if (!state || typeof state !== 'object' || state.status !== 'blocked' || !config) {
941
1092
  return { state, changed: false };
942
1093
  }
943
1094
 
944
1095
  const recovery = state.blocked_reason?.recovery;
945
1096
  const typedReason = recovery?.typed_reason;
946
- if (typedReason !== 'operator_escalation' && typedReason !== 'retries_exhausted') {
947
- return { state, changed: false };
948
- }
949
-
950
1097
  const currentAction = recovery?.recovery_action || null;
951
- const shouldRefresh = typedReason === 'retries_exhausted' || isLegacyEscalationRecoveryAction(currentAction);
952
- if (!shouldRefresh) {
953
- return { state, changed: false };
954
- }
955
-
956
1098
  const turnRetained = typeof recovery?.turn_retained === 'boolean'
957
1099
  ? recovery.turn_retained
958
1100
  : getActiveTurnCount(state) > 0;
959
- const nextAction = deriveEscalationRecoveryAction(state, config, {
960
- turnRetained,
961
- turnId: state.blocked_reason?.turn_id ?? state.escalation?.from_turn_id ?? null,
962
- });
1101
+ const turnId = state.blocked_reason?.turn_id ?? state.escalation?.from_turn_id ?? null;
1102
+
1103
+ let shouldRefresh = false;
1104
+ let nextAction = null;
1105
+
1106
+ if (typedReason === 'operator_escalation' || typedReason === 'retries_exhausted') {
1107
+ shouldRefresh = typedReason === 'retries_exhausted' || isLegacyEscalationRecoveryAction(currentAction);
1108
+ if (shouldRefresh) {
1109
+ nextAction = deriveEscalationRecoveryAction(state, config, {
1110
+ turnRetained,
1111
+ turnId,
1112
+ });
1113
+ }
1114
+ } else if (typedReason === 'needs_human') {
1115
+ shouldRefresh = isLegacyNeedsHumanRecoveryAction(currentAction);
1116
+ if (shouldRefresh) {
1117
+ nextAction = deriveNeedsHumanRecoveryAction(state, config, {
1118
+ turnRetained,
1119
+ turnId,
1120
+ });
1121
+ }
1122
+ } else if (typedReason === 'hook_tamper') {
1123
+ shouldRefresh = isLegacyHookTamperRecoveryAction(currentAction);
1124
+ if (shouldRefresh) {
1125
+ nextAction = deriveHookTamperRecoveryAction(state, config, {
1126
+ turnRetained,
1127
+ turnId,
1128
+ });
1129
+ }
1130
+ } else if (typedReason === 'hook_block') {
1131
+ shouldRefresh = isLegacyAfterDispatchHookBlockRecoveryAction(currentAction);
1132
+ if (shouldRefresh) {
1133
+ nextAction = deriveAfterDispatchHookRecoveryAction(state, config, {
1134
+ turnRetained,
1135
+ turnId,
1136
+ });
1137
+ }
1138
+ } else if (typedReason === 'conflict_loop') {
1139
+ shouldRefresh = isLegacyConflictLoopRecoveryAction(currentAction);
1140
+ if (shouldRefresh) {
1141
+ nextAction = deriveConflictLoopRecoveryAction(turnId);
1142
+ }
1143
+ }
1144
+
1145
+ if (!shouldRefresh || !nextAction) {
1146
+ return { state, changed: false };
1147
+ }
963
1148
 
964
1149
  let nextState = state;
965
1150
  let changed = false;
@@ -1015,7 +1200,10 @@ function inferBlockedReasonFromState(state) {
1015
1200
  recovery: {
1016
1201
  typed_reason: 'needs_human',
1017
1202
  owner: 'human',
1018
- recovery_action: 'Resolve the stated issue, then run agentxchain step --resume',
1203
+ recovery_action: deriveNeedsHumanRecoveryAction(state, null, {
1204
+ turnRetained,
1205
+ turnId: activeTurn?.turn_id ?? state.blocked_reason?.turn_id ?? null,
1206
+ }),
1019
1207
  turn_retained: turnRetained,
1020
1208
  detail,
1021
1209
  },
@@ -1049,7 +1237,10 @@ function inferBlockedReasonFromState(state) {
1049
1237
  recovery: {
1050
1238
  typed_reason: 'dispatch_error',
1051
1239
  owner: 'human',
1052
- recovery_action: 'Resolve the dispatch issue, then run agentxchain step --resume',
1240
+ recovery_action: deriveDispatchRecoveryAction(state, null, {
1241
+ turnRetained,
1242
+ turnId: activeTurn?.turn_id ?? state.blocked_reason?.turn_id ?? null,
1243
+ }),
1053
1244
  turn_retained: turnRetained,
1054
1245
  detail,
1055
1246
  },
@@ -1802,6 +1993,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
1802
1993
  const runtime = config.runtimes?.[runtimeId];
1803
1994
  const runtimeType = runtime?.type || 'manual';
1804
1995
  materializeDerivedReviewArtifact(root, turnResult, state, runtimeType, baseline);
1996
+ materializeDerivedProposalArtifact(root, turnResult, state, runtimeType);
1805
1997
  const writeAuthority = role?.write_authority || 'review_only';
1806
1998
  const diffComparison = compareDeclaredVsObserved(
1807
1999
  turnResult.files_changed || [],
@@ -1859,7 +2051,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
1859
2051
  recovery: {
1860
2052
  typed_reason: 'conflict_loop',
1861
2053
  owner: 'human',
1862
- recovery_action: `Serialize the conflicting work, then run agentxchain step --resume --turn ${currentTurn.turn_id}`,
2054
+ recovery_action: deriveConflictLoopRecoveryAction(currentTurn.turn_id),
1863
2055
  turn_retained: true,
1864
2056
  detail: buildConflictDetail(conflict),
1865
2057
  },
@@ -2018,7 +2210,10 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2018
2210
  recovery: {
2019
2211
  typed_reason: 'needs_human',
2020
2212
  owner: 'human',
2021
- recovery_action: 'Resolve the stated issue, then run agentxchain step --resume',
2213
+ recovery_action: deriveNeedsHumanRecoveryAction(updatedState, config, {
2214
+ turnRetained: false,
2215
+ turnId: turnResult.turn_id,
2216
+ }),
2022
2217
  turn_retained: false,
2023
2218
  detail: turnResult.needs_human_reason || 'unspecified',
2024
2219
  },
@@ -389,11 +389,11 @@ export function validateV4Config(data, projectRoot) {
389
389
  errors.push(`Role "${id}" is review_only but uses local_cli runtime "${role.runtime}" — review_only roles should not have authoritative write access`);
390
390
  }
391
391
  }
392
- // v1 api_proxy restriction: only review_only roles may bind to api_proxy runtimes (Session #19 freeze)
392
+ // api_proxy restriction: only review_only and proposed roles may bind to api_proxy runtimes
393
393
  if (role.runtime && data.runtimes[role.runtime]) {
394
394
  const rt = data.runtimes[role.runtime];
395
- if (rt.type === 'api_proxy' && role.write_authority !== 'review_only') {
396
- errors.push(`Role "${id}" has write_authority "${role.write_authority}" but uses api_proxy runtime "${role.runtime}" — v1 api_proxy only supports review_only roles`);
395
+ if (rt.type === 'api_proxy' && role.write_authority !== 'review_only' && role.write_authority !== 'proposed') {
396
+ errors.push(`Role "${id}" has write_authority "${role.write_authority}" but uses api_proxy runtime "${role.runtime}" — api_proxy only supports review_only and proposed roles`);
397
397
  }
398
398
  }
399
399
  }