Merge origin/main into fix/auto-mode-infinite-loop-96-109

Resolve conflicts keeping PR's improvements (idempotency, recovery backoff,
self-heal, completedKeySet) merged with main's existing partial fixes
(dispatch reorder, alternating loop detection, slice-branch-chaining guard).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-12 09:01:27 -06:00
commit 527b63ac36
18 changed files with 897 additions and 178 deletions

View file

@ -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:

View file

@ -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 <text|json|rpc> Output mode (default: interactive)\n')
process.stdout.write(' --print, -p Single-shot print mode\n')
process.stdout.write(' --model <id> Override model (e.g. claude-opus-4-6)\n')
process.stdout.write(' --no-session Disable session persistence\n')
process.stdout.write(' --extension <path> Load additional extension\n')
process.stdout.write(' --tools <a,b,c> 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()

View file

@ -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: {} }],

View file

@ -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);

View file

@ -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<Record<string, string>>) || []).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) || '',

View file

@ -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"))) {

View file

@ -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;

View file

@ -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<ValidationResult> {
@ -26,9 +26,11 @@ export async function validatePlanningDirectory(path: string): Promise<Validatio
return { valid: false, issues };
}
// ROADMAP.md is required (fatal if missing)
// ROADMAP.md — warn if missing (transformer falls back to filesystem phases)
if (!existsSync(join(path, 'ROADMAP.md'))) {
issues.push(issue('ROADMAP.md', 'fatal', 'ROADMAP.md is required for migration'));
issues.push(issue('ROADMAP.md', 'warning',
'ROADMAP.md not found — milestone structure will be inferred from phases/ directory',
));
}
// Optional files — warn if missing

View file

@ -76,7 +76,7 @@ Titles live inside file content (headings, frontmatter), not in file or director
- **Slices** are demoable vertical increments (S01, S02, ...) ordered by risk. After each slice completes, the roadmap is reassessed before the next slice begins.
- **Tasks** are single-context-window units of work (T01, T02, ...)
- Checkboxes in roadmap and plan files track completion (`[ ]``[x]`)
- Each slice gets its own git branch: `gsd/M001/S01`
- Each slice gets its own git branch: `gsd/M001/S01` (or `gsd/<worktree>/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

View file

@ -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);

View file

@ -211,15 +211,15 @@ async function main(): Promise<void> {
}
}
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);
}

View file

@ -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
// ═══════════════════════════════════════════════════════════════════════════

View file

@ -0,0 +1,253 @@
/**
* Worktree Integration Tests
*
* Tests the full lifecycle of GSD operations inside a worktree:
* - Branch namespacing (gsd/<wt>/<M>/<S> instead of gsd/<M>/<S>)
* - getMainBranch returns worktree/<name> inside a worktree
* - switchToMain goes to worktree/<name>, not main
* - mergeSliceToMain merges into worktree/<name>
* - 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<T>(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<void> {
// ── 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);
});

View file

@ -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<void> {
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`);

View file

@ -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,
};
}

View file

@ -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<void> {
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);

View file

@ -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/<name>/).
*/
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/<milestoneId>/<sliceId>
* In a worktree: gsd/<worktreeName>/<milestoneId>/<sliceId>
*
* 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/<name> 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/<name>), 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;
}

View file

@ -64,6 +64,13 @@ const serverDetailCache = new Map<string, McpServerDetail>();
// ─── 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,