@loucompanion/forge-bridge 0.1.1-dev.9acab970e5d1 → 0.1.1-dev.ac656b52f9c6
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/dist/bridge/bridge.service.d.ts +1 -0
- package/dist/bridge/bridge.service.js +146 -17
- package/dist/bridge/bridge.types.d.ts +4 -2
- package/dist/cli/cli.service.js +225 -10
- package/dist/cli/local-config.d.ts +1 -0
- package/dist/cli/local-config.js +18 -3
- package/dist/config.js +1 -1
- package/dist/session/phase-completion.d.ts +8 -4
- package/dist/session/phase-completion.js +551 -19
- package/dist/session/provider-cli/provider-cli.base.d.ts +3 -1
- package/dist/session/provider-cli/provider-cli.service.d.ts +9 -1
- package/dist/session/provider-cli/provider-cli.service.js +870 -197
- package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
- package/dist/session/session.service.js +41 -4
- package/dist/session/session.types.d.ts +75 -0
- package/dist/session/session.types.js +143 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.js +52 -4
- package/dist/worktree/worktree.service.d.ts +1 -0
- package/dist/worktree/worktree.service.js +128 -3
- package/dist/worktree/worktree.types.d.ts +6 -1
- package/package.json +8 -3
|
@@ -54,6 +54,8 @@ const qaBlockerReasons = new Set([
|
|
|
54
54
|
"missing_worktree",
|
|
55
55
|
"runner_failure"
|
|
56
56
|
]);
|
|
57
|
+
const resolvedFindingStatusValues = ["resolved", "disputed", "not_reproduced"];
|
|
58
|
+
const resolvedFindingStatuses = new Set(resolvedFindingStatusValues);
|
|
57
59
|
const qaFindingSeverities = new Set(["critical", "major", "minor"]);
|
|
58
60
|
const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
|
|
59
61
|
const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
|
|
@@ -89,6 +91,10 @@ const phaseFiles = {
|
|
|
89
91
|
debug_regression: {
|
|
90
92
|
jsonFileName: "debug.regression.json",
|
|
91
93
|
reportFileName: "debug.report.md"
|
|
94
|
+
},
|
|
95
|
+
architect: {
|
|
96
|
+
jsonFileName: "architect.verdict.json",
|
|
97
|
+
reportFileName: "architect.report.md"
|
|
92
98
|
}
|
|
93
99
|
};
|
|
94
100
|
export function completionPhaseFromDispatch(dispatch) {
|
|
@@ -109,6 +115,9 @@ export function normalizeCompletionPhase(value) {
|
|
|
109
115
|
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
110
116
|
return "qa";
|
|
111
117
|
}
|
|
118
|
+
if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
|
|
119
|
+
return "architect";
|
|
120
|
+
}
|
|
112
121
|
return "implementation";
|
|
113
122
|
}
|
|
114
123
|
export function usesDevelopCycleCompletion(dispatch) {
|
|
@@ -138,6 +147,12 @@ export function usesDebugCycleCompletion(dispatch) {
|
|
|
138
147
|
&& lane === "debug"
|
|
139
148
|
&& isDebugCompletionPhase(completionPhaseFromDispatch(dispatch));
|
|
140
149
|
}
|
|
150
|
+
export function usesArchitectCycleCompletion(dispatch) {
|
|
151
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
152
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
153
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
154
|
+
return kind === "architect" || role === "architect" || lane === "architect";
|
|
155
|
+
}
|
|
141
156
|
export function completionToolNamesForPhase(phase) {
|
|
142
157
|
if (isDebugCompletionPhase(phase)) {
|
|
143
158
|
return completionToolNamesForDebugGate(phase);
|
|
@@ -148,7 +163,9 @@ export function completionToolNamesForPhase(phase) {
|
|
|
148
163
|
? "complete_self_review"
|
|
149
164
|
: phase === "validation"
|
|
150
165
|
? "complete_validation"
|
|
151
|
-
: "
|
|
166
|
+
: phase === "architect"
|
|
167
|
+
? "complete_architect_plan"
|
|
168
|
+
: "complete_qa";
|
|
152
169
|
return [`forge.${base}`, base];
|
|
153
170
|
}
|
|
154
171
|
export function completionToolNamesForQaGate(gate) {
|
|
@@ -171,14 +188,20 @@ export function expectedDebugCompletionToolName(gate) {
|
|
|
171
188
|
export function artifactRootFor(worktreePath, workItemId) {
|
|
172
189
|
return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
|
|
173
190
|
}
|
|
174
|
-
function artifactRootForOwner(worktreePath, ownerType, ownerId) {
|
|
191
|
+
export function artifactRootForOwner(worktreePath, ownerType, ownerId) {
|
|
175
192
|
return path.join(worktreePath, ".forge", artifactOwnerDirectory(ownerType), safePathSegment(ownerId, "owner id"), "artifacts");
|
|
176
193
|
}
|
|
177
|
-
export function completionArtifactJsonPath(worktreePath, workItemId, phase) {
|
|
178
|
-
|
|
194
|
+
export function completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId) {
|
|
195
|
+
const artifactRoot = ownerType && ownerId
|
|
196
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
197
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
198
|
+
return path.join(artifactRoot, phaseFiles[phase].jsonFileName);
|
|
179
199
|
}
|
|
180
|
-
export function completionArtifactReportPath(worktreePath, workItemId, phase) {
|
|
181
|
-
|
|
200
|
+
export function completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId) {
|
|
201
|
+
const artifactRoot = ownerType && ownerId
|
|
202
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
203
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
204
|
+
return path.join(artifactRoot, phaseFiles[phase].reportFileName);
|
|
182
205
|
}
|
|
183
206
|
export async function prepareCompletionToolRuntime(options) {
|
|
184
207
|
const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
|
|
@@ -195,7 +218,8 @@ export async function prepareCompletionToolRuntime(options) {
|
|
|
195
218
|
producedBy: `${options.dispatch.execution.provider}-cli`,
|
|
196
219
|
developCycle: usesDevelopCycleCompletion(options.dispatch),
|
|
197
220
|
qaCycle: usesQaCycleCompletion(options.dispatch),
|
|
198
|
-
debugCycle: usesDebugCycleCompletion(options.dispatch)
|
|
221
|
+
debugCycle: usesDebugCycleCompletion(options.dispatch),
|
|
222
|
+
architectCycle: usesArchitectCycleCompletion(options.dispatch)
|
|
199
223
|
});
|
|
200
224
|
const runtime = {
|
|
201
225
|
serverName: toolServerName,
|
|
@@ -234,6 +258,9 @@ export function buildCompletionToolServerArgs(config) {
|
|
|
234
258
|
if (config.debugCycle) {
|
|
235
259
|
args.push("--debug-cycle");
|
|
236
260
|
}
|
|
261
|
+
if (config.architectCycle) {
|
|
262
|
+
args.push("--architect-cycle");
|
|
263
|
+
}
|
|
237
264
|
return args;
|
|
238
265
|
}
|
|
239
266
|
export function codexMcpConfigArgs(runtime) {
|
|
@@ -266,6 +293,16 @@ Do not write QA artifacts by hand. When QA is complete, call \`${toolName}(...)\
|
|
|
266
293
|
That tool writes the durable QA bundle under \`.forge/work-items/{workItemId}/artifacts/\`: \`qa.plan.json\`, \`qa.evidence.json\`, \`qa.findings.json\`, \`${artifactName}\`, and \`qa.report.md\`.
|
|
267
294
|
Use \`status: "pass"\` only when the validated changes satisfy acceptance criteria with concrete evidence. Use \`status: "needs_rework"\` with actionable in-scope findings, or \`status: "blocked"\` with a concrete blocker_reason.
|
|
268
295
|
Forge will mirror only the tool-written QA artifacts; reporting QA completion in prose is not enough.`;
|
|
296
|
+
}
|
|
297
|
+
if (phase === "architect") {
|
|
298
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
299
|
+
Do not write Architect artifacts by hand. When the plan is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the full structured Architect planning payload.
|
|
300
|
+
That tool writes \`.forge/work-items/{workItemId}/artifacts/implementation.plan.json\`, \`qa.plan.json\`, \`${artifactName}\`, and \`architect.report.md\`.
|
|
301
|
+
The payload must put \`phases\`, \`tasks\`, \`dependencies\`, and \`phase_gates\` as top-level fields inside \`implementation_plan\`, for example:
|
|
302
|
+
\`{ "status": "pass", "summary": "...", "implementation_plan": { "schema_version": 1, "plan_id": "plan-...", "phases": [{ "id": "phase-1", "tasks": ["task-1"], "gate": "gate-1" }], "tasks": [{ "id": "task-1", "tests": [{ "command": "npm test" }] }], "dependencies": [], "phase_gates": [{ "id": "gate-1", "phase": "phase-1" }] }, "qa_plan": { "schema_version": 1, "final_qa": [{ "id": "qa-1", "coverage": "..." }] }, "report_markdown": "# Architect Report" }\`.
|
|
303
|
+
Dependency entries use canonical \`before\` and \`after\` task ids, for example \`{ "before": "task-1", "after": "task-2" }\`. Forge accepts \`from\`/\`to\` only as aliases and normalizes them before writing artifacts.
|
|
304
|
+
Use \`status: "pass"\` for a valid pending-approval plan, \`needs_revision\` when story/design input must change, or \`blocked\` with a concrete blocker.
|
|
305
|
+
Forge will mirror only the tool-written Architect artifacts; reporting the plan in prose is not enough.`;
|
|
269
306
|
}
|
|
270
307
|
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
271
308
|
Do not write phase artifacts by hand. When the phase is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the required evidence payload.
|
|
@@ -315,7 +352,9 @@ export function completionToolNamesForServer(config) {
|
|
|
315
352
|
? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
|
|
316
353
|
: config.developCycle
|
|
317
354
|
? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
|
|
318
|
-
:
|
|
355
|
+
: config.architectCycle
|
|
356
|
+
? completionToolNamesForPhase("architect")
|
|
357
|
+
: completionToolNamesForPhase(config.phase);
|
|
319
358
|
}
|
|
320
359
|
export class CompletionToolServerSession {
|
|
321
360
|
config;
|
|
@@ -511,7 +550,10 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
511
550
|
}
|
|
512
551
|
const payload = asRecord(input, "completion payload");
|
|
513
552
|
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
514
|
-
|
|
553
|
+
let validationPayload = { ...payload, status };
|
|
554
|
+
if (config.qaCycle && qaGate === "qa_verdict") {
|
|
555
|
+
validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
|
|
556
|
+
}
|
|
515
557
|
validatePayloadShape(phase, validationPayload);
|
|
516
558
|
validateOptionalWorkItemId(validationPayload, config.workItemId);
|
|
517
559
|
const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
|
|
@@ -522,6 +564,9 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
522
564
|
if (phase === "qa") {
|
|
523
565
|
return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
524
566
|
}
|
|
567
|
+
if (phase === "architect") {
|
|
568
|
+
return await writeArchitectCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
569
|
+
}
|
|
525
570
|
const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
|
|
526
571
|
phase,
|
|
527
572
|
workItemId: config.workItemId,
|
|
@@ -554,6 +599,48 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
554
599
|
producedBy
|
|
555
600
|
};
|
|
556
601
|
}
|
|
602
|
+
async function writeArchitectCompletionArtifact(config, payload, status, producedBy) {
|
|
603
|
+
const ownerFields = artifactOwnerFields(config);
|
|
604
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
605
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
606
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
607
|
+
const implementationPlanPath = path.join(artifactRoot, "implementation.plan.json");
|
|
608
|
+
const qaPlanPath = path.join(artifactRoot, "qa.plan.json");
|
|
609
|
+
const verdictPath = path.join(artifactRoot, "architect.verdict.json");
|
|
610
|
+
const reportPath = path.join(artifactRoot, "architect.report.md");
|
|
611
|
+
const implementationPlan = requiredRecord(payload, "implementation_plan");
|
|
612
|
+
const qaPlan = requiredRecord(payload, "qa_plan");
|
|
613
|
+
const report = optionalString(payload, "report") ?? optionalString(payload, "report_markdown") ?? reportMarkdown(payload, "architect", status, producedBy, "pending");
|
|
614
|
+
const artifactWithoutHash = normalizeArtifactPayload(payload, {
|
|
615
|
+
phase: "architect",
|
|
616
|
+
workItemId: config.workItemId,
|
|
617
|
+
ownerType: config.ownerType,
|
|
618
|
+
ownerId: config.ownerId,
|
|
619
|
+
producedBy,
|
|
620
|
+
status
|
|
621
|
+
});
|
|
622
|
+
const contentHash = sha256(stableStringify(artifactWithoutHash));
|
|
623
|
+
const verdict = {
|
|
624
|
+
...artifactWithoutHash,
|
|
625
|
+
content_hash: contentHash
|
|
626
|
+
};
|
|
627
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
628
|
+
await writeFileAtomic(implementationPlanPath, jsonText(implementationPlan));
|
|
629
|
+
await writeFileAtomic(qaPlanPath, jsonText(qaPlan));
|
|
630
|
+
await writeFileAtomic(verdictPath, jsonText(verdict));
|
|
631
|
+
await writeFileAtomic(reportPath, `${report.trim()}\n`);
|
|
632
|
+
return {
|
|
633
|
+
phase: "architect",
|
|
634
|
+
workItemId: config.workItemId,
|
|
635
|
+
...artifactReferenceOwnerFields(config),
|
|
636
|
+
repoPath: path.resolve(config.worktreePath),
|
|
637
|
+
path: verdictPath,
|
|
638
|
+
reportPath,
|
|
639
|
+
contentHash,
|
|
640
|
+
status,
|
|
641
|
+
producedBy
|
|
642
|
+
};
|
|
643
|
+
}
|
|
557
644
|
async function writeQaGateCompletionArtifact(config, gate, input) {
|
|
558
645
|
const payload = asRecord(input, "QA gate completion payload");
|
|
559
646
|
const status = normalizeQaGateStatus(requiredString(payload, "status"));
|
|
@@ -709,6 +796,103 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
709
796
|
producedBy
|
|
710
797
|
};
|
|
711
798
|
}
|
|
799
|
+
async function synthesizeQaCycleVerdictPayload(config, payload, status) {
|
|
800
|
+
const qaPlan = await readQaGateArtifact(config, "qa_plan");
|
|
801
|
+
const qaRegression = await readQaGateArtifact(config, "qa_regression");
|
|
802
|
+
const qaApi = await readQaGateArtifact(config, "qa_api");
|
|
803
|
+
const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
|
|
804
|
+
const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
|
|
805
|
+
const qaFindings = await readQaGateArtifact(config, "qa_findings");
|
|
806
|
+
const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
|
|
807
|
+
const payloadEvidence = qaEvidenceSource(payload);
|
|
808
|
+
const merged = { ...payload };
|
|
809
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
810
|
+
if (checklist.length === 0) {
|
|
811
|
+
const plannedChecklist = qaStringArray(qaPlan, "checklist");
|
|
812
|
+
merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
|
|
813
|
+
}
|
|
814
|
+
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
815
|
+
if (testedAcceptanceCriteria.length === 0) {
|
|
816
|
+
const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
|
|
817
|
+
const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
|
|
818
|
+
const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
|
|
819
|
+
if (acceptanceCriteria.length > 0) {
|
|
820
|
+
merged.tested_acceptance_criteria = acceptanceCriteria;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
merged.commands = dedupeUnknownArray([
|
|
824
|
+
...qaArray(payload, "commands", payloadEvidence),
|
|
825
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "commands"))
|
|
826
|
+
]);
|
|
827
|
+
merged.browser_checks = dedupeUnknownArray([
|
|
828
|
+
...qaArray(payload, "browser_checks", payloadEvidence),
|
|
829
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
|
|
830
|
+
]);
|
|
831
|
+
merged.sidecars = dedupeUnknownArray([
|
|
832
|
+
...qaArray(payload, "sidecars", payloadEvidence),
|
|
833
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
|
|
834
|
+
]);
|
|
835
|
+
const notes = dedupeStrings([
|
|
836
|
+
optionalString(payload, "notes"),
|
|
837
|
+
optionalString(payloadEvidence, "notes"),
|
|
838
|
+
...evidenceGates.map((gate) => optionalString(gate, "notes"))
|
|
839
|
+
]);
|
|
840
|
+
if (notes.length > 0) {
|
|
841
|
+
merged.notes = notes.join("\n");
|
|
842
|
+
}
|
|
843
|
+
merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
|
|
844
|
+
merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
|
|
845
|
+
return merged;
|
|
846
|
+
}
|
|
847
|
+
async function readQaGateArtifact(config, gate) {
|
|
848
|
+
const ownerFields = artifactOwnerFields(config);
|
|
849
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
850
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
851
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
852
|
+
const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
|
|
853
|
+
const content = await readOptionalFile(artifactPath);
|
|
854
|
+
return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
|
|
855
|
+
}
|
|
856
|
+
function dedupeUnknownArray(values) {
|
|
857
|
+
const seen = new Set();
|
|
858
|
+
const deduped = [];
|
|
859
|
+
for (const value of values) {
|
|
860
|
+
const key = stableStringify(value);
|
|
861
|
+
if (seen.has(key)) {
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
seen.add(key);
|
|
865
|
+
deduped.push(value);
|
|
866
|
+
}
|
|
867
|
+
return deduped;
|
|
868
|
+
}
|
|
869
|
+
function mergeQaObjectArraysById(primary, fallback) {
|
|
870
|
+
const seen = new Set();
|
|
871
|
+
const merged = [];
|
|
872
|
+
for (const value of [...primary, ...fallback]) {
|
|
873
|
+
const id = optionalString(value, "id");
|
|
874
|
+
const key = id ? `id:${id}` : stableStringify(value);
|
|
875
|
+
if (seen.has(key)) {
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
seen.add(key);
|
|
879
|
+
merged.push(value);
|
|
880
|
+
}
|
|
881
|
+
return merged;
|
|
882
|
+
}
|
|
883
|
+
function dedupeStrings(values) {
|
|
884
|
+
const seen = new Set();
|
|
885
|
+
const deduped = [];
|
|
886
|
+
for (const value of values) {
|
|
887
|
+
const trimmed = value?.trim();
|
|
888
|
+
if (!trimmed || seen.has(trimmed)) {
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
seen.add(trimmed);
|
|
892
|
+
deduped.push(trimmed);
|
|
893
|
+
}
|
|
894
|
+
return deduped;
|
|
895
|
+
}
|
|
712
896
|
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
|
|
713
897
|
if (sidecars.length === 0) {
|
|
714
898
|
return [];
|
|
@@ -762,19 +946,28 @@ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, o
|
|
|
762
946
|
}
|
|
763
947
|
return validated;
|
|
764
948
|
}
|
|
765
|
-
export async function readCompletionArtifact(worktreePath, workItemId, phase) {
|
|
766
|
-
const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
|
|
767
|
-
const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
|
|
949
|
+
export async function readCompletionArtifact(worktreePath, workItemId, phase, ownerType, ownerId) {
|
|
950
|
+
const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId);
|
|
951
|
+
const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId);
|
|
768
952
|
const verdictJson = await readOptionalFile(artifactPath);
|
|
769
953
|
if (verdictJson === null) {
|
|
770
954
|
return null;
|
|
771
955
|
}
|
|
772
956
|
const parsed = parseJsonObject(verdictJson, artifactPath);
|
|
773
957
|
const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
|
|
958
|
+
const artifactRoot = path.dirname(artifactPath);
|
|
959
|
+
const implementationPlanJson = phase === "architect"
|
|
960
|
+
? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
|
|
961
|
+
: null;
|
|
962
|
+
const qaPlanJson = phase === "architect"
|
|
963
|
+
? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
|
|
964
|
+
: null;
|
|
774
965
|
return {
|
|
775
966
|
repoPath: path.resolve(worktreePath),
|
|
776
967
|
path: artifactPath,
|
|
777
968
|
verdictJson,
|
|
969
|
+
implementationPlanJson,
|
|
970
|
+
qaPlanJson,
|
|
778
971
|
report,
|
|
779
972
|
producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
|
|
780
973
|
status: optionalString(parsed, "status") ?? "unknown",
|
|
@@ -812,6 +1005,7 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
812
1005
|
let developCycle = false;
|
|
813
1006
|
let qaCycle = false;
|
|
814
1007
|
let debugCycle = false;
|
|
1008
|
+
let architectCycle = false;
|
|
815
1009
|
for (let index = 1; index < argv.length; index += 1) {
|
|
816
1010
|
const arg = argv[index];
|
|
817
1011
|
if (arg === "--worktree") {
|
|
@@ -833,7 +1027,7 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
833
1027
|
const rawPhase = argv[++index];
|
|
834
1028
|
const parsedPhase = parseStrictCompletionPhase(rawPhase);
|
|
835
1029
|
if (!parsedPhase) {
|
|
836
|
-
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, debug-repro, debug-root-cause, debug-fix, debug-regression." };
|
|
1030
|
+
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, architect, debug-repro, debug-root-cause, debug-fix, debug-regression." };
|
|
837
1031
|
}
|
|
838
1032
|
phase = parsedPhase;
|
|
839
1033
|
}
|
|
@@ -849,6 +1043,9 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
849
1043
|
else if (arg === "--debug-cycle") {
|
|
850
1044
|
debugCycle = true;
|
|
851
1045
|
}
|
|
1046
|
+
else if (arg === "--architect-cycle") {
|
|
1047
|
+
architectCycle = true;
|
|
1048
|
+
}
|
|
852
1049
|
else {
|
|
853
1050
|
return { ok: false, error: `Unknown mcp argument: ${arg}` };
|
|
854
1051
|
}
|
|
@@ -856,7 +1053,7 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
856
1053
|
if (!worktreePath?.trim() || !workItemId?.trim()) {
|
|
857
1054
|
return { ok: false, error: "mcp requires --worktree and --work-item-id." };
|
|
858
1055
|
}
|
|
859
|
-
if ([developCycle, qaCycle, debugCycle].filter(Boolean).length > 1) {
|
|
1056
|
+
if ([developCycle, qaCycle, debugCycle, architectCycle].filter(Boolean).length > 1) {
|
|
860
1057
|
return { ok: false, error: "mcp accepts only one cycle mode." };
|
|
861
1058
|
}
|
|
862
1059
|
if (developCycle && !isDevelopCompletionPhase(phase)) {
|
|
@@ -868,6 +1065,9 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
868
1065
|
if (debugCycle && !isDebugCompletionPhase(phase)) {
|
|
869
1066
|
return { ok: false, error: "mcp --debug-cycle requires --phase debug-repro, debug-root-cause, debug-fix, or debug-regression." };
|
|
870
1067
|
}
|
|
1068
|
+
if (architectCycle && phase !== "architect") {
|
|
1069
|
+
return { ok: false, error: "mcp --architect-cycle requires --phase architect." };
|
|
1070
|
+
}
|
|
871
1071
|
return {
|
|
872
1072
|
ok: true,
|
|
873
1073
|
config: {
|
|
@@ -880,7 +1080,8 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
880
1080
|
redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
|
|
881
1081
|
developCycle,
|
|
882
1082
|
qaCycle,
|
|
883
|
-
debugCycle
|
|
1083
|
+
debugCycle,
|
|
1084
|
+
architectCycle
|
|
884
1085
|
}
|
|
885
1086
|
};
|
|
886
1087
|
}
|
|
@@ -1088,6 +1289,9 @@ function toolDescription(phase, qaGate = null) {
|
|
|
1088
1289
|
if (phase === "qa") {
|
|
1089
1290
|
return "Complete Forge QA by writing qa.plan.json, qa.evidence.json, qa.findings.json, qa.verdict.json, and qa.report.md with structured pass, needs-rework, or blocked evidence.";
|
|
1090
1291
|
}
|
|
1292
|
+
if (phase === "architect") {
|
|
1293
|
+
return "Complete Forge Architect planning by writing implementation.plan.json, qa.plan.json, architect.verdict.json, and architect.report.md with a pending-approval plan or explicit revision/blocker reasons.";
|
|
1294
|
+
}
|
|
1091
1295
|
return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
|
|
1092
1296
|
}
|
|
1093
1297
|
function toolInputSchema(phase, qaGate = null) {
|
|
@@ -1137,7 +1341,7 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1137
1341
|
},
|
|
1138
1342
|
tests_added_or_updated: {
|
|
1139
1343
|
type: "array",
|
|
1140
|
-
description: "Required for pass. Use an object explaining why no tests were needed for
|
|
1344
|
+
description: "Required for pass. Use an object explaining why no tests were needed for documentation/config-only work if empty would be ambiguous.",
|
|
1141
1345
|
items: evidenceObjectSchema(["path", "summary"], {
|
|
1142
1346
|
path: { type: "string" },
|
|
1143
1347
|
summary: { type: "string" }
|
|
@@ -1168,6 +1372,11 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1168
1372
|
},
|
|
1169
1373
|
next_phase_context: {
|
|
1170
1374
|
description: "Required for pass. Context the self-review phase should carry forward."
|
|
1375
|
+
},
|
|
1376
|
+
resolved_findings: {
|
|
1377
|
+
type: "array",
|
|
1378
|
+
description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
|
|
1379
|
+
items: resolvedFindingSchema()
|
|
1171
1380
|
}
|
|
1172
1381
|
}, ["status", "summary"], "For status pass, include plan, changed_files, tests_added_or_updated, docs_added_or_updated, acceptance_criteria, known_gaps, and next_phase_context. Acceptance criteria entries must use status: satisfied plus evidence.");
|
|
1173
1382
|
}
|
|
@@ -1303,6 +1512,83 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1303
1512
|
}
|
|
1304
1513
|
}, ["status", "summary"], "For pass, include tested_acceptance_criteria plus concrete command/browser/note evidence. For needs_rework, include at least one actionable in_scope finding. For blocked, include blocker_reason.");
|
|
1305
1514
|
}
|
|
1515
|
+
if (phase === "architect") {
|
|
1516
|
+
return objectToolSchema({
|
|
1517
|
+
status: {
|
|
1518
|
+
type: "string",
|
|
1519
|
+
enum: ["pass", "needs_revision", "blocked"],
|
|
1520
|
+
description: "Use pass only for a complete pending-approval Architect plan. Use needs_revision for fixable story/design/repo gaps, or blocked for missing required context or decisions."
|
|
1521
|
+
},
|
|
1522
|
+
summary: { type: "string", description: "Concise Architect plan verdict summary." },
|
|
1523
|
+
implementation_plan: {
|
|
1524
|
+
type: "object",
|
|
1525
|
+
description: "Required for pass. Top-level fields inside this object must include schema_version, plan_id, phases, tasks, dependencies, and phase_gates.",
|
|
1526
|
+
additionalProperties: true,
|
|
1527
|
+
properties: {
|
|
1528
|
+
schema_version: { type: "number", enum: [1] },
|
|
1529
|
+
plan_id: { type: "string" },
|
|
1530
|
+
phases: {
|
|
1531
|
+
type: "array",
|
|
1532
|
+
description: "Required top-level implementation_plan.phases array.",
|
|
1533
|
+
items: { type: "object", additionalProperties: true }
|
|
1534
|
+
},
|
|
1535
|
+
tasks: {
|
|
1536
|
+
type: "array",
|
|
1537
|
+
description: "Required task list. Each task must include id and a non-empty tests array.",
|
|
1538
|
+
items: { type: "object", additionalProperties: true }
|
|
1539
|
+
},
|
|
1540
|
+
dependencies: {
|
|
1541
|
+
type: "array",
|
|
1542
|
+
description: "Required dependency edge array. Use [] when no task dependencies exist. Each edge should use before and after task ids; from/to are accepted only as aliases and normalized before writing artifacts.",
|
|
1543
|
+
items: {
|
|
1544
|
+
type: "object",
|
|
1545
|
+
additionalProperties: true,
|
|
1546
|
+
properties: {
|
|
1547
|
+
id: { type: "string" },
|
|
1548
|
+
before: { type: "string", description: "Task id that must complete first." },
|
|
1549
|
+
after: { type: "string", description: "Task id that depends on before." },
|
|
1550
|
+
from: { type: "string", description: "Alias for before." },
|
|
1551
|
+
to: { type: "string", description: "Alias for after." },
|
|
1552
|
+
type: { type: "string" },
|
|
1553
|
+
rationale: { type: "string" }
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
},
|
|
1557
|
+
phase_gates: {
|
|
1558
|
+
type: "array",
|
|
1559
|
+
description: "Required non-empty phase gate array.",
|
|
1560
|
+
items: { type: "object", additionalProperties: true }
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
},
|
|
1564
|
+
qa_plan: {
|
|
1565
|
+
type: "object",
|
|
1566
|
+
description: "Required for pass. Must include schema_version: 1 and non-empty final_qa coverage.",
|
|
1567
|
+
additionalProperties: true,
|
|
1568
|
+
properties: {
|
|
1569
|
+
schema_version: { type: "number", enum: [1] },
|
|
1570
|
+
final_qa: {
|
|
1571
|
+
type: "array",
|
|
1572
|
+
description: "Required non-empty final QA coverage array.",
|
|
1573
|
+
items: { type: "object", additionalProperties: true }
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
},
|
|
1577
|
+
report_markdown: { type: "string", description: "Human-readable Architect report. Use report as an alias only when the client cannot send report_markdown." },
|
|
1578
|
+
report: { type: "string", description: "Alias for report_markdown." },
|
|
1579
|
+
blockers: baseProperties.blockers,
|
|
1580
|
+
revision_questions: {
|
|
1581
|
+
type: "array",
|
|
1582
|
+
description: "Required for needs_revision when blockers are not used.",
|
|
1583
|
+
items: { type: "string" }
|
|
1584
|
+
},
|
|
1585
|
+
revision_reasons: {
|
|
1586
|
+
type: "array",
|
|
1587
|
+
description: "Concrete reasons that prevent pass status.",
|
|
1588
|
+
items: { type: "string" }
|
|
1589
|
+
}
|
|
1590
|
+
}, ["status", "summary"], "For status pass, include implementation_plan and qa_plan at the top level of the MCP tool arguments. implementation_plan.phases must be a top-level array inside implementation_plan, not nested under another object.");
|
|
1591
|
+
}
|
|
1306
1592
|
return objectToolSchema({
|
|
1307
1593
|
...baseProperties,
|
|
1308
1594
|
commands: {
|
|
@@ -1382,8 +1668,8 @@ function debugToolInputSchema(phase, baseProperties) {
|
|
|
1382
1668
|
},
|
|
1383
1669
|
resolved_findings: {
|
|
1384
1670
|
type: "array",
|
|
1385
|
-
description: "Required for QA rework.
|
|
1386
|
-
items:
|
|
1671
|
+
description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
|
|
1672
|
+
items: resolvedFindingSchema()
|
|
1387
1673
|
}
|
|
1388
1674
|
}, ["status", "summary"], "For status pass, include changed_files, tests_added_or_updated, and remaining_risks.");
|
|
1389
1675
|
}
|
|
@@ -1490,6 +1776,29 @@ function commandEvidenceSchema() {
|
|
|
1490
1776
|
summary: { type: "string" }
|
|
1491
1777
|
});
|
|
1492
1778
|
}
|
|
1779
|
+
function resolvedFindingSchema() {
|
|
1780
|
+
return evidenceObjectSchema([
|
|
1781
|
+
"qa_id",
|
|
1782
|
+
"status",
|
|
1783
|
+
"summary",
|
|
1784
|
+
"evidence",
|
|
1785
|
+
"changed_files",
|
|
1786
|
+
"tests_added_or_updated",
|
|
1787
|
+
"regression_evidence"
|
|
1788
|
+
], {
|
|
1789
|
+
qa_id: { type: "string" },
|
|
1790
|
+
status: {
|
|
1791
|
+
type: "string",
|
|
1792
|
+
enum: [...resolvedFindingStatusValues],
|
|
1793
|
+
description: "Use resolved when fixed, disputed when the QA finding is incorrect/out-of-scope, or not_reproduced when retesting cannot reproduce it."
|
|
1794
|
+
},
|
|
1795
|
+
summary: { type: "string" },
|
|
1796
|
+
evidence: { description: "Concrete evidence for the resolution." },
|
|
1797
|
+
changed_files: { type: "array", description: "Required and non-empty when status is resolved." },
|
|
1798
|
+
tests_added_or_updated: { type: "array", description: "Required and non-empty when status is resolved." },
|
|
1799
|
+
regression_evidence: { description: "Concrete regression evidence for this QA finding." }
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1493
1802
|
function acceptanceCriteriaSchema() {
|
|
1494
1803
|
return {
|
|
1495
1804
|
type: "array",
|
|
@@ -1524,6 +1833,13 @@ function normalizeCompletionStatus(value, phase) {
|
|
|
1524
1833
|
}
|
|
1525
1834
|
return canonical;
|
|
1526
1835
|
}
|
|
1836
|
+
if (phase === "architect") {
|
|
1837
|
+
const canonical = normalized === "needsrevision" ? "needs_revision" : normalized;
|
|
1838
|
+
if (canonical !== "pass" && canonical !== "needs_revision" && canonical !== "blocked") {
|
|
1839
|
+
throw new Error("Architect status must be pass, needs_revision, or blocked.");
|
|
1840
|
+
}
|
|
1841
|
+
return canonical;
|
|
1842
|
+
}
|
|
1527
1843
|
const canonical = normalized === "blocked" || normalized === "needsrework"
|
|
1528
1844
|
? "needs_rework"
|
|
1529
1845
|
: normalized === "failed"
|
|
@@ -1551,6 +1867,10 @@ function validatePayloadShape(phase, payload) {
|
|
|
1551
1867
|
validateQaPayloadShape(payload, status);
|
|
1552
1868
|
return;
|
|
1553
1869
|
}
|
|
1870
|
+
if (phase === "architect") {
|
|
1871
|
+
validateArchitectPayloadShape(payload, status);
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1554
1874
|
const pass = status === "pass";
|
|
1555
1875
|
if (!pass) {
|
|
1556
1876
|
requireNonPassEvidence(payload);
|
|
@@ -1570,6 +1890,7 @@ function validatePayloadShape(phase, payload) {
|
|
|
1570
1890
|
requireField(payload, "next_phase_context");
|
|
1571
1891
|
requireImplementationEvidence(payload);
|
|
1572
1892
|
requireSatisfiedAcceptanceCriteria(payload);
|
|
1893
|
+
validateResolvedFindings(payload);
|
|
1573
1894
|
return;
|
|
1574
1895
|
}
|
|
1575
1896
|
if (phase === "self_review") {
|
|
@@ -1623,6 +1944,7 @@ function validateDebugPayloadShape(phase, payload) {
|
|
|
1623
1944
|
if (arrayLength(payload, "tests_added_or_updated") === 0 && arrayLength(payload, "remaining_risks") === 0) {
|
|
1624
1945
|
throw new Error("tests_added_or_updated is empty; remaining_risks must explain why no regression coverage changed.");
|
|
1625
1946
|
}
|
|
1947
|
+
validateResolvedFindings(payload);
|
|
1626
1948
|
return;
|
|
1627
1949
|
}
|
|
1628
1950
|
requireArray(payload, "commands");
|
|
@@ -1677,6 +1999,160 @@ function validateQaGatePayloadShape(gate, payload, status) {
|
|
|
1677
1999
|
}
|
|
1678
2000
|
}
|
|
1679
2001
|
}
|
|
2002
|
+
function validateArchitectPayloadShape(payload, status) {
|
|
2003
|
+
if (status !== "pass") {
|
|
2004
|
+
requireArchitectNonPassEvidence(payload);
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
const implementationPlan = requiredRecord(payload, "implementation_plan");
|
|
2008
|
+
const qaPlan = requiredRecord(payload, "qa_plan");
|
|
2009
|
+
requireNumericOne(implementationPlan, "schema_version", "implementation_plan.schema_version");
|
|
2010
|
+
requireNonEmptyStringRecord(implementationPlan, "plan_id", "implementation_plan.");
|
|
2011
|
+
requireArchitectArray(implementationPlan, "phases", payload);
|
|
2012
|
+
requireArchitectArray(implementationPlan, "tasks", payload);
|
|
2013
|
+
requireArchitectArray(implementationPlan, "dependencies", payload, { allowEmpty: true });
|
|
2014
|
+
requireArchitectArray(implementationPlan, "phase_gates", payload);
|
|
2015
|
+
if (arrayLength(implementationPlan, "tasks") === 0) {
|
|
2016
|
+
throw new Error("implementation_plan.tasks: expected non-empty array; correction: include at least one task with id and tests.");
|
|
2017
|
+
}
|
|
2018
|
+
const taskIds = new Set();
|
|
2019
|
+
for (const task of implementationPlan.tasks) {
|
|
2020
|
+
if (!task || typeof task !== "object" || Array.isArray(task)) {
|
|
2021
|
+
throw new Error("implementation_plan.tasks[]: expected object entries; correction: each task must be an object with id and tests.");
|
|
2022
|
+
}
|
|
2023
|
+
const taskRecord = task;
|
|
2024
|
+
requireNonEmptyStringRecord(taskRecord, "id", "implementation_plan.tasks[].");
|
|
2025
|
+
const taskId = optionalString(taskRecord, "id") ?? "";
|
|
2026
|
+
if (taskIds.has(taskId)) {
|
|
2027
|
+
throw new Error(`implementation_plan.tasks: duplicate task id '${taskId}'.`);
|
|
2028
|
+
}
|
|
2029
|
+
taskIds.add(taskId);
|
|
2030
|
+
requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
|
|
2031
|
+
if (arrayLength(taskRecord, "tests") === 0) {
|
|
2032
|
+
throw new Error(`implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests: expected non-empty array; correction: include task-specific verification.`);
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
validateAndNormalizeArchitectDependencies(implementationPlan, taskIds);
|
|
2036
|
+
requireNumericOne(qaPlan, "schema_version", "qa_plan.schema_version");
|
|
2037
|
+
requireArchitectArray(qaPlan, "final_qa", payload, { path: "qa_plan.final_qa" });
|
|
2038
|
+
if (arrayLength(qaPlan, "final_qa") === 0) {
|
|
2039
|
+
throw new Error("qa_plan.final_qa: expected non-empty array; correction: include final QA coverage for the completed plan.");
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
function validateAndNormalizeArchitectDependencies(implementationPlan, taskIds) {
|
|
2043
|
+
const dependencies = implementationPlan.dependencies;
|
|
2044
|
+
const edges = new Map();
|
|
2045
|
+
for (const taskId of taskIds) {
|
|
2046
|
+
edges.set(taskId, []);
|
|
2047
|
+
}
|
|
2048
|
+
const normalizedDependencies = [];
|
|
2049
|
+
for (const [index, dependency] of dependencies.entries()) {
|
|
2050
|
+
if (!dependency || typeof dependency !== "object" || Array.isArray(dependency)) {
|
|
2051
|
+
throw new Error("implementation_plan.dependencies[]: expected object entries; correction: each dependency must be an object with before and after task ids.");
|
|
2052
|
+
}
|
|
2053
|
+
const record = dependency;
|
|
2054
|
+
const before = architectDependencyEndpoint(record, index, "before", "from");
|
|
2055
|
+
const after = architectDependencyEndpoint(record, index, "after", "to");
|
|
2056
|
+
if (!taskIds.has(before) || !taskIds.has(after)) {
|
|
2057
|
+
throw new Error("implementation_plan.dependencies: dependency references an unknown task id.");
|
|
2058
|
+
}
|
|
2059
|
+
const normalized = stripUndefined({
|
|
2060
|
+
...record,
|
|
2061
|
+
before,
|
|
2062
|
+
after,
|
|
2063
|
+
from: undefined,
|
|
2064
|
+
to: undefined
|
|
2065
|
+
});
|
|
2066
|
+
normalizedDependencies.push(normalized);
|
|
2067
|
+
edges.get(before)?.push(after);
|
|
2068
|
+
}
|
|
2069
|
+
implementationPlan.dependencies = normalizedDependencies;
|
|
2070
|
+
if (hasArchitectDependencyCycle(edges)) {
|
|
2071
|
+
throw new Error("implementation_plan.dependencies: expected acyclic graph; correction: remove or revise cyclic task dependencies.");
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
function architectDependencyEndpoint(dependency, index, canonicalKey, aliasKey) {
|
|
2075
|
+
const canonical = optionalString(dependency, canonicalKey);
|
|
2076
|
+
const alias = optionalString(dependency, aliasKey);
|
|
2077
|
+
if (canonical && alias && canonical !== alias) {
|
|
2078
|
+
throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: conflicts with ${aliasKey}; correction: use one task id for ${canonicalKey}.`);
|
|
2079
|
+
}
|
|
2080
|
+
const value = canonical ?? alias;
|
|
2081
|
+
if (!value) {
|
|
2082
|
+
throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: expected string; correction: use before and after task ids for each dependency.`);
|
|
2083
|
+
}
|
|
2084
|
+
return value;
|
|
2085
|
+
}
|
|
2086
|
+
function hasArchitectDependencyCycle(edges) {
|
|
2087
|
+
const visiting = new Set();
|
|
2088
|
+
const visited = new Set();
|
|
2089
|
+
const visit = (node) => {
|
|
2090
|
+
if (visited.has(node)) {
|
|
2091
|
+
return false;
|
|
2092
|
+
}
|
|
2093
|
+
if (visiting.has(node)) {
|
|
2094
|
+
return true;
|
|
2095
|
+
}
|
|
2096
|
+
visiting.add(node);
|
|
2097
|
+
for (const next of edges.get(node) ?? []) {
|
|
2098
|
+
if (visit(next)) {
|
|
2099
|
+
return true;
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
visiting.delete(node);
|
|
2103
|
+
visited.add(node);
|
|
2104
|
+
return false;
|
|
2105
|
+
};
|
|
2106
|
+
for (const node of edges.keys()) {
|
|
2107
|
+
if (visit(node)) {
|
|
2108
|
+
return true;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
return false;
|
|
2112
|
+
}
|
|
2113
|
+
function requireArchitectNonPassEvidence(payload) {
|
|
2114
|
+
const evidenceKeys = ["blockers", "revision_questions", "revision_reasons", "known_gaps", "findings", "remaining_risks", "failures"];
|
|
2115
|
+
const hasEvidence = evidenceKeys.some((key) => {
|
|
2116
|
+
const value = payload[key];
|
|
2117
|
+
if (typeof value === "string") {
|
|
2118
|
+
return value.trim().length > 0;
|
|
2119
|
+
}
|
|
2120
|
+
if (Array.isArray(value)) {
|
|
2121
|
+
return value.length > 0;
|
|
2122
|
+
}
|
|
2123
|
+
return Boolean(value && typeof value === "object");
|
|
2124
|
+
});
|
|
2125
|
+
if (!hasEvidence) {
|
|
2126
|
+
throw new Error("status: non-pass Architect completion requires blockers, revision_questions, revision_reasons, findings, failures, or known_gaps; correction: explain why the plan cannot pass.");
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
function requireArchitectArray(source, key, rootPayload, options = {}) {
|
|
2130
|
+
const pathLabel = options.path ?? `implementation_plan.${key}`;
|
|
2131
|
+
if (!Array.isArray(source[key])) {
|
|
2132
|
+
const nestedHint = architectNestedArrayHint(rootPayload, key);
|
|
2133
|
+
throw new Error(`${pathLabel}: expected array${key === "phases" ? " as a top-level field inside implementation_plan" : ""}; correction: ${nestedHint ?? `set ${pathLabel} to an array${options.allowEmpty ? " (use [] when none exist)" : " with at least one entry"}.`}`);
|
|
2134
|
+
}
|
|
2135
|
+
if (!options.allowEmpty && arrayLength(source, key) === 0) {
|
|
2136
|
+
throw new Error(`${pathLabel}: expected non-empty array; correction: include at least one entry.`);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
function architectNestedArrayHint(rootPayload, key) {
|
|
2140
|
+
if (Array.isArray(rootPayload[key])) {
|
|
2141
|
+
return `move top-level ${key} into implementation_plan.${key}.`;
|
|
2142
|
+
}
|
|
2143
|
+
const implementationPlan = rootPayload.implementation_plan;
|
|
2144
|
+
if (!implementationPlan || typeof implementationPlan !== "object" || Array.isArray(implementationPlan)) {
|
|
2145
|
+
return null;
|
|
2146
|
+
}
|
|
2147
|
+
const plan = implementationPlan;
|
|
2148
|
+
for (const container of ["plan", "dag", "implementation_dag", "implementationPlan", "implementation_plan"]) {
|
|
2149
|
+
const nested = plan[container];
|
|
2150
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested) && Array.isArray(nested[key])) {
|
|
2151
|
+
return `move implementation_plan.${container}.${key} to implementation_plan.${key}.`;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
return null;
|
|
2155
|
+
}
|
|
1680
2156
|
function validateQaPayloadShape(payload, status) {
|
|
1681
2157
|
const evidenceSource = qaEvidenceSource(payload);
|
|
1682
2158
|
const commands = qaArray(payload, "commands", evidenceSource);
|
|
@@ -1881,6 +2357,9 @@ function parseStrictCompletionPhase(value) {
|
|
|
1881
2357
|
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
1882
2358
|
return "qa";
|
|
1883
2359
|
}
|
|
2360
|
+
if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
|
|
2361
|
+
return "architect";
|
|
2362
|
+
}
|
|
1884
2363
|
return null;
|
|
1885
2364
|
}
|
|
1886
2365
|
function normalizeDebugCompletionPhase(value) {
|
|
@@ -1949,6 +2428,9 @@ function phaseFromToolName(toolName) {
|
|
|
1949
2428
|
if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
|
|
1950
2429
|
return "qa";
|
|
1951
2430
|
}
|
|
2431
|
+
if (normalized === "forge.complete_architect_plan" || normalized === "complete_architect_plan") {
|
|
2432
|
+
return "architect";
|
|
2433
|
+
}
|
|
1952
2434
|
const debugGate = debugGateFromToolName(toolName);
|
|
1953
2435
|
if (debugGate) {
|
|
1954
2436
|
return debugGate;
|
|
@@ -2067,11 +2549,20 @@ function requireObject(payload, key) {
|
|
|
2067
2549
|
throw new Error(`${key} must be an object.`);
|
|
2068
2550
|
}
|
|
2069
2551
|
}
|
|
2552
|
+
function requiredRecord(payload, key) {
|
|
2553
|
+
requireObject(payload, key);
|
|
2554
|
+
return payload[key];
|
|
2555
|
+
}
|
|
2070
2556
|
function requireBoolean(payload, key) {
|
|
2071
2557
|
if (typeof payload[key] !== "boolean") {
|
|
2072
2558
|
throw new Error(`${key} must be a boolean.`);
|
|
2073
2559
|
}
|
|
2074
2560
|
}
|
|
2561
|
+
function requireNumericOne(payload, key, pathLabel) {
|
|
2562
|
+
if (payload[key] !== 1) {
|
|
2563
|
+
throw new Error(`${pathLabel}: expected numeric 1; correction: set ${pathLabel} to 1.`);
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2075
2566
|
function requireSatisfiedAcceptanceCriteria(payload) {
|
|
2076
2567
|
const criteria = payload.acceptance_criteria;
|
|
2077
2568
|
if (!Array.isArray(criteria)) {
|
|
@@ -2089,6 +2580,37 @@ function requireSatisfiedAcceptanceCriteria(payload) {
|
|
|
2089
2580
|
}
|
|
2090
2581
|
}
|
|
2091
2582
|
}
|
|
2583
|
+
function validateResolvedFindings(payload) {
|
|
2584
|
+
const resolvedFindings = payload.resolved_findings;
|
|
2585
|
+
if (resolvedFindings === undefined || resolvedFindings === null) {
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2588
|
+
if (!Array.isArray(resolvedFindings)) {
|
|
2589
|
+
throw new Error("resolved_findings must be an array.");
|
|
2590
|
+
}
|
|
2591
|
+
for (const [index, finding] of resolvedFindings.entries()) {
|
|
2592
|
+
const prefix = `resolved_findings[${index}].`;
|
|
2593
|
+
if (!finding || typeof finding !== "object" || Array.isArray(finding)) {
|
|
2594
|
+
throw new Error(`resolved_findings[${index}] must be an object.`);
|
|
2595
|
+
}
|
|
2596
|
+
const record = finding;
|
|
2597
|
+
requireNonEmptyStringRecord(record, "qa_id", prefix);
|
|
2598
|
+
requireKnownStringRecord(record, "status", resolvedFindingStatuses, prefix);
|
|
2599
|
+
requireNonEmptyStringRecord(record, "summary", prefix);
|
|
2600
|
+
requireSubstantiveValueRecord(record, "evidence", prefix);
|
|
2601
|
+
requireArrayRecord(record, "changed_files", prefix);
|
|
2602
|
+
requireArrayRecord(record, "tests_added_or_updated", prefix);
|
|
2603
|
+
requireSubstantiveValueRecord(record, "regression_evidence", prefix);
|
|
2604
|
+
if (normalizedString(record.status) === "resolved") {
|
|
2605
|
+
if (record.changed_files.length === 0) {
|
|
2606
|
+
throw new Error(`${prefix}changed_files must include at least one changed file for resolved QA findings.`);
|
|
2607
|
+
}
|
|
2608
|
+
if (record.tests_added_or_updated.length === 0) {
|
|
2609
|
+
throw new Error(`${prefix}tests_added_or_updated must include at least one regression test for resolved QA findings.`);
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2092
2614
|
function qaEvidenceSource(payload) {
|
|
2093
2615
|
const evidence = payload.evidence;
|
|
2094
2616
|
return evidence && typeof evidence === "object" && !Array.isArray(evidence)
|
|
@@ -2138,6 +2660,11 @@ function requireNonEmptyStringRecord(source, key, prefix = "") {
|
|
|
2138
2660
|
throw new Error(`${prefix}${key} is required.`);
|
|
2139
2661
|
}
|
|
2140
2662
|
}
|
|
2663
|
+
function requireArrayRecord(source, key, prefix = "") {
|
|
2664
|
+
if (!Array.isArray(source[key])) {
|
|
2665
|
+
throw new Error(`${prefix}${key} must be an array.`);
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2141
2668
|
function requireKnownStringRecord(source, key, allowed, prefix = "") {
|
|
2142
2669
|
const value = normalizedString(optionalString(source, key));
|
|
2143
2670
|
if (!allowed.has(value)) {
|
|
@@ -2155,6 +2682,11 @@ function requireNonEmptyStringArrayRecord(source, key, prefix = "") {
|
|
|
2155
2682
|
}
|
|
2156
2683
|
}
|
|
2157
2684
|
}
|
|
2685
|
+
function requireSubstantiveValueRecord(source, key, prefix = "") {
|
|
2686
|
+
if (!hasSubstantiveJsonValue(source[key])) {
|
|
2687
|
+
throw new Error(`${prefix}${key} must include at least one concrete evidence reference or summary.`);
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2158
2690
|
function requireSubstantiveEvidenceRecord(source, prefix = "") {
|
|
2159
2691
|
const evidence = source.evidence;
|
|
2160
2692
|
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
|
|
@@ -2237,7 +2769,7 @@ function parseJsonObject(content, label) {
|
|
|
2237
2769
|
return asRecord(JSON.parse(content), label);
|
|
2238
2770
|
}
|
|
2239
2771
|
catch (error) {
|
|
2240
|
-
throw new Error(`${label} is not valid JSON: ${errorMessage(error)}
|
|
2772
|
+
throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`, { cause: error });
|
|
2241
2773
|
}
|
|
2242
2774
|
}
|
|
2243
2775
|
function parseOptionalJsonObject(content, label) {
|