feat: Allow teams to work together without milestone name clashes and share context by checking in certain .gsd/ directory artifacts (#338)

* feat(M001/S01): ID generation and config plumbing

Tasks:
- chore(M001/S01/T02): auto-commit after execute-task
- chore(M001/S01): auto-commit after plan-slice
- docs(S01): add slice plan

Branch: gsd/M001/S01

* feat(M001/S02): Regex hardening and backwards compat

Tasks:
- chore(M001/S02/T02): auto-commit after execute-task
- chore(M001/S02/T01): auto-commit after execute-task
- chore: untrack .gsd/ runtime files from git index
- docs(S02): add slice plan

Branch: gsd/M001/S02

* docs(M001/S03): UX — wizard toggle and documentation

Tasks:
- chore(M001/S03/T01): auto-commit after execute-task
- docs(S03): add slice plan

Branch: gsd/M001/S03

* test(M001/S04): Integration tests and end-to-end verification

Tasks:
- chore(M001/S04/T02): auto-commit after execute-task
- chore(M001/S04/T01): auto-commit after execute-task
- docs(S04): add slice plan

Branch: gsd/M001/S04

* chore(M001): record integration branch

* chore(M002): record integration branch

* docs(M002/S01): Format swap — production code, tests, and docs

Tasks:
- chore(gsd/M002/S01): auto-commit after pre-switch
- chore(M002/S01/T01): auto-commit after execute-task
- chore: untrack .gsd/ runtime files from git index
- docs(S01): add slice plan

Branch: gsd/M002/S01

* chore(M002): auto-commit after complete-milestone

* Updated to document that we don't automatically always squash to main if you started on a different branch (like a dev or feature branch)

* fix: replace vitest import with node:test in regex-hardening test

The test imported from 'vitest' which isn't installed, causing
ERR_MODULE_NOT_FOUND and failing the CI unit test step. All other
test files use node:test. Swapped the import and removed the
vitest conditional wrapper.

* chore: untrack .gsd/ (already gitignored)

* docs: fix stale 'main' references in merge comments and prompts

The slice merge code correctly resolves to the integration branch
(which may be a feature branch, worktree branch, etc.), but comments,
JSDoc, and prompt templates still said 'main' as if it were always
the literal main branch.

Updated git-service.ts, worktree.ts, system.md, and
guided-complete-slice.md to say 'integration branch' with a clear
explanation of what that means.

* Fixed preferences.md case mismatch; Added fallback for backwards compat

* Updated preferences file example to show new unique_milestone_ids setting

* Updated readme to explain best practice for working in teams

---------

Co-authored-by: TÂCHES <afromanguy@me.com>
This commit is contained in:
Adam Dry 2026-03-14 13:00:14 +00:00 committed by GitHub
parent c13b1bfc6e
commit cf8dfc8c37
21 changed files with 1471 additions and 144 deletions

3
.gitignore vendored
View file

@ -55,3 +55,6 @@ TODOS.md
.gsd/auto.lock
.gsd/metrics.json
.gsd/STATE.md
# ── GSD baseline (auto-generated) ──
.gsd/completed-units.json

210
README.md
View file

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

View file

@ -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<string, unknown>): 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<string>();

View file

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

View file

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

View file

@ -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<string | null> {
@ -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;
}

View file

@ -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/<name>)
* 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();

View file

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

View file

@ -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}.`,

View file

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

View file

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

View file

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

View file

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

View file

@ -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<GSDState> {
}
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) {

View file

@ -15,6 +15,7 @@ git:
snapshots:
pre_merge_check:
commit_type:
unique_milestone_ids:
---
# GSD Skill Preferences

View file

@ -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<T>(actual: T, expected: T, message: string): void {
if (JSON.stringify(actual) === JSON.stringify(expected)) {
passed++;
} else {
failed++;
console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function 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<void> {
// ─── 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);
});
}

View file

@ -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<T>(actual: T, expected: T, message: string): void {
if (JSON.stringify(actual) === JSON.stringify(expected)) {
passed++;
} else {
failed++;
console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function 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<void> {
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();
});

View file

@ -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<T>(actual: T, expected: T, message: string): void {
if (JSON.stringify(actual) === JSON.stringify(expected)) {
passed++;
} else {
failed++;
console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function 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<void> {
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);
});
}

View file

@ -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<WorkspaceSliceTarget> {

View file

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

View file

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