@loucompanion/forge-bridge 0.1.1-dev.e52b249f07a8 → 0.1.1-dev.f599637d804e
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 +101 -21
- package/dist/bridge/bridge.types.d.ts +2 -2
- package/dist/cli/cli.service.js +13 -9
- package/dist/session/internals.d.ts +1 -1
- package/dist/session/phase-completion.d.ts +30 -5
- package/dist/session/phase-completion.js +1177 -26
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +3 -0
- package/dist/session/provider-cli/provider-cli.service.js +1061 -236
- package/dist/session/session.service.js +30 -5
- package/dist/session/session.types.d.ts +84 -0
- package/dist/session/session.types.js +154 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +1 -0
- package/dist/worktree/artifact-injector.js +78 -1
- package/dist/worktree/worktree.service.d.ts +1 -0
- package/dist/worktree/worktree.service.js +132 -6
- package/dist/worktree/worktree.types.d.ts +8 -1
- package/package.json +8 -3
|
@@ -8,11 +8,52 @@ import { GitClient } from "../../shared/git/git.js";
|
|
|
8
8
|
import { resolveCursorAgentDirectLaunch } from "../../shared/cursor-agent/cursor-agent.launch.js";
|
|
9
9
|
import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "../qa-sidecar-validation.js";
|
|
10
10
|
import { buildSpecBody, selectLaneCommand } from "../../worktree/artifact-injector.js";
|
|
11
|
-
import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForDevelopCycle, completionPromptForPhase, completionRedactionEnv, expectedCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, usesDevelopCycleCompletion, writeCompletionArtifact, artifactRootFor } from "../phase-completion.js";
|
|
11
|
+
import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForDebugCycle, completionPromptForDevelopCycle, completionPromptForPhase, completionPromptForQaCycle, completionRedactionEnv, expectedCompletionToolName, expectedDebugCompletionToolName, expectedQaCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, usesDebugCycleCompletion, usesDevelopCycleCompletion, writeCompletionArtifact, artifactRootFor, artifactRootForOwner, debugCompletionPhaseOrder, qaCompletionGateOrder } from "../phase-completion.js";
|
|
12
12
|
import { addUsage, buildProviderUsageEvent, emptyUsage, hasUsage, readUsageNumber } from "./provider-usage.js";
|
|
13
13
|
const fakeWorkerModel = "server-local-fake-worker";
|
|
14
14
|
const execFileAsync = promisify(execFile);
|
|
15
15
|
const developCyclePhaseOrder = ["implementation", "self_review", "validation"];
|
|
16
|
+
const allowedProviderParentEnvNames = [
|
|
17
|
+
"PATH",
|
|
18
|
+
"HOME",
|
|
19
|
+
"USERPROFILE",
|
|
20
|
+
"TMPDIR",
|
|
21
|
+
"TEMP",
|
|
22
|
+
"TMP",
|
|
23
|
+
"LANG"
|
|
24
|
+
];
|
|
25
|
+
const windowsAllowedProviderParentEnvNames = [
|
|
26
|
+
"SystemRoot",
|
|
27
|
+
"ComSpec",
|
|
28
|
+
"PATHEXT",
|
|
29
|
+
"APPDATA",
|
|
30
|
+
"LOCALAPPDATA"
|
|
31
|
+
];
|
|
32
|
+
function buildProviderChildEnvironment(requestEnv) {
|
|
33
|
+
const env = {};
|
|
34
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
35
|
+
if (typeof value === "string" && isAllowedProviderParentEnv(key)) {
|
|
36
|
+
env[key] = value;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (requestEnv) {
|
|
40
|
+
for (const [key, value] of Object.entries(requestEnv)) {
|
|
41
|
+
const trimmedKey = key.trim();
|
|
42
|
+
if (trimmedKey.length > 0) {
|
|
43
|
+
env[trimmedKey] = value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return env;
|
|
48
|
+
}
|
|
49
|
+
function isAllowedProviderParentEnv(key) {
|
|
50
|
+
return isAllowedProviderParentEnvName(key, allowedProviderParentEnvNames)
|
|
51
|
+
|| key.toUpperCase().startsWith("LC_")
|
|
52
|
+
|| (process.platform === "win32" && isAllowedProviderParentEnvName(key, windowsAllowedProviderParentEnvNames));
|
|
53
|
+
}
|
|
54
|
+
function isAllowedProviderParentEnvName(key, allowedNames) {
|
|
55
|
+
return allowedNames.some((allowed) => allowed.toLowerCase() === key.toLowerCase());
|
|
56
|
+
}
|
|
16
57
|
export class RoutingProviderCliService {
|
|
17
58
|
realProvider;
|
|
18
59
|
deterministicProvider;
|
|
@@ -25,6 +66,11 @@ export class RoutingProviderCliService {
|
|
|
25
66
|
? await this.deterministicProvider.runImplementation(context)
|
|
26
67
|
: await this.realProvider.runImplementation(context);
|
|
27
68
|
}
|
|
69
|
+
async runArchitect(context) {
|
|
70
|
+
return isFakeWorkerModel(context.dispatch.execution.model)
|
|
71
|
+
? await this.deterministicProvider.runArchitect(context)
|
|
72
|
+
: await this.realProvider.runArchitect(context);
|
|
73
|
+
}
|
|
28
74
|
async runConflictResolve(context) {
|
|
29
75
|
return isFakeWorkerModel(context.dispatch.execution.model)
|
|
30
76
|
? await this.deterministicProvider.runConflictResolve(context)
|
|
@@ -47,6 +93,7 @@ export class ProcessBackedProviderCliService {
|
|
|
47
93
|
const { dispatch, worktree } = context;
|
|
48
94
|
const phase = completionPhaseFromDispatch(dispatch);
|
|
49
95
|
const developCycle = usesDevelopCycleCompletion(dispatch);
|
|
96
|
+
const debugCycle = usesDebugCycleCompletion(dispatch);
|
|
50
97
|
const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
|
|
51
98
|
await fs.mkdir(metadataRoot, { recursive: true });
|
|
52
99
|
const laneCommand = selectLaneCommand(dispatch.artifacts, dispatch.execution.lane, dispatch.cycleRun.phase);
|
|
@@ -60,7 +107,11 @@ export class ProcessBackedProviderCliService {
|
|
|
60
107
|
const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase });
|
|
61
108
|
const command = buildProviderCliCommand(dispatch, {
|
|
62
109
|
worktreePath: worktree.worktreePath,
|
|
63
|
-
prompt:
|
|
110
|
+
prompt: debugCycle
|
|
111
|
+
? buildDebugCyclePrompt(dispatch, phase)
|
|
112
|
+
: developCycle
|
|
113
|
+
? buildDevelopCyclePrompt(dispatch, phase)
|
|
114
|
+
: buildImplementationPrompt(dispatch, phase),
|
|
64
115
|
completionTools
|
|
65
116
|
});
|
|
66
117
|
await context.emit({
|
|
@@ -72,13 +123,15 @@ export class ProcessBackedProviderCliService {
|
|
|
72
123
|
await context.emit({
|
|
73
124
|
role: "runner",
|
|
74
125
|
kind: "checkpoint",
|
|
75
|
-
content:
|
|
76
|
-
? "Forge completion tools configured for
|
|
77
|
-
:
|
|
126
|
+
content: debugCycle
|
|
127
|
+
? "Forge Debug completion tools configured for debug_repro -> debug_root_cause -> debug_fix -> debug_regression; provider must call each MCP completion tool before exit."
|
|
128
|
+
: developCycle
|
|
129
|
+
? "Forge completion tools configured for implementation -> self_review -> validation; provider must call each MCP completion tool before exit."
|
|
130
|
+
: `Forge completion tools configured for ${phase}; provider must call the MCP completion tool before exit.`,
|
|
78
131
|
eventType: "completion_tools_configured"
|
|
79
132
|
});
|
|
80
133
|
const outputState = createProviderOutputState(dispatch.execution.provider);
|
|
81
|
-
const liveCompletionEvents = new LiveCompletionArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, context.emit);
|
|
134
|
+
const liveCompletionEvents = new LiveCompletionArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, debugCycle, context.emit);
|
|
82
135
|
let result;
|
|
83
136
|
try {
|
|
84
137
|
liveCompletionEvents.start();
|
|
@@ -86,11 +139,10 @@ export class ProcessBackedProviderCliService {
|
|
|
86
139
|
command: command.command,
|
|
87
140
|
args: command.args,
|
|
88
141
|
cwd: worktree.worktreePath,
|
|
89
|
-
env:
|
|
142
|
+
env: providerProcessEnv(dispatch)
|
|
90
143
|
}, {
|
|
91
144
|
onStdoutLine: async (line) => {
|
|
92
|
-
const event
|
|
93
|
-
if (event) {
|
|
145
|
+
for (const event of outputState.consume(line)) {
|
|
94
146
|
await context.emit(event);
|
|
95
147
|
}
|
|
96
148
|
}
|
|
@@ -112,7 +164,7 @@ export class ProcessBackedProviderCliService {
|
|
|
112
164
|
});
|
|
113
165
|
await emitProviderUsageEvent(context.emit, dispatch, outputState);
|
|
114
166
|
const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
|
|
115
|
-
const completionArtifacts = await readCompletionArtifactsForRun(worktree.worktreePath, dispatch.workItem.id, phase, developCycle);
|
|
167
|
+
const completionArtifacts = await readCompletionArtifactsForRun(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, debugCycle);
|
|
116
168
|
for (const artifact of completionArtifacts) {
|
|
117
169
|
if (!liveCompletionEvents.hasEmitted(completionPhaseFromArtifact(artifact))) {
|
|
118
170
|
await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, artifact);
|
|
@@ -138,7 +190,7 @@ export class ProcessBackedProviderCliService {
|
|
|
138
190
|
});
|
|
139
191
|
}
|
|
140
192
|
return {
|
|
141
|
-
summary: `${dispatch.execution.provider} CLI completed ${worktree.branchName} (${developCycle ? "develop cycle" : phase}, ${committed.commitSha.slice(0, 12)}).`,
|
|
193
|
+
summary: `${dispatch.execution.provider} CLI completed ${worktree.branchName} (${debugCycle ? "debug cycle" : developCycle ? "develop cycle" : phase}, ${committed.commitSha.slice(0, 12)}).`,
|
|
142
194
|
spec: {
|
|
143
195
|
repoPath: worktree.worktreePath,
|
|
144
196
|
path: specPath,
|
|
@@ -150,12 +202,105 @@ export class ProcessBackedProviderCliService {
|
|
|
150
202
|
artifacts: completionArtifacts
|
|
151
203
|
};
|
|
152
204
|
}
|
|
205
|
+
async runArchitect(context) {
|
|
206
|
+
const { dispatch, worktree } = context;
|
|
207
|
+
const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "architect" });
|
|
208
|
+
const command = buildProviderCliCommand(dispatch, {
|
|
209
|
+
worktreePath: worktree.worktreePath,
|
|
210
|
+
prompt: buildArchitectPrompt(dispatch),
|
|
211
|
+
completionTools
|
|
212
|
+
});
|
|
213
|
+
const commandText = formatProviderCliCommand(command);
|
|
214
|
+
await context.emit({
|
|
215
|
+
role: "runner",
|
|
216
|
+
kind: "checkpoint",
|
|
217
|
+
content: `Starting provider CLI Architect: ${commandText} in ${worktree.worktreePath}`,
|
|
218
|
+
eventType: "provider_cli_started"
|
|
219
|
+
});
|
|
220
|
+
await context.emit({
|
|
221
|
+
role: "runner",
|
|
222
|
+
kind: "checkpoint",
|
|
223
|
+
content: "Forge Architect completion tools configured; provider must call forge.complete_architect_plan before exit.",
|
|
224
|
+
eventType: "completion_tools_configured"
|
|
225
|
+
});
|
|
226
|
+
const outputState = createProviderOutputState(dispatch.execution.provider);
|
|
227
|
+
const liveArchitectEvents = new LiveArchitectArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, context.emit);
|
|
228
|
+
let result;
|
|
229
|
+
try {
|
|
230
|
+
liveArchitectEvents.start();
|
|
231
|
+
result = await this.processRunner.run({
|
|
232
|
+
command: command.command,
|
|
233
|
+
args: command.args,
|
|
234
|
+
cwd: worktree.worktreePath,
|
|
235
|
+
env: providerProcessEnv(dispatch)
|
|
236
|
+
}, {
|
|
237
|
+
onStdoutLine: async (line) => {
|
|
238
|
+
for (const event of outputState.consume(line)) {
|
|
239
|
+
await context.emit(event);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}, context.signal);
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await liveArchitectEvents.stop();
|
|
246
|
+
await cleanupCompletionTools(completionTools, context.emit);
|
|
247
|
+
}
|
|
248
|
+
if (result.exitCode !== 0) {
|
|
249
|
+
const stderr = clip(result.stderr.trim() || result.stdout.trim(), 1200);
|
|
250
|
+
throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
|
|
251
|
+
}
|
|
252
|
+
await context.emit({
|
|
253
|
+
role: "runner",
|
|
254
|
+
kind: "checkpoint",
|
|
255
|
+
content: `Provider CLI Architect completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
|
|
256
|
+
eventType: "provider_cli_completed"
|
|
257
|
+
});
|
|
258
|
+
await emitProviderUsageEvent(context.emit, dispatch, outputState);
|
|
259
|
+
const architectArtifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "architect");
|
|
260
|
+
if (!architectArtifact) {
|
|
261
|
+
throw new Error("Provider CLI Architect completed but did not call the Forge Architect completion tool.");
|
|
262
|
+
}
|
|
263
|
+
if (!liveArchitectEvents.hasEmitted()) {
|
|
264
|
+
await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, architectArtifact);
|
|
265
|
+
await emitArchitectStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, architectArtifact);
|
|
266
|
+
}
|
|
267
|
+
await context.emit({
|
|
268
|
+
role: "assistant",
|
|
269
|
+
kind: "message",
|
|
270
|
+
content: outputState.finalMessage || `${dispatch.execution.provider} CLI produced an Architect plan for ${worktree.branchName}.`,
|
|
271
|
+
eventType: "architect_complete"
|
|
272
|
+
});
|
|
273
|
+
const specPath = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id, "spec.md");
|
|
274
|
+
const specBody = await fs.readFile(specPath, "utf8").catch(() => `# ${dispatch.workItem.title}\n\nArchitect plan pending approval.\n`);
|
|
275
|
+
return {
|
|
276
|
+
summary: `${dispatch.execution.provider} CLI Architect plan produced.`,
|
|
277
|
+
spec: {
|
|
278
|
+
repoPath: worktree.worktreePath,
|
|
279
|
+
path: specPath,
|
|
280
|
+
body: specBody,
|
|
281
|
+
producedBy: `${dispatch.execution.provider}-cli`,
|
|
282
|
+
status: "pending_approval"
|
|
283
|
+
},
|
|
284
|
+
artifact: architectArtifact,
|
|
285
|
+
artifacts: [architectArtifact]
|
|
286
|
+
};
|
|
287
|
+
}
|
|
153
288
|
async runQa(context) {
|
|
154
289
|
const { dispatch, worktree } = context;
|
|
290
|
+
const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
|
|
291
|
+
await fs.mkdir(metadataRoot, { recursive: true });
|
|
292
|
+
const laneCommand = selectLaneCommand(dispatch.artifacts, dispatch.execution.lane, dispatch.cycleRun.phase);
|
|
293
|
+
if (!laneCommand) {
|
|
294
|
+
throw new Error(`Lane command for lane '${dispatch.execution.lane}' phase '${dispatch.cycleRun.phase}' was not resolved.`);
|
|
295
|
+
}
|
|
296
|
+
const specBody = buildSpecBody(laneCommand, dispatch.workItem, "qa");
|
|
297
|
+
const specPath = path.join(metadataRoot, "spec.md");
|
|
298
|
+
await fs.writeFile(specPath, specBody, "utf8");
|
|
299
|
+
await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "approved", produced_by: "forge-lane-command" }) + "\n", "utf8");
|
|
155
300
|
const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "qa" });
|
|
156
301
|
const command = buildProviderCliCommand(dispatch, {
|
|
157
302
|
worktreePath: worktree.worktreePath,
|
|
158
|
-
prompt: buildQaPrompt(dispatch),
|
|
303
|
+
prompt: buildQaPrompt(dispatch, specBody),
|
|
159
304
|
completionTools
|
|
160
305
|
});
|
|
161
306
|
const commandText = formatProviderCliCommand(command);
|
|
@@ -168,7 +313,7 @@ export class ProcessBackedProviderCliService {
|
|
|
168
313
|
await context.emit({
|
|
169
314
|
role: "runner",
|
|
170
315
|
kind: "checkpoint",
|
|
171
|
-
content: "Forge QA completion
|
|
316
|
+
content: "Forge QA completion tools configured; provider must call all seven QA MCP gates before exit.",
|
|
172
317
|
eventType: "completion_tools_configured"
|
|
173
318
|
});
|
|
174
319
|
const outputState = createProviderOutputState(dispatch.execution.provider);
|
|
@@ -180,11 +325,10 @@ export class ProcessBackedProviderCliService {
|
|
|
180
325
|
command: command.command,
|
|
181
326
|
args: command.args,
|
|
182
327
|
cwd: worktree.worktreePath,
|
|
183
|
-
env:
|
|
328
|
+
env: providerProcessEnv(dispatch)
|
|
184
329
|
}, {
|
|
185
330
|
onStdoutLine: async (line) => {
|
|
186
|
-
const event
|
|
187
|
-
if (event) {
|
|
331
|
+
for (const event of outputState.consume(line)) {
|
|
188
332
|
await context.emit(event);
|
|
189
333
|
}
|
|
190
334
|
}
|
|
@@ -205,7 +349,7 @@ export class ProcessBackedProviderCliService {
|
|
|
205
349
|
eventType: "provider_cli_completed"
|
|
206
350
|
});
|
|
207
351
|
await emitProviderUsageEvent(context.emit, dispatch, outputState);
|
|
208
|
-
const qaArtifact = await readQaCompletionArtifactBundle(worktree.worktreePath, dispatch.workItem.id);
|
|
352
|
+
const qaArtifact = await readQaCompletionArtifactBundle(worktree.worktreePath, dispatch.workItem.id, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId);
|
|
209
353
|
if (!qaArtifact) {
|
|
210
354
|
const verdictOnly = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "qa");
|
|
211
355
|
throw new Error(verdictOnly
|
|
@@ -215,7 +359,7 @@ export class ProcessBackedProviderCliService {
|
|
|
215
359
|
enforceQaBrowserLaunchTargetGate(dispatch, qaArtifact);
|
|
216
360
|
if (!liveQaEvents.hasEmitted()) {
|
|
217
361
|
await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
|
|
218
|
-
await emitQaStructuredEvents(context.emit,
|
|
362
|
+
await emitQaStructuredEvents(context.emit, qaArtifact);
|
|
219
363
|
}
|
|
220
364
|
const status = (qaArtifact.status ?? "unknown").trim().toLowerCase();
|
|
221
365
|
const statusSummary = status === "pass"
|
|
@@ -225,31 +369,41 @@ export class ProcessBackedProviderCliService {
|
|
|
225
369
|
: "found rework";
|
|
226
370
|
return {
|
|
227
371
|
summary: `${dispatch.execution.provider} CLI QA ${statusSummary}.`,
|
|
372
|
+
spec: {
|
|
373
|
+
repoPath: worktree.worktreePath,
|
|
374
|
+
path: specPath,
|
|
375
|
+
body: specBody,
|
|
376
|
+
producedBy: "forge-lane-command",
|
|
377
|
+
status: "approved"
|
|
378
|
+
},
|
|
228
379
|
artifact: qaArtifact,
|
|
229
380
|
artifacts: [qaArtifact]
|
|
230
381
|
};
|
|
231
382
|
}
|
|
232
383
|
async runConflictResolve(context) {
|
|
233
384
|
const { dispatch, worktree } = context;
|
|
385
|
+
const addressComments = isPullRequestCommentsDispatch(dispatch);
|
|
234
386
|
const command = buildProviderCliCommand(dispatch, {
|
|
235
387
|
worktreePath: worktree.worktreePath,
|
|
236
|
-
prompt:
|
|
388
|
+
prompt: addressComments
|
|
389
|
+
? buildPullRequestCommentsPrompt(dispatch, worktree.branchName)
|
|
390
|
+
: buildConflictResolvePrompt(dispatch, worktree.branchName)
|
|
237
391
|
});
|
|
238
392
|
await context.emit({
|
|
239
393
|
role: "runner",
|
|
240
394
|
kind: "checkpoint",
|
|
241
|
-
content: `Starting provider CLI conflict resolve: ${formatProviderCliCommand(command)} in ${worktree.worktreePath}`,
|
|
395
|
+
content: `Starting provider CLI ${addressComments ? "PR comment address" : "conflict resolve"}: ${formatProviderCliCommand(command)} in ${worktree.worktreePath}`,
|
|
242
396
|
eventType: "provider_cli_started"
|
|
243
397
|
});
|
|
244
398
|
const outputState = createProviderOutputState(dispatch.execution.provider);
|
|
245
399
|
const result = await this.processRunner.run({
|
|
246
400
|
command: command.command,
|
|
247
401
|
args: command.args,
|
|
248
|
-
cwd: worktree.worktreePath
|
|
402
|
+
cwd: worktree.worktreePath,
|
|
403
|
+
env: providerProcessEnv(dispatch)
|
|
249
404
|
}, {
|
|
250
405
|
onStdoutLine: async (line) => {
|
|
251
|
-
const event
|
|
252
|
-
if (event) {
|
|
406
|
+
for (const event of outputState.consume(line)) {
|
|
253
407
|
await context.emit(event);
|
|
254
408
|
}
|
|
255
409
|
}
|
|
@@ -264,11 +418,15 @@ export class ProcessBackedProviderCliService {
|
|
|
264
418
|
await context.emit({
|
|
265
419
|
role: "assistant",
|
|
266
420
|
kind: "message",
|
|
267
|
-
content: outputState.finalMessage ||
|
|
268
|
-
|
|
421
|
+
content: outputState.finalMessage || (addressComments
|
|
422
|
+
? `${dispatch.execution.provider} CLI addressed pull request comments for ${worktree.branchName}.`
|
|
423
|
+
: `${dispatch.execution.provider} CLI resolved merge conflicts for ${worktree.branchName}.`),
|
|
424
|
+
eventType: addressComments ? "pr_comments_complete" : "conflict_resolve_complete"
|
|
269
425
|
});
|
|
270
426
|
return {
|
|
271
|
-
summary:
|
|
427
|
+
summary: addressComments
|
|
428
|
+
? `${dispatch.execution.provider} CLI addressed PR comments on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
|
|
429
|
+
: `${dispatch.execution.provider} CLI resolved conflicts on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
|
|
272
430
|
};
|
|
273
431
|
}
|
|
274
432
|
}
|
|
@@ -281,6 +439,7 @@ export class DeterministicProviderCliService {
|
|
|
281
439
|
const { dispatch, worktree } = context;
|
|
282
440
|
const phase = completionPhaseFromDispatch(dispatch);
|
|
283
441
|
const developCycle = usesDevelopCycleCompletion(dispatch);
|
|
442
|
+
const debugCycle = usesDebugCycleCompletion(dispatch);
|
|
284
443
|
const providerCommand = buildProviderCliCommand(dispatch);
|
|
285
444
|
const providerCommandText = formatProviderCliCommand(providerCommand);
|
|
286
445
|
const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
|
|
@@ -311,9 +470,11 @@ export class DeterministicProviderCliService {
|
|
|
311
470
|
content: `Created bridge changeset at ${changesetPath}`,
|
|
312
471
|
eventType: "changeset"
|
|
313
472
|
});
|
|
314
|
-
const phases =
|
|
315
|
-
?
|
|
316
|
-
:
|
|
473
|
+
const phases = debugCycle
|
|
474
|
+
? debugCompletionPhaseOrder
|
|
475
|
+
: developCycle
|
|
476
|
+
? ["implementation", "self_review", "validation"]
|
|
477
|
+
: [phase];
|
|
317
478
|
const completionArtifacts = [];
|
|
318
479
|
for (const completionPhase of phases) {
|
|
319
480
|
await writeCompletionArtifact({
|
|
@@ -322,7 +483,9 @@ export class DeterministicProviderCliService {
|
|
|
322
483
|
phase: completionPhase,
|
|
323
484
|
producedBy: "forge-bridge",
|
|
324
485
|
redactionSecrets: exactRedactionSecrets(dispatch)
|
|
325
|
-
},
|
|
486
|
+
}, isDebugCompletionPhaseForProvider(completionPhase)
|
|
487
|
+
? expectedDebugCompletionToolName(completionPhase)
|
|
488
|
+
: expectedCompletionToolName(completionPhase), deterministicCompletionPayload(completionPhase, dispatch, changesetBody));
|
|
326
489
|
const completionArtifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, completionPhase);
|
|
327
490
|
if (!completionArtifact) {
|
|
328
491
|
throw new Error(`Deterministic provider did not produce the Forge ${completionPhase} completion artifact.`);
|
|
@@ -338,7 +501,7 @@ export class DeterministicProviderCliService {
|
|
|
338
501
|
}
|
|
339
502
|
await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
|
|
340
503
|
return {
|
|
341
|
-
summary: `forge-bridge completed ${worktree.branchName} (${developCycle ? "develop cycle" : phase}).`,
|
|
504
|
+
summary: `forge-bridge completed ${worktree.branchName} (${debugCycle ? "debug cycle" : developCycle ? "develop cycle" : phase}).`,
|
|
342
505
|
spec: {
|
|
343
506
|
repoPath: worktree.worktreePath,
|
|
344
507
|
path: specPath,
|
|
@@ -350,6 +513,47 @@ export class DeterministicProviderCliService {
|
|
|
350
513
|
artifacts: completionArtifacts
|
|
351
514
|
};
|
|
352
515
|
}
|
|
516
|
+
async runArchitect(context) {
|
|
517
|
+
const { dispatch, worktree } = context;
|
|
518
|
+
const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
|
|
519
|
+
await fs.mkdir(metadataRoot, { recursive: true });
|
|
520
|
+
const specBody = `---\ntitle: "${dispatch.workItem.title}"\nstatus: pending_approval\nproduced_by: forge-bridge\n---\n\n# ${dispatch.workItem.title}\n\nArchitect plan pending approval.\n`;
|
|
521
|
+
const specPath = path.join(metadataRoot, "spec.md");
|
|
522
|
+
await fs.writeFile(specPath, specBody, "utf8");
|
|
523
|
+
await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "pending_approval", produced_by: "forge-bridge" }) + "\n", "utf8");
|
|
524
|
+
await writeCompletionArtifact({
|
|
525
|
+
worktreePath: worktree.worktreePath,
|
|
526
|
+
workItemId: dispatch.workItem.id,
|
|
527
|
+
phase: "architect",
|
|
528
|
+
producedBy: "forge-bridge",
|
|
529
|
+
redactionSecrets: exactRedactionSecrets(dispatch),
|
|
530
|
+
architectCycle: true
|
|
531
|
+
}, expectedCompletionToolName("architect"), deterministicArchitectPayload(dispatch));
|
|
532
|
+
const artifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "architect");
|
|
533
|
+
if (!artifact) {
|
|
534
|
+
throw new Error("Deterministic provider did not produce the Forge Architect completion artifact.");
|
|
535
|
+
}
|
|
536
|
+
await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, artifact);
|
|
537
|
+
await emitArchitectStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, artifact);
|
|
538
|
+
await context.emit({
|
|
539
|
+
role: "assistant",
|
|
540
|
+
kind: "message",
|
|
541
|
+
content: "Architect plan complete. forge-bridge produced deterministic planning artifacts.",
|
|
542
|
+
eventType: "architect_complete"
|
|
543
|
+
});
|
|
544
|
+
return {
|
|
545
|
+
summary: "forge-bridge completed Architect plan (deterministic).",
|
|
546
|
+
spec: {
|
|
547
|
+
repoPath: worktree.worktreePath,
|
|
548
|
+
path: specPath,
|
|
549
|
+
body: specBody,
|
|
550
|
+
producedBy: "forge-bridge",
|
|
551
|
+
status: "pending_approval"
|
|
552
|
+
},
|
|
553
|
+
artifact,
|
|
554
|
+
artifacts: [artifact]
|
|
555
|
+
};
|
|
556
|
+
}
|
|
353
557
|
async runQa(context) {
|
|
354
558
|
const { dispatch, worktree } = context;
|
|
355
559
|
await context.emit({
|
|
@@ -362,30 +566,45 @@ export class DeterministicProviderCliService {
|
|
|
362
566
|
const launchTarget = qaBrowser?.launchTarget ?? null;
|
|
363
567
|
const missingLaunchTarget = qaBrowser?.required === true && !hasLaunchTarget(launchTarget);
|
|
364
568
|
const browserSidecar = !missingLaunchTarget && qaBrowser?.required === true && launchTarget
|
|
365
|
-
? deterministicBrowserSidecar(dispatch.workItem.id, launchTarget)
|
|
569
|
+
? deterministicBrowserSidecar(dispatch.workItem.id, launchTarget, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId)
|
|
366
570
|
: null;
|
|
367
571
|
const browserChecks = browserSidecar && launchTarget
|
|
368
572
|
? [deterministicBrowserCheck(launchTarget, browserSidecar.relativePath)]
|
|
369
573
|
: [];
|
|
574
|
+
const sidecars = await writeQaSidecarFiles(worktree.worktreePath, browserSidecar ? [browserSidecar] : []);
|
|
370
575
|
const summary = missingLaunchTarget
|
|
371
576
|
? "QA blocked because browser testing is required but no app launch target was provided."
|
|
372
577
|
: "All checks passed. forge-bridge QA completed.";
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
578
|
+
const criterion = dispatch.workItem.acceptanceCriteria.trim() || "Forge QA evidence exists";
|
|
579
|
+
for (const gate of qaCompletionGateOrder) {
|
|
580
|
+
await writeCompletionArtifact({
|
|
581
|
+
worktreePath: worktree.worktreePath,
|
|
582
|
+
workItemId: dispatch.workItem.id,
|
|
583
|
+
phase: "qa",
|
|
584
|
+
producedBy: "forge-bridge",
|
|
585
|
+
redactionSecrets: exactRedactionSecrets(dispatch),
|
|
586
|
+
qaCycle: true,
|
|
587
|
+
ownerType: dispatch.cycleRun.ownerType ?? undefined,
|
|
588
|
+
ownerId: dispatch.cycleRun.ownerId ?? undefined
|
|
589
|
+
}, expectedQaCompletionToolName(gate), deterministicQaGatePayload({
|
|
590
|
+
gate,
|
|
591
|
+
criterion,
|
|
592
|
+
summary,
|
|
593
|
+
missingLaunchTarget,
|
|
594
|
+
browserChecks,
|
|
595
|
+
sidecars
|
|
596
|
+
}));
|
|
597
|
+
const gateArtifact = await readQaGateArtifact(qaArtifactRootForDispatch(worktree.worktreePath, dispatch.workItem.id, dispatch), gate);
|
|
598
|
+
if (gateArtifact) {
|
|
599
|
+
await emitQaGateAccepted(context.emit, worktree.worktreePath, gateArtifact, nextQaGate(gate));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const qaArtifact = await readQaCompletionArtifactBundle(worktree.worktreePath, dispatch.workItem.id, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId);
|
|
603
|
+
if (!qaArtifact) {
|
|
604
|
+
throw new Error("Deterministic QA did not produce the complete durable QA artifact bundle.");
|
|
605
|
+
}
|
|
387
606
|
await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
|
|
388
|
-
await emitQaStructuredEvents(context.emit,
|
|
607
|
+
await emitQaStructuredEvents(context.emit, qaArtifact);
|
|
389
608
|
await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
|
|
390
609
|
return {
|
|
391
610
|
summary: missingLaunchTarget ? "forge-bridge QA blocked." : "forge-bridge QA passed.",
|
|
@@ -395,22 +614,30 @@ export class DeterministicProviderCliService {
|
|
|
395
614
|
}
|
|
396
615
|
async runConflictResolve(context) {
|
|
397
616
|
const { dispatch, worktree } = context;
|
|
398
|
-
|
|
617
|
+
const addressComments = isPullRequestCommentsDispatch(dispatch);
|
|
618
|
+
await this.git.run(worktree.worktreePath, ["commit", "--allow-empty", "-m", `forge bridge ${addressComments ? "pr comments" : "conflict resolve"} for ${dispatch.workItem.id.slice(0, 8)}`]);
|
|
399
619
|
await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
|
|
400
620
|
await context.emit({
|
|
401
621
|
role: "assistant",
|
|
402
622
|
kind: "message",
|
|
403
|
-
content:
|
|
404
|
-
|
|
623
|
+
content: addressComments
|
|
624
|
+
? `Deterministic PR comment addressing completed for ${worktree.branchName}.`
|
|
625
|
+
: `Deterministic conflict resolve completed for ${worktree.branchName}.`,
|
|
626
|
+
eventType: addressComments ? "pr_comments_complete" : "conflict_resolve_complete"
|
|
405
627
|
});
|
|
406
628
|
return {
|
|
407
|
-
summary:
|
|
629
|
+
summary: addressComments
|
|
630
|
+
? `forge-bridge addressed PR comments for ${worktree.branchName}.`
|
|
631
|
+
: `forge-bridge resolved conflicts for ${worktree.branchName}.`
|
|
408
632
|
};
|
|
409
633
|
}
|
|
410
634
|
}
|
|
411
635
|
function phaseCompleteEventType(phase) {
|
|
412
636
|
return phase === "implementation" ? "implementation_complete" : `${phase}_complete`;
|
|
413
637
|
}
|
|
638
|
+
function isDebugCompletionPhaseForProvider(phase) {
|
|
639
|
+
return debugCompletionPhaseOrder.includes(phase);
|
|
640
|
+
}
|
|
414
641
|
function deterministicCompletionPayload(phase, dispatch, changesetBody) {
|
|
415
642
|
const changesetHash = sha256(changesetBody);
|
|
416
643
|
const criterion = dispatch.workItem.acceptanceCriteria || "Deterministic bridge changeset exists";
|
|
@@ -433,6 +660,56 @@ function deterministicCompletionPayload(phase, dispatch, changesetBody) {
|
|
|
433
660
|
next_phase_context: "Review FORGE_PHASE5_CHANGESET.md and the bridge commit before validation."
|
|
434
661
|
};
|
|
435
662
|
}
|
|
663
|
+
if (phase === "debug_repro") {
|
|
664
|
+
return {
|
|
665
|
+
status: "pass",
|
|
666
|
+
summary: "forge-bridge reproduced the bug deterministically.",
|
|
667
|
+
repro_steps: ["Run the deterministic fake-worker debug reproduction."],
|
|
668
|
+
observed: "The configured bug scenario is reproducible in deterministic bridge mode.",
|
|
669
|
+
expected: "The bug scenario should be fixed before QA handoff.",
|
|
670
|
+
evidence: [{ kind: "changeset", path: "FORGE_PHASE5_CHANGESET.md", content_hash: changesetHash }]
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
if (phase === "debug_root_cause") {
|
|
674
|
+
return {
|
|
675
|
+
status: "pass",
|
|
676
|
+
summary: "forge-bridge isolated a deterministic root cause.",
|
|
677
|
+
root_cause: "The deterministic fake-worker bug is represented by the generated changeset evidence.",
|
|
678
|
+
evidence: [{ kind: "changeset", path: "FORGE_PHASE5_CHANGESET.md", content_hash: changesetHash }],
|
|
679
|
+
eliminated_alternatives: [{ summary: "No alternate deterministic fake-worker path was implicated." }],
|
|
680
|
+
risk_area: "forge-bridge deterministic debug lane"
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
if (phase === "debug_fix") {
|
|
684
|
+
return {
|
|
685
|
+
status: "pass",
|
|
686
|
+
summary: "forge-bridge applied a deterministic debug fix.",
|
|
687
|
+
changed_files: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Recorded deterministic debug fix evidence." }],
|
|
688
|
+
tests_added_or_updated: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Deterministic debug evidence doubles as fake-worker regression coverage." }],
|
|
689
|
+
remaining_risks: [],
|
|
690
|
+
resolved_findings: (dispatch.qaRework?.findingIds ?? []).map((qaId) => ({
|
|
691
|
+
qa_id: qaId,
|
|
692
|
+
status: "resolved",
|
|
693
|
+
summary: `Resolved deterministic QA finding ${qaId}.`,
|
|
694
|
+
evidence: { path: "FORGE_PHASE5_CHANGESET.md", content_hash: changesetHash },
|
|
695
|
+
changed_files: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Recorded deterministic debug fix evidence." }],
|
|
696
|
+
tests_added_or_updated: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Deterministic fake-worker regression evidence." }],
|
|
697
|
+
regression_evidence: [{ command: "forge-bridge deterministic debug regression", status: "pass" }]
|
|
698
|
+
}))
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
if (phase === "debug_regression") {
|
|
702
|
+
return {
|
|
703
|
+
status: "pass",
|
|
704
|
+
summary: "forge-bridge proved the deterministic debug regression is QA-ready.",
|
|
705
|
+
commands: [{ command: "forge-bridge deterministic debug regression", exit_code: 0, status: "pass", summary: "Deterministic debug regression completed." }],
|
|
706
|
+
required_regressions: ["Original deterministic repro no longer fails."],
|
|
707
|
+
build_status: "not_applicable",
|
|
708
|
+
test_status: "pass",
|
|
709
|
+
repro_negative: true,
|
|
710
|
+
qa_ready: true
|
|
711
|
+
};
|
|
712
|
+
}
|
|
436
713
|
if (phase === "self_review") {
|
|
437
714
|
return {
|
|
438
715
|
status: "pass",
|
|
@@ -493,6 +770,149 @@ function deterministicCompletionPayload(phase, dispatch, changesetBody) {
|
|
|
493
770
|
ready_for_dev_test: true
|
|
494
771
|
};
|
|
495
772
|
}
|
|
773
|
+
function deterministicArchitectPayload(dispatch) {
|
|
774
|
+
const planId = `architect-${dispatch.workItem.id.replace(/-/g, "")}`;
|
|
775
|
+
return {
|
|
776
|
+
status: "pass",
|
|
777
|
+
summary: "forge-bridge produced a deterministic Architect plan.",
|
|
778
|
+
implementation_plan: {
|
|
779
|
+
schema_version: 1,
|
|
780
|
+
plan_id: planId,
|
|
781
|
+
story_id: dispatch.workItem.id,
|
|
782
|
+
phases: [
|
|
783
|
+
{ id: "phase-1", name: "Foundation", tasks: ["task-1"], gate: "phase-1-gate" }
|
|
784
|
+
],
|
|
785
|
+
tasks: [
|
|
786
|
+
{
|
|
787
|
+
id: "task-1",
|
|
788
|
+
title: `Implement ${dispatch.workItem.title}`,
|
|
789
|
+
phase: "phase-1",
|
|
790
|
+
summary: "Deterministic bridge Architect task proving MCP artifact contract persistence.",
|
|
791
|
+
acceptance_criteria: [dispatch.workItem.acceptanceCriteria || `Preserve story intent for ${dispatch.workItem.title}`],
|
|
792
|
+
tests: [
|
|
793
|
+
{ command: "dotnet test /home/dillon/repos/Forge/Forge.sln --filter Architect", purpose: "Architect bridge contract regression" }
|
|
794
|
+
]
|
|
795
|
+
}
|
|
796
|
+
],
|
|
797
|
+
dependencies: [],
|
|
798
|
+
phase_gates: [
|
|
799
|
+
{ id: "phase-1-gate", phase: "phase-1", checks: ["Task tests pass", "Artifacts validate"] }
|
|
800
|
+
]
|
|
801
|
+
},
|
|
802
|
+
qa_plan: {
|
|
803
|
+
schema_version: 1,
|
|
804
|
+
plan_id: planId,
|
|
805
|
+
final_qa: [
|
|
806
|
+
{ id: "qa-1", coverage: `Verify Architect plan artifacts and pending approval status for ${dispatch.workItem.title}` }
|
|
807
|
+
]
|
|
808
|
+
},
|
|
809
|
+
report_markdown: `# Architect Report\n\nWork item: ${dispatch.workItem.id}\nTitle: ${dispatch.workItem.title}\n\nVerdict: pass\n\nDeterministic bridge Architect plan produced for pending approval.\n`
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
function deterministicQaGatePayload(options) {
|
|
813
|
+
const blocked = options.missingLaunchTarget;
|
|
814
|
+
const skippedBecauseBlocked = blocked
|
|
815
|
+
? {
|
|
816
|
+
status: "skipped",
|
|
817
|
+
summary: "Skipped because browser QA was blocked by missing launch target.",
|
|
818
|
+
reason: "Upstream browser QA blocker prevents meaningful downstream evidence."
|
|
819
|
+
}
|
|
820
|
+
: null;
|
|
821
|
+
switch (options.gate) {
|
|
822
|
+
case "qa_plan":
|
|
823
|
+
return {
|
|
824
|
+
status: "pass",
|
|
825
|
+
summary: "Deterministic QA plan recorded.",
|
|
826
|
+
checklist: [
|
|
827
|
+
"Review the validated work item against acceptance criteria.",
|
|
828
|
+
"Run deterministic regression evidence.",
|
|
829
|
+
"Confirm no in-scope QA findings remain."
|
|
830
|
+
],
|
|
831
|
+
acceptance_criteria: [options.criterion]
|
|
832
|
+
};
|
|
833
|
+
case "qa_regression":
|
|
834
|
+
return {
|
|
835
|
+
status: "pass",
|
|
836
|
+
summary: "Deterministic regression QA completed.",
|
|
837
|
+
commands: [
|
|
838
|
+
{
|
|
839
|
+
command: "forge-bridge deterministic QA",
|
|
840
|
+
exit_code: 0,
|
|
841
|
+
status: "pass",
|
|
842
|
+
summary: "Deterministic regression evidence completed."
|
|
843
|
+
}
|
|
844
|
+
],
|
|
845
|
+
notes: "Regression gate passed in deterministic fake-worker mode."
|
|
846
|
+
};
|
|
847
|
+
case "qa_api":
|
|
848
|
+
return {
|
|
849
|
+
status: "skipped",
|
|
850
|
+
summary: "No API/backend surface was identified for deterministic QA.",
|
|
851
|
+
reason: "The deterministic fake-worker changeset does not exercise API/backend behavior."
|
|
852
|
+
};
|
|
853
|
+
case "qa_manual_web":
|
|
854
|
+
return blocked
|
|
855
|
+
? {
|
|
856
|
+
status: "blocked",
|
|
857
|
+
summary: "Browser QA could not run because no app launch target was provided.",
|
|
858
|
+
blocker_reason: "missing_app_launch_target"
|
|
859
|
+
}
|
|
860
|
+
: options.browserChecks.length > 0
|
|
861
|
+
? {
|
|
862
|
+
status: "pass",
|
|
863
|
+
summary: "Deterministic browser QA exercised the configured launch target.",
|
|
864
|
+
browser_checks: options.browserChecks,
|
|
865
|
+
sidecars: options.sidecars,
|
|
866
|
+
notes: "Browser sidecar evidence recorded."
|
|
867
|
+
}
|
|
868
|
+
: {
|
|
869
|
+
status: "skipped",
|
|
870
|
+
summary: "Manual web QA was not required for this deterministic run.",
|
|
871
|
+
reason: "Forge did not require browser QA and no launch target was necessary."
|
|
872
|
+
};
|
|
873
|
+
case "qa_bug_repro":
|
|
874
|
+
return skippedBecauseBlocked ?? {
|
|
875
|
+
status: "skipped",
|
|
876
|
+
summary: "No failed QA observation required bug reproduction.",
|
|
877
|
+
reason: "No defects or candidate bugs were observed."
|
|
878
|
+
};
|
|
879
|
+
case "qa_findings":
|
|
880
|
+
return skippedBecauseBlocked ?? {
|
|
881
|
+
status: "pass",
|
|
882
|
+
summary: "No QA findings or candidate bugs were identified.",
|
|
883
|
+
findings: [],
|
|
884
|
+
candidate_bugs: []
|
|
885
|
+
};
|
|
886
|
+
case "qa_verdict":
|
|
887
|
+
return {
|
|
888
|
+
status: blocked ? "blocked" : "pass",
|
|
889
|
+
summary: options.summary,
|
|
890
|
+
checklist: [
|
|
891
|
+
"Review the validated work item against acceptance criteria.",
|
|
892
|
+
"Run deterministic regression evidence.",
|
|
893
|
+
blocked ? "Record missing browser launch target blocker." : "Confirm no in-scope QA findings remain."
|
|
894
|
+
],
|
|
895
|
+
tested_acceptance_criteria: blocked ? [] : [options.criterion],
|
|
896
|
+
commands: blocked
|
|
897
|
+
? []
|
|
898
|
+
: [
|
|
899
|
+
{
|
|
900
|
+
command: "forge-bridge deterministic QA",
|
|
901
|
+
exit_code: 0,
|
|
902
|
+
status: "pass",
|
|
903
|
+
summary: "Deterministic QA command completed."
|
|
904
|
+
}
|
|
905
|
+
],
|
|
906
|
+
browser_checks: blocked ? [] : options.browserChecks,
|
|
907
|
+
sidecars: blocked ? [] : options.sidecars,
|
|
908
|
+
notes: options.summary,
|
|
909
|
+
findings: [],
|
|
910
|
+
candidate_bugs: [],
|
|
911
|
+
blocker_reason: blocked ? "missing_app_launch_target" : null,
|
|
912
|
+
report_markdown: `# QA report\n\n${options.summary}\n`
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
}
|
|
496
916
|
const qaArtifactFileNames = {
|
|
497
917
|
plan: "qa.plan.json",
|
|
498
918
|
evidence: "qa.evidence.json",
|
|
@@ -500,16 +920,25 @@ const qaArtifactFileNames = {
|
|
|
500
920
|
verdict: "qa.verdict.json",
|
|
501
921
|
report: "qa.report.md"
|
|
502
922
|
};
|
|
923
|
+
const qaGateArtifactFileNames = {
|
|
924
|
+
qa_plan: "qa.plan.json",
|
|
925
|
+
qa_regression: "qa.regression.json",
|
|
926
|
+
qa_api: "qa.api.json",
|
|
927
|
+
qa_manual_web: "qa.manual-web.json",
|
|
928
|
+
qa_bug_repro: "qa.bug-repro.json",
|
|
929
|
+
qa_findings: "qa.findings.json",
|
|
930
|
+
qa_verdict: "qa.verdict.json"
|
|
931
|
+
};
|
|
503
932
|
function hasLaunchTarget(target) {
|
|
504
933
|
return Boolean(target?.url?.trim()
|
|
505
934
|
|| target?.ready_url?.trim()
|
|
506
935
|
|| target?.command?.trim()
|
|
507
936
|
|| (target?.routes ?? []).some((route) => route.trim().length > 0));
|
|
508
937
|
}
|
|
509
|
-
function deterministicBrowserSidecar(workItemId, target) {
|
|
938
|
+
function deterministicBrowserSidecar(workItemId, target, ownerType, ownerId) {
|
|
510
939
|
const route = primaryLaunchRoute(target);
|
|
511
940
|
return {
|
|
512
|
-
relativePath: qaArtifactRelativePath(workItemId, "qa/screenshots/deterministic-browser-check.txt"),
|
|
941
|
+
relativePath: qaArtifactRelativePath(workItemId, "qa/screenshots/deterministic-browser-check.txt", ownerType, ownerId),
|
|
513
942
|
content: `Deterministic Forge QA browser evidence\nRoute: ${route}\nConsole errors: []\nNetwork errors: []\nResponsive checks: desktop, mobile\n`
|
|
514
943
|
};
|
|
515
944
|
}
|
|
@@ -551,78 +980,6 @@ async function writeQaSidecarFiles(worktreePath, files) {
|
|
|
551
980
|
}
|
|
552
981
|
return sidecars;
|
|
553
982
|
}
|
|
554
|
-
async function writeQaArtifacts(request) {
|
|
555
|
-
const artifactRoot = artifactRootFor(request.worktreePath, request.workItemId);
|
|
556
|
-
await fs.mkdir(artifactRoot, { recursive: true });
|
|
557
|
-
const sidecars = await writeQaSidecarFiles(request.worktreePath, request.sidecarFiles ?? []);
|
|
558
|
-
const status = request.status ?? "pass";
|
|
559
|
-
const criterion = request.acceptanceCriteria.trim() || "Forge QA evidence exists";
|
|
560
|
-
const relativeEvidencePath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.evidence);
|
|
561
|
-
const relativeFindingsPath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.findings);
|
|
562
|
-
const relativeReportPath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.report);
|
|
563
|
-
const planJson = jsonText({
|
|
564
|
-
schema_version: 1,
|
|
565
|
-
phase: "qa",
|
|
566
|
-
checklist: [
|
|
567
|
-
"Review the validated work item against acceptance criteria.",
|
|
568
|
-
"Run relevant provider QA verification.",
|
|
569
|
-
"Confirm no in-scope QA findings remain."
|
|
570
|
-
],
|
|
571
|
-
acceptance_criteria: [criterion]
|
|
572
|
-
});
|
|
573
|
-
const evidenceJson = jsonText({
|
|
574
|
-
schema_version: 1,
|
|
575
|
-
phase: "qa",
|
|
576
|
-
commands: status === "blocked"
|
|
577
|
-
? []
|
|
578
|
-
: [
|
|
579
|
-
{
|
|
580
|
-
command: request.command,
|
|
581
|
-
exit_code: 0,
|
|
582
|
-
status: "pass",
|
|
583
|
-
summary: `${request.provider} QA completed successfully.`
|
|
584
|
-
}
|
|
585
|
-
],
|
|
586
|
-
browser_checks: request.browserChecks ?? [],
|
|
587
|
-
sidecars,
|
|
588
|
-
notes: request.summary
|
|
589
|
-
});
|
|
590
|
-
const findingsJson = jsonText({
|
|
591
|
-
schema_version: 1,
|
|
592
|
-
phase: "qa",
|
|
593
|
-
findings: [],
|
|
594
|
-
candidate_bugs: []
|
|
595
|
-
});
|
|
596
|
-
const verdictJson = jsonText({
|
|
597
|
-
schema_version: 1,
|
|
598
|
-
phase: "qa",
|
|
599
|
-
status,
|
|
600
|
-
summary: request.summary,
|
|
601
|
-
tested_acceptance_criteria: status === "blocked" ? [] : [criterion],
|
|
602
|
-
finding_ids: [],
|
|
603
|
-
candidate_bug_ids: [],
|
|
604
|
-
blocker_reason: status === "blocked" ? request.blockerReason ?? "inconclusive" : null,
|
|
605
|
-
evidence_path: relativeEvidencePath,
|
|
606
|
-
findings_path: relativeFindingsPath,
|
|
607
|
-
report_path: relativeReportPath
|
|
608
|
-
});
|
|
609
|
-
const report = `# QA report\n\n${request.summary}\n\n- Provider: ${singleLine(request.provider)}\n- Model: ${singleLine(request.model)}\n- Command: ${singleLine(request.command)}\n`;
|
|
610
|
-
await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.plan), planJson, "utf8");
|
|
611
|
-
await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.evidence), evidenceJson, "utf8");
|
|
612
|
-
await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.findings), findingsJson, "utf8");
|
|
613
|
-
const verdictPath = path.join(artifactRoot, qaArtifactFileNames.verdict);
|
|
614
|
-
await fs.writeFile(verdictPath, verdictJson, "utf8");
|
|
615
|
-
await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.report), report, "utf8");
|
|
616
|
-
return {
|
|
617
|
-
repoPath: request.worktreePath,
|
|
618
|
-
path: verdictPath,
|
|
619
|
-
verdictJson,
|
|
620
|
-
report,
|
|
621
|
-
producedBy: request.producedBy,
|
|
622
|
-
status,
|
|
623
|
-
phase: "qa"
|
|
624
|
-
};
|
|
625
|
-
}
|
|
626
983
|
async function emitQaArtifactRecorded(emit, worktreePath, artifact) {
|
|
627
984
|
const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
|
|
628
985
|
await emit({
|
|
@@ -638,16 +995,29 @@ async function emitQaArtifactRecorded(emit, worktreePath, artifact) {
|
|
|
638
995
|
eventType: "phase_gate"
|
|
639
996
|
});
|
|
640
997
|
}
|
|
641
|
-
async function readQaCompletionArtifactBundle(worktreePath, workItemId) {
|
|
642
|
-
const artifact = await readCompletionArtifact(worktreePath, workItemId, "qa");
|
|
643
|
-
if (!artifact || !await hasCompleteQaArtifactBundle(worktreePath, workItemId)) {
|
|
998
|
+
async function readQaCompletionArtifactBundle(worktreePath, workItemId, ownerType, ownerId) {
|
|
999
|
+
const artifact = await readCompletionArtifact(worktreePath, workItemId, "qa", ownerType, ownerId);
|
|
1000
|
+
if (!artifact || !await hasCompleteQaArtifactBundle(worktreePath, workItemId, ownerType, ownerId)) {
|
|
644
1001
|
return null;
|
|
645
1002
|
}
|
|
646
|
-
|
|
647
|
-
|
|
1003
|
+
const artifactRoot = ownerType && ownerId
|
|
1004
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
1005
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
1006
|
+
const planJson = await fs.readFile(path.join(artifactRoot, qaArtifactFileNames.plan), "utf8");
|
|
1007
|
+
const evidenceJson = await fs.readFile(path.join(artifactRoot, qaArtifactFileNames.evidence), "utf8");
|
|
1008
|
+
const findingsJson = await fs.readFile(path.join(artifactRoot, qaArtifactFileNames.findings), "utf8");
|
|
1009
|
+
await validateQaBundleSidecarReferences(worktreePath, workItemId, ownerType, ownerId);
|
|
1010
|
+
return {
|
|
1011
|
+
...artifact,
|
|
1012
|
+
planJson,
|
|
1013
|
+
evidenceJson,
|
|
1014
|
+
findingsJson
|
|
1015
|
+
};
|
|
648
1016
|
}
|
|
649
|
-
async function hasCompleteQaArtifactBundle(worktreePath, workItemId) {
|
|
650
|
-
const artifactRoot =
|
|
1017
|
+
async function hasCompleteQaArtifactBundle(worktreePath, workItemId, ownerType, ownerId) {
|
|
1018
|
+
const artifactRoot = ownerType && ownerId
|
|
1019
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
1020
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
651
1021
|
for (const fileName of Object.values(qaArtifactFileNames)) {
|
|
652
1022
|
try {
|
|
653
1023
|
const stat = await fs.stat(path.join(artifactRoot, fileName));
|
|
@@ -676,8 +1046,10 @@ function enforceQaBrowserLaunchTargetGate(dispatch, artifact) {
|
|
|
676
1046
|
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.");
|
|
677
1047
|
}
|
|
678
1048
|
}
|
|
679
|
-
async function validateQaBundleSidecarReferences(worktreePath, workItemId) {
|
|
680
|
-
const artifactRoot =
|
|
1049
|
+
async function validateQaBundleSidecarReferences(worktreePath, workItemId, ownerType, ownerId) {
|
|
1050
|
+
const artifactRoot = ownerType && ownerId
|
|
1051
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
1052
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
681
1053
|
const evidence = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.evidence));
|
|
682
1054
|
const sidecars = arrayField(evidence, "sidecars");
|
|
683
1055
|
if (sidecars.length === 0) {
|
|
@@ -696,7 +1068,9 @@ async function validateQaBundleSidecarReferences(worktreePath, workItemId) {
|
|
|
696
1068
|
}
|
|
697
1069
|
const fullPath = path.resolve(worktreePath, sidecarPath);
|
|
698
1070
|
if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
|
|
699
|
-
|
|
1071
|
+
const ownerDirectory = ownerType && ownerId && ownerType.trim().toLowerCase() === "story" ? "stories" : "work-items";
|
|
1072
|
+
const ownerSegment = ownerType && ownerId ? ownerId : workItemId;
|
|
1073
|
+
throw new Error(`QA sidecar ${index} path must be under .forge/${ownerDirectory}/${ownerSegment}/artifacts/qa/.`);
|
|
700
1074
|
}
|
|
701
1075
|
const stat = await fs.lstat(fullPath).catch((error) => {
|
|
702
1076
|
if (isNodeError(error) && error.code === "ENOENT") {
|
|
@@ -717,8 +1091,8 @@ async function validateQaBundleSidecarReferences(worktreePath, workItemId) {
|
|
|
717
1091
|
}
|
|
718
1092
|
}
|
|
719
1093
|
}
|
|
720
|
-
async function emitQaStructuredEvents(emit,
|
|
721
|
-
const artifactRoot =
|
|
1094
|
+
async function emitQaStructuredEvents(emit, artifact) {
|
|
1095
|
+
const artifactRoot = path.dirname(artifact.path);
|
|
722
1096
|
const plan = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.plan));
|
|
723
1097
|
const evidence = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.evidence));
|
|
724
1098
|
const findings = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.findings));
|
|
@@ -758,12 +1132,76 @@ async function emitQaStructuredEvents(emit, worktreePath, workItemId, artifact)
|
|
|
758
1132
|
eventType: status === "blocked" ? "qa_blocked" : "qa_complete"
|
|
759
1133
|
});
|
|
760
1134
|
}
|
|
1135
|
+
function qaArtifactRootForDispatch(worktreePath, workItemId, dispatch) {
|
|
1136
|
+
return dispatch.cycleRun.ownerType && dispatch.cycleRun.ownerId
|
|
1137
|
+
? artifactRootForOwner(worktreePath, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId)
|
|
1138
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
1139
|
+
}
|
|
1140
|
+
async function readQaGateArtifact(artifactRoot, gate) {
|
|
1141
|
+
const artifactPath = path.join(artifactRoot, qaGateArtifactFileNames[gate]);
|
|
1142
|
+
try {
|
|
1143
|
+
const stat = await fs.stat(artifactPath);
|
|
1144
|
+
if (!stat.isFile()) {
|
|
1145
|
+
return null;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
catch (error) {
|
|
1149
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
throw error;
|
|
1153
|
+
}
|
|
1154
|
+
const payload = await readJsonObjectFile(artifactPath);
|
|
1155
|
+
return {
|
|
1156
|
+
gate,
|
|
1157
|
+
path: artifactPath,
|
|
1158
|
+
status: stringField(payload, "status") ?? "unknown",
|
|
1159
|
+
producedBy: stringField(payload, "produced_by") ?? stringField(payload, "producedBy") ?? undefined,
|
|
1160
|
+
contentHash: stringField(payload, "content_hash") ?? undefined
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
function nextQaGate(gate) {
|
|
1164
|
+
const index = qaCompletionGateOrder.indexOf(gate);
|
|
1165
|
+
return index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
|
|
1166
|
+
}
|
|
1167
|
+
async function emitQaGateAccepted(emit, worktreePath, artifact, nextGate) {
|
|
1168
|
+
const relativePath = path.relative(worktreePath, artifact.path).split(path.sep).join("/");
|
|
1169
|
+
await emit({
|
|
1170
|
+
role: "hook",
|
|
1171
|
+
kind: artifact.status === "blocked" ? "error" : "checkpoint",
|
|
1172
|
+
content: `phase_gate: ${artifact.gate} artifact accepted with status ${artifact.status}.`,
|
|
1173
|
+
eventType: "phase_gate",
|
|
1174
|
+
data: JSON.stringify({
|
|
1175
|
+
phase: "qa",
|
|
1176
|
+
qa_gate: artifact.gate,
|
|
1177
|
+
status: artifact.status,
|
|
1178
|
+
artifact_relative_paths: [relativePath],
|
|
1179
|
+
content_hash: artifact.contentHash ?? null,
|
|
1180
|
+
produced_by: artifact.producedBy ?? null
|
|
1181
|
+
})
|
|
1182
|
+
});
|
|
1183
|
+
if (!nextGate) {
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
await emit({
|
|
1187
|
+
role: "hook",
|
|
1188
|
+
kind: "checkpoint",
|
|
1189
|
+
content: `phase_progression: ${artifact.gate} -> ${nextGate}`,
|
|
1190
|
+
eventType: "phase_gate",
|
|
1191
|
+
data: JSON.stringify({
|
|
1192
|
+
phase: "qa",
|
|
1193
|
+
from_gate: artifact.gate,
|
|
1194
|
+
to_gate: nextGate
|
|
1195
|
+
})
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
761
1198
|
class LiveQaArtifactEmitter {
|
|
762
1199
|
worktreePath;
|
|
763
1200
|
workItemId;
|
|
764
1201
|
dispatch;
|
|
765
1202
|
emit;
|
|
766
|
-
|
|
1203
|
+
emittedBundle = false;
|
|
1204
|
+
emittedGates = new Set();
|
|
767
1205
|
timer = null;
|
|
768
1206
|
pending = Promise.resolve();
|
|
769
1207
|
constructor(worktreePath, workItemId, dispatch, emit) {
|
|
@@ -786,7 +1224,7 @@ class LiveQaArtifactEmitter {
|
|
|
786
1224
|
await this.pollQueued();
|
|
787
1225
|
}
|
|
788
1226
|
hasEmitted() {
|
|
789
|
-
return this.
|
|
1227
|
+
return this.emittedBundle;
|
|
790
1228
|
}
|
|
791
1229
|
pollQueued() {
|
|
792
1230
|
this.pending = this.pending
|
|
@@ -795,19 +1233,125 @@ class LiveQaArtifactEmitter {
|
|
|
795
1233
|
return this.pending;
|
|
796
1234
|
}
|
|
797
1235
|
async poll() {
|
|
798
|
-
|
|
1236
|
+
await this.emitAvailableGateEvents();
|
|
1237
|
+
if (this.emittedBundle) {
|
|
799
1238
|
return;
|
|
800
1239
|
}
|
|
801
|
-
const artifact = await readQaCompletionArtifactBundle(this.worktreePath, this.workItemId);
|
|
1240
|
+
const artifact = await readQaCompletionArtifactBundle(this.worktreePath, this.workItemId, this.dispatch.cycleRun.ownerType, this.dispatch.cycleRun.ownerId);
|
|
802
1241
|
if (!artifact) {
|
|
803
1242
|
return;
|
|
804
1243
|
}
|
|
805
1244
|
enforceQaBrowserLaunchTargetGate(this.dispatch, artifact);
|
|
806
|
-
this.
|
|
1245
|
+
this.emittedBundle = true;
|
|
807
1246
|
await emitQaArtifactRecorded(this.emit, this.worktreePath, artifact);
|
|
808
|
-
await emitQaStructuredEvents(this.emit,
|
|
1247
|
+
await emitQaStructuredEvents(this.emit, artifact);
|
|
1248
|
+
}
|
|
1249
|
+
async emitAvailableGateEvents() {
|
|
1250
|
+
const artifactRoot = qaArtifactRootForDispatch(this.worktreePath, this.workItemId, this.dispatch);
|
|
1251
|
+
for (const gate of qaCompletionGateOrder) {
|
|
1252
|
+
if (this.emittedGates.has(gate)) {
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
const artifact = await readQaGateArtifact(artifactRoot, gate);
|
|
1256
|
+
if (!artifact) {
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
this.emittedGates.add(gate);
|
|
1260
|
+
await emitQaGateAccepted(this.emit, this.worktreePath, artifact, nextQaGate(gate));
|
|
1261
|
+
}
|
|
809
1262
|
}
|
|
810
1263
|
}
|
|
1264
|
+
class LiveArchitectArtifactEmitter {
|
|
1265
|
+
worktreePath;
|
|
1266
|
+
workItemId;
|
|
1267
|
+
emit;
|
|
1268
|
+
emitted = false;
|
|
1269
|
+
timer = null;
|
|
1270
|
+
pending = Promise.resolve();
|
|
1271
|
+
constructor(worktreePath, workItemId, emit) {
|
|
1272
|
+
this.worktreePath = worktreePath;
|
|
1273
|
+
this.workItemId = workItemId;
|
|
1274
|
+
this.emit = emit;
|
|
1275
|
+
}
|
|
1276
|
+
start() {
|
|
1277
|
+
void this.pollQueued();
|
|
1278
|
+
this.timer = setInterval(() => {
|
|
1279
|
+
void this.pollQueued();
|
|
1280
|
+
}, 250);
|
|
1281
|
+
}
|
|
1282
|
+
async stop() {
|
|
1283
|
+
if (this.timer) {
|
|
1284
|
+
clearInterval(this.timer);
|
|
1285
|
+
this.timer = null;
|
|
1286
|
+
}
|
|
1287
|
+
await this.pollQueued();
|
|
1288
|
+
}
|
|
1289
|
+
hasEmitted() {
|
|
1290
|
+
return this.emitted;
|
|
1291
|
+
}
|
|
1292
|
+
pollQueued() {
|
|
1293
|
+
this.pending = this.pending
|
|
1294
|
+
.then(() => this.poll())
|
|
1295
|
+
.catch(() => undefined);
|
|
1296
|
+
return this.pending;
|
|
1297
|
+
}
|
|
1298
|
+
async poll() {
|
|
1299
|
+
if (this.emitted || !await hasCompleteArchitectArtifactBundle(this.worktreePath, this.workItemId)) {
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const artifact = await readCompletionArtifact(this.worktreePath, this.workItemId, "architect");
|
|
1303
|
+
if (!artifact) {
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
this.emitted = true;
|
|
1307
|
+
await emitCompletionArtifactRecorded(this.emit, this.worktreePath, artifact);
|
|
1308
|
+
await emitArchitectStructuredEvents(this.emit, this.worktreePath, this.workItemId, artifact);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
async function hasCompleteArchitectArtifactBundle(worktreePath, workItemId) {
|
|
1312
|
+
const artifactRoot = artifactRootFor(worktreePath, workItemId);
|
|
1313
|
+
for (const fileName of ["implementation.plan.json", "qa.plan.json", "architect.verdict.json", "architect.report.md"]) {
|
|
1314
|
+
try {
|
|
1315
|
+
const stat = await fs.stat(path.join(artifactRoot, fileName));
|
|
1316
|
+
if (!stat.isFile()) {
|
|
1317
|
+
return false;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
catch (error) {
|
|
1321
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
throw error;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
return true;
|
|
1328
|
+
}
|
|
1329
|
+
async function emitArchitectStructuredEvents(emit, worktreePath, workItemId, artifact) {
|
|
1330
|
+
const artifactRoot = artifactRootFor(worktreePath, workItemId);
|
|
1331
|
+
const implementationPlan = await readJsonObjectFile(path.join(artifactRoot, "implementation.plan.json"));
|
|
1332
|
+
const qaPlan = await readJsonObjectFile(path.join(artifactRoot, "qa.plan.json"));
|
|
1333
|
+
const verdict = parseJsonLine(artifact.verdictJson) ?? {};
|
|
1334
|
+
const status = stringField(verdict, "status") ?? artifact.status ?? "unknown";
|
|
1335
|
+
const planId = stringField(implementationPlan, "plan_id") ?? "unknown";
|
|
1336
|
+
await emit({
|
|
1337
|
+
role: "worker",
|
|
1338
|
+
kind: "checkpoint",
|
|
1339
|
+
content: `Architect plan recorded: ${planId} with ${arrayField(implementationPlan, "phases").length} phases and ${arrayField(implementationPlan, "tasks").length} tasks.`,
|
|
1340
|
+
eventType: "architect_plan"
|
|
1341
|
+
});
|
|
1342
|
+
await emit({
|
|
1343
|
+
role: "worker",
|
|
1344
|
+
kind: "checkpoint",
|
|
1345
|
+
content: `Architect QA seed recorded with ${arrayField(qaPlan, "final_qa").length} final QA items.`,
|
|
1346
|
+
eventType: "architect_qa_seed"
|
|
1347
|
+
});
|
|
1348
|
+
await emit({
|
|
1349
|
+
role: "hook",
|
|
1350
|
+
kind: status === "pass" ? "checkpoint" : "error",
|
|
1351
|
+
content: `architect_mcp_complete: ${status === "pass" ? "accepted" : "recorded"} - ${stringField(verdict, "summary") ?? "Architect completion payload recorded."}`,
|
|
1352
|
+
eventType: "architect_mcp_complete"
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
811
1355
|
async function readJsonObjectFile(filePath) {
|
|
812
1356
|
const content = await fs.readFile(filePath, "utf8").catch(() => "");
|
|
813
1357
|
return parseJsonLine(content) ?? {};
|
|
@@ -832,19 +1376,36 @@ function summarizeQaObject(source) {
|
|
|
832
1376
|
?? stringField(source, "id")
|
|
833
1377
|
?? JSON.stringify(source), 500);
|
|
834
1378
|
}
|
|
835
|
-
function qaArtifactRelativePath(workItemId, fileName) {
|
|
1379
|
+
function qaArtifactRelativePath(workItemId, fileName, ownerType, ownerId) {
|
|
1380
|
+
if (ownerType && ownerId) {
|
|
1381
|
+
const ownerDirectory = ownerType.trim().toLowerCase() === "story" ? "stories" : "work-items";
|
|
1382
|
+
return [".forge", ownerDirectory, ownerId, "artifacts", fileName].join("/");
|
|
1383
|
+
}
|
|
836
1384
|
return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
|
|
837
1385
|
}
|
|
838
|
-
function jsonText(value) {
|
|
839
|
-
return `${JSON.stringify(value, null, 2)}\n`;
|
|
840
|
-
}
|
|
841
1386
|
async function emitCompletionArtifactRecorded(emit, worktreePath, artifact) {
|
|
842
1387
|
const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
|
|
1388
|
+
await emit({
|
|
1389
|
+
role: "tool",
|
|
1390
|
+
kind: "checkpoint",
|
|
1391
|
+
content: `Forge completion tool validation passed for ${artifact.phase ?? "unknown"} (${artifact.status}).`,
|
|
1392
|
+
eventType: "completion_tool_validation_passed",
|
|
1393
|
+
data: JSON.stringify({
|
|
1394
|
+
phase: artifact.phase ?? "unknown",
|
|
1395
|
+
status: artifact.status,
|
|
1396
|
+
artifact_relative_paths: [relativePath.split(path.sep).join("/")]
|
|
1397
|
+
})
|
|
1398
|
+
});
|
|
843
1399
|
await emit({
|
|
844
1400
|
role: "tool",
|
|
845
1401
|
kind: "tool_result",
|
|
846
1402
|
content: `Forge completion tool recorded ${artifact.phase ?? "unknown"} artifact ${relativePath} (${artifact.status}).`,
|
|
847
|
-
eventType: "completion_tool_artifact_recorded"
|
|
1403
|
+
eventType: "completion_tool_artifact_recorded",
|
|
1404
|
+
data: JSON.stringify({
|
|
1405
|
+
phase: artifact.phase ?? "unknown",
|
|
1406
|
+
status: artifact.status,
|
|
1407
|
+
artifact_relative_paths: [relativePath.split(path.sep).join("/")]
|
|
1408
|
+
})
|
|
848
1409
|
});
|
|
849
1410
|
}
|
|
850
1411
|
async function emitPhaseCompletionEvent(emit, artifact) {
|
|
@@ -872,7 +1433,15 @@ async function emitPhaseGateEvents(emit, artifact) {
|
|
|
872
1433
|
? "phase_progression: implementation -> self_review"
|
|
873
1434
|
: phase === "self_review"
|
|
874
1435
|
? "phase_progression: self_review -> validation"
|
|
875
|
-
:
|
|
1436
|
+
: phase === "debug_repro"
|
|
1437
|
+
? "phase_progression: debug_repro -> debug_root_cause"
|
|
1438
|
+
: phase === "debug_root_cause"
|
|
1439
|
+
? "phase_progression: debug_root_cause -> debug_fix"
|
|
1440
|
+
: phase === "debug_fix"
|
|
1441
|
+
? "phase_progression: debug_fix -> debug_regression"
|
|
1442
|
+
: phase === "debug_regression"
|
|
1443
|
+
? "phase_progression: debug_regression -> QA ready"
|
|
1444
|
+
: "phase_progression: validation -> QA ready";
|
|
876
1445
|
await emit({
|
|
877
1446
|
role: "hook",
|
|
878
1447
|
kind: "checkpoint",
|
|
@@ -883,6 +1452,7 @@ async function emitPhaseGateEvents(emit, artifact) {
|
|
|
883
1452
|
function completionPhaseFromArtifact(artifact) {
|
|
884
1453
|
const phase = artifact.phase?.trim();
|
|
885
1454
|
return phase === "self_review" || phase === "validation" || phase === "implementation" || phase === "qa"
|
|
1455
|
+
|| phase === "architect" || phase === "debug_repro" || phase === "debug_root_cause" || phase === "debug_fix" || phase === "debug_regression"
|
|
886
1456
|
? phase
|
|
887
1457
|
: "implementation";
|
|
888
1458
|
}
|
|
@@ -894,13 +1464,18 @@ class LiveCompletionArtifactEmitter {
|
|
|
894
1464
|
emitted = new Set();
|
|
895
1465
|
timer = null;
|
|
896
1466
|
pending = Promise.resolve();
|
|
897
|
-
constructor(worktreePath, workItemId, phase, developCycle, emit) {
|
|
1467
|
+
constructor(worktreePath, workItemId, phase, developCycle, debugCycle, emit) {
|
|
898
1468
|
this.worktreePath = worktreePath;
|
|
899
1469
|
this.workItemId = workItemId;
|
|
900
1470
|
this.emit = emit;
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1471
|
+
if (debugCycle && isDebugCompletionPhaseForProvider(phase)) {
|
|
1472
|
+
this.phases = debugCompletionPhaseOrder.slice(debugCompletionPhaseOrder.indexOf(phase));
|
|
1473
|
+
}
|
|
1474
|
+
else {
|
|
1475
|
+
this.phases = developCycle
|
|
1476
|
+
? developCyclePhaseOrder.slice(developCyclePhaseOrder.indexOf(phase))
|
|
1477
|
+
: [phase];
|
|
1478
|
+
}
|
|
904
1479
|
}
|
|
905
1480
|
start() {
|
|
906
1481
|
void this.pollQueued();
|
|
@@ -943,20 +1518,23 @@ class LiveCompletionArtifactEmitter {
|
|
|
943
1518
|
}
|
|
944
1519
|
}
|
|
945
1520
|
}
|
|
946
|
-
async function readCompletionArtifactsForRun(worktreePath, workItemId, phase, developCycle) {
|
|
947
|
-
if (!developCycle) {
|
|
1521
|
+
async function readCompletionArtifactsForRun(worktreePath, workItemId, phase, developCycle, debugCycle) {
|
|
1522
|
+
if (!developCycle && !debugCycle) {
|
|
948
1523
|
const artifact = await readCompletionArtifact(worktreePath, workItemId, phase);
|
|
949
1524
|
if (!artifact) {
|
|
950
1525
|
throw new Error(`Provider CLI completed but did not call the Forge ${phase} completion tool.`);
|
|
951
1526
|
}
|
|
952
1527
|
return [artifact];
|
|
953
1528
|
}
|
|
1529
|
+
const expectedPhases = debugCycle && isDebugCompletionPhaseForProvider(phase)
|
|
1530
|
+
? debugCompletionPhaseOrder.slice(debugCompletionPhaseOrder.indexOf(phase))
|
|
1531
|
+
: ["implementation", "self_review", "validation"];
|
|
954
1532
|
const artifacts = [];
|
|
955
|
-
for (const expectedPhase of
|
|
1533
|
+
for (const expectedPhase of expectedPhases) {
|
|
956
1534
|
const artifact = await readCompletionArtifact(worktreePath, workItemId, expectedPhase);
|
|
957
1535
|
if (!artifact) {
|
|
958
1536
|
if (artifacts.length === 0) {
|
|
959
|
-
throw new Error(
|
|
1537
|
+
throw new Error(`Provider CLI completed but did not call the Forge ${expectedPhase} completion tool.`);
|
|
960
1538
|
}
|
|
961
1539
|
const previous = artifacts[artifacts.length - 1];
|
|
962
1540
|
if ((previous.status ?? "").trim().toLowerCase() === "pass") {
|
|
@@ -971,10 +1549,24 @@ async function readCompletionArtifactsForRun(worktreePath, workItemId, phase, de
|
|
|
971
1549
|
}
|
|
972
1550
|
return artifacts;
|
|
973
1551
|
}
|
|
974
|
-
function
|
|
975
|
-
const env =
|
|
1552
|
+
function providerProcessEnv(dispatch) {
|
|
1553
|
+
const env = {
|
|
1554
|
+
...completionRedactionEnv(exactRedactionSecrets(dispatch)),
|
|
1555
|
+
...providerCredentialEnv(dispatch)
|
|
1556
|
+
};
|
|
976
1557
|
return Object.keys(env).length > 0 ? env : undefined;
|
|
977
1558
|
}
|
|
1559
|
+
function providerCredentialEnv(dispatch) {
|
|
1560
|
+
const secret = dispatch.apiKeySecretGrant?.secret;
|
|
1561
|
+
if (!secret || !dispatch.execution.requiresApiKeySecretGrant) {
|
|
1562
|
+
return {};
|
|
1563
|
+
}
|
|
1564
|
+
const provider = dispatch.execution.provider.trim().toLowerCase();
|
|
1565
|
+
if (provider === "codex" || provider === "openai") {
|
|
1566
|
+
return { OPENAI_API_KEY: secret };
|
|
1567
|
+
}
|
|
1568
|
+
return {};
|
|
1569
|
+
}
|
|
978
1570
|
function exactRedactionSecrets(dispatch) {
|
|
979
1571
|
return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
|
|
980
1572
|
}
|
|
@@ -997,7 +1589,7 @@ export class StreamingProviderProcessRunner {
|
|
|
997
1589
|
return await new Promise((resolve, reject) => {
|
|
998
1590
|
const child = spawn(launch.command, launch.args, {
|
|
999
1591
|
cwd: request.cwd,
|
|
1000
|
-
env: request.env
|
|
1592
|
+
env: buildProviderChildEnvironment(request.env),
|
|
1001
1593
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1002
1594
|
windowsHide: true
|
|
1003
1595
|
});
|
|
@@ -1132,9 +1724,6 @@ function quoteWindowsCommandLineArg(value) {
|
|
|
1132
1724
|
}
|
|
1133
1725
|
return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1")}"`;
|
|
1134
1726
|
}
|
|
1135
|
-
async function fileExists(inputPath) {
|
|
1136
|
-
return fs.stat(inputPath).then((stat) => stat.isFile()).catch(() => false);
|
|
1137
|
-
}
|
|
1138
1727
|
async function readProcessLines(stream, onLine) {
|
|
1139
1728
|
const reader = readline.createInterface({ input: stream });
|
|
1140
1729
|
try {
|
|
@@ -1155,9 +1744,6 @@ function sha256(content) {
|
|
|
1155
1744
|
export function formatProviderCliCommand(command) {
|
|
1156
1745
|
return [command.command, ...command.args].map(singleLine).join(" ");
|
|
1157
1746
|
}
|
|
1158
|
-
function yamlQuotedScalar(value) {
|
|
1159
|
-
return JSON.stringify(singleLine(value));
|
|
1160
|
-
}
|
|
1161
1747
|
function singleLine(value) {
|
|
1162
1748
|
return value.replace(/\s+/g, " ").trim();
|
|
1163
1749
|
}
|
|
@@ -1166,13 +1752,13 @@ export function buildProviderCliCommand(dispatch, run) {
|
|
|
1166
1752
|
const normalizedProvider = provider.toLowerCase();
|
|
1167
1753
|
const model = dispatch.execution.model.trim();
|
|
1168
1754
|
if (normalizedProvider === "codex") {
|
|
1169
|
-
const args = ["exec"];
|
|
1755
|
+
const args = run ? ["-s", "workspace-write", "-a", "never", "exec"] : ["exec"];
|
|
1170
1756
|
if (run?.completionTools) {
|
|
1171
1757
|
args.push(...codexMcpConfigArgs(run.completionTools));
|
|
1172
1758
|
}
|
|
1173
1759
|
args.push("--model", model);
|
|
1174
1760
|
if (run) {
|
|
1175
|
-
args.push("--json"
|
|
1761
|
+
args.push("--json");
|
|
1176
1762
|
if (run.worktreePath) {
|
|
1177
1763
|
args.push("--cd", run.worktreePath);
|
|
1178
1764
|
}
|
|
@@ -1196,6 +1782,31 @@ export function buildProviderCliCommand(dispatch, run) {
|
|
|
1196
1782
|
}
|
|
1197
1783
|
return { command: provider, args };
|
|
1198
1784
|
}
|
|
1785
|
+
function buildArchitectPrompt(dispatch) {
|
|
1786
|
+
return `You are Forge's Architect lane running locally in a prepared git worktree.
|
|
1787
|
+
|
|
1788
|
+
Work item: ${dispatch.workItem.id} — ${dispatch.workItem.title}
|
|
1789
|
+
|
|
1790
|
+
Plan only. Do not implement source changes, do not create Develop phase artifacts, and do not mark the spec approved.
|
|
1791
|
+
Read injected Forge context under .forge plus the repository shape, then produce a repo-grounded implementation DAG with task tests, phase gates, final QA coverage, and a pending-approval spec.
|
|
1792
|
+
${architectRevisionInstruction(dispatch)}
|
|
1793
|
+
Forge owns protocol files: write Architect artifacts only through the completion MCP tool.
|
|
1794
|
+
|
|
1795
|
+
${completionPromptForPhase("architect")}
|
|
1796
|
+
|
|
1797
|
+
Finish only after calling forge.complete_architect_plan with implementation_plan, qa_plan, status, summary, and report/report_markdown.`;
|
|
1798
|
+
}
|
|
1799
|
+
function architectRevisionInstruction(dispatch) {
|
|
1800
|
+
if (!dispatch.architectRevision) {
|
|
1801
|
+
return "";
|
|
1802
|
+
}
|
|
1803
|
+
return `
|
|
1804
|
+
Architect revision context:
|
|
1805
|
+
- Previous plan id: ${dispatch.architectRevision.priorPlanId}
|
|
1806
|
+
- Previous content hash: ${dispatch.architectRevision.priorContentHash}
|
|
1807
|
+
- Reviewer feedback: ${dispatch.architectRevision.feedback}
|
|
1808
|
+
Revise the prior plan in response to the feedback. Preserve accepted scope and only change what the feedback requires.`;
|
|
1809
|
+
}
|
|
1199
1810
|
function buildImplementationPrompt(dispatch, phase = completionPhaseFromDispatch(dispatch)) {
|
|
1200
1811
|
return `You are Forge's ${phase.replace(/_/g, " ")} phase runner running locally in a prepared git worktree.
|
|
1201
1812
|
|
|
@@ -1224,6 +1835,20 @@ ${completionPromptForDevelopCycle(phase)}
|
|
|
1224
1835
|
|
|
1225
1836
|
Finish with a concise summary of changed files and verification after the validation completion tool has accepted the work, or after the current phase tool records a non-pass blocker.`;
|
|
1226
1837
|
}
|
|
1838
|
+
function buildDebugCyclePrompt(dispatch, phase = completionPhaseFromDispatch(dispatch)) {
|
|
1839
|
+
return `You are Forge's Debug lane running locally in a prepared git worktree for a bug work item.
|
|
1840
|
+
|
|
1841
|
+
Read .forge/work-items/${dispatch.workItem.id}/spec.md, reproduce or characterize the bug, isolate root cause, apply the smallest safe fix, then prove the regression in this same provider session.
|
|
1842
|
+
Start in the ${phase.replace(/_/g, " ")} gate. Do not stop after a passing Debug gate; continue through the next Debug MCP instruction returned by the tool server until debug_regression is accepted or the current gate records a non-pass blocker.
|
|
1843
|
+
${debugQaReworkInstruction(dispatch)}
|
|
1844
|
+
Use the repository's existing conventions. Run focused tests when practical, especially the original repro and required regressions.
|
|
1845
|
+
Forge owns protocol files: worktree prep, injected .forge commands/hooks/skills, Debug verdict artifacts, reports, and commits.
|
|
1846
|
+
Read Forge-provided context under .forge when needed, but do not edit .forge files or create Forge Debug artifacts by hand.
|
|
1847
|
+
|
|
1848
|
+
${completionPromptForDebugCycle(isDebugCompletionPhaseForProvider(phase) ? phase : "debug_repro")}
|
|
1849
|
+
|
|
1850
|
+
Finish with a concise summary of changed files and verification after the debug_regression completion tool has accepted the work, or after the current Debug gate tool records a non-pass blocker.`;
|
|
1851
|
+
}
|
|
1227
1852
|
function qaReworkImplementationInstruction(dispatch) {
|
|
1228
1853
|
if (!dispatch.qaRework) {
|
|
1229
1854
|
return "";
|
|
@@ -1233,7 +1858,20 @@ QA rework context:
|
|
|
1233
1858
|
- Attempt ${dispatch.qaRework.attempt}/${dispatch.qaRework.maxAttempts}
|
|
1234
1859
|
- Rework packet: ${dispatch.qaRework.packetPath}
|
|
1235
1860
|
- Required QA finding IDs: ${dispatch.qaRework.findingIds.join(", ")}
|
|
1236
|
-
Read the packet JSON and fix or explicitly dispute each listed finding. The implementation completion artifact must include resolved_findings entries for every required QA ID with qa_id, status, summary, evidence, changed_files, tests_added_or_updated, and regression_evidence
|
|
1861
|
+
Read the packet JSON and fix or explicitly dispute each listed finding. The implementation completion artifact must include resolved_findings entries for every required QA ID with qa_id, status, summary, evidence, changed_files, tests_added_or_updated, and regression_evidence.
|
|
1862
|
+
Use only these resolved_findings status values: resolved, disputed, or not_reproduced. Use resolved when the finding was fixed; do not use fixed.`;
|
|
1863
|
+
}
|
|
1864
|
+
function debugQaReworkInstruction(dispatch) {
|
|
1865
|
+
if (!dispatch.qaRework) {
|
|
1866
|
+
return "";
|
|
1867
|
+
}
|
|
1868
|
+
return `
|
|
1869
|
+
QA rework context for Debug:
|
|
1870
|
+
- Attempt ${dispatch.qaRework.attempt}/${dispatch.qaRework.maxAttempts}
|
|
1871
|
+
- Rework packet: ${dispatch.qaRework.packetPath}
|
|
1872
|
+
- Required QA finding IDs: ${dispatch.qaRework.findingIds.join(", ")}
|
|
1873
|
+
Read the packet JSON, reproduce or characterize each listed finding, fix or explicitly dispute it, and include resolved_findings entries for every required QA ID in forge.complete_debug_fix with qa_id, status, summary, evidence, changed_files, tests_added_or_updated, and regression_evidence.
|
|
1874
|
+
Use only these resolved_findings status values: resolved, disputed, or not_reproduced. Use resolved when the finding was fixed; do not use fixed.`;
|
|
1237
1875
|
}
|
|
1238
1876
|
function qaReworkRetestInstruction(dispatch) {
|
|
1239
1877
|
if (!dispatch.qaRework) {
|
|
@@ -1270,9 +1908,49 @@ function qaBrowserInstruction(dispatch) {
|
|
|
1270
1908
|
const routes = launchTarget.routes.length > 0 ? launchTarget.routes.join(", ") : "not specified";
|
|
1271
1909
|
return `Browser/app launch context:\n- Browser QA required: ${qaBrowser.required ? "yes" : "no, unless the diff/AC requires it"}\n- URL: ${launchTarget.url || "not specified"}\n- Ready URL: ${launchTarget.ready_url || "not specified"}\n- Launch command: ${launchTarget.command || "not specified"}\n- Working directory: ${launchTarget.working_directory || "repo root"}\n- Routes to cover: ${routes}\n- Notes: ${launchTarget.notes || "none"}\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
|
|
1272
1910
|
}
|
|
1273
|
-
function
|
|
1911
|
+
function storyQaInstruction(dispatch) {
|
|
1912
|
+
const storyQa = dispatch.storyQa;
|
|
1913
|
+
if (!storyQa) {
|
|
1914
|
+
return "";
|
|
1915
|
+
}
|
|
1916
|
+
const childLines = storyQa.childTasks.map((child) => {
|
|
1917
|
+
const dependencies = child.dependencyWorkItemIds.length > 0
|
|
1918
|
+
? child.dependencyWorkItemIds.join(", ")
|
|
1919
|
+
: "none";
|
|
1920
|
+
const validation = child.validationArtifact
|
|
1921
|
+
? `${child.validationArtifact.status} (${child.validationArtifact.path || "artifact path unavailable"})`
|
|
1922
|
+
: "missing";
|
|
1923
|
+
const childQa = child.qaArtifact
|
|
1924
|
+
? `${child.qaArtifact.status} (${child.qaArtifact.path || "artifact path unavailable"})`
|
|
1925
|
+
: "not present";
|
|
1926
|
+
return `- ${child.title} (${child.workItemId}, architect ${child.architectTaskId}, phase ${child.phaseId}/${child.phaseOrder}); deps: ${dependencies}; validation: ${validation}; child QA: ${childQa}`;
|
|
1927
|
+
}).join("\n");
|
|
1928
|
+
const finalQa = storyQa.finalQaChecks.length > 0
|
|
1929
|
+
? storyQa.finalQaChecks.map((check) => `- ${check}`).join("\n")
|
|
1930
|
+
: "- No final QA checks were provided in qa.plan.final_qa.";
|
|
1931
|
+
return `Story QA integration context:
|
|
1932
|
+
- Story work item: ${storyQa.storyWorkItemId}
|
|
1933
|
+
- Plan: ${storyQa.planId} / ${storyQa.planContentHash}
|
|
1934
|
+
- Story worktree: ${storyQa.worktreeKey}
|
|
1935
|
+
- Branch: ${storyQa.branch}
|
|
1936
|
+
- Architect implementation.plan.json provided: ${storyQa.implementationPlanJson ? "yes" : "no"}
|
|
1937
|
+
- Architect qa.plan.json provided: ${storyQa.qaPlanJson ? "yes" : "no"}
|
|
1938
|
+
Materialized child tasks and evidence:
|
|
1939
|
+
${childLines || "- No child tasks were provided."}
|
|
1940
|
+
Architect final QA checks:
|
|
1941
|
+
${finalQa}
|
|
1942
|
+
This is parent Story QA: inspect the integrated Story result, all current-plan child specs/evidence, and the final worktree state. Do not treat the final child task as separately signed off.`;
|
|
1943
|
+
}
|
|
1944
|
+
function buildQaPrompt(dispatch, laneSpec) {
|
|
1274
1945
|
return `You are Forge's QA lane running locally in a prepared git worktree.
|
|
1275
1946
|
|
|
1947
|
+
First read and follow the injected QA lane command at .forge/work-items/${dispatch.workItem.id}/spec.md. It defines the required gated QA skill workflow for this run.
|
|
1948
|
+
|
|
1949
|
+
Injected QA lane command and work item spec:
|
|
1950
|
+
${laneSpec}
|
|
1951
|
+
|
|
1952
|
+
---
|
|
1953
|
+
|
|
1276
1954
|
Work item: ${dispatch.workItem.id}
|
|
1277
1955
|
Title: ${dispatch.workItem.title}
|
|
1278
1956
|
Description:
|
|
@@ -1287,6 +1965,7 @@ ${dispatch.workItem.reproSteps || "Not provided."}
|
|
|
1287
1965
|
System info / environment notes:
|
|
1288
1966
|
${dispatch.workItem.systemInfo || "Not provided."}
|
|
1289
1967
|
|
|
1968
|
+
${storyQaInstruction(dispatch)}
|
|
1290
1969
|
Inspect the validated implementation and Forge artifacts under .forge/work-items/${dispatch.workItem.id}/artifacts/ when present, especially implementation, self-review, and validation evidence.
|
|
1291
1970
|
${qaBrowserInstruction(dispatch)}
|
|
1292
1971
|
${qaReworkRetestInstruction(dispatch)}
|
|
@@ -1295,9 +1974,9 @@ Record concrete evidence. For UI/browser work, include browser_checks with URLs/
|
|
|
1295
1974
|
Report in-scope defects as actionable findings with repro steps, expected/actual behavior, evidence, likely area, required fix, and required regression.
|
|
1296
1975
|
Out-of-scope or follow-up issues belong in candidate_bugs instead of blocking the current item.
|
|
1297
1976
|
|
|
1298
|
-
${
|
|
1977
|
+
${completionPromptForQaCycle()}
|
|
1299
1978
|
|
|
1300
|
-
Finish with a concise summary only after forge.
|
|
1979
|
+
Finish with a concise summary only after forge.complete_qa_verdict accepts the QA bundle.`;
|
|
1301
1980
|
}
|
|
1302
1981
|
function buildConflictResolvePrompt(dispatch, branchName) {
|
|
1303
1982
|
const targetBranch = dispatch.cycleRun.requestedBase || dispatch.repo.defaultBranch || "main";
|
|
@@ -1312,6 +1991,34 @@ Keep the original work item intent intact:
|
|
|
1312
1991
|
|
|
1313
1992
|
Finish with a concise summary of the conflicts resolved and verification performed.`;
|
|
1314
1993
|
}
|
|
1994
|
+
function buildPullRequestCommentsPrompt(dispatch, branchName) {
|
|
1995
|
+
const pr = dispatch.pullRequest;
|
|
1996
|
+
const targetBranch = pr?.targetBranch || dispatch.cycleRun.requestedBase || dispatch.repo.defaultBranch || "main";
|
|
1997
|
+
const prLocator = pr
|
|
1998
|
+
? `PR #${pr.number}${pr.url ? ` (${pr.url})` : ""}`
|
|
1999
|
+
: "the open pull request for this work item";
|
|
2000
|
+
return `You are Forge's Pull Request Comments lane running locally in a prepared git worktree.
|
|
2001
|
+
|
|
2002
|
+
The current branch is the pull request branch ${branchName}. Address unresolved review comments on ${prLocator}.
|
|
2003
|
+
PR context:
|
|
2004
|
+
- Provider: ${pr?.provider || "not provided"}
|
|
2005
|
+
- Source branch: ${pr?.sourceBranch || branchName}
|
|
2006
|
+
- Target branch: ${targetBranch}
|
|
2007
|
+
- Unresolved comments: ${pr?.unresolvedThreads ?? "unknown"}
|
|
2008
|
+
|
|
2009
|
+
Use the PR URL, local git metadata, and available GitHub tooling/auth to inspect unresolved review threads. Make focused code or test changes that answer the feedback, commit the changes, and push ${branchName} to origin.
|
|
2010
|
+
After the push succeeds, Forge will resolve the addressed review threads through the pull request API.
|
|
2011
|
+
If the review thread contents are unavailable, stop with a clear summary of what could not be accessed instead of guessing.
|
|
2012
|
+
Keep the original work item intent intact:
|
|
2013
|
+
- Work item: ${dispatch.workItem.id}
|
|
2014
|
+
- Title: ${dispatch.workItem.title}
|
|
2015
|
+
- Acceptance criteria: ${dispatch.workItem.acceptanceCriteria || "Not specified"}
|
|
2016
|
+
|
|
2017
|
+
Finish with a concise summary of comments addressed and verification performed.`;
|
|
2018
|
+
}
|
|
2019
|
+
function isPullRequestCommentsDispatch(dispatch) {
|
|
2020
|
+
return dispatch.cycleRun.kind.trim().toLowerCase() === "pr_comments";
|
|
2021
|
+
}
|
|
1315
2022
|
async function cleanupCompletionTools(completionTools, emit) {
|
|
1316
2023
|
try {
|
|
1317
2024
|
await completionTools.cleanup();
|
|
@@ -1369,31 +2076,31 @@ class CursorStreamJsonState {
|
|
|
1369
2076
|
consume(line) {
|
|
1370
2077
|
const event = parseJsonLine(line);
|
|
1371
2078
|
if (!event) {
|
|
1372
|
-
return
|
|
2079
|
+
return [];
|
|
1373
2080
|
}
|
|
1374
2081
|
const type = stringField(event, "type");
|
|
1375
2082
|
if (type === "system" && stringField(event, "subtype") === "init") {
|
|
1376
2083
|
this.sessionId = stringField(event, "session_id");
|
|
1377
2084
|
if (this.sessionId && !this.emittedSession) {
|
|
1378
2085
|
this.emittedSession = true;
|
|
1379
|
-
return {
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
2086
|
+
return [{
|
|
2087
|
+
role: "runner",
|
|
2088
|
+
kind: "checkpoint",
|
|
2089
|
+
content: `Cursor session started: ${this.sessionId}.`,
|
|
2090
|
+
eventType: "provider_thread_started"
|
|
2091
|
+
}];
|
|
1385
2092
|
}
|
|
1386
2093
|
}
|
|
1387
2094
|
if (type === "assistant") {
|
|
1388
2095
|
const text = assistantMessageText(event);
|
|
1389
2096
|
if (text) {
|
|
1390
2097
|
this.finalMessage = clip(text, 4000);
|
|
1391
|
-
return {
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
2098
|
+
return [{
|
|
2099
|
+
role: "assistant",
|
|
2100
|
+
kind: "message",
|
|
2101
|
+
content: this.finalMessage,
|
|
2102
|
+
eventType: "provider_message"
|
|
2103
|
+
}];
|
|
1397
2104
|
}
|
|
1398
2105
|
}
|
|
1399
2106
|
if (type === "tool_call") {
|
|
@@ -1402,23 +2109,23 @@ class CursorStreamJsonState {
|
|
|
1402
2109
|
if (subtype === "started") {
|
|
1403
2110
|
const summary = formatCursorToolCallStarted(toolCall);
|
|
1404
2111
|
if (summary) {
|
|
1405
|
-
return {
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
2112
|
+
return [{
|
|
2113
|
+
role: "worker",
|
|
2114
|
+
kind: "checkpoint",
|
|
2115
|
+
content: summary,
|
|
2116
|
+
eventType: "provider_tool_call"
|
|
2117
|
+
}];
|
|
1411
2118
|
}
|
|
1412
2119
|
}
|
|
1413
2120
|
if (subtype === "completed") {
|
|
1414
2121
|
const completed = formatCursorToolCallCompleted(toolCall);
|
|
1415
2122
|
if (completed) {
|
|
1416
|
-
return {
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
2123
|
+
return [{
|
|
2124
|
+
role: "worker",
|
|
2125
|
+
kind: completed.kind,
|
|
2126
|
+
content: completed.content,
|
|
2127
|
+
eventType: completed.kind === "file_edit" ? "provider_file_edit" : "provider_tool_call"
|
|
2128
|
+
}];
|
|
1422
2129
|
}
|
|
1423
2130
|
}
|
|
1424
2131
|
}
|
|
@@ -1430,14 +2137,14 @@ class CursorStreamJsonState {
|
|
|
1430
2137
|
this.accumulateUsage(event);
|
|
1431
2138
|
}
|
|
1432
2139
|
if (type === "error" || (type === "result" && event.is_error === true)) {
|
|
1433
|
-
return {
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
2140
|
+
return [{
|
|
2141
|
+
role: "runner",
|
|
2142
|
+
kind: "error",
|
|
2143
|
+
content: clip(line.trim(), 1000),
|
|
2144
|
+
eventType: "provider_cli_error"
|
|
2145
|
+
}];
|
|
1439
2146
|
}
|
|
1440
|
-
return
|
|
2147
|
+
return [];
|
|
1441
2148
|
}
|
|
1442
2149
|
accumulateUsage(event) {
|
|
1443
2150
|
const usage = objectField(event, "usage");
|
|
@@ -1568,37 +2275,37 @@ class CodexJsonState {
|
|
|
1568
2275
|
consume(line) {
|
|
1569
2276
|
const event = parseJsonLine(line);
|
|
1570
2277
|
if (!event) {
|
|
1571
|
-
return
|
|
2278
|
+
return [];
|
|
1572
2279
|
}
|
|
1573
2280
|
const type = stringField(event, "type") ?? "";
|
|
1574
2281
|
if (type === "thread.started") {
|
|
1575
2282
|
this.threadId = stringField(event, "thread_id");
|
|
1576
2283
|
if (this.threadId && !this.emittedThread) {
|
|
1577
2284
|
this.emittedThread = true;
|
|
1578
|
-
return {
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
2285
|
+
return [{
|
|
2286
|
+
role: "runner",
|
|
2287
|
+
kind: "checkpoint",
|
|
2288
|
+
content: `Codex thread started: ${this.threadId}.`,
|
|
2289
|
+
eventType: "provider_thread_started"
|
|
2290
|
+
}];
|
|
1584
2291
|
}
|
|
1585
2292
|
}
|
|
1586
2293
|
if (type === "turn.completed") {
|
|
1587
2294
|
this.accumulateUsage(event);
|
|
1588
|
-
return {
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
2295
|
+
return [{
|
|
2296
|
+
role: "runner",
|
|
2297
|
+
kind: "checkpoint",
|
|
2298
|
+
content: "Codex turn completed.",
|
|
2299
|
+
eventType: "provider_turn_completed"
|
|
2300
|
+
}];
|
|
1594
2301
|
}
|
|
1595
2302
|
if (type === "turn.failed" || type === "error") {
|
|
1596
|
-
return {
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
2303
|
+
return [{
|
|
2304
|
+
role: "runner",
|
|
2305
|
+
kind: "error",
|
|
2306
|
+
content: clip(line.trim(), 1000),
|
|
2307
|
+
eventType: "provider_cli_error"
|
|
2308
|
+
}];
|
|
1602
2309
|
}
|
|
1603
2310
|
if (type === "item.completed" || type === "item.updated") {
|
|
1604
2311
|
const item = objectField(event, "item");
|
|
@@ -1607,28 +2314,32 @@ class CodexJsonState {
|
|
|
1607
2314
|
const text = stringField(item, "text");
|
|
1608
2315
|
if (text) {
|
|
1609
2316
|
this.finalMessage = clip(text, 4000);
|
|
1610
|
-
return {
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
2317
|
+
return [{
|
|
2318
|
+
role: "assistant",
|
|
2319
|
+
kind: "message",
|
|
2320
|
+
content: this.finalMessage,
|
|
2321
|
+
eventType: "provider_message"
|
|
2322
|
+
}];
|
|
1616
2323
|
}
|
|
1617
2324
|
}
|
|
1618
2325
|
if (itemType === "command_execution") {
|
|
1619
2326
|
const command = stringField(item, "command");
|
|
1620
2327
|
const status = stringField(item, "status");
|
|
1621
2328
|
if (command) {
|
|
1622
|
-
return {
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
2329
|
+
return [{
|
|
2330
|
+
role: "worker",
|
|
2331
|
+
kind: "checkpoint",
|
|
2332
|
+
content: `Codex command ${status || "completed"}: ${clip(command, 500)}`,
|
|
2333
|
+
eventType: "provider_command"
|
|
2334
|
+
}];
|
|
1628
2335
|
}
|
|
1629
2336
|
}
|
|
1630
2337
|
}
|
|
1631
|
-
|
|
2338
|
+
const mcpEvents = formatCodexMcpToolCallEvents(type, event);
|
|
2339
|
+
if (mcpEvents.length > 0) {
|
|
2340
|
+
return mcpEvents;
|
|
2341
|
+
}
|
|
2342
|
+
return [];
|
|
1632
2343
|
}
|
|
1633
2344
|
accumulateUsage(event) {
|
|
1634
2345
|
const usage = objectField(event, "usage");
|
|
@@ -1644,6 +2355,113 @@ class CodexJsonState {
|
|
|
1644
2355
|
});
|
|
1645
2356
|
}
|
|
1646
2357
|
}
|
|
2358
|
+
function formatCodexMcpToolCallEvents(type, event) {
|
|
2359
|
+
if (type !== "item.started" && type !== "item.updated" && type !== "item.completed") {
|
|
2360
|
+
return [];
|
|
2361
|
+
}
|
|
2362
|
+
const item = objectField(event, "item");
|
|
2363
|
+
const itemType = stringField(item, "type") ?? "";
|
|
2364
|
+
const serverName = mcpServerName(item);
|
|
2365
|
+
const toolName = mcpToolName(item);
|
|
2366
|
+
const isMcpItem = itemType.toLowerCase().includes("mcp")
|
|
2367
|
+
|| Boolean(serverName)
|
|
2368
|
+
|| (toolName ? isForgeCompletionTool(toolName) || toolName.toLowerCase().includes("mcp") : false);
|
|
2369
|
+
if (!isMcpItem || !toolName) {
|
|
2370
|
+
return [];
|
|
2371
|
+
}
|
|
2372
|
+
const status = normalizedString(stringField(item, "status")
|
|
2373
|
+
?? stringField(event, "status")
|
|
2374
|
+
?? (type === "item.started" ? "started" : type === "item.completed" ? "completed" : "updated"));
|
|
2375
|
+
const failed = status === "failed" || status === "error" || status === "cancelled" || Boolean(item.error);
|
|
2376
|
+
const completed = failed || type === "item.completed" || status === "completed" || status === "succeeded" || status === "success";
|
|
2377
|
+
const started = type === "item.started" || status === "started" || status === "running" || status === "in_progress";
|
|
2378
|
+
const data = safeProviderMcpData(item, serverName ?? "unknown", toolName, status);
|
|
2379
|
+
const label = `${serverName ?? "unknown"}.${toolName}`;
|
|
2380
|
+
if (failed) {
|
|
2381
|
+
const summary = mcpErrorSummary(item) ?? "MCP tool call failed.";
|
|
2382
|
+
const events = [{
|
|
2383
|
+
role: "tool",
|
|
2384
|
+
kind: "error",
|
|
2385
|
+
content: `MCP tool call failed: ${label} - ${summary}`,
|
|
2386
|
+
eventType: "provider_mcp_call_failed",
|
|
2387
|
+
data
|
|
2388
|
+
}];
|
|
2389
|
+
if (isForgeCompletionTool(toolName)) {
|
|
2390
|
+
events.push({
|
|
2391
|
+
role: "tool",
|
|
2392
|
+
kind: "error",
|
|
2393
|
+
content: `Forge completion tool validation failed: ${label} - ${summary}`,
|
|
2394
|
+
eventType: "completion_tool_validation_failed",
|
|
2395
|
+
data
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
return events;
|
|
2399
|
+
}
|
|
2400
|
+
if (completed) {
|
|
2401
|
+
return [{
|
|
2402
|
+
role: "tool",
|
|
2403
|
+
kind: "checkpoint",
|
|
2404
|
+
content: `MCP tool call completed: ${label}.`,
|
|
2405
|
+
eventType: "provider_mcp_call_completed",
|
|
2406
|
+
data
|
|
2407
|
+
}];
|
|
2408
|
+
}
|
|
2409
|
+
if (started) {
|
|
2410
|
+
return [{
|
|
2411
|
+
role: "tool",
|
|
2412
|
+
kind: "checkpoint",
|
|
2413
|
+
content: `MCP tool call started: ${label}.`,
|
|
2414
|
+
eventType: "provider_mcp_call_started",
|
|
2415
|
+
data
|
|
2416
|
+
}];
|
|
2417
|
+
}
|
|
2418
|
+
return [];
|
|
2419
|
+
}
|
|
2420
|
+
function mcpServerName(item) {
|
|
2421
|
+
return stringField(item, "server")
|
|
2422
|
+
?? stringField(item, "server_name")
|
|
2423
|
+
?? stringField(item, "mcp_server")
|
|
2424
|
+
?? stringField(objectField(item, "mcp"), "server")
|
|
2425
|
+
?? stringField(objectField(item, "mcp"), "server_name");
|
|
2426
|
+
}
|
|
2427
|
+
function mcpToolName(item) {
|
|
2428
|
+
return stringField(item, "tool")
|
|
2429
|
+
?? stringField(item, "tool_name")
|
|
2430
|
+
?? stringField(item, "name")
|
|
2431
|
+
?? stringField(objectField(item, "function"), "name")
|
|
2432
|
+
?? stringField(objectField(item, "mcp"), "tool")
|
|
2433
|
+
?? stringField(objectField(item, "mcp"), "tool_name");
|
|
2434
|
+
}
|
|
2435
|
+
function mcpErrorSummary(item) {
|
|
2436
|
+
const direct = stringField(item, "error")
|
|
2437
|
+
?? stringField(item, "error_message")
|
|
2438
|
+
?? stringField(item, "message");
|
|
2439
|
+
if (direct) {
|
|
2440
|
+
return clip(direct, 600);
|
|
2441
|
+
}
|
|
2442
|
+
const error = objectField(item, "error");
|
|
2443
|
+
return stringField(error, "message")
|
|
2444
|
+
?? stringField(error, "summary")
|
|
2445
|
+
?? stringField(objectField(item, "result"), "error")
|
|
2446
|
+
?? null;
|
|
2447
|
+
}
|
|
2448
|
+
function safeProviderMcpData(item, serverName, toolName, status) {
|
|
2449
|
+
const elapsedMs = numericField(item, "elapsed_ms") ?? numericField(item, "duration_ms");
|
|
2450
|
+
return JSON.stringify(stripUndefined({
|
|
2451
|
+
server_name: serverName,
|
|
2452
|
+
tool_name: toolName,
|
|
2453
|
+
status,
|
|
2454
|
+
elapsed_ms: elapsedMs,
|
|
2455
|
+
error_summary: mcpErrorSummary(item)
|
|
2456
|
+
}));
|
|
2457
|
+
}
|
|
2458
|
+
function isForgeCompletionTool(toolName) {
|
|
2459
|
+
const normalized = toolName.trim().toLowerCase();
|
|
2460
|
+
return normalized.startsWith("forge.complete_")
|
|
2461
|
+
|| normalized.startsWith("complete_")
|
|
2462
|
+
|| normalized.includes("forge.complete_")
|
|
2463
|
+
|| normalized.includes("complete_architect_plan");
|
|
2464
|
+
}
|
|
1647
2465
|
function objectField(source, key) {
|
|
1648
2466
|
const value = source[key];
|
|
1649
2467
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
@@ -1654,6 +2472,13 @@ function stringField(source, key) {
|
|
|
1654
2472
|
const value = source[key];
|
|
1655
2473
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
1656
2474
|
}
|
|
2475
|
+
function numericField(source, key) {
|
|
2476
|
+
const value = source[key];
|
|
2477
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
2478
|
+
}
|
|
2479
|
+
function stripUndefined(value) {
|
|
2480
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
2481
|
+
}
|
|
1657
2482
|
function normalizedString(value) {
|
|
1658
2483
|
return value.trim().toLowerCase().replace(/-/g, "_");
|
|
1659
2484
|
}
|