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.
- package/README.md +8 -3
- package/bin/agentxchain.js +19 -2
- package/package.json +7 -1
- package/scripts/release-downstream-truth.sh +133 -0
- package/scripts/release-postflight.sh +84 -8
- package/src/commands/intake-approve.js +2 -10
- package/src/commands/intake-handoff.js +58 -0
- package/src/commands/intake-plan.js +2 -11
- package/src/commands/intake-record.js +2 -10
- package/src/commands/intake-resolve.js +2 -10
- package/src/commands/intake-scan.js +2 -10
- package/src/commands/intake-start.js +2 -10
- package/src/commands/intake-status.js +6 -10
- package/src/commands/intake-triage.js +2 -10
- package/src/commands/intake-workspace.js +58 -0
- package/src/commands/multi.js +58 -2
- package/src/lib/coordinator-acceptance.js +24 -98
- package/src/lib/coordinator-barriers.js +116 -0
- package/src/lib/coordinator-config.js +93 -0
- package/src/lib/coordinator-recovery.js +123 -68
- package/src/lib/coordinator-state.js +1 -0
- package/src/lib/cross-repo-context.js +68 -1
- package/src/lib/intake-handoff.js +58 -0
- package/src/lib/intake.js +300 -11
|
@@ -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
|
+
}
|
package/src/commands/multi.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 (
|
|
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
|
),
|