agentxchain 2.138.1 → 2.140.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.
@@ -1031,6 +1031,7 @@ intakeCmd
1031
1031
  .command('resolve')
1032
1032
  .description('Resolve an executing intent by reading the governed run outcome')
1033
1033
  .option('--intent <id>', 'Intent ID to resolve')
1034
+ .option('--outcome <status>', 'Force transition to this status (e.g., "completed")')
1034
1035
  .option('-j, --json', 'Output as JSON')
1035
1036
  .action(intakeResolveCommand);
1036
1037
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.138.1",
3
+ "version": "2.140.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,11 @@ export async function intakeResolveCommand(opts) {
15
15
  process.exit(1);
16
16
  }
17
17
 
18
- const result = resolveIntent(root, opts.intent);
18
+ const resolveOpts = {};
19
+ if (opts.outcome) {
20
+ resolveOpts.outcome = opts.outcome;
21
+ }
22
+ const result = resolveIntent(root, opts.intent, resolveOpts);
19
23
 
20
24
  if (opts.json) {
21
25
  console.log(JSON.stringify(result, null, 2));
@@ -68,6 +68,10 @@ import {
68
68
  } from './verification-replay.js';
69
69
  import { executeGateActions } from './gate-actions.js';
70
70
  import { detectPendingCheckpoint } from './turn-checkpoint.js';
71
+ import {
72
+ derivePhaseScopeFromIntentMetadata,
73
+ evaluateAcceptanceItemLifecycle,
74
+ } from './intent-phase-scope.js';
71
75
 
72
76
  // ── Constants ────────────────────────────────────────────────────────────────
73
77
 
@@ -78,6 +82,7 @@ const STAGING_PATH = '.agentxchain/staging/turn-result.json';
78
82
  const TALK_PATH = 'TALK.md';
79
83
  const ACCEPTANCE_LOCK_PATH = '.agentxchain/locks/accept-turn.lock';
80
84
  const ACCEPTANCE_JOURNAL_DIR = '.agentxchain/transactions/accept';
85
+ const INTAKE_INTENTS_DIR = '.agentxchain/intake/intents';
81
86
  const STALE_LOCK_TIMEOUT_MS = 30_000;
82
87
  const GOVERNED_SCHEMA_VERSION = '1.1';
83
88
 
@@ -101,6 +106,113 @@ function buildInitialPhaseGateStatus(config) {
101
106
  );
102
107
  }
103
108
 
109
+ function listIntakeIntentFiles(root) {
110
+ const intentsDir = join(root, INTAKE_INTENTS_DIR);
111
+ if (!existsSync(intentsDir)) return [];
112
+
113
+ return readdirSync(intentsDir)
114
+ .filter((name) => name.endsWith('.json'))
115
+ .map((name) => join(intentsDir, name));
116
+ }
117
+
118
+ // BUG-45: Reconcile embedded intake_context against the live intent file.
119
+ // The embedded copy is historical state; the live intent file is authoritative.
120
+ // If the live intent cannot be read, acceptance must fail closed instead of
121
+ // silently reusing the stale embedded contract.
122
+ const INTENT_TERMINAL_STATES = ['completed', 'satisfied', 'superseded', 'suppressed', 'failed', 'rejected'];
123
+
124
+ function reconcileIntakeContext(root, intakeCtx) {
125
+ if (!intakeCtx || !intakeCtx.intent_id) {
126
+ return { ok: true, intakeCtx };
127
+ }
128
+
129
+ try {
130
+ const intentPath = join(root, INTAKE_INTENTS_DIR, `${intakeCtx.intent_id}.json`);
131
+ if (!existsSync(intentPath)) {
132
+ return {
133
+ ok: false,
134
+ error: `Intent reconciliation failed: live intent ${intakeCtx.intent_id} not found at ${INTAKE_INTENTS_DIR}/${intakeCtx.intent_id}.json`,
135
+ };
136
+ }
137
+
138
+ const liveIntent = JSON.parse(readFileSync(intentPath, 'utf8'));
139
+
140
+ // If the intent has reached a terminal state, skip coverage enforcement
141
+ if (INTENT_TERMINAL_STATES.includes(liveIntent.status)) {
142
+ return { ok: true, intakeCtx: null };
143
+ }
144
+
145
+ // Intent is still active — use the CURRENT acceptance_contract from disk
146
+ if (Array.isArray(liveIntent.acceptance_contract)) {
147
+ return {
148
+ ok: true,
149
+ intakeCtx: { ...intakeCtx, acceptance_contract: liveIntent.acceptance_contract },
150
+ };
151
+ }
152
+
153
+ return { ok: true, intakeCtx };
154
+ } catch (error) {
155
+ return {
156
+ ok: false,
157
+ error: `Intent reconciliation failed: could not read live intent ${intakeCtx.intent_id}: ${error.message}`,
158
+ };
159
+ }
160
+ }
161
+
162
+ function retireApprovedPhaseScopedIntents(root, state, config, exitedPhase, now) {
163
+ const retired = [];
164
+
165
+ for (const intentPath of listIntakeIntentFiles(root)) {
166
+ let intent;
167
+ try {
168
+ intent = JSON.parse(readFileSync(intentPath, 'utf8'));
169
+ } catch {
170
+ continue;
171
+ }
172
+
173
+ if (!intent || intent.status !== 'approved') continue;
174
+ if (state?.run_id && intent.approved_run_id && intent.approved_run_id !== state.run_id) continue;
175
+ if (state?.run_id && !intent.approved_run_id && intent.cross_run_durable !== true) continue;
176
+
177
+ const effectivePhaseScope = derivePhaseScopeFromIntentMetadata(intent, config);
178
+ const acceptanceItems = Array.isArray(intent.acceptance_contract) ? intent.acceptance_contract : [];
179
+ const lifecycleStates = acceptanceItems.map((item) => evaluateAcceptanceItemLifecycle(
180
+ item,
181
+ { phase_scope: effectivePhaseScope },
182
+ state,
183
+ config,
184
+ ));
185
+ const retireByPhaseExit = effectivePhaseScope === exitedPhase;
186
+ const retireByGateState = acceptanceItems.length > 0
187
+ && lifecycleStates.every((entry) => entry.satisfied_by_gate_state || entry.phase_exited);
188
+
189
+ if (!retireByPhaseExit && !retireByGateState) continue;
190
+
191
+ intent.status = 'satisfied';
192
+ intent.phase_scope = effectivePhaseScope || intent.phase_scope || null;
193
+ intent.updated_at = now;
194
+ intent.satisfied_at = now;
195
+ intent.satisfied_reason = retireByPhaseExit
196
+ ? `phase ${exitedPhase} exited; ${exitedPhase}-scoped repair no longer required`
197
+ : `acceptance items satisfied by gate state after phase advance from ${exitedPhase}`;
198
+ if (!Array.isArray(intent.history)) {
199
+ intent.history = [];
200
+ }
201
+ intent.history.push({
202
+ from: 'approved',
203
+ to: 'satisfied',
204
+ at: now,
205
+ reason: intent.satisfied_reason,
206
+ exited_phase: exitedPhase,
207
+ entered_phase: state?.phase || null,
208
+ });
209
+ safeWriteJson(intentPath, intent);
210
+ retired.push(intent.intent_id);
211
+ }
212
+
213
+ return retired;
214
+ }
215
+
104
216
  function buildFreshIdleStateForNewRun(state, config) {
105
217
  return {
106
218
  schema_version: state?.schema_version || GOVERNED_SCHEMA_VERSION,
@@ -3154,12 +3266,38 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3154
3266
  };
3155
3267
  }
3156
3268
 
3157
- // ── Intent coverage validation (BUG-14) ──────────────────────────────────
3158
- // When a turn is bound to an injected intent, verify the turn result
3159
- // addresses each acceptance item. Default: strict for p0, lenient for others.
3160
- const intakeCtx = currentTurn.intake_context;
3269
+ // ── BUG-45: Reconcile intake_context against live intent state ──────────
3270
+ // The embedded intake_context is a snapshot from dispatch time. The intent
3271
+ // may have since been completed, satisfied, or had its contract updated.
3272
+ // Re-read the live intent file and reconcile before evaluating coverage.
3273
+ const intakeReconciliation = reconcileIntakeContext(root, currentTurn.intake_context);
3274
+ if (!intakeReconciliation.ok) {
3275
+ transitionToFailedAcceptance(root, state, currentTurn, intakeReconciliation.error, {
3276
+ error_code: 'intent_reconciliation_failed',
3277
+ stage: 'intent_reconciliation',
3278
+ extra: {
3279
+ intent_id: currentTurn.intake_context?.intent_id || null,
3280
+ },
3281
+ });
3282
+ return {
3283
+ ok: false,
3284
+ error: intakeReconciliation.error,
3285
+ validation: {
3286
+ ...validation,
3287
+ ok: false,
3288
+ stage: 'intent_reconciliation',
3289
+ error_class: 'intent_reconciliation_error',
3290
+ errors: [intakeReconciliation.error],
3291
+ warnings: validation.warnings,
3292
+ },
3293
+ };
3294
+ }
3295
+ const intakeCtx = intakeReconciliation.intakeCtx;
3161
3296
  if (intakeCtx && Array.isArray(intakeCtx.acceptance_contract) && intakeCtx.acceptance_contract.length > 0) {
3162
- const intentCoverage = evaluateIntentCoverage(turnResult, intakeCtx);
3297
+ const intentCoverage = evaluateIntentCoverage(turnResult, intakeCtx, {
3298
+ state,
3299
+ config,
3300
+ });
3163
3301
  const priority = intakeCtx.priority || currentTurn.intake_context?.priority || 'p0';
3164
3302
  const intentCoverageMode = config.intent_coverage_mode
3165
3303
  || (priority === 'p0' ? 'strict' : 'lenient');
@@ -3977,6 +4115,21 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3977
4115
  [gateResult.gate_id || 'no_gate']: 'passed',
3978
4116
  };
3979
4117
  updatedState.queued_phase_transition = null;
4118
+ const retiredIntentIds = retireApprovedPhaseScopedIntents(root, updatedState, config, prevPhase, now);
4119
+ if (retiredIntentIds.length > 0) {
4120
+ emitRunEvent(root, 'intent_retired_by_phase_advance', {
4121
+ run_id: updatedState.run_id,
4122
+ phase: updatedState.phase,
4123
+ status: updatedState.status,
4124
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4125
+ payload: {
4126
+ exited_phase: prevPhase,
4127
+ entered_phase: gateResult.next_phase,
4128
+ retired_count: retiredIntentIds.length,
4129
+ retired_intent_ids: retiredIntentIds,
4130
+ },
4131
+ });
4132
+ }
3980
4133
  emitRunEvent(root, 'phase_entered', {
3981
4134
  run_id: updatedState.run_id,
3982
4135
  phase: updatedState.phase,
@@ -4019,6 +4172,21 @@ function _acceptGovernedTurnLocked(root, config, opts) {
4019
4172
  gate_id: gateResult.gate_id,
4020
4173
  timestamp: now,
4021
4174
  });
4175
+ const retiredIntentIds = retireApprovedPhaseScopedIntents(root, updatedState, config, prevPhase, now);
4176
+ if (retiredIntentIds.length > 0) {
4177
+ emitRunEvent(root, 'intent_retired_by_phase_advance', {
4178
+ run_id: updatedState.run_id,
4179
+ phase: updatedState.phase,
4180
+ status: updatedState.status,
4181
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4182
+ payload: {
4183
+ exited_phase: prevPhase,
4184
+ entered_phase: gateResult.next_phase,
4185
+ retired_count: retiredIntentIds.length,
4186
+ retired_intent_ids: retiredIntentIds,
4187
+ },
4188
+ });
4189
+ }
4022
4190
  emitRunEvent(root, 'phase_entered', {
4023
4191
  run_id: updatedState.run_id,
4024
4192
  phase: updatedState.phase,
@@ -5226,7 +5394,7 @@ function deriveNextRecommendedRole(turnResult, state, config) {
5226
5394
  // intent. Uses a hybrid approach: structural (`intent_response` field) first,
5227
5395
  // with semantic fallback scanning `summary`, `decisions`, and `files_changed`.
5228
5396
 
5229
- function evaluateIntentCoverage(turnResult, intakeContext) {
5397
+ function evaluateIntentCoverage(turnResult, intakeContext, { state = null, config = null } = {}) {
5230
5398
  const acceptanceItems = intakeContext.acceptance_contract || [];
5231
5399
  const addressed = [];
5232
5400
  const unaddressed = [];
@@ -5253,6 +5421,14 @@ function evaluateIntentCoverage(turnResult, intakeContext) {
5253
5421
 
5254
5422
  for (const item of acceptanceItems) {
5255
5423
  const normalizedItem = item.toLowerCase().trim();
5424
+ const lifecycle = state && config
5425
+ ? evaluateAcceptanceItemLifecycle(item, intakeContext, state, config)
5426
+ : null;
5427
+
5428
+ if (lifecycle?.phase_exited || lifecycle?.satisfied_by_gate_state) {
5429
+ addressed.push(item);
5430
+ continue;
5431
+ }
5256
5432
 
5257
5433
  // Check 1: Structural — intent_response field with explicit status
5258
5434
  const structuralEntry = responseMap.get(normalizedItem);
package/src/lib/intake.js CHANGED
@@ -23,6 +23,10 @@ import {
23
23
  formatLegacyIntentMigrationNotice,
24
24
  isPhantomIntent,
25
25
  } from './intent-startup-migration.js';
26
+ import {
27
+ derivePhaseScopeFromIntentMetadata,
28
+ getPhaseOrder,
29
+ } from './intent-phase-scope.js';
26
30
 
27
31
  const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule', 'vision_scan'];
28
32
  const VALID_PRIORITIES = ['p0', 'p1', 'p2', 'p3'];
@@ -31,8 +35,8 @@ const INTENT_ID_RE = /^intent_\d+_[0-9a-f]{4}$/;
31
35
 
32
36
  // V3-S1 through S5 states. `failed` remains read-tolerant for historical/manual
33
37
  // intent files, but current first-party intake writers do not transition into it.
34
- const S1_STATES = new Set(['detected', 'triaged', 'approved', 'planned', 'executing', 'blocked', 'completed', 'failed', 'suppressed', 'rejected']);
35
- const TERMINAL_STATES = new Set(['suppressed', 'rejected', 'completed', 'failed']);
38
+ const S1_STATES = new Set(['detected', 'triaged', 'approved', 'planned', 'executing', 'blocked', 'completed', 'satisfied', 'failed', 'suppressed', 'rejected']);
39
+ const TERMINAL_STATES = new Set(['suppressed', 'rejected', 'completed', 'satisfied', 'failed']);
36
40
  const DISPATCHABLE_STATUSES = new Set(['planned', 'approved']);
37
41
  const PRIORITY_RANK = { p0: 0, p1: 1, p2: 2, p3: 3 };
38
42
 
@@ -283,7 +287,7 @@ export function validateEventPayload(payload) {
283
287
  return { valid: errors.length === 0, errors };
284
288
  }
285
289
 
286
- export function validateTriageFields(fields) {
290
+ export function validateTriageFields(fields, config = null) {
287
291
  const errors = [];
288
292
 
289
293
  if (!VALID_PRIORITIES.includes(fields.priority)) {
@@ -302,6 +306,17 @@ export function validateTriageFields(fields) {
302
306
  errors.push('acceptance_contract must be a non-empty array');
303
307
  }
304
308
 
309
+ if ('phase_scope' in fields && fields.phase_scope != null) {
310
+ if (typeof fields.phase_scope !== 'string' || !fields.phase_scope.trim()) {
311
+ errors.push('phase_scope must be a non-empty string when provided');
312
+ } else if (config) {
313
+ const validPhases = getPhaseOrder(config);
314
+ if (validPhases.length > 0 && !validPhases.includes(fields.phase_scope.trim())) {
315
+ errors.push(`phase_scope must be one of: ${validPhases.join(', ')}`);
316
+ }
317
+ }
318
+ }
319
+
305
320
  return { valid: errors.length === 0, errors };
306
321
  }
307
322
 
@@ -355,6 +370,7 @@ export function recordEvent(root, payload) {
355
370
  template: null,
356
371
  charter: null,
357
372
  acceptance_contract: [],
373
+ phase_scope: null,
358
374
  requires_human_start: true,
359
375
  target_run: null,
360
376
  created_at: now,
@@ -420,17 +436,25 @@ export function triageIntent(root, intentId, fields) {
420
436
  return { ok: false, error: `cannot triage from status "${intent.status}" (must be detected)`, exitCode: 1 };
421
437
  }
422
438
 
423
- const validation = validateTriageFields(fields);
439
+ const context = loadProjectContext(root);
440
+ const config = context?.config || null;
441
+ const normalizedFields = {
442
+ ...fields,
443
+ phase_scope: fields.phase_scope || derivePhaseScopeFromIntentMetadata(fields, config),
444
+ };
445
+
446
+ const validation = validateTriageFields(normalizedFields, config);
424
447
  if (!validation.valid) {
425
448
  return { ok: false, error: validation.errors.join('; '), exitCode: 1 };
426
449
  }
427
450
 
428
451
  const now = nowISO();
429
452
  intent.status = 'triaged';
430
- intent.priority = fields.priority;
431
- intent.template = fields.template;
432
- intent.charter = fields.charter;
433
- intent.acceptance_contract = fields.acceptance_contract;
453
+ intent.priority = normalizedFields.priority;
454
+ intent.template = normalizedFields.template;
455
+ intent.charter = normalizedFields.charter;
456
+ intent.acceptance_contract = normalizedFields.acceptance_contract;
457
+ intent.phase_scope = normalizedFields.phase_scope || null;
434
458
  intent.updated_at = now;
435
459
  intent.history.push({ from: 'detected', to: 'triaged', at: now, reason: 'triage completed' });
436
460
 
@@ -946,6 +970,7 @@ export function startIntent(root, intentId, options = {}) {
946
970
  category: event.category || null,
947
971
  charter: intent.charter || null,
948
972
  acceptance_contract: Array.isArray(intent.acceptance_contract) ? intent.acceptance_contract : [],
973
+ phase_scope: intent.phase_scope || null,
949
974
  };
950
975
 
951
976
  // Load governed project context
@@ -1245,7 +1270,7 @@ export function handoffIntent(root, intentId, options = {}) {
1245
1270
  // Resolve — execution exit and intent closure linkage (V3-S5)
1246
1271
  // ---------------------------------------------------------------------------
1247
1272
 
1248
- export function resolveIntent(root, intentId) {
1273
+ export function resolveIntent(root, intentId, opts = {}) {
1249
1274
  const loadedIntent = readIntent(root, intentId);
1250
1275
  if (!loadedIntent.ok) {
1251
1276
  return loadedIntent;
@@ -1273,6 +1298,38 @@ export function resolveIntent(root, intentId) {
1273
1298
  };
1274
1299
  }
1275
1300
 
1301
+ // BUG-45: operator-forced completion — transition executing → completed
1302
+ // when the operator knows the work is done but the framework hasn't retired
1303
+ // the intent automatically (e.g., retained-turn deadlock recovery).
1304
+ if (opts.outcome === 'completed' && intent.status === 'executing') {
1305
+ const now = nowISO();
1306
+ const previousStatus = intent.status;
1307
+ intent.status = 'completed';
1308
+ intent.completed_at = now;
1309
+ intent.updated_at = now;
1310
+ if (!Array.isArray(intent.history)) intent.history = [];
1311
+ intent.history.push({
1312
+ from: previousStatus,
1313
+ to: 'completed',
1314
+ at: now,
1315
+ reason: opts.reason || 'operator-resolved: intent marked completed via intake resolve --outcome completed',
1316
+ });
1317
+
1318
+ const obsDir = join(dirs.base, 'observations', intent.intent_id);
1319
+ mkdirSync(obsDir, { recursive: true });
1320
+
1321
+ safeWriteJson(intentPath, intent);
1322
+ return {
1323
+ ok: true,
1324
+ intent,
1325
+ previous_status: previousStatus,
1326
+ new_status: 'completed',
1327
+ run_outcome: 'completed',
1328
+ no_change: false,
1329
+ exitCode: 0,
1330
+ };
1331
+ }
1332
+
1276
1333
  if (intent.target_workstream) {
1277
1334
  return resolveCoordinatorBackedIntent(root, intentPath, dirs, intent);
1278
1335
  }
@@ -1782,6 +1839,7 @@ export function injectIntent(root, description, options = {}) {
1782
1839
  : [description.trim()];
1783
1840
  const approver = options.approver || 'human';
1784
1841
  const noApprove = options.noApprove === true;
1842
+ const phase_scope = options.phase_scope || null;
1785
1843
 
1786
1844
  // Step 1: Record event
1787
1845
  const recordResult = recordEvent(root, {
@@ -1815,6 +1873,7 @@ export function injectIntent(root, description, options = {}) {
1815
1873
  template,
1816
1874
  charter,
1817
1875
  acceptance_contract,
1876
+ phase_scope,
1818
1877
  });
1819
1878
 
1820
1879
  if (!triageResult.ok) {
@@ -0,0 +1,97 @@
1
+ function normalizeText(value) {
2
+ return typeof value === 'string' ? value.toLowerCase() : '';
3
+ }
4
+
5
+ export function getPhaseOrder(config) {
6
+ if (Array.isArray(config?.phases) && config.phases.length > 0) {
7
+ return config.phases
8
+ .map((phase) => phase?.id)
9
+ .filter((phaseId) => typeof phaseId === 'string' && phaseId.trim().length > 0);
10
+ }
11
+ return Object.keys(config?.routing || {});
12
+ }
13
+
14
+ export function buildGateToPhaseMap(config) {
15
+ const mapping = {};
16
+ for (const [phaseId, route] of Object.entries(config?.routing || {})) {
17
+ const gateId = route?.exit_gate;
18
+ if (typeof gateId === 'string' && gateId.trim().length > 0) {
19
+ mapping[gateId] = phaseId;
20
+ }
21
+ }
22
+ return mapping;
23
+ }
24
+
25
+ export function extractReferencedGateIds(text, config) {
26
+ const haystack = normalizeText(text);
27
+ if (!haystack) return [];
28
+
29
+ return Object.keys(config?.gates || {}).filter((gateId) => haystack.includes(gateId.toLowerCase()));
30
+ }
31
+
32
+ function derivePhaseScopeFromGateIds(gateIds, config) {
33
+ const gateToPhase = buildGateToPhaseMap(config);
34
+ const matchedPhases = [...new Set(
35
+ gateIds
36
+ .map((gateId) => gateToPhase[gateId] || null)
37
+ .filter(Boolean),
38
+ )];
39
+ return matchedPhases.length === 1 ? matchedPhases[0] : null;
40
+ }
41
+
42
+ export function derivePhaseScopeFromIntentMetadata(intentLike, config) {
43
+ const explicitScope = typeof intentLike?.phase_scope === 'string' && intentLike.phase_scope.trim().length > 0
44
+ ? intentLike.phase_scope.trim()
45
+ : null;
46
+ if (explicitScope) return explicitScope;
47
+ if (!config) return null;
48
+
49
+ const texts = [
50
+ intentLike?.charter || '',
51
+ ...(Array.isArray(intentLike?.acceptance_contract) ? intentLike.acceptance_contract : []),
52
+ ];
53
+ const gateIds = [...new Set(texts.flatMap((text) => extractReferencedGateIds(text, config)))];
54
+ return derivePhaseScopeFromGateIds(gateIds, config);
55
+ }
56
+
57
+ export function deriveAcceptanceItemPhaseScope(item, fallbackPhaseScope, config) {
58
+ if (!config) return fallbackPhaseScope || null;
59
+ const gateIds = extractReferencedGateIds(item, config);
60
+ const derived = derivePhaseScopeFromGateIds(gateIds, config);
61
+ return derived || fallbackPhaseScope || null;
62
+ }
63
+
64
+ export function isPhaseExited(currentPhase, scopedPhase, config) {
65
+ if (!currentPhase || !scopedPhase || !config) return false;
66
+ const order = getPhaseOrder(config);
67
+ const currentIndex = order.indexOf(currentPhase);
68
+ const scopeIndex = order.indexOf(scopedPhase);
69
+ if (currentIndex === -1 || scopeIndex === -1) return false;
70
+ return currentIndex > scopeIndex;
71
+ }
72
+
73
+ export function isAcceptanceItemSatisfiedByGateState(item, state, config) {
74
+ if (!state || !config) return false;
75
+ const itemText = normalizeText(item);
76
+ if (!itemText) return false;
77
+
78
+ const gateIds = extractReferencedGateIds(item, config);
79
+ if (gateIds.length === 0) return false;
80
+
81
+ const hasGatePassLanguage = /\b(can advance|advance to|gate can advance|gate passes|gate passed|passes|passed)\b/.test(itemText);
82
+ if (!hasGatePassLanguage) return false;
83
+
84
+ return gateIds.some((gateId) => state?.phase_gate_status?.[gateId] === 'passed');
85
+ }
86
+
87
+ export function evaluateAcceptanceItemLifecycle(item, intakeContext, state, config) {
88
+ const fallbackPhaseScope = typeof intakeContext?.phase_scope === 'string' && intakeContext.phase_scope.trim().length > 0
89
+ ? intakeContext.phase_scope.trim()
90
+ : null;
91
+ const phaseScope = deriveAcceptanceItemPhaseScope(item, fallbackPhaseScope, config);
92
+ return {
93
+ phase_scope: phaseScope,
94
+ phase_exited: isPhaseExited(state?.phase || null, phaseScope, config),
95
+ satisfied_by_gate_state: isAcceptanceItemSatisfiedByGateState(item, state, config),
96
+ };
97
+ }
@@ -57,10 +57,15 @@ function describeEvent(eventType, entry) {
57
57
  case 'dispatch_progress':
58
58
  return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
59
59
  case 'intents_migrated':
60
- case 'intents_superseded': {
60
+ case 'intents_superseded':
61
+ case 'intent_retired_by_phase_advance': {
61
62
  const count = Number.isFinite(entry.payload?.archived_count)
62
63
  ? entry.payload.archived_count
63
- : (Number.isFinite(entry.payload?.superseded_count) ? entry.payload.superseded_count : null);
64
+ : (
65
+ Number.isFinite(entry.payload?.superseded_count)
66
+ ? entry.payload.superseded_count
67
+ : (Number.isFinite(entry.payload?.retired_count) ? entry.payload.retired_count : null)
68
+ );
64
69
  return `${prefix}${eventType}${count !== null ? ` (${count})` : ''}`;
65
70
  }
66
71
  case 'run_blocked':
@@ -50,6 +50,7 @@ const ORCHESTRATOR_STATE_FILES = [
50
50
  '.agentxchain/human-escalations.jsonl',
51
51
  '.agentxchain/sla-reminders.json',
52
52
  'TALK.md',
53
+ 'HUMAN_TASKS.md',
53
54
  ];
54
55
 
55
56
  // Evidence paths may legitimately remain dirty across turns without blocking the
@@ -16,6 +16,7 @@ export const VALID_RUN_EVENTS = [
16
16
  'phase_entered',
17
17
  'intents_migrated',
18
18
  'intents_superseded',
19
+ 'intent_retired_by_phase_advance',
19
20
  'turn_dispatched',
20
21
  'turn_accepted',
21
22
  'turn_rejected',