@loucompanion/forge-bridge 0.1.3-dev.2c3feac3af65 → 0.1.3-dev.62c25087ca89
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.js +8 -2
- package/dist/cli/cli.help.d.ts +2 -0
- package/dist/cli/cli.help.js +171 -0
- package/dist/cli/cli.service.js +5 -183
- package/dist/session/phase-completion.artifacts.d.ts +10 -0
- package/dist/session/phase-completion.artifacts.js +599 -0
- package/dist/session/phase-completion.d.ts +32 -44
- package/dist/session/phase-completion.helpers.d.ts +112 -0
- package/dist/session/phase-completion.helpers.js +660 -0
- package/dist/session/phase-completion.js +28 -3485
- package/dist/session/phase-completion.prompts.d.ts +14 -0
- package/dist/session/phase-completion.prompts.js +220 -0
- package/dist/session/phase-completion.runtime.d.ts +36 -0
- package/dist/session/phase-completion.runtime.js +609 -0
- package/dist/session/phase-completion.schemas.d.ts +3 -0
- package/dist/session/phase-completion.schemas.js +685 -0
- package/dist/session/phase-completion.validation.d.ts +34 -0
- package/dist/session/phase-completion.validation.js +724 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +93 -0
- package/dist/session/provider-cli/provider-cli.artifacts.js +754 -0
- package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
- package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +12 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +318 -0
- package/dist/session/provider-cli/provider-cli.process.d.ts +31 -0
- package/dist/session/provider-cli/provider-cli.process.js +265 -0
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +32 -0
- package/dist/session/provider-cli/provider-cli.prompts.js +387 -0
- package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +65 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +9 -0
- package/dist/session/provider-cli/provider-cli.runtime.js +40 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +6 -53
- package/dist/session/provider-cli/provider-cli.service.js +11 -2495
- package/dist/session/provider-cli/provider-output-state.d.ts +20 -0
- package/dist/session/provider-cli/provider-output-state.js +445 -0
- package/dist/session/session.types.d.ts +17 -0
- package/dist/session/session.types.js +39 -0
- package/dist/shared/process/windows-launch.d.ts +8 -0
- package/dist/shared/process/windows-launch.js +38 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/worktree.service.d.ts +2 -1
- package/dist/worktree/worktree.service.js +32 -12
- package/package.json +1 -1
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { redactSecrets } from "../shared/redaction/redaction.js";
|
|
4
|
+
import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "./qa-sidecar-validation.js";
|
|
5
|
+
import { artifactRootFor, artifactRootForOwner, completionArtifactJsonPath, completionArtifactReportPath, decomposeGateFileNames, draftGateFileNames, phaseFiles, qaGateFileNames } from "./phase-completion.js";
|
|
6
|
+
import { artifactOwnerFields, artifactReferenceOwnerFields, artifactOwnerRelativeRoot, defaultQaChecklist, fallbackReport, isNodeError, jsonText, normalizeArtifactPayload, normalizeCompletionStatus, normalizeQaGateArtifactPayload, normalizeQaGateStatus, normalizedString, optionalString, numberOrDefault, parseJsonObject, parseOptionalJsonObject, phaseFromToolName, qaArray, qaArtifactRelativePath, qaArtifactRelativePathForOwner, qaEvidenceSource, qaObjectArray, qaPolicyWarnings, qaStringArray, readOptionalFile, reportMarkdown, requiredRecord, requiredString, qaGateFromToolName, sha256, stableStringify, validateOptionalWorkItemId, validatePayloadShape, validateQaGatePayloadShape, validateQaScopeRecord, writeFileAtomic, asRecord } from "./phase-completion.validation.js";
|
|
7
|
+
export async function writeDecomposeGateCompletionArtifact(config, gate, toolName, input) {
|
|
8
|
+
const payload = asRecord(input, "Decompose completion payload");
|
|
9
|
+
const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
|
|
10
|
+
if (gate === "decompose_proposal") {
|
|
11
|
+
const proposalJson = asRecord(payload["proposal_json"] ?? payload["decomposition_json"], "proposal_json");
|
|
12
|
+
validateDecomposeProposalJson(proposalJson);
|
|
13
|
+
}
|
|
14
|
+
const redactedPayload = redactSecrets({ ...payload, status, phase: "decompose", gate, tool_name: toolName }, config.redactionSecrets ?? []);
|
|
15
|
+
const root = artifactRootFor(config.worktreePath, config.workItemId);
|
|
16
|
+
await fs.mkdir(root, { recursive: true });
|
|
17
|
+
const artifactPath = path.join(root, decomposeGateFileNames[gate]);
|
|
18
|
+
const reportPath = gate === "decompose_proposal" ? path.join(root, phaseFiles.decompose.reportFileName) : path.join(root, `${gate}.md`);
|
|
19
|
+
const verdictJson = jsonText(redactedPayload);
|
|
20
|
+
const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}
|
|
21
|
+
|
|
22
|
+
${optionalString(redactedPayload, "summary") ?? "Decompose gate recorded."}
|
|
23
|
+
`;
|
|
24
|
+
await writeFileAtomic(artifactPath, verdictJson);
|
|
25
|
+
await writeFileAtomic(reportPath, report);
|
|
26
|
+
return {
|
|
27
|
+
phase: "decompose",
|
|
28
|
+
workItemId: config.workItemId,
|
|
29
|
+
ownerType: config.ownerType,
|
|
30
|
+
ownerId: config.ownerId,
|
|
31
|
+
repoPath: path.resolve(config.worktreePath),
|
|
32
|
+
path: artifactPath,
|
|
33
|
+
reportPath,
|
|
34
|
+
contentHash: sha256(verdictJson),
|
|
35
|
+
status,
|
|
36
|
+
producedBy: config.producedBy ?? "forge-completion-tool"
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export async function writeDraftGateCompletionArtifact(config, gate, toolName, input) {
|
|
40
|
+
const payload = asRecord(input, "Draft completion payload");
|
|
41
|
+
const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
|
|
42
|
+
if (gate === "draft_proposal") {
|
|
43
|
+
const draftJson = asRecord(payload["draft_json"], "draft_json");
|
|
44
|
+
validateDraftProposalJson(draftJson);
|
|
45
|
+
}
|
|
46
|
+
const redactedPayload = redactSecrets({ ...payload, status, phase: "draft", gate, tool_name: toolName }, config.redactionSecrets ?? []);
|
|
47
|
+
const root = artifactRootFor(config.worktreePath, config.workItemId);
|
|
48
|
+
await fs.mkdir(root, { recursive: true });
|
|
49
|
+
const artifactPath = path.join(root, draftGateFileNames[gate]);
|
|
50
|
+
const reportPath = gate === "draft_proposal" ? path.join(root, phaseFiles.draft.reportFileName) : path.join(root, `${gate}.md`);
|
|
51
|
+
const verdictJson = jsonText(redactedPayload);
|
|
52
|
+
const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}\n\n${optionalString(redactedPayload, "summary") ?? "Draft gate recorded."}\n`;
|
|
53
|
+
await writeFileAtomic(artifactPath, verdictJson);
|
|
54
|
+
await writeFileAtomic(reportPath, report);
|
|
55
|
+
return {
|
|
56
|
+
phase: "draft",
|
|
57
|
+
workItemId: config.workItemId,
|
|
58
|
+
ownerType: config.ownerType,
|
|
59
|
+
ownerId: config.ownerId,
|
|
60
|
+
repoPath: path.resolve(config.worktreePath),
|
|
61
|
+
path: artifactPath,
|
|
62
|
+
reportPath,
|
|
63
|
+
contentHash: sha256(verdictJson),
|
|
64
|
+
status,
|
|
65
|
+
producedBy: config.producedBy ?? "forge-completion-tool"
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function normalizeDraftStatus(value) {
|
|
69
|
+
const normalized = value.trim().toLowerCase();
|
|
70
|
+
return normalized === "blocked" || normalized === "needs_revision" || normalized === "needs_rework" ? normalized : "pass";
|
|
71
|
+
}
|
|
72
|
+
function validateDraftProposalJson(draftJson) {
|
|
73
|
+
if (draftJson.suggested_qa_scope !== undefined && draftJson.suggested_qa_scope !== null) {
|
|
74
|
+
if (!draftJson.suggested_qa_scope || typeof draftJson.suggested_qa_scope !== "object" || Array.isArray(draftJson.suggested_qa_scope)) {
|
|
75
|
+
throw new Error("draft_json.suggested_qa_scope: expected object when present.");
|
|
76
|
+
}
|
|
77
|
+
validateQaScopeRecord(draftJson.suggested_qa_scope, "draft_json.suggested_qa_scope");
|
|
78
|
+
}
|
|
79
|
+
if (isDecompositionDraftJson(draftJson)) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (isWorkItemDraftJson(draftJson)) {
|
|
83
|
+
for (const key of ["title", "description", "acceptanceCriteria"]) {
|
|
84
|
+
if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
|
|
85
|
+
throw new Error(`draft_json.${key}: expected non-empty string`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
for (const key of ["title", "summary", "objective", "planningPacket", "decompositionContext"]) {
|
|
91
|
+
if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
|
|
92
|
+
throw new Error(`draft_json.${key}: expected non-empty string`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function isDecompositionDraftJson(draftJson) {
|
|
97
|
+
const summary = draftJson["summary"];
|
|
98
|
+
return typeof summary === "string"
|
|
99
|
+
&& summary.trim().length > 0
|
|
100
|
+
&& Array.isArray(draftJson["candidates"]);
|
|
101
|
+
}
|
|
102
|
+
function isWorkItemDraftJson(draftJson) {
|
|
103
|
+
return typeof draftJson.title === "string"
|
|
104
|
+
&& typeof draftJson.description === "string"
|
|
105
|
+
&& typeof draftJson.acceptanceCriteria === "string";
|
|
106
|
+
}
|
|
107
|
+
function validateDecomposeProposalJson(proposalJson) {
|
|
108
|
+
const candidates = proposalJson.candidates;
|
|
109
|
+
if (!Array.isArray(candidates)) {
|
|
110
|
+
throw new Error("proposal_json.candidates: expected array");
|
|
111
|
+
}
|
|
112
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
113
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
114
|
+
throw new Error(`proposal_json.candidates[${index}]: expected object.`);
|
|
115
|
+
}
|
|
116
|
+
const record = candidate;
|
|
117
|
+
validateOptionalCandidateQaScope(record, "estimated_qa_scope", `proposal_json.candidates[${index}].estimated_qa_scope`);
|
|
118
|
+
validateOptionalCandidateQaScope(record, "estimatedQaScope", `proposal_json.candidates[${index}].estimatedQaScope`);
|
|
119
|
+
validateOptionalCandidateQaScope(record, "qa_scope", `proposal_json.candidates[${index}].qa_scope`);
|
|
120
|
+
validateOptionalCandidateQaScope(record, "qaScope", `proposal_json.candidates[${index}].qaScope`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function validateOptionalCandidateQaScope(candidate, key, pathLabel) {
|
|
124
|
+
const value = candidate[key];
|
|
125
|
+
if (value === undefined || value === null) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
129
|
+
throw new Error(`${pathLabel}: expected object when present.`);
|
|
130
|
+
}
|
|
131
|
+
validateQaScopeRecord(value, pathLabel);
|
|
132
|
+
}
|
|
133
|
+
export async function writeCompletionArtifact(config, toolName, input) {
|
|
134
|
+
const phase = phaseFromToolName(toolName);
|
|
135
|
+
if (phase !== config.phase) {
|
|
136
|
+
throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
|
|
137
|
+
}
|
|
138
|
+
const qaGate = qaGateFromToolName(toolName);
|
|
139
|
+
if (qaGate && qaGate !== "qa_verdict") {
|
|
140
|
+
return await writeQaGateCompletionArtifact(config, qaGate, input);
|
|
141
|
+
}
|
|
142
|
+
const payload = asRecord(input, "completion payload");
|
|
143
|
+
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
144
|
+
let validationPayload = { ...payload, status };
|
|
145
|
+
if (config.qaCycle && qaGate === "qa_verdict") {
|
|
146
|
+
validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
|
|
147
|
+
}
|
|
148
|
+
validatePayloadShape(phase, validationPayload);
|
|
149
|
+
validateOptionalWorkItemId(validationPayload, config.workItemId);
|
|
150
|
+
const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
|
|
151
|
+
const producedBy = optionalString(redactedPayload, "produced_by")
|
|
152
|
+
?? optionalString(redactedPayload, "producedBy")
|
|
153
|
+
?? config.producedBy
|
|
154
|
+
?? "forge-completion-tool";
|
|
155
|
+
if (phase === "qa") {
|
|
156
|
+
return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
157
|
+
}
|
|
158
|
+
if (phase === "architect") {
|
|
159
|
+
return await writeArchitectCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
160
|
+
}
|
|
161
|
+
const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
|
|
162
|
+
phase,
|
|
163
|
+
workItemId: config.workItemId,
|
|
164
|
+
ownerType: config.ownerType,
|
|
165
|
+
ownerId: config.ownerId,
|
|
166
|
+
producedBy,
|
|
167
|
+
status
|
|
168
|
+
});
|
|
169
|
+
const contentHash = sha256(stableStringify(artifactWithoutHash));
|
|
170
|
+
const artifact = {
|
|
171
|
+
...artifactWithoutHash,
|
|
172
|
+
content_hash: contentHash
|
|
173
|
+
};
|
|
174
|
+
const artifactJson = `${JSON.stringify(artifact, null, 2)}\n`;
|
|
175
|
+
const report = reportMarkdown(redactedPayload, phase, status, producedBy, contentHash);
|
|
176
|
+
const artifactPath = completionArtifactJsonPath(config.worktreePath, config.workItemId, phase);
|
|
177
|
+
const reportPath = completionArtifactReportPath(config.worktreePath, config.workItemId, phase);
|
|
178
|
+
await fs.mkdir(path.dirname(artifactPath), { recursive: true });
|
|
179
|
+
await writeFileAtomic(artifactPath, artifactJson);
|
|
180
|
+
await writeFileAtomic(reportPath, report);
|
|
181
|
+
return {
|
|
182
|
+
phase,
|
|
183
|
+
workItemId: config.workItemId,
|
|
184
|
+
...artifactReferenceOwnerFields(config),
|
|
185
|
+
repoPath: path.resolve(config.worktreePath),
|
|
186
|
+
path: artifactPath,
|
|
187
|
+
reportPath,
|
|
188
|
+
contentHash,
|
|
189
|
+
status,
|
|
190
|
+
producedBy
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
export async function writeArchitectCompletionArtifact(config, payload, status, producedBy) {
|
|
194
|
+
const ownerFields = artifactOwnerFields(config);
|
|
195
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
196
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
197
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
198
|
+
const implementationPlanPath = path.join(artifactRoot, "implementation.plan.json");
|
|
199
|
+
const qaPlanPath = path.join(artifactRoot, "qa.plan.json");
|
|
200
|
+
const verdictPath = path.join(artifactRoot, "architect.verdict.json");
|
|
201
|
+
const reportPath = path.join(artifactRoot, "architect.report.md");
|
|
202
|
+
const implementationPlan = requiredRecord(payload, "implementation_plan");
|
|
203
|
+
const qaPlan = requiredRecord(payload, "qa_plan");
|
|
204
|
+
const report = optionalString(payload, "report") ?? optionalString(payload, "report_markdown") ?? reportMarkdown(payload, "architect", status, producedBy, "pending");
|
|
205
|
+
const artifactWithoutHash = normalizeArtifactPayload(payload, {
|
|
206
|
+
phase: "architect",
|
|
207
|
+
workItemId: config.workItemId,
|
|
208
|
+
ownerType: config.ownerType,
|
|
209
|
+
ownerId: config.ownerId,
|
|
210
|
+
producedBy,
|
|
211
|
+
status
|
|
212
|
+
});
|
|
213
|
+
const contentHash = sha256(stableStringify(artifactWithoutHash));
|
|
214
|
+
const verdict = {
|
|
215
|
+
...artifactWithoutHash,
|
|
216
|
+
content_hash: contentHash
|
|
217
|
+
};
|
|
218
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
219
|
+
await writeFileAtomic(implementationPlanPath, jsonText(implementationPlan));
|
|
220
|
+
await writeFileAtomic(qaPlanPath, jsonText(qaPlan));
|
|
221
|
+
await writeFileAtomic(verdictPath, jsonText(verdict));
|
|
222
|
+
await writeFileAtomic(reportPath, `${report.trim()}\n`);
|
|
223
|
+
return {
|
|
224
|
+
phase: "architect",
|
|
225
|
+
workItemId: config.workItemId,
|
|
226
|
+
...artifactReferenceOwnerFields(config),
|
|
227
|
+
repoPath: path.resolve(config.worktreePath),
|
|
228
|
+
path: verdictPath,
|
|
229
|
+
reportPath,
|
|
230
|
+
contentHash,
|
|
231
|
+
status,
|
|
232
|
+
producedBy
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
export async function writeQaGateCompletionArtifact(config, gate, input) {
|
|
236
|
+
const payload = asRecord(input, "QA gate completion payload");
|
|
237
|
+
const status = normalizeQaGateStatus(requiredString(payload, "status"));
|
|
238
|
+
validateQaGatePayloadShape(gate, payload, status);
|
|
239
|
+
validateOptionalWorkItemId(payload, config.workItemId);
|
|
240
|
+
const redactedPayload = redactSecrets({ ...payload, status }, config.redactionSecrets ?? []);
|
|
241
|
+
const producedBy = optionalString(redactedPayload, "produced_by")
|
|
242
|
+
?? optionalString(redactedPayload, "producedBy")
|
|
243
|
+
?? config.producedBy
|
|
244
|
+
?? "forge-completion-tool";
|
|
245
|
+
const ownerFields = artifactOwnerFields(config);
|
|
246
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
247
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
248
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
249
|
+
const fileName = qaGateFileNames[gate];
|
|
250
|
+
const artifactPath = path.join(artifactRoot, fileName);
|
|
251
|
+
const reportPath = path.join(artifactRoot, "qa.report.md");
|
|
252
|
+
const artifactWithoutHash = normalizeQaGateArtifactPayload(redactedPayload, {
|
|
253
|
+
gate,
|
|
254
|
+
workItemId: config.workItemId,
|
|
255
|
+
ownerType: config.ownerType,
|
|
256
|
+
ownerId: config.ownerId,
|
|
257
|
+
producedBy,
|
|
258
|
+
status
|
|
259
|
+
});
|
|
260
|
+
const contentHash = sha256(stableStringify(artifactWithoutHash));
|
|
261
|
+
const artifact = {
|
|
262
|
+
...artifactWithoutHash,
|
|
263
|
+
content_hash: contentHash
|
|
264
|
+
};
|
|
265
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
266
|
+
await writeFileAtomic(artifactPath, jsonText(artifact));
|
|
267
|
+
return {
|
|
268
|
+
phase: "qa",
|
|
269
|
+
workItemId: config.workItemId,
|
|
270
|
+
...artifactReferenceOwnerFields(config),
|
|
271
|
+
repoPath: path.resolve(config.worktreePath),
|
|
272
|
+
path: artifactPath,
|
|
273
|
+
reportPath,
|
|
274
|
+
contentHash,
|
|
275
|
+
status,
|
|
276
|
+
producedBy
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
export async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
280
|
+
const ownerFields = artifactOwnerFields(config);
|
|
281
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
282
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
283
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
284
|
+
const planPath = path.join(artifactRoot, "qa.plan.json");
|
|
285
|
+
const evidencePath = path.join(artifactRoot, "qa.evidence.json");
|
|
286
|
+
const findingsPath = path.join(artifactRoot, "qa.findings.json");
|
|
287
|
+
const verdictPath = path.join(artifactRoot, "qa.verdict.json");
|
|
288
|
+
const reportPath = path.join(artifactRoot, "qa.report.md");
|
|
289
|
+
const relativeEvidencePath = ownerFields.owner_type && ownerFields.owner_id
|
|
290
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.evidence.json")
|
|
291
|
+
: qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
|
|
292
|
+
const relativeFindingsPath = ownerFields.owner_type && ownerFields.owner_id
|
|
293
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.findings.json")
|
|
294
|
+
: qaArtifactRelativePath(config.workItemId, "qa.findings.json");
|
|
295
|
+
const relativeReportPath = ownerFields.owner_type && ownerFields.owner_id
|
|
296
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.report.md")
|
|
297
|
+
: qaArtifactRelativePath(config.workItemId, "qa.report.md");
|
|
298
|
+
const evidenceSource = qaEvidenceSource(payload);
|
|
299
|
+
const commands = qaArray(payload, "commands", evidenceSource);
|
|
300
|
+
const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
|
|
301
|
+
const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource), ownerFields.owner_type, ownerFields.owner_id);
|
|
302
|
+
const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
|
|
303
|
+
const findings = qaObjectArray(payload, "findings");
|
|
304
|
+
const candidateBugs = qaObjectArray(payload, "candidate_bugs");
|
|
305
|
+
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
306
|
+
const explicitFindingIds = qaStringArray(payload, "finding_ids");
|
|
307
|
+
const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
|
|
308
|
+
const policyWarnings = qaPolicyWarnings(payload);
|
|
309
|
+
const blockerReason = optionalString(payload, "blocker_reason");
|
|
310
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
311
|
+
const planJson = jsonText({
|
|
312
|
+
schema_version: 1,
|
|
313
|
+
phase: "qa",
|
|
314
|
+
...ownerFields,
|
|
315
|
+
checklist: checklist.length > 0 ? checklist : defaultQaChecklist(status),
|
|
316
|
+
acceptance_criteria: testedAcceptanceCriteria
|
|
317
|
+
});
|
|
318
|
+
const evidenceJson = jsonText({
|
|
319
|
+
schema_version: 1,
|
|
320
|
+
phase: "qa",
|
|
321
|
+
...ownerFields,
|
|
322
|
+
commands,
|
|
323
|
+
browser_checks: browserChecks,
|
|
324
|
+
sidecars,
|
|
325
|
+
notes
|
|
326
|
+
});
|
|
327
|
+
const findingsJson = jsonText({
|
|
328
|
+
schema_version: 1,
|
|
329
|
+
phase: "qa",
|
|
330
|
+
...ownerFields,
|
|
331
|
+
findings,
|
|
332
|
+
candidate_bugs: candidateBugs
|
|
333
|
+
});
|
|
334
|
+
const findingIds = status === "pass"
|
|
335
|
+
? []
|
|
336
|
+
: explicitFindingIds.length > 0
|
|
337
|
+
? explicitFindingIds
|
|
338
|
+
: findings
|
|
339
|
+
.filter((finding) => normalizedString(finding.scope) === "in_scope")
|
|
340
|
+
.map((finding) => optionalString(finding, "id"))
|
|
341
|
+
.filter((id) => Boolean(id));
|
|
342
|
+
const candidateBugIds = explicitCandidateBugIds.length > 0
|
|
343
|
+
? explicitCandidateBugIds
|
|
344
|
+
: candidateBugs
|
|
345
|
+
.map((candidateBug) => optionalString(candidateBug, "id"))
|
|
346
|
+
.filter((id) => Boolean(id));
|
|
347
|
+
const verdictWithoutHash = {
|
|
348
|
+
schema_version: numberOrDefault(payload.schema_version, 1),
|
|
349
|
+
phase: "qa",
|
|
350
|
+
...ownerFields,
|
|
351
|
+
status,
|
|
352
|
+
summary: requiredString(payload, "summary"),
|
|
353
|
+
tested_acceptance_criteria: testedAcceptanceCriteria,
|
|
354
|
+
finding_ids: findingIds,
|
|
355
|
+
candidate_bug_ids: candidateBugIds,
|
|
356
|
+
policy_warnings: policyWarnings,
|
|
357
|
+
blocker_reason: status === "blocked" ? normalizedString(blockerReason) : null,
|
|
358
|
+
evidence_path: relativeEvidencePath,
|
|
359
|
+
findings_path: relativeFindingsPath,
|
|
360
|
+
report_path: relativeReportPath,
|
|
361
|
+
produced_by: producedBy
|
|
362
|
+
};
|
|
363
|
+
const contentHash = sha256(stableStringify({
|
|
364
|
+
plan: JSON.parse(planJson),
|
|
365
|
+
evidence: JSON.parse(evidenceJson),
|
|
366
|
+
findings: JSON.parse(findingsJson),
|
|
367
|
+
verdict: verdictWithoutHash
|
|
368
|
+
}));
|
|
369
|
+
const verdictJson = jsonText({
|
|
370
|
+
...verdictWithoutHash,
|
|
371
|
+
content_hash: contentHash
|
|
372
|
+
});
|
|
373
|
+
const finalReport = reportMarkdown(payload, "qa", status, producedBy, contentHash);
|
|
374
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
375
|
+
await writeFileAtomic(planPath, planJson);
|
|
376
|
+
await writeFileAtomic(evidencePath, evidenceJson);
|
|
377
|
+
await writeFileAtomic(findingsPath, findingsJson);
|
|
378
|
+
await writeFileAtomic(verdictPath, verdictJson);
|
|
379
|
+
await writeFileAtomic(reportPath, finalReport);
|
|
380
|
+
return {
|
|
381
|
+
phase: "qa",
|
|
382
|
+
workItemId: config.workItemId,
|
|
383
|
+
...artifactReferenceOwnerFields(config),
|
|
384
|
+
repoPath: path.resolve(config.worktreePath),
|
|
385
|
+
path: verdictPath,
|
|
386
|
+
reportPath,
|
|
387
|
+
contentHash,
|
|
388
|
+
status,
|
|
389
|
+
producedBy
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
async function synthesizeQaCycleVerdictPayload(config, payload, status) {
|
|
393
|
+
const qaPlan = await readQaGateArtifact(config, "qa_plan");
|
|
394
|
+
const qaRegression = await readQaGateArtifact(config, "qa_regression");
|
|
395
|
+
const qaApi = await readQaGateArtifact(config, "qa_api");
|
|
396
|
+
const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
|
|
397
|
+
const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
|
|
398
|
+
const qaFindings = await readQaGateArtifact(config, "qa_findings");
|
|
399
|
+
const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
|
|
400
|
+
const payloadEvidence = qaEvidenceSource(payload);
|
|
401
|
+
const merged = { ...payload };
|
|
402
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
403
|
+
if (checklist.length === 0) {
|
|
404
|
+
const plannedChecklist = qaStringArray(qaPlan, "checklist");
|
|
405
|
+
merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
|
|
406
|
+
}
|
|
407
|
+
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
408
|
+
if (testedAcceptanceCriteria.length === 0) {
|
|
409
|
+
const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
|
|
410
|
+
const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
|
|
411
|
+
const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
|
|
412
|
+
if (acceptanceCriteria.length > 0) {
|
|
413
|
+
merged.tested_acceptance_criteria = acceptanceCriteria;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const payloadCommands = qaArray(payload, "commands", payloadEvidence);
|
|
417
|
+
const inheritedCommands = evidenceGates.flatMap((gate) => qaArray(gate, "commands"));
|
|
418
|
+
const inheritedDiagnosticCommandNotes = status === "pass"
|
|
419
|
+
? qaNonPassingCommandNotes(inheritedCommands)
|
|
420
|
+
: [];
|
|
421
|
+
merged.commands = dedupeUnknownArray([
|
|
422
|
+
...payloadCommands,
|
|
423
|
+
...(status === "pass" ? inheritedCommands.filter(isQaPassingCommandEvidence) : inheritedCommands)
|
|
424
|
+
]);
|
|
425
|
+
merged.browser_checks = dedupeUnknownArray([
|
|
426
|
+
...qaArray(payload, "browser_checks", payloadEvidence),
|
|
427
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
|
|
428
|
+
]);
|
|
429
|
+
merged.sidecars = dedupeUnknownArray([
|
|
430
|
+
...qaArray(payload, "sidecars", payloadEvidence),
|
|
431
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
|
|
432
|
+
]);
|
|
433
|
+
const notes = dedupeStrings([
|
|
434
|
+
optionalString(payload, "notes"),
|
|
435
|
+
optionalString(payloadEvidence, "notes"),
|
|
436
|
+
...evidenceGates.map((gate) => optionalString(gate, "notes")),
|
|
437
|
+
...inheritedDiagnosticCommandNotes
|
|
438
|
+
]);
|
|
439
|
+
if (notes.length > 0) {
|
|
440
|
+
merged.notes = notes.join("\n");
|
|
441
|
+
}
|
|
442
|
+
merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
|
|
443
|
+
merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
|
|
444
|
+
return merged;
|
|
445
|
+
}
|
|
446
|
+
export async function readQaGateArtifact(config, gate) {
|
|
447
|
+
const ownerFields = artifactOwnerFields(config);
|
|
448
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
449
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
450
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
451
|
+
const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
|
|
452
|
+
const content = await readOptionalFile(artifactPath);
|
|
453
|
+
return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
|
|
454
|
+
}
|
|
455
|
+
function dedupeUnknownArray(values) {
|
|
456
|
+
const seen = new Set();
|
|
457
|
+
const deduped = [];
|
|
458
|
+
for (const value of values) {
|
|
459
|
+
const key = stableStringify(value);
|
|
460
|
+
if (seen.has(key)) {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
seen.add(key);
|
|
464
|
+
deduped.push(value);
|
|
465
|
+
}
|
|
466
|
+
return deduped;
|
|
467
|
+
}
|
|
468
|
+
function isQaPassingCommandEvidence(value) {
|
|
469
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
const record = value;
|
|
473
|
+
return record.exit_code === 0 && normalizedString(record.status) === "pass";
|
|
474
|
+
}
|
|
475
|
+
function qaNonPassingCommandNotes(values) {
|
|
476
|
+
return values
|
|
477
|
+
.filter((value) => !isQaPassingCommandEvidence(value))
|
|
478
|
+
.map((value, index) => {
|
|
479
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
480
|
+
return `QA diagnostic command ${index + 1} was omitted from pass evidence because it was not a structured passing command.`;
|
|
481
|
+
}
|
|
482
|
+
const record = value;
|
|
483
|
+
const command = optionalString(record, "command") ?? "<missing command>";
|
|
484
|
+
const status = optionalString(record, "status") ?? "<missing status>";
|
|
485
|
+
const exitCode = typeof record.exit_code === "number" && Number.isFinite(record.exit_code)
|
|
486
|
+
? String(record.exit_code)
|
|
487
|
+
: "<missing exit_code>";
|
|
488
|
+
const summary = optionalString(record, "summary");
|
|
489
|
+
return `QA diagnostic command omitted from pass evidence: ${command} (status ${status}, exit_code ${exitCode})${summary ? ` — ${summary}` : ""}.`;
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
function mergeQaObjectArraysById(primary, fallback) {
|
|
493
|
+
const seen = new Set();
|
|
494
|
+
const merged = [];
|
|
495
|
+
for (const value of [...primary, ...fallback]) {
|
|
496
|
+
const id = optionalString(value, "id");
|
|
497
|
+
const key = id ? `id:${id}` : stableStringify(value);
|
|
498
|
+
if (seen.has(key)) {
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
seen.add(key);
|
|
502
|
+
merged.push(value);
|
|
503
|
+
}
|
|
504
|
+
return merged;
|
|
505
|
+
}
|
|
506
|
+
function dedupeStrings(values) {
|
|
507
|
+
const seen = new Set();
|
|
508
|
+
const deduped = [];
|
|
509
|
+
for (const value of values) {
|
|
510
|
+
const trimmed = value?.trim();
|
|
511
|
+
if (!trimmed || seen.has(trimmed)) {
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
seen.add(trimmed);
|
|
515
|
+
deduped.push(trimmed);
|
|
516
|
+
}
|
|
517
|
+
return deduped;
|
|
518
|
+
}
|
|
519
|
+
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
|
|
520
|
+
if (sidecars.length === 0) {
|
|
521
|
+
return [];
|
|
522
|
+
}
|
|
523
|
+
const artifactRoot = ownerType && ownerId
|
|
524
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
525
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
526
|
+
const qaSidecarRoot = path.resolve(artifactRoot, "qa");
|
|
527
|
+
const validated = [];
|
|
528
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
529
|
+
if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar)) {
|
|
530
|
+
throw new Error(`sidecars[${index}] must be an object.`);
|
|
531
|
+
}
|
|
532
|
+
const record = sidecar;
|
|
533
|
+
const sidecarPath = optionalString(record, "path");
|
|
534
|
+
const contentHash = optionalString(record, "content_hash");
|
|
535
|
+
const bytes = record.bytes;
|
|
536
|
+
if (!sidecarPath || !contentHash || typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
|
|
537
|
+
throw new Error(`sidecars[${index}] must include path, content_hash, and positive bytes.`);
|
|
538
|
+
}
|
|
539
|
+
if (path.isAbsolute(sidecarPath)) {
|
|
540
|
+
throw new Error(`sidecars[${index}].path must be relative to the worktree.`);
|
|
541
|
+
}
|
|
542
|
+
const fullPath = path.resolve(worktreePath, sidecarPath);
|
|
543
|
+
if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
|
|
544
|
+
throw new Error(`sidecars[${index}].path must be under ${artifactOwnerRelativeRoot(ownerType, ownerId, workItemId)}/qa/.`);
|
|
545
|
+
}
|
|
546
|
+
const stat = await fs.lstat(fullPath).catch((error) => {
|
|
547
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
548
|
+
throw new Error(`sidecars[${index}].path does not exist: ${sidecarPath}`);
|
|
549
|
+
}
|
|
550
|
+
throw error;
|
|
551
|
+
});
|
|
552
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
553
|
+
throw new Error(`sidecars[${index}].path must reference a regular file.`);
|
|
554
|
+
}
|
|
555
|
+
await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `sidecars[${index}].path must not traverse symbolic links.`);
|
|
556
|
+
if (stat.size !== bytes) {
|
|
557
|
+
throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
|
|
558
|
+
}
|
|
559
|
+
const actualHash = await sha256File(fullPath);
|
|
560
|
+
if (actualHash !== contentHash.trim().toLowerCase()) {
|
|
561
|
+
throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
|
|
562
|
+
}
|
|
563
|
+
validated.push({
|
|
564
|
+
...record,
|
|
565
|
+
path: path.relative(worktreePath, fullPath).split(path.sep).join("/"),
|
|
566
|
+
content_hash: actualHash,
|
|
567
|
+
bytes: stat.size
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
return validated;
|
|
571
|
+
}
|
|
572
|
+
export async function readCompletionArtifact(worktreePath, workItemId, phase, ownerType, ownerId) {
|
|
573
|
+
const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId);
|
|
574
|
+
const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId);
|
|
575
|
+
const verdictJson = await readOptionalFile(artifactPath);
|
|
576
|
+
if (verdictJson === null) {
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
const parsed = parseJsonObject(verdictJson, artifactPath);
|
|
580
|
+
const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
|
|
581
|
+
const artifactRoot = path.dirname(artifactPath);
|
|
582
|
+
const implementationPlanJson = phase === "architect"
|
|
583
|
+
? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
|
|
584
|
+
: null;
|
|
585
|
+
const qaPlanJson = phase === "architect"
|
|
586
|
+
? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
|
|
587
|
+
: null;
|
|
588
|
+
return {
|
|
589
|
+
repoPath: path.resolve(worktreePath),
|
|
590
|
+
path: artifactPath,
|
|
591
|
+
verdictJson,
|
|
592
|
+
implementationPlanJson,
|
|
593
|
+
qaPlanJson,
|
|
594
|
+
report,
|
|
595
|
+
producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
|
|
596
|
+
status: optionalString(parsed, "status") ?? "unknown",
|
|
597
|
+
phase
|
|
598
|
+
};
|
|
599
|
+
}
|