diff --git a/.gitignore b/.gitignore index 0ca939ee9..7631dc9a1 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ TODOS.md .gsd/auto.lock .gsd/metrics.json .gsd/STATE.md + +# ── GSD baseline (auto-generated) ── +.gsd/completed-units.json diff --git a/README.md b/README.md index 416341b72..3677dff82 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The original GSD went viral as a prompt framework for Claude Code. It worked, but it was fighting the tool — injecting prompts through slash commands, hoping the LLM would follow instructions, with no actual control over context windows, sessions, or execution. -This version is different. GSD is now a standalone CLI built on the [Pi SDK](https://github.com/badlogic/pi-mono), which gives it direct TypeScript access to the agent harness itself. That means GSD can actually *do* what v1 could only *ask* the LLM to do: clear context between tasks, inject exactly the right files at dispatch time, manage git branches, track cost and tokens, detect stuck loops, recover from crashes, and auto-advance through an entire milestone without human intervention. +This version is different. GSD is now a standalone CLI built on the [Pi SDK](https://github.com/badlogic/pi-mono), which gives it direct TypeScript access to the agent harness itself. That means GSD can actually _do_ what v1 could only _ask_ the LLM to do: clear context between tasks, inject exactly the right files at dispatch time, manage git branches, track cost and tokens, detect stuck loops, recover from crashes, and auto-advance through an entire milestone without human intervention. One command. Walk away. Come back to a built project with clean git history. @@ -30,21 +30,21 @@ The original GSD was a collection of markdown prompts installed into `~/.claude/ - **No crash recovery.** If the session died mid-task, you started over. - **No observability.** No cost tracking, no progress dashboard, no stuck detection. -GSD v2 solves all of these because it's not a prompt framework anymore — it's a TypeScript application that *controls* the agent session. +GSD v2 solves all of these because it's not a prompt framework anymore — it's a TypeScript application that _controls_ the agent session. -| | v1 (Prompt Framework) | v2 (Agent Application) | -|---|---|---| -| Runtime | Claude Code slash commands | Standalone CLI via Pi SDK | -| Context management | Hope the LLM doesn't fill up | Fresh session per task, programmatic | -| Auto mode | LLM self-loop | State machine reading `.gsd/` files | -| Crash recovery | None | Lock files + session forensics | -| Git strategy | LLM writes git commands | Programmatic branch-per-slice, squash merge | -| Cost tracking | None | Per-unit token/cost ledger with dashboard | -| Stuck detection | None | Retry once, then stop with diagnostics | -| Timeout supervision | None | Soft/idle/hard timeouts with recovery steering | -| Context injection | "Read this file" | Pre-inlined into dispatch prompt | -| Roadmap reassessment | Manual | Automatic after each slice completes | -| Skill discovery | None | Auto-detect and install relevant skills during research | +| | v1 (Prompt Framework) | v2 (Agent Application) | +| -------------------- | ---------------------------- | ------------------------------------------------------- | +| Runtime | Claude Code slash commands | Standalone CLI via Pi SDK | +| Context management | Hope the LLM doesn't fill up | Fresh session per task, programmatic | +| Auto mode | LLM self-loop | State machine reading `.gsd/` files | +| Crash recovery | None | Lock files + session forensics | +| Git strategy | LLM writes git commands | Programmatic branch-per-slice, squash merge | +| Cost tracking | None | Per-unit token/cost ledger with dashboard | +| Stuck detection | None | Retry once, then stop with diagnostics | +| Timeout supervision | None | Soft/idle/hard timeouts with recovery steering | +| Context injection | "Read this file" | Pre-inlined into dispatch prompt | +| Roadmap reassessment | Manual | Automatic after each slice completes | +| Skill discovery | None | Auto-detect and install relevant skills during research | ### Migrating from v1 @@ -61,6 +61,7 @@ If you have projects with `.planning` directories from the original Get Shit Don ``` The migration tool: + - Parses your old `PROJECT.md`, `ROADMAP.md`, `REQUIREMENTS.md`, phase directories, plans, summaries, and research - Maps phases → slices, plans → tasks, milestones → milestones - Preserves completion state (`[x]` phases stay done, summaries carry over) @@ -110,7 +111,7 @@ Auto mode is a state machine driven by files on disk. It reads `.gsd/STATE.md`, 2. **Context pre-loading** — The dispatch prompt includes inlined task plans, slice plans, prior task summaries, dependency summaries, roadmap excerpts, and decisions register. The LLM starts with everything it needs instead of spending tool calls reading files. -3. **Git branch-per-slice** — Each slice gets its own branch (`gsd/M001/S01`). Tasks commit atomically on the branch. When the slice completes, it's squash-merged to main as one clean commit. +3. **Git branch-per-slice** — Each slice gets its own branch (`gsd/M001/S01`). Tasks commit atomically on the branch. When the slice completes, it's squash-merged to main (or whichever branch you started from) as one clean commit. 4. **Crash recovery** — A lock file tracks the current unit. If the session dies, the next `/gsd auto` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context. @@ -183,12 +184,14 @@ GSD opens an interactive agent session. From there, you have two ways to work: The real workflow: run auto mode in one terminal, steer from another. **Terminal 1 — let it build** + ```bash gsd /gsd auto ``` **Terminal 2 — steer while it works** + ```bash gsd /gsd discuss # talk through architecture decisions @@ -204,28 +207,28 @@ On first run, GSD launches a branded setup wizard that walks you through LLM pro ### Commands -| Command | What it does | -|---------|-------------| -| `/gsd` | Step mode — executes one unit at a time, pauses between each | -| `/gsd next` | Explicit step mode (same as bare `/gsd`) | -| `/gsd auto` | Autonomous mode — researches, plans, executes, commits, repeats | -| `/gsd stop` | Stop auto mode gracefully | -| `/gsd discuss` | Discuss architecture and decisions (works alongside auto mode) | -| `/gsd status` | Progress dashboard | -| `/gsd queue` | Queue future milestones (safe during auto mode) | -| `/gsd prefs` | Model selection, timeouts, budget ceiling | -| `/gsd migrate` | Migrate a v1 `.planning` directory to `.gsd` format | -| `/gsd doctor` | Validate `.gsd/` integrity, find and fix issues | -| `/worktree` (`/wt`) | Git worktree lifecycle — create, switch, merge, remove | -| `/voice` | Toggle real-time speech-to-text (macOS only) | -| `/exit` | Graceful shutdown — saves session state before exiting | -| `/kill` | Kill GSD process immediately | -| `/clear` | Start a new session (alias for `/new`) | -| `Ctrl+Alt+G` | Toggle dashboard overlay | -| `Ctrl+Alt+V` | Toggle voice transcription | -| `Ctrl+Alt+B` | Show background shell processes | -| `gsd config` | Re-run the setup wizard (LLM provider + tool keys) | -| `gsd --continue` (`-c`) | Resume the most recent session for the current directory | +| Command | What it does | +| ----------------------- | --------------------------------------------------------------- | +| `/gsd` | Step mode — executes one unit at a time, pauses between each | +| `/gsd next` | Explicit step mode (same as bare `/gsd`) | +| `/gsd auto` | Autonomous mode — researches, plans, executes, commits, repeats | +| `/gsd stop` | Stop auto mode gracefully | +| `/gsd discuss` | Discuss architecture and decisions (works alongside auto mode) | +| `/gsd status` | Progress dashboard | +| `/gsd queue` | Queue future milestones (safe during auto mode) | +| `/gsd prefs` | Model selection, timeouts, budget ceiling | +| `/gsd migrate` | Migrate a v1 `.planning` directory to `.gsd` format | +| `/gsd doctor` | Validate `.gsd/` integrity, find and fix issues | +| `/worktree` (`/wt`) | Git worktree lifecycle — create, switch, merge, remove | +| `/voice` | Toggle real-time speech-to-text (macOS only) | +| `/exit` | Graceful shutdown — saves session state before exiting | +| `/kill` | Kill GSD process immediately | +| `/clear` | Start a new session (alias for `/new`) | +| `Ctrl+Alt+G` | Toggle dashboard overlay | +| `Ctrl+Alt+V` | Toggle voice transcription | +| `Ctrl+Alt+B` | Show background shell processes | +| `gsd config` | Re-run the setup wizard (LLM provider + tool keys) | +| `gsd --continue` (`-c`) | Resume the most recent session for the current directory | --- @@ -235,18 +238,18 @@ On first run, GSD launches a branded setup wizard that walks you through LLM pro Every dispatch is carefully constructed. The LLM never wastes tool calls on orientation. -| Artifact | Purpose | -|----------|---------| -| `PROJECT.md` | Living doc — what the project is right now | -| `DECISIONS.md` | Append-only register of architectural decisions | -| `STATE.md` | Quick-glance dashboard — always read first | -| `M001-ROADMAP.md` | Milestone plan with slice checkboxes, risk levels, dependencies | -| `M001-CONTEXT.md` | User decisions from the discuss phase | -| `M001-RESEARCH.md` | Codebase and ecosystem research | -| `S01-PLAN.md` | Slice task decomposition with must-haves | -| `T01-PLAN.md` | Individual task plan with verification criteria | -| `T01-SUMMARY.md` | What happened — YAML frontmatter + narrative | -| `S01-UAT.md` | Human test script derived from slice outcomes | +| Artifact | Purpose | +| ------------------ | --------------------------------------------------------------- | +| `PROJECT.md` | Living doc — what the project is right now | +| `DECISIONS.md` | Append-only register of architectural decisions | +| `STATE.md` | Quick-glance dashboard — always read first | +| `M001-ROADMAP.md` | Milestone plan with slice checkboxes, risk levels, dependencies | +| `M001-CONTEXT.md` | User decisions from the discuss phase | +| `M001-RESEARCH.md` | Codebase and ecosystem research | +| `S01-PLAN.md` | Slice task decomposition with must-haves | +| `T01-PLAN.md` | Individual task plan with verification criteria | +| `T01-SUMMARY.md` | What happened — YAML frontmatter + narrative | +| `S01-UAT.md` | Human test script derived from slice outcomes | ### Git Strategy @@ -265,7 +268,7 @@ gsd/M001/S01 (deleted after merge): feat(S01/T01): core types and interfaces ``` -One commit per slice on main. Squash commits are the permanent record — branches are deleted after merge. Git bisect works. Individual slices are revertable. +One commit per slice on main (or whichever branch you started from). Squash commits are the permanent record — branches are deleted after merge. Git bisect works. Individual slices are revertable. ### Verification @@ -313,51 +316,95 @@ auto_supervisor: idle_timeout_minutes: 10 hard_timeout_minutes: 30 budget_ceiling: 50.00 +unique_milestone_ids: true --- ``` **Key settings:** -| Setting | What it controls | -|---------|-----------------| -| `models.*` | Per-phase model selection — string for a single model, or `{model, fallbacks}` for automatic failover | -| `skill_discovery` | `auto` / `suggest` / `off` — how GSD finds and applies skills | -| `auto_supervisor.*` | Timeout thresholds for auto mode supervision | -| `budget_ceiling` | USD ceiling — auto mode pauses when reached | -| `uat_dispatch` | Enable automatic UAT runs after slice completion | -| `always_use_skills` | Skills to always load when relevant | -| `skill_rules` | Situational rules for skill routing | +| Setting | What it controls | +| ---------------------- | ----------------------------------------------------------------------------------------------------- | +| `models.*` | Per-phase model selection — string for a single model, or `{model, fallbacks}` for automatic failover | +| `skill_discovery` | `auto` / `suggest` / `off` — how GSD finds and applies skills | +| `auto_supervisor.*` | Timeout thresholds for auto mode supervision | +| `budget_ceiling` | USD ceiling — auto mode pauses when reached | +| `uat_dispatch` | Enable automatic UAT runs after slice completion | +| `always_use_skills` | Skills to always load when relevant | +| `skill_rules` | Situational rules for skill routing | +| `unique_milestone_ids` | Uses unique milestone names to avoid clashes when working in teams of people | ### Bundled Tools GSD ships with 14 extensions, all loaded automatically: -| Extension | What it provides | -|-----------|-----------------| -| **GSD** | Core workflow engine, auto mode, commands, dashboard | -| **Browser Tools** | Playwright-based browser with form intelligence, intent-ranked element finding, and semantic actions | -| **Search the Web** | Brave Search, Tavily, or Jina page extraction | -| **Google Search** | Gemini-powered web search with AI-synthesized answers | -| **Context7** | Up-to-date library/framework documentation | -| **Background Shell** | Long-running process management with readiness detection | -| **Subagent** | Delegated tasks with isolated context windows | -| **Mac Tools** | macOS native app automation via Accessibility APIs | -| **MCPorter** | Lazy on-demand MCP server integration | -| **Voice** | Real-time speech-to-text transcription (macOS) | -| **Slash Commands** | Custom command creation | -| **LSP** | Language Server Protocol integration — diagnostics, go-to-definition, references, hover, symbols, rename, code actions | -| **Ask User Questions** | Structured user input with single/multi-select | -| **Secure Env Collect** | Masked secret collection without manual .env editing | +| Extension | What it provides | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| **GSD** | Core workflow engine, auto mode, commands, dashboard | +| **Browser Tools** | Playwright-based browser with form intelligence, intent-ranked element finding, and semantic actions | +| **Search the Web** | Brave Search, Tavily, or Jina page extraction | +| **Google Search** | Gemini-powered web search with AI-synthesized answers | +| **Context7** | Up-to-date library/framework documentation | +| **Background Shell** | Long-running process management with readiness detection | +| **Subagent** | Delegated tasks with isolated context windows | +| **Mac Tools** | macOS native app automation via Accessibility APIs | +| **MCPorter** | Lazy on-demand MCP server integration | +| **Voice** | Real-time speech-to-text transcription (macOS) | +| **Slash Commands** | Custom command creation | +| **LSP** | Language Server Protocol integration — diagnostics, go-to-definition, references, hover, symbols, rename, code actions | +| **Ask User Questions** | Structured user input with single/multi-select | +| **Secure Env Collect** | Masked secret collection without manual .env editing | ### Bundled Agents Three specialized subagents for delegated work: -| Agent | Role | -|-------|------| -| **Scout** | Fast codebase recon — returns compressed context for handoff | -| **Researcher** | Web research — finds and synthesizes current information | -| **Worker** | General-purpose execution in an isolated context window | +| Agent | Role | +| -------------- | ------------------------------------------------------------ | +| **Scout** | Fast codebase recon — returns compressed context for handoff | +| **Researcher** | Web research — finds and synthesizes current information | +| **Worker** | General-purpose execution in an isolated context window | + +--- + +## Working in teams + +The best practice for working in teams is to ensure unique milestone names across all branches (by using `unique_milestone_ids`) and checking in the right `.gsd/` artifacts to share valueable context between teammates. + +### Suggested .gitignore setup +```bash +# ── GSD: Runtime / Ephemeral (per-developer, per-session) ────────────────── +# Crash detection sentinel — PID lock, written per auto-mode session +.gsd/auto.lock +# Auto-mode dispatch tracker — prevents re-running completed units +.gsd/completed-units.json +# Derived state cache — regenerated from plan/roadmap files on disk +.gsd/STATE.md +# Per-developer token/cost accumulator +.gsd/metrics.json +# Raw JSONL session dumps — crash recovery forensics, auto-pruned +.gsd/activity/ +# Unit execution records — dispatch phase, timeouts, recovery tracking +.gsd/runtime/ +# Git worktree working copies +.gsd/worktrees/ +# Session-specific interrupted-work markers +.gsd/milestones/**/continue.md +.gsd/milestones/**/*-CONTINUE.md +``` + +### Unique Milestone Names + +Create or amend your `.gsd/preferences.md` file within the repo to include `unique_milestone_ids: true` e.g. +```markdown +--- +version: 1 +unique_milestone_ids: true +--- +``` + +With the above `.gitignore` set up, the `.gsd/preferences.md` file is checked into the repo ensuring all teammates use unique milestone names to avoid collisions. + +Milestone names will now be generated with a 6 char random string appended e.g. instead of `M001` you'll get something like `M001-ush8s3` --- @@ -397,6 +444,7 @@ gsd (CLI binary) - **Git** — initialized automatically if missing Optional: + - Brave Search API key (web research) - Tavily API key (web research — alternative to Brave) - Google Gemini API key (web research via Gemini Search grounding) diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 09829dcbd..f1b661f6e 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -393,6 +393,16 @@ async function handlePrefsWizard( prefs.skill_discovery = discoveryChoice; } + // ─── Unique milestone IDs ────────────────────────────────────────────── + const currentUnique = prefs.unique_milestone_ids; + const uniqueChoice = await ctx.ui.select( + `Unique milestone IDs${currentUnique !== undefined ? ` (current: ${currentUnique})` : ""}:`, + ["true", "false", "(keep current)"], + ); + if (uniqueChoice && uniqueChoice !== "(keep current)") { + prefs.unique_milestone_ids = uniqueChoice === "true"; + } + // ─── Serialize to frontmatter ─────────────────────────────────────────── prefs.version = prefs.version || 1; const frontmatter = serializePreferencesToFrontmatter(prefs); @@ -485,7 +495,7 @@ function serializePreferencesToFrontmatter(prefs: Record): stri const orderedKeys = [ "version", "always_use_skills", "prefer_skills", "avoid_skills", "skill_rules", "custom_instructions", "models", "skill_discovery", - "auto_supervisor", "uat_dispatch", "budget_ceiling", "remote_questions", "git", + "auto_supervisor", "uat_dispatch", "unique_milestone_ids", "budget_ceiling", "remote_questions", "git", ]; const seen = new Set(); diff --git a/src/resources/extensions/gsd/dispatch-guard.ts b/src/resources/extensions/gsd/dispatch-guard.ts index 26dd0fe57..6d49e35d3 100644 --- a/src/resources/extensions/gsd/dispatch-guard.ts +++ b/src/resources/extensions/gsd/dispatch-guard.ts @@ -1,6 +1,8 @@ import { execSync } from "node:child_process"; -import { relMilestoneFile } from "./paths.js"; +import { readdirSync } from "node:fs"; +import { relMilestoneFile, milestonesDir } from "./paths.js"; import { parseRoadmapSlices } from "./roadmap-slices.ts"; +import { extractMilestoneSeq, milestoneIdSort } from "./guided-flow.js"; const SLICE_DISPATCH_TYPES = new Set([ "research-slice", @@ -22,21 +24,32 @@ function readTrackedFileFromBranch(base: string, branch: string, relPath: string } } -function milestoneIdFromNumber(num: number): string { - return `M${String(num).padStart(3, "0")}`; -} - export function getPriorSliceCompletionBlocker(base: string, mainBranch: string, unitType: string, unitId: string): string | null { if (!SLICE_DISPATCH_TYPES.has(unitType)) return null; const [targetMid, targetSid] = unitId.split("/"); if (!targetMid || !targetSid) return null; - const targetMidNumber = Number.parseInt(targetMid.slice(1), 10); - if (!Number.isFinite(targetMidNumber)) return null; + const targetSeq = extractMilestoneSeq(targetMid); + if (targetSeq === 0) return null; - for (let milestoneNumber = 1; milestoneNumber <= targetMidNumber; milestoneNumber += 1) { - const mid = milestoneIdFromNumber(milestoneNumber); + // Scan actual milestone directories instead of iterating by number + let milestoneIds: string[]; + try { + milestoneIds = readdirSync(milestonesDir(base), { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => { + const match = d.name.match(/^(M\d+(?:-[a-z0-9]{6})?)/); + return match ? match[1] : null; + }) + .filter((id): id is string => id !== null) + .sort(milestoneIdSort) + .filter(id => extractMilestoneSeq(id) <= targetSeq); + } catch { + return null; + } + + for (const mid of milestoneIds) { const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); if (!roadmapRel) continue; diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md index 127eac849..5cb87c252 100644 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -107,6 +107,8 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `commit_type`: string — override the conventional commit type prefix. Must be one of: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`. Default: inferred from diff content. - `main_branch`: string — the primary branch name for new git repos (e.g., `"main"`, `"master"`, `"trunk"`). Also used by `getMainBranch()` as the preferred branch when auto-detection is ambiguous. Default: `"main"`. +- `unique_milestone_ids`: boolean — when `true`, generates milestone IDs in `M{seq}-{rand6}` format (e.g. `M001-eh88as`) instead of plain sequential `M001`. Prevents ID collisions in team workflows where multiple contributors create milestones concurrently. Both formats coexist — existing `M001`-style milestones remain valid. Default: `false`. + --- ## Best Practices diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts index b2c8f2f50..3d027d992 100644 --- a/src/resources/extensions/gsd/files.ts +++ b/src/resources/extensions/gsd/files.ts @@ -6,6 +6,7 @@ import { promises as fs, readdirSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { milestonesDir, resolveMilestoneFile, relMilestoneFile } from './paths.js'; +import { milestoneIdSort } from './guided-flow.js'; import type { Roadmap, BoundaryMapEntry, @@ -755,7 +756,7 @@ export function parseContextDependsOn(content: string | null): string[] { * Inline the prior milestone's SUMMARY.md as context for the current milestone's planning prompt. * Returns null when: (1) `mid` is the first milestone, (2) prior milestone has no SUMMARY file. * - * Scans the milestones directory using the same readdirSync + sort + M\d+ match pattern + * Scans the milestones directory using the same readdirSync + milestoneIdSort + M\d+(?:-[a-z0-9]{6})? match pattern * as findMilestoneIds in state.ts. */ export async function inlinePriorMilestoneSummary(mid: string, base: string): Promise { @@ -765,10 +766,10 @@ export async function inlinePriorMilestoneSummary(mid: string, base: string): Pr sorted = readdirSync(dir, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => { - const match = d.name.match(/^(M\d+)/); + const match = d.name.match(/^(M\d+(?:-[a-z0-9]{6})?)/); return match ? match[1] : d.name; }) - .sort(); + .sort(milestoneIdSort); } catch { return null; } diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 0866155a7..00e9a4975 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -374,18 +374,20 @@ export class GitServiceImpl { // ─── Branch Queries ──────────────────────────────────────────────────── /** - * Get the "main" (integration) branch for this repo. + * Get the integration branch for this repo — the branch that slice + * branches are created from and merged back into. + * + * This is often `main` or `master`, but not necessarily. When a user + * starts GSD on a feature branch like `f-123-new-thing`, that branch + * is recorded as the integration target, and all slice branches merge + * back into it — not the repo's default branch. The name "main branch" + * in variable names is historical; think of it as "integration branch". * * Resolution order: * 1. Explicit `main_branch` preference (user override, highest priority) * 2. Milestone integration branch from metadata file (recorded at milestone start) * 3. Worktree base branch (worktree/) * 4. origin/HEAD symbolic-ref → main/master fallback → current branch - * - * The integration branch (step 2) is what makes feature-branch workflows - * work correctly: when a user starts GSD on `f-123-new-thing`, that branch - * is recorded as the integration target, and all slice branches merge back - * to it instead of the repo's default branch. */ getMainBranch(): string { // Explicit preference takes priority (double-check validity as defense-in-depth) @@ -465,8 +467,8 @@ export class GitServiceImpl { * Ensure the slice branch exists and is checked out. * * Creates the branch from the current working branch if it's not a slice - * branch (preserves planning artifacts). Falls back to main when on another - * slice branch (avoids chaining slice branches). + * branch (preserves planning artifacts). Falls back to the integration + * branch when on another slice branch (avoids chaining slice branches). * * Auto-commits dirty state via smart staging before checkout so runtime * files are never accidentally committed during branch switches. @@ -501,7 +503,7 @@ export class GitServiceImpl { } // Branch from current when it's a normal working branch (not a slice). - // If already on a slice branch, fall back to main to avoid chaining. + // If already on a slice branch, fall back to the integration branch to avoid chaining. const mainBranch = this.getMainBranch(); const base = SLICE_BRANCH_RE.test(current) ? mainBranch : current; this.git(["branch", branch, base]); @@ -532,7 +534,7 @@ export class GitServiceImpl { } /** - * Switch to main, auto-committing dirty state via smart staging first. + * Switch to the integration branch, auto-committing dirty state via smart staging first. */ switchToMain(): void { const mainBranch = this.getMainBranch(); @@ -654,18 +656,21 @@ export class GitServiceImpl { } /** - * Squash-merge a slice branch into main and delete it. + * Squash-merge a slice branch into the integration branch and delete it. + * + * The integration branch is resolved by getMainBranch() — this may be + * `main`, a feature branch, or a worktree branch depending on context. * * Flow: snapshot branch HEAD → squash merge → rich commit via stdin → * auto-push (if enabled) → delete branch. * - * Must be called from the main branch. Uses `inferCommitType(sliceTitle)` + * Must be called from the integration branch. Uses `inferCommitType(sliceTitle)` * for the conventional commit type instead of hardcoding `feat`. * * Throws when: - * - Not currently on the main branch + * - Not currently on the integration branch * - The slice branch does not exist - * - The slice branch has no commits ahead of main + * - The slice branch has no commits ahead of the integration branch */ mergeSliceToMain(milestoneId: string, sliceId: string, sliceTitle: string): MergeSliceResult { const mainBranch = this.getMainBranch(); diff --git a/src/resources/extensions/gsd/gitignore.ts b/src/resources/extensions/gsd/gitignore.ts index 02e8ea924..3d6a52c74 100644 --- a/src/resources/extensions/gsd/gitignore.ts +++ b/src/resources/extensions/gsd/gitignore.ts @@ -1,8 +1,8 @@ /** - * GSD bootstrappers for .gitignore and PREFERENCES.md + * GSD bootstrappers for .gitignore and preferences.md * * Ensures baseline .gitignore exists with universally-correct patterns. - * Creates an empty PREFERENCES.md template if it doesn't exist. + * Creates an empty preferences.md template if it doesn't exist. * Both idempotent — non-destructive if already present. */ @@ -134,14 +134,18 @@ export function untrackRuntimeFiles(basePath: string): void { } /** - * Ensure basePath/.gsd/PREFERENCES.md exists as an empty template. + * Ensure basePath/.gsd/preferences.md exists as an empty template. * Creates the file with frontmatter only if it doesn't exist. * Returns true if created, false if already exists. + * + * Checks both lowercase (canonical) and uppercase (legacy) to avoid + * creating a duplicate when an uppercase file already exists. */ export function ensurePreferences(basePath: string): boolean { - const preferencesPath = join(basePath, ".gsd", "PREFERENCES.md"); + const preferencesPath = join(basePath, ".gsd", "preferences.md"); + const legacyPath = join(basePath, ".gsd", "PREFERENCES.md"); - if (existsSync(preferencesPath)) { + if (existsSync(preferencesPath) || existsSync(legacyPath)) { return false; } diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 3d16194b2..2d8185373 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -18,6 +18,7 @@ import { resolveSliceFile, resolveSlicePath, resolveGsdRootFile, relGsdRootFile, relMilestoneFile, relSliceFile, relSlicePath, } from "./paths.js"; +import { randomInt } from "node:crypto"; import { join } from "node:path"; import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; import { execSync, execFileSync } from "node:child_process"; @@ -103,26 +104,66 @@ function findMilestoneIds(basePath: string): string[] { return readdirSync(dir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => { - const match = d.name.match(/^(M\d+)/); + const match = d.name.match(/^(M\d+(?:-[a-z0-9]{6})?)/); return match ? match[1] : d.name; }) - .sort(); + .sort(milestoneIdSort); } catch { return []; } } +// ─── Milestone ID primitives ──────────────────────────────────────────────── + +/** Matches both classic `M001` and unique `M001-abc123` formats (anchored). */ +export const MILESTONE_ID_RE = /^M\d{3}(?:-[a-z0-9]{6})?$/; + +/** Extract the trailing sequential number from a milestone ID. Returns 0 for non-matches. */ +export function extractMilestoneSeq(id: string): number { + const m = id.match(/^M(\d{3})(?:-[a-z0-9]{6})?$/); + return m ? parseInt(m[1], 10) : 0; +} + +/** Structured parse of a milestone ID into optional prefix and sequence number. */ +export function parseMilestoneId(id: string): { prefix?: string; num: number } { + const m = id.match(/^M(\d{3})(?:-([a-z0-9]{6}))?$/); + if (!m) return { num: 0 }; + return { + ...(m[2] ? { prefix: m[2] } : {}), + num: parseInt(m[1], 10), + }; +} + +/** Comparator for sorting milestone IDs by sequential number. */ +export function milestoneIdSort(a: string, b: string): number { + return extractMilestoneSeq(a) - extractMilestoneSeq(b); +} + +/** Generate a 6-char lowercase `[a-z0-9]` prefix using crypto.randomInt(). */ +export function generateMilestonePrefix(): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + for (let i = 0; i < 6; i++) { + result += chars[randomInt(36)]; + } + return result; +} + /** Return the highest numeric suffix among milestone IDs (0 when the list is empty or has no numeric IDs). */ export function maxMilestoneNum(milestoneIds: string[]): number { return milestoneIds.reduce((max, id) => { - const num = parseInt(id.replace(/^M/, ""), 10); + const num = extractMilestoneSeq(id); return num > max ? num : max; }, 0); } /** Derive the next milestone ID from existing IDs using max-based approach to avoid collisions after deletions. */ -export function nextMilestoneId(milestoneIds: string[]): string { - return `M${String(maxMilestoneNum(milestoneIds) + 1).padStart(3, "0")}`; +export function nextMilestoneId(milestoneIds: string[], uniqueEnabled?: boolean): string { + const seq = String(maxMilestoneNum(milestoneIds) + 1).padStart(3, "0"); + if (uniqueEnabled) { + return `M${seq}-${generateMilestonePrefix()}`; + } + return `M${seq}`; } // ─── Queue ───────────────────────────────────────────────────────────────────── @@ -166,9 +207,9 @@ export async function showQueue( const existingContext = await buildExistingMilestonesContext(basePath, milestoneIds, state); // ── Determine next milestone ID ───────────────────────────────────── - const max = maxMilestoneNum(milestoneIds); - const nextId = `M${String(max + 1).padStart(3, "0")}`; - const nextIdPlus1 = `M${String(max + 2).padStart(3, "0")}`; + const uniqueEnabled = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; + const nextId = nextMilestoneId(milestoneIds, uniqueEnabled); + const nextIdPlus1 = nextMilestoneId([...milestoneIds, nextId], uniqueEnabled); // ── Build preamble ────────────────────────────────────────────────── const activePart = state.activeMilestone @@ -518,7 +559,8 @@ export async function showSmartEntry( } const milestoneIds = findMilestoneIds(basePath); - const nextId = nextMilestoneId(milestoneIds); + const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; + const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); const isFirst = milestoneIds.length === 0; if (isFirst) { @@ -580,7 +622,8 @@ export async function showSmartEntry( if (choice === "new_milestone") { const milestoneIds = findMilestoneIds(basePath); - const nextId = nextMilestoneId(milestoneIds); + const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; + const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; dispatchWorkflow(pi, buildDiscussPrompt(nextId, @@ -648,7 +691,8 @@ export async function showSmartEntry( })); } else if (choice === "skip_milestone") { const milestoneIds = findMilestoneIds(basePath); - const nextId = nextMilestoneId(milestoneIds); + const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; + const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index afc113cd8..9672b395c 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -60,7 +60,7 @@ export function isDepthVerified(): boolean { } // ── Write-gate: block CONTEXT.md writes during discussion without depth verification ── -const MILESTONE_CONTEXT_RE = /M\d+-CONTEXT\.md$/; +const MILESTONE_CONTEXT_RE = /M\d+(?:-[a-z0-9]{6})?-CONTEXT\.md$/; export function shouldBlockContextWrite( toolName: string, @@ -481,13 +481,13 @@ export default function (pi: ExtensionAPI) { } async function buildGuidedExecuteContextInjection(prompt: string, basePath: string): Promise { - const executeMatch = prompt.match(/Execute the next task:\s+(T\d+)\s+\("([^"]+)"\)\s+in slice\s+(S\d+)\s+of milestone\s+(M\d+)/i); + const executeMatch = prompt.match(/Execute the next task:\s+(T\d+)\s+\("([^"]+)"\)\s+in slice\s+(S\d+)\s+of milestone\s+(M\d+(?:-[a-z0-9]{6})?)/i); if (executeMatch) { const [, taskId, taskTitle, sliceId, milestoneId] = executeMatch; return buildTaskExecutionContextInjection(basePath, milestoneId, sliceId, taskId, taskTitle); } - const resumeMatch = prompt.match(/Resume interrupted work\.[\s\S]*?slice\s+(S\d+)\s+of milestone\s+(M\d+)/i); + const resumeMatch = prompt.match(/Resume interrupted work\.[\s\S]*?slice\s+(S\d+)\s+of milestone\s+(M\d+(?:-[a-z0-9]{6})?)/i); if (resumeMatch) { const [, sliceId, milestoneId] = resumeMatch; const state = await deriveState(basePath); diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index e4f96aadd..86846065e 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -8,6 +8,10 @@ import { VALID_BRANCH_NAME } from "./git-service.ts"; const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md"); const LEGACY_GLOBAL_PREFERENCES_PATH = join(homedir(), ".pi", "agent", "gsd-preferences.md"); const PROJECT_PREFERENCES_PATH = join(process.cwd(), ".gsd", "preferences.md"); +// Bootstrap in gitignore.ts historically created PREFERENCES.md (uppercase) by mistake. +// Check uppercase as a fallback so those files aren't silently ignored. +const GLOBAL_PREFERENCES_PATH_UPPERCASE = join(homedir(), ".gsd", "PREFERENCES.md"); +const PROJECT_PREFERENCES_PATH_UPPERCASE = join(process.cwd(), ".gsd", "PREFERENCES.md"); const SKILL_ACTIONS = new Set(["use", "prefer", "avoid"]); export interface GSDSkillRule { @@ -83,6 +87,7 @@ export interface GSDPreferences { skill_discovery?: SkillDiscoveryMode; auto_supervisor?: AutoSupervisorConfig; uat_dispatch?: boolean; + unique_milestone_ids?: boolean; budget_ceiling?: number; remote_questions?: RemoteQuestionsConfig; git?: GitPreferences; @@ -108,11 +113,13 @@ export function getProjectGSDPreferencesPath(): string { export function loadGlobalGSDPreferences(): LoadedGSDPreferences | null { return loadPreferencesFile(GLOBAL_PREFERENCES_PATH, "global") + ?? loadPreferencesFile(GLOBAL_PREFERENCES_PATH_UPPERCASE, "global") ?? loadPreferencesFile(LEGACY_GLOBAL_PREFERENCES_PATH, "global"); } export function loadProjectGSDPreferences(): LoadedGSDPreferences | null { - return loadPreferencesFile(PROJECT_PREFERENCES_PATH, "project"); + return loadPreferencesFile(PROJECT_PREFERENCES_PATH, "project") + ?? loadPreferencesFile(PROJECT_PREFERENCES_PATH_UPPERCASE, "project"); } export function loadEffectiveGSDPreferences(): LoadedGSDPreferences | null { @@ -572,6 +579,7 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr skill_discovery: override.skill_discovery ?? base.skill_discovery, auto_supervisor: { ...(base.auto_supervisor ?? {}), ...(override.auto_supervisor ?? {}) }, uat_dispatch: override.uat_dispatch ?? base.uat_dispatch, + unique_milestone_ids: override.unique_milestone_ids ?? base.unique_milestone_ids, budget_ceiling: override.budget_ceiling ?? base.budget_ceiling, remote_questions: override.remote_questions ? { ...(base.remote_questions ?? {}), ...override.remote_questions } @@ -651,6 +659,10 @@ function validatePreferences(preferences: GSDPreferences): { validated.uat_dispatch = !!preferences.uat_dispatch; } + if (preferences.unique_milestone_ids !== undefined) { + validated.unique_milestone_ids = !!preferences.unique_milestone_ids; + } + if (preferences.budget_ceiling !== undefined) { const raw = preferences.budget_ceiling; if (typeof raw === "number" && Number.isFinite(raw)) { diff --git a/src/resources/extensions/gsd/prompts/guided-complete-slice.md b/src/resources/extensions/gsd/prompts/guided-complete-slice.md index 93c665675..ce054f84e 100644 --- a/src/resources/extensions/gsd/prompts/guided-complete-slice.md +++ b/src/resources/extensions/gsd/prompts/guided-complete-slice.md @@ -1 +1 @@ -Complete slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. All tasks are done. Read the templates at `~/.gsd/agent/extensions/gsd/templates/slice-summary.md` and `uat.md`. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during completion, without relaxing required verification or artifact rules. Write `{{sliceId}}-SUMMARY.md` (compress task summaries), write `{{sliceId}}-UAT.md`, and fill the `UAT Type` plus `Not Proven By This UAT` sections explicitly so the artifact states what class of acceptance it covers and what still remains unproven. Review task summaries for `key_decisions` and ensure any significant ones are in `.gsd/DECISIONS.md`. Mark the slice checkbox done in the roadmap, update STATE.md, update milestone summary, and leave the slice branch clean for the extension to squash-merge back to main automatically. +Complete slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. All tasks are done. Read the templates at `~/.gsd/agent/extensions/gsd/templates/slice-summary.md` and `uat.md`. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during completion, without relaxing required verification or artifact rules. Write `{{sliceId}}-SUMMARY.md` (compress task summaries), write `{{sliceId}}-UAT.md`, and fill the `UAT Type` plus `Not Proven By This UAT` sections explicitly so the artifact states what class of acceptance it covers and what still remains unproven. Review task summaries for `key_decisions` and ensure any significant ones are in `.gsd/DECISIONS.md`. Mark the slice checkbox done in the roadmap, update STATE.md, update milestone summary, and leave the slice branch clean for the extension to squash-merge back into the integration branch automatically. diff --git a/src/resources/extensions/gsd/prompts/system.md b/src/resources/extensions/gsd/prompts/system.md index 80ee7706f..7bf62e149 100644 --- a/src/resources/extensions/gsd/prompts/system.md +++ b/src/resources/extensions/gsd/prompts/system.md @@ -50,7 +50,7 @@ If a `GSD Skill Preferences` block is present below this contract, treat it as e Directories use bare IDs. Files use ID-SUFFIX format: -- Milestone dirs: `M001/` +- Milestone dirs: `M001/` (with `unique_milestone_ids: true`, format is `M{seq}-{rand6}/`, e.g. `M001-eh88as/`) - Milestone files: `M001-CONTEXT.md`, `M001-ROADMAP.md`, `M001-RESEARCH.md` - Slice dirs: `S01/` - Slice files: `S01-PLAN.md`, `S01-RESEARCH.md`, `S01-SUMMARY.md`, `S01-UAT.md` @@ -93,7 +93,7 @@ Titles live inside file content (headings, frontmatter), not in file or director - **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` (or `gsd//M001/S01` when inside a worktree) -- Slices are squash-merged to main when complete +- Slices are squash-merged to the integration branch when complete (this is the branch GSD was started from — often `main`, but could be a feature branch like `f-123-new-thing`) - Summaries compress prior work - read them instead of re-reading all task details - `STATE.md` is the quick-glance status file - keep it updated after changes diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index dc09a9003..3a26202ad 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -30,6 +30,7 @@ import { resolveGsdRootFile, } from './paths.ts'; import { getActiveSliceBranch } from './worktree.ts'; +import { milestoneIdSort } from './guided-flow.js'; import { readdirSync } from 'fs'; import { join } from 'path'; @@ -62,10 +63,10 @@ function findMilestoneIds(basePath: string): string[] { return readdirSync(dir, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => { - const match = d.name.match(/^(M\d+)/); + const match = d.name.match(/^(M\d+(?:-[a-z0-9]{6})?)/); return match ? match[1] : d.name; }) - .sort(); + .sort(milestoneIdSort); } catch { return []; } @@ -167,7 +168,7 @@ export async function deriveState(basePath: string): Promise { } const roadmap = parseRoadmap(content); - const title = roadmap.title.replace(/^M\d+[^:]*:\s*/, ''); + const title = roadmap.title.replace(/^M\d+(?:-[a-z0-9]{6})?[^:]*:\s*/, ''); const complete = isMilestoneComplete(roadmap); if (complete) { diff --git a/src/resources/extensions/gsd/templates/preferences.md b/src/resources/extensions/gsd/templates/preferences.md index 404401a4e..b3c540f96 100644 --- a/src/resources/extensions/gsd/templates/preferences.md +++ b/src/resources/extensions/gsd/templates/preferences.md @@ -15,6 +15,7 @@ git: snapshots: pre_merge_check: commit_type: +unique_milestone_ids: --- # GSD Skill Preferences diff --git a/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts b/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts new file mode 100644 index 000000000..5d2076431 --- /dev/null +++ b/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts @@ -0,0 +1,607 @@ +/** + * Integration tests: deriveState, indexWorkspace, inlinePriorMilestoneSummary, + * dispatch-guard, and branch operations with unique-format (M001-abc123) and + * mixed classic+unique milestone directories. + * + * Uses real filesystem and git fixtures — no mocking. + */ + +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { deriveState } from '../state.ts'; +import { indexWorkspace } from '../workspace-index.ts'; +import { inlinePriorMilestoneSummary } from '../files.ts'; +import { getPriorSliceCompletionBlocker } from '../dispatch-guard.ts'; +import { + ensureSliceBranch, + getCurrentBranch, + getSliceBranchName, + mergeSliceToMain, + parseSliceBranch, + switchToMain, +} from '../worktree.ts'; + +// ─── Assertion Helpers ──────────────────────────────────────────────────── + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function assertMatch(actual: string, pattern: RegExp, message: string): void { + if (pattern.test(actual)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected match for ${pattern}, got ${JSON.stringify(actual)}`); + } +} + +// ─── Fixture Helpers ────────────────────────────────────────────────────── + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), 'gsd-integration-mixed-')); + mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true }); + return base; +} + +function writeRoadmap(base: string, mid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-ROADMAP.md`), content); +} + +function writePlan(base: string, mid: string, sid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid); + mkdirSync(join(dir, 'tasks'), { recursive: true }); + writeFileSync(join(dir, `${sid}-PLAN.md`), content); +} + +function writeMilestoneSummary(base: string, mid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-SUMMARY.md`), content); +} + +function cleanup(base: string): void { + rmSync(base, { recursive: true, force: true }); +} + +function run(command: string, cwd: string): string { + return execSync(command, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' }).trim(); +} + +function createGitRepo(): string { + const base = mkdtempSync(join(tmpdir(), 'gsd-integration-git-')); + mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true }); + run('git init -b main', base); + run("git config user.name 'Integration Test'", base); + run("git config user.email 'test@example.com'", base); + return base; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Test Groups +// ═══════════════════════════════════════════════════════════════════════════ + +async function main(): Promise { + + // ─── Group 1: deriveState with new-format-only milestones ───────────── + console.log('\n=== Group 1: deriveState with new-format-only milestones ==='); + { + const base = createFixtureBase(); + try { + // Create M001-abc123 with roadmap + 2 slices (S01 complete, S02 in-progress) + writeRoadmap(base, 'M001-abc123', `# M001-abc123: Test Feature + +**Vision:** Test vision + +## Slices +- [x] **S01: Setup** \`risk:low\` \`depends:[]\` + > Foundation work +- [ ] **S02: Core Logic** \`risk:medium\` \`depends:[]\` + > Main implementation +`); + + // S01 is complete — write a plan with all tasks done + writePlan(base, 'M001-abc123', 'S01', `# S01: Setup + +**Goal:** Setup +**Demo:** Setup works + +## Tasks +- [x] **T01: Init** \`est:10m\` + Initialize project. +`); + + // S02 is in-progress — write a plan with first task not done + writePlan(base, 'M001-abc123', 'S02', `# S02: Core Logic + +**Goal:** Implement core +**Demo:** Core works + +## Tasks +- [ ] **T01: Build core** \`est:20m\` + Build the core logic. +- [ ] **T02: Test core** \`est:15m\` + Test the core logic. +`); + + const state = await deriveState(base); + + // Phase should be executing (active milestone with incomplete slice + plan + tasks) + assertEq(state.phase, 'executing', 'G1: phase is executing'); + assert(state.activeMilestone !== null, 'G1: activeMilestone is not null'); + assertEq(state.activeMilestone?.id, 'M001-abc123', 'G1: activeMilestone id is M001-abc123'); + assertEq(state.activeMilestone?.title, 'Test Feature', 'G1: title stripped to Test Feature'); + + // Registry + assertEq(state.registry.length, 1, 'G1: registry has 1 entry'); + assertEq(state.registry[0]?.id, 'M001-abc123', 'G1: registry entry id'); + assertEq(state.registry[0]?.status, 'active', 'G1: registry entry status is active'); + assertEq(state.registry[0]?.title, 'Test Feature', 'G1: registry title stripped'); + + // Active slice + assert(state.activeSlice !== null, 'G1: activeSlice is not null'); + assertEq(state.activeSlice?.id, 'S02', 'G1: activeSlice is S02'); + + // Progress + assertEq(state.progress?.milestones?.done, 0, 'G1: milestones done = 0'); + assertEq(state.progress?.milestones?.total, 1, 'G1: milestones total = 1'); + } finally { + cleanup(base); + } + } + + // ─── Group 2: deriveState with mixed-format milestones ──────────────── + console.log('\n=== Group 2: deriveState with mixed old+new format milestones ==='); + { + const base = createFixtureBase(); + try { + // M001 — complete milestone (all slices done + summary) + writeRoadmap(base, 'M001', `# M001: Legacy Feature + +**Vision:** Legacy vision + +## Slices +- [x] **S01: Only Slice** \`risk:low\` \`depends:[]\` + > Done +`); + + writePlan(base, 'M001', 'S01', `# S01: Only Slice + +**Goal:** Done +**Demo:** Works + +## Tasks +- [x] **T01: Do it** \`est:10m\` + Did it. +`); + + writeMilestoneSummary(base, 'M001', `# M001: Legacy Feature Summary + +**One-liner summary** + +## What Happened +Everything worked. +`); + + // M002-abc123 — active milestone (incomplete slice) + writeRoadmap(base, 'M002-abc123', `# M002-abc123: New Feature + +**Vision:** New vision + +## Slices +- [x] **S01: Setup** \`risk:low\` \`depends:[]\` + > Setup done +- [ ] **S02: Implementation** \`risk:medium\` \`depends:[]\` + > Main work +`); + + writePlan(base, 'M002-abc123', 'S01', `# S01: Setup + +**Goal:** Setup +**Demo:** Setup done + +## Tasks +- [x] **T01: Init** \`est:10m\` + Init done. +`); + + writePlan(base, 'M002-abc123', 'S02', `# S02: Implementation + +**Goal:** Implement +**Demo:** Works + +## Tasks +- [ ] **T01: Build** \`est:20m\` + Build it. +`); + + const state = await deriveState(base); + + // Registry — should have 2 entries sorted by seq number + assertEq(state.registry.length, 2, 'G2: registry has 2 entries'); + assertEq(state.registry[0]?.id, 'M001', 'G2: registry[0] is M001 (sorted first)'); + assertEq(state.registry[1]?.id, 'M002-abc123', 'G2: registry[1] is M002-abc123 (sorted second)'); + + // M001 is complete + assertEq(state.registry[0]?.status, 'complete', 'G2: M001 status is complete'); + assertEq(state.registry[0]?.title, 'Legacy Feature', 'G2: M001 title stripped'); + + // M002-abc123 is active + assertEq(state.registry[1]?.status, 'active', 'G2: M002-abc123 status is active'); + assertEq(state.registry[1]?.title, 'New Feature', 'G2: M002-abc123 title stripped'); + + // Active milestone + assert(state.activeMilestone !== null, 'G2: activeMilestone is not null'); + assertEq(state.activeMilestone?.id, 'M002-abc123', 'G2: activeMilestone is M002-abc123'); + assertEq(state.activeMilestone?.title, 'New Feature', 'G2: activeMilestone title stripped'); + + // Phase + assertEq(state.phase, 'executing', 'G2: phase is executing'); + + // Active slice + assertEq(state.activeSlice?.id, 'S02', 'G2: activeSlice is S02'); + + // Progress + assertEq(state.progress?.milestones?.done, 1, 'G2: milestones done = 1'); + assertEq(state.progress?.milestones?.total, 2, 'G2: milestones total = 2'); + } finally { + cleanup(base); + } + } + + // ─── Group 3: indexWorkspace with mixed-format milestones ───────────── + console.log('\n=== Group 3: indexWorkspace with mixed-format milestones ==='); + { + const base = createFixtureBase(); + try { + // Same fixture as Group 2: M001 (complete) + M002-abc123 (active) + writeRoadmap(base, 'M001', `# M001: Legacy Feature + +**Vision:** Legacy vision + +## Slices +- [x] **S01: Only Slice** \`risk:low\` \`depends:[]\` + > Done +`); + + writePlan(base, 'M001', 'S01', `# S01: Only Slice + +**Goal:** Done +**Demo:** Works + +## Tasks +- [x] **T01: Do it** \`est:10m\` + Did it. +`); + + writeMilestoneSummary(base, 'M001', `# M001: Legacy Feature Summary + +**One-liner summary** + +## What Happened +Everything worked. +`); + + writeRoadmap(base, 'M002-abc123', `# M002-abc123: New Feature + +**Vision:** New vision + +## Slices +- [ ] **S01: First Slice** \`risk:low\` \`depends:[]\` + > First work +`); + + writePlan(base, 'M002-abc123', 'S01', `# S01: First Slice + +**Goal:** First +**Demo:** First works + +## Tasks +- [ ] **T01: Build** \`est:20m\` + Build it. +`); + + const index = await indexWorkspace(base); + + // Both milestones indexed + assertEq(index.milestones.length, 2, 'G3: 2 milestones in index'); + assertEq(index.milestones[0]?.id, 'M001', 'G3: index[0] is M001'); + assertEq(index.milestones[1]?.id, 'M002-abc123', 'G3: index[1] is M002-abc123'); + + // Titles stripped from both formats + assertEq(index.milestones[0]?.title, 'Legacy Feature', 'G3: M001 title stripped'); + assertEq(index.milestones[1]?.title, 'New Feature', 'G3: M002-abc123 title stripped'); + + // Active state + assertEq(index.active.milestoneId, 'M002-abc123', 'G3: active milestone is M002-abc123'); + assertEq(index.active.sliceId, 'S01', 'G3: active slice is S01'); + + // Scopes include new-format paths + assert( + index.scopes.some(s => s.scope === 'M002-abc123'), + 'G3: scope includes M002-abc123 milestone', + ); + assert( + index.scopes.some(s => s.scope === 'M002-abc123/S01'), + 'G3: scope includes M002-abc123/S01 slice', + ); + assert( + index.scopes.some(s => s.scope === 'M002-abc123/S01/T01'), + 'G3: scope includes M002-abc123/S01/T01 task', + ); + } finally { + cleanup(base); + } + } + + // ─── Group 4: inlinePriorMilestoneSummary with mixed formats ────────── + console.log('\n=== Group 4: inlinePriorMilestoneSummary with mixed formats ==='); + { + const base = createFixtureBase(); + try { + // M001 — completed with summary + mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true }); + writeMilestoneSummary(base, 'M001', `# M001: Legacy Feature Summary + +**Completed legacy feature** + +## What Happened +Built the legacy feature successfully. + +## Key Decisions +- Used old format for milestone IDs. +`); + + // M002-abc123 — active milestone (just needs directory to exist) + mkdirSync(join(base, '.gsd', 'milestones', 'M002-abc123'), { recursive: true }); + + const result = await inlinePriorMilestoneSummary('M002-abc123', base); + + // Result should be non-null (M001 is before M002-abc123) + assert(result !== null, 'G4: result is non-null'); + assert(typeof result === 'string', 'G4: result is a string'); + + // Should contain the M001 summary content + assert(result!.includes('Prior Milestone Summary'), 'G4: contains Prior Milestone Summary header'); + assert(result!.includes('Built the legacy feature successfully'), 'G4: contains M001 summary content'); + assert(result!.includes('Used old format for milestone IDs'), 'G4: contains M001 key decisions'); + } finally { + cleanup(base); + } + } + + // ─── Group 5: dispatch-guard with new-format milestones ────────────── + console.log('\n=== Group 5: dispatch-guard with new-format milestones ==='); + { + const base = createGitRepo(); + try { + // M001-abc123: all slices complete + writeRoadmap(base, 'M001-abc123', `# M001-abc123: First Feature + +**Vision:** First + +## Slices +- [x] **S01: Done** \`risk:low\` \`depends:[]\` + > Completed +`); + + // M002-abc123: S01 incomplete + writeRoadmap(base, 'M002-abc123', `# M002-abc123: Second Feature + +**Vision:** Second + +## Slices +- [ ] **S01: Pending** \`risk:low\` \`depends:[]\` + > Not started +- [ ] **S02: Also Pending** \`risk:low\` \`depends:[S01]\` + > Not started +`); + + // Initial commit so dispatch-guard can read from git branch + writeFileSync(join(base, 'README.md'), 'init\n'); + run('git add .', base); + run('git commit -m init', base); + + // No blocker: M001-abc123 is complete, dispatching M002-abc123/S01 + assertEq( + getPriorSliceCompletionBlocker(base, 'main', 'plan-slice', 'M002-abc123/S01'), + null, + 'G5: no blocker for M002-abc123/S01 when M001-abc123 all complete', + ); + + // No blocker for first slice of first milestone + assertEq( + getPriorSliceCompletionBlocker(base, 'main', 'execute-task', 'M001-abc123/S01/T01'), + null, + 'G5: no blocker for M001-abc123/S01/T01 (first milestone first slice)', + ); + + // Blocker: trying to dispatch M002-abc123/S02 when S01 is incomplete + assertMatch( + getPriorSliceCompletionBlocker(base, 'main', 'execute-task', 'M002-abc123/S02/T01') ?? '', + /earlier slice M002-abc123\/S01 is not complete/, + 'G5: blocks M002-abc123/S02 when S01 incomplete', + ); + + // Non-slice dispatch type should not be blocked + assertEq( + getPriorSliceCompletionBlocker(base, 'main', 'plan-milestone', 'M002-abc123'), + null, + 'G5: non-slice dispatch type not blocked', + ); + + // Mixed format: M001 (incomplete) + M002-abc123 + writeRoadmap(base, 'M001', `# M001: Legacy Feature + +**Vision:** Legacy + +## Slices +- [x] **S01: Done** \`risk:low\` \`depends:[]\` + > Done +- [ ] **S02: Pending** \`risk:low\` \`depends:[S01]\` + > Pending +`); + run('git add .', base); + run('git commit -m add-m001', base); + + // M001 (seq=1) < M001-abc123 (seq=1) — but M001 has incomplete S02 + // Since M001 seq=1 and M002-abc123 seq=2, blocker should reference M001/S02 + assertMatch( + getPriorSliceCompletionBlocker(base, 'main', 'plan-slice', 'M002-abc123/S01') ?? '', + /earlier slice M001\/S02 is not complete/, + 'G5: mixed-format blocker references M001/S02', + ); + + // Complete M001 and verify no blocker + writeRoadmap(base, 'M001', `# M001: Legacy Feature + +**Vision:** Legacy + +## Slices +- [x] **S01: Done** \`risk:low\` \`depends:[]\` + > Done +- [x] **S02: Done** \`risk:low\` \`depends:[S01]\` + > Done +`); + run('git add .', base); + run('git commit -m complete-m001', base); + + assertEq( + getPriorSliceCompletionBlocker(base, 'main', 'plan-slice', 'M002-abc123/S01'), + null, + 'G5: no blocker after M001 completed (mixed format)', + ); + + // M001-abc123 still has all complete, M002-abc123/S01 still incomplete + // Check that S02 of M002-abc123 is still blocked by its own S01 + assertMatch( + getPriorSliceCompletionBlocker(base, 'main', 'execute-task', 'M002-abc123/S02/T01') ?? '', + /earlier slice M002-abc123\/S01 is not complete/, + 'G5: intra-milestone blocker still works in mixed-format context', + ); + } finally { + cleanup(base); + } + } + + // ─── Group 6: Branch operations with new-format IDs ───────────────── + console.log('\n=== Group 6: Branch operations with new-format IDs ==='); + { + const base = createGitRepo(); + try { + // Need a milestone dir and initial commit for branch ops + writeRoadmap(base, 'M001-abc123', `# M001-abc123: Branch Test + +**Vision:** Test branches + +## Slices +- [ ] **S01: Slice One** \`risk:low\` \`depends:[]\` + > Branch test +`); + writePlan(base, 'M001-abc123', 'S01', `# S01: Slice One + +**Goal:** Test +**Demo:** Branch works + +## Tasks +- [ ] **T01: Build** \`est:10m\` + Build it. +`); + writeFileSync(join(base, 'README.md'), 'initial\n'); + run('git add .', base); + run('git commit -m init', base); + + // Test getSliceBranchName with new-format ID + assertEq( + getSliceBranchName('M001-abc123', 'S01'), + 'gsd/M001-abc123/S01', + 'G6: getSliceBranchName returns gsd/M001-abc123/S01', + ); + + // Test parseSliceBranch with new-format branch name + const parsed = parseSliceBranch('gsd/M001-abc123/S01'); + assert(parsed !== null, 'G6: parseSliceBranch returns non-null for new-format'); + assertEq(parsed?.milestoneId, 'M001-abc123', 'G6: parsed milestoneId is M001-abc123'); + assertEq(parsed?.sliceId, 'S01', 'G6: parsed sliceId is S01'); + assertEq(parsed?.worktreeName, null, 'G6: parsed worktreeName is null (no worktree)'); + + // Test ensureSliceBranch creates the branch + const created = ensureSliceBranch(base, 'M001-abc123', 'S01'); + assert(created, 'G6: ensureSliceBranch returns true (branch created)'); + assertEq( + getCurrentBranch(base), + 'gsd/M001-abc123/S01', + 'G6: getCurrentBranch returns gsd/M001-abc123/S01', + ); + + // Idempotent: second ensure should not create + const secondCreate = ensureSliceBranch(base, 'M001-abc123', 'S01'); + assertEq(secondCreate, false, 'G6: second ensureSliceBranch returns false'); + + // Make a change on the slice branch, commit, then merge to main + writeFileSync(join(base, 'feature.txt'), 'new feature from slice\n'); + run('git add feature.txt', base); + run("git commit -m 'feat: slice work'", base); + + // Switch to main and merge + switchToMain(base); + assertEq(getCurrentBranch(base), 'main', 'G6: back on main after switchToMain'); + + const merge = mergeSliceToMain(base, 'M001-abc123', 'S01', 'Slice One'); + assertEq(merge.branch, 'gsd/M001-abc123/S01', 'G6: merge reports correct branch'); + assertEq(getCurrentBranch(base), 'main', 'G6: still on main after merge'); + assert(merge.deletedBranch, 'G6: merge deleted the slice branch'); + + // Verify the merged content exists on main + const content = readFileSync(join(base, 'feature.txt'), 'utf-8'); + assert(content.includes('new feature from slice'), 'G6: merged content on main'); + + // Verify branch is gone + const branches = run('git branch', base); + assert(!branches.includes('gsd/M001-abc123/S01'), 'G6: slice branch deleted after merge'); + } finally { + cleanup(base); + } + } + + // ─── Summary ────────────────────────────────────────────────────────── + console.log(`\nResults: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); + console.log('All tests passed ✓'); +} + +// When run via vitest, wrap in test(); when run via tsx, call directly. +const isVitest = typeof globalThis !== 'undefined' && (globalThis as any).__vitest_worker__?.config?.defines != null && 'vitest' in (globalThis as any).__vitest_worker__.config.defines || process.env.VITEST; +if (isVitest) { + const { test } = await import('vitest'); + test('integration-mixed-milestones: all groups pass', async () => { + await main(); + }); +} else { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/src/resources/extensions/gsd/tests/regex-hardening.test.ts b/src/resources/extensions/gsd/tests/regex-hardening.test.ts new file mode 100644 index 000000000..b56504d28 --- /dev/null +++ b/src/resources/extensions/gsd/tests/regex-hardening.test.ts @@ -0,0 +1,317 @@ +// Regex-hardening tests for S02/T02 — proves all 12 regex/parser sites +// accept both M001 (classic) and M001-abc123 (unique) milestone ID formats. +// +// Sections: +// (a) Directory scanning regex — findMilestoneIds pattern +// (b) Title-strip regex — milestone title cleanup +// (c) SLICE_BRANCH_RE — branch name parsing (with/without worktree prefix) +// (d) Milestone detection regex — hasExistingMilestones pattern +// (e) MILESTONE_CONTEXT_RE — context write-gate filename match +// (f) Prompt dispatch regexes — executeMatch and resumeMatch capture +// (g) milestoneIdSort — mixed-format ordering +// (h) extractMilestoneSeq — numeric extraction from both formats + +import { test } from 'node:test'; + +import { + MILESTONE_ID_RE, + extractMilestoneSeq, + milestoneIdSort, +} from '../guided-flow.ts'; + +import { SLICE_BRANCH_RE } from '../worktree.ts'; + +// ─── Assertion helpers ───────────────────────────────────────────────────── + +let passed = 0; +let failed = 0; + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function assertTrue(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertMatch(value: string, pattern: RegExp, message: string): void { + if (pattern.test(value)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — "${value}" did not match ${pattern}`); + } +} + +function assertNoMatch(value: string, pattern: RegExp, message: string): void { + if (!pattern.test(value)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — "${value}" should NOT match ${pattern}`); + } +} + +// ─── Tests ───────────────────────────────────────────────────────────────── + +async function main(): Promise { + console.log('regex-hardening tests'); + + // (a) Directory scanning regex — used in state.ts, workspace-index.ts, files.ts + // Pattern: /^(M\d+(?:-[a-z0-9]{6})?)/ + { + console.log(' (a) Directory scanning regex'); + const DIR_SCAN_RE = /^(M\d+(?:-[a-z0-9]{6})?)/; + + // Classic format matches + assertTrue(DIR_SCAN_RE.test('M001'), 'dir scan matches M001'); + assertTrue(DIR_SCAN_RE.test('M042'), 'dir scan matches M042'); + assertTrue(DIR_SCAN_RE.test('M999'), 'dir scan matches M999'); + assertEq(('M001' as string).match(DIR_SCAN_RE)?.[1], 'M001', 'captures M001'); + + // Unique format matches + assertTrue(DIR_SCAN_RE.test('M001-abc123'), 'dir scan matches M001-abc123'); + assertTrue(DIR_SCAN_RE.test('M042-z9a8b7'), 'dir scan matches M042-z9a8b7'); + assertEq(('M001-abc123' as string).match(DIR_SCAN_RE)?.[1], 'M001-abc123', 'captures M001-abc123 from dir name'); + + // Rejects + assertTrue(!DIR_SCAN_RE.test('S01'), 'dir scan rejects S01'); + assertTrue(!DIR_SCAN_RE.test('X001'), 'dir scan rejects X001'); + assertTrue(!DIR_SCAN_RE.test('.DS_Store'), 'dir scan rejects .DS_Store'); + assertTrue(!DIR_SCAN_RE.test('notes'), 'dir scan rejects notes'); + } + + // (b) Title-strip regex — used in state.ts, workspace-index.ts + // Pattern: /^M\d+(?:-[a-z0-9]{6})?[^:]*:\s*/ + { + console.log(' (b) Title-strip regex'); + const TITLE_STRIP_RE = /^M\d+(?:-[a-z0-9]{6})?[^:]*:\s*/; + + // Classic format strip + assertEq('M001: Title'.replace(TITLE_STRIP_RE, ''), 'Title', 'strips M001: Title → Title'); + assertEq('M042: Payment Integration'.replace(TITLE_STRIP_RE, ''), 'Payment Integration', 'strips M042: Payment Integration'); + + // Unique format strip + assertEq('M001-abc123: Title'.replace(TITLE_STRIP_RE, ''), 'Title', 'strips M001-abc123: Title → Title'); + assertEq('M042-z9a8b7: Dashboard'.replace(TITLE_STRIP_RE, ''), 'Dashboard', 'strips M042-z9a8b7: Dashboard'); + + // Edge case: dash-style separator (M001 — Title: Subtitle preserves colon in body) + assertEq( + 'M001 — Unique Milestone IDs: Foo'.replace(TITLE_STRIP_RE, ''), + 'Foo', + 'strips M001 — Unique Milestone IDs: Foo → Foo (first colon consumed)', + ); + + // Edge case: colon inside title body preserved + assertEq( + 'M001: Note: important'.replace(TITLE_STRIP_RE, ''), + 'Note: important', + 'preserves colons in title body', + ); + + // No match — leaves non-milestone strings alone + assertEq('S01: Slice Title'.replace(TITLE_STRIP_RE, ''), 'S01: Slice Title', 'does not strip S01 prefix'); + } + + // (c) SLICE_BRANCH_RE — from worktree.ts + // Pattern: /^gsd\/(?:([a-zA-Z0-9_-]+)\/)?(M\d+(?:-[a-z0-9]{6})?)\/(S\d+)$/ + { + console.log(' (c) SLICE_BRANCH_RE'); + + // Classic format — no worktree prefix + { + const m = 'gsd/M001/S01'.match(SLICE_BRANCH_RE); + assertTrue(m !== null, 'matches gsd/M001/S01'); + assertEq(m?.[1], undefined, 'no worktree prefix for gsd/M001/S01'); + assertEq(m?.[2], 'M001', 'captures M001'); + assertEq(m?.[3], 'S01', 'captures S01'); + } + + // Unique format — no worktree prefix + { + const m = 'gsd/M001-abc123/S01'.match(SLICE_BRANCH_RE); + assertTrue(m !== null, 'matches gsd/M001-abc123/S01'); + assertEq(m?.[1], undefined, 'no worktree prefix for unique format'); + assertEq(m?.[2], 'M001-abc123', 'captures M001-abc123'); + assertEq(m?.[3], 'S01', 'captures S01'); + } + + // Classic format — with worktree prefix + { + const m = 'gsd/worktree/M001/S01'.match(SLICE_BRANCH_RE); + assertTrue(m !== null, 'matches gsd/worktree/M001/S01'); + assertEq(m?.[1], 'worktree', 'captures worktree prefix'); + assertEq(m?.[2], 'M001', 'captures M001 with worktree'); + assertEq(m?.[3], 'S01', 'captures S01 with worktree'); + } + + // Unique format — with worktree prefix + { + const m = 'gsd/worktree/M001-abc123/S01'.match(SLICE_BRANCH_RE); + assertTrue(m !== null, 'matches gsd/worktree/M001-abc123/S01'); + assertEq(m?.[1], 'worktree', 'captures worktree prefix with unique format'); + assertEq(m?.[2], 'M001-abc123', 'captures M001-abc123 with worktree'); + assertEq(m?.[3], 'S01', 'captures S01 with worktree and unique format'); + } + + // Rejects + assertTrue(!SLICE_BRANCH_RE.test('gsd/S01'), 'rejects gsd/S01 (no milestone)'); + assertTrue(!SLICE_BRANCH_RE.test('main'), 'rejects main'); + assertTrue(!SLICE_BRANCH_RE.test('gsd/M001'), 'rejects gsd/M001 (no slice)'); + assertTrue(!SLICE_BRANCH_RE.test('feature/M001/S01'), 'rejects feature/ prefix'); + } + + // (d) Milestone detection regex — used in worktree-command.ts (hasExistingMilestones) + // Pattern: /^M\d+(?:-[a-z0-9]{6})?/ + { + console.log(' (d) Milestone detection regex'); + const MILESTONE_DETECT_RE = /^M\d+(?:-[a-z0-9]{6})?/; + + // Classic format matches + assertTrue(MILESTONE_DETECT_RE.test('M001'), 'detect matches M001'); + assertTrue(MILESTONE_DETECT_RE.test('M042'), 'detect matches M042'); + + // Unique format matches + assertTrue(MILESTONE_DETECT_RE.test('M001-abc123'), 'detect matches M001-abc123'); + assertTrue(MILESTONE_DETECT_RE.test('M042-z9a8b7'), 'detect matches M042-z9a8b7'); + + // Rejects + assertTrue(!MILESTONE_DETECT_RE.test('S01'), 'detect rejects S01'); + assertTrue(!MILESTONE_DETECT_RE.test('notes'), 'detect rejects notes'); + assertTrue(!MILESTONE_DETECT_RE.test('.DS_Store'), 'detect rejects .DS_Store'); + } + + // (e) MILESTONE_CONTEXT_RE — used in index.ts (write-gate) + // Pattern: /M\d+(?:-[a-z0-9]{6})?-CONTEXT\.md$/ + { + console.log(' (e) MILESTONE_CONTEXT_RE'); + const CONTEXT_RE = /M\d+(?:-[a-z0-9]{6})?-CONTEXT\.md$/; + + // Classic format matches + assertTrue(CONTEXT_RE.test('M001-CONTEXT.md'), 'context matches M001-CONTEXT.md'); + assertTrue(CONTEXT_RE.test('.gsd/milestones/M001/M001-CONTEXT.md'), 'context matches full path classic format'); + + // Unique format matches + assertTrue(CONTEXT_RE.test('M001-abc123-CONTEXT.md'), 'context matches M001-abc123-CONTEXT.md'); + assertTrue(CONTEXT_RE.test('.gsd/milestones/M001-abc123/M001-abc123-CONTEXT.md'), 'context matches full path unique format'); + + // Rejects + assertTrue(!CONTEXT_RE.test('M001-ROADMAP.md'), 'context rejects M001-ROADMAP.md'); + assertTrue(!CONTEXT_RE.test('M001-SUMMARY.md'), 'context rejects M001-SUMMARY.md'); + assertTrue(!CONTEXT_RE.test('CONTEXT.md'), 'context rejects bare CONTEXT.md'); + } + + // (f) Prompt dispatch regexes — used in index.ts (executeMatch, resumeMatch) + { + console.log(' (f) Prompt dispatch regexes'); + const EXECUTE_RE = /Execute the next task:\s+(T\d+)\s+\("([^"]+)"\)\s+in slice\s+(S\d+)\s+of milestone\s+(M\d+(?:-[a-z0-9]{6})?)/i; + const RESUME_RE = /Resume interrupted work\.[\s\S]*?slice\s+(S\d+)\s+of milestone\s+(M\d+(?:-[a-z0-9]{6})?)/i; + + // Execute — classic format + { + const prompt = 'Execute the next task: T01 ("Write tests") in slice S01 of milestone M001'; + const m = prompt.match(EXECUTE_RE); + assertTrue(m !== null, 'execute matches classic format'); + assertEq(m?.[1], 'T01', 'execute captures T01'); + assertEq(m?.[3], 'S01', 'execute captures S01'); + assertEq(m?.[4], 'M001', 'execute captures M001'); + } + + // Execute — unique format + { + const prompt = 'Execute the next task: T02 ("Build feature") in slice S03 of milestone M001-abc123'; + const m = prompt.match(EXECUTE_RE); + assertTrue(m !== null, 'execute matches unique format'); + assertEq(m?.[1], 'T02', 'execute captures T02 (unique format)'); + assertEq(m?.[3], 'S03', 'execute captures S03 (unique format)'); + assertEq(m?.[4], 'M001-abc123', 'execute captures M001-abc123'); + } + + // Resume — classic format + { + const prompt = 'Resume interrupted work.\nContinuing slice S02 of milestone M001'; + const m = prompt.match(RESUME_RE); + assertTrue(m !== null, 'resume matches classic format'); + assertEq(m?.[1], 'S02', 'resume captures S02'); + assertEq(m?.[2], 'M001', 'resume captures M001'); + } + + // Resume — unique format + { + const prompt = 'Resume interrupted work.\nContinuing slice S01 of milestone M042-z9a8b7'; + const m = prompt.match(RESUME_RE); + assertTrue(m !== null, 'resume matches unique format'); + assertEq(m?.[1], 'S01', 'resume captures S01 (unique format)'); + assertEq(m?.[2], 'M042-z9a8b7', 'resume captures M042-z9a8b7'); + } + } + + // (g) milestoneIdSort — mixed-format ordering + { + console.log(' (g) milestoneIdSort'); + const mixed = ['M002-abc123', 'M001', 'M001-xyz789']; + const sorted = [...mixed].sort(milestoneIdSort); + assertEq(sorted, ['M001', 'M001-xyz789', 'M002-abc123'], 'sorts mixed IDs by sequence number'); + + // Stable within same seq — preserves insertion order + const sameSorted = ['M001-abc123', 'M001'].sort(milestoneIdSort); + assertEq(sameSorted[0], 'M001-abc123', 'same seq preserves order (first)'); + assertEq(sameSorted[1], 'M001', 'same seq preserves order (second)'); + + // Classic format only + const oldOnly = ['M003', 'M001', 'M002']; + assertEq([...oldOnly].sort(milestoneIdSort), ['M001', 'M002', 'M003'], 'sorts classic-format IDs'); + + // Unique format only + const newOnly = ['M003-abc123', 'M001-def456', 'M002-ghi789']; + assertEq([...newOnly].sort(milestoneIdSort), ['M001-def456', 'M002-ghi789', 'M003-abc123'], 'sorts unique-format IDs'); + } + + // (h) extractMilestoneSeq — numeric extraction from both formats + { + console.log(' (h) extractMilestoneSeq'); + + // Classic format + assertEq(extractMilestoneSeq('M001'), 1, 'M001 → 1'); + assertEq(extractMilestoneSeq('M042'), 42, 'M042 → 42'); + assertEq(extractMilestoneSeq('M999'), 999, 'M999 → 999'); + + // Unique format — confirms dispatch-guard refactor correctness + assertEq(extractMilestoneSeq('M001-abc123'), 1, 'M001-abc123 → 1'); + assertEq(extractMilestoneSeq('M042-z9a8b7'), 42, 'M042-z9a8b7 → 42'); + assertEq(extractMilestoneSeq('M100-xyz789'), 100, 'M100-xyz789 → 100'); + + // Invalid → 0 (not NaN — the old parseInt(slice(1)) bug) + assertEq(extractMilestoneSeq(''), 0, 'empty → 0'); + assertEq(extractMilestoneSeq('notes'), 0, 'notes → 0'); + assertEq(extractMilestoneSeq('S01'), 0, 'S01 → 0'); + assertTrue(!Number.isNaN(extractMilestoneSeq('M001-abc123')), 'unique format does not return NaN'); + assertTrue(!Number.isNaN(extractMilestoneSeq('M001-ABCDEF')), 'invalid format does not return NaN'); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Results + // ═══════════════════════════════════════════════════════════════════════════ + + console.log(`\n${'='.repeat(40)}`); + console.log(`Results: ${passed} passed, ${failed} failed`); + if (failed > 0) { + process.exit(1); + } else { + console.log('All tests passed'); + } +} + +test('regex-hardening: all 12 sites accept both formats', async () => { + await main(); +}); diff --git a/src/resources/extensions/gsd/tests/unique-milestone-ids.test.ts b/src/resources/extensions/gsd/tests/unique-milestone-ids.test.ts new file mode 100644 index 000000000..7b0991977 --- /dev/null +++ b/src/resources/extensions/gsd/tests/unique-milestone-ids.test.ts @@ -0,0 +1,258 @@ +// Tests for unique milestone ID exports from T01/S01 — covers the S01→S02 boundary contract. +// +// Sections: +// (a) MILESTONE_ID_RE: regex matching/rejection +// (b) extractMilestoneSeq: old/new/invalid → number +// (c) parseMilestoneId: old/new/invalid → structured result +// (d) milestoneIdSort: ordering of mixed arrays +// (e) generateMilestonePrefix: format, length, uniqueness +// (f) nextMilestoneId: uniqueEnabled true/false, mixed arrays +// (g) maxMilestoneNum: empty, old, new, mixed, non-matching +// (h) Preferences round-trip: validate, merge behavior via renderPreferencesForSystemPrompt + +import { + MILESTONE_ID_RE, + extractMilestoneSeq, + parseMilestoneId, + milestoneIdSort, + generateMilestonePrefix, + nextMilestoneId, + maxMilestoneNum, +} from '../guided-flow.ts'; + +import { renderPreferencesForSystemPrompt } from '../preferences.ts'; +import type { GSDPreferences } from '../preferences.ts'; + +// ─── Assertion helpers ───────────────────────────────────────────────────── + +let passed = 0; +let failed = 0; + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function assertTrue(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertMatch(value: string, pattern: RegExp, message: string): void { + if (pattern.test(value)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — "${value}" did not match ${pattern}`); + } +} + +// ─── Tests ───────────────────────────────────────────────────────────────── + +async function main(): Promise { + console.log('unique-milestone-ids tests'); + + // (a) MILESTONE_ID_RE + { + console.log(' (a) MILESTONE_ID_RE'); + // Should match + assertTrue(MILESTONE_ID_RE.test('M001'), 'matches M001'); + assertTrue(MILESTONE_ID_RE.test('M999'), 'matches M999'); + assertTrue(MILESTONE_ID_RE.test('M001-abc123'), 'matches M001-abc123'); + assertTrue(MILESTONE_ID_RE.test('M042-z9a8b7'), 'matches M042-z9a8b7'); + + // Should reject + assertTrue(!MILESTONE_ID_RE.test('M1'), 'rejects M1 (too few digits)'); + assertTrue(!MILESTONE_ID_RE.test('M0001'), 'rejects M0001 (too many digits)'); + assertTrue(!MILESTONE_ID_RE.test('M001-ABCDEF'), 'rejects M001-ABCDEF (uppercase prefix)'); + assertTrue(!MILESTONE_ID_RE.test('M001-short'), 'rejects M001-short (5-char prefix)'); + assertTrue(!MILESTONE_ID_RE.test('M001-toolong1'), 'rejects M001-toolong1 (>6-char prefix)'); + assertTrue(!MILESTONE_ID_RE.test('IM001'), 'rejects IM001 (prefix before M)'); + assertTrue(!MILESTONE_ID_RE.test(''), 'rejects empty string'); + assertTrue(!MILESTONE_ID_RE.test('M001extra'), 'rejects M001extra (trailing chars)'); + assertTrue(!MILESTONE_ID_RE.test('notes'), 'rejects non-milestone string'); + } + + // (b) extractMilestoneSeq + { + console.log(' (b) extractMilestoneSeq'); + // Old format + assertEq(extractMilestoneSeq('M001'), 1, 'M001 → 1'); + assertEq(extractMilestoneSeq('M042'), 42, 'M042 → 42'); + assertEq(extractMilestoneSeq('M999'), 999, 'M999 → 999'); + + // Unique format + assertEq(extractMilestoneSeq('M001-abc123'), 1, 'M001-abc123 → 1'); + assertEq(extractMilestoneSeq('M042-z9a8b7'), 42, 'M042-z9a8b7 → 42'); + + // Invalid → 0 + assertEq(extractMilestoneSeq(''), 0, 'empty → 0'); + assertEq(extractMilestoneSeq('notes'), 0, 'notes → 0'); + assertEq(extractMilestoneSeq('M1'), 0, 'M1 → 0'); + assertEq(extractMilestoneSeq('.DS_Store'), 0, '.DS_Store → 0'); + assertEq(extractMilestoneSeq('M-ABC-001'), 0, 'M-ABC-001 (old format) → 0'); + } + + // (c) parseMilestoneId + { + console.log(' (c) parseMilestoneId'); + // Old format — no prefix + assertEq(parseMilestoneId('M001'), { num: 1 }, 'M001 → { num: 1 }'); + assertEq(parseMilestoneId('M042'), { num: 42 }, 'M042 → { num: 42 }'); + + // Unique format — with prefix + assertEq(parseMilestoneId('M001-abc123'), { prefix: 'abc123', num: 1 }, 'M001-abc123 → { prefix, num }'); + assertEq(parseMilestoneId('M042-z9a8b7'), { prefix: 'z9a8b7', num: 42 }, 'M042-z9a8b7 → { prefix, num }'); + + // Invalid → { num: 0 } + assertEq(parseMilestoneId(''), { num: 0 }, 'empty → { num: 0 }'); + assertEq(parseMilestoneId('notes'), { num: 0 }, 'notes → { num: 0 }'); + assertEq(parseMilestoneId('M001-ABCDEF'), { num: 0 }, 'uppercase prefix → { num: 0 }'); + assertEq(parseMilestoneId('M1'), { num: 0 }, 'M1 → { num: 0 }'); + } + + // (d) milestoneIdSort + { + console.log(' (d) milestoneIdSort'); + const mixed = ['M003-abc123', 'M001', 'M002-z9a8b7']; + const sorted = [...mixed].sort(milestoneIdSort); + assertEq(sorted, ['M001', 'M002-z9a8b7', 'M003-abc123'], 'sorts mixed IDs by sequence number'); + + // All old format + const oldOnly = ['M003', 'M001', 'M002']; + assertEq([...oldOnly].sort(milestoneIdSort), ['M001', 'M002', 'M003'], 'sorts old-format IDs'); + + // Invalid entries sort to front (seq 0) + const withInvalid = ['M002', 'notes', 'M001']; + assertEq([...withInvalid].sort(milestoneIdSort), ['notes', 'M001', 'M002'], 'invalid entries (seq 0) sort first'); + } + + // (e) generateMilestonePrefix + { + console.log(' (e) generateMilestonePrefix'); + const prefix1 = generateMilestonePrefix(); + assertEq(prefix1.length, 6, 'prefix length is 6'); + assertMatch(prefix1, /^[a-z0-9]{6}$/, 'prefix matches [a-z0-9]{6}'); + + const prefix2 = generateMilestonePrefix(); + assertEq(prefix2.length, 6, 'second prefix length is 6'); + assertMatch(prefix2, /^[a-z0-9]{6}$/, 'second prefix matches [a-z0-9]{6}'); + + // Two calls should produce different results (36^6 = ~2.2B possibilities) + assertTrue(prefix1 !== prefix2, 'two calls produce different prefixes'); + } + + // (f) nextMilestoneId + { + console.log(' (f) nextMilestoneId'); + // uniqueEnabled=false (default) → old format + assertEq(nextMilestoneId([]), 'M001', 'empty + uniqueEnabled=false → M001'); + assertEq(nextMilestoneId(['M001', 'M002']), 'M003', 'sequential + uniqueEnabled=false → M003'); + assertEq(nextMilestoneId(['M001', 'M002'], false), 'M003', 'explicit false → M003'); + + // uniqueEnabled=true → unique format + const newId = nextMilestoneId([], true); + assertMatch(newId, MILESTONE_ID_RE, 'uniqueEnabled=true produces valid ID'); + assertTrue(newId.startsWith('M001-'), 'uniqueEnabled=true starts with M001-'); + assertMatch(newId, /^M001-[a-z0-9]{6}$/, 'empty + uniqueEnabled=true → M001-{rand6}'); + + // Mixed array with uniqueEnabled=true + const mixedIds = ['M001', 'M003-abc123', 'M002']; + const nextNew = nextMilestoneId(mixedIds, true); + assertMatch(nextNew, MILESTONE_ID_RE, 'mixed array + uniqueEnabled=true → valid ID'); + assertMatch(nextNew, /^M004-[a-z0-9]{6}$/, 'mixed array max=3 → M004-{rand6}'); + + // Mixed array with uniqueEnabled=false + assertEq(nextMilestoneId(mixedIds, false), 'M004', 'mixed array + uniqueEnabled=false → M004'); + + // Correct sequential number from mixed arrays + const mixedIds2 = ['M005-xyz999', 'M002']; + assertEq(nextMilestoneId(mixedIds2, false), 'M006', 'mixed max=5 → M006'); + const nextNew2 = nextMilestoneId(mixedIds2, true); + assertMatch(nextNew2, /^M006-[a-z0-9]{6}$/, 'mixed max=5 + unique → M006-{rand6}'); + } + + // (g) maxMilestoneNum + { + console.log(' (g) maxMilestoneNum'); + // Empty + assertEq(maxMilestoneNum([]), 0, 'empty → 0'); + + // Old format only + assertEq(maxMilestoneNum(['M001', 'M002', 'M003']), 3, 'old format only → 3'); + + // Unique format only — must not return NaN + assertEq(maxMilestoneNum(['M001-abc123', 'M002-def456']), 2, 'unique format only → 2'); + assertTrue(!Number.isNaN(maxMilestoneNum(['M001-abc123'])), 'unique format does not return NaN'); + + // Mixed formats + assertEq(maxMilestoneNum(['M001', 'M003-abc123', 'M002']), 3, 'mixed → 3'); + + // Non-matching entries ignored + assertEq(maxMilestoneNum(['M001', 'notes', '.DS_Store', 'M003']), 3, 'non-matching ignored → 3'); + assertEq(maxMilestoneNum(['notes', '.DS_Store']), 0, 'all non-matching → 0'); + } + + // (h) Preferences round-trip via renderPreferencesForSystemPrompt + { + console.log(' (h) Preferences round-trip'); + + // validate { unique_milestone_ids: true } → field preserved (no validation error) + const prefsTrue: GSDPreferences = { unique_milestone_ids: true }; + const renderedTrue = renderPreferencesForSystemPrompt(prefsTrue); + assertTrue(!renderedTrue.includes('some preference values were ignored'), 'unique_milestone_ids: true validates without error'); + + // validate { unique_milestone_ids: undefined } → field absent (no error) + const prefsUndefined: GSDPreferences = {}; + const renderedUndefined = renderPreferencesForSystemPrompt(prefsUndefined); + assertTrue(!renderedUndefined.includes('some preference values were ignored'), 'undefined unique_milestone_ids validates without error'); + + // validate { unique_milestone_ids: false } → also valid + const prefsFalse: GSDPreferences = { unique_milestone_ids: false }; + const renderedFalse = renderPreferencesForSystemPrompt(prefsFalse); + assertTrue(!renderedFalse.includes('some preference values were ignored'), 'unique_milestone_ids: false validates without error'); + + // validate coercion: truthy non-boolean → coerced to boolean (no crash) + const prefsCoerced: GSDPreferences = { unique_milestone_ids: 1 as unknown as boolean }; + const renderedCoerced = renderPreferencesForSystemPrompt(prefsCoerced); + assertTrue(!renderedCoerced.includes('some preference values were ignored'), 'truthy non-boolean coerces without validation error'); + + // GSDPreferences interface accepts the field (compile-time check — if this compiles, it works) + const prefs: GSDPreferences = { unique_milestone_ids: true, version: 1 }; + assertTrue(prefs.unique_milestone_ids === true, 'GSDPreferences interface accepts unique_milestone_ids'); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Results + // ═══════════════════════════════════════════════════════════════════════════ + + console.log(`\n${'='.repeat(40)}`); + console.log(`Results: ${passed} passed, ${failed} failed`); + if (failed > 0) { + process.exit(1); + } else { + console.log('All tests passed'); + } +} + +// When run via vitest, wrap in test(); when run via tsx, call directly. +const isVitest = typeof globalThis !== 'undefined' && (globalThis as any).__vitest_worker__?.config?.defines != null && 'vitest' in (globalThis as any).__vitest_worker__.config.defines || process.env.VITEST; +if (isVitest) { + const { test } = await import('vitest'); + test('unique-milestone-ids: all ID primitives handle both formats', async () => { + await main(); + }); +} else { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/src/resources/extensions/gsd/workspace-index.ts b/src/resources/extensions/gsd/workspace-index.ts index ada57dfe9..e8161e4f9 100644 --- a/src/resources/extensions/gsd/workspace-index.ts +++ b/src/resources/extensions/gsd/workspace-index.ts @@ -11,6 +11,7 @@ import { resolveTasksDir, } from "./paths.ts"; import { deriveState } from "./state.ts"; +import { milestoneIdSort } from "./guided-flow.js"; import { type ValidationIssue, validateCompleteBoundary, validatePlanBoundary } from "./observability-validator.ts"; import { getSliceBranchName, detectWorktreeName } from "./worktree.ts"; @@ -64,10 +65,10 @@ function findMilestoneIds(basePath: string): string[] { return readdirSync(milestonesDir(basePath), { withFileTypes: true }) .filter(entry => entry.isDirectory()) .map(entry => { - const match = entry.name.match(/^(M\d+)/); + const match = entry.name.match(/^(M\d+(?:-[a-z0-9]{6})?)/); return match ? match[1] : entry.name; }) - .sort(); + .sort(milestoneIdSort); } catch { return []; } @@ -75,7 +76,7 @@ function findMilestoneIds(basePath: string): string[] { function titleFromRoadmapHeader(content: string, fallbackId: string): string { const roadmap = parseRoadmap(content); - return roadmap.title.replace(/^M\d+[^:]*:\s*/, "") || fallbackId; + return roadmap.title.replace(/^M\d+(?:-[a-z0-9]{6})?[^:]*:\s*/, "") || fallbackId; } async function indexSlice(basePath: string, milestoneId: string, sliceId: string, fallbackTitle: string, done: boolean): Promise { diff --git a/src/resources/extensions/gsd/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts index 0ded7dd0b..300ddfa9f 100644 --- a/src/resources/extensions/gsd/worktree-command.ts +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -317,7 +317,7 @@ function hasExistingMilestones(wtPath: string): boolean { if (!existsSync(mDir)) return false; try { const entries = readdirSync(mDir, { withFileTypes: true }) - .filter(d => d.isDirectory() && /^M\d+/.test(d.name)); + .filter(d => d.isDirectory() && /^M\d+(?:-[a-z0-9]{6})?/.test(d.name)); return entries.length > 0; } catch { return false; diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 4a2c84516..0b337ab47 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -12,7 +12,7 @@ * Flow: * 1. ensureSliceBranch() — create + checkout slice branch * 2. agent does work, commits - * 3. mergeSliceToMain() — checkout main, squash-merge, delete branch + * 3. mergeSliceToMain() — checkout integration branch, squash-merge, delete slice branch */ import { sep } from "node:path"; @@ -98,7 +98,7 @@ export function getSliceBranchName(milestoneId: string, sliceId: string, worktre } /** Regex that matches both plain and worktree-namespaced slice branches. */ -export const SLICE_BRANCH_RE = /^gsd\/(?:([a-zA-Z0-9_-]+)\/)?(M\d+)\/(S\d+)$/; +export const SLICE_BRANCH_RE = /^gsd\/(?:([a-zA-Z0-9_-]+)\/)?(M\d+(?:-[a-z0-9]{6})?)\/(S\d+)$/; /** * Parse a slice branch name into its components. @@ -163,16 +163,16 @@ export function autoCommitCurrentBranch( } /** - * Switch to main, auto-committing any dirty files on the current branch first. + * Switch to the integration branch, auto-committing any dirty files on the current branch first. */ export function switchToMain(basePath: string): void { getService(basePath).switchToMain(); } /** - * Squash-merge a completed slice branch to main. - * Expects to already be on main (call switchToMain first). - * Deletes the branch after merge. + * Squash-merge a completed slice branch into the integration branch. + * Expects to already be on the integration branch (call switchToMain first). + * Deletes the slice branch after merge. */ export function mergeSliceToMain( basePath: string, milestoneId: string, sliceId: string, sliceTitle: string,