@loucompanion/forge-bridge 0.1.3-dev.b6be5e7f9668 → 0.1.3-dev.bf78fdcaa623
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 +44 -0
- package/dist/bridge/bridge.base.d.ts +5 -1
- package/dist/bridge/bridge.service.d.ts +36 -2
- package/dist/bridge/bridge.service.js +588 -18
- package/dist/bridge/bridge.types.d.ts +114 -1
- package/dist/cli/cli.help.d.ts +2 -0
- package/dist/cli/cli.help.js +183 -0
- package/dist/cli/cli.service.d.ts +12 -0
- package/dist/cli/cli.service.js +176 -184
- package/dist/cli/local-config.d.ts +2 -0
- package/dist/cli/local-config.js +6 -2
- package/dist/sandbox/command-runner.d.ts +4 -0
- package/dist/sandbox/command-runner.js +47 -0
- package/dist/sandbox/contract.d.ts +5 -0
- package/dist/sandbox/contract.js +276 -0
- package/dist/sandbox/event-budget.d.ts +11 -0
- package/dist/sandbox/event-budget.js +27 -0
- package/dist/sandbox/image-policy.d.ts +33 -0
- package/dist/sandbox/image-policy.js +245 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/lease-watchdog.d.ts +18 -0
- package/dist/sandbox/lease-watchdog.js +67 -0
- package/dist/sandbox/preflight.d.ts +36 -0
- package/dist/sandbox/preflight.js +239 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +124 -0
- package/dist/sandbox/provider-auth/codex.d.ts +26 -0
- package/dist/sandbox/provider-auth/codex.js +212 -0
- package/dist/sandbox/repository-lock.d.ts +5 -0
- package/dist/sandbox/repository-lock.js +83 -0
- package/dist/sandbox/runtime.d.ts +216 -0
- package/dist/sandbox/runtime.js +1658 -0
- package/dist/sandbox/sandbox.types.d.ts +83 -0
- package/dist/sandbox/sandbox.types.js +1 -0
- package/dist/sandbox/worker-supervisor.d.ts +13 -0
- package/dist/sandbox/worker-supervisor.js +36 -0
- package/dist/sandbox/worker.d.ts +1 -0
- package/dist/sandbox/worker.js +228 -0
- package/dist/sandbox/workspace.service.d.ts +12 -0
- package/dist/sandbox/workspace.service.js +49 -0
- package/dist/session/phase-completion.artifacts.d.ts +10 -0
- package/dist/session/phase-completion.artifacts.js +620 -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 +707 -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.base.d.ts +1 -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 +14 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +329 -0
- package/dist/session/provider-cli/provider-cli.process.d.ts +32 -0
- package/dist/session/provider-cli/provider-cli.process.js +287 -0
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +44 -0
- package/dist/session/provider-cli/provider-cli.prompts.js +528 -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 +9 -53
- package/dist/session/provider-cli/provider-cli.service.js +161 -2527
- package/dist/session/provider-cli/provider-cli.types.d.ts +1 -0
- 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.service.js +34 -24
- package/dist/session/session.types.d.ts +106 -1
- package/dist/session/session.types.js +248 -3
- package/dist/shared/process/windows-launch.d.ts +8 -0
- package/dist/shared/process/windows-launch.js +41 -0
- package/dist/shared/provider-capabilities.d.ts +1 -0
- package/dist/shared/provider-capabilities.js +3 -0
- package/dist/shared/redaction/redaction.js +4 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.js +39 -6
- package/dist/worktree/dependency-warmer.d.ts +11 -0
- package/dist/worktree/dependency-warmer.js +45 -1
- package/dist/worktree/worktree.service.d.ts +2 -1
- package/dist/worktree/worktree.service.js +32 -12
- package/package.json +4 -2
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { artifactRootFor, artifactRootForOwner, debugCompletionPhaseOrder, draftCompletionGateOrder, qaCompletionGateOrder, readCompletionArtifact } from "../phase-completion.js";
|
|
4
|
+
import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "../qa-sidecar-validation.js";
|
|
5
|
+
import { hasLaunchTarget } from "./provider-cli.qa-sidecars.js";
|
|
6
|
+
import { clip, isNodeError, messageFromError, normalizedString, parseJsonLine, stringField } from "./provider-output-state.js";
|
|
7
|
+
const developCyclePhaseOrder = ["implementation", "self_review", "validation"];
|
|
8
|
+
export async function emitDraftGateArtifacts(emit, worktreePath, workItemId) {
|
|
9
|
+
const draftArtifactFiles = {
|
|
10
|
+
draft_intent: "draft.intent.json",
|
|
11
|
+
draft_repo_context: "draft.repo-context.json",
|
|
12
|
+
draft_graph_context: "draft.graph-context.json",
|
|
13
|
+
draft_product_scope: "draft.product-scope.json",
|
|
14
|
+
draft_work_order_plan: "draft.work-order-plan.json",
|
|
15
|
+
draft_critic: "draft.critic.json",
|
|
16
|
+
draft_proposal: "draft.proposal.json"
|
|
17
|
+
};
|
|
18
|
+
for (const gate of draftCompletionGateOrder) {
|
|
19
|
+
const actualPath = path.join(worktreePath, ".forge", "work-items", workItemId, "artifacts", draftArtifactFiles[gate]);
|
|
20
|
+
if (!await fileExists(actualPath)) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const relativePath = path.relative(worktreePath, actualPath).split(path.sep).join("/");
|
|
24
|
+
await emit({
|
|
25
|
+
role: "tool",
|
|
26
|
+
kind: "tool_result",
|
|
27
|
+
content: `Forge Draft completion tool recorded ${gate} artifact ${relativePath}.`,
|
|
28
|
+
eventType: "completion_tool_artifact_recorded",
|
|
29
|
+
data: JSON.stringify({ phase: "draft", draft_gate: gate, artifact_relative_paths: [relativePath] })
|
|
30
|
+
});
|
|
31
|
+
await emit({
|
|
32
|
+
role: "hook",
|
|
33
|
+
kind: "checkpoint",
|
|
34
|
+
content: `phase_gate: ${gate} artifact accepted with status pass.`,
|
|
35
|
+
eventType: "phase_gate"
|
|
36
|
+
});
|
|
37
|
+
const next = draftCompletionGateOrder[draftCompletionGateOrder.indexOf(gate) + 1];
|
|
38
|
+
if (next) {
|
|
39
|
+
await emit({
|
|
40
|
+
role: "hook",
|
|
41
|
+
kind: "checkpoint",
|
|
42
|
+
content: `phase_progression: ${gate} -> ${next}`,
|
|
43
|
+
eventType: "phase_gate"
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function emitDecomposeGateArtifacts(emit, worktreePath, workItemId) {
|
|
49
|
+
const decomposeArtifactFiles = {
|
|
50
|
+
decompose_subject_intake: "decompose.subject-intake.json",
|
|
51
|
+
decompose_context_review: "decompose.context-review.json",
|
|
52
|
+
decompose_breakdown_strategy: "decompose.breakdown-strategy.json",
|
|
53
|
+
decompose_child_items: "decompose.child-items.json",
|
|
54
|
+
decompose_dependency_graph: "decompose.dependency-graph.json",
|
|
55
|
+
decompose_critic: "decompose.critic.json",
|
|
56
|
+
decompose_proposal: "decompose.proposal.json"
|
|
57
|
+
};
|
|
58
|
+
const gates = Object.keys(decomposeArtifactFiles);
|
|
59
|
+
for (const gate of gates) {
|
|
60
|
+
const actualPath = path.join(worktreePath, ".forge", "work-items", workItemId, "artifacts", decomposeArtifactFiles[gate]);
|
|
61
|
+
if (!await fileExists(actualPath)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const relativePath = path.relative(worktreePath, actualPath).split(path.sep).join("/");
|
|
65
|
+
await emit({
|
|
66
|
+
role: "tool",
|
|
67
|
+
kind: "tool_result",
|
|
68
|
+
content: `Forge Decompose completion tool recorded ${gate} artifact ${relativePath}.`,
|
|
69
|
+
eventType: "completion_tool_artifact_recorded",
|
|
70
|
+
data: JSON.stringify({ phase: "decompose", decompose_gate: gate, artifact_relative_paths: [relativePath] })
|
|
71
|
+
});
|
|
72
|
+
await emit({
|
|
73
|
+
role: "hook",
|
|
74
|
+
kind: "checkpoint",
|
|
75
|
+
content: `phase_gate: ${gate} artifact accepted with status pass.`,
|
|
76
|
+
eventType: "phase_gate"
|
|
77
|
+
});
|
|
78
|
+
const next = gates[gates.indexOf(gate) + 1];
|
|
79
|
+
if (next) {
|
|
80
|
+
await emit({
|
|
81
|
+
role: "hook",
|
|
82
|
+
kind: "checkpoint",
|
|
83
|
+
content: `phase_progression: ${gate} -> ${next}`,
|
|
84
|
+
eventType: "phase_gate"
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export async function fileExists(filePath) {
|
|
90
|
+
try {
|
|
91
|
+
await fs.access(filePath);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function summarizeStderr(stderr) {
|
|
99
|
+
const trimmed = stderr.trim();
|
|
100
|
+
return trimmed ? `: ${clip(trimmed, 1000)}` : "";
|
|
101
|
+
}
|
|
102
|
+
export async function waitForCompletionArtifact(worktreePath, workItemId, phase, signal) {
|
|
103
|
+
while (!signal?.aborted) {
|
|
104
|
+
const artifact = await readCompletionArtifact(worktreePath, workItemId, phase);
|
|
105
|
+
if (artifact) {
|
|
106
|
+
return artifact;
|
|
107
|
+
}
|
|
108
|
+
await delay(1000, signal);
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`${phase} proposal artifact wait was cancelled.`);
|
|
111
|
+
}
|
|
112
|
+
export async function delay(ms, signal) {
|
|
113
|
+
if (!signal) {
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const abortSignal = signal;
|
|
118
|
+
if (abortSignal.aborted) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
await new Promise((resolve) => {
|
|
122
|
+
const timeout = setTimeout(done, ms);
|
|
123
|
+
const abort = () => done();
|
|
124
|
+
function done() {
|
|
125
|
+
clearTimeout(timeout);
|
|
126
|
+
abortSignal.removeEventListener("abort", abort);
|
|
127
|
+
resolve();
|
|
128
|
+
}
|
|
129
|
+
abortSignal.addEventListener("abort", abort, { once: true });
|
|
130
|
+
if (abortSignal.aborted) {
|
|
131
|
+
done();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
export async function cleanupCompletionTools(completionTools, emit) {
|
|
136
|
+
try {
|
|
137
|
+
await completionTools.cleanup();
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
await emit({
|
|
141
|
+
role: "runner",
|
|
142
|
+
kind: "checkpoint",
|
|
143
|
+
content: `Forge completion tool cleanup failed and was ignored: ${clip(messageFromError(error), 600)}`,
|
|
144
|
+
eventType: "completion_tools_cleanup_failed"
|
|
145
|
+
}).catch(() => undefined);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export async function commitWorktreeChanges(git, worktreePath, workItemId) {
|
|
149
|
+
await git.run(worktreePath, ["add", "-A"]);
|
|
150
|
+
await git.run(worktreePath, ["reset", "--", ".forge"]).catch(() => "");
|
|
151
|
+
const changedFiles = (await git.run(worktreePath, ["diff", "--cached", "--name-only"]))
|
|
152
|
+
.split(/\r?\n/)
|
|
153
|
+
.map((line) => line.trim())
|
|
154
|
+
.filter(Boolean);
|
|
155
|
+
if (changedFiles.length > 0) {
|
|
156
|
+
await git.run(worktreePath, ["commit", "-m", `provider cli changeset for ${workItemId.slice(0, 8)}`]);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
await git.run(worktreePath, ["commit", "--allow-empty", "-m", `provider cli completed for ${workItemId.slice(0, 8)}`]);
|
|
160
|
+
}
|
|
161
|
+
const commitSha = (await git.run(worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
162
|
+
return { commitSha, changedFiles };
|
|
163
|
+
}
|
|
164
|
+
export function phaseCompleteEventType(phase) {
|
|
165
|
+
return phase === "implementation" ? "implementation_complete" : `${phase}_complete`;
|
|
166
|
+
}
|
|
167
|
+
export function isDebugCompletionPhaseForProvider(phase) {
|
|
168
|
+
return debugCompletionPhaseOrder.includes(phase);
|
|
169
|
+
}
|
|
170
|
+
const qaArtifactFileNames = {
|
|
171
|
+
plan: "qa.plan.json",
|
|
172
|
+
evidence: "qa.evidence.json",
|
|
173
|
+
findings: "qa.findings.json",
|
|
174
|
+
verdict: "qa.verdict.json",
|
|
175
|
+
report: "qa.report.md"
|
|
176
|
+
};
|
|
177
|
+
const qaGateArtifactFileNames = {
|
|
178
|
+
qa_plan: "qa.plan.json",
|
|
179
|
+
qa_regression: "qa.regression.json",
|
|
180
|
+
qa_api: "qa.api.json",
|
|
181
|
+
qa_manual_web: "qa.manual-web.json",
|
|
182
|
+
qa_bug_repro: "qa.bug-repro.json",
|
|
183
|
+
qa_findings: "qa.findings.json",
|
|
184
|
+
qa_verdict: "qa.verdict.json"
|
|
185
|
+
};
|
|
186
|
+
export async function emitQaArtifactRecorded(emit, worktreePath, artifact) {
|
|
187
|
+
const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
|
|
188
|
+
await emit({
|
|
189
|
+
role: "tool",
|
|
190
|
+
kind: "tool_result",
|
|
191
|
+
content: `Forge QA artifact recorded ${relativePath} (${artifact.status}).`,
|
|
192
|
+
eventType: "qa_artifact_recorded"
|
|
193
|
+
});
|
|
194
|
+
await emit({
|
|
195
|
+
role: "hook",
|
|
196
|
+
kind: "checkpoint",
|
|
197
|
+
content: `phase_gate: qa artifact accepted with status ${artifact.status}.`,
|
|
198
|
+
eventType: "phase_gate"
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
export async function readQaCompletionArtifactBundle(worktreePath, workItemId, ownerType, ownerId) {
|
|
202
|
+
const artifact = await readCompletionArtifact(worktreePath, workItemId, "qa", ownerType, ownerId);
|
|
203
|
+
if (!artifact || !await hasCompleteQaArtifactBundle(worktreePath, workItemId, ownerType, ownerId)) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
const artifactRoot = ownerType && ownerId
|
|
207
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
208
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
209
|
+
const planJson = await fs.readFile(path.join(artifactRoot, qaArtifactFileNames.plan), "utf8");
|
|
210
|
+
const evidenceJson = await fs.readFile(path.join(artifactRoot, qaArtifactFileNames.evidence), "utf8");
|
|
211
|
+
const findingsJson = await fs.readFile(path.join(artifactRoot, qaArtifactFileNames.findings), "utf8");
|
|
212
|
+
await validateQaBundleSidecarReferences(worktreePath, workItemId, ownerType, ownerId);
|
|
213
|
+
return {
|
|
214
|
+
...artifact,
|
|
215
|
+
planJson,
|
|
216
|
+
evidenceJson,
|
|
217
|
+
findingsJson
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
export async function hasCompleteQaArtifactBundle(worktreePath, workItemId, ownerType, ownerId) {
|
|
221
|
+
const artifactRoot = ownerType && ownerId
|
|
222
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
223
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
224
|
+
for (const fileName of Object.values(qaArtifactFileNames)) {
|
|
225
|
+
try {
|
|
226
|
+
const stat = await fs.stat(path.join(artifactRoot, fileName));
|
|
227
|
+
if (!stat.isFile()) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
export function enforceQaBrowserLaunchTargetGate(dispatch, artifact) {
|
|
241
|
+
const qaBrowser = dispatch.qaBrowser;
|
|
242
|
+
if (!qaBrowser?.required || hasLaunchTarget(qaBrowser.launchTarget ?? null)) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const verdict = parseJsonLine(artifact.verdictJson) ?? {};
|
|
246
|
+
const status = normalizedString(stringField(verdict, "status") ?? artifact.status ?? "");
|
|
247
|
+
const blockerReason = normalizedString(stringField(verdict, "blocker_reason") ?? "");
|
|
248
|
+
if (status !== "blocked" || blockerReason !== "missing_app_launch_target") {
|
|
249
|
+
throw new Error("QA browser testing is required but no app launch target was provided; provider must return blocked with blocker_reason missing_app_launch_target.");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
export async function validateQaBundleSidecarReferences(worktreePath, workItemId, ownerType, ownerId) {
|
|
253
|
+
const artifactRoot = ownerType && ownerId
|
|
254
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
255
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
256
|
+
const evidence = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.evidence));
|
|
257
|
+
const sidecars = arrayField(evidence, "sidecars");
|
|
258
|
+
if (sidecars.length === 0) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const qaSidecarRoot = path.resolve(artifactRoot, "qa");
|
|
262
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
263
|
+
const sidecarPath = stringField(sidecar, "path");
|
|
264
|
+
const expectedHash = stringField(sidecar, "content_hash");
|
|
265
|
+
const expectedBytes = sidecar.bytes;
|
|
266
|
+
if (!sidecarPath || !expectedHash || typeof expectedBytes !== "number" || !Number.isFinite(expectedBytes) || expectedBytes <= 0) {
|
|
267
|
+
throw new Error(`QA sidecar ${index} must include path, content_hash, and positive bytes.`);
|
|
268
|
+
}
|
|
269
|
+
if (path.isAbsolute(sidecarPath)) {
|
|
270
|
+
throw new Error(`QA sidecar ${index} path must be relative to the worktree.`);
|
|
271
|
+
}
|
|
272
|
+
const fullPath = path.resolve(worktreePath, sidecarPath);
|
|
273
|
+
if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
|
|
274
|
+
const ownerDirectory = ownerType && ownerId && ownerType.trim().toLowerCase() === "story" ? "stories" : "work-items";
|
|
275
|
+
const ownerSegment = ownerType && ownerId ? ownerId : workItemId;
|
|
276
|
+
throw new Error(`QA sidecar ${index} path must be under .forge/${ownerDirectory}/${ownerSegment}/artifacts/qa/.`);
|
|
277
|
+
}
|
|
278
|
+
const stat = await fs.lstat(fullPath).catch((error) => {
|
|
279
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
280
|
+
throw new Error(`QA sidecar ${index} file does not exist: ${sidecarPath}`);
|
|
281
|
+
}
|
|
282
|
+
throw error;
|
|
283
|
+
});
|
|
284
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
285
|
+
throw new Error(`QA sidecar ${index} must reference a regular file.`);
|
|
286
|
+
}
|
|
287
|
+
await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `QA sidecar ${index} path must not traverse symbolic links.`);
|
|
288
|
+
if (stat.size !== expectedBytes) {
|
|
289
|
+
throw new Error(`QA sidecar ${index} bytes do not match the referenced file.`);
|
|
290
|
+
}
|
|
291
|
+
const actualHash = await sha256File(fullPath);
|
|
292
|
+
if (actualHash !== expectedHash.trim().toLowerCase()) {
|
|
293
|
+
throw new Error(`QA sidecar ${index} content_hash does not match the referenced file.`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
export async function emitQaStructuredEvents(emit, artifact) {
|
|
298
|
+
const artifactRoot = path.dirname(artifact.path);
|
|
299
|
+
const plan = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.plan));
|
|
300
|
+
const evidence = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.evidence));
|
|
301
|
+
const findings = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.findings));
|
|
302
|
+
const verdict = parseJsonLine(artifact.verdictJson) ?? {};
|
|
303
|
+
const status = stringField(verdict, "status") ?? artifact.status ?? "unknown";
|
|
304
|
+
const checklist = stringArrayField(plan, "checklist");
|
|
305
|
+
await emit({
|
|
306
|
+
role: "worker",
|
|
307
|
+
kind: "message",
|
|
308
|
+
content: checklist.length > 0
|
|
309
|
+
? `QA plan: ${checklist.join("; ")}`
|
|
310
|
+
: "QA plan recorded.",
|
|
311
|
+
eventType: "qa_plan"
|
|
312
|
+
});
|
|
313
|
+
for (const browserCheck of arrayField(evidence, "browser_checks")) {
|
|
314
|
+
await emit({
|
|
315
|
+
role: "worker",
|
|
316
|
+
kind: "checkpoint",
|
|
317
|
+
content: `QA browser check: ${summarizeQaObject(browserCheck)}`,
|
|
318
|
+
eventType: "qa_browser_check"
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
for (const finding of [...arrayField(findings, "findings"), ...arrayField(findings, "candidate_bugs")]) {
|
|
322
|
+
await emit({
|
|
323
|
+
role: "worker",
|
|
324
|
+
kind: "checkpoint",
|
|
325
|
+
content: `QA finding: ${summarizeQaObject(finding)}`,
|
|
326
|
+
eventType: "qa_finding"
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
await emit({
|
|
330
|
+
role: "assistant",
|
|
331
|
+
kind: "message",
|
|
332
|
+
content: status === "blocked"
|
|
333
|
+
? `QA blocked: ${stringField(verdict, "summary") ?? "No summary provided."}`
|
|
334
|
+
: `QA completed with status ${status}: ${stringField(verdict, "summary") ?? "No summary provided."}`,
|
|
335
|
+
eventType: status === "blocked" ? "qa_blocked" : "qa_complete"
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
export function qaArtifactRootForDispatch(worktreePath, workItemId, dispatch) {
|
|
339
|
+
return dispatch.cycleRun.ownerType && dispatch.cycleRun.ownerId
|
|
340
|
+
? artifactRootForOwner(worktreePath, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId)
|
|
341
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
342
|
+
}
|
|
343
|
+
export async function readQaGateArtifact(artifactRoot, gate) {
|
|
344
|
+
const artifactPath = path.join(artifactRoot, qaGateArtifactFileNames[gate]);
|
|
345
|
+
try {
|
|
346
|
+
const stat = await fs.stat(artifactPath);
|
|
347
|
+
if (!stat.isFile()) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
const payload = await readJsonObjectFile(artifactPath);
|
|
358
|
+
return {
|
|
359
|
+
gate,
|
|
360
|
+
path: artifactPath,
|
|
361
|
+
status: stringField(payload, "status") ?? "unknown",
|
|
362
|
+
producedBy: stringField(payload, "produced_by") ?? stringField(payload, "producedBy") ?? undefined,
|
|
363
|
+
contentHash: stringField(payload, "content_hash") ?? undefined
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
export function nextQaGate(gate) {
|
|
367
|
+
const index = qaCompletionGateOrder.indexOf(gate);
|
|
368
|
+
return index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
|
|
369
|
+
}
|
|
370
|
+
export async function emitQaGateAccepted(emit, worktreePath, artifact, nextGate) {
|
|
371
|
+
const relativePath = path.relative(worktreePath, artifact.path).split(path.sep).join("/");
|
|
372
|
+
await emit({
|
|
373
|
+
role: "hook",
|
|
374
|
+
kind: artifact.status === "blocked" ? "error" : "checkpoint",
|
|
375
|
+
content: `phase_gate: ${artifact.gate} artifact accepted with status ${artifact.status}.`,
|
|
376
|
+
eventType: "phase_gate",
|
|
377
|
+
data: JSON.stringify({
|
|
378
|
+
phase: "qa",
|
|
379
|
+
qa_gate: artifact.gate,
|
|
380
|
+
status: artifact.status,
|
|
381
|
+
artifact_relative_paths: [relativePath],
|
|
382
|
+
content_hash: artifact.contentHash ?? null,
|
|
383
|
+
produced_by: artifact.producedBy ?? null
|
|
384
|
+
})
|
|
385
|
+
});
|
|
386
|
+
if (!nextGate) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
await emit({
|
|
390
|
+
role: "hook",
|
|
391
|
+
kind: "checkpoint",
|
|
392
|
+
content: `phase_progression: ${artifact.gate} -> ${nextGate}`,
|
|
393
|
+
eventType: "phase_gate",
|
|
394
|
+
data: JSON.stringify({
|
|
395
|
+
phase: "qa",
|
|
396
|
+
from_gate: artifact.gate,
|
|
397
|
+
to_gate: nextGate
|
|
398
|
+
})
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
export class LiveQaArtifactEmitter {
|
|
402
|
+
worktreePath;
|
|
403
|
+
workItemId;
|
|
404
|
+
dispatch;
|
|
405
|
+
emit;
|
|
406
|
+
emittedBundle = false;
|
|
407
|
+
emittedGates = new Set();
|
|
408
|
+
timer = null;
|
|
409
|
+
pending = Promise.resolve();
|
|
410
|
+
constructor(worktreePath, workItemId, dispatch, emit) {
|
|
411
|
+
this.worktreePath = worktreePath;
|
|
412
|
+
this.workItemId = workItemId;
|
|
413
|
+
this.dispatch = dispatch;
|
|
414
|
+
this.emit = emit;
|
|
415
|
+
}
|
|
416
|
+
start() {
|
|
417
|
+
void this.pollQueued();
|
|
418
|
+
this.timer = setInterval(() => {
|
|
419
|
+
void this.pollQueued();
|
|
420
|
+
}, 250);
|
|
421
|
+
}
|
|
422
|
+
async stop() {
|
|
423
|
+
if (this.timer) {
|
|
424
|
+
clearInterval(this.timer);
|
|
425
|
+
this.timer = null;
|
|
426
|
+
}
|
|
427
|
+
await this.pollQueued();
|
|
428
|
+
}
|
|
429
|
+
hasEmitted() {
|
|
430
|
+
return this.emittedBundle;
|
|
431
|
+
}
|
|
432
|
+
pollQueued() {
|
|
433
|
+
this.pending = this.pending
|
|
434
|
+
.then(() => this.poll())
|
|
435
|
+
.catch(() => undefined);
|
|
436
|
+
return this.pending;
|
|
437
|
+
}
|
|
438
|
+
async poll() {
|
|
439
|
+
await this.emitAvailableGateEvents();
|
|
440
|
+
if (this.emittedBundle) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const artifact = await readQaCompletionArtifactBundle(this.worktreePath, this.workItemId, this.dispatch.cycleRun.ownerType, this.dispatch.cycleRun.ownerId);
|
|
444
|
+
if (!artifact) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
enforceQaBrowserLaunchTargetGate(this.dispatch, artifact);
|
|
448
|
+
this.emittedBundle = true;
|
|
449
|
+
await emitQaArtifactRecorded(this.emit, this.worktreePath, artifact);
|
|
450
|
+
await emitQaStructuredEvents(this.emit, artifact);
|
|
451
|
+
}
|
|
452
|
+
async emitAvailableGateEvents() {
|
|
453
|
+
const artifactRoot = qaArtifactRootForDispatch(this.worktreePath, this.workItemId, this.dispatch);
|
|
454
|
+
for (const gate of qaCompletionGateOrder) {
|
|
455
|
+
if (this.emittedGates.has(gate)) {
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const artifact = await readQaGateArtifact(artifactRoot, gate);
|
|
459
|
+
if (!artifact) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
this.emittedGates.add(gate);
|
|
463
|
+
await emitQaGateAccepted(this.emit, this.worktreePath, artifact, nextQaGate(gate));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
export class LiveArchitectArtifactEmitter {
|
|
468
|
+
worktreePath;
|
|
469
|
+
workItemId;
|
|
470
|
+
emit;
|
|
471
|
+
emitted = false;
|
|
472
|
+
timer = null;
|
|
473
|
+
pending = Promise.resolve();
|
|
474
|
+
constructor(worktreePath, workItemId, emit) {
|
|
475
|
+
this.worktreePath = worktreePath;
|
|
476
|
+
this.workItemId = workItemId;
|
|
477
|
+
this.emit = emit;
|
|
478
|
+
}
|
|
479
|
+
start() {
|
|
480
|
+
void this.pollQueued();
|
|
481
|
+
this.timer = setInterval(() => {
|
|
482
|
+
void this.pollQueued();
|
|
483
|
+
}, 250);
|
|
484
|
+
}
|
|
485
|
+
async stop() {
|
|
486
|
+
if (this.timer) {
|
|
487
|
+
clearInterval(this.timer);
|
|
488
|
+
this.timer = null;
|
|
489
|
+
}
|
|
490
|
+
await this.pollQueued();
|
|
491
|
+
}
|
|
492
|
+
hasEmitted() {
|
|
493
|
+
return this.emitted;
|
|
494
|
+
}
|
|
495
|
+
pollQueued() {
|
|
496
|
+
this.pending = this.pending
|
|
497
|
+
.then(() => this.poll())
|
|
498
|
+
.catch(() => undefined);
|
|
499
|
+
return this.pending;
|
|
500
|
+
}
|
|
501
|
+
async poll() {
|
|
502
|
+
if (this.emitted || !await hasCompleteArchitectArtifactBundle(this.worktreePath, this.workItemId)) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const artifact = await readCompletionArtifact(this.worktreePath, this.workItemId, "architect");
|
|
506
|
+
if (!artifact) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
this.emitted = true;
|
|
510
|
+
await emitCompletionArtifactRecorded(this.emit, this.worktreePath, artifact);
|
|
511
|
+
await emitArchitectStructuredEvents(this.emit, this.worktreePath, this.workItemId, artifact);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
export async function hasCompleteArchitectArtifactBundle(worktreePath, workItemId) {
|
|
515
|
+
const artifactRoot = artifactRootFor(worktreePath, workItemId);
|
|
516
|
+
for (const fileName of ["implementation.plan.json", "qa.plan.json", "architect.verdict.json", "architect.report.md"]) {
|
|
517
|
+
try {
|
|
518
|
+
const stat = await fs.stat(path.join(artifactRoot, fileName));
|
|
519
|
+
if (!stat.isFile()) {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
export async function emitArchitectStructuredEvents(emit, worktreePath, workItemId, artifact) {
|
|
533
|
+
const artifactRoot = artifactRootFor(worktreePath, workItemId);
|
|
534
|
+
const implementationPlan = await readJsonObjectFile(path.join(artifactRoot, "implementation.plan.json"));
|
|
535
|
+
const qaPlan = await readJsonObjectFile(path.join(artifactRoot, "qa.plan.json"));
|
|
536
|
+
const verdict = parseJsonLine(artifact.verdictJson) ?? {};
|
|
537
|
+
const status = stringField(verdict, "status") ?? artifact.status ?? "unknown";
|
|
538
|
+
const planId = stringField(implementationPlan, "plan_id") ?? "unknown";
|
|
539
|
+
await emit({
|
|
540
|
+
role: "worker",
|
|
541
|
+
kind: "checkpoint",
|
|
542
|
+
content: `Architect plan recorded: ${planId} with ${arrayField(implementationPlan, "phases").length} phases and ${arrayField(implementationPlan, "tasks").length} tasks.`,
|
|
543
|
+
eventType: "architect_plan"
|
|
544
|
+
});
|
|
545
|
+
await emit({
|
|
546
|
+
role: "worker",
|
|
547
|
+
kind: "checkpoint",
|
|
548
|
+
content: `Architect QA seed recorded with ${arrayField(qaPlan, "final_qa").length} final QA items.`,
|
|
549
|
+
eventType: "architect_qa_seed"
|
|
550
|
+
});
|
|
551
|
+
await emit({
|
|
552
|
+
role: "hook",
|
|
553
|
+
kind: status === "pass" ? "checkpoint" : "error",
|
|
554
|
+
content: `architect_mcp_complete: ${status === "pass" ? "accepted" : "recorded"} - ${stringField(verdict, "summary") ?? "Architect completion payload recorded."}`,
|
|
555
|
+
eventType: "architect_mcp_complete"
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
export async function readJsonObjectFile(filePath) {
|
|
559
|
+
const content = await fs.readFile(filePath, "utf8").catch(() => "");
|
|
560
|
+
return parseJsonLine(content) ?? {};
|
|
561
|
+
}
|
|
562
|
+
export function arrayField(source, key) {
|
|
563
|
+
const value = source[key];
|
|
564
|
+
if (!Array.isArray(value)) {
|
|
565
|
+
return [];
|
|
566
|
+
}
|
|
567
|
+
return value.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry));
|
|
568
|
+
}
|
|
569
|
+
export function stringArrayField(source, key) {
|
|
570
|
+
const value = source[key];
|
|
571
|
+
if (!Array.isArray(value)) {
|
|
572
|
+
return [];
|
|
573
|
+
}
|
|
574
|
+
return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
|
|
575
|
+
}
|
|
576
|
+
export function summarizeQaObject(source) {
|
|
577
|
+
return clip(stringField(source, "title")
|
|
578
|
+
?? stringField(source, "summary")
|
|
579
|
+
?? stringField(source, "id")
|
|
580
|
+
?? JSON.stringify(source), 500);
|
|
581
|
+
}
|
|
582
|
+
export function qaArtifactRelativePath(workItemId, fileName, ownerType, ownerId) {
|
|
583
|
+
if (ownerType && ownerId) {
|
|
584
|
+
const ownerDirectory = ownerType.trim().toLowerCase() === "story" ? "stories" : "work-items";
|
|
585
|
+
return [".forge", ownerDirectory, ownerId, "artifacts", fileName].join("/");
|
|
586
|
+
}
|
|
587
|
+
return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
|
|
588
|
+
}
|
|
589
|
+
export async function emitCompletionArtifactRecorded(emit, worktreePath, artifact) {
|
|
590
|
+
const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
|
|
591
|
+
await emit({
|
|
592
|
+
role: "tool",
|
|
593
|
+
kind: "checkpoint",
|
|
594
|
+
content: `Forge completion tool validation passed for ${artifact.phase ?? "unknown"} (${artifact.status}).`,
|
|
595
|
+
eventType: "completion_tool_validation_passed",
|
|
596
|
+
data: JSON.stringify({
|
|
597
|
+
phase: artifact.phase ?? "unknown",
|
|
598
|
+
status: artifact.status,
|
|
599
|
+
artifact_relative_paths: [relativePath.split(path.sep).join("/")]
|
|
600
|
+
})
|
|
601
|
+
});
|
|
602
|
+
await emit({
|
|
603
|
+
role: "tool",
|
|
604
|
+
kind: "tool_result",
|
|
605
|
+
content: `Forge completion tool recorded ${artifact.phase ?? "unknown"} artifact ${relativePath} (${artifact.status}).`,
|
|
606
|
+
eventType: "completion_tool_artifact_recorded",
|
|
607
|
+
data: JSON.stringify({
|
|
608
|
+
phase: artifact.phase ?? "unknown",
|
|
609
|
+
status: artifact.status,
|
|
610
|
+
artifact_relative_paths: [relativePath.split(path.sep).join("/")]
|
|
611
|
+
})
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
export async function emitPhaseCompletionEvent(emit, artifact) {
|
|
615
|
+
const phase = completionPhaseFromArtifact(artifact);
|
|
616
|
+
await emit({
|
|
617
|
+
role: "assistant",
|
|
618
|
+
kind: "message",
|
|
619
|
+
content: `${phase.replace(/_/g, " ")} completion artifact recorded with status ${artifact.status}.`,
|
|
620
|
+
eventType: phaseCompleteEventType(phase)
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
export async function emitPhaseGateEvents(emit, artifact) {
|
|
624
|
+
const phase = completionPhaseFromArtifact(artifact);
|
|
625
|
+
const status = (artifact.status ?? "").trim().toLowerCase() || "unknown";
|
|
626
|
+
await emit({
|
|
627
|
+
role: "hook",
|
|
628
|
+
kind: "checkpoint",
|
|
629
|
+
content: `phase_gate: ${phase} artifact accepted with status ${status}.`,
|
|
630
|
+
eventType: "phase_gate"
|
|
631
|
+
});
|
|
632
|
+
if (status !== "pass") {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const progression = phase === "implementation"
|
|
636
|
+
? "phase_progression: implementation -> self_review"
|
|
637
|
+
: phase === "self_review"
|
|
638
|
+
? "phase_progression: self_review -> validation"
|
|
639
|
+
: phase === "debug_repro"
|
|
640
|
+
? "phase_progression: debug_repro -> debug_root_cause"
|
|
641
|
+
: phase === "debug_root_cause"
|
|
642
|
+
? "phase_progression: debug_root_cause -> debug_fix"
|
|
643
|
+
: phase === "debug_fix"
|
|
644
|
+
? "phase_progression: debug_fix -> debug_regression"
|
|
645
|
+
: phase === "debug_regression"
|
|
646
|
+
? "phase_progression: debug_regression -> QA ready"
|
|
647
|
+
: "phase_progression: validation -> QA ready";
|
|
648
|
+
await emit({
|
|
649
|
+
role: "hook",
|
|
650
|
+
kind: "checkpoint",
|
|
651
|
+
content: progression,
|
|
652
|
+
eventType: "phase_gate"
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
export function completionPhaseFromArtifact(artifact) {
|
|
656
|
+
const phase = artifact.phase?.trim();
|
|
657
|
+
return phase === "self_review" || phase === "validation" || phase === "implementation" || phase === "qa"
|
|
658
|
+
|| phase === "architect" || phase === "debug_repro" || phase === "debug_root_cause" || phase === "debug_fix" || phase === "debug_regression"
|
|
659
|
+
? phase
|
|
660
|
+
: "implementation";
|
|
661
|
+
}
|
|
662
|
+
export class LiveCompletionArtifactEmitter {
|
|
663
|
+
worktreePath;
|
|
664
|
+
workItemId;
|
|
665
|
+
emit;
|
|
666
|
+
phases;
|
|
667
|
+
emitted = new Set();
|
|
668
|
+
timer = null;
|
|
669
|
+
pending = Promise.resolve();
|
|
670
|
+
constructor(worktreePath, workItemId, phase, developCycle, debugCycle, emit) {
|
|
671
|
+
this.worktreePath = worktreePath;
|
|
672
|
+
this.workItemId = workItemId;
|
|
673
|
+
this.emit = emit;
|
|
674
|
+
if (debugCycle && isDebugCompletionPhaseForProvider(phase)) {
|
|
675
|
+
this.phases = debugCompletionPhaseOrder.slice(debugCompletionPhaseOrder.indexOf(phase));
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
this.phases = developCycle
|
|
679
|
+
? developCyclePhaseOrder.slice(developCyclePhaseOrder.indexOf(phase))
|
|
680
|
+
: [phase];
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
start() {
|
|
684
|
+
void this.pollQueued();
|
|
685
|
+
this.timer = setInterval(() => {
|
|
686
|
+
void this.pollQueued();
|
|
687
|
+
}, 250);
|
|
688
|
+
}
|
|
689
|
+
async stop() {
|
|
690
|
+
if (this.timer) {
|
|
691
|
+
clearInterval(this.timer);
|
|
692
|
+
this.timer = null;
|
|
693
|
+
}
|
|
694
|
+
await this.pollQueued();
|
|
695
|
+
}
|
|
696
|
+
hasEmitted(phase) {
|
|
697
|
+
return this.emitted.has(phase);
|
|
698
|
+
}
|
|
699
|
+
pollQueued() {
|
|
700
|
+
this.pending = this.pending
|
|
701
|
+
.then(() => this.poll())
|
|
702
|
+
.catch(() => undefined);
|
|
703
|
+
return this.pending;
|
|
704
|
+
}
|
|
705
|
+
async poll() {
|
|
706
|
+
for (const phase of this.phases) {
|
|
707
|
+
if (this.emitted.has(phase)) {
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
const artifact = await readCompletionArtifact(this.worktreePath, this.workItemId, phase);
|
|
711
|
+
if (!artifact) {
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
this.emitted.add(phase);
|
|
715
|
+
await emitCompletionArtifactRecorded(this.emit, this.worktreePath, artifact);
|
|
716
|
+
await emitPhaseCompletionEvent(this.emit, artifact);
|
|
717
|
+
await emitPhaseGateEvents(this.emit, artifact);
|
|
718
|
+
if ((artifact.status ?? "").trim().toLowerCase() !== "pass") {
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
export async function readCompletionArtifactsForRun(worktreePath, workItemId, phase, developCycle, debugCycle) {
|
|
725
|
+
if (!developCycle && !debugCycle) {
|
|
726
|
+
const artifact = await readCompletionArtifact(worktreePath, workItemId, phase);
|
|
727
|
+
if (!artifact) {
|
|
728
|
+
throw new Error(`Provider CLI completed but did not call the Forge ${phase} completion tool.`);
|
|
729
|
+
}
|
|
730
|
+
return [artifact];
|
|
731
|
+
}
|
|
732
|
+
const expectedPhases = debugCycle && isDebugCompletionPhaseForProvider(phase)
|
|
733
|
+
? debugCompletionPhaseOrder.slice(debugCompletionPhaseOrder.indexOf(phase))
|
|
734
|
+
: ["implementation", "self_review", "validation"];
|
|
735
|
+
const artifacts = [];
|
|
736
|
+
for (const expectedPhase of expectedPhases) {
|
|
737
|
+
const artifact = await readCompletionArtifact(worktreePath, workItemId, expectedPhase);
|
|
738
|
+
if (!artifact) {
|
|
739
|
+
if (artifacts.length === 0) {
|
|
740
|
+
throw new Error(`Provider CLI completed but did not call the Forge ${expectedPhase} completion tool.`);
|
|
741
|
+
}
|
|
742
|
+
const previous = artifacts[artifacts.length - 1];
|
|
743
|
+
if ((previous.status ?? "").trim().toLowerCase() === "pass") {
|
|
744
|
+
throw new Error(`Provider CLI completed after ${previous.phase} passed but did not call the Forge ${expectedPhase} completion tool.`);
|
|
745
|
+
}
|
|
746
|
+
return artifacts;
|
|
747
|
+
}
|
|
748
|
+
artifacts.push(artifact);
|
|
749
|
+
if ((artifact.status ?? "").trim().toLowerCase() !== "pass") {
|
|
750
|
+
return artifacts;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return artifacts;
|
|
754
|
+
}
|