agentxchain 0.8.8 → 2.2.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.
Files changed (74) hide show
  1. package/README.md +136 -136
  2. package/bin/agentxchain.js +186 -5
  3. package/dashboard/app.js +305 -0
  4. package/dashboard/components/blocked.js +145 -0
  5. package/dashboard/components/cross-repo.js +126 -0
  6. package/dashboard/components/gate.js +311 -0
  7. package/dashboard/components/hooks.js +177 -0
  8. package/dashboard/components/initiative.js +147 -0
  9. package/dashboard/components/ledger.js +165 -0
  10. package/dashboard/components/timeline.js +222 -0
  11. package/dashboard/index.html +352 -0
  12. package/package.json +14 -6
  13. package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
  14. package/scripts/publish-from-tag.sh +88 -0
  15. package/scripts/release-postflight.sh +231 -0
  16. package/scripts/release-preflight.sh +167 -0
  17. package/src/commands/accept-turn.js +160 -0
  18. package/src/commands/approve-completion.js +80 -0
  19. package/src/commands/approve-transition.js +85 -0
  20. package/src/commands/dashboard.js +70 -0
  21. package/src/commands/init.js +516 -0
  22. package/src/commands/migrate.js +348 -0
  23. package/src/commands/multi.js +549 -0
  24. package/src/commands/plugin.js +157 -0
  25. package/src/commands/reject-turn.js +204 -0
  26. package/src/commands/resume.js +389 -0
  27. package/src/commands/status.js +196 -3
  28. package/src/commands/step.js +947 -0
  29. package/src/commands/template-list.js +33 -0
  30. package/src/commands/template-set.js +279 -0
  31. package/src/commands/validate.js +20 -11
  32. package/src/commands/verify.js +71 -0
  33. package/src/lib/adapters/api-proxy-adapter.js +1076 -0
  34. package/src/lib/adapters/local-cli-adapter.js +337 -0
  35. package/src/lib/adapters/manual-adapter.js +169 -0
  36. package/src/lib/blocked-state.js +94 -0
  37. package/src/lib/config.js +97 -1
  38. package/src/lib/context-compressor.js +121 -0
  39. package/src/lib/context-section-parser.js +220 -0
  40. package/src/lib/coordinator-acceptance.js +428 -0
  41. package/src/lib/coordinator-config.js +461 -0
  42. package/src/lib/coordinator-dispatch.js +276 -0
  43. package/src/lib/coordinator-gates.js +487 -0
  44. package/src/lib/coordinator-hooks.js +239 -0
  45. package/src/lib/coordinator-recovery.js +523 -0
  46. package/src/lib/coordinator-state.js +365 -0
  47. package/src/lib/cross-repo-context.js +247 -0
  48. package/src/lib/dashboard/bridge-server.js +284 -0
  49. package/src/lib/dashboard/file-watcher.js +93 -0
  50. package/src/lib/dashboard/state-reader.js +96 -0
  51. package/src/lib/dispatch-bundle.js +568 -0
  52. package/src/lib/dispatch-manifest.js +252 -0
  53. package/src/lib/gate-evaluator.js +285 -0
  54. package/src/lib/governed-state.js +2139 -0
  55. package/src/lib/governed-templates.js +145 -0
  56. package/src/lib/hook-runner.js +788 -0
  57. package/src/lib/normalized-config.js +539 -0
  58. package/src/lib/plugin-config-schema.js +192 -0
  59. package/src/lib/plugins.js +692 -0
  60. package/src/lib/protocol-conformance.js +291 -0
  61. package/src/lib/reference-conformance-adapter.js +858 -0
  62. package/src/lib/repo-observer.js +597 -0
  63. package/src/lib/repo.js +0 -31
  64. package/src/lib/schema.js +121 -0
  65. package/src/lib/schemas/turn-result.schema.json +205 -0
  66. package/src/lib/token-budget.js +206 -0
  67. package/src/lib/token-counter.js +27 -0
  68. package/src/lib/turn-paths.js +67 -0
  69. package/src/lib/turn-result-validator.js +496 -0
  70. package/src/lib/validation.js +137 -0
  71. package/src/templates/governed/api-service.json +31 -0
  72. package/src/templates/governed/cli-tool.json +30 -0
  73. package/src/templates/governed/generic.json +10 -0
  74. package/src/templates/governed/web-app.json +30 -0
@@ -0,0 +1,2139 @@
1
+ /**
2
+ * Governed state writers — the accept/reject turn cycle.
3
+ *
4
+ * These are library primitives, not CLI commands. They implement the
5
+ * orchestrator-owned write path from the frozen spec (§39):
6
+ *
7
+ * - initializeGovernedRun() — create a run envelope from idle state
8
+ * - assignGovernedTurn() — assign a turn to a role
9
+ * - acceptGovernedTurn() — validate staged result, promote to accepted state
10
+ * - rejectGovernedTurn() — preserve rejected artifact, increment retry or escalate
11
+ *
12
+ * Design rules:
13
+ * - Only these functions may mutate state.json, history.jsonl, decision-ledger.jsonl
14
+ * - Accept does NOT auto-assign the next turn (§39.1)
15
+ * - Reject does NOT append to history or decision ledger (§39.2)
16
+ */
17
+
18
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, readdirSync, rmSync } from 'fs';
19
+ import { join, dirname } from 'path';
20
+ import { randomBytes } from 'crypto';
21
+ import { safeWriteJson } from './safe-write.js';
22
+ import { validateStagedTurnResult } from './turn-result-validator.js';
23
+ import { evaluatePhaseExit, evaluateRunCompletion } from './gate-evaluator.js';
24
+ import {
25
+ captureBaseline,
26
+ observeChanges,
27
+ classifyObservedChanges,
28
+ buildObservedArtifact,
29
+ normalizeVerification,
30
+ compareDeclaredVsObserved,
31
+ deriveAcceptedRef,
32
+ checkCleanBaseline,
33
+ } from './repo-observer.js';
34
+ import { getMaxConcurrentTurns } from './normalized-config.js';
35
+ import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir } from './turn-paths.js';
36
+ import { runHooks } from './hook-runner.js';
37
+
38
+ // ── Constants ────────────────────────────────────────────────────────────────
39
+
40
+ const STATE_PATH = '.agentxchain/state.json';
41
+ const HISTORY_PATH = '.agentxchain/history.jsonl';
42
+ const LEDGER_PATH = '.agentxchain/decision-ledger.jsonl';
43
+ const STAGING_PATH = '.agentxchain/staging/turn-result.json';
44
+ const TALK_PATH = 'TALK.md';
45
+ const ACCEPTANCE_LOCK_PATH = '.agentxchain/locks/accept-turn.lock';
46
+ const ACCEPTANCE_JOURNAL_DIR = '.agentxchain/transactions/accept';
47
+ const STALE_LOCK_TIMEOUT_MS = 30_000;
48
+ const GOVERNED_SCHEMA_VERSION = '1.1';
49
+
50
+ // ── Helpers ──────────────────────────────────────────────────────────────────
51
+
52
+ function generateId(prefix) {
53
+ return `${prefix}_${randomBytes(8).toString('hex')}`;
54
+ }
55
+
56
+ function normalizeActiveTurns(activeTurns) {
57
+ if (!activeTurns || typeof activeTurns !== 'object' || Array.isArray(activeTurns)) {
58
+ return {};
59
+ }
60
+
61
+ return Object.fromEntries(
62
+ Object.entries(activeTurns).filter(([, turn]) => turn && typeof turn === 'object' && !Array.isArray(turn)),
63
+ );
64
+ }
65
+
66
+ function stripLegacyCurrentTurn(state) {
67
+ if (!state || typeof state !== 'object') {
68
+ return state;
69
+ }
70
+
71
+ const { current_turn, ...rest } = state;
72
+ return {
73
+ ...rest,
74
+ active_turns: normalizeActiveTurns(rest.active_turns),
75
+ };
76
+ }
77
+
78
+ export function getActiveTurns(state) {
79
+ return normalizeActiveTurns(state?.active_turns);
80
+ }
81
+
82
+ export function getActiveTurnCount(state) {
83
+ return Object.keys(getActiveTurns(state)).length;
84
+ }
85
+
86
+ export function getActiveTurn(state) {
87
+ const turns = Object.values(getActiveTurns(state));
88
+ return turns.length === 1 ? turns[0] : null;
89
+ }
90
+
91
+ export function getActiveTurnOrThrow(state) {
92
+ const turns = Object.values(getActiveTurns(state));
93
+ if (turns.length === 0) {
94
+ throw new Error('No active turn is available in governed state.');
95
+ }
96
+ if (turns.length > 1) {
97
+ throw new Error('Multiple active turns are present; this command requires explicit turn targeting.');
98
+ }
99
+ return turns[0];
100
+ }
101
+
102
+ function attachLegacyCurrentTurnAlias(state) {
103
+ if (!state || typeof state !== 'object') {
104
+ return state;
105
+ }
106
+
107
+ const existing = Object.getOwnPropertyDescriptor(state, 'current_turn');
108
+ if (existing && existing.enumerable === false) {
109
+ return state;
110
+ }
111
+
112
+ Object.defineProperty(state, 'current_turn', {
113
+ configurable: true,
114
+ enumerable: false,
115
+ get() {
116
+ return getActiveTurn(state);
117
+ },
118
+ });
119
+
120
+ return state;
121
+ }
122
+
123
+ function normalizeV1toV1_1(state) {
124
+ const hadLegacyCurrentTurn = Object.prototype.hasOwnProperty.call(state, 'current_turn');
125
+ const activeTurns = normalizeActiveTurns(state.active_turns);
126
+ const legacyTurn = hadLegacyCurrentTurn ? state.current_turn : null;
127
+
128
+ if (legacyTurn && typeof legacyTurn === 'object' && legacyTurn.turn_id && !activeTurns[legacyTurn.turn_id]) {
129
+ activeTurns[legacyTurn.turn_id] = {
130
+ ...legacyTurn,
131
+ assigned_sequence: 1,
132
+ };
133
+ }
134
+
135
+ let turnSequence = Number.isInteger(state.turn_sequence) && state.turn_sequence >= 0
136
+ ? state.turn_sequence
137
+ : Object.keys(activeTurns).length > 0 ? 1 : 0;
138
+
139
+ if (Object.keys(activeTurns).length > 0 && turnSequence < 1) {
140
+ turnSequence = 1;
141
+ }
142
+
143
+ const normalizedActiveTurns = Object.fromEntries(
144
+ Object.entries(activeTurns).map(([turnId, turn]) => [
145
+ turnId,
146
+ {
147
+ ...turn,
148
+ assigned_sequence: Number.isInteger(turn.assigned_sequence) && turn.assigned_sequence >= 1
149
+ ? turn.assigned_sequence
150
+ : 1,
151
+ },
152
+ ]),
153
+ );
154
+
155
+ return {
156
+ ...state,
157
+ schema_version: GOVERNED_SCHEMA_VERSION,
158
+ active_turns: normalizedActiveTurns,
159
+ turn_sequence: turnSequence,
160
+ budget_reservations:
161
+ state.budget_reservations && typeof state.budget_reservations === 'object' && !Array.isArray(state.budget_reservations)
162
+ ? state.budget_reservations
163
+ : {},
164
+ queued_phase_transition: state.queued_phase_transition ?? null,
165
+ queued_run_completion: state.queued_run_completion ?? null,
166
+ };
167
+ }
168
+
169
+ function readState(root) {
170
+ const filePath = join(root, STATE_PATH);
171
+ if (!existsSync(filePath)) return null;
172
+ try {
173
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
174
+ const { state, changed } = normalizeGovernedStateShape(parsed);
175
+ if (changed) {
176
+ safeWriteJson(filePath, stripLegacyCurrentTurn(state));
177
+ }
178
+ return attachLegacyCurrentTurnAlias(state);
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ function writeState(root, state) {
185
+ safeWriteJson(join(root, STATE_PATH), stripLegacyCurrentTurn(state));
186
+ }
187
+
188
+ function appendJsonl(root, relPath, entry) {
189
+ const filePath = join(root, relPath);
190
+ mkdirSync(dirname(filePath), { recursive: true });
191
+ const line = JSON.stringify(entry) + '\n';
192
+ writeFileSync(filePath, line, { flag: 'a' });
193
+ }
194
+
195
+ function appendTalk(root, section) {
196
+ const filePath = join(root, TALK_PATH);
197
+ let existing = '';
198
+ if (existsSync(filePath)) {
199
+ existing = readFileSync(filePath, 'utf8');
200
+ }
201
+ const prefix = existing.endsWith('\n') || existing === '' ? '' : '\n';
202
+ writeFileSync(filePath, existing + prefix + section + '\n');
203
+ }
204
+
205
+ function loadHookStagedTurn(root, stagingRel) {
206
+ const stagingAbs = join(root, stagingRel);
207
+ if (!existsSync(stagingAbs)) {
208
+ return { turnResult: null };
209
+ }
210
+
211
+ let raw;
212
+ try {
213
+ raw = readFileSync(stagingAbs, 'utf8');
214
+ } catch (err) {
215
+ return { turnResult: null, read_error: err.message };
216
+ }
217
+
218
+ try {
219
+ return { turnResult: JSON.parse(raw) };
220
+ } catch (err) {
221
+ return { turnResult: null, parse_error: err.message };
222
+ }
223
+ }
224
+
225
+ function readJsonlEntries(root, relPath) {
226
+ const filePath = join(root, relPath);
227
+ if (!existsSync(filePath)) {
228
+ return [];
229
+ }
230
+
231
+ const content = readFileSync(filePath, 'utf8').trim();
232
+ if (!content) {
233
+ return [];
234
+ }
235
+
236
+ return content
237
+ .split('\n')
238
+ .filter(Boolean)
239
+ .map((line, index) => {
240
+ try {
241
+ const entry = JSON.parse(line);
242
+ const acceptedSequence = Number.isInteger(entry.accepted_sequence) && entry.accepted_sequence >= 1
243
+ ? entry.accepted_sequence
244
+ : index + 1;
245
+ return {
246
+ ...entry,
247
+ accepted_sequence: acceptedSequence,
248
+ assigned_sequence: Number.isInteger(entry.assigned_sequence) && entry.assigned_sequence >= 1
249
+ ? entry.assigned_sequence
250
+ : acceptedSequence,
251
+ concurrent_with: Array.isArray(entry.concurrent_with) ? entry.concurrent_with : [],
252
+ };
253
+ } catch {
254
+ return null;
255
+ }
256
+ })
257
+ .filter(Boolean);
258
+ }
259
+
260
+ function getObservedFiles(entry) {
261
+ if (Array.isArray(entry?.observed_artifact?.files_changed)) {
262
+ return entry.observed_artifact.files_changed;
263
+ }
264
+ if (Array.isArray(entry?.files_changed)) {
265
+ return entry.files_changed;
266
+ }
267
+ return [];
268
+ }
269
+
270
+ function resolveTurnTarget(state, turnId) {
271
+ const activeTurns = getActiveTurns(state);
272
+
273
+ if (turnId) {
274
+ const turn = activeTurns[turnId];
275
+ if (!turn) {
276
+ return { ok: false, error: `No active turn found for --turn ${turnId}`, error_code: 'not_found' };
277
+ }
278
+ return { ok: true, turn };
279
+ }
280
+
281
+ const activeEntries = Object.values(activeTurns);
282
+ if (activeEntries.length === 0) {
283
+ return { ok: false, error: 'No active turn to accept', error_code: 'not_found' };
284
+ }
285
+ if (activeEntries.length > 1) {
286
+ return {
287
+ ok: false,
288
+ error: 'Multiple active turns are present. Re-run with --turn <turn_id>.',
289
+ error_code: 'target_required',
290
+ };
291
+ }
292
+
293
+ return { ok: true, turn: activeEntries[0] };
294
+ }
295
+
296
+ // ── Acceptance Lock ─────────────────────────────────────────────────────────
297
+
298
+ function isProcessRunning(pid) {
299
+ try {
300
+ process.kill(pid, 0);
301
+ return true;
302
+ } catch {
303
+ return false;
304
+ }
305
+ }
306
+
307
+ export function acquireAcceptanceLock(root) {
308
+ const lockPath = join(root, ACCEPTANCE_LOCK_PATH);
309
+ mkdirSync(dirname(lockPath), { recursive: true });
310
+
311
+ if (existsSync(lockPath)) {
312
+ try {
313
+ const existing = JSON.parse(readFileSync(lockPath, 'utf8'));
314
+ const acquiredAt = new Date(existing.acquired_at).getTime();
315
+ const elapsed = Date.now() - acquiredAt;
316
+ const ownerAlive = existing.owner_pid && isProcessRunning(existing.owner_pid);
317
+
318
+ if (ownerAlive && elapsed < STALE_LOCK_TIMEOUT_MS) {
319
+ return { ok: false, error: `Acceptance lock held by PID ${existing.owner_pid}`, error_code: 'lock_timeout' };
320
+ }
321
+ // Stale lock — reclaim it
322
+ } catch {
323
+ // Corrupt lock file — reclaim it
324
+ }
325
+ }
326
+
327
+ const lock = {
328
+ owner_pid: process.pid,
329
+ acquired_at: new Date().toISOString(),
330
+ };
331
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2));
332
+ return { ok: true };
333
+ }
334
+
335
+ export function releaseAcceptanceLock(root) {
336
+ const lockPath = join(root, ACCEPTANCE_LOCK_PATH);
337
+ try {
338
+ if (existsSync(lockPath)) {
339
+ const existing = JSON.parse(readFileSync(lockPath, 'utf8'));
340
+ if (existing.owner_pid === process.pid) {
341
+ unlinkSync(lockPath);
342
+ }
343
+ }
344
+ } catch {
345
+ // Best-effort cleanup
346
+ }
347
+ }
348
+
349
+ // ── Acceptance Transaction Journal ──────────────────────────────────────────
350
+
351
+ function writeAcceptanceJournal(root, journal) {
352
+ const journalDir = join(root, ACCEPTANCE_JOURNAL_DIR);
353
+ mkdirSync(journalDir, { recursive: true });
354
+ const journalPath = join(journalDir, `${journal.transaction_id}.json`);
355
+ safeWriteJson(journalPath, journal);
356
+ return journalPath;
357
+ }
358
+
359
+ function commitAcceptanceJournal(root, transactionId) {
360
+ const journalPath = join(root, ACCEPTANCE_JOURNAL_DIR, `${transactionId}.json`);
361
+ try {
362
+ if (existsSync(journalPath)) {
363
+ unlinkSync(journalPath);
364
+ }
365
+ } catch {
366
+ // Best-effort
367
+ }
368
+ }
369
+
370
+ export function replayPreparedJournals(root) {
371
+ const journalDir = join(root, ACCEPTANCE_JOURNAL_DIR);
372
+ if (!existsSync(journalDir)) return [];
373
+
374
+ const replayed = [];
375
+ let files;
376
+ try {
377
+ files = readdirSync(journalDir).filter(f => f.endsWith('.json'));
378
+ } catch {
379
+ return [];
380
+ }
381
+
382
+ for (const file of files) {
383
+ const journalPath = join(journalDir, file);
384
+ let journal;
385
+ try {
386
+ journal = JSON.parse(readFileSync(journalPath, 'utf8'));
387
+ } catch {
388
+ continue;
389
+ }
390
+
391
+ if (journal.status !== 'prepared') continue;
392
+
393
+ const state = readState(root);
394
+ if (!state) continue;
395
+
396
+ const activeTurns = getActiveTurns(state);
397
+ const turnAlreadyRemoved = !activeTurns[journal.turn_id];
398
+ const sequenceAlreadyApplied = (state.turn_sequence || 0) >= journal.accepted_sequence;
399
+
400
+ if (turnAlreadyRemoved && sequenceAlreadyApplied) {
401
+ // State commit succeeded but cleanup may be incomplete — finish cleanup
402
+ cleanupTurnArtifacts(root, journal.turn_id);
403
+ commitAcceptanceJournal(root, journal.transaction_id);
404
+ replayed.push({ transaction_id: journal.transaction_id, action: 'cleanup_only' });
405
+ } else {
406
+ // State commit did not complete — replay from journal
407
+ if (journal.history_entry) {
408
+ appendJsonl(root, HISTORY_PATH, journal.history_entry);
409
+ }
410
+ if (journal.ledger_entries) {
411
+ for (const entry of journal.ledger_entries) {
412
+ appendJsonl(root, LEDGER_PATH, entry);
413
+ }
414
+ }
415
+ if (journal.next_state) {
416
+ writeState(root, journal.next_state);
417
+ }
418
+ cleanupTurnArtifacts(root, journal.turn_id);
419
+ commitAcceptanceJournal(root, journal.transaction_id);
420
+ replayed.push({ transaction_id: journal.transaction_id, action: 'full_replay' });
421
+ }
422
+ }
423
+ return replayed;
424
+ }
425
+
426
+ function cleanupTurnArtifacts(root, turnId) {
427
+ const stagingDir = join(root, getTurnStagingDir(turnId));
428
+ const dispatchDir = join(root, getDispatchTurnDir(turnId));
429
+
430
+ try {
431
+ if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true });
432
+ } catch { /* best-effort */ }
433
+ try {
434
+ if (existsSync(dispatchDir)) rmSync(dispatchDir, { recursive: true });
435
+ } catch { /* best-effort */ }
436
+ }
437
+
438
+ function detectAcceptanceConflict(targetTurn, observedArtifact, historyEntries) {
439
+ const observedFiles = [...new Set(getObservedFiles({ observed_artifact: observedArtifact }))];
440
+ if (observedFiles.length === 0) {
441
+ return null;
442
+ }
443
+
444
+ const observedFileSet = new Set(observedFiles);
445
+ const acceptedSince = [];
446
+ const conflictingFiles = new Set();
447
+
448
+ for (const entry of historyEntries) {
449
+ if ((entry.accepted_sequence || 0) <= (targetTurn.assigned_sequence || 0)) {
450
+ continue;
451
+ }
452
+
453
+ const overlap = [...new Set(getObservedFiles(entry).filter(file => observedFileSet.has(file)))];
454
+ if (overlap.length === 0) {
455
+ continue;
456
+ }
457
+
458
+ overlap.forEach(file => conflictingFiles.add(file));
459
+ acceptedSince.push({
460
+ turn_id: entry.turn_id,
461
+ role: entry.role,
462
+ accepted_sequence: entry.accepted_sequence,
463
+ files_changed: overlap,
464
+ });
465
+ }
466
+
467
+ if (acceptedSince.length === 0) {
468
+ return null;
469
+ }
470
+
471
+ const conflicting = [...conflictingFiles];
472
+ const overlapRatio = observedFiles.length > 0 ? conflicting.length / observedFiles.length : 0;
473
+
474
+ return {
475
+ type: 'file_conflict',
476
+ conflicting_turn: {
477
+ turn_id: targetTurn.turn_id,
478
+ role: targetTurn.assigned_role,
479
+ attempt: targetTurn.attempt,
480
+ files_changed: observedFiles,
481
+ },
482
+ accepted_since: acceptedSince,
483
+ conflicting_files: conflicting,
484
+ non_conflicting_files: observedFiles.filter(file => !conflictingFiles.has(file)),
485
+ overlap_ratio: overlapRatio,
486
+ suggested_resolution: overlapRatio < 0.5 ? 'reject_and_reassign' : 'human_merge',
487
+ };
488
+ }
489
+
490
+ function buildConflictContext(turn) {
491
+ const conflictError = turn?.conflict_state?.conflict_error;
492
+ if (!conflictError) {
493
+ return null;
494
+ }
495
+
496
+ const acceptedTurnsSince = Array.isArray(conflictError.accepted_since)
497
+ ? conflictError.accepted_since.map((entry) => ({
498
+ turn_id: entry.turn_id,
499
+ role: entry.role,
500
+ files_changed: Array.isArray(entry.files_changed) ? entry.files_changed : [],
501
+ }))
502
+ : [];
503
+
504
+ return {
505
+ prior_attempt_turn_id: turn.turn_id,
506
+ prior_attempt_number: turn.attempt,
507
+ conflict_type: conflictError.type || 'file_conflict',
508
+ conflicting_files: Array.isArray(conflictError.conflicting_files) ? conflictError.conflicting_files : [],
509
+ accepted_turns_since: acceptedTurnsSince,
510
+ non_conflicting_files_preserved: Array.isArray(conflictError.non_conflicting_files)
511
+ ? conflictError.non_conflicting_files
512
+ : [],
513
+ guidance: 'Rebase the rejected work on top of the current workspace state and preserve non-conflicting changes.',
514
+ };
515
+ }
516
+
517
+ function buildConflictDetail(conflict) {
518
+ if (!conflict?.conflicting_files?.length) {
519
+ return 'Resolve the retained file conflict, then resume the turn.';
520
+ }
521
+ return `Conflicting files: ${conflict.conflicting_files.join(', ')}`;
522
+ }
523
+
524
+ function hasBlockingActiveTurn(activeTurns) {
525
+ return Object.values(activeTurns || {}).some((turn) => turn?.status === 'failed' || turn?.status === 'conflicted');
526
+ }
527
+
528
+ function findHistoryTurnRequest(historyEntries, turnId, kind) {
529
+ if (!turnId) {
530
+ return null;
531
+ }
532
+
533
+ const entry = [...historyEntries].reverse().find(item => item.turn_id === turnId);
534
+ if (!entry) {
535
+ return null;
536
+ }
537
+
538
+ if (kind === 'run_completion') {
539
+ return { ...entry, run_completion_request: true };
540
+ }
541
+
542
+ if (kind === 'phase_transition') {
543
+ return { ...entry, phase_transition_request: entry.phase_transition_request || null };
544
+ }
545
+
546
+ return entry;
547
+ }
548
+
549
+ function buildBlockedReason({ category, recovery, turnId, blockedAt = new Date().toISOString() }) {
550
+ return {
551
+ category,
552
+ recovery,
553
+ blocked_at: blockedAt,
554
+ turn_id: turnId ?? null,
555
+ };
556
+ }
557
+
558
+ function canApprovePendingGate(state) {
559
+ return state?.status === 'paused' || state?.status === 'blocked';
560
+ }
561
+
562
+ function deriveHookRecovery(state, { phase, hookName, detail, errorCode, turnId, turnRetained }) {
563
+ const isTamper = errorCode?.includes('_tamper');
564
+ const pendingPhaseTransition = state?.pending_phase_transition;
565
+ const pendingRunCompletion = state?.pending_run_completion;
566
+
567
+ if (phase === 'before_gate' && pendingPhaseTransition) {
568
+ return {
569
+ typed_reason: isTamper ? 'hook_tamper' : 'pending_phase_transition',
570
+ owner: 'human',
571
+ recovery_action: isTamper
572
+ ? 'Disable or fix the hook, verify protected files, then rerun agentxchain approve-transition'
573
+ : 'agentxchain approve-transition',
574
+ turn_retained: false,
575
+ detail: pendingPhaseTransition.gate || detail || hookName || phase,
576
+ };
577
+ }
578
+
579
+ if (phase === 'before_gate' && pendingRunCompletion) {
580
+ return {
581
+ typed_reason: isTamper ? 'hook_tamper' : 'pending_run_completion',
582
+ owner: 'human',
583
+ recovery_action: isTamper
584
+ ? 'Disable or fix the hook, verify protected files, then rerun agentxchain approve-completion'
585
+ : 'agentxchain approve-completion',
586
+ turn_retained: false,
587
+ detail: pendingRunCompletion.gate || detail || hookName || phase,
588
+ };
589
+ }
590
+
591
+ return {
592
+ typed_reason: isTamper ? 'hook_tamper' : 'hook_block',
593
+ owner: 'human',
594
+ recovery_action: isTamper
595
+ ? 'Disable or fix the hook, verify protected files, then run agentxchain step --resume'
596
+ : `Fix or reconfigure hook "${hookName}", then rerun agentxchain accept-turn${turnId ? ` --turn ${turnId}` : ''}`,
597
+ turn_retained: Boolean(turnRetained),
598
+ detail: detail || hookName || phase,
599
+ };
600
+ }
601
+
602
+ function blockRunForHookIssue(root, state, { phase, turnId, hookName, detail, errorCode, turnRetained }) {
603
+ const blockedAt = new Date().toISOString();
604
+ const typedReason = errorCode?.includes('_tamper') ? 'hook_tamper' : 'hook_block';
605
+ const recovery = deriveHookRecovery(state, {
606
+ phase,
607
+ hookName,
608
+ detail,
609
+ errorCode,
610
+ turnId,
611
+ turnRetained,
612
+ });
613
+ const blockedState = {
614
+ ...state,
615
+ status: 'blocked',
616
+ blocked_on: `hook:${phase}:${hookName || 'unknown'}`,
617
+ blocked_reason: buildBlockedReason({
618
+ category: typedReason,
619
+ recovery,
620
+ turnId,
621
+ blockedAt,
622
+ }),
623
+ };
624
+ writeState(root, blockedState);
625
+ return attachLegacyCurrentTurnAlias(blockedState);
626
+ }
627
+
628
+ /**
629
+ * Fire on_escalation hooks (advisory-only) after blocked state is persisted.
630
+ * These hooks are for external notification (Slack, PagerDuty, etc.).
631
+ * They cannot block or mutate state. Failures are logged to hook-audit.jsonl only.
632
+ *
633
+ * IMPORTANT: Do not call this from blockRunForHookIssue() — that would create
634
+ * a circular invocation where a hook failure triggers another hook.
635
+ */
636
+ function _fireOnEscalationHooks(root, hooksConfig, payload) {
637
+ try {
638
+ const hookResult = runHooks(root, hooksConfig, 'on_escalation', payload, {
639
+ run_id: payload.run_id,
640
+ turn_id: payload.failed_turn_id,
641
+ });
642
+ // Advisory-only: result is logged in hook-audit.jsonl by runHooks().
643
+ // We do not act on the result — on_escalation cannot block.
644
+ return hookResult;
645
+ } catch (err) {
646
+ // Swallow errors — on_escalation must not prevent the blocked state from
647
+ // being returned to the caller. The error is already in hook-audit.jsonl
648
+ // if runHooks got far enough to write it.
649
+ return { ok: true, results: [], swallowed_error: err.message };
650
+ }
651
+ }
652
+
653
+ function normalizeRecoveryDescriptor(recovery, turnRetained, detail) {
654
+ if (!recovery || typeof recovery !== 'object') {
655
+ return null;
656
+ }
657
+
658
+ return {
659
+ typed_reason: typeof recovery.typed_reason === 'string' ? recovery.typed_reason : 'unknown_block',
660
+ owner: typeof recovery.owner === 'string' ? recovery.owner : 'human',
661
+ recovery_action: typeof recovery.recovery_action === 'string'
662
+ ? recovery.recovery_action
663
+ : 'Inspect state.json and resolve manually before rerunning agentxchain step',
664
+ turn_retained: typeof recovery.turn_retained === 'boolean' ? recovery.turn_retained : Boolean(turnRetained),
665
+ detail: recovery.detail ?? detail ?? null,
666
+ };
667
+ }
668
+
669
+ function inferBlockedReasonFromState(state) {
670
+ if (!state || typeof state !== 'object') {
671
+ return null;
672
+ }
673
+
674
+ if (typeof state.blocked_on !== 'string' || !state.blocked_on.trim()) {
675
+ return null;
676
+ }
677
+
678
+ const turnRetained = getActiveTurnCount(state) > 0;
679
+ const activeTurn = getActiveTurn(state);
680
+
681
+ if (state.blocked_on.startsWith('human:')) {
682
+ const detail = state.blocked_on.slice('human:'.length) || null;
683
+ return buildBlockedReason({
684
+ category: 'needs_human',
685
+ recovery: {
686
+ typed_reason: 'needs_human',
687
+ owner: 'human',
688
+ recovery_action: 'Resolve the stated issue, then run agentxchain step --resume',
689
+ turn_retained: turnRetained,
690
+ detail,
691
+ },
692
+ turnId: activeTurn?.turn_id ?? state.last_completed_turn_id ?? null,
693
+ });
694
+ }
695
+
696
+ if (state.blocked_on.startsWith('escalation:')) {
697
+ return buildBlockedReason({
698
+ category: 'retries_exhausted',
699
+ recovery: {
700
+ typed_reason: 'retries_exhausted',
701
+ owner: 'human',
702
+ recovery_action: 'Resolve the escalation, then run agentxchain step --resume',
703
+ turn_retained: turnRetained,
704
+ detail: state.blocked_on,
705
+ },
706
+ turnId: activeTurn?.turn_id ?? null,
707
+ });
708
+ }
709
+
710
+ if (state.blocked_on.startsWith('dispatch:')) {
711
+ const detail = state.blocked_on.slice('dispatch:'.length) || null;
712
+ return buildBlockedReason({
713
+ category: 'dispatch_error',
714
+ recovery: {
715
+ typed_reason: 'dispatch_error',
716
+ owner: 'human',
717
+ recovery_action: 'Resolve the dispatch issue, then run agentxchain step --resume',
718
+ turn_retained: turnRetained,
719
+ detail,
720
+ },
721
+ turnId: activeTurn?.turn_id ?? null,
722
+ });
723
+ }
724
+
725
+ return null;
726
+ }
727
+
728
+ export function normalizeGovernedStateShape(state) {
729
+ if (!state || typeof state !== 'object') {
730
+ return { state, changed: false };
731
+ }
732
+
733
+ let nextState = state;
734
+ let changed = false;
735
+
736
+ if (nextState.schema_version !== GOVERNED_SCHEMA_VERSION || 'current_turn' in nextState || !('active_turns' in nextState)) {
737
+ nextState = normalizeV1toV1_1(nextState);
738
+ changed = true;
739
+ }
740
+
741
+ const hasApprovalPause = Boolean(state.pending_phase_transition || state.pending_run_completion);
742
+ const legacyBlockedPause =
743
+ state.status === 'paused' &&
744
+ !hasApprovalPause &&
745
+ typeof state.blocked_on === 'string' &&
746
+ (state.blocked_on.startsWith('human:') || state.blocked_on.startsWith('escalation:'));
747
+
748
+ if (legacyBlockedPause) {
749
+ nextState = {
750
+ ...nextState,
751
+ status: 'blocked',
752
+ };
753
+ changed = true;
754
+ }
755
+
756
+ if (nextState.status === 'blocked') {
757
+ const inferred = inferBlockedReasonFromState(nextState);
758
+ const normalizedRecovery = normalizeRecoveryDescriptor(
759
+ nextState.blocked_reason?.recovery,
760
+ getActiveTurn(nextState),
761
+ nextState.blocked_reason?.recovery?.detail ?? inferred?.recovery?.detail ?? nextState.blocked_on ?? null,
762
+ );
763
+
764
+ if (!nextState.blocked_reason && inferred) {
765
+ nextState = {
766
+ ...nextState,
767
+ blocked_reason: inferred,
768
+ };
769
+ changed = true;
770
+ } else if (
771
+ nextState.blocked_reason &&
772
+ normalizedRecovery &&
773
+ JSON.stringify(nextState.blocked_reason.recovery) !== JSON.stringify(normalizedRecovery)
774
+ ) {
775
+ nextState = {
776
+ ...nextState,
777
+ blocked_reason: {
778
+ ...nextState.blocked_reason,
779
+ recovery: normalizedRecovery,
780
+ },
781
+ };
782
+ changed = true;
783
+ }
784
+ }
785
+
786
+ if (nextState.status !== 'blocked' && 'blocked_reason' in nextState && nextState.blocked_reason != null) {
787
+ nextState = {
788
+ ...nextState,
789
+ blocked_reason: null,
790
+ };
791
+ changed = true;
792
+ }
793
+
794
+ return { state: stripLegacyCurrentTurn(nextState), changed };
795
+ }
796
+
797
+ export function markRunBlocked(root, details) {
798
+ const state = readState(root);
799
+ if (!state) {
800
+ return { ok: false, error: 'No governed state.json found' };
801
+ }
802
+
803
+ const blockedAt = details.blockedAt || new Date().toISOString();
804
+ const turnId = details.turnId ?? getActiveTurn(state)?.turn_id ?? null;
805
+ const blockedReason = buildBlockedReason({
806
+ category: details.category,
807
+ recovery: details.recovery,
808
+ turnId,
809
+ blockedAt,
810
+ });
811
+
812
+ const updatedState = {
813
+ ...state,
814
+ status: 'blocked',
815
+ blocked_on: details.blockedOn,
816
+ blocked_reason: blockedReason,
817
+ escalation: details.escalation ?? state.escalation ?? null,
818
+ };
819
+
820
+ writeState(root, updatedState);
821
+
822
+ // Fire on_escalation hooks (advisory-only) after blocked state is persisted.
823
+ // Only fire for non-hook-caused blocks to prevent circular invocations.
824
+ if (details.hooksConfig?.on_escalation?.length > 0) {
825
+ const activeTurn = getActiveTurn(updatedState);
826
+ _fireOnEscalationHooks(root, details.hooksConfig, {
827
+ blocked_reason: details.category || 'unknown',
828
+ recovery_action: details.recovery?.recovery_action || 'unknown',
829
+ failed_turn_id: turnId || null,
830
+ failed_role: activeTurn?.assigned_role || null,
831
+ attempt_count: activeTurn?.attempt || 0,
832
+ last_error: details.recovery?.detail || details.blockedOn || 'unknown',
833
+ run_id: updatedState.run_id,
834
+ });
835
+ }
836
+
837
+ return { ok: true, state: updatedState };
838
+ }
839
+
840
+ // ── Core Operations ──────────────────────────────────────────────────────────
841
+
842
+ /**
843
+ * Initialize a governed run from idle state.
844
+ * Creates a run_id and sets status to 'active'.
845
+ *
846
+ * @param {string} root - project root directory
847
+ * @param {object} config - normalized config
848
+ * @returns {{ ok: boolean, error?: string, state?: object }}
849
+ */
850
+ export function initializeGovernedRun(root, config) {
851
+ const state = readState(root);
852
+ if (!state) {
853
+ return { ok: false, error: 'No governed state.json found' };
854
+ }
855
+ if (state.status === 'completed') {
856
+ return { ok: false, error: 'Cannot initialize run: this run is already completed. Start a new project or reset state.' };
857
+ }
858
+ const allowBlockedBootstrap = state.status === 'blocked' && state.run_id === null && getActiveTurnCount(state) === 0;
859
+ if (state.status !== 'idle' && state.status !== 'paused' && !allowBlockedBootstrap) {
860
+ return { ok: false, error: `Cannot initialize run: status is "${state.status}", expected "idle", "paused", or pre-run "blocked"` };
861
+ }
862
+
863
+ const runId = generateId('run');
864
+ const updatedState = {
865
+ ...state,
866
+ run_id: runId,
867
+ status: 'active',
868
+ blocked_on: null,
869
+ blocked_reason: null,
870
+ budget_status: {
871
+ spent_usd: 0,
872
+ remaining_usd: config.budget?.per_run_max_usd ?? null
873
+ }
874
+ };
875
+
876
+ writeState(root, updatedState);
877
+ return { ok: true, state: attachLegacyCurrentTurnAlias(updatedState) };
878
+ }
879
+
880
+ /**
881
+ * Assign a turn to a role.
882
+ * Supports parallel assignment up to max_concurrent_turns for the current phase.
883
+ *
884
+ * Guards (DEC-PARALLEL-006, DEC-PARALLEL-007, DEC-PARALLEL-011):
885
+ * - No assignment while run is blocked
886
+ * - Same role cannot hold two active turns
887
+ * - Concurrency limit per phase is respected
888
+ * - Budget reservation is created per turn
889
+ *
890
+ * @param {string} root - project root directory
891
+ * @param {object} config - normalized config
892
+ * @param {string} roleId - the role to assign
893
+ * @returns {{ ok: boolean, error?: string, warnings?: string[], state?: object }}
894
+ */
895
+ export function assignGovernedTurn(root, config, roleId) {
896
+ const state = readState(root);
897
+ if (!state) {
898
+ return { ok: false, error: 'No governed state.json found' };
899
+ }
900
+
901
+ // DEC-PARALLEL-007: No new assignment while run is blocked
902
+ if (state.status === 'blocked') {
903
+ return { ok: false, error: 'Cannot assign turn: run is blocked. Resolve the blocked state before assigning new turns.' };
904
+ }
905
+ if (state.status !== 'active') {
906
+ return { ok: false, error: `Cannot assign turn: status is "${state.status}", expected "active"` };
907
+ }
908
+
909
+ const role = config.roles?.[roleId];
910
+ if (!role) {
911
+ return { ok: false, error: `Unknown role: "${roleId}"` };
912
+ }
913
+ const runtimeId = role.runtime_id || role.runtime;
914
+ if (!runtimeId) {
915
+ return { ok: false, error: `Role "${roleId}" has no runtime identifier` };
916
+ }
917
+
918
+ // Concurrency checks
919
+ const activeTurns = getActiveTurns(state);
920
+ const activeCount = Object.keys(activeTurns).length;
921
+ const maxConcurrent = getMaxConcurrentTurns(config, state.phase);
922
+
923
+ // When max_concurrent_turns = 1 (sequential mode), preserve backward-compatible
924
+ // error message before any parallel-specific checks
925
+ if (maxConcurrent === 1 && activeCount >= 1) {
926
+ const existing = Object.values(activeTurns)[0];
927
+ return { ok: false, error: `Turn already assigned: ${existing.turn_id} to ${existing.assigned_role}` };
928
+ }
929
+
930
+ // DEC-PARALLEL-006: One active turn per role at a time
931
+ const existingRoleTurn = Object.values(activeTurns).find(t => t.assigned_role === roleId);
932
+ if (existingRoleTurn) {
933
+ return { ok: false, error: `Role "${roleId}" already has an active turn: ${existingRoleTurn.turn_id}` };
934
+ }
935
+
936
+ // Concurrency limit
937
+ if (activeCount >= maxConcurrent) {
938
+ return { ok: false, error: `Cannot assign turn: ${activeCount} active turn(s) already at capacity (max_concurrent_turns = ${maxConcurrent})` };
939
+ }
940
+
941
+ // DEC-PARALLEL-011: Budget reservation
942
+ const warnings = [];
943
+ const reservations = { ...(state.budget_reservations || {}) };
944
+ const turnId = generateId('turn');
945
+ const estimatedCost = estimateTurnBudget(config, roleId);
946
+
947
+ if (estimatedCost > 0 && state.budget_status?.remaining_usd != null) {
948
+ const alreadyReserved = Object.values(reservations).reduce((sum, r) => sum + (r.reserved_usd || 0), 0);
949
+ const available = state.budget_status.remaining_usd - alreadyReserved;
950
+ if (estimatedCost > available) {
951
+ return { ok: false, error: `Cannot assign turn: estimated cost $${estimatedCost.toFixed(2)} exceeds available budget $${available.toFixed(2)} (after reservations)` };
952
+ }
953
+ reservations[turnId] = {
954
+ reserved_usd: estimatedCost,
955
+ role_id: roleId,
956
+ created_at: new Date().toISOString(),
957
+ };
958
+ }
959
+
960
+ // DEC-PARALLEL-008: Advisory overlap warning (declared_file_scope)
961
+ if (role.declared_file_scope && activeCount > 0) {
962
+ const roleScope = new Set(Array.isArray(role.declared_file_scope) ? role.declared_file_scope : []);
963
+ for (const existingTurn of Object.values(activeTurns)) {
964
+ const existingRole = config.roles?.[existingTurn.assigned_role];
965
+ if (existingRole?.declared_file_scope) {
966
+ const existingScope = new Set(Array.isArray(existingRole.declared_file_scope) ? existingRole.declared_file_scope : []);
967
+ const overlap = [...roleScope].filter(f => existingScope.has(f));
968
+ if (overlap.length > 0) {
969
+ warnings.push(`Advisory: declared_file_scope overlap with turn ${existingTurn.turn_id} (${existingTurn.assigned_role}): ${overlap.join(', ')}`);
970
+ }
971
+ }
972
+ }
973
+ }
974
+
975
+ // v1 clean-baseline rule: authoritative/proposed turns require a clean working tree
976
+ const writeAuthority = role.write_authority || 'review_only';
977
+ const cleanCheck = checkCleanBaseline(root, writeAuthority);
978
+ if (!cleanCheck.clean) {
979
+ return { ok: false, error: cleanCheck.reason };
980
+ }
981
+
982
+ const hooksConfig = config.hooks || {};
983
+ if (hooksConfig.before_assignment && hooksConfig.before_assignment.length > 0) {
984
+ const historyLength = readJsonlEntries(root, HISTORY_PATH).length;
985
+ const beforeAssignmentPayload = {
986
+ role_id: roleId,
987
+ role_config: role,
988
+ phase: state.phase,
989
+ active_turns: Object.values(activeTurns).map((turn) => ({
990
+ turn_id: turn.turn_id,
991
+ role_id: turn.assigned_role,
992
+ status: turn.status,
993
+ attempt: turn.attempt,
994
+ })),
995
+ history_length: historyLength,
996
+ };
997
+ const beforeAssignmentHooks = runHooks(root, hooksConfig, 'before_assignment', beforeAssignmentPayload, {
998
+ run_id: state.run_id,
999
+ });
1000
+
1001
+ if (!beforeAssignmentHooks.ok) {
1002
+ const hookName = beforeAssignmentHooks.blocker?.hook_name
1003
+ || beforeAssignmentHooks.results?.find((entry) => entry.hook_name)?.hook_name
1004
+ || 'unknown';
1005
+ const detail = beforeAssignmentHooks.blocker?.message
1006
+ || beforeAssignmentHooks.tamper?.message
1007
+ || `before_assignment hook "${hookName}" halted assignment`;
1008
+
1009
+ if (beforeAssignmentHooks.tamper) {
1010
+ const blockedState = blockRunForHookIssue(root, state, {
1011
+ phase: 'before_assignment',
1012
+ turnId: null,
1013
+ hookName,
1014
+ detail,
1015
+ errorCode: beforeAssignmentHooks.tamper.error_code,
1016
+ turnRetained: activeCount > 0,
1017
+ });
1018
+ return {
1019
+ ok: false,
1020
+ error: detail,
1021
+ error_code: beforeAssignmentHooks.tamper.error_code,
1022
+ state: blockedState,
1023
+ hookResults: beforeAssignmentHooks,
1024
+ };
1025
+ }
1026
+
1027
+ return {
1028
+ ok: false,
1029
+ error: detail,
1030
+ error_code: 'hook_blocked',
1031
+ state: attachLegacyCurrentTurnAlias(state),
1032
+ hookResults: beforeAssignmentHooks,
1033
+ };
1034
+ }
1035
+ }
1036
+
1037
+ // Capture baseline snapshot for observed diff at acceptance time
1038
+ const baseline = captureBaseline(root);
1039
+
1040
+ const now = new Date().toISOString();
1041
+ const timeoutMinutes = 20;
1042
+ const nextSequence = (state.turn_sequence || 0) + 1;
1043
+
1044
+ // Record which turns are concurrent siblings (for conflict detection context)
1045
+ const concurrentWith = Object.keys(activeTurns);
1046
+
1047
+ const updatedState = {
1048
+ ...state,
1049
+ turn_sequence: nextSequence,
1050
+ budget_reservations: reservations,
1051
+ active_turns: {
1052
+ ...activeTurns,
1053
+ [turnId]: {
1054
+ turn_id: turnId,
1055
+ assigned_role: roleId,
1056
+ status: 'running',
1057
+ attempt: 1,
1058
+ started_at: now,
1059
+ deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
1060
+ runtime_id: runtimeId,
1061
+ baseline,
1062
+ assigned_sequence: nextSequence,
1063
+ concurrent_with: concurrentWith,
1064
+ },
1065
+ },
1066
+ };
1067
+
1068
+ writeState(root, updatedState);
1069
+ const result = { ok: true, state: attachLegacyCurrentTurnAlias(updatedState) };
1070
+ if (warnings.length > 0) {
1071
+ result.warnings = warnings;
1072
+ }
1073
+ return result;
1074
+ }
1075
+
1076
+ /**
1077
+ * Estimate the budget for a single turn based on role/runtime configuration.
1078
+ * Used for DEC-PARALLEL-011 budget reservation.
1079
+ *
1080
+ * @param {object} config - normalized config
1081
+ * @param {string} roleId - the role being assigned
1082
+ * @returns {number} estimated cost in USD (0 if not estimable)
1083
+ */
1084
+ function estimateTurnBudget(config, roleId) {
1085
+ // Use per_turn_max_usd as the reservation estimate
1086
+ if (config.budget?.per_turn_max_usd != null && config.budget.per_turn_max_usd > 0) {
1087
+ return config.budget.per_turn_max_usd;
1088
+ }
1089
+ return 0;
1090
+ }
1091
+
1092
+ /**
1093
+ * Accept a governed turn.
1094
+ *
1095
+ * 1. Load current state
1096
+ * 2. Validate .agentxchain/staging/turn-result.json
1097
+ * 3. Append accepted entry to history.jsonl
1098
+ * 4. Append decisions to decision-ledger.jsonl
1099
+ * 5. Append prose section to TALK.md
1100
+ * 6. Update state.json
1101
+ * 7. Clear staging file
1102
+ *
1103
+ * Does NOT auto-assign the next turn.
1104
+ *
1105
+ * @param {string} root - project root directory
1106
+ * @param {object} config - normalized config
1107
+ * @param {object} [opts]
1108
+ * @param {string} [opts.turnId] - explicit target turn when multiple turns are active
1109
+ * @returns {{ ok: boolean, error?: string, error_code?: string, validation?: object, state?: object }}
1110
+ */
1111
+ export function acceptGovernedTurn(root, config, opts = {}) {
1112
+ // Replay any prepared journals from previous crashes before starting
1113
+ replayPreparedJournals(root);
1114
+
1115
+ // Pre-lock target resolution (quick fail for obviously invalid requests)
1116
+ const preState = readState(root);
1117
+ if (!preState) {
1118
+ return { ok: false, error: 'No governed state.json found' };
1119
+ }
1120
+ const preResolution = resolveTurnTarget(preState, opts.turnId);
1121
+ if (!preResolution.ok) {
1122
+ return preResolution;
1123
+ }
1124
+
1125
+ // Acquire acceptance lock — serializes concurrent acceptance attempts
1126
+ const lockResult = acquireAcceptanceLock(root);
1127
+ if (!lockResult.ok) {
1128
+ return lockResult;
1129
+ }
1130
+
1131
+ try {
1132
+ return _acceptGovernedTurnLocked(root, config, opts);
1133
+ } finally {
1134
+ releaseAcceptanceLock(root);
1135
+ }
1136
+ }
1137
+
1138
+ function _acceptGovernedTurnLocked(root, config, opts) {
1139
+ // Re-read state under lock (a sibling acceptance may have committed)
1140
+ let state = readState(root);
1141
+ if (!state) {
1142
+ return { ok: false, error: 'No governed state.json found' };
1143
+ }
1144
+
1145
+ const targetResolution = resolveTurnTarget(state, opts.turnId);
1146
+ if (!targetResolution.ok) {
1147
+ return targetResolution;
1148
+ }
1149
+ let currentTurn = targetResolution.turn;
1150
+
1151
+ const resolutionMode = opts.resolutionMode || 'standard';
1152
+ if (resolutionMode !== 'standard' && resolutionMode !== 'human_merge') {
1153
+ return {
1154
+ ok: false,
1155
+ error: `Unknown resolution mode "${resolutionMode}"`,
1156
+ error_code: 'protocol_error',
1157
+ };
1158
+ }
1159
+
1160
+ if (resolutionMode === 'human_merge') {
1161
+ if (!currentTurn.conflict_state) {
1162
+ return {
1163
+ ok: false,
1164
+ error: 'human_merge resolution requires a conflicted active turn.',
1165
+ error_code: 'protocol_error',
1166
+ };
1167
+ }
1168
+
1169
+ if (currentTurn.conflict_state.status !== 'human_merging') {
1170
+ appendJsonl(root, LEDGER_PATH, {
1171
+ timestamp: new Date().toISOString(),
1172
+ decision: 'conflict_resolution_selected',
1173
+ turn_id: currentTurn.turn_id,
1174
+ attempt: currentTurn.attempt,
1175
+ role: currentTurn.assigned_role,
1176
+ phase: state.phase,
1177
+ conflict: {
1178
+ conflicting_files: currentTurn.conflict_state.conflict_error?.conflicting_files || [],
1179
+ accepted_since_turn_ids: (currentTurn.conflict_state.conflict_error?.accepted_since || []).map((entry) => entry.turn_id),
1180
+ overlap_ratio: currentTurn.conflict_state.conflict_error?.overlap_ratio ?? 0,
1181
+ },
1182
+ resolution_chosen: 'human_merge',
1183
+ });
1184
+
1185
+ state = {
1186
+ ...state,
1187
+ active_turns: {
1188
+ ...getActiveTurns(state),
1189
+ [currentTurn.turn_id]: {
1190
+ ...currentTurn,
1191
+ status: 'conflicted',
1192
+ conflict_state: {
1193
+ ...currentTurn.conflict_state,
1194
+ status: 'human_merging',
1195
+ },
1196
+ },
1197
+ },
1198
+ };
1199
+ writeState(root, state);
1200
+ currentTurn = state.active_turns[currentTurn.turn_id];
1201
+ }
1202
+ }
1203
+
1204
+ const turnStagingPath = getTurnStagingResultPath(currentTurn.turn_id);
1205
+ const resolvedStagingPath = existsSync(join(root, turnStagingPath)) ? turnStagingPath : STAGING_PATH;
1206
+ const stagedTurn = loadHookStagedTurn(root, resolvedStagingPath);
1207
+ const validationState = attachLegacyCurrentTurnAlias({
1208
+ ...state,
1209
+ active_turns: {
1210
+ [currentTurn.turn_id]: currentTurn,
1211
+ },
1212
+ });
1213
+ const hooksConfig = config.hooks || {};
1214
+
1215
+ if (hooksConfig.before_validation && hooksConfig.before_validation.length > 0) {
1216
+ const beforeValidationPayload = {
1217
+ turn_id: currentTurn.turn_id,
1218
+ role_id: currentTurn.assigned_role,
1219
+ staging_path: resolvedStagingPath,
1220
+ turn_result: stagedTurn.turnResult ?? null,
1221
+ ...(stagedTurn.parse_error ? { parse_error: stagedTurn.parse_error } : {}),
1222
+ ...(stagedTurn.read_error ? { read_error: stagedTurn.read_error } : {}),
1223
+ };
1224
+ const beforeValidationHooks = runHooks(root, hooksConfig, 'before_validation', beforeValidationPayload, {
1225
+ run_id: state.run_id,
1226
+ turn_id: currentTurn.turn_id,
1227
+ });
1228
+
1229
+ if (!beforeValidationHooks.ok) {
1230
+ const hookName = beforeValidationHooks.blocker?.hook_name
1231
+ || beforeValidationHooks.results?.find((entry) => entry.hook_name)?.hook_name
1232
+ || 'unknown';
1233
+ const detail = beforeValidationHooks.blocker?.message
1234
+ || beforeValidationHooks.tamper?.message
1235
+ || `before_validation hook "${hookName}" halted acceptance`;
1236
+ const blockedState = blockRunForHookIssue(root, state, {
1237
+ phase: 'before_validation',
1238
+ turnId: currentTurn.turn_id,
1239
+ hookName,
1240
+ detail,
1241
+ errorCode: beforeValidationHooks.tamper?.error_code || 'hook_blocked',
1242
+ turnRetained: true,
1243
+ });
1244
+ return {
1245
+ ok: false,
1246
+ error: detail,
1247
+ error_code: beforeValidationHooks.tamper?.error_code || 'hook_blocked',
1248
+ state: blockedState,
1249
+ hookResults: beforeValidationHooks,
1250
+ };
1251
+ }
1252
+ }
1253
+
1254
+ const validation = validateStagedTurnResult(root, validationState, config, { stagingPath: resolvedStagingPath });
1255
+ if (hooksConfig.after_validation && hooksConfig.after_validation.length > 0) {
1256
+ const afterValidationPayload = {
1257
+ turn_id: currentTurn.turn_id,
1258
+ role_id: currentTurn.assigned_role,
1259
+ validation_ok: validation.ok,
1260
+ validation_stage: validation.stage,
1261
+ errors: validation.errors,
1262
+ warnings: validation.warnings,
1263
+ turn_result: validation.turnResult ?? stagedTurn.turnResult ?? null,
1264
+ };
1265
+ const afterValidationHooks = runHooks(root, hooksConfig, 'after_validation', afterValidationPayload, {
1266
+ run_id: state.run_id,
1267
+ turn_id: currentTurn.turn_id,
1268
+ });
1269
+
1270
+ if (!afterValidationHooks.ok) {
1271
+ const hookName = afterValidationHooks.blocker?.hook_name
1272
+ || afterValidationHooks.results?.find((entry) => entry.hook_name)?.hook_name
1273
+ || 'unknown';
1274
+ const detail = afterValidationHooks.blocker?.message
1275
+ || afterValidationHooks.tamper?.message
1276
+ || `after_validation hook "${hookName}" halted acceptance`;
1277
+ const blockedState = blockRunForHookIssue(root, state, {
1278
+ phase: 'after_validation',
1279
+ turnId: currentTurn.turn_id,
1280
+ hookName,
1281
+ detail,
1282
+ errorCode: afterValidationHooks.tamper?.error_code || 'hook_blocked',
1283
+ turnRetained: true,
1284
+ });
1285
+ return {
1286
+ ok: false,
1287
+ error: detail,
1288
+ error_code: afterValidationHooks.tamper?.error_code || 'hook_blocked',
1289
+ state: blockedState,
1290
+ hookResults: afterValidationHooks,
1291
+ };
1292
+ }
1293
+ }
1294
+
1295
+ if (!validation.ok) {
1296
+ return {
1297
+ ok: false,
1298
+ error: `Validation failed at stage ${validation.stage}: ${validation.errors.join('; ')}`,
1299
+ validation,
1300
+ };
1301
+ }
1302
+
1303
+ const turnResult = validation.turnResult;
1304
+ const stagingFile = join(root, resolvedStagingPath);
1305
+ const now = new Date().toISOString();
1306
+ const baseline = currentTurn.baseline || null;
1307
+ const observation = observeChanges(root, baseline);
1308
+ const role = config.roles?.[turnResult.role];
1309
+ const runtimeId = turnResult.runtime_id;
1310
+ const runtime = config.runtimes?.[runtimeId];
1311
+ const runtimeType = runtime?.type || 'manual';
1312
+ const writeAuthority = role?.write_authority || 'review_only';
1313
+ const diffComparison = compareDeclaredVsObserved(
1314
+ turnResult.files_changed || [],
1315
+ observation.files_changed,
1316
+ writeAuthority,
1317
+ );
1318
+ if (diffComparison.errors.length > 0) {
1319
+ return {
1320
+ ok: false,
1321
+ error: `Observed artifact mismatch: ${diffComparison.errors.join('; ')}`,
1322
+ validation: {
1323
+ ...validation,
1324
+ ok: false,
1325
+ stage: 'artifact_observation',
1326
+ error_class: 'artifact_error',
1327
+ errors: diffComparison.errors,
1328
+ warnings: diffComparison.warnings,
1329
+ },
1330
+ };
1331
+ }
1332
+
1333
+ const observedArtifact = buildObservedArtifact(observation, baseline);
1334
+ const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
1335
+ const artifactType = turnResult.artifact?.type || 'review';
1336
+ const derivedRef = deriveAcceptedRef(observation, artifactType, state.accepted_integration_ref);
1337
+ const historyEntries = readJsonlEntries(root, HISTORY_PATH);
1338
+ const conflict = detectAcceptanceConflict(currentTurn, observedArtifact, historyEntries);
1339
+
1340
+ if (conflict) {
1341
+ const detectionCount = (currentTurn.conflict_state?.detection_count || 0) + 1;
1342
+ const conflictState = {
1343
+ detected_at: now,
1344
+ detection_count: detectionCount,
1345
+ status: 'pending_operator',
1346
+ conflict_error: conflict,
1347
+ };
1348
+ const updatedState = {
1349
+ ...state,
1350
+ active_turns: {
1351
+ ...getActiveTurns(state),
1352
+ [currentTurn.turn_id]: {
1353
+ ...currentTurn,
1354
+ status: 'conflicted',
1355
+ conflict_state: conflictState,
1356
+ },
1357
+ },
1358
+ };
1359
+
1360
+ if (detectionCount >= 3) {
1361
+ updatedState.status = 'blocked';
1362
+ updatedState.blocked_on = `human:conflict_loop:${currentTurn.turn_id}`;
1363
+ updatedState.blocked_reason = buildBlockedReason({
1364
+ category: 'conflict_loop',
1365
+ recovery: {
1366
+ typed_reason: 'conflict_loop',
1367
+ owner: 'human',
1368
+ recovery_action: `Serialize the conflicting work, then run agentxchain step --resume --turn ${currentTurn.turn_id}`,
1369
+ turn_retained: true,
1370
+ detail: buildConflictDetail(conflict),
1371
+ },
1372
+ turnId: currentTurn.turn_id,
1373
+ blockedAt: now,
1374
+ });
1375
+ }
1376
+
1377
+ appendJsonl(root, LEDGER_PATH, {
1378
+ timestamp: now,
1379
+ decision: 'conflict_detected',
1380
+ turn_id: currentTurn.turn_id,
1381
+ attempt: currentTurn.attempt,
1382
+ role: currentTurn.assigned_role,
1383
+ phase: state.phase,
1384
+ conflict: {
1385
+ conflicting_files: conflict.conflicting_files,
1386
+ accepted_since_turn_ids: conflict.accepted_since.map(entry => entry.turn_id),
1387
+ overlap_ratio: conflict.overlap_ratio,
1388
+ },
1389
+ });
1390
+
1391
+ writeState(root, updatedState);
1392
+ return {
1393
+ ok: false,
1394
+ error: `Acceptance conflict detected for turn ${currentTurn.turn_id}`,
1395
+ error_code: 'conflict',
1396
+ state: attachLegacyCurrentTurnAlias(updatedState),
1397
+ conflict,
1398
+ };
1399
+ }
1400
+
1401
+ if (hooksConfig.before_acceptance && hooksConfig.before_acceptance.length > 0) {
1402
+ const classified = classifyObservedChanges(root, observation, baseline);
1403
+ const beforeAcceptancePayload = {
1404
+ turn_id: currentTurn.turn_id,
1405
+ role_id: currentTurn.assigned_role,
1406
+ turn_result: turnResult,
1407
+ observed_changes: classified,
1408
+ conflict_detected: false,
1409
+ };
1410
+ const beforeAcceptanceHooks = runHooks(root, hooksConfig, 'before_acceptance', beforeAcceptancePayload, {
1411
+ run_id: state.run_id,
1412
+ turn_id: currentTurn.turn_id,
1413
+ });
1414
+
1415
+ if (!beforeAcceptanceHooks.ok) {
1416
+ const hookName = beforeAcceptanceHooks.blocker?.hook_name
1417
+ || beforeAcceptanceHooks.results?.find((entry) => entry.hook_name)?.hook_name
1418
+ || 'unknown';
1419
+ const detail = beforeAcceptanceHooks.blocker?.message
1420
+ || beforeAcceptanceHooks.tamper?.message
1421
+ || `before_acceptance hook "${hookName}" halted acceptance`;
1422
+ const blockedState = blockRunForHookIssue(root, state, {
1423
+ phase: 'before_acceptance',
1424
+ turnId: currentTurn.turn_id,
1425
+ hookName,
1426
+ detail,
1427
+ errorCode: beforeAcceptanceHooks.tamper?.error_code || 'hook_blocked',
1428
+ turnRetained: true,
1429
+ });
1430
+ return {
1431
+ ok: false,
1432
+ error: detail,
1433
+ error_code: beforeAcceptanceHooks.tamper?.error_code || 'hook_blocked',
1434
+ state: blockedState,
1435
+ hookResults: beforeAcceptanceHooks,
1436
+ };
1437
+ }
1438
+ }
1439
+
1440
+ const acceptedSequence = (state.turn_sequence || 0) + 1;
1441
+ const historyEntry = {
1442
+ turn_id: turnResult.turn_id,
1443
+ run_id: turnResult.run_id,
1444
+ role: turnResult.role,
1445
+ runtime_id: turnResult.runtime_id,
1446
+ status: turnResult.status,
1447
+ summary: turnResult.summary,
1448
+ decisions: turnResult.decisions || [],
1449
+ objections: turnResult.objections || [],
1450
+ files_changed: turnResult.files_changed || [],
1451
+ artifacts_created: turnResult.artifacts_created || [],
1452
+ verification: turnResult.verification || {},
1453
+ normalized_verification: normalizedVerification,
1454
+ artifact: turnResult.artifact || {},
1455
+ observed_artifact: observedArtifact,
1456
+ proposed_next_role: turnResult.proposed_next_role,
1457
+ phase_transition_request: turnResult.phase_transition_request,
1458
+ run_completion_request: Boolean(turnResult.run_completion_request),
1459
+ assigned_sequence: Number.isInteger(currentTurn.assigned_sequence) ? currentTurn.assigned_sequence : acceptedSequence,
1460
+ accepted_sequence: acceptedSequence,
1461
+ concurrent_with: Array.isArray(currentTurn.concurrent_with) ? currentTurn.concurrent_with : [],
1462
+ cost: turnResult.cost || {},
1463
+ accepted_at: now,
1464
+ };
1465
+ // Build ledger entries for the journal
1466
+ const ledgerEntries = [];
1467
+ if (turnResult.decisions && turnResult.decisions.length > 0) {
1468
+ for (const decision of turnResult.decisions) {
1469
+ ledgerEntries.push({
1470
+ id: decision.id,
1471
+ turn_id: turnResult.turn_id,
1472
+ role: turnResult.role,
1473
+ phase: state.phase,
1474
+ category: decision.category,
1475
+ statement: decision.statement,
1476
+ rationale: decision.rationale,
1477
+ objections_against: [],
1478
+ status: 'accepted',
1479
+ overridden_by: null,
1480
+ created_at: now,
1481
+ });
1482
+ }
1483
+ }
1484
+
1485
+ const turnNumber = turnResult.turn_id.replace(/^turn_/, '').slice(0, 8);
1486
+ const talkSection = `## Turn ${turnNumber} — ${turnResult.role} (${state.phase})\n\n- **Status:** ${turnResult.status}\n- **Summary:** ${turnResult.summary}\n${turnResult.decisions?.length ? turnResult.decisions.map(d => `- **Decision ${d.id}:** ${d.statement}`).join('\n') + '\n' : ''}${turnResult.objections?.length ? turnResult.objections.map(o => `- **Objection ${o.id} (${o.severity}):** ${o.statement}`).join('\n') + '\n' : ''}- **Proposed next:** ${turnResult.proposed_next_role || 'human'}\n\n---\n`;
1487
+
1488
+ const remainingTurns = { ...getActiveTurns(state) };
1489
+ delete remainingTurns[currentTurn.turn_id];
1490
+ const remainingReservations = { ...(state.budget_reservations || {}) };
1491
+ delete remainingReservations[currentTurn.turn_id];
1492
+ const costUsd = turnResult.cost?.usd || 0;
1493
+ const updatedState = {
1494
+ ...state,
1495
+ turn_sequence: acceptedSequence,
1496
+ last_completed_turn_id: currentTurn.turn_id,
1497
+ active_turns: remainingTurns,
1498
+ budget_reservations: remainingReservations,
1499
+ blocked_on: turnResult.status === 'needs_human' ? `human:${turnResult.needs_human_reason || 'unspecified'}` : null,
1500
+ blocked_reason: null,
1501
+ escalation: null,
1502
+ accepted_integration_ref: derivedRef,
1503
+ next_recommended_role: deriveNextRecommendedRole(turnResult, state, config),
1504
+ budget_status: {
1505
+ spent_usd: (state.budget_status?.spent_usd || 0) + costUsd,
1506
+ remaining_usd: state.budget_status?.remaining_usd != null
1507
+ ? state.budget_status.remaining_usd - costUsd
1508
+ : null,
1509
+ },
1510
+ };
1511
+
1512
+ if (updatedState.status === 'blocked' && !hasBlockingActiveTurn(remainingTurns)) {
1513
+ updatedState.status = 'active';
1514
+ updatedState.blocked_on = null;
1515
+ updatedState.blocked_reason = null;
1516
+ updatedState.escalation = null;
1517
+ }
1518
+
1519
+ if (turnResult.status === 'needs_human') {
1520
+ updatedState.status = 'blocked';
1521
+ updatedState.blocked_reason = buildBlockedReason({
1522
+ category: 'needs_human',
1523
+ recovery: {
1524
+ typed_reason: 'needs_human',
1525
+ owner: 'human',
1526
+ recovery_action: 'Resolve the stated issue, then run agentxchain step --resume',
1527
+ turn_retained: false,
1528
+ detail: turnResult.needs_human_reason || 'unspecified',
1529
+ },
1530
+ turnId: turnResult.turn_id,
1531
+ blockedAt: now,
1532
+ });
1533
+ }
1534
+
1535
+ let gateResult = null;
1536
+ let completionResult = null;
1537
+ const hasRemainingTurns = Object.keys(remainingTurns).length > 0;
1538
+ if (turnResult.status !== 'needs_human') {
1539
+ if (hasRemainingTurns) {
1540
+ if (turnResult.run_completion_request && !updatedState.queued_run_completion) {
1541
+ updatedState.queued_run_completion = {
1542
+ requested_by_turn: turnResult.turn_id,
1543
+ requested_at: now,
1544
+ };
1545
+ }
1546
+ if (turnResult.phase_transition_request && !updatedState.queued_phase_transition) {
1547
+ updatedState.queued_phase_transition = {
1548
+ from: state.phase,
1549
+ to: turnResult.phase_transition_request,
1550
+ requested_by_turn: turnResult.turn_id,
1551
+ requested_at: now,
1552
+ };
1553
+ }
1554
+ } else {
1555
+ const postAcceptanceState = {
1556
+ ...state,
1557
+ active_turns: remainingTurns,
1558
+ turn_sequence: acceptedSequence,
1559
+ };
1560
+ const nextHistoryEntries = [...historyEntries, historyEntry];
1561
+ const completionSource = turnResult.run_completion_request
1562
+ ? turnResult
1563
+ : findHistoryTurnRequest(nextHistoryEntries, state.queued_run_completion?.requested_by_turn, 'run_completion');
1564
+
1565
+ if (completionSource?.run_completion_request) {
1566
+ completionResult = evaluateRunCompletion({
1567
+ state: postAcceptanceState,
1568
+ config,
1569
+ acceptedTurn: completionSource,
1570
+ root,
1571
+ });
1572
+
1573
+ if (completionResult.action === 'complete') {
1574
+ updatedState.status = 'completed';
1575
+ updatedState.completed_at = now;
1576
+ if (completionResult.gate_id) {
1577
+ updatedState.phase_gate_status = {
1578
+ ...(updatedState.phase_gate_status || {}),
1579
+ [completionResult.gate_id]: 'passed',
1580
+ };
1581
+ }
1582
+ updatedState.queued_run_completion = null;
1583
+ updatedState.queued_phase_transition = null;
1584
+ } else if (completionResult.action === 'awaiting_human_approval') {
1585
+ updatedState.status = 'paused';
1586
+ updatedState.blocked_on = `human_approval:${completionResult.gate_id}`;
1587
+ updatedState.blocked_reason = null;
1588
+ updatedState.pending_run_completion = {
1589
+ gate: completionResult.gate_id,
1590
+ requested_by_turn: completionSource.turn_id,
1591
+ requested_at: now,
1592
+ };
1593
+ updatedState.queued_run_completion = null;
1594
+ updatedState.queued_phase_transition = null;
1595
+ } else if (state.queued_run_completion) {
1596
+ updatedState.queued_run_completion = null;
1597
+ }
1598
+ }
1599
+
1600
+ if (updatedState.status !== 'blocked' && updatedState.status !== 'paused' && updatedState.status !== 'completed') {
1601
+ const phaseSource = turnResult.phase_transition_request
1602
+ ? turnResult
1603
+ : findHistoryTurnRequest(nextHistoryEntries, state.queued_phase_transition?.requested_by_turn, 'phase_transition');
1604
+
1605
+ // Always evaluate phase exit when the run drains — even without a request,
1606
+ // evaluatePhaseExit returns { action: 'no_request' } which callers depend on.
1607
+ gateResult = evaluatePhaseExit({
1608
+ state: postAcceptanceState,
1609
+ config,
1610
+ acceptedTurn: phaseSource || turnResult,
1611
+ root,
1612
+ });
1613
+
1614
+ if (gateResult.action === 'advance') {
1615
+ updatedState.phase = gateResult.next_phase;
1616
+ updatedState.phase_gate_status = {
1617
+ ...(updatedState.phase_gate_status || {}),
1618
+ [gateResult.gate_id || 'no_gate']: 'passed',
1619
+ };
1620
+ updatedState.queued_phase_transition = null;
1621
+ } else if (gateResult.action === 'awaiting_human_approval') {
1622
+ updatedState.status = 'paused';
1623
+ updatedState.blocked_on = `human_approval:${gateResult.gate_id}`;
1624
+ updatedState.blocked_reason = null;
1625
+ updatedState.pending_phase_transition = {
1626
+ from: state.phase,
1627
+ to: gateResult.next_phase,
1628
+ gate: gateResult.gate_id,
1629
+ requested_by_turn: phaseSource.turn_id,
1630
+ };
1631
+ updatedState.queued_phase_transition = null;
1632
+ } else if (state.queued_phase_transition) {
1633
+ updatedState.queued_phase_transition = null;
1634
+ }
1635
+ }
1636
+ }
1637
+ }
1638
+
1639
+ // ── Transaction journal: prepare before committing writes ──────────────
1640
+ const transactionId = generateId('txn');
1641
+ const journal = {
1642
+ transaction_id: transactionId,
1643
+ kind: 'accept_turn',
1644
+ run_id: state.run_id,
1645
+ turn_id: currentTurn.turn_id,
1646
+ phase: state.phase,
1647
+ status: 'prepared',
1648
+ prepared_at: now,
1649
+ accepted_sequence: acceptedSequence,
1650
+ history_entry: historyEntry,
1651
+ ledger_entries: ledgerEntries,
1652
+ next_state: stripLegacyCurrentTurn(updatedState),
1653
+ };
1654
+ writeAcceptanceJournal(root, journal);
1655
+
1656
+ // ── Commit order: history → ledger → talk → state → cleanup → journal ─
1657
+ appendJsonl(root, HISTORY_PATH, historyEntry);
1658
+ for (const entry of ledgerEntries) {
1659
+ appendJsonl(root, LEDGER_PATH, entry);
1660
+ }
1661
+ appendTalk(root, talkSection);
1662
+ writeState(root, updatedState);
1663
+
1664
+ // Cleanup turn-scoped artifacts
1665
+ cleanupTurnArtifacts(root, currentTurn.turn_id);
1666
+ try {
1667
+ unlinkSync(stagingFile);
1668
+ } catch {}
1669
+
1670
+ // Journal committed — remove it
1671
+ commitAcceptanceJournal(root, transactionId);
1672
+
1673
+ // ── Post-acceptance hooks (advisory only — cannot block) ──────────────
1674
+ let hookResults = null;
1675
+ if (hooksConfig.after_acceptance && hooksConfig.after_acceptance.length > 0) {
1676
+ const hookPayload = {
1677
+ turn_id: currentTurn.turn_id,
1678
+ role_id: currentTurn.assigned_role,
1679
+ history_entry_index: acceptedSequence - 1,
1680
+ accepted_integration_ref: derivedRef,
1681
+ decisions_count: (turnResult.decisions || []).length,
1682
+ objections_count: (turnResult.objections || []).length,
1683
+ run_status: updatedState.status,
1684
+ phase: updatedState.phase,
1685
+ };
1686
+ hookResults = runHooks(root, hooksConfig, 'after_acceptance', hookPayload, {
1687
+ run_id: state.run_id,
1688
+ turn_id: currentTurn.turn_id,
1689
+ });
1690
+
1691
+ if (!hookResults.ok) {
1692
+ const hookName = hookResults.results?.find((entry) => entry.hook_name)?.hook_name || 'unknown';
1693
+ const detail = hookResults.tamper?.message || `after_acceptance hook "${hookName}" failed after commit`;
1694
+ const blockedState = blockRunForHookIssue(root, updatedState, {
1695
+ phase: 'after_acceptance',
1696
+ turnId: currentTurn.turn_id,
1697
+ hookName,
1698
+ detail,
1699
+ errorCode: hookResults.tamper?.error_code || 'hook_post_commit_error',
1700
+ turnRetained: Object.keys(getActiveTurns(updatedState)).length > 0,
1701
+ });
1702
+ return {
1703
+ ok: false,
1704
+ error: `Turn accepted, but post-commit hook handling failed: ${detail}`,
1705
+ error_code: hookResults.tamper?.error_code || 'hook_post_commit_error',
1706
+ state: blockedState,
1707
+ validation,
1708
+ accepted: historyEntry,
1709
+ gateResult,
1710
+ completionResult,
1711
+ hookResults,
1712
+ };
1713
+ }
1714
+ }
1715
+
1716
+ return {
1717
+ ok: true,
1718
+ state: attachLegacyCurrentTurnAlias(updatedState),
1719
+ validation,
1720
+ accepted: historyEntry,
1721
+ gateResult,
1722
+ completionResult,
1723
+ hookResults,
1724
+ };
1725
+ }
1726
+
1727
+ /**
1728
+ * Reject a governed turn.
1729
+ *
1730
+ * 1. Preserve the invalid staged artifact under .agentxchain/dispatch/rejected/
1731
+ * 2. Increment current_turn.attempt or escalate if retries exhausted
1732
+ * 3. Clear staging file
1733
+ *
1734
+ * Does NOT append to history.jsonl or decision-ledger.jsonl.
1735
+ *
1736
+ * @param {string} root - project root directory
1737
+ * @param {object} config - normalized config
1738
+ * @param {object} validationResult - validation failure details. Accepts either
1739
+ * `{ failed_stage, errors }` or the raw
1740
+ * validator shape `{ stage, errors }`.
1741
+ * @param {string} [reason] - human-readable rejection reason
1742
+ * @returns {{ ok: boolean, error?: string, state?: object, escalated?: boolean }}
1743
+ */
1744
+ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptions, opts = {}) {
1745
+ const state = readState(root);
1746
+ if (!state) {
1747
+ return { ok: false, error: 'No governed state.json found' };
1748
+ }
1749
+ const normalizedOpts = typeof reasonOrOptions === 'object' && reasonOrOptions !== null && !Array.isArray(reasonOrOptions)
1750
+ ? reasonOrOptions
1751
+ : { ...opts, reason: reasonOrOptions };
1752
+ const targetResolution = resolveTurnTarget(state, normalizedOpts.turnId);
1753
+ if (!targetResolution.ok) {
1754
+ return targetResolution.error_code === 'target_required'
1755
+ ? {
1756
+ ok: false,
1757
+ error: 'Multiple active turns are present. Re-run reject-turn with --turn <turn_id>.',
1758
+ error_code: 'target_required',
1759
+ }
1760
+ : targetResolution;
1761
+ }
1762
+ const currentTurn = targetResolution.turn;
1763
+
1764
+ const maxRetries = config.rules?.max_turn_retries ?? 2;
1765
+ const currentAttempt = currentTurn.attempt || 1;
1766
+ const canRetry = currentAttempt < maxRetries;
1767
+ const conflictContext = buildConflictContext(currentTurn);
1768
+ const isConflictReject = Boolean(conflictContext);
1769
+
1770
+ // Preserve rejected artifact
1771
+ const rejectedDir = join(root, '.agentxchain', 'dispatch', 'rejected');
1772
+ mkdirSync(rejectedDir, { recursive: true });
1773
+
1774
+ // Resolve staging path: prefer turn-scoped, fall back to flat
1775
+ const turnStagingRej = getTurnStagingResultPath(currentTurn.turn_id);
1776
+ const resolvedStagingRej = existsSync(join(root, turnStagingRej)) ? turnStagingRej : STAGING_PATH;
1777
+ const stagingFile = join(root, resolvedStagingRej);
1778
+ if (existsSync(stagingFile)) {
1779
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1780
+ const rejectedFile = join(rejectedDir, `${currentTurn.turn_id}-attempt-${currentAttempt}-${timestamp}.json`);
1781
+ try {
1782
+ const content = readFileSync(stagingFile, 'utf8');
1783
+ writeFileSync(rejectedFile, content);
1784
+ } catch {}
1785
+ try { unlinkSync(stagingFile); } catch {}
1786
+ }
1787
+
1788
+ // Write rejection context for the next retry
1789
+ const rejectionContext = {
1790
+ turn_id: currentTurn.turn_id,
1791
+ attempt: currentAttempt,
1792
+ rejected_at: new Date().toISOString(),
1793
+ reason: normalizedOpts.reason || (isConflictReject ? 'file_conflict' : 'Validation failed'),
1794
+ validation_errors: validationResult?.errors || [],
1795
+ failed_stage: validationResult?.failed_stage || validationResult?.stage || (isConflictReject ? 'conflict' : 'unknown'),
1796
+ };
1797
+
1798
+ if (conflictContext) {
1799
+ rejectionContext.conflict_context = conflictContext;
1800
+ }
1801
+
1802
+ if (isConflictReject) {
1803
+ appendJsonl(root, LEDGER_PATH, {
1804
+ timestamp: rejectionContext.rejected_at,
1805
+ decision: 'conflict_rejected',
1806
+ turn_id: currentTurn.turn_id,
1807
+ attempt: currentAttempt,
1808
+ role: currentTurn.assigned_role,
1809
+ phase: state.phase,
1810
+ conflict: {
1811
+ conflicting_files: currentTurn.conflict_state.conflict_error?.conflicting_files || [],
1812
+ accepted_since_turn_ids: (currentTurn.conflict_state.conflict_error?.accepted_since || []).map((entry) => entry.turn_id),
1813
+ overlap_ratio: currentTurn.conflict_state.conflict_error?.overlap_ratio ?? 0,
1814
+ },
1815
+ resolution_chosen: 'reject_and_reassign',
1816
+ operator_reason: normalizedOpts.reason || null,
1817
+ });
1818
+ }
1819
+
1820
+ if (canRetry) {
1821
+ const retryTurn = {
1822
+ ...currentTurn,
1823
+ attempt: currentAttempt + 1,
1824
+ status: 'retrying',
1825
+ last_rejection: rejectionContext,
1826
+ conflict_state: null,
1827
+ conflict_context: conflictContext,
1828
+ };
1829
+
1830
+ if (isConflictReject) {
1831
+ const retryStartedAt = new Date().toISOString();
1832
+ retryTurn.baseline = captureBaseline(root);
1833
+ retryTurn.assigned_sequence = Math.max(
1834
+ state.turn_sequence || 0,
1835
+ currentTurn.assigned_sequence || 0,
1836
+ );
1837
+ retryTurn.started_at = retryStartedAt;
1838
+ retryTurn.deadline_at = new Date(Date.now() + 20 * 60 * 1000).toISOString();
1839
+ retryTurn.concurrent_with = Object.keys(getActiveTurns(state)).filter((turnId) => turnId !== currentTurn.turn_id);
1840
+ }
1841
+
1842
+ // Increment attempt and keep the turn assigned
1843
+ const updatedState = {
1844
+ ...state,
1845
+ queued_phase_transition:
1846
+ isConflictReject && state.queued_phase_transition?.requested_by_turn === currentTurn.turn_id
1847
+ ? null
1848
+ : state.queued_phase_transition,
1849
+ active_turns: {
1850
+ ...getActiveTurns(state),
1851
+ [currentTurn.turn_id]: retryTurn,
1852
+ },
1853
+ };
1854
+
1855
+ writeState(root, updatedState);
1856
+ return {
1857
+ ok: true,
1858
+ state: attachLegacyCurrentTurnAlias(updatedState),
1859
+ escalated: false,
1860
+ turn: updatedState.active_turns[currentTurn.turn_id],
1861
+ };
1862
+ }
1863
+
1864
+ // Retries exhausted — escalate
1865
+ const updatedState = {
1866
+ ...state,
1867
+ status: 'blocked',
1868
+ active_turns: {
1869
+ ...getActiveTurns(state),
1870
+ [currentTurn.turn_id]: {
1871
+ ...currentTurn,
1872
+ status: 'failed',
1873
+ last_rejection: rejectionContext,
1874
+ conflict_state: null,
1875
+ conflict_context: conflictContext,
1876
+ },
1877
+ },
1878
+ blocked_on: `escalation:retries-exhausted:${currentTurn.assigned_role}`,
1879
+ blocked_reason: buildBlockedReason({
1880
+ category: 'retries_exhausted',
1881
+ recovery: {
1882
+ typed_reason: 'retries_exhausted',
1883
+ owner: 'human',
1884
+ recovery_action: 'Resolve the escalation, then run agentxchain step --resume',
1885
+ turn_retained: true,
1886
+ detail: `escalation:retries-exhausted:${currentTurn.assigned_role}`,
1887
+ },
1888
+ turnId: currentTurn.turn_id,
1889
+ }),
1890
+ escalation: {
1891
+ from_role: currentTurn.assigned_role,
1892
+ from_turn_id: currentTurn.turn_id,
1893
+ reason: `Turn rejected ${currentAttempt} times. Retries exhausted.`,
1894
+ validation_errors: validationResult?.errors || [],
1895
+ escalated_at: new Date().toISOString()
1896
+ }
1897
+ };
1898
+
1899
+ writeState(root, updatedState);
1900
+
1901
+ // Fire on_escalation hooks (advisory-only) after blocked state is persisted.
1902
+ const hooksConfig = config?.hooks || {};
1903
+ if (hooksConfig.on_escalation?.length > 0) {
1904
+ _fireOnEscalationHooks(root, hooksConfig, {
1905
+ blocked_reason: 'retries_exhausted',
1906
+ recovery_action: 'Resolve the escalation, then run agentxchain step --resume',
1907
+ failed_turn_id: currentTurn.turn_id,
1908
+ failed_role: currentTurn.assigned_role,
1909
+ attempt_count: currentAttempt,
1910
+ last_error: validationResult?.errors?.[0] || 'retries_exhausted',
1911
+ run_id: updatedState.run_id,
1912
+ });
1913
+ }
1914
+
1915
+ return {
1916
+ ok: true,
1917
+ state: attachLegacyCurrentTurnAlias(updatedState),
1918
+ escalated: true,
1919
+ turn: updatedState.active_turns[currentTurn.turn_id],
1920
+ };
1921
+ }
1922
+
1923
+ /**
1924
+ * Approve a pending phase transition.
1925
+ *
1926
+ * When a gate with requires_human_approval passes structurally,
1927
+ * the run pauses with a pending_phase_transition. This function
1928
+ * advances the phase after explicit human approval.
1929
+ *
1930
+ * Runs `before_gate` hooks when config is provided. A blocking hook
1931
+ * or tamper detection aborts the approval and blocks the run.
1932
+ *
1933
+ * @param {string} root - project root directory
1934
+ * @param {object} [config] - normalized config (optional; required for hook support)
1935
+ * @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, transition?: object, hookResults?: object }}
1936
+ */
1937
+ export function approvePhaseTransition(root, config) {
1938
+ const state = readState(root);
1939
+ if (!state) {
1940
+ return { ok: false, error: 'No governed state.json found' };
1941
+ }
1942
+ if (!state.pending_phase_transition) {
1943
+ return { ok: false, error: 'No pending phase transition to approve' };
1944
+ }
1945
+ if (!canApprovePendingGate(state)) {
1946
+ return { ok: false, error: `Cannot approve transition: status is "${state.status}", expected "paused" or "blocked"` };
1947
+ }
1948
+
1949
+ const transition = state.pending_phase_transition;
1950
+
1951
+ // ── before_gate hooks ──────────────────────────────────────────────
1952
+ const hooksConfig = config?.hooks || {};
1953
+ if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
1954
+ const historyLength = readJsonlEntries(root, HISTORY_PATH).length;
1955
+ const gatePayload = {
1956
+ gate_type: 'phase_transition',
1957
+ current_phase: transition.from,
1958
+ target_phase: transition.to,
1959
+ gate_config: transition,
1960
+ history_length: historyLength,
1961
+ };
1962
+ const gateHooks = runHooks(root, hooksConfig, 'before_gate', gatePayload, {
1963
+ run_id: state.run_id,
1964
+ });
1965
+
1966
+ if (!gateHooks.ok) {
1967
+ const hookName = gateHooks.blocker?.hook_name
1968
+ || gateHooks.results?.find((entry) => entry.hook_name)?.hook_name
1969
+ || 'unknown';
1970
+ const detail = gateHooks.blocker?.message
1971
+ || gateHooks.tamper?.message
1972
+ || `before_gate hook "${hookName}" blocked phase transition`;
1973
+ const blockedState = blockRunForHookIssue(root, state, {
1974
+ phase: 'before_gate',
1975
+ turnId: transition.requested_by_turn || null,
1976
+ hookName,
1977
+ detail,
1978
+ errorCode: gateHooks.tamper?.error_code || 'hook_blocked',
1979
+ turnRetained: false,
1980
+ });
1981
+ return {
1982
+ ok: false,
1983
+ error: detail,
1984
+ error_code: gateHooks.tamper?.error_code || 'hook_blocked',
1985
+ state: blockedState,
1986
+ hookResults: gateHooks,
1987
+ };
1988
+ }
1989
+ }
1990
+
1991
+ const updatedState = {
1992
+ ...state,
1993
+ phase: transition.to,
1994
+ status: 'active',
1995
+ blocked_on: null,
1996
+ blocked_reason: null,
1997
+ pending_phase_transition: null,
1998
+ phase_gate_status: {
1999
+ ...(state.phase_gate_status || {}),
2000
+ [transition.gate]: 'passed',
2001
+ },
2002
+ };
2003
+
2004
+ writeState(root, updatedState);
2005
+
2006
+ return {
2007
+ ok: true,
2008
+ state: attachLegacyCurrentTurnAlias(updatedState),
2009
+ transition,
2010
+ };
2011
+ }
2012
+
2013
+ /**
2014
+ * Approve a pending run completion.
2015
+ *
2016
+ * When the final phase gate with requires_human_approval passes structurally,
2017
+ * the run pauses with a pending_run_completion. This function marks the run
2018
+ * as completed after explicit human approval.
2019
+ *
2020
+ * Runs `before_gate` hooks when config is provided. A blocking hook
2021
+ * or tamper detection aborts the approval and blocks the run.
2022
+ *
2023
+ * @param {string} root - project root directory
2024
+ * @param {object} [config] - normalized config (optional; required for hook support)
2025
+ * @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, completion?: object, hookResults?: object }}
2026
+ */
2027
+ export function approveRunCompletion(root, config) {
2028
+ const state = readState(root);
2029
+ if (!state) {
2030
+ return { ok: false, error: 'No governed state.json found' };
2031
+ }
2032
+ if (!state.pending_run_completion) {
2033
+ return { ok: false, error: 'No pending run completion to approve' };
2034
+ }
2035
+ if (!canApprovePendingGate(state)) {
2036
+ return { ok: false, error: `Cannot approve completion: status is "${state.status}", expected "paused" or "blocked"` };
2037
+ }
2038
+
2039
+ const completion = state.pending_run_completion;
2040
+
2041
+ // ── before_gate hooks ──────────────────────────────────────────────
2042
+ const hooksConfig = config?.hooks || {};
2043
+ if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
2044
+ const historyLength = readJsonlEntries(root, HISTORY_PATH).length;
2045
+ const gatePayload = {
2046
+ gate_type: 'run_completion',
2047
+ current_phase: state.phase,
2048
+ target_phase: null,
2049
+ gate_config: completion,
2050
+ history_length: historyLength,
2051
+ };
2052
+ const gateHooks = runHooks(root, hooksConfig, 'before_gate', gatePayload, {
2053
+ run_id: state.run_id,
2054
+ });
2055
+
2056
+ if (!gateHooks.ok) {
2057
+ const hookName = gateHooks.blocker?.hook_name
2058
+ || gateHooks.results?.find((entry) => entry.hook_name)?.hook_name
2059
+ || 'unknown';
2060
+ const detail = gateHooks.blocker?.message
2061
+ || gateHooks.tamper?.message
2062
+ || `before_gate hook "${hookName}" blocked run completion`;
2063
+ const blockedState = blockRunForHookIssue(root, state, {
2064
+ phase: 'before_gate',
2065
+ turnId: completion.requested_by_turn || null,
2066
+ hookName,
2067
+ detail,
2068
+ errorCode: gateHooks.tamper?.error_code || 'hook_blocked',
2069
+ turnRetained: false,
2070
+ });
2071
+ return {
2072
+ ok: false,
2073
+ error: detail,
2074
+ error_code: gateHooks.tamper?.error_code || 'hook_blocked',
2075
+ state: blockedState,
2076
+ hookResults: gateHooks,
2077
+ };
2078
+ }
2079
+ }
2080
+
2081
+ const updatedState = {
2082
+ ...state,
2083
+ status: 'completed',
2084
+ completed_at: new Date().toISOString(),
2085
+ blocked_on: null,
2086
+ blocked_reason: null,
2087
+ pending_run_completion: null,
2088
+ phase_gate_status: {
2089
+ ...(state.phase_gate_status || {}),
2090
+ [completion.gate]: 'passed',
2091
+ },
2092
+ };
2093
+
2094
+ writeState(root, updatedState);
2095
+
2096
+ return {
2097
+ ok: true,
2098
+ state: attachLegacyCurrentTurnAlias(updatedState),
2099
+ completion,
2100
+ };
2101
+ }
2102
+
2103
+ // ── Routing Helpers ─────────────────────────────────────────────────────────
2104
+
2105
+ /**
2106
+ * Derive the next recommended role after an accepted turn.
2107
+ *
2108
+ * Rules:
2109
+ * - If proposed_next_role is routing-legal for the current phase and not 'human', use it
2110
+ * - Otherwise, use the current phase entry_role
2111
+ * - After escalation or human pause, clear recommendation (returns null)
2112
+ *
2113
+ * @param {object} turnResult — the accepted turn result
2114
+ * @param {object} state — the current state
2115
+ * @param {object} config — normalized config
2116
+ * @returns {string|null}
2117
+ */
2118
+ function deriveNextRecommendedRole(turnResult, state, config) {
2119
+ if (turnResult.status === 'needs_human' || turnResult.status === 'blocked') {
2120
+ return null;
2121
+ }
2122
+
2123
+ const proposed = turnResult.proposed_next_role;
2124
+ if (!proposed || proposed === 'human') return null;
2125
+
2126
+ // Check if proposed is routing-legal for the current phase
2127
+ const phase = state.phase;
2128
+ const routing = config.routing?.[phase];
2129
+ if (routing?.allowed_next_roles) {
2130
+ if (routing.allowed_next_roles.includes(proposed)) {
2131
+ return proposed;
2132
+ }
2133
+ }
2134
+
2135
+ // Fall back to phase entry_role
2136
+ return routing?.entry_role || null;
2137
+ }
2138
+
2139
+ export { STATE_PATH, HISTORY_PATH, LEDGER_PATH, STAGING_PATH, TALK_PATH };