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.
- 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 +147 -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 +188 -0
- package/src/lib/claude-local-auth.js +75 -1
- package/src/lib/connector-probe.js +21 -0
- package/src/lib/continuous-run.js +9 -0
- 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/turn-checkpoint.js +21 -0
- package/src/lib/turn-result-validator.js +5 -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 = []) {
|
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
) {
|
package/src/lib/validation.js
CHANGED
|
@@ -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
|
-
|
|
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 ${
|
|
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}):`,
|