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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.137.0",
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 intents stuck with approved_run_id: null.
2
+ * One-shot repair command for legacy and phantom intents.
3
3
  *
4
- * Belt-and-suspenders insurance for BUG-41: the automatic startup migration
5
- * is now idempotent, but operators who already have stuck repos need a direct
6
- * lever that works without starting a governed run.
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
- archived_count: legacyIntents.length,
68
- archived_intent_ids: legacyIntents.map(i => i.intent_id),
69
- dry_run: true,
70
- message: legacyIntents.length > 0
71
- ? `Would archive ${legacyIntents.length} pre-BUG-34 intent(s)`
72
- : 'No legacy intents found',
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 (legacyIntents.length === 0) {
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
- console.log(chalk.yellow(` Would archive ${legacyIntents.length} legacy intent(s):`));
79
- for (const item of legacyIntents) {
80
- console.log(` ${chalk.dim('•')} ${item.intent_id} (${item.status})`);
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 (legacyIntents.length === 0) {
214
+ if (totalIssues === 0) {
88
215
  if (opts.json) {
89
- console.log(JSON.stringify({
90
- archived_count: 0,
91
- archived_intent_ids: [],
92
- dry_run: false,
93
- message: 'No legacy intents found',
94
- }, null, 2));
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
- const runId = loadRunId(root);
102
- const result = migratePreBug34Intents(root, runId);
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
- archived_count: result.archived_migration_count,
107
- archived_intent_ids: result.archived_migration_intent_ids,
108
- dry_run: false,
109
- message: result.migration_notice || `Archived ${result.archived_migration_count} pre-BUG-34 intent(s)`,
110
- }, null, 2));
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 (result.archived_migration_count === 0) {
113
- console.log(chalk.green(' No legacy intents found. Nothing to migrate.'));
114
- } else {
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
  }
@@ -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
@@ -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
@@ -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 { ok: true, state: attachLegacyCurrentTurnAlias(updatedState), migration_notice: migrationNotice };
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':
@@ -14,6 +14,8 @@ export const RUN_EVENTS_PATH = '.agentxchain/events.jsonl';
14
14
  export const VALID_RUN_EVENTS = [
15
15
  'run_started',
16
16
  'phase_entered',
17
+ 'intents_migrated',
18
+ 'intents_superseded',
17
19
  'turn_dispatched',
18
20
  'turn_accepted',
19
21
  'turn_rejected',
@@ -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