Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

Commit 7a66503

Browse files
authored
Merge pull request #35 from closedloop-ai/FEAT-85
FEAT-85: Add GENERATE_PRD command to desktop loop handler
2 parents 46f5bc2 + 1bb2a06 commit 7a66503

4 files changed

Lines changed: 875 additions & 7 deletions

File tree

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "desktop",
3-
"version": "0.5.0",
3+
"version": "0.6.0",
44
"description": "ClosedLoop Desktop",
55
"author": "ClosedLoop AI <support@closedloop.ai>",
66
"private": true,

apps/desktop/src/main/job-store.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export type LocalJobStatus =
1414

1515
export type LocalJobKind = "SYMPHONY_LOOP";
1616

17-
export type LocalJobCommand = "PLAN" | "EXECUTE" | "REQUEST_CHANGES" | "DECOMPOSE";
17+
export type LocalJobCommand = "PLAN" | "EXECUTE" | "REQUEST_CHANGES" | "DECOMPOSE" | "GENERATE_PRD";
1818

1919
export type TaskProgress = {
2020
pending: number;

apps/desktop/src/server/operations/symphony-loop.ts

Lines changed: 164 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ import {
2121
// Types
2222
// ---------------------------------------------------------------------------
2323

24-
type LoopCommand = "PLAN" | "EXECUTE" | "REQUEST_CHANGES" | "DECOMPOSE";
24+
type LoopCommand = "PLAN" | "EXECUTE" | "REQUEST_CHANGES" | "DECOMPOSE" | "GENERATE_PRD";
2525

26-
const VALID_COMMANDS = new Set<LoopCommand>(["PLAN", "EXECUTE", "REQUEST_CHANGES", "DECOMPOSE"]);
26+
const VALID_COMMANDS = new Set<LoopCommand>(["PLAN", "EXECUTE", "REQUEST_CHANGES", "DECOMPOSE", "GENERATE_PRD"]);
2727

2828
interface LoopArtifact {
2929
id?: string;
@@ -369,6 +369,35 @@ function findWorktreeForBranch(
369369
// PLAN always creates a fresh worktree. EXECUTE/REQUEST_CHANGES reuse via
370370
// findWorktreeForBranch(parentBranchName) which matches the specific parent.
371371

372+
/**
373+
* Remove a GENERATE_PRD worktree via git worktree remove, falling back to
374+
* fs.rm + git worktree prune. Used from both handleProcessCompletion and
375+
* early-return cleanup in handleLoopRequest.
376+
*/
377+
async function cleanupGeneratePrdWorktree(
378+
worktreeDir: string,
379+
expandedRepoPath: string,
380+
loopId?: string
381+
): Promise<void> {
382+
try {
383+
execSync(`git worktree remove --force ${shellEscape(worktreeDir)}`, {
384+
cwd: expandedRepoPath,
385+
stdio: "pipe",
386+
timeout: 15_000,
387+
});
388+
} catch {
389+
if (loopId) {
390+
loopLog(loopId, `git worktree remove failed for GENERATE_PRD, falling back to fs.rm`);
391+
}
392+
await fs.rm(worktreeDir, { recursive: true, force: true });
393+
try {
394+
execSync("git worktree prune", { cwd: expandedRepoPath, stdio: "pipe", timeout: 10_000 });
395+
} catch {
396+
// Best-effort
397+
}
398+
}
399+
}
400+
372401
// ---------------------------------------------------------------------------
373402
// Per-command artifact writing
374403
// ---------------------------------------------------------------------------
@@ -468,6 +497,44 @@ async function writeArtifactsForDecompose(
468497
}
469498
}
470499

500+
/**
501+
* Write context pack files for GENERATE_PRD command.
502+
* Mirrors writeContextPackFiles in harness-agent.mjs (lines 744-816).
503+
* Files go under worktreeDir/.claude/context/ (NOT claudeWorkDir).
504+
*/
505+
async function writeArtifactsForGeneratePrd(
506+
worktreeDir: string,
507+
artifacts: LoopArtifact[],
508+
prompt: string,
509+
repo?: unknown
510+
): Promise<void> {
511+
const contextDir = path.join(worktreeDir, ".claude", "context");
512+
const artifactsDir = path.join(contextDir, "artifacts");
513+
await fs.mkdir(artifactsDir, { recursive: true });
514+
515+
// Write prompt
516+
await fs.writeFile(path.join(contextDir, "prompt.md"), prompt);
517+
518+
// Write repo-info.json when present
519+
if (repo) {
520+
await fs.writeFile(
521+
path.join(contextDir, "repo-info.json"),
522+
JSON.stringify(repo, null, 2)
523+
);
524+
}
525+
526+
// Write each artifact
527+
for (const artifact of artifacts) {
528+
const safeName = artifact.type.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
529+
const safeId = (artifact.id ?? "unknown").replace(/[^a-zA-Z0-9_-]/g, "_");
530+
const header = `# ${artifact.title ?? "Untitled"}\n\n`;
531+
await fs.writeFile(
532+
path.join(artifactsDir, `${safeName}-${safeId}.md`),
533+
header + artifact.content
534+
);
535+
}
536+
}
537+
471538
// ---------------------------------------------------------------------------
472539
// Per-command output reading
473540
// ---------------------------------------------------------------------------
@@ -527,6 +594,11 @@ function readDecomposeOutputs(workDir: string): Record<string, unknown> {
527594
return { features: features ?? undefined };
528595
}
529596

597+
function readGeneratePrdOutputs(worktreeDir: string): Record<string, unknown> {
598+
const prdContent = readTextFile(path.join(worktreeDir, "prd.md"));
599+
return { prd: prdContent ? { content: prdContent } : undefined };
600+
}
601+
530602
/** Parse token usage from claude-output.jsonl (JSONL stream output). */
531603
function parseTokenUsage(claudeWorkDir: string): { input: number; output: number } {
532604
const totals = { input: 0, output: 0 };
@@ -684,6 +756,7 @@ async function handleProcessCompletion(
684756
apiBaseUrl: string,
685757
worktreeDir: string | null,
686758
claudeWorkDir: string,
759+
expandedRepoPath: string | null,
687760
jobStore?: JobStore
688761
): Promise<void> {
689762
const { loopId, command, closedLoopAuthToken, committer } = body;
@@ -713,6 +786,9 @@ async function handleProcessCompletion(
713786
});
714787
}
715788
}
789+
if (command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) {
790+
await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, loopId);
791+
}
716792
return;
717793
}
718794

@@ -749,6 +825,8 @@ async function handleProcessCompletion(
749825
}
750826
} else if (command === "DECOMPOSE") {
751827
artifacts = readDecomposeOutputs(claudeWorkDir);
828+
} else if (command === "GENERATE_PRD") {
829+
artifacts = readGeneratePrdOutputs(worktreeDir ?? claudeWorkDir);
752830
}
753831

754832
// Read session ID if available
@@ -834,6 +912,8 @@ async function handleProcessCompletion(
834912
// Clean up DECOMPOSE temp directory after all reads and uploads are complete
835913
if (command === "DECOMPOSE") {
836914
fs.rm(claudeWorkDir, { recursive: true, force: true }).catch(() => {});
915+
} else if (command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) {
916+
await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, loopId);
837917
}
838918
}
839919

@@ -886,6 +966,11 @@ async function handleLoopRequest(
886966
return;
887967
}
888968

969+
if (body.command === "GENERATE_PRD" && (typeof body.prompt !== "string" || !body.prompt.trim())) {
970+
json(context, 400, { error: "No prompt found for GENERATE_PRD" });
971+
return;
972+
}
973+
889974
if (runningLoops.has(body.loopId)) {
890975
json(context, 409, { error: "Loop is already running on this machine" });
891976
return;
@@ -943,7 +1028,7 @@ async function handleLoopRequest(
9431028
await writeArtifactsForDecompose(claudeWorkDir, body.artifacts, body.prompt);
9441029
} else if (!expandedRepoPath) {
9451030
json(context, 400, {
946-
error: "Repository required for PLAN, EXECUTE, and REQUEST_CHANGES commands",
1031+
error: "Repository required for PLAN, EXECUTE, REQUEST_CHANGES, and GENERATE_PRD commands",
9471032
});
9481033
return;
9491034
} else if (body.command === "PLAN" || body.command === "EXECUTE" || body.command === "REQUEST_CHANGES") {
@@ -1044,6 +1129,51 @@ async function handleLoopRequest(
10441129
body.prompt
10451130
);
10461131
}
1132+
} else if (body.command === "GENERATE_PRD") {
1133+
// Use a dedicated branch namespace to avoid collisions with PLAN/EXECUTE worktrees.
1134+
// GENERATE_PRD always starts fresh -- it must not inherit a prior PLAN worktree.
1135+
const sanitizedSlug = body.artifactSlug
1136+
? slugifyLoopId(body.artifactSlug)
1137+
: null;
1138+
const worktreeKey = sanitizedSlug ?? pickStableId(body);
1139+
const branchName = sanitizedSlug
1140+
? `symphony/generate-prd-${sanitizedSlug}`
1141+
: `symphony/generate-prd-${pickStableId(body)}`;
1142+
1143+
worktreeDir = resolveLoopWorktreeDir(expandedRepoPath, `generate-prd-${worktreeKey}`);
1144+
1145+
// Always start fresh: remove any stale worktree for this branch before creation.
1146+
const staleWorktree = findWorktreeForBranch(expandedRepoPath, branchName);
1147+
if (staleWorktree) {
1148+
loopLog(body.loopId, `Removing stale worktree for fresh GENERATE_PRD: ${staleWorktree}`);
1149+
await cleanupGeneratePrdWorktree(staleWorktree, expandedRepoPath, body.loopId);
1150+
}
1151+
1152+
await ensureWorktree(
1153+
expandedRepoPath,
1154+
worktreeDir,
1155+
branchName,
1156+
body.repo?.branch ?? "main"
1157+
);
1158+
loopLog(body.loopId, `Created worktree for GENERATE_PRD: ${worktreeDir} (branch: ${branchName})`);
1159+
1160+
try {
1161+
assertPathAllowed(worktreeDir, allowedDirs);
1162+
} catch (e) {
1163+
if (e instanceof DirectoryNotAllowedError) {
1164+
await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, body.loopId);
1165+
json(context, 403, { error: `Worktree path not allowed: ${worktreeDir}` });
1166+
return;
1167+
}
1168+
throw e;
1169+
}
1170+
1171+
// claudeWorkDir is a separate operational dir inside the worktree (same pattern as PLAN/EXECUTE).
1172+
// Spawn uses cwd: worktreeDir so Claude writes prd.md to the repo root.
1173+
// Logs, PID, and prompt file go to claudeWorkDir, not the repo root.
1174+
claudeWorkDir = path.join(worktreeDir, ".claude", "work");
1175+
await fs.mkdir(claudeWorkDir, { recursive: true });
1176+
await writeArtifactsForGeneratePrd(worktreeDir, body.artifacts, body.prompt!, body.repo);
10471177
} else {
10481178
json(context, 400, { error: `Unknown command: ${body.command}` });
10491179
return;
@@ -1052,7 +1182,7 @@ async function handleLoopRequest(
10521182
// Pre-flight: verify required binary exists BEFORE posting 'started' event.
10531183
// PLAN and EXECUTE use run-loop.sh; REQUEST_CHANGES and DECOMPOSE use claude CLI directly.
10541184
const usesRunLoop = body.command === "PLAN" || body.command === "EXECUTE";
1055-
const usesClaude = body.command === "REQUEST_CHANGES" || body.command === "DECOMPOSE";
1185+
const usesClaude = body.command === "REQUEST_CHANGES" || body.command === "DECOMPOSE" || body.command === "GENERATE_PRD";
10561186
let scriptPath: string | null = null;
10571187

10581188
if (usesClaude) {
@@ -1068,6 +1198,9 @@ async function handleLoopRequest(
10681198
code: "BINARY_NOT_FOUND", message: "claude CLI not found in PATH",
10691199
}
10701200
);
1201+
if (body.command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) {
1202+
await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, body.loopId);
1203+
}
10711204
json(context, 500, { error: "claude CLI not found in PATH" });
10721205
return;
10731206
}
@@ -1108,6 +1241,9 @@ async function handleLoopRequest(
11081241
type: "error",
11091242
code: "SPAWN_FAILED", message: `Cannot open log file: ${msg}`,
11101243
});
1244+
if (body.command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) {
1245+
await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, body.loopId);
1246+
}
11111247
json(context, 500, { error: `Cannot open log file: ${msg}` });
11121248
return;
11131249
}
@@ -1184,6 +1320,26 @@ async function handleLoopRequest(
11841320
env: spawnEnv,
11851321
});
11861322
child.unref();
1323+
} else if (body.command === "GENERATE_PRD") {
1324+
const promptFile = path.join(claudeWorkDir, "generate-prd-prompt.txt");
1325+
await fs.writeFile(promptFile, body.prompt!);
1326+
1327+
const claudeArgs = [
1328+
"-p", "-",
1329+
"--output-format", "stream-json",
1330+
"--verbose",
1331+
"--allowedTools",
1332+
"Bash,Glob,Grep,Read,Write,Edit,Task,Skill,SlashCommand,TodoWrite",
1333+
"--max-turns", "200",
1334+
];
1335+
const pipeline = buildClaudePipeline(claudeArgs, claudeWorkDir, promptFile);
1336+
child = spawn(pipeline.cmd, pipeline.args, {
1337+
cwd: worktreeDir!,
1338+
detached: true,
1339+
stdio: ["ignore", logFd, logFd],
1340+
env: spawnEnv,
1341+
});
1342+
child.unref();
11871343
} else {
11881344
// PLAN, EXECUTE: spawn run-loop.sh
11891345
// Build args matching ECS harness-agent's buildRunLoopArgs():
@@ -1215,6 +1371,9 @@ async function handleLoopRequest(
12151371
type: "error",
12161372
code: "SPAWN_FAILED", message: msg,
12171373
});
1374+
if (body.command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) {
1375+
await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, body.loopId);
1376+
}
12181377
json(context, 500, { error: `Failed to spawn process: ${msg}` });
12191378
return;
12201379
}
@@ -1228,7 +1387,7 @@ async function handleLoopRequest(
12281387
}
12291388
completionHandled = true;
12301389
loopLog(body.loopId, `onceComplete fired, code=${code}`);
1231-
handleProcessCompletion(code, body, apiBaseUrl, worktreeDir, claudeWorkDir, jobStore).catch(
1390+
handleProcessCompletion(code, body, apiBaseUrl, worktreeDir, claudeWorkDir, expandedRepoPath, jobStore).catch(
12321391
(err) => loopError(body.loopId, "Completion handler error:", err)
12331392
);
12341393
};

0 commit comments

Comments
 (0)