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.
Files changed (39) hide show
  1. package/bin/agentxchain.js +22 -0
  2. package/dashboard/app.js +54 -0
  3. package/dashboard/components/org-audit-trail.js +161 -0
  4. package/dashboard/components/org-history.js +140 -0
  5. package/dashboard/components/org-overview.js +145 -0
  6. package/dashboard/components/org-runs.js +168 -0
  7. package/dashboard/index.html +4 -0
  8. package/package.json +2 -1
  9. package/src/commands/ci-report.js +80 -0
  10. package/src/commands/doctor.js +22 -1
  11. package/src/commands/intake-approve.js +1 -0
  12. package/src/commands/replay.js +1 -0
  13. package/src/commands/run.js +47 -0
  14. package/src/commands/serve.js +64 -0
  15. package/src/commands/step.js +16 -0
  16. package/src/commands/verify.js +1 -0
  17. package/src/lib/adapters/local-cli-adapter.js +184 -0
  18. package/src/lib/api/execution-worker.js +192 -0
  19. package/src/lib/api/hosted-runner.js +494 -0
  20. package/src/lib/api/job-queue.js +152 -0
  21. package/src/lib/api/org-state-aggregator.js +428 -0
  22. package/src/lib/api/project-registry.js +148 -0
  23. package/src/lib/api/protocol-bridge.js +476 -0
  24. package/src/lib/approval-policy.js +12 -0
  25. package/src/lib/ci-reporter.js +200 -0
  26. package/src/lib/claude-local-auth.js +75 -1
  27. package/src/lib/connector-probe.js +21 -0
  28. package/src/lib/continuous-run.js +75 -4
  29. package/src/lib/dashboard/bridge-server.js +10 -5
  30. package/src/lib/governed-state.js +1 -1
  31. package/src/lib/intake.js +32 -6
  32. package/src/lib/normalized-config.js +2 -0
  33. package/src/lib/scope-overlap.js +214 -0
  34. package/src/lib/stream-json-cost-parser.js +169 -0
  35. package/src/lib/turn-checkpoint.js +21 -0
  36. package/src/lib/turn-result-validator.js +25 -0
  37. package/src/lib/validation.js +11 -3
  38. package/src/lib/verification-replay.js +125 -4
  39. 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
- 'Run: agentxchain reconcile-state --accept-operator-head once the unsafe changes are resolved, or revert them.',
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: 'agentxchain reconcile-state --accept-operator-head',
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: 'agentxchain reconcile-state --accept-operator-head',
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('Session failure recovered - governed run still active, continuing.');
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
- const content = readFileSync(eventsPath, 'utf8');
291
- if (content.length <= lastEventsFileSize) {
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 (content.length < lastEventsFileSize) lastEventsFileSize = 0;
298
+ if (contentBuf.length < lastEventsFileSize) lastEventsFileSize = 0;
294
299
  else return;
295
300
  }
296
- const newContent = content.slice(lastEventsFileSize);
297
- lastEventsFileSize = content.length;
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
- return { ok: false, error: 'No governed state.json found', exitCode: 2 };
1082
- }
1083
-
1084
- let state = loadProjectState(root, config);
1085
- if (!state) {
1086
- return { ok: false, error: 'Failed to parse governed state.json', exitCode: 2 };
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
+ }