agentxchain 2.28.0 → 2.30.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.28.0",
3
+ "version": "2.30.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -62,7 +62,6 @@
62
62
  "chalk": "^5.4.0",
63
63
  "commander": "^13.0.0",
64
64
  "inquirer": "^12.0.0",
65
- "ora": "^8.0.0",
66
65
  "zod": "^4.3.6"
67
66
  },
68
67
  "engines": {
@@ -2,7 +2,7 @@
2
2
  * agentxchain run — drive a governed run to completion.
3
3
  *
4
4
  * Thin CLI surface over the runLoop library. Wires runLoop callbacks to:
5
- * - Existing adapter system (api_proxy, local_cli, mcp)
5
+ * - Existing adapter system (api_proxy, local_cli, mcp, remote_agent)
6
6
  * - Interactive gate prompting (stdin) or auto-approve mode
7
7
  * - Terminal output via chalk
8
8
  *
@@ -27,6 +27,7 @@ import {
27
27
  resolvePromptTransport,
28
28
  } from '../lib/adapters/local-cli-adapter.js';
29
29
  import { dispatchMcp, resolveMcpTransport, describeMcpRuntimeTarget } from '../lib/adapters/mcp-adapter.js';
30
+ import { dispatchRemoteAgent, describeRemoteAgentTarget } from '../lib/adapters/remote-agent-adapter.js';
30
31
  import { runHooks } from '../lib/hook-runner.js';
31
32
  import { finalizeDispatchManifest } from '../lib/dispatch-manifest.js';
32
33
  import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
@@ -200,6 +201,9 @@ export async function runCommand(opts) {
200
201
  const transport = runtime ? resolvePromptTransport(runtime) : 'dispatch_bundle_only';
201
202
  console.log(chalk.dim(` Dispatching to local CLI: ${runtime?.command || '(default)'} transport: ${transport}`));
202
203
  adapterResult = await dispatchLocalCli(projectRoot, state, cfg, adapterOpts);
204
+ } else if (runtimeType === 'remote_agent') {
205
+ console.log(chalk.dim(` Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
206
+ adapterResult = await dispatchRemoteAgent(projectRoot, state, cfg, adapterOpts);
203
207
  } else {
204
208
  return { accept: false, reason: `unknown runtime type "${runtimeType}"` };
205
209
  }
@@ -18,6 +18,7 @@
18
18
  * - api_proxy: implemented for synchronous review-only turns and stages
19
19
  * provider-backed JSON before validation/acceptance
20
20
  * - mcp: implemented for synchronous MCP stdio or streamable_http tool dispatch
21
+ * - remote_agent: implemented for synchronous HTTP dispatch to external agent services
21
22
  */
22
23
 
23
24
  import chalk from 'chalk';
@@ -49,6 +50,7 @@ import {
49
50
  resolvePromptTransport,
50
51
  } from '../lib/adapters/local-cli-adapter.js';
51
52
  import { describeMcpRuntimeTarget, dispatchMcp, resolveMcpTransport } from '../lib/adapters/mcp-adapter.js';
53
+ import { dispatchRemoteAgent, describeRemoteAgentTarget } from '../lib/adapters/remote-agent-adapter.js';
52
54
  import {
53
55
  getDispatchAssignmentPath,
54
56
  getDispatchContextPath,
@@ -504,11 +506,63 @@ export async function stepCommand(opts) {
504
506
 
505
507
  console.log(chalk.green(`MCP tool completed${mcpResult.toolName ? ` (${mcpResult.toolName})` : ''}. Staged result detected.`));
506
508
  console.log('');
509
+ } else if (runtimeType === 'remote_agent') {
510
+ console.log(chalk.cyan(`Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
511
+ console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase}`));
512
+
513
+ const remoteResult = await dispatchRemoteAgent(root, state, config, {
514
+ signal: controller.signal,
515
+ onStatus: (msg) => console.log(chalk.dim(` ${msg}`)),
516
+ verifyManifest: true,
517
+ });
518
+
519
+ if (remoteResult.logs?.length) {
520
+ saveDispatchLogs(root, turn.turn_id, remoteResult.logs);
521
+ }
522
+
523
+ if (remoteResult.aborted) {
524
+ console.log('');
525
+ console.log(chalk.yellow('Aborted. Turn remains assigned.'));
526
+ console.log(chalk.dim('Resume later with: agentxchain step --resume'));
527
+ console.log(chalk.dim('Or accept/reject manually: agentxchain accept-turn / reject-turn'));
528
+ process.exit(0);
529
+ }
530
+
531
+ if (!remoteResult.ok) {
532
+ const blocked = markRunBlocked(root, {
533
+ blockedOn: `dispatch:remote_agent_failure`,
534
+ category: 'dispatch_error',
535
+ recovery: {
536
+ typed_reason: 'dispatch_error',
537
+ owner: 'human',
538
+ recovery_action: 'Resolve the remote agent dispatch issue, then run agentxchain step --resume',
539
+ turn_retained: true,
540
+ detail: remoteResult.error,
541
+ },
542
+ turnId: turn.turn_id,
543
+ hooksConfig,
544
+ notificationConfig: config,
545
+ });
546
+ if (blocked.ok) {
547
+ state = blocked.state;
548
+ }
549
+
550
+ console.log('');
551
+ console.log(chalk.red(`Remote agent dispatch failed: ${remoteResult.error}`));
552
+ console.log(chalk.dim('The turn remains assigned. You can:'));
553
+ console.log(chalk.dim(' - Fix the issue and retry: agentxchain step --resume'));
554
+ console.log(chalk.dim(' - Complete manually: edit .agentxchain/staging/turn-result.json'));
555
+ console.log(chalk.dim(' - Reject: agentxchain reject-turn --reason "remote agent failed"'));
556
+ process.exit(1);
557
+ }
558
+
559
+ console.log(chalk.green('Remote agent completed. Staged result detected.'));
560
+ console.log('');
507
561
  }
508
562
 
509
563
  // ── Phase 3: Wait for turn completion ─────────────────────────────────────
510
564
 
511
- if (runtimeType === 'api_proxy' || runtimeType === 'mcp') {
565
+ if (runtimeType === 'api_proxy' || runtimeType === 'mcp' || runtimeType === 'remote_agent') {
512
566
  // api_proxy and mcp are synchronous — result already staged in Phase 2
513
567
  } else if (runtimeType === 'local_cli') {
514
568
  // ── Local CLI adapter: spawn subprocess ──
@@ -762,6 +816,7 @@ export async function stepCommand(opts) {
762
816
  console.log(chalk.dim(' - Fix the staged result and run: agentxchain accept-turn'));
763
817
  console.log(chalk.dim(' - Reject and retry: agentxchain reject-turn'));
764
818
  console.log(chalk.dim(' - Auto-reject on failure: agentxchain step --auto-reject'));
819
+ process.exit(1);
765
820
  }
766
821
  }
767
822
  }
@@ -28,4 +28,10 @@ export {
28
28
  describeMcpRuntimeTarget,
29
29
  } from './adapters/mcp-adapter.js';
30
30
 
31
+ export {
32
+ dispatchRemoteAgent,
33
+ describeRemoteAgentTarget,
34
+ DEFAULT_REMOTE_AGENT_TIMEOUT_MS,
35
+ } from './adapters/remote-agent-adapter.js';
36
+
31
37
  export const ADAPTER_INTERFACE_VERSION = '0.1';
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Remote Agent adapter — HTTP-based governed turn dispatch.
3
+ *
4
+ * Specification: .planning/REMOTE_AGENT_BRIDGE_CONNECTOR_SPEC.md
5
+ *
6
+ * This adapter proves connector replaceability (VISION.md Layer 3) by executing
7
+ * governed turns over HTTP against an external agent service. The protocol's
8
+ * staging, validation, and acceptance flow is preserved exactly — the remote
9
+ * service receives a turn envelope and must return a valid turn-result JSON.
10
+ *
11
+ * v1 scope: synchronous request/response only. No polling, no webhooks.
12
+ *
13
+ * Security: authorization headers are sent to the remote service but are
14
+ * never echoed into dispatch logs or governance artifacts.
15
+ */
16
+
17
+ import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs';
18
+ import { join } from 'path';
19
+ import {
20
+ getDispatchContextPath,
21
+ getDispatchPromptPath,
22
+ getDispatchTurnDir,
23
+ getTurnStagingDir,
24
+ getTurnStagingResultPath,
25
+ } from '../turn-paths.js';
26
+ import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
27
+
28
+ /** Default timeout for remote agent requests (ms). */
29
+ export const DEFAULT_REMOTE_AGENT_TIMEOUT_MS = 120_000;
30
+
31
+ /** Header keys that must never appear in logs or artifacts. */
32
+ const REDACTED_HEADER_PATTERNS = [/^authorization$/i, /^x-api-key$/i, /^cookie$/i, /^proxy-authorization$/i];
33
+
34
+ function isSecretHeader(key) {
35
+ return REDACTED_HEADER_PATTERNS.some(p => p.test(key));
36
+ }
37
+
38
+ /**
39
+ * Build a safe header summary for logs (redacts secret values).
40
+ */
41
+ function safeHeaderSummary(headers) {
42
+ if (!headers || typeof headers !== 'object') return {};
43
+ const safe = {};
44
+ for (const [k, v] of Object.entries(headers)) {
45
+ safe[k] = isSecretHeader(k) ? '[REDACTED]' : v;
46
+ }
47
+ return safe;
48
+ }
49
+
50
+ /**
51
+ * Dispatch a governed turn to a remote agent service.
52
+ *
53
+ * @param {string} root - project root directory
54
+ * @param {object} state - current governed state
55
+ * @param {object} config - normalized config
56
+ * @param {object} [options]
57
+ * @param {AbortSignal} [options.signal] - abort signal
58
+ * @param {function} [options.onStatus] - status callback
59
+ * @param {boolean} [options.verifyManifest] - require dispatch manifest verification
60
+ * @param {boolean} [options.skipManifestVerification] - skip manifest check
61
+ * @param {string} [options.turnId] - override turn ID
62
+ * @returns {Promise<{ ok: boolean, error?: string, logs?: string[], aborted?: boolean, timedOut?: boolean }>}
63
+ */
64
+ export async function dispatchRemoteAgent(root, state, config, options = {}) {
65
+ const { signal, onStatus, turnId } = options;
66
+ const logs = [];
67
+
68
+ const turn = resolveTargetTurn(state, turnId);
69
+ if (!turn) {
70
+ return { ok: false, error: 'No active turn in state', logs };
71
+ }
72
+
73
+ const manifestCheck = verifyDispatchManifestForAdapter(root, turn.turn_id, options);
74
+ if (!manifestCheck.ok) {
75
+ return { ok: false, error: `Dispatch manifest verification failed: ${manifestCheck.error}`, logs };
76
+ }
77
+
78
+ const runtimeId = turn.runtime_id;
79
+ const runtime = config.runtimes?.[runtimeId];
80
+ if (!runtime || runtime.type !== 'remote_agent') {
81
+ return { ok: false, error: `Runtime "${runtimeId}" is not a remote_agent runtime`, logs };
82
+ }
83
+
84
+ if (!runtime.url) {
85
+ return { ok: false, error: `Runtime "${runtimeId}" is missing required "url"`, logs };
86
+ }
87
+
88
+ const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
89
+ const contextPath = join(root, getDispatchContextPath(turn.turn_id));
90
+
91
+ if (!existsSync(promptPath)) {
92
+ return { ok: false, error: 'Dispatch bundle not found — PROMPT.md missing', logs };
93
+ }
94
+
95
+ const prompt = readFileSync(promptPath, 'utf8');
96
+ const context = existsSync(contextPath) ? readFileSync(contextPath, 'utf8') : '';
97
+
98
+ // Build request envelope — governed turn metadata + rendered content
99
+ const timeoutMs = runtime.timeout_ms || DEFAULT_REMOTE_AGENT_TIMEOUT_MS;
100
+ const requestBody = {
101
+ run_id: state.run_id,
102
+ turn_id: turn.turn_id,
103
+ role: turn.assigned_role,
104
+ phase: state.phase,
105
+ runtime_id: runtimeId,
106
+ dispatch_dir: join(root, getDispatchTurnDir(turn.turn_id)),
107
+ prompt,
108
+ context,
109
+ };
110
+
111
+ // Ensure staging directory exists
112
+ const stagingDir = join(root, getTurnStagingDir(turn.turn_id));
113
+ mkdirSync(stagingDir, { recursive: true });
114
+
115
+ // Log transport metadata (safe — no secrets)
116
+ logs.push(`[remote] POST ${runtime.url}`);
117
+ logs.push(`[remote] timeout_ms=${timeoutMs}`);
118
+ const safeHeaders = safeHeaderSummary(runtime.headers);
119
+ if (Object.keys(safeHeaders).length > 0) {
120
+ logs.push(`[remote] headers=${JSON.stringify(safeHeaders)}`);
121
+ }
122
+
123
+ onStatus?.(`Posting to remote agent: ${runtime.url}`);
124
+
125
+ try {
126
+ if (signal?.aborted) {
127
+ return { ok: false, aborted: true, logs };
128
+ }
129
+
130
+ const headers = {
131
+ 'content-type': 'application/json',
132
+ ...(runtime.headers || {}),
133
+ };
134
+
135
+ const controller = new AbortController();
136
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
137
+
138
+ // Chain external abort signal to our controller
139
+ if (signal) {
140
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
141
+ }
142
+
143
+ let response;
144
+ try {
145
+ response = await fetch(runtime.url, {
146
+ method: 'POST',
147
+ headers,
148
+ body: JSON.stringify(requestBody),
149
+ signal: controller.signal,
150
+ });
151
+ } catch (err) {
152
+ clearTimeout(timeout);
153
+ if (signal?.aborted) {
154
+ return { ok: false, aborted: true, logs };
155
+ }
156
+ if (err.name === 'AbortError' || err.code === 'ABORT_ERR') {
157
+ logs.push(`[remote] Request timed out after ${timeoutMs}ms`);
158
+ return { ok: false, timedOut: true, error: `Remote agent timed out after ${timeoutMs}ms`, logs };
159
+ }
160
+ logs.push(`[remote] Network error: ${err.message}`);
161
+ return { ok: false, error: `Remote agent network error: ${err.message}`, logs };
162
+ } finally {
163
+ clearTimeout(timeout);
164
+ }
165
+
166
+ if (signal?.aborted) {
167
+ return { ok: false, aborted: true, logs };
168
+ }
169
+
170
+ logs.push(`[remote] HTTP ${response.status} ${response.statusText}`);
171
+
172
+ if (!response.ok) {
173
+ let body = '';
174
+ try { body = await response.text(); } catch { /* ignore */ }
175
+ if (body) logs.push(`[remote] Body: ${body.slice(0, 500)}`);
176
+ return {
177
+ ok: false,
178
+ error: `Remote agent returned HTTP ${response.status}`,
179
+ logs,
180
+ };
181
+ }
182
+
183
+ // Parse response JSON
184
+ let responseData;
185
+ try {
186
+ responseData = await response.json();
187
+ } catch (err) {
188
+ logs.push(`[remote] JSON parse error: ${err.message}`);
189
+ return {
190
+ ok: false,
191
+ error: 'Remote agent response is not valid JSON',
192
+ logs,
193
+ };
194
+ }
195
+
196
+ // Validate turn result structure (lightweight — full validation happens in the acceptance pipeline)
197
+ if (!looksLikeTurnResult(responseData)) {
198
+ logs.push('[remote] Response missing required turn-result fields (need at least run_id/turn_id + status/role)');
199
+ return {
200
+ ok: false,
201
+ error: 'Remote agent response does not contain a valid turn result',
202
+ logs,
203
+ };
204
+ }
205
+
206
+ // Stage the result
207
+ const stagingPath = join(root, getTurnStagingResultPath(turn.turn_id));
208
+ writeFileSync(stagingPath, JSON.stringify(responseData, null, 2));
209
+
210
+ logs.push(`[remote] Turn result staged at ${getTurnStagingResultPath(turn.turn_id)}`);
211
+ onStatus?.('Remote agent returned valid turn result');
212
+ return { ok: true, logs };
213
+ } catch (error) {
214
+ if (signal?.aborted) {
215
+ return { ok: false, aborted: true, logs };
216
+ }
217
+ logs.push(`[remote] Unexpected error: ${error.message}`);
218
+ return {
219
+ ok: false,
220
+ error: `Remote agent dispatch failed: ${error.message}`,
221
+ logs,
222
+ };
223
+ }
224
+ }
225
+
226
+ // ── Helpers ─────────────────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * Lightweight structural check: does this object look like a turn result?
230
+ * Full validation happens later via validateStagedTurnResult.
231
+ */
232
+ function looksLikeTurnResult(value) {
233
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
234
+ const hasIdentity = 'run_id' in value || 'turn_id' in value;
235
+ const hasLifecycle = 'status' in value || 'role' in value || 'runtime_id' in value;
236
+ return hasIdentity && hasLifecycle;
237
+ }
238
+
239
+ function resolveTargetTurn(state, turnId) {
240
+ if (turnId && state?.active_turns?.[turnId]) {
241
+ return state.active_turns[turnId];
242
+ }
243
+ return state?.current_turn || Object.values(state?.active_turns || {})[0];
244
+ }
245
+
246
+ /**
247
+ * Describe a remote_agent runtime target for display (safe — no secrets).
248
+ */
249
+ export function describeRemoteAgentTarget(runtime) {
250
+ if (!runtime?.url) return '(unknown)';
251
+ try {
252
+ const u = new URL(runtime.url);
253
+ return `${u.protocol}//${u.host}${u.pathname}`;
254
+ } catch {
255
+ return runtime.url;
256
+ }
257
+ }
@@ -10,6 +10,7 @@ const SECTION_DEFINITIONS = [
10
10
  { id: 'last_turn_verification', header: null, required: false },
11
11
  { id: 'blockers', header: 'Blockers', required: true },
12
12
  { id: 'escalation', header: 'Escalation', required: true },
13
+ { id: 'workflow_artifacts', header: 'Workflow Artifacts', required: false },
13
14
  { id: 'gate_required_files', header: 'Gate Required Files', required: false },
14
15
  { id: 'phase_gate_status', header: 'Phase Gate Status', required: false },
15
16
  ];
@@ -88,6 +89,7 @@ export function renderContextSections(sections) {
88
89
 
89
90
  appendTopLevelSection(lines, 'Blockers', [sectionMap.get('blockers')?.content]);
90
91
  appendTopLevelSection(lines, 'Escalation', [sectionMap.get('escalation')?.content]);
92
+ appendTopLevelSection(lines, 'Workflow Artifacts', [sectionMap.get('workflow_artifacts')?.content]);
91
93
  appendTopLevelSection(lines, 'Gate Required Files', [sectionMap.get('gate_required_files')?.content]);
92
94
  appendTopLevelSection(lines, 'Phase Gate Status', [sectionMap.get('phase_gate_status')?.content]);
93
95
 
@@ -210,7 +210,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
210
210
  lines.push('- You may create/modify files under `.planning/` and `.agentxchain/reviews/`.');
211
211
  lines.push('- Your artifact type must be `review`.');
212
212
  lines.push('- You MUST raise at least one objection (even if minor).');
213
- if (runtimeType === 'api_proxy') {
213
+ if (runtimeType === 'api_proxy' || runtimeType === 'remote_agent') {
214
214
  const reviewArtifactPath = getReviewArtifactPath(turn.turn_id, roleId);
215
215
  lines.push('- **This runtime cannot write repo files directly.** Do NOT claim `.planning/*` or `.agentxchain/reviews/*` changes you did not actually make.');
216
216
  lines.push(`- The orchestrator will materialize your accepted review at \`${reviewArtifactPath}\`.`);
@@ -230,7 +230,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
230
230
  lines.push('');
231
231
  lines.push('- You may propose changes as patches but cannot directly commit.');
232
232
  lines.push('- Your artifact type should be `patch`.');
233
- if (runtimeType === 'api_proxy') {
233
+ if (runtimeType === 'api_proxy' || runtimeType === 'remote_agent') {
234
234
  lines.push('- **This runtime cannot write repo files directly.** When doing work, you MUST return proposed changes as structured JSON.');
235
235
  lines.push('- Include a `proposed_changes` array in your turn result with each file change (omit or set to `[]` on completion-only turns):');
236
236
  lines.push(' ```json');
@@ -248,6 +248,31 @@ function renderPrompt(role, roleId, turn, state, config, root) {
248
248
  lines.push('');
249
249
  }
250
250
 
251
+ const workflowResponsibilities = getWorkflowPromptResponsibilities(config, phase, roleId, root);
252
+ if (workflowResponsibilities.length > 0) {
253
+ const isReviewOnlyOwner = role.write_authority === 'review_only';
254
+ lines.push('## Workflow-Kit Responsibilities');
255
+ lines.push('');
256
+ if (isReviewOnlyOwner) {
257
+ lines.push(`You are accountable for reviewing and attesting to these workflow-kit artifacts in phase \`${phase}\`:`);
258
+ } else {
259
+ lines.push(`You are accountable for producing these workflow-kit artifacts in phase \`${phase}\`:`);
260
+ }
261
+ lines.push('');
262
+ for (const artifact of workflowResponsibilities) {
263
+ const requiredLabel = artifact.required ? 'required' : 'optional';
264
+ const semanticsLabel = artifact.semantics ? `\`${artifact.semantics}\`` : '—';
265
+ lines.push(`- \`${artifact.path}\` — ${requiredLabel}; semantics: ${semanticsLabel}; status: ${artifact.status}`);
266
+ }
267
+ lines.push('');
268
+ if (isReviewOnlyOwner) {
269
+ lines.push('You cannot write repo files directly. Your accountability means you must confirm these artifacts exist, meet quality standards, and satisfy their semantic requirements. If a required artifact you own is missing, escalate to the producing role — do not request phase transition.');
270
+ } else {
271
+ lines.push('Do not request phase transition or run completion while a required workflow-kit artifact you own is missing or incomplete.');
272
+ }
273
+ lines.push('');
274
+ }
275
+
251
276
  // Gate requirements
252
277
  if (gateConfig) {
253
278
  lines.push('## Phase Exit Gate');
@@ -393,7 +418,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
393
418
  lines.push(`- **You are in the \`${currentPhase}\` phase (not final phase).** When your work is complete${gateClause}, set \`phase_transition_request: "${nextPhase}"\`.`);
394
419
  } else if (phaseIdx >= 0 && phaseIdx === phaseNames.length - 1) {
395
420
  lines.push(`- **You are in the \`${currentPhase}\` phase (final phase).** When ready to ship, set \`run_completion_request: true\` and \`phase_transition_request: null\`.`);
396
- if (runtimeType === 'api_proxy') {
421
+ if (runtimeType === 'api_proxy' || runtimeType === 'remote_agent') {
397
422
  lines.push('- **Completion turns must be no-op:** set `proposed_changes` to `[]` or omit it, set `files_changed` to `[]`, and set `artifact.type` to `"review"`. Do NOT propose file changes on a completion turn.');
398
423
  }
399
424
  }
@@ -408,7 +433,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
408
433
  lines.push('- **If you found genuine blocking issues that prevent shipping:** set `status: "needs_human"` and explain the blockers in `needs_human_reason`.');
409
434
  lines.push('- Do NOT use `status: "needs_human"` to mean "human should approve the release." That is what `run_completion_request: true` is for.');
410
435
  lines.push('- Do NOT set `phase_transition_request` to the exit gate name.');
411
- if (runtimeType === 'api_proxy') {
436
+ if (runtimeType === 'api_proxy' || runtimeType === 'remote_agent') {
412
437
  lines.push('- `run_completion_request: true` does **not** mean this runtime wrote `.planning/acceptance-matrix.md`, `.planning/ship-verdict.md`, or `.planning/RELEASE_NOTES.md` for you.');
413
438
  }
414
439
  }
@@ -421,6 +446,47 @@ function renderPrompt(role, roleId, turn, state, config, root) {
421
446
  };
422
447
  }
423
448
 
449
+ function getWorkflowPromptResponsibilities(config, phase, roleId, root) {
450
+ const artifacts = config?.workflow_kit?.phases?.[phase]?.artifacts;
451
+ if (!Array.isArray(artifacts) || artifacts.length === 0) {
452
+ return [];
453
+ }
454
+
455
+ const entryRole = config?.routing?.[phase]?.entry_role || null;
456
+ const responsibilities = [];
457
+
458
+ for (const artifact of artifacts) {
459
+ if (!artifact?.path) {
460
+ continue;
461
+ }
462
+
463
+ const owner = typeof artifact.owned_by === 'string' && artifact.owned_by
464
+ ? artifact.owned_by
465
+ : null;
466
+ const responsibleRole = owner || entryRole;
467
+ if (responsibleRole !== roleId) {
468
+ continue;
469
+ }
470
+
471
+ const absPath = join(root, artifact.path);
472
+ let exists = false;
473
+ try {
474
+ exists = existsSync(absPath);
475
+ } catch {
476
+ exists = false;
477
+ }
478
+
479
+ responsibilities.push({
480
+ path: artifact.path,
481
+ required: artifact.required !== false,
482
+ semantics: artifact.semantics || null,
483
+ status: exists ? 'exists' : 'MISSING',
484
+ });
485
+ }
486
+
487
+ return responsibilities;
488
+ }
489
+
424
490
  // ── Context Rendering ───────────────────────────────────────────────────────
425
491
 
426
492
  function renderContext(state, config, root, turn, role) {
@@ -621,8 +687,59 @@ function renderContext(state, config, root, turn, role) {
621
687
  lines.push('');
622
688
  }
623
689
 
624
- // Phase gate requirements
690
+ // Workflow-kit artifacts for the current phase
625
691
  const phase = state.phase;
692
+ const wkArtifacts = config?.workflow_kit?.phases?.[phase]?.artifacts;
693
+ if (Array.isArray(wkArtifacts) && wkArtifacts.length > 0) {
694
+ lines.push('## Workflow Artifacts');
695
+ lines.push('');
696
+ lines.push(`Current phase **${phase}** declares the following artifacts:`);
697
+ lines.push('');
698
+ lines.push('| Artifact | Required | Semantics | Owner | Status |');
699
+ lines.push('|----------|----------|-----------|-------|--------|');
700
+ const isReviewRole = role?.write_authority === 'review_only';
701
+ const reviewPreviews = [];
702
+ for (const art of wkArtifacts) {
703
+ if (!art?.path) continue;
704
+ const absPath = join(root, art.path);
705
+ let exists = false;
706
+ try { exists = existsSync(absPath); } catch { /* treat as missing */ }
707
+ const req = art.required !== false ? 'yes' : 'no';
708
+ const owner = art.owned_by || '—';
709
+ const status = exists ? 'exists' : 'MISSING';
710
+ const semCol = art.semantics ? `\`${art.semantics}\`` : '—';
711
+ lines.push(`| \`${art.path}\` | ${req} | ${semCol} | ${owner} | ${status} |`);
712
+ if (isReviewRole && exists) {
713
+ reviewPreviews.push(art);
714
+ }
715
+ }
716
+ lines.push('');
717
+ if (reviewPreviews.length > 0) {
718
+ for (const art of reviewPreviews) {
719
+ const absPath = join(root, art.path);
720
+ const preview = buildGateFilePreview(absPath);
721
+ if (preview) {
722
+ lines.push(`### \`${art.path}\``);
723
+ lines.push('');
724
+ const semantic = extractGateFileSemantic(art.path, preview.raw);
725
+ if (semantic) {
726
+ lines.push(`**Semantic: ${semantic}**`);
727
+ lines.push('');
728
+ }
729
+ lines.push('```');
730
+ lines.push(preview.content);
731
+ lines.push('```');
732
+ if (preview.truncated) {
733
+ lines.push('');
734
+ lines.push(`_Preview truncated after ${GATE_FILE_PREVIEW_MAX_LINES} lines._`);
735
+ }
736
+ lines.push('');
737
+ }
738
+ }
739
+ }
740
+ }
741
+
742
+ // Phase gate requirements
626
743
  const routing = config.routing?.[phase];
627
744
  const exitGate = routing?.exit_gate;
628
745
  const gateConfig = exitGate ? config.gates?.[exitGate] : null;
@@ -139,7 +139,10 @@ function renderDerivedReviewArtifact(turnResult, state) {
139
139
  }
140
140
 
141
141
  function materializeDerivedReviewArtifact(root, turnResult, state, runtimeType, baseline = null) {
142
- if (turnResult?.artifact?.type !== 'review' || runtimeType !== 'api_proxy') {
142
+ if (
143
+ turnResult?.artifact?.type !== 'review'
144
+ || (runtimeType !== 'api_proxy' && runtimeType !== 'remote_agent')
145
+ ) {
143
146
  return null;
144
147
  }
145
148
 
@@ -156,7 +159,10 @@ function materializeDerivedReviewArtifact(root, turnResult, state, runtimeType,
156
159
  }
157
160
 
158
161
  function materializeDerivedProposalArtifact(root, turnResult, state, runtimeType) {
159
- if (turnResult?.artifact?.type !== 'patch' || runtimeType !== 'api_proxy') {
162
+ if (
163
+ turnResult?.artifact?.type !== 'patch'
164
+ || (runtimeType !== 'api_proxy' && runtimeType !== 'remote_agent')
165
+ ) {
160
166
  return null;
161
167
  }
162
168
  if (!Array.isArray(turnResult.proposed_changes) || turnResult.proposed_changes.length === 0) {
@@ -17,7 +17,7 @@ import { validateNotificationsConfig } from './notification-runner.js';
17
17
  import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
18
18
 
19
19
  const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
20
- const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp'];
20
+ const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp', 'remote_agent'];
21
21
  const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai'];
22
22
  export const VALID_PROMPT_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
23
23
  const VALID_MCP_TRANSPORTS = ['stdio', 'streamable_http'];
@@ -157,6 +157,42 @@ function validateMcpRuntime(runtimeId, runtime, errors) {
157
157
  }
158
158
  }
159
159
 
160
+ function validateRemoteAgentRuntime(runtimeId, runtime, errors) {
161
+ // url: required, absolute http(s)
162
+ if (typeof runtime?.url !== 'string' || !runtime.url.trim()) {
163
+ errors.push(`Runtime "${runtimeId}": remote_agent requires "url"`);
164
+ } else {
165
+ try {
166
+ const parsed = new URL(runtime.url);
167
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
168
+ errors.push(`Runtime "${runtimeId}": remote_agent url must use http or https`);
169
+ }
170
+ } catch {
171
+ errors.push(`Runtime "${runtimeId}": remote_agent url must be a valid absolute URL`);
172
+ }
173
+ }
174
+
175
+ // headers: optional object of string values
176
+ if ('headers' in runtime) {
177
+ if (!runtime.headers || typeof runtime.headers !== 'object' || Array.isArray(runtime.headers)) {
178
+ errors.push(`Runtime "${runtimeId}": remote_agent headers must be an object of string values`);
179
+ } else {
180
+ for (const [key, value] of Object.entries(runtime.headers)) {
181
+ if (typeof value !== 'string') {
182
+ errors.push(`Runtime "${runtimeId}": remote_agent headers["${key}"] must be a string`);
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ // timeout_ms: optional positive integer
189
+ if ('timeout_ms' in runtime) {
190
+ if (!Number.isInteger(runtime.timeout_ms) || runtime.timeout_ms <= 0) {
191
+ errors.push(`Runtime "${runtimeId}": remote_agent timeout_ms must be a positive integer`);
192
+ }
193
+ }
194
+ }
195
+
160
196
  function validateApiProxyRetryPolicy(runtimeId, retryPolicy, errors) {
161
197
  if (!retryPolicy || typeof retryPolicy !== 'object' || Array.isArray(retryPolicy)) {
162
198
  errors.push(`Runtime "${runtimeId}": retry_policy must be an object`);
@@ -391,6 +427,9 @@ export function validateV4Config(data, projectRoot) {
391
427
  if (rt.type === 'mcp') {
392
428
  validateMcpRuntime(id, rt, errors);
393
429
  }
430
+ if (rt.type === 'remote_agent') {
431
+ validateRemoteAgentRuntime(id, rt, errors);
432
+ }
394
433
  }
395
434
  }
396
435
 
@@ -412,11 +451,18 @@ export function validateV4Config(data, projectRoot) {
412
451
  errors.push(`Role "${id}" is review_only but uses local_cli runtime "${role.runtime}" — review_only roles should not have authoritative write access`);
413
452
  }
414
453
  }
415
- // api_proxy restriction: only review_only and proposed roles may bind to api_proxy runtimes
454
+ // api_proxy and remote_agent restriction: only review_only and proposed roles may bind.
455
+ // These adapters do not have a proven local workspace mutation path in v1.
416
456
  if (role.runtime && data.runtimes[role.runtime]) {
417
457
  const rt = data.runtimes[role.runtime];
418
- if (rt.type === 'api_proxy' && role.write_authority !== 'review_only' && role.write_authority !== 'proposed') {
419
- errors.push(`Role "${id}" has write_authority "${role.write_authority}" but uses api_proxy runtime "${role.runtime}" api_proxy only supports review_only and proposed roles`);
458
+ if (
459
+ (rt.type === 'api_proxy' || rt.type === 'remote_agent')
460
+ && role.write_authority !== 'review_only'
461
+ && role.write_authority !== 'proposed'
462
+ ) {
463
+ errors.push(
464
+ `Role "${id}" has write_authority "${role.write_authority}" but uses ${rt.type} runtime "${role.runtime}" — ${rt.type} only supports review_only and proposed roles`
465
+ );
420
466
  }
421
467
  }
422
468
  }
@@ -582,6 +628,25 @@ export function validateWorkflowKitConfig(wk, routing, roles) {
582
628
  errors.push(`${prefix} owned_by "${artifact.owned_by}" is not a valid role ID (must be lowercase alphanumeric with hyphens/underscores)`);
583
629
  } else if (roles && typeof roles === 'object' && !roles[artifact.owned_by]) {
584
630
  errors.push(`${prefix} owned_by "${artifact.owned_by}" does not reference a defined role`);
631
+ } else if (
632
+ artifact.required !== false &&
633
+ roles && typeof roles === 'object' &&
634
+ roles[artifact.owned_by]?.write_authority === 'review_only'
635
+ ) {
636
+ // Check if any authoritative/proposed role exists in this phase's routing
637
+ const phaseRouting = routing?.[phase];
638
+ const phaseRoles = new Set([
639
+ ...(phaseRouting?.allowed_next_roles || []),
640
+ ...(phaseRouting?.entry_role ? [phaseRouting.entry_role] : []),
641
+ ]);
642
+ const hasWriter = [...phaseRoles].some(rid =>
643
+ roles[rid]?.write_authority === 'authoritative' || roles[rid]?.write_authority === 'proposed',
644
+ );
645
+ if (!hasWriter) {
646
+ warnings.push(
647
+ `${prefix} owned_by "${artifact.owned_by}" is a review_only role in phase "${phase}" with no authoritative or proposed role — nobody can write this required artifact`,
648
+ );
649
+ }
585
650
  }
586
651
  }
587
652
  }
@@ -387,14 +387,14 @@ function validateArtifact(tr, config) {
387
387
  warnings.push('Authoritative role completed with no files_changed — is this intentional?');
388
388
  }
389
389
 
390
- // Validate proposed_changes for proposed + api_proxy turns
390
+ // Validate proposed_changes for proposed runtimes that cannot write repo files directly.
391
391
  const runtimeType = config.runtimes?.[tr.runtime_id]?.type;
392
- if (writeAuthority === 'proposed' && runtimeType === 'api_proxy') {
392
+ if (writeAuthority === 'proposed' && (runtimeType === 'api_proxy' || runtimeType === 'remote_agent')) {
393
393
  // Completion-request turns are explicitly allowed to have empty proposed_changes —
394
394
  // the turn is signaling run completion, not delivering work.
395
395
  const isCompletionRequest = tr.run_completion_request === true;
396
396
  if (tr.status === 'completed' && (!tr.proposed_changes || tr.proposed_changes.length === 0) && !isCompletionRequest) {
397
- errors.push('Proposed api_proxy turn completed but proposed_changes is empty or missing.');
397
+ errors.push(`Proposed ${runtimeType} turn completed but proposed_changes is empty or missing.`);
398
398
  }
399
399
  }
400
400
  if (tr.proposed_changes && Array.isArray(tr.proposed_changes)) {