agentxchain 2.17.0 → 2.18.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/README.md CHANGED
@@ -59,7 +59,7 @@ agentxchain status
59
59
  agentxchain step --role pm
60
60
  ```
61
61
 
62
- The default governed dev runtime is `claude --print` with stdin prompt delivery. If your local coding agent uses a different launch contract, set it during scaffold creation:
62
+ The default governed dev runtime is `claude --print --dangerously-skip-permissions` with stdin prompt delivery. The non-interactive governed path needs write access, so do not pretend bare `claude --print` is sufficient for unattended implementation turns. If your local coding agent uses a different launch contract, set it during scaffold creation:
63
63
 
64
64
  ```bash
65
65
  npx agentxchain init --governed --dir my-agentxchain-project --dev-command ./scripts/dev-agent.sh --dev-prompt-transport dispatch_bundle_only -y
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -96,7 +96,7 @@ const GOVERNED_ROLES = {
96
96
 
97
97
  const DEFAULT_GOVERNED_LOCAL_DEV_RUNTIME = Object.freeze({
98
98
  type: 'local_cli',
99
- command: ['claude', '--print'],
99
+ command: ['claude', '--print', '--dangerously-skip-permissions'],
100
100
  cwd: '.',
101
101
  prompt_transport: 'stdin',
102
102
  });
@@ -221,6 +221,8 @@ You are the Developer. Your mandate: **${role.mandate}**
221
221
  You must run verification commands and report them honestly:
222
222
  - \`verification.status\` must be \`"pass"\` only if all commands exited with code 0
223
223
  - \`verification.machine_evidence\` must list every command you ran with its actual exit code
224
+ - Expected-failure checks must be wrapped in a test harness or shell assertion that exits 0 only when the failure occurs as expected
225
+ - Do not mix raw non-zero negative-case commands into a passing turn; put them behind \`npm test\`, \`node --test\`, or an equivalent zero-exit verifier
224
226
  - Do NOT claim \`"pass"\` if you did not run the tests
225
227
 
226
228
  ## Phase Transition
@@ -246,7 +248,7 @@ You are QA. Your mandate: **${role.mandate}**
246
248
  1. **Read the previous turn, the ROADMAP, and the acceptance matrix.** Understand what was built and what the acceptance criteria are.
247
249
  2. **Challenge the implementation.** You MUST raise at least one objection — this is a protocol requirement for review_only roles. If the code is perfect, challenge the test coverage, the edge cases, or the documentation.
248
250
  3. **Evaluate against acceptance criteria.** Go through each criterion and determine pass/fail.
249
- 4. **Create review artifacts:**
251
+ 4. **Produce a review outcome:**
250
252
  - \`.planning/acceptance-matrix.md\` — updated with pass/fail verdicts per criterion
251
253
  - \`.planning/ship-verdict.md\` — your overall ship/no-ship recommendation
252
254
  - \`.planning/RELEASE_NOTES.md\` — user-facing release notes with impact and verification summary
@@ -255,6 +257,12 @@ You are QA. Your mandate: **${role.mandate}**
255
257
 
256
258
  You have \`review_only\` write authority. You may NOT modify product files. You may only create/modify files under \`.planning/\` and \`.agentxchain/reviews/\`. Your artifact type must be \`review\`.
257
259
 
260
+ ## Runtime Truth
261
+
262
+ - If your runtime is **manual** or another writable review path, you may update the QA-owned planning files directly.
263
+ - If your runtime is **api_proxy**, you cannot write repo files directly. Do **not** claim you created \`.planning/*\` files unless a writable/manual step actually changed them.
264
+ - For \`api_proxy\` review turns, the orchestrator will materialize a review artifact under \`.agentxchain/reviews/<turn_id>-<role>-review.md\` from your structured result.
265
+
258
266
  ## Objection Requirement
259
267
 
260
268
  You MUST raise at least one objection in your turn result. An empty \`objections\` array is a protocol violation and will be rejected by the validator. If the work is genuinely excellent, raise a low-severity observation about test coverage, documentation, or future risk.
@@ -275,9 +283,8 @@ Each objection must have:
275
283
  ## Ship Verdict & Run Completion
276
284
 
277
285
  When you are satisfied the work meets acceptance criteria:
278
- 1. Create \`.planning/ship-verdict.md\` with your verdict
279
- 2. Create/update \`.planning/acceptance-matrix.md\` with all criteria checked
280
- 3. Create/update \`.planning/RELEASE_NOTES.md\` with \`## User Impact\` and \`## Verification Summary\`
286
+ 1. If you are on a writable/manual review path, create/update the QA-owned planning artifacts with your verdict
287
+ 2. If you are on \`api_proxy\`, put the verdict and rationale in the structured turn result and review artifact instead of claiming repo writes you did not make
281
288
  4. Set \`run_completion_request: true\` in your turn result
282
289
 
283
290
  **Only set \`run_completion_request: true\` when:**
@@ -535,7 +542,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
535
542
  }
536
543
 
537
544
  // Planning artifacts
538
- writeFileSync(join(dir, '.planning', 'PM_SIGNOFF.md'), `# PM Signoff — ${projectName}\n\nApproved: NO\n\n## Discovery Checklist\n- [ ] Target user defined\n- [ ] Core pain point defined\n- [ ] Core workflow defined\n- [ ] MVP scope defined\n- [ ] Out-of-scope list defined\n- [ ] Success metric defined\n\n## Notes for team\n(PM and human add final kickoff notes here.)\n`);
545
+ writeFileSync(join(dir, '.planning', 'PM_SIGNOFF.md'), `# PM Signoff — ${projectName}\n\nApproved: NO\n\n> This scaffold starts blocked on purpose. Change this to \`Approved: YES\` only after a human reviews the planning artifacts and is ready to open the planning gate.\n\n## Discovery Checklist\n- [ ] Target user defined\n- [ ] Core pain point defined\n- [ ] Core workflow defined\n- [ ] MVP scope defined\n- [ ] Out-of-scope list defined\n- [ ] Success metric defined\n\n## Notes for team\n(PM and human add final kickoff notes here.)\n`);
539
546
  writeFileSync(join(dir, '.planning', 'ROADMAP.md'), `# Roadmap — ${projectName}\n\n## Phases\n\n| Phase | Goal | Status |\n|-------|------|--------|\n| Planning | Align scope, requirements, acceptance criteria | In progress |\n| Implementation | Build and verify | Pending |\n| QA | Challenge correctness and ship readiness | Pending |\n`);
540
547
  writeFileSync(join(dir, '.planning', 'SYSTEM_SPEC.md'), buildSystemSpecContent(projectName, template.system_spec_overlay));
541
548
  writeFileSync(join(dir, '.planning', 'IMPLEMENTATION_NOTES.md'), `# Implementation Notes — ${projectName}\n\n## Changes\n\n(Dev fills this during implementation)\n\n## Verification\n\n(Dev fills this during implementation)\n\n## Unresolved Follow-ups\n\n(Dev lists any known gaps, tech debt, or follow-up items here.)\n`);
@@ -907,7 +914,7 @@ export async function initCommand(opts) {
907
914
  writeFileSync(join(dir, '.planning', 'REQUIREMENTS.md'), `# Requirements — ${project}\n\n## v1 (MVP)\n\n(PM fills this: numbered list of requirements. Each requirement has one-sentence acceptance criteria.)\n\n| # | Requirement | Acceptance criteria | Phase | Status |\n|---|-------------|-------------------|-------|--------|\n| 1 | | | | Pending |\n\n## v2 (Future)\n\n(Out of scope for MVP. Captured here so they don't creep in.)\n\n## Out of scope\n\n(Explicitly not building.)\n`);
908
915
 
909
916
  writeFileSync(join(dir, '.planning', 'ROADMAP.md'), `# Roadmap — ${project}\n\n## Waves\n\n| Wave | Goal | Status |\n|------|------|--------|\n| Wave 1 | Discovery, planning, and phase setup | In progress |\n\n## Phases\n\n| Phase | Description | Status | Requirements |\n|-------|-------------|--------|-------------|\n| 1 | Discovery + setup | In progress | — |\n\n(PM updates this as phases are planned and completed.)\n`);
910
- writeFileSync(join(dir, '.planning', 'PM_SIGNOFF.md'), `# PM Signoff — ${project}\n\nApproved: NO\n\n## Discovery Checklist\n- [ ] Target user defined\n- [ ] Core pain point defined\n- [ ] Core workflow defined\n- [ ] MVP scope defined\n- [ ] Out-of-scope list defined\n- [ ] Success metric defined\n\n## Notes for team\n(PM and human add final kickoff notes here.)\n`);
917
+ writeFileSync(join(dir, '.planning', 'PM_SIGNOFF.md'), `# PM Signoff — ${project}\n\nApproved: NO\n\n> This scaffold starts blocked on purpose. Change this to \`Approved: YES\` only after a human reviews the planning artifacts and is ready to open the planning gate.\n\n## Discovery Checklist\n- [ ] Target user defined\n- [ ] Core pain point defined\n- [ ] Core workflow defined\n- [ ] MVP scope defined\n- [ ] Out-of-scope list defined\n- [ ] Success metric defined\n\n## Notes for team\n(PM and human add final kickoff notes here.)\n`);
911
918
 
912
919
  // QA structure
913
920
  mkdirSync(join(dir, '.planning', 'phases', 'phase-1'), { recursive: true });
@@ -40,7 +40,8 @@ export async function startCommand(opts) {
40
40
  console.log(chalk.dim(` - ${e}`));
41
41
  }
42
42
  console.log('');
43
- console.log(chalk.dim(' Suggested next step: complete .planning/PM_SIGNOFF.md and roadmap waves/phases, then run:'));
43
+ console.log(chalk.dim(' Suggested next step: complete .planning/PM_SIGNOFF.md and roadmap waves/phases.'));
44
+ console.log(chalk.dim(' Fresh governed scaffolds start at `Approved: NO`; flip that line to `Approved: YES` only after human kickoff approval, then run:'));
44
45
  console.log(chalk.bold(' agentxchain validate --mode kickoff'));
45
46
  console.log('');
46
47
  process.exit(1);
@@ -7,6 +7,7 @@ const SECTION_DEFINITIONS = [
7
7
  { id: 'last_turn_summary', header: null, required: false },
8
8
  { id: 'last_turn_decisions', header: null, required: false },
9
9
  { id: 'last_turn_objections', header: null, required: false },
10
+ { id: 'last_turn_verification', header: null, required: false },
10
11
  { id: 'blockers', header: 'Blockers', required: true },
11
12
  { id: 'escalation', header: 'Escalation', required: true },
12
13
  { id: 'gate_required_files', header: 'Gate Required Files', required: false },
@@ -48,12 +49,14 @@ export function parseContextSections(contextMd) {
48
49
  summaryLines,
49
50
  decisionsLines,
50
51
  objectionsLines,
52
+ verificationLines,
51
53
  } = splitLastAcceptedTurn(lastAcceptedTurnBody);
52
54
 
53
55
  pushSection(parsedSections, 'last_turn_header', headerLines);
54
56
  pushSection(parsedSections, 'last_turn_summary', summaryLines);
55
57
  pushSection(parsedSections, 'last_turn_decisions', decisionsLines);
56
58
  pushSection(parsedSections, 'last_turn_objections', objectionsLines);
59
+ pushSection(parsedSections, 'last_turn_verification', verificationLines);
57
60
  }
58
61
 
59
62
  for (const [header, id] of HEADER_TO_ID.entries()) {
@@ -80,6 +83,7 @@ export function renderContextSections(sections) {
80
83
  sectionMap.get('last_turn_summary')?.content,
81
84
  sectionMap.get('last_turn_decisions')?.content,
82
85
  sectionMap.get('last_turn_objections')?.content,
86
+ sectionMap.get('last_turn_verification')?.content,
83
87
  ]);
84
88
 
85
89
  appendTopLevelSection(lines, 'Blockers', [sectionMap.get('blockers')?.content]);
@@ -91,11 +95,20 @@ export function renderContextSections(sections) {
91
95
  }
92
96
 
93
97
  function appendTopLevelSection(lines, header, fragments) {
94
- const content = fragments
95
- .filter((fragment) => typeof fragment === 'string' && fragment.length > 0)
96
- .join('\n');
98
+ const validFragments = fragments
99
+ .filter((fragment) => typeof fragment === 'string' && fragment.length > 0);
97
100
 
98
- if (!content) return;
101
+ if (validFragments.length === 0) return;
102
+
103
+ // Join fragments with a blank-line separator before sub-headings (###)
104
+ const contentParts = [];
105
+ for (const fragment of validFragments) {
106
+ if (contentParts.length > 0 && fragment.startsWith('###')) {
107
+ contentParts.push('');
108
+ }
109
+ contentParts.push(fragment);
110
+ }
111
+ const content = contentParts.join('\n');
99
112
 
100
113
  lines.push(`## ${header}`);
101
114
  lines.push('');
@@ -106,9 +119,13 @@ function appendTopLevelSection(lines, header, fragments) {
106
119
  function splitTopLevelSections(contextMd) {
107
120
  const lines = normalizeNewlines(contextMd).split('\n');
108
121
  const sectionStarts = [];
122
+ let inCodeBlock = false;
109
123
 
110
124
  for (let index = 0; index < lines.length; index += 1) {
111
- if (lines[index].startsWith('## ')) {
125
+ if (lines[index].startsWith('```')) {
126
+ inCodeBlock = !inCodeBlock;
127
+ }
128
+ if (!inCodeBlock && lines[index].startsWith('## ')) {
112
129
  sectionStarts.push(index);
113
130
  }
114
131
  }
@@ -130,10 +147,30 @@ function splitLastAcceptedTurn(lines) {
130
147
  let summaryLines = [];
131
148
  let decisionsLines = [];
132
149
  let objectionsLines = [];
150
+ let verificationLines = [];
151
+
152
+ let inVerification = false;
133
153
 
134
154
  for (let index = 0; index < lines.length; index += 1) {
135
155
  const line = lines[index];
136
156
 
157
+ if (line.startsWith('### Verification')) {
158
+ inVerification = true;
159
+ verificationLines.push(line);
160
+ continue;
161
+ }
162
+
163
+ if (inVerification) {
164
+ // A new heading at level 2 or 3 ends the verification block
165
+ if (line.startsWith('## ') || (line.startsWith('### ') && !line.startsWith('### Verification'))) {
166
+ inVerification = false;
167
+ headerLines.push(line);
168
+ continue;
169
+ }
170
+ verificationLines.push(line);
171
+ continue;
172
+ }
173
+
137
174
  if (SUMMARY_LINE_PATTERN.test(line)) {
138
175
  summaryLines = [line];
139
176
  continue;
@@ -161,6 +198,7 @@ function splitLastAcceptedTurn(lines) {
161
198
  summaryLines: trimBlankLines(summaryLines),
162
199
  decisionsLines: trimBlankLines(decisionsLines),
163
200
  objectionsLines: trimBlankLines(objectionsLines),
201
+ verificationLines: trimBlankLines(verificationLines),
164
202
  };
165
203
  }
166
204
 
@@ -21,12 +21,19 @@ import {
21
21
  DISPATCH_INDEX_PATH,
22
22
  getDispatchAssignmentPath,
23
23
  getDispatchContextPath,
24
+ getDispatchLogPath,
24
25
  getDispatchPromptPath,
26
+ getReviewArtifactPath,
25
27
  getDispatchTurnDir,
26
28
  getTurnStagingResultPath,
27
29
  } from './turn-paths.js';
28
30
 
29
31
  const HISTORY_PATH = '.agentxchain/history.jsonl';
32
+ const FILE_PREVIEW_MAX_FILES = 5;
33
+ const FILE_PREVIEW_MAX_LINES = 120;
34
+ const GATE_FILE_PREVIEW_MAX_LINES = 60;
35
+ const DISPATCH_LOG_MAX_LINES = 50;
36
+ const DISPATCH_LOG_MAX_LINE_BYTES = 8192;
30
37
 
31
38
  // Reserved paths that agents must never modify
32
39
  const RESERVED_PATHS = [
@@ -125,7 +132,7 @@ export function writeDispatchBundle(root, state, config, opts = {}) {
125
132
  writeFileSync(join(root, getDispatchPromptPath(turn.turn_id)), prompt.content);
126
133
 
127
134
  // 3. CONTEXT.md
128
- const context = renderContext(state, config, root);
135
+ const context = renderContext(state, config, root, turn, role);
129
136
  warnings.push(...context.warnings);
130
137
  writeFileSync(join(root, getDispatchContextPath(turn.turn_id)), context.content);
131
138
 
@@ -143,6 +150,8 @@ function renderPrompt(role, roleId, turn, state, config, root) {
143
150
  const routing = config.routing?.[phase];
144
151
  const exitGate = routing?.exit_gate;
145
152
  const gateConfig = exitGate ? config.gates?.[exitGate] : null;
153
+ const runtime = config.runtimes?.[turn.runtime_id];
154
+ const runtimeType = runtime?.type || 'manual';
146
155
  const warnings = [];
147
156
 
148
157
  // Load custom prompt template from disk (best-effort)
@@ -200,6 +209,13 @@ function renderPrompt(role, roleId, turn, state, config, root) {
200
209
  lines.push('- You may create/modify files under `.planning/` and `.agentxchain/reviews/`.');
201
210
  lines.push('- Your artifact type must be `review`.');
202
211
  lines.push('- You MUST raise at least one objection (even if minor).');
212
+ if (runtimeType === 'api_proxy') {
213
+ const reviewArtifactPath = getReviewArtifactPath(turn.turn_id, roleId);
214
+ lines.push('- **This runtime cannot write repo files directly.** Do NOT claim `.planning/*` or `.agentxchain/reviews/*` changes you did not actually make.');
215
+ lines.push(`- The orchestrator will materialize your accepted review at \`${reviewArtifactPath}\`.`);
216
+ lines.push('- Use `summary`, `decisions`, `objections`, and `verification.evidence_summary` to communicate the review content.');
217
+ lines.push('- Gate file contents and semantic status are shown in CONTEXT.md under "Gate Required Files". Check them before requesting run completion.');
218
+ }
203
219
  lines.push('');
204
220
  } else if (role.write_authority === 'authoritative') {
205
221
  lines.push('### Write Authority: authoritative');
@@ -320,14 +336,51 @@ function renderPrompt(role, roleId, turn, state, config, root) {
320
336
  lines.push('- `objections[].id`: pattern `OBJ-NNN`');
321
337
  lines.push('- `objections[].severity`: one of `low`, `medium`, `high`, `blocking`');
322
338
  lines.push('- `verification.status`: one of `pass`, `fail`, `skipped`');
339
+ lines.push('- `verification.status: "pass"` is valid only when every `verification.machine_evidence[].exit_code` is `0`');
340
+ lines.push('- Expected-failure checks must be wrapped in a verifier that exits `0` when the failure occurs as expected; do not list raw non-zero negative-case commands on a passing turn');
323
341
  lines.push('- `artifact.type`: one of `workspace`, `patch`, `commit`, `review`');
324
342
  lines.push('- `proposed_next_role`: must be in allowed_next_roles for current phase, or `human`');
325
343
  if (role.write_authority === 'review_only') {
326
344
  lines.push('- `objections`: **must be non-empty** (challenge requirement for review_only roles)');
327
345
  }
328
- lines.push('- `phase_transition_request`: set to next phase name when gate requirements are met, or `null`');
346
+ // List valid phase names explicitly to prevent gate-name confusion
347
+ const phaseNames = config.routing ? Object.keys(config.routing) : [];
348
+ if (phaseNames.length > 0) {
349
+ lines.push(`- \`phase_transition_request\`: set to a **phase name** when gate requirements are met, or \`null\`. Valid phases: ${phaseNames.map((p) => `\`"${p}"\``).join(', ')}`);
350
+ lines.push('- **Do NOT use exit gate names** (e.g., `planning_signoff`, `implementation_complete`, `qa_ship_verdict`) as `phase_transition_request` values — those are gate identifiers, not phase names');
351
+ } else {
352
+ lines.push('- `phase_transition_request`: set to next phase name when gate requirements are met, or `null`');
353
+ }
329
354
  lines.push('- `run_completion_request`: set to `true` only in the final phase when ready to ship, or `null`');
330
355
  lines.push('- `phase_transition_request` and `run_completion_request` are **mutually exclusive**');
356
+ // Phase-specific guidance for authoritative roles
357
+ if (role.write_authority === 'authoritative' && phaseNames.length > 0) {
358
+ const currentPhase = state?.phase;
359
+ const phaseIdx = currentPhase ? phaseNames.indexOf(currentPhase) : -1;
360
+ if (phaseIdx >= 0 && phaseIdx < phaseNames.length - 1) {
361
+ const nextPhase = phaseNames[phaseIdx + 1];
362
+ const currentGate = config.routing?.[currentPhase]?.exit_gate;
363
+ const gateClause = currentGate ? ` and the exit gate (\`${currentGate}\`) is satisfied` : '';
364
+ lines.push(`- **You are in the \`${currentPhase}\` phase.** When your work is complete${gateClause}, set \`phase_transition_request: "${nextPhase}"\` to advance to the next phase.`);
365
+ } else if (phaseIdx === phaseNames.length - 1) {
366
+ lines.push(`- **You are in the \`${currentPhase}\` phase (final phase).** When ready to ship, set \`run_completion_request: true\` and \`phase_transition_request: null\`.`);
367
+ }
368
+ }
369
+ // Phase-specific guidance for review_only roles (terminal phase ship readiness)
370
+ if (role.write_authority === 'review_only' && phaseNames.length > 0) {
371
+ const currentPhase = state?.phase;
372
+ const isTerminal = currentPhase && phaseNames.indexOf(currentPhase) === phaseNames.length - 1;
373
+ if (isTerminal) {
374
+ lines.push(`- **You are in the \`${currentPhase}\` phase (final phase).**`);
375
+ lines.push('- **If your review verdict is ship-ready (no blocking issues):** set `run_completion_request: true` and `status: "completed"`. This triggers the human approval gate — it does NOT bypass human review.');
376
+ lines.push('- **If you found genuine blocking issues that prevent shipping:** set `status: "needs_human"` and explain the blockers in `needs_human_reason`.');
377
+ lines.push('- Do NOT use `status: "needs_human"` to mean "human should approve the release." That is what `run_completion_request: true` is for.');
378
+ lines.push('- Do NOT set `phase_transition_request` to the exit gate name.');
379
+ if (runtimeType === 'api_proxy') {
380
+ 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.');
381
+ }
382
+ }
383
+ }
331
384
  lines.push('');
332
385
 
333
386
  return {
@@ -338,7 +391,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
338
391
 
339
392
  // ── Context Rendering ───────────────────────────────────────────────────────
340
393
 
341
- function renderContext(state, config, root) {
394
+ function renderContext(state, config, root, turn, role) {
342
395
  const warnings = [];
343
396
  const lines = [];
344
397
 
@@ -382,6 +435,105 @@ function renderContext(state, config, root) {
382
435
  }
383
436
  }
384
437
  lines.push('');
438
+
439
+ // Files changed by the previous turn
440
+ const filesChanged = lastTurn.files_changed;
441
+ if (Array.isArray(filesChanged) && filesChanged.length > 0) {
442
+ lines.push('### Files Changed');
443
+ lines.push('');
444
+ for (const f of filesChanged) {
445
+ lines.push(`- \`${f}\``);
446
+ }
447
+ lines.push('');
448
+ }
449
+
450
+ const filePreviews = role?.write_authority === 'review_only'
451
+ ? buildChangedFilePreviews(root, filesChanged)
452
+ : [];
453
+ if (filePreviews.length > 0) {
454
+ lines.push('### Changed File Previews');
455
+ lines.push('');
456
+ for (const preview of filePreviews) {
457
+ lines.push(`#### \`${preview.path}\``);
458
+ lines.push('');
459
+ lines.push('```');
460
+ lines.push(preview.content);
461
+ lines.push('```');
462
+ if (preview.truncated) {
463
+ lines.push('');
464
+ lines.push(`_Preview truncated after ${FILE_PREVIEW_MAX_LINES} lines._`);
465
+ }
466
+ lines.push('');
467
+ }
468
+ }
469
+
470
+ // Verification evidence from the previous turn
471
+ // Use raw verification (has commands, machine_evidence, evidence_summary)
472
+ // and supplement with normalized_verification status when available
473
+ const v = lastTurn.verification;
474
+ if (v && typeof v === 'object' && Object.keys(v).length > 0) {
475
+ lines.push('### Verification');
476
+ lines.push('');
477
+ if (v.status) {
478
+ lines.push(`- **Status:** ${v.status}`);
479
+ }
480
+ const nv = lastTurn.normalized_verification;
481
+ if (nv?.status && nv.status !== v.status) {
482
+ lines.push(`- **Normalized status:** ${nv.status} — ${nv.reason || ''}`);
483
+ }
484
+ if (Array.isArray(v.commands) && v.commands.length > 0) {
485
+ lines.push('- **Commands:**');
486
+ for (const cmd of v.commands) {
487
+ lines.push(` - \`${cmd}\``);
488
+ }
489
+ }
490
+ if (v.evidence_summary) {
491
+ lines.push(`- **Evidence summary:** ${v.evidence_summary}`);
492
+ }
493
+ if (Array.isArray(v.machine_evidence) && v.machine_evidence.length > 0) {
494
+ lines.push('- **Machine evidence:**');
495
+ lines.push('');
496
+ lines.push(' | Command | Exit Code |');
497
+ lines.push(' |---------|-----------|');
498
+ for (const me of v.machine_evidence) {
499
+ lines.push(` | \`${me.command || '(unknown)'}\` | ${me.exit_code ?? '?'} |`);
500
+ }
501
+ }
502
+ lines.push('');
503
+ }
504
+
505
+ // Dispatch log excerpt for review-only turns
506
+ if (role?.write_authority === 'review_only' && lastTurn.turn_id) {
507
+ const logExcerpt = buildDispatchLogExcerpt(root, lastTurn.turn_id);
508
+ if (logExcerpt) {
509
+ lines.push('### Dispatch Log Excerpt');
510
+ lines.push('');
511
+ if (logExcerpt.truncated) {
512
+ lines.push(`_Log truncated — showing last ${DISPATCH_LOG_MAX_LINES} lines of ${logExcerpt.totalLines} total._`);
513
+ lines.push('');
514
+ }
515
+ lines.push('```');
516
+ lines.push(logExcerpt.content);
517
+ lines.push('```');
518
+ lines.push('');
519
+ }
520
+ }
521
+
522
+ // Observed artifact from the previous turn
523
+ const obs = lastTurn.observed_artifact;
524
+ if (obs && typeof obs === 'object') {
525
+ const obsFiles = obs.files_changed;
526
+ if (Array.isArray(obsFiles) && obsFiles.length > 0) {
527
+ lines.push('### Observed Artifact');
528
+ lines.push('');
529
+ lines.push(`- **Files observed:** ${obsFiles.length}`);
530
+ if (typeof obs.lines_added === 'number' || typeof obs.lines_removed === 'number') {
531
+ lines.push(`- **Lines added:** ${obs.lines_added ?? 0}`);
532
+ lines.push(`- **Lines removed:** ${obs.lines_removed ?? 0}`);
533
+ }
534
+ lines.push('');
535
+ }
536
+ }
385
537
  }
386
538
  }
387
539
 
@@ -410,9 +562,35 @@ function renderContext(state, config, root) {
410
562
  if (gateConfig?.requires_files) {
411
563
  lines.push('## Gate Required Files');
412
564
  lines.push('');
565
+ const isReviewRole = role?.write_authority === 'review_only';
413
566
  for (const f of gateConfig.requires_files) {
414
- const exists = existsSync(join(root, f));
415
- lines.push(`- \`${f}\` — ${exists ? 'exists' : 'MISSING'}`);
567
+ const absPath = join(root, f);
568
+ const exists = existsSync(absPath);
569
+ if (isReviewRole) {
570
+ lines.push(`### \`${f}\` — ${exists ? 'exists' : 'MISSING'}`);
571
+ lines.push('');
572
+ if (exists) {
573
+ const gatePreview = buildGateFilePreview(absPath);
574
+ if (gatePreview) {
575
+ // Semantic annotations for known gate files
576
+ const semantic = extractGateFileSemantic(f, gatePreview.raw);
577
+ if (semantic) {
578
+ lines.push(`**Gate semantic: ${semantic}**`);
579
+ lines.push('');
580
+ }
581
+ lines.push('```');
582
+ lines.push(gatePreview.content);
583
+ lines.push('```');
584
+ if (gatePreview.truncated) {
585
+ lines.push('');
586
+ lines.push(`_Preview truncated after ${GATE_FILE_PREVIEW_MAX_LINES} lines._`);
587
+ }
588
+ lines.push('');
589
+ }
590
+ }
591
+ } else {
592
+ lines.push(`- \`${f}\` — ${exists ? 'exists' : 'MISSING'}`);
593
+ }
416
594
  }
417
595
  lines.push('');
418
596
  }
@@ -433,6 +611,126 @@ function renderContext(state, config, root) {
433
611
  };
434
612
  }
435
613
 
614
+ function buildGateFilePreview(absPath) {
615
+ let raw;
616
+ try {
617
+ raw = readFileSync(absPath, 'utf8');
618
+ } catch {
619
+ return null;
620
+ }
621
+ const lines = raw.replace(/\r\n/g, '\n').split('\n');
622
+ const truncated = lines.length > GATE_FILE_PREVIEW_MAX_LINES;
623
+ const previewLines = truncated ? lines.slice(0, GATE_FILE_PREVIEW_MAX_LINES) : lines;
624
+ return {
625
+ raw,
626
+ content: previewLines.join('\n').trimEnd(),
627
+ truncated,
628
+ };
629
+ }
630
+
631
+ function extractGateFileSemantic(relPath, raw) {
632
+ const lower = relPath.toLowerCase();
633
+ if (lower.endsWith('pm_signoff.md')) {
634
+ const match = raw.match(/^Approved:\s*(YES|NO|PENDING)/im);
635
+ if (match && match[1].toUpperCase() === 'YES') {
636
+ return 'Approved: YES';
637
+ }
638
+ return 'approval not found';
639
+ }
640
+ if (lower.endsWith('ship-verdict.md')) {
641
+ const match = raw.match(/^##\s*Verdict:\s*(YES|SHIP|SHIP IT|NO|PENDING)/im);
642
+ if (match) {
643
+ const val = match[1].toUpperCase();
644
+ if (val === 'YES' || val === 'SHIP' || val === 'SHIP IT') {
645
+ return `Verdict: ${match[1]}`;
646
+ }
647
+ return 'verdict not affirmative';
648
+ }
649
+ return 'verdict not affirmative';
650
+ }
651
+ return null;
652
+ }
653
+
654
+ function buildChangedFilePreviews(root, filesChanged) {
655
+ if (!Array.isArray(filesChanged) || filesChanged.length === 0) {
656
+ return [];
657
+ }
658
+
659
+ const previews = [];
660
+ for (const relPath of filesChanged.slice(0, FILE_PREVIEW_MAX_FILES)) {
661
+ const absPath = join(root, relPath);
662
+ if (!existsSync(absPath)) {
663
+ continue;
664
+ }
665
+
666
+ let raw;
667
+ try {
668
+ raw = readFileSync(absPath, 'utf8');
669
+ } catch {
670
+ continue;
671
+ }
672
+
673
+ const lines = raw.replace(/\r\n/g, '\n').split('\n');
674
+ const truncated = lines.length > FILE_PREVIEW_MAX_LINES;
675
+ const previewLines = truncated ? lines.slice(0, FILE_PREVIEW_MAX_LINES) : lines;
676
+ previews.push({
677
+ path: relPath,
678
+ content: previewLines.join('\n').trimEnd(),
679
+ truncated,
680
+ });
681
+ }
682
+
683
+ return previews;
684
+ }
685
+
686
+ function buildDispatchLogExcerpt(root, turnId) {
687
+ const logPath = join(root, getDispatchLogPath(turnId));
688
+ if (!existsSync(logPath)) {
689
+ return null;
690
+ }
691
+
692
+ let raw;
693
+ try {
694
+ raw = readFileSync(logPath, 'utf8');
695
+ } catch {
696
+ return null;
697
+ }
698
+
699
+ if (!raw || raw.trim().length === 0) {
700
+ return null;
701
+ }
702
+
703
+ const allLines = raw.replace(/\r\n/g, '\n').split('\n');
704
+ // Remove trailing empty line from split
705
+ if (allLines.length > 0 && allLines[allLines.length - 1] === '') {
706
+ allLines.pop();
707
+ }
708
+
709
+ const totalLines = allLines.length;
710
+ if (totalLines === 0) {
711
+ return null;
712
+ }
713
+
714
+ const truncated = totalLines > DISPATCH_LOG_MAX_LINES;
715
+ const selectedLines = truncated
716
+ ? allLines.slice(totalLines - DISPATCH_LOG_MAX_LINES)
717
+ : allLines;
718
+
719
+ // Per-line byte cap
720
+ const cappedLines = selectedLines.map((line) => {
721
+ if (Buffer.byteLength(line, 'utf8') > DISPATCH_LOG_MAX_LINE_BYTES) {
722
+ return line.slice(0, DISPATCH_LOG_MAX_LINE_BYTES) + '…';
723
+ }
724
+ return line;
725
+ });
726
+
727
+ return {
728
+ content: cappedLines.join('\n').trimEnd(),
729
+ truncated,
730
+ totalLines,
731
+ };
732
+ }
733
+
436
734
  // ── Helpers ─────────────────────────────────────────────────────────────────
437
735
 
438
736
  function resolveTargetTurn(state, turnId) {
@@ -32,7 +32,7 @@ import {
32
32
  checkCleanBaseline,
33
33
  } from './repo-observer.js';
34
34
  import { getMaxConcurrentTurns } from './normalized-config.js';
35
- import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir } from './turn-paths.js';
35
+ import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
36
36
  import { runHooks } from './hook-runner.js';
37
37
  import { emitNotifications } from './notification-runner.js';
38
38
 
@@ -77,6 +77,84 @@ function emitPendingLifecycleNotification(root, config, state, eventType, payloa
77
77
  emitNotifications(root, config, state, eventType, payload, turn);
78
78
  }
79
79
 
80
+ function normalizeDerivedReviewPath(turnResult) {
81
+ const requestedPath = typeof turnResult?.artifact?.ref === 'string' ? turnResult.artifact.ref.trim() : '';
82
+ if (requestedPath.startsWith('.agentxchain/reviews/')) {
83
+ return requestedPath;
84
+ }
85
+ return getReviewArtifactPath(turnResult.turn_id, turnResult.role);
86
+ }
87
+
88
+ function renderDerivedReviewArtifact(turnResult, state) {
89
+ const lines = [];
90
+ lines.push(`# Review Artifact — ${turnResult.role}`);
91
+ lines.push('');
92
+ lines.push(`- **Run:** ${turnResult.run_id}`);
93
+ lines.push(`- **Turn:** ${turnResult.turn_id}`);
94
+ lines.push(`- **Phase:** ${state.phase}`);
95
+ lines.push(`- **Status:** ${turnResult.status}`);
96
+ lines.push(`- **Proposed next role:** ${turnResult.proposed_next_role || 'human'}`);
97
+ lines.push('');
98
+ lines.push('## Summary');
99
+ lines.push('');
100
+ lines.push(turnResult.summary || 'No summary provided.');
101
+ lines.push('');
102
+ lines.push('## Decisions');
103
+ lines.push('');
104
+ if (Array.isArray(turnResult.decisions) && turnResult.decisions.length > 0) {
105
+ for (const decision of turnResult.decisions) {
106
+ lines.push(`- **${decision.id}** (${decision.category}): ${decision.statement}`);
107
+ if (decision.rationale) {
108
+ lines.push(` - Rationale: ${decision.rationale}`);
109
+ }
110
+ }
111
+ } else {
112
+ lines.push('- None.');
113
+ }
114
+ lines.push('');
115
+ lines.push('## Objections');
116
+ lines.push('');
117
+ if (Array.isArray(turnResult.objections) && turnResult.objections.length > 0) {
118
+ for (const objection of turnResult.objections) {
119
+ lines.push(`- **${objection.id}** (${objection.severity}): ${objection.statement}`);
120
+ if (objection.status) {
121
+ lines.push(` - Status: ${objection.status}`);
122
+ }
123
+ }
124
+ } else {
125
+ lines.push('- None.');
126
+ }
127
+ lines.push('');
128
+ lines.push('## Verification');
129
+ lines.push('');
130
+ lines.push(`- **Status:** ${turnResult.verification?.status || 'skipped'}`);
131
+ if (turnResult.verification?.evidence_summary) {
132
+ lines.push(`- **Summary:** ${turnResult.verification.evidence_summary}`);
133
+ }
134
+ if (turnResult.needs_human_reason) {
135
+ lines.push(`- **Needs human reason:** ${turnResult.needs_human_reason}`);
136
+ }
137
+ lines.push('');
138
+ return lines.join('\n') + '\n';
139
+ }
140
+
141
+ function materializeDerivedReviewArtifact(root, turnResult, state, runtimeType, baseline = null) {
142
+ if (turnResult?.artifact?.type !== 'review' || runtimeType !== 'api_proxy') {
143
+ return null;
144
+ }
145
+
146
+ const reviewPath = normalizeDerivedReviewPath(turnResult);
147
+ const absReviewPath = join(root, reviewPath);
148
+ mkdirSync(dirname(absReviewPath), { recursive: true });
149
+
150
+ if (!existsSync(absReviewPath)) {
151
+ writeFileSync(absReviewPath, renderDerivedReviewArtifact(turnResult, state));
152
+ }
153
+
154
+ turnResult.artifact = { ...(turnResult.artifact || {}), ref: reviewPath };
155
+ return reviewPath;
156
+ }
157
+
80
158
  function normalizeActiveTurns(activeTurns) {
81
159
  if (!activeTurns || typeof activeTurns !== 'object' || Array.isArray(activeTurns)) {
82
160
  return {};
@@ -1503,11 +1581,13 @@ function _acceptGovernedTurnLocked(root, config, opts) {
1503
1581
  const runtimeId = turnResult.runtime_id;
1504
1582
  const runtime = config.runtimes?.[runtimeId];
1505
1583
  const runtimeType = runtime?.type || 'manual';
1584
+ materializeDerivedReviewArtifact(root, turnResult, state, runtimeType, baseline);
1506
1585
  const writeAuthority = role?.write_authority || 'review_only';
1507
1586
  const diffComparison = compareDeclaredVsObserved(
1508
1587
  turnResult.files_changed || [],
1509
1588
  observation.files_changed,
1510
1589
  writeAuthority,
1590
+ { observation_available: observation.observation_available },
1511
1591
  );
1512
1592
  if (diffComparison.errors.length > 0) {
1513
1593
  return {
@@ -92,12 +92,18 @@ export function captureBaseline(root) {
92
92
  *
93
93
  * @param {string} root — project root directory
94
94
  * @param {object} baseline — the baseline captured at assignment time
95
- * @returns {{ files_changed: string[], head_ref: string|null, diff_summary: string|null }}
95
+ * @returns {{ files_changed: string[], head_ref: string|null, diff_summary: string|null, observation_available: boolean, kind: string }}
96
96
  */
97
97
  export function observeChanges(root, baseline) {
98
98
  if (!isGitRepo(root) || (baseline && baseline.kind === 'no_git')) {
99
99
  // Non-git project — no observation possible
100
- return { files_changed: [], head_ref: null, diff_summary: null };
100
+ return {
101
+ files_changed: [],
102
+ head_ref: null,
103
+ diff_summary: null,
104
+ observation_available: false,
105
+ kind: 'no_git',
106
+ };
101
107
  }
102
108
 
103
109
  const currentHead = getHeadRef(root);
@@ -135,6 +141,8 @@ export function observeChanges(root, baseline) {
135
141
  files_changed: actorFiles.sort(),
136
142
  head_ref: currentHead,
137
143
  diff_summary: diffSummary,
144
+ observation_available: true,
145
+ kind: 'git_observed',
138
146
  };
139
147
  }
140
148
 
@@ -322,11 +330,13 @@ export function normalizeVerification(verification, runtimeType) {
322
330
  * @param {string[]} declared — files_changed from the turn result
323
331
  * @param {string[]} observed — files_changed from observeChanges()
324
332
  * @param {string} writeAuthority — 'authoritative' | 'proposed' | 'review_only'
333
+ * @param {{ observation_available?: boolean }} [options]
325
334
  * @returns {{ errors: string[], warnings: string[] }}
326
335
  */
327
- export function compareDeclaredVsObserved(declared, observed, writeAuthority) {
336
+ export function compareDeclaredVsObserved(declared, observed, writeAuthority, options = {}) {
328
337
  const errors = [];
329
338
  const warnings = [];
339
+ const observationAvailable = options.observation_available !== false;
330
340
 
331
341
  const declaredSet = new Set(declared || []);
332
342
  const observedSet = new Set(observed || []);
@@ -336,6 +346,11 @@ export function compareDeclaredVsObserved(declared, observed, writeAuthority) {
336
346
  // Files the agent declared but didn't actually change
337
347
  const phantom = [...declaredSet].filter(f => !observedSet.has(f));
338
348
 
349
+ if (!observationAvailable) {
350
+ warnings.push('Artifact observation unavailable; diff-based declared-vs-observed checks were skipped.');
351
+ return { errors, warnings };
352
+ }
353
+
339
354
  if (writeAuthority === 'authoritative') {
340
355
  if (undeclared.length > 0) {
341
356
  errors.push(`Undeclared file changes detected (observed but not in files_changed): ${undeclared.join(', ')}`);
@@ -351,6 +366,9 @@ export function compareDeclaredVsObserved(declared, observed, writeAuthority) {
351
366
  if (productFileChanges.length > 0) {
352
367
  errors.push(`review_only role modified product files (observed in actual diff): ${productFileChanges.join(', ')}`);
353
368
  }
369
+ if (phantom.length > 0) {
370
+ errors.push(`review_only role declared file changes that were not observed in the actual diff: ${phantom.join(', ')}`);
371
+ }
354
372
  }
355
373
 
356
374
  return { errors, warnings };
@@ -2,6 +2,7 @@ const DISPATCH_ROOT = '.agentxchain/dispatch';
2
2
  const DISPATCH_INDEX_PATH = `${DISPATCH_ROOT}/index.json`;
3
3
  const DISPATCH_TURNS_DIR = `${DISPATCH_ROOT}/turns`;
4
4
  const STAGING_ROOT = '.agentxchain/staging';
5
+ const REVIEW_ROOT = '.agentxchain/reviews';
5
6
 
6
7
  export function getDispatchTurnDir(turnId) {
7
8
  return `${DISPATCH_TURNS_DIR}/${turnId}`;
@@ -59,9 +60,14 @@ export function getTurnRetryTracePath(turnId) {
59
60
  return `${getTurnStagingDir(turnId)}/retry-trace.json`;
60
61
  }
61
62
 
63
+ export function getReviewArtifactPath(turnId, roleId = 'review') {
64
+ return `${REVIEW_ROOT}/${turnId}-${roleId}-review.md`;
65
+ }
66
+
62
67
  export {
63
68
  DISPATCH_ROOT,
64
69
  DISPATCH_INDEX_PATH,
65
70
  DISPATCH_TURNS_DIR,
71
+ REVIEW_ROOT,
66
72
  STAGING_ROOT,
67
73
  };
@@ -69,6 +69,25 @@ export function validateStagedTurnResult(root, state, config, opts = {}) {
69
69
  return result('schema', 'schema_error', [`Invalid JSON in ${stagingRel}: ${err.message}`]);
70
70
  }
71
71
 
72
+ // ── Pre-validation normalization ───────────────────────────────────────
73
+ // Build context for role/phase-aware normalization rules
74
+ const normContext = {};
75
+ if (state) {
76
+ normContext.phase = state.phase;
77
+ // Support both active_turns (v2+) and legacy current_turn formats
78
+ const activeTurn = getActiveTurn(state) || state.current_turn;
79
+ if (activeTurn) {
80
+ const roleKey = activeTurn.assigned_role || activeTurn.role;
81
+ const roleConfig = config?.roles?.[roleKey];
82
+ if (roleConfig) {
83
+ normContext.writeAuthority = roleConfig.write_authority;
84
+ }
85
+ }
86
+ }
87
+ const { normalized, corrections } = normalizeTurnResult(turnResult, config, normContext);
88
+ turnResult = normalized;
89
+ const normWarnings = corrections.map((c) => `[normalized] ${c}`);
90
+
72
91
  // ── Stage A: Schema Validation ─────────────────────────────────────────
73
92
  const schemaErrors = validateSchema(turnResult);
74
93
  if (schemaErrors.length > 0) {
@@ -101,6 +120,7 @@ export function validateStagedTurnResult(root, state, config, opts = {}) {
101
120
 
102
121
  // ── All stages passed ──────────────────────────────────────────────────
103
122
  const allWarnings = [
123
+ ...normWarnings,
104
124
  ...artifactResult.warnings,
105
125
  ...verificationResult.warnings,
106
126
  ...protocolResult.warnings,
@@ -417,7 +437,7 @@ function validateVerification(tr) {
417
437
  const failedCommands = v.machine_evidence.filter(e => typeof e.exit_code === 'number' && e.exit_code !== 0);
418
438
  if (failedCommands.length > 0) {
419
439
  errors.push(
420
- `verification.status is "pass" but ${failedCommands.length} command(s) have non-zero exit codes.`
440
+ `verification.status is "pass" but ${failedCommands.length} command(s) have non-zero exit codes. Wrap expected-failure checks in a verifier that exits 0 only when the failure occurs as expected, or do not report "pass".`
421
441
  );
422
442
  }
423
443
  }
@@ -480,6 +500,134 @@ function validateProtocol(tr, state, config) {
480
500
  return { errors, warnings };
481
501
  }
482
502
 
503
+ // ── Normalization ───────────────────────────────────────────────────────────
504
+
505
+ /**
506
+ * Best-effort normalization of predictable model-output drift patterns.
507
+ * Returns a shallow-cloned turn result with corrections applied plus an
508
+ * array of human-readable correction strings for logging.
509
+ *
510
+ * This runs BEFORE schema validation. It does not bypass validation —
511
+ * it only fixes patterns that are unambiguously recoverable.
512
+ */
513
+ export function normalizeTurnResult(tr, config, context = {}) {
514
+ const corrections = [];
515
+ if (tr === null || typeof tr !== 'object' || Array.isArray(tr)) {
516
+ return { normalized: tr, corrections };
517
+ }
518
+
519
+ const normalized = { ...tr };
520
+
521
+ // ── Rule 0: infer missing status only when intent is unambiguous ──────
522
+ if (!('status' in normalized)) {
523
+ const hasNeedsHumanReason = typeof normalized.needs_human_reason === 'string'
524
+ && normalized.needs_human_reason.trim().length > 0;
525
+ const hasPhaseTransitionRequest = typeof normalized.phase_transition_request === 'string'
526
+ && normalized.phase_transition_request.trim().length > 0;
527
+ const hasRunCompletionRequest = normalized.run_completion_request === true;
528
+
529
+ if (hasNeedsHumanReason) {
530
+ normalized.status = 'needs_human';
531
+ corrections.push('status: inferred "needs_human" from needs_human_reason');
532
+ } else if (hasPhaseTransitionRequest) {
533
+ normalized.status = 'completed';
534
+ corrections.push(`status: inferred "completed" from phase_transition_request "${normalized.phase_transition_request}"`);
535
+ } else if (hasRunCompletionRequest) {
536
+ normalized.status = 'completed';
537
+ corrections.push('status: inferred "completed" from run_completion_request: true');
538
+ }
539
+ }
540
+
541
+ // ── Rule 1: artifacts_created object coercion ─────────────────────────
542
+ if (Array.isArray(normalized.artifacts_created)) {
543
+ const coerced = [];
544
+ for (let i = 0; i < normalized.artifacts_created.length; i++) {
545
+ const item = normalized.artifacts_created[i];
546
+ if (typeof item === 'string') {
547
+ coerced.push(item);
548
+ } else if (item !== null && typeof item === 'object') {
549
+ const str = typeof item.path === 'string' ? item.path
550
+ : typeof item.name === 'string' ? item.name
551
+ : JSON.stringify(item);
552
+ corrections.push(`artifacts_created[${i}]: coerced object to string "${str}"`);
553
+ coerced.push(str);
554
+ } else {
555
+ coerced.push(item); // let validator catch non-string/non-object
556
+ }
557
+ }
558
+ normalized.artifacts_created = coerced;
559
+ }
560
+
561
+ // ── Rule 2: exit-gate-as-phase auto-correction ────────────────────────
562
+ const routing = config?.routing;
563
+ const gates = config?.gates;
564
+ if (
565
+ typeof normalized.phase_transition_request === 'string' &&
566
+ routing && gates &&
567
+ !normalized.run_completion_request // don't touch if both are set — let mutual-exclusivity validator catch it
568
+ ) {
569
+ const requested = normalized.phase_transition_request;
570
+ const isValidPhase = requested in routing;
571
+ const isGateName = requested in gates;
572
+
573
+ if (!isValidPhase && isGateName) {
574
+ // Find which phase owns this gate
575
+ const phaseNames = Object.keys(routing);
576
+ const ownerPhaseIndex = phaseNames.findIndex(
577
+ (p) => routing[p].exit_gate === requested
578
+ );
579
+
580
+ if (ownerPhaseIndex >= 0) {
581
+ const nextPhaseIndex = ownerPhaseIndex + 1;
582
+ if (nextPhaseIndex < phaseNames.length) {
583
+ // Non-terminal phase: correct to the next phase name
584
+ const nextPhase = phaseNames[nextPhaseIndex];
585
+ corrections.push(
586
+ `phase_transition_request: corrected gate name "${requested}" to phase "${nextPhase}"`
587
+ );
588
+ normalized.phase_transition_request = nextPhase;
589
+ } else {
590
+ // Terminal phase: the agent meant run_completion_request
591
+ corrections.push(
592
+ `phase_transition_request: corrected terminal gate name "${requested}" to run_completion_request: true`
593
+ );
594
+ normalized.phase_transition_request = null;
595
+ normalized.run_completion_request = true;
596
+ }
597
+ }
598
+ }
599
+ }
600
+
601
+ // ── Rule 3: review_only terminal needs_human → run_completion_request ──
602
+ if (
603
+ context.writeAuthority === 'review_only' &&
604
+ context.phase &&
605
+ routing &&
606
+ normalized.status === 'needs_human' &&
607
+ normalized.run_completion_request !== false
608
+ ) {
609
+ const phaseNames = Object.keys(routing);
610
+ const isTerminal = phaseNames.indexOf(context.phase) === phaseNames.length - 1;
611
+ if (isTerminal && typeof normalized.needs_human_reason === 'string') {
612
+ const reason = normalized.needs_human_reason.toLowerCase();
613
+ const affirmativeSignals = /\b(approv|ship|release|sign.?off|no.?block|ready|pass|good|accept|green.?light)\b/i;
614
+ const blockerSignals = /\b(critical|security|fail|block|cannot|must.?fix|regression|vulnerab|reject|unsafe|broken)\b/i;
615
+ const isAffirmative = affirmativeSignals.test(reason);
616
+ const isBlocker = blockerSignals.test(reason);
617
+ if (isAffirmative && !isBlocker) {
618
+ corrections.push(
619
+ `status: corrected review_only terminal "needs_human" to run_completion_request — reason indicated ship readiness ("${normalized.needs_human_reason.slice(0, 80)}"), not a genuine blocker`
620
+ );
621
+ normalized.status = 'completed';
622
+ normalized.run_completion_request = true;
623
+ delete normalized.needs_human_reason;
624
+ }
625
+ }
626
+ }
627
+
628
+ return { normalized, corrections };
629
+ }
630
+
483
631
  // ── Helpers ──────────────────────────────────────────────────────────────────
484
632
 
485
633
  function result(stage, errorClass, errors, warnings = []) {