agentxchain 2.155.26 → 2.155.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.155.26",
3
+ "version": "2.155.27",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,7 @@ import { findCurrentHumanEscalation } from '../lib/human-escalations.js';
23
23
  import { getDashboardPid, getDashboardSession } from './dashboard.js';
24
24
  import { readPreemptionMarker, validatePreemptionMarker, findPendingApprovedIntents } from '../lib/intake.js';
25
25
  import { readContinuousSession } from '../lib/continuous-run.js';
26
+ import { deriveRoadmapCandidates, detectRoadmapExhaustedVisionOpen, resolveVisionPath } from '../lib/vision-reader.js';
26
27
  import { readAllDispatchProgress } from '../lib/dispatch-progress.js';
27
28
  import { readCoordinatorWarnings } from '../lib/coordinator-warnings.js';
28
29
  import { reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
@@ -155,6 +156,7 @@ function renderGovernedStatus(context, opts) {
155
156
  const preemptionMarker = validatePreemptionMarker(root);
156
157
  const pendingIntents = findPendingApprovedIntents(root, { run_id: stateRunId || null });
157
158
  const continuousSession = readContinuousSession(root);
159
+ appendRoadmapOpenWorkNextAction(root, state, continuousSession, nextActions);
158
160
  const gateActionAttempt = state?.pending_phase_transition
159
161
  ? summarizeLatestGateActionAttempt(root, 'phase_transition', state.pending_phase_transition.gate)
160
162
  : state?.pending_run_completion
@@ -698,6 +700,41 @@ function renderGovernedStatus(context, opts) {
698
700
  console.log('');
699
701
  }
700
702
 
703
+ function appendRoadmapOpenWorkNextAction(root, state, continuousSession, nextActions) {
704
+ const status = state?.status || null;
705
+ const activeTurns = getActiveTurnCount(state);
706
+ const hasPendingGate = Boolean(state?.pending_phase_transition || state?.pending_run_completion);
707
+ const sessionTerminal = ['completed', 'idle_exit', 'vision_exhausted', 'vision_expansion_exhausted'].includes(continuousSession?.status);
708
+ const runTerminal = status === 'completed' || status === 'idle' || status === null;
709
+
710
+ if (activeTurns > 0 || hasPendingGate || (!runTerminal && !sessionTerminal)) {
711
+ return;
712
+ }
713
+
714
+ const roadmap = deriveRoadmapCandidates(root);
715
+ if (roadmap.ok && roadmap.candidates.length > 0) {
716
+ const candidate = roadmap.candidates[0];
717
+ const command = 'agentxchain run --continuous --vision .planning/VISION.md';
718
+ const reason = `Unchecked roadmap work remains (${candidate.section} line ${candidate.line}): ${candidate.goal}`;
719
+ if (!nextActions.some((action) => action.command === command && action.reason === reason)) {
720
+ nextActions.push({ command, reason, type: 'roadmap_open_work_detected' });
721
+ }
722
+ return;
723
+ }
724
+
725
+ // BUG-77: When roadmap has no unchecked work, check if roadmap is exhausted
726
+ // but VISION still has unplanned scope requiring PM roadmap-replenishment.
727
+ const visionPath = resolveVisionPath(root, continuousSession?.vision_path || '.planning/VISION.md');
728
+ const exhaustion = detectRoadmapExhaustedVisionOpen(root, visionPath);
729
+ if (exhaustion.open) {
730
+ const command = 'agentxchain run --continuous --vision .planning/VISION.md';
731
+ const reason = `Roadmap exhausted (${exhaustion.total_milestones} milestones checked through ${exhaustion.latest_milestone}). VISION.md has unplanned scope: ${exhaustion.unplanned_sections.join(', ')}`;
732
+ if (!nextActions.some((action) => action.type === 'roadmap_exhausted_vision_open')) {
733
+ nextActions.push({ command, reason, type: 'roadmap_exhausted_vision_open' });
734
+ }
735
+ }
736
+ }
737
+
701
738
  function renderConnectorHealthStatus(connectorHealth) {
702
739
  const connectors = Array.isArray(connectorHealth?.connectors)
703
740
  ? connectorHealth.connectors
@@ -15,6 +15,8 @@ import { randomUUID } from 'node:crypto';
15
15
  import {
16
16
  resolveVisionPath,
17
17
  deriveVisionCandidates,
18
+ deriveRoadmapCandidates,
19
+ detectRoadmapExhaustedVisionOpen,
18
20
  captureVisionHeadingsSnapshot,
19
21
  computeVisionContentSha,
20
22
  buildSourceManifest,
@@ -644,12 +646,139 @@ function reconcileContinuousStartupState(context, session, contOpts, log) {
644
646
  * @returns {{ ok: boolean, intentId?: string, section?: string, goal?: string, error?: string, idle?: boolean }}
645
647
  */
646
648
  export function seedFromVision(root, visionPath, options = {}) {
649
+ const roadmapResult = deriveRoadmapCandidates(root);
650
+ if (!roadmapResult.ok) {
651
+ return { ok: false, error: roadmapResult.error };
652
+ }
653
+
654
+ if (roadmapResult.candidates.length > 0) {
655
+ const candidate = roadmapResult.candidates[0];
656
+ const eventResult = recordEvent(root, {
657
+ source: 'vision_scan',
658
+ category: 'roadmap_open_work_detected',
659
+ signal: {
660
+ description: `${candidate.section}: ${candidate.goal}`,
661
+ roadmap_milestone: candidate.section,
662
+ roadmap_path: candidate.roadmap_path,
663
+ roadmap_line: candidate.line,
664
+ derived: true,
665
+ },
666
+ evidence: [
667
+ { type: 'file', value: `${candidate.roadmap_path}:${candidate.line}` },
668
+ { type: 'text', value: `Unchecked roadmap work: ${candidate.section} — ${candidate.goal}` },
669
+ ],
670
+ });
671
+
672
+ if (!eventResult.ok) {
673
+ if (eventResult.deduplicated) {
674
+ return { ok: true, idle: true };
675
+ }
676
+ return { ok: false, error: `intake record failed: ${eventResult.error}` };
677
+ }
678
+
679
+ if (eventResult.deduplicated) {
680
+ return { ok: true, idle: true };
681
+ }
682
+
683
+ const intentId = eventResult.intent.intent_id;
684
+ const triageResult = triageIntent(root, intentId, {
685
+ priority: candidate.priority,
686
+ template: 'generic',
687
+ charter: `[roadmap] ${candidate.section}: ${candidate.goal}`,
688
+ acceptance_contract: [
689
+ `Roadmap milestone addressed: ${candidate.section}`,
690
+ `Unchecked roadmap item completed: ${candidate.goal}`,
691
+ `Evidence source: ${candidate.roadmap_path}:${candidate.line}`,
692
+ ],
693
+ });
694
+
695
+ if (!triageResult.ok) {
696
+ return { ok: false, error: `triage failed: ${triageResult.error}` };
697
+ }
698
+
699
+ const triageApproval = options.triageApproval || 'auto';
700
+ if (triageApproval === 'auto') {
701
+ const approveResult = approveIntent(root, intentId, {
702
+ approver: 'continuous_loop',
703
+ reason: 'roadmap-open-work auto-approval',
704
+ });
705
+ if (!approveResult.ok) {
706
+ return { ok: false, error: `approve failed: ${approveResult.error}` };
707
+ }
708
+ }
709
+
710
+ return {
711
+ ok: true,
712
+ idle: false,
713
+ intentId,
714
+ section: candidate.section,
715
+ goal: candidate.goal,
716
+ source: candidate.source,
717
+ roadmap_path: candidate.roadmap_path,
718
+ roadmap_line: candidate.line,
719
+ };
720
+ }
721
+
647
722
  const result = deriveVisionCandidates(root, visionPath);
648
723
  if (!result.ok) {
649
724
  return { ok: false, error: result.error };
650
725
  }
651
726
 
652
727
  if (result.candidates.length === 0) {
728
+ // BUG-77: Before declaring idle, check if roadmap is exhausted but VISION
729
+ // has unplanned scope. If so, seed a PM roadmap-replenishment intent to
730
+ // derive the next bounded milestone instead of idle-exiting.
731
+ const exhaustion = detectRoadmapExhaustedVisionOpen(root, visionPath);
732
+ if (exhaustion.open) {
733
+ const sectionNames = exhaustion.unplanned_sections.join(', ');
734
+ const replenishmentEvent = recordEvent(root, {
735
+ source: 'vision_scan',
736
+ category: 'roadmap_exhausted_vision_open',
737
+ signal: {
738
+ description: `Roadmap exhausted (${exhaustion.total_milestones} milestones checked through ${exhaustion.latest_milestone}). VISION.md has unplanned scope: ${sectionNames}`,
739
+ unplanned_sections: exhaustion.unplanned_sections,
740
+ latest_milestone: exhaustion.latest_milestone,
741
+ derived: true,
742
+ },
743
+ evidence: [
744
+ { type: 'text', value: `All ${exhaustion.total_milestones} roadmap milestones checked. VISION sections not yet planned: ${sectionNames}` },
745
+ ],
746
+ });
747
+
748
+ if (replenishmentEvent.ok && !replenishmentEvent.deduplicated) {
749
+ const replenishmentIntentId = replenishmentEvent.intent.intent_id;
750
+ const triageResult = triageIntent(root, replenishmentIntentId, {
751
+ priority: 'p1',
752
+ template: 'generic',
753
+ charter: `[roadmap-replenishment] Derive next bounded roadmap increment from VISION.md. Unplanned scope: ${sectionNames}. Current roadmap checked through ${exhaustion.latest_milestone}. Read .planning/VISION.md and .planning/ROADMAP.md to select the next testable milestone. Produce concrete unchecked M${exhaustion.total_milestones + 1} items. Do not re-verify previous completed milestones.`,
754
+ acceptance_contract: [
755
+ `New unchecked milestone items added to .planning/ROADMAP.md`,
756
+ `Milestone scope derived from VISION.md sections: ${sectionNames}`,
757
+ `Milestone is bounded, testable, and does not duplicate existing checked milestones`,
758
+ ],
759
+ });
760
+
761
+ if (triageResult.ok) {
762
+ const triageApproval = options.triageApproval || 'auto';
763
+ if (triageApproval === 'auto') {
764
+ approveIntent(root, replenishmentIntentId, {
765
+ approver: 'continuous_loop',
766
+ reason: 'roadmap-replenishment auto-approval (BUG-77)',
767
+ });
768
+ }
769
+
770
+ return {
771
+ ok: true,
772
+ idle: false,
773
+ intentId: replenishmentIntentId,
774
+ section: 'Roadmap replenishment',
775
+ goal: `Derive next increment from: ${sectionNames}`,
776
+ source: 'roadmap_replenishment',
777
+ };
778
+ }
779
+ }
780
+ // If deduplicated or triage failed, fall through to idle
781
+ }
653
782
  return { ok: true, idle: true };
654
783
  }
655
784
 
@@ -714,6 +843,7 @@ export function seedFromVision(root, visionPath, options = {}) {
714
843
  intentId,
715
844
  section: candidate.section,
716
845
  goal: candidate.goal,
846
+ source: 'vision_scan',
717
847
  };
718
848
  }
719
849
 
@@ -1443,6 +1573,7 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
1443
1573
  : findNextDispatchableIntent(root, { run_id: session.current_run_id });
1444
1574
  let targetIntentId = null;
1445
1575
  let visionObjective = null;
1576
+ let seededSource = null;
1446
1577
 
1447
1578
  if (queued.ok) {
1448
1579
  targetIntentId = queued.intentId;
@@ -1478,13 +1609,24 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
1478
1609
 
1479
1610
  targetIntentId = seeded.intentId;
1480
1611
  visionObjective = `${seeded.section}: ${seeded.goal}`;
1612
+ seededSource = seeded.source || 'vision_scan';
1481
1613
  session.idle_cycles = 0;
1482
- log(`Vision-derived: ${visionObjective}`);
1614
+ if (seeded.source === 'roadmap_open_work') {
1615
+ log(`Roadmap-derived: ${visionObjective}`);
1616
+ } else if (seeded.source === 'roadmap_replenishment') {
1617
+ log(`Roadmap-replenishment (roadmap exhausted, vision open): ${visionObjective}`);
1618
+ } else {
1619
+ log(`Vision-derived: ${visionObjective}`);
1620
+ }
1483
1621
  }
1484
1622
 
1485
1623
  // Prepare intent through intake lifecycle
1486
1624
  const provenance = buildContinuousProvenance(targetIntentId, {
1487
- trigger: visionObjective ? 'vision_scan' : 'intake',
1625
+ trigger: visionObjective
1626
+ ? (seededSource === 'roadmap_open_work' ? 'roadmap_open_work'
1627
+ : seededSource === 'roadmap_replenishment' ? 'roadmap_replenishment'
1628
+ : 'vision_scan')
1629
+ : 'intake',
1488
1630
  triggerReason: visionObjective || readIntent(root, targetIntentId)?.charter || null,
1489
1631
  });
1490
1632
  const preparedIntent = prepareIntentForDispatch(root, targetIntentId, {
@@ -1519,7 +1661,9 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
1519
1661
  next_objective: nextObjective,
1520
1662
  next_intent_id: targetIntentId,
1521
1663
  runs_completed: session.runs_completed || 0,
1522
- trigger: visionObjective ? 'vision_scan' : 'intake',
1664
+ trigger: visionObjective
1665
+ ? (seededSource === 'roadmap_open_work' ? 'roadmap_open_work' : 'vision_scan')
1666
+ : 'intake',
1523
1667
  },
1524
1668
  });
1525
1669
  }
@@ -217,6 +217,68 @@ export function deriveVisionCandidates(root, visionPath) {
217
217
  return { ok: true, candidates };
218
218
  }
219
219
 
220
+ /**
221
+ * Derive concrete candidates from unchecked roadmap milestones.
222
+ *
223
+ * This is intentionally narrower than VISION.md derivation: a roadmap item is
224
+ * already PM-shaped product scope, so continuous mode must treat it as work
225
+ * before falling back to broad vision inference.
226
+ *
227
+ * @param {string} root - Project root
228
+ * @param {string} [roadmapPath] - Project-relative roadmap path
229
+ * @returns {{ ok: boolean, candidates: Array<{ section: string, goal: string, priority: string, source: string, roadmap_path: string, line: number }>, error?: string }}
230
+ */
231
+ export function deriveRoadmapCandidates(root, roadmapPath = '.planning/ROADMAP.md') {
232
+ const absPath = isAbsolute(roadmapPath) ? roadmapPath : pathResolve(root, roadmapPath);
233
+ if (!existsSync(absPath)) {
234
+ return { ok: true, candidates: [] };
235
+ }
236
+
237
+ let content;
238
+ try {
239
+ content = readFileSync(absPath, 'utf8');
240
+ } catch (err) {
241
+ return { ok: false, candidates: [], error: `Cannot read ROADMAP.md: ${err.message}` };
242
+ }
243
+
244
+ const activeSignals = loadActiveIntentSignals(root);
245
+ const completedSignals = loadCompletedIntentSignals(root);
246
+ const allSignals = [...activeSignals, ...completedSignals];
247
+
248
+ const candidates = [];
249
+ let currentMilestone = null;
250
+ const lines = content.split('\n');
251
+
252
+ for (let i = 0; i < lines.length; i += 1) {
253
+ const line = lines[i];
254
+ const headingMatch = line.match(/^#{2,6}\s+(M\d+\b.*)$/i);
255
+ if (headingMatch) {
256
+ currentMilestone = headingMatch[1].trim();
257
+ continue;
258
+ }
259
+
260
+ const uncheckedMatch = line.match(/^\s*[-*]\s+\[\s\]\s+(.+?)\s*$/);
261
+ if (!uncheckedMatch || !currentMilestone) continue;
262
+
263
+ const goal = uncheckedMatch[1].trim();
264
+ const combinedGoal = `${currentMilestone}: ${goal}`;
265
+ if (isGoalAddressed(combinedGoal, allSignals) || isGoalAddressed(goal, allSignals)) {
266
+ continue;
267
+ }
268
+
269
+ candidates.push({
270
+ section: currentMilestone,
271
+ goal,
272
+ priority: 'p1',
273
+ source: 'roadmap_open_work',
274
+ roadmap_path: roadmapPath,
275
+ line: i + 1,
276
+ });
277
+ }
278
+
279
+ return { ok: true, candidates };
280
+ }
281
+
220
282
  // ---------------------------------------------------------------------------
221
283
  // Vision snapshot capture (BUG-60 Slice 3)
222
284
  // ---------------------------------------------------------------------------
@@ -380,6 +442,128 @@ function truncatePreview(content, capBytes) {
380
442
  return head + marker + tail;
381
443
  }
382
444
 
445
+ /**
446
+ * Detect whether the roadmap is exhausted but VISION.md still has unplanned scope.
447
+ *
448
+ * BUG-77: When all ROADMAP.md milestones are checked and no M<n+1> exists,
449
+ * but VISION.md has sections with goals that are NOT mapped to any roadmap
450
+ * milestone, continuous mode should dispatch PM to derive the next bounded
451
+ * roadmap increment — not declare idle_exit or vision_exhausted.
452
+ *
453
+ * @param {string} root - Project root
454
+ * @param {string} visionPath - Absolute path to VISION.md
455
+ * @param {string} [roadmapPath] - Project-relative roadmap path
456
+ * @returns {{ open: boolean, reason: string, unplanned_sections?: string[], mapped_sections?: string[], total_milestones?: number, latest_milestone?: string, evidence_map?: Array<{ milestone: string, status: string }> }}
457
+ */
458
+ export function detectRoadmapExhaustedVisionOpen(root, visionPath, roadmapPath = '.planning/ROADMAP.md') {
459
+ const absRoadmap = isAbsolute(roadmapPath) ? roadmapPath : pathResolve(root, roadmapPath);
460
+ const absVision = isAbsolute(visionPath) ? visionPath : pathResolve(root, visionPath);
461
+
462
+ // If no roadmap, cannot be "exhausted"
463
+ if (!existsSync(absRoadmap)) {
464
+ return { open: false, reason: 'no_roadmap' };
465
+ }
466
+
467
+ let roadmapContent;
468
+ try {
469
+ roadmapContent = readFileSync(absRoadmap, 'utf8');
470
+ } catch {
471
+ return { open: false, reason: 'roadmap_unreadable' };
472
+ }
473
+
474
+ // Parse milestone headings and check for unchecked items
475
+ const milestoneHeadings = [];
476
+ let currentMilestone = null;
477
+ let hasUnchecked = false;
478
+
479
+ for (const line of roadmapContent.split('\n')) {
480
+ const hm = line.match(/^#{2,6}\s+(M\d+\b.*)$/i);
481
+ if (hm) {
482
+ currentMilestone = hm[1].trim();
483
+ milestoneHeadings.push(currentMilestone);
484
+ continue;
485
+ }
486
+ if (currentMilestone && /^\s*[-*]\s+\[\s\]/.test(line)) {
487
+ hasUnchecked = true;
488
+ }
489
+ }
490
+
491
+ // Not exhausted if unchecked work exists (BUG-76 handles this)
492
+ if (hasUnchecked) {
493
+ return { open: false, reason: 'has_unchecked' };
494
+ }
495
+ // No milestones at all — not a milestone-based roadmap
496
+ if (milestoneHeadings.length === 0) {
497
+ return { open: false, reason: 'no_milestones' };
498
+ }
499
+
500
+ // Roadmap IS exhausted (all milestones checked, none unchecked). Check VISION.
501
+ if (!existsSync(absVision)) {
502
+ return {
503
+ open: false,
504
+ reason: 'no_vision',
505
+ evidence_map: milestoneHeadings.map(m => ({ milestone: m, status: 'completed' })),
506
+ };
507
+ }
508
+
509
+ let visionContent;
510
+ try {
511
+ visionContent = readFileSync(absVision, 'utf8');
512
+ } catch {
513
+ return { open: false, reason: 'vision_unreadable' };
514
+ }
515
+
516
+ const { sections } = parseVisionDocument(visionContent);
517
+ const sectionsWithGoals = sections.filter(s => s.goals.length > 0);
518
+
519
+ if (sectionsWithGoals.length === 0) {
520
+ return {
521
+ open: false,
522
+ reason: 'vision_no_actionable_scope',
523
+ evidence_map: milestoneHeadings.map(m => ({ milestone: m, status: 'completed' })),
524
+ };
525
+ }
526
+
527
+ // Check which VISION sections are NOT mapped to any roadmap milestone heading
528
+ const unplanned = [];
529
+ const mapped = [];
530
+
531
+ for (const section of sectionsWithGoals) {
532
+ const sectionWords = extractSignificantWords(section.heading);
533
+ const isMapped = milestoneHeadings.some(m => {
534
+ const milestoneWords = extractSignificantWords(m);
535
+ if (sectionWords.length === 0 || milestoneWords.length === 0) return false;
536
+ const overlap = sectionWords.filter(w =>
537
+ milestoneWords.some(mw => mw.includes(w) || w.includes(mw)),
538
+ ).length;
539
+ return overlap / sectionWords.length >= 0.4;
540
+ });
541
+ if (isMapped) {
542
+ mapped.push(section.heading);
543
+ } else {
544
+ unplanned.push(section.heading);
545
+ }
546
+ }
547
+
548
+ if (unplanned.length > 0) {
549
+ return {
550
+ open: true,
551
+ reason: 'roadmap_exhausted_vision_open',
552
+ unplanned_sections: unplanned,
553
+ mapped_sections: mapped,
554
+ total_milestones: milestoneHeadings.length,
555
+ latest_milestone: milestoneHeadings[milestoneHeadings.length - 1],
556
+ };
557
+ }
558
+
559
+ return {
560
+ open: false,
561
+ reason: 'vision_fully_mapped',
562
+ evidence_map: milestoneHeadings.map(m => ({ milestone: m, status: 'completed' })),
563
+ mapped_sections: mapped,
564
+ };
565
+ }
566
+
383
567
  /**
384
568
  * Resolve a vision path relative to the project root.
385
569
  *