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,337 @@
1
+ /**
2
+ * Local CLI adapter — subprocess-based agent execution.
3
+ *
4
+ * Per the frozen spec (§20, §37, §38, Session #17):
5
+ * - dispatch: spawns a subprocess with the seed prompt
6
+ * - wait: watches for the staged turn-result.json (file-based, not output-scraping)
7
+ * - collect: reads the staged file (validation happens at the orchestrator level)
8
+ *
9
+ * Prompt transport (Session #17 freeze):
10
+ * - argv: command/args contain {prompt}; adapter substitutes before spawn
11
+ * - stdin: adapter writes prompt to subprocess stdin
12
+ * - dispatch_bundle_only: adapter does NOT deliver prompt; operator reads from disk
13
+ *
14
+ * Key design rules:
15
+ * - Subprocess success !== turn success. Only a validated staged result is turn success.
16
+ * - The adapter does NOT write state, decide transitions, or bypass validation.
17
+ * - Uses the same staging contract as manual mode (.agentxchain/staging/turn-result.json).
18
+ */
19
+
20
+ import { spawn } from 'child_process';
21
+ import { existsSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'fs';
22
+ import { join } from 'path';
23
+ import {
24
+ getDispatchContextPath,
25
+ getDispatchLogPath,
26
+ getDispatchPromptPath,
27
+ getDispatchTurnDir,
28
+ getTurnStagingDir,
29
+ getTurnStagingResultPath,
30
+ } from '../turn-paths.js';
31
+ import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
32
+
33
+ /**
34
+ * Launch a local CLI subprocess for a governed turn.
35
+ *
36
+ * Reads the rendered PROMPT.md + CONTEXT.md from the dispatch bundle and
37
+ * passes them as the prompt to the configured CLI command.
38
+ *
39
+ * @param {string} root - project root directory
40
+ * @param {object} state - current governed state (must have current_turn)
41
+ * @param {object} config - normalized config
42
+ * @param {object} [options]
43
+ * @param {AbortSignal} [options.signal] - abort signal for cancellation
44
+ * @param {function} [options.onStdout] - callback for stdout lines (for logging)
45
+ * @param {function} [options.onStderr] - callback for stderr lines (for logging)
46
+ * @param {boolean} [options.verifyManifest] - require MANIFEST.json and verify before execution
47
+ * @param {boolean} [options.skipManifestVerification] - explicit escape hatch; skip verification even if a manifest exists
48
+ * @returns {Promise<{ ok: boolean, exitCode?: number, timedOut?: boolean, aborted?: boolean, error?: string, logs?: string[] }>}
49
+ */
50
+ export async function dispatchLocalCli(root, state, config, options = {}) {
51
+ const { signal, onStdout, onStderr, turnId } = options;
52
+
53
+ const turn = resolveTargetTurn(state, turnId);
54
+ if (!turn) {
55
+ return { ok: false, error: 'No active turn in state' };
56
+ }
57
+
58
+ // Default policy verifies finalized bundles automatically; step.js still
59
+ // passes verifyManifest: true to require a manifest on governed dispatch.
60
+ const manifestCheck = verifyDispatchManifestForAdapter(root, turn.turn_id, options);
61
+ if (!manifestCheck.ok) {
62
+ return { ok: false, error: `Dispatch manifest verification failed: ${manifestCheck.error}` };
63
+ }
64
+
65
+ const runtimeId = turn.runtime_id;
66
+ const runtime = config.runtimes?.[runtimeId];
67
+ if (!runtime) {
68
+ return { ok: false, error: `Runtime "${runtimeId}" not found in config` };
69
+ }
70
+
71
+ // Read the dispatch bundle prompt
72
+ const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
73
+ const contextPath = join(root, getDispatchContextPath(turn.turn_id));
74
+
75
+ if (!existsSync(promptPath)) {
76
+ return { ok: false, error: 'Dispatch bundle not found. Run writeDispatchBundle() first.' };
77
+ }
78
+
79
+ const prompt = readFileSync(promptPath, 'utf8');
80
+ const context = existsSync(contextPath) ? readFileSync(contextPath, 'utf8') : '';
81
+ const fullPrompt = prompt + '\n---\n\n' + context;
82
+
83
+ // Resolve command, args, and prompt transport from runtime config
84
+ const { command, args, transport } = resolveCommand(runtime, fullPrompt);
85
+ if (!command) {
86
+ return { ok: false, error: `Cannot resolve CLI command for runtime "${runtimeId}". Expected "command" field in runtime config.` };
87
+ }
88
+
89
+ // Compute timeout from deadline or default (20 minutes)
90
+ const timeoutMs = turn.deadline_at
91
+ ? Math.max(0, new Date(turn.deadline_at).getTime() - Date.now())
92
+ : 1200000;
93
+
94
+ // Ensure staging directory exists and clear any previous result
95
+ const stagingDir = join(root, getTurnStagingDir(turn.turn_id));
96
+ mkdirSync(stagingDir, { recursive: true });
97
+ const stagingFile = join(root, getTurnStagingResultPath(turn.turn_id));
98
+ try {
99
+ if (existsSync(stagingFile)) {
100
+ writeFileSync(stagingFile, '{}');
101
+ }
102
+ } catch {}
103
+
104
+ // Capture logs for dispatch record
105
+ const logs = [];
106
+
107
+ return new Promise((resolve) => {
108
+ if (signal?.aborted) {
109
+ resolve({ ok: false, aborted: true, logs });
110
+ return;
111
+ }
112
+
113
+ let child;
114
+ try {
115
+ child = spawn(command, args, {
116
+ cwd: runtime.cwd ? join(root, runtime.cwd) : root,
117
+ stdio: ['pipe', 'pipe', 'pipe'],
118
+ env: { ...process.env },
119
+ });
120
+ } catch (err) {
121
+ resolve({ ok: false, error: `Failed to spawn "${command}": ${err.message}`, logs });
122
+ return;
123
+ }
124
+
125
+ let settled = false;
126
+ const settle = (result) => {
127
+ if (settled) return;
128
+ settled = true;
129
+ resolve(result);
130
+ };
131
+
132
+ // Deliver prompt via stdin if transport is "stdin"; otherwise close immediately
133
+ if (child.stdin) {
134
+ try {
135
+ if (transport === 'stdin') {
136
+ child.stdin.write(fullPrompt);
137
+ }
138
+ child.stdin.end();
139
+ } catch {}
140
+ }
141
+
142
+ // Collect stdout/stderr
143
+ if (child.stdout) {
144
+ child.stdout.on('data', (chunk) => {
145
+ const text = chunk.toString();
146
+ logs.push(text);
147
+ if (onStdout) onStdout(text);
148
+ });
149
+ }
150
+
151
+ if (child.stderr) {
152
+ child.stderr.on('data', (chunk) => {
153
+ const text = chunk.toString();
154
+ logs.push('[stderr] ' + text);
155
+ if (onStderr) onStderr(text);
156
+ });
157
+ }
158
+
159
+ // Timeout handling per §20.4
160
+ let timeoutHandle;
161
+ let sigkillHandle;
162
+
163
+ if (timeoutMs > 0 && timeoutMs < Infinity) {
164
+ timeoutHandle = setTimeout(() => {
165
+ logs.push(`[adapter] Turn timed out after ${Math.round(timeoutMs / 1000)}s. Sending SIGTERM.`);
166
+ try {
167
+ child.kill('SIGTERM');
168
+ } catch {}
169
+
170
+ // Grace period: SIGKILL after 10 seconds
171
+ sigkillHandle = setTimeout(() => {
172
+ logs.push('[adapter] Grace period expired. Sending SIGKILL.');
173
+ try {
174
+ child.kill('SIGKILL');
175
+ } catch {}
176
+ }, 10000);
177
+ }, timeoutMs);
178
+ }
179
+
180
+ // Abort signal handling
181
+ const onAbort = () => {
182
+ logs.push('[adapter] Abort signal received. Sending SIGTERM.');
183
+ clearTimeout(timeoutHandle);
184
+ clearTimeout(sigkillHandle);
185
+ try {
186
+ child.kill('SIGTERM');
187
+ } catch {}
188
+ // Give it 5 seconds to exit gracefully
189
+ setTimeout(() => {
190
+ try { child.kill('SIGKILL'); } catch {}
191
+ }, 5000);
192
+ };
193
+
194
+ if (signal) {
195
+ signal.addEventListener('abort', onAbort, { once: true });
196
+ }
197
+
198
+ // Process exit
199
+ child.on('close', (exitCode, killSignal) => {
200
+ clearTimeout(timeoutHandle);
201
+ clearTimeout(sigkillHandle);
202
+ if (signal) signal.removeEventListener('abort', onAbort);
203
+
204
+ if (signal?.aborted) {
205
+ settle({ ok: false, aborted: true, exitCode, logs });
206
+ return;
207
+ }
208
+
209
+ const timedOut = killSignal === 'SIGTERM' || killSignal === 'SIGKILL';
210
+
211
+ // Check if staged result was written (regardless of exit code)
212
+ const hasResult = isStagedResultReady(join(root, getTurnStagingResultPath(turn.turn_id)));
213
+
214
+ if (hasResult) {
215
+ settle({ ok: true, exitCode, timedOut: false, aborted: false, logs });
216
+ } else if (timedOut) {
217
+ settle({ ok: false, exitCode, timedOut: true, aborted: false, error: 'Turn timed out without producing a staged result.', logs });
218
+ } else {
219
+ settle({
220
+ ok: false,
221
+ exitCode,
222
+ timedOut: false,
223
+ aborted: false,
224
+ error: `Subprocess exited (code ${exitCode}) without writing a staged turn result to ${getTurnStagingResultPath(turn.turn_id)}.`,
225
+ logs,
226
+ });
227
+ }
228
+ });
229
+
230
+ child.on('error', (err) => {
231
+ clearTimeout(timeoutHandle);
232
+ clearTimeout(sigkillHandle);
233
+ if (signal) signal.removeEventListener('abort', onAbort);
234
+ settle({ ok: false, error: `Subprocess error: ${err.message}`, logs });
235
+ });
236
+ });
237
+ }
238
+
239
+ /**
240
+ * Save dispatch logs to the dispatch directory for auditability.
241
+ *
242
+ * @param {string} root - project root
243
+ * @param {string} turnId - target turn id
244
+ * @param {string[]} logs - collected log lines
245
+ */
246
+ export function saveDispatchLogs(root, turnId, logs) {
247
+ const logDir = join(root, getDispatchTurnDir(turnId));
248
+ if (!existsSync(logDir)) return;
249
+
250
+ try {
251
+ writeFileSync(join(root, getDispatchLogPath(turnId)), logs.join(''));
252
+ } catch {}
253
+ }
254
+
255
+ // ── Internal ──────────────────────────────────────────────────────────────────
256
+
257
+ /**
258
+ * Resolve the CLI command and arguments from runtime config.
259
+ *
260
+ * Supports two config shapes:
261
+ * 1. { command: ["claude", "--print", "-p", "{prompt}"] }
262
+ * → command = "claude", args = ["--print", "-p", "<rendered prompt>"]
263
+ *
264
+ * 2. { command: "claude", args: ["--print"] }
265
+ * → command = "claude", args = ["--print"]
266
+ *
267
+ * Prompt injection depends on prompt_transport (Session #17):
268
+ * - "argv" (default when {prompt} present): {prompt} placeholders replaced with prompt text
269
+ * - "stdin": prompt delivered via stdin, no argv substitution
270
+ * - "dispatch_bundle_only": no prompt delivery; subprocess reads from disk
271
+ *
272
+ * Returns { command, args, transport } where transport is the resolved prompt_transport.
273
+ */
274
+ function resolveCommand(runtime, fullPrompt) {
275
+ if (!runtime.command) return { command: null, args: [], transport: null };
276
+
277
+ const transport = resolvePromptTransport(runtime);
278
+
279
+ // Shape 1: command is an array
280
+ if (Array.isArray(runtime.command)) {
281
+ const [cmd, ...rest] = runtime.command;
282
+ const args = transport === 'argv'
283
+ ? rest.map(arg => arg === '{prompt}' ? fullPrompt : arg)
284
+ : rest.filter(arg => arg !== '{prompt}');
285
+ return { command: cmd, args, transport };
286
+ }
287
+
288
+ // Shape 2: command is a string, args is separate
289
+ const cmd = runtime.command;
290
+ const baseArgs = runtime.args || [];
291
+ const args = transport === 'argv'
292
+ ? baseArgs.map(a => a === '{prompt}' ? fullPrompt : a)
293
+ : baseArgs.filter(a => a !== '{prompt}');
294
+ return { command: cmd, args, transport };
295
+ }
296
+
297
+ /**
298
+ * Resolve the effective prompt transport for a local_cli runtime.
299
+ *
300
+ * Explicit prompt_transport field takes precedence.
301
+ * Fallback: if command/args contain {prompt} → "argv"; otherwise → "dispatch_bundle_only".
302
+ */
303
+ function resolvePromptTransport(runtime) {
304
+ const VALID_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
305
+ if (runtime.prompt_transport && VALID_TRANSPORTS.includes(runtime.prompt_transport)) {
306
+ return runtime.prompt_transport;
307
+ }
308
+
309
+ // Infer from command shape (backwards compat)
310
+ const commandParts = Array.isArray(runtime.command)
311
+ ? runtime.command
312
+ : [runtime.command, ...(runtime.args || [])];
313
+ const hasPlaceholder = commandParts.some(p => typeof p === 'string' && p.includes('{prompt}'));
314
+ return hasPlaceholder ? 'argv' : 'dispatch_bundle_only';
315
+ }
316
+
317
+ /**
318
+ * Check if the staged result file exists and has meaningful content.
319
+ */
320
+ function isStagedResultReady(filePath) {
321
+ try {
322
+ if (!existsSync(filePath)) return false;
323
+ const stat = statSync(filePath);
324
+ return stat.size > 2; // Must be more than just "{}" or empty
325
+ } catch {
326
+ return false;
327
+ }
328
+ }
329
+
330
+ function resolveTargetTurn(state, turnId) {
331
+ if (turnId && state?.active_turns?.[turnId]) {
332
+ return state.active_turns[turnId];
333
+ }
334
+ return state?.current_turn || Object.values(state?.active_turns || {})[0];
335
+ }
336
+
337
+ export { resolvePromptTransport };
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Manual adapter — the honest fallback.
3
+ *
4
+ * Per the frozen spec (§19, §8.2), the manual adapter:
5
+ * - dispatch: writes a prompt package and prints operator instructions
6
+ * - wait: watches for the staged turn-result.json to appear (fs polling)
7
+ * - collect: reads the staged file (validation happens at the orchestrator level)
8
+ *
9
+ * This adapter is intentionally simple. It does not parse TALK.md, does not
10
+ * auto-route, and does not pretend to be an orchestrator.
11
+ */
12
+
13
+ import { existsSync, readFileSync, statSync } from 'fs';
14
+ import { join } from 'path';
15
+ import {
16
+ getDispatchPromptPath,
17
+ getTurnStagingResultPath,
18
+ } from '../turn-paths.js';
19
+
20
+ /**
21
+ * Print operator instructions for a manual turn.
22
+ *
23
+ * @param {object} state - current governed state (must have current_turn)
24
+ * @param {object} config - normalized config
25
+ * @param {object} [options]
26
+ * @param {string} [options.turnId]
27
+ */
28
+ export function printManualDispatchInstructions(state, config, options = {}) {
29
+ const turn = resolveTargetTurn(state, options.turnId);
30
+ const role = config.roles?.[turn.assigned_role];
31
+ const promptPath = getDispatchPromptPath(turn.turn_id);
32
+ const stagingPath = getTurnStagingResultPath(turn.turn_id);
33
+
34
+ const lines = [];
35
+ lines.push('');
36
+ lines.push(' +---------------------------------------------------------+');
37
+ lines.push(' | MANUAL TURN REQUIRED |');
38
+ lines.push(' | |');
39
+ lines.push(` | Role: ${pad(turn.assigned_role, 46)}|`);
40
+ lines.push(` | Turn: ${pad(turn.turn_id, 46)}|`);
41
+ lines.push(` | Phase: ${pad(state.phase, 46)}|`);
42
+ lines.push(` | Attempt: ${pad(String(turn.attempt), 46)}|`);
43
+ lines.push(' | |');
44
+ lines.push(` | Prompt: ${pad(promptPath, 46)}|`);
45
+ lines.push(` | Result: ${pad(stagingPath, 46)}|`);
46
+ lines.push(' | |');
47
+ lines.push(' | 1. Read the prompt at the path above |');
48
+ lines.push(' | 2. Complete the work described in the prompt |');
49
+ lines.push(' | 3. Write your turn result JSON to the result path |');
50
+ lines.push(' | |');
51
+ lines.push(' | The step command will detect the file and proceed. |');
52
+ lines.push(' +---------------------------------------------------------+');
53
+ lines.push('');
54
+
55
+ return lines.join('\n');
56
+ }
57
+
58
+ /**
59
+ * Wait for the staged turn result file to appear.
60
+ *
61
+ * Uses polling with a configurable interval. Returns when the file exists
62
+ * and is non-empty, or when the abort signal fires.
63
+ *
64
+ * @param {string} root - project root directory
65
+ * @param {object} [options]
66
+ * @param {number} [options.pollIntervalMs=2000] - polling interval in ms
67
+ * @param {number} [options.timeoutMs=1200000] - max wait time (default: 20 min)
68
+ * @param {AbortSignal} [options.signal] - abort signal to cancel waiting
69
+ * @param {string} [options.turnId] - targeted turn id
70
+ * @returns {Promise<{ found: boolean, timedOut: boolean, aborted: boolean }>}
71
+ */
72
+ export async function waitForStagedResult(root, options = {}) {
73
+ const {
74
+ pollIntervalMs = 2000,
75
+ timeoutMs = 1200000,
76
+ signal,
77
+ turnId,
78
+ } = options;
79
+
80
+ const stagingFile = join(root, getTurnStagingResultPath(turnId));
81
+ const startTime = Date.now();
82
+
83
+ return new Promise((resolve) => {
84
+ // Check for abort signal
85
+ if (signal?.aborted) {
86
+ resolve({ found: false, timedOut: false, aborted: true });
87
+ return;
88
+ }
89
+
90
+ const onAbort = () => {
91
+ clearInterval(timer);
92
+ resolve({ found: false, timedOut: false, aborted: true });
93
+ };
94
+
95
+ if (signal) {
96
+ signal.addEventListener('abort', onAbort, { once: true });
97
+ }
98
+
99
+ // Initial check
100
+ if (isStagedResultReady(stagingFile)) {
101
+ signal?.removeEventListener('abort', onAbort);
102
+ resolve({ found: true, timedOut: false, aborted: false });
103
+ return;
104
+ }
105
+
106
+ const timer = setInterval(() => {
107
+ // Check timeout
108
+ if (Date.now() - startTime >= timeoutMs) {
109
+ clearInterval(timer);
110
+ signal?.removeEventListener('abort', onAbort);
111
+ resolve({ found: false, timedOut: true, aborted: false });
112
+ return;
113
+ }
114
+
115
+ // Check for file
116
+ if (isStagedResultReady(stagingFile)) {
117
+ clearInterval(timer);
118
+ signal?.removeEventListener('abort', onAbort);
119
+ resolve({ found: true, timedOut: false, aborted: false });
120
+ }
121
+ }, pollIntervalMs);
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Check if the staged result file exists and is non-empty.
127
+ */
128
+ function isStagedResultReady(filePath) {
129
+ try {
130
+ if (!existsSync(filePath)) return false;
131
+ const stat = statSync(filePath);
132
+ return stat.size > 2; // Must be more than just "{}" or empty
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Read the staged result file without validation.
140
+ *
141
+ * @param {string} root - project root directory
142
+ * @param {object} [options]
143
+ * @param {string} [options.turnId]
144
+ * @returns {{ ok: boolean, raw?: string, parsed?: object, error?: string }}
145
+ */
146
+ export function readStagedResult(root, options = {}) {
147
+ const stagingFile = join(root, getTurnStagingResultPath(options.turnId));
148
+ try {
149
+ const raw = readFileSync(stagingFile, 'utf8');
150
+ const parsed = JSON.parse(raw);
151
+ return { ok: true, raw, parsed };
152
+ } catch (err) {
153
+ return { ok: false, error: err.message };
154
+ }
155
+ }
156
+
157
+ // ── Helpers ─────────────────────────────────────────────────────────────────
158
+
159
+ function pad(str, width) {
160
+ if (str.length >= width) return '...' + str.slice(-(width - 3));
161
+ return str + ' '.repeat(width - str.length);
162
+ }
163
+
164
+ function resolveTargetTurn(state, turnId) {
165
+ if (turnId && state?.active_turns?.[turnId]) {
166
+ return state.active_turns[turnId];
167
+ }
168
+ return state?.current_turn || Object.values(state?.active_turns || {})[0];
169
+ }
@@ -0,0 +1,94 @@
1
+ import { getActiveTurnCount } from './governed-state.js';
2
+
3
+ export function deriveRecoveryDescriptor(state) {
4
+ if (!state || typeof state !== 'object') {
5
+ return null;
6
+ }
7
+
8
+ if (state.pending_run_completion) {
9
+ return {
10
+ typed_reason: 'pending_run_completion',
11
+ owner: 'human',
12
+ recovery_action: 'agentxchain approve-completion',
13
+ turn_retained: false,
14
+ detail: state.pending_run_completion.gate || null,
15
+ };
16
+ }
17
+
18
+ if (state.pending_phase_transition) {
19
+ return {
20
+ typed_reason: 'pending_phase_transition',
21
+ owner: 'human',
22
+ recovery_action: 'agentxchain approve-transition',
23
+ turn_retained: false,
24
+ detail: state.pending_phase_transition.gate || null,
25
+ };
26
+ }
27
+
28
+ const turnRetained = getActiveTurnCount(state) > 0;
29
+
30
+ const persistedRecovery = state.blocked_reason?.recovery;
31
+ if (persistedRecovery && typeof persistedRecovery === 'object') {
32
+ return {
33
+ typed_reason: persistedRecovery.typed_reason || 'unknown_block',
34
+ owner: persistedRecovery.owner || 'human',
35
+ recovery_action: persistedRecovery.recovery_action || 'Inspect state.json and resolve manually before rerunning agentxchain step',
36
+ turn_retained: typeof persistedRecovery.turn_retained === 'boolean'
37
+ ? persistedRecovery.turn_retained
38
+ : turnRetained,
39
+ detail: persistedRecovery.detail ?? state.blocked_on ?? null,
40
+ };
41
+ }
42
+
43
+ if (typeof state.blocked_on !== 'string' || !state.blocked_on.trim()) {
44
+ return null;
45
+ }
46
+
47
+ if (state.blocked_on.startsWith('human:')) {
48
+ return {
49
+ typed_reason: 'needs_human',
50
+ owner: 'human',
51
+ recovery_action: 'Resolve the stated issue, then run agentxchain step --resume',
52
+ turn_retained: turnRetained,
53
+ detail: state.blocked_on.slice('human:'.length) || null,
54
+ };
55
+ }
56
+
57
+ if (state.blocked_on.startsWith('human_approval:')) {
58
+ return {
59
+ typed_reason: 'pending_phase_transition',
60
+ owner: 'human',
61
+ recovery_action: 'agentxchain approve-transition',
62
+ turn_retained: false,
63
+ detail: state.blocked_on.slice('human_approval:'.length) || null,
64
+ };
65
+ }
66
+
67
+ if (state.blocked_on.startsWith('escalation:')) {
68
+ return {
69
+ typed_reason: 'retries_exhausted',
70
+ owner: 'human',
71
+ recovery_action: 'Resolve the escalation, then run agentxchain step --resume',
72
+ turn_retained: turnRetained,
73
+ detail: state.blocked_on,
74
+ };
75
+ }
76
+
77
+ if (state.blocked_on.startsWith('dispatch:')) {
78
+ return {
79
+ typed_reason: 'dispatch_error',
80
+ owner: 'human',
81
+ recovery_action: 'Resolve the dispatch issue, then run agentxchain step --resume',
82
+ turn_retained: turnRetained,
83
+ detail: state.blocked_on.slice('dispatch:'.length) || state.blocked_on,
84
+ };
85
+ }
86
+
87
+ return {
88
+ typed_reason: 'unknown_block',
89
+ owner: 'human',
90
+ recovery_action: 'Inspect state.json and resolve manually before rerunning agentxchain step',
91
+ turn_retained: turnRetained,
92
+ detail: state.blocked_on,
93
+ };
94
+ }