@@ -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
2828interface 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 - z 0 - 9 _ - ] / g, "_" ) ;
529+ const safeId = ( artifact . id ?? "unknown" ) . replace ( / [ ^ a - z A - Z 0 - 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). */
531603function 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