@loucompanion/forge-bridge 0.1.1-dev.eee62899e922 → 0.1.3-dev.04a84914da09

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.
@@ -9,7 +9,7 @@ import { GitClient } from "../../shared/git/git.js";
9
9
  import { resolveCursorAgentDirectLaunch } from "../../shared/cursor-agent/cursor-agent.launch.js";
10
10
  import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "../qa-sidecar-validation.js";
11
11
  import { buildSpecBody, selectLaneCommand } from "../../worktree/artifact-injector.js";
12
- 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";
13
13
  import { addUsage, buildProviderUsageEvent, emptyUsage, hasUsage, readUsageNumber } from "./provider-usage.js";
14
14
  const fakeWorkerModel = "server-local-fake-worker";
15
15
  const execFileAsync = promisify(execFile);
@@ -25,15 +25,31 @@ const allowedProviderParentEnvNames = [
25
25
  ];
26
26
  const windowsAllowedProviderParentEnvNames = [
27
27
  "SystemRoot",
28
+ "SystemDrive",
28
29
  "ComSpec",
29
30
  "PATHEXT",
30
31
  "APPDATA",
31
- "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"
32
46
  ];
33
- function buildProviderChildEnvironment(requestEnv) {
47
+ export function buildProviderChildEnvironment(requestEnv, options = {}) {
48
+ const parentEnv = options.parentEnv ?? process.env;
49
+ const platform = options.platform ?? process.platform;
34
50
  const env = {};
35
- for (const [key, value] of Object.entries(process.env)) {
36
- if (typeof value === "string" && isAllowedProviderParentEnv(key)) {
51
+ for (const [key, value] of Object.entries(parentEnv)) {
52
+ if (typeof value === "string" && isAllowedProviderParentEnv(key, platform)) {
37
53
  env[key] = value;
38
54
  }
39
55
  }
@@ -45,16 +61,88 @@ function buildProviderChildEnvironment(requestEnv) {
45
61
  }
46
62
  }
47
63
  }
64
+ if (platform === "win32") {
65
+ normalizeWindowsDotNetEnvironment(env, parentEnv, options.homeDir ?? os.homedir());
66
+ }
48
67
  return env;
49
68
  }
50
- function isAllowedProviderParentEnv(key) {
69
+ function isAllowedProviderParentEnv(key, platform = process.platform) {
51
70
  return isAllowedProviderParentEnvName(key, allowedProviderParentEnvNames)
52
71
  || key.toUpperCase().startsWith("LC_")
53
- || (process.platform === "win32" && isAllowedProviderParentEnvName(key, windowsAllowedProviderParentEnvNames));
72
+ || (platform === "win32" && isAllowedProviderParentEnvName(key, windowsAllowedProviderParentEnvNames));
54
73
  }
55
74
  function isAllowedProviderParentEnvName(key, allowedNames) {
56
75
  return allowedNames.some((allowed) => allowed.toLowerCase() === key.toLowerCase());
57
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
+ }
58
146
  export class RoutingProviderCliService {
59
147
  realProvider;
60
148
  deterministicProvider;
@@ -101,51 +189,165 @@ export class ProcessBackedProviderCliService {
101
189
  throw new Error("Draft dispatch context is missing.");
102
190
  }
103
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" });
104
205
  const command = buildProviderCliCommand(dispatch, {
105
206
  worktreePath: tempDir,
106
207
  prompt: buildDraftPrompt(dispatch),
208
+ completionTools: completionTools ?? undefined,
107
209
  skipGitRepoCheck: true
108
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
+ });
109
220
  await context.emit({
110
221
  role: "runner",
111
222
  kind: "checkpoint",
112
223
  content: `Starting provider CLI Draft: ${formatProviderCliCommand(command)}`,
113
224
  eventType: "provider_cli_started"
114
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
+ });
115
234
  const outputState = createProviderOutputState(dispatch.execution.provider);
116
235
  try {
117
- const result = await this.processRunner.run({
118
- command: command.command,
119
- args: command.args,
120
- cwd: tempDir,
121
- env: providerProcessEnv(dispatch)
122
- }, {
123
- onStdoutLine: async (line) => {
124
- for (const event of outputState.consume(line)) {
125
- await context.emit(event);
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
+ }
126
255
  }
256
+ }, providerCancellation.signal);
257
+ const artifactWaitCancellation = new AbortController();
258
+ const abortArtifactWait = () => artifactWaitCancellation.abort();
259
+ if (context.signal?.aborted) {
260
+ abortArtifactWait();
127
261
  }
128
- }, context.signal);
129
- if (result.exitCode !== 0) {
130
- throw new Error(`Provider CLI exited with code ${result.exitCode}${summarizeStderr(result.stderr)}.`);
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
+ };
131
344
  }
132
- const content = outputState.finalMessage ?? result.stdout.trim();
133
- if (!content) {
134
- throw new Error("Provider CLI completed without returning a draft.");
345
+ finally {
346
+ context.signal?.removeEventListener("abort", abortProvider);
135
347
  }
136
- const json = extractJsonObject(content);
137
- await context.emit({
138
- role: "assistant",
139
- kind: "message",
140
- content: json,
141
- eventType: "draft_complete"
142
- });
143
- await emitProviderUsageEvent(context.emit, dispatch, outputState);
144
- return {
145
- summary: "Draft generated through Forge Runner."
146
- };
147
348
  }
148
349
  finally {
350
+ await cleanupCompletionTools(completionTools, context.emit);
149
351
  await fs.rm(tempDir, { recursive: true, force: true });
150
352
  }
151
353
  }
@@ -224,6 +426,15 @@ export class ProcessBackedProviderCliService {
224
426
  });
225
427
  await emitProviderUsageEvent(context.emit, dispatch, outputState);
226
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
+ }
227
438
  const completionArtifacts = await readCompletionArtifactsForRun(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, debugCycle);
228
439
  for (const artifact of completionArtifacts) {
229
440
  if (!liveCompletionEvents.hasEmitted(completionPhaseFromArtifact(artifact))) {
@@ -264,6 +475,16 @@ export class ProcessBackedProviderCliService {
264
475
  }
265
476
  async runArchitect(context) {
266
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");
267
488
  const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "architect" });
268
489
  const command = buildProviderCliCommand(dispatch, {
269
490
  worktreePath: worktree.worktreePath,
@@ -330,15 +551,13 @@ export class ProcessBackedProviderCliService {
330
551
  content: outputState.finalMessage || `${dispatch.execution.provider} CLI produced an Architect plan for ${worktree.branchName}.`,
331
552
  eventType: "architect_complete"
332
553
  });
333
- const specPath = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id, "spec.md");
334
- const specBody = await fs.readFile(specPath, "utf8").catch(() => `# ${dispatch.workItem.title}\n\nArchitect plan pending approval.\n`);
335
554
  return {
336
555
  summary: `${dispatch.execution.provider} CLI Architect plan produced.`,
337
556
  spec: {
338
557
  repoPath: worktree.worktreePath,
339
558
  path: specPath,
340
559
  body: specBody,
341
- producedBy: `${dispatch.execution.provider}-cli`,
560
+ producedBy: "forge-lane-command",
342
561
  status: "pending_approval"
343
562
  },
344
563
  artifact: architectArtifact,
@@ -1812,38 +2031,255 @@ function isFakeWorkerModel(model) {
1812
2031
  function sha256(content) {
1813
2032
  return crypto.createHash("sha256").update(content).digest("hex");
1814
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
+ }
1815
2136
  function buildDraftPrompt(dispatch) {
1816
2137
  const draft = dispatch.draft;
1817
2138
  if (!draft) {
1818
2139
  throw new Error("Draft prompt is missing.");
1819
2140
  }
1820
- return `${draft.systemPrompt}
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}
1821
2155
 
1822
2156
  Draft kind: ${draft.kind}
1823
2157
 
1824
2158
  ${draft.userPrompt}
1825
2159
 
1826
- Return ONLY the requested JSON object. Do not wrap it in markdown and do not modify files.`;
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.`;
1827
2171
  }
1828
- function extractJsonObject(content) {
1829
- let trimmed = content.trim();
1830
- if (trimmed.startsWith("```")) {
1831
- const firstNewline = trimmed.indexOf("\n");
1832
- const lastFence = trimmed.lastIndexOf("```");
1833
- if (firstNewline >= 0 && lastFence > firstNewline) {
1834
- trimmed = trimmed.slice(firstNewline + 1, lastFence).trim();
1835
- }
1836
- }
1837
- const start = trimmed.indexOf("{");
1838
- const end = trimmed.lastIndexOf("}");
1839
- return start >= 0 && end >= start ? trimmed.slice(start, end + 1) : trimmed;
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());
1840
2179
  }
1841
2180
  function summarizeStderr(stderr) {
1842
2181
  const trimmed = stderr.trim();
1843
2182
  return trimmed ? `: ${clip(trimmed, 1000)}` : "";
1844
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
+ }
1845
2217
  function deterministicDraftPayload(dispatch) {
1846
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
+ }
1847
2283
  if (kind === "work_order") {
1848
2284
  return {
1849
2285
  title: "Deterministic runner Work Order draft",
@@ -1883,7 +2319,7 @@ export function buildProviderCliCommand(dispatch, run) {
1883
2319
  const normalizedProvider = provider.toLowerCase();
1884
2320
  const model = dispatch.execution.model.trim();
1885
2321
  if (normalizedProvider === "codex") {
1886
- const args = run ? ["-s", "workspace-write", "-a", "never", "exec"] : ["exec"];
2322
+ const args = run ? ["-s", codexSandboxModeForPlatform(), "-a", "never", "exec"] : ["exec"];
1887
2323
  if (run?.completionTools) {
1888
2324
  args.push(...codexMcpConfigArgs(run.completionTools));
1889
2325
  }
@@ -1916,6 +2352,9 @@ export function buildProviderCliCommand(dispatch, run) {
1916
2352
  }
1917
2353
  return { command: provider, args };
1918
2354
  }
2355
+ export function codexSandboxModeForPlatform(platform = process.platform) {
2356
+ return platform === "win32" ? "danger-full-access" : "workspace-write";
2357
+ }
1919
2358
  function buildArchitectPrompt(dispatch) {
1920
2359
  return `You are Forge's Architect lane running locally in a prepared git worktree.
1921
2360
 
@@ -1923,6 +2362,7 @@ Work item: ${dispatch.workItem.id} — ${dispatch.workItem.title}
1923
2362
 
1924
2363
  Plan only. Do not implement source changes, do not create Develop phase artifacts, and do not mark the spec approved.
1925
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.
1926
2366
  ${architectRevisionInstruction(dispatch)}
1927
2367
  Forge owns protocol files: write Architect artifacts only through the completion MCP tool.
1928
2368
 
@@ -2228,11 +2668,11 @@ class CursorStreamJsonState {
2228
2668
  if (type === "assistant") {
2229
2669
  const text = assistantMessageText(event);
2230
2670
  if (text) {
2231
- this.finalMessage = clip(text, 4000);
2671
+ this.finalMessage = text;
2232
2672
  return [{
2233
2673
  role: "assistant",
2234
2674
  kind: "message",
2235
- content: this.finalMessage,
2675
+ content: clip(text, 4000),
2236
2676
  eventType: "provider_message"
2237
2677
  }];
2238
2678
  }
@@ -2266,7 +2706,7 @@ class CursorStreamJsonState {
2266
2706
  if (type === "result" && stringField(event, "subtype") === "success") {
2267
2707
  const result = stringField(event, "result");
2268
2708
  if (result) {
2269
- this.finalMessage = clip(result, 4000);
2709
+ this.finalMessage = result;
2270
2710
  }
2271
2711
  this.accumulateUsage(event);
2272
2712
  }
@@ -2447,11 +2887,11 @@ class CodexJsonState {
2447
2887
  if (itemType === "agent_message") {
2448
2888
  const text = stringField(item, "text");
2449
2889
  if (text) {
2450
- this.finalMessage = clip(text, 4000);
2890
+ this.finalMessage = text;
2451
2891
  return [{
2452
2892
  role: "assistant",
2453
2893
  kind: "message",
2454
- content: this.finalMessage,
2894
+ content: clip(text, 4000),
2455
2895
  eventType: "provider_message"
2456
2896
  }];
2457
2897
  }