agentxchain 2.138.1 → 2.139.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.138.1",
3
+ "version": "2.139.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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,69 @@ 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
+ function retireApprovedPhaseScopedIntents(root, state, config, exitedPhase, now) {
119
+ const retired = [];
120
+
121
+ for (const intentPath of listIntakeIntentFiles(root)) {
122
+ let intent;
123
+ try {
124
+ intent = JSON.parse(readFileSync(intentPath, 'utf8'));
125
+ } catch {
126
+ continue;
127
+ }
128
+
129
+ if (!intent || intent.status !== 'approved') continue;
130
+ if (state?.run_id && intent.approved_run_id && intent.approved_run_id !== state.run_id) continue;
131
+ if (state?.run_id && !intent.approved_run_id && intent.cross_run_durable !== true) continue;
132
+
133
+ const effectivePhaseScope = derivePhaseScopeFromIntentMetadata(intent, config);
134
+ const acceptanceItems = Array.isArray(intent.acceptance_contract) ? intent.acceptance_contract : [];
135
+ const lifecycleStates = acceptanceItems.map((item) => evaluateAcceptanceItemLifecycle(
136
+ item,
137
+ { phase_scope: effectivePhaseScope },
138
+ state,
139
+ config,
140
+ ));
141
+ const retireByPhaseExit = effectivePhaseScope === exitedPhase;
142
+ const retireByGateState = acceptanceItems.length > 0
143
+ && lifecycleStates.every((entry) => entry.satisfied_by_gate_state || entry.phase_exited);
144
+
145
+ if (!retireByPhaseExit && !retireByGateState) continue;
146
+
147
+ intent.status = 'satisfied';
148
+ intent.phase_scope = effectivePhaseScope || intent.phase_scope || null;
149
+ intent.updated_at = now;
150
+ intent.satisfied_at = now;
151
+ intent.satisfied_reason = retireByPhaseExit
152
+ ? `phase ${exitedPhase} exited; ${exitedPhase}-scoped repair no longer required`
153
+ : `acceptance items satisfied by gate state after phase advance from ${exitedPhase}`;
154
+ if (!Array.isArray(intent.history)) {
155
+ intent.history = [];
156
+ }
157
+ intent.history.push({
158
+ from: 'approved',
159
+ to: 'satisfied',
160
+ at: now,
161
+ reason: intent.satisfied_reason,
162
+ exited_phase: exitedPhase,
163
+ entered_phase: state?.phase || null,
164
+ });
165
+ safeWriteJson(intentPath, intent);
166
+ retired.push(intent.intent_id);
167
+ }
168
+
169
+ return retired;
170
+ }
171
+
104
172
  function buildFreshIdleStateForNewRun(state, config) {
105
173
  return {
106
174
  schema_version: state?.schema_version || GOVERNED_SCHEMA_VERSION,
@@ -3159,7 +3227,10 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3159
3227
  // addresses each acceptance item. Default: strict for p0, lenient for others.
3160
3228
  const intakeCtx = currentTurn.intake_context;
3161
3229
  if (intakeCtx && Array.isArray(intakeCtx.acceptance_contract) && intakeCtx.acceptance_contract.length > 0) {
3162
- const intentCoverage = evaluateIntentCoverage(turnResult, intakeCtx);
3230
+ const intentCoverage = evaluateIntentCoverage(turnResult, intakeCtx, {
3231
+ state,
3232
+ config,
3233
+ });
3163
3234
  const priority = intakeCtx.priority || currentTurn.intake_context?.priority || 'p0';
3164
3235
  const intentCoverageMode = config.intent_coverage_mode
3165
3236
  || (priority === 'p0' ? 'strict' : 'lenient');
@@ -3977,6 +4048,21 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3977
4048
  [gateResult.gate_id || 'no_gate']: 'passed',
3978
4049
  };
3979
4050
  updatedState.queued_phase_transition = null;
4051
+ const retiredIntentIds = retireApprovedPhaseScopedIntents(root, updatedState, config, prevPhase, now);
4052
+ if (retiredIntentIds.length > 0) {
4053
+ emitRunEvent(root, 'intent_retired_by_phase_advance', {
4054
+ run_id: updatedState.run_id,
4055
+ phase: updatedState.phase,
4056
+ status: updatedState.status,
4057
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4058
+ payload: {
4059
+ exited_phase: prevPhase,
4060
+ entered_phase: gateResult.next_phase,
4061
+ retired_count: retiredIntentIds.length,
4062
+ retired_intent_ids: retiredIntentIds,
4063
+ },
4064
+ });
4065
+ }
3980
4066
  emitRunEvent(root, 'phase_entered', {
3981
4067
  run_id: updatedState.run_id,
3982
4068
  phase: updatedState.phase,
@@ -4019,6 +4105,21 @@ function _acceptGovernedTurnLocked(root, config, opts) {
4019
4105
  gate_id: gateResult.gate_id,
4020
4106
  timestamp: now,
4021
4107
  });
4108
+ const retiredIntentIds = retireApprovedPhaseScopedIntents(root, updatedState, config, prevPhase, now);
4109
+ if (retiredIntentIds.length > 0) {
4110
+ emitRunEvent(root, 'intent_retired_by_phase_advance', {
4111
+ run_id: updatedState.run_id,
4112
+ phase: updatedState.phase,
4113
+ status: updatedState.status,
4114
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4115
+ payload: {
4116
+ exited_phase: prevPhase,
4117
+ entered_phase: gateResult.next_phase,
4118
+ retired_count: retiredIntentIds.length,
4119
+ retired_intent_ids: retiredIntentIds,
4120
+ },
4121
+ });
4122
+ }
4022
4123
  emitRunEvent(root, 'phase_entered', {
4023
4124
  run_id: updatedState.run_id,
4024
4125
  phase: updatedState.phase,
@@ -5226,7 +5327,7 @@ function deriveNextRecommendedRole(turnResult, state, config) {
5226
5327
  // intent. Uses a hybrid approach: structural (`intent_response` field) first,
5227
5328
  // with semantic fallback scanning `summary`, `decisions`, and `files_changed`.
5228
5329
 
5229
- function evaluateIntentCoverage(turnResult, intakeContext) {
5330
+ function evaluateIntentCoverage(turnResult, intakeContext, { state = null, config = null } = {}) {
5230
5331
  const acceptanceItems = intakeContext.acceptance_contract || [];
5231
5332
  const addressed = [];
5232
5333
  const unaddressed = [];
@@ -5253,6 +5354,14 @@ function evaluateIntentCoverage(turnResult, intakeContext) {
5253
5354
 
5254
5355
  for (const item of acceptanceItems) {
5255
5356
  const normalizedItem = item.toLowerCase().trim();
5357
+ const lifecycle = state && config
5358
+ ? evaluateAcceptanceItemLifecycle(item, intakeContext, state, config)
5359
+ : null;
5360
+
5361
+ if (lifecycle?.phase_exited || lifecycle?.satisfied_by_gate_state) {
5362
+ addressed.push(item);
5363
+ continue;
5364
+ }
5256
5365
 
5257
5366
  // Check 1: Structural — intent_response field with explicit status
5258
5367
  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
@@ -1782,6 +1807,7 @@ export function injectIntent(root, description, options = {}) {
1782
1807
  : [description.trim()];
1783
1808
  const approver = options.approver || 'human';
1784
1809
  const noApprove = options.noApprove === true;
1810
+ const phase_scope = options.phase_scope || null;
1785
1811
 
1786
1812
  // Step 1: Record event
1787
1813
  const recordResult = recordEvent(root, {
@@ -1815,6 +1841,7 @@ export function injectIntent(root, description, options = {}) {
1815
1841
  template,
1816
1842
  charter,
1817
1843
  acceptance_contract,
1844
+ phase_scope,
1818
1845
  });
1819
1846
 
1820
1847
  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':
@@ -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',