agentxchain 2.155.72 → 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 (49) hide show
  1. package/README.md +4 -8
  2. package/bin/agentxchain.js +22 -0
  3. package/dashboard/app.js +54 -0
  4. package/dashboard/components/org-audit-trail.js +161 -0
  5. package/dashboard/components/org-history.js +140 -0
  6. package/dashboard/components/org-overview.js +145 -0
  7. package/dashboard/components/org-runs.js +168 -0
  8. package/dashboard/index.html +4 -0
  9. package/package.json +4 -5
  10. package/scripts/migrate-node-test-to-vitest.mjs +98 -0
  11. package/scripts/release-postflight.sh +1 -1
  12. package/scripts/release-preflight.sh +5 -5
  13. package/scripts/verify-post-publish.sh +1 -1
  14. package/src/commands/ci-report.js +80 -0
  15. package/src/commands/doctor.js +22 -1
  16. package/src/commands/intake-approve.js +1 -0
  17. package/src/commands/replay.js +1 -0
  18. package/src/commands/run.js +50 -0
  19. package/src/commands/serve.js +64 -0
  20. package/src/commands/step.js +63 -1
  21. package/src/commands/verify.js +1 -0
  22. package/src/lib/adapters/local-cli-adapter.js +326 -2
  23. package/src/lib/api/execution-worker.js +192 -0
  24. package/src/lib/api/hosted-runner.js +494 -0
  25. package/src/lib/api/job-queue.js +152 -0
  26. package/src/lib/api/org-state-aggregator.js +428 -0
  27. package/src/lib/api/project-registry.js +148 -0
  28. package/src/lib/api/protocol-bridge.js +476 -0
  29. package/src/lib/approval-policy.js +12 -0
  30. package/src/lib/ci-reporter.js +188 -0
  31. package/src/lib/claude-local-auth.js +89 -1
  32. package/src/lib/connector-probe.js +21 -0
  33. package/src/lib/continuous-run.js +51 -3
  34. package/src/lib/dashboard/bridge-server.js +10 -5
  35. package/src/lib/dispatch-bundle.js +7 -3
  36. package/src/lib/dispatch-progress.js +9 -0
  37. package/src/lib/governed-state.js +5 -4
  38. package/src/lib/intake.js +32 -6
  39. package/src/lib/normalized-config.js +4 -0
  40. package/src/lib/recovery-classification.js +158 -0
  41. package/src/lib/report.js +91 -0
  42. package/src/lib/run-events.js +7 -1
  43. package/src/lib/schemas/agentxchain-config.schema.json +10 -0
  44. package/src/lib/scope-overlap.js +214 -0
  45. package/src/lib/turn-checkpoint.js +33 -3
  46. package/src/lib/turn-result-validator.js +47 -6
  47. package/src/lib/validation.js +11 -3
  48. package/src/lib/verification-replay.js +125 -4
  49. package/src/lib/vision-reader.js +16 -1
@@ -0,0 +1,188 @@
1
+ import { appendFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * Detect CI environment from process.env.
5
+ * @returns {{ provider: string, run_url: string|null, run_id: string|null, ref: string|null, sha: string|null } | null}
6
+ */
7
+ export function detectCIEnvironment() {
8
+ if (process.env.GITHUB_ACTIONS === 'true') {
9
+ return {
10
+ provider: 'github_actions',
11
+ run_url: process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID
12
+ ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
13
+ : null,
14
+ run_id: process.env.GITHUB_RUN_ID || null,
15
+ ref: process.env.GITHUB_REF || null,
16
+ sha: process.env.GITHUB_SHA || null,
17
+ };
18
+ }
19
+ if (process.env.GITLAB_CI === 'true') {
20
+ return {
21
+ provider: 'gitlab_ci',
22
+ run_url: process.env.CI_PIPELINE_URL || null,
23
+ run_id: process.env.CI_PIPELINE_ID || null,
24
+ ref: process.env.CI_COMMIT_REF_NAME || null,
25
+ sha: process.env.CI_COMMIT_SHA || null,
26
+ };
27
+ }
28
+ if (process.env.CI === 'true') {
29
+ return {
30
+ provider: 'generic',
31
+ run_url: null,
32
+ run_id: null,
33
+ ref: null,
34
+ sha: null,
35
+ };
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /**
41
+ * Format governance report as GitHub Actions annotations.
42
+ * @param {{ overall: string, subject?: object }} report - Governance report from buildGovernanceReport()
43
+ * @returns {string} Newline-separated GitHub Actions workflow commands
44
+ */
45
+ export function formatGitHubAnnotations(report) {
46
+ const lines = [];
47
+
48
+ // Overall run status
49
+ if (report.overall === 'pass') {
50
+ lines.push(`::notice title=AgentXchain Governance::Run ${report.subject?.run?.run_id || 'unknown'} — PASS (${report.subject?.run?.phase || 'unknown'} phase)`);
51
+ } else if (report.overall === 'fail') {
52
+ lines.push(`::error title=AgentXchain Governance::Run ${report.subject?.run?.run_id || 'unknown'} — FAIL: ${report.message || 'governance check failed'}`);
53
+ } else {
54
+ lines.push(`::warning title=AgentXchain Governance::Run ${report.subject?.run?.run_id || 'unknown'} — ${report.overall}: ${report.message || 'review required'}`);
55
+ }
56
+
57
+ // Gate annotations
58
+ const gates = report.subject?.run?.gate_summary;
59
+ if (gates && typeof gates === 'object') {
60
+ for (const [gateName, gateStatus] of Object.entries(gates)) {
61
+ if (typeof gateStatus !== 'string') continue;
62
+ const normalizedStatus = gateStatus.toLowerCase();
63
+ if (normalizedStatus === 'satisfied' || normalizedStatus === 'pass' || normalizedStatus === 'passed') {
64
+ lines.push(`::notice title=Gate ${gateName}::satisfied`);
65
+ } else {
66
+ lines.push(`::warning title=Gate ${gateName}::${gateStatus}`);
67
+ }
68
+ }
69
+ }
70
+
71
+ // Decision annotations (first 20 to avoid flooding)
72
+ const decisions = report.subject?.run?.decisions || [];
73
+ for (const d of decisions.slice(0, 20)) {
74
+ lines.push(`::notice title=Decision ${d.id || 'unknown'}::${d.statement || d.summary || 'no statement'}`);
75
+ }
76
+
77
+ // Blocked-on annotation
78
+ if (report.subject?.run?.blocked_on) {
79
+ lines.push(`::error title=Blocked::${report.subject.run.blocked_reason || report.subject.run.blocked_on}`);
80
+ }
81
+
82
+ return lines.join('\n');
83
+ }
84
+
85
+ /**
86
+ * Write governance report summary as GitHub Actions output variables.
87
+ * @param {{ overall: string, subject?: object }} report
88
+ * @param {string} outputPath - Path to $GITHUB_OUTPUT file
89
+ * @returns {string[]} Array of key=value pairs written
90
+ */
91
+ export function writeGitHubOutputVars(report, outputPath) {
92
+ const run = report.subject?.run || {};
93
+ const pairs = [
94
+ `run_status=${report.overall || 'unknown'}`,
95
+ `run_id=${run.run_id || ''}`,
96
+ `phase=${run.phase || ''}`,
97
+ `blocked=${run.blocked_on ? 'true' : 'false'}`,
98
+ `turn_count=${(run.turns || []).length}`,
99
+ `decision_count=${(run.decisions || []).length}`,
100
+ ];
101
+
102
+ if (outputPath) {
103
+ appendFileSync(outputPath, pairs.join('\n') + '\n');
104
+ }
105
+
106
+ return pairs;
107
+ }
108
+
109
+ /**
110
+ * Format governance report as JUnit XML.
111
+ * @param {{ overall: string, subject?: object }} report
112
+ * @returns {string} JUnit XML string
113
+ */
114
+ export function formatJUnitXml(report) {
115
+ const run = report.subject?.run || {};
116
+ const gates = run.gate_summary || {};
117
+ const turns = run.turns || [];
118
+
119
+ // Escape XML special characters
120
+ const esc = (str) => String(str || '')
121
+ .replace(/&/g, '&')
122
+ .replace(/</g, '&lt;')
123
+ .replace(/>/g, '&gt;')
124
+ .replace(/"/g, '&quot;');
125
+
126
+ // Build gate test cases
127
+ const gateEntries = Object.entries(gates).filter(([, v]) => typeof v === 'string');
128
+ const gateFailures = gateEntries.filter(([, status]) => {
129
+ const s = status.toLowerCase();
130
+ return s !== 'satisfied' && s !== 'pass' && s !== 'passed';
131
+ });
132
+ const gateCases = gateEntries.map(([name, status]) => {
133
+ const s = status.toLowerCase();
134
+ const passed = s === 'satisfied' || s === 'pass' || s === 'passed';
135
+ if (passed) {
136
+ return ` <testcase name="${esc(name)}" classname="agentxchain.gates" time="0" />`;
137
+ }
138
+ return [
139
+ ` <testcase name="${esc(name)}" classname="agentxchain.gates" time="0">`,
140
+ ` <failure message="${esc(status)}">${esc(name)} gate status: ${esc(status)}</failure>`,
141
+ ' </testcase>',
142
+ ].join('\n');
143
+ });
144
+
145
+ // Build turn test cases
146
+ const turnFailures = turns.filter((t) => t.status === 'failed' || t.status === 'blocked');
147
+ const turnCases = turns.map((t) => {
148
+ const name = `${t.turn_id || 'unknown'} (${t.role || 'unknown'})`;
149
+ const time = typeof t.duration_seconds === 'number' ? t.duration_seconds.toFixed(1) : '0';
150
+ if (t.status === 'completed' || t.status === 'accepted') {
151
+ return ` <testcase name="${esc(name)}" classname="agentxchain.turns" time="${time}" />`;
152
+ }
153
+ return [
154
+ ` <testcase name="${esc(name)}" classname="agentxchain.turns" time="${time}">`,
155
+ ` <failure message="${esc(t.status || 'unknown')}">${esc(t.summary || t.status || 'turn did not complete successfully')}</failure>`,
156
+ ' </testcase>',
157
+ ].join('\n');
158
+ });
159
+
160
+ const totalTests = gateEntries.length + turns.length;
161
+ const totalFailures = gateFailures.length + turnFailures.length;
162
+ const totalTime = typeof run.duration_seconds === 'number' ? run.duration_seconds.toFixed(1) : '0';
163
+
164
+ const xml = [
165
+ '<?xml version="1.0" encoding="UTF-8"?>',
166
+ `<testsuites name="AgentXchain Governance" tests="${totalTests}" failures="${totalFailures}" errors="0" time="${totalTime}">`,
167
+ ` <testsuite name="Gates" tests="${gateEntries.length}" failures="${gateFailures.length}" errors="0">`,
168
+ ...gateCases,
169
+ ' </testsuite>',
170
+ ` <testsuite name="Turns" tests="${turns.length}" failures="${turnFailures.length}" errors="0">`,
171
+ ...turnCases,
172
+ ' </testsuite>',
173
+ '</testsuites>',
174
+ ].join('\n');
175
+
176
+ return xml;
177
+ }
178
+
179
+ /**
180
+ * Derive CI-appropriate exit code from governance report.
181
+ * @param {{ overall: string }} report
182
+ * @returns {number} 0 = pass, 1 = fail, 2 = error
183
+ */
184
+ export function deriveCIExitCode(report) {
185
+ if (report.overall === 'pass') return 0;
186
+ if (report.overall === 'fail') return 1;
187
+ return 2;
188
+ }
@@ -12,10 +12,23 @@ const CLAUDE_ENV_AUTH_KEYS = [
12
12
  const DEFAULT_SMOKE_PROBE_TIMEOUT_MS = 10_000;
13
13
  const DEFAULT_SMOKE_PROBE_STDIN = 'ok';
14
14
  const CLAUDE_AUTH_FAILURE_RE = /authentication_failed|authentication_error|invalid authentication credentials|unauthorized|API Error:\s*401/i;
15
+ const CODEX_AUTH_FAILURE_RE = /unauthorized|invalid api key|invalid_api_key|authentication failed|authentication_failed|openai[\s\S]{0,200}401|api[_ -]?key[\s\S]{0,200}invalid|401[\s\S]{0,200}(openai|invalid|unauthorized)/i;
15
16
  const CLAUDE_NODE_INCOMPATIBILITY_RE =
16
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;
17
18
  const CLAUDE_COMPATIBLE_NODE_MIN = { major: 20, minor: 5, patch: 0 };
18
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
+
19
32
  function normalizeCommandTokens(runtime) {
20
33
  if (Array.isArray(runtime?.command)) {
21
34
  return runtime.command.flatMap((element) =>
@@ -37,10 +50,50 @@ export function isClaudeLocalCliRuntime(runtime) {
37
50
  return head === 'claude' || head.endsWith('/claude');
38
51
  }
39
52
 
53
+ export function isCodexLocalCliRuntime(runtime) {
54
+ const tokens = normalizeCommandTokens(runtime);
55
+ if (tokens.length === 0) {
56
+ return false;
57
+ }
58
+ const head = tokens[0].toLowerCase();
59
+ return head === 'codex' || head.endsWith('/codex');
60
+ }
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
+
40
89
  export function hasClaudeAuthenticationFailureText(text) {
41
90
  return typeof text === 'string' && CLAUDE_AUTH_FAILURE_RE.test(text);
42
91
  }
43
92
 
93
+ export function hasCodexAuthenticationFailureText(text) {
94
+ return typeof text === 'string' && CODEX_AUTH_FAILURE_RE.test(text);
95
+ }
96
+
44
97
  export function hasClaudeNodeIncompatibilityText(text) {
45
98
  return typeof text === 'string' && CLAUDE_NODE_INCOMPATIBILITY_RE.test(text);
46
99
  }
@@ -49,6 +102,41 @@ export function hasClaudeBareFlag(runtime) {
49
102
  return normalizeCommandTokens(runtime).includes('--bare');
50
103
  }
51
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
+
52
140
  function parseNodeVersion(raw) {
53
141
  const match = String(raw || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
54
142
  if (!match) return null;
@@ -310,4 +398,4 @@ export async function runClaudeSmokeProbe(opts) {
310
398
  });
311
399
  }
312
400
 
313
- 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 = []) {
@@ -47,6 +47,7 @@ import { getDispatchLogPath, getTurnStagingResultPath } from './turn-paths.js';
47
47
  import { reconcileOperatorHead } from './operator-commit-reconcile.js';
48
48
  import { getContinuityStatus } from './continuity-status.js';
49
49
  import { resolveGovernedRole } from './role-resolution.js';
50
+ import { writeSessionCheckpoint } from './session-checkpoint.js';
50
51
  import {
51
52
  archiveStaleIntentsForRun,
52
53
  formatLegacyIntentMigrationNotice,
@@ -63,7 +64,7 @@ import {
63
64
 
64
65
  const CONTINUOUS_SESSION_PATH = '.agentxchain/continuous-session.json';
65
66
  const PRODUCTIVE_TIMEOUT_RETRY_MAX_PER_RUN = 1;
66
- const PRODUCTIVE_TIMEOUT_RETRY_DEADLINE_MINUTES = 60;
67
+ const PRODUCTIVE_TIMEOUT_RETRY_DEADLINE_MINUTES = 120;
67
68
  const PROVIDER_REQUEST_TIMEOUT_RE = /request timed out|timed out waiting for (?:provider|api)|provider request timed out/i;
68
69
 
69
70
  function getRoadmapReplenishmentTriageHints(root) {
@@ -636,9 +637,21 @@ function clearGhostBlockerAfterReissue(root, state) {
636
637
  escalation: null,
637
638
  };
638
639
  writeGovernedState(root, nextState);
640
+ writeSessionCheckpoint(root, nextState, 'blocker_cleared');
639
641
  return nextState;
640
642
  }
641
643
 
644
+ function isGovernedRunStillActiveForSession(root, config, session) {
645
+ try {
646
+ const state = loadProjectState(root, config);
647
+ if (!state || state.status !== 'active') return false;
648
+ if (session.current_run_id && state.run_id && session.current_run_id !== state.run_id) return false;
649
+ return true;
650
+ } catch {
651
+ return false;
652
+ }
653
+ }
654
+
642
655
  /**
643
656
  * Slice 2d (Turn 201): read the per-turn adapter dispatch log and return the
644
657
  * most recent stderr excerpt + exit code + signal from `process_exit` or
@@ -1253,7 +1266,7 @@ function reconcileContinuousStartupState(context, session, contOpts, log) {
1253
1266
  * @param {string} root
1254
1267
  * @param {string} visionPath - Absolute path to VISION.md
1255
1268
  * @param {{ triageApproval?: string }} options
1256
- * @returns {{ ok: boolean, intentId?: string, section?: string, goal?: string, error?: string, idle?: boolean }}
1269
+ * @returns {{ ok: boolean, intentId?: string, section?: string, goal?: string, source?: string, error?: string, idle?: boolean }}
1257
1270
  */
1258
1271
  export function seedFromVision(root, visionPath, options = {}) {
1259
1272
  const roadmapResult = deriveRoadmapCandidates(root);
@@ -1313,6 +1326,9 @@ export function seedFromVision(root, visionPath, options = {}) {
1313
1326
  reason: 'roadmap-open-work auto-approval',
1314
1327
  });
1315
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
+ }
1316
1332
  return { ok: false, error: `approve failed: ${approveResult.error}` };
1317
1333
  }
1318
1334
  }
@@ -1388,6 +1404,9 @@ export function seedFromVision(root, visionPath, options = {}) {
1388
1404
  reason: 'roadmap-replenishment auto-approval (BUG-77)',
1389
1405
  });
1390
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
+ }
1391
1410
  return { ok: false, error: `approve failed: ${approveResult.error}` };
1392
1411
  }
1393
1412
  }
@@ -1402,6 +1421,15 @@ export function seedFromVision(root, visionPath, options = {}) {
1402
1421
  };
1403
1422
  }
1404
1423
 
1424
+ if (exhaustion.reason === 'vision_fully_mapped' || exhaustion.reason === 'vision_no_actionable_scope') {
1425
+ return {
1426
+ ok: true,
1427
+ idle: true,
1428
+ source: 'vision_exhausted',
1429
+ reason: exhaustion.reason,
1430
+ };
1431
+ }
1432
+
1405
1433
  const result = deriveVisionCandidates(root, visionPath);
1406
1434
  if (!result.ok) {
1407
1435
  return { ok: false, error: result.error };
@@ -1462,6 +1490,9 @@ export function seedFromVision(root, visionPath, options = {}) {
1462
1490
  reason: 'vision-derived auto-approval',
1463
1491
  });
1464
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
+ }
1465
1496
  return { ok: false, error: `approve failed: ${approveResult.error}` };
1466
1497
  }
1467
1498
  }
@@ -2289,7 +2320,7 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
2289
2320
  if (seeded.source === 'roadmap_open_work') {
2290
2321
  log(`Roadmap-derived: ${visionObjective}`);
2291
2322
  } else if (seeded.source === 'roadmap_replenishment') {
2292
- log(`Roadmap-replenishment (roadmap exhausted, vision open): ${visionObjective}`);
2323
+ log(`Roadmap exhausted, vision still open, deriving next increment: ${visionObjective}`);
2293
2324
  } else {
2294
2325
  log(`Vision-derived: ${visionObjective}`);
2295
2326
  }
@@ -2551,6 +2582,23 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
2551
2582
  while (!stopping) {
2552
2583
  const step = await advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log);
2553
2584
 
2585
+ if (step.status === 'failed' && isGovernedRunStillActiveForSession(root, context.config, session)) {
2586
+ session.status = 'running';
2587
+ writeContinuousSession(root, session);
2588
+ emitRunEvent(root, 'session_failed_recovered_active_run', {
2589
+ run_id: session.current_run_id || null,
2590
+ phase: null,
2591
+ status: 'active',
2592
+ payload: {
2593
+ session_id: session.session_id,
2594
+ failed_action: step.action || null,
2595
+ failed_reason: step.stop_reason || null,
2596
+ },
2597
+ });
2598
+ log('Session failure recovered - governed run still active, continuing.');
2599
+ continue;
2600
+ }
2601
+
2554
2602
  // Terminal states
2555
2603
  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') {
2556
2604
  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 {
@@ -797,6 +797,9 @@ function renderContext(state, config, root, turn, role) {
797
797
  lines.push('');
798
798
  lines.push(`- **Turn:** ${lastTurn.turn_id}`);
799
799
  lines.push(`- **Role:** ${lastTurn.role}`);
800
+ if (lastTurn.runtime_id) {
801
+ lines.push(`- **Runtime:** ${lastTurn.runtime_id}`);
802
+ }
800
803
  lines.push(`- **Summary:** ${lastTurn.summary}`);
801
804
  if (lastTurn.decisions?.length) {
802
805
  lines.push('- **Decisions:**');
@@ -1412,12 +1415,13 @@ function renderDecisionHistory(root, warnings = []) {
1412
1415
  const lines = [];
1413
1416
  lines.push('## Decision History');
1414
1417
  lines.push('');
1415
- lines.push('| ID | Phase | Role | Statement |');
1416
- lines.push('|----|-------|------|-----------|');
1418
+ lines.push('| ID | Phase | Role | Runtime | Statement |');
1419
+ lines.push('|----|-------|------|---------|-----------|');
1417
1420
  for (const d of displayed) {
1418
1421
  // Escape pipes in statement to avoid breaking the table
1419
1422
  const stmt = (d.statement || '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
1420
- lines.push(`| ${d.id} | ${d.phase || ''} | ${d.role || ''} | ${stmt} |`);
1423
+ const runtimeId = (d.runtime_id || '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
1424
+ lines.push(`| ${d.id} | ${d.phase || ''} | ${d.role || ''} | ${runtimeId} | ${stmt} |`);
1421
1425
  }
1422
1426
  if (totalCount > DECISION_HISTORY_MAX_ENTRIES) {
1423
1427
  lines.push('');
@@ -199,6 +199,15 @@ export function createDispatchProgressTracker(root, turn, options = {}) {
199
199
  writeProgress();
200
200
  },
201
201
 
202
+ /** Record adapter keepalive without treating it as governed startup proof. */
203
+ heartbeat(summary = 'Adapter keepalive') {
204
+ state.activity_type = 'heartbeat';
205
+ state.activity_summary = summary;
206
+ state.last_activity_at = new Date().toISOString();
207
+ dirty = true;
208
+ maybeWrite();
209
+ },
210
+
202
211
  /** Update PID after spawn (local_cli). */
203
212
  setPid(newPid) {
204
213
  state.pid = newPid;
@@ -3672,7 +3672,7 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
3672
3672
  const baseline = captureBaseline(root);
3673
3673
 
3674
3674
  const now = new Date().toISOString();
3675
- const timeoutMinutes = 20;
3675
+ const timeoutMinutes = config?.timeouts?.per_turn_minutes || 120;
3676
3676
  const nextSequence = (state.turn_sequence || 0) + 1;
3677
3677
 
3678
3678
  // Record which turns are concurrent siblings (for conflict detection context)
@@ -3954,7 +3954,7 @@ export function reissueTurn(root, config, opts = {}) {
3954
3954
 
3955
3955
  // Create the new turn
3956
3956
  const newTurnId = `turn_${randomBytes(8).toString('hex')}`;
3957
- const timeoutMinutes = 20;
3957
+ const timeoutMinutes = config?.timeouts?.per_turn_minutes || 120;
3958
3958
  const nextSequence = (state.turn_sequence || 0) + 1;
3959
3959
 
3960
3960
  const newTurn = {
@@ -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) {
@@ -5238,6 +5238,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
5238
5238
  turn_id: turnResult.turn_id,
5239
5239
  role: turnResult.role,
5240
5240
  phase: state.phase,
5241
+ runtime_id: turnResult.runtime_id,
5241
5242
  category: decision.category,
5242
5243
  statement: decision.statement,
5243
5244
  rationale: decision.rationale,
@@ -6488,7 +6489,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
6488
6489
  const retryStartedAt = new Date().toISOString();
6489
6490
  retryTurn.baseline = captureBaseline(root);
6490
6491
  retryTurn.started_at = retryStartedAt;
6491
- retryTurn.deadline_at = new Date(Date.now() + 20 * 60 * 1000).toISOString();
6492
+ retryTurn.deadline_at = new Date(Date.now() + (config?.timeouts?.per_turn_minutes || 120) * 60 * 1000).toISOString();
6492
6493
 
6493
6494
  if (isConflictReject) {
6494
6495
  retryTurn.assigned_sequence = Math.max(
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
@@ -444,6 +444,8 @@ export function validateV4Config(data, projectRoot) {
444
444
  }
445
445
  if (rt.type === 'local_cli') {
446
446
  validateRuntimePositiveInteger(`Runtime "${id}": startup_watchdog_ms`, rt.startup_watchdog_ms, errors);
447
+ validateRuntimePositiveInteger(`Runtime "${id}": startup_heartbeat_ms`, rt.startup_heartbeat_ms, errors);
448
+ validateRuntimePositiveInteger(`Runtime "${id}": liveness_heartbeat_ms`, rt.liveness_heartbeat_ms, errors);
447
449
  }
448
450
  // Validate prompt_transport for local_cli runtimes
449
451
  if (rt.type === 'local_cli' && rt.prompt_transport) {
@@ -639,6 +641,8 @@ export function validateRunLoopConfig(runLoop) {
639
641
  return errors;
640
642
  }
641
643
  validateRunLoopPositiveInteger('run_loop.startup_watchdog_ms', runLoop.startup_watchdog_ms, errors);
644
+ validateRunLoopPositiveInteger('run_loop.startup_heartbeat_ms', runLoop.startup_heartbeat_ms, errors);
645
+ validateRunLoopPositiveInteger('run_loop.liveness_heartbeat_ms', runLoop.liveness_heartbeat_ms, errors);
642
646
  validateRunLoopPositiveInteger('run_loop.stale_turn_threshold_ms', runLoop.stale_turn_threshold_ms, errors);
643
647
  if (runLoop.continuous !== undefined && runLoop.continuous !== null) {
644
648
  validateRunLoopContinuousConfig('run_loop.continuous', runLoop.continuous, errors);