agentxchain 2.138.0 → 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 +1 -1
- package/src/commands/intake-approve.js +8 -0
- package/src/lib/governed-state.js +111 -2
- package/src/lib/intake.js +46 -8
- package/src/lib/intent-phase-scope.js +97 -0
- package/src/lib/intent-startup-migration.js +35 -21
- package/src/lib/recent-event-summary.js +7 -2
- package/src/lib/run-events.js +1 -0
package/package.json
CHANGED
|
@@ -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`));
|
|
@@ -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
|
@@ -21,7 +21,12 @@ import {
|
|
|
21
21
|
archiveStaleIntentsForRun,
|
|
22
22
|
migratePreBug34Intents,
|
|
23
23
|
formatLegacyIntentMigrationNotice,
|
|
24
|
+
isPhantomIntent,
|
|
24
25
|
} from './intent-startup-migration.js';
|
|
26
|
+
import {
|
|
27
|
+
derivePhaseScopeFromIntentMetadata,
|
|
28
|
+
getPhaseOrder,
|
|
29
|
+
} from './intent-phase-scope.js';
|
|
25
30
|
|
|
26
31
|
const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule', 'vision_scan'];
|
|
27
32
|
const VALID_PRIORITIES = ['p0', 'p1', 'p2', 'p3'];
|
|
@@ -30,8 +35,8 @@ const INTENT_ID_RE = /^intent_\d+_[0-9a-f]{4}$/;
|
|
|
30
35
|
|
|
31
36
|
// V3-S1 through S5 states. `failed` remains read-tolerant for historical/manual
|
|
32
37
|
// intent files, but current first-party intake writers do not transition into it.
|
|
33
|
-
const S1_STATES = new Set(['detected', 'triaged', 'approved', 'planned', 'executing', 'blocked', 'completed', 'failed', 'suppressed', 'rejected']);
|
|
34
|
-
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']);
|
|
35
40
|
const DISPATCHABLE_STATUSES = new Set(['planned', 'approved']);
|
|
36
41
|
const PRIORITY_RANK = { p0: 0, p1: 1, p2: 2, p3: 3 };
|
|
37
42
|
|
|
@@ -282,7 +287,7 @@ export function validateEventPayload(payload) {
|
|
|
282
287
|
return { valid: errors.length === 0, errors };
|
|
283
288
|
}
|
|
284
289
|
|
|
285
|
-
export function validateTriageFields(fields) {
|
|
290
|
+
export function validateTriageFields(fields, config = null) {
|
|
286
291
|
const errors = [];
|
|
287
292
|
|
|
288
293
|
if (!VALID_PRIORITIES.includes(fields.priority)) {
|
|
@@ -301,6 +306,17 @@ export function validateTriageFields(fields) {
|
|
|
301
306
|
errors.push('acceptance_contract must be a non-empty array');
|
|
302
307
|
}
|
|
303
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
|
+
|
|
304
320
|
return { valid: errors.length === 0, errors };
|
|
305
321
|
}
|
|
306
322
|
|
|
@@ -354,6 +370,7 @@ export function recordEvent(root, payload) {
|
|
|
354
370
|
template: null,
|
|
355
371
|
charter: null,
|
|
356
372
|
acceptance_contract: [],
|
|
373
|
+
phase_scope: null,
|
|
357
374
|
requires_human_start: true,
|
|
358
375
|
target_run: null,
|
|
359
376
|
created_at: now,
|
|
@@ -419,17 +436,25 @@ export function triageIntent(root, intentId, fields) {
|
|
|
419
436
|
return { ok: false, error: `cannot triage from status "${intent.status}" (must be detected)`, exitCode: 1 };
|
|
420
437
|
}
|
|
421
438
|
|
|
422
|
-
const
|
|
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);
|
|
423
447
|
if (!validation.valid) {
|
|
424
448
|
return { ok: false, error: validation.errors.join('; '), exitCode: 1 };
|
|
425
449
|
}
|
|
426
450
|
|
|
427
451
|
const now = nowISO();
|
|
428
452
|
intent.status = 'triaged';
|
|
429
|
-
intent.priority =
|
|
430
|
-
intent.template =
|
|
431
|
-
intent.charter =
|
|
432
|
-
intent.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;
|
|
433
458
|
intent.updated_at = now;
|
|
434
459
|
intent.history.push({ from: 'detected', to: 'triaged', at: now, reason: 'triage completed' });
|
|
435
460
|
|
|
@@ -807,6 +832,16 @@ export function approveIntent(root, intentId, options = {}) {
|
|
|
807
832
|
intent.status = 'approved';
|
|
808
833
|
intent.approved_by = approver;
|
|
809
834
|
intent.updated_at = now;
|
|
835
|
+
|
|
836
|
+
const phantomReason = 'planning artifacts for this intent already exist on disk; intent superseded during approval';
|
|
837
|
+
if (intent.approved_run_id && isPhantomIntent(root, intent)) {
|
|
838
|
+
intent.status = 'superseded';
|
|
839
|
+
intent.archived_reason = phantomReason;
|
|
840
|
+
intent.history.push({ from: previousStatus, to: 'superseded', at: now, reason: phantomReason, approver });
|
|
841
|
+
safeWriteJson(intentPath, intent);
|
|
842
|
+
return { ok: true, intent, superseded: true, exitCode: 0 };
|
|
843
|
+
}
|
|
844
|
+
|
|
810
845
|
intent.history.push({ from: previousStatus, to: 'approved', at: now, reason, approver });
|
|
811
846
|
|
|
812
847
|
safeWriteJson(intentPath, intent);
|
|
@@ -935,6 +970,7 @@ export function startIntent(root, intentId, options = {}) {
|
|
|
935
970
|
category: event.category || null,
|
|
936
971
|
charter: intent.charter || null,
|
|
937
972
|
acceptance_contract: Array.isArray(intent.acceptance_contract) ? intent.acceptance_contract : [],
|
|
973
|
+
phase_scope: intent.phase_scope || null,
|
|
938
974
|
};
|
|
939
975
|
|
|
940
976
|
// Load governed project context
|
|
@@ -1771,6 +1807,7 @@ export function injectIntent(root, description, options = {}) {
|
|
|
1771
1807
|
: [description.trim()];
|
|
1772
1808
|
const approver = options.approver || 'human';
|
|
1773
1809
|
const noApprove = options.noApprove === true;
|
|
1810
|
+
const phase_scope = options.phase_scope || null;
|
|
1774
1811
|
|
|
1775
1812
|
// Step 1: Record event
|
|
1776
1813
|
const recordResult = recordEvent(root, {
|
|
@@ -1804,6 +1841,7 @@ export function injectIntent(root, description, options = {}) {
|
|
|
1804
1841
|
template,
|
|
1805
1842
|
charter,
|
|
1806
1843
|
acceptance_contract,
|
|
1844
|
+
phase_scope,
|
|
1807
1845
|
});
|
|
1808
1846
|
|
|
1809
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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
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';
|
|
5
6
|
import { VALID_GOVERNED_TEMPLATE_IDS, loadGovernedTemplate } from './governed-templates.js';
|
|
6
7
|
|
|
@@ -28,7 +29,37 @@ function normalizeArtifactPaths(paths) {
|
|
|
28
29
|
)];
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function
|
|
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) {
|
|
32
63
|
const configPath = join(root, 'agentxchain.json');
|
|
33
64
|
if (!existsSync(configPath)) return [];
|
|
34
65
|
|
|
@@ -39,27 +70,10 @@ function readPlanningGateFiles(root) {
|
|
|
39
70
|
return [];
|
|
40
71
|
}
|
|
41
72
|
|
|
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
73
|
const exitGateId = config?.routing?.planning?.exit_gate;
|
|
62
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 [];
|
|
63
77
|
return normalizeArtifactPaths(requiresFiles);
|
|
64
78
|
}
|
|
65
79
|
|
|
@@ -146,7 +160,7 @@ export function listExpectedPlanningArtifacts(root, intent) {
|
|
|
146
160
|
return normalizeArtifactPaths([
|
|
147
161
|
...recordedArtifacts,
|
|
148
162
|
...templateArtifacts,
|
|
149
|
-
...readPlanningGateFiles(root),
|
|
163
|
+
...readPlanningGateFiles(root, intent),
|
|
150
164
|
]);
|
|
151
165
|
}
|
|
152
166
|
|
|
@@ -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
|
-
: (
|
|
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':
|