agentxchain 2.104.0 → 2.105.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.
Files changed (62) hide show
  1. package/README.md +12 -6
  2. package/bin/agentxchain.js +5 -5
  3. package/dashboard/app.js +111 -7
  4. package/dashboard/components/blocked.js +95 -11
  5. package/dashboard/components/blockers.js +85 -86
  6. package/dashboard/components/coordinator-timeouts.js +13 -0
  7. package/dashboard/components/cross-repo.js +17 -12
  8. package/dashboard/components/gate.js +31 -11
  9. package/dashboard/components/initiative.js +173 -78
  10. package/dashboard/components/ledger.js +28 -0
  11. package/dashboard/components/live-status.js +39 -0
  12. package/dashboard/components/run-history.js +76 -1
  13. package/dashboard/components/timeline.js +5 -1
  14. package/dashboard/index.html +21 -0
  15. package/dashboard/live-observer.js +91 -0
  16. package/package.json +1 -1
  17. package/scripts/release-bump.sh +26 -3
  18. package/src/commands/accept-turn.js +3 -3
  19. package/src/commands/decisions.js +98 -29
  20. package/src/commands/diff.js +27 -4
  21. package/src/commands/doctor.js +48 -16
  22. package/src/commands/history.js +21 -3
  23. package/src/commands/multi.js +223 -54
  24. package/src/commands/phase.js +11 -13
  25. package/src/commands/reject-turn.js +1 -1
  26. package/src/commands/restart.js +28 -11
  27. package/src/commands/resume.js +6 -6
  28. package/src/commands/role.js +51 -14
  29. package/src/commands/run.js +5 -11
  30. package/src/commands/status.js +145 -13
  31. package/src/commands/step.js +36 -29
  32. package/src/lib/admission-control.js +14 -12
  33. package/src/lib/blocked-state.js +150 -0
  34. package/src/lib/conflict-actions.js +17 -0
  35. package/src/lib/context-section-parser.js +2 -0
  36. package/src/lib/continuity-status.js +1 -1
  37. package/src/lib/coordinator-blocker-presentation.js +127 -0
  38. package/src/lib/coordinator-event-narrative.js +43 -0
  39. package/src/lib/coordinator-gate-approval.js +98 -0
  40. package/src/lib/coordinator-gate-evaluation-presentation.js +57 -0
  41. package/src/lib/coordinator-next-actions.js +128 -0
  42. package/src/lib/coordinator-pending-gate-presentation.js +79 -0
  43. package/src/lib/coordinator-presentation-detail.js +11 -0
  44. package/src/lib/coordinator-repo-snapshots.js +53 -0
  45. package/src/lib/coordinator-repo-status-presentation.js +134 -0
  46. package/src/lib/dashboard/actions.js +105 -29
  47. package/src/lib/dashboard/bridge-server.js +7 -0
  48. package/src/lib/dashboard/coordinator-blockers.js +17 -0
  49. package/src/lib/dashboard/coordinator-repo-status.js +50 -0
  50. package/src/lib/dashboard/coordinator-timeout-status.js +34 -11
  51. package/src/lib/dashboard/state-reader.js +36 -1
  52. package/src/lib/dispatch-bundle.js +23 -0
  53. package/src/lib/export-diff.js +70 -38
  54. package/src/lib/export-verifier.js +3 -0
  55. package/src/lib/history-diff-summary.js +249 -0
  56. package/src/lib/manual-qa-fallback.js +18 -0
  57. package/src/lib/normalized-config.js +27 -22
  58. package/src/lib/recent-event-summary.js +132 -0
  59. package/src/lib/repo-decisions.js +69 -28
  60. package/src/lib/report.js +353 -145
  61. package/src/lib/run-diff.js +4 -0
  62. package/src/lib/runtime-capabilities.js +222 -0
@@ -0,0 +1,91 @@
1
+ export const LIVE_OBSERVER_STALE_MS = 15_000;
2
+
3
+ function formatEventScope(scope) {
4
+ return scope === 'coordinator' ? 'coordinator' : 'run';
5
+ }
6
+
7
+ function resolveTimestamp(event) {
8
+ if (typeof event?.timestamp === 'string' && event.timestamp.trim()) {
9
+ return event.timestamp;
10
+ }
11
+ if (typeof event?.observedAt === 'string' && event.observedAt.trim()) {
12
+ return `${event.observedAt} (observed)`;
13
+ }
14
+ return 'unknown time';
15
+ }
16
+
17
+ export function buildLiveMeta({
18
+ connected,
19
+ lastRefreshAt,
20
+ lastEvent,
21
+ scope = 'run',
22
+ now = Date.now(),
23
+ staleAfterMs = LIVE_OBSERVER_STALE_MS,
24
+ } = {}) {
25
+ const refreshMs = typeof lastRefreshAt === 'string' ? new Date(lastRefreshAt).getTime() : Number.NaN;
26
+ const hasRefresh = Number.isFinite(refreshMs);
27
+ const scopeLabel = formatEventScope(scope);
28
+
29
+ let freshnessState = 'connecting';
30
+ let freshnessLabel = 'Connecting';
31
+
32
+ if (!connected) {
33
+ freshnessState = 'disconnected';
34
+ freshnessLabel = 'Disconnected';
35
+ } else if (hasRefresh) {
36
+ freshnessState = (now - refreshMs) > staleAfterMs ? 'stale' : 'live';
37
+ freshnessLabel = freshnessState === 'live' ? 'Live' : 'Stale';
38
+ }
39
+
40
+ const refreshDetail = hasRefresh
41
+ ? `Updated ${lastRefreshAt}`
42
+ : connected
43
+ ? 'Waiting for first dashboard refresh'
44
+ : 'Waiting for dashboard reconnect';
45
+
46
+ const connectionDetail = connected ? 'WebSocket connected' : 'WebSocket disconnected';
47
+
48
+ let eventDetail = `No ${scopeLabel} events observed yet`;
49
+ if (lastEvent) {
50
+ const repoSuffix = scope === 'coordinator' && lastEvent.repoId ? ` from ${lastEvent.repoId}` : '';
51
+ eventDetail = `Last ${scopeLabel} event: ${lastEvent.type || 'unknown_event'}${repoSuffix} at ${resolveTimestamp(lastEvent)}`;
52
+ }
53
+
54
+ return {
55
+ title: scope === 'coordinator' ? 'Live Coordinator Feed' : 'Live Run Feed',
56
+ freshness_state: freshnessState,
57
+ freshness_label: freshnessLabel,
58
+ refresh_detail: refreshDetail,
59
+ connection_detail: connectionDetail,
60
+ event_detail: eventDetail,
61
+ };
62
+ }
63
+
64
+ export function createLiveEventFromMessage(message, observedAt = new Date().toISOString()) {
65
+ if (message?.type === 'event') {
66
+ return {
67
+ type: message.event?.event_type || 'unknown_event',
68
+ timestamp: message.event?.timestamp || null,
69
+ observedAt,
70
+ };
71
+ }
72
+
73
+ if (message?.type === 'coordinator_event') {
74
+ return {
75
+ type: message.event?.event_type || 'unknown_event',
76
+ timestamp: message.event?.timestamp || null,
77
+ observedAt,
78
+ repoId: message.repo_id || message.event?.repo_id || null,
79
+ };
80
+ }
81
+
82
+ return null;
83
+ }
84
+
85
+ export function shouldRefreshViewForLiveMessage(viewName, messageType) {
86
+ if (messageType === 'invalidate') return true;
87
+ if (messageType === 'coordinator_event') {
88
+ return viewName === 'cross-repo' || viewName === 'gate';
89
+ }
90
+ return false;
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.104.0",
3
+ "version": "2.105.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # Release identity creation — replaces raw `npm version <semver>`.
3
3
  # Creates version bump commit + annotated tag with fail-closed verification.
4
- # Usage: bash scripts/release-bump.sh --target-version <semver> [--skip-preflight]
4
+ # Usage: bash scripts/release-bump.sh --target-version <semver> --coauthored-by "Name <email>" [--skip-preflight]
5
5
  set -euo pipefail
6
6
 
7
7
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -10,10 +10,11 @@ REPO_ROOT="$(cd "${CLI_DIR}/.." && pwd)"
10
10
  cd "$CLI_DIR"
11
11
 
12
12
  TARGET_VERSION=""
13
+ COAUTHORED_BY=""
13
14
  SKIP_PREFLIGHT=0
14
15
 
15
16
  usage() {
16
- echo "Usage: bash scripts/release-bump.sh --target-version <semver> [--skip-preflight]" >&2
17
+ echo "Usage: bash scripts/release-bump.sh --target-version <semver> --coauthored-by \"Name <email>\" [--skip-preflight]" >&2
17
18
  }
18
19
 
19
20
  while [[ $# -gt 0 ]]; do
@@ -32,6 +33,15 @@ while [[ $# -gt 0 ]]; do
32
33
  TARGET_VERSION="$2"
33
34
  shift 2
34
35
  ;;
36
+ --coauthored-by)
37
+ if [[ -z "${2:-}" ]]; then
38
+ echo "Error: --coauthored-by requires a trailer value like \"Name <email>\"" >&2
39
+ usage
40
+ exit 1
41
+ fi
42
+ COAUTHORED_BY="$2"
43
+ shift 2
44
+ ;;
35
45
  --skip-preflight)
36
46
  SKIP_PREFLIGHT=1
37
47
  shift
@@ -49,6 +59,12 @@ if [[ -z "$TARGET_VERSION" ]]; then
49
59
  exit 1
50
60
  fi
51
61
 
62
+ if [[ -z "$COAUTHORED_BY" ]]; then
63
+ echo "Error: --coauthored-by is required so the release commit carries the mandated trailer" >&2
64
+ usage
65
+ exit 1
66
+ fi
67
+
52
68
  echo "AgentXchain Release Identity: ${TARGET_VERSION}"
53
69
  echo "============================================="
54
70
 
@@ -280,13 +296,20 @@ echo " OK: version files and allowed release surfaces staged"
280
296
 
281
297
  # 9. Create release commit
282
298
  echo "[9/10] Creating release commit..."
283
- git commit -m "${TARGET_VERSION}"
299
+ git commit -m "${TARGET_VERSION}
300
+
301
+ Co-Authored-By: ${COAUTHORED_BY}"
284
302
  RELEASE_SHA=$(git rev-parse HEAD)
285
303
  COMMIT_MSG=$(git log -1 --format=%s)
286
304
  if [[ "$COMMIT_MSG" != "$TARGET_VERSION" ]]; then
287
305
  echo "FAIL: commit message is '${COMMIT_MSG}', expected '${TARGET_VERSION}'" >&2
288
306
  exit 1
289
307
  fi
308
+ COMMIT_BODY=$(git log -1 --format=%B)
309
+ if [[ "$COMMIT_BODY" != *"Co-Authored-By: ${COAUTHORED_BY}"* ]]; then
310
+ echo "FAIL: release commit body is missing the required Co-Authored-By trailer" >&2
311
+ exit 1
312
+ fi
290
313
  echo " OK: commit ${RELEASE_SHA:0:7} with message '${TARGET_VERSION}'"
291
314
 
292
315
  # 9.5. Inline preflight gate — tests, pack, and docs build must pass before tag
@@ -54,7 +54,7 @@ export async function acceptTurnCommand(opts = {}) {
54
54
  }
55
55
 
56
56
  if (result.error_code?.startsWith('hook_') || result.error_code === 'hook_blocked') {
57
- const recovery = deriveRecoveryDescriptor(result.state);
57
+ const recovery = deriveRecoveryDescriptor(result.state, config);
58
58
  const activeTurn = result.state?.current_turn;
59
59
  const hookName = result.hookResults?.blocker?.hook_name
60
60
  || result.hookResults?.results?.find((entry) => entry.hook_name)?.hook_name
@@ -94,7 +94,7 @@ export async function acceptTurnCommand(opts = {}) {
94
94
  console.log(` ${chalk.dim('Overlap:')} ${(result.conflict.overlap_ratio * 100).toFixed(0)}%`);
95
95
  console.log(` ${chalk.dim('Suggest:')} ${result.conflict.suggested_resolution}`);
96
96
  if (result.state?.status === 'blocked') {
97
- const recovery = deriveRecoveryDescriptor(result.state);
97
+ const recovery = deriveRecoveryDescriptor(result.state, config);
98
98
  if (recovery) {
99
99
  console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
100
100
  }
@@ -177,7 +177,7 @@ export async function acceptTurnCommand(opts = {}) {
177
177
  }
178
178
  console.log('');
179
179
 
180
- const recovery = deriveRecoveryDescriptor(result.state);
180
+ const recovery = deriveRecoveryDescriptor(result.state, config);
181
181
  if (recovery) {
182
182
  console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
183
183
  console.log(` ${chalk.dim('Owner:')} ${recovery.owner}`);
@@ -7,7 +7,14 @@
7
7
  import { resolve } from 'path';
8
8
  import { existsSync, readFileSync } from 'fs';
9
9
  import chalk from 'chalk';
10
- import { readRepoDecisions, getActiveRepoDecisions, getRepoDecisionById, resolveDecisionAuthority } from '../lib/repo-decisions.js';
10
+ import {
11
+ readRepoDecisions,
12
+ getActiveRepoDecisions,
13
+ getRepoDecisionById,
14
+ resolveDecisionAuthority,
15
+ getDecisionAuthorityMetadata,
16
+ summarizeRepoDecisions,
17
+ } from '../lib/repo-decisions.js';
11
18
 
12
19
  /**
13
20
  * @param {object} opts - { json?: boolean, all?: boolean, show?: string, dir?: string }
@@ -18,6 +25,7 @@ export async function decisionsCommand(opts) {
18
25
  console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
19
26
  process.exit(1);
20
27
  }
28
+ const config = loadConfig(root);
21
29
 
22
30
  // ── Show single decision ───────────────────────────────────────────────
23
31
  if (opts.show) {
@@ -26,59 +34,68 @@ export async function decisionsCommand(opts) {
26
34
  console.error(chalk.red(`Decision ${opts.show} not found in repo decisions.`));
27
35
  process.exit(1);
28
36
  }
37
+ const enriched = enrichDecision(dec, config);
29
38
  if (opts.json) {
30
- console.log(JSON.stringify(dec, null, 2));
39
+ console.log(JSON.stringify(enriched, null, 2));
31
40
  return;
32
41
  }
33
- console.log(chalk.bold(`Decision ${dec.id}`));
34
- console.log(` Category: ${dec.category}`);
35
- console.log(` Statement: ${dec.statement}`);
36
- console.log(` Rationale: ${dec.rationale}`);
37
- console.log(` Status: ${formatStatus(dec.status)}`);
38
- console.log(` Role: ${dec.role || '—'}`);
39
- console.log(` Phase: ${dec.phase || '—'}`);
40
- console.log(` Run: ${(dec.run_id || '—').slice(0, 16)}`);
41
- console.log(` Turn: ${(dec.turn_id || '—').slice(0, 16)}`);
42
- console.log(` Durability: ${dec.durability || 'repo'}`);
42
+ console.log(chalk.bold(`Decision ${enriched.id}`));
43
+ console.log(` Category: ${enriched.category || '—'}`);
44
+ console.log(` Statement: ${enriched.statement || '—'}`);
45
+ console.log(` Rationale: ${enriched.rationale || '—'}`);
46
+ console.log(` Status: ${formatStatus(enriched.status)}`);
47
+ console.log(` Binding: ${formatBindingState(enriched.binding_state)}`);
48
+ console.log(` Role: ${enriched.role || '—'}`);
49
+ console.log(` Phase: ${enriched.phase || '—'}`);
50
+ console.log(` Run: ${(enriched.run_id || '—').slice(0, 16)}`);
51
+ console.log(` Turn: ${(enriched.turn_id || '').slice(0, 16)}`);
52
+ console.log(` Durability: ${enriched.durability || 'repo'}`);
43
53
  // Show decision authority if config has it
44
- const config = loadConfig(root);
45
- if (config && dec.role) {
46
- const auth = resolveDecisionAuthority(dec.role, config);
54
+ if (config && enriched.role) {
55
+ const auth = resolveDecisionAuthority(enriched.role, config);
47
56
  if (auth !== null && !(typeof auth === 'object' && auth.unknown)) {
48
- console.log(` Authority: ${auth} (${dec.role})`);
57
+ console.log(` Authority: ${auth} (${enriched.role})`);
49
58
  }
50
59
  }
51
- if (dec.overrides) {
52
- console.log(` Supersedes: ${chalk.yellow(dec.overrides)}`);
60
+ if (enriched.overrides) {
61
+ console.log(` Supersedes: ${chalk.yellow(enriched.overrides)}`);
53
62
  }
54
- console.log(` Created: ${dec.created_at || '—'}`);
55
- if (dec.overridden_by) {
56
- console.log(` Overridden: ${chalk.yellow(dec.overridden_by)}`);
63
+ console.log(` Created: ${enriched.created_at || '—'}`);
64
+ if (enriched.overridden_by) {
65
+ console.log(` Overridden: ${chalk.yellow(enriched.overridden_by)}`);
57
66
  }
58
67
  return;
59
68
  }
60
69
 
61
70
  // ── List decisions ─────────────────────────────────────────────────────
62
- const decisions = opts.all ? readRepoDecisions(root) : getActiveRepoDecisions(root);
71
+ const allDecisions = readRepoDecisions(root);
72
+ const decisions = opts.all ? allDecisions : getActiveRepoDecisions(root);
73
+ const enrichedDecisions = decisions.map((dec) => enrichDecision(dec, config));
63
74
 
64
75
  if (opts.json) {
65
- console.log(JSON.stringify(decisions, null, 2));
76
+ console.log(JSON.stringify(enrichedDecisions, null, 2));
66
77
  return;
67
78
  }
68
79
 
69
- if (decisions.length === 0) {
70
- console.log(chalk.dim('No repo-level decisions found.'));
71
- if (!opts.all) {
80
+ if (enrichedDecisions.length === 0) {
81
+ console.log(chalk.dim(opts.all ? 'No repo-level decisions found.' : 'No active repo decisions found.'));
82
+ if (!opts.all && allDecisions.length > 0) {
72
83
  console.log(chalk.dim('Use --all to include overridden decisions.'));
84
+ return;
73
85
  }
74
86
  return;
75
87
  }
76
88
 
77
89
  const label = opts.all ? 'Repo Decisions (all)' : 'Active Repo Decisions';
78
- console.log(chalk.bold(`${label}: ${decisions.length}`));
90
+ const summary = buildDecisionListSummary(summarizeRepoDecisions(allDecisions, config), opts.all);
91
+ console.log(chalk.bold(`${label}: ${enrichedDecisions.length}`));
92
+ console.log(chalk.dim(summary.binding_line));
93
+ if (summary.category_line) console.log(chalk.dim(summary.category_line));
94
+ if (summary.history_line) console.log(chalk.dim(summary.history_line));
95
+ if (summary.authority_line) console.log(chalk.dim(summary.authority_line));
79
96
  console.log('');
80
97
 
81
- for (const dec of decisions) {
98
+ for (const dec of enrichedDecisions) {
82
99
  const status = formatStatus(dec.status);
83
100
  const runShort = (dec.run_id || '').slice(0, 12);
84
101
  const override = dec.overridden_by
@@ -86,8 +103,9 @@ export async function decisionsCommand(opts) {
86
103
  : dec.overrides
87
104
  ? chalk.dim(` ← supersedes ${dec.overrides}`)
88
105
  : '';
106
+ const authority = formatAuthority(dec);
89
107
  console.log(` ${chalk.cyan(dec.id)} ${status} ${chalk.dim(dec.category)} ${dec.statement}${override}`);
90
- console.log(` ${chalk.dim(`by ${dec.role || '?'} in ${runShort || '?'}`)}`);
108
+ console.log(` ${chalk.dim(`by ${dec.role || '?'} in ${runShort || '?'}${authority}`)}`);
91
109
  }
92
110
  }
93
111
 
@@ -97,6 +115,57 @@ function formatStatus(status) {
97
115
  return chalk.dim(status || '—');
98
116
  }
99
117
 
118
+ function enrichDecision(decision, config) {
119
+ const authority = getDecisionAuthorityMetadata(decision.role, config);
120
+ return {
121
+ ...decision,
122
+ binding_state: decision.status === 'active'
123
+ ? 'binding'
124
+ : decision.overridden_by
125
+ ? 'replaced'
126
+ : 'historical',
127
+ authority_level: authority?.level ?? null,
128
+ authority_source: authority?.source ?? null,
129
+ };
130
+ }
131
+
132
+ function buildDecisionListSummary(summary, includeAll) {
133
+ const categories = summary?.operator_summary?.active_categories || [];
134
+ const highestAuthority = summary?.operator_summary?.highest_active_authority_level;
135
+ const highestAuthorityRole = summary?.operator_summary?.highest_active_authority_role;
136
+ const activeCount = summary?.active_count || 0;
137
+ const overriddenCount = summary?.overridden_count || 0;
138
+ return {
139
+ binding_line: `binding now: ${activeCount} active decision${activeCount === 1 ? '' : 's'}`,
140
+ category_line: categories.length > 0
141
+ ? `categories: ${categories.join(', ')}`
142
+ : null,
143
+ history_line: overriddenCount > 0
144
+ ? includeAll
145
+ ? `history: ${overriddenCount} overridden decision${overriddenCount === 1 ? '' : 's'} recorded`
146
+ : `history: ${overriddenCount} overridden decision${overriddenCount === 1 ? '' : 's'} hidden (use --all)`
147
+ : null,
148
+ authority_line: typeof highestAuthority === 'number'
149
+ ? `highest active authority: ${highestAuthority} (${highestAuthorityRole || 'unknown'})`
150
+ : null,
151
+ };
152
+ }
153
+
154
+ function formatAuthority(decision) {
155
+ if (typeof decision.authority_level === 'number') {
156
+ if (decision.authority_source === 'unknown_role') return ' • authority unknown';
157
+ return ` • authority ${decision.authority_level}`;
158
+ }
159
+ return '';
160
+ }
161
+
162
+ function formatBindingState(bindingState) {
163
+ if (bindingState === 'binding') return chalk.green('binding');
164
+ if (bindingState === 'replaced') return chalk.yellow('replaced');
165
+ if (bindingState === 'historical') return chalk.dim('historical');
166
+ return chalk.dim('—');
167
+ }
168
+
100
169
  function findProjectRoot(dir) {
101
170
  let current = resolve(dir);
102
171
  while (current !== '/') {
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
 
3
3
  import { findProjectRoot } from '../lib/config.js';
4
4
  import { buildExportDiff, resolveExportArtifact } from '../lib/export-diff.js';
5
+ import { buildExportDiffSummary, buildRunDiffSummary } from '../lib/history-diff-summary.js';
5
6
  import { buildRunDiff, resolveRunHistoryReference } from '../lib/run-diff.js';
6
7
 
7
8
  export async function diffCommand(leftRef, rightRef, opts) {
@@ -28,7 +29,10 @@ export async function diffCommand(leftRef, rightRef, opts) {
28
29
  }
29
30
 
30
31
  if (opts.json) {
31
- console.log(JSON.stringify(exportDiff.diff, null, 2));
32
+ console.log(JSON.stringify({
33
+ ...exportDiff.diff,
34
+ summary: buildExportDiffSummary(exportDiff.diff),
35
+ }, null, 2));
32
36
  return;
33
37
  }
34
38
 
@@ -56,7 +60,10 @@ export async function diffCommand(leftRef, rightRef, opts) {
56
60
 
57
61
  const diff = buildRunDiff(leftResult.entry, rightResult.entry);
58
62
  if (opts.json) {
59
- console.log(JSON.stringify(diff, null, 2));
63
+ console.log(JSON.stringify({
64
+ ...diff,
65
+ summary: buildRunDiffSummary(diff),
66
+ }, null, 2));
60
67
  return;
61
68
  }
62
69
 
@@ -65,12 +72,13 @@ export async function diffCommand(leftRef, rightRef, opts) {
65
72
 
66
73
  function formatRunDiffText(diff) {
67
74
  const lines = [];
75
+ const summary = buildRunDiffSummary(diff);
68
76
  lines.push(chalk.bold('Run Diff'));
69
77
  lines.push(`${chalk.dim('Left:')} ${formatRunHeader(diff.left)}`);
70
78
  lines.push(`${chalk.dim('Right:')} ${formatRunHeader(diff.right)}`);
79
+ appendComparisonSummary(lines, summary);
71
80
 
72
81
  if (!diff.changed) {
73
- lines.push('');
74
82
  lines.push(chalk.green('No differences.'));
75
83
  return lines.join('\n');
76
84
  }
@@ -96,12 +104,13 @@ function formatRunDiffText(diff) {
96
104
 
97
105
  function formatExportDiffText(diff) {
98
106
  const lines = [];
107
+ const summary = buildExportDiffSummary(diff);
99
108
  lines.push(chalk.bold('Export Diff'));
100
109
  lines.push(`${chalk.dim('Left:')} ${formatExportHeader(diff.left_ref, diff.left)}`);
101
110
  lines.push(`${chalk.dim('Right:')} ${formatExportHeader(diff.right_ref, diff.right)}`);
111
+ appendComparisonSummary(lines, summary);
102
112
 
103
113
  if (!diff.changed) {
104
- lines.push('');
105
114
  lines.push(chalk.green('No differences.'));
106
115
  return lines.join('\n');
107
116
  }
@@ -158,6 +167,20 @@ function appendRegressionSection(lines, regressions) {
158
167
  }
159
168
  }
160
169
 
170
+ function appendComparisonSummary(lines, summary) {
171
+ lines.push('');
172
+ lines.push(chalk.bold('Comparison Summary'));
173
+ lines.push(`- Outcome: ${summary.outcome}`);
174
+ lines.push(`- Risk: ${summary.risk_level}`);
175
+ if (Array.isArray(summary.highlights) && summary.highlights.length > 0) {
176
+ lines.push('- Highlights:');
177
+ for (const item of summary.highlights) {
178
+ lines.push(` - ${item}`);
179
+ }
180
+ }
181
+ lines.push('');
182
+ }
183
+
161
184
  function listChangeItems(entry) {
162
185
  const items = [];
163
186
  if (entry.added.length > 0) {
@@ -11,6 +11,11 @@ import { loadNormalizedConfig, detectConfigVersion } from '../lib/normalized-con
11
11
  import { readDaemonState, evaluateDaemonStatus } from '../lib/run-schedule.js';
12
12
  import { getGovernedVersionSurface, formatGovernedVersionLabel } from '../lib/protocol-version.js';
13
13
  import { PLUGIN_MANIFEST_FILE } from '../lib/plugins.js';
14
+ import {
15
+ getRoleRuntimeCapabilityContract,
16
+ summarizeRoleRuntimeCapability,
17
+ summarizeRuntimeCapabilityContract,
18
+ } from '../lib/runtime-capabilities.js';
14
19
 
15
20
  export async function doctorCommand(opts = {}) {
16
21
  const root = findProjectRoot(process.cwd());
@@ -76,8 +81,9 @@ function governedDoctor(root, rawConfig, opts) {
76
81
  // 3. Runtime reachable — one sub-check per runtime
77
82
  // Use normalized runtimes if available, otherwise fall back to raw config
78
83
  const runtimes = (normalized && normalized.runtimes) || rawConfig.runtimes || {};
84
+ const rolesByRuntime = buildRolesByRuntime(normalized?.roles || {});
79
85
  for (const [rtId, rt] of Object.entries(runtimes)) {
80
- const check = checkRuntimeReachable(rtId, rt);
86
+ const check = checkRuntimeReachable(rtId, rt, rolesByRuntime[rtId] || []);
81
87
  checks.push(check);
82
88
  }
83
89
  const connectorProbe = getConnectorProbeRecommendation(runtimes);
@@ -290,6 +296,12 @@ function governedDoctor(root, rawConfig, opts) {
290
296
  ? chalk.dim('INFO')
291
297
  : chalk.red('FAIL');
292
298
  console.log(` ${badge} ${c.name.padEnd(24)} ${chalk.dim(c.detail)}`);
299
+ if (c.runtime_contract) {
300
+ console.log(` ${chalk.dim(summarizeRuntimeCapabilityContract(c.runtime_contract))}`);
301
+ if (Array.isArray(c.bound_roles) && c.bound_roles.length > 0) {
302
+ console.log(` ${chalk.dim(`roles: ${c.bound_roles.map(summarizeRoleRuntimeCapability).join(' | ')}`)}`);
303
+ }
304
+ }
293
305
  }
294
306
 
295
307
  console.log('');
@@ -309,25 +321,45 @@ function governedDoctor(root, rawConfig, opts) {
309
321
  process.exit(failCount > 0 ? 1 : 0);
310
322
  }
311
323
 
312
- function checkRuntimeReachable(rtId, rt) {
324
+ function buildRolesByRuntime(roles) {
325
+ const grouped = {};
326
+ for (const [roleId, role] of Object.entries(roles || {})) {
327
+ if (!role?.runtime_id) continue;
328
+ if (!grouped[role.runtime_id]) grouped[role.runtime_id] = [];
329
+ grouped[role.runtime_id].push([roleId, role]);
330
+ }
331
+ return grouped;
332
+ }
333
+
334
+ function attachRuntimeContract(baseCheck, rtId, rt, boundRoleEntries) {
335
+ const bound_roles = boundRoleEntries.map(([roleId, role]) => getRoleRuntimeCapabilityContract(roleId, role, rt));
336
+ return {
337
+ ...baseCheck,
338
+ runtime_type: rt?.type || 'unknown',
339
+ runtime_contract: bound_roles[0]?.runtime_contract || getRoleRuntimeCapabilityContract('__unbound__', { write_authority: 'unknown' }, rt).runtime_contract,
340
+ bound_roles,
341
+ };
342
+ }
343
+
344
+ function checkRuntimeReachable(rtId, rt, boundRoleEntries = []) {
313
345
  const base = { id: `runtime_${rtId}`, name: `Runtime: ${rtId}` };
314
346
 
315
347
  if (!rt || !rt.type) {
316
- return { ...base, level: 'warn', detail: 'No runtime type specified' };
348
+ return attachRuntimeContract({ ...base, level: 'warn', detail: 'No runtime type specified' }, rtId, rt, boundRoleEntries);
317
349
  }
318
350
 
319
351
  switch (rt.type) {
320
352
  case 'manual':
321
- return { ...base, level: 'pass', detail: 'Manual runtime (no binary needed)' };
353
+ return attachRuntimeContract({ ...base, level: 'pass', detail: 'Manual runtime (no binary needed)' }, rtId, rt, boundRoleEntries);
322
354
 
323
355
  case 'local_cli': {
324
356
  const cmd = Array.isArray(rt.command) ? rt.command[0] : (typeof rt.command === 'string' ? rt.command.split(/\s+/)[0] : null);
325
- if (!cmd) return { ...base, level: 'warn', detail: 'No command configured' };
357
+ if (!cmd) return attachRuntimeContract({ ...base, level: 'warn', detail: 'No command configured' }, rtId, rt, boundRoleEntries);
326
358
  try {
327
359
  execSync(`command -v ${cmd}`, { stdio: 'ignore' });
328
- return { ...base, level: 'pass', detail: `${cmd} binary found` };
360
+ return attachRuntimeContract({ ...base, level: 'pass', detail: `${cmd} binary found` }, rtId, rt, boundRoleEntries);
329
361
  } catch {
330
- return { ...base, level: 'fail', detail: `${cmd} not found in PATH` };
362
+ return attachRuntimeContract({ ...base, level: 'fail', detail: `${cmd} not found in PATH` }, rtId, rt, boundRoleEntries);
331
363
  }
332
364
  }
333
365
 
@@ -335,34 +367,34 @@ function checkRuntimeReachable(rtId, rt) {
335
367
  const envVar = rt.auth_env;
336
368
  if (!envVar) {
337
369
  // ollama and similar providers may not require auth
338
- return { ...base, level: 'pass', detail: `${rt.provider || 'unknown'} provider (no auth required)` };
370
+ return attachRuntimeContract({ ...base, level: 'pass', detail: `${rt.provider || 'unknown'} provider (no auth required)` }, rtId, rt, boundRoleEntries);
339
371
  }
340
372
  if (process.env[envVar]) {
341
- return { ...base, level: 'pass', detail: `${envVar} is set` };
373
+ return attachRuntimeContract({ ...base, level: 'pass', detail: `${envVar} is set` }, rtId, rt, boundRoleEntries);
342
374
  }
343
- return { ...base, level: 'fail', detail: `${envVar} not set` };
375
+ return attachRuntimeContract({ ...base, level: 'fail', detail: `${envVar} not set` }, rtId, rt, boundRoleEntries);
344
376
  }
345
377
 
346
378
  case 'mcp': {
347
379
  const transport = rt.transport || 'stdio';
348
380
  if (transport === 'streamable_http') {
349
- return { ...base, level: 'warn', detail: 'Remote MCP endpoint (cannot verify at doctor time)' };
381
+ return attachRuntimeContract({ ...base, level: 'warn', detail: 'Remote MCP endpoint (cannot verify at doctor time)' }, rtId, rt, boundRoleEntries);
350
382
  }
351
383
  const cmd = Array.isArray(rt.command) ? rt.command[0] : (typeof rt.command === 'string' ? rt.command.split(/\s+/)[0] : null);
352
- if (!cmd) return { ...base, level: 'warn', detail: 'No MCP command configured' };
384
+ if (!cmd) return attachRuntimeContract({ ...base, level: 'warn', detail: 'No MCP command configured' }, rtId, rt, boundRoleEntries);
353
385
  try {
354
386
  execSync(`command -v ${cmd}`, { stdio: 'ignore' });
355
- return { ...base, level: 'pass', detail: `${cmd} binary found` };
387
+ return attachRuntimeContract({ ...base, level: 'pass', detail: `${cmd} binary found` }, rtId, rt, boundRoleEntries);
356
388
  } catch {
357
- return { ...base, level: 'fail', detail: `${cmd} not found in PATH` };
389
+ return attachRuntimeContract({ ...base, level: 'fail', detail: `${cmd} not found in PATH` }, rtId, rt, boundRoleEntries);
358
390
  }
359
391
  }
360
392
 
361
393
  case 'remote_agent':
362
- return { ...base, level: 'warn', detail: 'Remote agent endpoint (cannot verify at doctor time)' };
394
+ return attachRuntimeContract({ ...base, level: 'warn', detail: 'Remote agent endpoint (cannot verify at doctor time)' }, rtId, rt, boundRoleEntries);
363
395
 
364
396
  default:
365
- return { ...base, level: 'warn', detail: `Unknown runtime type: ${rt.type}` };
397
+ return attachRuntimeContract({ ...base, level: 'warn', detail: `Unknown runtime type: ${rt.type}` }, rtId, rt, boundRoleEntries);
366
398
  }
367
399
  }
368
400
 
@@ -5,10 +5,11 @@
5
5
  */
6
6
 
7
7
  import { resolve } from 'path';
8
- import { existsSync, readFileSync } from 'fs';
8
+ import { existsSync } from 'fs';
9
9
  import chalk from 'chalk';
10
10
  import { queryRunHistory, queryRunLineage, isInheritable } from '../lib/run-history.js';
11
- import { getRunTriggerLabel, summarizeRunProvenance } from '../lib/run-provenance.js';
11
+ import { buildRunOutcomeSummary } from '../lib/history-diff-summary.js';
12
+ import { getRunTriggerLabel } from '../lib/run-provenance.js';
12
13
 
13
14
  /**
14
15
  * @param {object} opts - { json?: boolean, limit?: number, status?: string, dir?: string }
@@ -67,7 +68,11 @@ export async function historyCommand(opts) {
67
68
  });
68
69
 
69
70
  if (opts.json) {
70
- const enriched = entries.map(e => ({ ...e, inheritable: isInheritable(e) }));
71
+ const enriched = entries.map((e) => ({
72
+ ...e,
73
+ inheritable: isInheritable(e),
74
+ outcome_summary: buildRunOutcomeSummary(e),
75
+ }));
71
76
  console.log(JSON.stringify(enriched, null, 2));
72
77
  return;
73
78
  }
@@ -85,6 +90,7 @@ export async function historyCommand(opts) {
85
90
  pad('#', 4),
86
91
  pad('Run ID', 14),
87
92
  pad('Status', 11),
93
+ pad('Outcome', 11),
88
94
  pad('Trigger', 14),
89
95
  pad('Ctx', 4),
90
96
  pad('Phases', 8),
@@ -102,6 +108,7 @@ export async function historyCommand(opts) {
102
108
  const idx = String(i + 1);
103
109
  const runId = (entry.run_id || '—').slice(0, 12);
104
110
  const status = formatStatus(entry.status);
111
+ const outcome = buildRunOutcomeSummary(entry);
105
112
  const trigger = getRunTriggerLabel(entry.provenance);
106
113
  const ctx = isInheritable(entry) ? '✓' : '—';
107
114
  const phases = String(entry.phases_completed?.length || 0);
@@ -121,6 +128,7 @@ export async function historyCommand(opts) {
121
128
  pad(idx, 4),
122
129
  pad(runId, 14),
123
130
  pad(status, 11),
131
+ pad(outcome.label, 11),
124
132
  pad(trigger, 14),
125
133
  pad(ctx, 4),
126
134
  pad(phases, 8),
@@ -130,6 +138,10 @@ export async function historyCommand(opts) {
130
138
  pad(date, 20),
131
139
  pad(headline, 42),
132
140
  ].join(' '));
141
+
142
+ if (outcome.next_action) {
143
+ console.log(chalk.dim(` next: ${truncateLine(outcome.next_action, 148)}`));
144
+ }
133
145
  });
134
146
 
135
147
  console.log(chalk.dim(`\n${entries.length} run(s) shown`));
@@ -176,3 +188,9 @@ function formatHeadline(headline) {
176
188
  if (normalized.length <= 40) return normalized;
177
189
  return `${normalized.slice(0, 39)}…`;
178
190
  }
191
+
192
+ function truncateLine(value, max) {
193
+ if (typeof value !== 'string') return '';
194
+ if (value.length <= max) return value;
195
+ return `${value.slice(0, max - 1)}…`;
196
+ }