agentxchain 2.137.0 → 2.138.1

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.1",
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/",
@@ -24,6 +24,14 @@ export async function intakeApproveCommand(opts) {
24
24
  console.log(JSON.stringify(result, null, 2));
25
25
  } else if (result.ok) {
26
26
  console.log('');
27
+ if (result.superseded) {
28
+ console.log(chalk.yellow(` Superseded intent ${result.intent.intent_id}`));
29
+ console.log(chalk.dim(` Approver: ${result.intent.approved_by}`));
30
+ console.log(chalk.dim(` Status: ${result.intent.history.at(-2)?.to || 'triaged'} → superseded`));
31
+ console.log(chalk.dim(` Reason: ${result.intent.archived_reason}`));
32
+ console.log('');
33
+ process.exit(result.exitCode);
34
+ }
27
35
  console.log(chalk.green(` Approved intent ${result.intent.intent_id}`));
28
36
  console.log(chalk.dim(` Approver: ${result.intent.approved_by}`));
29
37
  console.log(chalk.dim(` Status: triaged → approved`));
@@ -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
  /**
package/src/lib/intake.js CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  archiveStaleIntentsForRun,
22
22
  migratePreBug34Intents,
23
23
  formatLegacyIntentMigrationNotice,
24
+ isPhantomIntent,
24
25
  } from './intent-startup-migration.js';
25
26
 
26
27
  const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule', 'vision_scan'];
@@ -807,6 +808,16 @@ export function approveIntent(root, intentId, options = {}) {
807
808
  intent.status = 'approved';
808
809
  intent.approved_by = approver;
809
810
  intent.updated_at = now;
811
+
812
+ const phantomReason = 'planning artifacts for this intent already exist on disk; intent superseded during approval';
813
+ if (intent.approved_run_id && isPhantomIntent(root, intent)) {
814
+ intent.status = 'superseded';
815
+ intent.archived_reason = phantomReason;
816
+ intent.history.push({ from: previousStatus, to: 'superseded', at: now, reason: phantomReason, approver });
817
+ safeWriteJson(intentPath, intent);
818
+ return { ok: true, intent, superseded: true, exitCode: 0 };
819
+ }
820
+
810
821
  intent.history.push({ from: previousStatus, to: 'approved', at: now, reason, approver });
811
822
 
812
823
  safeWriteJson(intentPath, intent);
@@ -1,7 +1,9 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
+ import { queryAcceptedTurnHistory } from './accepted-turn-history.js';
4
5
  import { safeWriteJson } from './safe-write.js';
6
+ import { VALID_GOVERNED_TEMPLATE_IDS, loadGovernedTemplate } from './governed-templates.js';
5
7
 
6
8
  const DISPATCHABLE_STATUSES = new Set(['planned', 'approved']);
7
9
 
@@ -18,11 +20,73 @@ function listIntentFiles(intentsDir) {
18
20
  return readdirSync(intentsDir).filter((file) => file.endsWith('.json') && !file.startsWith('.tmp-'));
19
21
  }
20
22
 
23
+ function normalizeArtifactPaths(paths) {
24
+ return [...new Set(
25
+ (Array.isArray(paths) ? paths : [])
26
+ .filter((value) => typeof value === 'string')
27
+ .map((value) => value.trim())
28
+ .filter(Boolean),
29
+ )];
30
+ }
31
+
32
+ function parseTimestamp(value) {
33
+ if (typeof value !== 'string' || !value.trim()) return null;
34
+ const parsed = Date.parse(value);
35
+ return Number.isFinite(parsed) ? parsed : null;
36
+ }
37
+
38
+ function hasPlanningHistoryEvidence(root, intent) {
39
+ const intentId = intent?.intent_id || null;
40
+ const runId = intent?.approved_run_id || null;
41
+ const intentTimestamp = parseTimestamp(intent?.approved_at)
42
+ ?? parseTimestamp(intent?.created_at)
43
+ ?? parseTimestamp(intent?.updated_at);
44
+
45
+ for (const entry of queryAcceptedTurnHistory(root)) {
46
+ if (entry?.phase !== 'planning') continue;
47
+
48
+ if (intentId && entry.intent_id === intentId) {
49
+ return true;
50
+ }
51
+
52
+ if (!runId || entry.run_id !== runId || intentTimestamp === null) continue;
53
+ const acceptedAt = parseTimestamp(entry.accepted_at);
54
+ if (acceptedAt !== null && acceptedAt >= intentTimestamp) {
55
+ return true;
56
+ }
57
+ }
58
+
59
+ return false;
60
+ }
61
+
62
+ function readPlanningGateFiles(root, intent) {
63
+ const configPath = join(root, 'agentxchain.json');
64
+ if (!existsSync(configPath)) return [];
65
+
66
+ let config;
67
+ try {
68
+ config = JSON.parse(readFileSync(configPath, 'utf8'));
69
+ } catch {
70
+ return [];
71
+ }
72
+
73
+ const exitGateId = config?.routing?.planning?.exit_gate;
74
+ const requiresFiles = exitGateId ? config?.gates?.[exitGateId]?.requires_files : null;
75
+ if (!Array.isArray(requiresFiles) || requiresFiles.length === 0) return [];
76
+ if (!hasPlanningHistoryEvidence(root, intent)) return [];
77
+ return normalizeArtifactPaths(requiresFiles);
78
+ }
79
+
21
80
  export function formatLegacyIntentMigrationNotice(intentIds) {
22
81
  if (!Array.isArray(intentIds) || intentIds.length === 0) return null;
23
82
  return `Archived ${intentIds.length} pre-BUG-34 intent(s): ${intentIds.join(', ')}`;
24
83
  }
25
84
 
85
+ export function formatPhantomIntentSupersessionNotice(intentIds) {
86
+ if (!Array.isArray(intentIds) || intentIds.length === 0) return null;
87
+ return `Superseded ${intentIds.length} phantom intent(s): ${intentIds.join(', ')}`;
88
+ }
89
+
26
90
  export function migratePreBug34Intents(root, runId, options = {}) {
27
91
  const intentsDir = getIntentsDir(root);
28
92
  if (!existsSync(intentsDir)) {
@@ -30,6 +94,7 @@ export function migratePreBug34Intents(root, runId, options = {}) {
30
94
  archived_migration_count: 0,
31
95
  archived_migration_intent_ids: [],
32
96
  migration_notice: null,
97
+ phantom_supersession_notice: null,
33
98
  };
34
99
  }
35
100
 
@@ -72,12 +137,50 @@ export function migratePreBug34Intents(root, runId, options = {}) {
72
137
  };
73
138
  }
74
139
 
140
+ /**
141
+ * BUG-42: Detect phantom intents — approved intents bound to the current run
142
+ * whose planning artifacts already exist on disk. These intents would fail with
143
+ * "existing planning artifacts would be overwritten" if dispatched.
144
+ */
145
+ export function listExpectedPlanningArtifacts(root, intent) {
146
+ const recordedArtifacts = normalizeArtifactPaths(intent?.planning_artifacts);
147
+ let templateArtifacts = [];
148
+
149
+ if (intent?.template) {
150
+ try {
151
+ const manifest = loadGovernedTemplate(intent.template);
152
+ templateArtifacts = normalizeArtifactPaths(
153
+ (manifest.planning_artifacts || []).map((artifact) => `.planning/${artifact.filename}`),
154
+ );
155
+ } catch {
156
+ // Best-effort: unknown/broken template should not crash startup reconciliation.
157
+ }
158
+ }
159
+
160
+ return normalizeArtifactPaths([
161
+ ...recordedArtifacts,
162
+ ...templateArtifacts,
163
+ ...readPlanningGateFiles(root, intent),
164
+ ]);
165
+ }
166
+
167
+ export function isPhantomIntent(root, intent) {
168
+ if (intent.status !== 'approved') return false;
169
+ const artifacts = listExpectedPlanningArtifacts(root, intent);
170
+ if (artifacts.length === 0) return false;
171
+
172
+ return artifacts.some((artifact) => existsSync(join(root, artifact)));
173
+ }
174
+
75
175
  export function archiveStaleIntentsForRun(root, runId, options = {}) {
76
176
  const intentsDir = getIntentsDir(root);
77
177
  if (!existsSync(intentsDir)) {
78
178
  return {
79
179
  archived: 0,
80
180
  adopted: 0,
181
+ phantom_superseded: 0,
182
+ phantom_superseded_intent_ids: [],
183
+ phantom_supersession_notice: null,
81
184
  archived_migration_count: 0,
82
185
  archived_migration_intent_ids: [],
83
186
  migration_notice: null,
@@ -87,6 +190,8 @@ export function archiveStaleIntentsForRun(root, runId, options = {}) {
87
190
  const now = nowISO();
88
191
  let archived = 0;
89
192
  let adopted = 0;
193
+ let phantomSuperseded = 0;
194
+ const phantomSupersededIntentIds = [];
90
195
 
91
196
  for (const file of listIntentFiles(intentsDir)) {
92
197
  const intentPath = join(intentsDir, file);
@@ -136,6 +241,28 @@ export function archiveStaleIntentsForRun(root, runId, options = {}) {
136
241
  });
137
242
  safeWriteJson(intentPath, intent);
138
243
  archived += 1;
244
+ continue;
245
+ }
246
+
247
+ // BUG-42: Detect phantom intents — approved intents bound to the current
248
+ // run whose planning artifacts already exist on disk. These would fail with
249
+ // "existing planning artifacts would be overwritten" if dispatched.
250
+ if (intent.approved_run_id === runId && isPhantomIntent(root, intent)) {
251
+ const prevStatus = intent.status;
252
+ intent.status = 'superseded';
253
+ intent.updated_at = now;
254
+ intent.archived_reason = 'planning artifacts for this intent already exist on disk; intent superseded';
255
+ if (!intent.history) intent.history = [];
256
+ intent.history.push({
257
+ from: prevStatus,
258
+ to: 'superseded',
259
+ at: now,
260
+ reason: intent.archived_reason,
261
+ });
262
+ safeWriteJson(intentPath, intent);
263
+ phantomSuperseded += 1;
264
+ if (intent.intent_id) phantomSupersededIntentIds.push(intent.intent_id);
265
+ continue;
139
266
  }
140
267
  }
141
268
 
@@ -144,6 +271,9 @@ export function archiveStaleIntentsForRun(root, runId, options = {}) {
144
271
  return {
145
272
  archived,
146
273
  adopted,
274
+ phantom_superseded: phantomSuperseded,
275
+ phantom_superseded_intent_ids: phantomSupersededIntentIds,
276
+ phantom_supersession_notice: formatPhantomIntentSupersessionNotice(phantomSupersededIntentIds),
147
277
  archived_migration_count: migration.archived_migration_count,
148
278
  archived_migration_intent_ids: migration.archived_migration_intent_ids,
149
279
  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