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.
- package/README.md +4 -8
- 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 +4 -5
- package/scripts/migrate-node-test-to-vitest.mjs +98 -0
- package/scripts/release-postflight.sh +1 -1
- package/scripts/release-preflight.sh +5 -5
- package/scripts/verify-post-publish.sh +1 -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 +50 -0
- package/src/commands/serve.js +64 -0
- package/src/commands/step.js +63 -1
- package/src/commands/verify.js +1 -0
- package/src/lib/adapters/local-cli-adapter.js +326 -2
- 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 +89 -1
- package/src/lib/connector-probe.js +21 -0
- package/src/lib/continuous-run.js +51 -3
- package/src/lib/dashboard/bridge-server.js +10 -5
- package/src/lib/dispatch-bundle.js +7 -3
- package/src/lib/dispatch-progress.js +9 -0
- package/src/lib/governed-state.js +5 -4
- package/src/lib/intake.js +32 -6
- package/src/lib/normalized-config.js +4 -0
- package/src/lib/recovery-classification.js +158 -0
- package/src/lib/report.js +91 -0
- package/src/lib/run-events.js +7 -1
- package/src/lib/schemas/agentxchain-config.schema.json +10 -0
- package/src/lib/scope-overlap.js +214 -0
- package/src/lib/turn-checkpoint.js +33 -3
- package/src/lib/turn-result-validator.js +47 -6
- package/src/lib/validation.js +11 -3
- package/src/lib/verification-replay.js +125 -4
- 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>`
|
package/src/lib/run-events.js
CHANGED
|
@@ -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:
|
|
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
|
|
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: ${
|
|
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
|
});
|