@loucompanion/forge-bridge 0.1.1-dev.03b8ba168599 → 0.1.1-dev.2ac58b6f94ce
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge/bridge.service.js +67 -8
- package/dist/bridge/bridge.types.d.ts +9 -0
- package/dist/cli/cli.service.d.ts +4 -0
- package/dist/cli/cli.service.js +21 -1
- package/dist/cli/local-config.js +18 -1
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +1 -0
- package/dist/session/internals.d.ts +3 -1
- package/dist/session/internals.js +1 -0
- package/dist/session/phase-completion.d.ts +101 -0
- package/dist/session/phase-completion.js +1985 -0
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -2
- package/dist/session/provider-cli/provider-cli.service.d.ts +7 -4
- package/dist/session/provider-cli/provider-cli.service.js +1333 -194
- package/dist/session/provider-cli/provider-cli.types.d.ts +1 -0
- package/dist/session/provider-cli/provider-usage.d.ts +25 -0
- package/dist/session/provider-cli/provider-usage.js +64 -0
- package/dist/session/qa-sidecar-validation.d.ts +3 -0
- package/dist/session/qa-sidecar-validation.js +44 -0
- package/dist/session/session.service.js +16 -15
- package/dist/session/session.types.d.ts +55 -0
- package/dist/session/session.types.js +119 -2
- package/dist/shared/cursor-agent/cursor-agent.launch.d.ts +9 -0
- package/dist/shared/cursor-agent/cursor-agent.launch.js +66 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +1 -1
- package/dist/worktree/artifact-injector.js +9 -9
- package/package.json +1 -1
|
@@ -5,9 +5,14 @@ import { execFile, spawn } from "node:child_process";
|
|
|
5
5
|
import readline from "node:readline";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { GitClient } from "../../shared/git/git.js";
|
|
8
|
+
import { resolveCursorAgentDirectLaunch } from "../../shared/cursor-agent/cursor-agent.launch.js";
|
|
9
|
+
import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "../qa-sidecar-validation.js";
|
|
8
10
|
import { buildSpecBody, selectLaneCommand } from "../../worktree/artifact-injector.js";
|
|
11
|
+
import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForDevelopCycle, completionPromptForPhase, completionPromptForQaCycle, completionRedactionEnv, expectedCompletionToolName, expectedQaCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, usesDevelopCycleCompletion, writeCompletionArtifact, artifactRootFor, qaCompletionGateOrder } from "../phase-completion.js";
|
|
12
|
+
import { addUsage, buildProviderUsageEvent, emptyUsage, hasUsage, readUsageNumber } from "./provider-usage.js";
|
|
9
13
|
const fakeWorkerModel = "server-local-fake-worker";
|
|
10
14
|
const execFileAsync = promisify(execFile);
|
|
15
|
+
const developCyclePhaseOrder = ["implementation", "self_review", "validation"];
|
|
11
16
|
export class RoutingProviderCliService {
|
|
12
17
|
realProvider;
|
|
13
18
|
deterministicProvider;
|
|
@@ -25,10 +30,10 @@ export class RoutingProviderCliService {
|
|
|
25
30
|
? await this.deterministicProvider.runConflictResolve(context)
|
|
26
31
|
: await this.realProvider.runConflictResolve(context);
|
|
27
32
|
}
|
|
28
|
-
async runQa(
|
|
29
|
-
return isFakeWorkerModel(dispatch.execution.model)
|
|
30
|
-
? await this.deterministicProvider.runQa(
|
|
31
|
-
: await this.realProvider.runQa(
|
|
33
|
+
async runQa(context) {
|
|
34
|
+
return isFakeWorkerModel(context.dispatch.execution.model)
|
|
35
|
+
? await this.deterministicProvider.runQa(context)
|
|
36
|
+
: await this.realProvider.runQa(context);
|
|
32
37
|
}
|
|
33
38
|
}
|
|
34
39
|
export class ProcessBackedProviderCliService {
|
|
@@ -40,20 +45,23 @@ export class ProcessBackedProviderCliService {
|
|
|
40
45
|
}
|
|
41
46
|
async runImplementation(context) {
|
|
42
47
|
const { dispatch, worktree } = context;
|
|
48
|
+
const phase = completionPhaseFromDispatch(dispatch);
|
|
49
|
+
const developCycle = usesDevelopCycleCompletion(dispatch);
|
|
43
50
|
const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
|
|
44
|
-
|
|
45
|
-
await fs.mkdir(artifactRoot, { recursive: true });
|
|
51
|
+
await fs.mkdir(metadataRoot, { recursive: true });
|
|
46
52
|
const laneCommand = selectLaneCommand(dispatch.artifacts, dispatch.execution.lane, dispatch.cycleRun.phase);
|
|
47
53
|
if (!laneCommand) {
|
|
48
54
|
throw new Error(`Lane command for lane '${dispatch.execution.lane}' phase '${dispatch.cycleRun.phase}' was not resolved.`);
|
|
49
55
|
}
|
|
50
|
-
const specBody = buildSpecBody(laneCommand, dispatch.workItem);
|
|
56
|
+
const specBody = buildSpecBody(laneCommand, dispatch.workItem, phase);
|
|
51
57
|
const specPath = path.join(metadataRoot, "spec.md");
|
|
52
58
|
await fs.writeFile(specPath, specBody, "utf8");
|
|
53
59
|
await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "approved", produced_by: "forge-lane-command" }) + "\n", "utf8");
|
|
60
|
+
const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase });
|
|
54
61
|
const command = buildProviderCliCommand(dispatch, {
|
|
55
62
|
worktreePath: worktree.worktreePath,
|
|
56
|
-
prompt: buildImplementationPrompt(dispatch)
|
|
63
|
+
prompt: developCycle ? buildDevelopCyclePrompt(dispatch, phase) : buildImplementationPrompt(dispatch, phase),
|
|
64
|
+
completionTools
|
|
57
65
|
});
|
|
58
66
|
await context.emit({
|
|
59
67
|
role: "runner",
|
|
@@ -61,19 +69,37 @@ export class ProcessBackedProviderCliService {
|
|
|
61
69
|
content: `Starting provider CLI: ${formatProviderCliCommand(command)} in ${worktree.worktreePath}`,
|
|
62
70
|
eventType: "provider_cli_started"
|
|
63
71
|
});
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
await context.emit({
|
|
73
|
+
role: "runner",
|
|
74
|
+
kind: "checkpoint",
|
|
75
|
+
content: developCycle
|
|
76
|
+
? "Forge completion tools configured for implementation -> self_review -> validation; provider must call each MCP completion tool before exit."
|
|
77
|
+
: `Forge completion tools configured for ${phase}; provider must call the MCP completion tool before exit.`,
|
|
78
|
+
eventType: "completion_tools_configured"
|
|
79
|
+
});
|
|
80
|
+
const outputState = createProviderOutputState(dispatch.execution.provider);
|
|
81
|
+
const liveCompletionEvents = new LiveCompletionArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, context.emit);
|
|
82
|
+
let result;
|
|
83
|
+
try {
|
|
84
|
+
liveCompletionEvents.start();
|
|
85
|
+
result = await this.processRunner.run({
|
|
86
|
+
command: command.command,
|
|
87
|
+
args: command.args,
|
|
88
|
+
cwd: worktree.worktreePath,
|
|
89
|
+
env: providerRedactionEnv(dispatch)
|
|
90
|
+
}, {
|
|
91
|
+
onStdoutLine: async (line) => {
|
|
92
|
+
const event = outputState.consume(line);
|
|
93
|
+
if (event) {
|
|
94
|
+
await context.emit(event);
|
|
95
|
+
}
|
|
74
96
|
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
97
|
+
}, context.signal);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await liveCompletionEvents.stop();
|
|
101
|
+
await cleanupCompletionTools(completionTools, context.emit);
|
|
102
|
+
}
|
|
77
103
|
if (result.exitCode !== 0) {
|
|
78
104
|
const stderr = clip(result.stderr.trim() || result.stdout.trim(), 1200);
|
|
79
105
|
throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
|
|
@@ -81,43 +107,26 @@ export class ProcessBackedProviderCliService {
|
|
|
81
107
|
await context.emit({
|
|
82
108
|
role: "runner",
|
|
83
109
|
kind: "checkpoint",
|
|
84
|
-
content: `Provider CLI completed: ${command.command} exit ${result.exitCode}${
|
|
110
|
+
content: `Provider CLI completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
|
|
85
111
|
eventType: "provider_cli_completed"
|
|
86
112
|
});
|
|
113
|
+
await emitProviderUsageEvent(context.emit, dispatch, outputState);
|
|
87
114
|
const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
command: formatProviderCliCommand(command),
|
|
99
|
-
exit_code: result.exitCode
|
|
100
|
-
}
|
|
101
|
-
],
|
|
102
|
-
metrics: {
|
|
103
|
-
files_changed: committed.changedFiles.length
|
|
104
|
-
},
|
|
105
|
-
provider: dispatch.execution.provider,
|
|
106
|
-
model: dispatch.execution.model,
|
|
107
|
-
produced_by: `${dispatch.execution.provider}-cli`,
|
|
108
|
-
thread_id: codexState.threadId,
|
|
109
|
-
commit_sha: committed.commitSha,
|
|
110
|
-
content_hash: sha256(report)
|
|
111
|
-
};
|
|
112
|
-
const verdictJson = JSON.stringify(verdict);
|
|
113
|
-
await fs.writeFile(artifactPath, `${verdictJson}\n`, "utf8");
|
|
114
|
-
await fs.writeFile(reportPath, report, "utf8");
|
|
115
|
-
if (codexState.finalMessage) {
|
|
115
|
+
const completionArtifacts = await readCompletionArtifactsForRun(worktree.worktreePath, dispatch.workItem.id, phase, developCycle);
|
|
116
|
+
for (const artifact of completionArtifacts) {
|
|
117
|
+
if (!liveCompletionEvents.hasEmitted(completionPhaseFromArtifact(artifact))) {
|
|
118
|
+
await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, artifact);
|
|
119
|
+
await emitPhaseCompletionEvent(context.emit, artifact);
|
|
120
|
+
await emitPhaseGateEvents(context.emit, artifact);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const completionArtifact = completionArtifacts[0];
|
|
124
|
+
if (outputState.finalMessage) {
|
|
116
125
|
await context.emit({
|
|
117
126
|
role: "assistant",
|
|
118
127
|
kind: "message",
|
|
119
|
-
content:
|
|
120
|
-
eventType: "
|
|
128
|
+
content: outputState.finalMessage,
|
|
129
|
+
eventType: "provider_final_message"
|
|
121
130
|
});
|
|
122
131
|
}
|
|
123
132
|
else {
|
|
@@ -125,11 +134,11 @@ export class ProcessBackedProviderCliService {
|
|
|
125
134
|
role: "assistant",
|
|
126
135
|
kind: "message",
|
|
127
136
|
content: `${dispatch.execution.provider} CLI completed for ${worktree.branchName}.`,
|
|
128
|
-
eventType: "
|
|
137
|
+
eventType: "provider_final_message"
|
|
129
138
|
});
|
|
130
139
|
}
|
|
131
140
|
return {
|
|
132
|
-
summary: `${dispatch.execution.provider} CLI completed ${worktree.branchName}.`,
|
|
141
|
+
summary: `${dispatch.execution.provider} CLI completed ${worktree.branchName} (${developCycle ? "develop cycle" : phase}, ${committed.commitSha.slice(0, 12)}).`,
|
|
133
142
|
spec: {
|
|
134
143
|
repoPath: worktree.worktreePath,
|
|
135
144
|
path: specPath,
|
|
@@ -137,73 +146,129 @@ export class ProcessBackedProviderCliService {
|
|
|
137
146
|
producedBy: "forge-lane-command",
|
|
138
147
|
status: "approved"
|
|
139
148
|
},
|
|
140
|
-
artifact:
|
|
141
|
-
|
|
142
|
-
path: artifactPath,
|
|
143
|
-
verdictJson,
|
|
144
|
-
report,
|
|
145
|
-
producedBy: `${dispatch.execution.provider}-cli`,
|
|
146
|
-
status: "pass"
|
|
147
|
-
}
|
|
149
|
+
artifact: completionArtifact,
|
|
150
|
+
artifacts: completionArtifacts
|
|
148
151
|
};
|
|
149
152
|
}
|
|
150
|
-
async runQa(
|
|
153
|
+
async runQa(context) {
|
|
154
|
+
const { dispatch, worktree } = context;
|
|
155
|
+
const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
|
|
156
|
+
await fs.mkdir(metadataRoot, { recursive: true });
|
|
157
|
+
const laneCommand = selectLaneCommand(dispatch.artifacts, dispatch.execution.lane, dispatch.cycleRun.phase);
|
|
158
|
+
if (!laneCommand) {
|
|
159
|
+
throw new Error(`Lane command for lane '${dispatch.execution.lane}' phase '${dispatch.cycleRun.phase}' was not resolved.`);
|
|
160
|
+
}
|
|
161
|
+
const specBody = buildSpecBody(laneCommand, dispatch.workItem, "qa");
|
|
162
|
+
const specPath = path.join(metadataRoot, "spec.md");
|
|
163
|
+
await fs.writeFile(specPath, specBody, "utf8");
|
|
164
|
+
await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "approved", produced_by: "forge-lane-command" }) + "\n", "utf8");
|
|
165
|
+
const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "qa" });
|
|
151
166
|
const command = buildProviderCliCommand(dispatch, {
|
|
152
|
-
|
|
167
|
+
worktreePath: worktree.worktreePath,
|
|
168
|
+
prompt: buildQaPrompt(dispatch, specBody),
|
|
169
|
+
completionTools
|
|
153
170
|
});
|
|
154
|
-
|
|
171
|
+
const commandText = formatProviderCliCommand(command);
|
|
172
|
+
await context.emit({
|
|
155
173
|
role: "runner",
|
|
156
174
|
kind: "checkpoint",
|
|
157
|
-
content: `Starting provider CLI QA: ${
|
|
175
|
+
content: `Starting provider CLI QA: ${commandText} in ${worktree.worktreePath}`,
|
|
158
176
|
eventType: "provider_cli_started"
|
|
159
177
|
});
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
178
|
+
await context.emit({
|
|
179
|
+
role: "runner",
|
|
180
|
+
kind: "checkpoint",
|
|
181
|
+
content: "Forge QA completion tools configured; provider must call all seven QA MCP gates before exit.",
|
|
182
|
+
eventType: "completion_tools_configured"
|
|
183
|
+
});
|
|
184
|
+
const outputState = createProviderOutputState(dispatch.execution.provider);
|
|
185
|
+
const liveQaEvents = new LiveQaArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, dispatch, context.emit);
|
|
186
|
+
let result;
|
|
187
|
+
try {
|
|
188
|
+
liveQaEvents.start();
|
|
189
|
+
result = await this.processRunner.run({
|
|
190
|
+
command: command.command,
|
|
191
|
+
args: command.args,
|
|
192
|
+
cwd: worktree.worktreePath,
|
|
193
|
+
env: providerRedactionEnv(dispatch)
|
|
194
|
+
}, {
|
|
195
|
+
onStdoutLine: async (line) => {
|
|
196
|
+
const event = outputState.consume(line);
|
|
197
|
+
if (event) {
|
|
198
|
+
await context.emit(event);
|
|
199
|
+
}
|
|
170
200
|
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
201
|
+
}, context.signal);
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
await liveQaEvents.stop();
|
|
205
|
+
await cleanupCompletionTools(completionTools, context.emit);
|
|
206
|
+
}
|
|
173
207
|
if (result.exitCode !== 0) {
|
|
174
208
|
const stderr = clip(result.stderr.trim() || result.stdout.trim(), 1200);
|
|
175
209
|
throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
|
|
176
210
|
}
|
|
177
|
-
await emit({
|
|
211
|
+
await context.emit({
|
|
178
212
|
role: "runner",
|
|
179
213
|
kind: "checkpoint",
|
|
180
|
-
content: `Provider CLI QA completed: ${command.command} exit ${result.exitCode}${
|
|
214
|
+
content: `Provider CLI QA completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
|
|
181
215
|
eventType: "provider_cli_completed"
|
|
182
216
|
});
|
|
217
|
+
await emitProviderUsageEvent(context.emit, dispatch, outputState);
|
|
218
|
+
const qaArtifact = await readQaCompletionArtifactBundle(worktree.worktreePath, dispatch.workItem.id);
|
|
219
|
+
if (!qaArtifact) {
|
|
220
|
+
const verdictOnly = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "qa");
|
|
221
|
+
throw new Error(verdictOnly
|
|
222
|
+
? "Provider CLI QA completed but did not produce the complete durable QA artifact bundle."
|
|
223
|
+
: "Provider CLI QA completed but did not call the Forge QA completion tool.");
|
|
224
|
+
}
|
|
225
|
+
enforceQaBrowserLaunchTargetGate(dispatch, qaArtifact);
|
|
226
|
+
if (!liveQaEvents.hasEmitted()) {
|
|
227
|
+
await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
|
|
228
|
+
await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
|
|
229
|
+
}
|
|
230
|
+
const status = (qaArtifact.status ?? "unknown").trim().toLowerCase();
|
|
231
|
+
const statusSummary = status === "pass"
|
|
232
|
+
? "passed"
|
|
233
|
+
: status === "blocked"
|
|
234
|
+
? "blocked"
|
|
235
|
+
: "found rework";
|
|
183
236
|
return {
|
|
184
|
-
summary: `${dispatch.execution.provider} CLI QA
|
|
237
|
+
summary: `${dispatch.execution.provider} CLI QA ${statusSummary}.`,
|
|
238
|
+
spec: {
|
|
239
|
+
repoPath: worktree.worktreePath,
|
|
240
|
+
path: specPath,
|
|
241
|
+
body: specBody,
|
|
242
|
+
producedBy: "forge-lane-command",
|
|
243
|
+
status: "approved"
|
|
244
|
+
},
|
|
245
|
+
artifact: qaArtifact,
|
|
246
|
+
artifacts: [qaArtifact]
|
|
185
247
|
};
|
|
186
248
|
}
|
|
187
249
|
async runConflictResolve(context) {
|
|
188
250
|
const { dispatch, worktree } = context;
|
|
251
|
+
const addressComments = isPullRequestCommentsDispatch(dispatch);
|
|
189
252
|
const command = buildProviderCliCommand(dispatch, {
|
|
190
253
|
worktreePath: worktree.worktreePath,
|
|
191
|
-
prompt:
|
|
254
|
+
prompt: addressComments
|
|
255
|
+
? buildPullRequestCommentsPrompt(dispatch, worktree.branchName)
|
|
256
|
+
: buildConflictResolvePrompt(dispatch, worktree.branchName)
|
|
192
257
|
});
|
|
193
258
|
await context.emit({
|
|
194
259
|
role: "runner",
|
|
195
260
|
kind: "checkpoint",
|
|
196
|
-
content: `Starting provider CLI conflict resolve: ${formatProviderCliCommand(command)} in ${worktree.worktreePath}`,
|
|
261
|
+
content: `Starting provider CLI ${addressComments ? "PR comment address" : "conflict resolve"}: ${formatProviderCliCommand(command)} in ${worktree.worktreePath}`,
|
|
197
262
|
eventType: "provider_cli_started"
|
|
198
263
|
});
|
|
199
|
-
const
|
|
264
|
+
const outputState = createProviderOutputState(dispatch.execution.provider);
|
|
200
265
|
const result = await this.processRunner.run({
|
|
201
266
|
command: command.command,
|
|
202
267
|
args: command.args,
|
|
203
268
|
cwd: worktree.worktreePath
|
|
204
269
|
}, {
|
|
205
270
|
onStdoutLine: async (line) => {
|
|
206
|
-
const event =
|
|
271
|
+
const event = outputState.consume(line);
|
|
207
272
|
if (event) {
|
|
208
273
|
await context.emit(event);
|
|
209
274
|
}
|
|
@@ -215,14 +280,19 @@ export class ProcessBackedProviderCliService {
|
|
|
215
280
|
}
|
|
216
281
|
const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
|
|
217
282
|
await this.git.run(worktree.worktreePath, ["push", "origin", `HEAD:${worktree.branchName}`]);
|
|
283
|
+
await emitProviderUsageEvent(context.emit, dispatch, outputState);
|
|
218
284
|
await context.emit({
|
|
219
285
|
role: "assistant",
|
|
220
286
|
kind: "message",
|
|
221
|
-
content:
|
|
222
|
-
|
|
287
|
+
content: outputState.finalMessage || (addressComments
|
|
288
|
+
? `${dispatch.execution.provider} CLI addressed pull request comments for ${worktree.branchName}.`
|
|
289
|
+
: `${dispatch.execution.provider} CLI resolved merge conflicts for ${worktree.branchName}.`),
|
|
290
|
+
eventType: addressComments ? "pr_comments_complete" : "conflict_resolve_complete"
|
|
223
291
|
});
|
|
224
292
|
return {
|
|
225
|
-
summary:
|
|
293
|
+
summary: addressComments
|
|
294
|
+
? `${dispatch.execution.provider} CLI addressed PR comments on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
|
|
295
|
+
: `${dispatch.execution.provider} CLI resolved conflicts on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
|
|
226
296
|
};
|
|
227
297
|
}
|
|
228
298
|
}
|
|
@@ -233,6 +303,8 @@ export class DeterministicProviderCliService {
|
|
|
233
303
|
}
|
|
234
304
|
async runImplementation(context) {
|
|
235
305
|
const { dispatch, worktree } = context;
|
|
306
|
+
const phase = completionPhaseFromDispatch(dispatch);
|
|
307
|
+
const developCycle = usesDevelopCycleCompletion(dispatch);
|
|
236
308
|
const providerCommand = buildProviderCliCommand(dispatch);
|
|
237
309
|
const providerCommandText = formatProviderCliCommand(providerCommand);
|
|
238
310
|
const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
|
|
@@ -242,7 +314,7 @@ export class DeterministicProviderCliService {
|
|
|
242
314
|
if (!laneCommand) {
|
|
243
315
|
throw new Error(`Lane command for lane '${dispatch.execution.lane}' phase '${dispatch.cycleRun.phase}' was not resolved.`);
|
|
244
316
|
}
|
|
245
|
-
const specBody = `${buildSpecBody(laneCommand, dispatch.workItem)}
|
|
317
|
+
const specBody = `${buildSpecBody(laneCommand, dispatch.workItem, phase)}
|
|
246
318
|
## Execution
|
|
247
319
|
|
|
248
320
|
- Provider: ${singleLine(dispatch.execution.provider)}
|
|
@@ -263,27 +335,34 @@ export class DeterministicProviderCliService {
|
|
|
263
335
|
content: `Created bridge changeset at ${changesetPath}`,
|
|
264
336
|
eventType: "changeset"
|
|
265
337
|
});
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
338
|
+
const phases = developCycle
|
|
339
|
+
? ["implementation", "self_review", "validation"]
|
|
340
|
+
: [phase];
|
|
341
|
+
const completionArtifacts = [];
|
|
342
|
+
for (const completionPhase of phases) {
|
|
343
|
+
await writeCompletionArtifact({
|
|
344
|
+
worktreePath: worktree.worktreePath,
|
|
345
|
+
workItemId: dispatch.workItem.id,
|
|
346
|
+
phase: completionPhase,
|
|
347
|
+
producedBy: "forge-bridge",
|
|
348
|
+
redactionSecrets: exactRedactionSecrets(dispatch)
|
|
349
|
+
}, expectedCompletionToolName(completionPhase), deterministicCompletionPayload(completionPhase, dispatch, changesetBody));
|
|
350
|
+
const completionArtifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, completionPhase);
|
|
351
|
+
if (!completionArtifact) {
|
|
352
|
+
throw new Error(`Deterministic provider did not produce the Forge ${completionPhase} completion artifact.`);
|
|
353
|
+
}
|
|
354
|
+
completionArtifacts.push(completionArtifact);
|
|
355
|
+
await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, completionArtifact);
|
|
356
|
+
await context.emit({
|
|
357
|
+
role: "assistant",
|
|
358
|
+
kind: "message",
|
|
359
|
+
content: `${completionPhase.replace(/_/g, " ")} complete. forge-bridge produced deterministic phase artifacts.`,
|
|
360
|
+
eventType: phaseCompleteEventType(completionPhase)
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
|
|
285
364
|
return {
|
|
286
|
-
summary: `forge-bridge completed ${worktree.branchName}.`,
|
|
365
|
+
summary: `forge-bridge completed ${worktree.branchName} (${developCycle ? "develop cycle" : phase}).`,
|
|
287
366
|
spec: {
|
|
288
367
|
repoPath: worktree.worktreePath,
|
|
289
368
|
path: specPath,
|
|
@@ -291,46 +370,774 @@ export class DeterministicProviderCliService {
|
|
|
291
370
|
producedBy: "forge-lane-command",
|
|
292
371
|
status: "approved"
|
|
293
372
|
},
|
|
294
|
-
artifact:
|
|
295
|
-
|
|
296
|
-
path: artifactPath,
|
|
297
|
-
verdictJson,
|
|
298
|
-
report,
|
|
299
|
-
producedBy: "forge-bridge",
|
|
300
|
-
status: "pass"
|
|
301
|
-
}
|
|
373
|
+
artifact: completionArtifacts[0],
|
|
374
|
+
artifacts: completionArtifacts
|
|
302
375
|
};
|
|
303
376
|
}
|
|
304
|
-
async runQa(
|
|
305
|
-
|
|
377
|
+
async runQa(context) {
|
|
378
|
+
const { dispatch, worktree } = context;
|
|
379
|
+
await context.emit({
|
|
306
380
|
role: "worker",
|
|
307
381
|
kind: "message",
|
|
308
|
-
content: `
|
|
309
|
-
eventType: "
|
|
310
|
-
});
|
|
311
|
-
await emit({
|
|
312
|
-
role: "assistant",
|
|
313
|
-
kind: "message",
|
|
314
|
-
content: "All checks passed. forge-bridge QA completed.",
|
|
315
|
-
eventType: "qa_complete"
|
|
382
|
+
content: `Planning ${dispatch.execution.provider} QA checks through forge-bridge.`,
|
|
383
|
+
eventType: "qa_plan"
|
|
316
384
|
});
|
|
385
|
+
const qaBrowser = dispatch.qaBrowser;
|
|
386
|
+
const launchTarget = qaBrowser?.launchTarget ?? null;
|
|
387
|
+
const missingLaunchTarget = qaBrowser?.required === true && !hasLaunchTarget(launchTarget);
|
|
388
|
+
const browserSidecar = !missingLaunchTarget && qaBrowser?.required === true && launchTarget
|
|
389
|
+
? deterministicBrowserSidecar(dispatch.workItem.id, launchTarget)
|
|
390
|
+
: null;
|
|
391
|
+
const browserChecks = browserSidecar && launchTarget
|
|
392
|
+
? [deterministicBrowserCheck(launchTarget, browserSidecar.relativePath)]
|
|
393
|
+
: [];
|
|
394
|
+
const sidecars = await writeQaSidecarFiles(worktree.worktreePath, browserSidecar ? [browserSidecar] : []);
|
|
395
|
+
const summary = missingLaunchTarget
|
|
396
|
+
? "QA blocked because browser testing is required but no app launch target was provided."
|
|
397
|
+
: "All checks passed. forge-bridge QA completed.";
|
|
398
|
+
const criterion = dispatch.workItem.acceptanceCriteria.trim() || "Forge QA evidence exists";
|
|
399
|
+
for (const gate of qaCompletionGateOrder) {
|
|
400
|
+
await writeCompletionArtifact({
|
|
401
|
+
worktreePath: worktree.worktreePath,
|
|
402
|
+
workItemId: dispatch.workItem.id,
|
|
403
|
+
phase: "qa",
|
|
404
|
+
producedBy: "forge-bridge",
|
|
405
|
+
redactionSecrets: exactRedactionSecrets(dispatch),
|
|
406
|
+
qaCycle: true
|
|
407
|
+
}, expectedQaCompletionToolName(gate), deterministicQaGatePayload({
|
|
408
|
+
gate,
|
|
409
|
+
criterion,
|
|
410
|
+
summary,
|
|
411
|
+
missingLaunchTarget,
|
|
412
|
+
browserChecks,
|
|
413
|
+
sidecars
|
|
414
|
+
}));
|
|
415
|
+
await context.emit({
|
|
416
|
+
role: "hook",
|
|
417
|
+
kind: "checkpoint",
|
|
418
|
+
content: `qa_gate: ${gate} artifact accepted.`,
|
|
419
|
+
eventType: "qa_gate"
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
const qaArtifact = await readQaCompletionArtifactBundle(worktree.worktreePath, dispatch.workItem.id);
|
|
423
|
+
if (!qaArtifact) {
|
|
424
|
+
throw new Error("Deterministic QA did not produce the complete durable QA artifact bundle.");
|
|
425
|
+
}
|
|
426
|
+
await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
|
|
427
|
+
await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
|
|
428
|
+
await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
|
|
317
429
|
return {
|
|
318
|
-
summary: "forge-bridge QA passed."
|
|
430
|
+
summary: missingLaunchTarget ? "forge-bridge QA blocked." : "forge-bridge QA passed.",
|
|
431
|
+
artifact: qaArtifact,
|
|
432
|
+
artifacts: [qaArtifact]
|
|
319
433
|
};
|
|
320
434
|
}
|
|
321
435
|
async runConflictResolve(context) {
|
|
322
436
|
const { dispatch, worktree } = context;
|
|
323
|
-
|
|
437
|
+
const addressComments = isPullRequestCommentsDispatch(dispatch);
|
|
438
|
+
await this.git.run(worktree.worktreePath, ["commit", "--allow-empty", "-m", `forge bridge ${addressComments ? "pr comments" : "conflict resolve"} for ${dispatch.workItem.id.slice(0, 8)}`]);
|
|
439
|
+
await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
|
|
324
440
|
await context.emit({
|
|
325
441
|
role: "assistant",
|
|
326
442
|
kind: "message",
|
|
327
|
-
content:
|
|
328
|
-
|
|
443
|
+
content: addressComments
|
|
444
|
+
? `Deterministic PR comment addressing completed for ${worktree.branchName}.`
|
|
445
|
+
: `Deterministic conflict resolve completed for ${worktree.branchName}.`,
|
|
446
|
+
eventType: addressComments ? "pr_comments_complete" : "conflict_resolve_complete"
|
|
329
447
|
});
|
|
330
448
|
return {
|
|
331
|
-
summary:
|
|
449
|
+
summary: addressComments
|
|
450
|
+
? `forge-bridge addressed PR comments for ${worktree.branchName}.`
|
|
451
|
+
: `forge-bridge resolved conflicts for ${worktree.branchName}.`
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function phaseCompleteEventType(phase) {
|
|
456
|
+
return phase === "implementation" ? "implementation_complete" : `${phase}_complete`;
|
|
457
|
+
}
|
|
458
|
+
function deterministicCompletionPayload(phase, dispatch, changesetBody) {
|
|
459
|
+
const changesetHash = sha256(changesetBody);
|
|
460
|
+
const criterion = dispatch.workItem.acceptanceCriteria || "Deterministic bridge changeset exists";
|
|
461
|
+
if (phase === "implementation") {
|
|
462
|
+
return {
|
|
463
|
+
status: "pass",
|
|
464
|
+
summary: "forge-bridge produced a deterministic implementation changeset.",
|
|
465
|
+
plan: [{ step: "Create deterministic changeset", status: "done" }],
|
|
466
|
+
changed_files: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Recorded deterministic bridge evidence." }],
|
|
467
|
+
tests_added_or_updated: [{ name: "forge_bridge_run_once", evidence: "The bridge committed FORGE_PHASE5_CHANGESET.md." }],
|
|
468
|
+
docs_added_or_updated: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Deterministic evidence document created for the run." }],
|
|
469
|
+
acceptance_criteria: [
|
|
470
|
+
{
|
|
471
|
+
criterion,
|
|
472
|
+
evidence: `FORGE_PHASE5_CHANGESET.md was committed with content hash ${changesetHash}.`,
|
|
473
|
+
status: "satisfied"
|
|
474
|
+
}
|
|
475
|
+
],
|
|
476
|
+
known_gaps: [],
|
|
477
|
+
next_phase_context: "Review FORGE_PHASE5_CHANGESET.md and the bridge commit before validation."
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
if (phase === "self_review") {
|
|
481
|
+
return {
|
|
482
|
+
status: "pass",
|
|
483
|
+
summary: "forge-bridge fake worker completed deterministic self-review.",
|
|
484
|
+
reviewed_diff: true,
|
|
485
|
+
findings: [],
|
|
486
|
+
security_review: {
|
|
487
|
+
status: "pass",
|
|
488
|
+
notes: "No secret handling, auth, or destructive behavior changes were introduced by the deterministic changeset."
|
|
489
|
+
},
|
|
490
|
+
edge_case_review: {
|
|
491
|
+
status: "pass",
|
|
492
|
+
notes: "The deterministic fake-worker changeset is intentionally narrow and repeatable."
|
|
493
|
+
},
|
|
494
|
+
design_review: {
|
|
495
|
+
status: "not_applicable",
|
|
496
|
+
screenshots: [],
|
|
497
|
+
notes: "No UI changes."
|
|
498
|
+
},
|
|
499
|
+
deterministic_comparison: {
|
|
500
|
+
status: "pass",
|
|
501
|
+
command_or_tool: "git diff --stat HEAD~1..HEAD",
|
|
502
|
+
notes: `Reviewed deterministic changeset with content hash ${changesetHash}.`
|
|
503
|
+
},
|
|
504
|
+
fixes_applied: [],
|
|
505
|
+
remaining_risks: []
|
|
332
506
|
};
|
|
333
507
|
}
|
|
508
|
+
return {
|
|
509
|
+
status: "pass",
|
|
510
|
+
summary: "forge-bridge fake worker completed deterministic validation.",
|
|
511
|
+
commands: [
|
|
512
|
+
{
|
|
513
|
+
command: "git status --short && git diff --check",
|
|
514
|
+
exit_code: 0,
|
|
515
|
+
status: "pass",
|
|
516
|
+
summary: "Deterministic validation command completed for the fake-worker changeset."
|
|
517
|
+
}
|
|
518
|
+
],
|
|
519
|
+
acceptance_criteria: [
|
|
520
|
+
{
|
|
521
|
+
criterion,
|
|
522
|
+
evidence: `FORGE_PHASE5_CHANGESET.md exists with content hash ${changesetHash}.`,
|
|
523
|
+
status: "satisfied"
|
|
524
|
+
}
|
|
525
|
+
],
|
|
526
|
+
build_status: "pass",
|
|
527
|
+
test_status: "pass",
|
|
528
|
+
lint_status: "not_applicable",
|
|
529
|
+
design_status: "not_applicable",
|
|
530
|
+
drift_checks: [
|
|
531
|
+
{
|
|
532
|
+
command_or_tool: "git diff --check",
|
|
533
|
+
status: "pass",
|
|
534
|
+
summary: "No deterministic drift or whitespace errors were reported for the fake-worker changeset."
|
|
535
|
+
}
|
|
536
|
+
],
|
|
537
|
+
ready_for_dev_test: true
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function deterministicQaGatePayload(options) {
|
|
541
|
+
const blocked = options.missingLaunchTarget;
|
|
542
|
+
const skippedBecauseBlocked = blocked
|
|
543
|
+
? {
|
|
544
|
+
status: "skipped",
|
|
545
|
+
summary: "Skipped because browser QA was blocked by missing launch target.",
|
|
546
|
+
reason: "Upstream browser QA blocker prevents meaningful downstream evidence."
|
|
547
|
+
}
|
|
548
|
+
: null;
|
|
549
|
+
switch (options.gate) {
|
|
550
|
+
case "qa_plan":
|
|
551
|
+
return {
|
|
552
|
+
status: "pass",
|
|
553
|
+
summary: "Deterministic QA plan recorded.",
|
|
554
|
+
checklist: [
|
|
555
|
+
"Review the validated work item against acceptance criteria.",
|
|
556
|
+
"Run deterministic regression evidence.",
|
|
557
|
+
"Confirm no in-scope QA findings remain."
|
|
558
|
+
],
|
|
559
|
+
acceptance_criteria: [options.criterion]
|
|
560
|
+
};
|
|
561
|
+
case "qa_regression":
|
|
562
|
+
return {
|
|
563
|
+
status: "pass",
|
|
564
|
+
summary: "Deterministic regression QA completed.",
|
|
565
|
+
commands: [
|
|
566
|
+
{
|
|
567
|
+
command: "forge-bridge deterministic QA",
|
|
568
|
+
exit_code: 0,
|
|
569
|
+
status: "pass",
|
|
570
|
+
summary: "Deterministic regression evidence completed."
|
|
571
|
+
}
|
|
572
|
+
],
|
|
573
|
+
notes: "Regression gate passed in deterministic fake-worker mode."
|
|
574
|
+
};
|
|
575
|
+
case "qa_api":
|
|
576
|
+
return {
|
|
577
|
+
status: "skipped",
|
|
578
|
+
summary: "No API/backend surface was identified for deterministic QA.",
|
|
579
|
+
reason: "The deterministic fake-worker changeset does not exercise API/backend behavior."
|
|
580
|
+
};
|
|
581
|
+
case "qa_manual_web":
|
|
582
|
+
return blocked
|
|
583
|
+
? {
|
|
584
|
+
status: "blocked",
|
|
585
|
+
summary: "Browser QA could not run because no app launch target was provided.",
|
|
586
|
+
blocker_reason: "missing_app_launch_target"
|
|
587
|
+
}
|
|
588
|
+
: options.browserChecks.length > 0
|
|
589
|
+
? {
|
|
590
|
+
status: "pass",
|
|
591
|
+
summary: "Deterministic browser QA exercised the configured launch target.",
|
|
592
|
+
browser_checks: options.browserChecks,
|
|
593
|
+
sidecars: options.sidecars,
|
|
594
|
+
notes: "Browser sidecar evidence recorded."
|
|
595
|
+
}
|
|
596
|
+
: {
|
|
597
|
+
status: "skipped",
|
|
598
|
+
summary: "Manual web QA was not required for this deterministic run.",
|
|
599
|
+
reason: "Forge did not require browser QA and no launch target was necessary."
|
|
600
|
+
};
|
|
601
|
+
case "qa_bug_repro":
|
|
602
|
+
return skippedBecauseBlocked ?? {
|
|
603
|
+
status: "skipped",
|
|
604
|
+
summary: "No failed QA observation required bug reproduction.",
|
|
605
|
+
reason: "No defects or candidate bugs were observed."
|
|
606
|
+
};
|
|
607
|
+
case "qa_findings":
|
|
608
|
+
return skippedBecauseBlocked ?? {
|
|
609
|
+
status: "pass",
|
|
610
|
+
summary: "No QA findings or candidate bugs were identified.",
|
|
611
|
+
findings: [],
|
|
612
|
+
candidate_bugs: []
|
|
613
|
+
};
|
|
614
|
+
case "qa_verdict":
|
|
615
|
+
return {
|
|
616
|
+
status: blocked ? "blocked" : "pass",
|
|
617
|
+
summary: options.summary,
|
|
618
|
+
checklist: [
|
|
619
|
+
"Review the validated work item against acceptance criteria.",
|
|
620
|
+
"Run deterministic regression evidence.",
|
|
621
|
+
blocked ? "Record missing browser launch target blocker." : "Confirm no in-scope QA findings remain."
|
|
622
|
+
],
|
|
623
|
+
tested_acceptance_criteria: blocked ? [] : [options.criterion],
|
|
624
|
+
commands: blocked
|
|
625
|
+
? []
|
|
626
|
+
: [
|
|
627
|
+
{
|
|
628
|
+
command: "forge-bridge deterministic QA",
|
|
629
|
+
exit_code: 0,
|
|
630
|
+
status: "pass",
|
|
631
|
+
summary: "Deterministic QA command completed."
|
|
632
|
+
}
|
|
633
|
+
],
|
|
634
|
+
browser_checks: blocked ? [] : options.browserChecks,
|
|
635
|
+
sidecars: blocked ? [] : options.sidecars,
|
|
636
|
+
notes: options.summary,
|
|
637
|
+
findings: [],
|
|
638
|
+
candidate_bugs: [],
|
|
639
|
+
blocker_reason: blocked ? "missing_app_launch_target" : null,
|
|
640
|
+
report_markdown: `# QA report\n\n${options.summary}\n`
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const qaArtifactFileNames = {
|
|
645
|
+
plan: "qa.plan.json",
|
|
646
|
+
evidence: "qa.evidence.json",
|
|
647
|
+
findings: "qa.findings.json",
|
|
648
|
+
verdict: "qa.verdict.json",
|
|
649
|
+
report: "qa.report.md"
|
|
650
|
+
};
|
|
651
|
+
function hasLaunchTarget(target) {
|
|
652
|
+
return Boolean(target?.url?.trim()
|
|
653
|
+
|| target?.ready_url?.trim()
|
|
654
|
+
|| target?.command?.trim()
|
|
655
|
+
|| (target?.routes ?? []).some((route) => route.trim().length > 0));
|
|
656
|
+
}
|
|
657
|
+
function deterministicBrowserSidecar(workItemId, target) {
|
|
658
|
+
const route = primaryLaunchRoute(target);
|
|
659
|
+
return {
|
|
660
|
+
relativePath: qaArtifactRelativePath(workItemId, "qa/screenshots/deterministic-browser-check.txt"),
|
|
661
|
+
content: `Deterministic Forge QA browser evidence\nRoute: ${route}\nConsole errors: []\nNetwork errors: []\nResponsive checks: desktop, mobile\n`
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
function deterministicBrowserCheck(target, screenshotPath) {
|
|
665
|
+
const route = primaryLaunchRoute(target);
|
|
666
|
+
return {
|
|
667
|
+
route,
|
|
668
|
+
url: route,
|
|
669
|
+
status: "pass",
|
|
670
|
+
summary: "Deterministic browser QA exercised the configured launch target.",
|
|
671
|
+
actions: ["open launch target", "walk acceptance criteria", "check console/network summaries", "check responsive viewport"],
|
|
672
|
+
screenshots: [screenshotPath],
|
|
673
|
+
console_errors: [],
|
|
674
|
+
network_errors: [],
|
|
675
|
+
responsive_checks: ["desktop", "mobile"]
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
function primaryLaunchRoute(target) {
|
|
679
|
+
return target.ready_url?.trim()
|
|
680
|
+
|| target.url?.trim()
|
|
681
|
+
|| target.routes.find((route) => route.trim().length > 0)?.trim()
|
|
682
|
+
|| "app launch command";
|
|
683
|
+
}
|
|
684
|
+
async function writeQaSidecarFiles(worktreePath, files) {
|
|
685
|
+
const sidecars = [];
|
|
686
|
+
for (const file of files) {
|
|
687
|
+
const fullPath = path.resolve(worktreePath, file.relativePath);
|
|
688
|
+
const relativePath = path.relative(worktreePath, fullPath).split(path.sep).join("/");
|
|
689
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
690
|
+
throw new Error(`QA sidecar path must stay under the worktree: ${file.relativePath}`);
|
|
691
|
+
}
|
|
692
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
693
|
+
await fs.writeFile(fullPath, file.content, "utf8");
|
|
694
|
+
sidecars.push({
|
|
695
|
+
path: relativePath,
|
|
696
|
+
content_hash: sha256(file.content),
|
|
697
|
+
bytes: Buffer.byteLength(file.content, "utf8")
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
return sidecars;
|
|
701
|
+
}
|
|
702
|
+
async function writeQaArtifacts(request) {
|
|
703
|
+
const artifactRoot = artifactRootFor(request.worktreePath, request.workItemId);
|
|
704
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
705
|
+
const sidecars = await writeQaSidecarFiles(request.worktreePath, request.sidecarFiles ?? []);
|
|
706
|
+
const status = request.status ?? "pass";
|
|
707
|
+
const criterion = request.acceptanceCriteria.trim() || "Forge QA evidence exists";
|
|
708
|
+
const relativeEvidencePath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.evidence);
|
|
709
|
+
const relativeFindingsPath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.findings);
|
|
710
|
+
const relativeReportPath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.report);
|
|
711
|
+
const planJson = jsonText({
|
|
712
|
+
schema_version: 1,
|
|
713
|
+
phase: "qa",
|
|
714
|
+
checklist: [
|
|
715
|
+
"Review the validated work item against acceptance criteria.",
|
|
716
|
+
"Run relevant provider QA verification.",
|
|
717
|
+
"Confirm no in-scope QA findings remain."
|
|
718
|
+
],
|
|
719
|
+
acceptance_criteria: [criterion]
|
|
720
|
+
});
|
|
721
|
+
const evidenceJson = jsonText({
|
|
722
|
+
schema_version: 1,
|
|
723
|
+
phase: "qa",
|
|
724
|
+
commands: status === "blocked"
|
|
725
|
+
? []
|
|
726
|
+
: [
|
|
727
|
+
{
|
|
728
|
+
command: request.command,
|
|
729
|
+
exit_code: 0,
|
|
730
|
+
status: "pass",
|
|
731
|
+
summary: `${request.provider} QA completed successfully.`
|
|
732
|
+
}
|
|
733
|
+
],
|
|
734
|
+
browser_checks: request.browserChecks ?? [],
|
|
735
|
+
sidecars,
|
|
736
|
+
notes: request.summary
|
|
737
|
+
});
|
|
738
|
+
const findingsJson = jsonText({
|
|
739
|
+
schema_version: 1,
|
|
740
|
+
phase: "qa",
|
|
741
|
+
findings: [],
|
|
742
|
+
candidate_bugs: []
|
|
743
|
+
});
|
|
744
|
+
const verdictJson = jsonText({
|
|
745
|
+
schema_version: 1,
|
|
746
|
+
phase: "qa",
|
|
747
|
+
status,
|
|
748
|
+
summary: request.summary,
|
|
749
|
+
tested_acceptance_criteria: status === "blocked" ? [] : [criterion],
|
|
750
|
+
finding_ids: [],
|
|
751
|
+
candidate_bug_ids: [],
|
|
752
|
+
blocker_reason: status === "blocked" ? request.blockerReason ?? "inconclusive" : null,
|
|
753
|
+
evidence_path: relativeEvidencePath,
|
|
754
|
+
findings_path: relativeFindingsPath,
|
|
755
|
+
report_path: relativeReportPath
|
|
756
|
+
});
|
|
757
|
+
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`;
|
|
758
|
+
await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.plan), planJson, "utf8");
|
|
759
|
+
await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.evidence), evidenceJson, "utf8");
|
|
760
|
+
await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.findings), findingsJson, "utf8");
|
|
761
|
+
const verdictPath = path.join(artifactRoot, qaArtifactFileNames.verdict);
|
|
762
|
+
await fs.writeFile(verdictPath, verdictJson, "utf8");
|
|
763
|
+
await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.report), report, "utf8");
|
|
764
|
+
return {
|
|
765
|
+
repoPath: request.worktreePath,
|
|
766
|
+
path: verdictPath,
|
|
767
|
+
verdictJson,
|
|
768
|
+
report,
|
|
769
|
+
producedBy: request.producedBy,
|
|
770
|
+
status,
|
|
771
|
+
phase: "qa"
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
async function emitQaArtifactRecorded(emit, worktreePath, artifact) {
|
|
775
|
+
const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
|
|
776
|
+
await emit({
|
|
777
|
+
role: "tool",
|
|
778
|
+
kind: "tool_result",
|
|
779
|
+
content: `Forge QA artifact recorded ${relativePath} (${artifact.status}).`,
|
|
780
|
+
eventType: "qa_artifact_recorded"
|
|
781
|
+
});
|
|
782
|
+
await emit({
|
|
783
|
+
role: "hook",
|
|
784
|
+
kind: "checkpoint",
|
|
785
|
+
content: `phase_gate: qa artifact accepted with status ${artifact.status}.`,
|
|
786
|
+
eventType: "phase_gate"
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
async function readQaCompletionArtifactBundle(worktreePath, workItemId) {
|
|
790
|
+
const artifact = await readCompletionArtifact(worktreePath, workItemId, "qa");
|
|
791
|
+
if (!artifact || !await hasCompleteQaArtifactBundle(worktreePath, workItemId)) {
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
await validateQaBundleSidecarReferences(worktreePath, workItemId);
|
|
795
|
+
return artifact;
|
|
796
|
+
}
|
|
797
|
+
async function hasCompleteQaArtifactBundle(worktreePath, workItemId) {
|
|
798
|
+
const artifactRoot = artifactRootFor(worktreePath, workItemId);
|
|
799
|
+
for (const fileName of Object.values(qaArtifactFileNames)) {
|
|
800
|
+
try {
|
|
801
|
+
const stat = await fs.stat(path.join(artifactRoot, fileName));
|
|
802
|
+
if (!stat.isFile()) {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
catch (error) {
|
|
807
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
throw error;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return true;
|
|
814
|
+
}
|
|
815
|
+
function enforceQaBrowserLaunchTargetGate(dispatch, artifact) {
|
|
816
|
+
const qaBrowser = dispatch.qaBrowser;
|
|
817
|
+
if (!qaBrowser?.required || hasLaunchTarget(qaBrowser.launchTarget ?? null)) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const verdict = parseJsonLine(artifact.verdictJson) ?? {};
|
|
821
|
+
const status = normalizedString(stringField(verdict, "status") ?? artifact.status ?? "");
|
|
822
|
+
const blockerReason = normalizedString(stringField(verdict, "blocker_reason") ?? "");
|
|
823
|
+
if (status !== "blocked" || blockerReason !== "missing_app_launch_target") {
|
|
824
|
+
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.");
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
async function validateQaBundleSidecarReferences(worktreePath, workItemId) {
|
|
828
|
+
const artifactRoot = artifactRootFor(worktreePath, workItemId);
|
|
829
|
+
const evidence = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.evidence));
|
|
830
|
+
const sidecars = arrayField(evidence, "sidecars");
|
|
831
|
+
if (sidecars.length === 0) {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const qaSidecarRoot = path.resolve(artifactRoot, "qa");
|
|
835
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
836
|
+
const sidecarPath = stringField(sidecar, "path");
|
|
837
|
+
const expectedHash = stringField(sidecar, "content_hash");
|
|
838
|
+
const expectedBytes = sidecar.bytes;
|
|
839
|
+
if (!sidecarPath || !expectedHash || typeof expectedBytes !== "number" || !Number.isFinite(expectedBytes) || expectedBytes <= 0) {
|
|
840
|
+
throw new Error(`QA sidecar ${index} must include path, content_hash, and positive bytes.`);
|
|
841
|
+
}
|
|
842
|
+
if (path.isAbsolute(sidecarPath)) {
|
|
843
|
+
throw new Error(`QA sidecar ${index} path must be relative to the worktree.`);
|
|
844
|
+
}
|
|
845
|
+
const fullPath = path.resolve(worktreePath, sidecarPath);
|
|
846
|
+
if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
|
|
847
|
+
throw new Error(`QA sidecar ${index} path must be under .forge/work-items/${workItemId}/artifacts/qa/.`);
|
|
848
|
+
}
|
|
849
|
+
const stat = await fs.lstat(fullPath).catch((error) => {
|
|
850
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
851
|
+
throw new Error(`QA sidecar ${index} file does not exist: ${sidecarPath}`);
|
|
852
|
+
}
|
|
853
|
+
throw error;
|
|
854
|
+
});
|
|
855
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
856
|
+
throw new Error(`QA sidecar ${index} must reference a regular file.`);
|
|
857
|
+
}
|
|
858
|
+
await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `QA sidecar ${index} path must not traverse symbolic links.`);
|
|
859
|
+
if (stat.size !== expectedBytes) {
|
|
860
|
+
throw new Error(`QA sidecar ${index} bytes do not match the referenced file.`);
|
|
861
|
+
}
|
|
862
|
+
const actualHash = await sha256File(fullPath);
|
|
863
|
+
if (actualHash !== expectedHash.trim().toLowerCase()) {
|
|
864
|
+
throw new Error(`QA sidecar ${index} content_hash does not match the referenced file.`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
async function emitQaStructuredEvents(emit, worktreePath, workItemId, artifact) {
|
|
869
|
+
const artifactRoot = artifactRootFor(worktreePath, workItemId);
|
|
870
|
+
const plan = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.plan));
|
|
871
|
+
const evidence = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.evidence));
|
|
872
|
+
const findings = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.findings));
|
|
873
|
+
const verdict = parseJsonLine(artifact.verdictJson) ?? {};
|
|
874
|
+
const status = stringField(verdict, "status") ?? artifact.status ?? "unknown";
|
|
875
|
+
const checklist = stringArrayField(plan, "checklist");
|
|
876
|
+
await emit({
|
|
877
|
+
role: "worker",
|
|
878
|
+
kind: "message",
|
|
879
|
+
content: checklist.length > 0
|
|
880
|
+
? `QA plan: ${checklist.join("; ")}`
|
|
881
|
+
: "QA plan recorded.",
|
|
882
|
+
eventType: "qa_plan"
|
|
883
|
+
});
|
|
884
|
+
for (const browserCheck of arrayField(evidence, "browser_checks")) {
|
|
885
|
+
await emit({
|
|
886
|
+
role: "worker",
|
|
887
|
+
kind: "checkpoint",
|
|
888
|
+
content: `QA browser check: ${summarizeQaObject(browserCheck)}`,
|
|
889
|
+
eventType: "qa_browser_check"
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
for (const finding of [...arrayField(findings, "findings"), ...arrayField(findings, "candidate_bugs")]) {
|
|
893
|
+
await emit({
|
|
894
|
+
role: "worker",
|
|
895
|
+
kind: "checkpoint",
|
|
896
|
+
content: `QA finding: ${summarizeQaObject(finding)}`,
|
|
897
|
+
eventType: "qa_finding"
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
await emit({
|
|
901
|
+
role: "assistant",
|
|
902
|
+
kind: "message",
|
|
903
|
+
content: status === "blocked"
|
|
904
|
+
? `QA blocked: ${stringField(verdict, "summary") ?? "No summary provided."}`
|
|
905
|
+
: `QA completed with status ${status}: ${stringField(verdict, "summary") ?? "No summary provided."}`,
|
|
906
|
+
eventType: status === "blocked" ? "qa_blocked" : "qa_complete"
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
class LiveQaArtifactEmitter {
|
|
910
|
+
worktreePath;
|
|
911
|
+
workItemId;
|
|
912
|
+
dispatch;
|
|
913
|
+
emit;
|
|
914
|
+
emitted = false;
|
|
915
|
+
timer = null;
|
|
916
|
+
pending = Promise.resolve();
|
|
917
|
+
constructor(worktreePath, workItemId, dispatch, emit) {
|
|
918
|
+
this.worktreePath = worktreePath;
|
|
919
|
+
this.workItemId = workItemId;
|
|
920
|
+
this.dispatch = dispatch;
|
|
921
|
+
this.emit = emit;
|
|
922
|
+
}
|
|
923
|
+
start() {
|
|
924
|
+
void this.pollQueued();
|
|
925
|
+
this.timer = setInterval(() => {
|
|
926
|
+
void this.pollQueued();
|
|
927
|
+
}, 250);
|
|
928
|
+
}
|
|
929
|
+
async stop() {
|
|
930
|
+
if (this.timer) {
|
|
931
|
+
clearInterval(this.timer);
|
|
932
|
+
this.timer = null;
|
|
933
|
+
}
|
|
934
|
+
await this.pollQueued();
|
|
935
|
+
}
|
|
936
|
+
hasEmitted() {
|
|
937
|
+
return this.emitted;
|
|
938
|
+
}
|
|
939
|
+
pollQueued() {
|
|
940
|
+
this.pending = this.pending
|
|
941
|
+
.then(() => this.poll())
|
|
942
|
+
.catch(() => undefined);
|
|
943
|
+
return this.pending;
|
|
944
|
+
}
|
|
945
|
+
async poll() {
|
|
946
|
+
if (this.emitted) {
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
const artifact = await readQaCompletionArtifactBundle(this.worktreePath, this.workItemId);
|
|
950
|
+
if (!artifact) {
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
enforceQaBrowserLaunchTargetGate(this.dispatch, artifact);
|
|
954
|
+
this.emitted = true;
|
|
955
|
+
await emitQaArtifactRecorded(this.emit, this.worktreePath, artifact);
|
|
956
|
+
await emitQaStructuredEvents(this.emit, this.worktreePath, this.workItemId, artifact);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
async function readJsonObjectFile(filePath) {
|
|
960
|
+
const content = await fs.readFile(filePath, "utf8").catch(() => "");
|
|
961
|
+
return parseJsonLine(content) ?? {};
|
|
962
|
+
}
|
|
963
|
+
function arrayField(source, key) {
|
|
964
|
+
const value = source[key];
|
|
965
|
+
if (!Array.isArray(value)) {
|
|
966
|
+
return [];
|
|
967
|
+
}
|
|
968
|
+
return value.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry));
|
|
969
|
+
}
|
|
970
|
+
function stringArrayField(source, key) {
|
|
971
|
+
const value = source[key];
|
|
972
|
+
if (!Array.isArray(value)) {
|
|
973
|
+
return [];
|
|
974
|
+
}
|
|
975
|
+
return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
|
|
976
|
+
}
|
|
977
|
+
function summarizeQaObject(source) {
|
|
978
|
+
return clip(stringField(source, "title")
|
|
979
|
+
?? stringField(source, "summary")
|
|
980
|
+
?? stringField(source, "id")
|
|
981
|
+
?? JSON.stringify(source), 500);
|
|
982
|
+
}
|
|
983
|
+
function qaArtifactRelativePath(workItemId, fileName) {
|
|
984
|
+
return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
|
|
985
|
+
}
|
|
986
|
+
function jsonText(value) {
|
|
987
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
988
|
+
}
|
|
989
|
+
async function emitCompletionArtifactRecorded(emit, worktreePath, artifact) {
|
|
990
|
+
const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
|
|
991
|
+
await emit({
|
|
992
|
+
role: "tool",
|
|
993
|
+
kind: "tool_result",
|
|
994
|
+
content: `Forge completion tool recorded ${artifact.phase ?? "unknown"} artifact ${relativePath} (${artifact.status}).`,
|
|
995
|
+
eventType: "completion_tool_artifact_recorded"
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
async function emitPhaseCompletionEvent(emit, artifact) {
|
|
999
|
+
const phase = completionPhaseFromArtifact(artifact);
|
|
1000
|
+
await emit({
|
|
1001
|
+
role: "assistant",
|
|
1002
|
+
kind: "message",
|
|
1003
|
+
content: `${phase.replace(/_/g, " ")} completion artifact recorded with status ${artifact.status}.`,
|
|
1004
|
+
eventType: phaseCompleteEventType(phase)
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
async function emitPhaseGateEvents(emit, artifact) {
|
|
1008
|
+
const phase = completionPhaseFromArtifact(artifact);
|
|
1009
|
+
const status = (artifact.status ?? "").trim().toLowerCase() || "unknown";
|
|
1010
|
+
await emit({
|
|
1011
|
+
role: "hook",
|
|
1012
|
+
kind: "checkpoint",
|
|
1013
|
+
content: `phase_gate: ${phase} artifact accepted with status ${status}.`,
|
|
1014
|
+
eventType: "phase_gate"
|
|
1015
|
+
});
|
|
1016
|
+
if (status !== "pass") {
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
const progression = phase === "implementation"
|
|
1020
|
+
? "phase_progression: implementation -> self_review"
|
|
1021
|
+
: phase === "self_review"
|
|
1022
|
+
? "phase_progression: self_review -> validation"
|
|
1023
|
+
: "phase_progression: validation -> QA ready";
|
|
1024
|
+
await emit({
|
|
1025
|
+
role: "hook",
|
|
1026
|
+
kind: "checkpoint",
|
|
1027
|
+
content: progression,
|
|
1028
|
+
eventType: "phase_gate"
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
function completionPhaseFromArtifact(artifact) {
|
|
1032
|
+
const phase = artifact.phase?.trim();
|
|
1033
|
+
return phase === "self_review" || phase === "validation" || phase === "implementation" || phase === "qa"
|
|
1034
|
+
? phase
|
|
1035
|
+
: "implementation";
|
|
1036
|
+
}
|
|
1037
|
+
class LiveCompletionArtifactEmitter {
|
|
1038
|
+
worktreePath;
|
|
1039
|
+
workItemId;
|
|
1040
|
+
emit;
|
|
1041
|
+
phases;
|
|
1042
|
+
emitted = new Set();
|
|
1043
|
+
timer = null;
|
|
1044
|
+
pending = Promise.resolve();
|
|
1045
|
+
constructor(worktreePath, workItemId, phase, developCycle, emit) {
|
|
1046
|
+
this.worktreePath = worktreePath;
|
|
1047
|
+
this.workItemId = workItemId;
|
|
1048
|
+
this.emit = emit;
|
|
1049
|
+
this.phases = developCycle
|
|
1050
|
+
? developCyclePhaseOrder.slice(developCyclePhaseOrder.indexOf(phase))
|
|
1051
|
+
: [phase];
|
|
1052
|
+
}
|
|
1053
|
+
start() {
|
|
1054
|
+
void this.pollQueued();
|
|
1055
|
+
this.timer = setInterval(() => {
|
|
1056
|
+
void this.pollQueued();
|
|
1057
|
+
}, 250);
|
|
1058
|
+
}
|
|
1059
|
+
async stop() {
|
|
1060
|
+
if (this.timer) {
|
|
1061
|
+
clearInterval(this.timer);
|
|
1062
|
+
this.timer = null;
|
|
1063
|
+
}
|
|
1064
|
+
await this.pollQueued();
|
|
1065
|
+
}
|
|
1066
|
+
hasEmitted(phase) {
|
|
1067
|
+
return this.emitted.has(phase);
|
|
1068
|
+
}
|
|
1069
|
+
pollQueued() {
|
|
1070
|
+
this.pending = this.pending
|
|
1071
|
+
.then(() => this.poll())
|
|
1072
|
+
.catch(() => undefined);
|
|
1073
|
+
return this.pending;
|
|
1074
|
+
}
|
|
1075
|
+
async poll() {
|
|
1076
|
+
for (const phase of this.phases) {
|
|
1077
|
+
if (this.emitted.has(phase)) {
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
const artifact = await readCompletionArtifact(this.worktreePath, this.workItemId, phase);
|
|
1081
|
+
if (!artifact) {
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
this.emitted.add(phase);
|
|
1085
|
+
await emitCompletionArtifactRecorded(this.emit, this.worktreePath, artifact);
|
|
1086
|
+
await emitPhaseCompletionEvent(this.emit, artifact);
|
|
1087
|
+
await emitPhaseGateEvents(this.emit, artifact);
|
|
1088
|
+
if ((artifact.status ?? "").trim().toLowerCase() !== "pass") {
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
async function readCompletionArtifactsForRun(worktreePath, workItemId, phase, developCycle) {
|
|
1095
|
+
if (!developCycle) {
|
|
1096
|
+
const artifact = await readCompletionArtifact(worktreePath, workItemId, phase);
|
|
1097
|
+
if (!artifact) {
|
|
1098
|
+
throw new Error(`Provider CLI completed but did not call the Forge ${phase} completion tool.`);
|
|
1099
|
+
}
|
|
1100
|
+
return [artifact];
|
|
1101
|
+
}
|
|
1102
|
+
const artifacts = [];
|
|
1103
|
+
for (const expectedPhase of ["implementation", "self_review", "validation"]) {
|
|
1104
|
+
const artifact = await readCompletionArtifact(worktreePath, workItemId, expectedPhase);
|
|
1105
|
+
if (!artifact) {
|
|
1106
|
+
if (artifacts.length === 0) {
|
|
1107
|
+
throw new Error("Provider CLI completed but did not call the Forge implementation completion tool.");
|
|
1108
|
+
}
|
|
1109
|
+
const previous = artifacts[artifacts.length - 1];
|
|
1110
|
+
if ((previous.status ?? "").trim().toLowerCase() === "pass") {
|
|
1111
|
+
throw new Error(`Provider CLI completed after ${previous.phase} passed but did not call the Forge ${expectedPhase} completion tool.`);
|
|
1112
|
+
}
|
|
1113
|
+
return artifacts;
|
|
1114
|
+
}
|
|
1115
|
+
artifacts.push(artifact);
|
|
1116
|
+
if ((artifact.status ?? "").trim().toLowerCase() !== "pass") {
|
|
1117
|
+
return artifacts;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return artifacts;
|
|
1121
|
+
}
|
|
1122
|
+
function providerRedactionEnv(dispatch) {
|
|
1123
|
+
const env = completionRedactionEnv(exactRedactionSecrets(dispatch));
|
|
1124
|
+
return Object.keys(env).length > 0 ? env : undefined;
|
|
1125
|
+
}
|
|
1126
|
+
function exactRedactionSecrets(dispatch) {
|
|
1127
|
+
return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
|
|
1128
|
+
}
|
|
1129
|
+
async function emitProviderUsageEvent(emit, dispatch, outputState) {
|
|
1130
|
+
const usage = outputState.usage;
|
|
1131
|
+
if (hasUsage(usage)) {
|
|
1132
|
+
await emit(buildProviderUsageEvent(dispatch, usage));
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
function estimatedDeterministicUsage() {
|
|
1136
|
+
return addUsage(emptyUsage("estimated"), {
|
|
1137
|
+
inputTokens: 1000,
|
|
1138
|
+
outputTokens: 400,
|
|
1139
|
+
source: "estimated"
|
|
1140
|
+
});
|
|
334
1141
|
}
|
|
335
1142
|
export class StreamingProviderProcessRunner {
|
|
336
1143
|
async run(request, handlers = {}, signal) {
|
|
@@ -338,6 +1145,7 @@ export class StreamingProviderProcessRunner {
|
|
|
338
1145
|
return await new Promise((resolve, reject) => {
|
|
339
1146
|
const child = spawn(launch.command, launch.args, {
|
|
340
1147
|
cwd: request.cwd,
|
|
1148
|
+
env: request.env ? { ...process.env, ...request.env } : undefined,
|
|
341
1149
|
stdio: ["ignore", "pipe", "pipe"],
|
|
342
1150
|
windowsHide: true
|
|
343
1151
|
});
|
|
@@ -406,6 +1214,10 @@ async function resolveProviderProcessLaunch(command, args) {
|
|
|
406
1214
|
if (process.platform !== "win32") {
|
|
407
1215
|
return { command, args };
|
|
408
1216
|
}
|
|
1217
|
+
const cursorDirect = await resolveCursorAgentDirectLaunch(command, args);
|
|
1218
|
+
if (cursorDirect) {
|
|
1219
|
+
return cursorDirect;
|
|
1220
|
+
}
|
|
409
1221
|
const resolved = await resolveWindowsCommand(command);
|
|
410
1222
|
return resolved ? buildWindowsLaunchCommand(resolved, args) : { command, args };
|
|
411
1223
|
}
|
|
@@ -413,10 +1225,6 @@ async function resolveWindowsCommand(command) {
|
|
|
413
1225
|
if (path.isAbsolute(command)) {
|
|
414
1226
|
return command;
|
|
415
1227
|
}
|
|
416
|
-
const cursorScript = await resolveCursorAgentPowerShellScript(command);
|
|
417
|
-
if (cursorScript) {
|
|
418
|
-
return cursorScript;
|
|
419
|
-
}
|
|
420
1228
|
try {
|
|
421
1229
|
const result = await execFileAsync("where.exe", [command], { timeout: 2000 });
|
|
422
1230
|
return chooseWindowsCommandCandidate(result.stdout.split(/\r?\n/));
|
|
@@ -425,18 +1233,6 @@ async function resolveWindowsCommand(command) {
|
|
|
425
1233
|
return null;
|
|
426
1234
|
}
|
|
427
1235
|
}
|
|
428
|
-
async function resolveCursorAgentPowerShellScript(command) {
|
|
429
|
-
const normalized = command.trim().toLowerCase();
|
|
430
|
-
if (normalized !== "cursor-agent" && normalized !== "agent") {
|
|
431
|
-
return null;
|
|
432
|
-
}
|
|
433
|
-
const localAppData = process.env.LOCALAPPDATA;
|
|
434
|
-
if (!localAppData) {
|
|
435
|
-
return null;
|
|
436
|
-
}
|
|
437
|
-
const script = path.join(localAppData, "cursor-agent", "cursor-agent.ps1");
|
|
438
|
-
return await fileExists(script) ? script : null;
|
|
439
|
-
}
|
|
440
1236
|
export function chooseWindowsCommandCandidate(candidates) {
|
|
441
1237
|
const normalized = candidates
|
|
442
1238
|
.map((candidate) => candidate.trim())
|
|
@@ -518,7 +1314,11 @@ export function buildProviderCliCommand(dispatch, run) {
|
|
|
518
1314
|
const normalizedProvider = provider.toLowerCase();
|
|
519
1315
|
const model = dispatch.execution.model.trim();
|
|
520
1316
|
if (normalizedProvider === "codex") {
|
|
521
|
-
const args = ["exec"
|
|
1317
|
+
const args = ["exec"];
|
|
1318
|
+
if (run?.completionTools) {
|
|
1319
|
+
args.push(...codexMcpConfigArgs(run.completionTools));
|
|
1320
|
+
}
|
|
1321
|
+
args.push("--model", model);
|
|
522
1322
|
if (run) {
|
|
523
1323
|
args.push("--json", "--dangerously-bypass-approvals-and-sandbox");
|
|
524
1324
|
if (run.worktreePath) {
|
|
@@ -529,10 +1329,12 @@ export function buildProviderCliCommand(dispatch, run) {
|
|
|
529
1329
|
return { command: "codex", args };
|
|
530
1330
|
}
|
|
531
1331
|
if (normalizedProvider === "cursor") {
|
|
532
|
-
const args = ["--model", model];
|
|
1332
|
+
const args = ["--print", "--output-format", "stream-json", "--model", model];
|
|
1333
|
+
if (run?.worktreePath) {
|
|
1334
|
+
args.push("--workspace", run.worktreePath);
|
|
1335
|
+
}
|
|
533
1336
|
if (run?.prompt) {
|
|
534
|
-
args.push("--yolo");
|
|
535
|
-
args.push(run.prompt);
|
|
1337
|
+
args.push("--yolo", "--approve-mcps", "--trust", run.prompt);
|
|
536
1338
|
}
|
|
537
1339
|
return { command: "cursor-agent", args };
|
|
538
1340
|
}
|
|
@@ -542,20 +1344,115 @@ export function buildProviderCliCommand(dispatch, run) {
|
|
|
542
1344
|
}
|
|
543
1345
|
return { command: provider, args };
|
|
544
1346
|
}
|
|
545
|
-
function buildImplementationPrompt(dispatch) {
|
|
546
|
-
return `You are Forge's
|
|
1347
|
+
function buildImplementationPrompt(dispatch, phase = completionPhaseFromDispatch(dispatch)) {
|
|
1348
|
+
return `You are Forge's ${phase.replace(/_/g, " ")} phase runner running locally in a prepared git worktree.
|
|
1349
|
+
|
|
1350
|
+
Read .forge/work-items/${dispatch.workItem.id}/spec.md and any prior phase artifacts required by this phase.
|
|
1351
|
+
${phaseWorkInstruction(phase)}
|
|
1352
|
+
${qaReworkImplementationInstruction(dispatch)}
|
|
1353
|
+
Use the repository's existing conventions. Run relevant tests when practical.
|
|
1354
|
+
Forge owns protocol files: worktree prep, injected .forge commands/hooks/skills, phase verdict artifacts, reports, and commits.
|
|
1355
|
+
Read Forge-provided context under .forge when needed, but do not edit .forge files or create Forge verdict/report artifacts by hand.
|
|
1356
|
+
|
|
1357
|
+
${completionPromptForPhase(phase)}
|
|
1358
|
+
|
|
1359
|
+
Finish with a concise summary of changed files and verification after calling the completion tool.`;
|
|
1360
|
+
}
|
|
1361
|
+
function buildDevelopCyclePrompt(dispatch, phase = completionPhaseFromDispatch(dispatch)) {
|
|
1362
|
+
return `You are Forge's develop-cycle runner running locally in a prepared git worktree.
|
|
547
1363
|
|
|
548
|
-
Read .forge/work-items/${dispatch.workItem.id}/spec.md, implement the work item,
|
|
1364
|
+
Read .forge/work-items/${dispatch.workItem.id}/spec.md, implement the work item, self-review the diff, then validate it in this same provider session.
|
|
1365
|
+
Start in the ${phase.replace(/_/g, " ")} phase. Do not stop after implementation if it passes; continue through self-review and validation using the next instruction returned by each completion tool.
|
|
1366
|
+
${qaReworkImplementationInstruction(dispatch)}
|
|
549
1367
|
Use the repository's existing conventions. Run relevant tests when practical.
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
1368
|
+
Forge owns protocol files: worktree prep, injected .forge commands/hooks/skills, phase verdict artifacts, reports, and commits.
|
|
1369
|
+
Read Forge-provided context under .forge when needed, but do not edit .forge files or create Forge verdict/report artifacts by hand.
|
|
1370
|
+
|
|
1371
|
+
${completionPromptForDevelopCycle(phase)}
|
|
1372
|
+
|
|
1373
|
+
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.`;
|
|
1374
|
+
}
|
|
1375
|
+
function qaReworkImplementationInstruction(dispatch) {
|
|
1376
|
+
if (!dispatch.qaRework) {
|
|
1377
|
+
return "";
|
|
1378
|
+
}
|
|
1379
|
+
return `
|
|
1380
|
+
QA rework context:
|
|
1381
|
+
- Attempt ${dispatch.qaRework.attempt}/${dispatch.qaRework.maxAttempts}
|
|
1382
|
+
- Rework packet: ${dispatch.qaRework.packetPath}
|
|
1383
|
+
- Required QA finding IDs: ${dispatch.qaRework.findingIds.join(", ")}
|
|
1384
|
+
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.`;
|
|
1385
|
+
}
|
|
1386
|
+
function qaReworkRetestInstruction(dispatch) {
|
|
1387
|
+
if (!dispatch.qaRework) {
|
|
1388
|
+
return "";
|
|
1389
|
+
}
|
|
1390
|
+
return `
|
|
1391
|
+
QA retest context:
|
|
1392
|
+
- Rework attempt ${dispatch.qaRework.attempt}/${dispatch.qaRework.maxAttempts}
|
|
1393
|
+
- Rework packet: ${dispatch.qaRework.packetPath}
|
|
1394
|
+
- Priority finding IDs: ${dispatch.qaRework.findingIds.join(", ")}
|
|
1395
|
+
Prioritize retesting the listed QA findings and their required regressions before deciding pass, needs_rework, or blocked.`;
|
|
1396
|
+
}
|
|
1397
|
+
function phaseWorkInstruction(phase) {
|
|
1398
|
+
if (phase === "self_review") {
|
|
1399
|
+
return "Review the implementation diff, apply safe fixes when needed, and record remaining risks explicitly.";
|
|
1400
|
+
}
|
|
1401
|
+
if (phase === "validation") {
|
|
1402
|
+
return "Run the validation commands and deterministic drift checks needed to prove the work is ready for QA.";
|
|
1403
|
+
}
|
|
1404
|
+
return "Implement the work item and keep the change focused.";
|
|
553
1405
|
}
|
|
554
|
-
function
|
|
555
|
-
|
|
1406
|
+
function qaBrowserInstruction(dispatch) {
|
|
1407
|
+
const qaBrowser = dispatch.qaBrowser;
|
|
1408
|
+
if (!qaBrowser) {
|
|
1409
|
+
return "Browser/app launch context: not provided by Forge.";
|
|
1410
|
+
}
|
|
1411
|
+
const launchTarget = qaBrowser.launchTarget;
|
|
1412
|
+
if (qaBrowser.required && !hasLaunchTarget(launchTarget ?? null)) {
|
|
1413
|
+
return `Browser/app launch context:\n- UI/browser QA is required for this item.\n- No app launch target was provided.\n- Return blocked with blocker_reason: missing_app_launch_target instead of pretending browser testing occurred.\n- Artifact sidecars path when available: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
|
|
1414
|
+
}
|
|
1415
|
+
if (!launchTarget) {
|
|
1416
|
+
return `Browser/app launch context:\n- UI/browser QA is not required by Forge heuristics for this item.\n- If you determine browser testing is needed, return blocked with blocker_reason: missing_app_launch_target unless you can safely identify a launch target from the repo.\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
|
|
1417
|
+
}
|
|
1418
|
+
const routes = launchTarget.routes.length > 0 ? launchTarget.routes.join(", ") : "not specified";
|
|
1419
|
+
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`}`;
|
|
1420
|
+
}
|
|
1421
|
+
function buildQaPrompt(dispatch, laneSpec) {
|
|
1422
|
+
return `You are Forge's QA lane running locally in a prepared git worktree.
|
|
1423
|
+
|
|
1424
|
+
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.
|
|
1425
|
+
|
|
1426
|
+
Injected QA lane command and work item spec:
|
|
1427
|
+
${laneSpec}
|
|
556
1428
|
|
|
557
|
-
|
|
558
|
-
|
|
1429
|
+
---
|
|
1430
|
+
|
|
1431
|
+
Work item: ${dispatch.workItem.id}
|
|
1432
|
+
Title: ${dispatch.workItem.title}
|
|
1433
|
+
Description:
|
|
1434
|
+
${dispatch.workItem.description || "Not provided."}
|
|
1435
|
+
|
|
1436
|
+
Acceptance criteria:
|
|
1437
|
+
${dispatch.workItem.acceptanceCriteria || "Not specified."}
|
|
1438
|
+
|
|
1439
|
+
Repro steps / scenario:
|
|
1440
|
+
${dispatch.workItem.reproSteps || "Not provided."}
|
|
1441
|
+
|
|
1442
|
+
System info / environment notes:
|
|
1443
|
+
${dispatch.workItem.systemInfo || "Not provided."}
|
|
1444
|
+
|
|
1445
|
+
Inspect the validated implementation and Forge artifacts under .forge/work-items/${dispatch.workItem.id}/artifacts/ when present, especially implementation, self-review, and validation evidence.
|
|
1446
|
+
${qaBrowserInstruction(dispatch)}
|
|
1447
|
+
${qaReworkRetestInstruction(dispatch)}
|
|
1448
|
+
QA is read-mostly: inspect code, run commands, and exercise browser/app flows when available, but do not edit product source or tests.
|
|
1449
|
+
Record concrete evidence. For UI/browser work, include browser_checks with URLs/routes, actions, observed results, screenshots/traces when available, console errors, failed network requests, and responsive checks. Store browser sidecars under .forge/work-items/${dispatch.workItem.id}/artifacts/qa/.
|
|
1450
|
+
Report in-scope defects as actionable findings with repro steps, expected/actual behavior, evidence, likely area, required fix, and required regression.
|
|
1451
|
+
Out-of-scope or follow-up issues belong in candidate_bugs instead of blocking the current item.
|
|
1452
|
+
|
|
1453
|
+
${completionPromptForQaCycle()}
|
|
1454
|
+
|
|
1455
|
+
Finish with a concise summary only after forge.complete_qa_verdict accepts the QA bundle.`;
|
|
559
1456
|
}
|
|
560
1457
|
function buildConflictResolvePrompt(dispatch, branchName) {
|
|
561
1458
|
const targetBranch = dispatch.cycleRun.requestedBase || dispatch.repo.defaultBranch || "main";
|
|
@@ -570,6 +1467,46 @@ Keep the original work item intent intact:
|
|
|
570
1467
|
|
|
571
1468
|
Finish with a concise summary of the conflicts resolved and verification performed.`;
|
|
572
1469
|
}
|
|
1470
|
+
function buildPullRequestCommentsPrompt(dispatch, branchName) {
|
|
1471
|
+
const pr = dispatch.pullRequest;
|
|
1472
|
+
const targetBranch = pr?.targetBranch || dispatch.cycleRun.requestedBase || dispatch.repo.defaultBranch || "main";
|
|
1473
|
+
const prLocator = pr
|
|
1474
|
+
? `PR #${pr.number}${pr.url ? ` (${pr.url})` : ""}`
|
|
1475
|
+
: "the open pull request for this work item";
|
|
1476
|
+
return `You are Forge's Pull Request Comments lane running locally in a prepared git worktree.
|
|
1477
|
+
|
|
1478
|
+
The current branch is the pull request branch ${branchName}. Address unresolved review comments on ${prLocator}.
|
|
1479
|
+
PR context:
|
|
1480
|
+
- Provider: ${pr?.provider || "not provided"}
|
|
1481
|
+
- Source branch: ${pr?.sourceBranch || branchName}
|
|
1482
|
+
- Target branch: ${targetBranch}
|
|
1483
|
+
- Unresolved comments: ${pr?.unresolvedThreads ?? "unknown"}
|
|
1484
|
+
|
|
1485
|
+
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.
|
|
1486
|
+
If the review thread contents are unavailable, stop with a clear summary of what could not be accessed instead of guessing.
|
|
1487
|
+
Keep the original work item intent intact:
|
|
1488
|
+
- Work item: ${dispatch.workItem.id}
|
|
1489
|
+
- Title: ${dispatch.workItem.title}
|
|
1490
|
+
- Acceptance criteria: ${dispatch.workItem.acceptanceCriteria || "Not specified"}
|
|
1491
|
+
|
|
1492
|
+
Finish with a concise summary of comments addressed and verification performed.`;
|
|
1493
|
+
}
|
|
1494
|
+
function isPullRequestCommentsDispatch(dispatch) {
|
|
1495
|
+
return dispatch.cycleRun.kind.trim().toLowerCase() === "pr_comments";
|
|
1496
|
+
}
|
|
1497
|
+
async function cleanupCompletionTools(completionTools, emit) {
|
|
1498
|
+
try {
|
|
1499
|
+
await completionTools.cleanup();
|
|
1500
|
+
}
|
|
1501
|
+
catch (error) {
|
|
1502
|
+
await emit({
|
|
1503
|
+
role: "runner",
|
|
1504
|
+
kind: "checkpoint",
|
|
1505
|
+
content: `Forge completion tool cleanup failed and was ignored: ${clip(messageFromError(error), 600)}`,
|
|
1506
|
+
eventType: "completion_tools_cleanup_failed"
|
|
1507
|
+
}).catch(() => undefined);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
573
1510
|
async function commitWorktreeChanges(git, worktreePath, workItemId) {
|
|
574
1511
|
await git.run(worktreePath, ["add", "-A"]);
|
|
575
1512
|
await git.run(worktreePath, ["reset", "--", ".forge"]).catch(() => "");
|
|
@@ -586,26 +1523,8 @@ async function commitWorktreeChanges(git, worktreePath, workItemId) {
|
|
|
586
1523
|
const commitSha = (await git.run(worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
587
1524
|
return { commitSha, changedFiles };
|
|
588
1525
|
}
|
|
589
|
-
function
|
|
590
|
-
|
|
591
|
-
? committed.changedFiles.map((file) => `- ${file}`).join("\n")
|
|
592
|
-
: "- No staged repository files changed; Forge recorded an empty completion commit.";
|
|
593
|
-
return `# Implementation report
|
|
594
|
-
|
|
595
|
-
Provider: ${dispatch.execution.provider}
|
|
596
|
-
Model: ${dispatch.execution.model}
|
|
597
|
-
Command: ${formatProviderCliCommand(command)}
|
|
598
|
-
Thread: ${codexState.threadId ?? "n/a"}
|
|
599
|
-
Commit: ${committed.commitSha}
|
|
600
|
-
|
|
601
|
-
## Changed files
|
|
602
|
-
|
|
603
|
-
${files}
|
|
604
|
-
|
|
605
|
-
## Final message
|
|
606
|
-
|
|
607
|
-
${codexState.finalMessage ?? "Provider CLI completed without a final assistant message."}
|
|
608
|
-
`;
|
|
1526
|
+
function messageFromError(error) {
|
|
1527
|
+
return error instanceof Error ? error.message : String(error);
|
|
609
1528
|
}
|
|
610
1529
|
function clip(value, maxLength) {
|
|
611
1530
|
if (value.length <= maxLength) {
|
|
@@ -613,27 +1532,227 @@ function clip(value, maxLength) {
|
|
|
613
1532
|
}
|
|
614
1533
|
return `${value.slice(0, maxLength)}...`;
|
|
615
1534
|
}
|
|
616
|
-
|
|
617
|
-
|
|
1535
|
+
function createProviderOutputState(provider) {
|
|
1536
|
+
return provider.trim().toLowerCase() === "cursor"
|
|
1537
|
+
? new CursorStreamJsonState()
|
|
1538
|
+
: new CodexJsonState();
|
|
1539
|
+
}
|
|
1540
|
+
class CursorStreamJsonState {
|
|
1541
|
+
sessionId = null;
|
|
618
1542
|
finalMessage = null;
|
|
619
|
-
|
|
1543
|
+
emittedSession = false;
|
|
1544
|
+
accumulatedUsage = null;
|
|
1545
|
+
get sessionLabel() {
|
|
1546
|
+
return this.sessionId ? `session ${this.sessionId}` : null;
|
|
1547
|
+
}
|
|
1548
|
+
get usage() {
|
|
1549
|
+
return this.accumulatedUsage;
|
|
1550
|
+
}
|
|
620
1551
|
consume(line) {
|
|
621
|
-
const
|
|
622
|
-
if (!
|
|
1552
|
+
const event = parseJsonLine(line);
|
|
1553
|
+
if (!event) {
|
|
623
1554
|
return null;
|
|
624
1555
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1556
|
+
const type = stringField(event, "type");
|
|
1557
|
+
if (type === "system" && stringField(event, "subtype") === "init") {
|
|
1558
|
+
this.sessionId = stringField(event, "session_id");
|
|
1559
|
+
if (this.sessionId && !this.emittedSession) {
|
|
1560
|
+
this.emittedSession = true;
|
|
1561
|
+
return {
|
|
1562
|
+
role: "runner",
|
|
1563
|
+
kind: "checkpoint",
|
|
1564
|
+
content: `Cursor session started: ${this.sessionId}.`,
|
|
1565
|
+
eventType: "provider_thread_started"
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
if (type === "assistant") {
|
|
1570
|
+
const text = assistantMessageText(event);
|
|
1571
|
+
if (text) {
|
|
1572
|
+
this.finalMessage = clip(text, 4000);
|
|
1573
|
+
return {
|
|
1574
|
+
role: "assistant",
|
|
1575
|
+
kind: "message",
|
|
1576
|
+
content: this.finalMessage,
|
|
1577
|
+
eventType: "provider_message"
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
if (type === "tool_call") {
|
|
1582
|
+
const subtype = stringField(event, "subtype");
|
|
1583
|
+
const toolCall = objectField(event, "tool_call");
|
|
1584
|
+
if (subtype === "started") {
|
|
1585
|
+
const summary = formatCursorToolCallStarted(toolCall);
|
|
1586
|
+
if (summary) {
|
|
1587
|
+
return {
|
|
1588
|
+
role: "worker",
|
|
1589
|
+
kind: "checkpoint",
|
|
1590
|
+
content: summary,
|
|
1591
|
+
eventType: "provider_tool_call"
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (subtype === "completed") {
|
|
1596
|
+
const completed = formatCursorToolCallCompleted(toolCall);
|
|
1597
|
+
if (completed) {
|
|
1598
|
+
return {
|
|
1599
|
+
role: "worker",
|
|
1600
|
+
kind: completed.kind,
|
|
1601
|
+
content: completed.content,
|
|
1602
|
+
eventType: completed.kind === "file_edit" ? "provider_file_edit" : "provider_tool_call"
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
if (type === "result" && stringField(event, "subtype") === "success") {
|
|
1608
|
+
const result = stringField(event, "result");
|
|
1609
|
+
if (result) {
|
|
1610
|
+
this.finalMessage = clip(result, 4000);
|
|
1611
|
+
}
|
|
1612
|
+
this.accumulateUsage(event);
|
|
628
1613
|
}
|
|
629
|
-
|
|
1614
|
+
if (type === "error" || (type === "result" && event.is_error === true)) {
|
|
1615
|
+
return {
|
|
1616
|
+
role: "runner",
|
|
1617
|
+
kind: "error",
|
|
1618
|
+
content: clip(line.trim(), 1000),
|
|
1619
|
+
eventType: "provider_cli_error"
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
return null;
|
|
1623
|
+
}
|
|
1624
|
+
accumulateUsage(event) {
|
|
1625
|
+
const usage = objectField(event, "usage");
|
|
1626
|
+
const durationMs = readUsageNumber(event, "duration_ms") || readUsageNumber(event, "duration_api_ms");
|
|
1627
|
+
if (Object.keys(usage).length === 0 && durationMs === 0) {
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
this.accumulatedUsage = addUsage(this.accumulatedUsage ?? emptyUsage("provider_reported"), {
|
|
1631
|
+
inputTokens: readUsageNumber(usage, "inputTokens"),
|
|
1632
|
+
outputTokens: readUsageNumber(usage, "outputTokens"),
|
|
1633
|
+
cacheReadTokens: readUsageNumber(usage, "cacheReadTokens"),
|
|
1634
|
+
cacheCreationTokens: readUsageNumber(usage, "cacheWriteTokens"),
|
|
1635
|
+
providerDurationMs: durationMs > 0 ? durationMs : null,
|
|
1636
|
+
source: "provider_reported"
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
function formatCursorToolCallStarted(toolCall) {
|
|
1641
|
+
const readArgs = objectField(objectField(toolCall, "readToolCall"), "args");
|
|
1642
|
+
const readPath = stringField(readArgs, "path");
|
|
1643
|
+
if (readPath) {
|
|
1644
|
+
return `Reading ${readPath}`;
|
|
1645
|
+
}
|
|
1646
|
+
const writeArgs = objectField(objectField(toolCall, "writeToolCall"), "args");
|
|
1647
|
+
const writePath = stringField(writeArgs, "path");
|
|
1648
|
+
if (writePath) {
|
|
1649
|
+
return `Writing ${writePath}`;
|
|
1650
|
+
}
|
|
1651
|
+
const shellArgs = objectField(objectField(toolCall, "shellToolCall"), "args");
|
|
1652
|
+
const command = stringField(shellArgs, "command");
|
|
1653
|
+
if (command) {
|
|
1654
|
+
return `Shell command started: ${clip(command, 500)}`;
|
|
1655
|
+
}
|
|
1656
|
+
const fn = objectField(toolCall, "function");
|
|
1657
|
+
const name = stringField(fn, "name");
|
|
1658
|
+
if (name) {
|
|
1659
|
+
return `Tool call started: ${name}`;
|
|
1660
|
+
}
|
|
1661
|
+
return null;
|
|
1662
|
+
}
|
|
1663
|
+
function formatCursorToolCallCompleted(toolCall) {
|
|
1664
|
+
const writeArgs = objectField(objectField(toolCall, "writeToolCall"), "args");
|
|
1665
|
+
const writePath = stringField(writeArgs, "path");
|
|
1666
|
+
const writeResult = objectField(objectField(objectField(toolCall, "writeToolCall"), "result"), "success");
|
|
1667
|
+
const writtenPath = stringField(writeResult, "path") ?? writePath;
|
|
1668
|
+
if (writtenPath) {
|
|
1669
|
+
return {
|
|
1670
|
+
kind: "file_edit",
|
|
1671
|
+
content: `Wrote ${writtenPath}`
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
const readArgs = objectField(objectField(toolCall, "readToolCall"), "args");
|
|
1675
|
+
const readPath = stringField(readArgs, "path");
|
|
1676
|
+
if (readPath) {
|
|
1677
|
+
const readResult = objectField(objectField(objectField(toolCall, "readToolCall"), "result"), "success");
|
|
1678
|
+
const totalLines = readResult.totalLines;
|
|
1679
|
+
const lineSummary = typeof totalLines === "number" ? ` (${totalLines} lines)` : "";
|
|
1680
|
+
return {
|
|
1681
|
+
kind: "checkpoint",
|
|
1682
|
+
content: `Read ${readPath}${lineSummary}`
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
const shellArgs = objectField(objectField(toolCall, "shellToolCall"), "args");
|
|
1686
|
+
const command = stringField(shellArgs, "command");
|
|
1687
|
+
if (command) {
|
|
1688
|
+
return {
|
|
1689
|
+
kind: "checkpoint",
|
|
1690
|
+
content: `Shell command completed: ${clip(command, 500)}`
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
const fn = objectField(toolCall, "function");
|
|
1694
|
+
const name = stringField(fn, "name");
|
|
1695
|
+
if (name) {
|
|
1696
|
+
return {
|
|
1697
|
+
kind: "checkpoint",
|
|
1698
|
+
content: `Tool call completed: ${name}`
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
return null;
|
|
1702
|
+
}
|
|
1703
|
+
function assistantMessageText(event) {
|
|
1704
|
+
const message = objectField(event, "message");
|
|
1705
|
+
const content = message.content;
|
|
1706
|
+
if (!Array.isArray(content)) {
|
|
1707
|
+
return null;
|
|
1708
|
+
}
|
|
1709
|
+
const parts = content
|
|
1710
|
+
.map((entry) => {
|
|
1711
|
+
if (!entry || typeof entry !== "object") {
|
|
630
1712
|
return null;
|
|
631
1713
|
}
|
|
632
|
-
|
|
1714
|
+
const block = entry;
|
|
1715
|
+
return stringField(block, "text");
|
|
1716
|
+
})
|
|
1717
|
+
.filter((text) => Boolean(text));
|
|
1718
|
+
if (parts.length === 0) {
|
|
1719
|
+
return null;
|
|
1720
|
+
}
|
|
1721
|
+
return parts.join("");
|
|
1722
|
+
}
|
|
1723
|
+
function parseJsonLine(line) {
|
|
1724
|
+
const trimmed = line.trim();
|
|
1725
|
+
if (!trimmed.startsWith("{")) {
|
|
1726
|
+
return null;
|
|
1727
|
+
}
|
|
1728
|
+
let parsed;
|
|
1729
|
+
try {
|
|
1730
|
+
parsed = JSON.parse(trimmed);
|
|
1731
|
+
}
|
|
1732
|
+
catch {
|
|
1733
|
+
return null;
|
|
1734
|
+
}
|
|
1735
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
1736
|
+
? parsed
|
|
1737
|
+
: null;
|
|
1738
|
+
}
|
|
1739
|
+
class CodexJsonState {
|
|
1740
|
+
threadId = null;
|
|
1741
|
+
finalMessage = null;
|
|
1742
|
+
emittedThread = false;
|
|
1743
|
+
accumulatedUsage = null;
|
|
1744
|
+
get sessionLabel() {
|
|
1745
|
+
return this.threadId ? `thread ${this.threadId}` : null;
|
|
1746
|
+
}
|
|
1747
|
+
get usage() {
|
|
1748
|
+
return this.accumulatedUsage;
|
|
1749
|
+
}
|
|
1750
|
+
consume(line) {
|
|
1751
|
+
const event = parseJsonLine(line);
|
|
1752
|
+
if (!event) {
|
|
633
1753
|
return null;
|
|
634
1754
|
}
|
|
635
|
-
const
|
|
636
|
-
const type = typeof event.type === "string" ? event.type : "";
|
|
1755
|
+
const type = stringField(event, "type") ?? "";
|
|
637
1756
|
if (type === "thread.started") {
|
|
638
1757
|
this.threadId = stringField(event, "thread_id");
|
|
639
1758
|
if (this.threadId && !this.emittedThread) {
|
|
@@ -647,6 +1766,7 @@ class CodexJsonState {
|
|
|
647
1766
|
}
|
|
648
1767
|
}
|
|
649
1768
|
if (type === "turn.completed") {
|
|
1769
|
+
this.accumulateUsage(event);
|
|
650
1770
|
return {
|
|
651
1771
|
role: "runner",
|
|
652
1772
|
kind: "checkpoint",
|
|
@@ -658,7 +1778,7 @@ class CodexJsonState {
|
|
|
658
1778
|
return {
|
|
659
1779
|
role: "runner",
|
|
660
1780
|
kind: "error",
|
|
661
|
-
content: clip(
|
|
1781
|
+
content: clip(line.trim(), 1000),
|
|
662
1782
|
eventType: "provider_cli_error"
|
|
663
1783
|
};
|
|
664
1784
|
}
|
|
@@ -692,6 +1812,19 @@ class CodexJsonState {
|
|
|
692
1812
|
}
|
|
693
1813
|
return null;
|
|
694
1814
|
}
|
|
1815
|
+
accumulateUsage(event) {
|
|
1816
|
+
const usage = objectField(event, "usage");
|
|
1817
|
+
if (Object.keys(usage).length === 0) {
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
this.accumulatedUsage = addUsage(this.accumulatedUsage ?? emptyUsage("provider_reported"), {
|
|
1821
|
+
inputTokens: readUsageNumber(usage, "input_tokens"),
|
|
1822
|
+
outputTokens: readUsageNumber(usage, "output_tokens"),
|
|
1823
|
+
cacheReadTokens: readUsageNumber(usage, "cached_input_tokens"),
|
|
1824
|
+
reasoningOutputTokens: readUsageNumber(usage, "reasoning_output_tokens"),
|
|
1825
|
+
source: "provider_reported"
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
695
1828
|
}
|
|
696
1829
|
function objectField(source, key) {
|
|
697
1830
|
const value = source[key];
|
|
@@ -703,3 +1836,9 @@ function stringField(source, key) {
|
|
|
703
1836
|
const value = source[key];
|
|
704
1837
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
705
1838
|
}
|
|
1839
|
+
function normalizedString(value) {
|
|
1840
|
+
return value.trim().toLowerCase().replace(/-/g, "_");
|
|
1841
|
+
}
|
|
1842
|
+
function isNodeError(error) {
|
|
1843
|
+
return error instanceof Error && "code" in error;
|
|
1844
|
+
}
|