agentxchain 0.8.7 → 2.1.1

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 (94) hide show
  1. package/README.md +123 -154
  2. package/bin/agentxchain.js +240 -8
  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 +16 -7
  13. package/scripts/agentxchain-autonudge.applescript +32 -5
  14. package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
  15. package/scripts/publish-from-tag.sh +88 -0
  16. package/scripts/release-postflight.sh +231 -0
  17. package/scripts/release-preflight.sh +167 -0
  18. package/scripts/run-autonudge.sh +1 -1
  19. package/src/adapters/claude-code.js +7 -14
  20. package/src/adapters/cursor-local.js +17 -16
  21. package/src/commands/accept-turn.js +160 -0
  22. package/src/commands/approve-completion.js +80 -0
  23. package/src/commands/approve-transition.js +85 -0
  24. package/src/commands/branch.js +2 -2
  25. package/src/commands/claim.js +84 -9
  26. package/src/commands/config.js +16 -0
  27. package/src/commands/dashboard.js +70 -0
  28. package/src/commands/doctor.js +9 -1
  29. package/src/commands/init.js +540 -5
  30. package/src/commands/migrate.js +348 -0
  31. package/src/commands/multi.js +549 -0
  32. package/src/commands/plugin.js +157 -0
  33. package/src/commands/reject-turn.js +204 -0
  34. package/src/commands/resume.js +389 -0
  35. package/src/commands/status.js +196 -3
  36. package/src/commands/step.js +947 -0
  37. package/src/commands/stop.js +65 -33
  38. package/src/commands/template-list.js +33 -0
  39. package/src/commands/template-set.js +279 -0
  40. package/src/commands/update.js +24 -3
  41. package/src/commands/validate.js +20 -11
  42. package/src/commands/verify.js +71 -0
  43. package/src/commands/watch.js +112 -25
  44. package/src/lib/adapters/api-proxy-adapter.js +1076 -0
  45. package/src/lib/adapters/local-cli-adapter.js +337 -0
  46. package/src/lib/adapters/manual-adapter.js +169 -0
  47. package/src/lib/blocked-state.js +94 -0
  48. package/src/lib/config.js +143 -12
  49. package/src/lib/context-compressor.js +121 -0
  50. package/src/lib/context-section-parser.js +220 -0
  51. package/src/lib/coordinator-acceptance.js +428 -0
  52. package/src/lib/coordinator-config.js +461 -0
  53. package/src/lib/coordinator-dispatch.js +276 -0
  54. package/src/lib/coordinator-gates.js +487 -0
  55. package/src/lib/coordinator-hooks.js +239 -0
  56. package/src/lib/coordinator-recovery.js +523 -0
  57. package/src/lib/coordinator-state.js +365 -0
  58. package/src/lib/cross-repo-context.js +247 -0
  59. package/src/lib/dashboard/bridge-server.js +284 -0
  60. package/src/lib/dashboard/file-watcher.js +93 -0
  61. package/src/lib/dashboard/state-reader.js +96 -0
  62. package/src/lib/dispatch-bundle.js +568 -0
  63. package/src/lib/dispatch-manifest.js +252 -0
  64. package/src/lib/filter-agents.js +12 -0
  65. package/src/lib/gate-evaluator.js +285 -0
  66. package/src/lib/generate-vscode.js +158 -68
  67. package/src/lib/governed-state.js +2139 -0
  68. package/src/lib/governed-templates.js +145 -0
  69. package/src/lib/hook-runner.js +788 -0
  70. package/src/lib/next-owner.js +61 -6
  71. package/src/lib/normalized-config.js +539 -0
  72. package/src/lib/notify.js +14 -12
  73. package/src/lib/plugin-config-schema.js +192 -0
  74. package/src/lib/plugins.js +692 -0
  75. package/src/lib/prompt-core.js +108 -0
  76. package/src/lib/protocol-conformance.js +291 -0
  77. package/src/lib/reference-conformance-adapter.js +717 -0
  78. package/src/lib/repo-observer.js +597 -0
  79. package/src/lib/repo.js +0 -31
  80. package/src/lib/safe-write.js +44 -0
  81. package/src/lib/schema.js +189 -0
  82. package/src/lib/schemas/turn-result.schema.json +205 -0
  83. package/src/lib/seed-prompt-polling.js +15 -73
  84. package/src/lib/seed-prompt.js +17 -63
  85. package/src/lib/token-budget.js +206 -0
  86. package/src/lib/token-counter.js +27 -0
  87. package/src/lib/turn-paths.js +67 -0
  88. package/src/lib/turn-result-validator.js +496 -0
  89. package/src/lib/validation.js +167 -19
  90. package/src/lib/verify-command.js +72 -0
  91. package/src/templates/governed/api-service.json +31 -0
  92. package/src/templates/governed/cli-tool.json +30 -0
  93. package/src/templates/governed/generic.json +10 -0
  94. package/src/templates/governed/web-app.json +30 -0
@@ -0,0 +1,568 @@
1
+ /**
2
+ * Dispatch bundle writer — materializes the filesystem handoff artifacts
3
+ * for a governed turn assignment.
4
+ *
5
+ * Per the frozen spec (§46), a dispatch bundle lives at:
6
+ * .agentxchain/dispatch/turns/<turn_id>/
7
+ *
8
+ * And contains:
9
+ * - ASSIGNMENT.json — machine-readable turn envelope
10
+ * - PROMPT.md — rendered role prompt with protocol rules
11
+ * - CONTEXT.md — execution context (state, last turn, blockers, gates)
12
+ *
13
+ * This module is a library primitive. The resume command and future
14
+ * orchestrator turn loop call it after assignGovernedTurn().
15
+ */
16
+
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { getActiveTurn, getActiveTurns } from './governed-state.js';
20
+ import {
21
+ DISPATCH_INDEX_PATH,
22
+ getDispatchAssignmentPath,
23
+ getDispatchContextPath,
24
+ getDispatchPromptPath,
25
+ getDispatchTurnDir,
26
+ getTurnStagingResultPath,
27
+ } from './turn-paths.js';
28
+
29
+ const HISTORY_PATH = '.agentxchain/history.jsonl';
30
+
31
+ // Reserved paths that agents must never modify
32
+ const RESERVED_PATHS = [
33
+ '.agentxchain/state.json',
34
+ '.agentxchain/history.jsonl',
35
+ '.agentxchain/decision-ledger.jsonl',
36
+ '.agentxchain/lock.json',
37
+ ];
38
+
39
+ /**
40
+ * Write a dispatch bundle for the currently assigned turn.
41
+ *
42
+ * @param {string} root - project root directory
43
+ * @param {object} state - current governed state (must have current_turn)
44
+ * @param {object} config - normalized config
45
+ * @param {object} [opts]
46
+ * @param {string} [opts.turnId]
47
+ * @param {string[]} [opts.warnings]
48
+ * @returns {{ ok: boolean, error?: string, bundlePath?: string, warnings?: string[] }}
49
+ */
50
+ export function writeDispatchBundle(root, state, config, opts = {}) {
51
+ const targetTurn = resolveTargetTurn(state, opts.turnId);
52
+ if (!targetTurn) {
53
+ return { ok: false, error: 'No active turn in state — cannot write dispatch bundle' };
54
+ }
55
+
56
+ const turn = targetTurn;
57
+ const roleId = turn.assigned_role;
58
+ const role = config.roles?.[roleId];
59
+
60
+ if (!role) {
61
+ return { ok: false, error: `Role "${roleId}" not found in config` };
62
+ }
63
+
64
+ const phase = state.phase;
65
+ const routing = config.routing?.[phase];
66
+ const allowedNextRoles = routing?.allowed_next_roles || [];
67
+ const exitGate = routing?.exit_gate;
68
+ const gateConfig = exitGate ? config.gates?.[exitGate] : null;
69
+
70
+ const bundleDir = join(root, getDispatchTurnDir(turn.turn_id));
71
+ const warnings = [...(opts.warnings || [])];
72
+
73
+ // Clear and recreate only the targeted turn bundle
74
+ try {
75
+ rmSync(bundleDir, { recursive: true, force: true });
76
+ } catch (err) {
77
+ return { ok: false, error: `Failed to clear existing dispatch bundle: ${err.message}` };
78
+ }
79
+ mkdirSync(bundleDir, { recursive: true });
80
+
81
+ const activeTurns = getActiveTurns(state);
82
+ const activeSiblings = Object.values(activeTurns)
83
+ .filter((activeTurn) => activeTurn.turn_id !== turn.turn_id)
84
+ .map((activeTurn) => ({
85
+ turn_id: activeTurn.turn_id,
86
+ role: activeTurn.assigned_role,
87
+ status: activeTurn.status,
88
+ assigned_sequence: activeTurn.assigned_sequence ?? null,
89
+ declared_file_scope: activeTurn.declared_file_scope,
90
+ }));
91
+
92
+ // 1. ASSIGNMENT.json
93
+ const assignment = {
94
+ run_id: state.run_id,
95
+ turn_id: turn.turn_id,
96
+ phase,
97
+ role: roleId,
98
+ runtime_id: turn.runtime_id,
99
+ write_authority: role.write_authority,
100
+ accepted_integration_ref: state.accepted_integration_ref,
101
+ staging_result_path: getTurnStagingResultPath(turn.turn_id),
102
+ reserved_paths: RESERVED_PATHS,
103
+ allowed_next_roles: allowedNextRoles,
104
+ attempt: turn.attempt,
105
+ deadline_at: turn.deadline_at,
106
+ assigned_sequence: turn.assigned_sequence ?? null,
107
+ budget_reservation_usd: state.budget_reservations?.[turn.turn_id]?.reserved_usd ?? null,
108
+ active_siblings: activeSiblings,
109
+ };
110
+ if (turn.conflict_context) {
111
+ assignment.conflict_context = turn.conflict_context;
112
+ }
113
+ if (warnings.length > 0) {
114
+ assignment.advisory_warnings = warnings.map((message) => ({ code: 'advisory_scope_overlap', message }));
115
+ }
116
+
117
+ writeFileSync(
118
+ join(root, getDispatchAssignmentPath(turn.turn_id)),
119
+ JSON.stringify(assignment, null, 2) + '\n'
120
+ );
121
+
122
+ // 2. PROMPT.md
123
+ const prompt = renderPrompt(role, roleId, turn, state, config, root);
124
+ warnings.push(...prompt.warnings);
125
+ writeFileSync(join(root, getDispatchPromptPath(turn.turn_id)), prompt.content);
126
+
127
+ // 3. CONTEXT.md
128
+ const context = renderContext(state, config, root);
129
+ warnings.push(...context.warnings);
130
+ writeFileSync(join(root, getDispatchContextPath(turn.turn_id)), context.content);
131
+
132
+ writeDispatchIndex(root, state, warningsByTurnId(state, turn.turn_id, warnings));
133
+
134
+ return warnings.length
135
+ ? { ok: true, bundlePath: bundleDir, warnings }
136
+ : { ok: true, bundlePath: bundleDir };
137
+ }
138
+
139
+ // ── Prompt Rendering ────────────────────────────────────────────────────────
140
+
141
+ function renderPrompt(role, roleId, turn, state, config, root) {
142
+ const phase = state.phase;
143
+ const routing = config.routing?.[phase];
144
+ const exitGate = routing?.exit_gate;
145
+ const gateConfig = exitGate ? config.gates?.[exitGate] : null;
146
+ const warnings = [];
147
+
148
+ // Load custom prompt template from disk (best-effort)
149
+ const promptPath = config.prompts?.[roleId];
150
+ let customPrompt = '';
151
+ if (promptPath) {
152
+ try {
153
+ const absPromptPath = join(root, promptPath);
154
+ if (existsSync(absPromptPath)) {
155
+ customPrompt = readFileSync(absPromptPath, 'utf8').trim();
156
+ }
157
+ } catch (err) {
158
+ warnings.push(`Failed to load prompt template "${promptPath}": ${err.message}`);
159
+ }
160
+ }
161
+
162
+ const lines = [];
163
+
164
+ // Identity block
165
+ lines.push(`# Turn Assignment: ${role.title} (${roleId})`);
166
+ lines.push('');
167
+ lines.push(`**Run:** ${state.run_id}`);
168
+ lines.push(`**Turn:** ${turn.turn_id}`);
169
+ lines.push(`**Phase:** ${phase}`);
170
+ lines.push(`**Attempt:** ${turn.attempt}`);
171
+ lines.push(`**Write Authority:** ${role.write_authority}`);
172
+ lines.push(`**Runtime:** ${turn.runtime_id}`);
173
+ lines.push('');
174
+
175
+ // Mandate
176
+ lines.push('## Your Mandate');
177
+ lines.push('');
178
+ lines.push(role.mandate);
179
+ lines.push('');
180
+
181
+ // Protocol rules
182
+ lines.push('## Protocol Rules');
183
+ lines.push('');
184
+ lines.push('You MUST follow these rules:');
185
+ lines.push('');
186
+ lines.push('1. **Challenge the previous turn explicitly.** Do not rubber-stamp prior work.');
187
+ lines.push('2. **Do not claim verification you did not perform.** If you did not run the tests, do not say they pass.');
188
+ lines.push('3. **Do not modify reserved state files.** These are orchestrator-owned:');
189
+ for (const p of RESERVED_PATHS) {
190
+ lines.push(` - \`${p}\``);
191
+ }
192
+ lines.push(`4. **Emit a structured turn result** to \`${getTurnStagingResultPath(turn.turn_id)}\`.`);
193
+ lines.push('5. **Propose the next role**, but do not assume routing authority.');
194
+ lines.push('');
195
+
196
+ if (role.write_authority === 'review_only') {
197
+ lines.push('### Write Authority: review_only');
198
+ lines.push('');
199
+ lines.push('- You may NOT modify product/code files.');
200
+ lines.push('- You may create/modify files under `.planning/` and `.agentxchain/reviews/`.');
201
+ lines.push('- Your artifact type must be `review`.');
202
+ lines.push('- You MUST raise at least one objection (even if minor).');
203
+ lines.push('');
204
+ } else if (role.write_authority === 'authoritative') {
205
+ lines.push('### Write Authority: authoritative');
206
+ lines.push('');
207
+ lines.push('- You may directly modify repository files.');
208
+ lines.push('- Your artifact type should be `workspace` or `commit`.');
209
+ lines.push('- You must accurately declare all files you changed.');
210
+ lines.push('');
211
+ } else if (role.write_authority === 'proposed') {
212
+ lines.push('### Write Authority: proposed');
213
+ lines.push('');
214
+ lines.push('- You may propose changes as patches but cannot directly commit.');
215
+ lines.push('- Your artifact type should be `patch`.');
216
+ lines.push('');
217
+ }
218
+
219
+ // Gate requirements
220
+ if (gateConfig) {
221
+ lines.push('## Phase Exit Gate');
222
+ lines.push('');
223
+ lines.push(`Gate: \`${exitGate}\``);
224
+ lines.push('');
225
+ if (gateConfig.requires_files) {
226
+ lines.push('Required files:');
227
+ for (const f of gateConfig.requires_files) {
228
+ lines.push(`- \`${f}\``);
229
+ }
230
+ }
231
+ if (gateConfig.requires_verification_pass) {
232
+ lines.push('- Requires verification pass');
233
+ }
234
+ if (gateConfig.requires_human_approval) {
235
+ lines.push('- Requires human approval');
236
+ }
237
+ lines.push('');
238
+ }
239
+
240
+ // Retry context
241
+ if (turn.attempt > 1 && turn.last_rejection) {
242
+ lines.push('## Previous Attempt Failed');
243
+ lines.push('');
244
+ lines.push(`This is attempt ${turn.attempt}. The previous attempt was rejected:`);
245
+ lines.push('');
246
+ lines.push(`- **Reason:** ${turn.last_rejection.reason}`);
247
+ lines.push(`- **Failed stage:** ${turn.last_rejection.failed_stage}`);
248
+ if (turn.last_rejection.validation_errors?.length) {
249
+ lines.push('- **Errors:**');
250
+ for (const err of turn.last_rejection.validation_errors) {
251
+ lines.push(` - ${err}`);
252
+ }
253
+ }
254
+ lines.push('');
255
+ lines.push('Fix the issues above before proceeding.');
256
+ lines.push('');
257
+ }
258
+
259
+ if (turn.conflict_context) {
260
+ lines.push('## File Conflict - Retry Required');
261
+ lines.push('');
262
+ lines.push('Your prior attempt conflicted with work accepted after your assignment.');
263
+ lines.push('');
264
+ if (turn.conflict_context.conflicting_files?.length) {
265
+ lines.push('Conflicting files:');
266
+ for (const file of turn.conflict_context.conflicting_files) {
267
+ lines.push(`- \`${file}\``);
268
+ }
269
+ lines.push('');
270
+ }
271
+ if (turn.conflict_context.accepted_turns_since?.length) {
272
+ lines.push('Accepted turns since assignment:');
273
+ for (const acceptedTurn of turn.conflict_context.accepted_turns_since) {
274
+ lines.push(`- \`${acceptedTurn.turn_id}\` (${acceptedTurn.role}) touched: ${acceptedTurn.files_changed.join(', ') || '(none)'}`);
275
+ }
276
+ lines.push('');
277
+ }
278
+ if (turn.conflict_context.non_conflicting_files_preserved?.length) {
279
+ lines.push('Non-conflicting files to preserve from your prior attempt:');
280
+ for (const file of turn.conflict_context.non_conflicting_files_preserved) {
281
+ lines.push(`- \`${file}\``);
282
+ }
283
+ lines.push('');
284
+ }
285
+ lines.push(turn.conflict_context.guidance || 'You MUST rebase your changes on top of the current workspace state before retrying.');
286
+ lines.push('');
287
+ }
288
+
289
+ // Role-specific instructions (loaded from custom prompt file)
290
+ if (customPrompt) {
291
+ lines.push('## Role-Specific Instructions');
292
+ lines.push('');
293
+ lines.push(customPrompt);
294
+ lines.push('');
295
+ }
296
+
297
+ // Output format with complete JSON template
298
+ lines.push('## Required Output');
299
+ lines.push('');
300
+ lines.push('When your work is complete, write your structured turn result to:');
301
+ lines.push('');
302
+ lines.push('```');
303
+ lines.push(getTurnStagingResultPath(turn.turn_id));
304
+ lines.push('```');
305
+ lines.push('');
306
+ lines.push('The JSON **must** match this exact schema. The orchestrator validates every field.');
307
+ lines.push('');
308
+ lines.push('```json');
309
+ lines.push(JSON.stringify(buildTurnResultTemplate(state, turn, roleId, role), null, 2));
310
+ lines.push('```');
311
+ lines.push('');
312
+ lines.push('### Field Rules');
313
+ lines.push('');
314
+ lines.push('- `schema_version`: always `"1.0"`');
315
+ lines.push('- `run_id`, `turn_id`, `role`, `runtime_id`: must match the values above exactly');
316
+ lines.push('- `status`: one of `completed`, `blocked`, `needs_human`, `failed`');
317
+ lines.push('- `summary`: concise description of what you did this turn');
318
+ lines.push('- `decisions[].id`: pattern `DEC-NNN` (increment from previous turn)');
319
+ lines.push('- `decisions[].category`: one of `implementation`, `architecture`, `scope`, `process`, `quality`, `release`');
320
+ lines.push('- `objections[].id`: pattern `OBJ-NNN`');
321
+ lines.push('- `objections[].severity`: one of `low`, `medium`, `high`, `blocking`');
322
+ lines.push('- `verification.status`: one of `pass`, `fail`, `skipped`');
323
+ lines.push('- `artifact.type`: one of `workspace`, `patch`, `commit`, `review`');
324
+ lines.push('- `proposed_next_role`: must be in allowed_next_roles for current phase, or `human`');
325
+ if (role.write_authority === 'review_only') {
326
+ lines.push('- `objections`: **must be non-empty** (challenge requirement for review_only roles)');
327
+ }
328
+ lines.push('- `phase_transition_request`: set to next phase name when gate requirements are met, or `null`');
329
+ lines.push('- `run_completion_request`: set to `true` only in the final phase when ready to ship, or `null`');
330
+ lines.push('- `phase_transition_request` and `run_completion_request` are **mutually exclusive**');
331
+ lines.push('');
332
+
333
+ return {
334
+ content: lines.join('\n') + '\n',
335
+ warnings,
336
+ };
337
+ }
338
+
339
+ // ── Context Rendering ───────────────────────────────────────────────────────
340
+
341
+ function renderContext(state, config, root) {
342
+ const warnings = [];
343
+ const lines = [];
344
+
345
+ lines.push('# Execution Context');
346
+ lines.push('');
347
+
348
+ // State summary
349
+ lines.push('## Current State');
350
+ lines.push('');
351
+ lines.push(`- **Run:** ${state.run_id}`);
352
+ lines.push(`- **Status:** ${state.status}`);
353
+ lines.push(`- **Phase:** ${state.phase}`);
354
+ lines.push(`- **Integration ref:** ${state.accepted_integration_ref || 'none'}`);
355
+ if (state.budget_status) {
356
+ lines.push(`- **Budget spent:** $${(state.budget_status.spent_usd || 0).toFixed(2)}`);
357
+ if (state.budget_status.remaining_usd != null) {
358
+ lines.push(`- **Budget remaining:** $${state.budget_status.remaining_usd.toFixed(2)}`);
359
+ }
360
+ }
361
+ lines.push('');
362
+
363
+ // Last accepted turn summary
364
+ if (state.last_completed_turn_id) {
365
+ const lastTurn = readLastHistoryEntry(root, warnings);
366
+ if (lastTurn) {
367
+ lines.push('## Last Accepted Turn');
368
+ lines.push('');
369
+ lines.push(`- **Turn:** ${lastTurn.turn_id}`);
370
+ lines.push(`- **Role:** ${lastTurn.role}`);
371
+ lines.push(`- **Summary:** ${lastTurn.summary}`);
372
+ if (lastTurn.decisions?.length) {
373
+ lines.push('- **Decisions:**');
374
+ for (const d of lastTurn.decisions) {
375
+ lines.push(` - ${d.id}: ${d.statement}`);
376
+ }
377
+ }
378
+ if (lastTurn.objections?.length) {
379
+ lines.push('- **Objections:**');
380
+ for (const o of lastTurn.objections) {
381
+ lines.push(` - ${o.id} (${o.severity}): ${o.statement}`);
382
+ }
383
+ }
384
+ lines.push('');
385
+ }
386
+ }
387
+
388
+ // Blockers / escalation
389
+ if (state.blocked_on) {
390
+ lines.push('## Blockers');
391
+ lines.push('');
392
+ lines.push(`- **Blocked on:** ${state.blocked_on}`);
393
+ lines.push('');
394
+ }
395
+
396
+ if (state.escalation) {
397
+ lines.push('## Escalation');
398
+ lines.push('');
399
+ lines.push(`- **From:** ${state.escalation.from_role}`);
400
+ lines.push(`- **Reason:** ${state.escalation.reason}`);
401
+ lines.push('');
402
+ }
403
+
404
+ // Phase gate requirements
405
+ const phase = state.phase;
406
+ const routing = config.routing?.[phase];
407
+ const exitGate = routing?.exit_gate;
408
+ const gateConfig = exitGate ? config.gates?.[exitGate] : null;
409
+
410
+ if (gateConfig?.requires_files) {
411
+ lines.push('## Gate Required Files');
412
+ lines.push('');
413
+ for (const f of gateConfig.requires_files) {
414
+ const exists = existsSync(join(root, f));
415
+ lines.push(`- \`${f}\` — ${exists ? 'exists' : 'MISSING'}`);
416
+ }
417
+ lines.push('');
418
+ }
419
+
420
+ // Phase gate status
421
+ if (state.phase_gate_status) {
422
+ lines.push('## Phase Gate Status');
423
+ lines.push('');
424
+ for (const [gate, status] of Object.entries(state.phase_gate_status)) {
425
+ lines.push(`- \`${gate}\`: ${status}`);
426
+ }
427
+ lines.push('');
428
+ }
429
+
430
+ return {
431
+ content: lines.join('\n') + '\n',
432
+ warnings,
433
+ };
434
+ }
435
+
436
+ // ── Helpers ─────────────────────────────────────────────────────────────────
437
+
438
+ function resolveTargetTurn(state, turnId) {
439
+ const activeTurns = getActiveTurns(state);
440
+ if (turnId) {
441
+ return activeTurns[turnId] || null;
442
+ }
443
+ return getActiveTurn(state) || state?.current_turn || null;
444
+ }
445
+
446
+ function warningsByTurnId(state, targetTurnId, targetWarnings) {
447
+ const warningMap = {};
448
+ for (const turnId of Object.keys(getActiveTurns(state))) {
449
+ warningMap[turnId] = [];
450
+ }
451
+ if (targetTurnId && targetWarnings?.length) {
452
+ warningMap[targetTurnId] = [...targetWarnings];
453
+ }
454
+ return warningMap;
455
+ }
456
+
457
+ function writeDispatchIndex(root, state, warningsByTurn = {}) {
458
+ const activeTurns = getActiveTurns(state);
459
+ const activeEntries = {};
460
+
461
+ for (const [turnId, turn] of Object.entries(activeTurns)) {
462
+ const turnWarnings = warningsByTurn[turnId] || [];
463
+ activeEntries[turnId] = {
464
+ turn_id: turnId,
465
+ role: turn.assigned_role,
466
+ runtime_id: turn.runtime_id,
467
+ attempt: turn.attempt,
468
+ status: turn.status,
469
+ bundle_path: getDispatchTurnDir(turnId),
470
+ staging_result_path: getTurnStagingResultPath(turnId),
471
+ assigned_sequence: turn.assigned_sequence ?? null,
472
+ advisory_warnings: turnWarnings.map((message) => ({
473
+ code: 'advisory_scope_overlap',
474
+ message,
475
+ })),
476
+ };
477
+ }
478
+
479
+ mkdirSync(join(root, '.agentxchain/dispatch'), { recursive: true });
480
+ writeFileSync(
481
+ join(root, DISPATCH_INDEX_PATH),
482
+ JSON.stringify(
483
+ {
484
+ run_id: state.run_id,
485
+ phase: state.phase,
486
+ updated_at: new Date().toISOString(),
487
+ active_turns: activeEntries,
488
+ },
489
+ null,
490
+ 2,
491
+ ) + '\n',
492
+ );
493
+ }
494
+
495
+ function readLastHistoryEntry(root, warnings = []) {
496
+ const historyPath = join(root, HISTORY_PATH);
497
+ if (!existsSync(historyPath)) return null;
498
+ try {
499
+ const content = readFileSync(historyPath, 'utf8').trim();
500
+ if (!content) return null;
501
+ const lines = content.split('\n');
502
+ return JSON.parse(lines[lines.length - 1]);
503
+ } catch (err) {
504
+ warnings.push(`Failed to read ${HISTORY_PATH}: ${err.message}`);
505
+ return null;
506
+ }
507
+ }
508
+
509
+ // ── Turn Result Template ───────────────────────────────────────────────────
510
+
511
+ function buildTurnResultTemplate(state, turn, roleId, role) {
512
+ const isReviewOnly = role.write_authority === 'review_only';
513
+ return {
514
+ schema_version: '1.0',
515
+ run_id: state.run_id,
516
+ turn_id: turn.turn_id,
517
+ role: roleId,
518
+ runtime_id: turn.runtime_id,
519
+ status: 'completed',
520
+ summary: 'TODO: describe what you accomplished this turn',
521
+ decisions: [
522
+ {
523
+ id: 'DEC-001',
524
+ category: 'implementation',
525
+ statement: 'TODO: describe the decision',
526
+ rationale: 'TODO: explain why',
527
+ },
528
+ ],
529
+ objections: isReviewOnly
530
+ ? [
531
+ {
532
+ id: 'OBJ-001',
533
+ severity: 'medium',
534
+ against_turn_id: state.last_completed_turn_id || 'TODO',
535
+ statement: 'TODO: challenge the previous turn (required for review_only roles)',
536
+ status: 'raised',
537
+ },
538
+ ]
539
+ : [],
540
+ files_changed: isReviewOnly ? [] : ['TODO: list every file you modified'],
541
+ artifacts_created: [],
542
+ verification: {
543
+ status: isReviewOnly ? 'skipped' : 'pass',
544
+ commands: isReviewOnly ? [] : ['TODO: list commands you ran'],
545
+ evidence_summary: isReviewOnly
546
+ ? 'Review turn — no verification commands required.'
547
+ : 'TODO: describe what you verified',
548
+ machine_evidence: isReviewOnly
549
+ ? []
550
+ : [{ command: 'TODO', exit_code: 0 }],
551
+ },
552
+ artifact: {
553
+ type: isReviewOnly ? 'review' : 'workspace',
554
+ ref: isReviewOnly ? null : 'git:dirty',
555
+ },
556
+ proposed_next_role: 'TODO',
557
+ phase_transition_request: null,
558
+ run_completion_request: null,
559
+ needs_human_reason: null,
560
+ cost: {
561
+ input_tokens: 0,
562
+ output_tokens: 0,
563
+ usd: 0,
564
+ },
565
+ };
566
+ }
567
+
568
+ export { DISPATCH_INDEX_PATH, RESERVED_PATHS, getDispatchTurnDir, getTurnStagingResultPath };