@loucompanion/forge-bridge 0.1.1-dev.f599637d804e → 0.1.3-dev.2c3feac3af65

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.
@@ -1,5 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { execFile, spawn } from "node:child_process";
5
6
  import readline from "node:readline";
@@ -8,7 +9,7 @@ import { GitClient } from "../../shared/git/git.js";
8
9
  import { resolveCursorAgentDirectLaunch } from "../../shared/cursor-agent/cursor-agent.launch.js";
9
10
  import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "../qa-sidecar-validation.js";
10
11
  import { buildSpecBody, selectLaneCommand } from "../../worktree/artifact-injector.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
+ import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForDebugCycle, completionPromptForDevelopCycle, completionPromptForDraftCycle, completionPromptForPhase, completionPromptForQaCycle, completionRedactionEnv, expectedCompletionToolName, expectedDebugCompletionToolName, expectedQaCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, usesDebugCycleCompletion, usesDevelopCycleCompletion, writeCompletionArtifact, artifactRootFor, artifactRootForOwner, debugCompletionPhaseOrder, draftCompletionGateOrder, qaCompletionGateOrder } from "../phase-completion.js";
12
13
  import { addUsage, buildProviderUsageEvent, emptyUsage, hasUsage, readUsageNumber } from "./provider-usage.js";
13
14
  const fakeWorkerModel = "server-local-fake-worker";
14
15
  const execFileAsync = promisify(execFile);
@@ -24,15 +25,31 @@ const allowedProviderParentEnvNames = [
24
25
  ];
25
26
  const windowsAllowedProviderParentEnvNames = [
26
27
  "SystemRoot",
28
+ "SystemDrive",
27
29
  "ComSpec",
28
30
  "PATHEXT",
29
31
  "APPDATA",
30
- "LOCALAPPDATA"
32
+ "LOCALAPPDATA",
33
+ "USERNAME",
34
+ "USERDOMAIN",
35
+ "HOMEDRIVE",
36
+ "HOMEPATH",
37
+ "ProgramData",
38
+ "ProgramFiles",
39
+ "ProgramFiles(x86)",
40
+ "ProgramW6432",
41
+ "DOTNET_ROOT",
42
+ "DOTNET_ROOT(x86)",
43
+ "DOTNET_CLI_HOME",
44
+ "NUGET_PACKAGES",
45
+ "NUGET_HTTP_CACHE_PATH"
31
46
  ];
32
- function buildProviderChildEnvironment(requestEnv) {
47
+ export function buildProviderChildEnvironment(requestEnv, options = {}) {
48
+ const parentEnv = options.parentEnv ?? process.env;
49
+ const platform = options.platform ?? process.platform;
33
50
  const env = {};
34
- for (const [key, value] of Object.entries(process.env)) {
35
- if (typeof value === "string" && isAllowedProviderParentEnv(key)) {
51
+ for (const [key, value] of Object.entries(parentEnv)) {
52
+ if (typeof value === "string" && isAllowedProviderParentEnv(key, platform)) {
36
53
  env[key] = value;
37
54
  }
38
55
  }
@@ -44,16 +61,88 @@ function buildProviderChildEnvironment(requestEnv) {
44
61
  }
45
62
  }
46
63
  }
64
+ if (platform === "win32") {
65
+ normalizeWindowsDotNetEnvironment(env, parentEnv, options.homeDir ?? os.homedir());
66
+ }
47
67
  return env;
48
68
  }
49
- function isAllowedProviderParentEnv(key) {
69
+ function isAllowedProviderParentEnv(key, platform = process.platform) {
50
70
  return isAllowedProviderParentEnvName(key, allowedProviderParentEnvNames)
51
71
  || key.toUpperCase().startsWith("LC_")
52
- || (process.platform === "win32" && isAllowedProviderParentEnvName(key, windowsAllowedProviderParentEnvNames));
72
+ || (platform === "win32" && isAllowedProviderParentEnvName(key, windowsAllowedProviderParentEnvNames));
53
73
  }
54
74
  function isAllowedProviderParentEnvName(key, allowedNames) {
55
75
  return allowedNames.some((allowed) => allowed.toLowerCase() === key.toLowerCase());
56
76
  }
77
+ function normalizeWindowsDotNetEnvironment(env, parentEnv, homeDir) {
78
+ const systemRoot = readEnvironmentValue(env, "SystemRoot") ?? readEnvironmentValue(parentEnv, "SystemRoot") ?? "C:\\Windows";
79
+ const systemDrive = path.win32.parse(systemRoot).root.replace(/\\$/, "") || "C:";
80
+ setEnvironmentValueIfMissing(env, "SystemRoot", systemRoot);
81
+ setEnvironmentValueIfMissing(env, "SystemDrive", readEnvironmentValue(parentEnv, "SystemDrive") ?? systemDrive);
82
+ setEnvironmentValueIfMissing(env, "ComSpec", readEnvironmentValue(parentEnv, "ComSpec") ?? path.win32.join(systemRoot, "System32", "cmd.exe"));
83
+ setEnvironmentValueIfMissing(env, "PATHEXT", readEnvironmentValue(parentEnv, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.CPL");
84
+ const userProfile = resolveWindowsUserProfile(env, parentEnv, homeDir);
85
+ if (userProfile) {
86
+ setEnvironmentValueIfMissing(env, "USERPROFILE", userProfile);
87
+ setEnvironmentValueIfMissing(env, "HOME", userProfile);
88
+ setEnvironmentValueIfMissing(env, "DOTNET_CLI_HOME", userProfile);
89
+ setEnvironmentValueIfMissing(env, "NUGET_PACKAGES", path.win32.join(userProfile, ".nuget", "packages"));
90
+ const parsed = path.win32.parse(userProfile);
91
+ if (parsed.root) {
92
+ setEnvironmentValueIfMissing(env, "HOMEDRIVE", parsed.root.replace(/\\$/, ""));
93
+ setEnvironmentValueIfMissing(env, "HOMEPATH", userProfile.slice(parsed.root.length - (parsed.root.endsWith("\\") ? 1 : 0)) || "\\");
94
+ }
95
+ }
96
+ const appData = readEnvironmentValue(env, "APPDATA")
97
+ ?? readEnvironmentValue(parentEnv, "APPDATA")
98
+ ?? (userProfile ? path.win32.join(userProfile, "AppData", "Roaming") : undefined);
99
+ const localAppData = readEnvironmentValue(env, "LOCALAPPDATA")
100
+ ?? readEnvironmentValue(parentEnv, "LOCALAPPDATA")
101
+ ?? (userProfile ? path.win32.join(userProfile, "AppData", "Local") : undefined);
102
+ if (appData) {
103
+ setEnvironmentValueIfMissing(env, "APPDATA", appData);
104
+ }
105
+ if (localAppData) {
106
+ setEnvironmentValueIfMissing(env, "LOCALAPPDATA", localAppData);
107
+ setEnvironmentValueIfMissing(env, "NUGET_HTTP_CACHE_PATH", path.win32.join(localAppData, "NuGet", "v3-cache"));
108
+ }
109
+ setEnvironmentValueIfMissing(env, "DOTNET_NOLOGO", "1");
110
+ setEnvironmentValueIfMissing(env, "DOTNET_CLI_TELEMETRY_OPTOUT", "1");
111
+ setEnvironmentValueIfMissing(env, "DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1");
112
+ setEnvironmentValueIfMissing(env, "NUGET_XMLDOC_MODE", "skip");
113
+ }
114
+ function resolveWindowsUserProfile(env, parentEnv, homeDir) {
115
+ const existing = readEnvironmentValue(env, "USERPROFILE") ?? readEnvironmentValue(parentEnv, "USERPROFILE");
116
+ if (existing) {
117
+ return existing;
118
+ }
119
+ const home = readEnvironmentValue(env, "HOME") ?? readEnvironmentValue(parentEnv, "HOME");
120
+ if (home && path.win32.isAbsolute(home)) {
121
+ return home;
122
+ }
123
+ const homeDrive = readEnvironmentValue(env, "HOMEDRIVE") ?? readEnvironmentValue(parentEnv, "HOMEDRIVE");
124
+ const homePath = readEnvironmentValue(env, "HOMEPATH") ?? readEnvironmentValue(parentEnv, "HOMEPATH");
125
+ if (homeDrive && homePath) {
126
+ return `${homeDrive}${homePath}`;
127
+ }
128
+ if (homeDir && path.win32.isAbsolute(homeDir)) {
129
+ return homeDir;
130
+ }
131
+ const systemDrive = readEnvironmentValue(env, "SystemDrive") ?? readEnvironmentValue(parentEnv, "SystemDrive") ?? "C:";
132
+ const username = readEnvironmentValue(env, "USERNAME") ?? readEnvironmentValue(parentEnv, "USERNAME");
133
+ return username ? path.win32.join(systemDrive, "Users", username) : undefined;
134
+ }
135
+ function readEnvironmentValue(env, name) {
136
+ const entry = Object.entries(env).find(([key]) => key.toLowerCase() === name.toLowerCase());
137
+ const value = entry?.[1];
138
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
139
+ }
140
+ function setEnvironmentValueIfMissing(env, name, value) {
141
+ if (readEnvironmentValue(env, name)) {
142
+ return;
143
+ }
144
+ env[name] = value;
145
+ }
57
146
  export class RoutingProviderCliService {
58
147
  realProvider;
59
148
  deterministicProvider;
@@ -81,6 +170,11 @@ export class RoutingProviderCliService {
81
170
  ? await this.deterministicProvider.runQa(context)
82
171
  : await this.realProvider.runQa(context);
83
172
  }
173
+ async runDraft(context) {
174
+ return isFakeWorkerModel(context.dispatch.execution.model)
175
+ ? await this.deterministicProvider.runDraft(context)
176
+ : await this.realProvider.runDraft(context);
177
+ }
84
178
  }
85
179
  export class ProcessBackedProviderCliService {
86
180
  processRunner;
@@ -89,6 +183,174 @@ export class ProcessBackedProviderCliService {
89
183
  this.processRunner = processRunner;
90
184
  this.git = git;
91
185
  }
186
+ async runDraft(context) {
187
+ const { dispatch } = context;
188
+ if (!dispatch.draft) {
189
+ throw new Error("Draft dispatch context is missing.");
190
+ }
191
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "forge-bridge-draft-"));
192
+ const draftWorktree = {
193
+ repoPath: tempDir,
194
+ worktreePath: tempDir,
195
+ branchName: "draft",
196
+ baseBranch: dispatch.cycleRun.baseBranch || dispatch.repo.defaultBranch || "draft",
197
+ cleanupWorktrees: true
198
+ };
199
+ const metadataRoot = path.join(tempDir, ".forge", "work-items", dispatch.workItem.id);
200
+ await fs.mkdir(metadataRoot, { recursive: true });
201
+ await fs.writeFile(path.join(metadataRoot, "draft-input.json"), JSON.stringify(dispatch.draft, null, 2) + "\n", "utf8");
202
+ await fs.writeFile(path.join(metadataRoot, "spec.md"), buildDraftSpec(dispatch), "utf8");
203
+ const decomposeDraft = isDecomposeDraftDispatch(dispatch);
204
+ const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree: draftWorktree, phase: decomposeDraft ? "decompose" : "draft" });
205
+ const command = buildProviderCliCommand(dispatch, {
206
+ worktreePath: tempDir,
207
+ prompt: buildDraftPrompt(dispatch),
208
+ completionTools: completionTools ?? undefined,
209
+ skipGitRepoCheck: true
210
+ });
211
+ const draftArtifactSlugs = dispatch.artifacts
212
+ .filter((artifact) => artifact.scope === "operational" && ["command", "skill", "hook"].includes(artifact.kind))
213
+ .map((artifact) => `${artifact.kind}:${artifact.slug}`);
214
+ await context.emit({
215
+ role: "runner",
216
+ kind: "checkpoint",
217
+ content: `Draft artifacts injected: ${draftArtifactSlugs.length}${draftArtifactSlugs.length ? ` (${draftArtifactSlugs.join(", ")})` : ""}. Project: ${dispatch.draft.projectName ?? dispatch.repo.name}. Repo: ${dispatch.draft.repoName ?? dispatch.repo.name}.`,
218
+ eventType: "draft_artifacts_injected"
219
+ });
220
+ await context.emit({
221
+ role: "runner",
222
+ kind: "checkpoint",
223
+ content: `Starting provider CLI Draft: ${formatProviderCliCommand(command)}`,
224
+ eventType: "provider_cli_started"
225
+ });
226
+ await context.emit({
227
+ role: "runner",
228
+ kind: "checkpoint",
229
+ content: decomposeDraft
230
+ ? "Forge Decompose completion tools configured for decompose_subject_intake -> decompose_context_review -> decompose_breakdown_strategy -> decompose_child_items -> decompose_dependency_graph -> decompose_critic -> decompose_proposal; provider must call each MCP completion tool before exit."
231
+ : "Forge Draft completion tools configured for draft_intent -> draft_repo_context -> draft_graph_context -> draft_product_scope -> draft_work_order_plan -> draft_critic -> draft_proposal; provider must call each MCP completion tool before exit.",
232
+ eventType: "completion_tools_configured"
233
+ });
234
+ const outputState = createProviderOutputState(dispatch.execution.provider);
235
+ try {
236
+ const providerCancellation = new AbortController();
237
+ const abortProvider = () => providerCancellation.abort();
238
+ if (context.signal?.aborted) {
239
+ abortProvider();
240
+ }
241
+ else {
242
+ context.signal?.addEventListener("abort", abortProvider, { once: true });
243
+ }
244
+ try {
245
+ const runPromise = this.processRunner.run({
246
+ command: command.command,
247
+ args: command.args,
248
+ cwd: tempDir,
249
+ env: providerProcessEnv(dispatch)
250
+ }, {
251
+ onStdoutLine: async (line) => {
252
+ for (const event of outputState.consume(line)) {
253
+ await context.emit(event);
254
+ }
255
+ }
256
+ }, providerCancellation.signal);
257
+ const artifactWaitCancellation = new AbortController();
258
+ const abortArtifactWait = () => artifactWaitCancellation.abort();
259
+ if (context.signal?.aborted) {
260
+ abortArtifactWait();
261
+ }
262
+ else {
263
+ context.signal?.addEventListener("abort", abortArtifactWait, { once: true });
264
+ }
265
+ const artifactWaitPromise = waitForCompletionArtifact(tempDir, dispatch.workItem.id, decomposeDraft ? "decompose" : "draft", artifactWaitCancellation.signal);
266
+ let result;
267
+ let earlyProposalArtifact = null;
268
+ try {
269
+ const terminal = await Promise.race([
270
+ runPromise.then(value => ({ kind: "process", value })),
271
+ artifactWaitPromise.then(value => ({ kind: "artifact", value }))
272
+ ]);
273
+ artifactWaitCancellation.abort();
274
+ await artifactWaitPromise.catch(() => undefined);
275
+ if (terminal.kind === "artifact") {
276
+ earlyProposalArtifact = terminal.value;
277
+ providerCancellation.abort();
278
+ result = await runPromise.catch(() => ({ exitCode: 0, stdout: "", stderr: "" }));
279
+ }
280
+ else {
281
+ result = terminal.value;
282
+ }
283
+ }
284
+ finally {
285
+ artifactWaitCancellation.abort();
286
+ context.signal?.removeEventListener("abort", abortArtifactWait);
287
+ }
288
+ if (result.exitCode !== 0 && !earlyProposalArtifact) {
289
+ throw new Error(`Provider CLI exited with code ${result.exitCode}${summarizeStderr(result.stderr)}.`);
290
+ }
291
+ await context.emit({
292
+ role: "runner",
293
+ kind: "checkpoint",
294
+ content: `Provider CLI Draft completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
295
+ eventType: "provider_cli_completed"
296
+ });
297
+ await emitProviderUsageEvent(context.emit, dispatch, outputState);
298
+ if (decomposeDraft) {
299
+ await emitDecomposeGateArtifacts(context.emit, tempDir, dispatch.workItem.id);
300
+ const proposalArtifact = earlyProposalArtifact ?? await readCompletionArtifact(tempDir, dispatch.workItem.id, "decompose");
301
+ if (!proposalArtifact) {
302
+ throw new Error("Provider CLI Decompose completed but did not call forge.complete_decompose_proposal.");
303
+ }
304
+ const proposal = parseJsonLine(proposalArtifact.verdictJson) ?? {};
305
+ const proposalJson = proposal["proposal_json"] ?? proposal["decomposition_json"];
306
+ if (!proposalJson || typeof proposalJson !== "object" || Array.isArray(proposalJson)) {
307
+ throw new Error("Provider CLI Decompose completed but decompose.proposal.json did not contain proposal_json.");
308
+ }
309
+ const json = JSON.stringify(proposalJson);
310
+ await context.emit({
311
+ role: "assistant",
312
+ kind: "message",
313
+ content: json,
314
+ eventType: "decompose_proposal"
315
+ });
316
+ return {
317
+ summary: "Decomposition proposal generated through Forge Decompose completion cycle.",
318
+ artifact: proposalArtifact,
319
+ artifacts: [proposalArtifact]
320
+ };
321
+ }
322
+ await emitDraftGateArtifacts(context.emit, tempDir, dispatch.workItem.id);
323
+ const proposalArtifact = earlyProposalArtifact ?? await readCompletionArtifact(tempDir, dispatch.workItem.id, "draft");
324
+ if (!proposalArtifact) {
325
+ throw new Error("Provider CLI Draft completed but did not call forge.complete_draft_proposal.");
326
+ }
327
+ const proposal = parseJsonLine(proposalArtifact.verdictJson) ?? {};
328
+ const draftJson = proposal["draft_json"];
329
+ if (!draftJson || typeof draftJson !== "object" || Array.isArray(draftJson)) {
330
+ throw new Error("Provider CLI Draft completed but draft.proposal.json did not contain draft_json.");
331
+ }
332
+ const json = JSON.stringify(draftJson);
333
+ await context.emit({
334
+ role: "assistant",
335
+ kind: "message",
336
+ content: json,
337
+ eventType: "draft_complete"
338
+ });
339
+ return {
340
+ summary: "Draft generated through Forge Draft completion cycle.",
341
+ artifact: proposalArtifact,
342
+ artifacts: [proposalArtifact]
343
+ };
344
+ }
345
+ finally {
346
+ context.signal?.removeEventListener("abort", abortProvider);
347
+ }
348
+ }
349
+ finally {
350
+ await cleanupCompletionTools(completionTools, context.emit);
351
+ await fs.rm(tempDir, { recursive: true, force: true });
352
+ }
353
+ }
92
354
  async runImplementation(context) {
93
355
  const { dispatch, worktree } = context;
94
356
  const phase = completionPhaseFromDispatch(dispatch);
@@ -164,6 +426,15 @@ export class ProcessBackedProviderCliService {
164
426
  });
165
427
  await emitProviderUsageEvent(context.emit, dispatch, outputState);
166
428
  const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
429
+ if (developCycle || debugCycle || phase === "implementation") {
430
+ await this.git.run(worktree.worktreePath, ["push", "origin", `HEAD:${worktree.branchName}`]);
431
+ await context.emit({
432
+ role: "runner",
433
+ kind: "checkpoint",
434
+ content: `Pushed ${worktree.branchName} to origin after ${phase} completion.`,
435
+ eventType: "worktree_branch_pushed"
436
+ });
437
+ }
167
438
  const completionArtifacts = await readCompletionArtifactsForRun(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, debugCycle);
168
439
  for (const artifact of completionArtifacts) {
169
440
  if (!liveCompletionEvents.hasEmitted(completionPhaseFromArtifact(artifact))) {
@@ -204,6 +475,16 @@ export class ProcessBackedProviderCliService {
204
475
  }
205
476
  async runArchitect(context) {
206
477
  const { dispatch, worktree } = context;
478
+ const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
479
+ await fs.mkdir(metadataRoot, { recursive: true });
480
+ const laneCommand = selectLaneCommand(dispatch.artifacts, dispatch.execution.lane, dispatch.cycleRun.phase);
481
+ if (!laneCommand) {
482
+ throw new Error(`Lane command for lane '${dispatch.execution.lane}' phase '${dispatch.cycleRun.phase}' was not resolved.`);
483
+ }
484
+ const specBody = buildSpecBody(laneCommand, dispatch.workItem, "architect");
485
+ const specPath = path.join(metadataRoot, "spec.md");
486
+ await fs.writeFile(specPath, specBody, "utf8");
487
+ await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "pending_approval", produced_by: "forge-lane-command" }) + "\n", "utf8");
207
488
  const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "architect" });
208
489
  const command = buildProviderCliCommand(dispatch, {
209
490
  worktreePath: worktree.worktreePath,
@@ -270,15 +551,13 @@ export class ProcessBackedProviderCliService {
270
551
  content: outputState.finalMessage || `${dispatch.execution.provider} CLI produced an Architect plan for ${worktree.branchName}.`,
271
552
  eventType: "architect_complete"
272
553
  });
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
554
  return {
276
555
  summary: `${dispatch.execution.provider} CLI Architect plan produced.`,
277
556
  spec: {
278
557
  repoPath: worktree.worktreePath,
279
558
  path: specPath,
280
559
  body: specBody,
281
- producedBy: `${dispatch.execution.provider}-cli`,
560
+ producedBy: "forge-lane-command",
282
561
  status: "pending_approval"
283
562
  },
284
563
  artifact: architectArtifact,
@@ -435,6 +714,17 @@ export class DeterministicProviderCliService {
435
714
  constructor(git = new GitClient()) {
436
715
  this.git = git;
437
716
  }
717
+ async runDraft(context) {
718
+ const content = JSON.stringify(deterministicDraftPayload(context.dispatch));
719
+ await context.emit({
720
+ role: "assistant",
721
+ kind: "message",
722
+ content,
723
+ eventType: "draft_complete"
724
+ });
725
+ await context.emit(buildProviderUsageEvent(context.dispatch, estimatedDeterministicUsage()));
726
+ return { summary: "forge-bridge generated deterministic draft." };
727
+ }
438
728
  async runImplementation(context) {
439
729
  const { dispatch, worktree } = context;
440
730
  const phase = completionPhaseFromDispatch(dispatch);
@@ -1741,6 +2031,283 @@ function isFakeWorkerModel(model) {
1741
2031
  function sha256(content) {
1742
2032
  return crypto.createHash("sha256").update(content).digest("hex");
1743
2033
  }
2034
+ function buildDraftSpec(dispatch) {
2035
+ return `# Draft Generation Context
2036
+
2037
+ Project: ${dispatch.draft?.projectName ?? dispatch.repo.name}
2038
+ Repository: ${dispatch.draft?.repoName ?? dispatch.repo.name}
2039
+ Draft kind: ${dispatch.draft?.kind ?? "draft"}
2040
+
2041
+ ## User Prompt
2042
+
2043
+ ${dispatch.draft?.userPrompt ?? ""}
2044
+ `;
2045
+ }
2046
+ async function emitDraftGateArtifacts(emit, worktreePath, workItemId) {
2047
+ const draftArtifactFiles = {
2048
+ draft_intent: "draft.intent.json",
2049
+ draft_repo_context: "draft.repo-context.json",
2050
+ draft_graph_context: "draft.graph-context.json",
2051
+ draft_product_scope: "draft.product-scope.json",
2052
+ draft_work_order_plan: "draft.work-order-plan.json",
2053
+ draft_critic: "draft.critic.json",
2054
+ draft_proposal: "draft.proposal.json"
2055
+ };
2056
+ for (const gate of draftCompletionGateOrder) {
2057
+ const actualPath = path.join(worktreePath, ".forge", "work-items", workItemId, "artifacts", draftArtifactFiles[gate]);
2058
+ if (!await fileExists(actualPath)) {
2059
+ continue;
2060
+ }
2061
+ const relativePath = path.relative(worktreePath, actualPath).split(path.sep).join("/");
2062
+ await emit({
2063
+ role: "tool",
2064
+ kind: "tool_result",
2065
+ content: `Forge Draft completion tool recorded ${gate} artifact ${relativePath}.`,
2066
+ eventType: "completion_tool_artifact_recorded",
2067
+ data: JSON.stringify({ phase: "draft", draft_gate: gate, artifact_relative_paths: [relativePath] })
2068
+ });
2069
+ await emit({
2070
+ role: "hook",
2071
+ kind: "checkpoint",
2072
+ content: `phase_gate: ${gate} artifact accepted with status pass.`,
2073
+ eventType: "phase_gate"
2074
+ });
2075
+ const next = draftCompletionGateOrder[draftCompletionGateOrder.indexOf(gate) + 1];
2076
+ if (next) {
2077
+ await emit({
2078
+ role: "hook",
2079
+ kind: "checkpoint",
2080
+ content: `phase_progression: ${gate} -> ${next}`,
2081
+ eventType: "phase_gate"
2082
+ });
2083
+ }
2084
+ }
2085
+ }
2086
+ async function emitDecomposeGateArtifacts(emit, worktreePath, workItemId) {
2087
+ const decomposeArtifactFiles = {
2088
+ decompose_subject_intake: "decompose.subject-intake.json",
2089
+ decompose_context_review: "decompose.context-review.json",
2090
+ decompose_breakdown_strategy: "decompose.breakdown-strategy.json",
2091
+ decompose_child_items: "decompose.child-items.json",
2092
+ decompose_dependency_graph: "decompose.dependency-graph.json",
2093
+ decompose_critic: "decompose.critic.json",
2094
+ decompose_proposal: "decompose.proposal.json"
2095
+ };
2096
+ const gates = Object.keys(decomposeArtifactFiles);
2097
+ for (const gate of gates) {
2098
+ const actualPath = path.join(worktreePath, ".forge", "work-items", workItemId, "artifacts", decomposeArtifactFiles[gate]);
2099
+ if (!await fileExists(actualPath)) {
2100
+ continue;
2101
+ }
2102
+ const relativePath = path.relative(worktreePath, actualPath).split(path.sep).join("/");
2103
+ await emit({
2104
+ role: "tool",
2105
+ kind: "tool_result",
2106
+ content: `Forge Decompose completion tool recorded ${gate} artifact ${relativePath}.`,
2107
+ eventType: "completion_tool_artifact_recorded",
2108
+ data: JSON.stringify({ phase: "decompose", decompose_gate: gate, artifact_relative_paths: [relativePath] })
2109
+ });
2110
+ await emit({
2111
+ role: "hook",
2112
+ kind: "checkpoint",
2113
+ content: `phase_gate: ${gate} artifact accepted with status pass.`,
2114
+ eventType: "phase_gate"
2115
+ });
2116
+ const next = gates[gates.indexOf(gate) + 1];
2117
+ if (next) {
2118
+ await emit({
2119
+ role: "hook",
2120
+ kind: "checkpoint",
2121
+ content: `phase_progression: ${gate} -> ${next}`,
2122
+ eventType: "phase_gate"
2123
+ });
2124
+ }
2125
+ }
2126
+ }
2127
+ async function fileExists(filePath) {
2128
+ try {
2129
+ await fs.access(filePath);
2130
+ return true;
2131
+ }
2132
+ catch {
2133
+ return false;
2134
+ }
2135
+ }
2136
+ function buildDraftPrompt(dispatch) {
2137
+ const draft = dispatch.draft;
2138
+ if (!draft) {
2139
+ throw new Error("Draft prompt is missing.");
2140
+ }
2141
+ const decomposeDraft = isDecomposeDraftDispatch(dispatch);
2142
+ const draftArtifacts = dispatch.artifacts
2143
+ .filter((artifact) => artifact.scope === "operational" && ["command", "skill", "hook"].includes(artifact.kind))
2144
+ .map((artifact) => `## ${artifact.kind}: ${artifact.slug}\n${artifact.body.trim()}`)
2145
+ .join("\n\n");
2146
+ const artifactSection = draftArtifacts
2147
+ ? `\n\n# Injected Draft command/skills/hooks\n\n${draftArtifacts}`
2148
+ : "";
2149
+ const projectSection = [
2150
+ draft.projectName ? `Project: ${draft.projectName}` : null,
2151
+ draft.repoName ? `Repository: ${draft.repoName}` : null
2152
+ ].filter(Boolean).join("\n");
2153
+ const scopedProjectSection = projectSection ? `\n\n# Selected project context\n\n${projectSection}` : "";
2154
+ return `${draft.systemPrompt}${scopedProjectSection}${artifactSection}
2155
+
2156
+ Draft kind: ${draft.kind}
2157
+
2158
+ ${draft.userPrompt}
2159
+
2160
+ ${decomposeDraft
2161
+ ? `Use the injected command/skills/hooks as lane instructions. You must call each Forge Decompose MCP completion tool in order. Do not claim Decompose is complete until forge.complete_decompose_proposal accepts the final proposal_json.
2162
+
2163
+ Complete these gates in order: decompose_subject_intake -> decompose_context_review -> decompose_breakdown_strategy -> decompose_child_items -> decompose_dependency_graph -> decompose_critic -> decompose_proposal.
2164
+
2165
+ The final proposal_json object must contain summary, optional blockedReason, and candidates.`
2166
+ : `${completionPromptForDraftCycle("draft_intent")}
2167
+
2168
+ Use the injected command/skills/hooks as lane instructions. You must call each Forge Draft MCP completion tool in order. Do not claim the Draft is complete until forge.complete_draft_proposal accepts the final draft_json.`}
2169
+
2170
+ Do not modify source files.`;
2171
+ }
2172
+ function isDecomposeDraftKind(kind) {
2173
+ return kind === "work_order_decomposition" || kind === "feature_decomposition";
2174
+ }
2175
+ function isDecomposeDraftDispatch(dispatch) {
2176
+ return dispatch.execution.lane.trim().toLowerCase() === "decompose"
2177
+ || dispatch.session.lane.trim().toLowerCase() === "decompose"
2178
+ || isDecomposeDraftKind(dispatch.draft?.kind?.trim().toLowerCase());
2179
+ }
2180
+ function summarizeStderr(stderr) {
2181
+ const trimmed = stderr.trim();
2182
+ return trimmed ? `: ${clip(trimmed, 1000)}` : "";
2183
+ }
2184
+ async function waitForCompletionArtifact(worktreePath, workItemId, phase, signal) {
2185
+ while (!signal?.aborted) {
2186
+ const artifact = await readCompletionArtifact(worktreePath, workItemId, phase);
2187
+ if (artifact) {
2188
+ return artifact;
2189
+ }
2190
+ await delay(1000, signal);
2191
+ }
2192
+ throw new Error(`${phase} proposal artifact wait was cancelled.`);
2193
+ }
2194
+ async function delay(ms, signal) {
2195
+ if (!signal) {
2196
+ await new Promise((resolve) => setTimeout(resolve, ms));
2197
+ return;
2198
+ }
2199
+ const abortSignal = signal;
2200
+ if (abortSignal.aborted) {
2201
+ return;
2202
+ }
2203
+ await new Promise((resolve) => {
2204
+ const timeout = setTimeout(done, ms);
2205
+ const abort = () => done();
2206
+ function done() {
2207
+ clearTimeout(timeout);
2208
+ abortSignal.removeEventListener("abort", abort);
2209
+ resolve();
2210
+ }
2211
+ abortSignal.addEventListener("abort", abort, { once: true });
2212
+ if (abortSignal.aborted) {
2213
+ done();
2214
+ }
2215
+ });
2216
+ }
2217
+ function deterministicDraftPayload(dispatch) {
2218
+ const kind = dispatch.draft?.kind ?? "work_item";
2219
+ if (kind === "work_order_decomposition") {
2220
+ return {
2221
+ summary: "Fake decompose proposed a Feature DAG for the Work Order.",
2222
+ blockedReason: "",
2223
+ candidates: [
2224
+ {
2225
+ origin: "agent",
2226
+ key: "intake-submission",
2227
+ title: "Customer intake submission",
2228
+ summary: "Collect, validate, de-duplicate, and confirm incoming customer requests.",
2229
+ planningPacket: "# Customer intake submission\n\nPlan Stories for request form validation, attachment support, duplicate prevention, and submission confirmation.",
2230
+ sortOrder: 10,
2231
+ dependsOn: []
2232
+ },
2233
+ {
2234
+ origin: "agent",
2235
+ key: "review-queue",
2236
+ title: "Reviewer triage queue",
2237
+ summary: "Provide reviewer triage, assignment, notes, status transitions, and audit trail.",
2238
+ planningPacket: "# Reviewer triage queue\n\nPlan Stories for queue filtering, assignment, reviewer notes, status changes, and immutable audit trail.",
2239
+ sortOrder: 20,
2240
+ dependsOn: ["intake-submission"]
2241
+ }
2242
+ ]
2243
+ };
2244
+ }
2245
+ if (kind === "feature_decomposition") {
2246
+ return {
2247
+ summary: "Fake decompose proposed a Story DAG for the Feature.",
2248
+ blockedReason: "",
2249
+ candidates: [
2250
+ {
2251
+ origin: "agent",
2252
+ key: "foundation",
2253
+ title: "Build the feature foundation",
2254
+ description: "Create the persistence, API, and UI shell needed by the feature.",
2255
+ acceptanceCriteria: "Scenario: Foundation exists\nGiven the Feature is selected\nWhen the foundation is delivered\nThen downstream Stories can build on stable contracts",
2256
+ points: 3,
2257
+ sortOrder: 10,
2258
+ dependsOn: []
2259
+ },
2260
+ {
2261
+ origin: "agent",
2262
+ key: "workflow-behavior",
2263
+ title: "Implement the primary workflow behavior",
2264
+ description: "Implement the main user-visible behavior for the feature after the foundation is ready.",
2265
+ acceptanceCriteria: "Scenario: Workflow behavior works\nGiven the foundation Story is complete\nWhen the user performs the primary workflow\nThen the expected state change and feedback are visible",
2266
+ points: 5,
2267
+ sortOrder: 20,
2268
+ dependsOn: ["foundation"]
2269
+ },
2270
+ {
2271
+ origin: "agent",
2272
+ key: "audit-and-tests",
2273
+ title: "Add audit coverage and regression tests",
2274
+ description: "Add audit/evidence behavior and automated regression coverage for the feature.",
2275
+ acceptanceCriteria: "Scenario: Feature is verifiable\nGiven the workflow behavior is complete\nWhen validation runs\nThen audit evidence and tests cover the expected outcomes",
2276
+ points: 3,
2277
+ sortOrder: 30,
2278
+ dependsOn: ["workflow-behavior"]
2279
+ }
2280
+ ]
2281
+ };
2282
+ }
2283
+ if (kind === "work_order") {
2284
+ return {
2285
+ title: "Deterministic runner Work Order draft",
2286
+ summary: "Forge Runner generated this deterministic Work Order draft.",
2287
+ objective: "Validate runner-backed Draft generation.",
2288
+ planningPacket: "Runner-backed draft generation returned a Work Order JSON payload.",
2289
+ decompositionContext: "Use this deterministic fixture only in bridge tests.",
2290
+ assumptions: ["Forge Runner dispatch is available."],
2291
+ warnings: []
2292
+ };
2293
+ }
2294
+ if (kind === "story") {
2295
+ return {
2296
+ title: "Deterministic runner Story draft",
2297
+ description: "Forge Runner generated this deterministic Story draft.",
2298
+ acceptanceCriteria: "Given runner-backed Draft generation, when Forge requests a Story draft, then JSON is returned.",
2299
+ points: 3
2300
+ };
2301
+ }
2302
+ return {
2303
+ title: "Deterministic runner Work Item draft",
2304
+ description: "Forge Runner generated this deterministic Work Item draft.",
2305
+ acceptanceCriteria: "Given runner-backed Draft generation, when Forge requests a draft, then JSON is returned.",
2306
+ reproSteps: "Run Draft with a Forge Runner provider selected.",
2307
+ systemInfo: "forge-bridge deterministic provider",
2308
+ points: 1
2309
+ };
2310
+ }
1744
2311
  export function formatProviderCliCommand(command) {
1745
2312
  return [command.command, ...command.args].map(singleLine).join(" ");
1746
2313
  }
@@ -1752,7 +2319,7 @@ export function buildProviderCliCommand(dispatch, run) {
1752
2319
  const normalizedProvider = provider.toLowerCase();
1753
2320
  const model = dispatch.execution.model.trim();
1754
2321
  if (normalizedProvider === "codex") {
1755
- const args = run ? ["-s", "workspace-write", "-a", "never", "exec"] : ["exec"];
2322
+ const args = run ? ["-s", codexSandboxModeForPlatform(), "-a", "never", "exec"] : ["exec"];
1756
2323
  if (run?.completionTools) {
1757
2324
  args.push(...codexMcpConfigArgs(run.completionTools));
1758
2325
  }
@@ -1762,6 +2329,9 @@ export function buildProviderCliCommand(dispatch, run) {
1762
2329
  if (run.worktreePath) {
1763
2330
  args.push("--cd", run.worktreePath);
1764
2331
  }
2332
+ if (run.skipGitRepoCheck) {
2333
+ args.push("--skip-git-repo-check");
2334
+ }
1765
2335
  args.push(run.prompt ?? "");
1766
2336
  }
1767
2337
  return { command: "codex", args };
@@ -1782,6 +2352,9 @@ export function buildProviderCliCommand(dispatch, run) {
1782
2352
  }
1783
2353
  return { command: provider, args };
1784
2354
  }
2355
+ export function codexSandboxModeForPlatform(platform = process.platform) {
2356
+ return platform === "win32" ? "danger-full-access" : "workspace-write";
2357
+ }
1785
2358
  function buildArchitectPrompt(dispatch) {
1786
2359
  return `You are Forge's Architect lane running locally in a prepared git worktree.
1787
2360
 
@@ -1789,6 +2362,7 @@ Work item: ${dispatch.workItem.id} — ${dispatch.workItem.title}
1789
2362
 
1790
2363
  Plan only. Do not implement source changes, do not create Develop phase artifacts, and do not mark the spec approved.
1791
2364
  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.
2365
+ Implementation DAG tasks are Develop child tasks only: include focused repo/product implementation changes. Do not create child Develop tasks for parent-story QA, sign-off, materialized child inventory, child-count evidence, parent plan artifact checks, or final integrated acceptance proof; put those checks in qa_plan.final_qa.
1792
2366
  ${architectRevisionInstruction(dispatch)}
1793
2367
  Forge owns protocol files: write Architect artifacts only through the completion MCP tool.
1794
2368
 
@@ -1895,18 +2469,27 @@ function phaseWorkInstruction(phase) {
1895
2469
  }
1896
2470
  function qaBrowserInstruction(dispatch) {
1897
2471
  const qaBrowser = dispatch.qaBrowser;
2472
+ const qaScope = dispatch.qaScope;
1898
2473
  if (!qaBrowser) {
1899
2474
  return "Browser/app launch context: not provided by Forge.";
1900
2475
  }
2476
+ const scopeAuthority = qaScope
2477
+ ? qaScope.source === "heuristic"
2478
+ ? "Forge's transient heuristic fallback"
2479
+ : "persisted QA scope"
2480
+ : "Forge heuristics";
2481
+ const scopeEvidence = qaScope
2482
+ ? `\n- QA scope required evidence: ${qaScope.required_evidence.length > 0 ? qaScope.required_evidence.join(", ") : "none"}\n- QA scope not applicable: ${qaScope.not_applicable.length > 0 ? qaScope.not_applicable.join(", ") : "none"}${qaScope.rationale ? `\n- QA scope rationale: ${qaScope.rationale}` : ""}`
2483
+ : "";
1901
2484
  const launchTarget = qaBrowser.launchTarget;
1902
2485
  if (qaBrowser.required && !hasLaunchTarget(launchTarget ?? null)) {
1903
- 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`}`;
2486
+ return `Browser/app launch context:\n- UI/browser QA is required for this item by ${scopeAuthority}.\n- No app launch target was provided.\n- Return blocked with blocker_reason: missing_app_launch_target instead of pretending browser testing occurred.${scopeEvidence}\n- Artifact sidecars path when available: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
1904
2487
  }
1905
2488
  if (!launchTarget) {
1906
- 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`}`;
2489
+ return `Browser/app launch context:\n- UI/browser QA is not required by ${scopeAuthority} 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.${scopeEvidence}\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
1907
2490
  }
1908
2491
  const routes = launchTarget.routes.length > 0 ? launchTarget.routes.join(", ") : "not specified";
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`}`;
2492
+ return `Browser/app launch context:\n- Browser QA required: ${qaBrowser.required ? `yes, by ${scopeAuthority}` : `no by ${scopeAuthority}, 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"}${scopeEvidence}\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
1910
2493
  }
1911
2494
  function storyQaInstruction(dispatch) {
1912
2495
  const storyQa = dispatch.storyQa;
@@ -2094,11 +2677,11 @@ class CursorStreamJsonState {
2094
2677
  if (type === "assistant") {
2095
2678
  const text = assistantMessageText(event);
2096
2679
  if (text) {
2097
- this.finalMessage = clip(text, 4000);
2680
+ this.finalMessage = text;
2098
2681
  return [{
2099
2682
  role: "assistant",
2100
2683
  kind: "message",
2101
- content: this.finalMessage,
2684
+ content: clip(text, 4000),
2102
2685
  eventType: "provider_message"
2103
2686
  }];
2104
2687
  }
@@ -2132,7 +2715,7 @@ class CursorStreamJsonState {
2132
2715
  if (type === "result" && stringField(event, "subtype") === "success") {
2133
2716
  const result = stringField(event, "result");
2134
2717
  if (result) {
2135
- this.finalMessage = clip(result, 4000);
2718
+ this.finalMessage = result;
2136
2719
  }
2137
2720
  this.accumulateUsage(event);
2138
2721
  }
@@ -2313,11 +2896,11 @@ class CodexJsonState {
2313
2896
  if (itemType === "agent_message") {
2314
2897
  const text = stringField(item, "text");
2315
2898
  if (text) {
2316
- this.finalMessage = clip(text, 4000);
2899
+ this.finalMessage = text;
2317
2900
  return [{
2318
2901
  role: "assistant",
2319
2902
  kind: "message",
2320
- content: this.finalMessage,
2903
+ content: clip(text, 4000),
2321
2904
  eventType: "provider_message"
2322
2905
  }];
2323
2906
  }