diff --git a/README.md b/README.md index f94fea295..65d5c4681 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ GSD v2 solves all of these because it's not a prompt framework anymore — it's ### Migrating from v1 -> **Note:** A `ROADMAP.md` file is **required** for migration. If your project doesn't have one, you'll need to create it before running the migration command. +> **Note:** Migration works best with a `ROADMAP.md` file for milestone structure. Without one, milestones are inferred from the `phases/` directory. If you have projects with `.planning` directories from the original Get Shit Done, you can migrate them to GSD-2's `.gsd` format: diff --git a/src/cli.ts b/src/cli.ts index 97ed54a79..813f954ec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { createAgentSession, InteractiveMode, runPrintMode, + runRpcMode, } from '@mariozechner/pi-coding-agent' import { existsSync, readdirSync, renameSync, readFileSync } from 'node:fs' import { join } from 'node:path' @@ -49,6 +50,22 @@ function parseCliArgs(argv: string[]): CliFlags { flags.appendSystemPrompt = args[++i] } else if (arg === '--tools' && i + 1 < args.length) { flags.tools = args[++i].split(',') + } else if (arg === '--version' || arg === '-v') { + process.stdout.write((process.env.GSD_VERSION || '0.0.0') + '\n') + process.exit(0) + } else if (arg === '--help' || arg === '-h') { + process.stdout.write(`GSD v${process.env.GSD_VERSION || '0.0.0'} — Get Shit Done\n\n`) + process.stdout.write('Usage: gsd [options] [message...]\n\n') + process.stdout.write('Options:\n') + process.stdout.write(' --mode Output mode (default: interactive)\n') + process.stdout.write(' --print, -p Single-shot print mode\n') + process.stdout.write(' --model Override model (e.g. claude-opus-4-6)\n') + process.stdout.write(' --no-session Disable session persistence\n') + process.stdout.write(' --extension Load additional extension\n') + process.stdout.write(' --tools Restrict available tools\n') + process.stdout.write(' --version, -v Print version and exit\n') + process.stdout.write(' --help, -h Print this help and exit\n') + process.exit(0) } else if (!arg.startsWith('--') && !arg.startsWith('-')) { flags.messages.push(arg) } @@ -164,8 +181,14 @@ if (isPrintMode) { } const mode = cliFlags.mode || 'text' + + if (mode === 'rpc') { + await runRpcMode(session) + process.exit(0) + } + await runPrintMode(session, { - mode: mode === 'rpc' ? 'json' : mode, + mode, messages: cliFlags.messages, }) process.exit(0) @@ -267,5 +290,14 @@ if (enabledModelPatterns && enabledModelPatterns.length > 0) { } } +if (!process.stdin.isTTY) { + process.stderr.write('[gsd] Error: Interactive mode requires a terminal (TTY).\n') + process.stderr.write('[gsd] Non-interactive alternatives:\n') + process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n') + process.stderr.write('[gsd] gsd --mode rpc JSON-RPC over stdin/stdout\n') + process.stderr.write('[gsd] gsd --mode text "message" Text output mode\n') + process.exit(1) +} + const interactiveMode = new InteractiveMode(session) await interactiveMode.run() diff --git a/src/resources/extensions/google-search/index.ts b/src/resources/extensions/google-search/index.ts index f19ac9155..409ae5b5f 100644 --- a/src/resources/extensions/google-search/index.ts +++ b/src/resources/extensions/google-search/index.ts @@ -141,7 +141,7 @@ export default function (pi: ExtensionAPI) { try { const ai = getClient(); const response = await ai.models.generateContent({ - model: "gemini-2.5-flash", + model: process.env.GEMINI_SEARCH_MODEL || "gemini-2.5-flash", contents: params.query, config: { tools: [{ googleSearch: {} }], diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 3e54b13e2..bbc061bea 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -61,7 +61,9 @@ import { autoCommitCurrentBranch, ensureSliceBranch, getCurrentBranch, + getMainBranch, getSliceBranchName, + parseSliceBranch, switchToMain, mergeSliceToMain, } from "./worktree.ts"; @@ -333,6 +335,7 @@ export async function startAuto( stepMode = requestedStepMode; cmdCtx = ctx; basePath = base; + unitDispatchCount.clear(); // Re-initialize metrics in case ledger was lost during pause if (!getLedger()) initMetrics(base); ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto"); @@ -974,10 +977,10 @@ async function dispatchNextUnit( // - complete-milestone runs on a slice branch (last slice bypass) { const currentBranch = getCurrentBranch(basePath); - const branchMatch = currentBranch.match(/^gsd\/(M\d+)\/(S\d+)$/); - if (branchMatch) { - const branchMid = branchMatch[1]!; - const branchSid = branchMatch[2]!; + const parsedBranch = parseSliceBranch(currentBranch); + if (parsedBranch) { + const branchMid = parsedBranch.milestoneId; + const branchSid = parsedBranch.sliceId; // Check if this slice is marked done in the roadmap const roadmapFile = resolveMilestoneFile(basePath, branchMid, "ROADMAP"); const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; @@ -991,8 +994,9 @@ async function dispatchNextUnit( const mergeResult = mergeSliceToMain( basePath, branchMid, branchSid, sliceTitleForMerge, ); + const targetBranch = getMainBranch(basePath); ctx.ui.notify( - `Merged ${mergeResult.branch} → main.`, + `Merged ${mergeResult.branch} → ${targetBranch}.`, "info", ); // Re-derive state from main so downstream logic sees merged state @@ -1071,112 +1075,114 @@ async function dispatchNextUnit( // can perform the UAT manually. On next resume, result file will exist → skip. let pauseAfterUatDispatch = false; - // ── Adaptive Replanning: check if last completed slice needs reassessment ── - // After a slice completes, we reassess the roadmap before moving to the next slice. - // Skip reassessment for the final slice (milestone complete) or if already assessed. - const needsReassess = await checkNeedsReassessment(basePath, mid, state); + // ── Phase-first dispatch: complete-slice MUST run before reassessment ── + // If the current phase is "summarizing", complete-slice is responsible for + // mergeSliceToMain. Reassessment must wait until the merge is done. if (state.phase === "summarizing") { - // complete-slice MUST run before reassessment to guarantee mergeSliceToMain const sid = state.activeSlice!.id; const sTitle = state.activeSlice!.title; unitType = "complete-slice"; unitId = `${mid}/${sid}`; prompt = await buildCompleteSlicePrompt(mid, midTitle!, sid, sTitle, basePath); + } else { + // ── Adaptive Replanning: check if last completed slice needs reassessment ── + // Computed here (after summarizing guard) so complete-slice always runs first. + const needsReassess = await checkNeedsReassessment(basePath, mid, state); + if (needsRunUat) { + const { sliceId, uatType } = needsRunUat; + unitType = "run-uat"; + unitId = `${mid}/${sliceId}`; + const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!; + const uatContent = await loadFile(uatFile); + prompt = await buildRunUatPrompt( + mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath, + ); + // For non-artifact-driven UAT types, pause after the prompt is dispatched. + // The agent receives the prompt, writes S0x-UAT-RESULT.md surfacing the UAT, + // then auto-mode pauses for human execution. On resume, result file exists → skip. + if (uatType !== "artifact-driven") { + pauseAfterUatDispatch = true; + } + } else if (needsReassess) { + unitType = "reassess-roadmap"; + unitId = `${mid}/${needsReassess.sliceId}`; + prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath); + } else if (state.phase === "pre-planning") { + // Need roadmap — check if context exists + const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const hasContext = !!(contextFile && await loadFile(contextFile)); - } else if (needsRunUat) { - const { sliceId, uatType } = needsRunUat; - unitType = "run-uat"; - unitId = `${mid}/${sliceId}`; - const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!; - const uatContent = await loadFile(uatFile); - prompt = await buildRunUatPrompt( - mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath, - ); - // For non-artifact-driven UAT types, pause after the prompt is dispatched. - // The agent receives the prompt, writes S0x-UAT-RESULT.md surfacing the UAT, - // then auto-mode pauses for human execution. On resume, result file exists → skip. - if (uatType !== "artifact-driven") { - pauseAfterUatDispatch = true; - } - } else if (needsReassess) { - unitType = "reassess-roadmap"; - unitId = `${mid}/${needsReassess.sliceId}`; - prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath); - } else if (state.phase === "pre-planning") { - // Need roadmap — check if context exists - const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); - const hasContext = !!(contextFile && await loadFile(contextFile)); + if (!hasContext) { + await stopAuto(ctx, pi); + ctx.ui.notify("No context or roadmap yet. Run /gsd to discuss first.", "warning"); + return; + } - if (!hasContext) { + // Research before roadmap if no research exists + const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH"); + const hasResearch = !!(researchFile && await loadFile(researchFile)); + + if (!hasResearch) { + unitType = "research-milestone"; + unitId = mid; + prompt = await buildResearchMilestonePrompt(mid, midTitle!, basePath); + } else { + unitType = "plan-milestone"; + unitId = mid; + prompt = await buildPlanMilestonePrompt(mid, midTitle!, basePath); + } + + } else if (state.phase === "planning") { + // Slice needs planning — but research first if no research exists + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH"); + const hasResearch = !!(researchFile && await loadFile(researchFile)); + + if (!hasResearch) { + unitType = "research-slice"; + unitId = `${mid}/${sid}`; + prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath); + } else { + unitType = "plan-slice"; + unitId = `${mid}/${sid}`; + prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath); + } + + } else if (state.phase === "replanning-slice") { + // Blocker discovered — replan the slice before continuing + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + unitType = "replan-slice"; + unitId = `${mid}/${sid}`; + prompt = await buildReplanSlicePrompt(mid, midTitle!, sid, sTitle, basePath); + + } else if (state.phase === "executing" && state.activeTask) { + // Execute next task + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + const tid = state.activeTask.id; + const tTitle = state.activeTask.title; + unitType = "execute-task"; + unitId = `${mid}/${sid}/${tid}`; + prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath); + + } else if (state.phase === "completing-milestone") { + // All slices done — complete the milestone + unitType = "complete-milestone"; + unitId = mid; + prompt = await buildCompleteMilestonePrompt(mid, midTitle!, basePath); + + } else { + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); + } await stopAuto(ctx, pi); - ctx.ui.notify("No context or roadmap yet. Run /gsd to discuss first.", "warning"); + ctx.ui.notify(`Unexpected phase: ${state.phase}. Stopping auto-mode.`, "warning"); return; } - - // Research before roadmap if no research exists - const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH"); - const hasResearch = !!(researchFile && await loadFile(researchFile)); - - if (!hasResearch) { - unitType = "research-milestone"; - unitId = mid; - prompt = await buildResearchMilestonePrompt(mid, midTitle!, basePath); - } else { - unitType = "plan-milestone"; - unitId = mid; - prompt = await buildPlanMilestonePrompt(mid, midTitle!, basePath); - } - - } else if (state.phase === "planning") { - // Slice needs planning — but research first if no research exists - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; - const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH"); - const hasResearch = !!(researchFile && await loadFile(researchFile)); - - if (!hasResearch) { - unitType = "research-slice"; - unitId = `${mid}/${sid}`; - prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath); - } else { - unitType = "plan-slice"; - unitId = `${mid}/${sid}`; - prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath); - } - - } else if (state.phase === "replanning-slice") { - // Blocker discovered — replan the slice before continuing - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; - unitType = "replan-slice"; - unitId = `${mid}/${sid}`; - prompt = await buildReplanSlicePrompt(mid, midTitle!, sid, sTitle, basePath); - - } else if (state.phase === "executing" && state.activeTask) { - // Execute next task - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; - const tid = state.activeTask.id; - const tTitle = state.activeTask.title; - unitType = "execute-task"; - unitId = `${mid}/${sid}/${tid}`; - prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath); - - } else if (state.phase === "completing-milestone") { - // All slices done — complete the milestone - unitType = "complete-milestone"; - unitId = mid; - prompt = await buildCompleteMilestonePrompt(mid, midTitle!, basePath); - - } else { - if (currentUnit) { - const modelId = ctx.model?.id ?? "unknown"; - snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); - saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); - } - await stopAuto(ctx, pi); - ctx.ui.notify(`Unexpected phase: ${state.phase}. Stopping auto-mode.`, "warning"); - return; } await emitObservabilityWarnings(ctx, unitType, unitId); diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts index fe93f0cd3..a640bc336 100644 --- a/src/resources/extensions/gsd/files.ts +++ b/src/resources/extensions/gsd/files.ts @@ -347,21 +347,23 @@ export function parseSummary(content: string): Summary { const [fmLines, body] = splitFrontmatter(content); const fm = fmLines ? parseFrontmatterMap(fmLines) : {}; + const asStringArray = (v: unknown): string[] => + Array.isArray(v) ? v : (typeof v === 'string' && v ? [v] : []); const frontmatter: SummaryFrontmatter = { id: (fm.id as string) || '', parent: (fm.parent as string) || '', milestone: (fm.milestone as string) || '', - provides: (fm.provides as string[]) || [], + provides: asStringArray(fm.provides), requires: ((fm.requires as Array>) || []).map(r => ({ slice: r.slice || '', provides: r.provides || '', })), - affects: (fm.affects as string[]) || [], - key_files: (fm.key_files as string[]) || [], - key_decisions: (fm.key_decisions as string[]) || [], - patterns_established: (fm.patterns_established as string[]) || [], - drill_down_paths: (fm.drill_down_paths as string[]) || [], - observability_surfaces: (fm.observability_surfaces as string[]) || [], + affects: asStringArray(fm.affects), + key_files: asStringArray(fm.key_files), + key_decisions: asStringArray(fm.key_decisions), + patterns_established: asStringArray(fm.patterns_established), + drill_down_paths: asStringArray(fm.drill_down_paths), + observability_surfaces: asStringArray(fm.observability_surfaces), duration: (fm.duration as string) || '', verification_result: (fm.verification_result as string) || 'untested', completed_at: (fm.completed_at as string) || '', diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 37c255fd0..573baac72 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -184,8 +184,10 @@ export default function (pi: ExtensionAPI) { }); // ── Ctrl+Alt+G shortcut — GSD dashboard overlay ──────────────────────── + // Requires Kitty keyboard protocol or modifyOtherKeys support. + // Terminals without support (macOS Terminal.app, JetBrains): use /gsd status instead. pi.registerShortcut(Key.ctrlAlt("g"), { - description: "Open GSD dashboard", + description: "Open GSD dashboard (or use /gsd status)", handler: async (ctx) => { // Only show if .gsd/ exists if (!existsSync(join(process.cwd(), ".gsd"))) { diff --git a/src/resources/extensions/gsd/migrate/command.ts b/src/resources/extensions/gsd/migrate/command.ts index 84071edbf..e80731c14 100644 --- a/src/resources/extensions/gsd/migrate/command.ts +++ b/src/resources/extensions/gsd/migrate/command.ts @@ -96,7 +96,10 @@ export async function handleMigrate( if (!existsSync(sourcePath)) { ctx.ui.notify( - `Directory not found: ${sourcePath}\n\nMake sure the path points to a project root with a .planning directory.`, + `Directory not found: ${sourcePath}\n\n` + + 'Migration converts a .planning/ directory (from older GSD versions) into .gsd/ format.\n' + + 'If you are starting a new project, use /gsd:new-project instead.\n' + + 'If migrating, ensure the path contains a .planning/ directory.', "error", ); return; diff --git a/src/resources/extensions/gsd/migrate/validator.ts b/src/resources/extensions/gsd/migrate/validator.ts index 4093352b9..2bbf44dfb 100644 --- a/src/resources/extensions/gsd/migrate/validator.ts +++ b/src/resources/extensions/gsd/migrate/validator.ts @@ -14,7 +14,7 @@ function issue(file: string, severity: ValidationSeverity, message: string): Val /** * Validate that a .planning directory has the minimum required structure. * Returns structured issues with severity levels: - * - fatal: directory doesn't exist or ROADMAP.md missing (migration cannot proceed) + * - fatal: directory doesn't exist (migration cannot proceed) * - warning: optional files missing (migration can proceed with reduced data) */ export async function validatePlanningDirectory(path: string): Promise { @@ -26,9 +26,11 @@ export async function validatePlanningDirectory(path: string): Promise/M001/S01` when inside a worktree) - Slices are squash-merged to main when complete - Summaries compress prior work — read them instead of re-reading all task details - `STATE.md` is the quick-glance status file — keep it updated after changes diff --git a/src/resources/extensions/gsd/tests/migrate-parser.test.ts b/src/resources/extensions/gsd/tests/migrate-parser.test.ts index da5a19491..f30499374 100644 --- a/src/resources/extensions/gsd/tests/migrate-parser.test.ts +++ b/src/resources/extensions/gsd/tests/migrate-parser.test.ts @@ -725,8 +725,8 @@ Another orphan. } } - // ─── Test 12: Validation — missing ROADMAP.md → fatal ───────────────── - console.log('\n=== Validation: missing ROADMAP.md → fatal ==='); + // ─── Test 12: Validation — missing ROADMAP.md → warning (not fatal) ─── + console.log('\n=== Validation: missing ROADMAP.md → warning (not fatal) ==='); { const base = createFixtureBase(); try { @@ -736,10 +736,10 @@ Another orphan. const result = await validatePlanningDirectory(planning); - assertEq(result.valid, false, 'no roadmap: validation fails'); + assertEq(result.valid, true, 'no roadmap: validation still passes'); assert( - result.issues.some(i => i.severity === 'fatal' && i.file.includes('ROADMAP')), - 'no roadmap: fatal issue mentions ROADMAP' + result.issues.some(i => i.severity === 'warning' && i.file.includes('ROADMAP')), + 'no roadmap: warning issue mentions ROADMAP' ); } finally { cleanup(base); diff --git a/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts b/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts index 6e9804e8e..fa5b149bd 100644 --- a/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +++ b/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts @@ -211,15 +211,15 @@ async function main(): Promise { } } - console.log('\n=== Validator: missing ROADMAP.md → fatal ==='); + console.log('\n=== Validator: missing ROADMAP.md → warning (not fatal) ==='); { const base = createFixtureBase(); try { const planning = createPlanningDir(base); writeFileSync(join(planning, 'PROJECT.md'), SAMPLE_PROJECT); const result = await validatePlanningDirectory(planning); - assertEq(result.valid, false, 'no roadmap: validation fails'); - assert(result.issues.some(i => i.severity === 'fatal' && i.file.includes('ROADMAP')), 'no roadmap: fatal issue mentions ROADMAP'); + assertEq(result.valid, true, 'no roadmap: validation still passes'); + assert(result.issues.some(i => i.severity === 'warning' && i.file.includes('ROADMAP')), 'no roadmap: warning issue mentions ROADMAP'); } finally { cleanup(base); } diff --git a/src/resources/extensions/gsd/tests/parsers.test.ts b/src/resources/extensions/gsd/tests/parsers.test.ts index 0e774ddcb..570b737a8 100644 --- a/src/resources/extensions/gsd/tests/parsers.test.ts +++ b/src/resources/extensions/gsd/tests/parsers.test.ts @@ -1248,6 +1248,100 @@ console.log('\n=== parseRequirementCounts: total is sum of all section counts == assertEq(counts.total, counts.active + counts.validated + counts.deferred + counts.outOfScope, 'total is exact sum'); } +// ═══════════════════════════════════════════════════════════════════════════ +// parseSummary: bare scalar frontmatter fields (regression test for #91) +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseSummary: bare scalar "none" coerced to string array (#91) ==='); +{ + const content = `--- +id: T04 +parent: S03 +milestone: M001 +provides: + - iOS rules +key_files: + - .claude/rules/swift-style.md +key_decisions: none +patterns_established: none +drill_down_paths: none +observability_surfaces: none — static reference files +affects: single-value +--- + +# T04: iOS Rules + +**Created iOS-specific rules.** + +## What Happened + +Added rules. + +## Deviations + +None. +`; + + const s = parseSummary(content); + + // Array fields should remain arrays + assertEq(s.frontmatter.provides.length, 1, '#91: provides array preserved'); + assertEq(s.frontmatter.provides[0], 'iOS rules', '#91: provides value'); + assertEq(s.frontmatter.key_files.length, 1, '#91: key_files array preserved'); + + // Bare scalar "none" must be coerced to ["none"], not crash + assertEq(Array.isArray(s.frontmatter.key_decisions), true, '#91: key_decisions is array'); + assertEq(s.frontmatter.key_decisions.length, 1, '#91: key_decisions has 1 element'); + assertEq(s.frontmatter.key_decisions[0], 'none', '#91: key_decisions[0] is "none"'); + + assertEq(Array.isArray(s.frontmatter.patterns_established), true, '#91: patterns_established is array'); + assertEq(s.frontmatter.patterns_established.length, 1, '#91: patterns_established coerced'); + + assertEq(Array.isArray(s.frontmatter.drill_down_paths), true, '#91: drill_down_paths is array'); + assertEq(s.frontmatter.drill_down_paths.length, 1, '#91: drill_down_paths coerced'); + + // Scalar with spaces: "none — static reference files" + assertEq(Array.isArray(s.frontmatter.observability_surfaces), true, '#91: observability_surfaces is array'); + assertEq(s.frontmatter.observability_surfaces.length, 1, '#91: observability_surfaces coerced'); + assertEq(s.frontmatter.observability_surfaces[0], 'none — static reference files', '#91: full scalar preserved'); + + // Single value (not "none") also coerced + assertEq(Array.isArray(s.frontmatter.affects), true, '#91: affects is array'); + assertEq(s.frontmatter.affects.length, 1, '#91: affects single value coerced'); + assertEq(s.frontmatter.affects[0], 'single-value', '#91: affects value'); + + // .slice().join() must not crash (the original bug) + const decisions = s.frontmatter.key_decisions.slice(0, 2).join('; '); + assertEq(decisions, 'none', '#91: .slice().join() works on coerced array'); +} + +console.log('\n=== parseSummary: missing/empty frontmatter fields yield empty arrays ==='); +{ + const content = `--- +id: T05 +parent: S04 +milestone: M001 +--- + +# T05: Minimal Summary + +**Minimal.** + +## What Happened + +Nothing. +`; + + const s = parseSummary(content); + assertEq(s.frontmatter.provides.length, 0, 'missing provides = empty array'); + assertEq(s.frontmatter.key_decisions.length, 0, 'missing key_decisions = empty array'); + assertEq(s.frontmatter.affects.length, 0, 'missing affects = empty array'); + assertEq(s.frontmatter.key_files.length, 0, 'missing key_files = empty array'); + assertEq(s.frontmatter.patterns_established.length, 0, 'missing patterns_established = empty array'); + assertEq(s.frontmatter.drill_down_paths.length, 0, 'missing drill_down_paths = empty array'); + assertEq(s.frontmatter.observability_surfaces.length, 0, 'missing observability_surfaces = empty array'); +} + // ═══════════════════════════════════════════════════════════════════════════ // Results // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/resources/extensions/gsd/tests/worktree-integration.test.ts b/src/resources/extensions/gsd/tests/worktree-integration.test.ts new file mode 100644 index 000000000..0e576abfa --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-integration.test.ts @@ -0,0 +1,253 @@ +/** + * Worktree Integration Tests + * + * Tests the full lifecycle of GSD operations inside a worktree: + * - Branch namespacing (gsd/// instead of gsd//) + * - getMainBranch returns worktree/ inside a worktree + * - switchToMain goes to worktree/, not main + * - mergeSliceToMain merges into worktree/ + * - Parallel worktrees don't conflict on branch names + * - State derivation works correctly inside worktrees + */ + +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +import { + createWorktree, + listWorktrees, + removeWorktree, + worktreePath, + worktreeBranchName, +} from "../worktree-manager.ts"; + +import { + detectWorktreeName, + ensureSliceBranch, + getActiveSliceBranch, + getCurrentBranch, + getMainBranch, + getSliceBranchName, + isOnSliceBranch, + mergeSliceToMain, + switchToMain, + autoCommitCurrentBranch, +} from "../worktree.ts"; + +import { deriveState } from "../state.ts"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) passed++; + else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) passed++; + else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function run(command: string, cwd: string): string { + return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); +} + +// ─── Test repo setup ────────────────────────────────────────────────────────── + +const base = mkdtempSync(join(tmpdir(), "gsd-wt-integration-")); +run("git init -b main", base); +run("git config user.name 'Pi Test'", base); +run("git config user.email 'pi@example.com'", base); + +// Create a project with one milestone and two slices +mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true }); +mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S02", "tasks"), { recursive: true }); +writeFileSync(join(base, "README.md"), "# Test Project\n", "utf-8"); +writeFileSync( + join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), + [ + "# M001: Demo", + "", + "## Slices", + "- [ ] **S01: First** `risk:low` `depends:[]`", + " > After this: part one works", + "- [ ] **S02: Second** `risk:low` `depends:[]`", + " > After this: part two works", + ].join("\n") + "\n", + "utf-8", +); +writeFileSync( + join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"), + "# S01: First\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Implement** `est:10m`\n do it\n", + "utf-8", +); +writeFileSync( + join(base, ".gsd", "milestones", "M001", "slices", "S02", "S02-PLAN.md"), + "# S02: Second\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Implement** `est:10m`\n do it\n", + "utf-8", +); +run("git add .", base); +run("git commit -m 'chore: init'", base); + +async function main(): Promise { + // ── Verify main tree baseline ────────────────────────────────────────────── + + console.log("\n=== Main tree baseline ==="); + assertEq(getMainBranch(base), "main", "main tree getMainBranch returns main"); + assertEq(detectWorktreeName(base), null, "main tree not detected as worktree"); + + // ── Create worktree and verify detection ─────────────────────────────────── + + console.log("\n=== Create worktree ==="); + const wt = createWorktree(base, "alpha"); + assert(existsSync(wt.path), "worktree created on disk"); + assertEq(wt.branch, "worktree/alpha", "worktree branch name"); + + console.log("\n=== Worktree detection ==="); + assertEq(detectWorktreeName(wt.path), "alpha", "detectWorktreeName inside worktree"); + assertEq(getMainBranch(wt.path), "worktree/alpha", "getMainBranch returns worktree branch inside worktree"); + + // ── Verify current branch inside worktree ────────────────────────────────── + + console.log("\n=== Worktree initial branch ==="); + assertEq(getCurrentBranch(wt.path), "worktree/alpha", "worktree starts on its own branch"); + + // ── ensureSliceBranch inside worktree ────────────────────────────────────── + + console.log("\n=== ensureSliceBranch in worktree ==="); + const created = ensureSliceBranch(wt.path, "M001", "S01"); + assert(created, "slice branch created"); + assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "worktree-namespaced slice branch"); + assert(isOnSliceBranch(wt.path), "isOnSliceBranch returns true"); + assertEq(getActiveSliceBranch(wt.path), "gsd/alpha/M001/S01", "getActiveSliceBranch returns namespaced branch"); + + // ── Verify branch name helper ────────────────────────────────────────────── + + console.log("\n=== getSliceBranchName with worktree ==="); + assertEq(getSliceBranchName("M001", "S01", "alpha"), "gsd/alpha/M001/S01", "explicit worktree param"); + assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "no worktree param = plain branch"); + + // ── Do work on slice branch, then merge to worktree branch ───────────────── + + console.log("\n=== Work and merge slice in worktree ==="); + writeFileSync(join(wt.path, "feature.txt"), "new feature\n", "utf-8"); + run("git add .", wt.path); + run("git commit -m 'feat: add feature'", wt.path); + + // switchToMain should go to worktree/alpha, NOT main + switchToMain(wt.path); + assertEq(getCurrentBranch(wt.path), "worktree/alpha", "switchToMain goes to worktree branch, not main"); + + // mergeSliceToMain should merge into worktree/alpha + const merge = mergeSliceToMain(wt.path, "M001", "S01", "First"); + assertEq(merge.branch, "gsd/alpha/M001/S01", "merged the namespaced branch"); + assert(merge.deletedBranch, "slice branch deleted after merge"); + assertEq(getCurrentBranch(wt.path), "worktree/alpha", "still on worktree branch after merge"); + assert(readFileSync(join(wt.path, "feature.txt"), "utf-8").includes("new feature"), "merge brought feature to worktree branch"); + + // Verify slice branch is gone + const branches = run("git branch", base); + assert(!branches.includes("gsd/alpha/M001/S01"), "slice branch cleaned up"); + + // ── Second slice in same worktree ────────────────────────────────────────── + + console.log("\n=== Second slice in worktree ==="); + const created2 = ensureSliceBranch(wt.path, "M001", "S02"); + assert(created2, "S02 branch created"); + assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S02", "on S02 namespaced branch"); + + writeFileSync(join(wt.path, "feature2.txt"), "second feature\n", "utf-8"); + run("git add .", wt.path); + run("git commit -m 'feat: add feature 2'", wt.path); + + switchToMain(wt.path); + const merge2 = mergeSliceToMain(wt.path, "M001", "S02", "Second"); + assertEq(merge2.branch, "gsd/alpha/M001/S02", "S02 merge correct"); + assertEq(getCurrentBranch(wt.path), "worktree/alpha", "back on worktree branch"); + + // ── Main tree can still do its own slice work independently ──────────────── + + console.log("\n=== Main tree independent slice work ==="); + assertEq(getCurrentBranch(base), "main", "main tree still on main"); + const mainCreated = ensureSliceBranch(base, "M001", "S01"); + assert(mainCreated, "main tree can create S01 branch (no conflict with worktree)"); + assertEq(getCurrentBranch(base), "gsd/M001/S01", "main tree on plain branch name"); + + writeFileSync(join(base, "main-feature.txt"), "main work\n", "utf-8"); + run("git add .", base); + run("git commit -m 'feat: main work'", base); + + switchToMain(base); + assertEq(getCurrentBranch(base), "main", "main tree switchToMain goes to main"); + const mainMerge = mergeSliceToMain(base, "M001", "S01", "First"); + assertEq(mainMerge.branch, "gsd/M001/S01", "main tree merge uses plain branch"); + + // ── Parallel worktrees don't conflict ────────────────────────────────────── + + console.log("\n=== Parallel worktrees ==="); + const wt2 = createWorktree(base, "beta"); + assertEq(getMainBranch(wt2.path), "worktree/beta", "second worktree has its own base branch"); + + // Both worktrees can create S01 branches without conflict + const betaCreated = ensureSliceBranch(wt2.path, "M001", "S01"); + assert(betaCreated, "beta worktree can create S01"); + assertEq(getCurrentBranch(wt2.path), "gsd/beta/M001/S01", "beta has its own namespaced branch"); + + // Alpha worktree can re-create S01 too (it was already merged+deleted earlier) + const alphaReCreated = ensureSliceBranch(wt.path, "M001", "S01"); + assert(alphaReCreated, "alpha worktree can re-create S01"); + assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "alpha re-created S01"); + + // Both exist simultaneously + const allBranches = run("git branch", base); + assert(allBranches.includes("gsd/alpha/M001/S01"), "alpha S01 branch exists"); + assert(allBranches.includes("gsd/beta/M001/S01"), "beta S01 branch exists"); + + // ── State derivation in worktree ─────────────────────────────────────────── + + console.log("\n=== State derivation in worktree ==="); + // Switch alpha back to its base so deriveState sees milestone files + switchToMain(wt.path); + const state = await deriveState(wt.path); + assert(state.activeMilestone !== null, "worktree has active milestone"); + assertEq(state.activeMilestone?.id, "M001", "correct milestone"); + + // ── autoCommitCurrentBranch in worktree ──────────────────────────────────── + + console.log("\n=== autoCommitCurrentBranch in worktree ==="); + ensureSliceBranch(wt2.path, "M001", "S01"); // re-checkout if needed + writeFileSync(join(wt2.path, "dirty.txt"), "uncommitted\n", "utf-8"); + const commitMsg = autoCommitCurrentBranch(wt2.path, "execute-task", "M001/S01/T01"); + assert(commitMsg !== null, "auto-commit works in worktree"); + assertEq(run("git status --short", wt2.path), "", "worktree clean after auto-commit"); + + // ── Cleanup ──────────────────────────────────────────────────────────────── + + console.log("\n=== Cleanup ==="); + // Switch worktrees back to their base branches before removal + switchToMain(wt.path); + switchToMain(wt2.path); + removeWorktree(base, "alpha", { deleteBranch: true }); + removeWorktree(base, "beta", { deleteBranch: true }); + assertEq(listWorktrees(base).length, 0, "all worktrees removed"); + + rmSync(base, { recursive: true, force: true }); + + console.log(`\nResults: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); + console.log("All tests passed ✓"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/tests/worktree.test.ts b/src/resources/extensions/gsd/tests/worktree.test.ts index 5e9c0c1cf..5411e9a54 100644 --- a/src/resources/extensions/gsd/tests/worktree.test.ts +++ b/src/resources/extensions/gsd/tests/worktree.test.ts @@ -1,15 +1,19 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; import { autoCommitCurrentBranch, + detectWorktreeName, ensureSliceBranch, getActiveSliceBranch, getCurrentBranch, getSliceBranchName, + isOnSliceBranch, mergeSliceToMain, + parseSliceBranch, + SLICE_BRANCH_RE, switchToMain, } from "../worktree.ts"; import { deriveState } from "../state.ts"; @@ -136,6 +140,117 @@ async function main(): Promise { console.log("\n=== getSliceBranchName ==="); assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "branch name format correct"); + assertEq(getSliceBranchName("M001", "S01", null), "gsd/M001/S01", "null worktree = plain branch"); + assertEq(getSliceBranchName("M001", "S01", "my-wt"), "gsd/my-wt/M001/S01", "worktree-namespaced branch"); + + console.log("\n=== parseSliceBranch ==="); + const plain = parseSliceBranch("gsd/M001/S01"); + assert(plain !== null, "parses plain branch"); + assertEq(plain!.worktreeName, null, "plain branch has no worktree name"); + assertEq(plain!.milestoneId, "M001", "plain branch milestone"); + assertEq(plain!.sliceId, "S01", "plain branch slice"); + + const namespaced = parseSliceBranch("gsd/feature-auth/M001/S01"); + assert(namespaced !== null, "parses worktree-namespaced branch"); + assertEq(namespaced!.worktreeName, "feature-auth", "worktree name extracted"); + assertEq(namespaced!.milestoneId, "M001", "namespaced branch milestone"); + assertEq(namespaced!.sliceId, "S01", "namespaced branch slice"); + + const invalid = parseSliceBranch("main"); + assertEq(invalid, null, "non-slice branch returns null"); + + const worktreeBranch = parseSliceBranch("worktree/foo"); + assertEq(worktreeBranch, null, "worktree/ prefix is not a slice branch"); + + console.log("\n=== SLICE_BRANCH_RE ==="); + assert(SLICE_BRANCH_RE.test("gsd/M001/S01"), "regex matches plain branch"); + assert(SLICE_BRANCH_RE.test("gsd/my-wt/M001/S01"), "regex matches worktree branch"); + assert(!SLICE_BRANCH_RE.test("main"), "regex rejects main"); + assert(!SLICE_BRANCH_RE.test("gsd/"), "regex rejects bare gsd/"); + assert(!SLICE_BRANCH_RE.test("worktree/foo"), "regex rejects worktree/foo"); + + console.log("\n=== detectWorktreeName ==="); + assertEq(detectWorktreeName("/projects/myapp"), null, "no worktree in plain path"); + assertEq(detectWorktreeName("/projects/myapp/.gsd/worktrees/feature-auth"), "feature-auth", "detects worktree name"); + assertEq(detectWorktreeName("/projects/myapp/.gsd/worktrees/my-wt/subdir"), "my-wt", "detects worktree with subdir"); + + // ── Regression: slice branch from non-main working branch ─────────── + // Reproduces the bug where planning artifacts committed to a working + // branch (e.g. "developer") are lost when the slice branch is created + // from "main" which doesn't have them. + console.log("\n=== ensureSliceBranch from non-main working branch ==="); + const base2 = mkdtempSync(join(tmpdir(), "gsd-branch-base-test-")); + run("git init -b main", base2); + run("git config user.name 'Pi Test'", base2); + run("git config user.email 'pi@example.com'", base2); + writeFileSync(join(base2, "README.md"), "hello\n", "utf-8"); + run("git add .", base2); + run("git commit -m 'chore: init'", base2); + + // Create a "developer" branch with planning artifacts (like the real scenario) + run("git checkout -b developer", base2); + mkdirSync(join(base2, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true }); + writeFileSync(join(base2, ".gsd", "milestones", "M001", "M001-CONTEXT.md"), "# M001 Context\nGoal: fix eslint\n", "utf-8"); + writeFileSync(join(base2, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [ + "# M001: ESLint Cleanup", "", "## Slices", + "- [ ] **S01: Config Fix** `risk:low` `depends:[]`", " > Fix config", + ].join("\n") + "\n", "utf-8"); + run("git add .", base2); + run("git commit -m 'docs(M001): context and roadmap'", base2); + + // Verify main does NOT have the artifacts + const mainRoadmap = run("git show main:.gsd/milestones/M001/M001-ROADMAP.md 2>&1 || echo MISSING", base2); + assert(mainRoadmap.includes("MISSING") || mainRoadmap.includes("does not exist"), "main branch lacks roadmap"); + + // Now create slice branch from developer — should inherit artifacts + assertEq(getCurrentBranch(base2), "developer", "on developer branch before ensure"); + const created3 = ensureSliceBranch(base2, "M001", "S01"); + assert(created3, "slice branch created from developer"); + assertEq(getCurrentBranch(base2), "gsd/M001/S01", "switched to slice branch"); + + // The critical assertion: planning artifacts must exist on the slice branch + assert(existsSync(join(base2, ".gsd", "milestones", "M001", "M001-ROADMAP.md")), "roadmap exists on slice branch"); + assert(existsSync(join(base2, ".gsd", "milestones", "M001", "M001-CONTEXT.md")), "context exists on slice branch"); + + // Verify deriveState sees the correct phase (not pre-planning) + const state2 = await deriveState(base2); + assertEq(state2.phase, "planning", "deriveState sees planning phase on slice branch"); + assert(state2.activeSlice !== null, "active slice found"); + assertEq(state2.activeSlice!.id, "S01", "active slice is S01"); + + rmSync(base2, { recursive: true, force: true }); + + // ── Slice branch from another slice branch falls back to main ─────── + console.log("\n=== ensureSliceBranch from slice branch falls back to main ==="); + const base3 = mkdtempSync(join(tmpdir(), "gsd-branch-chain-test-")); + run("git init -b main", base3); + run("git config user.name 'Pi Test'", base3); + run("git config user.email 'pi@example.com'", base3); + mkdirSync(join(base3, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true }); + mkdirSync(join(base3, ".gsd", "milestones", "M001", "slices", "S02", "tasks"), { recursive: true }); + writeFileSync(join(base3, "README.md"), "hello\n", "utf-8"); + writeFileSync(join(base3, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [ + "# M001: Demo", "", "## Slices", + "- [ ] **S01: First** `risk:low` `depends:[]`", " > first", + "- [ ] **S02: Second** `risk:low` `depends:[]`", " > second", + ].join("\n") + "\n", "utf-8"); + run("git add .", base3); + run("git commit -m 'chore: init'", base3); + + ensureSliceBranch(base3, "M001", "S01"); + assertEq(getCurrentBranch(base3), "gsd/M001/S01", "on S01 slice branch"); + + // Creating S02 while on S01 should NOT chain from S01 — should use main + const created4 = ensureSliceBranch(base3, "M001", "S02"); + assert(created4, "S02 branch created"); + assertEq(getCurrentBranch(base3), "gsd/M001/S02", "switched to S02"); + + // S02 should be based on main, not on gsd/M001/S01 + const s02Base = run("git merge-base main gsd/M001/S02", base3); + const mainHead = run("git rev-parse main", base3); + assertEq(s02Base, mainHead, "S02 is based on main, not on S01 slice branch"); + + rmSync(base3, { recursive: true, force: true }); rmSync(base, { recursive: true, force: true }); console.log(`\nResults: ${passed} passed, ${failed} failed`); diff --git a/src/resources/extensions/gsd/workspace-index.ts b/src/resources/extensions/gsd/workspace-index.ts index 767e3dab7..ada57dfe9 100644 --- a/src/resources/extensions/gsd/workspace-index.ts +++ b/src/resources/extensions/gsd/workspace-index.ts @@ -12,7 +12,7 @@ import { } from "./paths.ts"; import { deriveState } from "./state.ts"; import { type ValidationIssue, validateCompleteBoundary, validatePlanBoundary } from "./observability-validator.ts"; -import { getSliceBranchName } from "./worktree.ts"; +import { getSliceBranchName, detectWorktreeName } from "./worktree.ts"; export interface WorkspaceTaskTarget { id: string; @@ -112,7 +112,7 @@ async function indexSlice(basePath: string, milestoneId: string, sliceId: string summaryPath, uatPath, tasksDir, - branch: getSliceBranchName(milestoneId, sliceId), + branch: getSliceBranchName(milestoneId, sliceId, detectWorktreeName(basePath)), tasks, }; } diff --git a/src/resources/extensions/gsd/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts index 489976cd2..569da3108 100644 --- a/src/resources/extensions/gsd/worktree-command.ts +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -14,6 +14,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-cod import { loadPrompt } from "./prompt-loader.js"; import { autoCommitCurrentBranch } from "./worktree.js"; import { showConfirm } from "../shared/confirm-ui.js"; +import { gsdRoot, milestonesDir } from "./paths.js"; import { createWorktree, listWorktrees, @@ -28,7 +29,7 @@ import { worktreePath, } from "./worktree-manager.js"; import type { FileLineStat } from "./worktree-manager.js"; -import { existsSync, realpathSync, readFileSync, utimesSync } from "node:fs"; +import { existsSync, realpathSync, readFileSync, readdirSync, rmSync, unlinkSync, utimesSync } from "node:fs"; import { join, resolve, sep } from "node:path"; /** @@ -304,6 +305,44 @@ export function registerWorktreeCommand(pi: ExtensionAPI): void { // ─── Handlers ────────────────────────────────────────────────────────────── +/** + * Check if the worktree has existing GSD milestones that would + * cause auto-mode to continue previous work instead of starting fresh. + */ +function hasExistingMilestones(wtPath: string): boolean { + const mDir = milestonesDir(wtPath); + if (!existsSync(mDir)) return false; + try { + const entries = readdirSync(mDir, { withFileTypes: true }) + .filter(d => d.isDirectory() && /^M\d+/.test(d.name)); + return entries.length > 0; + } catch { + return false; + } +} + +/** + * Clear GSD planning artifacts so auto-mode starts fresh with the discuss flow. + * Keeps the .gsd/ directory structure intact but removes milestones and root planning files. + */ +function clearGSDPlans(wtPath: string): void { + const mDir = milestonesDir(wtPath); + if (existsSync(mDir)) { + rmSync(mDir, { recursive: true, force: true }); + } + + // Remove root planning files — PROJECT.md, DECISIONS.md, QUEUE.md, REQUIREMENTS.md + // Keep STATE.md (gitignored, will be rebuilt) and other runtime files + const root = gsdRoot(wtPath); + const planningFiles = ["PROJECT.md", "DECISIONS.md", "QUEUE.md", "REQUIREMENTS.md"]; + for (const file of planningFiles) { + const filePath = join(root, file); + if (existsSync(filePath)) { + unlinkSync(filePath); + } + } +} + async function handleCreate( basePath: string, name: string, @@ -324,16 +363,45 @@ async function handleCreate( process.chdir(info.path); nudgeGitBranchCache(prevCwd); - const commitNote = commitMsg ? `\n Auto-committed on previous branch before switching.` : ""; + // If the worktree inherited existing milestones, ask whether to keep or clear them + let clearedPlans = false; + if (hasExistingMilestones(info.path)) { + // confirmLabel = Continue (safe default, on the left / first) + // declineLabel = Start fresh (destructive, on the right) + const keepExisting = await showConfirm(ctx, { + title: "Worktree Setup", + message: [ + `This worktree inherited existing GSD milestones from the main branch.`, + ``, + ` Continue — keep milestones and pick up where main left off`, + ` Start fresh — clear milestones so /gsd auto starts a new project`, + ].join("\n"), + confirmLabel: "Continue", + declineLabel: "Start fresh", + }); + if (!keepExisting) { + clearGSDPlans(info.path); + clearedPlans = true; + } + } + + const commitNote = commitMsg + ? ` ${CLR.muted("Auto-committed on previous branch before switching.")}` + : ""; + const freshNote = clearedPlans + ? ` ${CLR.ok("✓")} Cleared milestones — ${CLR.hint("/gsd auto")} will start fresh.` + : ""; ctx.ui.notify( [ - `Worktree "${name}" created and activated.`, - ` Path: ${info.path}`, - ` Branch: ${info.branch}`, + `${CLR.ok("✓")} Worktree ${CLR.name(name)} created and activated.`, + "", + ` ${CLR.label("path")} ${CLR.path(info.path)}`, + ` ${CLR.label("branch")} ${CLR.branch(info.branch)}`, commitNote, - `Session is now in the worktree. All commands run here.`, - `Use /worktree merge ${name} to merge back when done.`, - `Use /worktree return to switch back to the main tree.`, + freshNote, + "", + ` ${CLR.hint(`/worktree merge ${name}`)} ${CLR.muted("merge back when done")}`, + ` ${CLR.hint("/worktree return")}${" ".repeat(Math.max(1, name.length - 2))} ${CLR.muted("switch back to main tree")}`, ].filter(Boolean).join("\n"), "info", ); @@ -370,14 +438,18 @@ async function handleSwitch( process.chdir(wtPath); nudgeGitBranchCache(prevCwd); - const commitNote = commitMsg ? `\n Auto-committed on previous branch before switching.` : ""; + const commitNote = commitMsg + ? ` ${CLR.muted("Auto-committed on previous branch before switching.")}` + : ""; ctx.ui.notify( [ - `Switched to worktree "${name}".`, - ` Path: ${wtPath}`, - ` Branch: ${worktreeBranchName(name)}`, + `${CLR.ok("✓")} Switched to worktree ${CLR.name(name)}.`, + "", + ` ${CLR.label("path")} ${CLR.path(wtPath)}`, + ` ${CLR.label("branch")} ${CLR.branch(worktreeBranchName(name))}`, commitNote, - `Use /worktree return to switch back to the main tree.`, + "", + ` ${CLR.hint("/worktree return")} ${CLR.muted("switch back to main tree")}`, ].filter(Boolean).join("\n"), "info", ); @@ -403,26 +475,56 @@ async function handleReturn(ctx: ExtensionCommandContext): Promise { process.chdir(returnTo); nudgeGitBranchCache(prevCwd); - const commitNote = commitMsg ? `\n Auto-committed on worktree branch before returning.` : ""; + const commitNote = commitMsg + ? ` ${CLR.muted("Auto-committed on worktree branch before returning.")}` + : ""; ctx.ui.notify( [ - `Returned to main project tree.`, - ` Path: ${returnTo}`, + `${CLR.ok("✓")} Returned to main project tree.`, + "", + ` ${CLR.label("path")} ${CLR.path(returnTo)}`, commitNote, ].filter(Boolean).join("\n"), "info", ); } -// ANSI helpers for list formatting -const BOLD = "\x1b[1m"; -const DIM = "\x1b[2m"; -const RESET = "\x1b[0m"; -const CYAN = "\x1b[36m"; -const GREEN = "\x1b[32m"; -const RED = "\x1b[31m"; +// ─── ANSI styling ───────────────────────────────────────────────────────── +// Consistent palette for all worktree command output. + +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; +const RESET = "\x1b[0m"; +const CYAN = "\x1b[36m"; +const GREEN = "\x1b[32m"; +const RED = "\x1b[31m"; const YELLOW = "\x1b[33m"; -const WHITE = "\x1b[37m"; +const WHITE = "\x1b[37m"; +const MAGENTA = "\x1b[35m"; + +// Semantic aliases for consistent use across all handlers +const CLR = { + /** Worktree names and primary emphasis */ + name: (s: string) => `${BOLD}${CYAN}${s}${RESET}`, + /** Active worktree name */ + nameActive: (s: string) => `${BOLD}${GREEN}${s}${RESET}`, + /** Branch names */ + branch: (s: string) => `${MAGENTA}${s}${RESET}`, + /** File paths */ + path: (s: string) => `${DIM}${s}${RESET}`, + /** Labels (key in key:value pairs) */ + label: (s: string) => `${WHITE}${s}${RESET}`, + /** Hints and commands the user can run */ + hint: (s: string) => `${DIM}${CYAN}${s}${RESET}`, + /** Success messages and checks */ + ok: (s: string) => `${GREEN}${s}${RESET}`, + /** Warning badges */ + warn: (s: string) => `${YELLOW}${s}${RESET}`, + /** Section headers */ + header: (s: string) => `${BOLD}${WHITE}${s}${RESET}`, + /** Muted secondary info */ + muted: (s: string) => `${DIM}${s}${RESET}`, +} as const; async function handleList( basePath: string, @@ -438,22 +540,26 @@ async function handleList( } const cwd = process.cwd(); - const lines = [`${BOLD}${WHITE}GSD Worktrees${RESET}`, ""]; + const lines = [CLR.header("GSD Worktrees"), ""]; for (const wt of worktrees) { const isCurrent = cwd === wt.path || (existsSync(cwd) && existsSync(wt.path) && realpathSync(cwd) === realpathSync(wt.path)); - const nameColor = isCurrent ? GREEN : CYAN; - const badge = isCurrent ? ` ${GREEN}● active${RESET}` : !wt.exists ? ` ${YELLOW}✗ missing${RESET}` : ""; - lines.push(` ${BOLD}${nameColor}${wt.name}${RESET}${badge}`); - lines.push(` ${DIM} branch${RESET} ${wt.branch}`); - lines.push(` ${DIM} path${RESET} ${DIM}${wt.path}${RESET}`); + const styledName = isCurrent ? CLR.nameActive(wt.name) : CLR.name(wt.name); + const badge = isCurrent + ? ` ${CLR.ok("● active")}` + : !wt.exists + ? ` ${CLR.warn("✗ missing")}` + : ""; + lines.push(` ${styledName}${badge}`); + lines.push(` ${CLR.label("branch")} ${CLR.branch(wt.branch)}`); + lines.push(` ${CLR.label("path")} ${CLR.path(wt.path)}`); lines.push(""); } if (originalCwd) { - lines.push(`${DIM}Main tree: ${originalCwd}${RESET}`); + lines.push(` ${CLR.label("main tree")} ${CLR.path(originalCwd)}`); } ctx.ui.notify(lines.join("\n"), "info"); @@ -491,7 +597,7 @@ async function handleMerge( const totalChanges = diffSummary.added.length + diffSummary.modified.length + diffSummary.removed.length; if (totalChanges === 0 && !commitLog.trim()) { - ctx.ui.notify(`Worktree "${name}" has no changes to merge.`, "info"); + ctx.ui.notify(`Worktree ${CLR.name(name)} has no changes to merge.`, "info"); return; } @@ -516,15 +622,15 @@ async function handleMerge( // Format a file line with +/- stats const formatFileLine = (prefix: string, file: string): string => { const s = statMap.get(file); - const stat = s ? ` ${GREEN}+${s.added}${RESET} ${RED}-${s.removed}${RESET}` : ""; + const stat = s ? ` ${CLR.ok(`+${s.added}`)} ${RED}-${s.removed}${RESET}` : ""; return ` ${prefix} ${file}${stat}`; }; // Preview confirmation before merge dispatch const previewLines = [ - `Merge worktree "${name}" → ${mainBranch}`, + `Merge ${CLR.name(name)} → ${CLR.branch(mainBranch)}`, "", - ` ${totalChanges} file${totalChanges === 1 ? "" : "s"} changed, ${GREEN}+${totalAdded}${RESET} ${RED}-${totalRemoved}${RESET} lines (${codeChanges} code, ${gsdChanges} GSD)`, + ` ${totalChanges} file${totalChanges === 1 ? "" : "s"} changed, ${CLR.ok(`+${totalAdded}`)} ${RED}-${totalRemoved}${RESET} lines ${CLR.muted(`(${codeChanges} code, ${gsdChanges} GSD)`)}`, ]; const appendFileList = (label: string, files: string[], prefix: string, limit = 10) => { @@ -590,7 +696,7 @@ async function handleMerge( ); ctx.ui.notify( - `Merge helper started for worktree "${name}" (${codeChanges} code + ${gsdChanges} GSD artifact change${totalChanges === 1 ? "" : "s"}).`, + `${CLR.ok("✓")} Merge helper started for ${CLR.name(name)} ${CLR.muted(`(${codeChanges} code + ${gsdChanges} GSD artifact change${totalChanges === 1 ? "" : "s"})`)}`, "info", ); } catch (error) { @@ -617,7 +723,7 @@ async function handleRemove( const confirmed = await showConfirm(ctx, { title: "Remove Worktree", - message: `Remove worktree "${name}" and delete branch ${wt.branch}?`, + message: `Remove worktree ${CLR.name(name)} and delete branch ${CLR.branch(wt.branch)}?`, confirmLabel: "Remove", declineLabel: "Cancel", }); @@ -635,7 +741,7 @@ async function handleRemove( originalCwd = null; } - ctx.ui.notify(`Worktree "${name}" removed (branch deleted).`, "info"); + ctx.ui.notify(`${CLR.ok("✓")} Worktree ${CLR.name(name)} removed ${CLR.muted("(branch deleted)")}.`, "info"); } catch (error) { const msg = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Failed to remove worktree: ${msg}`, "error"); @@ -658,7 +764,7 @@ async function handleRemoveAll( const names = worktrees.map(w => w.name); const confirmed = await showConfirm(ctx, { title: "Remove All Worktrees", - message: `This will remove ${worktrees.length} worktree${worktrees.length === 1 ? "" : "s"} and delete their branches:\n\n${names.map(n => ` • ${n}`).join("\n")}`, + message: `Remove ${worktrees.length} worktree${worktrees.length === 1 ? "" : "s"} and delete their branches?\n\n${names.map(n => ` • ${CLR.name(n)}`).join("\n")}`, confirmLabel: "Remove all", declineLabel: "Cancel", }); @@ -687,8 +793,8 @@ async function handleRemoveAll( } const lines: string[] = []; - if (removed.length > 0) lines.push(`Removed: ${removed.join(", ")}`); - if (failed.length > 0) lines.push(`Failed: ${failed.join(", ")}`); + if (removed.length > 0) lines.push(`${CLR.ok("✓")} Removed: ${removed.map(n => CLR.name(n)).join(", ")}`); + if (failed.length > 0) lines.push(`${CLR.warn("✗")} Failed: ${failed.map(n => CLR.name(n)).join(", ")}`); ctx.ui.notify(lines.join("\n"), failed.length > 0 ? "warning" : "info"); } catch (error) { const msg = error instanceof Error ? error.message : String(error); diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 4f696be38..31f6fe7d1 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -13,6 +13,7 @@ import { existsSync } from "node:fs"; import { execSync } from "node:child_process"; +import { sep } from "node:path"; export interface MergeSliceResult { branch: string; @@ -34,11 +35,83 @@ function runGit(basePath: string, args: string[], options: { allowFailure?: bool } } -export function getSliceBranchName(milestoneId: string, sliceId: string): string { +/** + * Detect the active worktree name from the current working directory. + * Returns null if not inside a GSD worktree (.gsd/worktrees//). + */ +export function detectWorktreeName(basePath: string): string | null { + const marker = `${sep}.gsd${sep}worktrees${sep}`; + const idx = basePath.indexOf(marker); + if (idx === -1) return null; + const afterMarker = basePath.slice(idx + marker.length); + const name = afterMarker.split(sep)[0] ?? afterMarker.split("/")[0]; + return name || null; +} + +/** + * Get the slice branch name, namespaced by worktree when inside one. + * + * In the main tree: gsd// + * In a worktree: gsd/// + * + * This prevents branch conflicts when multiple worktrees work on the + * same milestone/slice IDs — git doesn't allow a branch to be checked + * out in more than one worktree simultaneously. + */ +export function getSliceBranchName(milestoneId: string, sliceId: string, worktreeName?: string | null): string { + if (worktreeName) { + return `gsd/${worktreeName}/${milestoneId}/${sliceId}`; + } return `gsd/${milestoneId}/${sliceId}`; } +/** Regex that matches both plain and worktree-namespaced slice branches. */ +export const SLICE_BRANCH_RE = /^gsd\/(?:([a-zA-Z0-9_-]+)\/)?(M\d+)\/(S\d+)$/; + +/** + * Parse a slice branch name into its components. + * Handles both `gsd/M001/S01` and `gsd/myworktree/M001/S01`. + */ +export function parseSliceBranch(branchName: string): { + worktreeName: string | null; + milestoneId: string; + sliceId: string; +} | null { + const match = branchName.match(SLICE_BRANCH_RE); + if (!match) return null; + return { + worktreeName: match[1] ?? null, + milestoneId: match[2]!, + sliceId: match[3]!, + }; +} + +/** + * Get the "main" branch for GSD slice operations. + * + * In the main working tree: returns main/master (the repo's default branch). + * In a worktree: returns worktree/ — the worktree's own base branch. + * + * This is critical because git doesn't allow a branch to be checked out + * in more than one worktree. Slice branches merge into the worktree's base + * branch, and the worktree branch later merges into the real main via + * /worktree merge. + */ export function getMainBranch(basePath: string): string { + // When inside a worktree, slice branches should merge into the worktree's + // own branch (worktree/), not main — main is checked out by the + // parent working tree and git would refuse the checkout. + const wtName = detectWorktreeName(basePath); + if (wtName) { + const wtBranch = `worktree/${wtName}`; + // Verify the branch exists (it should — createWorktree made it) + const exists = runGit(basePath, ["show-ref", "--verify", `refs/heads/${wtBranch}`], { allowFailure: true }); + if (exists) return wtBranch; + // Worktree branch is gone — return current branch rather than falling + // through to main/master which would cause a checkout conflict + return runGit(basePath, ["branch", "--show-current"]); + } + const symbolic = runGit(basePath, ["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true }); if (symbolic) { const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/); @@ -69,11 +142,16 @@ function branchExists(basePath: string, branch: string): boolean { /** * Ensure the slice branch exists and is checked out. - * Creates the branch from main if it doesn't exist. + * Creates the branch from the current branch if it's not a slice branch, + * otherwise from main. This preserves planning artifacts (CONTEXT, ROADMAP, + * etc.) that were committed on the working branch — which may differ from + * the repo's default branch (e.g. `developer` vs `main`). + * When inside a worktree, the branch is namespaced to avoid conflicts. * Returns true if the branch was newly created. */ export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId: string): boolean { - const branch = getSliceBranchName(milestoneId, sliceId); + const wtName = detectWorktreeName(basePath); + const branch = getSliceBranchName(milestoneId, sliceId, wtName); const current = getCurrentBranch(basePath); if (current === branch) return false; @@ -81,8 +159,25 @@ export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId let created = false; if (!branchExists(basePath, branch)) { - runGit(basePath, ["branch", branch]); + // Branch from the current branch when it's a normal working branch + // (not itself a slice branch). This ensures the new slice branch + // inherits planning artifacts that may only exist on the working + // branch and haven't been merged to main yet. + // If we're already on a slice branch (e.g. creating S02 while S01 + // wasn't merged yet), fall back to main to avoid chaining slice branches. + const mainBranch = getMainBranch(basePath); + const base = SLICE_BRANCH_RE.test(current) ? mainBranch : current; + runGit(basePath, ["branch", branch, base]); created = true; + } else { + // Check if the branch is already checked out in another worktree + const worktreeList = runGit(basePath, ["worktree", "list", "--porcelain"]); + if (worktreeList.includes(`branch refs/heads/${branch}`)) { + throw new Error( + `Branch "${branch}" is already in use by another worktree. ` + + `Remove that worktree first, or switch it to a different branch.`, + ); + } } // Auto-commit dirty files before checkout to prevent "would be overwritten" errors. @@ -142,7 +237,8 @@ export function switchToMain(basePath: string): void { export function mergeSliceToMain( basePath: string, milestoneId: string, sliceId: string, sliceTitle: string, ): MergeSliceResult { - const branch = getSliceBranchName(milestoneId, sliceId); + const wtName = detectWorktreeName(basePath); + const branch = getSliceBranchName(milestoneId, sliceId, wtName); const mainBranch = getMainBranch(basePath); const current = getCurrentBranch(basePath); @@ -173,19 +269,21 @@ export function mergeSliceToMain( /** * Check if we're currently on a slice branch (not main). + * Handles both plain (gsd/M001/S01) and worktree-namespaced (gsd/wt/M001/S01) branches. */ export function isOnSliceBranch(basePath: string): boolean { const current = getCurrentBranch(basePath); - return current.startsWith("gsd/"); + return SLICE_BRANCH_RE.test(current); } /** * Get the active slice branch name, or null if on main. + * Handles both plain and worktree-namespaced branch patterns. */ export function getActiveSliceBranch(basePath: string): string | null { try { const current = getCurrentBranch(basePath); - return current.startsWith("gsd/") ? current : null; + return SLICE_BRANCH_RE.test(current) ? current : null; } catch { return null; } diff --git a/src/resources/extensions/mcporter/index.ts b/src/resources/extensions/mcporter/index.ts index 943dbeacf..f32b5af2c 100644 --- a/src/resources/extensions/mcporter/index.ts +++ b/src/resources/extensions/mcporter/index.ts @@ -64,6 +64,13 @@ const serverDetailCache = new Map(); // ─── Helpers ────────────────────────────────────────────────────────────────── +function escapeShellArg(arg: string): string { + if (process.platform === "win32") { + return `"${arg.replace(/"/g, '""')}"`; + } + return `'${arg.replace(/'/g, "'\\''")}'`; +} + async function runMcporter( args: string[], signal?: AbortSignal, @@ -72,18 +79,17 @@ async function runMcporter( // Cross-platform: use execFile on Windows to avoid quote handling issues // On Windows, cmd.exe doesn't strip single quotes like Unix shells do if (process.platform === "win32") { - // Use execFile directly - Windows doesn't need shell for PATH resolution here const { stdout } = await execFileAsync("mcporter", args, { timeout: timeoutMs, maxBuffer: 1024 * 1024, signal, env: { ...process.env }, - shell: true, // Enable shell for PATH resolution on Windows + shell: true, }); return stdout; } // Use shell exec so PATH resolution works on Unix - const escaped = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" "); + const escaped = args.map((a) => escapeShellArg(a)).join(" "); const { stdout } = await execAsync(`mcporter ${escaped}`, { timeout: timeoutMs, maxBuffer: 1024 * 1024,