agentxchain 2.116.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/README.md +1 -1
- package/bin/agentxchain.js +24 -0
- package/package.json +1 -1
- package/src/commands/events.js +8 -1
- package/src/commands/inject.js +81 -0
- package/src/commands/resume.js +6 -4
- package/src/commands/run.js +13 -0
- package/src/commands/schedule.js +165 -19
- package/src/commands/status.js +52 -0
- package/src/commands/unblock.js +67 -0
- package/src/lib/continuous-run.js +448 -0
- package/src/lib/governed-state.js +37 -1
- package/src/lib/human-escalations.js +434 -0
- package/src/lib/intake.js +243 -11
- package/src/lib/notification-runner.js +3 -1
- package/src/lib/run-events.js +2 -0
- package/src/lib/run-loop.js +17 -0
- package/src/lib/run-provenance.js +4 -0
- package/src/lib/run-schedule.js +43 -0
- package/src/lib/vision-reader.js +229 -0
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
|
-
|
|
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 };
|
|
@@ -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}"`);
|
package/src/lib/run-events.js
CHANGED
package/src/lib/run-loop.js
CHANGED
|
@@ -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}")`
|
package/src/lib/run-schedule.js
CHANGED
|
@@ -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
|
+
}
|