agentxchain 2.99.0 → 2.101.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.
@@ -59,7 +59,7 @@ import { generateCommand } from '../src/commands/generate.js';
59
59
  import { doctorCommand } from '../src/commands/doctor.js';
60
60
  import { superviseCommand } from '../src/commands/supervise.js';
61
61
  import { validateCommand } from '../src/commands/validate.js';
62
- import { verifyExportCommand, verifyProtocolCommand, verifyTurnCommand } from '../src/commands/verify.js';
62
+ import { verifyDiffCommand, verifyExportCommand, verifyProtocolCommand, verifyTurnCommand } from '../src/commands/verify.js';
63
63
  import { replayTurnCommand } from '../src/commands/replay.js';
64
64
  import { replayExportCommand } from '../src/commands/replay-export.js';
65
65
  import { kickoffCommand } from '../src/commands/kickoff.js';
@@ -401,6 +401,12 @@ verifyCmd
401
401
  .option('--format <format>', 'Output format: text or json', 'text')
402
402
  .action(verifyExportCommand);
403
403
 
404
+ verifyCmd
405
+ .command('diff <left_export> <right_export>')
406
+ .description('Verify two export artifacts, then detect governance regressions between them')
407
+ .option('--format <format>', 'Output format: text or json', 'text')
408
+ .action(verifyDiffCommand);
409
+
404
410
  const replayCmd = program
405
411
  .command('replay')
406
412
  .description('Replay accepted governed evidence against the current workspace');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.99.0",
3
+ "version": "2.101.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const repoRoot = resolve(__dirname, '..', '..');
9
+ const releasesDir = join(repoRoot, 'website-v2', 'docs', 'releases');
10
+
11
+ function parseReleaseFile(file) {
12
+ const match = /^v(\d+)-(\d+)-(\d+)\.mdx$/.exec(file);
13
+ if (!match) {
14
+ throw new Error(`Unparseable release note filename: ${file}`);
15
+ }
16
+ return {
17
+ file,
18
+ version: match.slice(1).map(Number),
19
+ };
20
+ }
21
+
22
+ function compareDesc(left, right) {
23
+ for (let i = 0; i < 3; i += 1) {
24
+ if (left.version[i] !== right.version[i]) {
25
+ return right.version[i] - left.version[i];
26
+ }
27
+ }
28
+ return 0;
29
+ }
30
+
31
+ function updateSidebarPosition(content, nextPosition) {
32
+ if (!content.startsWith('---\n')) {
33
+ throw new Error('Release note is missing YAML frontmatter opening delimiter');
34
+ }
35
+
36
+ const frontmatterEnd = content.indexOf('\n---\n', 4);
37
+ if (frontmatterEnd === -1) {
38
+ throw new Error('Release note is missing YAML frontmatter closing delimiter');
39
+ }
40
+
41
+ const frontmatter = content.slice(0, frontmatterEnd + 5);
42
+ const body = content.slice(frontmatterEnd + 5);
43
+ const expectedLine = `sidebar_position: ${nextPosition}`;
44
+
45
+ let updatedFrontmatter;
46
+ if (/^sidebar_position:\s*\d+\s*$/m.test(frontmatter)) {
47
+ updatedFrontmatter = frontmatter.replace(/^sidebar_position:\s*\d+\s*$/m, expectedLine);
48
+ } else {
49
+ updatedFrontmatter = frontmatter.replace(/^---\n/, `---\n${expectedLine}\n`);
50
+ }
51
+
52
+ const updated = updatedFrontmatter + body;
53
+ return {
54
+ updated,
55
+ changed: updated !== content,
56
+ };
57
+ }
58
+
59
+ const releaseFiles = readdirSync(releasesDir)
60
+ .filter((file) => /^v\d+-\d+-\d+\.mdx$/.test(file))
61
+ .map(parseReleaseFile)
62
+ .sort(compareDesc);
63
+
64
+ if (releaseFiles.length === 0) {
65
+ throw new Error(`No release notes found in ${releasesDir}`);
66
+ }
67
+
68
+ let changedCount = 0;
69
+ for (const [index, release] of releaseFiles.entries()) {
70
+ const filePath = join(releasesDir, release.file);
71
+ const content = readFileSync(filePath, 'utf8');
72
+ const { updated, changed } = updateSidebarPosition(content, index);
73
+ if (changed) {
74
+ writeFileSync(filePath, updated);
75
+ changedCount += 1;
76
+ }
77
+ }
78
+
79
+ console.log(
80
+ `Normalized release note sidebar positions across ${releaseFiles.length} files (${changedCount} updated).`,
81
+ );
@@ -192,13 +192,18 @@ if [[ "${#SURFACE_ERRORS[@]}" -gt 0 ]]; then
192
192
  fi
193
193
  echo " OK: all 8 governed version surfaces reference ${TARGET_VERSION}"
194
194
 
195
- # 5. Auto-align Homebrew mirror to target version
195
+ # 5. Normalize release-note sidebar ordering
196
+ echo "[5/10] Normalizing release-note sidebar positions..."
197
+ node "${CLI_DIR}/scripts/normalize-release-note-sidebar-positions.mjs"
198
+ echo " OK: release-note sidebar positions normalized newest-first"
199
+
200
+ # 6. Auto-align Homebrew mirror to target version
196
201
  # The formula URL and README version/tarball are updated automatically.
197
202
  # The SHA256 is carried from the previous committed formula — it is inherently a
198
203
  # post-publish artifact (npm registry tarballs are not byte-identical to
199
204
  # local npm-pack output). Any working-tree SHA edit is overwritten here.
200
205
  # sync-homebrew.sh corrects the SHA after publish.
201
- echo "[5/9] Auto-aligning Homebrew mirror to ${TARGET_VERSION}..."
206
+ echo "[6/10] Auto-aligning Homebrew mirror to ${TARGET_VERSION}..."
202
207
  HOMEBREW_MIRROR="${REPO_ROOT}/cli/homebrew/agentxchain.rb"
203
208
  HOMEBREW_MIRROR_README="${REPO_ROOT}/cli/homebrew/README.md"
204
209
  TARBALL_URL="https://registry.npmjs.org/agentxchain/-/agentxchain-${TARGET_VERSION}.tgz"
@@ -256,13 +261,13 @@ else
256
261
  echo " Skipped: no Homebrew mirror files found"
257
262
  fi
258
263
 
259
- # 6. Update version files (no git operations)
260
- echo "[6/9] Updating version files..."
264
+ # 7. Update version files (no git operations)
265
+ echo "[7/10] Updating version files..."
261
266
  npm version "$TARGET_VERSION" --no-git-tag-version
262
267
  echo " OK: package.json updated to ${TARGET_VERSION}"
263
268
 
264
- # 7. Stage version files
265
- echo "[7/9] Staging version files..."
269
+ # 8. Stage version files
270
+ echo "[8/10] Staging version files..."
266
271
  git add -- package.json
267
272
  if [[ -f package-lock.json ]]; then
268
273
  git add -- package-lock.json
@@ -270,10 +275,11 @@ fi
270
275
  for rel_path in "${ALLOWED_RELEASE_PATHS[@]}"; do
271
276
  stage_if_present "$rel_path"
272
277
  done
278
+ git -C "$REPO_ROOT" add -- website-v2/docs/releases
273
279
  echo " OK: version files and allowed release surfaces staged"
274
280
 
275
- # 8. Create release commit
276
- echo "[8/9] Creating release commit..."
281
+ # 9. Create release commit
282
+ echo "[9/10] Creating release commit..."
277
283
  git commit -m "${TARGET_VERSION}"
278
284
  RELEASE_SHA=$(git rev-parse HEAD)
279
285
  COMMIT_MSG=$(git log -1 --format=%s)
@@ -283,13 +289,13 @@ if [[ "$COMMIT_MSG" != "$TARGET_VERSION" ]]; then
283
289
  fi
284
290
  echo " OK: commit ${RELEASE_SHA:0:7} with message '${TARGET_VERSION}'"
285
291
 
286
- # 8.5. Inline preflight gate — tests, pack, and docs build must pass before tag
292
+ # 9.5. Inline preflight gate — tests, pack, and docs build must pass before tag
287
293
  if [[ "$SKIP_PREFLIGHT" -eq 1 ]]; then
288
294
  echo ""
289
- echo "[8.5/10] Inline preflight gate SKIPPED (--skip-preflight)"
295
+ echo "[9.5/11] Inline preflight gate SKIPPED (--skip-preflight)"
290
296
  else
291
297
  echo ""
292
- echo "[8.5/10] Running inline preflight gate..."
298
+ echo "[9.5/11] Running inline preflight gate..."
293
299
  echo " Running test suite..."
294
300
 
295
301
  # Install MCP example deps if needed (same as release-preflight.sh)
@@ -342,8 +348,8 @@ else
342
348
  echo " Inline preflight gate passed — proceeding to tag"
343
349
  fi
344
350
 
345
- # 9. Create annotated tag
346
- echo "[9/10] Creating annotated tag..."
351
+ # 10. Create annotated tag
352
+ echo "[10/11] Creating annotated tag..."
347
353
  git tag -a "v${TARGET_VERSION}" -m "v${TARGET_VERSION}"
348
354
  TAG_SHA=$(git rev-parse "v${TARGET_VERSION}")
349
355
  if [[ -z "$TAG_SHA" ]]; then
@@ -132,6 +132,8 @@ function formatExportDiffText(diff) {
132
132
  .map((entry) => `${entry.key}: ${formatValue(entry.left)} -> ${formatValue(entry.right)}${formatDelta(entry.delta, entry.label)}`));
133
133
  }
134
134
 
135
+ appendRegressionSection(lines, diff.regressions);
136
+
135
137
  return lines.join('\n');
136
138
  }
137
139
 
@@ -144,6 +146,18 @@ function appendChangedSection(lines, heading, items) {
144
146
  }
145
147
  }
146
148
 
149
+ function appendRegressionSection(lines, regressions) {
150
+ if (!regressions || regressions.length === 0) return;
151
+ lines.push('');
152
+ lines.push(chalk.bold.red('Governance Regressions:'));
153
+ for (const reg of regressions) {
154
+ const severityTag = reg.severity === 'error'
155
+ ? chalk.red(`[${reg.severity}]`)
156
+ : chalk.yellow(`[${reg.severity}]`);
157
+ lines.push(`${severityTag} ${reg.id}: ${reg.message}`);
158
+ }
159
+ }
160
+
147
161
  function listChangeItems(entry) {
148
162
  const items = [];
149
163
  if (entry.added.length > 0) {
@@ -6,6 +6,7 @@ import { validateStagedTurnResult } from '../lib/turn-result-validator.js';
6
6
  import { getTurnStagingResultPath } from '../lib/turn-paths.js';
7
7
  import { resolve } from 'node:path';
8
8
  import { loadExportArtifact, verifyExportArtifact } from '../lib/export-verifier.js';
9
+ import { buildExportDiff, resolveExportArtifact } from '../lib/export-diff.js';
9
10
  import { verifyProtocolConformance } from '../lib/protocol-conformance.js';
10
11
  import {
11
12
  DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS,
@@ -72,6 +73,18 @@ function emitProtocolVerifyError(format, message) {
72
73
  console.log(chalk.red(`Protocol verification failed: ${message}`));
73
74
  }
74
75
 
76
+ function emitVerifyDiffError(format, message) {
77
+ if (format === 'json') {
78
+ console.log(JSON.stringify({
79
+ overall: 'error',
80
+ message,
81
+ }, null, 2));
82
+ return;
83
+ }
84
+
85
+ console.log(chalk.red(`Diff verification failed: ${message}`));
86
+ }
87
+
75
88
  export async function verifyExportCommand(opts) {
76
89
  const format = opts.format || 'text';
77
90
  const loaded = loadExportArtifact(opts.input || '-', process.cwd());
@@ -104,6 +117,75 @@ export async function verifyExportCommand(opts) {
104
117
  process.exit(result.ok ? 0 : 1);
105
118
  }
106
119
 
120
+ export async function verifyDiffCommand(leftRef, rightRef, opts = {}) {
121
+ const format = opts.format || 'text';
122
+ const leftLoaded = resolveExportArtifact(leftRef);
123
+ if (!leftLoaded.ok) {
124
+ emitVerifyDiffError(format, leftLoaded.error);
125
+ process.exit(2);
126
+ }
127
+
128
+ const rightLoaded = resolveExportArtifact(rightRef);
129
+ if (!rightLoaded.ok) {
130
+ emitVerifyDiffError(format, rightLoaded.error);
131
+ process.exit(2);
132
+ }
133
+
134
+ if (leftLoaded.artifact.export_kind !== rightLoaded.artifact.export_kind) {
135
+ emitVerifyDiffError(
136
+ format,
137
+ `Export kinds do not match: ${leftLoaded.artifact.export_kind} vs ${rightLoaded.artifact.export_kind}`,
138
+ );
139
+ process.exit(2);
140
+ }
141
+
142
+ const leftVerification = verifyExportArtifact(leftLoaded.artifact);
143
+ const rightVerification = verifyExportArtifact(rightLoaded.artifact);
144
+ const leftReport = { ...leftVerification.report, input: leftLoaded.resolved_ref };
145
+ const rightReport = { ...rightVerification.report, input: rightLoaded.resolved_ref };
146
+
147
+ let diff = null;
148
+ let overall = 'pass';
149
+ let exitCode = 0;
150
+ let message = null;
151
+
152
+ if (!leftVerification.ok || !rightVerification.ok) {
153
+ overall = 'fail';
154
+ exitCode = 1;
155
+ message = 'Diff skipped because one or both exports failed verification.';
156
+ } else {
157
+ const diffResult = buildExportDiff(leftLoaded.artifact, rightLoaded.artifact, {
158
+ left_ref: leftLoaded.resolved_ref,
159
+ right_ref: rightLoaded.resolved_ref,
160
+ });
161
+ if (!diffResult.ok) {
162
+ emitVerifyDiffError(format, diffResult.error);
163
+ process.exit(2);
164
+ }
165
+ diff = diffResult.diff;
166
+ if (diff.has_regressions) {
167
+ overall = 'fail';
168
+ exitCode = 1;
169
+ }
170
+ }
171
+
172
+ const report = {
173
+ overall,
174
+ left: leftReport,
175
+ right: rightReport,
176
+ diff,
177
+ message,
178
+ };
179
+
180
+ if (format === 'json') {
181
+ console.log(JSON.stringify(report, null, 2));
182
+ } else {
183
+ printVerifyDiffReport(report);
184
+ }
185
+
186
+ process.exit(exitCode);
187
+ }
188
+
107
189
  export async function verifyTurnCommand(turnId, opts = {}) {
108
190
  const context = loadProjectContext();
109
191
  if (!context) {
@@ -237,6 +319,54 @@ function printExportReport(report) {
237
319
  console.log('');
238
320
  }
239
321
 
322
+ function printVerifyDiffReport(report) {
323
+ console.log('');
324
+ console.log(chalk.bold(' AgentXchain Diff Verification'));
325
+ console.log(chalk.dim(' ' + '─'.repeat(41)));
326
+ console.log(chalk.dim(` Left: ${report.left.input}`));
327
+ console.log(chalk.dim(` Right: ${report.right.input}`));
328
+ console.log('');
329
+
330
+ const overallLabel = report.overall === 'pass'
331
+ ? chalk.green('PASS')
332
+ : report.overall === 'fail'
333
+ ? chalk.red('FAIL')
334
+ : chalk.red('ERROR');
335
+ console.log(` Overall: ${overallLabel}`);
336
+ console.log(` Left export: ${formatVerifyStatus(report.left.overall)}`);
337
+ console.log(` Right export: ${formatVerifyStatus(report.right.overall)}`);
338
+
339
+ if (report.diff) {
340
+ console.log(chalk.dim(` Diff subject: ${report.diff.subject_kind}`));
341
+ console.log(chalk.dim(` Changed: ${report.diff.changed ? 'yes' : 'no'}`));
342
+ console.log(chalk.dim(` Governance regressions: ${report.diff.regression_count}`));
343
+ }
344
+
345
+ if (report.message) {
346
+ console.log(chalk.yellow(` ${report.message}`));
347
+ }
348
+
349
+ for (const error of report.left.errors || []) {
350
+ console.log(chalk.red(` left ✗ ${error}`));
351
+ }
352
+ for (const error of report.right.errors || []) {
353
+ console.log(chalk.red(` right ✗ ${error}`));
354
+ }
355
+
356
+ if (report.diff?.regressions?.length > 0) {
357
+ console.log('');
358
+ console.log(chalk.bold.red(' Governance Regressions'));
359
+ for (const regression of report.diff.regressions) {
360
+ const severity = regression.severity === 'error'
361
+ ? chalk.red(`[${regression.severity}]`)
362
+ : chalk.yellow(`[${regression.severity}]`);
363
+ console.log(` ${severity} ${regression.id}: ${regression.message}`);
364
+ }
365
+ }
366
+
367
+ console.log('');
368
+ }
369
+
240
370
  function resolveTargetTurnId(requestedTurnId, activeTurns) {
241
371
  const turnIds = Object.keys(activeTurns);
242
372
 
@@ -351,3 +481,9 @@ function formatOutcome(outcome) {
351
481
  if (outcome === 'mismatch') return chalk.red('mismatch');
352
482
  return chalk.yellow('not_reproducible');
353
483
  }
484
+
485
+ function formatVerifyStatus(status) {
486
+ if (status === 'pass') return chalk.green('PASS');
487
+ if (status === 'fail') return chalk.red('FAIL');
488
+ return chalk.red(String(status || 'error').toUpperCase());
489
+ }
@@ -1,6 +1,8 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { resolve } from 'path';
3
3
 
4
+ const FAILED_STATUSES = new Set(['failed', 'error', 'crashed']);
5
+
4
6
  const RUN_EXPORT_SCALAR_FIELDS = [
5
7
  ['run_id', 'Run ID'],
6
8
  ['status', 'Status'],
@@ -119,6 +121,7 @@ function buildRunExportDiff(leftArtifact, rightArtifact, refs) {
119
121
  };
120
122
 
121
123
  const changed = hasChanged(scalar_changes, numeric_changes, list_changes);
124
+ const regressions = detectRunRegressions(left, right);
122
125
 
123
126
  return {
124
127
  comparison_mode: 'export',
@@ -132,6 +135,9 @@ function buildRunExportDiff(leftArtifact, rightArtifact, refs) {
132
135
  scalar_changes,
133
136
  numeric_changes,
134
137
  list_changes,
138
+ regressions,
139
+ regression_count: regressions.length,
140
+ has_regressions: regressions.length > 0,
135
141
  };
136
142
  }
137
143
 
@@ -153,6 +159,7 @@ function buildCoordinatorExportDiff(leftArtifact, rightArtifact, refs) {
153
159
  || repo_status_changes.some((entry) => entry.changed)
154
160
  || repo_export_changes.some((entry) => entry.changed)
155
161
  || event_type_changes.some((entry) => entry.changed);
162
+ const regressions = detectCoordinatorRegressions(left, right);
156
163
 
157
164
  return {
158
165
  comparison_mode: 'export',
@@ -169,17 +176,24 @@ function buildCoordinatorExportDiff(leftArtifact, rightArtifact, refs) {
169
176
  repo_status_changes,
170
177
  repo_export_changes,
171
178
  event_type_changes,
179
+ regressions,
180
+ regression_count: regressions.length,
181
+ has_regressions: regressions.length > 0,
172
182
  };
173
183
  }
174
184
 
175
185
  function normalizeRunExport(artifact) {
176
186
  const summary = artifact.summary || {};
177
187
  const repoDecisions = summary.repo_decisions || {};
188
+ const state = artifact.state || {};
189
+ const budgetStatus = state.budget_status || {};
190
+ const phaseGateStatus = state.phase_gate_status || {};
178
191
  return {
179
192
  export_kind: artifact.export_kind,
180
193
  run_id: summary.run_id || null,
181
194
  status: summary.status || null,
182
195
  phase: summary.phase || null,
196
+ workflow_phase_order: Array.isArray(summary.workflow_phase_order) ? summary.workflow_phase_order : null,
183
197
  project_name: artifact.project?.name || null,
184
198
  project_goal: summary.project_goal || artifact.project?.goal || null,
185
199
  provenance_trigger: summary.provenance?.trigger || null,
@@ -197,6 +211,10 @@ function normalizeRunExport(artifact) {
197
211
  retained_turn_ids: normalizeStringArray(summary.retained_turn_ids),
198
212
  active_repo_decision_ids: normalizeStringArray((repoDecisions.active || []).map((entry) => entry?.id)),
199
213
  overridden_repo_decision_ids: normalizeStringArray((repoDecisions.overridden || []).map((entry) => entry?.id)),
214
+ budget_warn_mode: budgetStatus.warn_mode === true,
215
+ budget_exhausted: budgetStatus.exhausted === true,
216
+ phase_gate_status: normalizeGateStatusMap(phaseGateStatus),
217
+ delegation_missing_decisions: normalizeDelegationMissingMap(summary.delegation_summary),
200
218
  };
201
219
  }
202
220
 
@@ -211,6 +229,7 @@ function normalizeCoordinatorExport(artifact) {
211
229
  super_run_id: summary.super_run_id || null,
212
230
  status: summary.status || null,
213
231
  phase: summary.phase || null,
232
+ workflow_phase_order: Array.isArray(summary.workflow_phase_order) ? summary.workflow_phase_order : null,
214
233
  project_name: artifact.coordinator?.project_name || null,
215
234
  barrier_count: toNumber(summary.barrier_count),
216
235
  history_entries: toNumber(summary.history_entries),
@@ -338,6 +357,27 @@ function normalizeNumericMap(value) {
338
357
  );
339
358
  }
340
359
 
360
+ function normalizeDelegationMissingMap(summary) {
361
+ if (!summary || typeof summary !== 'object' || Array.isArray(summary)) return {};
362
+ const chains = Array.isArray(summary.delegation_chains) ? summary.delegation_chains : [];
363
+ const entries = [];
364
+ for (const chain of chains) {
365
+ const delegations = Array.isArray(chain?.delegations) ? chain.delegations : [];
366
+ for (const delegation of delegations) {
367
+ if (!delegation || typeof delegation !== 'object' || Array.isArray(delegation)) continue;
368
+ const delegationId = typeof delegation.delegation_id === 'string' && delegation.delegation_id.trim()
369
+ ? delegation.delegation_id.trim()
370
+ : null;
371
+ if (!delegationId) continue;
372
+ entries.push([
373
+ delegationId,
374
+ normalizeStringArray(delegation.missing_decision_ids),
375
+ ]);
376
+ }
377
+ }
378
+ return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right, 'en')));
379
+ }
380
+
341
381
  function toNumber(value) {
342
382
  return typeof value === 'number' ? value : null;
343
383
  }
@@ -345,3 +385,224 @@ function toNumber(value) {
345
385
  function isEqual(left, right) {
346
386
  return JSON.stringify(left) === JSON.stringify(right);
347
387
  }
388
+
389
+ function normalizeGateStatusMap(gateStatus) {
390
+ if (!gateStatus || typeof gateStatus !== 'object' || Array.isArray(gateStatus)) return {};
391
+ return Object.fromEntries(
392
+ Object.entries(gateStatus)
393
+ .filter(([key]) => typeof key === 'string' && key.trim().length > 0)
394
+ .map(([key, value]) => [key.trim(), typeof value === 'string' ? value.trim() : String(value)]),
395
+ );
396
+ }
397
+
398
+ const GATE_PASSED_STATES = new Set(['passed', 'approved', 'satisfied']);
399
+ const GATE_FAILED_STATES = new Set(['failed', 'blocked', 'rejected']);
400
+
401
+ function detectRunRegressions(left, right) {
402
+ const regressions = [];
403
+ let counter = 0;
404
+
405
+ // Status regression: completed/active -> failed/error/crashed
406
+ if (left.status && right.status && !FAILED_STATUSES.has(left.status) && FAILED_STATUSES.has(right.status)) {
407
+ regressions.push({
408
+ id: `REG-STATUS-${String(++counter).padStart(3, '0')}`,
409
+ category: 'status',
410
+ severity: 'error',
411
+ message: `Run status regressed from ${left.status} to ${right.status}`,
412
+ field: 'status',
413
+ left: left.status,
414
+ right: right.status,
415
+ });
416
+ }
417
+
418
+ // Phase regression: backward movement in workflow phase order
419
+ if (left.phase && right.phase === null) {
420
+ // Phase disappeared — information loss
421
+ regressions.push({
422
+ id: `REG-PHASE-${String(++counter).padStart(3, '0')}`,
423
+ category: 'phase',
424
+ severity: 'warning',
425
+ message: `Phase regressed from "${left.phase}" to null (phase information lost)`,
426
+ field: 'phase',
427
+ left: left.phase,
428
+ right: null,
429
+ });
430
+ } else if (left.phase && right.phase && left.phase !== right.phase) {
431
+ // Use right export's phase order as canonical (or left if right doesn't have one)
432
+ const phaseOrder = right.workflow_phase_order || left.workflow_phase_order;
433
+ if (Array.isArray(phaseOrder) && phaseOrder.length > 0) {
434
+ const leftIndex = phaseOrder.indexOf(left.phase);
435
+ const rightIndex = phaseOrder.indexOf(right.phase);
436
+ // Only flag when both phases are known and right is earlier than left
437
+ if (leftIndex !== -1 && rightIndex !== -1 && rightIndex < leftIndex) {
438
+ regressions.push({
439
+ id: `REG-PHASE-${String(++counter).padStart(3, '0')}`,
440
+ category: 'phase',
441
+ severity: 'warning',
442
+ message: `Phase moved backward from "${left.phase}" (position ${leftIndex}) to "${right.phase}" (position ${rightIndex})`,
443
+ field: 'phase',
444
+ left: left.phase,
445
+ right: right.phase,
446
+ });
447
+ }
448
+ }
449
+ }
450
+
451
+ // Budget warn_mode regression
452
+ if (left.budget_warn_mode === false && right.budget_warn_mode === true) {
453
+ regressions.push({
454
+ id: `REG-BUDGET-WARN-${String(++counter).padStart(3, '0')}`,
455
+ category: 'budget',
456
+ severity: 'warning',
457
+ message: 'Budget entered warn mode (over budget)',
458
+ field: 'budget_warn_mode',
459
+ left: false,
460
+ right: true,
461
+ });
462
+ }
463
+
464
+ // Budget exhausted regression
465
+ if (left.budget_exhausted === false && right.budget_exhausted === true) {
466
+ regressions.push({
467
+ id: `REG-BUDGET-EXHAUST-${String(++counter).padStart(3, '0')}`,
468
+ category: 'budget',
469
+ severity: 'error',
470
+ message: 'Budget exhausted',
471
+ field: 'budget_exhausted',
472
+ left: false,
473
+ right: true,
474
+ });
475
+ }
476
+
477
+ // Decision override count increase
478
+ const leftOverrides = typeof left.overridden_repo_decision_count === 'number' ? left.overridden_repo_decision_count : 0;
479
+ const rightOverrides = typeof right.overridden_repo_decision_count === 'number' ? right.overridden_repo_decision_count : 0;
480
+ if (rightOverrides > leftOverrides) {
481
+ regressions.push({
482
+ id: `REG-DECISION-OVERRIDE-${String(++counter).padStart(3, '0')}`,
483
+ category: 'decisions',
484
+ severity: 'warning',
485
+ message: `Repo decision overrides increased from ${leftOverrides} to ${rightOverrides}`,
486
+ field: 'overridden_repo_decision_count',
487
+ left: leftOverrides,
488
+ right: rightOverrides,
489
+ });
490
+ }
491
+
492
+ // Delegation contract regressions: newly missing required decisions.
493
+ const allDelegationIds = new Set([
494
+ ...Object.keys(left.delegation_missing_decisions || {}),
495
+ ...Object.keys(right.delegation_missing_decisions || {}),
496
+ ]);
497
+ for (const delegationId of allDelegationIds) {
498
+ const leftMissing = normalizeStringArray((left.delegation_missing_decisions || {})[delegationId]);
499
+ const rightMissing = normalizeStringArray((right.delegation_missing_decisions || {})[delegationId]);
500
+ const leftSet = new Set(leftMissing);
501
+ const newlyMissing = rightMissing.filter((decisionId) => !leftSet.has(decisionId));
502
+ if (newlyMissing.length > 0) {
503
+ regressions.push({
504
+ id: `REG-DELEGATION-MISSING-${String(++counter).padStart(3, '0')}`,
505
+ category: 'delegation',
506
+ severity: 'error',
507
+ message: `Delegation "${delegationId}" is now missing required decisions: ${newlyMissing.join(', ')}`,
508
+ field: `delegation_summary.${delegationId}.missing_decision_ids`,
509
+ left: leftMissing,
510
+ right: rightMissing,
511
+ });
512
+ }
513
+ }
514
+
515
+ // Gate regressions: passed/approved -> failed/blocked
516
+ const allGateIds = new Set([...Object.keys(left.phase_gate_status || {}), ...Object.keys(right.phase_gate_status || {})]);
517
+ for (const gateId of allGateIds) {
518
+ const leftGate = (left.phase_gate_status || {})[gateId] || null;
519
+ const rightGate = (right.phase_gate_status || {})[gateId] || null;
520
+ if (leftGate && rightGate && GATE_PASSED_STATES.has(leftGate) && GATE_FAILED_STATES.has(rightGate)) {
521
+ regressions.push({
522
+ id: `REG-GATE-${String(++counter).padStart(3, '0')}`,
523
+ category: 'gate',
524
+ severity: 'error',
525
+ message: `Gate "${gateId}" regressed from ${leftGate} to ${rightGate}`,
526
+ field: `phase_gate_status.${gateId}`,
527
+ left: leftGate,
528
+ right: rightGate,
529
+ });
530
+ }
531
+ }
532
+
533
+ return regressions;
534
+ }
535
+
536
+ function detectCoordinatorRegressions(left, right) {
537
+ // Start with the run-level regressions that apply to coordinator summaries
538
+ const regressions = detectRunRegressions(left, right);
539
+ let counter = regressions.length;
540
+
541
+ // Repo status regressions: child repo completed -> failed
542
+ const allRepoIds = new Set([...Object.keys(left.repo_run_statuses || {}), ...Object.keys(right.repo_run_statuses || {})]);
543
+ for (const repoId of allRepoIds) {
544
+ const leftStatus = (left.repo_run_statuses || {})[repoId] || null;
545
+ const rightStatus = (right.repo_run_statuses || {})[repoId] || null;
546
+ if (leftStatus && rightStatus && !FAILED_STATUSES.has(leftStatus) && FAILED_STATUSES.has(rightStatus)) {
547
+ regressions.push({
548
+ id: `REG-REPO-STATUS-${String(++counter).padStart(3, '0')}`,
549
+ category: 'repo_status',
550
+ severity: 'error',
551
+ message: `Child repo "${repoId}" status regressed from ${leftStatus} to ${rightStatus}`,
552
+ field: `repo_run_statuses.${repoId}`,
553
+ left: leftStatus,
554
+ right: rightStatus,
555
+ });
556
+ }
557
+ }
558
+
559
+ // Repo export regressions: ok true -> false
560
+ const allExportRepoIds = new Set([...Object.keys(left.repo_export_status || {}), ...Object.keys(right.repo_export_status || {})]);
561
+ for (const repoId of allExportRepoIds) {
562
+ const leftOk = (left.repo_export_status || {})[repoId];
563
+ const rightOk = (right.repo_export_status || {})[repoId];
564
+ if (leftOk === true && rightOk === false) {
565
+ regressions.push({
566
+ id: `REG-REPO-EXPORT-${String(++counter).padStart(3, '0')}`,
567
+ category: 'repo_export',
568
+ severity: 'error',
569
+ message: `Child repo "${repoId}" export regressed from ok to failed`,
570
+ field: `repo_export_status.${repoId}`,
571
+ left: true,
572
+ right: false,
573
+ });
574
+ }
575
+ }
576
+
577
+ // Barrier count decrease
578
+ const leftBarriers = typeof left.barrier_count === 'number' ? left.barrier_count : 0;
579
+ const rightBarriers = typeof right.barrier_count === 'number' ? right.barrier_count : 0;
580
+ if (leftBarriers > 0 && rightBarriers < leftBarriers) {
581
+ regressions.push({
582
+ id: `REG-BARRIER-${String(++counter).padStart(3, '0')}`,
583
+ category: 'barrier',
584
+ severity: 'warning',
585
+ message: `Barrier count decreased from ${leftBarriers} to ${rightBarriers}`,
586
+ field: 'barrier_count',
587
+ left: leftBarriers,
588
+ right: rightBarriers,
589
+ });
590
+ }
591
+
592
+ // Event loss: total_events decreased
593
+ const leftEvents = typeof left.total_events === 'number' ? left.total_events : 0;
594
+ const rightEvents = typeof right.total_events === 'number' ? right.total_events : 0;
595
+ if (leftEvents > 0 && rightEvents < leftEvents) {
596
+ regressions.push({
597
+ id: `REG-EVENT-LOSS-${String(++counter).padStart(3, '0')}`,
598
+ category: 'events',
599
+ severity: 'warning',
600
+ message: `Aggregated event count decreased from ${leftEvents} to ${rightEvents}`,
601
+ field: 'total_events',
602
+ left: leftEvents,
603
+ right: rightEvents,
604
+ });
605
+ }
606
+
607
+ return regressions;
608
+ }
package/src/lib/export.js CHANGED
@@ -454,6 +454,9 @@ export function buildRunExport(startDir = process.cwd()) {
454
454
  run_id: state?.run_id || null,
455
455
  status: state?.status || null,
456
456
  phase: state?.phase || null,
457
+ workflow_phase_order: config.routing && Object.keys(config.routing).length > 0
458
+ ? Object.keys(config.routing)
459
+ : null,
457
460
  provenance: normalizeRunProvenance(state?.provenance),
458
461
  inherited_context: state?.inherited_context || null,
459
462
  active_turn_ids: activeTurns,
@@ -640,6 +643,10 @@ export function buildCoordinatorExport(startDir = process.cwd()) {
640
643
  super_run_id: coordState?.super_run_id || null,
641
644
  status: coordState?.status || null,
642
645
  phase: coordState?.phase || null,
646
+ workflow_phase_order: rawConfig.routing && typeof rawConfig.routing === 'object'
647
+ && Object.keys(rawConfig.routing).length > 0
648
+ ? Object.keys(rawConfig.routing)
649
+ : null,
643
650
  repo_run_statuses: repoRunStatuses,
644
651
  barrier_count: barrierCount,
645
652
  history_entries: countJsonl(files, '.agentxchain/multirepo/history.jsonl'),