agentxchain 2.115.0 → 2.117.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/src/lib/intake.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
2
2
  import { join, basename, resolve as pathResolve } from 'node:path';
3
3
  import { createHash, randomBytes } from 'node:crypto';
4
4
  import { safeWriteJson } from './safe-write.js';
@@ -18,7 +18,7 @@ import { loadCoordinatorConfig } from './coordinator-config.js';
18
18
  import { loadCoordinatorState, readBarriers } from './coordinator-state.js';
19
19
  import { writeCoordinatorHandoff } from './intake-handoff.js';
20
20
 
21
- const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule'];
21
+ const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule', 'vision_scan'];
22
22
  const VALID_PRIORITIES = ['p0', 'p1', 'p2', 'p3'];
23
23
  const EVENT_ID_RE = /^evt_\d+_[0-9a-f]{4}$/;
24
24
  const INTENT_ID_RE = /^intent_\d+_[0-9a-f]{4}$/;
@@ -680,6 +680,18 @@ export function startIntent(root, intentId, options = {}) {
680
680
  return { ok: false, error: 'Failed to parse governed state.json', exitCode: 2 };
681
681
  }
682
682
 
683
+ const allowCompletedRestart = options.allowTerminalRestart === true
684
+ && state.status === 'completed'
685
+ && getActiveTurnCount(state) === 0;
686
+
687
+ const startProvenance = {
688
+ trigger: 'intake',
689
+ intake_intent_id: intent.intent_id,
690
+ trigger_reason: intent.charter || null,
691
+ created_by: 'operator',
692
+ ...(options.provenance && typeof options.provenance === 'object' ? options.provenance : {}),
693
+ };
694
+
683
695
  // Check busy-run conditions
684
696
  const activeTurns = getActiveTurns(state);
685
697
  const activeCount = getActiveTurnCount(state);
@@ -698,7 +710,7 @@ export function startIntent(root, intentId, options = {}) {
698
710
  return { ok: false, error: `cannot start: run is blocked (${reason})`, exitCode: 1 };
699
711
  }
700
712
 
701
- if (state.status === 'completed') {
713
+ if (state.status === 'completed' && !allowCompletedRestart) {
702
714
  return {
703
715
  ok: false,
704
716
  error: 'cannot start: governed run is already completed. S3 does not reopen completed runs.',
@@ -719,14 +731,10 @@ export function startIntent(root, intentId, options = {}) {
719
731
  }
720
732
 
721
733
  // Bootstrap: idle with no run → initialize
722
- if (state.status === 'idle' && !state.run_id) {
734
+ if ((state.status === 'idle' && !state.run_id) || allowCompletedRestart) {
723
735
  const initResult = initializeGovernedRun(root, config, {
724
- provenance: {
725
- trigger: 'intake',
726
- intake_intent_id: intent.intent_id,
727
- trigger_reason: intent.charter || null,
728
- created_by: 'operator',
729
- },
736
+ provenance: startProvenance,
737
+ allow_terminal_restart: allowCompletedRestart,
730
738
  });
731
739
  if (!initResult.ok) {
732
740
  return { ok: false, error: `run initialization failed: ${initResult.error}`, exitCode: 1 };
@@ -1370,8 +1378,232 @@ export function scanSource(root, source, snapshot) {
1370
1378
  };
1371
1379
  }
1372
1380
 
1381
+ // ---------------------------------------------------------------------------
1382
+ // Inject — composed record + triage + approve in one operation
1383
+ // ---------------------------------------------------------------------------
1384
+
1385
+ const PREEMPTION_MARKER_PATH = '.agentxchain/intake/injected-priority.json';
1386
+
1387
+ function preemptionMarkerPath(root) {
1388
+ return join(root, PREEMPTION_MARKER_PATH);
1389
+ }
1390
+
1391
+ export function readPreemptionMarker(root) {
1392
+ const p = preemptionMarkerPath(root);
1393
+ if (!existsSync(p)) return null;
1394
+ try {
1395
+ return JSON.parse(readFileSync(p, 'utf8'));
1396
+ } catch {
1397
+ return null;
1398
+ }
1399
+ }
1400
+
1401
+ export function clearPreemptionMarker(root) {
1402
+ const p = preemptionMarkerPath(root);
1403
+ if (existsSync(p)) {
1404
+ try {
1405
+ unlinkSync(p);
1406
+ } catch {
1407
+ // best-effort
1408
+ }
1409
+ }
1410
+ }
1411
+
1412
+ export function consumePreemptionMarker(root, options = {}) {
1413
+ const marker = readPreemptionMarker(root);
1414
+ if (!marker?.intent_id) {
1415
+ return { ok: false, error: 'no preemption marker found', exitCode: 1 };
1416
+ }
1417
+
1418
+ const loadedIntent = readIntent(root, marker.intent_id);
1419
+ if (!loadedIntent.ok) {
1420
+ return {
1421
+ ok: false,
1422
+ error: `preemption marker references missing intent ${marker.intent_id}`,
1423
+ marker,
1424
+ exitCode: 1,
1425
+ };
1426
+ }
1427
+
1428
+ const startingStatus = loadedIntent.intent.status;
1429
+ let intent = loadedIntent.intent;
1430
+ let planned = false;
1431
+ let started = false;
1432
+
1433
+ if (intent.status === 'approved') {
1434
+ const plannedResult = planIntent(root, intent.intent_id, {
1435
+ projectName: options.projectName,
1436
+ force: options.forcePlan === true,
1437
+ });
1438
+ if (!plannedResult.ok) {
1439
+ return {
1440
+ ok: false,
1441
+ error: `failed to plan injected intent ${intent.intent_id}: ${plannedResult.error}`,
1442
+ marker,
1443
+ intent_status: intent.status,
1444
+ exitCode: plannedResult.exitCode || 1,
1445
+ };
1446
+ }
1447
+ intent = plannedResult.intent;
1448
+ planned = true;
1449
+ }
1450
+
1451
+ if (intent.status === 'planned') {
1452
+ const startResult = startIntent(root, intent.intent_id, {
1453
+ role: options.role || undefined,
1454
+ });
1455
+ if (!startResult.ok) {
1456
+ return {
1457
+ ok: false,
1458
+ error: `failed to start injected intent ${intent.intent_id}: ${startResult.error}`,
1459
+ marker,
1460
+ intent_status: intent.status,
1461
+ exitCode: startResult.exitCode || 1,
1462
+ };
1463
+ }
1464
+ clearPreemptionMarker(root);
1465
+ return {
1466
+ ok: true,
1467
+ marker,
1468
+ intent_id: intent.intent_id,
1469
+ starting_status: startingStatus,
1470
+ final_status: startResult.intent.status,
1471
+ planned,
1472
+ started: true,
1473
+ run_id: startResult.run_id,
1474
+ turn_id: startResult.turn_id,
1475
+ role: startResult.role,
1476
+ intent: startResult.intent,
1477
+ exitCode: 0,
1478
+ };
1479
+ }
1480
+
1481
+ if (intent.status === 'executing') {
1482
+ clearPreemptionMarker(root);
1483
+ return {
1484
+ ok: true,
1485
+ marker,
1486
+ intent_id: intent.intent_id,
1487
+ starting_status: startingStatus,
1488
+ final_status: intent.status,
1489
+ planned,
1490
+ started: false,
1491
+ run_id: intent.target_run || null,
1492
+ turn_id: intent.target_turn || null,
1493
+ role: options.role || null,
1494
+ intent,
1495
+ exitCode: 0,
1496
+ };
1497
+ }
1498
+
1499
+ return {
1500
+ ok: false,
1501
+ error: `cannot consume preemption marker from intent status "${intent.status}"`,
1502
+ marker,
1503
+ intent_id: intent.intent_id,
1504
+ intent_status: intent.status,
1505
+ exitCode: 1,
1506
+ };
1507
+ }
1508
+
1509
+ export function injectIntent(root, description, options = {}) {
1510
+ if (!description || typeof description !== 'string' || !description.trim()) {
1511
+ return { ok: false, error: 'description is required', exitCode: 1 };
1512
+ }
1513
+
1514
+ const priority = options.priority || 'p0';
1515
+ if (!VALID_PRIORITIES.includes(priority)) {
1516
+ return { ok: false, error: `priority must be one of: ${VALID_PRIORITIES.join(', ')}`, exitCode: 1 };
1517
+ }
1518
+
1519
+ const template = options.template || 'generic';
1520
+ if (!VALID_GOVERNED_TEMPLATE_IDS.includes(template)) {
1521
+ return { ok: false, error: `template must be one of: ${VALID_GOVERNED_TEMPLATE_IDS.join(', ')}`, exitCode: 1 };
1522
+ }
1523
+
1524
+ const charter = options.charter || description.trim();
1525
+ const acceptance_contract = options.acceptance
1526
+ ? options.acceptance.split(',').map(s => s.trim()).filter(Boolean)
1527
+ : [description.trim()];
1528
+ const approver = options.approver || 'human';
1529
+ const noApprove = options.noApprove === true;
1530
+
1531
+ // Step 1: Record event
1532
+ const recordResult = recordEvent(root, {
1533
+ source: 'manual',
1534
+ category: 'operator_injection',
1535
+ signal: { description: description.trim(), injected: true, priority },
1536
+ evidence: [{ type: 'text', value: description.trim() }],
1537
+ });
1538
+
1539
+ if (!recordResult.ok) {
1540
+ return recordResult;
1541
+ }
1542
+
1543
+ // If deduplicated, return existing intent
1544
+ if (recordResult.deduplicated) {
1545
+ return {
1546
+ ok: true,
1547
+ intent: recordResult.intent,
1548
+ event: recordResult.event,
1549
+ deduplicated: true,
1550
+ preemption_marker: false,
1551
+ exitCode: 0,
1552
+ };
1553
+ }
1554
+
1555
+ const intentId = recordResult.intent.intent_id;
1556
+
1557
+ // Step 2: Triage
1558
+ const triageResult = triageIntent(root, intentId, {
1559
+ priority,
1560
+ template,
1561
+ charter,
1562
+ acceptance_contract,
1563
+ });
1564
+
1565
+ if (!triageResult.ok) {
1566
+ return { ok: false, error: `triage failed: ${triageResult.error}`, exitCode: 1 };
1567
+ }
1568
+
1569
+ // Step 3: Approve (unless --no-approve)
1570
+ if (!noApprove) {
1571
+ const approveResult = approveIntent(root, intentId, { approver, reason: 'operator injection' });
1572
+ if (!approveResult.ok) {
1573
+ return { ok: false, error: `approve failed: ${approveResult.error}`, exitCode: 1 };
1574
+ }
1575
+ }
1576
+
1577
+ // Step 4: Write preemption marker for p0
1578
+ let preemptionMarker = false;
1579
+ if (priority === 'p0' && !noApprove) {
1580
+ const marker = {
1581
+ intent_id: intentId,
1582
+ priority,
1583
+ description: description.trim(),
1584
+ injected_at: nowISO(),
1585
+ };
1586
+ ensureIntakeDirs(root);
1587
+ safeWriteJson(preemptionMarkerPath(root), marker);
1588
+ preemptionMarker = true;
1589
+ }
1590
+
1591
+ // Re-read final intent state
1592
+ const finalRead = readIntent(root, intentId);
1593
+ const finalIntent = finalRead.ok ? finalRead.intent : triageResult.intent;
1594
+
1595
+ return {
1596
+ ok: true,
1597
+ intent: finalIntent,
1598
+ event: recordResult.event,
1599
+ deduplicated: false,
1600
+ preemption_marker: preemptionMarker,
1601
+ exitCode: 0,
1602
+ };
1603
+ }
1604
+
1373
1605
  // ---------------------------------------------------------------------------
1374
1606
  // Exports for testing
1375
1607
  // ---------------------------------------------------------------------------
1376
1608
 
1377
- export { VALID_SOURCES, VALID_PRIORITIES, VALID_TRANSITIONS, S1_STATES, TERMINAL_STATES, SCAN_SOURCES };
1609
+ export { VALID_SOURCES, VALID_PRIORITIES, VALID_TRANSITIONS, S1_STATES, TERMINAL_STATES, SCAN_SOURCES, PREEMPTION_MARKER_PATH };
@@ -390,8 +390,12 @@ export function launchWorkstream(root, missionId, planId, workstreamId, options
390
390
  if (!plan) {
391
391
  return { ok: false, error: `Plan not found: ${planId}` };
392
392
  }
393
- if (plan.status !== 'approved') {
394
- return { ok: false, error: `Plan ${planId} is not approved (status: "${plan.status}"). Approve the plan before launching workstreams.` };
393
+ const allowNeedsAttention = options.allowNeedsAttention === true;
394
+ if (plan.status !== 'approved' && !(allowNeedsAttention && plan.status === 'needs_attention')) {
395
+ return {
396
+ ok: false,
397
+ error: `Plan ${planId} is not approved (status: "${plan.status}"). Approve the plan before launching workstreams.`,
398
+ };
395
399
  }
396
400
 
397
401
  const ws = plan.workstreams.find((w) => w.workstream_id === workstreamId);
@@ -10,6 +10,8 @@ export const VALID_NOTIFICATION_EVENTS = [
10
10
  'run_blocked',
11
11
  'operator_escalation_raised',
12
12
  'escalation_resolved',
13
+ 'human_escalation_raised',
14
+ 'human_escalation_resolved',
13
15
  'phase_transition_pending',
14
16
  'run_completion_pending',
15
17
  'run_completed',
@@ -170,7 +172,7 @@ export function validateNotificationsConfig(notifications) {
170
172
  return { ok: false, errors, warnings: [] };
171
173
  }
172
174
 
173
- const allowedKeys = new Set(['webhooks', 'approval_sla']);
175
+ const allowedKeys = new Set(['webhooks', 'approval_sla', 'local']);
174
176
  for (const key of Object.keys(notifications)) {
175
177
  if (!allowedKeys.has(key)) {
176
178
  errors.push(`notifications contains unknown field "${key}"`);
@@ -26,6 +26,8 @@ export const VALID_RUN_EVENTS = [
26
26
  'gate_approved',
27
27
  'gate_failed',
28
28
  'budget_exceeded_warn',
29
+ 'human_escalation_raised',
30
+ 'human_escalation_resolved',
29
31
  ];
30
32
 
31
33
  /**
@@ -36,6 +36,7 @@ import { runAdmissionControl } from './admission-control.js';
36
36
  import { mkdirSync, writeFileSync } from 'fs';
37
37
  import { join, dirname } from 'path';
38
38
  import { evaluateApprovalSlaReminders } from './notification-runner.js';
39
+ import { readPreemptionMarker } from './intake.js';
39
40
 
40
41
  const DEFAULT_MAX_TURNS = 50;
41
42
 
@@ -128,6 +129,22 @@ export async function runLoop(root, config, callbacks, options = {}) {
128
129
  return makeResult(false, 'max_turns_reached', state, turnsExecuted, turnHistory, gatesApproved, errors);
129
130
  }
130
131
 
132
+ // ── Priority preemption check ────────────────────────────────────────
133
+ // If a p0 intent was injected via `agentxchain inject`, yield the run
134
+ // so the scheduler/continuous loop can pick up the injected work.
135
+ // Only preempt when no turns are currently active (avoid mid-dispatch
136
+ // interruption).
137
+ const activeTurnCount = getActiveTurnCount(state);
138
+ if (activeTurnCount === 0) {
139
+ const marker = readPreemptionMarker(root);
140
+ if (marker && marker.priority === 'p0') {
141
+ emit({ type: 'priority_injected', intent_id: marker.intent_id, priority: marker.priority });
142
+ const result = makeResult(false, 'priority_preempted', state, turnsExecuted, turnHistory, gatesApproved, errors);
143
+ result.preempted_by = marker.intent_id;
144
+ return result;
145
+ }
146
+ }
147
+
131
148
  // ── Determine concurrency mode ────────────────────────────────────────
132
149
  const maxConcurrent = getMaxConcurrentTurns(config, state.phase);
133
150
 
@@ -3,12 +3,14 @@ const VALID_TRIGGERS = new Set([
3
3
  'continuation',
4
4
  'recovery',
5
5
  'intake',
6
+ 'vision_scan',
6
7
  'schedule',
7
8
  'coordinator',
8
9
  ]);
9
10
 
10
11
  const VALID_CREATORS = new Set([
11
12
  'operator',
13
+ 'continuous_loop',
12
14
  'coordinator',
13
15
  ]);
14
16
 
@@ -71,6 +73,8 @@ export function summarizeRunProvenance(provenance) {
71
73
  : normalized.trigger;
72
74
  const creatorSuffix = normalized.created_by === 'coordinator'
73
75
  ? ' (created by coordinator)'
76
+ : normalized.created_by === 'continuous_loop'
77
+ ? ' (created by continuous loop)'
74
78
  : '';
75
79
  const reasonSuffix = normalized.trigger_reason
76
80
  ? ` ("${normalized.trigger_reason}")`
@@ -161,6 +161,49 @@ export function evaluateScheduleLaunchEligibility(root, config) {
161
161
  return { ok: false, status, reason: `run_${status}` };
162
162
  }
163
163
 
164
+ function resolveScheduleTriggerReason(scheduleId, schedule) {
165
+ return schedule?.trigger_reason || `schedule:${scheduleId}`;
166
+ }
167
+
168
+ export function findContinuableScheduleRun(root, config, { scheduleId = null } = {}) {
169
+ const projectState = loadProjectState(root, config);
170
+ if (!projectState || projectState.status !== 'active' || !projectState.run_id) {
171
+ return { ok: false, reason: 'not_active' };
172
+ }
173
+
174
+ if (projectState.provenance?.trigger !== 'schedule') {
175
+ return { ok: false, reason: 'not_schedule_run' };
176
+ }
177
+
178
+ const scheduleState = readScheduleState(root, config);
179
+ const matches = Object.entries(config?.schedules || {}).filter(([candidateId, schedule]) => {
180
+ if (scheduleId && candidateId !== scheduleId) return false;
181
+
182
+ const record = scheduleState.schedules[candidateId];
183
+ if (record?.last_run_id === projectState.run_id) {
184
+ return true;
185
+ }
186
+
187
+ return projectState.provenance?.trigger_reason === resolveScheduleTriggerReason(candidateId, schedule);
188
+ });
189
+
190
+ if (matches.length === 0) {
191
+ return { ok: false, reason: 'no_matching_schedule' };
192
+ }
193
+
194
+ if (matches.length > 1) {
195
+ return { ok: false, reason: 'ambiguous_schedule_run' };
196
+ }
197
+
198
+ const [matchedId, matchedSchedule] = matches[0];
199
+ return {
200
+ ok: true,
201
+ schedule_id: matchedId,
202
+ schedule: matchedSchedule,
203
+ state: projectState,
204
+ };
205
+ }
206
+
164
207
  // ── Daemon Health State ─────────────────────────────────────────────────────
165
208
 
166
209
  export function readDaemonState(root) {
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Vision Reader — parse VISION.md and derive candidate intents.
3
+ *
4
+ * Reads a project-relative VISION.md, extracts sections and goals,
5
+ * compares against existing intake state (completed intents, run history),
6
+ * and produces ranked candidate intents for the continuous loop.
7
+ *
8
+ * IMPORTANT: The vision path is project-relative, never hardcoded to
9
+ * the agentxchain.dev repo. Each governed project has its own VISION.md.
10
+ *
11
+ * Spec: .planning/VISION_DRIVEN_CONTINUOUS_SPEC.md
12
+ */
13
+
14
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
15
+ import { join, resolve as pathResolve, isAbsolute } from 'node:path';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Parsing
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Parse a VISION.md file into structured sections with goals.
23
+ *
24
+ * @param {string} content - Raw markdown content
25
+ * @returns {{ sections: Array<{ heading: string, level: number, goals: string[], raw: string }> }}
26
+ */
27
+ export function parseVisionDocument(content) {
28
+ if (!content || typeof content !== 'string') {
29
+ return { sections: [] };
30
+ }
31
+
32
+ const lines = content.split('\n');
33
+ const sections = [];
34
+ let current = null;
35
+
36
+ for (const line of lines) {
37
+ // Match H2 or H3 headings (## or ###)
38
+ const headingMatch = line.match(/^(#{2,3})\s+(.+)$/);
39
+ if (headingMatch) {
40
+ if (current) sections.push(current);
41
+ current = {
42
+ heading: headingMatch[2].trim(),
43
+ level: headingMatch[1].length,
44
+ goals: [],
45
+ raw: '',
46
+ };
47
+ continue;
48
+ }
49
+
50
+ if (current) {
51
+ current.raw += line + '\n';
52
+
53
+ // Extract bullet points as goals
54
+ const bulletMatch = line.match(/^[-*]\s+\*{0,2}(.+?)\*{0,2}\s*$/);
55
+ if (bulletMatch) {
56
+ const goal = bulletMatch[1].trim();
57
+ if (goal.length > 5) { // skip trivially short bullets
58
+ current.goals.push(goal);
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ if (current) sections.push(current);
65
+
66
+ return { sections };
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Evidence comparison
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * Load completed intent descriptions from the intake directory.
75
+ *
76
+ * @param {string} root - Project root
77
+ * @returns {string[]} Array of completed intent charters/descriptions
78
+ */
79
+ export function loadCompletedIntentSignals(root) {
80
+ const intentsDir = join(root, '.agentxchain', 'intake', 'intents');
81
+ if (!existsSync(intentsDir)) return [];
82
+
83
+ const signals = [];
84
+ for (const file of readdirSync(intentsDir)) {
85
+ if (!file.endsWith('.json') || file.startsWith('.tmp-')) continue;
86
+ try {
87
+ const intent = JSON.parse(readFileSync(join(intentsDir, file), 'utf8'));
88
+ if (intent.status === 'completed' || intent.status === 'executing') {
89
+ const desc = intent.charter || intent.signal?.description || '';
90
+ if (desc) signals.push(desc.toLowerCase());
91
+ }
92
+ } catch {
93
+ // skip corrupt files
94
+ }
95
+ }
96
+ return signals;
97
+ }
98
+
99
+ /**
100
+ * Load existing intent signals (all statuses except suppressed/rejected) for dedup.
101
+ *
102
+ * @param {string} root - Project root
103
+ * @returns {string[]} Array of active intent charters/descriptions
104
+ */
105
+ export function loadActiveIntentSignals(root) {
106
+ const intentsDir = join(root, '.agentxchain', 'intake', 'intents');
107
+ if (!existsSync(intentsDir)) return [];
108
+
109
+ const signals = [];
110
+ const skip = new Set(['suppressed', 'rejected']);
111
+ for (const file of readdirSync(intentsDir)) {
112
+ if (!file.endsWith('.json') || file.startsWith('.tmp-')) continue;
113
+ try {
114
+ const intent = JSON.parse(readFileSync(join(intentsDir, file), 'utf8'));
115
+ if (!skip.has(intent.status)) {
116
+ const desc = intent.charter || '';
117
+ if (desc) signals.push(desc.toLowerCase());
118
+ }
119
+ } catch {
120
+ // skip corrupt files
121
+ }
122
+ }
123
+ return signals;
124
+ }
125
+
126
+ /**
127
+ * Check whether a vision goal appears to be addressed by existing work.
128
+ *
129
+ * Uses keyword overlap: if >= 60% of significant words in the goal
130
+ * appear in any completed intent description, the goal is considered addressed.
131
+ *
132
+ * @param {string} goal - The vision goal text
133
+ * @param {string[]} completedSignals - Lowercased completed intent descriptions
134
+ * @returns {boolean}
135
+ */
136
+ export function isGoalAddressed(goal, completedSignals) {
137
+ const words = extractSignificantWords(goal);
138
+ if (words.length === 0) return false;
139
+
140
+ for (const signal of completedSignals) {
141
+ const matchCount = words.filter(w => signal.includes(w)).length;
142
+ if (matchCount / words.length >= 0.6) return true;
143
+ }
144
+ return false;
145
+ }
146
+
147
+ const STOP_WORDS = new Set([
148
+ 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
149
+ 'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been',
150
+ 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
151
+ 'could', 'should', 'may', 'might', 'must', 'shall', 'can', 'that',
152
+ 'this', 'these', 'those', 'it', 'its', 'they', 'them', 'their',
153
+ 'not', 'no', 'nor', 'only', 'also', 'just', 'than', 'then',
154
+ 'each', 'every', 'all', 'any', 'both', 'such', 'as', 'more',
155
+ ]);
156
+
157
+ function extractSignificantWords(text) {
158
+ return text
159
+ .toLowerCase()
160
+ .replace(/[^a-z0-9\s-]/g, '')
161
+ .split(/\s+/)
162
+ .filter(w => w.length > 2 && !STOP_WORDS.has(w));
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Candidate derivation
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Derive candidate intents from a VISION.md file.
171
+ *
172
+ * @param {string} root - Project root
173
+ * @param {string} visionPath - Absolute path to VISION.md
174
+ * @returns {{ ok: boolean, candidates: Array<{ section: string, goal: string, priority: string }>, error?: string }}
175
+ */
176
+ export function deriveVisionCandidates(root, visionPath) {
177
+ if (!existsSync(visionPath)) {
178
+ return {
179
+ ok: false,
180
+ candidates: [],
181
+ error: `VISION.md not found at ${visionPath}. Create a .planning/VISION.md for your project to enable vision-driven operation.`,
182
+ };
183
+ }
184
+
185
+ let content;
186
+ try {
187
+ content = readFileSync(visionPath, 'utf8');
188
+ } catch (err) {
189
+ return { ok: false, candidates: [], error: `Cannot read VISION.md: ${err.message}` };
190
+ }
191
+
192
+ const { sections } = parseVisionDocument(content);
193
+ if (sections.length === 0) {
194
+ return { ok: false, candidates: [], error: 'VISION.md has no extractable sections.' };
195
+ }
196
+
197
+ const completedSignals = loadCompletedIntentSignals(root);
198
+ const activeSignals = loadActiveIntentSignals(root);
199
+ const allSignals = [...completedSignals, ...activeSignals];
200
+
201
+ const candidates = [];
202
+
203
+ for (const section of sections) {
204
+ for (const goal of section.goals) {
205
+ // Skip if this goal is already addressed
206
+ if (isGoalAddressed(goal, allSignals)) continue;
207
+
208
+ candidates.push({
209
+ section: section.heading,
210
+ goal,
211
+ priority: 'p2', // default; operators can override via triage_approval: "human"
212
+ });
213
+ }
214
+ }
215
+
216
+ return { ok: true, candidates };
217
+ }
218
+
219
+ /**
220
+ * Resolve a vision path relative to the project root.
221
+ *
222
+ * @param {string} root - Project root
223
+ * @param {string} visionPath - Path (absolute or project-relative)
224
+ * @returns {string} Absolute path
225
+ */
226
+ export function resolveVisionPath(root, visionPath) {
227
+ if (isAbsolute(visionPath)) return visionPath;
228
+ return pathResolve(root, visionPath);
229
+ }