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.
Files changed (39) hide show
  1. package/README.md +9 -3
  2. package/bin/agentxchain.js +20 -2
  3. package/package.json +3 -1
  4. package/scripts/release-downstream-truth.sh +15 -14
  5. package/scripts/release-postflight.sh +21 -5
  6. package/scripts/sync-homebrew.sh +225 -0
  7. package/src/commands/init.js +16 -6
  8. package/src/commands/intake-approve.js +2 -10
  9. package/src/commands/intake-handoff.js +58 -0
  10. package/src/commands/intake-plan.js +2 -11
  11. package/src/commands/intake-record.js +2 -10
  12. package/src/commands/intake-resolve.js +2 -10
  13. package/src/commands/intake-scan.js +2 -10
  14. package/src/commands/intake-start.js +2 -10
  15. package/src/commands/intake-status.js +6 -10
  16. package/src/commands/intake-triage.js +2 -10
  17. package/src/commands/intake-workspace.js +58 -0
  18. package/src/commands/migrate.js +7 -3
  19. package/src/commands/multi.js +58 -2
  20. package/src/commands/run.js +29 -1
  21. package/src/commands/template-set.js +51 -2
  22. package/src/lib/adapter-interface.js +31 -0
  23. package/src/lib/coordinator-acceptance.js +24 -98
  24. package/src/lib/coordinator-barriers.js +116 -0
  25. package/src/lib/coordinator-config.js +124 -0
  26. package/src/lib/coordinator-dispatch.js +10 -1
  27. package/src/lib/coordinator-gates.js +28 -1
  28. package/src/lib/coordinator-recovery.js +133 -68
  29. package/src/lib/coordinator-state.js +74 -0
  30. package/src/lib/cross-repo-context.js +68 -1
  31. package/src/lib/governed-templates.js +60 -0
  32. package/src/lib/intake-handoff.js +58 -0
  33. package/src/lib/intake.js +300 -11
  34. package/src/lib/report.js +759 -27
  35. package/src/lib/workflow-gate-semantics.js +209 -0
  36. package/src/templates/governed/api-service.json +8 -1
  37. package/src/templates/governed/cli-tool.json +8 -1
  38. package/src/templates/governed/library.json +8 -1
  39. 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
- // ── Internal helpers ────────────────────────────────────────────────────────
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
- function getAcceptedReposForWorkstream(history, workstreamId, requiredRepos) {
427
- const accepted = new Set();
428
- for (const entry of history) {
429
- if (entry?.type === 'acceptance_projection' && entry.workstream_id === workstreamId) {
430
- if (requiredRepos.includes(entry.repo_id)) {
431
- accepted.add(entry.repo_id);
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
- function recomputeBarrierStatus(barrier, history, config) {
439
- switch (barrier.type) {
440
- case 'all_repos_accepted': {
441
- const required = new Set(barrier.required_repos);
442
- const satisfied = new Set();
443
- for (const entry of history) {
444
- if (entry?.type === 'acceptance_projection' && entry.workstream_id === barrier.workstream_id) {
445
- if (required.has(entry.repo_id)) {
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
- case 'ordered_repo_sequence': {
456
- const workstream = config.workstreams?.[barrier.workstream_id];
457
- if (!workstream) return barrier.status;
458
- const entryRepo = workstream.entry_repo;
459
- const hasUpstream = history.some(
460
- e => e?.type === 'acceptance_projection'
461
- && e.workstream_id === barrier.workstream_id
462
- && e.repo_id === entryRepo
463
- );
464
- if (hasUpstream) return 'satisfied';
465
- const anyDownstream = history.some(
466
- e => e?.type === 'acceptance_projection'
467
- && e.workstream_id === barrier.workstream_id
468
- && e.repo_id !== entryRepo
469
- );
470
- if (anyDownstream) return 'partially_satisfied';
471
- return 'pending';
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
- case 'interface_alignment': {
475
- const required = new Set(barrier.required_repos);
476
- const acceptedRepos = new Set();
477
- for (const entry of history) {
478
- if (entry?.type === 'acceptance_projection' && entry.workstream_id === barrier.workstream_id) {
479
- if (required.has(entry.repo_id)) acceptedRepos.add(entry.repo_id);
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
- if (acceptedRepos.size === 0) return 'pending';
483
- if (acceptedRepos.size < required.size) return 'partially_satisfied';
484
- return 'satisfied';
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
- case 'shared_human_gate':
488
- return barrier.status; // Never auto-transition
547
+ return {
548
+ ok: true,
549
+ state: resumedState,
550
+ resumed_status: resumedStatus,
551
+ blocked_reason: previousBlockedReason,
552
+ resync,
553
+ };
554
+ }
489
555
 
490
- default:
491
- return barrier.status;
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
- lines.push(`- ${barrier.barrier_id}: ${barrier.type} (${barrier.status})`);
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
+ }