agentxchain 2.155.26 → 2.155.28

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.28",
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,
@@ -29,7 +31,7 @@ import {
29
31
  resolveIntent,
30
32
  buildVisionIdleExpansionSignal,
31
33
  } from './intake.js';
32
- import { loadProjectState } from './config.js';
34
+ import { loadProjectContext, loadProjectState } from './config.js';
33
35
  import { safeWriteJson } from './safe-write.js';
34
36
  import { emitRunEvent } from './run-events.js';
35
37
  import { reissueTurn } from './governed-state.js';
@@ -52,6 +54,15 @@ import {
52
54
 
53
55
  const CONTINUOUS_SESSION_PATH = '.agentxchain/continuous-session.json';
54
56
 
57
+ function getRoadmapReplenishmentTriageHints(root) {
58
+ const context = loadProjectContext(root);
59
+ const config = context?.config || null;
60
+ return {
61
+ preferred_role: config?.roles?.pm ? 'pm' : null,
62
+ phase_scope: config?.routing?.planning ? 'planning' : null,
63
+ };
64
+ }
65
+
55
66
  // ---------------------------------------------------------------------------
56
67
  // Session state
57
68
  // ---------------------------------------------------------------------------
@@ -644,12 +655,142 @@ function reconcileContinuousStartupState(context, session, contOpts, log) {
644
655
  * @returns {{ ok: boolean, intentId?: string, section?: string, goal?: string, error?: string, idle?: boolean }}
645
656
  */
646
657
  export function seedFromVision(root, visionPath, options = {}) {
658
+ const roadmapResult = deriveRoadmapCandidates(root);
659
+ if (!roadmapResult.ok) {
660
+ return { ok: false, error: roadmapResult.error };
661
+ }
662
+
663
+ if (roadmapResult.candidates.length > 0) {
664
+ const candidate = roadmapResult.candidates[0];
665
+ const eventResult = recordEvent(root, {
666
+ source: 'vision_scan',
667
+ category: 'roadmap_open_work_detected',
668
+ signal: {
669
+ description: `${candidate.section}: ${candidate.goal}`,
670
+ roadmap_milestone: candidate.section,
671
+ roadmap_path: candidate.roadmap_path,
672
+ roadmap_line: candidate.line,
673
+ derived: true,
674
+ },
675
+ evidence: [
676
+ { type: 'file', value: `${candidate.roadmap_path}:${candidate.line}` },
677
+ { type: 'text', value: `Unchecked roadmap work: ${candidate.section} — ${candidate.goal}` },
678
+ ],
679
+ });
680
+
681
+ if (!eventResult.ok) {
682
+ if (eventResult.deduplicated) {
683
+ return { ok: true, idle: true };
684
+ }
685
+ return { ok: false, error: `intake record failed: ${eventResult.error}` };
686
+ }
687
+
688
+ if (eventResult.deduplicated) {
689
+ return { ok: true, idle: true };
690
+ }
691
+
692
+ const intentId = eventResult.intent.intent_id;
693
+ const triageResult = triageIntent(root, intentId, {
694
+ priority: candidate.priority,
695
+ template: 'generic',
696
+ charter: `[roadmap] ${candidate.section}: ${candidate.goal}`,
697
+ acceptance_contract: [
698
+ `Roadmap milestone addressed: ${candidate.section}`,
699
+ `Unchecked roadmap item completed: ${candidate.goal}`,
700
+ `Evidence source: ${candidate.roadmap_path}:${candidate.line}`,
701
+ ],
702
+ });
703
+
704
+ if (!triageResult.ok) {
705
+ return { ok: false, error: `triage failed: ${triageResult.error}` };
706
+ }
707
+
708
+ const triageApproval = options.triageApproval || 'auto';
709
+ if (triageApproval === 'auto') {
710
+ const approveResult = approveIntent(root, intentId, {
711
+ approver: 'continuous_loop',
712
+ reason: 'roadmap-open-work auto-approval',
713
+ });
714
+ if (!approveResult.ok) {
715
+ return { ok: false, error: `approve failed: ${approveResult.error}` };
716
+ }
717
+ }
718
+
719
+ return {
720
+ ok: true,
721
+ idle: false,
722
+ intentId,
723
+ section: candidate.section,
724
+ goal: candidate.goal,
725
+ source: candidate.source,
726
+ roadmap_path: candidate.roadmap_path,
727
+ roadmap_line: candidate.line,
728
+ };
729
+ }
730
+
647
731
  const result = deriveVisionCandidates(root, visionPath);
648
732
  if (!result.ok) {
649
733
  return { ok: false, error: result.error };
650
734
  }
651
735
 
652
736
  if (result.candidates.length === 0) {
737
+ // BUG-77: Before declaring idle, check if roadmap is exhausted but VISION
738
+ // has unplanned scope. If so, seed a PM roadmap-replenishment intent to
739
+ // derive the next bounded milestone instead of idle-exiting.
740
+ const exhaustion = detectRoadmapExhaustedVisionOpen(root, visionPath);
741
+ if (exhaustion.open) {
742
+ const sectionNames = exhaustion.unplanned_sections.join(', ');
743
+ const replenishmentEvent = recordEvent(root, {
744
+ source: 'vision_scan',
745
+ category: 'roadmap_exhausted_vision_open',
746
+ signal: {
747
+ description: `Roadmap exhausted (${exhaustion.total_milestones} milestones checked through ${exhaustion.latest_milestone}). VISION.md has unplanned scope: ${sectionNames}`,
748
+ unplanned_sections: exhaustion.unplanned_sections,
749
+ latest_milestone: exhaustion.latest_milestone,
750
+ derived: true,
751
+ },
752
+ evidence: [
753
+ { type: 'text', value: `All ${exhaustion.total_milestones} roadmap milestones checked. VISION sections not yet planned: ${sectionNames}` },
754
+ ],
755
+ });
756
+
757
+ if (replenishmentEvent.ok && !replenishmentEvent.deduplicated) {
758
+ const replenishmentIntentId = replenishmentEvent.intent.intent_id;
759
+ const replenishmentHints = getRoadmapReplenishmentTriageHints(root);
760
+ const triageResult = triageIntent(root, replenishmentIntentId, {
761
+ priority: 'p1',
762
+ template: 'generic',
763
+ ...(replenishmentHints.preferred_role ? { preferred_role: replenishmentHints.preferred_role } : {}),
764
+ ...(replenishmentHints.phase_scope ? { phase_scope: replenishmentHints.phase_scope } : {}),
765
+ 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.`,
766
+ acceptance_contract: [
767
+ `New unchecked milestone items added to .planning/ROADMAP.md`,
768
+ `Milestone scope derived from VISION.md sections: ${sectionNames}`,
769
+ `Milestone is bounded, testable, and does not duplicate existing checked milestones`,
770
+ ],
771
+ });
772
+
773
+ if (triageResult.ok) {
774
+ const triageApproval = options.triageApproval || 'auto';
775
+ if (triageApproval === 'auto') {
776
+ approveIntent(root, replenishmentIntentId, {
777
+ approver: 'continuous_loop',
778
+ reason: 'roadmap-replenishment auto-approval (BUG-77)',
779
+ });
780
+ }
781
+
782
+ return {
783
+ ok: true,
784
+ idle: false,
785
+ intentId: replenishmentIntentId,
786
+ section: 'Roadmap replenishment',
787
+ goal: `Derive next increment from: ${sectionNames}`,
788
+ source: 'roadmap_replenishment',
789
+ };
790
+ }
791
+ }
792
+ // If deduplicated or triage failed, fall through to idle
793
+ }
653
794
  return { ok: true, idle: true };
654
795
  }
655
796
 
@@ -714,6 +855,7 @@ export function seedFromVision(root, visionPath, options = {}) {
714
855
  intentId,
715
856
  section: candidate.section,
716
857
  goal: candidate.goal,
858
+ source: 'vision_scan',
717
859
  };
718
860
  }
719
861
 
@@ -1443,6 +1585,7 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
1443
1585
  : findNextDispatchableIntent(root, { run_id: session.current_run_id });
1444
1586
  let targetIntentId = null;
1445
1587
  let visionObjective = null;
1588
+ let seededSource = null;
1446
1589
 
1447
1590
  if (queued.ok) {
1448
1591
  targetIntentId = queued.intentId;
@@ -1478,13 +1621,24 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
1478
1621
 
1479
1622
  targetIntentId = seeded.intentId;
1480
1623
  visionObjective = `${seeded.section}: ${seeded.goal}`;
1624
+ seededSource = seeded.source || 'vision_scan';
1481
1625
  session.idle_cycles = 0;
1482
- log(`Vision-derived: ${visionObjective}`);
1626
+ if (seeded.source === 'roadmap_open_work') {
1627
+ log(`Roadmap-derived: ${visionObjective}`);
1628
+ } else if (seeded.source === 'roadmap_replenishment') {
1629
+ log(`Roadmap-replenishment (roadmap exhausted, vision open): ${visionObjective}`);
1630
+ } else {
1631
+ log(`Vision-derived: ${visionObjective}`);
1632
+ }
1483
1633
  }
1484
1634
 
1485
1635
  // Prepare intent through intake lifecycle
1486
1636
  const provenance = buildContinuousProvenance(targetIntentId, {
1487
- trigger: visionObjective ? 'vision_scan' : 'intake',
1637
+ trigger: visionObjective
1638
+ ? (seededSource === 'roadmap_open_work' ? 'roadmap_open_work'
1639
+ : seededSource === 'roadmap_replenishment' ? 'roadmap_replenishment'
1640
+ : 'vision_scan')
1641
+ : 'intake',
1488
1642
  triggerReason: visionObjective || readIntent(root, targetIntentId)?.charter || null,
1489
1643
  });
1490
1644
  const preparedIntent = prepareIntentForDispatch(root, targetIntentId, {
@@ -1519,7 +1673,9 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
1519
1673
  next_objective: nextObjective,
1520
1674
  next_intent_id: targetIntentId,
1521
1675
  runs_completed: session.runs_completed || 0,
1522
- trigger: visionObjective ? 'vision_scan' : 'intake',
1676
+ trigger: visionObjective
1677
+ ? (seededSource === 'roadmap_open_work' ? 'roadmap_open_work' : 'vision_scan')
1678
+ : 'intake',
1523
1679
  },
1524
1680
  });
1525
1681
  }
@@ -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
  *