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.
- package/bin/agentxchain.js +1 -0
- package/package.json +1 -1
- package/src/commands/intake-resolve.js +5 -1
- package/src/lib/governed-state.js +182 -6
- package/src/lib/intake.js +68 -9
- package/src/lib/intent-phase-scope.js +97 -0
- package/src/lib/recent-event-summary.js +7 -2
- package/src/lib/repo-observer.js +1 -0
- package/src/lib/run-events.js +1 -0
package/bin/agentxchain.js
CHANGED
|
@@ -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
|
@@ -15,7 +15,11 @@ export async function intakeResolveCommand(opts) {
|
|
|
15
15
|
process.exit(1);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const
|
|
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
|
-
// ──
|
|
3158
|
-
//
|
|
3159
|
-
//
|
|
3160
|
-
|
|
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
|
|
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 =
|
|
431
|
-
intent.template =
|
|
432
|
-
intent.charter =
|
|
433
|
-
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;
|
|
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
|
-
: (
|
|
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':
|
package/src/lib/repo-observer.js
CHANGED