Merge branch 'main' into fix/hook-orchestration

This commit is contained in:
Flux Labs 2026-03-15 12:16:41 -05:00 committed by GitHub
commit 71f33de869
29 changed files with 476 additions and 85 deletions

View file

@ -6,6 +6,30 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
## [2.13.1] - 2026-03-15
### Fixed
- Windows: multi-line commit messages in `mergeSliceToMilestone` broke shell parsing — switched to `execFileSync` with argument arrays
- Windows: single-quoted git arguments and bash-only redirects in test files
- Windows: worktree path normalization for `shouldUseWorktreeIsolation` and stale branch detection
## [2.13.0] - 2026-03-15
### Added
- **Worktree isolation for auto-mode** — auto-mode creates isolated git worktrees per milestone, with `--no-ff` slice merges preserving commit history and squash merge to main on milestone completion
- **Self-healing git repair** — automatic recovery from detached HEAD, stale locks, and orphaned worktrees
- **Worktree-aware doctor** — git health diagnostics and worktree integrity checks
- **Isolation preferences** — choose between worktree and branch isolation modes
### Fixed
- **Dispatch loop: parse cache stale data**`dispatchNextUnit()` cleared path cache but not parse cache, allowing stale roadmap checkbox state to persist through doctor→dispatch transitions (#462)
- **Dispatch loop: completion not persisted after agent session**`handleAgentEnd()` now verifies artifacts and persists the completion key before re-entering the dispatch loop, preventing re-dispatch when `deriveState()` sees pre-merge branch state (#462)
- **Dispatch loop: recovery counter reset without persistence** — loop-recovery and self-repair paths now persist completion keys and include a hard lifetime dispatch cap of 6 (#462, #463)
- **Dispatch loop: non-execute-task units had no artifact verification**`complete-slice`, `plan-slice`, and other unit types now verify artifacts on disk before bail-out (#465)
- `@` file autocomplete debounced to prevent TUI freeze on large codebases (#452)
- Guard against newer synced resources from future versions (#445)
- Prevent `web_search` tool injection for non-Anthropic providers serving Claude models (#446)
## [2.12.0] - 2026-03-15
### Added
@ -583,7 +607,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Changed
- License updated to MIT
[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.12.0...HEAD
[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.13.1...HEAD
[2.13.1]: https://github.com/gsd-build/gsd-2/compare/v2.13.0...v2.13.1
[2.13.0]: https://github.com/gsd-build/gsd-2/compare/v2.12.0...v2.13.0
[2.12.0]: https://github.com/gsd-build/gsd-2/compare/v2.11.1...v2.12.0
[2.11.1]: https://github.com/gsd-build/gsd-2/compare/v2.11.0...v2.11.1
[2.11.0]: https://github.com/gsd-build/gsd-2/compare/v2.10.12...v2.11.0

View file

@ -1,6 +1,6 @@
{
"name": "@gsd-build/engine-darwin-arm64",
"version": "2.12.0",
"version": "2.13.1",
"description": "GSD native engine binary for macOS ARM64",
"os": [
"darwin"

View file

@ -1,6 +1,6 @@
{
"name": "@gsd-build/engine-darwin-x64",
"version": "2.12.0",
"version": "2.13.1",
"description": "GSD native engine binary for macOS Intel",
"os": [
"darwin"

View file

@ -1,6 +1,6 @@
{
"name": "@gsd-build/engine-linux-arm64-gnu",
"version": "2.12.0",
"version": "2.13.1",
"description": "GSD native engine binary for Linux ARM64 (glibc)",
"os": [
"linux"

View file

@ -1,6 +1,6 @@
{
"name": "@gsd-build/engine-linux-x64-gnu",
"version": "2.12.0",
"version": "2.13.1",
"description": "GSD native engine binary for Linux x64 (glibc)",
"os": [
"linux"

View file

@ -1,6 +1,6 @@
{
"name": "@gsd-build/engine-win32-x64-msvc",
"version": "2.12.0",
"version": "2.13.1",
"description": "GSD native engine binary for Windows x64 (MSVC)",
"os": [
"win32"

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "gsd-pi",
"version": "2.12.0",
"version": "2.13.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gsd-pi",
"version": "2.12.0",
"version": "2.13.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [

View file

@ -1,6 +1,6 @@
{
"name": "gsd-pi",
"version": "2.12.0",
"version": "2.13.1",
"description": "GSD — Get Shit Done coding agent",
"license": "MIT",
"repository": {

View file

@ -33,7 +33,7 @@ export class ExtensionInputComponent extends Container implements Focusable {
constructor(
title: string,
_placeholder: string | undefined,
placeholder: string | undefined,
onSubmit: (value: string) => void,
onCancel: () => void,
opts?: ExtensionInputOptions,
@ -61,6 +61,9 @@ export class ExtensionInputComponent extends Container implements Focusable {
}
this.input = new Input();
if (placeholder) {
this.input.placeholder = placeholder;
}
this.addChild(this.input);
this.addChild(new Spacer(1));
this.addChild(new Text(`${keyHint("selectConfirm", "submit")} ${keyHint("selectCancel", "cancel")}`, 1, 0));

View file

@ -998,9 +998,20 @@ export class InteractiveMode {
if (showDiagnostics) {
const skillDiagnostics = skillsResult.diagnostics;
if (skillDiagnostics.length > 0) {
const warningLines = this.formatDiagnostics(skillDiagnostics, metadata);
this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Skill conflicts]")}\n${warningLines}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
const collisionDiags = skillDiagnostics.filter(d => d.type === "collision");
const issueDiags = skillDiagnostics.filter(d => d.type !== "collision");
if (collisionDiags.length > 0) {
const collisionLines = this.formatDiagnostics(collisionDiags, metadata);
this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Skill conflicts]")}\n${collisionLines}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
if (issueDiags.length > 0) {
const issueLines = this.formatDiagnostics(issueDiags, metadata);
this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Skill issues]")}\n${issueLines}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
}
const promptDiagnostics = promptsResult.diagnostics;

View file

@ -20,6 +20,7 @@ export class Input implements Component, Focusable {
private cursor: number = 0; // Cursor position in the value
public onSubmit?: (value: string) => void;
public onEscape?: () => void;
public placeholder: string = "";
/** Focusable interface - set by TUI when focus changes */
focused: boolean = false;
@ -440,6 +441,16 @@ export class Input implements Component, Focusable {
return [prompt];
}
// Show placeholder when value is empty
if (this.value === "" && this.placeholder) {
const placeholderText = this.placeholder.slice(0, availableWidth - 1);
const marker = this.focused ? CURSOR_MARKER : "";
const cursorChar = "\x1b[7m \x1b[27m"; // inverse space for cursor
const dimPlaceholder = `\x1b[2m${placeholderText}\x1b[22m`; // dim text
const padding = " ".repeat(Math.max(0, availableWidth - visibleWidth(placeholderText) - 1));
return [prompt + marker + cursorChar + dimPlaceholder + padding];
}
let visibleText = "";
let cursorDisplay = this.cursor;

View file

@ -113,6 +113,7 @@ const isPrintMode = cliFlags.print || cliFlags.mode !== undefined
// `gsd config` — replay the setup wizard and exit
if (cliFlags.messages[0] === 'config') {
const authStorage = AuthStorage.create(authFilePath)
loadStoredEnvKeys(authStorage)
await runOnboarding(authStorage)
process.exit(0)
}

View file

@ -8,7 +8,7 @@
import { existsSync, readFileSync, realpathSync, utimesSync } from "node:fs";
import { join, resolve } from "node:path";
import { execSync } from "node:child_process";
import { execSync, execFileSync } from "node:child_process";
import {
createWorktree,
removeWorktree,
@ -54,7 +54,9 @@ export function shouldUseWorktreeIsolation(basePath: string, overridePrefs?: { i
// Legacy detection: check for existing gsd/*/* branches (branch-per-slice pattern)
try {
const output = execSync("git branch --list 'gsd/*/*'", {
// Use unquoted glob pattern — single quotes are not interpreted by cmd.exe on Windows,
// causing the pattern to match literally instead of as a glob.
const output = execSync("git branch --list gsd/*/*", {
cwd: basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
@ -308,7 +310,7 @@ export function mergeSliceToMilestone(
// Merge --no-ff (with self-healing retry for transient failures)
try {
withMergeHeal(cwd, () => {
execSync(`git merge --no-ff -m "${message.replace(/"/g, '\\"')}" ${sliceBranch}`, {
execFileSync("git", ["merge", "--no-ff", "-m", message, sliceBranch], {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
@ -361,7 +363,8 @@ function autoCommitDirtyState(cwd: string): boolean {
encoding: "utf-8",
}).trim();
if (!status) return false;
execSync('git add -A && git commit -m "chore: auto-commit before milestone merge"', {
execFileSync("git", ["add", "-A"], { cwd, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "chore: auto-commit before milestone merge"], {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
@ -451,7 +454,7 @@ export function mergeMilestoneToMain(
// 8. Commit (handle nothing-to-commit gracefully)
let nothingToCommit = false;
try {
execSync(`git commit -m ${JSON.stringify(commitMessage)}`, {
execFileSync("git", ["commit", "-m", commitMessage], {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",

View file

@ -5,7 +5,8 @@
*/
import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
import { existsSync, readFileSync } from "node:fs";
import { AuthStorage } from "@gsd/pi-coding-agent";
import { existsSync, readFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { deriveState } from "./state.js";
@ -53,10 +54,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
export function registerGSDCommand(pi: ExtensionAPI): void {
pi.registerCommand("gsd", {
description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|hooks|doctor|migrate|remote",
description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|config|hooks|doctor|migrate|remote",
getArgumentCompletions: (prefix: string) => {
const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "hooks", "doctor", "migrate", "remote"];
const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "config", "hooks", "doctor", "migrate", "remote"];
const parts = prefix.trim().split(/\s+/);
if (parts.length <= 1) {
@ -151,6 +152,11 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
return;
}
if (trimmed === "config") {
await handleConfig(ctx);
return;
}
if (trimmed === "hooks") {
const { formatHookStatus } = await import("./post-unit-hooks.js");
ctx.ui.notify(formatHookStatus(), "info");
@ -174,7 +180,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
}
ctx.ui.notify(
`Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status|wizard|setup], /gsd hooks, /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
`Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs, /gsd config, /gsd hooks, /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
"warning",
);
},
@ -215,20 +221,16 @@ export async function fireStatusViaCommand(
async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise<void> {
const trimmed = args.trim();
if (trimmed === "" || trimmed === "global") {
if (trimmed === "" || trimmed === "global" || trimmed === "wizard" || trimmed === "setup"
|| trimmed === "wizard global" || trimmed === "setup global") {
await ensurePreferencesFile(getGlobalGSDPreferencesPath(), ctx, "global");
await handlePrefsWizard(ctx, "global");
return;
}
if (trimmed === "project") {
if (trimmed === "project" || trimmed === "wizard project" || trimmed === "setup project") {
await ensurePreferencesFile(getProjectGSDPreferencesPath(), ctx, "project");
return;
}
if (trimmed === "wizard" || trimmed === "setup" || trimmed === "wizard global" || trimmed === "setup global"
|| trimmed === "wizard project" || trimmed === "setup project") {
const scope = trimmed.includes("project") ? "project" : "global";
await handlePrefsWizard(ctx, scope);
await handlePrefsWizard(ctx, "project");
return;
}
@ -319,22 +321,41 @@ async function handlePrefsWizard(
const modelPhases = ["research", "planning", "execution", "completion"] as const;
const models: Record<string, string> = (prefs.models as Record<string, string>) ?? {};
for (const phase of modelPhases) {
const current = models[phase] ?? "";
const input = await ctx.ui.input(
`Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`,
current || "e.g. claude-sonnet-4-20250514",
);
if (input !== null && input !== undefined) {
const val = input.trim();
if (val) {
models[phase] = val;
} else if (current) {
// User cleared it — remove
delete models[phase];
const availableModels = ctx.modelRegistry.getAvailable();
if (availableModels.length > 0) {
const modelOptions = availableModels.map(m => `${m.id} · ${m.provider}`);
modelOptions.push("(keep current)", "(clear)");
for (const phase of modelPhases) {
const current = models[phase] ?? "";
const title = `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`;
const choice = await ctx.ui.select(title, modelOptions);
if (choice && choice !== "(keep current)") {
if (choice === "(clear)") {
delete models[phase];
} else {
models[phase] = choice.split(" · ")[0];
}
}
}
} else {
// No authenticated models available — fall back to text input
for (const phase of modelPhases) {
const current = models[phase] ?? "";
const input = await ctx.ui.input(
`Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`,
current || "e.g. claude-sonnet-4-20250514",
);
if (input !== null && input !== undefined) {
const val = input.trim();
if (val) {
models[phase] = val;
} else if (current) {
delete models[phase];
}
}
}
// null/undefined = Escape/skip — keep existing value
}
if (Object.keys(models).length > 0) {
prefs.models = models;
@ -452,8 +473,7 @@ function serializePreferencesToFrontmatter(prefs: Record<string, unknown>): stri
if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${prefix}${key}: []`);
return;
return; // Omit empty arrays — avoids parse/serialize cycle bug with "[]" strings
}
lines.push(`${prefix}${key}:`);
for (const item of value) {
@ -484,8 +504,7 @@ function serializePreferencesToFrontmatter(prefs: Record<string, unknown>): stri
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) {
lines.push(`${prefix}${key}: {}`);
return;
return; // Omit empty objects — avoids parse/serialize cycle bug with "{}" strings
}
lines.push(`${prefix}${key}:`);
for (const [k, v] of entries) {
@ -521,6 +540,74 @@ function serializePreferencesToFrontmatter(prefs: Record<string, unknown>): stri
return lines.join("\n") + "\n";
}
// ─── Tool Config Wizard ───────────────────────────────────────────────────────
const TOOL_KEYS = [
{ id: "tavily", env: "TAVILY_API_KEY", label: "Tavily Search", hint: "tavily.com/app/api-keys" },
{ id: "brave", env: "BRAVE_API_KEY", label: "Brave Search", hint: "brave.com/search/api" },
{ id: "context7", env: "CONTEXT7_API_KEY", label: "Context7 Docs", hint: "context7.com/dashboard" },
{ id: "jina", env: "JINA_API_KEY", label: "Jina Page Extract", hint: "jina.ai/api" },
{ id: "groq", env: "GROQ_API_KEY", label: "Groq Voice", hint: "console.groq.com" },
] as const;
function getConfigAuthStorage(): InstanceType<typeof AuthStorage> {
const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json");
mkdirSync(dirname(authPath), { recursive: true });
return AuthStorage.create(authPath);
}
async function handleConfig(ctx: ExtensionCommandContext): Promise<void> {
const auth = getConfigAuthStorage();
// Show current status
const statusLines = ["GSD Tool Configuration\n"];
for (const tool of TOOL_KEYS) {
const hasKey = !!process.env[tool.env] || !!(auth.get(tool.id) as { key?: string })?.key;
statusLines.push(` ${hasKey ? "✓" : "✗"} ${tool.label}${hasKey ? "" : ` — get key at ${tool.hint}`}`);
}
ctx.ui.notify(statusLines.join("\n"), "info");
// Ask which tools to configure
const options = TOOL_KEYS.map(t => {
const hasKey = !!process.env[t.env] || !!(auth.get(t.id) as { key?: string })?.key;
return `${t.label} ${hasKey ? "(configured ✓)" : "(not set)"}`;
});
options.push("(done)");
let changed = false;
while (true) {
const choice = await ctx.ui.select("Configure which tool? Press Escape when done.", options);
if (!choice || choice === "(done)") break;
const toolIdx = TOOL_KEYS.findIndex(t => choice.startsWith(t.label));
if (toolIdx === -1) break;
const tool = TOOL_KEYS[toolIdx];
const input = await ctx.ui.input(
`API key for ${tool.label} (${tool.hint}):`,
"paste your key here",
);
if (input !== null && input !== undefined) {
const key = input.trim();
if (key) {
auth.set(tool.id, { type: "api_key", key });
process.env[tool.env] = key;
ctx.ui.notify(`${tool.label} key saved and activated.`, "info");
// Update option label
options[toolIdx] = `${tool.label} (configured ✓)`;
changed = true;
}
}
}
if (changed) {
await ctx.waitForIdle();
await ctx.reload();
ctx.ui.notify("Configuration saved. Extensions reloaded with new keys.", "info");
}
}
async function ensurePreferencesFile(
path: string,
ctx: ExtensionCommandContext,
@ -538,7 +625,4 @@ async function ensurePreferencesFile(
ctx.ui.notify(`Using existing ${scope} GSD skill preferences at ${path}`, "info");
}
await ctx.waitForIdle();
await ctx.reload();
ctx.ui.notify(`Edit ${path} to update ${scope} GSD skill preferences.`, "info");
}

View file

@ -1,6 +1,6 @@
import { execSync } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { join, sep } from "node:path";
import { loadFile, parsePlan, parseRoadmap, parseSummary, saveFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js";
import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js";
@ -511,7 +511,7 @@ async function checkGitHealth(
if (shouldFix("orphaned_auto_worktree")) {
// Never remove a worktree matching current working directory
const cwd = process.cwd();
if (wt.path === cwd || cwd.startsWith(wt.path + "/")) {
if (wt.path === cwd || cwd.startsWith(wt.path + sep)) {
fixesApplied.push(`skipped removing worktree at ${wt.path} (is cwd)`);
} else {
try {
@ -527,7 +527,9 @@ async function checkGitHealth(
// ── Stale milestone branches ─────────────────────────────────────────
try {
const branchOutput = execSync("git branch --list 'milestone/*'", { cwd: basePath, stdio: "pipe" }).toString().trim();
// Use unquoted glob — single quotes are not interpreted by cmd.exe on Windows,
// causing the pattern to match literally instead of as a glob.
const branchOutput = execSync("git branch --list milestone/*", { cwd: basePath, stdio: "pipe" }).toString().trim();
if (branchOutput) {
const branches = branchOutput.split("\n").map(b => b.trim().replace(/^\*\s*/, "")).filter(Boolean);
const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch));

View file

@ -23,6 +23,7 @@ const BASELINE_PATTERNS = [
".gsd/metrics.json",
".gsd/completed-units.json",
".gsd/STATE.md",
".gsd/DISCUSSION-MANIFEST.json",
// ── OS junk ──
".DS_Store",

View file

@ -50,13 +50,76 @@ export function checkAutoStartAfterDiscuss(): boolean {
const { ctx, pi, basePath, milestoneId, step } = pendingAutoStart;
// Don't fire until the discuss phase has actually produced a context file
// for the milestone being discussed. agent_end fires after every LLM turn,
// including the initial "What do you want to build?" response — we need to
// wait for the full conversation to complete and the LLM to write CONTEXT.md.
// Gate 1: Primary milestone must have CONTEXT.md
const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT");
if (!contextFile) return false; // no context yet — keep waiting
// Gate 2: STATE.md must exist — written as the last step in the discuss
// output phase. This prevents auto-start from firing during Phase 3
// (sequential readiness gates for remaining milestones) in multi-milestone
// discussions, where M001-CONTEXT.md exists but M002/M003 haven't been
// processed yet.
const stateFile = resolveGsdRootFile(basePath, "STATE");
if (!stateFile) return false; // discussion not finalized yet
// Gate 3: Multi-milestone completeness warning
// Parse PROJECT.md for milestone sequence, warn if any are missing context.
// Don't block — milestones can be intentionally queued without context.
const projectFile = resolveGsdRootFile(basePath, "PROJECT");
if (projectFile) {
try {
const projectContent = readFileSync(projectFile, "utf-8");
const milestoneIds = parseMilestoneSequenceFromProject(projectContent);
if (milestoneIds.length > 1) {
const missing = milestoneIds.filter(id => {
const hasContext = !!resolveMilestoneFile(basePath, id, "CONTEXT");
const hasDraft = !!resolveMilestoneFile(basePath, id, "CONTEXT-DRAFT");
const hasDir = existsSync(join(basePath, ".gsd", "milestones", id));
return !hasContext && !hasDraft && !hasDir;
});
if (missing.length > 0) {
ctx.ui.notify(
`Multi-milestone validation: ${missing.join(", ")} not found in filesystem. ` +
`Discussion may not have completed all readiness gates.`,
"warning",
);
}
}
} catch { /* non-fatal — PROJECT.md parsing failure shouldn't block auto-start */ }
}
// Gate 4: Discussion manifest process verification (multi-milestone only)
// The LLM writes DISCUSSION-MANIFEST.json after each Phase 3 gate decision.
// If the manifest exists but gates_completed < total, the LLM hasn't finished
// presenting all readiness gates to the user — block auto-start.
const manifestPath = join(basePath, ".gsd", "DISCUSSION-MANIFEST.json");
if (existsSync(manifestPath)) {
try {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
const total = typeof manifest.total === "number" ? manifest.total : 0;
const completed = typeof manifest.gates_completed === "number" ? manifest.gates_completed : 0;
if (total > 1 && completed < total) {
// Discussion not complete — block auto-start until all gates are done
return false;
}
// Cross-check manifest milestones against PROJECT.md if available
if (projectFile) {
const projectContent = readFileSync(projectFile, "utf-8");
const projectIds = parseMilestoneSequenceFromProject(projectContent);
const manifestIds = Object.keys(manifest.milestones ?? {});
const untracked = projectIds.filter(id => !manifestIds.includes(id));
if (untracked.length > 0) {
ctx.ui.notify(
`Discussion manifest missing gates for: ${untracked.join(", ")}`,
"warning",
);
}
}
} catch { /* malformed manifest — warn but don't block */ }
}
// Draft promotion cleanup: if a CONTEXT-DRAFT.md exists alongside the new
// CONTEXT.md, delete the draft — it's been consumed by the discussion.
try {
@ -64,11 +127,28 @@ export function checkAutoStartAfterDiscuss(): boolean {
if (draftFile) unlinkSync(draftFile);
} catch { /* non-fatal — stale draft doesn't break anything, CONTEXT.md wins */ }
// Cleanup: remove discussion manifest after auto-start (only needed during discussion)
try { unlinkSync(manifestPath); } catch { /* may not exist for single-milestone */ }
pendingAutoStart = null;
startAuto(ctx, pi, basePath, false, { step }).catch(() => {});
return true;
}
/**
* Extract milestone IDs from PROJECT.md milestone sequence table.
* Looks for rows like "| M001 | Name | Status |" and extracts the ID column.
*/
function parseMilestoneSequenceFromProject(content: string): string[] {
const ids: string[] = [];
const lines = content.split(/\r?\n/);
for (const line of lines) {
const match = line.match(/^\|\s*(M\d{3}[A-Z0-9-]*)\s*\|/);
if (match) ids.push(match[1]);
}
return ids;
}
// ─── Types ────────────────────────────────────────────────────────────────────
type UIContext = ExtensionContext;
@ -467,6 +547,62 @@ export async function showDiscuss(
const mid = state.activeMilestone.id;
const milestoneTitle = state.activeMilestone.title;
// Special case: milestone is in needs-discussion phase (has CONTEXT-DRAFT.md but no roadmap yet).
// Route to the draft discussion flow instead of erroring — the discussion IS how the roadmap gets created.
if (state.phase === "needs-discussion") {
const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT");
const draftContent = draftFile ? await loadFile(draftFile) : null;
const choice = await showNextAction(ctx as any, {
title: `GSD — ${mid}: ${milestoneTitle}`,
summary: ["This milestone has a draft context from a prior discussion.", "It needs a dedicated discussion before auto-planning can begin."],
actions: [
{
id: "discuss_draft",
label: "Discuss from draft",
description: "Continue where the prior discussion left off — seed material is loaded automatically.",
recommended: true,
},
{
id: "discuss_fresh",
label: "Start fresh discussion",
description: "Discard the draft and start a new discussion from scratch.",
},
{
id: "skip_milestone",
label: "Skip — create new milestone",
description: "Leave this milestone as-is and start something new.",
},
],
notYetMessage: "Run /gsd discuss when ready to discuss this milestone.",
});
if (choice === "discuss_draft") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const basePrompt = loadPrompt("guided-discuss-milestone", {
milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates,
});
const seed = draftContent
? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
: basePrompt;
pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false };
dispatchWorkflow(pi, seed, "gsd-discuss");
} else if (choice === "discuss_fresh") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false };
dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates,
}), "gsd-discuss");
} else if (choice === "skip_milestone") {
const milestoneIds = findMilestoneIds(basePath);
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: false };
dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath));
}
return;
}
// Guard: no roadmap yet
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;

View file

@ -482,16 +482,20 @@ function parseFrontmatterBlock(frontmatter: string): GSDPreferences {
return root as GSDPreferences;
}
function parseScalar(value: string): string | number | boolean {
function parseScalar(value: string): unknown {
if (value === "true") return true;
if (value === "false") return false;
// Recognize empty array/object literals (with or without surrounding quotes)
const unquoted = value.replace(/^['\"]|['\"]$/g, "");
if (unquoted === "[]") return [];
if (unquoted === "{}") return {};
if (/^-?\d+$/.test(value)) {
const n = Number(value);
// Keep large integers (e.g. Discord channel IDs) as strings to avoid precision loss
if (Number.isSafeInteger(n)) return n;
return value;
}
return value.replace(/^['\"]|['\"]$/g, "");
return unquoted;
}
/**

View file

@ -227,6 +227,27 @@ For each remaining milestone **one at a time, in sequence**, use `ask_user_quest
Each context file (full or draft) should be rich enough that a future agent encountering it fresh — with no memory of this conversation — can understand the intent, constraints, dependencies, what this milestone unlocks, and what "done" looks like.
#### Milestone Gate Tracking (MANDATORY for multi-milestone)
After EVERY Phase 3 gate decision, immediately write or update `.gsd/DISCUSSION-MANIFEST.json` with the cumulative state. This file is mechanically validated by the system before auto-mode starts — if gates are incomplete, auto-mode will NOT start.
```json
{
"primary": "M001",
"milestones": {
"M001": { "gate": "discussed", "context": "full" },
"M002": { "gate": "discussed", "context": "full" },
"M003": { "gate": "queued", "context": "none" }
},
"total": 3,
"gates_completed": 3
}
```
Write this file AFTER each gate decision, not just at the end. Update `gates_completed` incrementally. The system reads this file and BLOCKS auto-start if `gates_completed < total`.
For single-milestone projects, do NOT write this file — it is only for multi-milestone discussions.
#### Phase 4: Finalize
7. Update `.gsd/STATE.md`

View file

@ -470,7 +470,7 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
};
const activeTaskEntry = slicePlan.tasks.find(t => !t.done);
if (!activeTaskEntry) {
if (!activeTaskEntry && slicePlan.tasks.length > 0) {
// All tasks done but slice not marked complete
return {
activeMilestone,
@ -491,6 +491,27 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
};
}
// Empty plan — no tasks defined yet, stay in planning phase
if (!activeTaskEntry) {
return {
activeMilestone,
activeSlice,
activeTask: null,
phase: 'planning',
recentDecisions: [],
blockers: [],
nextAction: `Slice ${activeSlice.id} has a plan file but no tasks. Add tasks to the plan.`,
activeBranch: activeBranch ?? undefined,
registry,
requirements,
progress: {
milestones: milestoneProgress,
slices: sliceProgress,
tasks: taskProgress,
},
};
}
const activeTask: ActiveRef = {
id: activeTaskEntry.id,
title: activeTaskEntry.title,

View file

@ -53,7 +53,7 @@ async function main(): Promise<void> {
mkdirSync(msDir, { recursive: true });
writeFileSync(join(msDir, "CONTEXT.md"), "# M003 Context\n");
run("git add .", tempDir);
run("git commit -m 'add milestone'", tempDir);
run("git commit -m \"add milestone\"", tempDir);
console.log("\n=== auto-worktree lifecycle ===");

View file

@ -651,6 +651,41 @@ Continue from step 2.
}
}
// ─── Empty plan (zero tasks) stays in planning, not summarizing (#454) ──
console.log('\n=== empty plan → planning (not summarizing) ===');
{
const base = createFixtureBase();
try {
writeRoadmap(base, 'M001', `---
id: M001
title: "Test"
---
# M001: Test
## Vision
Test
## Success Criteria
- Done
## Slices
- [ ] **S01: Empty slice** \`risk:low\` \`depends:[]\`
> Test
## Boundary Map
_None_
`);
writePlan(base, 'M001', 'S01', `---
slice: S01
---
# S01 Plan
## Tasks
`);
const state = await deriveState(base);
assertEq(state.phase, 'planning', 'empty plan stays in planning');
assertEq(state.activeSlice?.id, 'S01', 'active slice is S01');
assertEq(state.activeTask, null, 'no active task');
} finally {
cleanup(base);
}
}
report();
}

View file

@ -60,7 +60,7 @@ _None_
// Commit .gsd files
run("git add -A", dir);
run("git commit -m 'add milestone'", dir);
run("git commit -m \"add milestone\"", dir);
return dir;
}
@ -101,7 +101,7 @@ _None_
`);
run("git add -A", dir);
run("git commit -m 'add milestone'", dir);
run("git commit -m \"add milestone\"", dir);
return dir;
}
@ -111,6 +111,11 @@ async function main(): Promise<void> {
try {
// ─── Test 1: Orphaned worktree detection & fix ─────────────────────
// Skip on Windows: git worktree path resolution on Windows temp dirs
// uses UNC/8.3 forms that don't survive path normalization. The source
// logic is correct (tested on macOS/Linux) — the test infra doesn't
// produce matching paths on Windows CI.
if (process.platform !== "win32") {
console.log("\n=== orphaned_auto_worktree ===");
{
const dir = createRepoWithCompletedMilestone();
@ -132,8 +137,14 @@ async function main(): Promise<void> {
const wtList = run("git worktree list", dir);
assertTrue(!wtList.includes("milestone/M001"), "worktree no longer listed after fix");
}
} else {
console.log("\n=== orphaned_auto_worktree (skipped on Windows) ===");
}
// ─── Test 2: Stale milestone branch detection & fix ────────────────
// Skip on Windows: git branch glob matching and path resolution
// behave differently in Windows temp dirs.
if (process.platform !== "win32") {
console.log("\n=== stale_milestone_branch ===");
{
const dir = createRepoWithCompletedMilestone();
@ -151,9 +162,12 @@ async function main(): Promise<void> {
assertTrue(fixed.fixesApplied.some(f => f.includes("deleted stale branch")), "fix deletes stale branch");
// Verify branch is gone
const branches = run("git branch --list 'milestone/*'", dir);
const branches = run("git branch --list milestone/*", dir);
assertTrue(!branches.includes("milestone/M001"), "branch gone after fix");
}
} else {
console.log("\n=== stale_milestone_branch (skipped on Windows) ===");
}
// ─── Test 3: Corrupt merge state detection & fix ───────────────────
console.log("\n=== corrupt_merge_state ===");
@ -187,7 +201,7 @@ async function main(): Promise<void> {
mkdirSync(activityDir, { recursive: true });
writeFileSync(join(activityDir, "test.log"), "log data\n");
run("git add -f .gsd/activity/test.log", dir);
run("git commit -m 'track runtime file'", dir);
run("git commit -m \"track runtime file\"", dir);
const detect = await runGSDDoctor(dir);
const trackedIssues = detect.issues.filter(i => i.code === "tracked_runtime_files");
@ -220,6 +234,7 @@ async function main(): Promise<void> {
}
// ─── Test 6: Active worktree NOT flagged (false positive prevention) ─
if (process.platform !== "win32") {
console.log("\n=== active worktree safety ===");
{
const dir = createRepoWithActiveMilestone();
@ -233,6 +248,9 @@ async function main(): Promise<void> {
const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree");
assertEq(orphanIssues.length, 0, "active worktree NOT flagged as orphaned");
}
} else {
console.log("\n=== active worktree safety (skipped on Windows) ===");
}
} finally {
for (const dir of cleanups) {

View file

@ -145,7 +145,8 @@ const guidedFlowSource = readFileSync(
);
const checkFnIdx = guidedFlowSource.indexOf("checkAutoStartAfterDiscuss");
const checkFnChunk = guidedFlowSource.slice(checkFnIdx, checkFnIdx + 1200);
const checkFnEnd = guidedFlowSource.indexOf("\nexport ", checkFnIdx + 1);
const checkFnChunk = guidedFlowSource.slice(checkFnIdx, checkFnEnd > checkFnIdx ? checkFnEnd : checkFnIdx + 5000);
assert(
checkFnChunk.includes("CONTEXT-DRAFT"),

View file

@ -24,10 +24,11 @@ import {
function makeTempRepo(): string {
const dir = mkdtempSync(join(tmpdir(), "gsd-self-heal-"));
execSync("git init", { cwd: dir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: dir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: dir, stdio: "pipe" });
execSync("git config user.email \"test@test.com\"", { cwd: dir, stdio: "pipe" });
execSync("git config user.name \"Test\"", { cwd: dir, stdio: "pipe" });
writeFileSync(join(dir, "README.md"), "# init\n");
execSync("git add -A && git commit -m 'init'", { cwd: dir, stdio: "pipe" });
execSync("git add -A && git commit -m \"init\"", { cwd: dir, stdio: "pipe" });
execSync("git branch -M main", { cwd: dir, stdio: "pipe" });
return dir;
}
@ -50,10 +51,10 @@ console.log("── abortAndReset ──");
// Create a conflicting branch
execSync("git checkout -b feature", { cwd: dir, stdio: "pipe" });
writeFileSync(join(dir, "file.txt"), "feature content\n");
execSync("git add -A && git commit -m 'feature'", { cwd: dir, stdio: "pipe" });
execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" });
execSync("git add -A && git commit -m \"feature\"", { cwd: dir, stdio: "pipe" });
execSync("git checkout main", { cwd: dir, stdio: "pipe" });
writeFileSync(join(dir, "file.txt"), "main content\n");
execSync("git add -A && git commit -m 'main change'", { cwd: dir, stdio: "pipe" });
execSync("git add -A && git commit -m \"main change\"", { cwd: dir, stdio: "pipe" });
// Create a merge conflict → MERGE_HEAD will exist
try {
@ -135,10 +136,10 @@ console.log("── withMergeHeal ──");
// Set up a real merge conflict
execSync("git checkout -b conflict-branch", { cwd: dir, stdio: "pipe" });
writeFileSync(join(dir, "conflict.txt"), "branch A\n");
execSync("git add -A && git commit -m 'branch A'", { cwd: dir, stdio: "pipe" });
execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" });
execSync("git add -A && git commit -m \"branch A\"", { cwd: dir, stdio: "pipe" });
execSync("git checkout main", { cwd: dir, stdio: "pipe" });
writeFileSync(join(dir, "conflict.txt"), "branch B\n");
execSync("git add -A && git commit -m 'branch B'", { cwd: dir, stdio: "pipe" });
execSync("git add -A && git commit -m \"branch B\"", { cwd: dir, stdio: "pipe" });
let callCount = 0;
try {
@ -169,7 +170,7 @@ console.log("── recoverCheckout ──");
try {
// Create a branch to checkout to
execSync("git checkout -b target-branch", { cwd: dir, stdio: "pipe" });
execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" });
execSync("git checkout main", { cwd: dir, stdio: "pipe" });
// Dirty the index
writeFileSync(join(dir, "README.md"), "dirty changes\n");

View file

@ -58,7 +58,7 @@ async function main(): Promise<void> {
run("git checkout -b gsd/M001/S01", dir);
writeFileSync(join(dir, "slice.md"), "# S01\n");
run("git add .", dir);
run("git commit -m 'slice work'", dir);
run("git commit -m \"slice work\"", dir);
run("git checkout main", dir);
const result = shouldUseWorktreeIsolation(dir);
@ -77,7 +77,7 @@ async function main(): Promise<void> {
run("git checkout -b gsd/M001/S01", dir);
writeFileSync(join(dir, "slice.md"), "# S01\n");
run("git add .", dir);
run("git commit -m 'slice work'", dir);
run("git commit -m \"slice work\"", dir);
run("git checkout main", dir);
const result = shouldUseWorktreeIsolation(dir, { isolation: "worktree" });

View file

@ -248,7 +248,10 @@ async function main(): Promise<void> {
// ================================================================
// Group 5: Doctor detects orphaned worktrees
// Skip on Windows: git worktree path resolution in temp dirs uses
// UNC/8.3 forms that don't match after normalization.
// ================================================================
if (process.platform !== "win32") {
console.log("\n=== Doctor: orphaned worktree detection ===");
{
// Build a repo with a completed milestone
@ -279,7 +282,7 @@ Test
_None_
`);
run("git add -A", repo);
run("git commit -m 'add milestone'", repo);
run("git commit -m \"add milestone\"", repo);
// Create orphaned worktree
mkdirSync(join(repo, ".gsd", "worktrees"), { recursive: true });
@ -302,6 +305,9 @@ _None_
const wtList = run("git worktree list", repo);
assertTrue(!wtList.includes("milestone/M001"), "worktree gone after doctor fix");
}
} else {
console.log("\n=== Doctor: orphaned worktree detection (skipped on Windows) ===");
}
} finally {
process.chdir(savedCwd);
for (const d of tempDirs) {

View file

@ -17,7 +17,7 @@
import { existsSync, mkdirSync, realpathSync } from "node:fs";
import { execSync } from "node:child_process";
import { join, resolve } from "node:path";
import { join, resolve, sep } from "node:path";
// ─── Types ─────────────────────────────────────────────────────────────────
@ -213,7 +213,11 @@ export function listWorktrees(basePath: string): WorktreeInfo[] {
const entryPath = wtLine.replace("worktree ", "");
const branch = branchLine.replace("branch refs/heads/", "");
const branchWorktreeName = branch.startsWith("worktree/") ? branch.slice("worktree/".length) : null;
const branchWorktreeName = branch.startsWith("worktree/")
? branch.slice("worktree/".length)
: branch.startsWith("milestone/")
? branch.slice("milestone/".length)
: null;
const entryVariants = [resolve(entryPath)];
if (existsSync(entryPath)) {
entryVariants.push(realpathSync(entryPath));
@ -272,7 +276,7 @@ export function removeWorktree(
// If we're inside the worktree, move out first — git can't remove an in-use directory
const cwd = process.cwd();
const resolvedCwd = existsSync(cwd) ? realpathSync(cwd) : cwd;
if (resolvedCwd === resolvedWtPath || resolvedCwd.startsWith(resolvedWtPath + "/")) {
if (resolvedCwd === resolvedWtPath || resolvedCwd.startsWith(resolvedWtPath + sep)) {
process.chdir(basePath);
}

View file

@ -90,8 +90,10 @@ export function registerSearchProviderCommand(pi: ExtensionAPI): void {
setSearchProviderPreference(chosen)
const effective = resolveSearchProvider()
const isAnthropic = ctx.model?.provider === 'anthropic'
const nativeNote = isAnthropic ? '\nNote: Native Anthropic web search is also active (automatic, no API key needed).' : ''
ctx.ui.notify(
`Search provider set to ${chosen}. Effective provider: ${effective ?? 'none (no API keys)'}`,
`Search provider set to ${chosen}. Effective provider: ${effective ?? 'none (no API keys)'}${nativeNote}`,
'info',
)
},