agentxchain 0.8.8 → 2.2.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 (74) hide show
  1. package/README.md +136 -136
  2. package/bin/agentxchain.js +186 -5
  3. package/dashboard/app.js +305 -0
  4. package/dashboard/components/blocked.js +145 -0
  5. package/dashboard/components/cross-repo.js +126 -0
  6. package/dashboard/components/gate.js +311 -0
  7. package/dashboard/components/hooks.js +177 -0
  8. package/dashboard/components/initiative.js +147 -0
  9. package/dashboard/components/ledger.js +165 -0
  10. package/dashboard/components/timeline.js +222 -0
  11. package/dashboard/index.html +352 -0
  12. package/package.json +14 -6
  13. package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
  14. package/scripts/publish-from-tag.sh +88 -0
  15. package/scripts/release-postflight.sh +231 -0
  16. package/scripts/release-preflight.sh +167 -0
  17. package/src/commands/accept-turn.js +160 -0
  18. package/src/commands/approve-completion.js +80 -0
  19. package/src/commands/approve-transition.js +85 -0
  20. package/src/commands/dashboard.js +70 -0
  21. package/src/commands/init.js +516 -0
  22. package/src/commands/migrate.js +348 -0
  23. package/src/commands/multi.js +549 -0
  24. package/src/commands/plugin.js +157 -0
  25. package/src/commands/reject-turn.js +204 -0
  26. package/src/commands/resume.js +389 -0
  27. package/src/commands/status.js +196 -3
  28. package/src/commands/step.js +947 -0
  29. package/src/commands/template-list.js +33 -0
  30. package/src/commands/template-set.js +279 -0
  31. package/src/commands/validate.js +20 -11
  32. package/src/commands/verify.js +71 -0
  33. package/src/lib/adapters/api-proxy-adapter.js +1076 -0
  34. package/src/lib/adapters/local-cli-adapter.js +337 -0
  35. package/src/lib/adapters/manual-adapter.js +169 -0
  36. package/src/lib/blocked-state.js +94 -0
  37. package/src/lib/config.js +97 -1
  38. package/src/lib/context-compressor.js +121 -0
  39. package/src/lib/context-section-parser.js +220 -0
  40. package/src/lib/coordinator-acceptance.js +428 -0
  41. package/src/lib/coordinator-config.js +461 -0
  42. package/src/lib/coordinator-dispatch.js +276 -0
  43. package/src/lib/coordinator-gates.js +487 -0
  44. package/src/lib/coordinator-hooks.js +239 -0
  45. package/src/lib/coordinator-recovery.js +523 -0
  46. package/src/lib/coordinator-state.js +365 -0
  47. package/src/lib/cross-repo-context.js +247 -0
  48. package/src/lib/dashboard/bridge-server.js +284 -0
  49. package/src/lib/dashboard/file-watcher.js +93 -0
  50. package/src/lib/dashboard/state-reader.js +96 -0
  51. package/src/lib/dispatch-bundle.js +568 -0
  52. package/src/lib/dispatch-manifest.js +252 -0
  53. package/src/lib/gate-evaluator.js +285 -0
  54. package/src/lib/governed-state.js +2139 -0
  55. package/src/lib/governed-templates.js +145 -0
  56. package/src/lib/hook-runner.js +788 -0
  57. package/src/lib/normalized-config.js +539 -0
  58. package/src/lib/plugin-config-schema.js +192 -0
  59. package/src/lib/plugins.js +692 -0
  60. package/src/lib/protocol-conformance.js +291 -0
  61. package/src/lib/reference-conformance-adapter.js +858 -0
  62. package/src/lib/repo-observer.js +597 -0
  63. package/src/lib/repo.js +0 -31
  64. package/src/lib/schema.js +121 -0
  65. package/src/lib/schemas/turn-result.schema.json +205 -0
  66. package/src/lib/token-budget.js +206 -0
  67. package/src/lib/token-counter.js +27 -0
  68. package/src/lib/turn-paths.js +67 -0
  69. package/src/lib/turn-result-validator.js +496 -0
  70. package/src/lib/validation.js +137 -0
  71. package/src/templates/governed/api-service.json +31 -0
  72. package/src/templates/governed/cli-tool.json +30 -0
  73. package/src/templates/governed/generic.json +10 -0
  74. package/src/templates/governed/web-app.json +30 -0
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Coordinator divergence detection and state recovery.
3
+ *
4
+ * Compares coordinator expectations against repo-local authority and
5
+ * rebuilds coordinator state from explicit event contracts only.
6
+ *
7
+ * Design rules:
8
+ * - Repo-local state is always authoritative (DEC-MR-IMPL-004)
9
+ * - Recovery NEVER writes to repo-local .agentxchain/
10
+ * - Rebuild uses only explicit coordinator history event types
11
+ * - Ambiguous state forces blocked status, not silent repair
12
+ * - Pending gates survive safe resync; ambiguous gates force blocked
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import {
18
+ loadCoordinatorState,
19
+ saveCoordinatorState,
20
+ readCoordinatorHistory,
21
+ readBarriers,
22
+ } from './coordinator-state.js';
23
+ import { safeWriteJson } from './safe-write.js';
24
+
25
+ // ── Paths ───────────────────────────────────────────────────────────────────
26
+
27
+ const MULTIREPO_DIR = '.agentxchain/multirepo';
28
+
29
+ function barriersPath(ws) {
30
+ return join(ws, MULTIREPO_DIR, 'barriers.json');
31
+ }
32
+
33
+ function historyPath(ws) {
34
+ return join(ws, MULTIREPO_DIR, 'history.jsonl');
35
+ }
36
+
37
+ function barrierLedgerPath(ws) {
38
+ return join(ws, MULTIREPO_DIR, 'barrier-ledger.jsonl');
39
+ }
40
+
41
+ // ── Helpers ─────────────────────────────────────────────────────────────────
42
+
43
+ function appendJsonl(filePath, entry) {
44
+ appendFileSync(filePath, JSON.stringify(entry) + '\n');
45
+ }
46
+
47
+ function readRepoLocalState(repoPath) {
48
+ const stateFile = join(repoPath, '.agentxchain/state.json');
49
+ if (!existsSync(stateFile)) return null;
50
+ try {
51
+ return JSON.parse(readFileSync(stateFile, 'utf8'));
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function readRepoLocalHistory(repoPath) {
58
+ const file = join(repoPath, '.agentxchain/history.jsonl');
59
+ if (!existsSync(file)) return [];
60
+ try {
61
+ const content = readFileSync(file, 'utf8').trim();
62
+ if (!content) return [];
63
+ return content.split('\n').map(line => JSON.parse(line));
64
+ } catch {
65
+ return [];
66
+ }
67
+ }
68
+
69
+ // ── Divergence Detection ────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Detect divergence between coordinator expectations and repo-local authority.
73
+ *
74
+ * Compares:
75
+ * - Coordinator repo_runs status vs actual repo-local state
76
+ * - Dispatched turns expected active vs repo-local acceptance/rejection
77
+ * - Coordinator phase expectations vs repo-local phase
78
+ *
79
+ * @param {string} workspacePath
80
+ * @param {object} state - current coordinator state
81
+ * @param {object} config - normalized coordinator config
82
+ * @returns {{ diverged: boolean, mismatches: object[] }}
83
+ */
84
+ export function detectDivergence(workspacePath, state, config) {
85
+ const mismatches = [];
86
+ const history = readCoordinatorHistory(workspacePath);
87
+
88
+ for (const [repoId, repoRun] of Object.entries(state.repo_runs || {})) {
89
+ const repo = config.repos?.[repoId];
90
+ if (!repo?.resolved_path) {
91
+ mismatches.push({
92
+ type: 'repo_config_missing',
93
+ repo_id: repoId,
94
+ detail: `Repo "${repoId}" has no resolved_path in config`,
95
+ });
96
+ continue;
97
+ }
98
+
99
+ const repoState = readRepoLocalState(repo.resolved_path);
100
+ if (!repoState) {
101
+ mismatches.push({
102
+ type: 'repo_state_missing',
103
+ repo_id: repoId,
104
+ detail: `Repo "${repoId}" has no .agentxchain/state.json`,
105
+ });
106
+ continue;
107
+ }
108
+
109
+ // Run ID mismatch
110
+ if (repoRun.run_id && repoState.run_id && repoRun.run_id !== repoState.run_id) {
111
+ mismatches.push({
112
+ type: 'run_id_mismatch',
113
+ repo_id: repoId,
114
+ coordinator_run_id: repoRun.run_id,
115
+ repo_run_id: repoState.run_id,
116
+ detail: `Coordinator expects run "${repoRun.run_id}" but repo has "${repoState.run_id}"`,
117
+ });
118
+ }
119
+
120
+ // Repo completed but coordinator thinks it's active
121
+ if (repoState.status === 'completed' && repoRun.status !== 'completed') {
122
+ mismatches.push({
123
+ type: 'repo_completed_unexpectedly',
124
+ repo_id: repoId,
125
+ coordinator_status: repoRun.status,
126
+ repo_status: repoState.status,
127
+ detail: `Coordinator expects repo "${repoId}" as "${repoRun.status}" but repo is completed`,
128
+ });
129
+ }
130
+
131
+ // Repo blocked but coordinator doesn't know
132
+ if (repoState.status === 'blocked' && repoRun.status !== 'blocked') {
133
+ mismatches.push({
134
+ type: 'repo_blocked_unexpectedly',
135
+ repo_id: repoId,
136
+ coordinator_status: repoRun.status,
137
+ repo_status: repoState.status,
138
+ detail: `Coordinator expects repo "${repoId}" as "${repoRun.status}" but repo is blocked`,
139
+ });
140
+ }
141
+ }
142
+
143
+ // Check dispatched turns that coordinator thinks are active but repo has already accepted/rejected
144
+ const dispatchedTurns = history.filter(e => e?.type === 'turn_dispatched');
145
+ const projectedTurnIds = new Set(
146
+ history
147
+ .filter(e => e?.type === 'acceptance_projection')
148
+ .map(e => e.repo_turn_id)
149
+ );
150
+
151
+ for (const dispatch of dispatchedTurns) {
152
+ if (projectedTurnIds.has(dispatch.repo_turn_id)) continue; // Already projected
153
+
154
+ const repo = config.repos?.[dispatch.repo_id];
155
+ if (!repo?.resolved_path) continue;
156
+
157
+ const repoState = readRepoLocalState(repo.resolved_path);
158
+ if (!repoState) continue;
159
+
160
+ // Check if the turn is still active in the repo
161
+ const activeTurns = repoState.active_turns || {};
162
+ const turnStillActive = Object.keys(activeTurns).includes(dispatch.repo_turn_id);
163
+
164
+ if (!turnStillActive) {
165
+ // Turn is no longer active but coordinator hasn't projected it
166
+ // Read repo-local history to determine what happened
167
+ const repoHistory = readRepoLocalHistory(repo.resolved_path);
168
+ const turnAccepted = repoHistory.some(
169
+ e => e?.turn_id === dispatch.repo_turn_id && e?.status === 'accepted'
170
+ );
171
+ const turnRejected = repoHistory.some(
172
+ e => e?.turn_id === dispatch.repo_turn_id && e?.status === 'rejected'
173
+ );
174
+
175
+ mismatches.push({
176
+ type: turnAccepted ? 'turn_accepted_unprojected' : turnRejected ? 'turn_rejected_unprojected' : 'turn_disappeared',
177
+ repo_id: dispatch.repo_id,
178
+ turn_id: dispatch.repo_turn_id,
179
+ workstream_id: dispatch.workstream_id,
180
+ detail: turnAccepted
181
+ ? `Turn "${dispatch.repo_turn_id}" accepted in repo "${dispatch.repo_id}" but not projected in coordinator`
182
+ : turnRejected
183
+ ? `Turn "${dispatch.repo_turn_id}" rejected in repo "${dispatch.repo_id}" but coordinator has no record`
184
+ : `Turn "${dispatch.repo_turn_id}" no longer active in repo "${dispatch.repo_id}" for unknown reason`,
185
+ });
186
+ }
187
+ }
188
+
189
+ return {
190
+ diverged: mismatches.length > 0,
191
+ mismatches,
192
+ };
193
+ }
194
+
195
+ // ── Resync ──────────────────────────────────────────────────────────────────
196
+
197
+ /**
198
+ * Rebuild coordinator state from repo-local authority.
199
+ *
200
+ * Reads repo-local state and history, rebuilds coordinator projections
201
+ * and barriers from explicit event contracts only. Preserves pending
202
+ * gates when they are still coherent with repo state.
203
+ *
204
+ * Event contracts used for rebuild:
205
+ * - acceptance_projection (from coordinator history — kept if repo confirms)
206
+ * - turn_dispatched (from coordinator history — kept as-is)
207
+ * - phase_transition_requested / phase_transition_approved
208
+ * - run_completion_requested / run_completed
209
+ * - barrier_transition (from barrier-ledger.jsonl)
210
+ *
211
+ * @param {string} workspacePath
212
+ * @param {object} state - current coordinator state
213
+ * @param {object} config - normalized coordinator config
214
+ * @returns {{ ok: boolean, resynced_repos: string[], projected_acceptances: object[], barrier_changes: object[], errors: string[], blocked_reason?: string }}
215
+ */
216
+ export function resyncFromRepoAuthority(workspacePath, state, config) {
217
+ const errors = [];
218
+ const resyncedRepos = [];
219
+ const projectedAcceptances = [];
220
+ const barrierChanges = [];
221
+
222
+ // Step 1: Refresh repo_runs from repo-local authority
223
+ const updatedRepoRuns = { ...state.repo_runs };
224
+
225
+ for (const [repoId, repoRun] of Object.entries(state.repo_runs || {})) {
226
+ const repo = config.repos?.[repoId];
227
+ if (!repo?.resolved_path) {
228
+ errors.push(`Repo "${repoId}" has no resolved_path in config`);
229
+ continue;
230
+ }
231
+
232
+ const repoState = readRepoLocalState(repo.resolved_path);
233
+ if (!repoState) {
234
+ errors.push(`Repo "${repoId}" state unreadable`);
235
+ continue;
236
+ }
237
+
238
+ const changes = {};
239
+
240
+ // Update run_id if it changed (e.g., repo was re-initialized outside coordinator)
241
+ if (repoState.run_id && repoState.run_id !== repoRun.run_id) {
242
+ changes.run_id = repoState.run_id;
243
+ }
244
+
245
+ // Update status from repo-local authority
246
+ if (repoState.status === 'completed' && repoRun.status !== 'completed') {
247
+ changes.status = 'completed';
248
+ } else if (repoState.status === 'blocked') {
249
+ changes.status = 'blocked';
250
+ } else if (repoState.status === 'active') {
251
+ changes.status = repoRun.initialized_by_coordinator ? 'initialized' : 'linked';
252
+ }
253
+
254
+ // Update phase
255
+ if (repoState.phase && repoState.phase !== repoRun.phase) {
256
+ changes.phase = repoState.phase;
257
+ }
258
+
259
+ if (Object.keys(changes).length > 0) {
260
+ updatedRepoRuns[repoId] = { ...repoRun, ...changes };
261
+ resyncedRepos.push(repoId);
262
+ }
263
+ }
264
+
265
+ // Step 2: Rebuild projections for unprojected accepted turns
266
+ const history = readCoordinatorHistory(workspacePath);
267
+ const projectedTurnIds = new Set(
268
+ history
269
+ .filter(e => e?.type === 'acceptance_projection')
270
+ .map(e => e.repo_turn_id)
271
+ );
272
+ const dispatchedTurns = history.filter(e => e?.type === 'turn_dispatched');
273
+
274
+ for (const dispatch of dispatchedTurns) {
275
+ if (projectedTurnIds.has(dispatch.repo_turn_id)) continue;
276
+
277
+ const repo = config.repos?.[dispatch.repo_id];
278
+ if (!repo?.resolved_path) continue;
279
+
280
+ const repoHistory = readRepoLocalHistory(repo.resolved_path);
281
+ const acceptedEntry = repoHistory.find(
282
+ e => e?.turn_id === dispatch.repo_turn_id && e?.status === 'accepted'
283
+ );
284
+
285
+ if (acceptedEntry) {
286
+ // Create a recovery projection
287
+ const projection = {
288
+ type: 'acceptance_projection',
289
+ timestamp: new Date().toISOString(),
290
+ super_run_id: state.super_run_id,
291
+ projection_ref: `proj_recovery_${dispatch.repo_id}_${Date.now()}`,
292
+ workstream_id: dispatch.workstream_id,
293
+ repo_id: dispatch.repo_id,
294
+ repo_run_id: dispatch.repo_run_id,
295
+ repo_turn_id: dispatch.repo_turn_id,
296
+ summary: acceptedEntry.summary ?? '',
297
+ files_changed: acceptedEntry.files_changed ?? [],
298
+ decisions: acceptedEntry.decisions ?? [],
299
+ verification: acceptedEntry.verification ?? null,
300
+ recovery: true,
301
+ };
302
+
303
+ appendJsonl(historyPath(workspacePath), projection);
304
+ resyncedRepos.push(dispatch.repo_id);
305
+ projectedAcceptances.push({
306
+ repo_id: dispatch.repo_id,
307
+ repo_turn_id: dispatch.repo_turn_id,
308
+ workstream_id: dispatch.workstream_id,
309
+ projection_ref: projection.projection_ref,
310
+ summary: acceptedEntry.summary ?? '',
311
+ files_changed: acceptedEntry.files_changed ?? [],
312
+ decisions: acceptedEntry.decisions ?? [],
313
+ verification: acceptedEntry.verification ?? null,
314
+ });
315
+ }
316
+ }
317
+
318
+ // Step 3: Rebuild barrier snapshot from coordinator history (including new recovery projections)
319
+ const fullHistory = readCoordinatorHistory(workspacePath); // Re-read after appending
320
+ const barriers = readBarriers(workspacePath);
321
+ let barriersChanged = false;
322
+
323
+ for (const [barrierId, barrier] of Object.entries(barriers)) {
324
+ if (barrier.status === 'satisfied') continue;
325
+ if (barrier.type === 'shared_human_gate') continue; // Never auto-transition
326
+
327
+ const newStatus = recomputeBarrierStatus(barrier, fullHistory, config);
328
+ if (newStatus !== barrier.status) {
329
+ const previousStatus = barrier.status;
330
+ barrierChanges.push({
331
+ barrier_id: barrierId,
332
+ previous_status: previousStatus,
333
+ new_status: newStatus,
334
+ workstream_id: barrier.workstream_id,
335
+ type: barrier.type,
336
+ });
337
+
338
+ barrier.status = newStatus;
339
+
340
+ // Update satisfied_repos for tracking
341
+ if (barrier.type === 'all_repos_accepted') {
342
+ barrier.satisfied_repos = getAcceptedReposForWorkstream(
343
+ fullHistory, barrier.workstream_id, barrier.required_repos
344
+ );
345
+ }
346
+
347
+ barriersChanged = true;
348
+
349
+ appendJsonl(barrierLedgerPath(workspacePath), {
350
+ type: 'barrier_transition',
351
+ timestamp: new Date().toISOString(),
352
+ barrier_id: barrierId,
353
+ previous_status: previousStatus,
354
+ new_status: newStatus,
355
+ causation: {
356
+ super_run_id: state.super_run_id,
357
+ trigger: 'resync',
358
+ },
359
+ });
360
+ }
361
+ }
362
+
363
+ if (barriersChanged) {
364
+ safeWriteJson(barriersPath(workspacePath), barriers);
365
+ }
366
+
367
+ // Step 4: Validate pending_gate coherence
368
+ let blockedReason = null;
369
+ const pendingGate = state.pending_gate;
370
+
371
+ if (pendingGate) {
372
+ const gateCoherent = validatePendingGateCoherence(pendingGate, updatedRepoRuns, config);
373
+ if (!gateCoherent.ok) {
374
+ blockedReason = gateCoherent.reason;
375
+ }
376
+ }
377
+
378
+ // Step 5: Update coordinator state
379
+ const newStatus = blockedReason ? 'blocked' : state.status;
380
+ const updatedState = {
381
+ ...state,
382
+ repo_runs: updatedRepoRuns,
383
+ status: newStatus,
384
+ blocked_reason: blockedReason || undefined,
385
+ };
386
+
387
+ // Clean up blocked_reason if no longer blocked
388
+ if (!blockedReason && updatedState.blocked_reason) {
389
+ delete updatedState.blocked_reason;
390
+ }
391
+
392
+ saveCoordinatorState(workspacePath, updatedState);
393
+
394
+ // Step 6: Append resync event to history
395
+ appendJsonl(historyPath(workspacePath), {
396
+ type: 'state_resynced',
397
+ timestamp: new Date().toISOString(),
398
+ super_run_id: state.super_run_id,
399
+ resynced_repos: resyncedRepos,
400
+ barrier_changes: barrierChanges,
401
+ blocked_reason: blockedReason || null,
402
+ });
403
+
404
+ return {
405
+ ok: !blockedReason,
406
+ resynced_repos: [...new Set(resyncedRepos)],
407
+ projected_acceptances: projectedAcceptances,
408
+ barrier_changes: barrierChanges,
409
+ errors,
410
+ blocked_reason: blockedReason || undefined,
411
+ };
412
+ }
413
+
414
+ // ── Internal helpers ────────────────────────────────────────────────────────
415
+
416
+ function getAcceptedReposForWorkstream(history, workstreamId, requiredRepos) {
417
+ const accepted = new Set();
418
+ for (const entry of history) {
419
+ if (entry?.type === 'acceptance_projection' && entry.workstream_id === workstreamId) {
420
+ if (requiredRepos.includes(entry.repo_id)) {
421
+ accepted.add(entry.repo_id);
422
+ }
423
+ }
424
+ }
425
+ return [...accepted];
426
+ }
427
+
428
+ function recomputeBarrierStatus(barrier, history, config) {
429
+ switch (barrier.type) {
430
+ case 'all_repos_accepted': {
431
+ const required = new Set(barrier.required_repos);
432
+ const satisfied = new Set();
433
+ for (const entry of history) {
434
+ if (entry?.type === 'acceptance_projection' && entry.workstream_id === barrier.workstream_id) {
435
+ if (required.has(entry.repo_id)) {
436
+ satisfied.add(entry.repo_id);
437
+ }
438
+ }
439
+ }
440
+ if (satisfied.size === required.size) return 'satisfied';
441
+ if (satisfied.size > 0) return 'partially_satisfied';
442
+ return 'pending';
443
+ }
444
+
445
+ case 'ordered_repo_sequence': {
446
+ const workstream = config.workstreams?.[barrier.workstream_id];
447
+ if (!workstream) return barrier.status;
448
+ const entryRepo = workstream.entry_repo;
449
+ const hasUpstream = history.some(
450
+ e => e?.type === 'acceptance_projection'
451
+ && e.workstream_id === barrier.workstream_id
452
+ && e.repo_id === entryRepo
453
+ );
454
+ if (hasUpstream) return 'satisfied';
455
+ const anyDownstream = history.some(
456
+ e => e?.type === 'acceptance_projection'
457
+ && e.workstream_id === barrier.workstream_id
458
+ && e.repo_id !== entryRepo
459
+ );
460
+ if (anyDownstream) return 'partially_satisfied';
461
+ return 'pending';
462
+ }
463
+
464
+ case 'interface_alignment': {
465
+ const required = new Set(barrier.required_repos);
466
+ const acceptedRepos = new Set();
467
+ for (const entry of history) {
468
+ if (entry?.type === 'acceptance_projection' && entry.workstream_id === barrier.workstream_id) {
469
+ if (required.has(entry.repo_id)) acceptedRepos.add(entry.repo_id);
470
+ }
471
+ }
472
+ if (acceptedRepos.size === 0) return 'pending';
473
+ if (acceptedRepos.size < required.size) return 'partially_satisfied';
474
+ return 'satisfied';
475
+ }
476
+
477
+ case 'shared_human_gate':
478
+ return barrier.status; // Never auto-transition
479
+
480
+ default:
481
+ return barrier.status;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Validate that a pending gate is still coherent with repo state.
487
+ *
488
+ * For phase transitions: all required repos must still exist and not have
489
+ * moved to an unexpected state.
490
+ *
491
+ * For completion: all required repos must still be completion-ready.
492
+ */
493
+ function validatePendingGateCoherence(pendingGate, repoRuns, config) {
494
+ if (pendingGate.gate_type === 'phase_transition') {
495
+ // Check that required repos haven't entered a state that invalidates the gate
496
+ for (const repoId of pendingGate.required_repos || []) {
497
+ const repoRun = repoRuns[repoId];
498
+ if (!repoRun) {
499
+ return { ok: false, reason: `Phase gate "${pendingGate.gate}" invalid: repo "${repoId}" missing from coordinator state` };
500
+ }
501
+ // A repo going blocked during a pending gate makes the gate ambiguous
502
+ if (repoRun.status === 'blocked') {
503
+ return { ok: false, reason: `Phase gate "${pendingGate.gate}" ambiguous: repo "${repoId}" is now blocked` };
504
+ }
505
+ }
506
+ return { ok: true };
507
+ }
508
+
509
+ if (pendingGate.gate_type === 'run_completion') {
510
+ for (const repoId of pendingGate.required_repos || []) {
511
+ const repoRun = repoRuns[repoId];
512
+ if (!repoRun) {
513
+ return { ok: false, reason: `Completion gate invalid: repo "${repoId}" missing from coordinator state` };
514
+ }
515
+ if (repoRun.status === 'blocked') {
516
+ return { ok: false, reason: `Completion gate ambiguous: repo "${repoId}" is now blocked` };
517
+ }
518
+ }
519
+ return { ok: true };
520
+ }
521
+
522
+ return { ok: true };
523
+ }