agentxchain 2.1.1 → 2.3.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.
@@ -0,0 +1,924 @@
1
+ import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import { safeWriteJson } from './safe-write.js';
5
+ import { VALID_GOVERNED_TEMPLATE_IDS, loadGovernedTemplate } from './governed-templates.js';
6
+ import {
7
+ initializeGovernedRun,
8
+ assignGovernedTurn,
9
+ getActiveTurns,
10
+ getActiveTurnCount,
11
+ STATE_PATH,
12
+ } from './governed-state.js';
13
+ import { loadProjectContext, loadProjectState } from './config.js';
14
+ import { writeDispatchBundle } from './dispatch-bundle.js';
15
+ import { finalizeDispatchManifest } from './dispatch-manifest.js';
16
+ import { getDispatchTurnDir } from './turn-paths.js';
17
+
18
+ const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule'];
19
+ const VALID_PRIORITIES = ['p0', 'p1', 'p2', 'p3'];
20
+ const EVENT_ID_RE = /^evt_\d+_[0-9a-f]{4}$/;
21
+ const INTENT_ID_RE = /^intent_\d+_[0-9a-f]{4}$/;
22
+
23
+ // V3-S1 through S5 states
24
+ const S1_STATES = new Set(['detected', 'triaged', 'approved', 'planned', 'executing', 'blocked', 'completed', 'failed', 'suppressed', 'rejected']);
25
+ const TERMINAL_STATES = new Set(['suppressed', 'rejected', 'completed', 'failed']);
26
+
27
+ const VALID_TRANSITIONS = {
28
+ detected: ['triaged', 'suppressed'],
29
+ triaged: ['approved', 'rejected'],
30
+ approved: ['planned'],
31
+ planned: ['executing'],
32
+ executing: ['blocked', 'completed', 'failed'],
33
+ blocked: ['approved'],
34
+ };
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function generateId(prefix) {
41
+ const ts = Date.now();
42
+ const rand = randomBytes(2).toString('hex');
43
+ return `${prefix}_${ts}_${rand}`;
44
+ }
45
+
46
+ function computeDedupKey(source, signal) {
47
+ const sorted = JSON.stringify(signal, Object.keys(signal).sort());
48
+ const hash = createHash('sha256').update(sorted).digest('hex').slice(0, 16);
49
+ return `${source}:${hash}`;
50
+ }
51
+
52
+ function nowISO() {
53
+ return new Date().toISOString();
54
+ }
55
+
56
+ function intakeDirs(root) {
57
+ const base = join(root, '.agentxchain', 'intake');
58
+ return {
59
+ base,
60
+ events: join(base, 'events'),
61
+ intents: join(base, 'intents'),
62
+ };
63
+ }
64
+
65
+ function ensureIntakeDirs(root) {
66
+ const dirs = intakeDirs(root);
67
+ mkdirSync(dirs.events, { recursive: true });
68
+ mkdirSync(dirs.intents, { recursive: true });
69
+ return dirs;
70
+ }
71
+
72
+ function readJsonDir(dirPath) {
73
+ if (!existsSync(dirPath)) return [];
74
+ return readdirSync(dirPath)
75
+ .filter(f => f.endsWith('.json') && !f.startsWith('.tmp-'))
76
+ .map(f => {
77
+ try {
78
+ return JSON.parse(readFileSync(join(dirPath, f), 'utf8'));
79
+ } catch {
80
+ return null;
81
+ }
82
+ })
83
+ .filter(Boolean);
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Validation
88
+ // ---------------------------------------------------------------------------
89
+
90
+ export function validateEventPayload(payload) {
91
+ const errors = [];
92
+
93
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
94
+ return { valid: false, errors: ['payload must be a JSON object'] };
95
+ }
96
+
97
+ if (!VALID_SOURCES.includes(payload.source)) {
98
+ errors.push(`source must be one of: ${VALID_SOURCES.join(', ')}`);
99
+ }
100
+
101
+ if (!payload.signal || typeof payload.signal !== 'object' || Array.isArray(payload.signal) || Object.keys(payload.signal).length === 0) {
102
+ errors.push('signal must be a non-empty object');
103
+ }
104
+
105
+ if (!Array.isArray(payload.evidence) || payload.evidence.length === 0) {
106
+ errors.push('evidence must be a non-empty array');
107
+ } else {
108
+ for (const e of payload.evidence) {
109
+ if (!e || typeof e !== 'object') {
110
+ errors.push('each evidence entry must be an object');
111
+ } else {
112
+ if (!['url', 'file', 'text'].includes(e.type)) {
113
+ errors.push(`evidence type must be one of: url, file, text (got "${e.type}")`);
114
+ }
115
+ if (typeof e.value !== 'string' || !e.value.trim()) {
116
+ errors.push('evidence value must be a non-empty string');
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ return { valid: errors.length === 0, errors };
123
+ }
124
+
125
+ export function validateTriageFields(fields) {
126
+ const errors = [];
127
+
128
+ if (!VALID_PRIORITIES.includes(fields.priority)) {
129
+ errors.push(`priority must be one of: ${VALID_PRIORITIES.join(', ')}`);
130
+ }
131
+
132
+ if (!VALID_GOVERNED_TEMPLATE_IDS.includes(fields.template)) {
133
+ errors.push(`template must be one of: ${VALID_GOVERNED_TEMPLATE_IDS.join(', ')}`);
134
+ }
135
+
136
+ if (typeof fields.charter !== 'string' || !fields.charter.trim()) {
137
+ errors.push('charter must be a non-empty string');
138
+ }
139
+
140
+ if (!Array.isArray(fields.acceptance_contract) || fields.acceptance_contract.length === 0) {
141
+ errors.push('acceptance_contract must be a non-empty array');
142
+ }
143
+
144
+ return { valid: errors.length === 0, errors };
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Record
149
+ // ---------------------------------------------------------------------------
150
+
151
+ export function recordEvent(root, payload) {
152
+ const validation = validateEventPayload(payload);
153
+ if (!validation.valid) {
154
+ return { ok: false, error: validation.errors.join('; '), exitCode: 1 };
155
+ }
156
+
157
+ const dirs = ensureIntakeDirs(root);
158
+ const dedupKey = computeDedupKey(payload.source, payload.signal);
159
+
160
+ // Check for duplicate
161
+ const existingEvents = readJsonDir(dirs.events);
162
+ const dup = existingEvents.find(e => e.dedup_key === dedupKey);
163
+ if (dup) {
164
+ const existingIntents = readJsonDir(dirs.intents);
165
+ const linkedIntent = existingIntents.find(i => i.event_id === dup.event_id);
166
+ return { ok: true, event: dup, intent: linkedIntent || null, deduplicated: true, exitCode: 0 };
167
+ }
168
+
169
+ const now = nowISO();
170
+ const eventId = generateId('evt');
171
+ const event = {
172
+ schema_version: '1.0',
173
+ event_id: eventId,
174
+ source: payload.source,
175
+ category: payload.category || `${payload.source}_signal`,
176
+ created_at: now,
177
+ repo: payload.repo || null,
178
+ ref: payload.ref || null,
179
+ signal: payload.signal,
180
+ evidence: payload.evidence,
181
+ dedup_key: dedupKey,
182
+ };
183
+
184
+ safeWriteJson(join(dirs.events, `${eventId}.json`), event);
185
+
186
+ // Create detected intent
187
+ const intentId = generateId('intent');
188
+ const intent = {
189
+ schema_version: '1.0',
190
+ intent_id: intentId,
191
+ event_id: eventId,
192
+ status: 'detected',
193
+ priority: null,
194
+ template: null,
195
+ charter: null,
196
+ acceptance_contract: [],
197
+ requires_human_start: true,
198
+ target_run: null,
199
+ created_at: now,
200
+ updated_at: now,
201
+ history: [
202
+ { from: null, to: 'detected', at: now, reason: 'event ingested' },
203
+ ],
204
+ };
205
+
206
+ safeWriteJson(join(dirs.intents, `${intentId}.json`), intent);
207
+
208
+ return { ok: true, event, intent, deduplicated: false, exitCode: 0 };
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Triage
213
+ // ---------------------------------------------------------------------------
214
+
215
+ export function triageIntent(root, intentId, fields) {
216
+ const dirs = intakeDirs(root);
217
+ const intentPath = join(dirs.intents, `${intentId}.json`);
218
+
219
+ if (!existsSync(intentPath)) {
220
+ return { ok: false, error: `intent ${intentId} not found`, exitCode: 2 };
221
+ }
222
+
223
+ const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
224
+
225
+ // Suppress path
226
+ if (fields.suppress) {
227
+ if (intent.status !== 'detected') {
228
+ return { ok: false, error: `cannot suppress from status "${intent.status}" (must be detected)`, exitCode: 1 };
229
+ }
230
+ if (!fields.reason) {
231
+ return { ok: false, error: 'suppress requires --reason', exitCode: 1 };
232
+ }
233
+ const now = nowISO();
234
+ intent.status = 'suppressed';
235
+ intent.updated_at = now;
236
+ intent.history.push({ from: 'detected', to: 'suppressed', at: now, reason: fields.reason });
237
+ safeWriteJson(intentPath, intent);
238
+ return { ok: true, intent, exitCode: 0 };
239
+ }
240
+
241
+ // Reject path
242
+ if (fields.reject) {
243
+ if (intent.status !== 'triaged') {
244
+ return { ok: false, error: `cannot reject from status "${intent.status}" (must be triaged)`, exitCode: 1 };
245
+ }
246
+ if (!fields.reason) {
247
+ return { ok: false, error: 'reject requires --reason', exitCode: 1 };
248
+ }
249
+ const now = nowISO();
250
+ intent.status = 'rejected';
251
+ intent.updated_at = now;
252
+ intent.history.push({ from: 'triaged', to: 'rejected', at: now, reason: fields.reason });
253
+ safeWriteJson(intentPath, intent);
254
+ return { ok: true, intent, exitCode: 0 };
255
+ }
256
+
257
+ // Triage path: detected -> triaged
258
+ if (intent.status !== 'detected') {
259
+ return { ok: false, error: `cannot triage from status "${intent.status}" (must be detected)`, exitCode: 1 };
260
+ }
261
+
262
+ const validation = validateTriageFields(fields);
263
+ if (!validation.valid) {
264
+ return { ok: false, error: validation.errors.join('; '), exitCode: 1 };
265
+ }
266
+
267
+ const now = nowISO();
268
+ intent.status = 'triaged';
269
+ intent.priority = fields.priority;
270
+ intent.template = fields.template;
271
+ intent.charter = fields.charter;
272
+ intent.acceptance_contract = fields.acceptance_contract;
273
+ intent.updated_at = now;
274
+ intent.history.push({ from: 'detected', to: 'triaged', at: now, reason: 'triage completed' });
275
+
276
+ safeWriteJson(intentPath, intent);
277
+ return { ok: true, intent, exitCode: 0 };
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Status
282
+ // ---------------------------------------------------------------------------
283
+
284
+ export function intakeStatus(root, intentId) {
285
+ const dirs = intakeDirs(root);
286
+
287
+ if (intentId) {
288
+ const intentPath = join(dirs.intents, `${intentId}.json`);
289
+ if (!existsSync(intentPath)) {
290
+ return { ok: false, error: `intent ${intentId} not found`, exitCode: 2 };
291
+ }
292
+ const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
293
+ const eventPath = join(dirs.events, `${intent.event_id}.json`);
294
+ const event = existsSync(eventPath) ? JSON.parse(readFileSync(eventPath, 'utf8')) : null;
295
+ return { ok: true, intent, event, exitCode: 0 };
296
+ }
297
+
298
+ const events = readJsonDir(dirs.events);
299
+ const intents = readJsonDir(dirs.intents);
300
+
301
+ const counts = {};
302
+ for (const intent of intents) {
303
+ counts[intent.status] = (counts[intent.status] || 0) + 1;
304
+ }
305
+
306
+ const summary = {
307
+ schema_version: '1.0',
308
+ last_updated_at: nowISO(),
309
+ total_events: events.length,
310
+ total_intents: intents.length,
311
+ by_status: counts,
312
+ intents: intents
313
+ .sort((a, b) => (b.updated_at || b.created_at).localeCompare(a.updated_at || a.created_at))
314
+ .map(i => ({
315
+ intent_id: i.intent_id,
316
+ priority: i.priority,
317
+ template: i.template,
318
+ status: i.status,
319
+ updated_at: i.updated_at,
320
+ })),
321
+ };
322
+
323
+ // Write loop-state cache
324
+ const loopState = {
325
+ schema_version: '1.0',
326
+ last_updated_at: summary.last_updated_at,
327
+ pending_events: events.filter(e => {
328
+ const linked = intents.find(i => i.event_id === e.event_id);
329
+ return !linked || linked.status === 'detected';
330
+ }).length,
331
+ pending_intents: intents.filter(i => i.status === 'detected' || i.status === 'triaged').length,
332
+ active_intents: intents.filter(i => !TERMINAL_STATES.has(i.status) && i.status !== 'detected').length,
333
+ };
334
+
335
+ try {
336
+ ensureIntakeDirs(root);
337
+ safeWriteJson(join(dirs.base, 'loop-state.json'), loopState);
338
+ } catch {
339
+ // non-fatal — loop-state is a cache
340
+ }
341
+
342
+ return { ok: true, summary, exitCode: 0 };
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // Approve
347
+ // ---------------------------------------------------------------------------
348
+
349
+ export function approveIntent(root, intentId, options = {}) {
350
+ const dirs = intakeDirs(root);
351
+ const intentPath = join(dirs.intents, `${intentId}.json`);
352
+
353
+ if (!existsSync(intentPath)) {
354
+ return { ok: false, error: `intent ${intentId} not found`, exitCode: 2 };
355
+ }
356
+
357
+ const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
358
+
359
+ if (intent.status !== 'triaged' && intent.status !== 'blocked') {
360
+ return { ok: false, error: `cannot approve from status "${intent.status}" (must be triaged or blocked)`, exitCode: 1 };
361
+ }
362
+
363
+ const approver = options.approver || 'operator';
364
+ const previousStatus = intent.status;
365
+ const reason = options.reason || (previousStatus === 'blocked' ? 're-approved after block resolution' : 'approved for planning');
366
+ const now = nowISO();
367
+
368
+ intent.status = 'approved';
369
+ intent.approved_by = approver;
370
+ intent.updated_at = now;
371
+ intent.history.push({ from: previousStatus, to: 'approved', at: now, reason, approver });
372
+
373
+ safeWriteJson(intentPath, intent);
374
+ return { ok: true, intent, exitCode: 0 };
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Plan
379
+ // ---------------------------------------------------------------------------
380
+
381
+ export function planIntent(root, intentId, options = {}) {
382
+ const dirs = intakeDirs(root);
383
+ const intentPath = join(dirs.intents, `${intentId}.json`);
384
+
385
+ if (!existsSync(intentPath)) {
386
+ return { ok: false, error: `intent ${intentId} not found`, exitCode: 2 };
387
+ }
388
+
389
+ const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
390
+
391
+ if (intent.status !== 'approved') {
392
+ return { ok: false, error: `cannot plan from status "${intent.status}" (must be approved)`, exitCode: 1 };
393
+ }
394
+
395
+ // Load the governed template
396
+ let manifest;
397
+ try {
398
+ manifest = loadGovernedTemplate(intent.template);
399
+ } catch (err) {
400
+ return { ok: false, error: err.message, exitCode: 2 };
401
+ }
402
+
403
+ const planningDir = join(root, '.planning');
404
+ const projectName = options.projectName || basename(root);
405
+ const artifacts = manifest.planning_artifacts || [];
406
+
407
+ // Check for conflicts
408
+ if (!options.force) {
409
+ const conflicts = [];
410
+ for (const artifact of artifacts) {
411
+ const targetPath = join(planningDir, artifact.filename);
412
+ if (existsSync(targetPath)) {
413
+ conflicts.push(`.planning/${artifact.filename}`);
414
+ }
415
+ }
416
+ if (conflicts.length > 0) {
417
+ return {
418
+ ok: false,
419
+ error: 'existing planning artifacts would be overwritten',
420
+ conflicts,
421
+ exitCode: 1,
422
+ };
423
+ }
424
+ }
425
+
426
+ // Generate artifacts
427
+ mkdirSync(planningDir, { recursive: true });
428
+ const generated = [];
429
+ for (const artifact of artifacts) {
430
+ const targetPath = join(planningDir, artifact.filename);
431
+ const content = artifact.content_template.replace(/\{\{project_name\}\}/g, projectName);
432
+ writeFileSync(targetPath, content + '\n');
433
+ generated.push(`.planning/${artifact.filename}`);
434
+ }
435
+
436
+ const now = nowISO();
437
+ intent.status = 'planned';
438
+ intent.planning_artifacts = generated;
439
+ intent.updated_at = now;
440
+ intent.history.push({
441
+ from: 'approved',
442
+ to: 'planned',
443
+ at: now,
444
+ reason: `generated ${generated.length} planning artifact(s) from template "${intent.template}"`,
445
+ artifacts: generated,
446
+ });
447
+
448
+ safeWriteJson(intentPath, intent);
449
+ return { ok: true, intent, artifacts_generated: generated, artifacts_skipped: [], exitCode: 0 };
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Start — planned → executing bridge (V3-S3)
454
+ // ---------------------------------------------------------------------------
455
+
456
+ export function startIntent(root, intentId, options = {}) {
457
+ const dirs = intakeDirs(root);
458
+ const intentPath = join(dirs.intents, `${intentId}.json`);
459
+
460
+ if (!existsSync(intentPath)) {
461
+ return { ok: false, error: `intent ${intentId} not found`, exitCode: 2 };
462
+ }
463
+
464
+ const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
465
+
466
+ if (intent.status !== 'planned') {
467
+ return { ok: false, error: `cannot start from status "${intent.status}" (must be planned)`, exitCode: 1 };
468
+ }
469
+
470
+ // Verify planning artifacts still exist on disk
471
+ const planningArtifacts = intent.planning_artifacts || [];
472
+ const missingArtifacts = [];
473
+ for (const relPath of planningArtifacts) {
474
+ if (!existsSync(join(root, relPath))) {
475
+ missingArtifacts.push(relPath);
476
+ }
477
+ }
478
+ if (missingArtifacts.length > 0) {
479
+ return {
480
+ ok: false,
481
+ error: 'recorded planning artifacts are missing on disk',
482
+ missing: missingArtifacts,
483
+ exitCode: 1,
484
+ };
485
+ }
486
+
487
+ // Load governed project context
488
+ const context = loadProjectContext(root);
489
+ if (!context) {
490
+ return { ok: false, error: 'agentxchain.json not found or invalid', exitCode: 2 };
491
+ }
492
+ const { config } = context;
493
+
494
+ if (config.protocol_mode !== 'governed') {
495
+ return { ok: false, error: 'intake start requires a governed project', exitCode: 2 };
496
+ }
497
+
498
+ // Load governed state
499
+ const statePath = join(root, STATE_PATH);
500
+ if (!existsSync(statePath)) {
501
+ return { ok: false, error: 'No governed state.json found', exitCode: 2 };
502
+ }
503
+
504
+ let state = loadProjectState(root, config);
505
+ if (!state) {
506
+ return { ok: false, error: 'Failed to parse governed state.json', exitCode: 2 };
507
+ }
508
+
509
+ // Check busy-run conditions
510
+ const activeTurns = getActiveTurns(state);
511
+ const activeCount = getActiveTurnCount(state);
512
+
513
+ if (activeCount > 0) {
514
+ const turnIds = Object.keys(activeTurns);
515
+ return {
516
+ ok: false,
517
+ error: `cannot start: active turn(s) already exist: ${turnIds.join(', ')}`,
518
+ exitCode: 1,
519
+ };
520
+ }
521
+
522
+ if (state.status === 'blocked') {
523
+ const reason = state.blocked_reason?.recovery?.detail || state.blocked_on || 'unknown';
524
+ return { ok: false, error: `cannot start: run is blocked (${reason})`, exitCode: 1 };
525
+ }
526
+
527
+ if (state.status === 'completed') {
528
+ return {
529
+ ok: false,
530
+ error: 'cannot start: governed run is already completed. S3 does not reopen completed runs.',
531
+ exitCode: 1,
532
+ };
533
+ }
534
+
535
+ if (state.pending_phase_transition) {
536
+ return { ok: false, error: `cannot start: pending phase transition to "${state.pending_phase_transition}"`, exitCode: 1 };
537
+ }
538
+
539
+ if (state.pending_run_completion) {
540
+ return { ok: false, error: 'cannot start: pending run completion approval', exitCode: 1 };
541
+ }
542
+
543
+ // Bootstrap: idle with no run → initialize
544
+ if (state.status === 'idle' && !state.run_id) {
545
+ const initResult = initializeGovernedRun(root, config);
546
+ if (!initResult.ok) {
547
+ return { ok: false, error: `run initialization failed: ${initResult.error}`, exitCode: 1 };
548
+ }
549
+ state = initResult.state;
550
+ }
551
+
552
+ // Resume: paused with no active turns → reactivate
553
+ if (state.status === 'paused' && state.run_id) {
554
+ state.status = 'active';
555
+ state.blocked_on = null;
556
+ state.escalation = null;
557
+ safeWriteJson(statePath, state);
558
+ }
559
+
560
+ // Resolve role
561
+ const roleId = resolveIntakeRole(options.role, state, config);
562
+ if (!roleId.ok) {
563
+ return { ok: false, error: roleId.error, exitCode: 1 };
564
+ }
565
+
566
+ // Assign governed turn
567
+ const assignResult = assignGovernedTurn(root, config, roleId.role);
568
+ if (!assignResult.ok) {
569
+ return { ok: false, error: `turn assignment failed: ${assignResult.error}`, exitCode: 1 };
570
+ }
571
+ state = assignResult.state;
572
+
573
+ // Find the newly assigned turn
574
+ const newActiveTurns = getActiveTurns(state);
575
+ const assignedTurn = Object.values(newActiveTurns).find(t => t.assigned_role === roleId.role);
576
+ if (!assignedTurn) {
577
+ return { ok: false, error: 'turn assignment succeeded but turn not found in state', exitCode: 1 };
578
+ }
579
+
580
+ // Write dispatch bundle
581
+ const bundleResult = writeDispatchBundle(root, state, config);
582
+ if (!bundleResult.ok) {
583
+ return { ok: false, error: `dispatch bundle failed: ${bundleResult.error}`, exitCode: 1 };
584
+ }
585
+
586
+ // Finalize dispatch manifest
587
+ finalizeDispatchManifest(root, assignedTurn.turn_id, {
588
+ run_id: state.run_id,
589
+ role: assignedTurn.assigned_role,
590
+ });
591
+
592
+ // Update intent: planned → executing
593
+ const now = nowISO();
594
+ intent.status = 'executing';
595
+ intent.target_run = state.run_id;
596
+ intent.target_turn = assignedTurn.turn_id;
597
+ intent.started_at = now;
598
+ intent.updated_at = now;
599
+ intent.history.push({
600
+ from: 'planned',
601
+ to: 'executing',
602
+ at: now,
603
+ run_id: state.run_id,
604
+ turn_id: assignedTurn.turn_id,
605
+ role: roleId.role,
606
+ reason: 'governed execution started',
607
+ });
608
+
609
+ safeWriteJson(intentPath, intent);
610
+
611
+ return {
612
+ ok: true,
613
+ intent,
614
+ run_id: state.run_id,
615
+ turn_id: assignedTurn.turn_id,
616
+ role: roleId.role,
617
+ dispatch_dir: getDispatchTurnDir(assignedTurn.turn_id),
618
+ exitCode: 0,
619
+ };
620
+ }
621
+
622
+ function resolveIntakeRole(roleOverride, state, config) {
623
+ const phase = state.phase;
624
+ const routing = config.routing?.[phase];
625
+
626
+ if (roleOverride) {
627
+ if (!config.roles?.[roleOverride]) {
628
+ const available = Object.keys(config.roles || {}).join(', ');
629
+ return { ok: false, error: `unknown role: "${roleOverride}". Available: ${available}` };
630
+ }
631
+ return { ok: true, role: roleOverride };
632
+ }
633
+
634
+ if (routing?.entry_role) {
635
+ return { ok: true, role: routing.entry_role };
636
+ }
637
+
638
+ const roles = Object.keys(config.roles || {});
639
+ if (roles.length > 0) {
640
+ return { ok: true, role: roles[0] };
641
+ }
642
+
643
+ return { ok: false, error: 'no roles defined in project config' };
644
+ }
645
+
646
+ // ---------------------------------------------------------------------------
647
+ // Resolve — execution exit and intent closure linkage (V3-S5)
648
+ // ---------------------------------------------------------------------------
649
+
650
+ export function resolveIntent(root, intentId) {
651
+ const dirs = intakeDirs(root);
652
+ const intentPath = join(dirs.intents, `${intentId}.json`);
653
+
654
+ if (!existsSync(intentPath)) {
655
+ return { ok: false, error: `intent ${intentId} not found`, exitCode: 2 };
656
+ }
657
+
658
+ const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
659
+
660
+ if (intent.status !== 'executing') {
661
+ return { ok: false, error: `cannot resolve from status "${intent.status}" (must be executing)`, exitCode: 1 };
662
+ }
663
+
664
+ if (!intent.target_run) {
665
+ return { ok: false, error: `intent ${intentId} has no linked run (target_run is null)`, exitCode: 1 };
666
+ }
667
+
668
+ // Load governed state
669
+ const statePath = join(root, STATE_PATH);
670
+ if (!existsSync(statePath)) {
671
+ return { ok: false, error: 'governed state not found at .agentxchain/state.json', exitCode: 1 };
672
+ }
673
+
674
+ let state;
675
+ try {
676
+ state = JSON.parse(readFileSync(statePath, 'utf8'));
677
+ } catch {
678
+ return { ok: false, error: 'failed to parse governed state.json', exitCode: 1 };
679
+ }
680
+
681
+ // Validate run identity
682
+ if (state.run_id !== intent.target_run) {
683
+ return {
684
+ ok: false,
685
+ error: `run_id mismatch: intent targets ${intent.target_run} but governed state has ${state.run_id}`,
686
+ exitCode: 1,
687
+ };
688
+ }
689
+
690
+ if (state.status === 'idle') {
691
+ return {
692
+ ok: false,
693
+ error: 'governed run is idle — state may have been reset after intent start',
694
+ exitCode: 1,
695
+ };
696
+ }
697
+
698
+ // Map run outcome to intent transition
699
+ const now = nowISO();
700
+ const previousStatus = intent.status;
701
+
702
+ if (state.status === 'blocked' || state.status === 'failed') {
703
+ const newStatus = state.status === 'blocked' ? 'blocked' : 'failed';
704
+ intent.status = newStatus;
705
+ intent.run_blocked_on = state.blocked_on || null;
706
+ intent.run_blocked_reason = state.blocked_reason?.category || null;
707
+ intent.run_blocked_recovery = state.blocked_reason?.recovery?.recovery_action || null;
708
+ if (newStatus === 'failed') {
709
+ intent.run_failed_at = now;
710
+ }
711
+ intent.updated_at = now;
712
+ intent.history.push({
713
+ from: previousStatus,
714
+ to: newStatus,
715
+ at: now,
716
+ reason: `governed run ${intent.target_run} reached status ${state.status}`,
717
+ run_id: intent.target_run,
718
+ run_status: state.status,
719
+ });
720
+
721
+ safeWriteJson(intentPath, intent);
722
+ return {
723
+ ok: true,
724
+ intent,
725
+ previous_status: previousStatus,
726
+ new_status: newStatus,
727
+ run_outcome: state.status,
728
+ no_change: false,
729
+ exitCode: 0,
730
+ };
731
+ }
732
+
733
+ if (state.status === 'completed') {
734
+ intent.status = 'completed';
735
+ intent.run_completed_at = state.completed_at || now;
736
+ intent.run_final_turn = state.last_completed_turn_id || null;
737
+ intent.updated_at = now;
738
+ intent.history.push({
739
+ from: previousStatus,
740
+ to: 'completed',
741
+ at: now,
742
+ reason: `governed run ${intent.target_run} reached status completed`,
743
+ run_id: intent.target_run,
744
+ run_status: 'completed',
745
+ });
746
+
747
+ // Create observation directory scaffold
748
+ const obsDir = join(dirs.base, 'observations', intentId);
749
+ mkdirSync(obsDir, { recursive: true });
750
+
751
+ safeWriteJson(intentPath, intent);
752
+ return {
753
+ ok: true,
754
+ intent,
755
+ previous_status: previousStatus,
756
+ new_status: 'completed',
757
+ run_outcome: 'completed',
758
+ no_change: false,
759
+ exitCode: 0,
760
+ };
761
+ }
762
+
763
+ // active or paused — no transition yet
764
+ return {
765
+ ok: true,
766
+ intent,
767
+ previous_status: previousStatus,
768
+ new_status: previousStatus,
769
+ run_outcome: state.status,
770
+ no_change: true,
771
+ exitCode: 0,
772
+ };
773
+ }
774
+
775
+ // ---------------------------------------------------------------------------
776
+ // Scan — deterministic source-snapshot ingestion (V3-S4)
777
+ // ---------------------------------------------------------------------------
778
+
779
+ const SCAN_SOURCES = ['ci_failure', 'git_ref_change', 'schedule'];
780
+
781
+ function validateSnapshotItem(item) {
782
+ const errors = [];
783
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
784
+ return { valid: false, errors: ['item must be a JSON object'] };
785
+ }
786
+ if (!item.signal || typeof item.signal !== 'object' || Array.isArray(item.signal) || Object.keys(item.signal).length === 0) {
787
+ errors.push('signal must be a non-empty object');
788
+ }
789
+ if (!Array.isArray(item.evidence) || item.evidence.length === 0) {
790
+ errors.push('evidence must be a non-empty array');
791
+ } else {
792
+ for (const e of item.evidence) {
793
+ if (!e || typeof e !== 'object') {
794
+ errors.push('each evidence entry must be an object');
795
+ } else {
796
+ if (!['url', 'file', 'text'].includes(e.type)) {
797
+ errors.push(`evidence type must be one of: url, file, text (got "${e.type}")`);
798
+ }
799
+ if (typeof e.value !== 'string' || !e.value.trim()) {
800
+ errors.push('evidence value must be a non-empty string');
801
+ }
802
+ }
803
+ }
804
+ }
805
+ return { valid: errors.length === 0, errors };
806
+ }
807
+
808
+ export function scanSource(root, source, snapshot) {
809
+ // Validate source
810
+ if (!SCAN_SOURCES.includes(source)) {
811
+ return {
812
+ ok: false,
813
+ error: `unknown scan source: "${source}". Supported: ${SCAN_SOURCES.join(', ')}`,
814
+ exitCode: 1,
815
+ };
816
+ }
817
+
818
+ // Validate snapshot structure
819
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
820
+ return { ok: false, error: 'snapshot must be a JSON object', exitCode: 1 };
821
+ }
822
+
823
+ if (snapshot.source !== source) {
824
+ return {
825
+ ok: false,
826
+ error: `source mismatch: CLI flag "${source}" but snapshot declares "${snapshot.source}"`,
827
+ exitCode: 1,
828
+ };
829
+ }
830
+
831
+ if (!Array.isArray(snapshot.items) || snapshot.items.length === 0) {
832
+ return { ok: false, error: 'snapshot must contain a non-empty items array', exitCode: 1 };
833
+ }
834
+
835
+ const results = [];
836
+ let created = 0;
837
+ let deduplicated = 0;
838
+ let rejected = 0;
839
+
840
+ for (let i = 0; i < snapshot.items.length; i++) {
841
+ const item = snapshot.items[i];
842
+
843
+ // Validate item structure
844
+ const validation = validateSnapshotItem(item);
845
+ if (!validation.valid) {
846
+ results.push({
847
+ status: 'rejected',
848
+ index: i,
849
+ error: validation.errors.join('; '),
850
+ });
851
+ rejected++;
852
+ continue;
853
+ }
854
+
855
+ // Build recordEvent payload from snapshot item
856
+ const payload = {
857
+ source,
858
+ signal: item.signal,
859
+ evidence: item.evidence,
860
+ category: item.category || undefined,
861
+ repo: item.repo || undefined,
862
+ ref: item.ref || undefined,
863
+ };
864
+
865
+ const recordResult = recordEvent(root, payload);
866
+ if (!recordResult.ok) {
867
+ results.push({
868
+ status: 'rejected',
869
+ index: i,
870
+ error: recordResult.error,
871
+ });
872
+ rejected++;
873
+ continue;
874
+ }
875
+
876
+ if (recordResult.deduplicated) {
877
+ results.push({
878
+ status: 'deduplicated',
879
+ event_id: recordResult.event.event_id,
880
+ intent_id: recordResult.intent?.intent_id || null,
881
+ });
882
+ deduplicated++;
883
+ } else {
884
+ results.push({
885
+ status: 'created',
886
+ event_id: recordResult.event.event_id,
887
+ intent_id: recordResult.intent?.intent_id || null,
888
+ });
889
+ created++;
890
+ }
891
+ }
892
+
893
+ // If every item was rejected, fail
894
+ if (created === 0 && deduplicated === 0) {
895
+ return {
896
+ ok: false,
897
+ error: 'all scanned items were rejected',
898
+ source,
899
+ scanned: snapshot.items.length,
900
+ created,
901
+ deduplicated,
902
+ rejected,
903
+ results,
904
+ exitCode: 1,
905
+ };
906
+ }
907
+
908
+ return {
909
+ ok: true,
910
+ source,
911
+ scanned: snapshot.items.length,
912
+ created,
913
+ deduplicated,
914
+ rejected,
915
+ results,
916
+ exitCode: 0,
917
+ };
918
+ }
919
+
920
+ // ---------------------------------------------------------------------------
921
+ // Exports for testing
922
+ // ---------------------------------------------------------------------------
923
+
924
+ export { VALID_SOURCES, VALID_PRIORITIES, VALID_TRANSITIONS, S1_STATES, TERMINAL_STATES, SCAN_SOURCES };