agentxchain 2.107.0 → 2.109.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.
@@ -521,11 +521,13 @@ program
521
521
  program
522
522
  .command('approve-transition')
523
523
  .description('Approve a pending phase transition that requires human sign-off')
524
+ .option('--dry-run', 'Show configured gate actions without executing approval')
524
525
  .action(approveTransitionCommand);
525
526
 
526
527
  program
527
528
  .command('approve-completion')
528
529
  .description('Approve a pending run completion that requires human sign-off')
530
+ .option('--dry-run', 'Show configured gate actions without executing approval')
529
531
  .action(approveCompletionCommand);
530
532
 
531
533
  program
package/dashboard/app.js CHANGED
@@ -25,12 +25,12 @@ import {
25
25
  } from './live-observer.js';
26
26
 
27
27
  const VIEWS = {
28
- timeline: { fetch: ['state', 'continuity', 'history', 'audit', 'annotations', 'connectors', 'coordinatorAudit', 'coordinatorAnnotations'], render: renderTimeline },
28
+ timeline: { fetch: ['state', 'continuity', 'history', 'events', 'audit', 'annotations', 'connectors', 'coordinatorAudit', 'coordinatorAnnotations'], render: renderTimeline },
29
29
  delegations: { fetch: ['state', 'history'], render: renderDelegations },
30
30
  ledger: { fetch: ['state', 'ledger', 'coordinatorState', 'coordinatorLedger', 'repoDecisionsSummary'], render: renderLedger },
31
31
  hooks: { fetch: ['audit', 'annotations', 'coordinatorAudit', 'coordinatorAnnotations'], render: renderHooks },
32
- blocked: { fetch: ['state', 'audit', 'coordinatorState', 'coordinatorAudit', 'coordinatorBlockers', 'coordinatorRepoStatusRows'], render: renderBlocked },
33
- gate: { fetch: ['state', 'history', 'coordinatorState', 'coordinatorHistory', 'coordinatorBarriers'], render: renderGate },
32
+ blocked: { fetch: ['state', 'audit', 'coordinatorState', 'coordinatorAudit', 'coordinatorBlockers', 'coordinatorRepoStatusRows', 'gateActions'], render: renderBlocked },
33
+ gate: { fetch: ['state', 'history', 'coordinatorState', 'coordinatorHistory', 'coordinatorBarriers', 'gateActions'], render: renderGate },
34
34
  initiative: { fetch: ['coordinatorState', 'coordinatorBarriers', 'barrierLedger', 'coordinatorBlockers', 'coordinatorRepoStatusRows'], render: renderInitiative },
35
35
  'cross-repo': { fetch: ['coordinatorState', 'coordinatorHistory'], render: renderCrossRepo },
36
36
  blockers: { fetch: ['coordinatorBlockers'], render: renderBlockers },
@@ -62,6 +62,8 @@ const API_MAP = {
62
62
  runHistory: '/api/run-history',
63
63
  timeouts: '/api/timeouts',
64
64
  coordinatorTimeouts: '/api/coordinator/timeouts',
65
+ gateActions: '/api/gate-actions',
66
+ events: '/api/events?type=turn_conflicted&limit=10',
65
67
  };
66
68
 
67
69
  const viewState = {
@@ -78,11 +80,14 @@ const viewState = {
78
80
  hookName: 'all',
79
81
  },
80
82
  };
83
+ const DASHBOARD_POLL_INTERVAL_MS = 60 * 1000;
81
84
 
82
85
  let activeViewName = null;
83
86
  let activeViewData = null;
84
87
  let dashboardSession = null;
85
88
  let actionInFlight = false;
89
+ let pollInFlight = false;
90
+ let pollTimer = null;
86
91
  const liveObserverState = {
87
92
  connected: false,
88
93
  lastRefreshAt: null,
@@ -145,7 +150,7 @@ async function pickInitialView() {
145
150
  }
146
151
 
147
152
  function buildRenderData(viewName, data) {
148
- const liveMeta = viewName === 'timeline'
153
+ const liveMeta = (viewName === 'timeline' || viewName === 'timeouts')
149
154
  ? buildLiveMeta({
150
155
  connected: liveObserverState.connected,
151
156
  lastRefreshAt: liveObserverState.lastRefreshAt,
@@ -273,6 +278,39 @@ function rerenderActiveView() {
273
278
  renderView(activeViewName, activeViewData);
274
279
  }
275
280
 
281
+ async function pollDashboard({ refreshView = false } = {}) {
282
+ if (pollInFlight) return;
283
+ pollInFlight = true;
284
+ try {
285
+ await fetch('/api/poll', { cache: 'no-store' });
286
+ if (refreshView) {
287
+ await loadView(currentView());
288
+ }
289
+ } catch {
290
+ // Best-effort heartbeat only
291
+ } finally {
292
+ pollInFlight = false;
293
+ }
294
+ }
295
+
296
+ function startDashboardPolling() {
297
+ if (pollTimer) return;
298
+
299
+ const tick = () => {
300
+ if (document.visibilityState === 'hidden') return;
301
+ if (actionInFlight) return;
302
+ void pollDashboard({ refreshView: true });
303
+ };
304
+
305
+ pollTimer = setInterval(tick, DASHBOARD_POLL_INTERVAL_MS);
306
+
307
+ document.addEventListener('visibilitychange', () => {
308
+ if (document.visibilityState === 'visible') {
309
+ void pollDashboard({ refreshView: true });
310
+ }
311
+ });
312
+ }
313
+
276
314
  // ── WebSocket connection ──────────────────────────────────────────────────
277
315
 
278
316
  let ws = null;
@@ -290,7 +328,9 @@ function connect() {
290
328
  statusLabel.textContent = 'Connected';
291
329
  reconnectDelay = 1000;
292
330
  liveObserverState.connected = true;
293
- loadView(currentView());
331
+ void pollDashboard().finally(() => {
332
+ loadView(currentView());
333
+ });
294
334
  };
295
335
 
296
336
  ws.onmessage = (event) => {
@@ -500,5 +540,6 @@ function fallbackSelect(el) {
500
540
 
501
541
  Promise.all([pickInitialView(), loadSession()]).finally(() => {
502
542
  updateNav();
543
+ startDashboardPolling();
503
544
  connect();
504
545
  });
@@ -92,6 +92,54 @@ function formatCoordinatorRepoCardMeta(row) {
92
92
  return parts.join(' | ') || '-';
93
93
  }
94
94
 
95
+ function getGateActionDryRunCommand(gateActions, state) {
96
+ const gateType = gateActions?.latest_attempt?.gate_type
97
+ || (state?.pending_run_completion ? 'run_completion' : null)
98
+ || (state?.pending_phase_transition ? 'phase_transition' : null);
99
+
100
+ return gateType === 'run_completion'
101
+ ? 'agentxchain approve-completion --dry-run'
102
+ : 'agentxchain approve-transition --dry-run';
103
+ }
104
+
105
+ function renderGateActionFailure(gateActions, state) {
106
+ if (!gateActions?.latest_attempt || gateActions.latest_attempt.status !== 'failed') return '';
107
+
108
+ const attempt = gateActions.latest_attempt;
109
+ const actions = Array.isArray(attempt.actions) ? attempt.actions : [];
110
+ const dryRunCommand = getGateActionDryRunCommand(gateActions, state);
111
+
112
+ let html = `<div class="section"><h3>Gate Action Failure</h3>`;
113
+ html += `<dl class="detail-list">`;
114
+ html += `<dt>Attempt</dt><dd class="mono">${esc(attempt.attempt_id || '-')}</dd>`;
115
+ html += `<dt>Gate</dt><dd class="mono">${esc(attempt.gate_id || '-')}</dd>`;
116
+ html += `<dt>Attempted At</dt><dd class="mono">${esc(attempt.attempted_at || '-')}</dd>`;
117
+ html += `</dl>`;
118
+
119
+ if (actions.length > 0) {
120
+ html += `<div class="annotation-list">`;
121
+ for (const action of actions) {
122
+ const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
123
+ const outcome = action.status === 'failed' ? '❌ failed' : '✅ succeeded';
124
+ const exitStr = action.exit_code != null ? ` (exit ${action.exit_code})` : '';
125
+ html += `<div class="annotation-card">
126
+ <span class="mono">${esc(String(action.action_index || '?'))}.</span>
127
+ <span>${esc(label)}</span>
128
+ <span>${esc(outcome)}${esc(exitStr)}</span>
129
+ </div>`;
130
+ if (action.status === 'failed' && action.stderr_tail) {
131
+ html += `<pre class="recovery-command mono">${esc(action.stderr_tail)}</pre>`;
132
+ }
133
+ }
134
+ html += `</div>`;
135
+ }
136
+
137
+ html += `<p class="recovery-hint">Re-run with dry-run first:</p>`;
138
+ html += `<pre class="recovery-command mono" data-copy="${esc(dryRunCommand)}">${esc(dryRunCommand)}</pre>`;
139
+ html += `</div>`;
140
+ return html;
141
+ }
142
+
95
143
  export function render({
96
144
  state,
97
145
  audit = [],
@@ -99,6 +147,7 @@ export function render({
99
147
  coordinatorAudit = [],
100
148
  coordinatorBlockers = null,
101
149
  coordinatorRepoStatusRows = null,
150
+ gateActions = null,
102
151
  }) {
103
152
  const activeState = state?.status === 'blocked' ? state : coordinatorState;
104
153
  const activeAudit = activeState === state ? audit : coordinatorAudit;
@@ -168,6 +217,12 @@ export function render({
168
217
  </div>`;
169
218
  }
170
219
 
220
+ // Gate-action failure detail (only for gate_action_failed blocks)
221
+ const category = String(reason).toLowerCase();
222
+ if (category.includes('gate_action_failed') && !isCoordinator) {
223
+ html += renderGateActionFailure(gateActions, activeState);
224
+ }
225
+
171
226
  if (runtimeGuidance.length > 0) {
172
227
  html += `<div class="section"><h3>Runtime Guidance</h3><div class="annotation-list">`;
173
228
  for (const entry of runtimeGuidance) {
@@ -210,12 +210,55 @@ function aggregateCoordinatorEvidence(entries) {
210
210
  return { summaries, decisions, objections: [], risks: [], files };
211
211
  }
212
212
 
213
+ function renderGateActionsSection(gateActions) {
214
+ if (!gateActions) return '';
215
+
216
+ const configured = Array.isArray(gateActions.configured) ? gateActions.configured : [];
217
+ const attempt = gateActions.latest_attempt || null;
218
+
219
+ if (configured.length === 0 && !attempt) return '';
220
+
221
+ let html = `<div class="gate-support"><p><strong>Gate Actions:</strong></p>`;
222
+
223
+ if (configured.length > 0) {
224
+ html += `<ul>`;
225
+ for (const action of configured) {
226
+ const label = action.label || action.run || `action ${action.index || '?'}`;
227
+ html += `<li><div><span class="mono">${esc(String(action.index || '?'))}.</span> ${esc(label)}</div>`;
228
+ if (action.run) {
229
+ html += `<div class="mono">${esc(action.run)}</div>`;
230
+ }
231
+ html += `</li>`;
232
+ }
233
+ html += `</ul>`;
234
+ }
235
+
236
+ if (attempt) {
237
+ const statusLabel = attempt.status === 'failed' ? '❌ Failed' : '✅ Succeeded';
238
+ html += `<p><strong>Last Attempt:</strong> ${esc(statusLabel)} at ${esc(attempt.attempted_at || 'unknown')}</p>`;
239
+ if (Array.isArray(attempt.actions) && attempt.actions.length > 0) {
240
+ html += `<ul>`;
241
+ for (const a of attempt.actions) {
242
+ const aLabel = a.action_label || a.command || `action ${a.action_index || '?'}`;
243
+ const outcome = a.status === 'failed' ? '❌' : '✅';
244
+ const exitStr = a.exit_code != null ? ` (exit ${a.exit_code})` : '';
245
+ html += `<li>${outcome} ${esc(aLabel)}${esc(exitStr)}</li>`;
246
+ }
247
+ html += `</ul>`;
248
+ }
249
+ }
250
+
251
+ html += `</div>`;
252
+ return html;
253
+ }
254
+
213
255
  export function render({
214
256
  state,
215
257
  history = [],
216
258
  coordinatorState = null,
217
259
  coordinatorHistory = [],
218
260
  coordinatorBarriers = {},
261
+ gateActions = null,
219
262
  }) {
220
263
  const repoPendingTransition = state?.pending_phase_transition || null;
221
264
  const repoPendingCompletion = state?.pending_run_completion || null;
@@ -284,6 +327,9 @@ export function render({
284
327
  )).join('')}</ul></div>`;
285
328
  }
286
329
  }
330
+ if (!isCoordinator) {
331
+ html += renderGateActionsSection(gateActions);
332
+ }
287
333
  html += renderApproveControls({
288
334
  buttonLabel: isCoordinator ? 'Approve Coordinator Gate' : 'Approve Transition',
289
335
  cliCommand: isCoordinator ? 'agentxchain multi approve-gate' : 'agentxchain approve-transition',
@@ -329,6 +375,9 @@ export function render({
329
375
  if (evidence.files.length > 0) {
330
376
  html += `<div class="gate-support"><p><strong>Files Changed:</strong></p><ul>${evidence.files.map(f => `<li class="mono">${esc(f)}</li>`).join('')}</ul></div>`;
331
377
  }
378
+ if (!isCoordinator) {
379
+ html += renderGateActionsSection(gateActions);
380
+ }
332
381
  html += renderApproveControls({
333
382
  buttonLabel: isCoordinator ? 'Approve Coordinator Gate' : 'Approve Completion',
334
383
  cliCommand: isCoordinator ? 'agentxchain multi approve-gate' : 'agentxchain approve-completion',
@@ -90,6 +90,11 @@ function formatTimestamp(iso) {
90
90
  }
91
91
  }
92
92
 
93
+ function formatPercent(value) {
94
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null;
95
+ return `${Math.round(value * 100)}%`;
96
+ }
97
+
93
98
  function statusBadge(status) {
94
99
  const colors = {
95
100
  running: 'var(--green)',
@@ -206,6 +211,99 @@ function renderDelegationReview(review) {
206
211
  return `<div class="turn-detail"><span class="detail-label">Delegation Review:</span> <span class="mono">${esc(review.parent_turn_id || 'unknown')}</span> with ${esc(resultCount)} result${resultCount === 1 ? '' : 's'}</div>`;
207
212
  }
208
213
 
214
+ function collectConflictCards(state, events) {
215
+ const latestByTurn = new Map();
216
+
217
+ if (Array.isArray(events)) {
218
+ for (const event of [...events].reverse()) {
219
+ if (event?.event_type !== 'turn_conflicted') continue;
220
+ const turnId = event?.turn?.turn_id;
221
+ if (!turnId || latestByTurn.has(turnId)) continue;
222
+ latestByTurn.set(turnId, {
223
+ turn_id: turnId,
224
+ role_id: event?.turn?.role_id || 'unknown',
225
+ detected_at: event?.timestamp || null,
226
+ state_label: event?.status === 'blocked' ? 'conflict loop blocked run' : 'recent conflict',
227
+ detection_count: typeof event?.payload?.detection_count === 'number' ? event.payload.detection_count : null,
228
+ conflicting_files: Array.isArray(event?.payload?.conflicting_files) ? event.payload.conflicting_files : [],
229
+ accepted_since_turn_ids: Array.isArray(event?.payload?.accepted_since_turn_ids) ? event.payload.accepted_since_turn_ids : [],
230
+ overlap_ratio: typeof event?.payload?.overlap_ratio === 'number' ? event.payload.overlap_ratio : null,
231
+ });
232
+ }
233
+ }
234
+
235
+ const activeTurns = state?.active_turns ? Object.values(state.active_turns) : [];
236
+ for (const turn of activeTurns) {
237
+ if (turn?.status !== 'conflicted') continue;
238
+ if (latestByTurn.has(turn.turn_id)) {
239
+ const existing = latestByTurn.get(turn.turn_id);
240
+ latestByTurn.set(turn.turn_id, {
241
+ ...existing,
242
+ state_label: state?.blocked_reason?.category === 'conflict_loop' && state?.blocked_on?.includes(turn.turn_id)
243
+ ? 'conflict loop blocked run'
244
+ : 'active conflict',
245
+ });
246
+ continue;
247
+ }
248
+
249
+ const conflictError = turn?.conflict_state?.conflict_error || {};
250
+ latestByTurn.set(turn.turn_id, {
251
+ turn_id: turn.turn_id,
252
+ role_id: getRole(turn),
253
+ detected_at: turn?.conflict_state?.detected_at || null,
254
+ state_label: state?.blocked_reason?.category === 'conflict_loop' && state?.blocked_on?.includes(turn.turn_id)
255
+ ? 'conflict loop blocked run'
256
+ : 'active conflict',
257
+ detection_count: typeof turn?.conflict_state?.detection_count === 'number' ? turn.conflict_state.detection_count : null,
258
+ conflicting_files: Array.isArray(conflictError.conflicting_files) ? conflictError.conflicting_files : [],
259
+ accepted_since_turn_ids: Array.isArray(conflictError.accepted_since)
260
+ ? conflictError.accepted_since.map((entry) => entry?.turn_id).filter(Boolean)
261
+ : [],
262
+ overlap_ratio: typeof conflictError.overlap_ratio === 'number' ? conflictError.overlap_ratio : null,
263
+ });
264
+ }
265
+
266
+ return [...latestByTurn.values()].slice(0, 5);
267
+ }
268
+
269
+ function renderConflictPanel(state, events) {
270
+ const conflicts = collectConflictCards(state, events);
271
+ if (conflicts.length === 0) return '';
272
+
273
+ let html = `<div class="section"><h3>Conflicts</h3><div class="turn-list">`;
274
+ for (const conflict of conflicts) {
275
+ const detectedAt = formatTimestamp(conflict.detected_at);
276
+ const overlap = formatPercent(conflict.overlap_ratio);
277
+ html += `<div class="turn-card">
278
+ <div class="turn-header">
279
+ ${roleBadge(conflict.role_id)}
280
+ <span class="mono">${esc(conflict.turn_id)}</span>
281
+ ${statusBadge('conflicted')}
282
+ </div>
283
+ <div class="turn-detail"><span class="detail-label">Scope:</span> ${esc(conflict.state_label)}</div>`;
284
+
285
+ if (detectedAt) {
286
+ html += `<div class="turn-detail"><span class="detail-label">Detected:</span> ${esc(detectedAt)}</div>`;
287
+ }
288
+ if (conflict.conflicting_files.length > 0) {
289
+ html += `<div class="turn-detail"><span class="detail-label">Files:</span> <span class="mono">${conflict.conflicting_files.map((file) => esc(file)).join(', ')}</span></div>`;
290
+ }
291
+ if (conflict.accepted_since_turn_ids.length > 0) {
292
+ html += `<div class="turn-detail"><span class="detail-label">Accepted since:</span> <span class="mono">${conflict.accepted_since_turn_ids.map((turnId) => esc(turnId)).join(', ')}</span></div>`;
293
+ }
294
+ if (overlap) {
295
+ html += `<div class="turn-detail"><span class="detail-label">Overlap:</span> ${esc(overlap)}</div>`;
296
+ }
297
+ if (conflict.detection_count != null) {
298
+ html += `<div class="turn-detail"><span class="detail-label">Detection count:</span> ${esc(conflict.detection_count)}</div>`;
299
+ }
300
+
301
+ html += `</div>`;
302
+ }
303
+ html += `</div></div>`;
304
+ return html;
305
+ }
306
+
209
307
  function renderContinuityPanel(continuity) {
210
308
  if (!continuity) return '';
211
309
 
@@ -306,7 +404,7 @@ function renderConnectorHealthPanel(connectorsPayload) {
306
404
 
307
405
  export { formatDuration, computeElapsed, formatTimestamp };
308
406
 
309
- export function render({ state, continuity, history, annotations, audit, connectors, coordinatorAudit = null, coordinatorAnnotations = null, liveMeta = null }) {
407
+ export function render({ state, continuity, history, events = null, annotations, audit, connectors, coordinatorAudit = null, coordinatorAnnotations = null, liveMeta = null }) {
310
408
  if (!state) {
311
409
  return `<div class="placeholder"><h2>No Run</h2><p>No governed run found. Start one with <code class="mono">agentxchain init --governed</code></p></div>`;
312
410
  }
@@ -330,6 +428,7 @@ export function render({ state, continuity, history, annotations, audit, connect
330
428
 
331
429
  html += renderContinuityPanel(continuity);
332
430
  html += renderConnectorHealthPanel(connectors);
431
+ html += renderConflictPanel(state, events);
333
432
 
334
433
  // Active turns
335
434
  if (activeTurns.length > 0) {
@@ -4,9 +4,13 @@
4
4
  * Pure render function: takes data from /api/timeouts, returns HTML.
5
5
  * All evaluation is server-side. This view renders the snapshot.
6
6
  *
7
+ * Receives liveMeta from the live-observer for websocket freshness display.
8
+ *
7
9
  * See: TIMEOUT_DASHBOARD_SURFACE_SPEC.md
8
10
  */
9
11
 
12
+ import { renderLiveStatus } from './live-status.js';
13
+
10
14
  function esc(str) {
11
15
  if (!str) return '';
12
16
  return String(str)
@@ -82,14 +86,22 @@ function renderConfigTable(config) {
82
86
  }
83
87
 
84
88
  function renderLivePressure(live) {
89
+ const liveContext = live?.context || null;
85
90
  const hasExceeded = live.exceeded && live.exceeded.length > 0;
86
91
  const hasWarnings = live.warnings && live.warnings.length > 0;
92
+ const approvalNote = liveContext?.awaiting_approval
93
+ ? `<p style="color:var(--yellow)">Approval wait does not mutate timeout state, but phase/run clocks continue until the next accepted turn.${liveContext.requested_at ? ` Requested: <code>${esc(liveContext.requested_at)}</code>.` : ''}</p>`
94
+ : '';
87
95
 
88
96
  if (!hasExceeded && !hasWarnings) {
89
- return `<div class="section"><h3>Live Pressure</h3><p style="color:var(--green)">No timeouts exceeded or approaching limits.</p></div>`;
97
+ const message = liveContext?.awaiting_approval
98
+ ? `<p style="color:var(--text-dim)">No current phase/run timeout pressure during this approval wait.</p>`
99
+ : `<p style="color:var(--green)">No timeouts exceeded or approaching limits.</p>`;
100
+ return `<div class="section"><h3>Live Pressure</h3>${approvalNote}${message}</div>`;
90
101
  }
91
102
 
92
103
  let html = `<div class="section"><h3>Live Pressure</h3>
104
+ ${approvalNote}
93
105
  <table class="data-table">
94
106
  <thead><tr><th>Status</th><th>Scope</th><th>Turn</th><th>Phase</th><th>Elapsed</th><th>Limit</th><th>Exceeded By</th><th>Action</th></tr></thead>
95
107
  <tbody>`;
@@ -157,7 +169,7 @@ function renderEvents(events) {
157
169
  return html;
158
170
  }
159
171
 
160
- export function render({ timeouts }) {
172
+ export function render({ timeouts, liveMeta }) {
161
173
  if (!timeouts) {
162
174
  return `<div class="placeholder"><h2>Timeouts</h2><p>No timeout data available. Ensure a governed run is active.</p></div>`;
163
175
  }
@@ -175,6 +187,9 @@ export function render({ timeouts }) {
175
187
 
176
188
  let html = `<div class="timeouts-view">`;
177
189
 
190
+ // Freshness banner — timeout data is time-sensitive so operators need visibility
191
+ html += renderLiveStatus(liveMeta);
192
+
178
193
  // Header
179
194
  html += `<div class="run-header"><div class="run-meta">`;
180
195
  html += `<span class="phase-label"><strong>Timeouts</strong></span>`;
@@ -190,7 +205,10 @@ export function render({ timeouts }) {
190
205
 
191
206
  // Live pressure
192
207
  if (timeouts.live) {
193
- html += renderLivePressure(timeouts.live);
208
+ html += renderLivePressure({
209
+ ...timeouts.live,
210
+ context: timeouts.live_context || null,
211
+ });
194
212
  }
195
213
 
196
214
  // Persisted events
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.107.0",
3
+ "version": "2.109.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,11 +37,30 @@ export async function approveCompletionCommand(opts) {
37
37
  console.log(` ${chalk.dim('Turn:')} ${pc.requested_by_turn}`);
38
38
  console.log('');
39
39
 
40
- const result = approveRunCompletion(root, config);
40
+ const result = approveRunCompletion(root, config, { dryRun: opts.dryRun });
41
+
42
+ if (result.dry_run) {
43
+ console.log(chalk.cyan(' Dry Run: gate approval preview only'));
44
+ if (result.gate_actions?.length > 0) {
45
+ console.log(` ${chalk.dim('Gate actions:')} ${result.gate_actions.length}`);
46
+ for (const action of result.gate_actions) {
47
+ console.log(` ${action.index}. ${action.label || action.run}`);
48
+ if (action.label) {
49
+ console.log(` ${chalk.dim(action.run)}`);
50
+ }
51
+ }
52
+ } else {
53
+ console.log(` ${chalk.dim('Gate actions:')} none configured`);
54
+ }
55
+ console.log('');
56
+ return;
57
+ }
41
58
 
42
59
  if (!result.ok) {
43
60
  if (result.error_code?.startsWith('hook_') || result.error_code === 'hook_blocked') {
44
61
  printGateHookFailure(result, 'run_completion', pc);
62
+ } else if (result.error_code === 'gate_action_failed') {
63
+ printGateActionFailure(result, pc);
45
64
  } else {
46
65
  console.log(chalk.red(` Failed: ${result.error}`));
47
66
  }
@@ -49,6 +68,9 @@ export async function approveCompletionCommand(opts) {
49
68
  }
50
69
 
51
70
  console.log(chalk.green(' \u2713 Run completed'));
71
+ if (result.gateActionRun?.actions?.length > 0) {
72
+ console.log(` ${chalk.dim('Gate actions:')} ${result.gateActionRun.actions.length} completed`);
73
+ }
52
74
  console.log(chalk.dim(` Completed at: ${result.state.completed_at}`));
53
75
  console.log('');
54
76
  }
@@ -78,3 +100,20 @@ function printGateHookFailure(result, gateType, gateInfo) {
78
100
  }
79
101
  console.log('');
80
102
  }
103
+
104
+ function printGateActionFailure(result, gateInfo) {
105
+ const failure = result.gateActionRun?.failed_action;
106
+
107
+ console.log('');
108
+ console.log(chalk.yellow(' Run Completion Blocked By Gate Action'));
109
+ console.log(chalk.dim(' ' + '-'.repeat(44)));
110
+ console.log('');
111
+ console.log(` ${chalk.dim('Gate:')} ${gateInfo.gate}`);
112
+ console.log(` ${chalk.dim('Action:')} ${failure?.action_label || failure?.command || '(unknown)'}`);
113
+ console.log(` ${chalk.dim('Exit:')} ${failure?.exit_code ?? failure?.signal ?? 'unknown'}`);
114
+ if (failure?.stderr_tail) {
115
+ console.log(` ${chalk.dim('stderr:')} ${failure.stderr_tail}`);
116
+ }
117
+ console.log(` ${chalk.dim('Retry:')} agentxchain approve-completion`);
118
+ console.log('');
119
+ }
@@ -36,11 +36,30 @@ export async function approveTransitionCommand(opts) {
36
36
  console.log(` ${chalk.dim('Turn:')} ${pt.requested_by_turn}`);
37
37
  console.log('');
38
38
 
39
- const result = approvePhaseTransition(root, config);
39
+ const result = approvePhaseTransition(root, config, { dryRun: opts.dryRun });
40
+
41
+ if (result.dry_run) {
42
+ console.log(chalk.cyan(' Dry Run: gate approval preview only'));
43
+ if (result.gate_actions?.length > 0) {
44
+ console.log(` ${chalk.dim('Gate actions:')} ${result.gate_actions.length}`);
45
+ for (const action of result.gate_actions) {
46
+ console.log(` ${action.index}. ${action.label || action.run}`);
47
+ if (action.label) {
48
+ console.log(` ${chalk.dim(action.run)}`);
49
+ }
50
+ }
51
+ } else {
52
+ console.log(` ${chalk.dim('Gate actions:')} none configured`);
53
+ }
54
+ console.log('');
55
+ return;
56
+ }
40
57
 
41
58
  if (!result.ok) {
42
59
  if (result.error_code?.startsWith('hook_') || result.error_code === 'hook_blocked') {
43
60
  printGateHookFailure(result, 'phase_transition', pt);
61
+ } else if (result.error_code === 'gate_action_failed') {
62
+ printGateActionFailure(result, 'phase_transition', pt);
44
63
  } else {
45
64
  console.log(chalk.red(` Failed: ${result.error}`));
46
65
  }
@@ -48,6 +67,9 @@ export async function approveTransitionCommand(opts) {
48
67
  }
49
68
 
50
69
  console.log(chalk.green(` ✓ Phase advanced: ${pt.from} → ${pt.to}`));
70
+ if (result.gateActionRun?.actions?.length > 0) {
71
+ console.log(` ${chalk.dim('Gate actions:')} ${result.gateActionRun.actions.length} completed`);
72
+ }
51
73
  console.log(chalk.dim(` Run status: ${result.state.status}`));
52
74
  console.log('');
53
75
  console.log(chalk.dim(` Next: agentxchain step (to run the first turn in ${pt.to} phase)`));
@@ -83,3 +105,24 @@ function printGateHookFailure(result, gateType, gateInfo) {
83
105
  }
84
106
  console.log('');
85
107
  }
108
+
109
+ function printGateActionFailure(result, gateType, gateInfo) {
110
+ const failure = result.gateActionRun?.failed_action;
111
+
112
+ console.log('');
113
+ console.log(chalk.yellow(` ${gateType === 'phase_transition' ? 'Phase Transition' : 'Run Completion'} Blocked By Gate Action`));
114
+ console.log(chalk.dim(' ' + '-'.repeat(44)));
115
+ console.log('');
116
+ if (gateType === 'phase_transition') {
117
+ console.log(` ${chalk.dim('From:')} ${gateInfo.from}`);
118
+ console.log(` ${chalk.dim('To:')} ${gateInfo.to}`);
119
+ }
120
+ console.log(` ${chalk.dim('Gate:')} ${gateInfo.gate}`);
121
+ console.log(` ${chalk.dim('Action:')} ${failure?.action_label || failure?.command || '(unknown)'}`);
122
+ console.log(` ${chalk.dim('Exit:')} ${failure?.exit_code ?? failure?.signal ?? 'unknown'}`);
123
+ if (failure?.stderr_tail) {
124
+ console.log(` ${chalk.dim('stderr:')} ${failure.stderr_tail}`);
125
+ }
126
+ console.log(` ${chalk.dim('Retry:')} ${gateType === 'phase_transition' ? 'agentxchain approve-transition' : 'agentxchain approve-completion'}`);
127
+ console.log('');
128
+ }
@@ -61,6 +61,9 @@ function printEvent(evt) {
61
61
  const runId = evt.run_id ? evt.run_id.slice(0, 12) : '—';
62
62
  const phase = evt.phase || '—';
63
63
  const turnInfo = evt.turn?.role_id ? ` [${evt.turn.role_id}]` : '';
64
+ const conflictDetail = evt.event_type === 'turn_conflicted'
65
+ ? ` — ${formatConflictDetail(evt)}`
66
+ : '';
64
67
  const rejectionDetail = evt.event_type === 'turn_rejected' && evt.payload?.reason
65
68
  ? ` — ${evt.payload.reason}${evt.payload.failed_stage ? ` (${evt.payload.failed_stage})` : ''}`
66
69
  : '';
@@ -70,7 +73,34 @@ function printEvent(evt) {
70
73
  const gateFailedDetail = evt.event_type === 'gate_failed' && evt.payload?.from_phase
71
74
  ? ` ${evt.payload.from_phase} → ${evt.payload.to_phase || '?'}${evt.payload.reasons?.length ? ` — ${evt.payload.reasons[0]}` : ''}${evt.payload.gate_id ? ` (${evt.payload.gate_id})` : ''}`
72
75
  : '';
73
- console.log(`${chalk.dim(ts)} ${type} ${chalk.cyan(runId)} ${phase}${turnInfo}${rejectionDetail}${phaseTransitionDetail}${gateFailedDetail}`);
76
+ console.log(`${chalk.dim(ts)} ${type} ${chalk.cyan(runId)} ${phase}${turnInfo}${conflictDetail}${rejectionDetail}${phaseTransitionDetail}${gateFailedDetail}`);
77
+ }
78
+
79
+ function formatConflictDetail(evt) {
80
+ const payload = evt.payload || {};
81
+ const fileSummary = summarizeList(payload.conflicting_files, 3) || 'unknown files';
82
+ const overlapRatio = typeof payload.overlap_ratio === 'number'
83
+ ? `${Math.round(payload.overlap_ratio * 100)}% overlap`
84
+ : null;
85
+ const detectionCount = Number.isInteger(payload.detection_count)
86
+ ? `detection ${payload.detection_count}`
87
+ : null;
88
+ const turnSummary = summarizeList(payload.accepted_since_turn_ids, 2);
89
+ const parts = [fileSummary, overlapRatio, detectionCount];
90
+ if (turnSummary) {
91
+ parts.push(`accepted since ${turnSummary}`);
92
+ }
93
+ if (evt.status === 'blocked') {
94
+ parts.push('run blocked');
95
+ }
96
+ return parts.filter(Boolean).join(' | ');
97
+ }
98
+
99
+ function summarizeList(items, limit) {
100
+ if (!Array.isArray(items) || items.length === 0) return '';
101
+ const shown = items.slice(0, limit).join(', ');
102
+ if (items.length <= limit) return shown;
103
+ return `${shown} +${items.length - limit} more`;
74
104
  }
75
105
 
76
106
  function colorEventType(type) {
@@ -81,12 +111,14 @@ function colorEventType(type) {
81
111
  turn_dispatched: chalk.blue,
82
112
  turn_accepted: chalk.green,
83
113
  turn_rejected: chalk.yellow,
114
+ turn_conflicted: chalk.redBright,
84
115
  phase_entered: chalk.magenta,
85
116
  escalation_raised: chalk.red.bold,
86
117
  escalation_resolved: chalk.green,
87
118
  gate_pending: chalk.yellow,
88
119
  gate_approved: chalk.green,
89
120
  gate_failed: chalk.red,
121
+ budget_exceeded_warn: chalk.yellowBright,
90
122
  };
91
123
  const colorFn = colors[type] || chalk.white;
92
124
  return colorFn(pad(type, 22));