agentxchain 2.24.2 → 2.25.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/dashboard/app.js CHANGED
@@ -12,6 +12,7 @@ import { render as renderBlocked } from './components/blocked.js';
12
12
  import { render as renderGate } from './components/gate.js';
13
13
  import { render as renderInitiative } from './components/initiative.js';
14
14
  import { render as renderCrossRepo } from './components/cross-repo.js';
15
+ import { render as renderBlockers } from './components/blockers.js';
15
16
 
16
17
  const VIEWS = {
17
18
  timeline: { fetch: ['state', 'history', 'audit', 'annotations'], render: renderTimeline },
@@ -19,8 +20,9 @@ const VIEWS = {
19
20
  hooks: { fetch: ['audit', 'annotations'], render: renderHooks },
20
21
  blocked: { fetch: ['state', 'audit', 'coordinatorState', 'coordinatorAudit'], render: renderBlocked },
21
22
  gate: { fetch: ['state', 'history', 'coordinatorState', 'coordinatorHistory', 'coordinatorBarriers'], render: renderGate },
22
- initiative: { fetch: ['coordinatorState', 'coordinatorBarriers', 'barrierLedger'], render: renderInitiative },
23
+ initiative: { fetch: ['coordinatorState', 'coordinatorBarriers', 'barrierLedger', 'coordinatorBlockers'], render: renderInitiative },
23
24
  'cross-repo': { fetch: ['coordinatorState', 'coordinatorHistory'], render: renderCrossRepo },
25
+ blockers: { fetch: ['coordinatorBlockers'], render: renderBlockers },
24
26
  };
25
27
 
26
28
  const API_MAP = {
@@ -34,6 +36,7 @@ const API_MAP = {
34
36
  coordinatorBarriers: '/api/coordinator/barriers',
35
37
  barrierLedger: '/api/coordinator/barrier-ledger',
36
38
  coordinatorAudit: '/api/coordinator/hooks/audit',
39
+ coordinatorBlockers: '/api/coordinator/blockers',
37
40
  };
38
41
 
39
42
  const viewState = {
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Coordinator Blockers view — renders computed coordinator gate blockers.
3
+ *
4
+ * Pure render function: takes data from /api/coordinator/blockers, returns HTML.
5
+ * All blocker semantics are server-side (coordinator-blockers.js). This view
6
+ * renders the snapshot without reimplementing gate logic.
7
+ *
8
+ * Per DEC-DASH-COORD-BLOCKERS-002: blocker evaluation must come from the same
9
+ * server-side gate evaluators used by `multi step` and `multi approve-gate`.
10
+ */
11
+
12
+ function esc(str) {
13
+ if (!str) return '';
14
+ return String(str)
15
+ .replace(/&/g, '&')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;')
19
+ .replace(/'/g, '&#39;');
20
+ }
21
+
22
+ function badge(label, color = 'var(--text-dim)') {
23
+ return `<span class="badge" style="color:${color};border-color:${color}">${esc(label)}</span>`;
24
+ }
25
+
26
+ function modeColor(mode) {
27
+ const colors = {
28
+ pending_gate: 'var(--yellow)',
29
+ phase_transition: 'var(--accent)',
30
+ run_completion: 'var(--green)',
31
+ };
32
+ return colors[mode] || 'var(--text-dim)';
33
+ }
34
+
35
+ function blockerColor(code) {
36
+ if (code === 'repo_run_id_mismatch') return 'var(--red)';
37
+ if (code === 'no_next_phase') return 'var(--text-dim)';
38
+ return 'var(--yellow)';
39
+ }
40
+
41
+ function renderBlockerRow(blocker) {
42
+ const code = blocker.code || 'unknown';
43
+ const color = blockerColor(code);
44
+ let html = `<div class="turn-card" style="border-left: 3px solid ${color}">
45
+ <div class="turn-header">
46
+ ${badge(code, color)}
47
+ </div>`;
48
+
49
+ if (blocker.message) {
50
+ html += `<div class="turn-summary">${esc(blocker.message)}</div>`;
51
+ }
52
+
53
+ if (code === 'repo_run_id_mismatch' && blocker.repo_id) {
54
+ html += `<dl class="detail-list">
55
+ <dt>Repo</dt><dd class="mono">${esc(blocker.repo_id)}</dd>`;
56
+ if (blocker.expected_run_id) {
57
+ html += `<dt>Expected</dt><dd class="mono">${esc(blocker.expected_run_id)}</dd>`;
58
+ }
59
+ if (blocker.actual_run_id) {
60
+ html += `<dt>Actual</dt><dd class="mono">${esc(blocker.actual_run_id)}</dd>`;
61
+ }
62
+ html += `</dl>`;
63
+ }
64
+
65
+ if (code === 'repo_not_ready' && blocker.repo_id) {
66
+ html += `<dl class="detail-list">
67
+ <dt>Repo</dt><dd class="mono">${esc(blocker.repo_id)}</dd>`;
68
+ if (blocker.current_phase) {
69
+ html += `<dt>Current Phase</dt><dd>${esc(blocker.current_phase)}</dd>`;
70
+ }
71
+ if (blocker.required_phase) {
72
+ html += `<dt>Required Phase</dt><dd>${esc(blocker.required_phase)}</dd>`;
73
+ }
74
+ html += `</dl>`;
75
+ }
76
+
77
+ html += `</div>`;
78
+ return html;
79
+ }
80
+
81
+ function renderActiveGate(active) {
82
+ if (!active) return '';
83
+
84
+ let html = `<div class="gate-card">
85
+ <h3>Active Gate</h3>
86
+ <dl class="detail-list">
87
+ <dt>Type</dt><dd>${esc(active.gate_type)}</dd>`;
88
+
89
+ if (active.gate_id) {
90
+ html += `<dt>Gate</dt><dd class="mono">${esc(active.gate_id)}</dd>`;
91
+ }
92
+ if (active.current_phase) {
93
+ html += `<dt>Current Phase</dt><dd>${esc(active.current_phase)}</dd>`;
94
+ }
95
+ if (active.target_phase) {
96
+ html += `<dt>Target Phase</dt><dd>${esc(active.target_phase)}</dd>`;
97
+ }
98
+ if (typeof active.ready === 'boolean') {
99
+ html += `<dt>Ready</dt><dd>${active.ready ? 'Yes' : 'No'}</dd>`;
100
+ }
101
+ if (active.pending === true) {
102
+ html += `<dt>Pending Approval</dt><dd>Yes</dd>`;
103
+ }
104
+ if (Array.isArray(active.required_repos) && active.required_repos.length > 0) {
105
+ html += `<dt>Required Repos</dt><dd>${esc(active.required_repos.join(', '))}</dd>`;
106
+ }
107
+ if (Array.isArray(active.human_barriers) && active.human_barriers.length > 0) {
108
+ html += `<dt>Human Barriers</dt><dd>${esc(active.human_barriers.join(', '))}</dd>`;
109
+ }
110
+
111
+ html += `</dl>`;
112
+
113
+ if (Array.isArray(active.blockers) && active.blockers.length > 0) {
114
+ html += `<div class="section" style="margin-top:12px"><h3>Blockers (${active.blockers.length})</h3>
115
+ <div class="turn-list">`;
116
+ for (const blocker of active.blockers) {
117
+ html += renderBlockerRow(blocker);
118
+ }
119
+ html += `</div></div>`;
120
+ }
121
+
122
+ html += `</div>`;
123
+ return html;
124
+ }
125
+
126
+ function renderRecoveryCommand(data) {
127
+ if (data.mode === 'pending_gate') {
128
+ return `<div class="section"><h3>Recovery</h3>
129
+ <p class="recovery-hint">Approve the pending gate:</p>
130
+ <pre class="recovery-command mono" data-copy="agentxchain multi approve-gate">agentxchain multi approve-gate</pre>
131
+ </div>`;
132
+ }
133
+
134
+ const hasRunIdMismatch = Array.isArray(data.active?.blockers)
135
+ && data.active.blockers.some(b => b.code === 'repo_run_id_mismatch');
136
+
137
+ if (hasRunIdMismatch) {
138
+ return `<div class="section"><h3>Recovery</h3>
139
+ <p class="recovery-hint">Run identity drift detected. Investigate child repos before resuming:</p>
140
+ <pre class="recovery-command mono" data-copy="agentxchain multi resume">agentxchain multi resume</pre>
141
+ </div>`;
142
+ }
143
+
144
+ return '';
145
+ }
146
+
147
+ export function render({ coordinatorBlockers }) {
148
+ if (!coordinatorBlockers) {
149
+ return `<div class="placeholder"><h2>Coordinator Blockers</h2><p>No coordinator blocker data available. Ensure a coordinator run is active.</p></div>`;
150
+ }
151
+
152
+ if (coordinatorBlockers.ok === false) {
153
+ return `<div class="placeholder"><h2>Coordinator Blockers</h2><p>${esc(coordinatorBlockers.error || 'Failed to load coordinator blockers.')}</p></div>`;
154
+ }
155
+
156
+ const data = coordinatorBlockers;
157
+ const blockers = data.active?.blockers || [];
158
+ const hasBlockers = blockers.length > 0 && !blockers.every(b => b.code === 'no_next_phase');
159
+
160
+ let html = `<div class="blockers-view">`;
161
+
162
+ // Header
163
+ html += `<div class="run-header">
164
+ <div class="run-meta">`;
165
+ if (data.super_run_id) {
166
+ html += `<span class="mono run-id">${esc(data.super_run_id)}</span>`;
167
+ }
168
+ if (data.status) {
169
+ const statusColors = { active: 'var(--green)', blocked: 'var(--red)', completed: 'var(--accent)', paused: 'var(--yellow)' };
170
+ html += badge(data.status, statusColors[data.status] || 'var(--text-dim)');
171
+ }
172
+ html += `${badge(data.mode, modeColor(data.mode))}`;
173
+ if (data.phase) {
174
+ html += `<span class="phase-label">Phase: <strong>${esc(data.phase)}</strong></span>`;
175
+ }
176
+ html += `</div></div>`;
177
+
178
+ // Blocked reason
179
+ if (data.blocked_reason) {
180
+ html += `<div class="blocked-banner">
181
+ <div class="blocked-icon">BLOCKED</div>
182
+ <div class="blocked-reason">${esc(
183
+ typeof data.blocked_reason === 'string'
184
+ ? data.blocked_reason
185
+ : JSON.stringify(data.blocked_reason)
186
+ )}</div>
187
+ </div>`;
188
+ }
189
+
190
+ // Status summary
191
+ if (!hasBlockers && data.mode === 'pending_gate') {
192
+ html += `<div class="gate-card"><h3>Awaiting Approval</h3>
193
+ <p class="turn-summary">All prerequisites are satisfied. The coordinator is waiting for human gate approval.</p></div>`;
194
+ } else if (!hasBlockers) {
195
+ html += `<div class="gate-card"><h3>No Blockers</h3>
196
+ <p class="turn-summary">The coordinator gate has no outstanding blockers.</p></div>`;
197
+ }
198
+
199
+ // Active gate detail
200
+ html += renderActiveGate(data.active);
201
+
202
+ // Recovery
203
+ html += renderRecoveryCommand(data);
204
+
205
+ // Evaluations summary (collapsed detail for deeper inspection)
206
+ if (data.evaluations) {
207
+ html += `<div class="section"><h3>Gate Evaluations</h3>`;
208
+ const { phase_transition, run_completion } = data.evaluations;
209
+
210
+ if (phase_transition) {
211
+ html += `<div class="turn-card" data-turn-expand>
212
+ <div class="turn-header">
213
+ <span>Phase Transition</span>
214
+ ${badge(phase_transition.ready ? 'ready' : 'not ready', phase_transition.ready ? 'var(--green)' : 'var(--yellow)')}
215
+ </div>
216
+ <div class="turn-detail-panel">
217
+ <dl class="detail-list">`;
218
+ if (phase_transition.current_phase) html += `<dt>Current</dt><dd>${esc(phase_transition.current_phase)}</dd>`;
219
+ if (phase_transition.target_phase) html += `<dt>Target</dt><dd>${esc(phase_transition.target_phase)}</dd>`;
220
+ if (phase_transition.gate_id) html += `<dt>Gate</dt><dd class="mono">${esc(phase_transition.gate_id)}</dd>`;
221
+ html += `<dt>Blockers</dt><dd>${phase_transition.blockers?.length || 0}</dd>`;
222
+ html += `</dl>`;
223
+ if (phase_transition.blockers?.length > 0) {
224
+ html += `<div class="annotation-list" style="margin-top:8px">`;
225
+ for (const b of phase_transition.blockers) {
226
+ html += `<div class="annotation-card">
227
+ <span class="mono">${esc(b.code || 'unknown')}</span>
228
+ <span>${esc(b.message || '')}</span>
229
+ </div>`;
230
+ }
231
+ html += `</div>`;
232
+ }
233
+ html += `</div></div>`;
234
+ }
235
+
236
+ if (run_completion) {
237
+ html += `<div class="turn-card" data-turn-expand>
238
+ <div class="turn-header">
239
+ <span>Run Completion</span>
240
+ ${badge(run_completion.ready ? 'ready' : 'not ready', run_completion.ready ? 'var(--green)' : 'var(--yellow)')}
241
+ </div>
242
+ <div class="turn-detail-panel">
243
+ <dl class="detail-list">`;
244
+ if (run_completion.gate_id) html += `<dt>Gate</dt><dd class="mono">${esc(run_completion.gate_id)}</dd>`;
245
+ html += `<dt>Blockers</dt><dd>${run_completion.blockers?.length || 0}</dd>`;
246
+ if (typeof run_completion.requires_human_approval === 'boolean') {
247
+ html += `<dt>Human Approval</dt><dd>${run_completion.requires_human_approval ? 'Required' : 'Not required'}</dd>`;
248
+ }
249
+ html += `</dl>`;
250
+ if (run_completion.blockers?.length > 0) {
251
+ html += `<div class="annotation-list" style="margin-top:8px">`;
252
+ for (const b of run_completion.blockers) {
253
+ html += `<div class="annotation-card">
254
+ <span class="mono">${esc(b.code || 'unknown')}</span>
255
+ <span>${esc(b.message || '')}</span>
256
+ </div>`;
257
+ }
258
+ html += `</div>`;
259
+ }
260
+ html += `</div></div>`;
261
+ }
262
+
263
+ html += `</div>`;
264
+ }
265
+
266
+ html += `</div>`;
267
+ return html;
268
+ }
@@ -37,7 +37,86 @@ function summarizeBarriers(barriers) {
37
37
  return counts;
38
38
  }
39
39
 
40
- export function render({ coordinatorState, coordinatorBarriers = {}, barrierLedger = [] }) {
40
+ function renderCoordinatorAttentionSnapshot(coordinatorBlockers) {
41
+ if (!coordinatorBlockers || coordinatorBlockers.ok === false) {
42
+ return '';
43
+ }
44
+
45
+ const active = coordinatorBlockers.active || {};
46
+ const blockers = Array.isArray(active.blockers)
47
+ ? active.blockers.filter((blocker) => blocker?.code !== 'no_next_phase')
48
+ : [];
49
+ const hasBlockers = blockers.length > 0;
50
+ const title = coordinatorBlockers.mode === 'pending_gate' ? 'Approval Snapshot' : 'Blocker Snapshot';
51
+
52
+ let html = `<div class="gate-card">
53
+ <h3>${title}</h3>
54
+ <dl class="detail-list">`;
55
+ if (coordinatorBlockers.mode) {
56
+ html += `<dt>Mode</dt><dd>${esc(coordinatorBlockers.mode)}</dd>`;
57
+ }
58
+ if (active.gate_type) {
59
+ html += `<dt>Type</dt><dd>${esc(active.gate_type)}</dd>`;
60
+ }
61
+ if (active.gate_id) {
62
+ html += `<dt>Gate</dt><dd class="mono">${esc(active.gate_id)}</dd>`;
63
+ }
64
+ if (active.current_phase) {
65
+ html += `<dt>Current</dt><dd>${esc(active.current_phase)}</dd>`;
66
+ }
67
+ if (active.target_phase) {
68
+ html += `<dt>Target</dt><dd>${esc(active.target_phase)}</dd>`;
69
+ }
70
+ if (hasBlockers) {
71
+ html += `<dt>Blockers</dt><dd>${blockers.length}</dd>`;
72
+ }
73
+ html += `</dl>`;
74
+
75
+ if (coordinatorBlockers.mode === 'pending_gate') {
76
+ html += `<p class="turn-summary">All coordinator prerequisites are satisfied. Human approval is the remaining action.</p>`;
77
+ } else if (hasBlockers) {
78
+ html += `<div class="turn-list">`;
79
+ for (const blocker of blockers) {
80
+ html += `<div class="turn-card">
81
+ <div class="turn-header"><span class="mono">${esc(blocker.code || 'unknown')}</span></div>`;
82
+ if (blocker.message) {
83
+ html += `<div class="turn-summary">${esc(blocker.message)}</div>`;
84
+ }
85
+ if (blocker.repo_id || blocker.expected_run_id || blocker.actual_run_id) {
86
+ html += `<dl class="detail-list">`;
87
+ if (blocker.repo_id) html += `<dt>Repo</dt><dd class="mono">${esc(blocker.repo_id)}</dd>`;
88
+ if (blocker.expected_run_id) html += `<dt>Expected</dt><dd class="mono">${esc(blocker.expected_run_id)}</dd>`;
89
+ if (blocker.actual_run_id) html += `<dt>Actual</dt><dd class="mono">${esc(blocker.actual_run_id)}</dd>`;
90
+ if (blocker.current_phase) html += `<dt>Current Phase</dt><dd>${esc(blocker.current_phase)}</dd>`;
91
+ if (blocker.required_phase) html += `<dt>Required Phase</dt><dd>${esc(blocker.required_phase)}</dd>`;
92
+ html += `</dl>`;
93
+ }
94
+ html += `</div>`;
95
+ }
96
+ html += `</div>`;
97
+ } else if (coordinatorBlockers.blocked_reason) {
98
+ html += `<p class="turn-summary">${esc(
99
+ typeof coordinatorBlockers.blocked_reason === 'string'
100
+ ? coordinatorBlockers.blocked_reason
101
+ : JSON.stringify(coordinatorBlockers.blocked_reason)
102
+ )}</p>`;
103
+ }
104
+
105
+ html += `<div class="gate-action">
106
+ <p>Inspect full diagnostics:</p>
107
+ <p><a href="#blockers">Open Blockers view</a></p>
108
+ </div>
109
+ </div>`;
110
+
111
+ return html;
112
+ }
113
+
114
+ export function render({
115
+ coordinatorState,
116
+ coordinatorBarriers = {},
117
+ barrierLedger = [],
118
+ coordinatorBlockers = null,
119
+ }) {
41
120
  if (!coordinatorState) {
42
121
  return `<div class="placeholder"><h2>No Initiative</h2><p>No coordinator run found. Start one with <code class="mono">agentxchain multi init</code></p></div>`;
43
122
  }
@@ -80,7 +159,10 @@ export function render({ coordinatorState, coordinatorBarriers = {}, barrierLedg
80
159
  </div>
81
160
  </div>`;
82
161
  }
83
- if (coordinatorState.blocked_reason) {
162
+ const blockerSnapshot = renderCoordinatorAttentionSnapshot(coordinatorBlockers);
163
+ if (blockerSnapshot) {
164
+ html += blockerSnapshot;
165
+ } else if (coordinatorState.blocked_reason) {
84
166
  html += `<div class="gate-card">
85
167
  <h3>Blocked State</h3>
86
168
  <p class="turn-summary">${esc(
@@ -381,6 +381,7 @@
381
381
  <a href="#hooks">Hooks</a>
382
382
  <a href="#blocked">Blocked</a>
383
383
  <a href="#gate">Gates</a>
384
+ <a href="#blockers">Blockers</a>
384
385
  </nav>
385
386
  <main id="view-container">
386
387
  <div class="placeholder">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.24.2",
3
+ "version": "2.25.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -681,6 +681,7 @@ async function initGoverned(opts) {
681
681
  console.log(` ${chalk.dim('└──')} TALK.md`);
682
682
  console.log('');
683
683
  console.log(` ${chalk.dim('Roles:')} pm, dev, qa, eng_director`);
684
+ console.log(` ${chalk.dim('Phases:')} planning → implementation → qa ${chalk.dim('(default; extend via routing in agentxchain.json)')}`);
684
685
  console.log(` ${chalk.dim('Template:')} ${templateId}`);
685
686
  console.log(` ${chalk.dim('Dev runtime:')} ${formatGovernedRuntimeCommand(localDevRuntime)} ${chalk.dim(`(${localDevRuntime.prompt_transport})`)}`);
686
687
  console.log(` ${chalk.dim('Protocol:')} governed convergence`);
@@ -257,7 +257,12 @@ export async function multiStepCommand(options) {
257
257
  if (gate.blockers.length > 0) {
258
258
  console.error(`Coordinator ${gate.type === 'phase_transition' ? 'phase' : 'completion'} gate is not ready:`);
259
259
  for (const blocker of gate.blockers) {
260
- console.error(` - ${blocker.message}`);
260
+ const codeTag = blocker.code ? `[${blocker.code}] ` : '';
261
+ console.error(` - ${codeTag}${blocker.message}`);
262
+ if (blocker.code === 'repo_run_id_mismatch') {
263
+ console.error(` expected: ${blocker.expected_run_id}`);
264
+ console.error(` actual: ${blocker.actual_run_id}`);
265
+ }
261
266
  }
262
267
  }
263
268
  process.exitCode = 1;
@@ -6,7 +6,8 @@ import { safeParseJson } from './schema.js';
6
6
  export const COORDINATOR_CONFIG_FILE = 'agentxchain-multi.json';
7
7
 
8
8
  const VALID_ID = /^[a-z0-9_-]+$/;
9
- const VALID_PHASES = new Set(['planning', 'implementation', 'qa']);
9
+ const DEFAULT_PHASES = new Set(['planning', 'implementation', 'qa']);
10
+ const VALID_PHASE_NAME = /^[a-z][a-z0-9_-]*$/;
10
11
  const VALID_BARRIER_TYPES = new Set([
11
12
  'all_repos_accepted',
12
13
  'interface_alignment',
@@ -88,11 +89,14 @@ function validateWorkstreams(raw, repoIds, errors) {
88
89
  continue;
89
90
  }
90
91
 
91
- if (!VALID_PHASES.has(workstream.phase)) {
92
+ // Derive valid phases from routing keys when present; fall back to defaults
93
+ const validPhases = raw.routing ? new Set(Object.keys(raw.routing)) : DEFAULT_PHASES;
94
+ if (!validPhases.has(workstream.phase)) {
95
+ const phaseList = [...validPhases].join(', ');
92
96
  pushError(
93
97
  errors,
94
98
  'workstream_phase_invalid',
95
- `workstream "${workstreamId}" phase must be one of: planning, implementation, qa`,
99
+ `workstream "${workstreamId}" phase must be one of: ${phaseList}`,
96
100
  );
97
101
  }
98
102
 
@@ -279,8 +283,8 @@ function validateRouting(raw, workstreamIds, errors) {
279
283
 
280
284
  const workstreamIdSet = new Set(workstreamIds);
281
285
  for (const [phase, route] of Object.entries(raw.routing)) {
282
- if (!VALID_PHASES.has(phase)) {
283
- pushError(errors, 'routing_phase_invalid', `routing phase "${phase}" must be one of: planning, implementation, qa`);
286
+ if (!VALID_PHASE_NAME.test(phase)) {
287
+ pushError(errors, 'routing_phase_invalid', `routing phase "${phase}" must be lowercase alphanumeric starting with a letter (hyphens and underscores allowed)`);
284
288
  }
285
289
 
286
290
  if (!route || typeof route !== 'object' || Array.isArray(route)) {
@@ -21,6 +21,26 @@ function loadRepoState(repoPath) {
21
21
  }
22
22
  }
23
23
 
24
+ function buildRunIdMismatchBlocker(state, repoId, repoState) {
25
+ const expectedRunId = state?.repo_runs?.[repoId]?.run_id ?? null;
26
+ if (!expectedRunId) {
27
+ return null;
28
+ }
29
+
30
+ const actualRunId = repoState?.run_id ?? null;
31
+ if (actualRunId === expectedRunId) {
32
+ return null;
33
+ }
34
+
35
+ return {
36
+ code: 'repo_run_id_mismatch',
37
+ repo_id: repoId,
38
+ expected_run_id: expectedRunId,
39
+ actual_run_id: actualRunId,
40
+ message: `Repo "${repoId}" run identity drifted: coordinator expects "${expectedRunId}" but repo has "${actualRunId ?? 'null'}"`,
41
+ };
42
+ }
43
+
24
44
  function getActiveTurnCount(repoState) {
25
45
  if (!repoState?.active_turns || typeof repoState.active_turns !== 'object' || Array.isArray(repoState.active_turns)) {
26
46
  return 0;
@@ -68,7 +88,7 @@ function getRequiredReposForPhase(state, config) {
68
88
  return [...required];
69
89
  }
70
90
 
71
- function buildRepoBlockers(config, repoIds) {
91
+ function buildRepoBlockers(state, config, repoIds) {
72
92
  const blockers = [];
73
93
 
74
94
  for (const repoId of repoIds) {
@@ -84,6 +104,12 @@ function buildRepoBlockers(config, repoIds) {
84
104
  continue;
85
105
  }
86
106
 
107
+ const runIdMismatch = buildRunIdMismatchBlocker(state, repoId, repoState);
108
+ if (runIdMismatch) {
109
+ blockers.push(runIdMismatch);
110
+ continue;
111
+ }
112
+
87
113
  if (repoState.status === 'blocked') {
88
114
  blockers.push({
89
115
  code: 'repo_blocked',
@@ -234,7 +260,7 @@ export function evaluatePhaseGate(workspacePath, state, config, targetPhase) {
234
260
  }
235
261
 
236
262
  const repoIds = getRequiredReposForPhase(state, config);
237
- blockers.push(...buildRepoBlockers(config, repoIds));
263
+ blockers.push(...buildRepoBlockers(state, config, repoIds));
238
264
 
239
265
  const barriers = readBarriers(workspacePath);
240
266
  const barrierState = buildPhaseBarrierState(state, config, barriers);
@@ -391,6 +417,12 @@ export function evaluateCompletionGate(workspacePath, state, config) {
391
417
  continue;
392
418
  }
393
419
 
420
+ const runIdMismatch = buildRunIdMismatchBlocker(state, repoId, repoState);
421
+ if (runIdMismatch) {
422
+ blockers.push(runIdMismatch);
423
+ continue;
424
+ }
425
+
394
426
  if (repoState.status === 'blocked') {
395
427
  blockers.push({
396
428
  code: 'repo_blocked',
@@ -83,6 +83,10 @@ function isAcceptedRepoHistoryEntry(entry) {
83
83
  return Boolean(entry.accepted_at) || entry.status === 'accepted';
84
84
  }
85
85
 
86
+ function buildRunIdMismatchReason(repoId, expectedRunId, actualRunId) {
87
+ return `Repo "${repoId}" run identity drifted: coordinator expects "${expectedRunId}" but repo has "${actualRunId ?? 'null'}"`;
88
+ }
89
+
86
90
  // ── Divergence Detection ────────────────────────────────────────────────────
87
91
 
88
92
  /**
@@ -124,13 +128,13 @@ export function detectDivergence(workspacePath, state, config) {
124
128
  }
125
129
 
126
130
  // Run ID mismatch
127
- if (repoRun.run_id && repoState.run_id && repoRun.run_id !== repoState.run_id) {
131
+ if (repoRun.run_id && repoRun.run_id !== (repoState.run_id ?? null)) {
128
132
  mismatches.push({
129
133
  type: 'run_id_mismatch',
130
134
  repo_id: repoId,
131
135
  coordinator_run_id: repoRun.run_id,
132
- repo_run_id: repoState.run_id,
133
- detail: `Coordinator expects run "${repoRun.run_id}" but repo has "${repoState.run_id}"`,
136
+ repo_run_id: repoState.run_id ?? null,
137
+ detail: buildRunIdMismatchReason(repoId, repoRun.run_id, repoState.run_id ?? null),
134
138
  });
135
139
  }
136
140
 
@@ -238,6 +242,7 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
238
242
 
239
243
  // Step 1: Refresh repo_runs from repo-local authority
240
244
  const updatedRepoRuns = { ...state.repo_runs };
245
+ const runIdMismatches = [];
241
246
 
242
247
  for (const [repoId, repoRun] of Object.entries(state.repo_runs || {})) {
243
248
  const repo = config.repos?.[repoId];
@@ -254,9 +259,11 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
254
259
 
255
260
  const changes = {};
256
261
 
257
- // Update run_id if it changed (e.g., repo was re-initialized outside coordinator)
258
- if (repoState.run_id && repoState.run_id !== repoRun.run_id) {
259
- changes.run_id = repoState.run_id;
262
+ if (repoRun.run_id && repoRun.run_id !== (repoState.run_id ?? null)) {
263
+ const reason = buildRunIdMismatchReason(repoId, repoRun.run_id, repoState.run_id ?? null);
264
+ runIdMismatches.push(reason);
265
+ errors.push(reason);
266
+ continue;
260
267
  }
261
268
 
262
269
  // Update status from repo-local authority
@@ -395,7 +402,11 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
395
402
  let blockedReason = null;
396
403
  const pendingGate = state.pending_gate;
397
404
 
398
- if (pendingGate) {
405
+ if (runIdMismatches.length > 0) {
406
+ blockedReason = runIdMismatches.join('; ');
407
+ }
408
+
409
+ if (!blockedReason && pendingGate) {
399
410
  const gateCoherent = validatePendingGateCoherence(pendingGate, updatedRepoRuns, config);
400
411
  if (!gateCoherent.ok) {
401
412
  blockedReason = gateCoherent.reason;
@@ -17,6 +17,7 @@ import { join, extname, resolve, sep } from 'path';
17
17
  import { readResource } from './state-reader.js';
18
18
  import { FileWatcher } from './file-watcher.js';
19
19
  import { approvePendingDashboardGate } from './actions.js';
20
+ import { readCoordinatorBlockerSnapshot } from './coordinator-blockers.js';
20
21
 
21
22
  const MIME_TYPES = {
22
23
  '.html': 'text/html; charset=utf-8',
@@ -208,6 +209,7 @@ function resolveDashboardAssetPath(dashboardDir, pathname) {
208
209
  // ── Bridge Server ───────────────────────────────────────────────────────────
209
210
 
210
211
  export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }) {
212
+ const workspacePath = resolve(agentxchainDir, '..');
211
213
  const wsClients = new Set();
212
214
  const watcher = new FileWatcher(agentxchainDir);
213
215
  const mutationToken = randomBytes(24).toString('hex');
@@ -271,6 +273,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
271
273
  return;
272
274
  }
273
275
 
276
+ if (pathname === '/api/coordinator/blockers') {
277
+ const result = readCoordinatorBlockerSnapshot(workspacePath);
278
+ writeJson(res, result.status, result.body);
279
+ return;
280
+ }
281
+
274
282
  // API routes
275
283
  if (pathname.startsWith('/api/')) {
276
284
  const result = readResource(agentxchainDir, pathname);
@@ -0,0 +1,168 @@
1
+ import { evaluateCompletionGate, evaluatePhaseGate } from '../coordinator-gates.js';
2
+ import { loadCoordinatorConfig } from '../coordinator-config.js';
3
+ import { loadCoordinatorState } from '../coordinator-state.js';
4
+
5
+ function normalizePendingGate(pendingGate) {
6
+ if (!pendingGate || typeof pendingGate !== 'object' || Array.isArray(pendingGate)) {
7
+ return null;
8
+ }
9
+
10
+ if (typeof pendingGate.gate !== 'string' || pendingGate.gate.length === 0) {
11
+ return null;
12
+ }
13
+
14
+ if (typeof pendingGate.gate_type !== 'string' || pendingGate.gate_type.length === 0) {
15
+ return null;
16
+ }
17
+
18
+ const normalized = {
19
+ gate: pendingGate.gate,
20
+ gate_type: pendingGate.gate_type,
21
+ };
22
+
23
+ if (typeof pendingGate.from === 'string' && pendingGate.from.length > 0) {
24
+ normalized.from = pendingGate.from;
25
+ }
26
+
27
+ if (typeof pendingGate.to === 'string' && pendingGate.to.length > 0) {
28
+ normalized.to = pendingGate.to;
29
+ }
30
+
31
+ if (Array.isArray(pendingGate.required_repos)) {
32
+ normalized.required_repos = pendingGate.required_repos;
33
+ }
34
+
35
+ if (Array.isArray(pendingGate.human_barriers)) {
36
+ normalized.human_barriers = pendingGate.human_barriers;
37
+ }
38
+
39
+ if (typeof pendingGate.requested_at === 'string' && pendingGate.requested_at.length > 0) {
40
+ normalized.requested_at = pendingGate.requested_at;
41
+ }
42
+
43
+ return normalized;
44
+ }
45
+
46
+ function normalizePhaseEvaluation(evaluation) {
47
+ return {
48
+ ready: Boolean(evaluation?.ready),
49
+ gate_id: evaluation?.gate_id ?? null,
50
+ current_phase: evaluation?.current_phase ?? null,
51
+ target_phase: evaluation?.target_phase ?? null,
52
+ required_repos: Array.isArray(evaluation?.required_repos) ? evaluation.required_repos : [],
53
+ workstreams: Array.isArray(evaluation?.workstreams) ? evaluation.workstreams : [],
54
+ human_barriers: Array.isArray(evaluation?.human_barriers) ? evaluation.human_barriers : [],
55
+ blockers: Array.isArray(evaluation?.blockers) ? evaluation.blockers : [],
56
+ };
57
+ }
58
+
59
+ function normalizeCompletionEvaluation(evaluation) {
60
+ return {
61
+ ready: Boolean(evaluation?.ready),
62
+ gate_id: evaluation?.gate_id ?? null,
63
+ required_repos: Array.isArray(evaluation?.required_repos) ? evaluation.required_repos : [],
64
+ human_barriers: Array.isArray(evaluation?.human_barriers) ? evaluation.human_barriers : [],
65
+ requires_human_approval: evaluation?.requires_human_approval !== false,
66
+ blockers: Array.isArray(evaluation?.blockers) ? evaluation.blockers : [],
67
+ };
68
+ }
69
+
70
+ function buildPendingGateSnapshot(pendingGate) {
71
+ return {
72
+ gate_type: pendingGate.gate_type,
73
+ gate_id: pendingGate.gate,
74
+ ready: true,
75
+ current_phase: pendingGate.from ?? null,
76
+ target_phase: pendingGate.to ?? null,
77
+ required_repos: Array.isArray(pendingGate.required_repos) ? pendingGate.required_repos : [],
78
+ human_barriers: Array.isArray(pendingGate.human_barriers) ? pendingGate.human_barriers : [],
79
+ blockers: [],
80
+ pending: true,
81
+ };
82
+ }
83
+
84
+ function phaseIsFinal(phaseEvaluation) {
85
+ return phaseEvaluation.blockers.length === 1
86
+ && phaseEvaluation.blockers[0]?.code === 'no_next_phase';
87
+ }
88
+
89
+ function getConfigErrorResponse(errors) {
90
+ const issueList = Array.isArray(errors) ? errors : [];
91
+ const missing = issueList.some((error) => typeof error === 'string' && error.startsWith('config_missing:'));
92
+
93
+ return {
94
+ ok: false,
95
+ status: missing ? 404 : 422,
96
+ body: {
97
+ ok: false,
98
+ code: missing ? 'coordinator_config_missing' : 'coordinator_config_invalid',
99
+ error: missing
100
+ ? 'Coordinator config not found. Run `agentxchain multi init` first.'
101
+ : 'Coordinator config is invalid.',
102
+ errors: issueList,
103
+ },
104
+ };
105
+ }
106
+
107
+ export function readCoordinatorBlockerSnapshot(workspacePath) {
108
+ const configResult = loadCoordinatorConfig(workspacePath);
109
+ if (!configResult.ok) {
110
+ return getConfigErrorResponse(configResult.errors);
111
+ }
112
+
113
+ const state = loadCoordinatorState(workspacePath);
114
+ if (!state) {
115
+ return {
116
+ ok: false,
117
+ status: 404,
118
+ body: {
119
+ ok: false,
120
+ code: 'coordinator_state_missing',
121
+ error: 'Coordinator state not found. Run `agentxchain multi init` first.',
122
+ },
123
+ };
124
+ }
125
+
126
+ const phaseEvaluation = normalizePhaseEvaluation(
127
+ evaluatePhaseGate(workspacePath, state, configResult.config),
128
+ );
129
+ const completionEvaluation = normalizeCompletionEvaluation(
130
+ evaluateCompletionGate(workspacePath, state, configResult.config),
131
+ );
132
+ const pendingGate = normalizePendingGate(state.pending_gate);
133
+
134
+ let mode = 'phase_transition';
135
+ let active = {
136
+ gate_type: 'phase_transition',
137
+ ...phaseEvaluation,
138
+ };
139
+
140
+ if (pendingGate) {
141
+ mode = 'pending_gate';
142
+ active = buildPendingGateSnapshot(pendingGate);
143
+ } else if (phaseIsFinal(phaseEvaluation)) {
144
+ mode = 'run_completion';
145
+ active = {
146
+ gate_type: 'run_completion',
147
+ ...completionEvaluation,
148
+ };
149
+ }
150
+
151
+ return {
152
+ ok: true,
153
+ status: 200,
154
+ body: {
155
+ mode,
156
+ super_run_id: state.super_run_id ?? null,
157
+ status: state.status ?? null,
158
+ phase: state.phase ?? null,
159
+ blocked_reason: state.blocked_reason ?? null,
160
+ pending_gate: pendingGate,
161
+ active,
162
+ evaluations: {
163
+ phase_transition: phaseEvaluation,
164
+ run_completion: completionEvaluation,
165
+ },
166
+ },
167
+ };
168
+ }
@@ -73,6 +73,15 @@ export function evaluatePhaseExit({ state, config, acceptedTurn, root }) {
73
73
  };
74
74
  }
75
75
 
76
+ const invalidOrderReason = getInvalidPhaseTransitionReason(currentPhase, transitionRequest, routing);
77
+ if (invalidOrderReason) {
78
+ return {
79
+ ...baseResult,
80
+ action: 'gate_failed',
81
+ reasons: [invalidOrderReason],
82
+ };
83
+ }
84
+
76
85
  // Find the exit gate for the current phase
77
86
  const currentRouting = routing[currentPhase];
78
87
  if (!currentRouting || !currentRouting.exit_gate) {
@@ -285,6 +294,43 @@ export function getPhaseOrder(routing) {
285
294
  return Object.keys(routing || {});
286
295
  }
287
296
 
297
+ /**
298
+ * Return the next declared phase after the current phase, or null when the
299
+ * current phase is final or not part of the routing config.
300
+ *
301
+ * @param {string} currentPhase
302
+ * @param {object} routing
303
+ * @returns {string|null}
304
+ */
305
+ export function getNextPhase(currentPhase, routing) {
306
+ const phases = getPhaseOrder(routing || {});
307
+ const currentIndex = phases.indexOf(currentPhase);
308
+ if (currentIndex === -1 || currentIndex >= phases.length - 1) {
309
+ return null;
310
+ }
311
+ return phases[currentIndex + 1];
312
+ }
313
+
314
+ /**
315
+ * Validate that a requested phase transition follows the declared routing
316
+ * order. Returns null when the request is valid.
317
+ *
318
+ * @param {string} currentPhase
319
+ * @param {string} requestedPhase
320
+ * @param {object} routing
321
+ * @returns {string|null}
322
+ */
323
+ export function getInvalidPhaseTransitionReason(currentPhase, requestedPhase, routing) {
324
+ const nextPhase = getNextPhase(currentPhase, routing);
325
+ if (!nextPhase) {
326
+ return `phase_transition_request "${requestedPhase}" is invalid in final phase "${currentPhase}"; use run_completion_request instead.`;
327
+ }
328
+ if (requestedPhase !== nextPhase) {
329
+ return `phase_transition_request "${requestedPhase}" is invalid from phase "${currentPhase}"; next phase is "${nextPhase}".`;
330
+ }
331
+ return null;
332
+ }
333
+
288
334
  /**
289
335
  * Check if a phase is the final phase in the routing config.
290
336
  *
@@ -21,7 +21,8 @@ const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp'];
21
21
  const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai'];
22
22
  export const VALID_PROMPT_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
23
23
  const VALID_MCP_TRANSPORTS = ['stdio', 'streamable_http'];
24
- const VALID_PHASES = ['planning', 'implementation', 'qa'];
24
+ const DEFAULT_PHASES = ['planning', 'implementation', 'qa'];
25
+ const VALID_PHASE_NAME = /^[a-z][a-z0-9_-]*$/;
25
26
  const VALID_API_PROXY_RETRY_JITTER = ['none', 'full'];
26
27
  const VALID_API_PROXY_RETRY_CLASSES = [
27
28
  'rate_limited',
@@ -400,10 +401,11 @@ export function validateV4Config(data, projectRoot) {
400
401
  }
401
402
 
402
403
  // Routing (optional but validated if present)
404
+ // Phase names are derived from routing keys when present; fall back to defaults
403
405
  if (data.routing) {
404
406
  for (const [phase, route] of Object.entries(data.routing)) {
405
- if (!VALID_PHASES.includes(phase)) {
406
- errors.push(`Routing references unknown phase: "${phase}"`);
407
+ if (!VALID_PHASE_NAME.test(phase)) {
408
+ errors.push(`Routing phase name "${phase}" must be lowercase alphanumeric starting with a letter (hyphens and underscores allowed)`);
407
409
  }
408
410
  if (route.entry_role && data.roles && !data.roles[route.entry_role]) {
409
411
  errors.push(`Routing "${phase}": entry_role "${route.entry_role}" is not a defined role`);
package/src/lib/report.js CHANGED
@@ -299,6 +299,23 @@ function normalizeCoordinatorBlockedReason(blockedReason) {
299
299
  return null;
300
300
  }
301
301
 
302
+ function detectRunIdMismatches(repos, coordinatorRepoRuns) {
303
+ const mismatches = [];
304
+ for (const repo of repos) {
305
+ if (!repo.ok || !repo.run_id) continue;
306
+ const expected = coordinatorRepoRuns[repo.repo_id]?.run_id;
307
+ if (!expected) continue;
308
+ if (expected !== repo.run_id) {
309
+ mismatches.push({
310
+ repo_id: repo.repo_id,
311
+ expected_run_id: expected,
312
+ actual_run_id: repo.run_id,
313
+ });
314
+ }
315
+ }
316
+ return mismatches;
317
+ }
318
+
302
319
  function normalizePendingGate(pendingGate) {
303
320
  if (!pendingGate || typeof pendingGate !== 'object' || Array.isArray(pendingGate)) return null;
304
321
  if (typeof pendingGate.gate !== 'string' || pendingGate.gate.length === 0) return null;
@@ -317,7 +334,7 @@ function normalizePendingGate(pendingGate) {
317
334
  return normalized;
318
335
  }
319
336
 
320
- function deriveCoordinatorNextActions({ status, blockedReason, pendingGate, repos, coordinatorRepoRuns }) {
337
+ function deriveCoordinatorNextActions({ status, blockedReason, pendingGate, repos, coordinatorRepoRuns, runIdMismatches }) {
321
338
  const nextActions = [];
322
339
 
323
340
  if (status === 'blocked') {
@@ -325,6 +342,14 @@ function deriveCoordinatorNextActions({ status, blockedReason, pendingGate, repo
325
342
  command: 'agentxchain multi resume',
326
343
  reason: `Coordinator is blocked${blockedReason ? `: ${blockedReason}` : ''}. Resume after fixing the underlying issue.`,
327
344
  });
345
+ if (runIdMismatches && runIdMismatches.length > 0) {
346
+ for (const m of runIdMismatches) {
347
+ nextActions.push({
348
+ command: `# repo_run_id_mismatch: ${m.repo_id}`,
349
+ reason: `Repo "${m.repo_id}" run identity drifted: coordinator expects "${m.expected_run_id}" but repo has "${m.actual_run_id}". Re-initialize the child repo with the correct run or use multi resume after investigation.`,
350
+ });
351
+ }
352
+ }
328
353
  if (pendingGate) {
329
354
  nextActions.push({
330
355
  command: 'agentxchain multi approve-gate',
@@ -574,12 +599,14 @@ function buildCoordinatorSubject(artifact) {
574
599
  const timing = computeCoordinatorTiming(artifact, coordinatorTimeline);
575
600
  const blockedReason = normalizeCoordinatorBlockedReason(coordinatorState.blocked_reason);
576
601
  const pendingGate = normalizePendingGate(coordinatorState.pending_gate);
602
+ const runIdMismatches = detectRunIdMismatches(repos, coordinatorState.repo_runs || {});
577
603
  const nextActions = deriveCoordinatorNextActions({
578
604
  status: artifact.summary?.status || null,
579
605
  blockedReason,
580
606
  pendingGate,
581
607
  repos,
582
608
  coordinatorRepoRuns: coordinatorState.repo_runs || {},
609
+ runIdMismatches,
583
610
  });
584
611
 
585
612
  return {
@@ -597,6 +624,7 @@ function buildCoordinatorSubject(artifact) {
597
624
  phase: artifact.summary?.phase || null,
598
625
  blocked_reason: blockedReason,
599
626
  pending_gate: pendingGate,
627
+ run_id_mismatches: runIdMismatches,
600
628
  next_actions: nextActions,
601
629
  created_at: timing.created_at,
602
630
  completed_at: timing.completed_at,
@@ -797,6 +825,16 @@ export function formatGovernanceReportText(report) {
797
825
  `Status: ${run.status || 'unknown'}`,
798
826
  `Phase: ${run.phase || 'unknown'}`,
799
827
  `Blocked reason: ${run.blocked_reason || 'none'}`,
828
+ ];
829
+
830
+ if (run.run_id_mismatches && run.run_id_mismatches.length > 0) {
831
+ lines.push(`Run ID mismatches: ${run.run_id_mismatches.length}`);
832
+ for (const m of run.run_id_mismatches) {
833
+ lines.push(` - ${m.repo_id}: expected ${m.expected_run_id}, actual ${m.actual_run_id}`);
834
+ }
835
+ }
836
+
837
+ lines.push(
800
838
  `Started: ${run.created_at || 'n/a'}`,
801
839
  `Repos: ${coordinator.repo_count} total, ${run.repo_ok_count} exported cleanly, ${run.repo_error_count} failed`,
802
840
  `Workstreams: ${coordinator.workstream_count}`,
@@ -804,7 +842,7 @@ export function formatGovernanceReportText(report) {
804
842
  `Repo statuses: ${formatStatusCounts(run.repo_status_counts)}`,
805
843
  `History entries: ${artifacts.history_entries}`,
806
844
  `Decision entries: ${artifacts.decision_entries}`,
807
- ];
845
+ );
808
846
 
809
847
  if (run.completed_at) {
810
848
  lines.push(`Completed: ${run.completed_at}`);
@@ -1044,6 +1082,16 @@ export function formatGovernanceReportMarkdown(report) {
1044
1082
  `- Status: \`${run.status || 'unknown'}\``,
1045
1083
  `- Phase: \`${run.phase || 'unknown'}\``,
1046
1084
  `- Blocked reason: \`${run.blocked_reason || 'none'}\``,
1085
+ ];
1086
+
1087
+ if (run.run_id_mismatches && run.run_id_mismatches.length > 0) {
1088
+ mdLines.push(`- **Run ID mismatches: ${run.run_id_mismatches.length}**`);
1089
+ for (const m of run.run_id_mismatches) {
1090
+ mdLines.push(` - \`${m.repo_id}\`: expected \`${m.expected_run_id}\`, actual \`${m.actual_run_id}\``);
1091
+ }
1092
+ }
1093
+
1094
+ mdLines.push(
1047
1095
  `- Started: \`${run.created_at || 'n/a'}\``,
1048
1096
  `- Repos: ${coordinator.repo_count} total, ${run.repo_ok_count} exported cleanly, ${run.repo_error_count} failed`,
1049
1097
  `- Workstreams: ${coordinator.workstream_count}`,
@@ -1051,7 +1099,7 @@ export function formatGovernanceReportMarkdown(report) {
1051
1099
  `- Repo statuses: ${formatStatusCounts(run.repo_status_counts)}`,
1052
1100
  `- History entries: ${artifacts.history_entries}`,
1053
1101
  `- Decision entries: ${artifacts.decision_entries}`,
1054
- ];
1102
+ );
1055
1103
 
1056
1104
  if (run.completed_at) {
1057
1105
  mdLines.push(`- Completed: \`${run.completed_at}\``);
@@ -15,6 +15,7 @@
15
15
  import { existsSync, readFileSync } from 'fs';
16
16
  import { join } from 'path';
17
17
  import { getActiveTurn } from './governed-state.js';
18
+ import { getInvalidPhaseTransitionReason } from './gate-evaluator.js';
18
19
 
19
20
  // ── Constants ────────────────────────────────────────────────────────────────
20
21
 
@@ -519,6 +520,15 @@ function validateProtocol(tr, state, config) {
519
520
  errors.push(
520
521
  `phase_transition_request "${tr.phase_transition_request}" is not a defined phase in routing.`
521
522
  );
523
+ } else if (config.routing && state?.phase) {
524
+ const invalidOrderReason = getInvalidPhaseTransitionReason(
525
+ state.phase,
526
+ tr.phase_transition_request,
527
+ config.routing
528
+ );
529
+ if (invalidOrderReason) {
530
+ errors.push(invalidOrderReason);
531
+ }
522
532
  }
523
533
  }
524
534