agentxchain 2.155.73 → 2.156.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 (38) 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 +147 -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 +188 -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 +9 -0
  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/turn-checkpoint.js +21 -0
  35. package/src/lib/turn-result-validator.js +5 -0
  36. package/src/lib/validation.js +11 -3
  37. package/src/lib/verification-replay.js +125 -4
  38. 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 = []) {
@@ -1326,6 +1326,9 @@ export function seedFromVision(root, visionPath, options = {}) {
1326
1326
  reason: 'roadmap-open-work auto-approval',
1327
1327
  });
1328
1328
  if (!approveResult.ok) {
1329
+ if (approveResult.error === 'scope_overlap_detected') {
1330
+ return { ok: true, idle: true, deferred_reason: 'scope_overlap', overlap: approveResult.overlap };
1331
+ }
1329
1332
  return { ok: false, error: `approve failed: ${approveResult.error}` };
1330
1333
  }
1331
1334
  }
@@ -1401,6 +1404,9 @@ export function seedFromVision(root, visionPath, options = {}) {
1401
1404
  reason: 'roadmap-replenishment auto-approval (BUG-77)',
1402
1405
  });
1403
1406
  if (!approveResult.ok) {
1407
+ if (approveResult.error === 'scope_overlap_detected') {
1408
+ return { ok: true, idle: true, deferred_reason: 'scope_overlap', overlap: approveResult.overlap };
1409
+ }
1404
1410
  return { ok: false, error: `approve failed: ${approveResult.error}` };
1405
1411
  }
1406
1412
  }
@@ -1484,6 +1490,9 @@ export function seedFromVision(root, visionPath, options = {}) {
1484
1490
  reason: 'vision-derived auto-approval',
1485
1491
  });
1486
1492
  if (!approveResult.ok) {
1493
+ if (approveResult.error === 'scope_overlap_detected') {
1494
+ return { ok: true, idle: true, deferred_reason: 'scope_overlap', overlap: approveResult.overlap };
1495
+ }
1487
1496
  return { ok: false, error: `approve failed: ${approveResult.error}` };
1488
1497
  }
1489
1498
  }
@@ -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
+ }
@@ -352,6 +352,27 @@ export function checkpointAcceptedTurn(root, opts = {}) {
352
352
  }
353
353
 
354
354
  const entry = resolved.entry;
355
+
356
+ // Proposed turns materialize their files under .agentxchain/proposed/<turn_id>/, never
357
+ // the workspace — there is nothing to `git add` until `proposal apply` promotes them.
358
+ // Attempting to stage the declared workspace paths fails with "pathspec did not match",
359
+ // so checkpoint cleanly skips proposed/patch turns.
360
+ if (
361
+ entry?.artifact
362
+ && typeof entry.artifact === 'object'
363
+ && !Array.isArray(entry.artifact)
364
+ && entry.artifact.type === 'patch'
365
+ && typeof entry.artifact.ref === 'string'
366
+ && entry.artifact.ref.startsWith('.agentxchain/proposed/')
367
+ ) {
368
+ return {
369
+ ok: true,
370
+ skipped: true,
371
+ turn: entry,
372
+ reason: 'Proposed turn materialized under .agentxchain/proposed/; no workspace checkpoint needed until proposal apply.',
373
+ };
374
+ }
375
+
355
376
  const supplementalFilesChanged = entry.checkpoint_sha
356
377
  ? recoverSupplementalCheckpointFiles(root, entry)
357
378
  : [];
@@ -1523,6 +1523,11 @@ export function normalizeTurnResult(tr, config, context = {}) {
1523
1523
  && (
1524
1524
  context.forceReviewArtifact
1525
1525
  || hasExplicitNoEditLifecycleSignal
1526
+ // review_only roles (PM/QA) legitimately produce no-edit reviews — an empty
1527
+ // workspace from a COMPLETED review_only turn normalizes to a review. Authoritative
1528
+ // /code-writing roles claiming a completed workspace artifact with no files stay
1529
+ // fail-closed, and non-completed (blocked/failed) turns never normalize (BUG-78).
1530
+ || (isReviewOnly && normalized.status === 'completed')
1526
1531
  || (normalized.status === 'needs_human' && normalized.proposed_next_role === 'human')
1527
1532
  )
1528
1533
  ) {
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { validateStagedTurnResult, STAGING_PATH } from './turn-result-validator.js';
4
4
  import { getActiveTurn } from './governed-state.js';
5
+ import { getTurnStagingResultPath } from './turn-paths.js';
5
6
  import {
6
7
  validateGovernedProjectTemplate,
7
8
  validateGovernedTemplateRegistry,
@@ -191,11 +192,18 @@ export function validateGovernedProject(root, rawConfig, config, opts = {}) {
191
192
 
192
193
  // ── Staged turn-result validation (the acceptance boundary) ─────────────
193
194
  if (mode === 'turn' && state) {
194
- const stagingAbs = join(root, STAGING_PATH);
195
+ // RB-12: prefer the turn-scoped staging path for the active turn; the shared
196
+ // legacy path is only a fallback. Reading the legacy path unconditionally
197
+ // made `validate --mode turn` look at the wrong file and falsely report a
198
+ // missing turn result for turns staged at the turn-scoped path.
199
+ const activeTurn = getActiveTurn(state) || state.current_turn || null;
200
+ const turnScoped = activeTurn?.turn_id ? getTurnStagingResultPath(activeTurn.turn_id) : null;
201
+ const stagingRel = (turnScoped && existsSync(join(root, turnScoped))) ? turnScoped : STAGING_PATH;
202
+ const stagingAbs = join(root, stagingRel);
195
203
  if (!existsSync(stagingAbs)) {
196
- warnings.push(`No staged turn result found at ${STAGING_PATH}. Agent has not yet emitted a turn result.`);
204
+ warnings.push(`No staged turn result found at ${stagingRel}. Agent has not yet emitted a turn result.`);
197
205
  } else {
198
- const turnValidation = validateStagedTurnResult(root, state, config);
206
+ const turnValidation = validateStagedTurnResult(root, state, config, { stagingPath: stagingRel });
199
207
  if (!turnValidation.ok) {
200
208
  errors.push(
201
209
  `Staged turn result failed at stage "${turnValidation.stage}" (${turnValidation.error_class}):`,