Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "desktop",
"version": "0.5.0",
"version": "0.6.0",
"description": "ClosedLoop Desktop",
"author": "ClosedLoop AI <support@closedloop.ai>",
"private": true,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/job-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type LocalJobStatus =

export type LocalJobKind = "SYMPHONY_LOOP";

export type LocalJobCommand = "PLAN" | "EXECUTE" | "REQUEST_CHANGES" | "DECOMPOSE";
export type LocalJobCommand = "PLAN" | "EXECUTE" | "REQUEST_CHANGES" | "DECOMPOSE" | "GENERATE_PRD";

export type TaskProgress = {
pending: number;
Expand Down
169 changes: 164 additions & 5 deletions apps/desktop/src/server/operations/symphony-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ import {
// Types
// ---------------------------------------------------------------------------

type LoopCommand = "PLAN" | "EXECUTE" | "REQUEST_CHANGES" | "DECOMPOSE";
type LoopCommand = "PLAN" | "EXECUTE" | "REQUEST_CHANGES" | "DECOMPOSE" | "GENERATE_PRD";

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

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

/**
* Remove a GENERATE_PRD worktree via git worktree remove, falling back to
* fs.rm + git worktree prune. Used from both handleProcessCompletion and
* early-return cleanup in handleLoopRequest.
*/
async function cleanupGeneratePrdWorktree(
worktreeDir: string,
expandedRepoPath: string,
loopId?: string
): Promise<void> {
try {
execSync(`git worktree remove --force ${shellEscape(worktreeDir)}`, {
cwd: expandedRepoPath,
stdio: "pipe",
timeout: 15_000,
});
} catch {
if (loopId) {
loopLog(loopId, `git worktree remove failed for GENERATE_PRD, falling back to fs.rm`);
}
await fs.rm(worktreeDir, { recursive: true, force: true });
try {
execSync("git worktree prune", { cwd: expandedRepoPath, stdio: "pipe", timeout: 10_000 });
} catch {
// Best-effort
}
}
}

// ---------------------------------------------------------------------------
// Per-command artifact writing
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -468,6 +497,44 @@ async function writeArtifactsForDecompose(
}
}

/**
* Write context pack files for GENERATE_PRD command.
* Mirrors writeContextPackFiles in harness-agent.mjs (lines 744-816).
* Files go under worktreeDir/.claude/context/ (NOT claudeWorkDir).
*/
async function writeArtifactsForGeneratePrd(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like this got refactored in #30

good to synchronize on the correct one-true-way to do this.

worktreeDir: string,
artifacts: LoopArtifact[],
prompt: string,
repo?: unknown
): Promise<void> {
const contextDir = path.join(worktreeDir, ".claude", "context");
const artifactsDir = path.join(contextDir, "artifacts");
await fs.mkdir(artifactsDir, { recursive: true });

// Write prompt
await fs.writeFile(path.join(contextDir, "prompt.md"), prompt);

// Write repo-info.json when present
if (repo) {
await fs.writeFile(
path.join(contextDir, "repo-info.json"),
JSON.stringify(repo, null, 2)
);
}

// Write each artifact
for (const artifact of artifacts) {
const safeName = artifact.type.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
const safeId = (artifact.id ?? "unknown").replace(/[^a-zA-Z0-9_-]/g, "_");
const header = `# ${artifact.title ?? "Untitled"}\n\n`;
await fs.writeFile(
path.join(artifactsDir, `${safeName}-${safeId}.md`),
header + artifact.content
);
}
}

// ---------------------------------------------------------------------------
// Per-command output reading
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -527,6 +594,11 @@ function readDecomposeOutputs(workDir: string): Record<string, unknown> {
return { features: features ?? undefined };
}

function readGeneratePrdOutputs(worktreeDir: string): Record<string, unknown> {
const prdContent = readTextFile(path.join(worktreeDir, "prd.md"));
return { prd: prdContent ? { content: prdContent } : undefined };
}

/** Parse token usage from claude-output.jsonl (JSONL stream output). */
function parseTokenUsage(claudeWorkDir: string): { input: number; output: number } {
const totals = { input: 0, output: 0 };
Expand Down Expand Up @@ -684,6 +756,7 @@ async function handleProcessCompletion(
apiBaseUrl: string,
worktreeDir: string | null,
claudeWorkDir: string,
expandedRepoPath: string | null,
jobStore?: JobStore
): Promise<void> {
const { loopId, command, closedLoopAuthToken, committer } = body;
Expand Down Expand Up @@ -713,6 +786,9 @@ async function handleProcessCompletion(
});
}
}
if (command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) {
await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, loopId);
}
return;
}

Expand Down Expand Up @@ -749,6 +825,8 @@ async function handleProcessCompletion(
}
} else if (command === "DECOMPOSE") {
artifacts = readDecomposeOutputs(claudeWorkDir);
} else if (command === "GENERATE_PRD") {
artifacts = readGeneratePrdOutputs(worktreeDir ?? claudeWorkDir);
}

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

Expand Down Expand Up @@ -886,6 +966,11 @@ async function handleLoopRequest(
return;
}

if (body.command === "GENERATE_PRD" && (typeof body.prompt !== "string" || !body.prompt.trim())) {
json(context, 400, { error: "No prompt found for GENERATE_PRD" });
return;
}

if (runningLoops.has(body.loopId)) {
json(context, 409, { error: "Loop is already running on this machine" });
return;
Expand Down Expand Up @@ -943,7 +1028,7 @@ async function handleLoopRequest(
await writeArtifactsForDecompose(claudeWorkDir, body.artifacts, body.prompt);
} else if (!expandedRepoPath) {
json(context, 400, {
error: "Repository required for PLAN, EXECUTE, and REQUEST_CHANGES commands",
error: "Repository required for PLAN, EXECUTE, REQUEST_CHANGES, and GENERATE_PRD commands",
});
return;
} else if (body.command === "PLAN" || body.command === "EXECUTE" || body.command === "REQUEST_CHANGES") {
Expand Down Expand Up @@ -1044,6 +1129,51 @@ async function handleLoopRequest(
body.prompt
);
}
} else if (body.command === "GENERATE_PRD") {
// Use a dedicated branch namespace to avoid collisions with PLAN/EXECUTE worktrees.
// GENERATE_PRD always starts fresh -- it must not inherit a prior PLAN worktree.
const sanitizedSlug = body.artifactSlug
? slugifyLoopId(body.artifactSlug)
: null;
const worktreeKey = sanitizedSlug ?? pickStableId(body);
const branchName = sanitizedSlug
? `symphony/generate-prd-${sanitizedSlug}`
: `symphony/generate-prd-${pickStableId(body)}`;

worktreeDir = resolveLoopWorktreeDir(expandedRepoPath, `generate-prd-${worktreeKey}`);

// Always start fresh: remove any stale worktree for this branch before creation.
const staleWorktree = findWorktreeForBranch(expandedRepoPath, branchName);
Comment thread
shafty023 marked this conversation as resolved.
if (staleWorktree) {
loopLog(body.loopId, `Removing stale worktree for fresh GENERATE_PRD: ${staleWorktree}`);
await cleanupGeneratePrdWorktree(staleWorktree, expandedRepoPath, body.loopId);
}

await ensureWorktree(
expandedRepoPath,
worktreeDir,
branchName,
body.repo?.branch ?? "main"
);
loopLog(body.loopId, `Created worktree for GENERATE_PRD: ${worktreeDir} (branch: ${branchName})`);

try {
assertPathAllowed(worktreeDir, allowedDirs);
} catch (e) {
if (e instanceof DirectoryNotAllowedError) {
await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, body.loopId);
json(context, 403, { error: `Worktree path not allowed: ${worktreeDir}` });
return;
Comment thread
shafty023 marked this conversation as resolved.
}
throw e;
}

// claudeWorkDir is a separate operational dir inside the worktree (same pattern as PLAN/EXECUTE).
// Spawn uses cwd: worktreeDir so Claude writes prd.md to the repo root.
// Logs, PID, and prompt file go to claudeWorkDir, not the repo root.
claudeWorkDir = path.join(worktreeDir, ".claude", "work");
await fs.mkdir(claudeWorkDir, { recursive: true });
await writeArtifactsForGeneratePrd(worktreeDir, body.artifacts, body.prompt!, body.repo);
} else {
json(context, 400, { error: `Unknown command: ${body.command}` });
return;
Expand All @@ -1052,7 +1182,7 @@ async function handleLoopRequest(
// Pre-flight: verify required binary exists BEFORE posting 'started' event.
// PLAN and EXECUTE use run-loop.sh; REQUEST_CHANGES and DECOMPOSE use claude CLI directly.
const usesRunLoop = body.command === "PLAN" || body.command === "EXECUTE";
const usesClaude = body.command === "REQUEST_CHANGES" || body.command === "DECOMPOSE";
const usesClaude = body.command === "REQUEST_CHANGES" || body.command === "DECOMPOSE" || body.command === "GENERATE_PRD";
let scriptPath: string | null = null;

if (usesClaude) {
Expand All @@ -1068,6 +1198,9 @@ async function handleLoopRequest(
code: "BINARY_NOT_FOUND", message: "claude CLI not found in PATH",
}
);
if (body.command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) {
await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, body.loopId);
}
json(context, 500, { error: "claude CLI not found in PATH" });
return;
}
Expand Down Expand Up @@ -1108,6 +1241,9 @@ async function handleLoopRequest(
type: "error",
code: "SPAWN_FAILED", message: `Cannot open log file: ${msg}`,
});
if (body.command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) {
await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, body.loopId);
}
json(context, 500, { error: `Cannot open log file: ${msg}` });
return;
}
Expand Down Expand Up @@ -1184,6 +1320,26 @@ async function handleLoopRequest(
env: spawnEnv,
});
child.unref();
} else if (body.command === "GENERATE_PRD") {
const promptFile = path.join(claudeWorkDir, "generate-prd-prompt.txt");
await fs.writeFile(promptFile, body.prompt!);

const claudeArgs = [
"-p", "-",
"--output-format", "stream-json",
"--verbose",
"--allowedTools",
"Bash,Glob,Grep,Read,Write,Edit,Task,Skill,SlashCommand,TodoWrite",
"--max-turns", "200",
];
const pipeline = buildClaudePipeline(claudeArgs, claudeWorkDir, promptFile);
child = spawn(pipeline.cmd, pipeline.args, {
cwd: worktreeDir!,
detached: true,
stdio: ["ignore", logFd, logFd],
env: spawnEnv,
});
child.unref();
} else {
// PLAN, EXECUTE: spawn run-loop.sh
// Build args matching ECS harness-agent's buildRunLoopArgs():
Expand Down Expand Up @@ -1215,6 +1371,9 @@ async function handleLoopRequest(
type: "error",
code: "SPAWN_FAILED", message: msg,
});
if (body.command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) {
await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, body.loopId);
}
json(context, 500, { error: `Failed to spawn process: ${msg}` });
return;
}
Expand All @@ -1228,7 +1387,7 @@ async function handleLoopRequest(
}
completionHandled = true;
loopLog(body.loopId, `onceComplete fired, code=${code}`);
handleProcessCompletion(code, body, apiBaseUrl, worktreeDir, claudeWorkDir, jobStore).catch(
handleProcessCompletion(code, body, apiBaseUrl, worktreeDir, claudeWorkDir, expandedRepoPath, jobStore).catch(
(err) => loopError(body.loopId, "Completion handler error:", err)
);
};
Expand Down
Loading
Loading