agentxchain 2.14.0 → 2.16.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 +9 -3
- package/bin/agentxchain.js +20 -2
- package/package.json +3 -1
- package/scripts/release-downstream-truth.sh +15 -14
- package/scripts/release-postflight.sh +21 -5
- package/scripts/sync-homebrew.sh +225 -0
- package/src/commands/init.js +16 -6
- 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/migrate.js +7 -3
- package/src/commands/multi.js +58 -2
- package/src/commands/run.js +29 -1
- package/src/commands/template-set.js +51 -2
- package/src/lib/adapter-interface.js +31 -0
- package/src/lib/coordinator-acceptance.js +24 -98
- package/src/lib/coordinator-barriers.js +116 -0
- package/src/lib/coordinator-config.js +124 -0
- package/src/lib/coordinator-dispatch.js +10 -1
- package/src/lib/coordinator-gates.js +28 -1
- package/src/lib/coordinator-recovery.js +133 -68
- package/src/lib/coordinator-state.js +74 -0
- package/src/lib/cross-repo-context.js +68 -1
- package/src/lib/governed-templates.js +60 -0
- package/src/lib/intake-handoff.js +58 -0
- package/src/lib/intake.js +300 -11
- package/src/lib/report.js +759 -27
- package/src/lib/workflow-gate-semantics.js +209 -0
- package/src/templates/governed/api-service.json +8 -1
- package/src/templates/governed/cli-tool.json +8 -1
- package/src/templates/governed/library.json +8 -1
- package/src/templates/governed/web-app.json +8 -1
|
@@ -19,8 +19,14 @@ import {
|
|
|
19
19
|
saveCoordinatorState,
|
|
20
20
|
readCoordinatorHistory,
|
|
21
21
|
readBarriers,
|
|
22
|
+
recordCoordinatorDecision,
|
|
22
23
|
} from './coordinator-state.js';
|
|
23
24
|
import { safeWriteJson } from './safe-write.js';
|
|
25
|
+
import {
|
|
26
|
+
computeBarrierStatus as computeCoordinatorBarrierStatus,
|
|
27
|
+
getAcceptedReposForWorkstream,
|
|
28
|
+
getAlignedReposForBarrier,
|
|
29
|
+
} from './coordinator-barriers.js';
|
|
24
30
|
|
|
25
31
|
// ── Paths ───────────────────────────────────────────────────────────────────
|
|
26
32
|
|
|
@@ -335,6 +341,23 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
|
|
|
335
341
|
if (barrier.type === 'shared_human_gate') continue; // Never auto-transition
|
|
336
342
|
|
|
337
343
|
const newStatus = recomputeBarrierStatus(barrier, fullHistory, config);
|
|
344
|
+
if (barrier.type === 'all_repos_accepted') {
|
|
345
|
+
const satisfiedRepos = getAcceptedReposForWorkstream(
|
|
346
|
+
fullHistory, barrier.workstream_id, barrier.required_repos
|
|
347
|
+
);
|
|
348
|
+
if (JSON.stringify(barrier.satisfied_repos || []) !== JSON.stringify(satisfiedRepos)) {
|
|
349
|
+
barrier.satisfied_repos = satisfiedRepos;
|
|
350
|
+
barriersChanged = true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (barrier.type === 'interface_alignment') {
|
|
354
|
+
const satisfiedRepos = getAlignedReposForBarrier(barrier, fullHistory);
|
|
355
|
+
if (JSON.stringify(barrier.satisfied_repos || []) !== JSON.stringify(satisfiedRepos)) {
|
|
356
|
+
barrier.satisfied_repos = satisfiedRepos;
|
|
357
|
+
barriersChanged = true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
338
361
|
if (newStatus !== barrier.status) {
|
|
339
362
|
const previousStatus = barrier.status;
|
|
340
363
|
barrierChanges.push({
|
|
@@ -347,13 +370,6 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
|
|
|
347
370
|
|
|
348
371
|
barrier.status = newStatus;
|
|
349
372
|
|
|
350
|
-
// Update satisfied_repos for tracking
|
|
351
|
-
if (barrier.type === 'all_repos_accepted') {
|
|
352
|
-
barrier.satisfied_repos = getAcceptedReposForWorkstream(
|
|
353
|
-
fullHistory, barrier.workstream_id, barrier.required_repos
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
373
|
barriersChanged = true;
|
|
358
374
|
|
|
359
375
|
appendJsonl(barrierLedgerPath(workspacePath), {
|
|
@@ -421,75 +437,124 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
|
|
|
421
437
|
};
|
|
422
438
|
}
|
|
423
439
|
|
|
424
|
-
|
|
440
|
+
/**
|
|
441
|
+
* Clear a coordinator blocked state after the operator resolves the cause.
|
|
442
|
+
*
|
|
443
|
+
* Recovery always begins with repo-authority resync, then restores the
|
|
444
|
+
* coordinator to `active` or `paused` based on whether a pending gate still
|
|
445
|
+
* exists. It never mutates repo-local governed state.
|
|
446
|
+
*
|
|
447
|
+
* @param {string} workspacePath
|
|
448
|
+
* @param {object} state
|
|
449
|
+
* @param {object} config
|
|
450
|
+
* @returns {{ ok: boolean, state?: object, resumed_status?: string, blocked_reason?: string, blocked_repos?: string[], resync?: object, error?: string }}
|
|
451
|
+
*/
|
|
452
|
+
export function resumeCoordinatorFromBlockedState(workspacePath, state, config) {
|
|
453
|
+
if (!state || state.status !== 'blocked') {
|
|
454
|
+
return {
|
|
455
|
+
ok: false,
|
|
456
|
+
error: `Cannot resume coordinator: status is "${state?.status || 'missing'}", expected "blocked"`,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
425
459
|
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
460
|
+
const previousBlockedReason = state.blocked_reason || 'unknown blocked reason';
|
|
461
|
+
const expectedSuperRunId = state.super_run_id;
|
|
462
|
+
const resync = resyncFromRepoAuthority(workspacePath, state, config);
|
|
463
|
+
const refreshedState = loadCoordinatorState(workspacePath);
|
|
464
|
+
|
|
465
|
+
if (!refreshedState) {
|
|
466
|
+
return {
|
|
467
|
+
ok: false,
|
|
468
|
+
error: 'Coordinator state could not be reloaded after resync',
|
|
469
|
+
blocked_reason: previousBlockedReason,
|
|
470
|
+
resync,
|
|
471
|
+
};
|
|
434
472
|
}
|
|
435
|
-
return [...accepted];
|
|
436
|
-
}
|
|
437
473
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
satisfied.add(entry.repo_id);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
if (satisfied.size === required.size) return 'satisfied';
|
|
451
|
-
if (satisfied.size > 0) return 'partially_satisfied';
|
|
452
|
-
return 'pending';
|
|
453
|
-
}
|
|
474
|
+
if (refreshedState.super_run_id !== expectedSuperRunId) {
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
error: `Cannot resume coordinator: super_run_id changed from "${expectedSuperRunId}" to "${refreshedState.super_run_id}"`,
|
|
478
|
+
blocked_reason: previousBlockedReason,
|
|
479
|
+
resync,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
454
482
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
483
|
+
if (!resync.ok) {
|
|
484
|
+
return {
|
|
485
|
+
ok: false,
|
|
486
|
+
error: `Coordinator remains blocked: ${resync.blocked_reason || refreshedState.blocked_reason || previousBlockedReason}`,
|
|
487
|
+
blocked_reason: resync.blocked_reason || refreshedState.blocked_reason || previousBlockedReason,
|
|
488
|
+
resync,
|
|
489
|
+
state: refreshedState,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const blockedRepos = Object.entries(refreshedState.repo_runs || {})
|
|
494
|
+
.filter(([, repoRun]) => repoRun?.status === 'blocked')
|
|
495
|
+
.map(([repoId]) => repoId);
|
|
496
|
+
|
|
497
|
+
if (blockedRepos.length > 0) {
|
|
498
|
+
const blockedReason = `child repos remain blocked: ${blockedRepos.join(', ')}`;
|
|
499
|
+
const blockedState = {
|
|
500
|
+
...refreshedState,
|
|
501
|
+
status: 'blocked',
|
|
502
|
+
blocked_reason: blockedReason,
|
|
503
|
+
};
|
|
504
|
+
saveCoordinatorState(workspacePath, blockedState);
|
|
505
|
+
return {
|
|
506
|
+
ok: false,
|
|
507
|
+
error: `Cannot resume coordinator: ${blockedReason}`,
|
|
508
|
+
blocked_reason: blockedReason,
|
|
509
|
+
blocked_repos: blockedRepos,
|
|
510
|
+
resync,
|
|
511
|
+
state: blockedState,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const resumedStatus = refreshedState.pending_gate ? 'paused' : 'active';
|
|
516
|
+
const resumedState = {
|
|
517
|
+
...refreshedState,
|
|
518
|
+
status: resumedStatus,
|
|
519
|
+
};
|
|
520
|
+
delete resumedState.blocked_reason;
|
|
473
521
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
522
|
+
saveCoordinatorState(workspacePath, resumedState);
|
|
523
|
+
appendJsonl(historyPath(workspacePath), {
|
|
524
|
+
type: 'blocked_resolved',
|
|
525
|
+
timestamp: new Date().toISOString(),
|
|
526
|
+
super_run_id: refreshedState.super_run_id,
|
|
527
|
+
from: 'blocked',
|
|
528
|
+
to: resumedStatus,
|
|
529
|
+
blocked_reason: previousBlockedReason,
|
|
530
|
+
pending_gate: refreshedState.pending_gate
|
|
531
|
+
? {
|
|
532
|
+
gate: refreshedState.pending_gate.gate,
|
|
533
|
+
gate_type: refreshedState.pending_gate.gate_type,
|
|
481
534
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
535
|
+
: null,
|
|
536
|
+
});
|
|
537
|
+
recordCoordinatorDecision(workspacePath, resumedState, {
|
|
538
|
+
category: 'recovery',
|
|
539
|
+
from: 'blocked',
|
|
540
|
+
to: resumedStatus,
|
|
541
|
+
reason: typeof previousBlockedReason === 'string'
|
|
542
|
+
? previousBlockedReason
|
|
543
|
+
: JSON.stringify(previousBlockedReason),
|
|
544
|
+
statement: `Resumed coordinator from blocked to ${resumedStatus}`,
|
|
545
|
+
});
|
|
486
546
|
|
|
487
|
-
|
|
488
|
-
|
|
547
|
+
return {
|
|
548
|
+
ok: true,
|
|
549
|
+
state: resumedState,
|
|
550
|
+
resumed_status: resumedStatus,
|
|
551
|
+
blocked_reason: previousBlockedReason,
|
|
552
|
+
resync,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
489
555
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
556
|
+
function recomputeBarrierStatus(barrier, history, config) {
|
|
557
|
+
return computeCoordinatorBarrierStatus(barrier, history, config);
|
|
493
558
|
}
|
|
494
559
|
|
|
495
560
|
/**
|
|
@@ -64,6 +64,21 @@ function appendJsonl(filePath, entry) {
|
|
|
64
64
|
writeFileSync(filePath, JSON.stringify(entry) + '\n', { flag: 'a' });
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
function nextCoordinatorDecisionId(entries) {
|
|
68
|
+
let max = 0;
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const match = typeof entry?.id === 'string'
|
|
71
|
+
? entry.id.match(/^DEC-COORD-(\d+)$/)
|
|
72
|
+
: null;
|
|
73
|
+
if (!match) continue;
|
|
74
|
+
const current = Number.parseInt(match[1], 10);
|
|
75
|
+
if (Number.isFinite(current) && current > max) {
|
|
76
|
+
max = current;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return `DEC-COORD-${String(max + 1).padStart(3, '0')}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
67
82
|
function readRepoLocalState(repoPath) {
|
|
68
83
|
const stateFile = join(repoPath, '.agentxchain/state.json');
|
|
69
84
|
if (!existsSync(stateFile)) return null;
|
|
@@ -97,6 +112,7 @@ function bootstrapBarriers(config) {
|
|
|
97
112
|
status: 'pending',
|
|
98
113
|
required_repos: [...workstream.repos],
|
|
99
114
|
satisfied_repos: [],
|
|
115
|
+
alignment_decision_ids: workstream.interface_alignment?.decision_ids_by_repo || null,
|
|
100
116
|
created_at: new Date().toISOString(),
|
|
101
117
|
};
|
|
102
118
|
}
|
|
@@ -251,6 +267,11 @@ export function initializeCoordinatorRun(workspacePath, preloadedConfig) {
|
|
|
251
267
|
),
|
|
252
268
|
timestamp: now,
|
|
253
269
|
});
|
|
270
|
+
recordCoordinatorDecision(workspacePath, state, {
|
|
271
|
+
timestamp: now,
|
|
272
|
+
category: 'initialization',
|
|
273
|
+
statement: `Initialized coordinator run for ${config.project.id} with ${Object.keys(repoRuns).length} repo${Object.keys(repoRuns).length === 1 ? '' : 's'}`,
|
|
274
|
+
});
|
|
254
275
|
} catch (err) {
|
|
255
276
|
// Atomic failure: clean up partial state
|
|
256
277
|
try {
|
|
@@ -297,6 +318,59 @@ export function saveCoordinatorState(workspacePath, state) {
|
|
|
297
318
|
safeWriteJson(statePath(workspacePath), updated);
|
|
298
319
|
}
|
|
299
320
|
|
|
321
|
+
export function readCoordinatorDecisionLedger(workspacePath) {
|
|
322
|
+
const file = ledgerPath(workspacePath);
|
|
323
|
+
if (!existsSync(file)) return [];
|
|
324
|
+
try {
|
|
325
|
+
const content = readFileSync(file, 'utf8').trim();
|
|
326
|
+
if (!content) return [];
|
|
327
|
+
return content.split('\n').map((line) => JSON.parse(line));
|
|
328
|
+
} catch {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function recordCoordinatorDecision(workspacePath, state, decision) {
|
|
334
|
+
const statement = typeof decision?.statement === 'string' ? decision.statement.trim() : '';
|
|
335
|
+
if (statement.length === 0) {
|
|
336
|
+
throw new Error('Coordinator decision statement is required');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const existingEntries = readCoordinatorDecisionLedger(workspacePath);
|
|
340
|
+
const entry = {
|
|
341
|
+
id: typeof decision?.id === 'string' && decision.id.length > 0
|
|
342
|
+
? decision.id
|
|
343
|
+
: nextCoordinatorDecisionId(existingEntries),
|
|
344
|
+
timestamp: decision?.timestamp || new Date().toISOString(),
|
|
345
|
+
super_run_id: decision?.super_run_id || state?.super_run_id || null,
|
|
346
|
+
role: decision?.role || 'coordinator',
|
|
347
|
+
phase: decision?.phase || state?.phase || null,
|
|
348
|
+
category: decision?.category || 'governance',
|
|
349
|
+
statement,
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
for (const key of [
|
|
353
|
+
'repo_id',
|
|
354
|
+
'repo_run_id',
|
|
355
|
+
'repo_turn_id',
|
|
356
|
+
'workstream_id',
|
|
357
|
+
'gate',
|
|
358
|
+
'from',
|
|
359
|
+
'to',
|
|
360
|
+
'reason',
|
|
361
|
+
'context_ref',
|
|
362
|
+
'projection_ref',
|
|
363
|
+
]) {
|
|
364
|
+
const value = decision?.[key];
|
|
365
|
+
if (value != null) {
|
|
366
|
+
entry[key] = value;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
appendJsonl(ledgerPath(workspacePath), entry);
|
|
371
|
+
return entry;
|
|
372
|
+
}
|
|
373
|
+
|
|
300
374
|
/**
|
|
301
375
|
* Get a full coordinator status snapshot.
|
|
302
376
|
*
|
|
@@ -2,6 +2,7 @@ import { mkdirSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { randomBytes } from 'node:crypto';
|
|
4
4
|
import { readCoordinatorHistory, readBarriers } from './coordinator-state.js';
|
|
5
|
+
import { listWorkstreamHandoffs } from './intake-handoff.js';
|
|
5
6
|
|
|
6
7
|
const CONTEXT_ROOT = '.agentxchain/multirepo/context';
|
|
7
8
|
|
|
@@ -60,6 +61,7 @@ function collectActiveBarriers(barriers, workstreamIds, targetRepoId) {
|
|
|
60
61
|
type: barrier.type || 'unknown',
|
|
61
62
|
status: barrier.status,
|
|
62
63
|
notes: barrier.notes || null,
|
|
64
|
+
alignment_decision_ids: barrier.alignment_decision_ids || null,
|
|
63
65
|
}));
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -75,6 +77,17 @@ function buildRequiredFollowups(workstreamId, dependencyIds, upstreamAcceptances
|
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
for (const barrier of activeBarriers) {
|
|
80
|
+
if (
|
|
81
|
+
barrier.type === 'interface_alignment'
|
|
82
|
+
&& barrier.alignment_decision_ids
|
|
83
|
+
&& Array.isArray(barrier.alignment_decision_ids[targetRepoId])
|
|
84
|
+
&& barrier.alignment_decision_ids[targetRepoId].length > 0
|
|
85
|
+
) {
|
|
86
|
+
followups.push(
|
|
87
|
+
`Accept declared interface-alignment decisions for ${targetRepoId}: ${barrier.alignment_decision_ids[targetRepoId].join(', ')}.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
78
91
|
if (barrier.notes) {
|
|
79
92
|
followups.push(barrier.notes);
|
|
80
93
|
} else {
|
|
@@ -85,6 +98,21 @@ function buildRequiredFollowups(workstreamId, dependencyIds, upstreamAcceptances
|
|
|
85
98
|
return [...new Set(followups)];
|
|
86
99
|
}
|
|
87
100
|
|
|
101
|
+
function collectIntakeHandoffs(workspacePath, state, workstreamId) {
|
|
102
|
+
return listWorkstreamHandoffs(workspacePath, workstreamId, state.super_run_id).map((handoff) => ({
|
|
103
|
+
intent_id: handoff.intent_id,
|
|
104
|
+
source_repo: handoff.source_repo,
|
|
105
|
+
source_event_id: handoff.source_event_id,
|
|
106
|
+
source_signal_source: handoff.source_signal_source || null,
|
|
107
|
+
source_signal_category: handoff.source_signal_category || null,
|
|
108
|
+
charter: handoff.charter || '',
|
|
109
|
+
acceptance_contract: Array.isArray(handoff.acceptance_contract) ? handoff.acceptance_contract : [],
|
|
110
|
+
source_event_ref: handoff.source_event_ref || null,
|
|
111
|
+
evidence_refs: Array.isArray(handoff.evidence_refs) ? handoff.evidence_refs : [],
|
|
112
|
+
handed_off_at: handoff.handed_off_at || null,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
function renderContextMarkdown(snapshot) {
|
|
89
117
|
const lines = [
|
|
90
118
|
'# Coordinator Context',
|
|
@@ -116,7 +144,16 @@ function renderContextMarkdown(snapshot) {
|
|
|
116
144
|
lines.push('- None');
|
|
117
145
|
} else {
|
|
118
146
|
for (const barrier of snapshot.active_barriers) {
|
|
119
|
-
|
|
147
|
+
let suffix = '';
|
|
148
|
+
if (
|
|
149
|
+
barrier.type === 'interface_alignment'
|
|
150
|
+
&& barrier.alignment_decision_ids
|
|
151
|
+
&& Array.isArray(barrier.alignment_decision_ids[snapshot.target_repo_id])
|
|
152
|
+
&& barrier.alignment_decision_ids[snapshot.target_repo_id].length > 0
|
|
153
|
+
) {
|
|
154
|
+
suffix = ` Required decision IDs for ${snapshot.target_repo_id}: ${barrier.alignment_decision_ids[snapshot.target_repo_id].join(', ')}.`;
|
|
155
|
+
}
|
|
156
|
+
lines.push(`- ${barrier.barrier_id}: ${barrier.type} (${barrier.status})${suffix}`);
|
|
120
157
|
}
|
|
121
158
|
}
|
|
122
159
|
|
|
@@ -132,6 +169,34 @@ function renderContextMarkdown(snapshot) {
|
|
|
132
169
|
}
|
|
133
170
|
}
|
|
134
171
|
|
|
172
|
+
if (snapshot.intake_handoffs.length > 0) {
|
|
173
|
+
lines.push('');
|
|
174
|
+
lines.push('## Intake Handoff');
|
|
175
|
+
lines.push('');
|
|
176
|
+
|
|
177
|
+
for (const handoff of snapshot.intake_handoffs) {
|
|
178
|
+
lines.push(`### ${handoff.intent_id}`);
|
|
179
|
+
lines.push('');
|
|
180
|
+
lines.push(`- Source Repo: ${handoff.source_repo}`);
|
|
181
|
+
if (handoff.source_signal_source || handoff.source_signal_category) {
|
|
182
|
+
lines.push(`- Original Signal: ${handoff.source_signal_source || 'unknown'} — ${handoff.source_signal_category || 'unknown'}`);
|
|
183
|
+
}
|
|
184
|
+
if (handoff.charter) {
|
|
185
|
+
lines.push(`- Charter: ${handoff.charter}`);
|
|
186
|
+
}
|
|
187
|
+
if (handoff.source_event_ref) {
|
|
188
|
+
lines.push(`- Evidence: ${handoff.source_repo}/${handoff.source_event_ref}`);
|
|
189
|
+
}
|
|
190
|
+
if (handoff.acceptance_contract.length > 0) {
|
|
191
|
+
lines.push('- Acceptance Contract:');
|
|
192
|
+
for (const requirement of handoff.acceptance_contract) {
|
|
193
|
+
lines.push(` - ${requirement}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
lines.push('');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
135
200
|
lines.push('');
|
|
136
201
|
return lines.join('\n');
|
|
137
202
|
}
|
|
@@ -197,6 +262,7 @@ export function generateCrossRepoContext(workspacePath, state, config, targetRep
|
|
|
197
262
|
const relevantWorkstreamIds = [workstreamId, ...(Array.isArray(workstream.depends_on) ? workstream.depends_on : [])];
|
|
198
263
|
const upstreamAcceptances = collectUpstreamAcceptances(history, targetRepoId, relevantWorkstreamIds);
|
|
199
264
|
const activeBarriers = collectActiveBarriers(barriers, relevantWorkstreamIds, targetRepoId);
|
|
265
|
+
const intakeHandoffs = collectIntakeHandoffs(workspacePath, state, workstreamId);
|
|
200
266
|
const snapshot = {
|
|
201
267
|
schema_version: '0.1',
|
|
202
268
|
super_run_id: state.super_run_id,
|
|
@@ -213,6 +279,7 @@ export function generateCrossRepoContext(workspacePath, state, config, targetRep
|
|
|
213
279
|
activeBarriers,
|
|
214
280
|
targetRepoId,
|
|
215
281
|
),
|
|
282
|
+
intake_handoffs: intakeHandoffs,
|
|
216
283
|
};
|
|
217
284
|
|
|
218
285
|
const contextDir = getContextDir(workspacePath, contextRef);
|
|
@@ -73,6 +73,32 @@ function validateAcceptanceHints(acceptanceHints, errors) {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
const VALID_SPEC_OVERLAY_KEYS = new Set([
|
|
77
|
+
'purpose_guidance',
|
|
78
|
+
'interface_guidance',
|
|
79
|
+
'behavior_guidance',
|
|
80
|
+
'error_cases_guidance',
|
|
81
|
+
'acceptance_tests_guidance',
|
|
82
|
+
'extra_sections',
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
function validateSystemSpecOverlay(overlay, errors) {
|
|
86
|
+
if (overlay === undefined) return;
|
|
87
|
+
if (!overlay || typeof overlay !== 'object' || Array.isArray(overlay)) {
|
|
88
|
+
errors.push('system_spec_overlay must be an object when provided');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
93
|
+
if (!VALID_SPEC_OVERLAY_KEYS.has(key)) {
|
|
94
|
+
errors.push(`system_spec_overlay contains unknown key "${key}"`);
|
|
95
|
+
}
|
|
96
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
97
|
+
errors.push(`system_spec_overlay["${key}"] must be a non-empty string`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
76
102
|
export function validateGovernedTemplateManifest(manifest, expectedId = null) {
|
|
77
103
|
const errors = [];
|
|
78
104
|
|
|
@@ -111,6 +137,7 @@ export function validateGovernedTemplateManifest(manifest, expectedId = null) {
|
|
|
111
137
|
validatePlanningArtifacts(manifest.planning_artifacts, errors);
|
|
112
138
|
validatePromptOverrides(manifest.prompt_overrides, errors);
|
|
113
139
|
validateAcceptanceHints(manifest.acceptance_hints, errors);
|
|
140
|
+
validateSystemSpecOverlay(manifest.system_spec_overlay, errors);
|
|
114
141
|
|
|
115
142
|
return { ok: errors.length === 0, errors };
|
|
116
143
|
}
|
|
@@ -251,6 +278,7 @@ const TEMPLATE_GUIDANCE_HEADER = '## Template Guidance';
|
|
|
251
278
|
const GOVERNED_WORKFLOW_KIT_BASE_FILES = Object.freeze([
|
|
252
279
|
'.planning/PM_SIGNOFF.md',
|
|
253
280
|
'.planning/ROADMAP.md',
|
|
281
|
+
'.planning/SYSTEM_SPEC.md',
|
|
254
282
|
'.planning/acceptance-matrix.md',
|
|
255
283
|
'.planning/ship-verdict.md',
|
|
256
284
|
]);
|
|
@@ -267,6 +295,24 @@ const GOVERNED_WORKFLOW_KIT_STRUCTURAL_CHECKS = Object.freeze([
|
|
|
267
295
|
pattern: /^##\s+Phases\b/im,
|
|
268
296
|
description: 'Roadmap defines a ## Phases section',
|
|
269
297
|
},
|
|
298
|
+
{
|
|
299
|
+
id: 'system_spec_purpose_section',
|
|
300
|
+
file: '.planning/SYSTEM_SPEC.md',
|
|
301
|
+
pattern: /^##\s+Purpose\b/im,
|
|
302
|
+
description: 'System spec defines a ## Purpose section',
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
id: 'system_spec_interface_section',
|
|
306
|
+
file: '.planning/SYSTEM_SPEC.md',
|
|
307
|
+
pattern: /^##\s+Interface\b/im,
|
|
308
|
+
description: 'System spec defines a ## Interface section',
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
id: 'system_spec_acceptance_tests_section',
|
|
312
|
+
file: '.planning/SYSTEM_SPEC.md',
|
|
313
|
+
pattern: /^##\s+Acceptance Tests\b/im,
|
|
314
|
+
description: 'System spec defines a ## Acceptance Tests section',
|
|
315
|
+
},
|
|
270
316
|
{
|
|
271
317
|
id: 'acceptance_matrix_table_header',
|
|
272
318
|
file: '.planning/acceptance-matrix.md',
|
|
@@ -452,6 +498,20 @@ export function validateGovernedWorkflowKit(root, config = {}) {
|
|
|
452
498
|
};
|
|
453
499
|
}
|
|
454
500
|
|
|
501
|
+
export const SYSTEM_SPEC_OVERLAY_SEPARATOR = '## Template-Specific Guidance';
|
|
502
|
+
|
|
503
|
+
export function buildSystemSpecContent(projectName, overlay) {
|
|
504
|
+
const o = overlay || {};
|
|
505
|
+
const purpose = o.purpose_guidance || '(Describe the problem this slice solves and why it exists.)';
|
|
506
|
+
const iface = o.interface_guidance || '(List the user-facing commands, files, APIs, or contracts this slice changes.)';
|
|
507
|
+
const behavior = o.behavior_guidance || '(Describe the expected behavior, including important edge cases.)';
|
|
508
|
+
const errorCases = o.error_cases_guidance || '(List the failure modes and how the system should respond.)';
|
|
509
|
+
const acceptance = o.acceptance_tests_guidance || '- [ ] Name the executable checks that prove this slice works.';
|
|
510
|
+
const extra = o.extra_sections ? `\n${o.extra_sections}\n` : '';
|
|
511
|
+
|
|
512
|
+
return `# System Spec — ${projectName}\n\n## Purpose\n\n${purpose}\n\n## Interface\n\n${iface}\n\n## Behavior\n\n${behavior}\n\n## Error Cases\n\n${errorCases}\n\n## Acceptance Tests\n\n${acceptance}\n\n## Open Questions\n\n- (Capture unresolved product or implementation questions here.)\n${extra}`;
|
|
513
|
+
}
|
|
514
|
+
|
|
455
515
|
export function validateGovernedProjectTemplate(templateId, source = 'agentxchain.json') {
|
|
456
516
|
const effectiveTemplateId = templateId || 'generic';
|
|
457
517
|
const effectiveSource = templateId ? source : 'implicit_default';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { safeWriteJson } from './safe-write.js';
|
|
4
|
+
|
|
5
|
+
const HANDOFF_DIR = '.agentxchain/multirepo/handoffs';
|
|
6
|
+
|
|
7
|
+
export function getCoordinatorHandoffDir(workspacePath) {
|
|
8
|
+
return join(workspacePath, HANDOFF_DIR);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getCoordinatorHandoffPath(workspacePath, intentId) {
|
|
12
|
+
return join(getCoordinatorHandoffDir(workspacePath), `${intentId}.json`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function writeCoordinatorHandoff(workspacePath, intentId, handoff) {
|
|
16
|
+
const dir = getCoordinatorHandoffDir(workspacePath);
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
const filePath = getCoordinatorHandoffPath(workspacePath, intentId);
|
|
19
|
+
safeWriteJson(filePath, handoff);
|
|
20
|
+
return filePath;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readCoordinatorHandoff(workspacePath, intentId) {
|
|
24
|
+
const filePath = getCoordinatorHandoffPath(workspacePath, intentId);
|
|
25
|
+
if (!existsSync(filePath)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function listCoordinatorHandoffs(workspacePath) {
|
|
36
|
+
const dir = getCoordinatorHandoffDir(workspacePath);
|
|
37
|
+
if (!existsSync(dir)) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return readdirSync(dir)
|
|
42
|
+
.filter((entry) => entry.endsWith('.json') && !entry.startsWith('.tmp-'))
|
|
43
|
+
.map((entry) => {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(join(dir, entry), 'utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function listWorkstreamHandoffs(workspacePath, workstreamId, superRunId) {
|
|
54
|
+
return listCoordinatorHandoffs(workspacePath).filter((handoff) => (
|
|
55
|
+
handoff.workstream_id === workstreamId
|
|
56
|
+
&& (!superRunId || handoff.super_run_id === superRunId)
|
|
57
|
+
));
|
|
58
|
+
}
|