agentxchain 2.130.1 → 2.132.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.130.1",
3
+ "version": "2.132.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,12 +12,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
  const REPO_ROOT = join(__dirname, '..', '..');
13
13
 
14
14
  function usage() {
15
- console.error('Usage: node cli/scripts/check-release-alignment.mjs [--target-version <semver>] [--scope prebump|current] [--json]');
15
+ console.error('Usage: node cli/scripts/check-release-alignment.mjs [--target-version <semver>] [--scope prebump|current] [--json|--report]');
16
16
  }
17
17
 
18
18
  let targetVersion = null;
19
19
  let scope = RELEASE_ALIGNMENT_SCOPES.CURRENT;
20
20
  let json = false;
21
+ let report = false;
21
22
 
22
23
  for (let index = 2; index < process.argv.length; index += 1) {
23
24
  const arg = process.argv[index];
@@ -45,15 +46,40 @@ for (let index = 2; index < process.argv.length; index += 1) {
45
46
  json = true;
46
47
  continue;
47
48
  }
49
+ if (arg === '--report') {
50
+ report = true;
51
+ continue;
52
+ }
48
53
  console.error(`Error: unknown argument "${arg}"`);
49
54
  usage();
50
55
  process.exit(1);
51
56
  }
52
57
 
58
+ if (json && report) {
59
+ console.error('Error: --json and --report are mutually exclusive');
60
+ usage();
61
+ process.exit(1);
62
+ }
63
+
53
64
  const result = validateReleaseAlignment(REPO_ROOT, { targetVersion, scope });
54
65
 
55
66
  if (json) {
56
67
  console.log(JSON.stringify(result, null, 2));
68
+ } else if (report) {
69
+ const readyCount = result.surfaceResults.filter((surface) => surface.ok).length;
70
+ const needsUpdateCount = result.surfaceResults.length - readyCount;
71
+ console.log(
72
+ `Release alignment report for ${result.targetVersion} (${result.scope}, ${result.checkedSurfaceCount} surfaces).`,
73
+ );
74
+ for (const surface of result.surfaceResults) {
75
+ console.log(`- [${surface.ok ? 'ready' : 'needs update'}] (${surface.surface_id}) ${surface.label}`);
76
+ for (const error of surface.errors) {
77
+ console.log(` - ${error}`);
78
+ }
79
+ }
80
+ console.log(
81
+ `Summary: ${readyCount} ready, ${needsUpdateCount} need update.`,
82
+ );
57
83
  } else if (result.ok) {
58
84
  console.log(`Release alignment OK for ${result.targetVersion} (${result.scope}, ${result.checkedSurfaceCount} surfaces).`);
59
85
  } else {
@@ -82,6 +82,9 @@ ALLOWED_RELEASE_PATHS=(
82
82
  ".planning/MARKETING/REDDIT_POSTS.md"
83
83
  ".planning/MARKETING/HN_SUBMISSION.md"
84
84
  "website-v2/static/llms.txt"
85
+ "website-v2/docs/getting-started.mdx"
86
+ "website-v2/docs/quickstart.mdx"
87
+ "website-v2/docs/five-minute-tutorial.mdx"
85
88
  "cli/homebrew/agentxchain.rb"
86
89
  "cli/homebrew/README.md"
87
90
  )
@@ -10,11 +10,13 @@ cd "$CLI_DIR"
10
10
 
11
11
  STRICT_MODE=0
12
12
  PUBLISH_GATE=0
13
+ DRY_RUN=0
13
14
  TARGET_VERSION="2.0.0"
14
15
 
15
16
  usage() {
16
- echo "Usage: bash scripts/release-preflight.sh [--strict] [--publish-gate] [--target-version <semver>]" >&2
17
+ echo "Usage: bash scripts/release-preflight.sh [--strict] [--publish-gate] [--dry-run] [--target-version <semver>]" >&2
17
18
  echo " --publish-gate Run only release-critical checks (no full test suite). Use in CI publish workflows." >&2
19
+ echo " --dry-run Preview manual release-alignment surfaces without running the full gate." >&2
18
20
  }
19
21
 
20
22
  while [[ $# -gt 0 ]]; do
@@ -28,6 +30,10 @@ while [[ $# -gt 0 ]]; do
28
30
  STRICT_MODE=1
29
31
  shift
30
32
  ;;
33
+ --dry-run)
34
+ DRY_RUN=1
35
+ shift
36
+ ;;
31
37
  --target-version)
32
38
  if [[ -z "${2:-}" ]]; then
33
39
  echo "Error: --target-version requires a semver argument" >&2
@@ -49,6 +55,12 @@ while [[ $# -gt 0 ]]; do
49
55
  esac
50
56
  done
51
57
 
58
+ if [[ "$DRY_RUN" -eq 1 && "$STRICT_MODE" -eq 1 ]]; then
59
+ echo "Error: --dry-run cannot be combined with --strict or --publish-gate" >&2
60
+ usage
61
+ exit 1
62
+ fi
63
+
52
64
  PASS=0
53
65
  FAIL=0
54
66
  WARN=0
@@ -84,6 +96,31 @@ else
84
96
  fi
85
97
  echo ""
86
98
 
99
+ if [[ "$DRY_RUN" -eq 1 ]]; then
100
+ echo "Release Preflight Preview"
101
+ echo "========================="
102
+ echo "Mode: DRY RUN (manual release-alignment surfaces only; no git/npm gate checks executed)"
103
+ echo ""
104
+ ALIGNMENT_SCRIPT="${SCRIPT_DIR}/check-release-alignment.mjs"
105
+ if [[ ! -f "$ALIGNMENT_SCRIPT" ]]; then
106
+ echo "Error: release alignment preview requires ${ALIGNMENT_SCRIPT}" >&2
107
+ exit 1
108
+ fi
109
+ if run_and_capture ALIGNMENT_REPORT node "$ALIGNMENT_SCRIPT" --scope prebump --target-version "$TARGET_VERSION" --report; then
110
+ ALIGNMENT_STATUS=0
111
+ else
112
+ ALIGNMENT_STATUS=$?
113
+ fi
114
+ printf '%s\n' "$ALIGNMENT_REPORT"
115
+ echo ""
116
+ if [[ "$ALIGNMENT_STATUS" -eq 0 ]]; then
117
+ echo "PREVIEW COMPLETE: manual release-alignment surfaces are ready for ${TARGET_VERSION}."
118
+ else
119
+ echo "PREVIEW COMPLETE: manual release-alignment surfaces still need updates before a real preflight/tag push."
120
+ fi
121
+ exit 0
122
+ fi
123
+
87
124
  # 1. Clean working tree
88
125
  echo "[1/7] Git status"
89
126
  if git diff --quiet HEAD 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
@@ -65,6 +65,9 @@ function printEvent(evt) {
65
65
  const conflictDetail = evt.event_type === 'turn_conflicted'
66
66
  ? ` — ${formatConflictDetail(evt)}`
67
67
  : '';
68
+ const conflictResolvedDetail = evt.event_type === 'conflict_resolved'
69
+ ? ` — ${formatConflictResolvedDetail(evt)}`
70
+ : '';
68
71
  const rejectionDetail = evt.event_type === 'turn_rejected' && evt.payload?.reason
69
72
  ? ` — ${evt.payload.reason}${evt.payload.failed_stage ? ` (${evt.payload.failed_stage})` : ''}`
70
73
  : '';
@@ -82,7 +85,10 @@ function printEvent(evt) {
82
85
  : evt.event_type === 'human_escalation_resolved' && evt.payload?.escalation_id
83
86
  ? ` ${evt.payload.escalation_id} via ${evt.payload.resolved_via || '?'}`
84
87
  : '';
85
- console.log(`${chalk.dim(ts)} ${type} ${chalk.cyan(runId)} ${phase}${turnInfo}${intentInfo}${conflictDetail}${rejectionDetail}${acceptanceFailedDetail}${phaseTransitionDetail}${gateFailedDetail}${humanEscalationDetail}`);
88
+ const coordinatorRetryDetail = evt.event_type === 'coordinator_retry' && evt.payload
89
+ ? ` — ws ${evt.payload.workstream_id || '?'} repo ${evt.payload.repo_id || '?'} (retry of ${evt.payload.failed_turn_id || '?'})`
90
+ : '';
91
+ console.log(`${chalk.dim(ts)} ${type} ${chalk.cyan(runId)} ${phase}${turnInfo}${intentInfo}${conflictDetail}${conflictResolvedDetail}${rejectionDetail}${acceptanceFailedDetail}${phaseTransitionDetail}${gateFailedDetail}${humanEscalationDetail}${coordinatorRetryDetail}`);
86
92
  }
87
93
 
88
94
  function formatConflictDetail(evt) {
@@ -105,6 +111,16 @@ function formatConflictDetail(evt) {
105
111
  return parts.filter(Boolean).join(' | ');
106
112
  }
107
113
 
114
+ function formatConflictResolvedDetail(evt) {
115
+ const payload = evt.payload || {};
116
+ const fileSummary = summarizeList(payload.conflicting_files, 3) || 'resolved conflict';
117
+ const resolvedVia = payload.resolution ? `via ${payload.resolution}` : null;
118
+ const overlapRatio = typeof payload.overlap_ratio === 'number'
119
+ ? `${Math.round(payload.overlap_ratio * 100)}% overlap`
120
+ : null;
121
+ return [fileSummary, resolvedVia, overlapRatio].filter(Boolean).join(' | ');
122
+ }
123
+
108
124
  function summarizeList(items, limit) {
109
125
  if (!Array.isArray(items) || items.length === 0) return '';
110
126
  const shown = items.slice(0, limit).join(', ');
@@ -123,6 +139,7 @@ function colorEventType(type) {
123
139
  acceptance_failed: chalk.red.bold,
124
140
  turn_reissued: chalk.cyan,
125
141
  turn_conflicted: chalk.redBright,
142
+ conflict_resolved: chalk.greenBright,
126
143
  phase_entered: chalk.magenta,
127
144
  escalation_raised: chalk.red.bold,
128
145
  escalation_resolved: chalk.green,
@@ -132,6 +149,9 @@ function colorEventType(type) {
132
149
  gate_approved: chalk.green,
133
150
  gate_failed: chalk.red,
134
151
  budget_exceeded_warn: chalk.yellowBright,
152
+ coordinator_retry: chalk.cyan.bold,
153
+ turn_checkpointed: chalk.green,
154
+ dispatch_progress: chalk.blue.dim,
135
155
  };
136
156
  const colorFn = colors[type] || chalk.white;
137
157
  return colorFn(pad(type, 22));
@@ -22,6 +22,7 @@ import {
22
22
  getWorkstreamStatusSummary,
23
23
  launchCoordinatorWorkstream,
24
24
  launchWorkstream,
25
+ retryCoordinatorWorkstream,
25
26
  retryWorkstream,
26
27
  markWorkstreamOutcome,
27
28
  loadAllPlans,
@@ -541,11 +542,6 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
541
542
  }
542
543
 
543
544
  if (mission.coordinator && plan.coordinator_scope) {
544
- if (opts.retry) {
545
- console.error(chalk.red('--retry is not supported for coordinator-bound mission plans yet.'));
546
- process.exit(1);
547
- }
548
-
549
545
  const coordinatorConfigResult = loadCoordinatorConfig(mission.coordinator.workspace_path);
550
546
  if (!coordinatorConfigResult.ok) {
551
547
  console.error(chalk.red('Coordinator config validation failed:'));
@@ -565,6 +561,113 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
565
561
  process.exit(1);
566
562
  }
567
563
 
564
+ if (opts.retry) {
565
+ const retry = retryCoordinatorWorkstream(
566
+ root,
567
+ mission,
568
+ plan.plan_id,
569
+ opts.workstream,
570
+ coordinatorConfigResult.config,
571
+ {
572
+ reason: `mission plan retry ${opts.workstream}`,
573
+ },
574
+ );
575
+ if (!retry.ok) {
576
+ console.error(chalk.red(retry.error));
577
+ process.exit(1);
578
+ }
579
+
580
+ const executor = opts._executeGovernedRun || executeGovernedRun;
581
+ const repoContext = loadProjectContext(retry.retryResult.repo_path);
582
+ if (!repoContext) {
583
+ console.error(chalk.red(`Cannot load project context for retried repo at ${retry.retryResult.repo_path}.`));
584
+ process.exit(1);
585
+ }
586
+
587
+ const runOpts = {
588
+ autoApprove: !!opts.autoApprove,
589
+ provenance: {
590
+ trigger: 'manual',
591
+ created_by: 'operator',
592
+ trigger_reason: `mission:${mission.mission_id} workstream:${opts.workstream} coordinator-retry:${retry.retryResult.repo_id}`,
593
+ },
594
+ };
595
+
596
+ let execution;
597
+ try {
598
+ execution = await executor(repoContext, runOpts);
599
+ } catch (error) {
600
+ const syncedRetryFailure = synchronizeCoordinatorPlanState(root, mission, retry.plan);
601
+ console.error(chalk.red(`Coordinator retry execution failed: ${error.message}`));
602
+ if (opts.json) {
603
+ console.log(JSON.stringify({
604
+ dispatch_mode: 'coordinator',
605
+ retry: true,
606
+ mission_id: mission.mission_id,
607
+ plan_id: retry.plan.plan_id,
608
+ workstream_id: opts.workstream,
609
+ repo_id: retry.retryResult.repo_id,
610
+ retried_repo_turn_id: retry.retryResult.failed_turn_id,
611
+ repo_turn_id: retry.retryResult.reissued_turn_id,
612
+ workstream_status: syncedRetryFailure.ok
613
+ ? syncedRetryFailure.plan.workstreams.find((ws) => ws.workstream_id === opts.workstream)?.launch_status || 'needs_attention'
614
+ : 'needs_attention',
615
+ launch_record: retry.launchRecord,
616
+ error: error.message,
617
+ }, null, 2));
618
+ }
619
+ process.exit(1);
620
+ }
621
+
622
+ const syncedRetry = synchronizeCoordinatorPlanState(root, mission, retry.plan);
623
+ const retriedPlan = syncedRetry.ok ? syncedRetry.plan : retry.plan;
624
+ const retriedWorkstream = retriedPlan.workstreams.find((ws) => ws.workstream_id === opts.workstream);
625
+ const retriedLaunchRecord = retriedPlan.launch_records.find(
626
+ (record) => record.workstream_id === opts.workstream && record.dispatch_mode === 'coordinator',
627
+ ) || retry.launchRecord;
628
+
629
+ if (opts.json) {
630
+ console.log(JSON.stringify({
631
+ dispatch_mode: 'coordinator',
632
+ retry: true,
633
+ mission_id: mission.mission_id,
634
+ plan_id: retriedPlan.plan_id,
635
+ workstream_id: opts.workstream,
636
+ super_run_id: mission.coordinator.super_run_id,
637
+ repo_id: retry.retryResult.repo_id,
638
+ retried_repo_turn_id: retry.retryResult.failed_turn_id,
639
+ repo_turn_id: retry.retryResult.reissued_turn_id,
640
+ role: retry.retryResult.role,
641
+ bundle_path: retry.retryResult.bundle_path,
642
+ context_ref: retry.retryResult.context_ref,
643
+ workstream_status: retriedWorkstream?.launch_status || 'launched',
644
+ launch_record: retriedLaunchRecord,
645
+ exit_code: execution?.exitCode ?? 0,
646
+ }, null, 2));
647
+ if ((execution?.exitCode ?? 0) !== 0) {
648
+ process.exit(execution.exitCode);
649
+ }
650
+ return;
651
+ }
652
+
653
+ console.log(chalk.green(`Retried coordinator workstream ${chalk.bold(opts.workstream)} in ${chalk.bold(retry.retryResult.repo_id)}`));
654
+ console.log('');
655
+ console.log(chalk.dim(` Mission: ${mission.mission_id}`));
656
+ console.log(chalk.dim(` Plan: ${retriedPlan.plan_id}`));
657
+ console.log(chalk.dim(` Super Run: ${mission.coordinator.super_run_id}`));
658
+ console.log(chalk.dim(` Repo: ${retry.retryResult.repo_id}`));
659
+ console.log(chalk.dim(` Old Turn: ${retry.retryResult.failed_turn_id}`));
660
+ console.log(chalk.dim(` New Turn: ${retry.retryResult.reissued_turn_id}`));
661
+ console.log(chalk.dim(` Workstream: ${retriedWorkstream?.launch_status || 'launched'}`));
662
+ console.log('');
663
+ renderPlan(retriedPlan);
664
+ if ((execution?.exitCode ?? 0) !== 0) {
665
+ console.error(chalk.red(`Coordinator retry execution ended with exit code ${execution.exitCode}.`));
666
+ process.exit(execution.exitCode);
667
+ }
668
+ return;
669
+ }
670
+
568
671
  const assignment = selectAssignmentForWorkstream(
569
672
  mission.coordinator.workspace_path,
570
673
  coordinatorState,
@@ -85,6 +85,17 @@ function buildPlanSummary(plan) {
85
85
  completed_at: lr.completed_at || null,
86
86
  status: lr.status,
87
87
  terminal_reason: lr.terminal_reason || null,
88
+ dispatch_mode: lr.dispatch_mode || null,
89
+ ...(lr.dispatch_mode === 'coordinator' && Array.isArray(lr.repo_dispatches) ? {
90
+ repo_dispatches: lr.repo_dispatches.map((rd) => ({
91
+ repo_id: rd.repo_id,
92
+ repo_turn_id: rd.repo_turn_id,
93
+ role: rd.role,
94
+ dispatched_at: rd.dispatched_at,
95
+ ...(rd.is_retry ? { is_retry: true, retry_of: rd.retry_of } : {}),
96
+ ...(rd.retried_at ? { retried_at: rd.retried_at, retry_reason: rd.retry_reason } : {}),
97
+ })),
98
+ } : {}),
88
99
  })),
89
100
  };
90
101
  }
@@ -1088,15 +1088,63 @@ function cleanupTurnArtifacts(root, turnId) {
1088
1088
  } catch { /* best-effort */ }
1089
1089
  }
1090
1090
 
1091
- function detectAcceptanceConflict(targetTurn, conflictFiles, historyEntries) {
1091
+ function getWorkflowArtifactOwners(config, filePath) {
1092
+ const owners = new Set();
1093
+ const phases = config?.workflow_kit?.phases;
1094
+ if (!phases || typeof phases !== 'object') {
1095
+ return owners;
1096
+ }
1097
+
1098
+ for (const phaseConfig of Object.values(phases)) {
1099
+ const artifacts = Array.isArray(phaseConfig?.artifacts) ? phaseConfig.artifacts : [];
1100
+ for (const artifact of artifacts) {
1101
+ if (artifact?.path !== filePath) {
1102
+ continue;
1103
+ }
1104
+ if (typeof artifact.owned_by === 'string' && artifact.owned_by.length > 0) {
1105
+ owners.add(artifact.owned_by);
1106
+ }
1107
+ }
1108
+ }
1109
+
1110
+ return owners;
1111
+ }
1112
+
1113
+ function isForwardRevisionFile(targetTurn, historyEntry, filePath, config) {
1114
+ if (!targetTurn || !historyEntry) {
1115
+ return false;
1116
+ }
1117
+ if (historyEntry.role !== targetTurn.assigned_role) {
1118
+ return false;
1119
+ }
1120
+
1121
+ const explicitOwners = getWorkflowArtifactOwners(config, filePath);
1122
+ if (explicitOwners.size > 0) {
1123
+ return explicitOwners.size === 1 && explicitOwners.has(targetTurn.assigned_role);
1124
+ }
1125
+
1126
+ if (!filePath.startsWith('.planning/')) {
1127
+ return false;
1128
+ }
1129
+
1130
+ return config?.routing?.planning?.entry_role === targetTurn.assigned_role;
1131
+ }
1132
+
1133
+ function classifyAcceptanceOverlap(targetTurn, conflictFiles, historyEntries, config) {
1092
1134
  const observedFiles = [...new Set(Array.isArray(conflictFiles) ? conflictFiles : [])];
1093
1135
  if (observedFiles.length === 0) {
1094
- return null;
1136
+ return {
1137
+ conflict: null,
1138
+ forward_revision_files: [],
1139
+ forward_revision_turns: [],
1140
+ };
1095
1141
  }
1096
1142
 
1097
1143
  const observedFileSet = new Set(observedFiles);
1098
1144
  const acceptedSince = [];
1099
1145
  const conflictingFiles = new Set();
1146
+ const forwardRevisionFiles = new Set();
1147
+ const forwardRevisionTurns = new Map();
1100
1148
 
1101
1149
  for (const entry of historyEntries) {
1102
1150
  if ((entry.accepted_sequence || 0) <= (targetTurn.assigned_sequence || 0)) {
@@ -1108,35 +1156,77 @@ function detectAcceptanceConflict(targetTurn, conflictFiles, historyEntries) {
1108
1156
  continue;
1109
1157
  }
1110
1158
 
1111
- overlap.forEach(file => conflictingFiles.add(file));
1112
- acceptedSince.push({
1113
- turn_id: entry.turn_id,
1114
- role: entry.role,
1115
- accepted_sequence: entry.accepted_sequence,
1116
- files_changed: overlap,
1117
- });
1159
+ const destructiveFiles = [];
1160
+ const forwardFiles = [];
1161
+ for (const file of overlap) {
1162
+ if (isForwardRevisionFile(targetTurn, entry, file, config)) {
1163
+ forwardFiles.push(file);
1164
+ } else {
1165
+ destructiveFiles.push(file);
1166
+ }
1167
+ }
1168
+
1169
+ if (destructiveFiles.length > 0) {
1170
+ destructiveFiles.forEach(file => conflictingFiles.add(file));
1171
+ acceptedSince.push({
1172
+ turn_id: entry.turn_id,
1173
+ role: entry.role,
1174
+ accepted_sequence: entry.accepted_sequence,
1175
+ files_changed: destructiveFiles,
1176
+ });
1177
+ }
1178
+
1179
+ if (forwardFiles.length > 0) {
1180
+ forwardFiles.forEach(file => forwardRevisionFiles.add(file));
1181
+ const existing = forwardRevisionTurns.get(entry.turn_id) || {
1182
+ turn_id: entry.turn_id,
1183
+ role: entry.role,
1184
+ accepted_sequence: entry.accepted_sequence,
1185
+ files_changed: [],
1186
+ };
1187
+ existing.files_changed = [...new Set([...existing.files_changed, ...forwardFiles])];
1188
+ forwardRevisionTurns.set(entry.turn_id, existing);
1189
+ }
1118
1190
  }
1119
1191
 
1192
+ const forwardRevisionContext = {
1193
+ files: [...forwardRevisionFiles],
1194
+ accepted_since_turn_ids: [...forwardRevisionTurns.values()].map((entry) => entry.turn_id),
1195
+ accepted_since: [...forwardRevisionTurns.values()],
1196
+ };
1197
+
1120
1198
  if (acceptedSince.length === 0) {
1121
- return null;
1199
+ return {
1200
+ conflict: null,
1201
+ forward_revision_files: forwardRevisionContext.files,
1202
+ forward_revision_turns: forwardRevisionContext.accepted_since,
1203
+ };
1122
1204
  }
1123
1205
 
1124
1206
  const conflicting = [...conflictingFiles];
1125
1207
  const overlapRatio = observedFiles.length > 0 ? conflicting.length / observedFiles.length : 0;
1126
1208
 
1127
1209
  return {
1128
- type: 'file_conflict',
1129
- conflicting_turn: {
1130
- turn_id: targetTurn.turn_id,
1131
- role: targetTurn.assigned_role,
1132
- attempt: targetTurn.attempt,
1133
- files_changed: observedFiles,
1210
+ conflict: {
1211
+ type: 'file_conflict',
1212
+ conflicting_turn: {
1213
+ turn_id: targetTurn.turn_id,
1214
+ role: targetTurn.assigned_role,
1215
+ attempt: targetTurn.attempt,
1216
+ files_changed: observedFiles,
1217
+ },
1218
+ accepted_since: acceptedSince,
1219
+ conflicting_files: conflicting,
1220
+ non_conflicting_files: observedFiles.filter(
1221
+ (file) => !conflictingFiles.has(file) && !forwardRevisionFiles.has(file),
1222
+ ),
1223
+ forward_revision_files: forwardRevisionContext.files,
1224
+ forward_revision_turns: forwardRevisionContext.accepted_since,
1225
+ overlap_ratio: overlapRatio,
1226
+ suggested_resolution: overlapRatio < 0.5 ? 'reject_and_reassign' : 'human_merge',
1134
1227
  },
1135
- accepted_since: acceptedSince,
1136
- conflicting_files: conflicting,
1137
- non_conflicting_files: observedFiles.filter(file => !conflictingFiles.has(file)),
1138
- overlap_ratio: overlapRatio,
1139
- suggested_resolution: overlapRatio < 0.5 ? 'reject_and_reassign' : 'human_merge',
1228
+ forward_revision_files: forwardRevisionContext.files,
1229
+ forward_revision_turns: forwardRevisionContext.accepted_since,
1140
1230
  };
1141
1231
  }
1142
1232
 
@@ -2749,40 +2839,6 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2749
2839
  error_code: 'protocol_error',
2750
2840
  };
2751
2841
  }
2752
-
2753
- if (currentTurn.conflict_state.status !== 'human_merging') {
2754
- appendJsonl(root, LEDGER_PATH, {
2755
- timestamp: new Date().toISOString(),
2756
- decision: 'conflict_resolution_selected',
2757
- turn_id: currentTurn.turn_id,
2758
- attempt: currentTurn.attempt,
2759
- role: currentTurn.assigned_role,
2760
- phase: state.phase,
2761
- conflict: {
2762
- conflicting_files: currentTurn.conflict_state.conflict_error?.conflicting_files || [],
2763
- accepted_since_turn_ids: (currentTurn.conflict_state.conflict_error?.accepted_since || []).map((entry) => entry.turn_id),
2764
- overlap_ratio: currentTurn.conflict_state.conflict_error?.overlap_ratio ?? 0,
2765
- },
2766
- resolution_chosen: 'human_merge',
2767
- });
2768
-
2769
- state = {
2770
- ...state,
2771
- active_turns: {
2772
- ...getActiveTurns(state),
2773
- [currentTurn.turn_id]: {
2774
- ...currentTurn,
2775
- status: 'conflicted',
2776
- conflict_state: {
2777
- ...currentTurn.conflict_state,
2778
- status: 'human_merging',
2779
- },
2780
- },
2781
- },
2782
- };
2783
- writeState(root, state);
2784
- currentTurn = state.active_turns[currentTurn.turn_id];
2785
- }
2786
2842
  }
2787
2843
 
2788
2844
  const turnStagingPath = getTurnStagingResultPath(currentTurn.turn_id);
@@ -3142,11 +3198,29 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3142
3198
  };
3143
3199
  }
3144
3200
 
3145
- const conflict = detectAcceptanceConflict(
3201
+ const overlapClassification = classifyAcceptanceOverlap(
3146
3202
  currentTurn,
3147
3203
  buildConflictCandidateFiles(rawObservation, observation, turnResult.files_changed || []),
3148
3204
  historyEntries,
3205
+ config,
3149
3206
  );
3207
+ const forwardRevision = overlapClassification.forward_revision_files.length > 0
3208
+ ? {
3209
+ files: overlapClassification.forward_revision_files,
3210
+ accepted_since_turn_ids: overlapClassification.forward_revision_turns.map((entry) => entry.turn_id),
3211
+ accepted_since: overlapClassification.forward_revision_turns,
3212
+ }
3213
+ : null;
3214
+ const conflict = resolutionMode === 'human_merge' ? null : overlapClassification.conflict;
3215
+ const conflictResolution = resolutionMode === 'human_merge'
3216
+ ? {
3217
+ mode: 'human_merge',
3218
+ merge_strategy: 'operator_authoritative_staged_result',
3219
+ conflicting_files: currentTurn.conflict_state?.conflict_error?.conflicting_files || [],
3220
+ accepted_since_turn_ids: (currentTurn.conflict_state?.conflict_error?.accepted_since || []).map((entry) => entry.turn_id),
3221
+ overlap_ratio: currentTurn.conflict_state?.conflict_error?.overlap_ratio ?? 0,
3222
+ }
3223
+ : null;
3150
3224
 
3151
3225
  if (conflict) {
3152
3226
  const detectionCount = (currentTurn.conflict_state?.detection_count || 0) + 1;
@@ -3301,6 +3375,8 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3301
3375
  assigned_sequence: Number.isInteger(currentTurn.assigned_sequence) ? currentTurn.assigned_sequence : acceptedSequence,
3302
3376
  accepted_sequence: acceptedSequence,
3303
3377
  concurrent_with: Array.isArray(currentTurn.concurrent_with) ? currentTurn.concurrent_with : [],
3378
+ ...(forwardRevision ? { forward_revision: forwardRevision } : {}),
3379
+ ...(conflictResolution ? { conflict_resolution: conflictResolution } : {}),
3304
3380
  cost: turnResult.cost || {},
3305
3381
  ...(currentTurn.started_at ? { started_at: currentTurn.started_at } : {}),
3306
3382
  accepted_at: now,
@@ -3357,6 +3433,49 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3357
3433
  });
3358
3434
  }
3359
3435
  }
3436
+ if (forwardRevision) {
3437
+ ledgerEntries.push({
3438
+ timestamp: now,
3439
+ decision: 'forward_revision_accepted',
3440
+ turn_id: currentTurn.turn_id,
3441
+ attempt: currentTurn.attempt,
3442
+ role: currentTurn.assigned_role,
3443
+ phase: state.phase,
3444
+ forward_revision: {
3445
+ files: forwardRevision.files,
3446
+ accepted_since_turn_ids: forwardRevision.accepted_since_turn_ids,
3447
+ },
3448
+ });
3449
+ }
3450
+ if (conflictResolution) {
3451
+ const conflictSummary = {
3452
+ conflicting_files: conflictResolution.conflicting_files,
3453
+ accepted_since_turn_ids: conflictResolution.accepted_since_turn_ids,
3454
+ overlap_ratio: conflictResolution.overlap_ratio,
3455
+ };
3456
+ ledgerEntries.push({
3457
+ timestamp: now,
3458
+ decision: 'conflict_resolution_selected',
3459
+ turn_id: currentTurn.turn_id,
3460
+ attempt: currentTurn.attempt,
3461
+ role: currentTurn.assigned_role,
3462
+ phase: state.phase,
3463
+ conflict: conflictSummary,
3464
+ resolution_chosen: conflictResolution.mode,
3465
+ merge_strategy: conflictResolution.merge_strategy,
3466
+ });
3467
+ ledgerEntries.push({
3468
+ timestamp: now,
3469
+ decision: 'conflict_resolved',
3470
+ turn_id: currentTurn.turn_id,
3471
+ attempt: currentTurn.attempt,
3472
+ role: currentTurn.assigned_role,
3473
+ phase: state.phase,
3474
+ conflict: conflictSummary,
3475
+ resolution_chosen: conflictResolution.mode,
3476
+ merge_strategy: conflictResolution.merge_strategy,
3477
+ });
3478
+ }
3360
3479
 
3361
3480
  const turnNumber = turnResult.turn_id.replace(/^turn_/, '').slice(0, 8);
3362
3481
  const talkSection = `## Turn ${turnNumber} — ${turnResult.role} (${state.phase})\n\n- **Status:** ${turnResult.status}\n- **Summary:** ${turnResult.summary}\n${turnResult.decisions?.length ? turnResult.decisions.map(d => `- **Decision ${d.id}:** ${d.statement}`).join('\n') + '\n' : ''}${turnResult.objections?.length ? turnResult.objections.map(o => `- **Objection ${o.id} (${o.severity}):** ${o.statement}`).join('\n') + '\n' : ''}- **Proposed next:** ${turnResult.proposed_next_role || 'human'}\n\n---\n`;
@@ -4107,6 +4226,22 @@ function _acceptGovernedTurnLocked(root, config, opts) {
4107
4226
  intent_id: currentTurn.intake_context?.intent_id || null,
4108
4227
  payload: turnAcceptedPayload,
4109
4228
  });
4229
+ if (conflictResolution) {
4230
+ emitRunEvent(root, 'conflict_resolved', {
4231
+ run_id: updatedState.run_id,
4232
+ phase: updatedState.phase,
4233
+ status: updatedState.status,
4234
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4235
+ intent_id: currentTurn.intake_context?.intent_id || null,
4236
+ payload: {
4237
+ resolution: conflictResolution.mode,
4238
+ merge_strategy: conflictResolution.merge_strategy,
4239
+ conflicting_files: conflictResolution.conflicting_files,
4240
+ accepted_since_turn_ids: conflictResolution.accepted_since_turn_ids,
4241
+ overlap_ratio: conflictResolution.overlap_ratio,
4242
+ },
4243
+ });
4244
+ }
4110
4245
 
4111
4246
  if (updatedState.status === 'blocked') {
4112
4247
  // DEC-RHTR-SPEC: Record blocked outcome in cross-run history (non-fatal)
@@ -6,11 +6,15 @@
6
6
  * Plans are NOT protocol-normative.
7
7
  */
8
8
 
9
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
9
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
10
10
  import { randomUUID } from 'crypto';
11
11
  import { join } from 'path';
12
12
  import { loadChainReport } from './chain-reports.js';
13
- import { readBarriers, readCoordinatorHistory } from './coordinator-state.js';
13
+ import { writeDispatchBundle } from './dispatch-bundle.js';
14
+ import { loadProjectContext, loadProjectState } from './config.js';
15
+ import { reissueTurn } from './governed-state.js';
16
+ import { emitRunEvent } from './run-events.js';
17
+ import { readBarriers, readCoordinatorHistory, recordCoordinatorDecision } from './coordinator-state.js';
14
18
  import { loadCoordinatorConfig } from './coordinator-config.js';
15
19
 
16
20
  // ── Plan artifact directory ──────────────────────────────────────────────────
@@ -486,6 +490,7 @@ function isAcceptedRepoHistoryEntry(entry) {
486
490
  }
487
491
 
488
492
  const REPO_FAILURE_STATUSES = new Set(['failed_acceptance', 'failed', 'rejected', 'retrying', 'conflicted']);
493
+ const RETRYABLE_COORDINATOR_FAILURE_STATUSES = new Set(['failed', 'failed_acceptance']);
489
494
 
490
495
  function getLatestRepoDispatches(launchRecord) {
491
496
  const latestByRepo = new Map();
@@ -554,6 +559,76 @@ function buildCoordinatorRepoFailures(coordinatorConfig, launchRecord) {
554
559
  return failures;
555
560
  }
556
561
 
562
+ function appendCoordinatorHistoryEntry(workspacePath, entry) {
563
+ const historyPath = join(workspacePath, '.agentxchain', 'multirepo', 'history.jsonl');
564
+ mkdirSync(join(workspacePath, '.agentxchain', 'multirepo'), { recursive: true });
565
+ appendFileSync(historyPath, `${JSON.stringify(entry)}\n`);
566
+ }
567
+
568
+ function findDependentDispatchAfter(plan, workstreamId, timestamp) {
569
+ const since = new Date(timestamp || 0).getTime();
570
+ if (Number.isNaN(since)) return null;
571
+
572
+ for (const candidate of plan.workstreams || []) {
573
+ if (!Array.isArray(candidate.depends_on) || !candidate.depends_on.includes(workstreamId)) {
574
+ continue;
575
+ }
576
+
577
+ for (const record of plan.launch_records || []) {
578
+ if (record?.workstream_id !== candidate.workstream_id) {
579
+ continue;
580
+ }
581
+
582
+ if (record.dispatch_mode === 'coordinator') {
583
+ for (const dispatch of record.repo_dispatches || []) {
584
+ const dispatchedAt = new Date(dispatch?.dispatched_at || 0).getTime();
585
+ if (!Number.isNaN(dispatchedAt) && dispatchedAt > since) {
586
+ return {
587
+ workstream_id: candidate.workstream_id,
588
+ repo_id: dispatch.repo_id || null,
589
+ dispatched_at: dispatch.dispatched_at || null,
590
+ };
591
+ }
592
+ }
593
+ continue;
594
+ }
595
+
596
+ const launchedAt = new Date(record.launched_at || 0).getTime();
597
+ if (!Number.isNaN(launchedAt) && launchedAt > since) {
598
+ return {
599
+ workstream_id: candidate.workstream_id,
600
+ repo_id: null,
601
+ dispatched_at: record.launched_at || null,
602
+ };
603
+ }
604
+ }
605
+ }
606
+
607
+ return null;
608
+ }
609
+
610
+ function loadRepoTurnForRetry(repoPath, repoTurnId) {
611
+ const context = loadProjectContext(repoPath);
612
+ if (!context) {
613
+ return { ok: false, error: `Repo at "${repoPath}" is not a loadable governed project.` };
614
+ }
615
+
616
+ const state = loadProjectState(context.root, context.config);
617
+ if (!state) {
618
+ return { ok: false, error: `Repo at "${repoPath}" has no governed state.` };
619
+ }
620
+
621
+ const activeTurn = state.active_turns?.[repoTurnId] || null;
622
+ if (!activeTurn) {
623
+ return {
624
+ ok: false,
625
+ error: `Repo turn "${repoTurnId}" is no longer active. Use repo-local recovery instead of coordinator --retry.`,
626
+ };
627
+ }
628
+
629
+ return { ok: true, root: context.root, config: context.config, state, activeTurn };
630
+ }
631
+
557
632
  function buildCoordinatorWorkstreamProgress(coordinatorConfig, history, barriers, workstreamId) {
558
633
  const coordinatorWorkstream = coordinatorConfig?.workstreams?.[workstreamId];
559
634
  if (!coordinatorWorkstream) {
@@ -904,6 +979,188 @@ export function launchCoordinatorWorkstream(root, mission, planId, workstreamId,
904
979
  return { ok: true, plan: synced.ok ? synced.plan : plan, workstream: ws, launchRecord };
905
980
  }
906
981
 
982
+ export function retryCoordinatorWorkstream(root, mission, planId, workstreamId, coordinatorConfig, options = {}) {
983
+ const plan = loadPlan(root, mission.mission_id, planId);
984
+ if (!plan) {
985
+ return { ok: false, error: `Plan not found: ${planId}` };
986
+ }
987
+ if (!mission?.coordinator?.workspace_path || !mission?.coordinator?.super_run_id) {
988
+ return { ok: false, error: 'Mission is not bound to a coordinator run.' };
989
+ }
990
+
991
+ const synced = synchronizeCoordinatorPlanState(root, mission, plan);
992
+ const workingPlan = synced.ok ? synced.plan : plan;
993
+ const ws = workingPlan.workstreams.find((candidate) => candidate.workstream_id === workstreamId);
994
+ if (!ws) {
995
+ return { ok: false, error: `Workstream not found: ${workstreamId}` };
996
+ }
997
+ if (ws.launch_status !== 'needs_attention') {
998
+ return {
999
+ ok: false,
1000
+ error: `Workstream ${workstreamId} is not in needs_attention state. Nothing to retry.`,
1001
+ };
1002
+ }
1003
+
1004
+ const launchRecord = getLatestCoordinatorLaunchRecord(workingPlan, workstreamId);
1005
+ if (!launchRecord) {
1006
+ return { ok: false, error: `No coordinator launch record found for workstream ${workstreamId}.` };
1007
+ }
1008
+
1009
+ const latestDispatchesByRepo = new Map(
1010
+ getLatestRepoDispatches(launchRecord).map((dispatch) => [dispatch.repo_id, dispatch]),
1011
+ );
1012
+ const repoFailures = buildCoordinatorRepoFailures(coordinatorConfig, launchRecord);
1013
+ launchRecord.repo_failures = repoFailures;
1014
+
1015
+ if (repoFailures.length === 0) {
1016
+ return { ok: false, error: `Workstream ${workstreamId} has no retryable repo failures.` };
1017
+ }
1018
+
1019
+ const retryableFailures = [];
1020
+ const blockedFailures = [];
1021
+
1022
+ for (const failure of repoFailures) {
1023
+ if (!RETRYABLE_COORDINATOR_FAILURE_STATUSES.has(failure.failure_status)) {
1024
+ blockedFailures.push(`${failure.repo_id} (${failure.failure_status})`);
1025
+ continue;
1026
+ }
1027
+ retryableFailures.push(failure);
1028
+ }
1029
+
1030
+ if (retryableFailures.length === 0) {
1031
+ return {
1032
+ ok: false,
1033
+ error: `No retryable repo failures in workstream ${workstreamId}. Manual intervention required: ${blockedFailures.join(', ')}`,
1034
+ };
1035
+ }
1036
+
1037
+ if (retryableFailures.length > 1) {
1038
+ return {
1039
+ ok: false,
1040
+ error: `Workstream ${workstreamId} has multiple retryable repo failures. Recover them repo-locally one at a time before relaunching the coordinator workstream.`,
1041
+ };
1042
+ }
1043
+
1044
+ const failure = retryableFailures[0];
1045
+ const failedDispatch = latestDispatchesByRepo.get(failure.repo_id);
1046
+ if (!failedDispatch) {
1047
+ return { ok: false, error: `Missing launch metadata for failed repo ${failure.repo_id}.` };
1048
+ }
1049
+
1050
+ const downstreamBlocker = findDependentDispatchAfter(workingPlan, workstreamId, failedDispatch.dispatched_at);
1051
+ if (downstreamBlocker) {
1052
+ return {
1053
+ ok: false,
1054
+ error: `Cannot retry workstream ${workstreamId}: dependent workstream "${downstreamBlocker.workstream_id}" has already dispatched since the failed repo turn.`,
1055
+ };
1056
+ }
1057
+
1058
+ const repoPath = coordinatorConfig?.repos?.[failure.repo_id]?.resolved_path;
1059
+ if (!repoPath) {
1060
+ return { ok: false, error: `Coordinator config has no resolved_path for repo "${failure.repo_id}".` };
1061
+ }
1062
+
1063
+ const repoTurn = loadRepoTurnForRetry(repoPath, failure.repo_turn_id);
1064
+ if (!repoTurn.ok) {
1065
+ return repoTurn;
1066
+ }
1067
+
1068
+ const reason = options.reason || `coordinator retry: ${workstreamId}/${failure.repo_id}`;
1069
+ const reissued = reissueTurn(repoTurn.root, repoTurn.config, {
1070
+ turnId: failure.repo_turn_id,
1071
+ reason,
1072
+ });
1073
+ if (!reissued.ok) {
1074
+ return { ok: false, error: reissued.error };
1075
+ }
1076
+
1077
+ const bundleResult = writeDispatchBundle(repoTurn.root, reissued.state, repoTurn.config, {
1078
+ turnId: reissued.newTurn.turn_id,
1079
+ });
1080
+ if (!bundleResult.ok) {
1081
+ return { ok: false, error: `Turn reissued but dispatch bundle failed: ${bundleResult.error}` };
1082
+ }
1083
+
1084
+ const now = new Date().toISOString();
1085
+ failedDispatch.retried_at = now;
1086
+ failedDispatch.retry_reason = failure.failure_status;
1087
+ launchRecord.repo_dispatches.push({
1088
+ repo_id: failure.repo_id,
1089
+ repo_turn_id: reissued.newTurn.turn_id,
1090
+ role: reissued.newTurn.assigned_role,
1091
+ dispatched_at: now,
1092
+ bundle_path: bundleResult.bundlePath,
1093
+ context_ref: failedDispatch.context_ref || null,
1094
+ is_retry: true,
1095
+ retry_of: failure.repo_turn_id,
1096
+ });
1097
+ launchRecord.status = 'launched';
1098
+ launchRecord.repo_failures = [];
1099
+ ws.launch_status = 'launched';
1100
+
1101
+ const otherNeedsAttention = (workingPlan.workstreams || []).some(
1102
+ (candidate) => candidate.workstream_id !== workstreamId && candidate.launch_status === 'needs_attention',
1103
+ );
1104
+ workingPlan.status = otherNeedsAttention ? 'needs_attention' : 'approved';
1105
+ workingPlan.updated_at = now;
1106
+ writePlanArtifact(root, mission.mission_id, workingPlan);
1107
+
1108
+ appendCoordinatorHistoryEntry(mission.coordinator.workspace_path, {
1109
+ type: 'coordinator_retry',
1110
+ timestamp: now,
1111
+ super_run_id: mission.coordinator.super_run_id,
1112
+ workstream_id: workstreamId,
1113
+ repo_id: failure.repo_id,
1114
+ failed_turn_id: failure.repo_turn_id,
1115
+ reissued_turn_id: reissued.newTurn.turn_id,
1116
+ retry_reason: failure.failure_status,
1117
+ });
1118
+ recordCoordinatorDecision(mission.coordinator.workspace_path, {
1119
+ super_run_id: mission.coordinator.super_run_id,
1120
+ phase: coordinatorConfig?.workstreams?.[workstreamId]?.phase || null,
1121
+ }, {
1122
+ category: 'retry',
1123
+ statement: `Retried ${failure.repo_id} for workstream ${workstreamId}`,
1124
+ repo_id: failure.repo_id,
1125
+ repo_turn_id: reissued.newTurn.turn_id,
1126
+ workstream_id: workstreamId,
1127
+ reason: failure.failure_status,
1128
+ context_ref: failedDispatch.context_ref || null,
1129
+ });
1130
+ emitRunEvent(mission.coordinator.workspace_path, 'coordinator_retry', {
1131
+ run_id: mission.coordinator.super_run_id,
1132
+ phase: coordinatorConfig?.workstreams?.[workstreamId]?.phase || null,
1133
+ status: 'active',
1134
+ turn: { turn_id: reissued.newTurn.turn_id, role_id: reissued.newTurn.assigned_role },
1135
+ payload: {
1136
+ workstream_id: workstreamId,
1137
+ repo_id: failure.repo_id,
1138
+ failed_turn_id: failure.repo_turn_id,
1139
+ reissued_turn_id: reissued.newTurn.turn_id,
1140
+ retry_reason: failure.failure_status,
1141
+ retry_count: launchRecord.repo_dispatches.filter((dispatch) => dispatch.repo_id === failure.repo_id && dispatch.is_retry).length,
1142
+ },
1143
+ });
1144
+
1145
+ const afterRetry = synchronizeCoordinatorPlanState(root, mission, workingPlan);
1146
+ return {
1147
+ ok: true,
1148
+ plan: afterRetry.ok ? afterRetry.plan : workingPlan,
1149
+ workstream: ws,
1150
+ launchRecord,
1151
+ retryResult: {
1152
+ repo_id: failure.repo_id,
1153
+ failed_turn_id: failure.repo_turn_id,
1154
+ reissued_turn_id: reissued.newTurn.turn_id,
1155
+ role: reissued.newTurn.assigned_role,
1156
+ repo_path: repoTurn.root,
1157
+ bundle_path: bundleResult.bundlePath,
1158
+ context_ref: failedDispatch.context_ref || null,
1159
+ retry_reason: failure.failure_status,
1160
+ },
1161
+ };
1162
+ }
1163
+
907
1164
  /**
908
1165
  * Record the outcome of a launched workstream after its chain completes.
909
1166
  *
@@ -36,6 +36,21 @@ function describeEvent(eventType, entry) {
36
36
  case 'gate_approved':
37
37
  case 'gate_failed':
38
38
  return `${prefix}${eventType}${gateId ? ` (${gateId})` : ''}`;
39
+ case 'turn_conflicted':
40
+ return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
41
+ case 'conflict_resolved': {
42
+ const resolution = trimToNull(entry.payload?.resolution);
43
+ return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}${resolution ? ` via ${resolution}` : ''}`;
44
+ }
45
+ case 'coordinator_retry': {
46
+ const wsId = trimToNull(entry.payload?.workstream_id);
47
+ const retryRepo = trimToNull(entry.payload?.repo_id);
48
+ return `${prefix}${eventType}${wsId ? ` ${wsId}` : ''}${retryRepo ? ` (${retryRepo})` : ''}`;
49
+ }
50
+ case 'turn_checkpointed':
51
+ return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
52
+ case 'dispatch_progress':
53
+ return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
39
54
  case 'run_blocked':
40
55
  case 'run_completed':
41
56
  case 'run_started':
@@ -22,6 +22,12 @@ export function formatCount(value) {
22
22
  return new Intl.NumberFormat('en-US').format(value);
23
23
  }
24
24
 
25
+ const ONBOARDING_PREREQ_DOCS = [
26
+ 'website-v2/docs/getting-started.mdx',
27
+ 'website-v2/docs/quickstart.mdx',
28
+ 'website-v2/docs/five-minute-tutorial.mdx',
29
+ ];
30
+
25
31
  function normalizeEvidenceText(value) {
26
32
  return value
27
33
  .replace(/^\s*-\s*/, '')
@@ -137,6 +143,28 @@ function validateTextIncludesVersionAndEvidence(relativePath, label) {
137
143
  };
138
144
  }
139
145
 
146
+ function validateOnboardingPrereqs(ctx, repoRoot) {
147
+ const errors = [];
148
+ const requiredTokens = [
149
+ `Minimum CLI version: \`agentxchain ${ctx.targetVersion}\` or newer`,
150
+ 'agentxchain --version',
151
+ 'npm install -g agentxchain@latest',
152
+ 'brew upgrade agentxchain',
153
+ 'npx --yes -p agentxchain@latest -c "agentxchain <command>"',
154
+ ];
155
+
156
+ for (const relativePath of ONBOARDING_PREREQ_DOCS) {
157
+ const content = read(repoRoot, relativePath);
158
+ for (const token of requiredTokens) {
159
+ if (!content.includes(token)) {
160
+ errors.push(`${relativePath} must include: ${token}`);
161
+ }
162
+ }
163
+ }
164
+
165
+ return errors;
166
+ }
167
+
140
168
  export const RELEASE_ALIGNMENT_SURFACES = [
141
169
  {
142
170
  id: 'changelog',
@@ -274,6 +302,12 @@ export const RELEASE_ALIGNMENT_SURFACES = [
274
302
  : [`website-v2/static/llms.txt must list ${ctx.releaseRoute}`];
275
303
  },
276
304
  },
305
+ {
306
+ id: 'onboarding_prereqs',
307
+ label: 'onboarding docs CLI-version prerequisites',
308
+ scopes: [RELEASE_ALIGNMENT_SCOPES.PREBUMP, RELEASE_ALIGNMENT_SCOPES.CURRENT],
309
+ check: validateOnboardingPrereqs,
310
+ },
277
311
  {
278
312
  id: 'homebrew_formula_url',
279
313
  label: 'homebrew mirror formula url',
@@ -311,9 +345,23 @@ export function validateReleaseAlignment(repoRoot, { targetVersion, scope = RELE
311
345
  const context = getReleaseAlignmentContext(repoRoot, { targetVersion });
312
346
  const surfaces = RELEASE_ALIGNMENT_SURFACES.filter((surface) => surface.scopes.includes(scope));
313
347
  const errors = [];
348
+ const surfaceResults = [];
314
349
 
315
350
  for (const surface of surfaces) {
316
- const surfaceErrors = surface.check(context, repoRoot) || [];
351
+ let surfaceErrors = [];
352
+ try {
353
+ surfaceErrors = surface.check(context, repoRoot) || [];
354
+ } catch (error) {
355
+ surfaceErrors = [
356
+ error instanceof Error ? error.message : String(error),
357
+ ];
358
+ }
359
+ surfaceResults.push({
360
+ surface_id: surface.id,
361
+ label: surface.label,
362
+ ok: surfaceErrors.length === 0,
363
+ errors: surfaceErrors,
364
+ });
317
365
  for (const error of surfaceErrors) {
318
366
  errors.push({
319
367
  surface_id: surface.id,
@@ -331,6 +379,12 @@ export function validateReleaseAlignment(repoRoot, { targetVersion, scope = RELE
331
379
  aggregateEvidenceLine: context.aggregateEvidenceLine,
332
380
  checkedSurfaceCount: surfaces.length,
333
381
  checkedSurfaceIds: surfaces.map((surface) => surface.id),
382
+ checkedSurfaces: surfaces.map((surface) => ({
383
+ id: surface.id,
384
+ label: surface.label,
385
+ scopes: [...surface.scopes],
386
+ })),
387
+ surfaceResults,
334
388
  errors,
335
389
  };
336
390
  }
@@ -18,9 +18,11 @@ export const VALID_RUN_EVENTS = [
18
18
  'turn_accepted',
19
19
  'turn_rejected',
20
20
  'turn_conflicted',
21
+ 'conflict_resolved',
21
22
  'acceptance_failed',
22
23
  'turn_reissued',
23
24
  'turn_checkpointed',
25
+ 'coordinator_retry',
24
26
  'run_blocked',
25
27
  'run_completed',
26
28
  'escalation_raised',