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.
- package/README.md +123 -154
- package/bin/agentxchain.js +240 -8
- package/dashboard/app.js +305 -0
- package/dashboard/components/blocked.js +145 -0
- package/dashboard/components/cross-repo.js +126 -0
- package/dashboard/components/gate.js +311 -0
- package/dashboard/components/hooks.js +177 -0
- package/dashboard/components/initiative.js +147 -0
- package/dashboard/components/ledger.js +165 -0
- package/dashboard/components/timeline.js +222 -0
- package/dashboard/index.html +352 -0
- package/package.json +16 -7
- package/scripts/agentxchain-autonudge.applescript +32 -5
- package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
- package/scripts/publish-from-tag.sh +88 -0
- package/scripts/release-postflight.sh +231 -0
- package/scripts/release-preflight.sh +167 -0
- package/scripts/run-autonudge.sh +1 -1
- package/src/adapters/claude-code.js +7 -14
- package/src/adapters/cursor-local.js +17 -16
- package/src/commands/accept-turn.js +160 -0
- package/src/commands/approve-completion.js +80 -0
- package/src/commands/approve-transition.js +85 -0
- package/src/commands/branch.js +2 -2
- package/src/commands/claim.js +84 -9
- package/src/commands/config.js +16 -0
- package/src/commands/dashboard.js +70 -0
- package/src/commands/doctor.js +9 -1
- package/src/commands/init.js +540 -5
- package/src/commands/migrate.js +348 -0
- package/src/commands/multi.js +549 -0
- package/src/commands/plugin.js +157 -0
- package/src/commands/reject-turn.js +204 -0
- package/src/commands/resume.js +389 -0
- package/src/commands/status.js +196 -3
- package/src/commands/step.js +947 -0
- package/src/commands/stop.js +65 -33
- package/src/commands/template-list.js +33 -0
- package/src/commands/template-set.js +279 -0
- package/src/commands/update.js +24 -3
- package/src/commands/validate.js +20 -11
- package/src/commands/verify.js +71 -0
- package/src/commands/watch.js +112 -25
- package/src/lib/adapters/api-proxy-adapter.js +1076 -0
- package/src/lib/adapters/local-cli-adapter.js +337 -0
- package/src/lib/adapters/manual-adapter.js +169 -0
- package/src/lib/blocked-state.js +94 -0
- package/src/lib/config.js +143 -12
- package/src/lib/context-compressor.js +121 -0
- package/src/lib/context-section-parser.js +220 -0
- package/src/lib/coordinator-acceptance.js +428 -0
- package/src/lib/coordinator-config.js +461 -0
- package/src/lib/coordinator-dispatch.js +276 -0
- package/src/lib/coordinator-gates.js +487 -0
- package/src/lib/coordinator-hooks.js +239 -0
- package/src/lib/coordinator-recovery.js +523 -0
- package/src/lib/coordinator-state.js +365 -0
- package/src/lib/cross-repo-context.js +247 -0
- package/src/lib/dashboard/bridge-server.js +284 -0
- package/src/lib/dashboard/file-watcher.js +93 -0
- package/src/lib/dashboard/state-reader.js +96 -0
- package/src/lib/dispatch-bundle.js +568 -0
- package/src/lib/dispatch-manifest.js +252 -0
- package/src/lib/filter-agents.js +12 -0
- package/src/lib/gate-evaluator.js +285 -0
- package/src/lib/generate-vscode.js +158 -68
- package/src/lib/governed-state.js +2139 -0
- package/src/lib/governed-templates.js +145 -0
- package/src/lib/hook-runner.js +788 -0
- package/src/lib/next-owner.js +61 -6
- package/src/lib/normalized-config.js +539 -0
- package/src/lib/notify.js +14 -12
- package/src/lib/plugin-config-schema.js +192 -0
- package/src/lib/plugins.js +692 -0
- package/src/lib/prompt-core.js +108 -0
- package/src/lib/protocol-conformance.js +291 -0
- package/src/lib/reference-conformance-adapter.js +717 -0
- package/src/lib/repo-observer.js +597 -0
- package/src/lib/repo.js +0 -31
- package/src/lib/safe-write.js +44 -0
- package/src/lib/schema.js +189 -0
- package/src/lib/schemas/turn-result.schema.json +205 -0
- package/src/lib/seed-prompt-polling.js +15 -73
- package/src/lib/seed-prompt.js +17 -63
- package/src/lib/token-budget.js +206 -0
- package/src/lib/token-counter.js +27 -0
- package/src/lib/turn-paths.js +67 -0
- package/src/lib/turn-result-validator.js +496 -0
- package/src/lib/validation.js +167 -19
- package/src/lib/verify-command.js +72 -0
- package/src/templates/governed/api-service.json +31 -0
- package/src/templates/governed/cli-tool.json +30 -0
- package/src/templates/governed/generic.json +10 -0
- 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 };
|