agentxchain 2.13.0 → 2.15.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.
@@ -0,0 +1,58 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join, parse as pathParse, resolve } from 'node:path';
4
+ import { findProjectRoot } from '../lib/config.js';
5
+ import { COORDINATOR_CONFIG_FILE } from '../lib/coordinator-config.js';
6
+
7
+ function findCoordinatorWorkspaceRoot(startDir = process.cwd()) {
8
+ let dir = resolve(startDir);
9
+ const { root: fsRoot } = pathParse(dir);
10
+
11
+ while (true) {
12
+ if (existsSync(join(dir, COORDINATOR_CONFIG_FILE))) {
13
+ return dir;
14
+ }
15
+ if (dir === fsRoot) {
16
+ return null;
17
+ }
18
+ dir = join(dir, '..');
19
+ }
20
+ }
21
+
22
+ function listCoordinatorChildRepos(coordinatorRoot) {
23
+ const configPath = join(coordinatorRoot, COORDINATOR_CONFIG_FILE);
24
+ if (!existsSync(configPath)) {
25
+ return [];
26
+ }
27
+
28
+ try {
29
+ const raw = JSON.parse(readFileSync(configPath, 'utf8'));
30
+ return Object.keys(raw?.repos || {});
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ export function requireIntakeWorkspaceOrExit(opts, startDir = process.cwd()) {
37
+ const projectRoot = findProjectRoot(startDir);
38
+ if (projectRoot) {
39
+ return projectRoot;
40
+ }
41
+
42
+ const coordinatorRoot = findCoordinatorWorkspaceRoot(startDir);
43
+ const childRepos = coordinatorRoot ? listCoordinatorChildRepos(coordinatorRoot) : [];
44
+ const repoHint = childRepos.length > 0
45
+ ? ` Available child repos: ${childRepos.join(', ')}.`
46
+ : '';
47
+ const error = coordinatorRoot
48
+ ? `intake commands are repo-local only. Found coordinator workspace at ${coordinatorRoot} (${COORDINATOR_CONFIG_FILE}). Run intake inside a child governed repo (agentxchain.json).${repoHint} Then use \`agentxchain multi step\` for cross-repo coordination.`
49
+ : 'agentxchain.json not found';
50
+
51
+ if (opts.json) {
52
+ console.log(JSON.stringify({ ok: false, error }, null, 2));
53
+ } else {
54
+ console.log(chalk.red(error));
55
+ }
56
+
57
+ process.exit(2);
58
+ }
@@ -5,6 +5,7 @@
5
5
  * multi init — bootstrap a multi-repo coordinator run
6
6
  * multi status — show coordinator status and repo-run snapshots
7
7
  * multi step — reconcile repo truth, then dispatch or request the next coordinator gate
8
+ * multi resume — clear coordinator blocked state after operator recovery
8
9
  * multi approve-gate — approve a pending phase transition or completion gate
9
10
  * multi resync — detect divergence and rebuild coordinator state from repo authority
10
11
  */
@@ -26,7 +27,11 @@ import {
26
27
  requestCoordinatorCompletion,
27
28
  requestPhaseTransition,
28
29
  } from '../lib/coordinator-gates.js';
29
- import { detectDivergence, resyncFromRepoAuthority } from '../lib/coordinator-recovery.js';
30
+ import {
31
+ detectDivergence,
32
+ resyncFromRepoAuthority,
33
+ resumeCoordinatorFromBlockedState,
34
+ } from '../lib/coordinator-recovery.js';
30
35
  import {
31
36
  fireCoordinatorHook,
32
37
  buildAssignmentPayload,
@@ -154,7 +159,7 @@ export async function multiStepCommand(options) {
154
159
  // Fire on_escalation hook (advisory — cannot block, only notifies)
155
160
  fireEscalationHook(workspacePath, configResult.config, state, state.blocked_reason || 'unknown reason');
156
161
  console.error(`Coordinator is blocked: ${state.blocked_reason || 'unknown reason'}`);
157
- console.error('Resolve the blocked state before stepping.');
162
+ console.error('Resolve the blocked state, then run `agentxchain multi resume` before stepping again.');
158
163
  process.exitCode = 1;
159
164
  return;
160
165
  }
@@ -362,6 +367,57 @@ function maybeRequestCoordinatorGate(workspacePath, state, config) {
362
367
  return { ok: false, type: 'run_completion', blockers: completionEvaluation.blockers };
363
368
  }
364
369
 
370
+ // ── multi resume ───────────────────────────────────────────────────────────
371
+
372
+ export async function multiResumeCommand(options) {
373
+ const workspacePath = process.cwd();
374
+ const configResult = loadCoordinatorConfig(workspacePath);
375
+
376
+ if (!configResult.ok) {
377
+ console.error('Coordinator config error:');
378
+ for (const err of configResult.errors || []) {
379
+ console.error(` - ${typeof err === 'string' ? err : err.message || JSON.stringify(err)}`);
380
+ }
381
+ process.exitCode = 1;
382
+ return;
383
+ }
384
+
385
+ const state = loadCoordinatorState(workspacePath);
386
+ if (!state) {
387
+ console.error('No coordinator state found. Run `agentxchain multi init` first.');
388
+ process.exitCode = 1;
389
+ return;
390
+ }
391
+
392
+ const result = resumeCoordinatorFromBlockedState(workspacePath, state, configResult.config);
393
+
394
+ if (!result.ok) {
395
+ console.error(result.error || 'Coordinator recovery failed.');
396
+ process.exitCode = 1;
397
+ return;
398
+ }
399
+
400
+ if (options.json) {
401
+ console.log(JSON.stringify({
402
+ ok: true,
403
+ previous_status: 'blocked',
404
+ resumed_status: result.resumed_status,
405
+ blocked_reason: result.blocked_reason,
406
+ pending_gate: result.state?.pending_gate || null,
407
+ resync: result.resync,
408
+ }, null, 2));
409
+ return;
410
+ }
411
+
412
+ console.log(`Coordinator resumed: ${result.resumed_status}`);
413
+ console.log(`Previous block: ${result.blocked_reason}`);
414
+ if (result.resumed_status === 'paused' && result.state?.pending_gate) {
415
+ console.log(`Next action: agentxchain multi approve-gate (${result.state.pending_gate.gate})`);
416
+ } else {
417
+ console.log('Next action: agentxchain multi step');
418
+ }
419
+ }
420
+
365
421
  // ── multi approve-gate ─────────────────────────────────────────────────────
366
422
 
367
423
  export async function multiApproveGateCommand(options) {
@@ -17,6 +17,11 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } fr
17
17
  import { isAbsolute, join, dirname, relative, resolve } from 'node:path';
18
18
  import { randomBytes } from 'node:crypto';
19
19
  import { readBarriers, readCoordinatorHistory, saveCoordinatorState } from './coordinator-state.js';
20
+ import {
21
+ computeBarrierStatus as computeCoordinatorBarrierStatus,
22
+ getAcceptedReposForWorkstream,
23
+ getAlignedReposForBarrier,
24
+ } from './coordinator-barriers.js';
20
25
 
21
26
  // ── Paths ───────────────────────────────────────────────────────────────────
22
27
 
@@ -238,100 +243,8 @@ export function recordBarrierTransition(workspacePath, barrierId, previousStatus
238
243
  });
239
244
  }
240
245
 
241
- // ── Internal barrier logic ──────────────────────────────────────────────────
242
-
243
- function getAcceptedReposForWorkstream(history, workstreamId, requiredRepos) {
244
- const accepted = new Set();
245
- for (const entry of history) {
246
- if (entry?.type === 'acceptance_projection' && entry.workstream_id === workstreamId) {
247
- if (requiredRepos.includes(entry.repo_id)) {
248
- accepted.add(entry.repo_id);
249
- }
250
- }
251
- }
252
- return [...accepted];
253
- }
254
-
255
246
  function computeBarrierStatus(barrier, history, config) {
256
- switch (barrier.type) {
257
- case 'all_repos_accepted':
258
- return computeAllReposAccepted(barrier, history);
259
-
260
- case 'ordered_repo_sequence':
261
- return computeOrderedRepoSequence(barrier, history, config);
262
-
263
- case 'interface_alignment':
264
- return computeInterfaceAlignment(barrier, history);
265
-
266
- case 'shared_human_gate':
267
- // Never auto-satisfied — requires explicit human approval
268
- return barrier.status;
269
-
270
- default:
271
- return barrier.status;
272
- }
273
- }
274
-
275
- function computeAllReposAccepted(barrier, history) {
276
- const required = new Set(barrier.required_repos);
277
- const satisfied = new Set();
278
-
279
- for (const entry of history) {
280
- if (entry?.type === 'acceptance_projection' && entry.workstream_id === barrier.workstream_id) {
281
- if (required.has(entry.repo_id)) {
282
- satisfied.add(entry.repo_id);
283
- }
284
- }
285
- }
286
-
287
- if (satisfied.size === required.size) return 'satisfied';
288
- if (satisfied.size > 0) return 'partially_satisfied';
289
- return 'pending';
290
- }
291
-
292
- function computeOrderedRepoSequence(barrier, history, config) {
293
- const workstream = config.workstreams?.[barrier.workstream_id];
294
- if (!workstream) return barrier.status;
295
-
296
- // Upstream is entry_repo. The barrier is satisfied when entry_repo has an accepted turn.
297
- const entryRepo = workstream.entry_repo;
298
- const hasUpstreamAcceptance = history.some(
299
- entry => entry?.type === 'acceptance_projection'
300
- && entry.workstream_id === barrier.workstream_id
301
- && entry.repo_id === entryRepo
302
- );
303
-
304
- if (hasUpstreamAcceptance) return 'satisfied';
305
-
306
- // Check if any non-entry repos have been accepted (partial)
307
- const anyDownstreamAccepted = history.some(
308
- entry => entry?.type === 'acceptance_projection'
309
- && entry.workstream_id === barrier.workstream_id
310
- && entry.repo_id !== entryRepo
311
- );
312
-
313
- if (anyDownstreamAccepted) return 'partially_satisfied';
314
- return 'pending';
315
- }
316
-
317
- function computeInterfaceAlignment(barrier, history) {
318
- const required = new Set(barrier.required_repos);
319
- const repoDecisions = {};
320
-
321
- for (const entry of history) {
322
- if (entry?.type === 'acceptance_projection' && entry.workstream_id === barrier.workstream_id) {
323
- if (required.has(entry.repo_id)) {
324
- repoDecisions[entry.repo_id] = entry.decisions ?? [];
325
- }
326
- }
327
- }
328
-
329
- const acceptedRepos = Object.keys(repoDecisions);
330
- if (acceptedRepos.length === 0) return 'pending';
331
- if (acceptedRepos.length < required.size) return 'partially_satisfied';
332
-
333
- // All repos accepted — check for DEC-* alignment (heuristic)
334
- return 'satisfied';
247
+ return computeCoordinatorBarrierStatus(barrier, history, config);
335
248
  }
336
249
 
337
250
  // ── Barrier effects from a single acceptance ────────────────────────────────
@@ -340,19 +253,32 @@ function evaluateBarrierEffects(workspacePath, state, config, repoId, workstream
340
253
  const barriers = readBarriers(workspacePath);
341
254
  const history = readCoordinatorHistory(workspacePath);
342
255
  const effects = [];
256
+ let snapshotChanged = false;
343
257
 
344
258
  for (const [barrierId, barrier] of Object.entries(barriers)) {
345
259
  if (barrier.status === 'satisfied') continue;
346
260
  if (barrier.workstream_id !== workstreamId) continue;
347
261
 
348
262
  const newStatus = computeBarrierStatus(barrier, history, config);
263
+ if (barrier.type === 'all_repos_accepted') {
264
+ const satisfiedRepos = getAcceptedReposForWorkstream(history, workstreamId, barrier.required_repos);
265
+ if (JSON.stringify(barrier.satisfied_repos || []) !== JSON.stringify(satisfiedRepos)) {
266
+ barrier.satisfied_repos = satisfiedRepos;
267
+ snapshotChanged = true;
268
+ }
269
+ }
270
+ if (barrier.type === 'interface_alignment') {
271
+ const satisfiedRepos = getAlignedReposForBarrier(barrier, history);
272
+ if (JSON.stringify(barrier.satisfied_repos || []) !== JSON.stringify(satisfiedRepos)) {
273
+ barrier.satisfied_repos = satisfiedRepos;
274
+ snapshotChanged = true;
275
+ }
276
+ }
277
+
349
278
  if (newStatus !== barrier.status) {
350
279
  const previousStatus = barrier.status;
351
280
  barrier.status = newStatus;
352
-
353
- if (barrier.type === 'all_repos_accepted') {
354
- barrier.satisfied_repos = getAcceptedReposForWorkstream(history, workstreamId, barrier.required_repos);
355
- }
281
+ snapshotChanged = true;
356
282
 
357
283
  effects.push({
358
284
  barrier_id: barrierId,
@@ -370,7 +296,7 @@ function evaluateBarrierEffects(workspacePath, state, config, repoId, workstream
370
296
  }
371
297
 
372
298
  // Persist updated barrier snapshot
373
- if (effects.length > 0) {
299
+ if (snapshotChanged) {
374
300
  writeFileSync(barriersPath(workspacePath), JSON.stringify(barriers, null, 2) + '\n');
375
301
  }
376
302
 
@@ -0,0 +1,116 @@
1
+ function isProjectionEntry(entry, workstreamId) {
2
+ return entry?.type === 'acceptance_projection' && entry.workstream_id === workstreamId;
3
+ }
4
+
5
+ function normalizeDecisionId(decision) {
6
+ if (typeof decision === 'string') return decision;
7
+ if (decision && typeof decision === 'object' && typeof decision.id === 'string') return decision.id;
8
+ return null;
9
+ }
10
+
11
+ function collectAcceptedDecisionIds(history, workstreamId, requiredRepos = []) {
12
+ const required = new Set(requiredRepos);
13
+ const repoDecisionIds = {};
14
+ const acceptedRepos = new Set();
15
+
16
+ for (const entry of history) {
17
+ if (!isProjectionEntry(entry, workstreamId)) continue;
18
+ if (required.size > 0 && !required.has(entry.repo_id)) continue;
19
+
20
+ acceptedRepos.add(entry.repo_id);
21
+ if (!repoDecisionIds[entry.repo_id]) {
22
+ repoDecisionIds[entry.repo_id] = new Set();
23
+ }
24
+
25
+ for (const decision of Array.isArray(entry.decisions) ? entry.decisions : []) {
26
+ const id = normalizeDecisionId(decision);
27
+ if (id) {
28
+ repoDecisionIds[entry.repo_id].add(id);
29
+ }
30
+ }
31
+ }
32
+
33
+ return { acceptedRepos, repoDecisionIds };
34
+ }
35
+
36
+ export function getAcceptedReposForWorkstream(history, workstreamId, requiredRepos = []) {
37
+ return [
38
+ ...collectAcceptedDecisionIds(history, workstreamId, requiredRepos).acceptedRepos,
39
+ ];
40
+ }
41
+
42
+ export function getAlignedReposForBarrier(barrier, history) {
43
+ const requiredRepos = Array.isArray(barrier.required_repos) ? barrier.required_repos : [];
44
+ const alignmentDecisionIds = barrier.alignment_decision_ids || {};
45
+ const { repoDecisionIds } = collectAcceptedDecisionIds(history, barrier.workstream_id, requiredRepos);
46
+ const alignedRepos = [];
47
+
48
+ for (const repoId of requiredRepos) {
49
+ const requiredIds = Array.isArray(alignmentDecisionIds[repoId]) ? alignmentDecisionIds[repoId] : [];
50
+ if (requiredIds.length === 0) continue;
51
+
52
+ const acceptedIds = repoDecisionIds[repoId] || new Set();
53
+ if (requiredIds.every((decisionId) => acceptedIds.has(decisionId))) {
54
+ alignedRepos.push(repoId);
55
+ }
56
+ }
57
+
58
+ return alignedRepos;
59
+ }
60
+
61
+ export function computeAllReposAcceptedStatus(barrier, history) {
62
+ const requiredRepos = Array.isArray(barrier.required_repos) ? barrier.required_repos : [];
63
+ const acceptedRepos = getAcceptedReposForWorkstream(history, barrier.workstream_id, requiredRepos);
64
+
65
+ if (acceptedRepos.length === requiredRepos.length && requiredRepos.length > 0) return 'satisfied';
66
+ if (acceptedRepos.length > 0) return 'partially_satisfied';
67
+ return 'pending';
68
+ }
69
+
70
+ export function computeOrderedRepoSequenceStatus(barrier, history, config) {
71
+ const workstream = config.workstreams?.[barrier.workstream_id];
72
+ if (!workstream) return barrier.status;
73
+
74
+ const entryRepo = workstream.entry_repo;
75
+ const hasUpstreamAcceptance = history.some(
76
+ (entry) => isProjectionEntry(entry, barrier.workstream_id) && entry.repo_id === entryRepo,
77
+ );
78
+
79
+ if (hasUpstreamAcceptance) return 'satisfied';
80
+
81
+ const anyDownstreamAccepted = history.some(
82
+ (entry) => isProjectionEntry(entry, barrier.workstream_id) && entry.repo_id !== entryRepo,
83
+ );
84
+
85
+ if (anyDownstreamAccepted) return 'partially_satisfied';
86
+ return 'pending';
87
+ }
88
+
89
+ export function computeInterfaceAlignmentStatus(barrier, history) {
90
+ const requiredRepos = Array.isArray(barrier.required_repos) ? barrier.required_repos : [];
91
+ const alignedRepos = getAlignedReposForBarrier(barrier, history);
92
+ const acceptedRepos = getAcceptedReposForWorkstream(history, barrier.workstream_id, requiredRepos);
93
+
94
+ if (alignedRepos.length === requiredRepos.length && requiredRepos.length > 0) return 'satisfied';
95
+ if (acceptedRepos.length > 0) return 'partially_satisfied';
96
+ return 'pending';
97
+ }
98
+
99
+ export function computeBarrierStatus(barrier, history, config) {
100
+ switch (barrier.type) {
101
+ case 'all_repos_accepted':
102
+ return computeAllReposAcceptedStatus(barrier, history);
103
+
104
+ case 'ordered_repo_sequence':
105
+ return computeOrderedRepoSequenceStatus(barrier, history, config);
106
+
107
+ case 'interface_alignment':
108
+ return computeInterfaceAlignmentStatus(barrier, history);
109
+
110
+ case 'shared_human_gate':
111
+ return barrier.status;
112
+
113
+ default:
114
+ return barrier.status;
115
+ }
116
+ }
@@ -146,12 +146,95 @@ function validateWorkstreams(raw, repoIds, errors) {
146
146
  `workstream "${workstreamId}" completion_barrier must be one of: ${Array.from(VALID_BARRIER_TYPES).join(', ')}`,
147
147
  );
148
148
  }
149
+
150
+ validateInterfaceAlignment(workstreamId, workstream, errors);
149
151
  }
150
152
 
151
153
  detectWorkstreamCycles(raw.workstreams, errors);
152
154
  return workstreamIds;
153
155
  }
154
156
 
157
+ function validateInterfaceAlignment(workstreamId, workstream, errors) {
158
+ if (workstream.completion_barrier !== 'interface_alignment') {
159
+ return;
160
+ }
161
+
162
+ const alignment = workstream.interface_alignment;
163
+ if (!alignment || typeof alignment !== 'object' || Array.isArray(alignment)) {
164
+ pushError(
165
+ errors,
166
+ 'workstream_interface_alignment_invalid',
167
+ `workstream "${workstreamId}" with completion_barrier "interface_alignment" must declare interface_alignment.decision_ids_by_repo`,
168
+ );
169
+ return;
170
+ }
171
+
172
+ const byRepo = alignment.decision_ids_by_repo;
173
+ if (!byRepo || typeof byRepo !== 'object' || Array.isArray(byRepo)) {
174
+ pushError(
175
+ errors,
176
+ 'workstream_interface_alignment_decisions_invalid',
177
+ `workstream "${workstreamId}" interface_alignment.decision_ids_by_repo must be an object`,
178
+ );
179
+ return;
180
+ }
181
+
182
+ const repoIds = Array.isArray(workstream.repos) ? workstream.repos : [];
183
+ const repoIdSet = new Set(repoIds);
184
+
185
+ for (const repoId of repoIds) {
186
+ if (!(repoId in byRepo)) {
187
+ pushError(
188
+ errors,
189
+ 'workstream_interface_alignment_repo_missing',
190
+ `workstream "${workstreamId}" must declare interface_alignment.decision_ids_by_repo["${repoId}"]`,
191
+ );
192
+ continue;
193
+ }
194
+
195
+ const decisionIds = byRepo[repoId];
196
+ if (!Array.isArray(decisionIds) || decisionIds.length === 0) {
197
+ pushError(
198
+ errors,
199
+ 'workstream_interface_alignment_repo_invalid',
200
+ `workstream "${workstreamId}" interface_alignment.decision_ids_by_repo["${repoId}"] must be a non-empty array`,
201
+ );
202
+ continue;
203
+ }
204
+
205
+ const seen = new Set();
206
+ for (const decisionId of decisionIds) {
207
+ if (typeof decisionId !== 'string' || !/^DEC-\d+$/.test(decisionId)) {
208
+ pushError(
209
+ errors,
210
+ 'workstream_interface_alignment_decision_invalid',
211
+ `workstream "${workstreamId}" interface_alignment decision "${decisionId}" for repo "${repoId}" must match DEC-NNN`,
212
+ );
213
+ continue;
214
+ }
215
+ if (seen.has(decisionId)) {
216
+ pushError(
217
+ errors,
218
+ 'workstream_interface_alignment_decision_duplicate',
219
+ `workstream "${workstreamId}" interface_alignment decision "${decisionId}" is duplicated for repo "${repoId}"`,
220
+ );
221
+ continue;
222
+ }
223
+ seen.add(decisionId);
224
+ }
225
+ }
226
+
227
+ for (const repoId of Object.keys(byRepo)) {
228
+ if (!repoIdSet.has(repoId)) {
229
+ pushError(
230
+ errors,
231
+ 'workstream_interface_alignment_repo_unknown',
232
+ `workstream "${workstreamId}" interface_alignment references undeclared repo "${repoId}"`,
233
+ );
234
+ }
235
+ }
236
+ }
237
+
155
238
  function detectWorkstreamCycles(workstreams, errors) {
156
239
  const visiting = new Set();
157
240
  const visited = new Set();
@@ -344,6 +427,16 @@ export function normalizeCoordinatorConfig(raw) {
344
427
  entry_repo: workstream.entry_repo,
345
428
  depends_on: Array.isArray(workstream.depends_on) ? [...new Set(workstream.depends_on)] : [],
346
429
  completion_barrier: workstream.completion_barrier,
430
+ interface_alignment: workstream.interface_alignment?.decision_ids_by_repo
431
+ ? {
432
+ decision_ids_by_repo: Object.fromEntries(
433
+ Object.entries(workstream.interface_alignment.decision_ids_by_repo).map(([repoId, decisionIds]) => [
434
+ repoId,
435
+ Array.isArray(decisionIds) ? [...new Set(decisionIds)] : [],
436
+ ]),
437
+ ),
438
+ }
439
+ : null,
347
440
  },
348
441
  ]),
349
442
  ),