agentxchain 2.155.73 → 2.157.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/bin/agentxchain.js +22 -0
- package/dashboard/app.js +54 -0
- package/dashboard/components/org-audit-trail.js +161 -0
- package/dashboard/components/org-history.js +140 -0
- package/dashboard/components/org-overview.js +145 -0
- package/dashboard/components/org-runs.js +168 -0
- package/dashboard/index.html +4 -0
- package/package.json +2 -1
- package/src/commands/ci-report.js +80 -0
- package/src/commands/doctor.js +22 -1
- package/src/commands/intake-approve.js +1 -0
- package/src/commands/replay.js +1 -0
- package/src/commands/run.js +47 -0
- package/src/commands/serve.js +64 -0
- package/src/commands/step.js +16 -0
- package/src/commands/verify.js +1 -0
- package/src/lib/adapters/local-cli-adapter.js +184 -0
- package/src/lib/api/execution-worker.js +192 -0
- package/src/lib/api/hosted-runner.js +494 -0
- package/src/lib/api/job-queue.js +152 -0
- package/src/lib/api/org-state-aggregator.js +428 -0
- package/src/lib/api/project-registry.js +148 -0
- package/src/lib/api/protocol-bridge.js +476 -0
- package/src/lib/approval-policy.js +12 -0
- package/src/lib/ci-reporter.js +200 -0
- package/src/lib/claude-local-auth.js +75 -1
- package/src/lib/connector-probe.js +21 -0
- package/src/lib/continuous-run.js +75 -4
- package/src/lib/dashboard/bridge-server.js +10 -5
- package/src/lib/governed-state.js +1 -1
- package/src/lib/intake.js +32 -6
- package/src/lib/normalized-config.js +2 -0
- package/src/lib/scope-overlap.js +214 -0
- package/src/lib/stream-json-cost-parser.js +169 -0
- package/src/lib/turn-checkpoint.js +21 -0
- package/src/lib/turn-result-validator.js +25 -0
- package/src/lib/validation.js +11 -3
- package/src/lib/verification-replay.js +125 -4
- package/src/lib/vision-reader.js +7 -2
|
@@ -17,6 +17,18 @@ const CLAUDE_NODE_INCOMPATIBILITY_RE =
|
|
|
17
17
|
/TypeError:\s*Object not disposable|TypeError\(["']Object not disposable["']\)|Object not disposable[\s\S]{0,2000}Node\.js v(?:1[0-9]|20\.[0-4]\.)/i;
|
|
18
18
|
const CLAUDE_COMPATIBLE_NODE_MIN = { major: 20, minor: 5, patch: 0 };
|
|
19
19
|
|
|
20
|
+
// Rate-limit detection — provider-agnostic patterns for subprocess output.
|
|
21
|
+
// Matches Claude/Anthropic, OpenAI/Codex, and generic rate-limit signatures.
|
|
22
|
+
const RATE_LIMIT_RE = /rate_limit_error|"error"\s*:\s*"rate_limit"|rate[_\s-]?limited|hit[_\s-]your[_\s-]limit|usage\s+limit|rate\s+limit\s+reached|429\s*too\s*many\s*requests|too\s+many\s+requests/i;
|
|
23
|
+
|
|
24
|
+
// Reset time extraction — best-effort parsing of provider-reported reset delay.
|
|
25
|
+
const RESET_PATTERNS = [
|
|
26
|
+
/resets?\s+in\s+(\d+)\s*s/i, // "Resets in 42s"
|
|
27
|
+
/retry[_\s-]?after\s*[:=]\s*(\d+)/i, // "Retry-After: 30"
|
|
28
|
+
/wait\s+(\d+)\s*s(?:econds?)?/i, // "Please wait 60 seconds"
|
|
29
|
+
/(?:rate[_\s-]?limit|too\s+many)[\s\S]{0,80}?(\d+)\s*seconds?/i, // "Rate limited. Try again in 120 seconds."
|
|
30
|
+
];
|
|
31
|
+
|
|
20
32
|
function normalizeCommandTokens(runtime) {
|
|
21
33
|
if (Array.isArray(runtime?.command)) {
|
|
22
34
|
return runtime.command.flatMap((element) =>
|
|
@@ -47,6 +59,33 @@ export function isCodexLocalCliRuntime(runtime) {
|
|
|
47
59
|
return head === 'codex' || head.endsWith('/codex');
|
|
48
60
|
}
|
|
49
61
|
|
|
62
|
+
export function isCursorLocalCliRuntime(runtime) {
|
|
63
|
+
const tokens = normalizeCommandTokens(runtime);
|
|
64
|
+
if (tokens.length === 0) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const head = tokens[0].toLowerCase();
|
|
68
|
+
return head === 'cursor' || head.endsWith('/cursor');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isWindsurfLocalCliRuntime(runtime) {
|
|
72
|
+
const tokens = normalizeCommandTokens(runtime);
|
|
73
|
+
if (tokens.length === 0) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const head = tokens[0].toLowerCase();
|
|
77
|
+
return head === 'windsurf' || head.endsWith('/windsurf');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isOpenCodeLocalCliRuntime(runtime) {
|
|
81
|
+
const tokens = normalizeCommandTokens(runtime);
|
|
82
|
+
if (tokens.length === 0) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
const head = tokens[0].toLowerCase();
|
|
86
|
+
return head === 'opencode' || head.endsWith('/opencode');
|
|
87
|
+
}
|
|
88
|
+
|
|
50
89
|
export function hasClaudeAuthenticationFailureText(text) {
|
|
51
90
|
return typeof text === 'string' && CLAUDE_AUTH_FAILURE_RE.test(text);
|
|
52
91
|
}
|
|
@@ -63,6 +102,41 @@ export function hasClaudeBareFlag(runtime) {
|
|
|
63
102
|
return normalizeCommandTokens(runtime).includes('--bare');
|
|
64
103
|
}
|
|
65
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Returns true if the text contains any provider rate-limit signature.
|
|
107
|
+
* Provider-agnostic — works for Claude, OpenAI/Codex, Cursor, Windsurf, OpenCode, and generic 429 patterns.
|
|
108
|
+
*/
|
|
109
|
+
export function hasProviderRateLimitText(text) {
|
|
110
|
+
return typeof text === 'string' && RATE_LIMIT_RE.test(text);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns true if any log line contains a rate-limit signature.
|
|
115
|
+
* Mirrors hasClaudeAuthFailureOutput / hasCodexAuthFailureOutput pattern.
|
|
116
|
+
*/
|
|
117
|
+
export function hasRateLimitOutput(logs) {
|
|
118
|
+
if (!Array.isArray(logs)) return false;
|
|
119
|
+
return logs.some((line) => hasProviderRateLimitText(line));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Best-effort extraction of reset delay from subprocess output text.
|
|
124
|
+
* Returns milliseconds or null if no reset time is found.
|
|
125
|
+
*/
|
|
126
|
+
export function parseRateLimitResetMs(text) {
|
|
127
|
+
if (typeof text !== 'string') return null;
|
|
128
|
+
for (const pattern of RESET_PATTERNS) {
|
|
129
|
+
const match = text.match(pattern);
|
|
130
|
+
if (match) {
|
|
131
|
+
const seconds = Number.parseInt(match[1], 10);
|
|
132
|
+
if (seconds > 0 && seconds <= 3600) {
|
|
133
|
+
return seconds * 1000;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
66
140
|
function parseNodeVersion(raw) {
|
|
67
141
|
const match = String(raw || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
|
68
142
|
if (!match) return null;
|
|
@@ -324,4 +398,4 @@ export async function runClaudeSmokeProbe(opts) {
|
|
|
324
398
|
});
|
|
325
399
|
}
|
|
326
400
|
|
|
327
|
-
export { CLAUDE_ENV_AUTH_KEYS, normalizeCommandTokens };
|
|
401
|
+
export { CLAUDE_ENV_AUTH_KEYS, normalizeCommandTokens, RESET_PATTERNS };
|
|
@@ -28,6 +28,24 @@ const KNOWN_CLI_AUTHORITY_FLAGS = [
|
|
|
28
28
|
weak_flags: ['--full-auto'],
|
|
29
29
|
label: 'OpenAI Codex CLI',
|
|
30
30
|
},
|
|
31
|
+
{
|
|
32
|
+
binary: 'cursor',
|
|
33
|
+
authoritative_flag: '--background-agent',
|
|
34
|
+
weak_flags: [],
|
|
35
|
+
label: 'Cursor IDE',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
binary: 'windsurf',
|
|
39
|
+
authoritative_flag: '--agent',
|
|
40
|
+
weak_flags: [],
|
|
41
|
+
label: 'Windsurf IDE',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
binary: 'opencode',
|
|
45
|
+
authoritative_flag: '--non-interactive',
|
|
46
|
+
weak_flags: [],
|
|
47
|
+
label: 'OpenCode CLI',
|
|
48
|
+
},
|
|
31
49
|
];
|
|
32
50
|
|
|
33
51
|
/**
|
|
@@ -37,6 +55,9 @@ const KNOWN_CLI_AUTHORITY_FLAGS = [
|
|
|
37
55
|
const KNOWN_CLI_TRANSPORTS = {
|
|
38
56
|
claude: ['stdin'],
|
|
39
57
|
codex: ['argv', 'stdin'],
|
|
58
|
+
cursor: ['dispatch_bundle_only'],
|
|
59
|
+
windsurf: ['dispatch_bundle_only'],
|
|
60
|
+
opencode: ['stdin'],
|
|
40
61
|
};
|
|
41
62
|
|
|
42
63
|
function formatCommand(command, args = []) {
|
|
@@ -1141,10 +1141,27 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
|
|
|
1141
1141
|
}
|
|
1142
1142
|
|
|
1143
1143
|
const errorClass = result.error_class || 'reconcile_refused';
|
|
1144
|
+
// RB-14: `reconcile-state --accept-operator-head` runs the SAME safety check, so it
|
|
1145
|
+
// cannot clear an unsafe-edit refusal — recommending it for governance_state_modified
|
|
1146
|
+
// or critical_artifact_deleted is a dead end. Give the recovery that actually applies.
|
|
1147
|
+
const offendingCommit = result.offending_commit || result.commit || null;
|
|
1148
|
+
const offendingPath = result.offending_path || result.path || null;
|
|
1149
|
+
let recoveryAction;
|
|
1150
|
+
let recoveryDetailTail;
|
|
1151
|
+
if (errorClass === 'governance_state_modified') {
|
|
1152
|
+
recoveryAction = offendingCommit ? `git revert ${offendingCommit.slice(0, 12)}` : 'git revert <offending-commit>';
|
|
1153
|
+
recoveryDetailTail = `${offendingPath || 'A commit'} edits governed state under .agentxchain/ directly, which the anti-tamper guard never auto-accepts (reconcile-state --accept-operator-head enforces the same check). Revert it (${recoveryAction}) to restore the governed lineage, or restart from an explicit prior checkpoint.`;
|
|
1154
|
+
} else if (errorClass === 'critical_artifact_deleted') {
|
|
1155
|
+
recoveryAction = (offendingCommit && offendingPath) ? `git checkout ${offendingCommit.slice(0, 12)}^ -- ${offendingPath}` : 'git checkout <commit>^ -- <deleted-path>';
|
|
1156
|
+
recoveryDetailTail = `Critical governed evidence ${offendingPath || ''} was deleted; restore it (${recoveryAction}), then resume.`;
|
|
1157
|
+
} else {
|
|
1158
|
+
recoveryAction = 'agentxchain reconcile-state --accept-operator-head';
|
|
1159
|
+
recoveryDetailTail = `Run: ${recoveryAction} once the unsafe changes are resolved, or revert them.`;
|
|
1160
|
+
}
|
|
1144
1161
|
const detailLines = [
|
|
1145
1162
|
`Operator-commit auto-reconcile refused (${errorClass}).`,
|
|
1146
1163
|
result.error || 'Unsafe operator commits detected; manual recovery required.',
|
|
1147
|
-
|
|
1164
|
+
recoveryDetailTail,
|
|
1148
1165
|
];
|
|
1149
1166
|
const detail = detailLines.join(' ');
|
|
1150
1167
|
|
|
@@ -1164,7 +1181,7 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
|
|
|
1164
1181
|
...((state.blocked_reason || {}).recovery || {}),
|
|
1165
1182
|
typed_reason: 'operator_commit_reconcile_refused',
|
|
1166
1183
|
owner: 'human',
|
|
1167
|
-
recovery_action:
|
|
1184
|
+
recovery_action: recoveryAction,
|
|
1168
1185
|
turn_retained: false,
|
|
1169
1186
|
detail,
|
|
1170
1187
|
},
|
|
@@ -1196,7 +1213,7 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
|
|
|
1196
1213
|
status: 'blocked',
|
|
1197
1214
|
action: 'operator_commit_reconcile_refused',
|
|
1198
1215
|
run_id: session.current_run_id,
|
|
1199
|
-
recovery_action:
|
|
1216
|
+
recovery_action: recoveryAction,
|
|
1200
1217
|
blocked_category: 'operator_commit_reconcile_refused',
|
|
1201
1218
|
error_class: errorClass,
|
|
1202
1219
|
};
|
|
@@ -1326,6 +1343,9 @@ export function seedFromVision(root, visionPath, options = {}) {
|
|
|
1326
1343
|
reason: 'roadmap-open-work auto-approval',
|
|
1327
1344
|
});
|
|
1328
1345
|
if (!approveResult.ok) {
|
|
1346
|
+
if (approveResult.error === 'scope_overlap_detected') {
|
|
1347
|
+
return { ok: true, idle: true, deferred_reason: 'scope_overlap', overlap: approveResult.overlap };
|
|
1348
|
+
}
|
|
1329
1349
|
return { ok: false, error: `approve failed: ${approveResult.error}` };
|
|
1330
1350
|
}
|
|
1331
1351
|
}
|
|
@@ -1401,6 +1421,9 @@ export function seedFromVision(root, visionPath, options = {}) {
|
|
|
1401
1421
|
reason: 'roadmap-replenishment auto-approval (BUG-77)',
|
|
1402
1422
|
});
|
|
1403
1423
|
if (!approveResult.ok) {
|
|
1424
|
+
if (approveResult.error === 'scope_overlap_detected') {
|
|
1425
|
+
return { ok: true, idle: true, deferred_reason: 'scope_overlap', overlap: approveResult.overlap };
|
|
1426
|
+
}
|
|
1404
1427
|
return { ok: false, error: `approve failed: ${approveResult.error}` };
|
|
1405
1428
|
}
|
|
1406
1429
|
}
|
|
@@ -1484,6 +1507,9 @@ export function seedFromVision(root, visionPath, options = {}) {
|
|
|
1484
1507
|
reason: 'vision-derived auto-approval',
|
|
1485
1508
|
});
|
|
1486
1509
|
if (!approveResult.ok) {
|
|
1510
|
+
if (approveResult.error === 'scope_overlap_detected') {
|
|
1511
|
+
return { ok: true, idle: true, deferred_reason: 'scope_overlap', overlap: approveResult.overlap };
|
|
1512
|
+
}
|
|
1487
1513
|
return { ok: false, error: `approve failed: ${approveResult.error}` };
|
|
1488
1514
|
}
|
|
1489
1515
|
}
|
|
@@ -2570,10 +2596,47 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
|
|
|
2570
2596
|
process.on('SIGINT', sigHandler);
|
|
2571
2597
|
|
|
2572
2598
|
try {
|
|
2599
|
+
// RB-13: circuit-breaker for the recover-and-continue path. A deterministic,
|
|
2600
|
+
// recurring failure (e.g. an unclean baseline) must escalate, not busy-loop
|
|
2601
|
+
// forever — without this, the same start error spun 582 times in seconds.
|
|
2602
|
+
// Track consecutive identical recoveries; halt and escalate after a cap, and
|
|
2603
|
+
// back off between attempts so even allowed retries can't tight-loop.
|
|
2604
|
+
let consecutiveRecoveries = 0;
|
|
2605
|
+
let lastRecoveryReason = null;
|
|
2606
|
+
const maxConsecutiveRecoveries = Math.max(1, contOpts.maxConsecutiveRecoveries ?? 3);
|
|
2607
|
+
|
|
2573
2608
|
while (!stopping) {
|
|
2574
2609
|
const step = await advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log);
|
|
2575
2610
|
|
|
2576
2611
|
if (step.status === 'failed' && isGovernedRunStillActiveForSession(root, context.config, session)) {
|
|
2612
|
+
const recoveryReason = step.stop_reason || step.action || 'unknown';
|
|
2613
|
+
if (recoveryReason === lastRecoveryReason) {
|
|
2614
|
+
consecutiveRecoveries += 1;
|
|
2615
|
+
} else {
|
|
2616
|
+
consecutiveRecoveries = 1;
|
|
2617
|
+
lastRecoveryReason = recoveryReason;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
if (consecutiveRecoveries >= maxConsecutiveRecoveries) {
|
|
2621
|
+
// Circuit open: the same recoverable failure keeps recurring without
|
|
2622
|
+
// progress. Escalate to a human instead of spinning the loop.
|
|
2623
|
+
session.status = 'blocked';
|
|
2624
|
+
writeContinuousSession(root, session);
|
|
2625
|
+
emitRunEvent(root, 'continuous_recovery_circuit_open', {
|
|
2626
|
+
run_id: session.current_run_id || null,
|
|
2627
|
+
phase: null,
|
|
2628
|
+
status: 'blocked',
|
|
2629
|
+
payload: {
|
|
2630
|
+
session_id: session.session_id,
|
|
2631
|
+
failed_action: step.action || null,
|
|
2632
|
+
failed_reason: recoveryReason,
|
|
2633
|
+
consecutive_recoveries: consecutiveRecoveries,
|
|
2634
|
+
},
|
|
2635
|
+
});
|
|
2636
|
+
log(`Continuous loop halted (circuit-breaker): the same recoverable failure recurred ${consecutiveRecoveries} times with no progress — escalating to a human instead of spinning. Reason: ${recoveryReason}`);
|
|
2637
|
+
return { exitCode: 1, session };
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2577
2640
|
session.status = 'running';
|
|
2578
2641
|
writeContinuousSession(root, session);
|
|
2579
2642
|
emitRunEvent(root, 'session_failed_recovered_active_run', {
|
|
@@ -2584,12 +2647,20 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
|
|
|
2584
2647
|
session_id: session.session_id,
|
|
2585
2648
|
failed_action: step.action || null,
|
|
2586
2649
|
failed_reason: step.stop_reason || null,
|
|
2650
|
+
consecutive_recoveries: consecutiveRecoveries,
|
|
2587
2651
|
},
|
|
2588
2652
|
});
|
|
2589
|
-
log(
|
|
2653
|
+
log(`Session failure recovered - governed run still active, continuing (recovery ${consecutiveRecoveries}/${maxConsecutiveRecoveries}).`);
|
|
2654
|
+
// Backoff before retrying so a recurring failure cannot busy-loop.
|
|
2655
|
+
const recoveryBackoffMs = Math.max(1000, (contOpts.cooldownSeconds ?? 5) * 1000);
|
|
2656
|
+
await new Promise((resolve) => setTimeout(resolve, recoveryBackoffMs));
|
|
2590
2657
|
continue;
|
|
2591
2658
|
}
|
|
2592
2659
|
|
|
2660
|
+
// A non-recovery step means the loop made forward progress — reset the breaker.
|
|
2661
|
+
consecutiveRecoveries = 0;
|
|
2662
|
+
lastRecoveryReason = null;
|
|
2663
|
+
|
|
2593
2664
|
// Terminal states
|
|
2594
2665
|
if (step.status === 'completed' || step.status === 'idle_exit' || step.status === 'failed' || step.status === 'blocked' || step.status === 'stopped' || step.status === 'vision_exhausted' || step.status === 'vision_expansion_exhausted' || step.status === 'session_budget') {
|
|
2595
2666
|
const terminalMessage = describeContinuousTerminalStep(step, contOpts);
|
|
@@ -287,14 +287,19 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847,
|
|
|
287
287
|
try {
|
|
288
288
|
const eventsPath = join(agentxchainDir, 'events.jsonl');
|
|
289
289
|
if (!existsSync(eventsPath)) return;
|
|
290
|
-
|
|
291
|
-
|
|
290
|
+
// Read as Buffer so .length is byte count, consistent with the
|
|
291
|
+
// byte-based lastEventsFileSize initialized at startup. The old
|
|
292
|
+
// code read as 'utf8' (string), whose .length is *character* count
|
|
293
|
+
// — a mismatch that caused a full-replay on the first invalidation
|
|
294
|
+
// when events contained multi-byte UTF-8 content.
|
|
295
|
+
const contentBuf = readFileSync(eventsPath);
|
|
296
|
+
if (contentBuf.length <= lastEventsFileSize) {
|
|
292
297
|
// File was truncated — reset and push all
|
|
293
|
-
if (
|
|
298
|
+
if (contentBuf.length < lastEventsFileSize) lastEventsFileSize = 0;
|
|
294
299
|
else return;
|
|
295
300
|
}
|
|
296
|
-
const newContent =
|
|
297
|
-
lastEventsFileSize =
|
|
301
|
+
const newContent = contentBuf.slice(lastEventsFileSize).toString('utf8');
|
|
302
|
+
lastEventsFileSize = contentBuf.length;
|
|
298
303
|
const lines = newContent.split('\n').filter(Boolean);
|
|
299
304
|
for (const line of lines) {
|
|
300
305
|
try {
|
|
@@ -4868,7 +4868,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
4868
4868
|
|
|
4869
4869
|
const derivedRef = deriveAcceptedRef(observation, artifactType, state.accepted_integration_ref);
|
|
4870
4870
|
const verificationReplay = (config.policies || []).some((policy) => policy?.rule === 'require_reproducible_verification')
|
|
4871
|
-
? replayVerificationMachineEvidence({ root, verification: turnResult.verification })
|
|
4871
|
+
? replayVerificationMachineEvidence({ root, verification: turnResult.verification, allowCommandExecution: true })
|
|
4872
4872
|
: null;
|
|
4873
4873
|
|
|
4874
4874
|
if (verificationReplay?.workspace_guard?.ok === false) {
|
package/src/lib/intake.js
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
derivePhaseScopeFromIntentMetadata,
|
|
29
29
|
getPhaseOrder,
|
|
30
30
|
} from './intent-phase-scope.js';
|
|
31
|
+
import { checkIntentScopeOverlap } from './scope-overlap.js';
|
|
31
32
|
|
|
32
33
|
const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule', 'vision_scan', 'vision_idle_expansion'];
|
|
33
34
|
const VALID_PRIORITIES = ['p0', 'p1', 'p2', 'p3'];
|
|
@@ -886,6 +887,25 @@ export function approveIntent(root, intentId, options = {}) {
|
|
|
886
887
|
return { ok: false, error: `cannot approve from status "${intent.status}" (must be triaged or blocked)`, exitCode: 1 };
|
|
887
888
|
}
|
|
888
889
|
|
|
890
|
+
// --- Scope overlap guard (M10) ---
|
|
891
|
+
if (!options.forceScope) {
|
|
892
|
+
const charterText = intent.charter || '';
|
|
893
|
+
const acceptanceItems = Array.isArray(intent.acceptance_contract) ? intent.acceptance_contract : [];
|
|
894
|
+
if (charterText) {
|
|
895
|
+
const overlapCheck = checkIntentScopeOverlap(root, charterText, acceptanceItems, {
|
|
896
|
+
threshold: options.scopeOverlapThreshold ?? 0.4,
|
|
897
|
+
});
|
|
898
|
+
if (overlapCheck.overlapping) {
|
|
899
|
+
return {
|
|
900
|
+
ok: false,
|
|
901
|
+
error: 'scope_overlap_detected',
|
|
902
|
+
overlap: overlapCheck,
|
|
903
|
+
exitCode: 3,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
889
909
|
const approver = options.approver || 'operator';
|
|
890
910
|
const previousStatus = intent.status;
|
|
891
911
|
const reason = options.reason || (previousStatus === 'blocked' ? 're-approved after block resolution' : 'approved for planning');
|
|
@@ -1077,13 +1097,19 @@ export function startIntent(root, intentId, options = {}) {
|
|
|
1077
1097
|
|
|
1078
1098
|
// Load governed state
|
|
1079
1099
|
const statePath = join(root, STATE_PATH);
|
|
1100
|
+
let state;
|
|
1080
1101
|
if (!existsSync(statePath)) {
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1102
|
+
// RB-11: cold-start. No state.json yet — synthesize an idle state so the
|
|
1103
|
+
// bootstrap below initializes a fresh governed run, matching the `run`
|
|
1104
|
+
// command. Without this, `intake start` (and continuous mode, which starts
|
|
1105
|
+
// its first derived intent via this path) cannot start lights-out from a
|
|
1106
|
+
// clean initialized project.
|
|
1107
|
+
state = { status: 'idle', run_id: null };
|
|
1108
|
+
} else {
|
|
1109
|
+
state = loadProjectState(root, config);
|
|
1110
|
+
if (!state) {
|
|
1111
|
+
return { ok: false, error: 'Failed to parse governed state.json', exitCode: 2 };
|
|
1112
|
+
}
|
|
1087
1113
|
}
|
|
1088
1114
|
|
|
1089
1115
|
const allowCompletedRestart = options.allowTerminalRestart === true
|
|
@@ -445,6 +445,7 @@ export function validateV4Config(data, projectRoot) {
|
|
|
445
445
|
if (rt.type === 'local_cli') {
|
|
446
446
|
validateRuntimePositiveInteger(`Runtime "${id}": startup_watchdog_ms`, rt.startup_watchdog_ms, errors);
|
|
447
447
|
validateRuntimePositiveInteger(`Runtime "${id}": startup_heartbeat_ms`, rt.startup_heartbeat_ms, errors);
|
|
448
|
+
validateRuntimePositiveInteger(`Runtime "${id}": liveness_heartbeat_ms`, rt.liveness_heartbeat_ms, errors);
|
|
448
449
|
}
|
|
449
450
|
// Validate prompt_transport for local_cli runtimes
|
|
450
451
|
if (rt.type === 'local_cli' && rt.prompt_transport) {
|
|
@@ -641,6 +642,7 @@ export function validateRunLoopConfig(runLoop) {
|
|
|
641
642
|
}
|
|
642
643
|
validateRunLoopPositiveInteger('run_loop.startup_watchdog_ms', runLoop.startup_watchdog_ms, errors);
|
|
643
644
|
validateRunLoopPositiveInteger('run_loop.startup_heartbeat_ms', runLoop.startup_heartbeat_ms, errors);
|
|
645
|
+
validateRunLoopPositiveInteger('run_loop.liveness_heartbeat_ms', runLoop.liveness_heartbeat_ms, errors);
|
|
644
646
|
validateRunLoopPositiveInteger('run_loop.stale_turn_threshold_ms', runLoop.stale_turn_threshold_ms, errors);
|
|
645
647
|
if (runLoop.continuous !== undefined && runLoop.continuous !== null) {
|
|
646
648
|
validateRunLoopContinuousConfig('run_loop.continuous', runLoop.continuous, errors);
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Stop words aligned with vision-reader.js:155
|
|
5
|
+
const STOP_WORDS = new Set([
|
|
6
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
|
7
|
+
'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been',
|
|
8
|
+
'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
|
|
9
|
+
'could', 'should', 'may', 'might', 'must', 'shall', 'can', 'that',
|
|
10
|
+
'this', 'these', 'those', 'it', 'its', 'they', 'them', 'their',
|
|
11
|
+
'not', 'no', 'nor', 'only', 'also', 'just', 'than', 'then',
|
|
12
|
+
'each', 'every', 'all', 'any', 'both', 'such', 'as', 'more',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
// Structural boilerplate introduced by seedFromVision charter/acceptance templates.
|
|
16
|
+
// These tokens appear in every vision-derived intent and create false overlap.
|
|
17
|
+
const TEMPLATE_NOISE = new Set([
|
|
18
|
+
'vision', 'goal', 'addressed', 'section',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract a scope fingerprint from charter/acceptance text.
|
|
23
|
+
*
|
|
24
|
+
* Extracts normalized significant tokens:
|
|
25
|
+
* - Milestone references: M1, M2, ..., M10 (case-insensitive)
|
|
26
|
+
* - Bug references: BUG-54, BUG-78, etc.
|
|
27
|
+
* - MW reference (workflow kit milestone)
|
|
28
|
+
* - File paths: patterns matching cli/src/..., cli/test/..., .planning/...
|
|
29
|
+
* - Module keywords: significant words (>3 chars) after stop-word removal
|
|
30
|
+
*
|
|
31
|
+
* @param {string} text - Charter and/or acceptance contract text (concatenated)
|
|
32
|
+
* @returns {Set<string>} - Set of normalized lowercase tokens
|
|
33
|
+
*/
|
|
34
|
+
export function extractScopeFingerprint(text) {
|
|
35
|
+
if (!text || typeof text !== 'string') return new Set();
|
|
36
|
+
|
|
37
|
+
const tokens = new Set();
|
|
38
|
+
|
|
39
|
+
// 1. Milestone refs: M1, M2, ..., M10 etc.
|
|
40
|
+
const milestoneRefs = text.match(/\bM\d+\b/gi);
|
|
41
|
+
if (milestoneRefs) {
|
|
42
|
+
for (const ref of milestoneRefs) tokens.add(ref.toLowerCase());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 2. Bug refs: BUG-54, BUG-78, etc.
|
|
46
|
+
const bugRefs = text.match(/\bBUG-\d+\b/gi);
|
|
47
|
+
if (bugRefs) {
|
|
48
|
+
for (const ref of bugRefs) tokens.add(ref.toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 3. MW ref
|
|
52
|
+
const mwRefs = text.match(/\bMW\b/gi);
|
|
53
|
+
if (mwRefs) tokens.add('mw');
|
|
54
|
+
|
|
55
|
+
// 4. File paths: cli/src/..., cli/test/..., cli/bin/..., .planning/...
|
|
56
|
+
const filePaths = text.match(/(?:cli\/(?:src|test|bin)\/\S+|\.planning\/\S+)/g);
|
|
57
|
+
if (filePaths) {
|
|
58
|
+
for (const fp of filePaths) tokens.add(fp.toLowerCase());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 5. Module keywords: significant words (>3 chars) after stop-word removal
|
|
62
|
+
const cleaned = text
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
65
|
+
.split(/\s+/)
|
|
66
|
+
.filter(w => w.length > 3 && !STOP_WORDS.has(w) && !TEMPLATE_NOISE.has(w) && !/^\d+$/.test(w));
|
|
67
|
+
for (const word of cleaned) tokens.add(word);
|
|
68
|
+
|
|
69
|
+
return tokens;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Compute Jaccard similarity between two scope fingerprints.
|
|
74
|
+
*
|
|
75
|
+
* Jaccard = |A ∩ B| / |A ∪ B|
|
|
76
|
+
* Returns 0 when both sets are empty (no overlap by definition).
|
|
77
|
+
*
|
|
78
|
+
* @param {Set<string>} a - First fingerprint
|
|
79
|
+
* @param {Set<string>} b - Second fingerprint
|
|
80
|
+
* @returns {number} - Similarity score between 0.0 and 1.0
|
|
81
|
+
*/
|
|
82
|
+
export function computeScopeOverlap(a, b) {
|
|
83
|
+
if (a.size === 0 && b.size === 0) return 0;
|
|
84
|
+
|
|
85
|
+
let intersection = 0;
|
|
86
|
+
for (const token of a) {
|
|
87
|
+
if (b.has(token)) intersection++;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const union = a.size + b.size - intersection;
|
|
91
|
+
if (union === 0) return 0;
|
|
92
|
+
|
|
93
|
+
return intersection / union;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract charter text from state.json active run.
|
|
98
|
+
* Checks: 1) first active turn's intake_context.charter, 2) provenance.trigger_reason
|
|
99
|
+
*/
|
|
100
|
+
function extractActiveRunCharter(state) {
|
|
101
|
+
// Check active turns for intake_context.charter
|
|
102
|
+
if (state.active_turns && Array.isArray(state.active_turns)) {
|
|
103
|
+
for (const turn of state.active_turns) {
|
|
104
|
+
if (turn.intake_context && turn.intake_context.charter) {
|
|
105
|
+
return turn.intake_context.charter;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Fall back to provenance.trigger_reason
|
|
111
|
+
if (state.provenance && state.provenance.trigger_reason) {
|
|
112
|
+
return state.provenance.trigger_reason;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Load recent completed intents from .agentxchain/intake/intents/*.json
|
|
120
|
+
*/
|
|
121
|
+
function loadRecentCompletedIntents(root, limit) {
|
|
122
|
+
const intentsDir = join(root, '.agentxchain', 'intake', 'intents');
|
|
123
|
+
if (!existsSync(intentsDir)) return [];
|
|
124
|
+
|
|
125
|
+
let files;
|
|
126
|
+
try {
|
|
127
|
+
files = readdirSync(intentsDir).filter(f => f.endsWith('.json'));
|
|
128
|
+
} catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const intents = [];
|
|
133
|
+
for (const file of files) {
|
|
134
|
+
try {
|
|
135
|
+
const intent = JSON.parse(readFileSync(join(intentsDir, file), 'utf8'));
|
|
136
|
+
if (intent.status === 'completed' || intent.status === 'satisfied') {
|
|
137
|
+
intents.push(intent);
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// Skip malformed intent files
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Sort by updated_at descending, take first `limit`
|
|
145
|
+
intents.sort((a, b) => {
|
|
146
|
+
const dateA = a.updated_at || a.created_at || '';
|
|
147
|
+
const dateB = b.updated_at || b.created_at || '';
|
|
148
|
+
return dateB.localeCompare(dateA);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return intents.slice(0, limit);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check if an intent's charter overlaps with active or recent work.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} root - Project root
|
|
158
|
+
* @param {string} charter - The candidate intent's charter text
|
|
159
|
+
* @param {string[]} acceptanceContract - The candidate intent's acceptance items
|
|
160
|
+
* @param {object} [options]
|
|
161
|
+
* @param {number} [options.threshold=0.4] - Jaccard score above which overlap is reported
|
|
162
|
+
* @param {number} [options.lookbackIntents=10] - Max recent completed intents to check
|
|
163
|
+
* @returns {{ overlapping: boolean, matches: Array<{ source: string, charter: string, score: number }>, max_score: number }}
|
|
164
|
+
*/
|
|
165
|
+
export function checkIntentScopeOverlap(root, charter, acceptanceContract, options = {}) {
|
|
166
|
+
const threshold = options.threshold ?? 0.4;
|
|
167
|
+
const lookbackIntents = options.lookbackIntents ?? 10;
|
|
168
|
+
|
|
169
|
+
const candidateText = charter + ' ' + (Array.isArray(acceptanceContract) ? acceptanceContract.join(' ') : '');
|
|
170
|
+
const candidateFP = extractScopeFingerprint(candidateText);
|
|
171
|
+
|
|
172
|
+
// Too few tokens means there isn't enough semantic signal for meaningful
|
|
173
|
+
// comparison — skip overlap check to avoid false positives from
|
|
174
|
+
// section names or template scaffolding alone.
|
|
175
|
+
if (candidateFP.size < 3) {
|
|
176
|
+
return { overlapping: false, matches: [], max_score: 0 };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const matches = [];
|
|
180
|
+
|
|
181
|
+
// 1. Check active run
|
|
182
|
+
const statePath = join(root, '.agentxchain', 'state.json');
|
|
183
|
+
if (existsSync(statePath)) {
|
|
184
|
+
try {
|
|
185
|
+
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
186
|
+
if (state && state.status === 'active') {
|
|
187
|
+
const activeCharter = extractActiveRunCharter(state);
|
|
188
|
+
if (activeCharter) {
|
|
189
|
+
const activeFP = extractScopeFingerprint(activeCharter);
|
|
190
|
+
const score = computeScopeOverlap(candidateFP, activeFP);
|
|
191
|
+
if (score > 0) {
|
|
192
|
+
matches.push({ source: 'active_run', charter: activeCharter, score });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// Non-fatal — state read failure doesn't block approval
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 2. Check recent completed intents
|
|
202
|
+
const recentIntents = loadRecentCompletedIntents(root, lookbackIntents);
|
|
203
|
+
for (const intent of recentIntents) {
|
|
204
|
+
const intentText = (intent.charter || '') + ' ' + (Array.isArray(intent.acceptance_contract) ? intent.acceptance_contract.join(' ') : '');
|
|
205
|
+
const intentFP = extractScopeFingerprint(intentText);
|
|
206
|
+
const score = computeScopeOverlap(candidateFP, intentFP);
|
|
207
|
+
if (score > 0) {
|
|
208
|
+
matches.push({ source: `intent:${intent.intent_id}`, charter: intent.charter || '', score });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const max_score = Math.max(0, ...matches.map(m => m.score));
|
|
213
|
+
return { overlapping: max_score >= threshold, matches, max_score };
|
|
214
|
+
}
|