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,158 @@
1
+ const DOMAINS = ['ghost', 'budget', 'credential', 'crash'];
2
+ const OUTCOMES = ['recovered', 'exhausted', 'manual', 'pending'];
3
+
4
+ const EVENT_CLASSIFICATIONS = {
5
+ auto_retried_ghost: {
6
+ domain: 'ghost',
7
+ severity: 'medium',
8
+ outcome: 'recovered',
9
+ mechanism: 'auto_retry',
10
+ summary: 'Ghost turn reissued automatically',
11
+ },
12
+ ghost_retry_exhausted: {
13
+ domain: 'ghost',
14
+ severity: 'high',
15
+ outcome: 'exhausted',
16
+ mechanism: 'auto_retry',
17
+ summary: 'Ghost retry budget exhausted',
18
+ },
19
+ auto_retried_productive_timeout: {
20
+ domain: 'ghost',
21
+ severity: 'medium',
22
+ outcome: 'recovered',
23
+ mechanism: 'auto_retry',
24
+ summary: 'Productive timeout reissued automatically',
25
+ },
26
+ productive_timeout_retry_exhausted: {
27
+ domain: 'ghost',
28
+ severity: 'high',
29
+ outcome: 'exhausted',
30
+ mechanism: 'auto_retry',
31
+ summary: 'Productive timeout retry budget exhausted',
32
+ },
33
+ budget_exceeded_warn: {
34
+ domain: 'budget',
35
+ severity: 'medium',
36
+ outcome: 'pending',
37
+ mechanism: 'config_change',
38
+ summary: 'Budget warning threshold exceeded',
39
+ },
40
+ retained_claude_auth_escalation_reclassified: {
41
+ domain: 'credential',
42
+ severity: 'medium',
43
+ outcome: 'pending',
44
+ mechanism: 'env_refresh',
45
+ summary: 'Claude credential escalation reclassified',
46
+ },
47
+ continuous_paused_active_run_recovered: {
48
+ domain: 'crash',
49
+ severity: 'medium',
50
+ outcome: 'recovered',
51
+ mechanism: 'loop_guard',
52
+ summary: 'Paused continuous session recovered active run',
53
+ },
54
+ session_failed_recovered_active_run: {
55
+ domain: 'crash',
56
+ severity: 'medium',
57
+ outcome: 'recovered',
58
+ mechanism: 'loop_guard',
59
+ summary: 'Failed continuous step recovered active run',
60
+ },
61
+ };
62
+
63
+ function getEventPayload(event) {
64
+ return event && typeof event.payload === 'object' && !Array.isArray(event.payload)
65
+ ? event.payload
66
+ : {};
67
+ }
68
+
69
+ function escalateSeverity(eventType, payload, severity) {
70
+ if (eventType === 'ghost_retry_exhausted' && payload.exhaustion_reason === 'same_signature_repeat') {
71
+ return 'critical';
72
+ }
73
+ if (eventType === 'budget_exceeded_warn' && typeof payload.remaining_usd === 'number' && payload.remaining_usd <= 0) {
74
+ return 'high';
75
+ }
76
+ return severity;
77
+ }
78
+
79
+ function emptyOutcomeCounts() {
80
+ return Object.fromEntries(OUTCOMES.map((outcome) => [outcome, 0]));
81
+ }
82
+
83
+ function emptyDomainCounts() {
84
+ return Object.fromEntries(DOMAINS.map((domain) => [domain, { total: 0, ...emptyOutcomeCounts() }]));
85
+ }
86
+
87
+ function formatSummary(eventType, payload, fallback) {
88
+ if (payload.recovery_class && typeof payload.recovery_class === 'string') return payload.recovery_class;
89
+ if (payload.warning && typeof payload.warning === 'string') return payload.warning;
90
+ if (payload.recovery_action && typeof payload.recovery_action === 'string') return payload.recovery_action;
91
+ return fallback || eventType;
92
+ }
93
+
94
+ export function classifyRecoveryEvent(event) {
95
+ if (!event || typeof event !== 'object' || Array.isArray(event)) return null;
96
+ const eventType = event.event_type || event.type;
97
+ const base = EVENT_CLASSIFICATIONS[eventType];
98
+ if (!base) return null;
99
+
100
+ const payload = getEventPayload(event);
101
+ return {
102
+ domain: base.domain,
103
+ severity: escalateSeverity(eventType, payload, base.severity),
104
+ outcome: base.outcome,
105
+ mechanism: base.mechanism,
106
+ };
107
+ }
108
+
109
+ export function buildRecoveryClassificationReport(events) {
110
+ const byDomain = emptyDomainCounts();
111
+ const byOutcome = emptyOutcomeCounts();
112
+ const timeline = [];
113
+
114
+ for (const event of Array.isArray(events) ? events : []) {
115
+ const classification = classifyRecoveryEvent(event);
116
+ if (!classification) continue;
117
+
118
+ byDomain[classification.domain].total += 1;
119
+ byDomain[classification.domain][classification.outcome] += 1;
120
+ byOutcome[classification.outcome] += 1;
121
+
122
+ const eventType = event.event_type || event.type;
123
+ const payload = getEventPayload(event);
124
+ timeline.push({
125
+ event_id: event.event_id || null,
126
+ timestamp: event.timestamp || null,
127
+ event_type: eventType,
128
+ domain: classification.domain,
129
+ severity: classification.severity,
130
+ outcome: classification.outcome,
131
+ mechanism: classification.mechanism,
132
+ summary: formatSummary(eventType, payload, EVENT_CLASSIFICATIONS[eventType]?.summary),
133
+ });
134
+ }
135
+
136
+ timeline.sort((left, right) => {
137
+ const leftTime = Date.parse(left.timestamp || '');
138
+ const rightTime = Date.parse(right.timestamp || '');
139
+ const normalizedLeft = Number.isNaN(leftTime) ? Number.POSITIVE_INFINITY : leftTime;
140
+ const normalizedRight = Number.isNaN(rightTime) ? Number.POSITIVE_INFINITY : rightTime;
141
+ if (normalizedLeft !== normalizedRight) return normalizedLeft - normalizedRight;
142
+ return String(left.event_id || '').localeCompare(String(right.event_id || ''), 'en');
143
+ });
144
+
145
+ const totalRecoveryEvents = timeline.length;
146
+ const hasCritical = timeline.some((entry) => entry.severity === 'critical');
147
+ const healthScore = hasCritical || byOutcome.exhausted > byOutcome.recovered
148
+ ? 'critical'
149
+ : (byOutcome.exhausted > 0 || byOutcome.manual > 0 ? 'degraded' : 'healthy');
150
+
151
+ return {
152
+ total_recovery_events: totalRecoveryEvents,
153
+ by_domain: byDomain,
154
+ by_outcome: byOutcome,
155
+ timeline,
156
+ health_score: healthScore,
157
+ };
158
+ }
package/src/lib/report.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  import { buildCoordinatorRepoStatusEntries } from './coordinator-repo-status-presentation.js';
13
13
  import { summarizeCoordinatorEvent } from './coordinator-event-narrative.js';
14
14
  import { extractGateActionDigest } from './gate-actions.js';
15
+ import { buildRecoveryClassificationReport } from './recovery-classification.js';
15
16
 
16
17
  export const GOVERNANCE_REPORT_VERSION = '0.1';
17
18
 
@@ -703,6 +704,19 @@ function extractRecoverySummary(artifact) {
703
704
  };
704
705
  }
705
706
 
707
+ function extractRecoveryClassification(artifact) {
708
+ const report = buildRecoveryClassificationReport(extractRunEventTimeline(artifact));
709
+ return report.total_recovery_events > 0 ? report : null;
710
+ }
711
+
712
+ function formatRecoveryOutcomeCounts(counts) {
713
+ return `${counts.recovered} recovered, ${counts.exhausted} exhausted, ${counts.manual} manual, ${counts.pending} pending`;
714
+ }
715
+
716
+ function formatRecoveryDomainLabel(domain) {
717
+ return domain.charAt(0).toUpperCase() + domain.slice(1);
718
+ }
719
+
706
720
  function extractCoordinatorTimeline(artifact) {
707
721
  const data = extractFileData(artifact, '.agentxchain/multirepo/history.jsonl');
708
722
  if (!Array.isArray(data) || data.length === 0) return [];
@@ -1008,6 +1022,7 @@ function buildRunSubject(artifact) {
1008
1022
  const gateSummary = extractGateSummary(artifact);
1009
1023
  const intakeLinks = extractIntakeLinks(artifact);
1010
1024
  const recoverySummary = extractRecoverySummary(artifact);
1025
+ const recoveryClassification = extractRecoveryClassification(artifact);
1011
1026
  const nextActions = deriveGovernedRunNextActions(artifact.state, artifact.config);
1012
1027
  const continuity = extractContinuityMetadata(artifact);
1013
1028
  const governanceEvents = extractGovernanceEventDigest(artifact);
@@ -1057,6 +1072,7 @@ function buildRunSubject(artifact) {
1057
1072
  gate_summary: gateSummary,
1058
1073
  intake_links: intakeLinks,
1059
1074
  recovery_summary: recoverySummary,
1075
+ recovery_classification: recoveryClassification,
1060
1076
  next_actions: nextActions,
1061
1077
  continuity,
1062
1078
  workflow_kit_artifacts: extractWorkflowKitArtifacts(artifact),
@@ -1539,6 +1555,26 @@ export function formatGovernanceReportText(report) {
1539
1555
  }
1540
1556
  }
1541
1557
 
1558
+ if (run.recovery_classification) {
1559
+ const rc = run.recovery_classification;
1560
+ lines.push('', 'Recovery Classification:');
1561
+ lines.push(` Health: ${rc.health_score}`);
1562
+ lines.push(` Events: ${rc.total_recovery_events} total (${formatRecoveryOutcomeCounts(rc.by_outcome)})`);
1563
+ lines.push(' By Domain:');
1564
+ for (const [domain, counts] of Object.entries(rc.by_domain)) {
1565
+ lines.push(` ${formatRecoveryDomainLabel(domain)}: ${counts.total} (${formatRecoveryOutcomeCounts(counts)})`);
1566
+ }
1567
+ if (rc.timeline.length > 0) {
1568
+ const { items: boundedRecoveryEvents, omitted: recoveryEventsOmitted } = boundedSlice(rc.timeline);
1569
+ lines.push(' Timeline:');
1570
+ for (let i = 0; i < boundedRecoveryEvents.length; i++) {
1571
+ const evt = boundedRecoveryEvents[i];
1572
+ lines.push(` ${i + 1}. ${evt.timestamp || 'n/a'} | ${evt.domain} | ${evt.severity} | ${evt.outcome} | ${evt.mechanism} | ${evt.summary}`);
1573
+ }
1574
+ if (recoveryEventsOmitted > 0) lines.push(` (${recoveryEventsOmitted} more recovery events omitted)`);
1575
+ }
1576
+ }
1577
+
1542
1578
  if (run.next_actions && run.next_actions.length > 0) {
1543
1579
  lines.push('', 'Next Actions:');
1544
1580
  for (let i = 0; i < run.next_actions.length; i++) {
@@ -2137,6 +2173,27 @@ export function formatGovernanceReportMarkdown(report) {
2137
2173
  }
2138
2174
  }
2139
2175
 
2176
+ if (run.recovery_classification) {
2177
+ const rc = run.recovery_classification;
2178
+ lines.push('', '## Recovery Classification', '');
2179
+ lines.push(`- Health: \`${rc.health_score}\``);
2180
+ lines.push(`- Events: ${rc.total_recovery_events} total (${formatRecoveryOutcomeCounts(rc.by_outcome)})`);
2181
+ lines.push('', '| Domain | Total | Recovered | Exhausted | Manual | Pending |', '|--------|-------|-----------|-----------|--------|---------|');
2182
+ for (const [domain, counts] of Object.entries(rc.by_domain)) {
2183
+ lines.push(`| ${formatRecoveryDomainLabel(domain)} | ${counts.total} | ${counts.recovered} | ${counts.exhausted} | ${counts.manual} | ${counts.pending} |`);
2184
+ }
2185
+ if (rc.timeline.length > 0) {
2186
+ const { items: boundedRecoveryEvents, omitted: recoveryEventsOmitted } = boundedSlice(rc.timeline);
2187
+ lines.push('', '| # | Time | Domain | Severity | Outcome | Mechanism | Summary |', '|---|------|--------|----------|---------|-----------|---------|');
2188
+ for (let i = 0; i < boundedRecoveryEvents.length; i++) {
2189
+ const evt = boundedRecoveryEvents[i];
2190
+ const summary = (evt.summary || '').replace(/\|/g, '\\|');
2191
+ lines.push(`| ${i + 1} | \`${evt.timestamp || 'n/a'}\` | \`${evt.domain}\` | \`${evt.severity}\` | \`${evt.outcome}\` | \`${evt.mechanism}\` | ${summary} |`);
2192
+ }
2193
+ if (recoveryEventsOmitted > 0) lines.push('', `*(${recoveryEventsOmitted} more recovery events omitted)*`);
2194
+ }
2195
+ }
2196
+
2140
2197
  if (run.next_actions && run.next_actions.length > 0) {
2141
2198
  lines.push('', '## Next Actions', '');
2142
2199
  for (let i = 0; i < run.next_actions.length; i++) {
@@ -2877,6 +2934,40 @@ function renderRunHtml(report) {
2877
2934
  sections.push(`<div class="section">${htmlSection('Recovery', recoveryHtml)}</div>`);
2878
2935
  }
2879
2936
 
2937
+ if (run.recovery_classification) {
2938
+ const rc = run.recovery_classification;
2939
+ const domainRows = Object.entries(rc.by_domain).map(([domain, counts]) => [
2940
+ esc(formatRecoveryDomainLabel(domain)),
2941
+ String(counts.total),
2942
+ String(counts.recovered),
2943
+ String(counts.exhausted),
2944
+ String(counts.manual),
2945
+ String(counts.pending),
2946
+ ]);
2947
+ const { items: boundedRecoveryEvents, omitted: recoveryEventsOmitted } = boundedSlice(rc.timeline);
2948
+ const timelineRows = boundedRecoveryEvents.map((evt, index) => [
2949
+ String(index + 1),
2950
+ `<code>${esc(evt.timestamp || 'n/a')}</code>`,
2951
+ `<code>${esc(evt.domain)}</code>`,
2952
+ `<code>${esc(evt.severity)}</code>`,
2953
+ `<code>${esc(evt.outcome)}</code>`,
2954
+ `<code>${esc(evt.mechanism)}</code>`,
2955
+ esc(evt.summary || ''),
2956
+ ]);
2957
+ let classificationHtml = htmlDl([
2958
+ ['Health', `<code>${esc(rc.health_score)}</code>`],
2959
+ ['Events', `${rc.total_recovery_events} total (${esc(formatRecoveryOutcomeCounts(rc.by_outcome))})`],
2960
+ ]);
2961
+ classificationHtml += htmlSection('By Domain', htmlTable(['Domain', 'Total', 'Recovered', 'Exhausted', 'Manual', 'Pending'], domainRows), 3);
2962
+ if (timelineRows.length > 0) {
2963
+ classificationHtml += htmlSection('Timeline', htmlTable(['#', 'Time', 'Domain', 'Severity', 'Outcome', 'Mechanism', 'Summary'], timelineRows), 3);
2964
+ }
2965
+ if (recoveryEventsOmitted > 0) {
2966
+ classificationHtml += `<p><em>(${recoveryEventsOmitted} more recovery events omitted)</em></p>`;
2967
+ }
2968
+ sections.push(`<div class="section">${htmlSection('Recovery Classification', classificationHtml)}</div>`);
2969
+ }
2970
+
2880
2971
  if (run.next_actions?.length > 0) {
2881
2972
  const nextHtml = '<ol>' + run.next_actions.map((action) =>
2882
2973
  `<li><code>${esc(action.command)}</code>: ${esc(action.reason)}</li>`
@@ -8,6 +8,7 @@
8
8
  import { appendFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
9
9
  import { join, dirname } from 'node:path';
10
10
  import { randomBytes } from 'node:crypto';
11
+ import { classifyRecoveryEvent } from './recovery-classification.js';
11
12
 
12
13
  export const RUN_EVENTS_PATH = '.agentxchain/events.jsonl';
13
14
 
@@ -72,6 +73,11 @@ export const VALID_RUN_EVENTS = [
72
73
  */
73
74
  export function emitRunEvent(root, eventType, details = {}) {
74
75
  const event_id = `evt_${randomBytes(8).toString('hex')}`;
76
+ const payload = details.payload || {};
77
+ const recoveryClassification = classifyRecoveryEvent({ event_type: eventType, payload });
78
+ const classifiedPayload = recoveryClassification && !payload.recovery_classification
79
+ ? { ...payload, recovery_classification: recoveryClassification }
80
+ : payload;
75
81
  const entry = {
76
82
  event_id,
77
83
  event_type: eventType,
@@ -81,7 +87,7 @@ export function emitRunEvent(root, eventType, details = {}) {
81
87
  status: details.status || null,
82
88
  turn: details.turn || null,
83
89
  intent_id: details.intent_id || null,
84
- payload: details.payload || {},
90
+ payload: classifiedPayload,
85
91
  };
86
92
 
87
93
  try {
@@ -100,6 +100,11 @@
100
100
  "minimum": 1,
101
101
  "description": "Milliseconds to wait after dispatch for worker attach/first-output proof before retaining the turn as failed_start. Default 180000."
102
102
  },
103
+ "startup_heartbeat_ms": {
104
+ "type": "integer",
105
+ "minimum": 1,
106
+ "description": "Milliseconds between local_cli adapter startup keepalive diagnostics while a spawned subprocess is silent before first-output proof. Default 30000."
107
+ },
103
108
  "stale_turn_threshold_ms": {
104
109
  "type": "integer",
105
110
  "minimum": 1,
@@ -409,6 +414,11 @@
409
414
  "minimum": 1,
410
415
  "description": "Optional local_cli-specific override for the startup watchdog. When set, this runtime uses the declared threshold before falling back to run_loop.startup_watchdog_ms."
411
416
  },
417
+ "startup_heartbeat_ms": {
418
+ "type": "integer",
419
+ "minimum": 1,
420
+ "description": "Optional local_cli-specific startup keepalive diagnostic interval. When set, this runtime uses the declared interval before falling back to run_loop.startup_heartbeat_ms."
421
+ },
412
422
  "prompt_transport": {
413
423
  "enum": ["argv", "stdin", "dispatch_bundle_only"]
414
424
  },
@@ -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
+ }
@@ -201,14 +201,21 @@ function extractGitError(err) {
201
201
  return stderr || stdout || err?.message || 'git command failed';
202
202
  }
203
203
 
204
+ function normalizeRuntimeId(entry) {
205
+ return typeof entry?.runtime_id === 'string' && entry.runtime_id.trim()
206
+ ? entry.runtime_id.trim()
207
+ : null;
208
+ }
209
+
204
210
  function buildCheckpointCommit(entry) {
205
- const subject = `checkpoint: ${entry.turn_id} (role=${entry.role}, phase=${entry.phase})`;
211
+ const runtimeId = normalizeRuntimeId(entry) || '(unknown)';
212
+ const subject = `checkpoint: ${entry.turn_id} (role=${entry.role}, phase=${entry.phase}, runtime=${runtimeId})`;
206
213
  const bodyLines = [
207
214
  `Summary: ${entry.summary || '(none)'}`,
208
215
  `Turn-ID: ${entry.turn_id}`,
209
216
  `Role: ${entry.role || '(unknown)'}`,
210
217
  `Phase: ${entry.phase || '(unknown)'}`,
211
- `Runtime: ${entry.runtime_id || '(unknown)'}`,
218
+ `Runtime: ${runtimeId}`,
212
219
  ];
213
220
  if (entry.intent_id) bodyLines.push(`Intent-ID: ${entry.intent_id}`);
214
221
  if (entry.accepted_at) bodyLines.push(`Accepted-At: ${entry.accepted_at}`);
@@ -345,6 +352,27 @@ export function checkpointAcceptedTurn(root, opts = {}) {
345
352
  }
346
353
 
347
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
+
348
376
  const supplementalFilesChanged = entry.checkpoint_sha
349
377
  ? recoverSupplementalCheckpointFiles(root, entry)
350
378
  : [];
@@ -450,6 +478,7 @@ export function checkpointAcceptedTurn(root, opts = {}) {
450
478
 
451
479
  const checkpointSha = git(root, ['rev-parse', 'HEAD']);
452
480
  const checkpointedAt = new Date().toISOString();
481
+ const runtimeId = normalizeRuntimeId(entry);
453
482
 
454
483
  const historyEntries = readHistoryEntries(root).map((historyEntry) => (
455
484
  historyEntry.turn_id === entry.turn_id
@@ -470,6 +499,7 @@ export function checkpointAcceptedTurn(root, opts = {}) {
470
499
  turn_id: entry.turn_id,
471
500
  role: entry.role || null,
472
501
  phase: entry.phase || null,
502
+ runtime_id: runtimeId,
473
503
  checkpoint_sha: checkpointSha,
474
504
  checkpointed_at: checkpointedAt,
475
505
  intent_id: entry.intent_id || null,
@@ -480,7 +510,7 @@ export function checkpointAcceptedTurn(root, opts = {}) {
480
510
  run_id: state.run_id || null,
481
511
  phase: state.phase || null,
482
512
  status: state.status || null,
483
- turn: { turn_id: entry.turn_id, role_id: entry.role || null },
513
+ turn: { turn_id: entry.turn_id, role_id: entry.role || null, runtime_id: runtimeId },
484
514
  intent_id: entry.intent_id || null,
485
515
  payload: { checkpoint_sha: checkpointSha, checkpointed_at: checkpointedAt },
486
516
  });