agentxchain 2.76.0 → 2.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  CLI for governed multi-agent software delivery.
4
4
 
5
+ AgentXchain coordinates multiple AI agents — PM, developer, QA, architect, and any custom roles — to work together on a codebase with built-in governance: structured turns, mandatory challenge between agents, phase gates, human approvals, and an append-only audit trail. Think of it as the operating system for AI software teams.
6
+
5
7
  The canonical mode is governed delivery: orchestrator-owned state, structured turn results, phase gates, mandatory challenge, and explicit human approvals where required.
6
8
 
7
9
  Legacy IDE-window coordination is still shipped as a compatibility mode for teams that want lock-based handoff in Cursor, VS Code, or Claude Code.
@@ -36,6 +38,13 @@ npm install -g agentxchain
36
38
  agentxchain --version
37
39
  ```
38
40
 
41
+ Or via Homebrew (macOS/Linux):
42
+
43
+ ```bash
44
+ brew tap shivamtiwari93/tap
45
+ brew install agentxchain
46
+ ```
47
+
39
48
  For a zero-install one-off command, use the package-bound form:
40
49
 
41
50
  ```bash
@@ -75,6 +75,7 @@ import { approveTransitionCommand } from '../src/commands/approve-transition.js'
75
75
  import { approveCompletionCommand } from '../src/commands/approve-completion.js';
76
76
  import { dashboardCommand } from '../src/commands/dashboard.js';
77
77
  import { exportCommand } from '../src/commands/export.js';
78
+ import { auditCommand } from '../src/commands/audit.js';
78
79
  import { restoreCommand } from '../src/commands/restore.js';
79
80
  import { restartCommand } from '../src/commands/restart.js';
80
81
  import { reportCommand } from '../src/commands/report.js';
@@ -110,7 +111,9 @@ import { intakeResolveCommand } from '../src/commands/intake-resolve.js';
110
111
  import { intakeStatusCommand } from '../src/commands/intake-status.js';
111
112
  import { demoCommand } from '../src/commands/demo.js';
112
113
  import { historyCommand } from '../src/commands/history.js';
114
+ import { diffCommand } from '../src/commands/diff.js';
113
115
  import { eventsCommand } from '../src/commands/events.js';
116
+ import { connectorCheckCommand } from '../src/commands/connector.js';
114
117
  import { scheduleDaemonCommand, scheduleListCommand, scheduleRunDueCommand, scheduleStatusCommand } from '../src/commands/schedule.js';
115
118
 
116
119
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -149,6 +152,12 @@ program
149
152
  .option('--output <path>', 'Write the export artifact to a file instead of stdout')
150
153
  .action(exportCommand);
151
154
 
155
+ program
156
+ .command('audit')
157
+ .description('Render a governance audit directly from the current governed project or coordinator workspace')
158
+ .option('--format <format>', 'Output format: text, json, or markdown', 'text')
159
+ .action(auditCommand);
160
+
152
161
  program
153
162
  .command('restore')
154
163
  .description('Restore governed continuity roots from a run export artifact')
@@ -259,6 +268,17 @@ program
259
268
  .option('-j, --json', 'Output as JSON')
260
269
  .action(doctorCommand);
261
270
 
271
+ const connectorCmd = program
272
+ .command('connector')
273
+ .description('Probe governed runtime connectors directly');
274
+
275
+ connectorCmd
276
+ .command('check [runtime_id]')
277
+ .description('Run live connector probes for all non-manual runtimes or one named runtime')
278
+ .option('-j, --json', 'Output as JSON')
279
+ .option('--timeout <ms>', 'Per-probe timeout in milliseconds', '8000')
280
+ .action(connectorCheckCommand);
281
+
262
282
  program
263
283
  .command('demo')
264
284
  .description('Run a complete governed lifecycle demo (no API keys required)')
@@ -311,6 +331,13 @@ program
311
331
  .option('-d, --dir <path>', 'Project directory')
312
332
  .action(historyCommand);
313
333
 
334
+ program
335
+ .command('diff <left_run_id> <right_run_id>')
336
+ .description('Compare two recorded governed runs from run-history')
337
+ .option('-j, --json', 'Output as JSON')
338
+ .option('-d, --dir <path>', 'Project directory')
339
+ .action(diffCommand);
340
+
314
341
  program
315
342
  .command('events')
316
343
  .description('Show repo-local run lifecycle events')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.76.0",
3
+ "version": "2.78.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,75 @@
1
+ import chalk from 'chalk';
2
+
3
+ import { buildCoordinatorExport, buildRunExport } from '../lib/export.js';
4
+ import {
5
+ buildGovernanceReport,
6
+ formatGovernanceReportMarkdown,
7
+ formatGovernanceReportText,
8
+ } from '../lib/report.js';
9
+
10
+ function detectAuditKind(cwd) {
11
+ const runResult = buildRunExport(cwd);
12
+ if (runResult.ok) {
13
+ return {
14
+ ok: true,
15
+ input: cwd,
16
+ artifact: runResult.export,
17
+ };
18
+ }
19
+
20
+ const coordinatorResult = buildCoordinatorExport(cwd);
21
+ if (coordinatorResult.ok) {
22
+ return {
23
+ ok: true,
24
+ input: cwd,
25
+ artifact: coordinatorResult.export,
26
+ };
27
+ }
28
+
29
+ return {
30
+ ok: false,
31
+ error: runResult.error || coordinatorResult.error || 'No governed project or coordinator workspace found.',
32
+ };
33
+ }
34
+
35
+ function printAndExit(report, format, exitCode) {
36
+ if (format === 'json') {
37
+ console.log(JSON.stringify(report, null, 2));
38
+ process.exit(exitCode);
39
+ }
40
+
41
+ if (format === 'markdown') {
42
+ console.log(formatGovernanceReportMarkdown(report));
43
+ process.exit(exitCode);
44
+ }
45
+
46
+ if (format === 'text') {
47
+ if (report.overall === 'error' || report.overall === 'fail') {
48
+ console.log(chalk.red(formatGovernanceReportText(report)));
49
+ } else {
50
+ console.log(formatGovernanceReportText(report));
51
+ }
52
+ process.exit(exitCode);
53
+ }
54
+
55
+ console.error(`Unsupported audit format "${format}". Use "text", "json", or "markdown".`);
56
+ process.exit(2);
57
+ }
58
+
59
+ export async function auditCommand(options) {
60
+ const format = options.format || 'text';
61
+ const cwd = process.cwd();
62
+ const resolved = detectAuditKind(cwd);
63
+
64
+ if (!resolved.ok) {
65
+ printAndExit({
66
+ overall: 'error',
67
+ input: cwd,
68
+ message: resolved.error,
69
+ }, format, 2);
70
+ return;
71
+ }
72
+
73
+ const result = buildGovernanceReport(resolved.artifact, { input: resolved.input });
74
+ printAndExit(result.report, format, result.exitCode);
75
+ }
@@ -0,0 +1,122 @@
1
+ import chalk from 'chalk';
2
+
3
+ import { loadProjectContext } from '../lib/config.js';
4
+ import { DEFAULT_TIMEOUT_MS, probeConfiguredConnectors } from '../lib/connector-probe.js';
5
+
6
+ function printJson(result, exitCode) {
7
+ console.log(JSON.stringify(result, null, 2));
8
+ process.exit(exitCode);
9
+ }
10
+
11
+ function printText(result, exitCode) {
12
+ if (result.connectors.length === 0) {
13
+ console.log('');
14
+ console.log(chalk.bold(' AgentXchain Connector Check'));
15
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
16
+ console.log('');
17
+ console.log(` ${chalk.green('PASS')} No non-manual runtimes are configured`);
18
+ console.log('');
19
+ process.exit(exitCode);
20
+ }
21
+
22
+ console.log('');
23
+ console.log(chalk.bold(' AgentXchain Connector Check'));
24
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
25
+ console.log(` ${chalk.dim(`Timeout: ${result.timeout_ms}ms per connector`)}`);
26
+ console.log('');
27
+
28
+ for (const connector of result.connectors) {
29
+ const badge = connector.level === 'pass' ? chalk.green('PASS') : chalk.red('FAIL');
30
+ console.log(` ${badge} ${connector.runtime_id} (${connector.type})`);
31
+ console.log(` ${chalk.dim('Target:')} ${connector.target}`);
32
+ console.log(` ${chalk.dim('Probe:')} ${connector.probe_kind}`);
33
+ if (connector.endpoint) {
34
+ console.log(` ${chalk.dim('URL:')} ${connector.endpoint}`);
35
+ }
36
+ if (connector.status_code != null) {
37
+ console.log(` ${chalk.dim('HTTP:')} ${connector.status_code}`);
38
+ }
39
+ if (connector.auth_env) {
40
+ console.log(` ${chalk.dim('Auth:')} ${connector.auth_env}`);
41
+ }
42
+ if (connector.latency_ms != null) {
43
+ console.log(` ${chalk.dim('Time:')} ${connector.latency_ms}ms`);
44
+ }
45
+ console.log(` ${chalk.dim('Detail:')} ${connector.detail}`);
46
+ }
47
+
48
+ console.log('');
49
+ const summary = result.overall === 'pass'
50
+ ? chalk.green(` ✓ ${result.pass_count}/${result.connectors.length} connectors passed`)
51
+ : chalk.red(` ${result.fail_count} connector failure(s), ${result.pass_count} passed`);
52
+ console.log(summary);
53
+ console.log('');
54
+ process.exit(exitCode);
55
+ }
56
+
57
+ export async function connectorCheckCommand(runtimeId, options = {}) {
58
+ const context = loadProjectContext();
59
+ if (!context) {
60
+ const payload = { overall: 'error', error: 'No governed agentxchain.json found.' };
61
+ if (options.json) {
62
+ printJson(payload, 2);
63
+ return;
64
+ }
65
+ console.error(chalk.red('No governed agentxchain.json found. Run this inside a governed project.'));
66
+ process.exit(2);
67
+ }
68
+
69
+ if (context.config.protocol_mode !== 'governed') {
70
+ const payload = { overall: 'error', error: 'connector check only supports governed projects.' };
71
+ if (options.json) {
72
+ printJson(payload, 2);
73
+ return;
74
+ }
75
+ console.error(chalk.red('connector check only supports governed projects.'));
76
+ process.exit(2);
77
+ }
78
+
79
+ const timeoutMs = Number.parseInt(options.timeout || DEFAULT_TIMEOUT_MS, 10);
80
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
81
+ const payload = { overall: 'error', error: 'Timeout must be a positive integer.' };
82
+ if (options.json) {
83
+ printJson(payload, 2);
84
+ return;
85
+ }
86
+ console.error(chalk.red('Timeout must be a positive integer.'));
87
+ process.exit(2);
88
+ }
89
+
90
+ const result = await probeConfiguredConnectors(context.config, {
91
+ runtimeId: runtimeId || null,
92
+ timeoutMs,
93
+ onProbeStart: options.json ? null : (probeRuntimeId, runtime) => {
94
+ console.log(` ${chalk.dim('…')} Probing ${chalk.bold(probeRuntimeId)} ${chalk.dim(`(${runtime.type})`)}`);
95
+ },
96
+ });
97
+
98
+ if (!result.ok && result.error) {
99
+ const payload = { overall: 'error', error: result.error };
100
+ if (options.json) {
101
+ printJson(payload, result.exitCode || 2);
102
+ return;
103
+ }
104
+ console.error(chalk.red(result.error));
105
+ process.exit(result.exitCode || 2);
106
+ }
107
+
108
+ const payload = {
109
+ overall: result.overall,
110
+ timeout_ms: result.timeout_ms,
111
+ pass_count: result.pass_count,
112
+ fail_count: result.fail_count,
113
+ connectors: result.connectors,
114
+ };
115
+
116
+ if (options.json) {
117
+ printJson(payload, result.exitCode);
118
+ return;
119
+ }
120
+
121
+ printText(payload, result.exitCode);
122
+ }
@@ -608,7 +608,8 @@ All acceptance criteria met. OBJ-002 (clock skew) noted for follow-up. OBJ-003 (
608
608
  console.log('');
609
609
  console.log(` ${chalk.dim('1.')} ${chalk.bold('Scaffold')} agentxchain init --governed --goal "Your project mission"`);
610
610
  console.log(` ${chalk.dim('2.')} ${chalk.bold('Verify')} agentxchain doctor`);
611
- console.log(` ${chalk.dim('3.')} ${chalk.bold('First turn')} agentxchain run`);
611
+ console.log(` ${chalk.dim('3.')} ${chalk.bold('Probe')} agentxchain connector check`);
612
+ console.log(` ${chalk.dim('4.')} ${chalk.bold('First turn')} agentxchain run`);
612
613
  console.log('');
613
614
  console.log(` ${chalk.bold('Docs:')} https://agentxchain.dev/docs/quickstart`);
614
615
  console.log('');
@@ -0,0 +1,121 @@
1
+ import chalk from 'chalk';
2
+
3
+ import { findProjectRoot } from '../lib/config.js';
4
+ import { buildRunDiff, resolveRunHistoryReference } from '../lib/run-diff.js';
5
+
6
+ export async function diffCommand(leftRef, rightRef, opts) {
7
+ const root = findProjectRoot(opts.dir || process.cwd());
8
+ if (!root) {
9
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
10
+ process.exit(1);
11
+ }
12
+
13
+ const leftResult = resolveRunHistoryReference(root, leftRef);
14
+ if (!leftResult.ok) {
15
+ console.error(chalk.red(leftResult.error));
16
+ process.exit(1);
17
+ }
18
+
19
+ const rightResult = resolveRunHistoryReference(root, rightRef);
20
+ if (!rightResult.ok) {
21
+ console.error(chalk.red(rightResult.error));
22
+ process.exit(1);
23
+ }
24
+
25
+ const diff = buildRunDiff(leftResult.entry, rightResult.entry);
26
+ if (opts.json) {
27
+ console.log(JSON.stringify(diff, null, 2));
28
+ return;
29
+ }
30
+
31
+ console.log(formatRunDiffText(diff));
32
+ }
33
+
34
+ function formatRunDiffText(diff) {
35
+ const lines = [];
36
+ lines.push(chalk.bold('Run Diff'));
37
+ lines.push(`${chalk.dim('Left:')} ${formatRunHeader(diff.left)}`);
38
+ lines.push(`${chalk.dim('Right:')} ${formatRunHeader(diff.right)}`);
39
+
40
+ if (!diff.changed) {
41
+ lines.push('');
42
+ lines.push(chalk.green('No differences.'));
43
+ return lines.join('\n');
44
+ }
45
+
46
+ appendChangedSection(lines, 'Changed fields', Object.values(diff.scalar_changes)
47
+ .filter((entry) => entry.changed)
48
+ .map((entry) => `${entry.label}: ${formatValue(entry.left, entry.label)} -> ${formatValue(entry.right, entry.label)}`));
49
+
50
+ appendChangedSection(lines, 'Numeric deltas', Object.values(diff.numeric_changes)
51
+ .filter((entry) => entry.changed)
52
+ .map((entry) => `${entry.label}: ${formatValue(entry.left, entry.label)} -> ${formatValue(entry.right, entry.label)}${formatDelta(entry.delta, entry.label)}`));
53
+
54
+ appendChangedSection(lines, 'List changes', Object.values(diff.list_changes)
55
+ .filter((entry) => entry.changed)
56
+ .flatMap((entry) => {
57
+ const items = [];
58
+ if (entry.added.length > 0) {
59
+ items.push(`${entry.label} added: ${entry.added.join(', ')}`);
60
+ }
61
+ if (entry.removed.length > 0) {
62
+ items.push(`${entry.label} removed: ${entry.removed.join(', ')}`);
63
+ }
64
+ return items;
65
+ }));
66
+
67
+ appendChangedSection(lines, 'Gate changes', diff.gate_changes
68
+ .filter((entry) => entry.changed)
69
+ .map((entry) => `${entry.gate_id}: ${formatValue(entry.left)} -> ${formatValue(entry.right)}`));
70
+
71
+ return lines.join('\n');
72
+ }
73
+
74
+ function appendChangedSection(lines, heading, items) {
75
+ if (items.length === 0) return;
76
+ lines.push('');
77
+ lines.push(chalk.bold(heading));
78
+ for (const item of items) {
79
+ lines.push(`- ${item}`);
80
+ }
81
+ }
82
+
83
+ function formatRunHeader(entry) {
84
+ const status = entry.status || '—';
85
+ const trigger = entry.trigger || 'legacy';
86
+ const recordedAt = entry.recorded_at
87
+ ? new Date(entry.recorded_at).toLocaleString()
88
+ : 'unknown date';
89
+ return `${entry.run_id} (${status}, ${trigger}, ${recordedAt})`;
90
+ }
91
+
92
+ function formatValue(value, label = '') {
93
+ if (value == null) return '—';
94
+ if (typeof value === 'boolean') return value ? 'yes' : 'no';
95
+ if (label === 'Cost' || label === 'Budget') return `$${value.toFixed(4)}`;
96
+ if (label === 'Duration') return formatDuration(value);
97
+ return String(value);
98
+ }
99
+
100
+ function formatDelta(delta, label) {
101
+ if (delta == null || delta === 0) return '';
102
+ if (label === 'Cost' || label === 'Budget') {
103
+ return ` (${delta > 0 ? '+' : ''}$${delta.toFixed(4)})`;
104
+ }
105
+ if (label === 'Duration') {
106
+ return ` (${delta > 0 ? '+' : ''}${formatDuration(delta)})`;
107
+ }
108
+ return ` (${delta > 0 ? '+' : ''}${delta})`;
109
+ }
110
+
111
+ function formatDuration(ms) {
112
+ if (ms < 1000) return `${ms}ms`;
113
+ const secs = Math.floor(ms / 1000);
114
+ if (secs < 60) return `${secs}s`;
115
+ const mins = Math.floor(secs / 60);
116
+ const remainSecs = secs % 60;
117
+ if (mins < 60) return `${mins}m ${remainSecs}s`;
118
+ const hrs = Math.floor(mins / 60);
119
+ const remainMins = mins % 60;
120
+ return `${hrs}h ${remainMins}m`;
121
+ }
@@ -7,6 +7,7 @@ import { validateProject } from '../lib/validation.js';
7
7
  import { getWatchPid } from './watch.js';
8
8
  import { loadNormalizedConfig, detectConfigVersion } from '../lib/normalized-config.js';
9
9
  import { readDaemonState, evaluateDaemonStatus } from '../lib/run-schedule.js';
10
+ import { getGovernedVersionSurface, formatGovernedVersionLabel } from '../lib/protocol-version.js';
10
11
 
11
12
  export async function doctorCommand(opts = {}) {
12
13
  const root = findProjectRoot(process.cwd());
@@ -136,9 +137,11 @@ function governedDoctor(root, rawConfig, opts) {
136
137
 
137
138
  if (opts.json) {
138
139
  const projectId = rawConfig?.project?.id || rawConfig?.project?.name || 'unknown';
140
+ const versionSurface = getGovernedVersionSurface(rawConfig);
139
141
  console.log(JSON.stringify({
140
142
  project: projectId,
141
- config_version: 4,
143
+ ...versionSurface,
144
+ config_version: versionSurface.config_generation,
142
145
  overall,
143
146
  checks,
144
147
  fail_count: failCount,
@@ -149,7 +152,8 @@ function governedDoctor(root, rawConfig, opts) {
149
152
  console.log('');
150
153
  console.log(chalk.bold(' AgentXchain Governed Doctor'));
151
154
  console.log(chalk.dim(' ' + '─'.repeat(44)));
152
- console.log(chalk.dim(` Project: ${projectId} (v4)`));
155
+ console.log(chalk.dim(` Project: ${projectId}`));
156
+ console.log(chalk.dim(` Versioning: ${formatGovernedVersionLabel(rawConfig)}`));
153
157
  console.log('');
154
158
 
155
159
  for (const c of checks) {
@@ -971,6 +971,7 @@ async function initGoverned(opts) {
971
971
  }
972
972
  console.log(` ${chalk.bold('agentxchain template validate')} ${chalk.dim('# prove the scaffold contract before the first turn')}`);
973
973
  console.log(` ${chalk.bold('agentxchain doctor')} ${chalk.dim('# verify runtimes, config, and readiness')}`);
974
+ console.log(` ${chalk.bold('agentxchain connector check')} ${chalk.dim('# live-probe configured runtimes before the first turn')}`);
974
975
  console.log(` ${chalk.bold('git add -A')} ${chalk.dim('# stage the governed scaffold')}`);
975
976
  console.log(` ${chalk.bold('git commit -m "initial governed scaffold"')} ${chalk.dim('# checkpoint the starting state')}`);
976
977
  console.log(` ${chalk.bold('agentxchain step')} ${chalk.dim('# run the first governed turn')}`);
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadConfig, loadProjectContext } from '../lib/config.js';
3
3
  import { validateGovernedProject, validateProject } from '../lib/validation.js';
4
+ import { getGovernedVersionSurface, formatGovernedVersionLabel } from '../lib/protocol-version.js';
4
5
 
5
6
  export async function validateCommand(opts) {
6
7
  const context = loadProjectContext();
@@ -21,9 +22,13 @@ export async function validateCommand(opts) {
21
22
  });
22
23
 
23
24
  if (opts.json) {
25
+ const governedVersionSurface = context.config.protocol_mode === 'governed'
26
+ ? getGovernedVersionSurface(context.rawConfig)
27
+ : {};
24
28
  console.log(JSON.stringify({
25
29
  ...validation,
26
30
  protocol_mode: context.config.protocol_mode,
31
+ ...governedVersionSurface,
27
32
  version: context.version,
28
33
  }, null, 2));
29
34
  } else {
@@ -31,7 +36,11 @@ export async function validateCommand(opts) {
31
36
  console.log(chalk.bold(` AgentXchain Validate (${mode})`));
32
37
  console.log(chalk.dim(' ' + '─'.repeat(44)));
33
38
  console.log(chalk.dim(` Root: ${context.root}`));
34
- console.log(chalk.dim(` Protocol: ${context.config.protocol_mode} (v${context.version})`));
39
+ if (context.config.protocol_mode === 'governed') {
40
+ console.log(chalk.dim(` Protocol: ${context.config.protocol_mode} (${formatGovernedVersionLabel(context.rawConfig)})`));
41
+ } else {
42
+ console.log(chalk.dim(` Protocol: ${context.config.protocol_mode} (v${context.version})`));
43
+ }
35
44
  console.log('');
36
45
 
37
46
  if (validation.ok) {
@@ -0,0 +1,353 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ import {
4
+ buildProviderHeaders,
5
+ buildProviderRequest,
6
+ PROVIDER_ENDPOINTS,
7
+ } from './adapters/api-proxy-adapter.js';
8
+
9
+ const PROBEABLE_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
10
+ const DEFAULT_TIMEOUT_MS = 8_000;
11
+
12
+ function formatCommand(command, args = []) {
13
+ if (Array.isArray(command)) {
14
+ return command.join(' ');
15
+ }
16
+ if (typeof command === 'string' && command.length > 0) {
17
+ return [command, ...(Array.isArray(args) ? args : [])].join(' ');
18
+ }
19
+ return null;
20
+ }
21
+
22
+ function formatTarget(runtime) {
23
+ switch (runtime?.type) {
24
+ case 'api_proxy':
25
+ return [runtime.provider, runtime.model].filter(Boolean).join(' / ') || 'unknown target';
26
+ case 'remote_agent':
27
+ return runtime.url || 'unknown target';
28
+ case 'mcp':
29
+ return runtime.transport === 'streamable_http'
30
+ ? (runtime.url || 'unknown target')
31
+ : (formatCommand(runtime.command, runtime.args) || 'unknown target');
32
+ case 'local_cli':
33
+ return formatCommand(runtime.command, runtime.args) || 'unknown target';
34
+ default:
35
+ return 'unknown target';
36
+ }
37
+ }
38
+
39
+ function commandHead(runtime) {
40
+ if (Array.isArray(runtime?.command)) {
41
+ return runtime.command[0] || null;
42
+ }
43
+ if (typeof runtime?.command === 'string' && runtime.command.trim()) {
44
+ return runtime.command.trim().split(/\s+/)[0];
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function resolveBinary(command) {
50
+ const resolver = process.platform === 'win32' ? 'where' : 'which';
51
+ execFileSync(resolver, [command], { stdio: 'ignore' });
52
+ }
53
+
54
+ function resolveProviderEndpoint(runtime) {
55
+ if (typeof runtime?.base_url === 'string' && runtime.base_url.trim()) {
56
+ return runtime.base_url.trim();
57
+ }
58
+
59
+ const provider = String(runtime?.provider || '').trim().toLowerCase();
60
+ const endpointTemplate = PROVIDER_ENDPOINTS[provider];
61
+ if (!endpointTemplate) return null;
62
+
63
+ if (endpointTemplate.includes('{model}')) {
64
+ return endpointTemplate.replace('{model}', encodeURIComponent(runtime.model || ''));
65
+ }
66
+ return endpointTemplate;
67
+ }
68
+
69
+ function classifyHttpFailure(status, bodyText, authEnv) {
70
+ if (status === 401 || status === 403) {
71
+ return {
72
+ level: 'fail',
73
+ detail: authEnv
74
+ ? `${authEnv} was rejected by the remote endpoint`
75
+ : 'Remote endpoint rejected the request as unauthorized',
76
+ };
77
+ }
78
+ if (status === 404) {
79
+ return {
80
+ level: 'fail',
81
+ detail: 'Configured endpoint or model was not found',
82
+ };
83
+ }
84
+ if (status === 429) {
85
+ return {
86
+ level: 'fail',
87
+ detail: 'Remote endpoint is reachable but currently rate limited',
88
+ };
89
+ }
90
+ return {
91
+ level: 'fail',
92
+ detail: bodyText
93
+ ? `Remote endpoint returned HTTP ${status}: ${bodyText.slice(0, 160)}`
94
+ : `Remote endpoint returned HTTP ${status}`,
95
+ };
96
+ }
97
+
98
+ async function probeHttp({ url, method = 'GET', headers = {}, body, timeoutMs }) {
99
+ const startedAt = Date.now();
100
+ try {
101
+ const response = await fetch(url, {
102
+ method,
103
+ headers,
104
+ body,
105
+ signal: AbortSignal.timeout(timeoutMs),
106
+ });
107
+ const latencyMs = Date.now() - startedAt;
108
+ const responseText = await response.text();
109
+ return {
110
+ ok: true,
111
+ statusCode: response.status,
112
+ latencyMs,
113
+ responseText,
114
+ };
115
+ } catch (error) {
116
+ return {
117
+ ok: false,
118
+ latencyMs: Date.now() - startedAt,
119
+ error,
120
+ };
121
+ }
122
+ }
123
+
124
+ async function probeLocalCommand(runtimeId, runtime, probeKindLabel) {
125
+ const head = commandHead(runtime);
126
+ const base = {
127
+ runtime_id: runtimeId,
128
+ type: runtime.type,
129
+ target: formatTarget(runtime),
130
+ probe_kind: probeKindLabel,
131
+ };
132
+
133
+ if (!head) {
134
+ return {
135
+ ...base,
136
+ level: 'fail',
137
+ detail: 'No command configured',
138
+ };
139
+ }
140
+
141
+ try {
142
+ resolveBinary(head);
143
+ return {
144
+ ...base,
145
+ level: 'pass',
146
+ command: head,
147
+ detail: `${head} is available on PATH`,
148
+ };
149
+ } catch {
150
+ return {
151
+ ...base,
152
+ level: 'fail',
153
+ command: head,
154
+ detail: `${head} was not found on PATH`,
155
+ };
156
+ }
157
+ }
158
+
159
+ async function probeApiProxy(runtimeId, runtime, timeoutMs) {
160
+ const base = {
161
+ runtime_id: runtimeId,
162
+ type: runtime.type,
163
+ target: formatTarget(runtime),
164
+ probe_kind: 'live_api_request',
165
+ auth_env: runtime.auth_env || null,
166
+ };
167
+
168
+ if (runtime.auth_env && !process.env[runtime.auth_env]) {
169
+ return {
170
+ ...base,
171
+ level: 'fail',
172
+ detail: `${runtime.auth_env} is not set`,
173
+ };
174
+ }
175
+
176
+ const endpoint = resolveProviderEndpoint(runtime);
177
+ if (!endpoint) {
178
+ return {
179
+ ...base,
180
+ level: 'fail',
181
+ detail: `No probe endpoint is defined for provider "${runtime.provider || 'unknown'}"`,
182
+ };
183
+ }
184
+
185
+ const provider = String(runtime.provider || '').trim().toLowerCase();
186
+ const apiKey = runtime.auth_env ? process.env[runtime.auth_env] : '';
187
+ const requestBody = JSON.stringify(buildProviderRequest(provider, 'ping', '', runtime.model, 1));
188
+ const headers = buildProviderHeaders(provider, apiKey);
189
+ const result = await probeHttp({
190
+ url: endpoint,
191
+ method: 'POST',
192
+ headers,
193
+ body: requestBody,
194
+ timeoutMs,
195
+ });
196
+
197
+ if (!result.ok) {
198
+ return {
199
+ ...base,
200
+ endpoint,
201
+ level: 'fail',
202
+ latency_ms: result.latencyMs,
203
+ detail: `Probe failed: ${result.error?.name === 'TimeoutError' ? 'timed out' : (result.error?.message || 'network error')}`,
204
+ };
205
+ }
206
+
207
+ if (result.statusCode >= 200 && result.statusCode < 300) {
208
+ return {
209
+ ...base,
210
+ endpoint,
211
+ status_code: result.statusCode,
212
+ latency_ms: result.latencyMs,
213
+ level: 'pass',
214
+ detail: `${runtime.provider} accepted the live probe request`,
215
+ };
216
+ }
217
+
218
+ const classified = classifyHttpFailure(result.statusCode, result.responseText, runtime.auth_env);
219
+ return {
220
+ ...base,
221
+ endpoint,
222
+ status_code: result.statusCode,
223
+ latency_ms: result.latencyMs,
224
+ ...classified,
225
+ };
226
+ }
227
+
228
+ async function probeHttpRuntime(runtimeId, runtime, timeoutMs) {
229
+ const endpoint = runtime.url;
230
+ const base = {
231
+ runtime_id: runtimeId,
232
+ type: runtime.type,
233
+ target: formatTarget(runtime),
234
+ probe_kind: 'live_http_ping',
235
+ endpoint,
236
+ };
237
+
238
+ const result = await probeHttp({
239
+ url: endpoint,
240
+ method: 'GET',
241
+ headers: runtime.headers || {},
242
+ timeoutMs,
243
+ });
244
+
245
+ if (!result.ok) {
246
+ return {
247
+ ...base,
248
+ level: 'fail',
249
+ latency_ms: result.latencyMs,
250
+ detail: `Probe failed: ${result.error?.name === 'TimeoutError' ? 'timed out' : (result.error?.message || 'network error')}`,
251
+ };
252
+ }
253
+
254
+ if ((result.statusCode >= 200 && result.statusCode < 300) || result.statusCode === 405) {
255
+ return {
256
+ ...base,
257
+ level: 'pass',
258
+ status_code: result.statusCode,
259
+ latency_ms: result.latencyMs,
260
+ detail: result.statusCode === 405
261
+ ? 'Endpoint is reachable but rejects GET (expected for some RPC transports)'
262
+ : 'Endpoint responded to the live probe',
263
+ };
264
+ }
265
+
266
+ const classified = classifyHttpFailure(result.statusCode, result.responseText, null);
267
+ return {
268
+ ...base,
269
+ status_code: result.statusCode,
270
+ latency_ms: result.latencyMs,
271
+ ...classified,
272
+ };
273
+ }
274
+
275
+ export async function probeConnectorRuntime(runtimeId, runtime, options = {}) {
276
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
277
+
278
+ if (!runtime || !PROBEABLE_RUNTIME_TYPES.has(runtime.type)) {
279
+ return {
280
+ runtime_id: runtimeId,
281
+ type: runtime?.type || 'unknown',
282
+ target: formatTarget(runtime),
283
+ probe_kind: 'unsupported',
284
+ level: 'fail',
285
+ detail: `Runtime type "${runtime?.type || 'unknown'}" cannot be probed`,
286
+ };
287
+ }
288
+
289
+ if (runtime.type === 'local_cli') {
290
+ return probeLocalCommand(runtimeId, runtime, 'command_presence');
291
+ }
292
+
293
+ if (runtime.type === 'api_proxy') {
294
+ return probeApiProxy(runtimeId, runtime, timeoutMs);
295
+ }
296
+
297
+ if (runtime.type === 'mcp') {
298
+ if (runtime.transport === 'streamable_http') {
299
+ return probeHttpRuntime(runtimeId, runtime, timeoutMs);
300
+ }
301
+ return probeLocalCommand(runtimeId, runtime, 'command_presence');
302
+ }
303
+
304
+ return probeHttpRuntime(runtimeId, runtime, timeoutMs);
305
+ }
306
+
307
+ export async function probeConfiguredConnectors(config, options = {}) {
308
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
309
+ const requestedRuntimeId = options.runtimeId || null;
310
+ const onProbeStart = typeof options.onProbeStart === 'function' ? options.onProbeStart : null;
311
+
312
+ const runtimeEntries = Object.entries(config?.runtimes || {})
313
+ .filter(([, runtime]) => PROBEABLE_RUNTIME_TYPES.has(runtime?.type))
314
+ .sort(([left], [right]) => left.localeCompare(right, 'en'));
315
+
316
+ if (requestedRuntimeId) {
317
+ const match = runtimeEntries.find(([runtimeId]) => runtimeId === requestedRuntimeId);
318
+ if (!match) {
319
+ return {
320
+ ok: false,
321
+ error: `Unknown connector runtime "${requestedRuntimeId}"`,
322
+ exitCode: 2,
323
+ };
324
+ }
325
+ const [runtimeId, runtime] = match;
326
+ onProbeStart?.(runtimeId, runtime);
327
+ const connector = await probeConnectorRuntime(runtimeId, runtime, { timeoutMs });
328
+ return summarizeResults([connector], timeoutMs);
329
+ }
330
+
331
+ const connectors = [];
332
+ for (const [runtimeId, runtime] of runtimeEntries) {
333
+ onProbeStart?.(runtimeId, runtime);
334
+ connectors.push(await probeConnectorRuntime(runtimeId, runtime, { timeoutMs }));
335
+ }
336
+ return summarizeResults(connectors, timeoutMs);
337
+ }
338
+
339
+ function summarizeResults(connectors, timeoutMs) {
340
+ const failCount = connectors.filter((item) => item.level === 'fail').length;
341
+ const passCount = connectors.filter((item) => item.level === 'pass').length;
342
+ return {
343
+ ok: failCount === 0,
344
+ exitCode: failCount === 0 ? 0 : 1,
345
+ overall: failCount === 0 ? 'pass' : 'fail',
346
+ timeout_ms: timeoutMs,
347
+ pass_count: passCount,
348
+ fail_count: failCount,
349
+ connectors,
350
+ };
351
+ }
352
+
353
+ export { DEFAULT_TIMEOUT_MS, PROBEABLE_RUNTIME_TYPES };
@@ -0,0 +1,40 @@
1
+ export const CURRENT_PROTOCOL_VERSION = 'v6';
2
+ export const CURRENT_CONFIG_GENERATION = 4;
3
+
4
+ export function getGovernedConfigSchemaVersion(rawConfig) {
5
+ if (!rawConfig || typeof rawConfig !== 'object') {
6
+ return null;
7
+ }
8
+
9
+ if (typeof rawConfig.schema_version === 'string' && rawConfig.schema_version.trim()) {
10
+ return rawConfig.schema_version.trim();
11
+ }
12
+
13
+ if (rawConfig.schema_version === 4) {
14
+ return '1.0';
15
+ }
16
+
17
+ return null;
18
+ }
19
+
20
+ export function getGovernedVersionSurface(rawConfig) {
21
+ return {
22
+ protocol_version: CURRENT_PROTOCOL_VERSION,
23
+ config_generation: CURRENT_CONFIG_GENERATION,
24
+ config_schema_version: getGovernedConfigSchemaVersion(rawConfig),
25
+ };
26
+ }
27
+
28
+ export function formatGovernedVersionLabel(rawConfig) {
29
+ const surface = getGovernedVersionSurface(rawConfig);
30
+ const parts = [
31
+ `protocol ${surface.protocol_version}`,
32
+ `config generation v${surface.config_generation}`,
33
+ ];
34
+
35
+ if (surface.config_schema_version) {
36
+ parts.push(`config schema ${surface.config_schema_version}`);
37
+ }
38
+
39
+ return parts.join(', ');
40
+ }
package/src/lib/report.js CHANGED
@@ -149,6 +149,67 @@ function formatDurationCompact(ms) {
149
149
  return `${hrs}h ${remainMins}m`;
150
150
  }
151
151
 
152
+ function formatTokenCount(value) {
153
+ if (typeof value !== 'number' || !Number.isFinite(value)) return 'n/a';
154
+ return value.toLocaleString('en-US');
155
+ }
156
+
157
+ export function computeCostSummary(turns) {
158
+ if (!Array.isArray(turns) || turns.length === 0) return null;
159
+
160
+ let totalUsd = 0;
161
+ let costedTurnCount = 0;
162
+ let hasAnyTokens = false;
163
+ let totalInputTokens = 0;
164
+ let totalOutputTokens = 0;
165
+ const roleMap = new Map();
166
+ const phaseMap = new Map();
167
+
168
+ for (const t of turns) {
169
+ const costUsd = typeof t.cost_usd === 'number' && Number.isFinite(t.cost_usd) ? t.cost_usd : 0;
170
+ const hasFiniteCost = typeof t.cost_usd === 'number' && Number.isFinite(t.cost_usd);
171
+ if (hasFiniteCost) {
172
+ totalUsd += costUsd;
173
+ costedTurnCount++;
174
+ }
175
+
176
+ const inTok = typeof t.input_tokens === 'number' && Number.isFinite(t.input_tokens) ? t.input_tokens : 0;
177
+ const outTok = typeof t.output_tokens === 'number' && Number.isFinite(t.output_tokens) ? t.output_tokens : 0;
178
+ if (t.input_tokens != null || t.output_tokens != null) hasAnyTokens = true;
179
+ totalInputTokens += inTok;
180
+ totalOutputTokens += outTok;
181
+
182
+ // Aggregate by role
183
+ const role = t.role || 'unknown';
184
+ if (!roleMap.has(role)) roleMap.set(role, { role, usd: 0, turns: 0, input_tokens: 0, output_tokens: 0 });
185
+ const roleEntry = roleMap.get(role);
186
+ roleEntry.usd += costUsd;
187
+ roleEntry.turns++;
188
+ roleEntry.input_tokens += inTok;
189
+ roleEntry.output_tokens += outTok;
190
+
191
+ // Aggregate by phase
192
+ const phase = t.phase || 'unknown';
193
+ if (!phaseMap.has(phase)) phaseMap.set(phase, { phase, usd: 0, turns: 0 });
194
+ const phaseEntry = phaseMap.get(phase);
195
+ phaseEntry.usd += costUsd;
196
+ phaseEntry.turns++;
197
+ }
198
+
199
+ const byRole = [...roleMap.values()].sort((a, b) => a.role.localeCompare(b.role, 'en'));
200
+ const byPhase = [...phaseMap.values()].sort((a, b) => a.phase.localeCompare(b.phase, 'en'));
201
+
202
+ return {
203
+ total_usd: totalUsd,
204
+ total_input_tokens: hasAnyTokens ? totalInputTokens : null,
205
+ total_output_tokens: hasAnyTokens ? totalOutputTokens : null,
206
+ turn_count: turns.length,
207
+ costed_turn_count: costedTurnCount,
208
+ by_role: byRole,
209
+ by_phase: byPhase,
210
+ };
211
+ }
212
+
152
213
  function formatTurnTimelineTime(turn) {
153
214
  const acceptedAt = turn.accepted_at || 'n/a';
154
215
  const duration = formatDurationCompact(turn.duration_ms);
@@ -188,6 +249,8 @@ function extractHistoryTimeline(artifact) {
188
249
  decisions: Array.isArray(e.decisions) ? e.decisions.map((d) => d?.id || d).filter(Boolean) : [],
189
250
  objections: Array.isArray(e.objections) ? e.objections.map((o) => o?.id || o).filter(Boolean) : [],
190
251
  cost_usd: typeof e.cost?.total_usd === 'number' ? e.cost.total_usd : null,
252
+ input_tokens: typeof e.cost?.input_tokens === 'number' && Number.isFinite(e.cost.input_tokens) ? e.cost.input_tokens : null,
253
+ output_tokens: typeof e.cost?.output_tokens === 'number' && Number.isFinite(e.cost.output_tokens) ? e.cost.output_tokens : null,
191
254
  started_at: e.started_at || null,
192
255
  duration_ms: typeof e.duration_ms === 'number' ? e.duration_ms : null,
193
256
  accepted_at: e.accepted_at || null,
@@ -808,6 +871,7 @@ function buildRunSubject(artifact) {
808
871
  retained_turn_ids: retainedTurns,
809
872
  active_roles: activeRoles,
810
873
  budget_status: normalizeBudgetStatus(artifact.state?.budget_status),
874
+ cost_summary: computeCostSummary(turns),
811
875
  created_at: timing.created_at,
812
876
  completed_at: timing.completed_at,
813
877
  duration_seconds: timing.duration_seconds,
@@ -1086,6 +1150,30 @@ export function formatGovernanceReportText(report) {
1086
1150
  `Coordinator artifacts: ${yesNo(artifacts.coordinator_present)}`,
1087
1151
  );
1088
1152
 
1153
+ if (run.cost_summary) {
1154
+ const cs = run.cost_summary;
1155
+ lines.push('', 'Cost Summary:');
1156
+ lines.push(` Total: ${formatUsd(cs.total_usd)} across ${cs.turn_count} turn${cs.turn_count !== 1 ? 's' : ''} (${cs.costed_turn_count} with cost data)`);
1157
+ if (cs.total_input_tokens != null || cs.total_output_tokens != null) {
1158
+ lines.push(` Tokens: ${formatTokenCount(cs.total_input_tokens)} input / ${formatTokenCount(cs.total_output_tokens)} output`);
1159
+ }
1160
+ if (cs.by_role.length > 0) {
1161
+ lines.push(' By role:');
1162
+ for (const r of cs.by_role) {
1163
+ const tokens = r.input_tokens || r.output_tokens
1164
+ ? `, ${formatTokenCount(r.input_tokens)} in / ${formatTokenCount(r.output_tokens)} out`
1165
+ : '';
1166
+ lines.push(` ${r.role}: ${formatUsd(r.usd)} (${r.turns} turn${r.turns !== 1 ? 's' : ''}${tokens})`);
1167
+ }
1168
+ }
1169
+ if (cs.by_phase.length > 0) {
1170
+ lines.push(' By phase:');
1171
+ for (const p of cs.by_phase) {
1172
+ lines.push(` ${p.phase}: ${formatUsd(p.usd)} (${p.turns} turn${p.turns !== 1 ? 's' : ''})`);
1173
+ }
1174
+ }
1175
+ }
1176
+
1089
1177
  if (run.turns && run.turns.length > 0) {
1090
1178
  lines.push('', 'Turn Timeline:');
1091
1179
  for (let i = 0; i < run.turns.length; i++) {
@@ -1519,6 +1607,27 @@ export function formatGovernanceReportMarkdown(report) {
1519
1607
  `- Coordinator artifacts: \`${yesNo(artifacts.coordinator_present)}\``,
1520
1608
  );
1521
1609
 
1610
+ if (run.cost_summary) {
1611
+ const cs = run.cost_summary;
1612
+ lines.push('', '## Cost Summary', '');
1613
+ lines.push(`**Total:** ${formatUsd(cs.total_usd)} across ${cs.turn_count} turn${cs.turn_count !== 1 ? 's' : ''} (${cs.costed_turn_count} with cost data)`);
1614
+ if (cs.total_input_tokens != null || cs.total_output_tokens != null) {
1615
+ lines.push(`**Tokens:** ${formatTokenCount(cs.total_input_tokens)} input / ${formatTokenCount(cs.total_output_tokens)} output`);
1616
+ }
1617
+ if (cs.by_role.length > 0) {
1618
+ lines.push('', '| Role | Cost | Turns | Input Tokens | Output Tokens |', '|------|------|-------|--------------|---------------|');
1619
+ for (const r of cs.by_role) {
1620
+ lines.push(`| ${r.role} | ${formatUsd(r.usd)} | ${r.turns} | ${formatTokenCount(r.input_tokens)} | ${formatTokenCount(r.output_tokens)} |`);
1621
+ }
1622
+ }
1623
+ if (cs.by_phase.length > 0) {
1624
+ lines.push('', '| Phase | Cost | Turns |', '|-------|------|-------|');
1625
+ for (const p of cs.by_phase) {
1626
+ lines.push(`| ${p.phase} | ${formatUsd(p.usd)} | ${p.turns} |`);
1627
+ }
1628
+ }
1629
+ }
1630
+
1522
1631
  if (run.turns && run.turns.length > 0) {
1523
1632
  lines.push('', '## Turn Timeline', '', '| # | Role | Phase | Summary | Files | Cost | Time |', '|---|------|-------|---------|-------|------|------|');
1524
1633
  for (let i = 0; i < run.turns.length; i++) {
@@ -0,0 +1,194 @@
1
+ import { isInheritable, queryRunHistory } from './run-history.js';
2
+ import { getRunTriggerLabel } from './run-provenance.js';
3
+
4
+ const SCALAR_FIELDS = [
5
+ ['status', 'Status'],
6
+ ['trigger', 'Trigger'],
7
+ ['template', 'Template'],
8
+ ['connector_used', 'Connector'],
9
+ ['model_used', 'Model'],
10
+ ['blocked_reason', 'Blocked reason'],
11
+ ['headline', 'Headline'],
12
+ ['inheritable', 'Inheritance snapshot'],
13
+ ];
14
+
15
+ const NUMERIC_FIELDS = [
16
+ ['total_turns', 'Turns'],
17
+ ['decisions_count', 'Decisions'],
18
+ ['total_cost_usd', 'Cost'],
19
+ ['budget_limit_usd', 'Budget'],
20
+ ['duration_ms', 'Duration'],
21
+ ];
22
+
23
+ export function resolveRunHistoryReference(root, ref) {
24
+ const entries = queryRunHistory(root, {});
25
+
26
+ if (entries.length === 0) {
27
+ return {
28
+ ok: false,
29
+ error: 'No run history found. Run at least one governed run first.',
30
+ };
31
+ }
32
+
33
+ const exact = entries.find((entry) => entry.run_id === ref);
34
+ if (exact) {
35
+ return { ok: true, entry: exact, resolved_ref: exact.run_id, match_kind: 'exact' };
36
+ }
37
+
38
+ const prefixMatches = entries.filter((entry) => typeof entry.run_id === 'string' && entry.run_id.startsWith(ref));
39
+ if (prefixMatches.length === 1) {
40
+ return {
41
+ ok: true,
42
+ entry: prefixMatches[0],
43
+ resolved_ref: prefixMatches[0].run_id,
44
+ match_kind: 'prefix',
45
+ };
46
+ }
47
+
48
+ if (prefixMatches.length > 1) {
49
+ return {
50
+ ok: false,
51
+ error: `Run reference "${ref}" is ambiguous. Matches: ${prefixMatches.map((entry) => entry.run_id).join(', ')}`,
52
+ };
53
+ }
54
+
55
+ return {
56
+ ok: false,
57
+ error: `Run ${ref} not found in run history.`,
58
+ };
59
+ }
60
+
61
+ export function buildRunDiff(leftEntry, rightEntry) {
62
+ const left = normalizeRunEntry(leftEntry);
63
+ const right = normalizeRunEntry(rightEntry);
64
+
65
+ const scalar_changes = Object.fromEntries(
66
+ SCALAR_FIELDS.map(([field, label]) => {
67
+ const leftValue = left[field];
68
+ const rightValue = right[field];
69
+ return [field, {
70
+ label,
71
+ left: leftValue,
72
+ right: rightValue,
73
+ changed: !isEqual(leftValue, rightValue),
74
+ }];
75
+ }),
76
+ );
77
+
78
+ const numeric_changes = Object.fromEntries(
79
+ NUMERIC_FIELDS.map(([field, label]) => {
80
+ const leftValue = left[field];
81
+ const rightValue = right[field];
82
+ return [field, {
83
+ label,
84
+ left: leftValue,
85
+ right: rightValue,
86
+ changed: !isEqual(leftValue, rightValue),
87
+ delta: typeof leftValue === 'number' && typeof rightValue === 'number'
88
+ ? rightValue - leftValue
89
+ : null,
90
+ }];
91
+ }),
92
+ );
93
+
94
+ const list_changes = {
95
+ phases_completed: buildListChange('Phases', left.phases_completed, right.phases_completed),
96
+ roles_used: buildListChange('Roles', left.roles_used, right.roles_used),
97
+ };
98
+
99
+ const gate_changes = buildGateChanges(left.gate_results, right.gate_results);
100
+
101
+ const changed = [
102
+ ...Object.values(scalar_changes),
103
+ ...Object.values(numeric_changes),
104
+ ].some((entry) => entry.changed)
105
+ || Object.values(list_changes).some((entry) => entry.changed)
106
+ || gate_changes.some((entry) => entry.changed);
107
+
108
+ return {
109
+ changed,
110
+ left,
111
+ right,
112
+ scalar_changes,
113
+ numeric_changes,
114
+ list_changes,
115
+ gate_changes,
116
+ };
117
+ }
118
+
119
+ function normalizeRunEntry(entry) {
120
+ return {
121
+ run_id: entry.run_id || null,
122
+ status: entry.status || null,
123
+ trigger: getRunTriggerLabel(entry.provenance),
124
+ template: entry.template || null,
125
+ connector_used: entry.connector_used || null,
126
+ model_used: entry.model_used || null,
127
+ blocked_reason: entry.blocked_reason || null,
128
+ headline: entry.retrospective?.headline || null,
129
+ inheritable: isInheritable(entry),
130
+ total_turns: typeof entry.total_turns === 'number' ? entry.total_turns : null,
131
+ decisions_count: typeof entry.decisions_count === 'number' ? entry.decisions_count : null,
132
+ total_cost_usd: typeof entry.total_cost_usd === 'number' ? entry.total_cost_usd : null,
133
+ budget_limit_usd: typeof entry.budget_limit_usd === 'number' ? entry.budget_limit_usd : null,
134
+ duration_ms: typeof entry.duration_ms === 'number' ? entry.duration_ms : null,
135
+ phases_completed: normalizeStringArray(entry.phases_completed),
136
+ roles_used: normalizeStringArray(entry.roles_used),
137
+ gate_results: normalizeGateResults(entry.gate_results),
138
+ recorded_at: entry.recorded_at || null,
139
+ };
140
+ }
141
+
142
+ function buildListChange(label, left, right) {
143
+ const leftSet = new Set(left);
144
+ const rightSet = new Set(right);
145
+ const added = right.filter((value) => !leftSet.has(value));
146
+ const removed = left.filter((value) => !rightSet.has(value));
147
+ return {
148
+ label,
149
+ left,
150
+ right,
151
+ added,
152
+ removed,
153
+ changed: added.length > 0 || removed.length > 0,
154
+ };
155
+ }
156
+
157
+ function buildGateChanges(leftGateResults, rightGateResults) {
158
+ const gateIds = [...new Set([
159
+ ...Object.keys(leftGateResults),
160
+ ...Object.keys(rightGateResults),
161
+ ])].sort((a, b) => a.localeCompare(b, 'en'));
162
+
163
+ return gateIds.map((gateId) => {
164
+ const left = gateId in leftGateResults ? leftGateResults[gateId] : null;
165
+ const right = gateId in rightGateResults ? rightGateResults[gateId] : null;
166
+ return {
167
+ gate_id: gateId,
168
+ left,
169
+ right,
170
+ changed: !isEqual(left, right),
171
+ };
172
+ });
173
+ }
174
+
175
+ function normalizeStringArray(value) {
176
+ if (!Array.isArray(value)) return [];
177
+ return [...new Set(value
178
+ .filter((item) => typeof item === 'string' && item.trim().length > 0)
179
+ .map((item) => item.trim()))]
180
+ .sort((a, b) => a.localeCompare(b, 'en'));
181
+ }
182
+
183
+ function normalizeGateResults(value) {
184
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
185
+ return Object.fromEntries(
186
+ Object.entries(value)
187
+ .filter(([gateId]) => typeof gateId === 'string' && gateId.trim().length > 0)
188
+ .map(([gateId, result]) => [gateId.trim(), result ?? null]),
189
+ );
190
+ }
191
+
192
+ function isEqual(left, right) {
193
+ return JSON.stringify(left) === JSON.stringify(right);
194
+ }