agentxchain 2.16.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 +1 -1
- package/package.json +1 -1
- package/scripts/release-preflight.sh +1 -1
- package/src/commands/init.js +14 -7
- package/src/commands/multi.js +2 -0
- package/src/commands/start.js +2 -1
- package/src/lib/context-section-parser.js +43 -5
- package/src/lib/coordinator-recovery.js +20 -0
- package/src/lib/dispatch-bundle.js +303 -5
- package/src/lib/export.js +1 -0
- package/src/lib/governed-state.js +81 -1
- package/src/lib/repo-observer.js +21 -3
- package/src/lib/report.js +51 -2
- package/src/lib/turn-paths.js +6 -0
- package/src/lib/turn-result-validator.js +149 -1
- package/src/lib/workflow-gate-semantics.js +101 -1
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
|
@@ -107,7 +107,7 @@ for example_dir in "${CLI_DIR}/../examples/mcp-echo-agent" "${CLI_DIR}/../exampl
|
|
|
107
107
|
(cd "$example_dir" && env -u NODE_AUTH_TOKEN -u NPM_CONFIG_USERCONFIG npm install --ignore-scripts --userconfig /dev/null 2>&1) || true
|
|
108
108
|
fi
|
|
109
109
|
done
|
|
110
|
-
if run_and_capture TEST_OUTPUT npm test; then
|
|
110
|
+
if run_and_capture TEST_OUTPUT env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test; then
|
|
111
111
|
TEST_STATUS=0
|
|
112
112
|
else
|
|
113
113
|
TEST_STATUS=$?
|
package/src/commands/init.js
CHANGED
|
@@ -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. **
|
|
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.
|
|
279
|
-
2.
|
|
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 });
|
package/src/commands/multi.js
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
buildEscalationPayload,
|
|
41
41
|
} from '../lib/coordinator-hooks.js';
|
|
42
42
|
import { computeContextInvalidations } from '../lib/cross-repo-context.js';
|
|
43
|
+
import { scaffoldRecoveryReport } from '../lib/workflow-gate-semantics.js';
|
|
43
44
|
|
|
44
45
|
// ── multi init ─────────────────────────────────────────────────────────────
|
|
45
46
|
|
|
@@ -601,5 +602,6 @@ function blockCoordinator(workspacePath, state, blockedReason) {
|
|
|
601
602
|
blocked_reason: blockedReason,
|
|
602
603
|
};
|
|
603
604
|
saveCoordinatorState(workspacePath, blockedState);
|
|
605
|
+
scaffoldRecoveryReport(workspacePath, blockedReason);
|
|
604
606
|
return blockedState;
|
|
605
607
|
}
|
package/src/commands/start.js
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
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,6 +21,7 @@ import {
|
|
|
21
21
|
readBarriers,
|
|
22
22
|
recordCoordinatorDecision,
|
|
23
23
|
} from './coordinator-state.js';
|
|
24
|
+
import { evaluateRecoveryReport, scaffoldRecoveryReport } from './workflow-gate-semantics.js';
|
|
24
25
|
import { safeWriteJson } from './safe-write.js';
|
|
25
26
|
import {
|
|
26
27
|
computeBarrierStatus as computeCoordinatorBarrierStatus,
|
|
@@ -416,6 +417,9 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
|
|
|
416
417
|
}
|
|
417
418
|
|
|
418
419
|
saveCoordinatorState(workspacePath, updatedState);
|
|
420
|
+
if (blockedReason) {
|
|
421
|
+
scaffoldRecoveryReport(workspacePath, blockedReason);
|
|
422
|
+
}
|
|
419
423
|
|
|
420
424
|
// Step 6: Append resync event to history
|
|
421
425
|
appendJsonl(historyPath(workspacePath), {
|
|
@@ -458,6 +462,22 @@ export function resumeCoordinatorFromBlockedState(workspacePath, state, config)
|
|
|
458
462
|
}
|
|
459
463
|
|
|
460
464
|
const previousBlockedReason = state.blocked_reason || 'unknown blocked reason';
|
|
465
|
+
|
|
466
|
+
// Require a recovery report before allowing resume
|
|
467
|
+
const reportResult = evaluateRecoveryReport(workspacePath);
|
|
468
|
+
if (reportResult === null) {
|
|
469
|
+
return {
|
|
470
|
+
ok: false,
|
|
471
|
+
error: 'Recovery report required before resume. Create .agentxchain/multirepo/RECOVERY_REPORT.md with ## Trigger, ## Impact, and ## Mitigation sections.',
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
if (!reportResult.ok) {
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
error: reportResult.reason,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
461
481
|
const expectedSuperRunId = state.super_run_id;
|
|
462
482
|
const resync = resyncFromRepoAuthority(workspacePath, state, config);
|
|
463
483
|
const refreshedState = loadCoordinatorState(workspacePath);
|
|
@@ -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
|
-
|
|
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
|
|
415
|
-
|
|
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) {
|
package/src/lib/export.js
CHANGED
|
@@ -15,6 +15,7 @@ const COORDINATOR_INCLUDED_ROOTS = [
|
|
|
15
15
|
'.agentxchain/multirepo/barriers.json',
|
|
16
16
|
'.agentxchain/multirepo/decision-ledger.jsonl',
|
|
17
17
|
'.agentxchain/multirepo/barrier-ledger.jsonl',
|
|
18
|
+
'.agentxchain/multirepo/RECOVERY_REPORT.md',
|
|
18
19
|
];
|
|
19
20
|
|
|
20
21
|
const INCLUDED_ROOTS = [
|
|
@@ -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 {
|
package/src/lib/repo-observer.js
CHANGED
|
@@ -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 {
|
|
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 };
|
package/src/lib/report.js
CHANGED
|
@@ -507,6 +507,36 @@ function buildRunSubject(artifact) {
|
|
|
507
507
|
};
|
|
508
508
|
}
|
|
509
509
|
|
|
510
|
+
function extractRecoveryReportSection(content, heading) {
|
|
511
|
+
const pattern = new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm');
|
|
512
|
+
const match = content.match(pattern);
|
|
513
|
+
if (!match) return null;
|
|
514
|
+
const start = match.index + match[0].length;
|
|
515
|
+
const nextHeading = content.slice(start).match(/^## /m);
|
|
516
|
+
const sectionText = nextHeading
|
|
517
|
+
? content.slice(start, start + nextHeading.index).trim()
|
|
518
|
+
: content.slice(start).trim();
|
|
519
|
+
if (!sectionText || /^\(.*\)$/.test(sectionText)) return null;
|
|
520
|
+
return sectionText;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function extractRecoveryReportSummary(artifact) {
|
|
524
|
+
const entry = artifact.files?.['.agentxchain/multirepo/RECOVERY_REPORT.md'];
|
|
525
|
+
if (!entry) return null;
|
|
526
|
+
const content = typeof entry.data === 'string'
|
|
527
|
+
? entry.data
|
|
528
|
+
: (entry.content_base64 ? Buffer.from(entry.content_base64, 'base64').toString('utf8') : null);
|
|
529
|
+
if (!content) return null;
|
|
530
|
+
return {
|
|
531
|
+
present: true,
|
|
532
|
+
trigger: extractRecoveryReportSection(content, '## Trigger'),
|
|
533
|
+
impact: extractRecoveryReportSection(content, '## Impact'),
|
|
534
|
+
mitigation: extractRecoveryReportSection(content, '## Mitigation'),
|
|
535
|
+
owner: extractRecoveryReportSection(content, '## Owner'),
|
|
536
|
+
exit_condition: extractRecoveryReportSection(content, '## Exit Condition'),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
510
540
|
function buildCoordinatorSubject(artifact) {
|
|
511
541
|
const coordinatorState = extractFileData(artifact, '.agentxchain/multirepo/state.json') || {};
|
|
512
542
|
const repoStatuses = artifact.summary?.repo_run_statuses || {};
|
|
@@ -580,6 +610,7 @@ function buildCoordinatorSubject(artifact) {
|
|
|
580
610
|
barrier_summary: barrierSummary,
|
|
581
611
|
barrier_ledger_timeline: barrierLedgerTimeline,
|
|
582
612
|
decision_digest: decisionDigest,
|
|
613
|
+
recovery_report: extractRecoveryReportSummary(artifact),
|
|
583
614
|
repos,
|
|
584
615
|
artifacts: {
|
|
585
616
|
history_entries: artifact.summary?.history_entries || 0,
|
|
@@ -754,7 +785,7 @@ export function formatGovernanceReportText(report) {
|
|
|
754
785
|
return lines.join('\n');
|
|
755
786
|
}
|
|
756
787
|
|
|
757
|
-
const { coordinator, run, artifacts, repos, coordinator_timeline, barrier_summary, barrier_ledger_timeline, decision_digest } = report.subject;
|
|
788
|
+
const { coordinator, run, artifacts, repos, coordinator_timeline, barrier_summary, barrier_ledger_timeline, decision_digest, recovery_report } = report.subject;
|
|
758
789
|
const lines = [
|
|
759
790
|
'AgentXchain Governance Report',
|
|
760
791
|
`Input: ${report.input}`,
|
|
@@ -827,6 +858,15 @@ export function formatGovernanceReportText(report) {
|
|
|
827
858
|
}
|
|
828
859
|
}
|
|
829
860
|
|
|
861
|
+
if (recovery_report) {
|
|
862
|
+
lines.push('', 'Recovery Report:');
|
|
863
|
+
lines.push(` Trigger: ${recovery_report.trigger || 'n/a'}`);
|
|
864
|
+
lines.push(` Impact: ${recovery_report.impact || 'n/a'}`);
|
|
865
|
+
lines.push(` Mitigation: ${recovery_report.mitigation || 'n/a'}`);
|
|
866
|
+
lines.push(` Owner: ${recovery_report.owner || 'n/a'}`);
|
|
867
|
+
lines.push(` Exit Condition: ${recovery_report.exit_condition || 'n/a'}`);
|
|
868
|
+
}
|
|
869
|
+
|
|
830
870
|
lines.push('Repo details:');
|
|
831
871
|
lines.push(...repos.flatMap((repo) => {
|
|
832
872
|
if (!repo.ok) {
|
|
@@ -991,7 +1031,7 @@ export function formatGovernanceReportMarkdown(report) {
|
|
|
991
1031
|
return lines.join('\n');
|
|
992
1032
|
}
|
|
993
1033
|
|
|
994
|
-
const { coordinator, run, artifacts, repos, coordinator_timeline, barrier_summary, barrier_ledger_timeline, decision_digest } = report.subject;
|
|
1034
|
+
const { coordinator, run, artifacts, repos, coordinator_timeline, barrier_summary, barrier_ledger_timeline, decision_digest, recovery_report: coordRecoveryReport } = report.subject;
|
|
995
1035
|
const mdLines = [
|
|
996
1036
|
'# AgentXchain Governance Report',
|
|
997
1037
|
'',
|
|
@@ -1065,6 +1105,15 @@ export function formatGovernanceReportMarkdown(report) {
|
|
|
1065
1105
|
}
|
|
1066
1106
|
}
|
|
1067
1107
|
|
|
1108
|
+
if (coordRecoveryReport) {
|
|
1109
|
+
mdLines.push('', '## Recovery Report', '');
|
|
1110
|
+
mdLines.push(`- **Trigger:** ${coordRecoveryReport.trigger || 'n/a'}`);
|
|
1111
|
+
mdLines.push(`- **Impact:** ${coordRecoveryReport.impact || 'n/a'}`);
|
|
1112
|
+
mdLines.push(`- **Mitigation:** ${coordRecoveryReport.mitigation || 'n/a'}`);
|
|
1113
|
+
mdLines.push(`- **Owner:** ${coordRecoveryReport.owner || 'n/a'}`);
|
|
1114
|
+
mdLines.push(`- **Exit Condition:** ${coordRecoveryReport.exit_condition || 'n/a'}`);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1068
1117
|
mdLines.push('', '## Repo Details', '');
|
|
1069
1118
|
mdLines.push(...repos.flatMap((repo) => {
|
|
1070
1119
|
if (!repo.ok) {
|
package/src/lib/turn-paths.js
CHANGED
|
@@ -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 = []) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
4
|
export const PM_SIGNOFF_PATH = '.planning/PM_SIGNOFF.md';
|
|
@@ -7,6 +7,7 @@ export const IMPLEMENTATION_NOTES_PATH = '.planning/IMPLEMENTATION_NOTES.md';
|
|
|
7
7
|
export const ACCEPTANCE_MATRIX_PATH = '.planning/acceptance-matrix.md';
|
|
8
8
|
export const SHIP_VERDICT_PATH = '.planning/ship-verdict.md';
|
|
9
9
|
export const RELEASE_NOTES_PATH = '.planning/RELEASE_NOTES.md';
|
|
10
|
+
export const RECOVERY_REPORT_PATH = '.agentxchain/multirepo/RECOVERY_REPORT.md';
|
|
10
11
|
|
|
11
12
|
const AFFIRMATIVE_SHIP_VERDICTS = new Set(['YES', 'SHIP', 'SHIP IT']);
|
|
12
13
|
const AFFIRMATIVE_ACCEPTANCE_STATUSES = new Set(['PASS', 'PASSED', 'OK', 'YES']);
|
|
@@ -235,6 +236,105 @@ function evaluateReleaseNotes(content) {
|
|
|
235
236
|
return { ok: true };
|
|
236
237
|
}
|
|
237
238
|
|
|
239
|
+
const RECOVERY_REPORT_PLACEHOLDER = /^\(Operator fills this before running multi resume\)$/i;
|
|
240
|
+
|
|
241
|
+
function hasRecoveryReportSectionContent(content, sectionHeader) {
|
|
242
|
+
const lines = content.split(/\r?\n/);
|
|
243
|
+
const headerIndex = lines.findIndex((line) => line.trim().startsWith(sectionHeader));
|
|
244
|
+
if (headerIndex === -1) {
|
|
245
|
+
return { found: false, hasContent: false };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (let i = headerIndex + 1; i < lines.length; i++) {
|
|
249
|
+
const line = lines[i].trim();
|
|
250
|
+
if (line.startsWith('## ')) break;
|
|
251
|
+
if (!line) continue;
|
|
252
|
+
if (RECOVERY_REPORT_PLACEHOLDER.test(line)) continue;
|
|
253
|
+
return { found: true, hasContent: true };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { found: true, hasContent: false };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function evaluateRecoveryReport(workspacePath) {
|
|
260
|
+
const content = readFile(workspacePath, RECOVERY_REPORT_PATH);
|
|
261
|
+
if (content === null) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const trigger = hasRecoveryReportSectionContent(content, '## Trigger');
|
|
266
|
+
const impact = hasRecoveryReportSectionContent(content, '## Impact');
|
|
267
|
+
const mitigation = hasRecoveryReportSectionContent(content, '## Mitigation');
|
|
268
|
+
|
|
269
|
+
const missingSections = [];
|
|
270
|
+
if (!trigger.found) missingSections.push('## Trigger');
|
|
271
|
+
if (!impact.found) missingSections.push('## Impact');
|
|
272
|
+
if (!mitigation.found) missingSections.push('## Mitigation');
|
|
273
|
+
|
|
274
|
+
if (missingSections.length > 0) {
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
reason: `Recovery report must define ${missingSections.join(' and ')} before resume.`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const emptySections = [];
|
|
282
|
+
if (!trigger.hasContent) emptySections.push('## Trigger');
|
|
283
|
+
if (!impact.hasContent) emptySections.push('## Impact');
|
|
284
|
+
if (!mitigation.hasContent) emptySections.push('## Mitigation');
|
|
285
|
+
|
|
286
|
+
if (emptySections.length > 0) {
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
reason: `Recovery report sections still contain placeholder text: ${emptySections.join(', ')}. Replace placeholder content before running multi resume.`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { ok: true };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function scaffoldRecoveryReport(workspacePath, blockedReason) {
|
|
297
|
+
const absPath = join(workspacePath, RECOVERY_REPORT_PATH);
|
|
298
|
+
if (existsSync(absPath)) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const reasonText = typeof blockedReason === 'string'
|
|
303
|
+
? blockedReason
|
|
304
|
+
: JSON.stringify(blockedReason) || 'unknown';
|
|
305
|
+
|
|
306
|
+
const content = `# Recovery Report
|
|
307
|
+
|
|
308
|
+
Coordinator entered blocked state. Fill in the sections below before running \`agentxchain multi resume\`.
|
|
309
|
+
|
|
310
|
+
**Blocked reason:** ${reasonText}
|
|
311
|
+
**Blocked at:** ${new Date().toISOString()}
|
|
312
|
+
|
|
313
|
+
## Trigger
|
|
314
|
+
|
|
315
|
+
(Operator fills this before running multi resume)
|
|
316
|
+
|
|
317
|
+
## Impact
|
|
318
|
+
|
|
319
|
+
(Operator fills this before running multi resume)
|
|
320
|
+
|
|
321
|
+
## Mitigation
|
|
322
|
+
|
|
323
|
+
(Operator fills this before running multi resume)
|
|
324
|
+
|
|
325
|
+
## Owner
|
|
326
|
+
|
|
327
|
+
(Optional: who performed the recovery)
|
|
328
|
+
|
|
329
|
+
## Exit Condition
|
|
330
|
+
|
|
331
|
+
(Optional: what must remain true after recovery)
|
|
332
|
+
`;
|
|
333
|
+
|
|
334
|
+
writeFileSync(absPath, content);
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
|
|
238
338
|
function evaluateShipVerdict(content) {
|
|
239
339
|
const verdict = parseLineValue(content, /^##\s+Verdict\s*:\s*(.+)$/im);
|
|
240
340
|
if (!verdict) {
|