agentxchain 2.137.0 → 2.138.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/package.json +3 -2
- package/src/commands/migrate-intents.js +192 -39
- package/src/commands/restart.js +3 -0
- package/src/commands/resume.js +15 -0
- package/src/commands/step.js +12 -0
- package/src/lib/continuous-run.js +15 -0
- package/src/lib/governed-state.js +33 -1
- package/src/lib/intent-startup-migration.js +116 -0
- package/src/lib/recent-event-summary.js +7 -0
- package/src/lib/run-events.js +2 -0
- package/src/lib/schemas/workflow-kit-output.schema.json +139 -0
- package/src/lib/turn-checkpoint.js +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentxchain",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.138.0",
|
|
4
4
|
"description": "CLI for AgentXchain — governed multi-agent software delivery",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"./runner-interface": "./src/lib/runner-interface.js",
|
|
13
13
|
"./run-loop": "./src/lib/run-loop.js",
|
|
14
14
|
"./schemas/agentxchain-config": "./src/lib/schemas/agentxchain-config.schema.json",
|
|
15
|
-
"./schemas/connector-capabilities-output": "./src/lib/schemas/connector-capabilities-output.schema.json"
|
|
15
|
+
"./schemas/connector-capabilities-output": "./src/lib/schemas/connector-capabilities-output.schema.json",
|
|
16
|
+
"./schemas/workflow-kit-output": "./src/lib/schemas/workflow-kit-output.schema.json"
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
18
19
|
"bin/",
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One-shot repair command for legacy
|
|
2
|
+
* One-shot repair command for legacy and phantom intents.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Handles two classes of stuck intents:
|
|
5
|
+
* 1. Legacy (null-scoped): approved_run_id is null — pre-BUG-34 intents
|
|
6
|
+
* 2. Phantom: approved_run_id matches current run but planning artifacts
|
|
7
|
+
* already exist on disk — would fail with "existing planning artifacts
|
|
8
|
+
* would be overwritten" if dispatched (BUG-42)
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
@@ -11,7 +13,10 @@ import { join } from 'node:path';
|
|
|
11
13
|
import chalk from 'chalk';
|
|
12
14
|
|
|
13
15
|
import { findProjectRoot } from '../lib/config.js';
|
|
14
|
-
import { migratePreBug34Intents } from '../lib/intent-startup-migration.js';
|
|
16
|
+
import { isPhantomIntent, migratePreBug34Intents } from '../lib/intent-startup-migration.js';
|
|
17
|
+
import { safeWriteJson } from '../lib/safe-write.js';
|
|
18
|
+
|
|
19
|
+
const MIGRATE_INTENTS_SCOPE = 'legacy_and_phantom';
|
|
15
20
|
|
|
16
21
|
function loadRunId(root) {
|
|
17
22
|
const statePath = join(root, '.agentxchain', 'state.json');
|
|
@@ -48,6 +53,112 @@ function listLegacyIntents(root) {
|
|
|
48
53
|
return results;
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
function listRunScopedIntents(root) {
|
|
57
|
+
const intentsDir = join(root, '.agentxchain', 'intake', 'intents');
|
|
58
|
+
if (!existsSync(intentsDir)) return [];
|
|
59
|
+
|
|
60
|
+
const DISPATCHABLE = new Set(['planned', 'approved']);
|
|
61
|
+
const results = [];
|
|
62
|
+
|
|
63
|
+
for (const file of readdirSync(intentsDir)) {
|
|
64
|
+
if (!file.endsWith('.json') || file.startsWith('.tmp-')) continue;
|
|
65
|
+
const intentPath = join(intentsDir, file);
|
|
66
|
+
let intent;
|
|
67
|
+
try {
|
|
68
|
+
intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
69
|
+
} catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!intent || !DISPATCHABLE.has(intent.status)) continue;
|
|
73
|
+
if (intent.cross_run_durable === true) continue;
|
|
74
|
+
if (!intent.approved_run_id) continue;
|
|
75
|
+
results.push({
|
|
76
|
+
file,
|
|
77
|
+
intent_id: intent.intent_id || file.replace('.json', ''),
|
|
78
|
+
status: intent.status,
|
|
79
|
+
approved_run_id: intent.approved_run_id,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return results;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function listPhantomIntents(root, runId) {
|
|
86
|
+
const intentsDir = join(root, '.agentxchain', 'intake', 'intents');
|
|
87
|
+
if (!existsSync(intentsDir)) return [];
|
|
88
|
+
|
|
89
|
+
const DISPATCHABLE = new Set(['planned', 'approved']);
|
|
90
|
+
const results = [];
|
|
91
|
+
|
|
92
|
+
for (const file of readdirSync(intentsDir)) {
|
|
93
|
+
if (!file.endsWith('.json') || file.startsWith('.tmp-')) continue;
|
|
94
|
+
const intentPath = join(intentsDir, file);
|
|
95
|
+
let intent;
|
|
96
|
+
try {
|
|
97
|
+
intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
98
|
+
} catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!intent || !DISPATCHABLE.has(intent.status)) continue;
|
|
102
|
+
if (!intent.approved_run_id) continue;
|
|
103
|
+
// Only check intents bound to the current run — intents from other runs
|
|
104
|
+
// are a different reconciliation concern (stale cross-run archival).
|
|
105
|
+
if (runId && intent.approved_run_id !== runId) continue;
|
|
106
|
+
if (isPhantomIntent(root, intent)) {
|
|
107
|
+
results.push({
|
|
108
|
+
file,
|
|
109
|
+
intent_id: intent.intent_id || file.replace('.json', ''),
|
|
110
|
+
status: intent.status,
|
|
111
|
+
approved_run_id: intent.approved_run_id,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return results;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function supersedePhantomIntents(root, phantomIntents) {
|
|
119
|
+
const intentsDir = join(root, '.agentxchain', 'intake', 'intents');
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
const supersededIds = [];
|
|
122
|
+
|
|
123
|
+
for (const phantom of phantomIntents) {
|
|
124
|
+
const intentPath = join(intentsDir, phantom.file);
|
|
125
|
+
let intent;
|
|
126
|
+
try {
|
|
127
|
+
intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
128
|
+
} catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const prevStatus = intent.status;
|
|
133
|
+
intent.status = 'superseded';
|
|
134
|
+
intent.updated_at = now;
|
|
135
|
+
intent.archived_reason = 'planning artifacts for this intent already exist on disk; intent superseded';
|
|
136
|
+
if (!intent.history) intent.history = [];
|
|
137
|
+
intent.history.push({
|
|
138
|
+
from: prevStatus,
|
|
139
|
+
to: 'superseded',
|
|
140
|
+
at: now,
|
|
141
|
+
reason: intent.archived_reason,
|
|
142
|
+
});
|
|
143
|
+
safeWriteJson(intentPath, intent);
|
|
144
|
+
supersededIds.push(phantom.intent_id);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return supersededIds;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildJsonResult({ archivedCount, archivedIntentIds, phantomCount, phantomIntentIds, dryRun, message }) {
|
|
151
|
+
return {
|
|
152
|
+
archived_count: archivedCount,
|
|
153
|
+
archived_intent_ids: archivedIntentIds,
|
|
154
|
+
phantom_superseded_count: phantomCount,
|
|
155
|
+
phantom_superseded_intent_ids: phantomIntentIds,
|
|
156
|
+
scope: MIGRATE_INTENTS_SCOPE,
|
|
157
|
+
dry_run: dryRun,
|
|
158
|
+
message,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
51
162
|
export function migrateIntentsCommand(opts) {
|
|
52
163
|
const root = findProjectRoot();
|
|
53
164
|
if (!root) {
|
|
@@ -59,63 +170,105 @@ export function migrateIntentsCommand(opts) {
|
|
|
59
170
|
process.exit(1);
|
|
60
171
|
}
|
|
61
172
|
|
|
173
|
+
const runId = loadRunId(root);
|
|
62
174
|
const legacyIntents = listLegacyIntents(root);
|
|
175
|
+
const phantomIntents = listPhantomIntents(root, runId);
|
|
176
|
+
const totalIssues = legacyIntents.length + phantomIntents.length;
|
|
63
177
|
|
|
64
178
|
if (opts.dryRun) {
|
|
179
|
+
const msg = [];
|
|
180
|
+
if (legacyIntents.length > 0) msg.push(`Would archive ${legacyIntents.length} legacy intent(s)`);
|
|
181
|
+
if (phantomIntents.length > 0) msg.push(`Would supersede ${phantomIntents.length} phantom intent(s)`);
|
|
182
|
+
const message = msg.length > 0 ? msg.join('; ') : 'No legacy or phantom intents found';
|
|
183
|
+
|
|
65
184
|
if (opts.json) {
|
|
66
|
-
console.log(JSON.stringify({
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}, null, 2));
|
|
185
|
+
console.log(JSON.stringify(buildJsonResult({
|
|
186
|
+
archivedCount: legacyIntents.length,
|
|
187
|
+
archivedIntentIds: legacyIntents.map((i) => i.intent_id),
|
|
188
|
+
phantomCount: phantomIntents.length,
|
|
189
|
+
phantomIntentIds: phantomIntents.map((i) => i.intent_id),
|
|
190
|
+
dryRun: true,
|
|
191
|
+
message,
|
|
192
|
+
}), null, 2));
|
|
74
193
|
} else {
|
|
75
|
-
if (
|
|
76
|
-
console.log(chalk.green(' No legacy intents found. Nothing to migrate.'));
|
|
194
|
+
if (totalIssues === 0) {
|
|
195
|
+
console.log(chalk.green(' No legacy or phantom intents found. Nothing to migrate.'));
|
|
77
196
|
} else {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
197
|
+
if (legacyIntents.length > 0) {
|
|
198
|
+
console.log(chalk.yellow(` Would archive ${legacyIntents.length} legacy intent(s):`));
|
|
199
|
+
for (const item of legacyIntents) {
|
|
200
|
+
console.log(` ${chalk.dim('•')} ${item.intent_id} (${item.status})`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (phantomIntents.length > 0) {
|
|
204
|
+
console.log(chalk.yellow(` Would supersede ${phantomIntents.length} phantom intent(s) (planning artifacts already exist):`));
|
|
205
|
+
for (const item of phantomIntents) {
|
|
206
|
+
console.log(` ${chalk.dim('•')} ${item.intent_id} (${item.status}, run=${item.approved_run_id})`);
|
|
207
|
+
}
|
|
81
208
|
}
|
|
82
209
|
}
|
|
83
210
|
}
|
|
84
211
|
return;
|
|
85
212
|
}
|
|
86
213
|
|
|
87
|
-
if (
|
|
214
|
+
if (totalIssues === 0) {
|
|
88
215
|
if (opts.json) {
|
|
89
|
-
console.log(JSON.stringify({
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
216
|
+
console.log(JSON.stringify(buildJsonResult({
|
|
217
|
+
archivedCount: 0,
|
|
218
|
+
archivedIntentIds: [],
|
|
219
|
+
phantomCount: 0,
|
|
220
|
+
phantomIntentIds: [],
|
|
221
|
+
dryRun: false,
|
|
222
|
+
message: 'No legacy or phantom intents found',
|
|
223
|
+
}), null, 2));
|
|
95
224
|
} else {
|
|
96
|
-
console.log(chalk.green(' No legacy intents found. Nothing to migrate.'));
|
|
225
|
+
console.log(chalk.green(' No legacy or phantom intents found. Nothing to migrate.'));
|
|
97
226
|
}
|
|
98
227
|
return;
|
|
99
228
|
}
|
|
100
229
|
|
|
101
|
-
|
|
102
|
-
const
|
|
230
|
+
// Archive legacy intents
|
|
231
|
+
const legacyResult = legacyIntents.length > 0
|
|
232
|
+
? migratePreBug34Intents(root, runId)
|
|
233
|
+
: { archived_migration_count: 0, archived_migration_intent_ids: [], migration_notice: null };
|
|
234
|
+
|
|
235
|
+
// Supersede phantom intents
|
|
236
|
+
const phantomSupersededIds = phantomIntents.length > 0
|
|
237
|
+
? supersedePhantomIntents(root, phantomIntents)
|
|
238
|
+
: [];
|
|
239
|
+
|
|
240
|
+
const messages = [];
|
|
241
|
+
if (legacyResult.archived_migration_count > 0) {
|
|
242
|
+
messages.push(legacyResult.migration_notice || `Archived ${legacyResult.archived_migration_count} legacy intent(s)`);
|
|
243
|
+
}
|
|
244
|
+
if (phantomSupersededIds.length > 0) {
|
|
245
|
+
messages.push(`Superseded ${phantomSupersededIds.length} phantom intent(s)`);
|
|
246
|
+
}
|
|
103
247
|
|
|
104
248
|
if (opts.json) {
|
|
105
|
-
console.log(JSON.stringify({
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
249
|
+
console.log(JSON.stringify(buildJsonResult({
|
|
250
|
+
archivedCount: legacyResult.archived_migration_count,
|
|
251
|
+
archivedIntentIds: legacyResult.archived_migration_intent_ids,
|
|
252
|
+
phantomCount: phantomSupersededIds.length,
|
|
253
|
+
phantomIntentIds: phantomSupersededIds,
|
|
254
|
+
dryRun: false,
|
|
255
|
+
message: messages.join('; ') || 'No legacy or phantom intents found',
|
|
256
|
+
}), null, 2));
|
|
111
257
|
} else {
|
|
112
|
-
if (
|
|
113
|
-
console.log(chalk.green(
|
|
114
|
-
|
|
115
|
-
console.log(chalk.green(` ✓ ${result.migration_notice}`));
|
|
116
|
-
for (const id of result.archived_migration_intent_ids) {
|
|
258
|
+
if (legacyResult.archived_migration_count > 0) {
|
|
259
|
+
console.log(chalk.green(` ✓ ${legacyResult.migration_notice}`));
|
|
260
|
+
for (const id of legacyResult.archived_migration_intent_ids) {
|
|
117
261
|
console.log(` ${chalk.dim('•')} ${id}`);
|
|
118
262
|
}
|
|
119
263
|
}
|
|
264
|
+
if (phantomSupersededIds.length > 0) {
|
|
265
|
+
console.log(chalk.green(` ✓ Superseded ${phantomSupersededIds.length} phantom intent(s)`));
|
|
266
|
+
for (const id of phantomSupersededIds) {
|
|
267
|
+
console.log(` ${chalk.dim('•')} ${id}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (legacyResult.archived_migration_count === 0 && phantomSupersededIds.length === 0) {
|
|
271
|
+
console.log(chalk.green(' No legacy or phantom intents found. Nothing to migrate.'));
|
|
272
|
+
}
|
|
120
273
|
}
|
|
121
274
|
}
|
package/src/commands/restart.js
CHANGED
|
@@ -329,6 +329,9 @@ export async function restartCommand(opts) {
|
|
|
329
329
|
if (reactivated.migration_notice) {
|
|
330
330
|
console.log(chalk.yellow(reactivated.migration_notice));
|
|
331
331
|
}
|
|
332
|
+
if (reactivated.phantom_notice) {
|
|
333
|
+
console.log(chalk.yellow(reactivated.phantom_notice));
|
|
334
|
+
}
|
|
332
335
|
}
|
|
333
336
|
|
|
334
337
|
// Determine role from option or routing
|
package/src/commands/resume.js
CHANGED
|
@@ -147,6 +147,9 @@ export async function resumeCommand(opts) {
|
|
|
147
147
|
if (reactivated.migration_notice) {
|
|
148
148
|
console.log(chalk.yellow(reactivated.migration_notice));
|
|
149
149
|
}
|
|
150
|
+
if (reactivated.phantom_notice) {
|
|
151
|
+
console.log(chalk.yellow(reactivated.phantom_notice));
|
|
152
|
+
}
|
|
150
153
|
|
|
151
154
|
// Write dispatch bundle for the existing turn
|
|
152
155
|
const bundleResult = writeDispatchBundle(root, state, config);
|
|
@@ -210,6 +213,9 @@ export async function resumeCommand(opts) {
|
|
|
210
213
|
if (reactivated.migration_notice) {
|
|
211
214
|
console.log(chalk.yellow(reactivated.migration_notice));
|
|
212
215
|
}
|
|
216
|
+
if (reactivated.phantom_notice) {
|
|
217
|
+
console.log(chalk.yellow(reactivated.phantom_notice));
|
|
218
|
+
}
|
|
213
219
|
|
|
214
220
|
const bundleResult = writeDispatchBundle(root, state, config, { turnId: retainedTurn.turn_id });
|
|
215
221
|
if (!bundleResult.ok) {
|
|
@@ -242,6 +248,9 @@ export async function resumeCommand(opts) {
|
|
|
242
248
|
if (initResult.migration_notice) {
|
|
243
249
|
console.log(chalk.yellow(initResult.migration_notice));
|
|
244
250
|
}
|
|
251
|
+
if (initResult.phantom_notice) {
|
|
252
|
+
console.log(chalk.yellow(initResult.phantom_notice));
|
|
253
|
+
}
|
|
245
254
|
}
|
|
246
255
|
|
|
247
256
|
// §47: paused + run_id exists → resume same run
|
|
@@ -256,6 +265,9 @@ export async function resumeCommand(opts) {
|
|
|
256
265
|
if (reactivated.migration_notice) {
|
|
257
266
|
console.log(chalk.yellow(reactivated.migration_notice));
|
|
258
267
|
}
|
|
268
|
+
if (reactivated.phantom_notice) {
|
|
269
|
+
console.log(chalk.yellow(reactivated.phantom_notice));
|
|
270
|
+
}
|
|
259
271
|
}
|
|
260
272
|
|
|
261
273
|
// §47: paused + run_id exists → resume same run
|
|
@@ -270,6 +282,9 @@ export async function resumeCommand(opts) {
|
|
|
270
282
|
if (reactivated.migration_notice) {
|
|
271
283
|
console.log(chalk.yellow(reactivated.migration_notice));
|
|
272
284
|
}
|
|
285
|
+
if (reactivated.phantom_notice) {
|
|
286
|
+
console.log(chalk.yellow(reactivated.phantom_notice));
|
|
287
|
+
}
|
|
273
288
|
}
|
|
274
289
|
|
|
275
290
|
// Print run-context header before dispatch
|
package/src/commands/step.js
CHANGED
|
@@ -263,6 +263,9 @@ export async function stepCommand(opts) {
|
|
|
263
263
|
if (reactivated.migration_notice) {
|
|
264
264
|
console.log(chalk.yellow(reactivated.migration_notice));
|
|
265
265
|
}
|
|
266
|
+
if (reactivated.phantom_notice) {
|
|
267
|
+
console.log(chalk.yellow(reactivated.phantom_notice));
|
|
268
|
+
}
|
|
266
269
|
skipAssignment = true;
|
|
267
270
|
|
|
268
271
|
// BUG-1 fix: refresh baseline snapshot to capture files dirtied between assignment and dispatch
|
|
@@ -291,6 +294,9 @@ export async function stepCommand(opts) {
|
|
|
291
294
|
if (initResult.migration_notice) {
|
|
292
295
|
console.log(chalk.yellow(initResult.migration_notice));
|
|
293
296
|
}
|
|
297
|
+
if (initResult.phantom_notice) {
|
|
298
|
+
console.log(chalk.yellow(initResult.phantom_notice));
|
|
299
|
+
}
|
|
294
300
|
}
|
|
295
301
|
|
|
296
302
|
// paused → resume
|
|
@@ -305,6 +311,9 @@ export async function stepCommand(opts) {
|
|
|
305
311
|
if (reactivated.migration_notice) {
|
|
306
312
|
console.log(chalk.yellow(reactivated.migration_notice));
|
|
307
313
|
}
|
|
314
|
+
if (reactivated.phantom_notice) {
|
|
315
|
+
console.log(chalk.yellow(reactivated.phantom_notice));
|
|
316
|
+
}
|
|
308
317
|
}
|
|
309
318
|
|
|
310
319
|
if (!skipAssignment && state.status === 'paused' && state.run_id) {
|
|
@@ -318,6 +327,9 @@ export async function stepCommand(opts) {
|
|
|
318
327
|
if (reactivated.migration_notice) {
|
|
319
328
|
console.log(chalk.yellow(reactivated.migration_notice));
|
|
320
329
|
}
|
|
330
|
+
if (reactivated.phantom_notice) {
|
|
331
|
+
console.log(chalk.yellow(reactivated.phantom_notice));
|
|
332
|
+
}
|
|
321
333
|
}
|
|
322
334
|
|
|
323
335
|
// Assign the turn
|
|
@@ -28,6 +28,7 @@ import { emitRunEvent } from './run-events.js';
|
|
|
28
28
|
import {
|
|
29
29
|
archiveStaleIntentsForRun,
|
|
30
30
|
formatLegacyIntentMigrationNotice,
|
|
31
|
+
formatPhantomIntentSupersessionNotice,
|
|
31
32
|
} from './intent-startup-migration.js';
|
|
32
33
|
|
|
33
34
|
const CONTINUOUS_SESSION_PATH = '.agentxchain/continuous-session.json';
|
|
@@ -172,6 +173,20 @@ function reconcileContinuousStartupState(context, session, contOpts, log) {
|
|
|
172
173
|
const migrationNotice = formatLegacyIntentMigrationNotice(startupIntents.archived_migration_intent_ids);
|
|
173
174
|
if (migrationNotice) log(migrationNotice);
|
|
174
175
|
}
|
|
176
|
+
if (startupIntents.phantom_superseded_intent_ids?.length > 0) {
|
|
177
|
+
emitRunEvent(root, 'intents_superseded', {
|
|
178
|
+
run_id: scopedRunId,
|
|
179
|
+
phase: governedState?.phase || null,
|
|
180
|
+
status: governedState?.status || 'active',
|
|
181
|
+
payload: {
|
|
182
|
+
superseded_count: startupIntents.phantom_superseded_intent_ids.length,
|
|
183
|
+
superseded_intent_ids: startupIntents.phantom_superseded_intent_ids,
|
|
184
|
+
reason: 'approved intents already satisfied by on-disk planning artifacts superseded during continuous startup',
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
const phantomNotice = formatPhantomIntentSupersessionNotice(startupIntents.phantom_superseded_intent_ids);
|
|
188
|
+
if (phantomNotice) log(phantomNotice);
|
|
189
|
+
}
|
|
175
190
|
if (session.startup_reconciled_run_id !== scopedRunId) {
|
|
176
191
|
session.startup_reconciled_run_id = scopedRunId;
|
|
177
192
|
sessionChanged = true;
|
|
@@ -49,6 +49,7 @@ import { buildDefaultRunProvenance } from './run-provenance.js';
|
|
|
49
49
|
import {
|
|
50
50
|
archiveStaleIntentsForRun,
|
|
51
51
|
formatLegacyIntentMigrationNotice,
|
|
52
|
+
formatPhantomIntentSupersessionNotice,
|
|
52
53
|
} from './intent-startup-migration.js';
|
|
53
54
|
import {
|
|
54
55
|
ensureHumanEscalation,
|
|
@@ -2098,6 +2099,18 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
2098
2099
|
},
|
|
2099
2100
|
});
|
|
2100
2101
|
}
|
|
2102
|
+
if (startupIntents.phantom_superseded_intent_ids?.length > 0) {
|
|
2103
|
+
emitRunEvent(root, 'intents_superseded', {
|
|
2104
|
+
run_id: nextState.run_id,
|
|
2105
|
+
phase: nextState.phase,
|
|
2106
|
+
status: nextState.status,
|
|
2107
|
+
payload: {
|
|
2108
|
+
superseded_count: startupIntents.phantom_superseded_intent_ids.length,
|
|
2109
|
+
superseded_intent_ids: startupIntents.phantom_superseded_intent_ids,
|
|
2110
|
+
reason: 'approved intents already satisfied by on-disk planning artifacts superseded during run reactivation',
|
|
2111
|
+
},
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2101
2114
|
|
|
2102
2115
|
if (humanEscalation) {
|
|
2103
2116
|
resolveHumanEscalation(root, humanEscalation.escalation_id, {
|
|
@@ -2144,6 +2157,7 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
2144
2157
|
ok: true,
|
|
2145
2158
|
state: attachLegacyCurrentTurnAlias(nextState),
|
|
2146
2159
|
migration_notice: formatLegacyIntentMigrationNotice(startupIntents.archived_migration_intent_ids),
|
|
2160
|
+
phantom_notice: formatPhantomIntentSupersessionNotice(startupIntents.phantom_superseded_intent_ids),
|
|
2147
2161
|
};
|
|
2148
2162
|
}
|
|
2149
2163
|
|
|
@@ -2216,6 +2230,18 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2216
2230
|
},
|
|
2217
2231
|
});
|
|
2218
2232
|
}
|
|
2233
|
+
if (startupIntents.phantom_superseded_intent_ids?.length > 0) {
|
|
2234
|
+
emitRunEvent(root, 'intents_superseded', {
|
|
2235
|
+
run_id: runId,
|
|
2236
|
+
phase: updatedState.phase,
|
|
2237
|
+
status: 'active',
|
|
2238
|
+
payload: {
|
|
2239
|
+
superseded_count: startupIntents.phantom_superseded_intent_ids.length,
|
|
2240
|
+
superseded_intent_ids: startupIntents.phantom_superseded_intent_ids,
|
|
2241
|
+
reason: 'approved intents already satisfied by on-disk planning artifacts superseded during run initialization',
|
|
2242
|
+
},
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2219
2245
|
|
|
2220
2246
|
emitRunEvent(root, 'run_started', {
|
|
2221
2247
|
run_id: runId,
|
|
@@ -2225,8 +2251,14 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2225
2251
|
});
|
|
2226
2252
|
// BUG-39: return migration notice so callers can display it
|
|
2227
2253
|
const migrationNotice = startupIntents.migration_notice;
|
|
2254
|
+
const phantomNotice = formatPhantomIntentSupersessionNotice(startupIntents.phantom_superseded_intent_ids);
|
|
2228
2255
|
|
|
2229
|
-
return {
|
|
2256
|
+
return {
|
|
2257
|
+
ok: true,
|
|
2258
|
+
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
2259
|
+
migration_notice: migrationNotice,
|
|
2260
|
+
phantom_notice: phantomNotice,
|
|
2261
|
+
};
|
|
2230
2262
|
}
|
|
2231
2263
|
|
|
2232
2264
|
/**
|
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { safeWriteJson } from './safe-write.js';
|
|
5
|
+
import { VALID_GOVERNED_TEMPLATE_IDS, loadGovernedTemplate } from './governed-templates.js';
|
|
5
6
|
|
|
6
7
|
const DISPATCHABLE_STATUSES = new Set(['planned', 'approved']);
|
|
7
8
|
|
|
@@ -18,11 +19,60 @@ function listIntentFiles(intentsDir) {
|
|
|
18
19
|
return readdirSync(intentsDir).filter((file) => file.endsWith('.json') && !file.startsWith('.tmp-'));
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
function normalizeArtifactPaths(paths) {
|
|
23
|
+
return [...new Set(
|
|
24
|
+
(Array.isArray(paths) ? paths : [])
|
|
25
|
+
.filter((value) => typeof value === 'string')
|
|
26
|
+
.map((value) => value.trim())
|
|
27
|
+
.filter(Boolean),
|
|
28
|
+
)];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readPlanningGateFiles(root) {
|
|
32
|
+
const configPath = join(root, 'agentxchain.json');
|
|
33
|
+
if (!existsSync(configPath)) return [];
|
|
34
|
+
|
|
35
|
+
let config;
|
|
36
|
+
try {
|
|
37
|
+
config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Only use planning gate requires_files for phantom detection when:
|
|
43
|
+
// 1. The planning gate has NOT been passed yet (once passed, these files
|
|
44
|
+
// are expected to exist from normal planning work), AND
|
|
45
|
+
// 2. At least one turn has been completed (turn_sequence > 0). If no turns
|
|
46
|
+
// have been completed, the files are scaffolding templates, not evidence
|
|
47
|
+
// of completed planning work. Without this check, ANY approved intent
|
|
48
|
+
// in a freshly scaffolded project would be falsely detected as phantom.
|
|
49
|
+
const statePath = join(root, '.agentxchain', 'state.json');
|
|
50
|
+
try {
|
|
51
|
+
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
52
|
+
const gateStatus = state.phase_gate_status || {};
|
|
53
|
+
const exitGateId = config?.routing?.planning?.exit_gate;
|
|
54
|
+
if (exitGateId && gateStatus[exitGateId] === 'passed') return [];
|
|
55
|
+
const turnSequence = state.turn_sequence || 0;
|
|
56
|
+
if (turnSequence === 0) return [];
|
|
57
|
+
} catch {
|
|
58
|
+
// If state is unreadable, fall through to check gate files
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const exitGateId = config?.routing?.planning?.exit_gate;
|
|
62
|
+
const requiresFiles = exitGateId ? config?.gates?.[exitGateId]?.requires_files : null;
|
|
63
|
+
return normalizeArtifactPaths(requiresFiles);
|
|
64
|
+
}
|
|
65
|
+
|
|
21
66
|
export function formatLegacyIntentMigrationNotice(intentIds) {
|
|
22
67
|
if (!Array.isArray(intentIds) || intentIds.length === 0) return null;
|
|
23
68
|
return `Archived ${intentIds.length} pre-BUG-34 intent(s): ${intentIds.join(', ')}`;
|
|
24
69
|
}
|
|
25
70
|
|
|
71
|
+
export function formatPhantomIntentSupersessionNotice(intentIds) {
|
|
72
|
+
if (!Array.isArray(intentIds) || intentIds.length === 0) return null;
|
|
73
|
+
return `Superseded ${intentIds.length} phantom intent(s): ${intentIds.join(', ')}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
26
76
|
export function migratePreBug34Intents(root, runId, options = {}) {
|
|
27
77
|
const intentsDir = getIntentsDir(root);
|
|
28
78
|
if (!existsSync(intentsDir)) {
|
|
@@ -30,6 +80,7 @@ export function migratePreBug34Intents(root, runId, options = {}) {
|
|
|
30
80
|
archived_migration_count: 0,
|
|
31
81
|
archived_migration_intent_ids: [],
|
|
32
82
|
migration_notice: null,
|
|
83
|
+
phantom_supersession_notice: null,
|
|
33
84
|
};
|
|
34
85
|
}
|
|
35
86
|
|
|
@@ -72,12 +123,50 @@ export function migratePreBug34Intents(root, runId, options = {}) {
|
|
|
72
123
|
};
|
|
73
124
|
}
|
|
74
125
|
|
|
126
|
+
/**
|
|
127
|
+
* BUG-42: Detect phantom intents — approved intents bound to the current run
|
|
128
|
+
* whose planning artifacts already exist on disk. These intents would fail with
|
|
129
|
+
* "existing planning artifacts would be overwritten" if dispatched.
|
|
130
|
+
*/
|
|
131
|
+
export function listExpectedPlanningArtifacts(root, intent) {
|
|
132
|
+
const recordedArtifacts = normalizeArtifactPaths(intent?.planning_artifacts);
|
|
133
|
+
let templateArtifacts = [];
|
|
134
|
+
|
|
135
|
+
if (intent?.template) {
|
|
136
|
+
try {
|
|
137
|
+
const manifest = loadGovernedTemplate(intent.template);
|
|
138
|
+
templateArtifacts = normalizeArtifactPaths(
|
|
139
|
+
(manifest.planning_artifacts || []).map((artifact) => `.planning/${artifact.filename}`),
|
|
140
|
+
);
|
|
141
|
+
} catch {
|
|
142
|
+
// Best-effort: unknown/broken template should not crash startup reconciliation.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return normalizeArtifactPaths([
|
|
147
|
+
...recordedArtifacts,
|
|
148
|
+
...templateArtifacts,
|
|
149
|
+
...readPlanningGateFiles(root),
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function isPhantomIntent(root, intent) {
|
|
154
|
+
if (intent.status !== 'approved') return false;
|
|
155
|
+
const artifacts = listExpectedPlanningArtifacts(root, intent);
|
|
156
|
+
if (artifacts.length === 0) return false;
|
|
157
|
+
|
|
158
|
+
return artifacts.some((artifact) => existsSync(join(root, artifact)));
|
|
159
|
+
}
|
|
160
|
+
|
|
75
161
|
export function archiveStaleIntentsForRun(root, runId, options = {}) {
|
|
76
162
|
const intentsDir = getIntentsDir(root);
|
|
77
163
|
if (!existsSync(intentsDir)) {
|
|
78
164
|
return {
|
|
79
165
|
archived: 0,
|
|
80
166
|
adopted: 0,
|
|
167
|
+
phantom_superseded: 0,
|
|
168
|
+
phantom_superseded_intent_ids: [],
|
|
169
|
+
phantom_supersession_notice: null,
|
|
81
170
|
archived_migration_count: 0,
|
|
82
171
|
archived_migration_intent_ids: [],
|
|
83
172
|
migration_notice: null,
|
|
@@ -87,6 +176,8 @@ export function archiveStaleIntentsForRun(root, runId, options = {}) {
|
|
|
87
176
|
const now = nowISO();
|
|
88
177
|
let archived = 0;
|
|
89
178
|
let adopted = 0;
|
|
179
|
+
let phantomSuperseded = 0;
|
|
180
|
+
const phantomSupersededIntentIds = [];
|
|
90
181
|
|
|
91
182
|
for (const file of listIntentFiles(intentsDir)) {
|
|
92
183
|
const intentPath = join(intentsDir, file);
|
|
@@ -136,6 +227,28 @@ export function archiveStaleIntentsForRun(root, runId, options = {}) {
|
|
|
136
227
|
});
|
|
137
228
|
safeWriteJson(intentPath, intent);
|
|
138
229
|
archived += 1;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// BUG-42: Detect phantom intents — approved intents bound to the current
|
|
234
|
+
// run whose planning artifacts already exist on disk. These would fail with
|
|
235
|
+
// "existing planning artifacts would be overwritten" if dispatched.
|
|
236
|
+
if (intent.approved_run_id === runId && isPhantomIntent(root, intent)) {
|
|
237
|
+
const prevStatus = intent.status;
|
|
238
|
+
intent.status = 'superseded';
|
|
239
|
+
intent.updated_at = now;
|
|
240
|
+
intent.archived_reason = 'planning artifacts for this intent already exist on disk; intent superseded';
|
|
241
|
+
if (!intent.history) intent.history = [];
|
|
242
|
+
intent.history.push({
|
|
243
|
+
from: prevStatus,
|
|
244
|
+
to: 'superseded',
|
|
245
|
+
at: now,
|
|
246
|
+
reason: intent.archived_reason,
|
|
247
|
+
});
|
|
248
|
+
safeWriteJson(intentPath, intent);
|
|
249
|
+
phantomSuperseded += 1;
|
|
250
|
+
if (intent.intent_id) phantomSupersededIntentIds.push(intent.intent_id);
|
|
251
|
+
continue;
|
|
139
252
|
}
|
|
140
253
|
}
|
|
141
254
|
|
|
@@ -144,6 +257,9 @@ export function archiveStaleIntentsForRun(root, runId, options = {}) {
|
|
|
144
257
|
return {
|
|
145
258
|
archived,
|
|
146
259
|
adopted,
|
|
260
|
+
phantom_superseded: phantomSuperseded,
|
|
261
|
+
phantom_superseded_intent_ids: phantomSupersededIntentIds,
|
|
262
|
+
phantom_supersession_notice: formatPhantomIntentSupersessionNotice(phantomSupersededIntentIds),
|
|
147
263
|
archived_migration_count: migration.archived_migration_count,
|
|
148
264
|
archived_migration_intent_ids: migration.archived_migration_intent_ids,
|
|
149
265
|
migration_notice: migration.migration_notice,
|
|
@@ -56,6 +56,13 @@ function describeEvent(eventType, entry) {
|
|
|
56
56
|
return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
|
|
57
57
|
case 'dispatch_progress':
|
|
58
58
|
return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
|
|
59
|
+
case 'intents_migrated':
|
|
60
|
+
case 'intents_superseded': {
|
|
61
|
+
const count = Number.isFinite(entry.payload?.archived_count)
|
|
62
|
+
? entry.payload.archived_count
|
|
63
|
+
: (Number.isFinite(entry.payload?.superseded_count) ? entry.payload.superseded_count : null);
|
|
64
|
+
return `${prefix}${eventType}${count !== null ? ` (${count})` : ''}`;
|
|
65
|
+
}
|
|
59
66
|
case 'run_blocked':
|
|
60
67
|
case 'run_completed':
|
|
61
68
|
case 'run_started':
|
package/src/lib/run-events.js
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://agentxchain.dev/schemas/workflow-kit-output.schema.json",
|
|
4
|
+
"title": "AgentXchain Workflow Kit Output",
|
|
5
|
+
"description": "Output schema for `workflow-kit describe --json`. Describes the workflow kit contract including phase templates, artifacts, semantic validators, and gate artifact coverage.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"workflow_kit_version",
|
|
9
|
+
"source",
|
|
10
|
+
"phase_templates",
|
|
11
|
+
"phases",
|
|
12
|
+
"semantic_validators",
|
|
13
|
+
"gate_artifact_coverage"
|
|
14
|
+
],
|
|
15
|
+
"properties": {
|
|
16
|
+
"workflow_kit_version": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "Workflow kit contract version."
|
|
19
|
+
},
|
|
20
|
+
"source": {
|
|
21
|
+
"$ref": "#/$defs/kit_source",
|
|
22
|
+
"description": "How the workflow kit was derived: default (inferred from phase names), explicit (user-defined), or mixed."
|
|
23
|
+
},
|
|
24
|
+
"phase_templates": {
|
|
25
|
+
"$ref": "#/$defs/phase_templates",
|
|
26
|
+
"description": "Available and in-use phase templates."
|
|
27
|
+
},
|
|
28
|
+
"phases": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"additionalProperties": {
|
|
31
|
+
"$ref": "#/$defs/phase_contract"
|
|
32
|
+
},
|
|
33
|
+
"description": "Per-phase workflow kit contract keyed by phase ID."
|
|
34
|
+
},
|
|
35
|
+
"semantic_validators": {
|
|
36
|
+
"type": "array",
|
|
37
|
+
"items": { "type": "string" },
|
|
38
|
+
"description": "List of semantic validator IDs available in this workflow kit."
|
|
39
|
+
},
|
|
40
|
+
"gate_artifact_coverage": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"additionalProperties": {
|
|
43
|
+
"$ref": "#/$defs/gate_coverage"
|
|
44
|
+
},
|
|
45
|
+
"description": "Per-gate artifact coverage mapping keyed by gate ID."
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"additionalProperties": false,
|
|
49
|
+
"$defs": {
|
|
50
|
+
"kit_source": {
|
|
51
|
+
"enum": ["default", "explicit", "mixed"]
|
|
52
|
+
},
|
|
53
|
+
"phase_source": {
|
|
54
|
+
"enum": ["default", "explicit", "not_declared"]
|
|
55
|
+
},
|
|
56
|
+
"phase_templates": {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"required": ["available", "in_use"],
|
|
59
|
+
"properties": {
|
|
60
|
+
"available": {
|
|
61
|
+
"type": "array",
|
|
62
|
+
"items": { "type": "string" },
|
|
63
|
+
"description": "All valid workflow-kit phase template IDs."
|
|
64
|
+
},
|
|
65
|
+
"in_use": {
|
|
66
|
+
"type": "array",
|
|
67
|
+
"items": { "type": "string" },
|
|
68
|
+
"description": "Template IDs currently applied to phases."
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"additionalProperties": false
|
|
72
|
+
},
|
|
73
|
+
"artifact": {
|
|
74
|
+
"type": "object",
|
|
75
|
+
"required": ["path", "required", "semantics", "exists"],
|
|
76
|
+
"properties": {
|
|
77
|
+
"path": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "Relative path to the workflow artifact."
|
|
80
|
+
},
|
|
81
|
+
"required": {
|
|
82
|
+
"type": "boolean",
|
|
83
|
+
"description": "Whether the artifact is required for phase completion."
|
|
84
|
+
},
|
|
85
|
+
"semantics": {
|
|
86
|
+
"type": ["string", "null"],
|
|
87
|
+
"description": "Semantic validator ID, or null if no validator applies."
|
|
88
|
+
},
|
|
89
|
+
"exists": {
|
|
90
|
+
"type": "boolean",
|
|
91
|
+
"description": "Whether the artifact exists on disk at introspection time."
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"additionalProperties": false
|
|
95
|
+
},
|
|
96
|
+
"phase_contract": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"required": ["template", "source", "artifacts"],
|
|
99
|
+
"properties": {
|
|
100
|
+
"template": {
|
|
101
|
+
"type": ["string", "null"],
|
|
102
|
+
"description": "Phase template ID, or null if no template applies."
|
|
103
|
+
},
|
|
104
|
+
"source": {
|
|
105
|
+
"$ref": "#/$defs/phase_source",
|
|
106
|
+
"description": "How this phase was configured."
|
|
107
|
+
},
|
|
108
|
+
"artifacts": {
|
|
109
|
+
"type": "array",
|
|
110
|
+
"items": { "$ref": "#/$defs/artifact" },
|
|
111
|
+
"description": "Workflow artifacts for this phase."
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"additionalProperties": false
|
|
115
|
+
},
|
|
116
|
+
"gate_coverage": {
|
|
117
|
+
"type": "object",
|
|
118
|
+
"required": ["linked_phases", "predicates_referencing_artifacts", "artifacts_covered"],
|
|
119
|
+
"properties": {
|
|
120
|
+
"linked_phases": {
|
|
121
|
+
"type": "array",
|
|
122
|
+
"items": { "type": "string" },
|
|
123
|
+
"description": "Phase IDs linked to this gate via routing or explicit annotation."
|
|
124
|
+
},
|
|
125
|
+
"predicates_referencing_artifacts": {
|
|
126
|
+
"type": "integer",
|
|
127
|
+
"minimum": 0,
|
|
128
|
+
"description": "Number of artifact predicates this gate evaluates."
|
|
129
|
+
},
|
|
130
|
+
"artifacts_covered": {
|
|
131
|
+
"type": "array",
|
|
132
|
+
"items": { "type": "string" },
|
|
133
|
+
"description": "Artifact paths this gate references."
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
"additionalProperties": false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -52,12 +52,24 @@ function isGitRepo(root) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// BUG-43: Staging and dispatch dirs are ephemeral — cleaned up after acceptance.
|
|
56
|
+
// They must never appear in checkpoint git-add paths.
|
|
57
|
+
const EPHEMERAL_PATH_PREFIXES = [
|
|
58
|
+
'.agentxchain/staging/',
|
|
59
|
+
'.agentxchain/dispatch/',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function isEphemeralPath(filePath) {
|
|
63
|
+
return EPHEMERAL_PATH_PREFIXES.some((prefix) => filePath.startsWith(prefix));
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
function normalizeFilesChanged(filesChanged) {
|
|
56
67
|
return [...new Set(
|
|
57
68
|
(Array.isArray(filesChanged) ? filesChanged : [])
|
|
58
69
|
.filter((value) => typeof value === 'string')
|
|
59
70
|
.map((value) => value.trim())
|
|
60
|
-
.filter(Boolean)
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.filter((value) => !isEphemeralPath(value)),
|
|
61
73
|
)];
|
|
62
74
|
}
|
|
63
75
|
|