agentxchain 2.83.0 → 2.85.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.
@@ -2,7 +2,14 @@
2
2
 
3
3
  Built-in AgentXchain plugin that mirrors governed run status into a configured GitHub issue.
4
4
 
5
- Install from this repo:
5
+ Install by short name (recommended):
6
+
7
+ ```bash
8
+ agentxchain plugin install github-issues \
9
+ --config '{"repo":"owner/name","issue_number":42}'
10
+ ```
11
+
12
+ Install from local path:
6
13
 
7
14
  ```bash
8
15
  agentxchain plugin install ./plugins/plugin-github-issues
@@ -29,3 +36,8 @@ Scope notes:
29
36
  - One plugin-owned comment per run, updated in place
30
37
  - Managed labels track phase or blocked state only
31
38
  - This plugin does **not** close issues or claim post-gate approval state because the hook surface does not provide post-gate truth
39
+
40
+ Proof surfaces:
41
+
42
+ - Continuous subprocess proof: `cli/test/e2e-builtin-github-issues.test.js`
43
+ - Live product-example proof: `examples/governed-todo-app/run-github-issues-proof.mjs`
@@ -2,7 +2,13 @@
2
2
 
3
3
  Built-in AgentXchain plugin that writes structured lifecycle report artifacts into `.agentxchain/reports/`.
4
4
 
5
- Install from this repo:
5
+ Install by built-in short name (recommended):
6
+
7
+ ```bash
8
+ agentxchain plugin install json-report
9
+ ```
10
+
11
+ Install from a repo-local path:
6
12
 
7
13
  ```bash
8
14
  agentxchain plugin install ./plugins/plugin-json-report
@@ -15,6 +21,12 @@ agentxchain plugin install ./plugins/plugin-json-report \
15
21
  --config '{"report_dir":".agentxchain/custom-reports"}'
16
22
  ```
17
23
 
24
+ Live proof command:
25
+
26
+ ```bash
27
+ node examples/governed-todo-app/run-json-report-proof.mjs --json
28
+ ```
29
+
18
30
  Hook phases:
19
31
 
20
32
  - `after_acceptance`
@@ -28,3 +40,4 @@ Outputs:
28
40
  - `latest-<hook_phase>.json`
29
41
  - default output path `.agentxchain/reports`
30
42
  - `report_dir` may override the path, but it must stay inside the governed project root
43
+ - `latest.json` reflects the most recent hook invocation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.83.0",
3
+ "version": "2.85.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,312 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Model Compatibility Probe — api_proxy + proposed write authority
4
+ *
5
+ * Dispatches a lightweight governed turn to each target model and records
6
+ * whether the model can produce a well-formed turn result with proposed_changes.
7
+ *
8
+ * Usage:
9
+ * node cli/scripts/model-compatibility-probe.mjs
10
+ * node cli/scripts/model-compatibility-probe.mjs --json
11
+ *
12
+ * Requires ANTHROPIC_API_KEY in the environment.
13
+ */
14
+
15
+ const ANTHROPIC_ENDPOINT = 'https://api.anthropic.com/v1/messages';
16
+
17
+ const SYSTEM_PROMPT = [
18
+ 'You are acting as a governed agent in an AgentXchain protocol run.',
19
+ 'Your task and rules are described in the user message.',
20
+ 'You MUST respond with a valid JSON object matching the turn result schema provided in the prompt.',
21
+ 'Do NOT wrap the JSON in markdown code fences. Respond with raw JSON only.',
22
+ ].join('\n');
23
+
24
+ const PROBE_PROMPT = `You are a governed agent with write_authority: "proposed".
25
+
26
+ Your task: create a single file called \`probe-result.txt\` containing the text "model compatibility probe passed".
27
+
28
+ Respond with a single raw JSON object (no markdown fences) matching this exact schema:
29
+
30
+ {
31
+ "schema_version": "1.0",
32
+ "run_id": "probe_run",
33
+ "turn_id": "probe_turn_001",
34
+ "role": "dev",
35
+ "runtime_id": "probe_runtime",
36
+ "status": "completed",
37
+ "summary": "Created probe-result.txt for model compatibility verification.",
38
+ "decisions": [],
39
+ "objections": [],
40
+ "files_changed": ["probe-result.txt"],
41
+ "verification": { "status": "pass", "evidence": "File created successfully." },
42
+ "artifact": { "type": "patch", "ref": null },
43
+ "proposed_next_role": "human",
44
+ "proposed_changes": [
45
+ {
46
+ "path": "probe-result.txt",
47
+ "action": "create",
48
+ "content": "model compatibility probe passed"
49
+ }
50
+ ]
51
+ }
52
+
53
+ Return ONLY the JSON object. No explanation, no markdown fences, no extra text.`;
54
+
55
+ const MODELS = [
56
+ { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', maxTokens: 2048, costInput: 1.00, costOutput: 5.00 },
57
+ { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', maxTokens: 2048, costInput: 3.00, costOutput: 15.00 },
58
+ ];
59
+
60
+ function extractTurnResult(text) {
61
+ if (typeof text !== 'string' || !text.trim()) {
62
+ return { ok: false, error: 'Empty response text' };
63
+ }
64
+ const trimmed = text.trim();
65
+
66
+ // Direct JSON parse
67
+ try {
68
+ const parsed = JSON.parse(trimmed);
69
+ if (parsed && typeof parsed === 'object' && parsed.schema_version) {
70
+ return { ok: true, turnResult: parsed, method: 'direct' };
71
+ }
72
+ } catch { /* not pure JSON */ }
73
+
74
+ // Markdown fence extraction
75
+ const fenceMatch = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
76
+ if (fenceMatch) {
77
+ try {
78
+ const parsed = JSON.parse(fenceMatch[1].trim());
79
+ if (parsed && typeof parsed === 'object' && parsed.schema_version) {
80
+ return { ok: true, turnResult: parsed, method: 'fence' };
81
+ }
82
+ } catch { /* invalid JSON inside fence */ }
83
+ }
84
+
85
+ // Substring extraction
86
+ const jsonStart = trimmed.indexOf('{');
87
+ const jsonEnd = trimmed.lastIndexOf('}');
88
+ if (jsonStart >= 0 && jsonEnd > jsonStart) {
89
+ try {
90
+ const parsed = JSON.parse(trimmed.slice(jsonStart, jsonEnd + 1));
91
+ if (parsed && typeof parsed === 'object' && parsed.schema_version) {
92
+ return { ok: true, turnResult: parsed, method: 'substring' };
93
+ }
94
+ } catch { /* not valid JSON */ }
95
+ }
96
+
97
+ return { ok: false, error: 'Could not extract structured turn result JSON' };
98
+ }
99
+
100
+ function validateProposedChanges(turnResult) {
101
+ const changes = turnResult?.proposed_changes;
102
+ if (!Array.isArray(changes) || changes.length === 0) {
103
+ return { present: false, wellFormed: false, reason: 'proposed_changes missing or empty' };
104
+ }
105
+ for (const c of changes) {
106
+ if (!c.path || typeof c.path !== 'string') {
107
+ return { present: true, wellFormed: false, reason: `entry missing path` };
108
+ }
109
+ if (!['create', 'modify', 'delete'].includes(c.action)) {
110
+ return { present: true, wellFormed: false, reason: `invalid action: ${c.action}` };
111
+ }
112
+ if ((c.action === 'create' || c.action === 'modify') && (!c.content || typeof c.content !== 'string')) {
113
+ return { present: true, wellFormed: false, reason: `${c.action} entry missing content for ${c.path}` };
114
+ }
115
+ }
116
+ return { present: true, wellFormed: true, count: changes.length };
117
+ }
118
+
119
+ async function probeModel(model, apiKey) {
120
+ const startMs = Date.now();
121
+ const body = {
122
+ model: model.id,
123
+ max_tokens: model.maxTokens,
124
+ system: SYSTEM_PROMPT,
125
+ messages: [{ role: 'user', content: PROBE_PROMPT }],
126
+ };
127
+
128
+ let responseData;
129
+ let rawError = null;
130
+
131
+ try {
132
+ const res = await fetch(ANTHROPIC_ENDPOINT, {
133
+ method: 'POST',
134
+ headers: {
135
+ 'Content-Type': 'application/json',
136
+ 'x-api-key': apiKey,
137
+ 'anthropic-version': '2023-06-01',
138
+ },
139
+ body: JSON.stringify(body),
140
+ });
141
+
142
+ if (!res.ok) {
143
+ const errorBody = await res.text().catch(() => '');
144
+ rawError = `HTTP ${res.status}: ${errorBody.slice(0, 500)}`;
145
+ return {
146
+ model: model.id,
147
+ label: model.label,
148
+ extraction_success: false,
149
+ schema_valid: false,
150
+ proposed_changes_present: false,
151
+ proposed_changes_well_formed: false,
152
+ latency_ms: Date.now() - startMs,
153
+ cost_usd: 0,
154
+ classification: 'unsupported',
155
+ raw_error: rawError,
156
+ };
157
+ }
158
+
159
+ responseData = await res.json();
160
+ } catch (err) {
161
+ return {
162
+ model: model.id,
163
+ label: model.label,
164
+ extraction_success: false,
165
+ schema_valid: false,
166
+ proposed_changes_present: false,
167
+ proposed_changes_well_formed: false,
168
+ latency_ms: Date.now() - startMs,
169
+ cost_usd: 0,
170
+ classification: 'unsupported',
171
+ raw_error: err.message,
172
+ };
173
+ }
174
+
175
+ const latencyMs = Date.now() - startMs;
176
+
177
+ // Extract text from Anthropic response
178
+ const textBlock = responseData?.content?.find(b => b.type === 'text');
179
+ const responseText = textBlock?.text || '';
180
+
181
+ // Extract turn result
182
+ const extraction = extractTurnResult(responseText);
183
+
184
+ // Calculate cost
185
+ const usage = responseData?.usage || {};
186
+ const inputTokens = usage.input_tokens || 0;
187
+ const outputTokens = usage.output_tokens || 0;
188
+ const costUsd = (inputTokens / 1_000_000) * model.costInput + (outputTokens / 1_000_000) * model.costOutput;
189
+
190
+ if (!extraction.ok) {
191
+ return {
192
+ model: model.id,
193
+ label: model.label,
194
+ extraction_success: false,
195
+ extraction_method: null,
196
+ schema_valid: false,
197
+ proposed_changes_present: false,
198
+ proposed_changes_well_formed: false,
199
+ latency_ms: latencyMs,
200
+ input_tokens: inputTokens,
201
+ output_tokens: outputTokens,
202
+ cost_usd: Math.round(costUsd * 1_000_000) / 1_000_000,
203
+ classification: 'unsupported',
204
+ raw_error: extraction.error,
205
+ response_preview: responseText.slice(0, 300),
206
+ };
207
+ }
208
+
209
+ const tr = extraction.turnResult;
210
+ const schemaValid = typeof tr.schema_version === 'string' && tr.schema_version === '1.0';
211
+ const pcValidation = validateProposedChanges(tr);
212
+
213
+ let classification;
214
+ if (schemaValid && pcValidation.present && pcValidation.wellFormed) {
215
+ classification = 'reliable';
216
+ } else if (extraction.ok && (!pcValidation.present || !pcValidation.wellFormed)) {
217
+ classification = 'inconsistent';
218
+ } else {
219
+ classification = 'unsupported';
220
+ }
221
+
222
+ return {
223
+ model: model.id,
224
+ label: model.label,
225
+ extraction_success: true,
226
+ extraction_method: extraction.method,
227
+ schema_valid: schemaValid,
228
+ proposed_changes_present: pcValidation.present,
229
+ proposed_changes_well_formed: pcValidation.wellFormed,
230
+ proposed_changes_count: pcValidation.count || 0,
231
+ latency_ms: latencyMs,
232
+ input_tokens: inputTokens,
233
+ output_tokens: outputTokens,
234
+ cost_usd: Math.round(costUsd * 1_000_000) / 1_000_000,
235
+ classification,
236
+ raw_error: pcValidation.reason || null,
237
+ status_returned: tr.status,
238
+ summary_returned: typeof tr.summary === 'string' ? tr.summary.slice(0, 100) : null,
239
+ };
240
+ }
241
+
242
+ async function main() {
243
+ const apiKey = process.env.ANTHROPIC_API_KEY;
244
+ if (!apiKey) {
245
+ console.error('ANTHROPIC_API_KEY not set');
246
+ process.exit(1);
247
+ }
248
+
249
+ const jsonMode = process.argv.includes('--json');
250
+
251
+ if (!jsonMode) {
252
+ console.log('AgentXchain Model Compatibility Probe');
253
+ console.log('Provider: Anthropic | Write Authority: proposed');
254
+ console.log('─'.repeat(60));
255
+ }
256
+
257
+ const results = [];
258
+
259
+ for (const model of MODELS) {
260
+ if (!jsonMode) {
261
+ process.stdout.write(`Probing ${model.label} (${model.id})... `);
262
+ }
263
+
264
+ const result = await probeModel(model, apiKey);
265
+ results.push(result);
266
+
267
+ if (!jsonMode) {
268
+ const icon = result.classification === 'reliable' ? '✓' : result.classification === 'inconsistent' ? '~' : '✗';
269
+ console.log(`${icon} ${result.classification} (${result.latency_ms}ms, $${result.cost_usd})`);
270
+ if (result.raw_error) {
271
+ console.log(` └─ ${result.raw_error}`);
272
+ }
273
+ }
274
+ }
275
+
276
+ const output = {
277
+ probe_version: '1.0',
278
+ timestamp: new Date().toISOString(),
279
+ provider: 'anthropic',
280
+ write_authority: 'proposed',
281
+ models: results,
282
+ total_cost_usd: results.reduce((s, r) => s + r.cost_usd, 0),
283
+ };
284
+
285
+ if (jsonMode) {
286
+ console.log(JSON.stringify(output, null, 2));
287
+ } else {
288
+ console.log('─'.repeat(60));
289
+ console.log(`Total cost: $${output.total_cost_usd.toFixed(6)}`);
290
+ console.log();
291
+ console.log('Matrix:');
292
+ for (const r of results) {
293
+ console.log(` ${r.label.padEnd(12)} ${r.classification.padEnd(14)} extraction=${r.extraction_success} schema=${r.schema_valid} proposed=${r.proposed_changes_well_formed}`);
294
+ }
295
+ }
296
+
297
+ // Write results to .planning/ for durable reference
298
+ const { writeFileSync, mkdirSync } = await import('node:fs');
299
+ const { dirname, join } = await import('node:path');
300
+ const { fileURLToPath } = await import('node:url');
301
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
302
+ const outPath = join(repoRoot, '.planning', 'MODEL_COMPATIBILITY_RESULTS.json');
303
+ writeFileSync(outPath, JSON.stringify(output, null, 2) + '\n');
304
+ if (!jsonMode) {
305
+ console.log(`\nResults written to: .planning/MODEL_COMPATIBILITY_RESULTS.json`);
306
+ }
307
+ }
308
+
309
+ main().catch(err => {
310
+ console.error(err);
311
+ process.exit(1);
312
+ });
@@ -62,7 +62,6 @@ ALLOWED_RELEASE_PATHS=(
62
62
  "website-v2/docs/protocol-implementor-guide.mdx"
63
63
  ".planning/LAUNCH_EVIDENCE_REPORT.md"
64
64
  "website-v2/static/llms.txt"
65
- "website-v2/static/sitemap.xml"
66
65
  "cli/homebrew/agentxchain.rb"
67
66
  "cli/homebrew/README.md"
68
67
  )
@@ -148,9 +147,9 @@ if [[ ! -f "${REPO_ROOT}/${RELEASE_DOC_PATH}" ]]; then
148
147
  SURFACE_ERRORS+=("release notes page missing: ${RELEASE_DOC_PATH}")
149
148
  fi
150
149
 
151
- # 4c. Docs sidebar links the release page
152
- if ! grep -q "'releases/${RELEASE_DOC_ID}'" "${REPO_ROOT}/website-v2/sidebars.ts" 2>/dev/null; then
153
- SURFACE_ERRORS+=("sidebars.ts does not link 'releases/${RELEASE_DOC_ID}'")
150
+ # 4c. Docs sidebar auto-generates releases from dirName (release doc existence is sufficient)
151
+ if ! grep -q "dirName.*releases" "${REPO_ROOT}/website-v2/sidebars.ts" 2>/dev/null; then
152
+ SURFACE_ERRORS+=("sidebars.ts does not auto-generate releases (missing dirName: 'releases')")
154
153
  fi
155
154
 
156
155
  # 4d. Homepage hero badge shows target version
@@ -181,10 +180,7 @@ if ! grep -q "${CURRENT_RELEASE_ROUTE}" "${REPO_ROOT}/website-v2/static/llms.txt
181
180
  SURFACE_ERRORS+=("website-v2/static/llms.txt does not list '${CURRENT_RELEASE_ROUTE}'")
182
181
  fi
183
182
 
184
- # 4i. sitemap.xml must list the current release notes route
185
- if ! grep -q "${CURRENT_RELEASE_ROUTE}" "${REPO_ROOT}/website-v2/static/sitemap.xml" 2>/dev/null; then
186
- SURFACE_ERRORS+=("website-v2/static/sitemap.xml does not list '${CURRENT_RELEASE_ROUTE}'")
187
- fi
183
+ # 4i. sitemap.xml is now auto-generated by Docusaurus at build time — no static file check needed
188
184
 
189
185
  if [[ "${#SURFACE_ERRORS[@]}" -gt 0 ]]; then
190
186
  echo "FAIL: ${#SURFACE_ERRORS[@]} version-surface(s) not aligned to ${TARGET_VERSION}:" >&2
@@ -194,7 +190,7 @@ if [[ "${#SURFACE_ERRORS[@]}" -gt 0 ]]; then
194
190
  echo "create release identity when governed surfaces are stale." >&2
195
191
  exit 1
196
192
  fi
197
- echo " OK: all 9 governed version surfaces reference ${TARGET_VERSION}"
193
+ echo " OK: all 8 governed version surfaces reference ${TARGET_VERSION}"
198
194
 
199
195
  # 5. Auto-align Homebrew mirror to target version
200
196
  # The formula URL and README version/tarball are updated automatically.
@@ -219,6 +219,12 @@ function setSetting(config, configPath, keyValPair, context) {
219
219
  console.log('');
220
220
  console.log(chalk.green(` ✓ Set ${chalk.bold(key)} = ${val}`));
221
221
  if (oldVal !== undefined) console.log(chalk.dim(` (was: ${oldVal})`));
222
+ if ((validation.warnings || []).length > 0) {
223
+ console.log(chalk.yellow(' Warnings:'));
224
+ for (const warning of validation.warnings) {
225
+ console.log(chalk.dim(` - ${warning}`));
226
+ }
227
+ }
222
228
  console.log('');
223
229
  }
224
230
 
@@ -51,8 +51,11 @@ function governedDoctor(root, rawConfig, opts) {
51
51
 
52
52
  // 1. Config validation
53
53
  const configResult = loadNormalizedConfig(rawConfig, root);
54
- if (configResult.ok) {
54
+ if (configResult.ok && (configResult.warnings || []).length === 0) {
55
55
  checks.push({ id: 'config_valid', name: 'Config validation', level: 'pass', detail: 'Config loads and validates' });
56
+ } else if (configResult.ok) {
57
+ const warningSummary = configResult.warnings.slice(0, 2).join('; ');
58
+ checks.push({ id: 'config_valid', name: 'Config validation', level: 'warn', detail: warningSummary });
56
59
  } else {
57
60
  const errorSummary = configResult.errors.slice(0, 3).join('; ');
58
61
  checks.push({ id: 'config_valid', name: 'Config validation', level: 'fail', detail: errorSummary });
@@ -270,6 +270,7 @@ export async function executeGovernedRun(context, opts = {}) {
270
270
  signal: controller.signal,
271
271
  onStatus: (msg) => log(chalk.dim(` ${msg}`)),
272
272
  verifyManifest: true,
273
+ turnId: turn.turn_id,
273
274
  };
274
275
 
275
276
  if (verbose) {
@@ -330,9 +330,10 @@ export function detectConfigVersion(raw) {
330
330
  */
331
331
  export function validateV4Config(data, projectRoot) {
332
332
  const errors = [];
333
+ const warnings = [];
333
334
 
334
335
  if (!data || typeof data !== 'object') {
335
- return { ok: false, errors: ['Config must be a JSON object'] };
336
+ return { ok: false, errors: ['Config must be a JSON object'], warnings };
336
337
  }
337
338
 
338
339
  // Top-level required sections
@@ -555,7 +556,80 @@ export function validateV4Config(data, projectRoot) {
555
556
  errors.push(...timeoutValidation.errors);
556
557
  }
557
558
 
558
- return { ok: errors.length === 0, errors };
559
+ warnings.push(...collectRemoteReviewOnlyGateWarnings(data));
560
+
561
+ return { ok: errors.length === 0, errors, warnings };
562
+ }
563
+
564
+ export function collectRemoteReviewOnlyGateWarnings(data) {
565
+ const warnings = [];
566
+ const routing = data?.routing;
567
+ const gates = data?.gates;
568
+ const roles = data?.roles;
569
+ const runtimes = data?.runtimes;
570
+
571
+ if (!routing || !gates || !roles || !runtimes) {
572
+ return warnings;
573
+ }
574
+
575
+ for (const [phase, route] of Object.entries(routing)) {
576
+ const exitGateId = route?.exit_gate;
577
+ if (!exitGateId || !gates[exitGateId]) {
578
+ continue;
579
+ }
580
+
581
+ const requiredFiles = Array.isArray(gates[exitGateId]?.requires_files)
582
+ ? gates[exitGateId].requires_files.filter(filePath => typeof filePath === 'string' && filePath.trim())
583
+ : [];
584
+ if (requiredFiles.length === 0) {
585
+ continue;
586
+ }
587
+
588
+ const candidateRoleIds = [
589
+ route?.entry_role,
590
+ ...(Array.isArray(route?.allowed_next_roles) ? route.allowed_next_roles : []),
591
+ ].filter((roleId) => roleId && roleId !== 'human');
592
+
593
+ if (candidateRoleIds.length === 0) {
594
+ continue;
595
+ }
596
+
597
+ const candidateRoles = [...new Set(candidateRoleIds)]
598
+ .map((roleId) => {
599
+ const role = roles[roleId];
600
+ const runtime = role?.runtime ? runtimes[role.runtime] : null;
601
+ if (!role || !runtime) {
602
+ return null;
603
+ }
604
+ return { roleId, role, runtime };
605
+ })
606
+ .filter(Boolean);
607
+
608
+ if (candidateRoles.length === 0) {
609
+ continue;
610
+ }
611
+
612
+ const hasFileProducingRole = candidateRoles.some(({ role }) =>
613
+ role.write_authority === 'authoritative' || role.write_authority === 'proposed');
614
+ if (hasFileProducingRole) {
615
+ continue;
616
+ }
617
+
618
+ const allRemoteReviewOnly = candidateRoles.every(({ role, runtime }) =>
619
+ role.write_authority === 'review_only' && (runtime.type === 'api_proxy' || runtime.type === 'remote_agent'));
620
+ if (!allRemoteReviewOnly) {
621
+ continue;
622
+ }
623
+
624
+ const roleSummary = candidateRoles
625
+ .map(({ roleId, runtime }) => `${roleId}:${runtime.type}`)
626
+ .join(', ');
627
+ warnings.push(
628
+ `Routing "${phase}" exits through gate "${exitGateId}" with requires_files (${requiredFiles.join(', ')}) but all participating roles are review_only remote runtimes (${roleSummary}). Those files cannot be produced through governed turns; add a proposed/authoritative writer, remove the gate files, or expect operator-managed out-of-band artifacts.`,
629
+ );
630
+ }
631
+
632
+ return warnings;
559
633
  }
560
634
 
561
635
  export function validateBudgetConfig(budget) {
@@ -1133,6 +1207,7 @@ export function loadNormalizedConfig(raw, projectRoot) {
1133
1207
  ok: false,
1134
1208
  normalized: null,
1135
1209
  errors: ['Unrecognized config format. Expected version: 3 or schema_version: "1.0" / 4'],
1210
+ warnings: [],
1136
1211
  version: null,
1137
1212
  };
1138
1213
  }
@@ -1152,17 +1227,17 @@ export function loadNormalizedConfig(raw, projectRoot) {
1152
1227
  }
1153
1228
  }
1154
1229
  if (errors.length > 0) {
1155
- return { ok: false, normalized: null, errors, version: 3 };
1230
+ return { ok: false, normalized: null, errors, warnings: [], version: 3 };
1156
1231
  }
1157
- return { ok: true, normalized: normalizeV3(raw), errors: [], version: 3 };
1232
+ return { ok: true, normalized: normalizeV3(raw), errors: [], warnings: [], version: 3 };
1158
1233
  }
1159
1234
 
1160
1235
  if (version === 4) {
1161
1236
  const validation = validateV4Config(raw, projectRoot || null);
1162
1237
  if (!validation.ok) {
1163
- return { ok: false, normalized: null, errors: validation.errors, version: 4 };
1238
+ return { ok: false, normalized: null, errors: validation.errors, warnings: validation.warnings || [], version: 4 };
1164
1239
  }
1165
- return { ok: true, normalized: normalizeV4(raw), errors: [], version: 4 };
1240
+ return { ok: true, normalized: normalizeV4(raw), errors: [], warnings: validation.warnings || [], version: 4 };
1166
1241
  }
1167
1242
  }
1168
1243
 
@@ -5,6 +5,9 @@
5
5
  * well-defined pause points. Any runner (CLI, CI, hosted, custom) composes
6
6
  * this to implement continuous governed delivery.
7
7
  *
8
+ * Supports parallel turn dispatch when max_concurrent_turns > 1 is configured
9
+ * for the current phase (DEC-PARALLEL-RUN-LOOP-001).
10
+ *
8
11
  * Design rules:
9
12
  * - Never calls process dot exit
10
13
  * - No stdout/stderr
@@ -24,6 +27,8 @@ import {
24
27
  approveCompletionGate,
25
28
  getActiveTurn,
26
29
  getActiveTurnCount,
30
+ getActiveTurns,
31
+ getMaxConcurrentTurns,
27
32
  RUNNER_INTERFACE_VERSION,
28
33
  } from './runner-interface.js';
29
34
 
@@ -35,6 +40,10 @@ const DEFAULT_MAX_TURNS = 50;
35
40
  /**
36
41
  * Drive governed turns to a terminal state.
37
42
  *
43
+ * When max_concurrent_turns > 1 for the current phase, the loop fills
44
+ * available concurrency slots and dispatches all active turns concurrently.
45
+ * Acceptance is serialized by the existing lock mechanism.
46
+ *
38
47
  * @param {string} root - project root directory
39
48
  * @param {object} config - normalized governed config
40
49
  * @param {object} callbacks - { selectRole, dispatch, approveGate, onEvent? }
@@ -110,107 +119,307 @@ export async function runLoop(root, config, callbacks, options = {}) {
110
119
  return makeResult(false, 'max_turns_reached', state, turnsExecuted, turnHistory, gatesApproved, errors);
111
120
  }
112
121
 
113
- // ── Check for active turn (retry after rejection) ──────────────────��
114
- let turn;
115
- let assignState;
116
- const activeTurn = getActiveTurn(state);
122
+ // ── Determine concurrency mode ────────────────────────────────────────
123
+ const maxConcurrent = getMaxConcurrentTurns(config, state.phase);
117
124
 
118
- if (activeTurn && (activeTurn.status === 'running' || activeTurn.status === 'retrying')) {
119
- // Re-dispatch an existing active turn (retry after rejection)
120
- turn = activeTurn;
121
- assignState = state;
125
+ if (maxConcurrent <= 1) {
126
+ // ── Sequential mode (original behavior) ──────────────────────────
127
+ const seqResult = await executeSequentialTurn(root, config, state, callbacks, emit, errors);
128
+ if (seqResult.terminal) {
129
+ return makeResult(seqResult.ok, seqResult.stop_reason, loadState(root, config), turnsExecuted, turnHistory, gatesApproved, errors);
130
+ }
131
+ if (seqResult.accepted) {
132
+ turnsExecuted++;
133
+ }
134
+ turnHistory.push(...seqResult.history);
122
135
  } else {
123
- // ── Role selection ────────────────────────────────────────────────
124
- let roleId;
125
- try {
126
- roleId = callbacks.selectRole(state, config);
127
- } catch (err) {
128
- errors.push(`selectRole threw: ${err.message}`);
129
- return makeResult(false, 'dispatch_error', state, turnsExecuted, turnHistory, gatesApproved, errors);
136
+ // ── Parallel mode ────────────────────────────────────────────────
137
+ const parResult = await executeParallelTurns(root, config, state, maxConcurrent, callbacks, emit, errors);
138
+ if (parResult.terminal) {
139
+ return makeResult(parResult.ok, parResult.stop_reason, loadState(root, config), turnsExecuted, turnHistory, gatesApproved, errors);
130
140
  }
141
+ turnsExecuted += parResult.acceptedCount;
142
+ turnHistory.push(...parResult.history);
143
+ }
144
+ }
145
+ }
131
146
 
132
- if (roleId === null || roleId === undefined) {
133
- emit({ type: 'caller_stopped', state });
134
- return makeResult(false, 'caller_stopped', state, turnsExecuted, turnHistory, gatesApproved, errors);
135
- }
147
+ /**
148
+ * Execute a single turn (sequential mode original behavior preserved).
149
+ */
150
+ async function executeSequentialTurn(root, config, state, callbacks, emit, errors) {
151
+ let turn;
152
+ let assignState;
153
+ const activeTurn = getActiveTurn(state);
136
154
 
137
- // ── Turn assignment ───────────────────────────────────────────────
138
- const assignResult = assignTurn(root, config, roleId);
139
- if (!assignResult.ok) {
140
- errors.push(`assignTurn(${roleId}): ${assignResult.error}`);
141
- return makeResult(false, 'blocked', loadState(root, config), turnsExecuted, turnHistory, gatesApproved, errors);
142
- }
143
- turn = assignResult.turn;
144
- assignState = assignResult.state;
145
- emit({ type: 'turn_assigned', turn, role: roleId, state: assignState });
155
+ if (activeTurn && (activeTurn.status === 'running' || activeTurn.status === 'retrying')) {
156
+ turn = activeTurn;
157
+ assignState = state;
158
+ } else {
159
+ let roleId;
160
+ try {
161
+ roleId = callbacks.selectRole(state, config);
162
+ } catch (err) {
163
+ errors.push(`selectRole threw: ${err.message}`);
164
+ return { terminal: true, ok: false, stop_reason: 'dispatch_error', history: [] };
146
165
  }
147
166
 
148
- const roleId = turn.assigned_role;
167
+ if (roleId === null || roleId === undefined) {
168
+ emit({ type: 'caller_stopped', state });
169
+ return { terminal: true, ok: false, stop_reason: 'caller_stopped', history: [] };
170
+ }
149
171
 
150
- // ── Dispatch bundle ─────────────────────────────────────────────────
151
- const bundleResult = writeDispatchBundle(root, assignState, config);
152
- if (!bundleResult.ok) {
153
- errors.push(`writeDispatchBundle(${roleId}): ${bundleResult.error}`);
154
- return makeResult(false, 'blocked', loadState(root, config), turnsExecuted, turnHistory, gatesApproved, errors);
172
+ const assignResult = assignTurn(root, config, roleId);
173
+ if (!assignResult.ok) {
174
+ errors.push(`assignTurn(${roleId}): ${assignResult.error}`);
175
+ return { terminal: true, ok: false, stop_reason: 'blocked', history: [] };
155
176
  }
177
+ turn = assignResult.turn;
178
+ assignState = assignResult.state;
179
+ emit({ type: 'turn_assigned', turn, role: roleId, state: assignState });
180
+ }
181
+
182
+ return await dispatchAndProcess(root, config, turn, assignState, callbacks, emit, errors);
183
+ }
184
+
185
+ /**
186
+ * Fill concurrency slots and dispatch all active turns concurrently.
187
+ */
188
+ async function executeParallelTurns(root, config, state, maxConcurrent, callbacks, emit, errors) {
189
+ const history = [];
190
+ let acceptedCount = 0;
191
+
192
+ // ── Collect active turns that need dispatch (retries) ────────────────
193
+ const activeTurns = getActiveTurns(state);
194
+ const turnsToDispatch = [];
195
+ for (const turn of Object.values(activeTurns)) {
196
+ if (turn.status === 'running' || turn.status === 'retrying') {
197
+ turnsToDispatch.push({ turn, state });
198
+ }
199
+ }
200
+
201
+ // ── Fill concurrency slots with new assignments ──────────────────────
202
+ let activeCount = getActiveTurnCount(state);
203
+ const triedRoles = new Set();
204
+ while (activeCount < maxConcurrent) {
205
+ let roleId;
206
+ try {
207
+ roleId = callbacks.selectRole(state, config);
208
+ } catch (err) {
209
+ errors.push(`selectRole threw: ${err.message}`);
210
+ break;
211
+ }
212
+
213
+ if (roleId === null || roleId === undefined) {
214
+ // No more roles to assign — dispatch what we have
215
+ break;
216
+ }
217
+
218
+ // If selectRole returns a role we already tried (or assigned), try
219
+ // other eligible roles from the routing before giving up.
220
+ if (triedRoles.has(roleId)) {
221
+ const phase = state.phase;
222
+ const allowed = config?.routing?.[phase]?.allowed_next_roles || [];
223
+ const alternateFound = allowed.some((alt) => {
224
+ if (alt === 'human' || triedRoles.has(alt) || !config?.roles?.[alt]) return false;
225
+ const altResult = assignTurn(root, config, alt);
226
+ if (altResult.ok) {
227
+ triedRoles.add(alt);
228
+ turnsToDispatch.push({ turn: altResult.turn, state: altResult.state });
229
+ emit({ type: 'turn_assigned', turn: altResult.turn, role: alt, state: altResult.state });
230
+ state = loadState(root, config);
231
+ activeCount = getActiveTurnCount(state);
232
+ return true;
233
+ }
234
+ triedRoles.add(alt);
235
+ return false;
236
+ });
237
+ if (!alternateFound) break;
238
+ continue;
239
+ }
240
+
241
+ triedRoles.add(roleId);
242
+ const assignResult = assignTurn(root, config, roleId);
243
+ if (!assignResult.ok) {
244
+ // Cannot assign — try other eligible roles before giving up
245
+ continue;
246
+ }
247
+
248
+ turnsToDispatch.push({ turn: assignResult.turn, state: assignResult.state });
249
+ emit({ type: 'turn_assigned', turn: assignResult.turn, role: roleId, state: assignResult.state });
250
+
251
+ // Reload state after assignment to get accurate active count
252
+ state = loadState(root, config);
253
+ activeCount = getActiveTurnCount(state);
254
+ }
255
+
256
+ // ── Nothing to dispatch? ─────────────────────────────────────────────
257
+ if (turnsToDispatch.length === 0) {
258
+ // selectRole returned null with no active turns — caller is done
259
+ emit({ type: 'caller_stopped', state });
260
+ return { terminal: true, ok: false, stop_reason: 'caller_stopped', history: [] };
261
+ }
156
262
 
263
+ // ── Build dispatch contexts ──────────────────────────────────────────
264
+ const contexts = [];
265
+ for (const { turn, state: turnState } of turnsToDispatch) {
266
+ const bundleResult = writeDispatchBundle(root, turnState, config, { turnId: turn.turn_id });
267
+ if (!bundleResult.ok) {
268
+ errors.push(`writeDispatchBundle(${turn.assigned_role}): ${bundleResult.error}`);
269
+ continue;
270
+ }
157
271
  const stagingPath = getTurnStagingResultPath(turn.turn_id);
158
- const context = {
272
+ contexts.push({
159
273
  turn,
160
- state: assignState,
274
+ state: turnState,
161
275
  bundlePath: bundleResult.bundlePath,
162
276
  stagingPath,
163
277
  config,
164
278
  root,
165
- };
279
+ });
280
+ }
166
281
 
167
- // ── Dispatch ────────────────────────────────────────────────────────
168
- let dispatchResult;
169
- try {
170
- dispatchResult = await callbacks.dispatch(context);
171
- } catch (err) {
172
- errors.push(`dispatch threw for ${roleId}: ${err.message}`);
173
- return makeResult(false, 'dispatch_error', loadState(root, config), turnsExecuted, turnHistory, gatesApproved, errors);
174
- }
282
+ if (contexts.length === 0) {
283
+ errors.push('All dispatch bundles failed to write');
284
+ return { terminal: true, ok: false, stop_reason: 'blocked', history: [] };
285
+ }
286
+
287
+ // ── Dispatch concurrently ────────────────────────────────────────────
288
+ emit({ type: 'parallel_dispatch', count: contexts.length, turns: contexts.map(c => c.turn.turn_id) });
289
+
290
+ const dispatchResults = await Promise.allSettled(
291
+ contexts.map(async (ctx) => {
292
+ try {
293
+ return { ctx, result: await callbacks.dispatch(ctx) };
294
+ } catch (err) {
295
+ return { ctx, result: { accept: false, reason: `dispatch threw: ${err.message}` } };
296
+ }
297
+ })
298
+ );
299
+
300
+ // ── Process results sequentially (acceptance is lock-serialized) ─────
301
+ for (const settled of dispatchResults) {
302
+ const { ctx, result: dispatchResult } = settled.status === 'fulfilled'
303
+ ? settled.value
304
+ : { ctx: null, result: { accept: false, reason: `Promise rejected: ${settled.reason}` } };
305
+
306
+ if (!ctx) continue;
307
+
308
+ const { turn } = ctx;
309
+ const roleId = turn.assigned_role;
175
310
 
176
311
  if (dispatchResult.accept) {
177
- // Stage the turn result
178
- const absStaging = join(root, stagingPath);
312
+ const absStaging = join(root, ctx.stagingPath);
179
313
  mkdirSync(dirname(absStaging), { recursive: true });
180
314
  writeFileSync(absStaging, JSON.stringify(dispatchResult.turnResult, null, 2));
181
315
 
182
- // Accept
183
- const acceptResult = acceptTurn(root, config);
316
+ const acceptResult = acceptTurn(root, config, { turnId: turn.turn_id });
184
317
  if (!acceptResult.ok) {
185
318
  errors.push(`acceptTurn(${roleId}): ${acceptResult.error}`);
186
- const postState = loadState(root, config);
187
- return makeResult(false, 'blocked', postState, turnsExecuted, turnHistory, gatesApproved, errors);
319
+ // Record failure but try other turns
320
+ history.push({ role: roleId, turn_id: turn.turn_id, accepted: false, accept_error: acceptResult.error });
321
+ continue;
188
322
  }
189
323
 
190
- turnsExecuted++;
191
- turnHistory.push({ role: roleId, turn_id: turn.turn_id, accepted: true });
324
+ acceptedCount++;
325
+ history.push({ role: roleId, turn_id: turn.turn_id, accepted: true });
192
326
  emit({ type: 'turn_accepted', turn, role: roleId, state: acceptResult.state });
193
-
194
327
  } else {
195
- // Rejection
196
328
  const validationResult = {
197
329
  stage: 'dispatch',
198
330
  errors: [dispatchResult.reason || 'Dispatch callback rejected the turn'],
199
331
  };
200
- rejectTurn(root, config, validationResult, dispatchResult.reason || 'Dispatch rejection');
201
- turnHistory.push({ role: roleId, turn_id: turn.turn_id, accepted: false });
332
+ rejectTurn(root, config, validationResult, dispatchResult.reason || 'Dispatch rejection', { turnId: turn.turn_id });
333
+ history.push({ role: roleId, turn_id: turn.turn_id, accepted: false });
202
334
  emit({ type: 'turn_rejected', turn, role: roleId, reason: dispatchResult.reason });
203
335
 
204
- // Check if retries exhausted run blocked
336
+ // Check if rejection blocked the run
205
337
  const postRejectState = loadState(root, config);
206
338
  if (postRejectState?.status === 'blocked') {
207
339
  errors.push(`Turn rejected for ${roleId}, retries exhausted`);
208
340
  emit({ type: 'blocked', state: postRejectState });
209
- return makeResult(false, 'reject_exhausted', postRejectState, turnsExecuted, turnHistory, gatesApproved, errors);
341
+ return { terminal: true, ok: false, stop_reason: 'reject_exhausted', history, acceptedCount };
210
342
  }
211
- // Otherwise continue — loop will detect the active turn and re-dispatch
212
343
  }
213
344
  }
345
+
346
+ // ── Stall detection: if no turns were accepted and no new roles were ──
347
+ // ── assignable, terminate to avoid infinite re-dispatch loops. ────────
348
+ if (acceptedCount === 0 && history.length > 0) {
349
+ const allFailed = history.every(h => !h.accepted);
350
+ if (allFailed) {
351
+ errors.push('All parallel turns failed acceptance — stalled');
352
+ return { terminal: true, ok: false, stop_reason: 'blocked', history, acceptedCount };
353
+ }
354
+ }
355
+
356
+ return { terminal: false, history, acceptedCount };
357
+ }
358
+
359
+ /**
360
+ * Dispatch a single turn and process its result.
361
+ */
362
+ async function dispatchAndProcess(root, config, turn, assignState, callbacks, emit, errors) {
363
+ const roleId = turn.assigned_role;
364
+ const history = [];
365
+
366
+ const bundleResult = writeDispatchBundle(root, assignState, config);
367
+ if (!bundleResult.ok) {
368
+ errors.push(`writeDispatchBundle(${roleId}): ${bundleResult.error}`);
369
+ return { terminal: true, ok: false, stop_reason: 'blocked', history };
370
+ }
371
+
372
+ const stagingPath = getTurnStagingResultPath(turn.turn_id);
373
+ const context = {
374
+ turn,
375
+ state: assignState,
376
+ bundlePath: bundleResult.bundlePath,
377
+ stagingPath,
378
+ config,
379
+ root,
380
+ };
381
+
382
+ let dispatchResult;
383
+ try {
384
+ dispatchResult = await callbacks.dispatch(context);
385
+ } catch (err) {
386
+ errors.push(`dispatch threw for ${roleId}: ${err.message}`);
387
+ return { terminal: true, ok: false, stop_reason: 'dispatch_error', history };
388
+ }
389
+
390
+ if (dispatchResult.accept) {
391
+ const absStaging = join(root, stagingPath);
392
+ mkdirSync(dirname(absStaging), { recursive: true });
393
+ writeFileSync(absStaging, JSON.stringify(dispatchResult.turnResult, null, 2));
394
+
395
+ const acceptResult = acceptTurn(root, config);
396
+ if (!acceptResult.ok) {
397
+ errors.push(`acceptTurn(${roleId}): ${acceptResult.error}`);
398
+ return { terminal: true, ok: false, stop_reason: 'blocked', history };
399
+ }
400
+
401
+ history.push({ role: roleId, turn_id: turn.turn_id, accepted: true });
402
+ emit({ type: 'turn_accepted', turn, role: roleId, state: acceptResult.state });
403
+ return { terminal: false, accepted: true, history };
404
+ }
405
+
406
+ // Rejection
407
+ const validationResult = {
408
+ stage: 'dispatch',
409
+ errors: [dispatchResult.reason || 'Dispatch callback rejected the turn'],
410
+ };
411
+ rejectTurn(root, config, validationResult, dispatchResult.reason || 'Dispatch rejection');
412
+ history.push({ role: roleId, turn_id: turn.turn_id, accepted: false });
413
+ emit({ type: 'turn_rejected', turn, role: roleId, reason: dispatchResult.reason });
414
+
415
+ const postRejectState = loadState(root, config);
416
+ if (postRejectState?.status === 'blocked') {
417
+ errors.push(`Turn rejected for ${roleId}, retries exhausted`);
418
+ emit({ type: 'blocked', state: postRejectState });
419
+ return { terminal: true, ok: false, stop_reason: 'reject_exhausted', history };
420
+ }
421
+
422
+ return { terminal: false, accepted: false, history };
214
423
  }
215
424
 
216
425
  /**
@@ -9,6 +9,7 @@ import {
9
9
  validateAcceptanceHintCompletion,
10
10
  validateGovernedWorkflowKit,
11
11
  } from './governed-templates.js';
12
+ import { collectRemoteReviewOnlyGateWarnings } from './normalized-config.js';
12
13
 
13
14
  const DEFAULT_REQUIRED_FILES = [
14
15
  '.planning/PROJECT.md',
@@ -115,6 +116,9 @@ export function validateGovernedProject(root, rawConfig, config, opts = {}) {
115
116
  errors.push(...workflowKit.errors);
116
117
  warnings.push(...workflowKit.warnings);
117
118
 
119
+ // Config-shape warnings (dead-end gates, etc.) — mirrors doctor/config --set surfaces
120
+ warnings.push(...collectRemoteReviewOnlyGateWarnings(rawConfig));
121
+
118
122
  const mustExist = [
119
123
  config.files?.state || '.agentxchain/state.json',
120
124
  config.files?.history || '.agentxchain/history.jsonl',