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
@@ -14,6 +14,11 @@
14
14
  */
15
15
 
16
16
  import { getEffectiveGateArtifacts } from './gate-evaluator.js';
17
+ import {
18
+ canRoleParticipateInRequiredFileProduction,
19
+ canRoleSatisfyWorkflowArtifactOwnership,
20
+ getRoleRuntimeCapabilityContract,
21
+ } from './runtime-capabilities.js';
17
22
 
18
23
  /**
19
24
  * Run all admission control checks against a governed config.
@@ -68,17 +73,20 @@ export function runAdmissionControl(config, rawConfig) {
68
73
  .filter(({ role }) => role);
69
74
 
70
75
  const hasFileProducer = rolesWithAuthority.some(({ role, runtime }) =>
71
- canRoleProduceFiles(role, runtime));
76
+ canRoleParticipateInRequiredFileProduction(role, runtime));
72
77
 
73
78
  // Only flag non-manual roles as review_only dead-ends
74
79
  const nonManualRoles = rolesWithAuthority.filter(({ runtime }) => runtime?.type !== 'manual');
75
80
  if (!hasFileProducer && nonManualRoles.length > 0) {
76
81
  const roleSummary = nonManualRoles
77
- .map(({ id, role }) => `${id}:${role.write_authority}`)
82
+ .map(({ id, role, runtime }) => {
83
+ const contract = getRoleRuntimeCapabilityContract(id, role, runtime);
84
+ return `${id}:${role.write_authority}/${runtime?.type || 'unknown'}->${contract.effective_write_path}`;
85
+ })
78
86
  .join(', ');
79
87
  const fileSummary = requiredArtifacts.map(a => a.path).join(', ');
80
88
  errors.push(
81
- `ADM-001: Phase "${phase}" gate "${exitGateId}" requires files (${fileSummary}) but all routed roles are review_only (${roleSummary}). No agent can produce the required artifacts.`
89
+ `ADM-001: Phase "${phase}" gate "${exitGateId}" requires files (${fileSummary}) but no routed role has a reachable file-production path (${roleSummary}).`
82
90
  );
83
91
  }
84
92
 
@@ -97,9 +105,10 @@ export function runAdmissionControl(config, rawConfig) {
97
105
  const ownerRuntimeKey = ownerRole.runtime_id || ownerRole.runtime;
98
106
  const ownerRuntime = runtimes?.[ownerRuntimeKey];
99
107
 
100
- if (!canRoleProduceFiles(ownerRole, ownerRuntime)) {
108
+ if (!canRoleSatisfyWorkflowArtifactOwnership(ownerRole, ownerRuntime)) {
109
+ const contract = getRoleRuntimeCapabilityContract(artifact.owned_by, ownerRole, ownerRuntime);
101
110
  errors.push(
102
- `ADM-004: Phase "${phase}" artifact "${artifact.path}" is owned_by "${artifact.owned_by}" but that role is ${ownerRole.write_authority} on runtime type "${ownerRuntime?.type || 'unknown'}". The owner can participate in the phase but cannot produce the required artifact.`
111
+ `ADM-004: Phase "${phase}" artifact "${artifact.path}" is owned_by "${artifact.owned_by}" but that role resolves to workflow ownership "${contract.workflow_artifact_ownership}" via ${ownerRole.write_authority}/${ownerRuntime?.type || 'unknown'} (${contract.effective_write_path}).`
103
112
  );
104
113
  }
105
114
  }
@@ -238,10 +247,3 @@ function matchesPhaseRule(rule, phase) {
238
247
  if (rule.from_phase && rule.from_phase !== phase) return false;
239
248
  return true;
240
249
  }
241
-
242
- function canRoleProduceFiles(role, runtime) {
243
- if (!role) return false;
244
- return runtime?.type === 'manual'
245
- || role.write_authority === 'authoritative'
246
- || role.write_authority === 'proposed';
247
- }
@@ -8,6 +8,15 @@ import {
8
8
  derivePolicyEscalationRecoveryAction,
9
9
  getActiveTurnCount,
10
10
  } from './governed-state.js';
11
+ import { getEffectiveGateArtifacts } from './gate-evaluator.js';
12
+ import { getRoleRuntimeCapabilityContract } from './runtime-capabilities.js';
13
+
14
+ const RUNTIME_GUIDANCE_PRIORITY = new Map([
15
+ ['invalid_binding', 1],
16
+ ['review_only_remote_dead_end', 2],
17
+ ['proposal_apply_required', 3],
18
+ ['tool_defined_proof_not_strong_enough', 4],
19
+ ]);
11
20
 
12
21
  function isLegacyEscalationRecoveryAction(action) {
13
22
  return action === 'Resolve the escalation, then run agentxchain step --resume'
@@ -69,6 +78,139 @@ function maybeRefreshRecoveryAction(state, config, persistedRecovery, turnRetain
69
78
  return null;
70
79
  }
71
80
 
81
+ export function deriveRuntimeBlockedGuidance(state, config) {
82
+ if (!state || !config || typeof state !== 'object' || typeof config !== 'object') {
83
+ return [];
84
+ }
85
+
86
+ const failure = state.last_gate_failure;
87
+ if (!failure || typeof failure !== 'object') {
88
+ return [];
89
+ }
90
+
91
+ const phase = typeof failure.phase === 'string' && failure.phase
92
+ ? failure.phase
93
+ : state.phase;
94
+ const gateId = typeof failure.gate_id === 'string' && failure.gate_id
95
+ ? failure.gate_id
96
+ : config.routing?.[phase]?.exit_gate || null;
97
+ const missingFiles = Array.isArray(failure.missing_files)
98
+ ? failure.missing_files.filter((path) => typeof path === 'string' && path.length > 0)
99
+ : [];
100
+
101
+ if (!phase || !gateId || missingFiles.length === 0 || !config.gates?.[gateId]) {
102
+ return [];
103
+ }
104
+
105
+ const requiredArtifacts = getEffectiveGateArtifacts(config, config.gates[gateId], phase)
106
+ .filter((artifact) => artifact?.required !== false)
107
+ .filter((artifact) => missingFiles.includes(artifact.path));
108
+
109
+ if (requiredArtifacts.length === 0) {
110
+ return [];
111
+ }
112
+
113
+ const entryRole = config.routing?.[phase]?.entry_role || null;
114
+ const guidance = [];
115
+
116
+ for (const artifact of requiredArtifacts) {
117
+ const roleId = artifact.owned_by || entryRole;
118
+ if (!roleId) continue;
119
+
120
+ const role = config.roles?.[roleId];
121
+ if (!role) continue;
122
+
123
+ const runtimeId = role.runtime_id || role.runtime;
124
+ const runtime = config.runtimes?.[runtimeId];
125
+ if (!runtime) continue;
126
+
127
+ const contract = getRoleRuntimeCapabilityContract(roleId, role, runtime);
128
+ const invalidBinding = contract.effective_write_path.startsWith('invalid_')
129
+ || contract.workflow_artifact_ownership === 'invalid';
130
+
131
+ let code = null;
132
+ let command = null;
133
+ let reason = null;
134
+
135
+ if (invalidBinding) {
136
+ code = 'invalid_binding';
137
+ command = `Edit agentxchain.json for role "${roleId}", then run agentxchain validate`;
138
+ reason = `Artifact "${artifact.path}" is owned by "${roleId}", but ${role.write_authority}/${runtime.type} resolves to ${contract.effective_write_path}.`;
139
+ } else if (contract.workflow_artifact_ownership === 'no') {
140
+ code = 'review_only_remote_dead_end';
141
+ command = `Edit agentxchain.json for role "${roleId}", then run agentxchain validate`;
142
+ reason = `Artifact "${artifact.path}" is owned by "${roleId}", but ${role.write_authority}/${runtime.type} can only return review artifacts and cannot satisfy workflow ownership.`;
143
+ } else if (contract.workflow_artifact_ownership === 'proposal_apply_required') {
144
+ const turnId = failure.requested_by_turn || state.last_completed_turn_id || null;
145
+ code = 'proposal_apply_required';
146
+ command = turnId ? `agentxchain proposal apply ${turnId}` : 'agentxchain proposal list';
147
+ reason = `Artifact "${artifact.path}" is owned by "${roleId}", and ${role.write_authority}/${runtime.type} stages required files behind proposal apply.`;
148
+ } else if (contract.workflow_artifact_ownership === 'tool_defined') {
149
+ code = 'tool_defined_proof_not_strong_enough';
150
+ command = `agentxchain role show ${roleId}`;
151
+ reason = `Artifact "${artifact.path}" is owned by "${roleId}", but ${runtime.type} leaves the file-write contract tool-defined and not statically provable.`;
152
+ }
153
+
154
+ if (!code) continue;
155
+ guidance.push({
156
+ code,
157
+ phase,
158
+ gate_id: gateId,
159
+ role_id: roleId,
160
+ artifact_path: artifact.path,
161
+ command,
162
+ reason,
163
+ });
164
+ }
165
+
166
+ const seen = new Set();
167
+ return guidance
168
+ .sort((left, right) => {
169
+ const priority = (RUNTIME_GUIDANCE_PRIORITY.get(left.code) || 99)
170
+ - (RUNTIME_GUIDANCE_PRIORITY.get(right.code) || 99);
171
+ if (priority !== 0) return priority;
172
+ const commandCmp = left.command.localeCompare(right.command, 'en');
173
+ if (commandCmp !== 0) return commandCmp;
174
+ return left.artifact_path.localeCompare(right.artifact_path, 'en');
175
+ })
176
+ .filter((entry) => {
177
+ const key = `${entry.code}|${entry.role_id}|${entry.command}|${entry.artifact_path}`;
178
+ if (seen.has(key)) return false;
179
+ seen.add(key);
180
+ return true;
181
+ });
182
+ }
183
+
184
+ export function deriveGovernedRunNextActions(state, config = null) {
185
+ const recovery = deriveRecoveryDescriptor(state, config);
186
+ const runtimeGuidance = recovery?.runtime_guidance || deriveRuntimeBlockedGuidance(state, config);
187
+ const nextActions = [];
188
+ const seen = new Set();
189
+
190
+ const pushAction = (command, reason) => {
191
+ if (typeof command !== 'string' || !command.trim() || typeof reason !== 'string' || !reason.trim()) {
192
+ return;
193
+ }
194
+ const key = `${command}|${reason}`;
195
+ if (seen.has(key)) return;
196
+ seen.add(key);
197
+ nextActions.push({ command, reason });
198
+ };
199
+
200
+ for (const entry of runtimeGuidance) {
201
+ pushAction(entry.command, entry.reason);
202
+ }
203
+
204
+ if (recovery?.recovery_action) {
205
+ const reason = runtimeGuidance.length > 0
206
+ ? `After resolving the ${runtimeGuidance[0].code} blocker, continue the run.`
207
+ : `Run is blocked: ${recovery.typed_reason}.`;
208
+ pushAction(recovery.recovery_action, reason);
209
+ }
210
+
211
+ return nextActions;
212
+ }
213
+
72
214
  export function deriveRecoveryDescriptor(state, config = null) {
73
215
  if (!state || typeof state !== 'object') {
74
216
  return null;
@@ -99,6 +241,7 @@ export function deriveRecoveryDescriptor(state, config = null) {
99
241
  const persistedRecovery = state.blocked_reason?.recovery;
100
242
  if (persistedRecovery && typeof persistedRecovery === 'object') {
101
243
  const refreshedRecoveryAction = maybeRefreshRecoveryAction(state, config, persistedRecovery, turnRetained);
244
+ const runtimeGuidance = deriveRuntimeBlockedGuidance(state, config);
102
245
  return {
103
246
  typed_reason: persistedRecovery.typed_reason || 'unknown_block',
104
247
  owner: persistedRecovery.owner || 'human',
@@ -109,6 +252,7 @@ export function deriveRecoveryDescriptor(state, config = null) {
109
252
  ? persistedRecovery.turn_retained
110
253
  : turnRetained,
111
254
  detail: persistedRecovery.detail ?? state.blocked_on ?? null,
255
+ runtime_guidance: runtimeGuidance,
112
256
  };
113
257
  }
114
258
 
@@ -126,6 +270,7 @@ export function deriveRecoveryDescriptor(state, config = null) {
126
270
  }),
127
271
  turn_retained: turnRetained,
128
272
  detail: state.blocked_on.slice('human:'.length) || null,
273
+ runtime_guidance: deriveRuntimeBlockedGuidance(state, config),
129
274
  };
130
275
  }
131
276
 
@@ -151,6 +296,7 @@ export function deriveRecoveryDescriptor(state, config = null) {
151
296
  recovery_action: recoveryAction,
152
297
  turn_retained: turnRetained,
153
298
  detail: state.escalation?.detail || state.escalation?.reason || state.blocked_on,
299
+ runtime_guidance: deriveRuntimeBlockedGuidance(state, config),
154
300
  };
155
301
  }
156
302
 
@@ -164,6 +310,7 @@ export function deriveRecoveryDescriptor(state, config = null) {
164
310
  }),
165
311
  turn_retained: turnRetained,
166
312
  detail: state.blocked_on.slice('dispatch:'.length) || state.blocked_on,
313
+ runtime_guidance: deriveRuntimeBlockedGuidance(state, config),
167
314
  };
168
315
  }
169
316
 
@@ -179,6 +326,7 @@ export function deriveRecoveryDescriptor(state, config = null) {
179
326
  }),
180
327
  turn_retained: turnRetained,
181
328
  detail: derivePolicyEscalationDetail(state, { policyId }),
329
+ runtime_guidance: deriveRuntimeBlockedGuidance(state, config),
182
330
  };
183
331
  }
184
332
 
@@ -190,6 +338,7 @@ export function deriveRecoveryDescriptor(state, config = null) {
190
338
  recovery_action: 'agentxchain resume',
191
339
  turn_retained: false,
192
340
  detail: `${scope} timeout exceeded`,
341
+ runtime_guidance: deriveRuntimeBlockedGuidance(state, config),
193
342
  };
194
343
  }
195
344
 
@@ -199,5 +348,6 @@ export function deriveRecoveryDescriptor(state, config = null) {
199
348
  recovery_action: 'Inspect state.json and resolve manually before rerunning agentxchain step',
200
349
  turn_retained: turnRetained,
201
350
  detail: state.blocked_on,
351
+ runtime_guidance: deriveRuntimeBlockedGuidance(state, config),
202
352
  };
203
353
  }
@@ -0,0 +1,17 @@
1
+ export function deriveConflictedTurnResolutionActions(turnId) {
2
+ if (typeof turnId !== 'string' || !turnId.trim()) {
3
+ throw new Error('deriveConflictedTurnResolutionActions requires a non-empty turnId');
4
+ }
5
+
6
+ const normalizedTurnId = turnId.trim();
7
+ return [
8
+ {
9
+ command: `agentxchain reject-turn --turn ${normalizedTurnId} --reassign`,
10
+ description: 'reject and re-dispatch with conflict context',
11
+ },
12
+ {
13
+ command: `agentxchain accept-turn --turn ${normalizedTurnId} --resolution human_merge`,
14
+ description: 'manually merge and re-accept',
15
+ },
16
+ ];
17
+ }
@@ -3,6 +3,7 @@ const CONTEXT_TITLE = '# Execution Context';
3
3
  const SECTION_DEFINITIONS = [
4
4
  { id: 'current_state', header: 'Current State', required: true },
5
5
  { id: 'budget', header: null, required: false },
6
+ { id: 'runtime_capability_contract', header: 'Runtime Capability Contract', required: false },
6
7
  { id: 'project_goal', header: 'Project Goal', required: true },
7
8
  { id: 'inherited_run_context', header: 'Inherited Run Context', required: true },
8
9
  { id: 'last_turn_header', header: 'Last Accepted Turn', required: true },
@@ -82,6 +83,7 @@ export function renderContextSections(sections) {
82
83
  sectionMap.get('budget')?.content,
83
84
  ]);
84
85
 
86
+ appendTopLevelSection(lines, 'Runtime Capability Contract', [sectionMap.get('runtime_capability_contract')?.content]);
85
87
  appendTopLevelSection(lines, 'Project Goal', [sectionMap.get('project_goal')?.content]);
86
88
  appendTopLevelSection(lines, 'Inherited Run Context', [sectionMap.get('inherited_run_context')?.content]);
87
89
  appendTopLevelSection(lines, 'Last Accepted Turn', [
@@ -4,7 +4,7 @@ import { captureBaselineRef, readSessionCheckpoint } from './session-checkpoint.
4
4
 
5
5
  export const SESSION_RECOVERY_PATH = '.agentxchain/SESSION_RECOVERY.md';
6
6
 
7
- function deriveRecommendedContinuityAction(state) {
7
+ export function deriveRecommendedContinuityAction(state) {
8
8
  if (!state) {
9
9
  return {
10
10
  recommended_command: null,
@@ -0,0 +1,127 @@
1
+ import { pushDetail } from './coordinator-presentation-detail.js';
2
+ import { getCoordinatorPendingGateDetails } from './coordinator-pending-gate-presentation.js';
3
+
4
+ function isObject(value) {
5
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
6
+ }
7
+
8
+ function normalizeMessage(value) {
9
+ if (typeof value === 'string') {
10
+ return value;
11
+ }
12
+ if (value == null) {
13
+ return null;
14
+ }
15
+ return JSON.stringify(value);
16
+ }
17
+
18
+ export function getCoordinatorBlockerDetails(blocker) {
19
+ if (!isObject(blocker)) {
20
+ return [];
21
+ }
22
+
23
+ const details = [];
24
+ switch (blocker.code) {
25
+ case 'repo_run_id_mismatch':
26
+ pushDetail(details, 'Repo', blocker.repo_id, { mono: true });
27
+ pushDetail(details, 'Expected', blocker.expected_run_id, { mono: true });
28
+ pushDetail(details, 'Actual', blocker.actual_run_id, { mono: true });
29
+ break;
30
+ case 'repo_not_ready':
31
+ pushDetail(details, 'Repo', blocker.repo_id, { mono: true });
32
+ pushDetail(details, 'Current Phase', blocker.current_phase);
33
+ pushDetail(details, 'Required Phase', blocker.required_phase);
34
+ break;
35
+ default:
36
+ break;
37
+ }
38
+
39
+ return details;
40
+ }
41
+
42
+ export function summarizeCoordinatorAttention(coordinatorBlockers) {
43
+ if (!isObject(coordinatorBlockers) || coordinatorBlockers.ok === false) {
44
+ return null;
45
+ }
46
+
47
+ const active = isObject(coordinatorBlockers.active) ? coordinatorBlockers.active : {};
48
+ const blockers = Array.isArray(active.blockers)
49
+ ? active.blockers.filter((blocker) => isObject(blocker) && blocker.code !== 'no_next_phase')
50
+ : [];
51
+ const nextActions = Array.isArray(coordinatorBlockers.next_actions)
52
+ ? coordinatorBlockers.next_actions.filter((action) => isObject(action))
53
+ : [];
54
+
55
+ return {
56
+ title: coordinatorBlockers.mode === 'pending_gate' ? 'Approval Snapshot' : 'Blocker Snapshot',
57
+ active,
58
+ blockers,
59
+ nextActions,
60
+ primaryBlocker: blockers[0] || null,
61
+ primaryAction: nextActions[0] || null,
62
+ additionalBlockerCount: Math.max(0, blockers.length - 1),
63
+ additionalActionCount: Math.max(0, nextActions.length - 1),
64
+ };
65
+ }
66
+
67
+ export function buildCoordinatorAttentionSnapshotPresentation(coordinatorBlockers) {
68
+ const summary = summarizeCoordinatorAttention(coordinatorBlockers);
69
+ if (!summary) {
70
+ return null;
71
+ }
72
+
73
+ const details = [];
74
+ pushDetail(details, 'Mode', coordinatorBlockers?.mode);
75
+ if (summary.title === 'Approval Snapshot') {
76
+ details.push(
77
+ ...getCoordinatorPendingGateDetails({
78
+ pendingGate: coordinatorBlockers?.pending_gate,
79
+ active: summary.active,
80
+ }),
81
+ );
82
+ } else {
83
+ pushDetail(details, 'Type', summary.active?.gate_type);
84
+ pushDetail(details, 'Gate', summary.active?.gate_id, { mono: true });
85
+ pushDetail(details, 'Current Phase', summary.active?.current_phase);
86
+ pushDetail(details, 'Target Phase', summary.active?.target_phase);
87
+ }
88
+ if (summary.blockers.length > 0) {
89
+ pushDetail(details, 'Blockers', summary.blockers.length);
90
+ }
91
+ pushDetail(details, 'Primary Blocker', summary.primaryBlocker?.code, { mono: true });
92
+
93
+ return {
94
+ title: summary.title,
95
+ subtitle: 'First-glance coordinator attention only. Full blocker diagnostics stay in the Blockers view.',
96
+ details,
97
+ summaryMessage: summary.primaryBlocker
98
+ ? null
99
+ : summary.title === 'Approval Snapshot'
100
+ ? 'All coordinator prerequisites are satisfied. Human approval is the remaining action.'
101
+ : normalizeMessage(coordinatorBlockers?.blocked_reason),
102
+ primaryBlocker: summary.primaryBlocker,
103
+ primaryBlockerDetails: getCoordinatorBlockerDetails(summary.primaryBlocker),
104
+ primaryAction: summary.primaryAction,
105
+ additionalBlockerCount: summary.additionalBlockerCount,
106
+ additionalActionCount: summary.additionalActionCount,
107
+ };
108
+ }
109
+
110
+ export function getCoordinatorAttentionStatusCard(coordinatorBlockers) {
111
+ const summary = summarizeCoordinatorAttention(coordinatorBlockers);
112
+ if (!summary || summary.blockers.length > 0) {
113
+ return null;
114
+ }
115
+
116
+ if (summary.title === 'Approval Snapshot') {
117
+ return {
118
+ title: 'Approval Snapshot',
119
+ message: 'All coordinator prerequisites are satisfied. Human approval is the remaining action.',
120
+ };
121
+ }
122
+
123
+ return {
124
+ title: 'Gate Clear',
125
+ message: 'The coordinator gate has no outstanding blockers.',
126
+ };
127
+ }
@@ -0,0 +1,43 @@
1
+ function countLabel(count, noun) {
2
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
3
+ }
4
+
5
+ export function summarizeCoordinatorEvent(entry) {
6
+ const type = entry?.type || 'unknown';
7
+ const ts = entry?.timestamp || '';
8
+
9
+ switch (type) {
10
+ case 'run_initialized': {
11
+ const repoCount = entry?.repo_runs ? Object.keys(entry.repo_runs).length : 0;
12
+ return `Coordinator run initialized with ${countLabel(repoCount, 'repo')}`;
13
+ }
14
+ case 'turn_dispatched':
15
+ return `Dispatched turn to ${entry?.repo_id || 'unknown'} (${entry?.role || '?'}) in workstream ${entry?.workstream_id || 'unknown'}`;
16
+ case 'acceptance_projection': {
17
+ const turnRef = entry?.repo_turn_id ? ` (turn ${entry.repo_turn_id})` : '';
18
+ const summaryText = entry?.summary ? ` — ${entry.summary}` : '';
19
+ return `Projected acceptance from ${entry?.repo_id || 'unknown'}${turnRef}${summaryText}`;
20
+ }
21
+ case 'context_generated': {
22
+ const upstreamCount = Array.isArray(entry?.upstream_repo_ids) ? entry.upstream_repo_ids.length : 0;
23
+ return `Generated cross-repo context for ${entry?.target_repo_id || 'unknown'} from ${countLabel(upstreamCount, 'upstream repo')}`;
24
+ }
25
+ case 'phase_transition_requested':
26
+ return `Requested phase transition: ${entry?.from || '?'} → ${entry?.to || '?'}`;
27
+ case 'phase_transition_approved':
28
+ return `Phase transition approved: ${entry?.from || '?'} → ${entry?.to || '?'}`;
29
+ case 'run_completion_requested':
30
+ return `Requested run completion (gate: ${entry?.gate || 'unknown'})`;
31
+ case 'run_completed':
32
+ return 'Coordinator run completed';
33
+ case 'state_resynced': {
34
+ const resyncedCount = Array.isArray(entry?.resynced_repos) ? entry.resynced_repos.length : 0;
35
+ const barrierChangeCount = Array.isArray(entry?.barrier_changes) ? entry.barrier_changes.length : 0;
36
+ return `Resynced state for ${countLabel(resyncedCount, 'repo')}, ${countLabel(barrierChangeCount, 'barrier change')}`;
37
+ }
38
+ case 'blocked_resolved':
39
+ return `Blocked state resolved: ${entry?.from || '?'} → ${entry?.to || '?'}`;
40
+ default:
41
+ return `${type} event${ts ? ` at ${ts}` : ''}`;
42
+ }
43
+ }
@@ -0,0 +1,98 @@
1
+ import { deriveCoordinatorNextActions } from './coordinator-next-actions.js';
2
+ import { collectCoordinatorRepoSnapshots } from './coordinator-repo-snapshots.js';
3
+
4
+ export function deriveCoordinatorGateNextActions(state, config) {
5
+ return deriveCoordinatorNextActions({
6
+ status: state?.status ?? null,
7
+ blockedReason: state?.blocked_reason ?? null,
8
+ pendingGate: state?.pending_gate ?? null,
9
+ repos: config ? collectCoordinatorRepoSnapshots(config) : [],
10
+ coordinatorRepoRuns: state?.repo_runs || {},
11
+ });
12
+ }
13
+
14
+ function deriveTypedReason(code) {
15
+ if (code === 'hook_blocked') {
16
+ return 'hook_block';
17
+ }
18
+ if (code === 'hook_failed') {
19
+ return 'hook_failure';
20
+ }
21
+ return 'approval_failed';
22
+ }
23
+
24
+ function deriveRecoveryDetail(code, gate, hookName) {
25
+ const gateLabel = gate ? `pending gate "${gate}"` : 'pending coordinator gate';
26
+ if (code === 'hook_blocked') {
27
+ return `Coordinator state is unchanged. Fix or reconfigure hook "${hookName || 'before_gate'}", then rerun approval for ${gateLabel}.`;
28
+ }
29
+ if (code === 'hook_failed') {
30
+ return `Coordinator state is unchanged. Fix hook "${hookName || 'before_gate'}" or its execution failure, then rerun approval for ${gateLabel}.`;
31
+ }
32
+ return `Coordinator state is unchanged. Resolve the approval failure, then follow the next coordinator action for ${gateLabel}.`;
33
+ }
34
+
35
+ export function normalizeCoordinatorGateApprovalFailure({
36
+ state,
37
+ config,
38
+ code,
39
+ error,
40
+ hookName = null,
41
+ hookPhase = null,
42
+ }) {
43
+ const gate = state?.pending_gate?.gate ?? null;
44
+ const gateType = state?.pending_gate?.gate_type ?? null;
45
+ const nextActions = deriveCoordinatorGateNextActions(state, config);
46
+ const nextAction = nextActions[0]?.command ?? null;
47
+ const detail = deriveRecoveryDetail(code, gate, hookName);
48
+
49
+ return {
50
+ ok: false,
51
+ code: code || 'approval_failed',
52
+ error: error || 'Coordinator gate approval failed',
53
+ gate,
54
+ gate_type: gateType,
55
+ hook_phase: hookPhase || null,
56
+ hook_name: hookName || null,
57
+ next_action: nextAction,
58
+ next_actions: nextActions,
59
+ recovery_summary: {
60
+ typed_reason: deriveTypedReason(code),
61
+ owner: 'human',
62
+ recovery_action: nextAction,
63
+ detail,
64
+ },
65
+ };
66
+ }
67
+
68
+ export function normalizeCoordinatorGateApprovalSuccess({
69
+ result,
70
+ gateType,
71
+ phaseTransitionMessagePrefix = 'Coordinator phase transition approved',
72
+ completionMessage = 'Coordinator run completion approved. Run is now complete.',
73
+ }) {
74
+ const nextActions = deriveCoordinatorGateNextActions(result?.state, result?.config);
75
+ const nextAction = nextActions[0]?.command ?? null;
76
+
77
+ if (gateType === 'phase_transition') {
78
+ return {
79
+ ok: true,
80
+ gate_type: 'phase_transition',
81
+ status: result?.state?.status || null,
82
+ phase: result?.state?.phase || null,
83
+ message: `${phaseTransitionMessagePrefix}: ${result?.transition?.from} -> ${result?.transition?.to}`,
84
+ next_action: nextAction,
85
+ next_actions: nextActions,
86
+ };
87
+ }
88
+
89
+ return {
90
+ ok: true,
91
+ gate_type: 'run_completion',
92
+ status: result?.state?.status || null,
93
+ phase: result?.state?.phase || null,
94
+ message: completionMessage,
95
+ next_action: nextAction,
96
+ next_actions: nextActions,
97
+ };
98
+ }
@@ -0,0 +1,57 @@
1
+ function formatList(values) {
2
+ return values.join(', ');
3
+ }
4
+
5
+ export function buildCoordinatorGateEvaluationPresentation({
6
+ gateType,
7
+ evaluation,
8
+ includeReady = false,
9
+ includeBlockerCount = true,
10
+ } = {}) {
11
+ const normalizedGateType = gateType === 'run_completion' ? 'run_completion' : 'phase_transition';
12
+ const data = evaluation && typeof evaluation === 'object' ? evaluation : {};
13
+ const blockers = Array.isArray(data.blockers) ? data.blockers : [];
14
+ const details = [];
15
+
16
+ if (typeof data.gate_id === 'string' && data.gate_id.length > 0) {
17
+ details.push({ label: 'Gate', value: data.gate_id, mono: true });
18
+ }
19
+
20
+ if (typeof data.current_phase === 'string' && data.current_phase.length > 0) {
21
+ details.push({ label: 'Current Phase', value: data.current_phase });
22
+ }
23
+
24
+ if (typeof data.target_phase === 'string' && data.target_phase.length > 0) {
25
+ details.push({ label: 'Target Phase', value: data.target_phase });
26
+ }
27
+
28
+ if (Array.isArray(data.required_repos) && data.required_repos.length > 0) {
29
+ details.push({ label: 'Required Repos', value: formatList(data.required_repos) });
30
+ }
31
+
32
+ if (Array.isArray(data.human_barriers) && data.human_barriers.length > 0) {
33
+ details.push({ label: 'Human Barriers', value: formatList(data.human_barriers) });
34
+ }
35
+
36
+ if (normalizedGateType === 'run_completion' && typeof data.requires_human_approval === 'boolean') {
37
+ details.push({
38
+ label: 'Human Approval',
39
+ value: data.requires_human_approval ? 'Required' : 'Not required',
40
+ });
41
+ }
42
+
43
+ if (includeReady && typeof data.ready === 'boolean') {
44
+ details.push({ label: 'Ready', value: data.ready ? 'Yes' : 'No' });
45
+ }
46
+
47
+ if (includeBlockerCount) {
48
+ details.push({ label: 'Blockers', value: String(blockers.length) });
49
+ }
50
+
51
+ return {
52
+ title: normalizedGateType === 'run_completion' ? 'Run Completion' : 'Phase Transition',
53
+ statusLabel: data.ready ? 'ready' : 'not ready',
54
+ details,
55
+ blockers,
56
+ };
57
+ }