Merge branch 'main' into fix/arrow-keys-escape-sequence-splitting-493

This commit is contained in:
TÂCHES 2026-03-15 16:40:22 -06:00 committed by GitHub
commit 5493fe07fa
53 changed files with 2728 additions and 3678 deletions

29
.gitignore vendored
View file

@ -1,6 +1,17 @@
# ── GSD (user project artifacts — never commit) ──
.gsd/
# ── GSD runtime (not source artifacts — planning files are tracked) ──
.gsd/auto.lock
.gsd/completed-units.json
.gsd/STATE.md
.gsd/metrics.json
.gsd/gsd.db
.gsd/activity/
.gsd/runtime/
.gsd/worktrees/
.gsd/DISCUSSION-MANIFEST.json
.gsd/milestones/**/*-CONTINUE.md
.gsd/milestones/**/continue.md
.claude/
*.tgz
.DS_Store
@ -48,17 +59,3 @@ AGENTS.md
.bg-shell/
TODOS.md
.planning/
# ── GSD baseline (auto-generated) ──
.gsd/
# ── GSD baseline (auto-generated) ──
.gsd/activity/
.gsd/runtime/
.gsd/worktrees/
.gsd/auto.lock
.gsd/metrics.json
.gsd/STATE.md
# ── GSD baseline (auto-generated) ──
.gsd/completed-units.json

View file

@ -0,0 +1,157 @@
# GSD Startup Performance Analysis & Optimization Plan
## Measured Baseline (macOS, Node v25.6.1)
### `gsd --version` (simplest possible path): **2.2 seconds**
| Phase | Time | Notes |
|-------|------|-------|
| Node.js process startup | ~160ms | Unavoidable |
| loader.js top-level imports | ~13ms | fs, app-paths, logo |
| undici import + proxy setup | ~200ms | EnvHttpProxyAgent |
| **@gsd/pi-coding-agent barrel import** | **~970ms** | THE BOTTLENECK |
| cli.js other imports | ~3ms | resource-loader, wizard, etc. |
| Arg parsing + version print | ~0ms | |
| Measured wall time overhead | ~700ms | ESM resolution, gc, etc. |
### Full interactive startup: **~3.6 seconds** (post-node)
| Phase | Time | Notes |
|-------|------|-------|
| @gsd/pi-coding-agent import | ~750ms | (cached from loader measurement) |
| ensureManagedTools | ~0ms | No-op after first run |
| AuthStorage + env keys | ~3ms | |
| ModelRegistry | ~1ms | |
| SettingsManager | ~1ms | |
| **initResources (cpSync)** | **~128ms** | Copies all extensions/skills/agents on every launch |
| **resourceLoader.reload()** | **~2535ms** | jiti-compiles 17+ extensions from TypeScript |
### Inside @gsd/pi-coding-agent (barrel import breakdown)
| Sub-module | Time | Notes |
|------------|------|-------|
| Mistral SDK (@mistralai/mistralai) | 369ms | Loaded even if unused |
| Google GenAI SDK (@google/genai) | 186ms | Loaded even if unused |
| extensions/index.js (circular → index.js) | 497ms | Pulls in everything |
| tools/index.js | 124ms | Tool definitions |
| @sinclair/typebox | 64ms | Schema validation |
| OpenAI SDK | 52ms | |
| Anthropic SDK | 50ms | |
---
## Root Causes (Priority Order)
### 1. Extension JIT compilation via jiti (~2.5s)
Every launch compiles 17+ TypeScript extensions to JavaScript using jiti. No caching (`moduleCache: false` is explicitly set). This is the single largest cost.
### 2. Barrel import of @gsd/pi-coding-agent (~1s)
`cli.js` line 1 does a barrel import pulling in ALL exports including all LLM provider SDKs, TUI components, theme system, compaction, blob store, etc.
### 3. Eager LLM SDK loading (~660ms inside barrel)
All provider SDKs are imported at module evaluation time in `pi-ai/index.js`, even though only one provider is typically configured.
### 4. initResources copies files every launch (~128ms)
`cpSync` with `force: true` copies all bundled resources to `~/.gsd/agent/` on every startup, even when nothing changed.
### 5. undici import (~200ms)
Imported in loader.js for proxy support. Not needed for most users.
---
## Optimization Plan
### Phase 1: Quick Wins (est. save ~1-1.5s on --version, ~0.5s interactive)
#### 1A. Fast-path for `--version` and `--help`
Parse argv BEFORE importing cli.js. In loader.js, check for `--version`/`-v` and `--help`/`-h` and exit immediately without loading any dependencies.
**File**: `src/loader.ts`
**Change**: Add arg check before `await import('./cli.js')`
**Impact**: `gsd --version` goes from 2.2s → ~0.2s
#### 1B. Skip initResources when unchanged
Compare `managed-resources.json` version against current `GSD_VERSION`. If they match, skip the `cpSync` entirely.
**File**: `src/resource-loader.ts``initResources()`
**Change**: Early return if versions match
**Impact**: Save ~128ms per launch
#### 1C. Lazy-load undici
Only import undici when HTTP_PROXY/HTTPS_PROXY env vars are actually set.
**File**: `src/loader.ts`
**Change**: Wrap undici import in proxy env check
**Impact**: Save ~200ms for most users
### Phase 2: Lazy Provider Loading (est. save ~600ms interactive)
#### 2A. Lazy-load LLM provider SDKs
Instead of importing all providers at module level in `pi-ai/index.js`, use dynamic `import()` in the provider factory functions. Only load the SDK when a model from that provider is actually requested.
**Files**: `packages/pi-ai/src/providers/*.ts`
**Change**: Move `import { Anthropic } from '@anthropic-ai/sdk'` etc. to dynamic imports inside `complete()` / `stream()` functions
**Impact**: Save ~600ms (Mistral 369ms + Google 186ms + extras) for users who only use one provider
#### 2B. Selective re-exports in pi-ai barrel
Instead of `export * from "./providers/mistral.js"` etc., only export the registration function. Provider internals stay private.
**File**: `packages/pi-ai/src/index.ts`
### Phase 3: Extension Loading Optimization (est. save ~1.5-2s interactive)
#### 3A. Enable jiti module caching
Remove `moduleCache: false` from the jiti config, or use a persistent cache directory.
**File**: `packages/pi-coding-agent/src/core/extensions/loader.ts`
**Change**: Set `moduleCache: true` or configure `cacheDir`
**Impact**: Second+ launches save ~1-2s on extension loading
#### 3B. Pre-compile extensions at build time
Instead of JIT-compiling TypeScript extensions at runtime, compile them to JavaScript during `npm run build`. The runtime loader can then just `import()` the .js files directly without jiti.
**Files**: `package.json` build scripts, `src/resource-loader.ts`, extension loader
**Change**: Add build step to compile extensions; loader checks for .js first
**Impact**: Eliminate ~2.5s of jiti compilation entirely
**Complexity**: HIGH — requires careful handling of extension resolution paths
#### 3C. Parallel extension loading
Currently extensions load sequentially in a `for` loop. Load them in parallel with `Promise.all()`.
**File**: `packages/pi-coding-agent/src/core/extensions/loader.ts``loadExtensions()`
**Change**: `await Promise.all(paths.map(...))` instead of sequential for-loop
**Impact**: Wall time reduction depends on I/O overlap; est. 30-50% faster
### Phase 4: Bundle Optimization (est. save ~300-500ms)
#### 4A. Use esbuild/tsup for the main CLI bundle
Replace plain `tsc` with a bundler that does tree-shaking. A single-file bundle eliminates ESM resolution overhead and removes unused code.
**Impact**: Faster module resolution, smaller output, tree-shaking removes unused exports
**Complexity**: MEDIUM
#### 4B. Split pi-coding-agent into entry-point chunks
Instead of one barrel export, provide separate entry points for core, interactive, tools.
**Impact**: cli.js can import only what it needs for each code path
**Complexity**: HIGH — changes public API surface
---
## Recommended Implementation Order
1. **Phase 1A** — Fast-path --version/--help (trivial, huge UX impact)
2. **Phase 1C** — Lazy undici (easy, 200ms saved)
3. **Phase 1B** — Skip initResources (easy, 128ms saved)
4. **Phase 3C** — Parallel extension loading (moderate, ~1s saved)
5. **Phase 2A** — Lazy provider SDKs (moderate, ~600ms saved)
6. **Phase 3A** — jiti caching (easy, ~1s saved on repeat launches)
7. **Phase 3B** — Pre-compile extensions (hard, eliminates jiti entirely)
8. **Phase 4A** — Bundle with esbuild (medium, ~300-500ms)
### Expected Results
| Scenario | Before | After (Phase 1-3) | After (All) |
|----------|--------|-------------------|-------------|
| `gsd --version` | 2.2s | **~0.2s** | ~0.2s |
| Interactive startup | ~3.8s | **~1.5s** | **~0.8s** |

View file

@ -6,6 +6,57 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
## [2.14.4] - 2026-03-15
### Fixed
- **Session cwd update**`newSession()` now updates the LLM's perceived working directory to reflect `process.chdir()` into auto-worktrees. Previously the system prompt was frozen at the original project root, causing the LLM to `cd` back and write files to the wrong location. This was the root cause of complete-slice and plan-slice loops in worktree-based projects.
## [2.14.3] - 2026-03-15
### Fixed
- **Copy planning artifacts into new auto-worktrees**`createAutoWorktree` now copies `.gsd/milestones/`, `DECISIONS.md`, `REQUIREMENTS.md`, `PROJECT.md` from the source repo into the worktree. Prevents plan-slice loops in projects with pre-v2.14.0 `.gitignore`.
## [2.14.2] - 2026-03-15
### Fixed
- **Dispatch reentrancy deadlock**`_dispatching` flag was never reset after first dispatch, permanently blocking all subsequent unit dispatches. Wrapped in try/finally.
- **`.gitignore` self-heal** — existing projects with blanket `.gsd/` ignore now auto-remove it on next auto-mode start, replacing with explicit runtime-only patterns so planning artifacts are tracked in git.
- **Discuss depth verification** — render summary as chat text (markdown renders), use ask_user_questions for short confirmation only.
## [2.14.1] - 2026-03-15
### Fixed
- **Quiet auto-mode warnings** — internal recovery machinery (dispatch gap watchdog, model fallback chain) downgraded to verbose-only. Users only see warnings when action is needed.
- **Dispatch recovery hardening** — artifact fallback when completion key missing, TUI freeze prevention, reentrancy guard, atomic writes, stale runtime record cleanup
## [2.14.0] - 2026-03-15
### Added
- **Discussion manifest** — mechanical process verification for multi-milestone context discussions
- **Session-internal `/gsd config`** — configure GSD settings within a running session
- **Model selection UI** — select list instead of free-text input for model preferences
- **Startup performance** — faster GSD launch via optimized initialization
### Changed
- **Branchless worktree architecture** — eliminated slice branches entirely. All work commits sequentially on `milestone/<MID>` within auto-mode worktrees. No branch creation, switching, or merging within a worktree. ~2600 lines of merge/conflict/branch-switching code removed.
- **`.gitignore` overhaul** — planning artifacts (`.gsd/milestones/`) are tracked in git naturally. Only runtime files are gitignored. No more force-add hacks.
- **Multi-milestone enforcement**`depends_on` frontmatter enforced in multi-milestone CONTEXT.md
### Fixed
- **Auto-mode loop detection failures** — artifacts on wrong branch or invisible after branch switch no longer possible (root cause eliminated by branchless architecture)
- **Nested worktree creation** — auto-mode no longer creates worktrees inside existing manual worktrees, preventing wrong-repo state reads and "All milestones complete" false positives
- **Dispatch recovery hardening** — artifact fallback when completion key missing, TUI freeze prevention on cascading skips, reentrancy guard, atomic writes, stale runtime record cleanup, git index.lock cleanup
- **Hook orchestration** — finalize runtime records, add supervision, fix retry
- **Empty slice plan stays in planning** — no longer incorrectly transitions to summarizing
- **Prefs wizard** — launch directly from `/gsd prefs`, fix parse/serialize cycle for empty arrays
- **Discussion routing**`/gsd discuss` routes to draft when phase is needs-discussion
### Removed
- `ensureSliceBranch()`, `switchToMain()`, `mergeSliceToMain()`, `mergeSliceToMilestone()`
- `shouldUseWorktreeIsolation()`, `getMergeToMainMode()`, `buildFixMergePrompt()`
- `withMergeHeal()`, `recoverCheckout()`, `fix-merge` unit type
- `git.isolation` and `git.merge_to_main` preferences (deprecated with warnings)
## [2.13.1] - 2026-03-15
### Fixed
@ -607,7 +658,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Changed
- License updated to MIT
[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.13.1...HEAD
[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.14.4...HEAD
[2.14.4]: https://github.com/gsd-build/gsd-2/compare/v2.14.3...v2.14.4
[2.14.3]: https://github.com/gsd-build/gsd-2/compare/v2.14.2...v2.14.3
[2.14.2]: https://github.com/gsd-build/gsd-2/compare/v2.14.1...v2.14.2
[2.14.1]: https://github.com/gsd-build/gsd-2/compare/v2.14.0...v2.14.1
[2.14.0]: https://github.com/gsd-build/gsd-2/compare/v2.13.1...v2.14.0
[2.13.1]: https://github.com/gsd-build/gsd-2/compare/v2.13.0...v2.13.1
[2.13.0]: https://github.com/gsd-build/gsd-2/compare/v2.12.0...v2.13.0
[2.12.0]: https://github.com/gsd-build/gsd-2/compare/v2.11.1...v2.12.0

View file

@ -38,7 +38,7 @@ GSD v2 solves all of these because it's not a prompt framework anymore — it's
| 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 |
| Git strategy | LLM writes git commands | Worktree isolation, sequential commits, 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 |
@ -111,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 (or whichever branch you started from) as one clean commit.
3. **Git worktree isolation** — Each milestone runs in its own git worktree with a `milestone/<MID>` branch. All slice work commits sequentially — no branch switching, no merge conflicts. When the milestone completes, it's squash-merged to main 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.
@ -268,7 +268,7 @@ gsd/M001/S01 (deleted after merge):
feat(S01/T01): core types and interfaces
```
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.
One squash commit per milestone on main (or whichever branch you started from). The worktree is torn down after merge. Git bisect works. Individual milestones are revertable.
### Verification

View file

@ -0,0 +1,279 @@
# ADR-001: Branchless Worktree Architecture
**Status:** Proposed
**Date:** 2026-03-15
**Deciders:** Lex Christopherson
**Advisors:** Claude Opus 4.6, Gemini 2.5 Pro, GPT-5.4 (Codex)
## Context
GSD uses git for isolation during autonomous coding sessions. The current architecture (shipped in M003, v2.13.0) creates a **worktree per milestone** with **slice branches inside each worktree**. Each slice (`S01`, `S02`, ...) gets its own branch (`gsd/M001/S01`) within the worktree, which merges back to the milestone branch (`milestone/M001`) via `--no-ff` when the slice completes. The milestone branch squash-merges to `main` when the milestone completes.
This architecture replaced a previous "branch-per-slice" model that had severe `.gsd/` merge conflicts. M003 solved the merge conflicts but retained slice branches inside worktrees, inheriting complexity that has produced persistent, user-facing failures.
### Problems
**1. Planning artifact invisibility (loop detection failures)**
When `research-slice` or `plan-slice` dispatches, the agent writes artifacts (e.g., `S02-RESEARCH.md`) on a slice branch. After the agent completes, `handleAgentEnd` switches back to the milestone branch for the next dispatch. The artifact is on the slice branch, not the milestone branch. `verifyExpectedArtifact()` checks the milestone branch, can't find the file, increments the loop counter, retries, same result. After 3 retries → hard stop. After 6 lifetime dispatches → permanent stop. This burns budget and blocks progress.
Documented in the auto-stop architecture doc as "The Branch-Switching Problem."
**2. `.gsd/` state clobbering across branches**
`.gsd/` is gitignored (line 52 of `.gitignore`: `.gsd/`). Planning artifacts (roadmaps, plans, summaries, decisions, requirements) live in `.gsd/milestones/` but are invisible to git. When multiple branches or worktrees operate from the same repo, they share a single `.gsd/` directory on disk. Branch A's M001 roadmap overwrites Branch B's M001 roadmap. GSD reads corrupted state, shows wrong milestone as complete, or enters infinite dispatch loops.
The codebase has a contradictory workaround: `smartStage()` (git-service.ts:304-352) force-adds `GSD_DURABLE_PATHS` (milestones/, DECISIONS.md, PROJECT.md, REQUIREMENTS.md, QUEUE.md) despite the `.gitignore`. This means `.gsd/milestones/` IS partially tracked on some branches but the gitignore claims otherwise. The code fights the configuration.
**3. Merge/conflict code complexity**
The current slice branch model requires:
- `mergeSliceToMilestone()` — 98 lines, `--no-ff` merge with `withMergeHeal` wrapper
- `mergeSliceToMain()` — 189 lines, squash-merge with conflict detection/categorization/auto-resolution
- `git-self-heal.ts` — 198 lines, 3 recovery functions for merge failures
- `fix-merge` dispatch unit — dedicated LLM session to resolve conflicts the auto-resolver can't handle
- `smartStage()` — 49 lines of runtime exclusion during staging
- Conflict categorization — 80 lines classifying `.gsd/` vs runtime vs code conflicts
Total: **~582 lines** of merge/branch/conflict code across 3 files, plus the `fix-merge` prompt template and dispatch logic. This code exists solely because of slice branches.
**4. Dual isolation modes**
Branch-mode (`git-service.ts:mergeSliceToMain`) and worktree-mode (`auto-worktree.ts:mergeSliceToMilestone`) have parallel implementations with different merge strategies, different conflict handling, and different branch naming. Both paths must be maintained and tested. 11 test files exercise merge/branch/worktree logic.
**5. Bug history**
- v2.11.1: URGENT fix for parse cache staleness causing repeated unit dispatch (directly caused by branch switching invalidation timing)
- v2.13.1: Windows hotfix for multi-line commit messages in `mergeSliceToMilestone`
- 15+ separate bug fixes for `.gsd/` merge conflicts in the pre-M003 era
- Persistent user complaints about loop detection failures and state corruption
## Decision
**Eliminate slice branches entirely.** All work within a milestone worktree commits sequentially on a single branch (`milestone/<MID>`). No branch creation, no branch switching, no slice merges, no conflict resolution within a worktree.
Track `.gsd/` planning artifacts in git. Gitignore only runtime/ephemeral state.
### The Architecture
```
main ──────────────────────────────────────────── main
│ ↑
└─ worktree (milestone/M001) │
│ │
commit: feat(M001): context + roadmap │
commit: feat(M001/S01): research │
commit: feat(M001/S01): plan │
commit: feat(M001/S01/T01): impl │
commit: feat(M001/S01/T02): impl │
commit: feat(M001/S01): summary + UAT │
commit: feat(M001/S02): research │
commit: ... │
commit: feat(M001): milestone complete │
│ │
└──────────── squash merge ──────────────────┘
```
### Git Primitives Used
| Primitive | Purpose |
|-----------|---------|
| **Worktrees** | One per active milestone. Filesystem isolation. |
| **Commits** | Granular sequential history of every action. |
| **Squash merge** | Clean single commit on `main` per milestone. |
| **Branches** | Only `main` and `milestone/<MID>`. Nothing else. |
### Git Primitives NOT Used
| Primitive | Why Not |
|-----------|---------|
| Slice branches | Slices are sequential. Branches add complexity with no rollback benefit. |
| `--no-ff` merges | No branches to merge within a worktree. |
| Branch switching | Never happens. All work on one branch. |
| Conflict resolution | No merges within a worktree means no conflicts within a worktree. |
### `.gsd/` Tracking Model
**Tracked in git (travels with the branch):**
```
.gsd/milestones/ — roadmaps, plans, summaries, research, contexts, task plans/summaries
.gsd/PROJECT.md — project overview
.gsd/DECISIONS.md — architectural decision register
.gsd/REQUIREMENTS.md — requirements register
.gsd/QUEUE.md — work queue
```
**Gitignored (ephemeral, runtime, infrastructure):**
```
.gsd/runtime/ — dispatch records, timeout tracking
.gsd/activity/ — JSONL session dumps
.gsd/worktrees/ — git worktree working directories
.gsd/auto.lock — crash detection sentinel
.gsd/metrics.json — token/cost accumulator
.gsd/completed-units.json — dispatch idempotency tracker
.gsd/STATE.md — derived state cache (rebuilt by deriveState())
.gsd/gsd.db — SQLite cache (rebuilt from tracked markdown by importers)
.gsd/DISCUSSION-MANIFEST.json — discussion phase tracking
.gsd/milestones/**/*-CONTINUE.md — interrupted-work markers
.gsd/milestones/**/continue.md — legacy continue markers
```
### `.gitignore` Update
Replace the current blanket `.gsd/` ignore with explicit runtime-only ignores:
```gitignore
# ── GSD: Runtime / Ephemeral ─────────────────────────────────
.gsd/auto.lock
.gsd/completed-units.json
.gsd/STATE.md
.gsd/metrics.json
.gsd/gsd.db
.gsd/activity/
.gsd/runtime/
.gsd/worktrees/
.gsd/DISCUSSION-MANIFEST.json
.gsd/milestones/**/*-CONTINUE.md
.gsd/milestones/**/continue.md
```
Planning artifacts (milestones/, PROJECT.md, DECISIONS.md, REQUIREMENTS.md, QUEUE.md) are NOT in `.gitignore` and are tracked normally.
## Consequences
### Code Deletion
| File | Lines Deleted | What's Removed |
|------|--------------|----------------|
| `auto-worktree.ts` | ~246 | `mergeSliceToMilestone()`, `shouldUseWorktreeIsolation()`, `getMergeToMainMode()`, slice merge guards |
| `git-service.ts` | ~250 | `mergeSliceToMain()`, conflict resolution, runtime stripping post-merge, `ensureSliceBranch()`, `switchToMain()` |
| `git-self-heal.ts` | ~86 | `abortAndReset()`, `withMergeHeal()` (merge-specific recovery) |
| `auto.ts` | ~150 | Merge dispatch guards, `fix-merge` dispatch path, branch-mode routing |
| `worktree.ts` | ~40 | `getSliceBranchName()`, `ensureSliceBranch()`, `mergeSliceToMain()` delegates |
| **Test files** | ~11 files | `auto-worktree-merge.test.ts`, `auto-worktree-milestone-merge.test.ts`, merge-related test cases |
| **Total** | **~770+ lines** | |
### What `mergeMilestoneToMain()` Becomes
The function simplifies dramatically:
1. Auto-commit any dirty state in worktree
2. `chdir` back to main repo root
3. `git checkout main`
4. `git merge --squash milestone/<MID>`
5. `git commit` with milestone summary
6. Remove worktree + delete branch
No conflict categorization. No runtime file stripping. No `.gsd/` special handling. Planning artifacts merge cleanly because they're in `.gsd/milestones/M001/` which doesn't exist on `main` until this merge.
### What `smartStage()` Becomes
The force-add of `GSD_DURABLE_PATHS` is no longer needed — planning artifacts are not gitignored, so `git add -A` picks them up naturally. The function reduces to:
1. `git add -A`
2. `git reset HEAD -- <runtime paths>` (unstage runtime files)
The `_runtimeFilesCleanedUp` one-time migration logic can also be removed.
### What Happens to `handleAgentEnd()`
After any unit completes:
1. Invalidate caches
2. `autoCommitCurrentBranch()` — commits on the one and only branch
3. `verifyExpectedArtifact()` — file is always on the current branch (no branch switching)
4. Persist completion key
The "Path A fix" (lines 937-953) becomes the only path. No branch mismatch possible.
### What Happens to `fix-merge`
The `fix-merge` dispatch unit type is eliminated. Within a worktree, there are no merges that can conflict. The only merge is milestone→main (squash), and if that conflicts (rare, parallel milestone edge case), it's handled as a one-time resolution at milestone completion — not a dispatch loop.
### Backwards Compatibility
The `shouldUseWorktreeIsolation()` three-tier preference resolution is replaced by a single behavior: worktree isolation is always used. The `git.isolation: "branch"` preference is deprecated.
Projects with existing `gsd/M001/S01` slice branches can still be read by state derivation, but new work never creates slice branches.
### Risks
**1. Parallel milestone code conflicts at squash-merge time**
If two milestones modify the same source file, the second squash-merge to `main` will conflict. Mitigation: `git fetch origin main && git rebase main` before squash-merge. This is standard practice and rare in single-user workflows.
**2. Loss of per-slice git history after squash**
Squash merge collapses all commits into one on `main`. Mitigations:
- Commit messages tag slices (`feat(M001/S01/T01):`) — filterable with `git log --grep`
- The milestone branch can be preserved (not deleted) if history is needed
- Alternative: `merge --no-ff` instead of `--squash` to keep history on `main`
**3. SQLite DB desync after `git reset`**
If tracked markdown rolls back via `git reset --hard`, the gitignored `gsd.db` doesn't. Mitigation: the importer layer (M001/S02) rebuilds the DB from markdown on startup. The DB is a cache, markdown is truth.
**4. Disk space with multiple worktrees**
Each worktree duplicates the working directory (including `node_modules`). Mitigation: single active milestone at a time (single-user workflow), immediate cleanup after completion.
## Alternatives Considered
### A. Keep slice branches, fix visibility with immediate mini-merges
After `research-slice` or `plan-slice`, immediately merge the slice branch back to the milestone branch. This fixes the loop detection bug but retains all merge complexity.
**Rejected:** Adds another merge path instead of removing the root cause. Still requires conflict resolution, self-healing, branch switching.
### B. Keep `.gsd/` gitignored, bootstrap from git history for manual worktrees
When GSD detects an empty `.gsd/` in a worktree, reconstruct state from the branch's git history using `git show <commit>:.gsd/...`.
**Rejected:** Recovery logic, not architecture. Doesn't fix the fundamental problem of branch-agnostic state. Fails when git history has been rewritten.
### C. Branch-scoped `.gsd/` directories (`.gsd/branches/<branch-name>/milestones/...`)
Each branch writes to a namespaced subdirectory within `.gsd/`.
**Rejected:** Adds complexity instead of removing it. Requires renaming/moving on branch creation, doesn't work with standard git tools (`git checkout` doesn't rename directories).
## Validation
This architecture was stress-tested by three independent models:
**Gemini 2.5 Pro** identified 6 attack vectors. None broke the core model. Recommendations: pre-flight rebase before squash-merge (adopted), heartbeat locks (already exists), DB rebuild on startup (adopted via M001/S02 importers).
**GPT-5.4 (Codex)** read the full codebase and confirmed the model is sound. Identified that `smartStage()` already force-adds durable paths (validating the tracked-artifact approach) and that `resolveMainWorktreeRoot` in PR #487 is architecturally wrong (adopted — PR to be closed).
**Codebase analysis** confirmed `.gsd/milestones/` is already partially tracked on `main` despite the `.gitignore`, that `GSD_DURABLE_PATHS` exists as a code-level acknowledgment that planning artifacts should be tracked, and that the README already documents the correct runtime-only gitignore pattern.
### Codex (GPT-5.4) Dissent — "No Slice Branches Is a Redesign"
Codex read the full codebase and raised 4 concerns. Each is addressed:
**Concern 1: "Crash after slice done but before integration — today the runtime detects orphaned slice branches and merges them."**
Rebuttal: In the branchless model, there is no integration step to crash between. Slice work is committed directly on the milestone branch. On restart, `deriveState()` reads the branch state as-is. The orphaned-branch recovery path exists solely because of slice branches — removing branches removes the failure mode it recovers from.
**Concern 2: "Concurrent edits to shared root docs (PROJECT.md, DECISIONS.md) from two terminals."**
Rebuttal: Valid edge case. If `/gsd queue` edits `DECISIONS.md` on `main` while auto-mode edits it in a worktree, there's a content conflict at squash-merge time. This is a standard git content conflict — no different from two developers editing the same file. Handled by normal merge resolution. Not caused by or solved by slice branches.
**Concern 3: "Slice→milestone merges provide continuous integration. Removing them pushes conflict discovery to the end."**
Rebuttal: In a single-user sequential workflow, there is nothing to integrate against within a worktree. Each slice builds on the previous one. The only conflict source is `main` diverging (e.g., another milestone merging first), which slice→milestone merges don't catch anyway — they merge within the worktree, not against `main`. Pre-flight rebase before squash-merge catches this more directly.
**Concern 4: "Replace slice branches with another explicit slice-boundary primitive. Don't just delete them."**
Response: Accepted in spirit. Commits with conventional tags (`feat(M001/S01):`, `feat(M001/S01/T01):`) serve as the slice boundary primitive. `git log --grep="M001/S01"` isolates a slice's history. `git revert` targets specific commits. Git tags (`gsd/M001/S01-complete`) can mark slice completion if needed. The boundary primitive is commit metadata, not branches.
## Action Items
1. Close PR #487 (`resolveMainWorktreeRoot`) — contradicts this architecture
2. Implement as a GSD milestone with phases:
- Update `.gitignore` and force-add existing planning artifacts
- Remove slice branch creation/switching/merging code
- Simplify `mergeMilestoneToMain()` and `smartStage()`
- Remove `fix-merge` dispatch unit
- Remove branch-mode isolation (`git.isolation: "branch"`)
- Update/delete 11 test files
- Update README suggested gitignore
- Migration path for existing projects with slice branches

View file

@ -0,0 +1,383 @@
# PRD: Branchless Worktree Architecture
**Author:** Lex Christopherson
**Date:** 2026-03-15
**ADR:** [ADR-001-branchless-worktree-architecture.md](./ADR-001-branchless-worktree-architecture.md)
**Priority:** Critical — blocks reliable auto-mode operation
---
## Problem Statement
GSD's auto-mode is unreliable. Users experience:
1. **Infinite loop detection failures** — the agent writes planning artifacts on slice branches that become invisible after branch switching, causing `verifyExpectedArtifact()` to fail repeatedly. Auto-mode burns budget retrying the same unit 3-6 times before hard-stopping. This is the #1 user complaint.
2. **State corruption across branches**`.gsd/` planning artifacts (roadmaps, plans, decisions) are gitignored but branch-specific. Multiple branches sharing a single `.gsd/` directory clobber each other's state. Users see wrong milestones marked complete, wrong roadmaps loaded, and auto-mode starting from the wrong phase.
3. **Excessive complexity** — 770+ lines of merge, conflict resolution, branch switching, and self-healing code exist solely to manage slice branches inside worktrees. This code has required 15+ bug fixes across versions and remains the primary source of auto-mode failures.
These problems are architectural. They cannot be fixed by patching individual symptoms.
## Vision
Auto-mode uses git worktrees for isolation and sequential commits for history. No branch switching. No merge conflicts within a worktree. Planning artifacts are tracked in git and travel with the branch. The git layer is so simple it can't break.
## Success Criteria
| Criterion | Measurement |
|-----------|-------------|
| Zero loop detection failures from branch visibility | No `verifyExpectedArtifact()` failures caused by branch mismatch in 50 consecutive auto-mode runs |
| Zero `.gsd/` state corruption | Manual worktrees created via `git worktree add` have correct `.gsd/` state without any GSD-specific initialization |
| Code deletion | Net removal of ≥500 lines of merge/conflict/branch-switching code |
| Test simplification | Removal or simplification of ≥6 merge-specific test files |
| Backwards compatibility | Existing projects with `gsd/M001/S01` slice branches continue to work (read-only; new work uses new model) |
| No new git primitives | The implementation uses only: worktrees, commits, squash-merge. No new branch types, merge strategies, or conflict resolution. |
## Non-Goals
- Parallel slice execution within a single worktree (if needed later, use separate worktrees)
- Changing how milestones relate to `main` (squash-merge stays)
- Modifying the dispatch unit types or state machine (except removing `fix-merge`)
- Changing the worktree-manager.ts manual worktree API (`/worktree` command)
## Current Architecture
### Branch Model (M003, v2.13.0)
```
main
└─ milestone/M001 (worktree at .gsd/worktrees/M001/)
├─ gsd/M001/S01 (slice branch — code + .gsd/ artifacts)
│ └── merge --no-ff → milestone/M001
├─ gsd/M001/S02
│ └── merge --no-ff → milestone/M001
└── squash merge → main
```
### Data Flow
```
Agent writes file → on slice branch → handleAgentEnd → auto-commit on slice branch
→ switch to milestone branch → verifyExpectedArtifact → FILE NOT FOUND (it's on slice branch)
→ loop counter++ → retry → same result → HARD STOP
```
### Code Involved
| File | Lines | Purpose |
|------|-------|---------|
| `auto-worktree.ts` | 512 | Worktree lifecycle + slice→milestone merge |
| `git-service.ts` | 915 | Branch creation, switching, merge with conflict resolution |
| `git-self-heal.ts` | 198 | Merge failure recovery |
| `auto.ts` | ~150 lines | Merge dispatch guards, fix-merge routing, branch-mode vs worktree-mode branching |
| `worktree.ts` | ~40 lines | Slice branch delegates |
| 11 test files | ~2000 lines | Merge/branch/worktree test coverage |
### `.gsd/` Tracking (Current — Contradictory)
- `.gitignore` line 52: `.gsd/` — ignores everything
- `smartStage()` lines 338-349: force-adds `GSD_DURABLE_PATHS` — tracks milestones/, DECISIONS.md, PROJECT.md, REQUIREMENTS.md, QUEUE.md
- Result: `.gsd/milestones/` is partially tracked on some branches, fully ignored on others. The code fights the config.
## Proposed Architecture
### Branch Model
```
main
└─ milestone/M001 (worktree at .gsd/worktrees/M001/)
commit: feat(M001): context + roadmap
commit: feat(M001/S01): research
commit: feat(M001/S01): plan
commit: feat(M001/S01/T01): implement auth service
commit: feat(M001/S01/T02): implement auth tests
commit: feat(M001/S01): summary + UAT
commit: docs(M001): reassess roadmap after S01
commit: feat(M001/S02): research
commit: feat(M001/S02): plan
commit: ...
commit: feat(M001): milestone complete
└── squash merge → main
```
One branch. Sequential commits. No merges within the worktree.
### Data Flow
```
Agent writes file → on milestone branch → handleAgentEnd → auto-commit on milestone branch
→ verifyExpectedArtifact → FILE FOUND (same branch) → persist completion → next dispatch
```
### `.gsd/` Tracking (Proposed — Coherent)
**Tracked (travels with branch):**
```
.gsd/milestones/**/*.md (except CONTINUE markers)
.gsd/milestones/**/*.json (META.json integration records)
.gsd/PROJECT.md
.gsd/DECISIONS.md
.gsd/REQUIREMENTS.md
.gsd/QUEUE.md
```
**Gitignored (ephemeral):**
```
.gsd/auto.lock
.gsd/completed-units.json
.gsd/STATE.md
.gsd/metrics.json
.gsd/gsd.db
.gsd/activity/
.gsd/runtime/
.gsd/worktrees/
.gsd/DISCUSSION-MANIFEST.json
.gsd/milestones/**/*-CONTINUE.md
.gsd/milestones/**/continue.md
```
### Why This Works
| Problem | How It's Solved |
|---------|----------------|
| Artifact invisibility after branch switch | No branch switching. Artifacts commit on the one branch. |
| `.gsd/` state clobbering | Artifacts tracked in git. Each branch carries its own `.gsd/`. `git worktree add` and `git checkout` give correct state. |
| Merge conflict complexity | No merges within a worktree. Only merge is milestone→main (squash). |
| Manual worktree initialization | Tracked artifacts are checked out with the branch. No GSD-specific bootstrap needed. |
| Dual isolation mode maintenance | Single mode: worktree. Branch-mode (`git.isolation: "branch"`) deprecated. |
## Implementation Plan
### Phase 1: `.gitignore` + Tracking Fix
**Goal:** Planning artifacts are tracked in git. `.gitignore` reflects reality.
1. Update `.gitignore`:
- Remove blanket `.gsd/` ignore
- Add explicit runtime-only ignores (see proposed list above)
2. Force-add existing planning artifacts on current branch:
```
git add --force .gsd/milestones/ .gsd/PROJECT.md .gsd/DECISIONS.md .gsd/REQUIREMENTS.md .gsd/QUEUE.md
```
3. Ensure runtime files are NOT tracked:
```
git rm --cached -r .gsd/runtime/ .gsd/activity/ .gsd/STATE.md .gsd/metrics.json .gsd/completed-units.json .gsd/auto.lock
```
4. Update README suggested `.gitignore` section
5. Remove `smartStage()` force-add of `GSD_DURABLE_PATHS` — no longer needed since `.gitignore` doesn't block them
**Verification:** `git status` shows planning artifacts tracked, runtime files untracked. `git worktree add` on a new worktree has correct `.gsd/milestones/` state.
### Phase 2: Remove Slice Branch Creation + Switching
**Goal:** No code creates, switches to, or references slice branches for new work.
1. Remove `ensureSliceBranch()` from `git-service.ts` (lines 485-544)
2. Remove `switchToMain()` from `git-service.ts` (lines 549-563)
3. Remove `getSliceBranchName()` from `worktree.ts` (lines 94-98)
4. Remove `isOnSliceBranch()` and `getActiveSliceBranch()` from `worktree.ts`
5. Update `auto.ts` dispatch paths — remove branch creation before `execute-task`
6. Update `handleAgentEnd` — remove branch-switching logic post-dispatch
**Verification:** Auto-mode runs a full slice (research → plan → execute → complete) without creating any branches. All commits land on `milestone/<MID>`.
### Phase 3: Remove Slice Merge Code
**Goal:** All slice→milestone and slice→main merge code is deleted.
1. Remove `mergeSliceToMilestone()` from `auto-worktree.ts` (lines 253-350)
2. Remove `mergeSliceToMain()` from `git-service.ts` (lines 705-893)
3. Remove merge dispatch guards from `auto.ts` (lines 1635-1679)
4. Remove `fix-merge` dispatch unit type from `auto.ts`
5. Remove `buildPromptForFixMerge()` from `auto.ts`
6. Remove `withMergeHeal()` from `git-self-heal.ts` (lines 99-136)
7. Remove `abortAndReset()` from `git-self-heal.ts` (lines 37-84) — or simplify to crash-recovery-only
8. Remove `shouldUseWorktreeIsolation()` preference resolution — worktree is the only mode
9. Remove `getMergeToMainMode()` — milestone merge is the only mode
10. Deprecate `git.isolation: "branch"` and `git.merge_to_main: "slice"` preferences
**Verification:** `git grep mergeSliceToMilestone` returns zero results. `git grep mergeSliceToMain` returns zero results. `git grep fix-merge` returns zero results (outside of changelog/docs).
### Phase 4: Simplify `mergeMilestoneToMain()`
**Goal:** Milestone→main merge is clean and minimal.
The function becomes:
1. Auto-commit any dirty state in worktree
2. `process.chdir(originalBasePath)` — back to main repo
3. `git checkout main`
4. `git merge --squash milestone/<MID>`
5. Build commit message with milestone summary + slice manifest
6. `git commit`
7. Optional: `git push`
8. `removeWorktree()` + `git branch -D milestone/<MID>`
No conflict categorization. No runtime file stripping (runtime files are gitignored, not in the merge). No `.gsd/` special handling.
If squash-merge conflicts (parallel milestone edge case): stop auto-mode with clear error, user resolves manually or GSD dispatches a one-time resolution session.
**Verification:** Complete a full milestone in auto-mode. `main` receives one squash commit with all code and planning artifacts.
### Phase 5: Test Cleanup
**Goal:** Test suite reflects the simplified architecture.
1. Delete or rewrite:
- `auto-worktree-merge.test.ts` — tests slice→milestone merge (deleted)
- `auto-worktree-milestone-merge.test.ts` — rewrite for simplified milestone→main
- `worktree-e2e.test.ts` — rewrite for branchless flow
- `worktree-integration.test.ts` — rewrite for branchless flow
- Merge-related test cases in `git-service.test.ts`
2. Add new tests:
- Branchless worktree lifecycle: create → commit → commit → squash-merge → cleanup
- `.gsd/` tracking: planning artifacts tracked, runtime files ignored
- Manual worktree: `git worktree add` has correct `.gsd/` state
- Crash recovery: dirty state on milestone branch, restart, auto-commit, continue
3. Remove merge-specific doctor checks or simplify:
- `corrupt_merge_state` — keep (still relevant for milestone→main)
- `orphaned_auto_worktree` — keep
- `stale_milestone_branch` — keep
- `tracked_runtime_files` — keep
**Verification:** `npm run test` passes. No test references `mergeSliceToMilestone`, `mergeSliceToMain`, or `ensureSliceBranch`.
### Phase 6: Migration + Backwards Compatibility
**Goal:** Existing projects with slice branches continue to work.
1. State derivation (`deriveState()`) continues to read `gsd/M001/S01` branch naming for legacy detection
2. On first run after upgrade:
- Detect existing slice branches
- Notify user: "GSD no longer creates slice branches. Existing branches are preserved but new work commits directly to the milestone branch."
- No forced migration — legacy branches are read-only context
3. Doctor check: `legacy_slice_branches` — informational, not auto-fix
4. Update `shouldUseWorktreeIsolation()` preference handling:
- `git.isolation: "worktree"` → default behavior (only option)
- `git.isolation: "branch"` → warning, treated as worktree
- Remove preference UI for isolation mode
**Verification:** Open a project with existing `gsd/M001/S01` branches. GSD reads state correctly, new work commits on milestone branch without slice branches.
## Stress Test Results
Validated by three independent models:
### Gemini 2.5 Pro — 6 Attack Vectors
| Attack | Severity | Mitigation |
|--------|----------|------------|
| Parallel milestone code conflict at squash-merge | Medium | `git rebase main` before squash. Rare in single-user. |
| SQLite desync after `git reset --hard` | Low | DB rebuilt from tracked markdown on startup (M001/S02 importers). |
| Ghost lock after SIGKILL | Low | Existing heartbeat lock detection handles this. |
| Squash merge loses bisect granularity | Low | Commit messages tag slices. Branch preservable if needed. |
| Disk space with multiple worktrees | Low | Single active milestone at a time. Immediate cleanup. |
| Plan-action atomicity gap (crash between write and commit) | Low | `handleAgentEnd` auto-commits. Sequential model simplifies recovery. |
### GPT-5.4 (Codex) — Codebase-Informed Analysis
- Confirmed `smartStage()` force-add already implements tracked-artifact intent
- Confirmed `resolveMainWorktreeRoot` (PR #487) contradicts this architecture
- Confirmed `.gsd/milestones/` partially tracked on `main` despite `.gitignore`
- Verdict: **Model is sound. Removes only accidental complexity.**
### GPT-5.4 (Codex) — Dissenting Opinion
Codex agreed on tracked artifacts and worktree-per-milestone, but pushed back on removing slice branches, calling it "a redesign, not a simplification." Specific concerns:
| Concern | Rebuttal |
|---------|----------|
| Crash recovery for orphaned slice branches disappears | The failure mode (orphaned branch needing merge) is caused by slice branches. Removing branches removes the failure. Sequential commits on one branch need no orphan recovery. |
| Concurrent edits to shared root docs (DECISIONS.md) from two terminals | Standard content conflict at squash-merge time. Not caused by or solved by slice branches. |
| Continuous integration via slice→milestone merges | In sequential single-user work, there's nothing to integrate against within the worktree. Pre-flight rebase before squash-merge is more direct. |
| Need a replacement slice-boundary primitive | Accepted: conventional commit tags (`feat(M001/S01):`) + optional git tags (`gsd/M001/S01-complete`) serve as boundaries. |
Codex's analysis confirms the tracked-artifact approach but recommends treating branchless as a deliberate redesign with explicit replacement primitives, not a casual deletion.
### Edge Case: Two Milestones Touching Same Source Files
Scenario: M001 and M002 both modify `src/auth.ts`. M001 squash-merges first.
Resolution: Before M002 squash-merges, rebase onto updated `main`:
```
cd .gsd/worktrees/M002
git fetch origin main
git rebase main
# Resolve any conflicts (code-only, never .gsd/)
# Then squash-merge
```
This is standard git workflow. GSD can automate the rebase step as a pre-merge check.
### Edge Case: Agent Crash Mid-Commit
Scenario: Power loss during `git commit` on the milestone branch.
Resolution: Git's internal journaling protects the object store. On restart:
- If commit completed: state is consistent
- If commit didn't complete: working directory has uncommitted changes, `handleAgentEnd` auto-commits on next dispatch
- No branch to be "stuck between" — single branch means no split-brain state
### Edge Case: User Edits Main While Worktree Active
Scenario: User makes manual commits on `main` while M001 worktree is active.
Resolution: Worktree is on `milestone/M001` branch, independent of `main`. Manual `main` commits don't affect the worktree. At squash-merge time, `git merge --squash` handles the divergence normally. If there's a conflict, it's resolved once.
## Metrics
### Before (Current)
| Metric | Value |
|--------|-------|
| Merge/conflict/branch code | 770+ lines across 4 files |
| Merge-related test files | 11 files |
| Branch types | 4 (main, milestone/*, gsd/*/*, worktree/*) |
| Merge strategies | 3 (--no-ff, --squash, conflict resolution) |
| Dispatch unit types with merge logic | 2 (complete-slice, fix-merge) |
| Isolation modes | 2 (branch, worktree) |
| Doctor git checks | 4 |
### After (Proposed)
| Metric | Value |
|--------|-------|
| Merge/conflict/branch code | ~50 lines (simplified `mergeMilestoneToMain` only) |
| Merge-related test files | 3-4 files (rewritten) |
| Branch types | 2 (main, milestone/*) |
| Merge strategies | 1 (--squash) |
| Dispatch unit types with merge logic | 0 |
| Isolation modes | 1 (worktree) |
| Doctor git checks | 3-4 (simplified) |
### Net Impact
- **~720 lines deleted** (net, after simplified replacements)
- **~7 test files deleted or consolidated**
- **2 branch types eliminated**
- **2 merge strategies eliminated**
- **1 dispatch unit type eliminated** (fix-merge)
- **1 isolation mode eliminated** (branch)
- **0 merge conflicts possible within a worktree**
## Dependencies
- **M001 (Memory Database):** The SQLite database (`gsd.db`) must remain gitignored. The M001/S02 importer layer rebuilds it from tracked markdown. This PRD's `.gitignore` update explicitly ignores `gsd.db`.
- **PR #487:** Must be closed. The `resolveMainWorktreeRoot` approach (sharing `.gsd/` across worktrees) contradicts tracked-artifact architecture.
## Open Questions
1. **Squash vs `--no-ff` for milestone→main merge?** Squash gives clean history on `main` but loses bisect granularity. `--no-ff` preserves granular commits but clutters `main`. Current proposal: squash (matching existing behavior), with option to preserve milestone branch for debugging.
2. **Should `worktrees/` move outside `.gsd/`?** Having worktrees inside `.gsd/` creates a nesting-doll pattern (worktree contains `.gsd/` which is inside `.gsd/worktrees/`). Relocating to `.gsd-worktrees/` or `~/.gsd/worktrees/<repo-hash>/` is cleaner but changes the filesystem layout. Recommendation: defer, address separately if it causes issues.
3. **Pre-flight rebase automation?** Before milestone→main squash-merge, should GSD automatically `git rebase main`? Gemini recommends yes. Risk: rebase can fail with conflicts, adding a code path. Recommendation: implement as a doctor check ("milestone branch is behind main by N commits") with manual resolution, automate later if needed.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,20 @@
import {
type GenerateContentConfig,
type GenerateContentParameters,
// Lazy-loaded: Google GenAI SDK (~186ms) is imported on first use, not at startup.
// This avoids penalizing users who don't use Google models.
import type {
GenerateContentConfig,
GenerateContentParameters,
GoogleGenAI,
type ThinkingConfig,
ThinkingConfig,
} from "@google/genai";
let _GoogleGenAIClass: typeof GoogleGenAI | undefined;
async function getGoogleGenAIClass(): Promise<typeof GoogleGenAI> {
if (!_GoogleGenAIClass) {
const mod = await import("@google/genai");
_GoogleGenAIClass = mod.GoogleGenAI;
}
return _GoogleGenAIClass;
}
import { getEnvApiKey } from "../env-api-keys.js";
import { calculateCost } from "../models.js";
import type {
@ -73,7 +84,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions>
try {
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const client = createClient(model, apiKey, options?.headers);
const client = await createClient(model, apiKey, options?.headers);
let params = buildParams(model, context, options);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
@ -308,11 +319,11 @@ export const streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleSt
} satisfies GoogleOptions);
};
function createClient(
async function createClient(
model: Model<"google-generative-ai">,
apiKey?: string,
optionsHeaders?: Record<string, string>,
): GoogleGenAI {
): Promise<GoogleGenAI> {
const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {};
if (model.baseUrl) {
httpOptions.baseUrl = model.baseUrl;
@ -322,7 +333,8 @@ function createClient(
httpOptions.headers = { ...model.headers, ...optionsHeaders };
}
return new GoogleGenAI({
const GoogleGenAIClass = await getGoogleGenAIClass();
return new GoogleGenAIClass({
apiKey,
httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined,
});

View file

@ -1,4 +1,6 @@
import { Mistral } from "@mistralai/mistralai";
// Lazy-loaded: Mistral SDK (~369ms) is imported on first use, not at startup.
// This avoids penalizing users who don't use Mistral models.
import type { Mistral } from "@mistralai/mistralai";
import type { RequestOptions } from "@mistralai/mistralai/lib/sdks.js";
import type {
ChatCompletionStreamRequest,
@ -7,6 +9,15 @@ import type {
ContentChunk,
FunctionTool,
} from "@mistralai/mistralai/models/components/index.js";
let _MistralClass: typeof Mistral | undefined;
async function getMistralClass(): Promise<typeof Mistral> {
if (!_MistralClass) {
const mod = await import("@mistralai/mistralai");
_MistralClass = mod.Mistral;
}
return _MistralClass;
}
import { getEnvApiKey } from "../env-api-keys.js";
import { calculateCost } from "../models.js";
import type {
@ -61,7 +72,8 @@ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptio
}
// Intentionally per-request: avoids shared SDK mutable state across concurrent consumers.
const mistral = new Mistral({
const MistralSDK = await getMistralClass();
const mistral = new MistralSDK({
apiKey,
serverURL: model.baseUrl,
});

View file

@ -1354,6 +1354,9 @@ export class AgentSession {
this._disconnectFromAgent();
await this.abort();
this.agent.reset();
// Update cwd to current process directory — auto-mode may have chdir'd
// into a worktree since the original session was created.
this._cwd = process.cwd();
this.sessionManager.newSession({ parentSession: options?.parentSession });
this.agent.sessionId = this.sessionManager.getSessionId();
this._steeringMessages = [];

View file

@ -369,22 +369,26 @@ export async function loadExtensionFromFactory(
/**
* Load extensions from paths.
*
* Extensions are loaded in parallel to reduce wall-clock time (~30-50% faster
* than sequential loading for I/O-bound jiti compilation).
*/
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
const resolvedEventBus = eventBus ?? createEventBus();
const runtime = createExtensionRuntime();
for (const extPath of paths) {
const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime);
const results = await Promise.all(
paths.map((extPath) => loadExtension(extPath, cwd, resolvedEventBus, runtime)),
);
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
for (let i = 0; i < results.length; i++) {
const { extension, error } = results[i];
if (error) {
errors.push({ path: extPath, error });
continue;
}
if (extension) {
errors.push({ path: paths[i], error });
} else if (extension) {
extensions.push(extension);
}
}

View file

@ -1,7 +1,52 @@
#!/usr/bin/env node
// GSD Startup Loader
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { fileURLToPath } from 'url'
import { dirname, resolve, join, delimiter } from 'path'
import { existsSync, readFileSync, readdirSync, mkdirSync, symlinkSync } from 'fs'
// Fast-path: handle --version/-v and --help/-h before importing any heavy
// dependencies. This avoids loading the entire pi-coding-agent barrel import
// (~1s) just to print a version string.
const gsdRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const args = process.argv.slice(2)
const firstArg = args[0]
if (firstArg === '--version' || firstArg === '-v') {
try {
const pkg = JSON.parse(readFileSync(join(gsdRoot, 'package.json'), 'utf-8'))
process.stdout.write((pkg.version || '0.0.0') + '\n')
} catch {
process.stdout.write('0.0.0\n')
}
process.exit(0)
}
if (firstArg === '--help' || firstArg === '-h') {
let version = '0.0.0'
try {
const pkg = JSON.parse(readFileSync(join(gsdRoot, 'package.json'), 'utf-8'))
version = pkg.version || version
} catch { /* ignore */ }
process.stdout.write(`GSD v${version} — Get Shit Done\n\n`)
process.stdout.write('Usage: gsd [options] [message...]\n\n')
process.stdout.write('Options:\n')
process.stdout.write(' --mode <text|json|rpc> Output mode (default: interactive)\n')
process.stdout.write(' --print, -p Single-shot print mode\n')
process.stdout.write(' --continue, -c Resume the most recent session\n')
process.stdout.write(' --model <id> Override model (e.g. claude-opus-4-6)\n')
process.stdout.write(' --no-session Disable session persistence\n')
process.stdout.write(' --extension <path> Load additional extension\n')
process.stdout.write(' --tools <a,b,c> Restrict available tools\n')
process.stdout.write(' --list-models [search] List available models and exit\n')
process.stdout.write(' --version, -v Print version and exit\n')
process.stdout.write(' --help, -h Print this help and exit\n')
process.stdout.write('\nSubcommands:\n')
process.stdout.write(' config Re-run the setup wizard\n')
process.stdout.write(' update Update GSD to the latest version\n')
process.exit(0)
}
import { agentDir, appRoot } from './app-paths.js'
import { serializeBundledExtensionPaths } from './bundled-extension-paths.js'
import { renderLogo } from './logo.js'
@ -46,7 +91,6 @@ process.env.GSD_CODING_AGENT_DIR = agentDir
// Without this, extensions (e.g. browser-tools) can't resolve dependencies like
// `playwright` because jiti resolves modules from pi-coding-agent's location, not gsd's.
// Prepending gsd's node_modules to NODE_PATH fixes this for all extensions.
const gsdRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const gsdNodeModules = join(gsdRoot, 'node_modules')
process.env.NODE_PATH = [gsdNodeModules, process.env.NODE_PATH]
.filter(Boolean)
@ -72,9 +116,8 @@ process.env.GSD_BIN_PATH = process.argv[1]
// GSD_WORKFLOW_PATH — absolute path to bundled GSD-WORKFLOW.md, used by patched gsd extension
// when dispatching workflow prompts. Prefers dist/resources/ (stable, set at build time)
// over src/resources/ (live working tree) — see resource-loader.ts for rationale.
const loaderPackageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const distRes = join(loaderPackageRoot, 'dist', 'resources')
const srcRes = join(loaderPackageRoot, 'src', 'resources')
const distRes = join(gsdRoot, 'dist', 'resources')
const srcRes = join(gsdRoot, 'src', 'resources')
const resourcesDir = existsSync(distRes) ? distRes : srcRes
process.env.GSD_WORKFLOW_PATH = join(resourcesDir, 'GSD-WORKFLOW.md')
@ -116,8 +159,11 @@ process.env.GSD_BUNDLED_EXTENSION_PATHS = serializeBundledExtensionPaths(discove
// Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars for all outbound requests.
// pi-coding-agent's cli.ts sets this, but GSD bypasses that entry point — so we
// must set it here before any SDK clients are created.
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'
setGlobalDispatcher(new EnvHttpProxyAgent())
// Lazy-load undici (~200ms) only when proxy env vars are actually set.
if (process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) {
const { EnvHttpProxyAgent, setGlobalDispatcher } = await import('undici')
setGlobalDispatcher(new EnvHttpProxyAgent())
}
// Ensure workspace packages are linked before importing cli.js (which imports @gsd/*).
// npm postinstall handles this normally, but npx --ignore-scripts skips postinstall.

View file

@ -126,21 +126,29 @@ export function getNewerManagedResourceVersion(agentDir: string, currentVersion:
/**
* Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
*
* - extensions/ ~/.gsd/agent/extensions/ (always overwrite ensures updates ship on next launch)
* - agents/ ~/.gsd/agent/agents/ (always overwrite)
* - skills/ ~/.gsd/agent/skills/ (always overwrite)
* - extensions/ ~/.gsd/agent/extensions/ (overwrite when version changes)
* - agents/ ~/.gsd/agent/agents/ (overwrite when version changes)
* - skills/ ~/.gsd/agent/skills/ (overwrite when version changes)
* - GSD-WORKFLOW.md is read directly from bundled path via GSD_WORKFLOW_PATH env var
*
* Always-overwrite ensures `npm update -g @glittercowboy/gsd` takes effect immediately.
* User customizations should go in ~/.gsd/agent/extensions/ subdirs with unique names,
* not by editing the gsd-managed files.
* Skips the copy when the managed-resources.json version matches the current
* GSD version, avoiding ~128ms of synchronous cpSync on every startup.
* After `npm update -g @glittercowboy/gsd`, versions will differ and the
* copy runs once to land the new resources.
*
* Inspectable: `ls ~/.gsd/agent/extensions/`
*/
export function initResources(agentDir: string): void {
mkdirSync(agentDir, { recursive: true })
// Sync extensions — always overwrite so updates land on next launch
// Skip resource sync when versions match — saves ~128ms of cpSync per launch
const currentVersion = getBundledGsdVersion()
const managedVersion = readManagedResourceVersion(agentDir)
if (managedVersion && managedVersion === currentVersion) {
return
}
// Sync extensions — overwrite so updates land on next launch
const destExtensions = join(agentDir, 'extensions')
cpSync(bundledExtensionsDir, destExtensions, { recursive: true, force: true })
@ -151,7 +159,7 @@ export function initResources(agentDir: string): void {
cpSync(srcAgents, destAgents, { recursive: true, force: true })
}
// Sync skills — always overwrite so updates land on next launch
// Sync skills — overwrite so updates land on next launch
const destSkills = join(agentDir, 'skills')
const srcSkills = join(resourcesDir, 'skills')
if (existsSync(srcSkills)) {

View file

@ -6,7 +6,7 @@
* manages create, enter, detect, and teardown for auto-mode worktrees.
*/
import { existsSync, readFileSync, realpathSync, utimesSync } from "node:fs";
import { existsSync, cpSync, readFileSync, realpathSync, utimesSync } from "node:fs";
import { join, resolve } from "node:path";
import { execSync, execFileSync } from "node:child_process";
import {
@ -14,20 +14,9 @@ import {
removeWorktree,
worktreePath,
} from "./worktree-manager.js";
import {
detectWorktreeName,
getSliceBranchName,
} from "./worktree.js";
import {
MergeConflictError,
inferCommitType,
} from "./git-service.js";
import type { MergeSliceResult } from "./git-service.js";
import { recoverCheckout, withMergeHeal } from "./git-self-heal.js";
import {
nativeBranchExists,
nativeCommitCountBetween,
} from "./native-git-bridge.js";
import { parseRoadmap } from "./files.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
@ -36,48 +25,6 @@ import { loadEffectiveGSDPreferences } from "./preferences.js";
/** Original project root before chdir into auto-worktree. */
let originalBase: string | null = null;
// ─── Isolation Resolver ────────────────────────────────────────────────────
/**
* Determine whether auto-mode should use worktree isolation.
*
* Resolution order:
* 1. Explicit git.isolation preference -> return (isolation === "worktree")
* 2. Legacy detection: if gsd branches exist -> return false (branch mode)
* 3. Default: return true (worktree mode for new projects)
*/
export function shouldUseWorktreeIsolation(basePath: string, overridePrefs?: { isolation?: string }): boolean {
const prefs = overridePrefs ?? loadEffectiveGSDPreferences()?.preferences?.git;
if (prefs?.isolation) {
return prefs.isolation === "worktree";
}
// Legacy detection: check for existing gsd/*/* branches (branch-per-slice pattern)
try {
// Use unquoted glob pattern — single quotes are not interpreted by cmd.exe on Windows,
// causing the pattern to match literally instead of as a glob.
const output = execSync("git branch --list gsd/*/*", {
cwd: basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
}).trim();
if (output) return false; // Legacy branch-per-slice project
} catch {
// If git command fails, default to worktree
}
return true; // New project default
}
/**
* Resolve the merge_to_main preference value.
* Returns "milestone" (default) or "slice".
*/
export function getMergeToMainMode(): "milestone" | "slice" {
const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
return prefs?.merge_to_main ?? "milestone";
}
// ─── Git Helpers (local, mirrors worktree-command.ts pattern) ──────────────
function resolveGitHeadPath(dir: string): string | null {
@ -143,6 +90,14 @@ export function autoWorktreeBranch(milestoneId: string): string {
export function createAutoWorktree(basePath: string, milestoneId: string): string {
const branch = autoWorktreeBranch(milestoneId);
const info = createWorktree(basePath, milestoneId, { branch });
// Copy .gsd/ planning artifacts from the source repo into the new worktree.
// Worktrees are fresh git checkouts — untracked files don't carry over.
// Planning artifacts may be untracked if the project's .gitignore had a
// blanket .gsd/ rule (pre-v2.14.0). Without this copy, auto-mode loops
// on plan-slice because the plan file doesn't exist in the worktree.
copyPlanningArtifacts(basePath, info.path);
const previousCwd = process.cwd();
try {
@ -160,6 +115,36 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
return info.path;
}
/**
* Copy .gsd/ planning artifacts from source repo to a new worktree.
* Copies milestones/, DECISIONS.md, REQUIREMENTS.md, PROJECT.md, QUEUE.md.
* Skips runtime files (auto.lock, metrics.json, etc.) and the worktrees/ dir.
* Best-effort failures are non-fatal since auto-mode can recreate artifacts.
*/
function copyPlanningArtifacts(srcBase: string, wtPath: string): void {
const srcGsd = join(srcBase, ".gsd");
const dstGsd = join(wtPath, ".gsd");
if (!existsSync(srcGsd)) return;
// Copy milestones/ directory (planning files, roadmaps, plans, research)
const srcMilestones = join(srcGsd, "milestones");
if (existsSync(srcMilestones)) {
try {
cpSync(srcMilestones, join(dstGsd, "milestones"), { recursive: true, force: true });
} catch { /* non-fatal */ }
}
// Copy top-level planning files
for (const file of ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "QUEUE.md"]) {
const src = join(srcGsd, file);
if (existsSync(src)) {
try {
cpSync(src, join(dstGsd, file), { force: true });
} catch { /* non-fatal */ }
}
}
}
/**
* Teardown an auto-worktree: chdir back to original base, then remove
* the worktree and its branch.
@ -238,117 +223,6 @@ export function getAutoWorktreeOriginalBase(): string | null {
return originalBase;
}
// ─── Merge Slice -> Milestone ───────────────────────────────────────────────
/**
* Merge a completed slice branch into the milestone branch via `--no-ff`.
*
* Worktree-mode merge: `.gsd/` is local to the worktree (not tracked in
* git), so there are zero `.gsd/` conflict resolution concerns. No runtime
* exclusion untracking, no `--theirs` checkout, no snapshot creation.
*
* On conflict: throws MergeConflictError with conflicted file list.
* On success: deletes the slice branch and returns MergeSliceResult.
*/
export function mergeSliceToMilestone(
basePath: string,
milestoneId: string,
sliceId: string,
sliceTitle: string,
): MergeSliceResult {
if (!isInAutoWorktree(basePath)) {
throw new Error("mergeSliceToMilestone called outside auto-worktree");
}
const cwd = process.cwd();
const milestoneBranch = autoWorktreeBranch(milestoneId);
const worktreeName = detectWorktreeName(cwd);
const sliceBranch = getSliceBranchName(milestoneId, sliceId, worktreeName);
// Verify slice branch exists
if (!nativeBranchExists(cwd, sliceBranch)) {
throw new Error(`Slice branch "${sliceBranch}" does not exist`);
}
// Verify slice has commits ahead of milestone branch
const commitCount = nativeCommitCountBetween(cwd, milestoneBranch, sliceBranch);
if (commitCount === 0) {
throw new Error(
`Slice branch "${sliceBranch}" has no commits ahead of "${milestoneBranch}"`,
);
}
// Checkout milestone branch (with self-healing reset)
recoverCheckout(cwd, milestoneBranch);
// Build rich commit message (replicates GitServiceImpl.buildRichCommitMessage format)
const commitType = inferCommitType(sliceTitle);
const subject = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
let message = subject;
try {
const logOutput = execSync(
`git log --oneline --format=%s ${milestoneBranch}..${sliceBranch}`,
{ cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
).trim();
if (logOutput) {
const subjects = logOutput.split("\n").filter(Boolean);
const MAX_ENTRIES = 20;
const truncated = subjects.length > MAX_ENTRIES;
const displayed = truncated ? subjects.slice(0, MAX_ENTRIES) : subjects;
const taskLines = displayed.map(s => `- ${s}`).join("\n");
const truncationLine = truncated
? `\n- ... and ${subjects.length - MAX_ENTRIES} more`
: "";
message = `${subject}\n\nTasks:\n${taskLines}${truncationLine}\n\nBranch: ${sliceBranch}`;
}
} catch {
// Fall back to subject-only message
}
// Merge --no-ff (with self-healing retry for transient failures)
try {
withMergeHeal(cwd, () => {
execFileSync("git", ["merge", "--no-ff", "-m", message, sliceBranch], {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
});
} catch (err) {
if (err instanceof MergeConflictError) {
// Re-throw with correct branch context
throw new MergeConflictError(
err.conflictedFiles,
err.strategy,
sliceBranch,
milestoneBranch,
);
}
throw err;
}
// Delete slice branch
let deletedBranch = false;
try {
execSync(`git branch -d ${sliceBranch}`, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
deletedBranch = true;
} catch {
// Branch deletion is best-effort
}
return {
branch: sliceBranch,
mergedCommitMessage: message,
deletedBranch,
};
}
// ─── Merge Milestone -> Main ───────────────────────────────────────────────
/**
@ -416,8 +290,12 @@ export function mergeMilestoneToMain(
const prefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
const mainBranch = prefs.main_branch || "main";
// 5. Checkout main (with self-healing reset)
recoverCheckout(originalBasePath_, mainBranch);
// 5. Checkout main
execSync(`git checkout ${mainBranch}`, {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
// 6. Build rich commit message
const milestoneTitle = roadmap.title.replace(/^M\d+:\s*/, "").trim() || milestoneId;
@ -429,26 +307,29 @@ export function mergeMilestoneToMain(
}
const commitMessage = subject + body;
// 7. Squash merge (with self-healing retry for transient failures)
// 7. Squash merge
try {
withMergeHeal(originalBasePath_, () => {
execSync(`git merge --squash ${milestoneBranch}`, {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
execSync(`git merge --squash ${milestoneBranch}`, {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
} catch (err) {
if (err instanceof MergeConflictError) {
// Re-throw with correct branch context
throw new MergeConflictError(
err.conflictedFiles,
err.strategy,
milestoneBranch,
mainBranch,
);
} catch (mergeErr) {
// Check for real conflicts
try {
const conflictOutput = execSync("git diff --name-only --diff-filter=U", {
cwd: originalBasePath_,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
if (conflictOutput) {
const conflictedFiles = conflictOutput.split("\n").filter(Boolean);
throw new MergeConflictError(conflictedFiles, "squash", milestoneBranch, mainBranch);
}
} catch (diffErr) {
if (diffErr instanceof MergeConflictError) throw diffErr;
}
// Possibly "already up to date" -- fall through to commit which will handle nothing-to-commit
// No conflicts detected — possibly "already up to date", fall through to commit
}
// 8. Commit (handle nothing-to-commit gracefully)

View file

@ -17,7 +17,7 @@ import type {
} from "@gsd/pi-coding-agent";
import { deriveState, invalidateStateCache } from "./state.js";
import type { GSDState } from "./types.js";
import type { BudgetEnforcementMode, GSDState } from "./types.js";
import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary, getManifestStatus, clearParseCache } from "./files.js";
export { inlinePriorMilestoneSummary };
import type { UatType } from "./files.js";
@ -42,6 +42,7 @@ import {
writeUnitRuntimeRecord,
} from "./unit-runtime.js";
import { resolveAutoSupervisorConfig, resolveModelForUnit, resolveModelWithFallbacksForUnit, resolveSkillDiscoveryMode, loadEffectiveGSDPreferences } from "./preferences.js";
import { sendDesktopNotification } from "./notifications.js";
import type { GSDPreferences } from "./preferences.js";
import {
checkPostUnitHooks,
@ -69,22 +70,20 @@ import {
getProjectTotals, formatCost, formatTokenCount,
} from "./metrics.js";
import { dirname, join } from "node:path";
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
import { sep as pathSep } from "node:path";
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, renameSync, statSync } from "node:fs";
import { execSync, execFileSync } from "node:child_process";
import {
autoCommitCurrentBranch,
captureIntegrationBranch,
ensureSliceBranch,
detectWorktreeName,
getCurrentBranch,
getMainBranch,
MergeConflictError,
parseSliceBranch,
setActiveMilestoneId,
switchToMain,
mergeSliceToMain,
} from "./worktree.js";
import { GitServiceImpl, runGit } from "./git-service.js";
import { nativeCommitCountBetween } from "./native-git-bridge.js";
import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js";
import { formatGitError } from "./git-self-heal.js";
import {
@ -94,10 +93,7 @@ import {
isInAutoWorktree,
getAutoWorktreePath,
getAutoWorktreeOriginalBase,
mergeSliceToMilestone,
mergeMilestoneToMain,
shouldUseWorktreeIsolation,
getMergeToMainMode,
} from "./auto-worktree.js";
import type { GitPreferences } from "./git-service.js";
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
@ -122,7 +118,10 @@ function persistCompletedKey(base: string, key: string): void {
} catch { /* corrupt file — start fresh */ }
if (!keys.includes(key)) {
keys.push(key);
writeFileSync(file, JSON.stringify(keys), "utf-8");
// Atomic write: tmp file + rename prevents partial writes on crash
const tmpFile = file + ".tmp";
writeFileSync(tmpFile, JSON.stringify(keys), "utf-8");
renameSync(tmpFile, file);
}
}
@ -188,6 +187,7 @@ let currentUnit: { type: string; id: string; startedAt: number } | null = null;
/** Track current milestone to detect transitions */
let currentMilestoneId: string | null = null;
let lastBudgetAlertLevel: BudgetAlertLevel = 0;
/** Model the user had selected before auto-mode started */
let originalModelId: string | null = null;
@ -209,6 +209,31 @@ const DISPATCH_GAP_TIMEOUT_MS = 5_000; // 5 seconds
/** SIGTERM handler registered while auto-mode is active — cleared on stop/pause. */
let _sigtermHandler: (() => void) | null = null;
type BudgetAlertLevel = 0 | 75 | 90 | 100;
export function getBudgetAlertLevel(budgetPct: number): BudgetAlertLevel {
if (budgetPct >= 1.0) return 100;
if (budgetPct >= 0.90) return 90;
if (budgetPct >= 0.75) return 75;
return 0;
}
export function getNewBudgetAlertLevel(previousLevel: BudgetAlertLevel, budgetPct: number): BudgetAlertLevel | null {
const currentLevel = getBudgetAlertLevel(budgetPct);
if (currentLevel === 0 || currentLevel <= previousLevel) return null;
return currentLevel;
}
export function getBudgetEnforcementAction(
enforcement: BudgetEnforcementMode,
budgetPct: number,
): "none" | "warn" | "pause" | "halt" {
if (budgetPct < 1.0) return "none";
if (enforcement === "halt") return "halt";
if (enforcement === "pause") return "pause";
return "warn";
}
/**
* Register a SIGTERM handler that clears the lock file and exits cleanly.
* Captures the active base path at registration time so the handler
@ -337,10 +362,12 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void
// Auto-mode is active but no unit was dispatched — the state machine stalled.
// Re-derive state and attempt a fresh dispatch.
ctx.ui.notify(
"Dispatch gap detected — no unit dispatched after previous unit completed. Re-evaluating state.",
"warning",
);
if (verbose) {
ctx.ui.notify(
"Dispatch gap detected — re-evaluating state.",
"info",
);
}
try {
await dispatchNextUnit(ctx, pi);
@ -360,6 +387,8 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
clearUnitTimeout();
if (basePath) clearLock(basePath);
clearSkillSnapshot();
_dispatching = false;
_skipDepth = 0;
// Remove SIGTERM handler registered at auto-mode start
deregisterSigtermHandler();
@ -408,6 +437,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
stepMode = false;
unitDispatchCount.clear();
unitRecoveryCount.clear();
lastBudgetAlertLevel = 0;
unitLifetimeDispatches.clear();
currentUnit = null;
currentMilestoneId = null;
@ -468,136 +498,41 @@ async function selfHealRuntimeRecords(base: string, ctx: ExtensionContext): Prom
const { listUnitRuntimeRecords } = await import("./unit-runtime.js");
const records = listUnitRuntimeRecords(base);
let healed = 0;
const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
const now = Date.now();
for (const record of records) {
const { unitType, unitId } = record;
const artifactPath = resolveExpectedArtifactPath(unitType, unitId, base);
// Case 1: Artifact exists — unit completed but closeout didn't finish
if (artifactPath && existsSync(artifactPath)) {
// Artifact exists — unit completed but closeout didn't finish.
clearUnitRuntimeRecord(base, unitType, unitId);
// Also persist completion key if missing
const key = `${unitType}/${unitId}`;
if (!completedKeySet.has(key)) {
persistCompletedKey(base, key);
completedKeySet.add(key);
}
healed++;
continue;
}
// Case 2: No artifact but record is stale (dispatched > 1h ago, process crashed)
const age = now - (record.startedAt ?? 0);
if (record.phase === "dispatched" && age > STALE_THRESHOLD_MS) {
clearUnitRuntimeRecord(base, unitType, unitId);
healed++;
continue;
}
}
if (healed > 0) {
ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s) with completed artifacts.`, "info");
ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s).`, "info");
}
} catch {
// Non-fatal — self-heal should never block auto-mode start
}
}
/**
* Startup check: scan for orphaned completed slice branches and merge them.
*
* An orphaned completed slice branch is a `gsd/MID/SID` branch where the slice
* is marked done in the roadmap (on that branch) but hasn't been squash-merged
* to main yet. This happens when `complete-slice` succeeds and commits on the
* slice branch, but the subsequent merge to main is interrupted (crash, timeout,
* Ctrl+C, merge conflict that wasn't auto-resolved).
*
* Without this check, GSD gets stuck in an infinite loop: `deriveState()` on
* main sees no slice artifacts wants research-slice idempotency key removed
* (artifact not on main) ensurePreconditions switches branch merge guard
* merges re-derives repeats.
*/
async function mergeOrphanedSliceBranches(
base: string,
ctx: Pick<ExtensionContext, "ui">,
): Promise<void> {
// List all local gsd/<MID>/<SID> branches (non-worktree pattern).
// Use execFileSync (not runGit/execSync) to avoid shell glob-expanding gsd/*/*
// and to avoid shell syntax errors from %(refname:short) on /bin/sh.
let branchListRaw = "";
try {
branchListRaw = execFileSync(
"git",
["branch", "--list", "gsd/*/*", "--format=%(refname:short)"],
{ cwd: base, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
).trim();
} catch {
return; // no slice branches or git unavailable
}
if (!branchListRaw) return;
const branches = branchListRaw.split("\n").map(b => b.trim()).filter(Boolean);
for (const branch of branches) {
const parsed = parseSliceBranch(branch);
// Skip worktree-namespaced branches — those are managed by the worktree
// manager and should not be merged by the main-tree auto-mode.
if (!parsed || parsed.worktreeName) continue;
const { milestoneId, sliceId } = parsed;
// Ensure Git operations for this branch use the correct milestone context.
setActiveMilestoneId(base, milestoneId);
// Skip if already merged (no commits ahead of main)
const mainBranch = getMainBranch(base);
const aheadCount = nativeCommitCountBetween(base, mainBranch, branch);
if (aheadCount === 0) continue;
// Read the roadmap from the slice branch to check if the slice is done.
// relMilestoneFile resolves the actual directory name on disk (handles
// milestone directories with title suffixes like "M007 Payment System").
const roadmapRelPath = relMilestoneFile(base, milestoneId, "ROADMAP");
let roadmapContent: string | undefined;
try {
roadmapContent = execFileSync(
"git",
["-C", base, "show", `${branch}:${roadmapRelPath}`],
{ encoding: "utf8" },
);
} catch {
roadmapContent = undefined;
}
if (!roadmapContent) continue;
const roadmap = parseRoadmap(roadmapContent);
const sliceEntry = roadmap.slices.find(s => s.id === sliceId);
if (!sliceEntry?.done) continue;
// Orphaned completed branch detected — merge it to main now.
ctx.ui.notify(
`Orphaned completed slice branch detected: ${branch}. Merging to main before dispatch...`,
"info",
);
try {
let mergeResult;
if (isInAutoWorktree(base) && getMergeToMainMode() !== "slice") {
mergeResult = mergeSliceToMilestone(
base, milestoneId, sliceId, sliceEntry.title || sliceId,
);
} else {
switchToMain(base);
mergeResult = mergeSliceToMain(
base, milestoneId, sliceId, sliceEntry.title || sliceId,
);
}
ctx.ui.notify(
`Merged orphaned branch ${mergeResult.branch}${mainBranch}.`,
"info",
);
} catch (error) {
if (error instanceof MergeConflictError) {
// Abort and reset the incomplete merge so auto-mode can still start cleanly.
runGit(base, ["merge", "--abort"], { allowFailure: true });
runGit(base, ["reset", "--hard", "HEAD"], { allowFailure: true });
ctx.ui.notify(
`Orphaned branch ${branch} has merge conflicts — resolve manually and restart.\nConflicts in: ${error.conflictedFiles.join(", ")}`,
"error",
);
// Stop processing further branches after a conflict to avoid
// leaving the repo in a partially-merged state.
return;
}
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(
`Failed to merge orphaned branch ${branch}: ${message}`,
"warning",
);
}
}
}
export async function startAuto(
ctx: ExtensionCommandContext,
pi: ExtensionAPI,
@ -625,7 +560,8 @@ export async function startAuto(
if (currentMilestoneId) setActiveMilestoneId(base, currentMilestoneId);
// ── Auto-worktree: re-enter worktree on resume if not already inside ──
if (currentMilestoneId && originalBasePath && !isInAutoWorktree(basePath) && shouldUseWorktreeIsolation(originalBasePath)) {
// Skip if already inside a worktree (manual /worktree) to prevent nesting.
if (currentMilestoneId && originalBasePath && !isInAutoWorktree(basePath) && !detectWorktreeName(basePath) && !detectWorktreeName(originalBasePath)) {
try {
const existingWtPath = getAutoWorktreePath(originalBasePath, currentMilestoneId);
if (existingWtPath) {
@ -673,7 +609,7 @@ export async function startAuto(
return;
}
// Ensure git repo exists — GSD needs it for branch-per-slice
// Ensure git repo exists — GSD needs it for worktree isolation
try {
execSync("git rev-parse --git-dir", { cwd: base, stdio: "pipe" });
} catch {
@ -762,6 +698,7 @@ export async function startAuto(
basePath = base;
unitDispatchCount.clear();
unitRecoveryCount.clear();
lastBudgetAlertLevel = 0;
unitLifetimeDispatches.clear();
completedKeySet.clear();
loadPersistedKeys(base, completedKeySet);
@ -788,8 +725,22 @@ export async function startAuto(
// ── Auto-worktree: create or enter worktree for the active milestone ──
// Store the original project root before any chdir so we can restore on stop.
// Skip if already inside a worktree (manual /worktree or another auto-worktree)
// to prevent nested worktree creation.
originalBasePath = base;
if (currentMilestoneId && shouldUseWorktreeIsolation(base)) {
const isUnderGsdWorktrees = (p: string): boolean => {
// Prevent creating nested auto-worktrees when running from within any
// `.gsd/worktrees/...` directory (including manual worktrees).
const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
if (p.includes(marker)) {
return true;
}
const worktreesSuffix = `${pathSep}.gsd${pathSep}worktrees`;
return p.endsWith(worktreesSuffix);
};
if (currentMilestoneId && !detectWorktreeName(base) && !isUnderGsdWorktrees(base)) {
try {
const existingWtPath = getAutoWorktreePath(base, currentMilestoneId);
if (existingWtPath) {
@ -855,15 +806,46 @@ export async function startAuto(
);
}
// Merge any orphaned completed slice branches before dispatching.
// Orphaned branches arise when complete-slice commits on the slice branch
// but the merge to main is interrupted (crash, timeout, Ctrl+C).
// Without this check, GSD enters an infinite "Skipping ... Advancing" loop.
await mergeOrphanedSliceBranches(base, ctx);
// Self-heal: clear stale runtime records where artifacts already exist
await selfHealRuntimeRecords(base, ctx);
// Self-heal: remove stale .git/index.lock from prior crash.
// A stale lock file blocks all git operations (commit, merge, checkout).
// Only remove if older than 60 seconds (not from a concurrent process).
try {
const gitLockFile = join(base, ".git", "index.lock");
if (existsSync(gitLockFile)) {
const lockAge = Date.now() - statSync(gitLockFile).mtimeMs;
if (lockAge > 60_000) {
unlinkSync(gitLockFile);
ctx.ui.notify("Removed stale .git/index.lock from prior crash.", "info");
}
}
} catch { /* non-fatal */ }
// Pre-flight: validate milestone queue for multi-milestone runs.
// Warn about issues that will cause auto-mode to pause or block.
try {
const msDir = join(base, ".gsd", "milestones");
if (existsSync(msDir)) {
const milestoneIds = readdirSync(msDir, { withFileTypes: true })
.filter(d => d.isDirectory() && /^M\d{3}/.test(d.name))
.map(d => d.name.match(/^(M\d{3})/)?.[1] ?? d.name);
if (milestoneIds.length > 1) {
const issues: string[] = [];
for (const id of milestoneIds) {
const draft = resolveMilestoneFile(base, id, "CONTEXT-DRAFT");
if (draft) issues.push(`${id}: has CONTEXT-DRAFT.md (will pause for discussion)`);
}
if (issues.length > 0) {
ctx.ui.notify(`Pre-flight: ${milestoneIds.length} milestones queued.\n${issues.map(i => `${i}`).join("\n")}`, "warning");
} else {
ctx.ui.notify(`Pre-flight: ${milestoneIds.length} milestones queued. All have full context.`, "info");
}
}
}
} catch { /* non-fatal — pre-flight should never block auto-mode */ }
// Dispatch the first unit
await dispatchNextUnit(ctx, pi);
}
@ -1230,7 +1212,6 @@ function unitVerb(unitType: string): string {
case "replan-slice": return "replanning";
case "reassess-roadmap": return "reassessing";
case "run-uat": return "running UAT";
case "fix-merge": return "resolving conflicts";
default: return unitType;
}
}
@ -1247,7 +1228,6 @@ function unitPhaseLabel(unitType: string): string {
case "replan-slice": return "REPLAN";
case "reassess-roadmap": return "REASSESS";
case "run-uat": return "UAT";
case "fix-merge": return "MERGE-FIX";
default: return unitType.toUpperCase();
}
}
@ -1271,7 +1251,6 @@ function peekNext(unitType: string, state: GSDState): string {
case "replan-slice": return `re-execute ${sid}`;
case "reassess-roadmap": return "advance to next slice";
case "run-uat": return "reassess roadmap";
case "fix-merge": return "continue merge";
default: return "";
}
}
@ -1543,17 +1522,43 @@ function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks
// ─── Core Loop ────────────────────────────────────────────────────────────────
/** Tracks recursive skip depth to prevent TUI freeze on cascading completed-unit skips */
let _skipDepth = 0;
const MAX_SKIP_DEPTH = 20;
/** Reentrancy guard for dispatchNextUnit itself (not just handleAgentEnd).
* Prevents concurrent dispatch from watchdog timers, step wizard, and direct calls
* that bypass the _handlingAgentEnd guard. Recursive calls (from skip paths) are
* allowed via _skipDepth > 0. */
let _dispatching = false;
async function dispatchNextUnit(
ctx: ExtensionContext,
pi: ExtensionAPI,
): Promise<void> {
if (!active || !cmdCtx) {
if (active && !cmdCtx) {
ctx.ui.notify("Auto-mode dispatch failed: no command context. Run /gsd auto to restart.", "error");
ctx.ui.notify("Auto-mode session expired. Run /gsd auto to restart.", "info");
}
return;
}
// Reentrancy guard: allow recursive calls from skip paths (_skipDepth > 0)
// but block concurrent external calls (watchdog, step wizard, etc.)
if (_dispatching && _skipDepth === 0) {
return; // Another dispatch is in progress — bail silently
}
_dispatching = true;
try {
// Recursion depth guard: when many units are skipped in sequence (e.g., after
// crash recovery with 10+ completed units), recursive dispatchNextUnit calls
// can freeze the TUI or overflow the stack. Yield generously after MAX_SKIP_DEPTH.
if (_skipDepth > MAX_SKIP_DEPTH) {
_skipDepth = 0;
ctx.ui.notify(`Skipped ${MAX_SKIP_DEPTH}+ completed units. Yielding to UI before continuing.`, "info");
await new Promise(r => setTimeout(r, 200));
}
// Clear stale directory listing cache so deriveState sees fresh disk state (#431)
clearPathCache();
// Clear parsed roadmap/plan cache — doctor may have re-populated it with
@ -1570,6 +1575,7 @@ async function dispatchNextUnit(
`Milestone ${currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`,
"info",
);
sendDesktopNotification("GSD", `Milestone ${currentMilestoneId} complete!`, "success", "milestone");
// Reset stuck detection for new milestone
unitDispatchCount.clear();
unitRecoveryCount.clear();
@ -1589,6 +1595,7 @@ async function dispatchNextUnit(
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
}
sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
await stopAuto(ctx, pi);
return;
}
@ -1601,9 +1608,9 @@ async function dispatchNextUnit(
return;
}
// ── Mid-merge safety check: detect leftover state from a prior fix-merge session ──
// If MERGE_HEAD or SQUASH_MSG exists, a fix-merge session ran previously.
// Check whether it succeeded (no unmerged entries → finalize) or failed (still conflicted → reset + stop).
// ── Mid-merge safety check: detect leftover merge state from a prior session ──
// If MERGE_HEAD or SQUASH_MSG exists, check whether conflicts are resolved.
// If resolved: finalize the commit. If still conflicted: abort and reset.
{
const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD");
const squashMsgPath = join(basePath, ".git", "SQUASH_MSG");
@ -1612,178 +1619,37 @@ async function dispatchNextUnit(
if (hasMergeHead || hasSquashMsg) {
const unmerged = runGit(basePath, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
if (!unmerged || !unmerged.trim()) {
// fix-merge succeeded — finalize the commit if needed (squash or normal merge)
if (hasMergeHead || hasSquashMsg) {
try {
runGit(basePath, ["commit", "--no-edit"], { allowFailure: false });
const mode = hasMergeHead ? "merge" : "squash commit";
ctx.ui.notify(`Fix-merge session succeeded — finalized ${mode}.`, "info");
} catch {
// Commit may already exist; non-fatal
}
// All conflicts resolved — finalize the merge/squash commit
try {
runGit(basePath, ["commit", "--no-edit"], { allowFailure: false });
const mode = hasMergeHead ? "merge" : "squash commit";
ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info");
} catch {
// Commit may already exist; non-fatal
}
// Re-derive state from the now-merged working tree
invalidateStateCache();
clearParseCache();
clearPathCache();
state = await deriveState(basePath);
mid = state.activeMilestone?.id;
midTitle = state.activeMilestone?.title;
} else {
// fix-merge failed — still has unresolved conflicts, abort merge/squash, reset and stop
// Still conflicted — abort and reset
if (hasMergeHead) {
// Properly abort an in-progress merge so MERGE_HEAD and related metadata are cleared
runGit(basePath, ["merge", "--abort"], { allowFailure: true });
} else if (hasSquashMsg) {
// Squash-in-progress without MERGE_HEAD: remove stale squash metadata
try {
unlinkSync(squashMsgPath);
} catch {
// Best-effort cleanup; ignore failures
}
try { unlinkSync(squashMsgPath); } catch { /* best-effort */ }
}
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
ctx.ui.notify(
"Fix-merge session failed to resolve all conflicts. Working tree reset. Fix conflicts manually and restart.",
"error",
"Detected leftover merge state with unresolved conflicts — cleaned up. Re-deriving state.",
"warning",
);
if (currentUnit) {
const modelId = ctx.model?.id ?? "unknown";
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
}
await stopAuto(ctx, pi);
return;
}
invalidateStateCache();
clearParseCache();
clearPathCache();
state = await deriveState(basePath);
mid = state.activeMilestone?.id;
midTitle = state.activeMilestone?.title;
}
}
// ── General merge guard: merge completed slice branches before advancing ──
// If we're on a gsd/MID/SID branch and that slice is done (roadmap [x]),
// merge to main before dispatching the next unit. This handles:
// - Normal complete-slice → merge → reassess flow
// - LLM writes summary during task execution, skipping complete-slice
// - Doctor post-hook marks everything done, skipping complete-slice
// - complete-milestone runs on a slice branch (last slice bypass)
{
const currentBranch = getCurrentBranch(basePath);
const parsedBranch = parseSliceBranch(currentBranch);
if (parsedBranch) {
const branchMid = parsedBranch.milestoneId;
const branchSid = parsedBranch.sliceId;
// Check if this slice is marked done in the roadmap
const roadmapFile = resolveMilestoneFile(basePath, branchMid, "ROADMAP");
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
if (roadmapContent) {
const roadmap = parseRoadmap(roadmapContent);
const sliceEntry = roadmap.slices.find(s => s.id === branchSid);
if (sliceEntry?.done) {
try {
const sliceTitleForMerge = sliceEntry.title || branchSid;
let mergeResult;
if (isInAutoWorktree(basePath) && getMergeToMainMode() !== "slice") {
mergeResult = mergeSliceToMilestone(
basePath, branchMid, branchSid, sliceTitleForMerge,
);
} else {
switchToMain(basePath);
mergeResult = mergeSliceToMain(
basePath, branchMid, branchSid, sliceTitleForMerge,
);
}
const targetBranch = getMainBranch(basePath);
ctx.ui.notify(
`Merged ${mergeResult.branch}${targetBranch}.`,
"info",
);
// Re-derive state from main so downstream logic sees merged state
invalidateStateCache();
clearParseCache();
clearPathCache();
state = await deriveState(basePath);
mid = state.activeMilestone?.id;
midTitle = state.activeMilestone?.title;
} catch (error) {
// MergeConflictError: dispatch a fix-merge session to resolve conflicts
if (error instanceof MergeConflictError) {
const fixMergeUnitId = `${parsedBranch.milestoneId}/${parsedBranch.sliceId}`;
const fixMergePrompt = buildFixMergePrompt(error);
ctx.ui.notify(
`Merge conflict in ${error.conflictedFiles.length} file(s) — dispatching fix-merge session.`,
"warning",
);
// Close out the previously active unit before overwriting currentUnit.
if (currentUnit) {
const modelId = ctx.model?.id ?? "unknown";
snapshotUnitMetrics(
ctx,
currentUnit.type,
currentUnit.id,
currentUnit.startedAt,
modelId,
);
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
}
// Dispatch fix-merge as the next unit (early-dispatch-and-return)
const fixMergeUnitType = "fix-merge";
currentUnit = { type: fixMergeUnitType, id: fixMergeUnitId, startedAt: Date.now() };
writeUnitRuntimeRecord(basePath, fixMergeUnitType, fixMergeUnitId, currentUnit.startedAt, {
phase: "dispatched",
wrapupWarningSent: false,
timeoutAt: null,
lastProgressAt: currentUnit.startedAt,
progressCount: 0,
lastProgressKind: "dispatch",
});
updateProgressWidget(ctx, fixMergeUnitType, fixMergeUnitId, state);
const result = await cmdCtx!.newSession();
if (result.cancelled) {
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
await stopAuto(ctx, pi);
return;
}
const sessionFile = ctx.sessionManager.getSessionFile();
writeLock(basePath, fixMergeUnitType, fixMergeUnitId, completedUnits.length, sessionFile);
pi.sendMessage(
{ customType: "gsd-auto", content: fixMergePrompt, display: verbose },
{ triggerTurn: true },
);
return;
}
// Non-conflict errors: reset and stop
const message = formatGitError(error instanceof Error ? error : String(error));
try {
const status = runGit(basePath, ["status", "--porcelain"], { allowFailure: true });
if (status && (status.includes("UU ") || status.includes("AA ") || status.includes("UD "))) {
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
ctx.ui.notify(
`Cleaned up conflicted merge state after failed squash-merge.`,
"warning",
);
}
} catch { /* best-effort cleanup */ }
ctx.ui.notify(
`Slice merge failed — stopping auto-mode. Fix conflicts manually and restart.\n${message}`,
"error",
);
if (currentUnit) {
const modelId = ctx.model?.id ?? "unknown";
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
}
await stopAuto(ctx, pi);
return;
}
}
}
}
}
// After merge, mid/midTitle may have been re-derived and could be undefined
// After merge guard removal (branchless architecture), mid/midTitle could be undefined
if (!mid || !midTitle) {
if (currentUnit) {
const modelId = ctx.model?.id ?? "unknown";
@ -1811,9 +1677,8 @@ async function dispatchNextUnit(
if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8");
completedKeySet.clear();
} catch { /* non-fatal */ }
// ── Milestone merge: squash-merge milestone branch to main before stopping ──
if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath && getMergeToMainMode() === "milestone") {
if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath) {
try {
const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP");
const roadmapContent = readFileSync(roadmapPath, "utf-8");
@ -1831,7 +1696,7 @@ async function dispatchNextUnit(
);
}
}
sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
await stopAuto(ctx, pi);
return;
}
@ -1843,7 +1708,9 @@ async function dispatchNextUnit(
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
}
await stopAuto(ctx, pi);
ctx.ui.notify(`Blocked: ${state.blockers.join(", ")}. Fix and run /gsd auto.`, "warning");
const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
sendDesktopNotification("GSD", blockerMsg, "error", "attention");
return;
}
@ -1851,16 +1718,58 @@ async function dispatchNextUnit(
// Ensures the UAT file and slice summary are both on main when UAT runs.
const prefs = loadEffectiveGSDPreferences()?.preferences;
// Budget ceiling guard — pause before starting next unit if ceiling is hit
// Budget ceiling guard — enforce budget with configurable action
const budgetCeiling = prefs?.budget_ceiling;
if (budgetCeiling !== undefined) {
if (budgetCeiling !== undefined && budgetCeiling > 0) {
const currentLedger = getLedger();
const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0;
if (totalCost >= budgetCeiling) {
ctx.ui.notify(
`Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)}). Pausing auto-mode — /gsd auto to continue.`,
"warning",
);
const budgetPct = totalCost / budgetCeiling;
const budgetAlertLevel = getBudgetAlertLevel(budgetPct);
const newBudgetAlertLevel = getNewBudgetAlertLevel(lastBudgetAlertLevel, budgetPct);
const enforcement = prefs?.budget_enforcement ?? "pause";
const budgetEnforcementAction = getBudgetEnforcementAction(enforcement, budgetPct);
if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") {
const msg = `Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)}).`;
lastBudgetAlertLevel = newBudgetAlertLevel;
if (budgetEnforcementAction === "halt") {
ctx.ui.notify(`${msg} Stopping auto-mode.`, "error");
sendDesktopNotification("GSD", msg, "error", "budget");
await stopAuto(ctx, pi);
return;
}
if (budgetEnforcementAction === "pause") {
ctx.ui.notify(`${msg} Pausing auto-mode — /gsd auto to override and continue.`, "warning");
sendDesktopNotification("GSD", msg, "warning", "budget");
await pauseAuto(ctx, pi);
return;
}
ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
sendDesktopNotification("GSD", msg, "warning", "budget");
} else if (newBudgetAlertLevel === 90) {
lastBudgetAlertLevel = newBudgetAlertLevel;
ctx.ui.notify(`Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning");
sendDesktopNotification("GSD", `Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning", "budget");
} else if (newBudgetAlertLevel === 75) {
lastBudgetAlertLevel = newBudgetAlertLevel;
ctx.ui.notify(`Budget 75%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "info");
sendDesktopNotification("GSD", `Budget 75%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "info", "budget");
} else if (budgetAlertLevel === 0) {
lastBudgetAlertLevel = 0;
}
} else {
lastBudgetAlertLevel = 0;
}
// Context window guard — pause if approaching context limits
const contextThreshold = prefs?.context_pause_threshold ?? 0; // 0 = disabled by default
if (contextThreshold > 0 && cmdCtx) {
const contextUsage = cmdCtx.getContextUsage();
if (contextUsage && contextUsage.percent >= contextThreshold) {
const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`;
ctx.ui.notify(`${msg} Run /gsd auto to continue (will start fresh session).`, "warning");
sendDesktopNotification("GSD", `Context ${contextUsage.percent}% — paused`, "warning", "attention");
await pauseAuto(ctx, pi);
return;
}
@ -1902,7 +1811,7 @@ async function dispatchNextUnit(
// ── Phase-first dispatch: complete-slice MUST run before reassessment ──
// If the current phase is "summarizing", complete-slice is responsible for
// mergeSliceToMain. Reassessment must wait until the merge is done.
// complete-slice must run before reassessment.
if (state.phase === "summarizing") {
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
@ -2028,7 +1937,7 @@ async function dispatchNextUnit(
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
}
await stopAuto(ctx, pi);
ctx.ui.notify(`Unexpected phase: ${state.phase}. Stopping auto-mode.`, "warning");
ctx.ui.notify(`Unhandled phase "${state.phase}" — run /gsd doctor to diagnose.`, "info");
return;
}
}
@ -2074,10 +1983,10 @@ async function dispatchNextUnit(
`Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
"info",
);
// Yield to the event loop before re-dispatching to avoid tight recursion
// when many units are already completed (e.g., after crash recovery).
await new Promise(r => setImmediate(r));
_skipDepth++;
await new Promise(r => setTimeout(r, 50));
await dispatchNextUnit(ctx, pi);
_skipDepth = Math.max(0, _skipDepth - 1);
return;
} else {
// Stale completion record — artifact missing. Remove and re-run.
@ -2090,6 +1999,26 @@ async function dispatchNextUnit(
}
}
// Fallback: if the idempotency key is missing but the expected artifact already
// exists on disk, the task completed in a prior session without persisting the key.
// Persist it now and skip re-dispatch. This prevents infinite loops where a task
// completes successfully but the completion key was never written (e.g., completed
// on the first attempt before hitting the retry-threshold persistence logic).
if (verifyExpectedArtifact(unitType, unitId, basePath)) {
persistCompletedKey(basePath, idempotencyKey);
completedKeySet.add(idempotencyKey);
invalidateStateCache();
ctx.ui.notify(
`Skipping ${unitType} ${unitId} — artifact exists but completion key was missing. Repaired and advancing.`,
"info",
);
_skipDepth++;
await new Promise(r => setTimeout(r, 50));
await dispatchNextUnit(ctx, pi);
_skipDepth = Math.max(0, _skipDepth - 1);
return;
}
// Stuck detection — tracks total dispatches per unit (not just consecutive repeats).
// Pattern A→B→A→B would reset retryCount every time; this map catches it.
const dispatchKey = `${unitType}/${unitId}`;
@ -2177,9 +2106,33 @@ async function dispatchNextUnit(
return;
}
// Last resort for complete-milestone: generate stub summary to unblock pipeline.
// All slices are done (otherwise we wouldn't be in completing-milestone phase),
// but the LLM failed to write the summary N times. A stub lets the pipeline advance.
if (unitType === "complete-milestone") {
try {
const mPath = resolveMilestonePath(basePath, unitId);
if (mPath) {
const stubPath = join(mPath, `${unitId}-SUMMARY.md`);
if (!existsSync(stubPath)) {
writeFileSync(stubPath, `# ${unitId} Summary\n\nAuto-generated stub — milestone tasks completed but summary generation failed after ${prevCount + 1} attempts.\nReview and replace this stub with a proper summary.\n`);
ctx.ui.notify(`Generated stub summary for ${unitId} to unblock pipeline. Review later.`, "warning");
persistCompletedKey(basePath, dispatchKey);
completedKeySet.add(dispatchKey);
unitDispatchCount.delete(dispatchKey);
invalidateStateCache();
await new Promise(r => setImmediate(r));
await dispatchNextUnit(ctx, pi);
return;
}
}
} catch { /* non-fatal — fall through to normal stop */ }
}
const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
const remediation = buildLoopRemediationSteps(unitType, unitId, basePath);
await stopAuto(ctx, pi);
sendDesktopNotification("GSD", `Loop detected: ${unitType} ${unitId}`, "error", "error");
ctx.ui.notify(
`Loop detected: ${unitType} ${unitId} dispatched ${prevCount + 1} times total. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}${remediation ? `\n\n Remediation steps:\n${remediation}` : "\n Check branch state and .gsd/ artifacts."}`,
"error",
@ -2305,7 +2258,7 @@ async function dispatchNextUnit(
const result = await cmdCtx!.newSession();
if (result.cancelled) {
await stopAuto(ctx, pi);
ctx.ui.notify("New session cancelled — auto-mode stopped.", "warning");
ctx.ui.notify("Auto-mode stopped.", "info");
return;
}
@ -2411,7 +2364,7 @@ async function dispatchNextUnit(
}
}
if (!model) {
ctx.ui.notify(`Model ${modelId} not found in available models, trying fallback.`, "warning");
if (verbose) ctx.ui.notify(`Model ${modelId} not found, trying fallback.`, "info");
continue;
}
@ -2427,25 +2380,14 @@ async function dispatchNextUnit(
} else {
const nextModel = modelsToTry[modelsToTry.indexOf(modelId) + 1];
if (nextModel) {
ctx.ui.notify(
`Failed to set model ${modelId}, trying fallback ${nextModel}...`,
"warning",
);
if (verbose) ctx.ui.notify(`Failed to set model ${modelId}, trying ${nextModel}...`, "info");
} else {
ctx.ui.notify(
`Failed to set model ${modelId} and all fallbacks exhausted. Using default model.`,
"warning",
);
ctx.ui.notify(`All preferred models unavailable for ${unitType}. Using default.`, "warning");
}
}
}
if (!modelSet) {
ctx.ui.notify(
`Could not set any preferred model for ${unitType}. Continuing with default.`,
"warning",
);
}
// modelSet=false is already handled by the "all fallbacks exhausted" warning above
}
// Start progress-aware supervision: a soft warning, an idle watchdog, and
@ -2558,6 +2500,9 @@ async function dispatchNextUnit(
);
await pauseAuto(ctx, pi);
}
} finally {
_dispatching = false;
}
}
// ─── Skill Discovery ──────────────────────────────────────────────────────────
@ -3193,45 +3138,6 @@ async function buildReassessRoadmapPrompt(
});
}
/**
* Build a prompt for the fix-merge LLM session that resolves merge conflicts.
*/
function buildFixMergePrompt(err: MergeConflictError): string {
const strategyLabel = err.strategy === "merge" ? "merge --no-ff" : "squash merge";
const fileList = err.conflictedFiles.map(f => ` - \`${f}\``).join("\n");
return [
`# Fix Merge Conflicts`,
``,
`A ${strategyLabel} of branch \`${err.branch}\` into \`${err.mainBranch}\` produced conflicts in the following files:`,
``,
fileList,
``,
`## Instructions`,
``,
`1. Read each conflicted file listed above`,
`2. Resolve all conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) by choosing the correct content`,
`3. Stage the resolved files with \`git add <file>\``,
`4. Commit the resolution:`,
err.strategy === "squash"
? ` - This is a squash merge, so run: \`git commit --no-edit\` (the squash message is already prepared)`
: ` - This is a --no-ff merge, so run: \`git commit --no-edit\` (the merge message is already prepared)`,
``,
`## Rules`,
``,
`- Do NOT run \`git merge --abort\` or \`git reset\``,
`- Do NOT modify any files other than the conflicted ones listed above`,
`- Preserve the intent of both sides of the conflict — prefer the slice branch changes when the intent is unclear`,
``,
`## Verification`,
``,
`After committing, verify:`,
`1. \`git diff --name-only --diff-filter=U\` returns empty (no unmerged files)`,
`2. The conflicted files no longer contain any \`<<<<<<<\`, \`=======\`, or \`>>>>>>>\` markers`,
`3. \`git status\` shows a clean working tree`,
].join("\n");
}
function extractSliceExecutionExcerpt(content: string | null, relPath: string): string {
if (!content) {
return [
@ -3399,10 +3305,6 @@ function ensurePreconditions(
}
}
if (["research-slice", "plan-slice", "execute-task", "complete-slice", "replan-slice"].includes(unitType) && parts.length >= 2) {
const sid = parts[1]!;
ensureSliceBranch(base, mid, sid);
}
}
// ─── Diagnostics ──────────────────────────────────────────────────────────────
@ -3809,8 +3711,6 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
const dir = resolveMilestonePath(base, mid);
return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null;
}
case "fix-merge":
return null;
default:
return null;
}
@ -3833,14 +3733,6 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
// is managed by the hook engine, not the artifact verification system.
if (unitType.startsWith("hook/")) return true;
// fix-merge has no file artifact — verify by checking git state
if (unitType === "fix-merge") {
const unmerged = runGit(base, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
if (unmerged && unmerged.trim()) return false;
if (existsSync(join(base, ".git", "MERGE_HEAD"))) return false;
if (existsSync(join(base, ".git", "SQUASH_MSG"))) return false;
return true;
}
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
// Unit types with no verifiable artifact always pass (e.g. replan-slice).
@ -3948,8 +3840,6 @@ function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string
return `${relSliceFile(base, mid!, sid!, "UAT-RESULT")} (UAT result)`;
case "complete-milestone":
return `${relMilestoneFile(base, mid!, "SUMMARY")} (milestone summary)`;
case "fix-merge":
return "Clean working tree with no unmerged files, no MERGE_HEAD, no SQUASH_MSG (merge conflict resolution)";
default:
return null;
}

View file

@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url";
import { deriveState } from "./state.js";
import { GSDDashboardOverlay } from "./dashboard-overlay.js";
import { showQueue, showDiscuss } from "./guided-flow.js";
import { startAuto, stopAuto, isAutoActive, isAutoPaused, isStepMode } from "./auto.js";
import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode } from "./auto.js";
import {
getGlobalGSDPreferencesPath,
getLegacyGlobalGSDPreferencesPath,
@ -33,6 +33,9 @@ import {
import { loadPrompt } from "./prompt-loader.js";
import { handleMigrate } from "./migrate/command.js";
import { handleRemote } from "../remote-questions/remote-command.js";
import { handleHistory } from "./history.js";
import { handleUndo } from "./undo.js";
import { handleExport } from "./export.js";
function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
@ -54,10 +57,13 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
export function registerGSDCommand(pi: ExtensionAPI): void {
pi.registerCommand("gsd", {
description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|config|hooks|doctor|migrate|remote",
description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote",
getArgumentCompletions: (prefix: string) => {
const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "config", "hooks", "doctor", "migrate", "remote"];
const subcommands = [
"next", "auto", "stop", "pause", "status", "queue", "discuss",
"history", "undo", "skip", "export", "cleanup", "prefs",
"config", "hooks", "doctor", "migrate", "remote",
];
const parts = prefix.trim().split(/\s+/);
if (parts.length <= 1) {
@ -87,6 +93,38 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
.map((cmd) => ({ value: `remote ${cmd}`, label: cmd }));
}
if (parts[0] === "next" && parts.length <= 2) {
const flagPrefix = parts[1] ?? "";
return ["--verbose", "--dry-run"]
.filter((f) => f.startsWith(flagPrefix))
.map((f) => ({ value: `next ${f}`, label: f }));
}
if (parts[0] === "history" && parts.length <= 2) {
const flagPrefix = parts[1] ?? "";
return ["--cost", "--phase", "--model", "10", "20", "50"]
.filter((f) => f.startsWith(flagPrefix))
.map((f) => ({ value: `history ${f}`, label: f }));
}
if (parts[0] === "undo" && parts.length <= 2) {
return [{ value: "undo --force", label: "--force" }];
}
if (parts[0] === "export" && parts.length <= 2) {
const flagPrefix = parts[1] ?? "";
return ["--json", "--markdown"]
.filter((f) => f.startsWith(flagPrefix))
.map((f) => ({ value: `export ${f}`, label: f }));
}
if (parts[0] === "cleanup" && parts.length <= 2) {
const subPrefix = parts[1] ?? "";
return ["branches", "snapshots"]
.filter((cmd) => cmd.startsWith(subPrefix))
.map((cmd) => ({ value: `cleanup ${cmd}`, label: cmd }));
}
if (parts[0] === "doctor") {
const modePrefix = parts[1] ?? "";
const modes = ["fix", "heal", "audit"];
@ -122,6 +160,10 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
}
if (trimmed === "next" || trimmed.startsWith("next ")) {
if (trimmed.includes("--dry-run")) {
await handleDryRun(ctx, process.cwd());
return;
}
const verboseMode = trimmed.includes("--verbose");
await startAuto(ctx, pi, process.cwd(), verboseMode, { step: true });
return;
@ -142,6 +184,49 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
return;
}
if (trimmed === "pause") {
if (!isAutoActive()) {
if (isAutoPaused()) {
ctx.ui.notify("Auto-mode is already paused. /gsd auto to resume.", "info");
} else {
ctx.ui.notify("Auto-mode is not running.", "info");
}
return;
}
await pauseAuto(ctx, pi);
return;
}
if (trimmed === "history" || trimmed.startsWith("history ")) {
await handleHistory(trimmed.replace(/^history\s*/, "").trim(), ctx, process.cwd());
return;
}
if (trimmed === "undo" || trimmed.startsWith("undo ")) {
await handleUndo(trimmed.replace(/^undo\s*/, "").trim(), ctx, pi, process.cwd());
return;
}
if (trimmed.startsWith("skip ")) {
await handleSkip(trimmed.replace(/^skip\s*/, "").trim(), ctx, process.cwd());
return;
}
if (trimmed === "export" || trimmed.startsWith("export ")) {
await handleExport(trimmed.replace(/^export\s*/, "").trim(), ctx, process.cwd());
return;
}
if (trimmed === "cleanup branches") {
await handleCleanupBranches(ctx, process.cwd());
return;
}
if (trimmed === "cleanup snapshots") {
await handleCleanupSnapshots(ctx, process.cwd());
return;
}
if (trimmed === "queue") {
await showQueue(ctx, pi, process.cwd());
return;
@ -180,7 +265,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
}
ctx.ui.notify(
`Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs, /gsd config, /gsd hooks, /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
`Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|discuss|history|undo|skip <unit>|export|cleanup|prefs|config|hooks|doctor|migrate|remote.`,
"warning",
);
},
@ -626,3 +711,221 @@ async function ensurePreferencesFile(
}
}
// ─── Skip handler ─────────────────────────────────────────────────────────────
async function handleSkip(unitArg: string, ctx: ExtensionCommandContext, basePath: string): Promise<void> {
if (!unitArg) {
ctx.ui.notify("Usage: /gsd skip <unit-id> (e.g., /gsd skip execute-task/M001/S01/T03 or /gsd skip T03)", "info");
return;
}
const { existsSync: fileExists, writeFileSync: writeFile, mkdirSync: mkDir, readFileSync: readFile } = await import("node:fs");
const { join: pathJoin } = await import("node:path");
const completedKeysFile = pathJoin(basePath, ".gsd", "completed-units.json");
let keys: string[] = [];
try {
if (fileExists(completedKeysFile)) {
keys = JSON.parse(readFile(completedKeysFile, "utf-8"));
}
} catch { /* start fresh */ }
// Normalize: accept "execute-task/M001/S01/T03", "M001/S01/T03", or just "T03"
let skipKey = unitArg;
if (!skipKey.includes("execute-task") && !skipKey.includes("plan-") && !skipKey.includes("research-") && !skipKey.includes("complete-")) {
const state = await deriveState(basePath);
const mid = state.activeMilestone?.id;
const sid = state.activeSlice?.id;
if (unitArg.match(/^T\d+$/i) && mid && sid) {
skipKey = `execute-task/${mid}/${sid}/${unitArg.toUpperCase()}`;
} else if (unitArg.match(/^S\d+$/i) && mid) {
skipKey = `plan-slice/${mid}/${unitArg.toUpperCase()}`;
} else if (unitArg.includes("/")) {
skipKey = `execute-task/${unitArg}`;
}
}
if (keys.includes(skipKey)) {
ctx.ui.notify(`Already skipped: ${skipKey}`, "info");
return;
}
keys.push(skipKey);
mkDir(pathJoin(basePath, ".gsd"), { recursive: true });
writeFile(completedKeysFile, JSON.stringify(keys), "utf-8");
ctx.ui.notify(`Skipped: ${skipKey}. Will not be dispatched in auto-mode.`, "success");
}
// ─── Dry-run handler ──────────────────────────────────────────────────────────
async function handleDryRun(ctx: ExtensionCommandContext, basePath: string): Promise<void> {
const state = await deriveState(basePath);
if (!state.activeMilestone) {
ctx.ui.notify("No active milestone — nothing to dispatch.", "info");
return;
}
const { getLedger, getProjectTotals, formatCost, formatTokenCount, loadLedgerFromDisk } = await import("./metrics.js");
const { loadEffectiveGSDPreferences: loadPrefs } = await import("./preferences.js");
const { formatDuration } = await import("./history.js");
const ledger = getLedger();
const units = ledger?.units ?? loadLedgerFromDisk(basePath)?.units ?? [];
const prefs = loadPrefs()?.preferences;
let nextType = "unknown";
let nextId = "unknown";
const mid = state.activeMilestone.id;
const midTitle = state.activeMilestone.title;
if (state.phase === "pre-planning") {
nextType = "research-milestone";
nextId = mid;
} else if (state.phase === "planning" && state.activeSlice) {
nextType = "plan-slice";
nextId = `${mid}/${state.activeSlice.id}`;
} else if (state.phase === "executing" && state.activeTask && state.activeSlice) {
nextType = "execute-task";
nextId = `${mid}/${state.activeSlice.id}/${state.activeTask.id}`;
} else if (state.phase === "summarizing" && state.activeSlice) {
nextType = "complete-slice";
nextId = `${mid}/${state.activeSlice.id}`;
} else if (state.phase === "completing-milestone") {
nextType = "complete-milestone";
nextId = mid;
} else {
nextType = state.phase;
nextId = mid;
}
const sameTypeUnits = units.filter(u => u.type === nextType);
const avgCost = sameTypeUnits.length > 0
? sameTypeUnits.reduce((s, u) => s + u.cost, 0) / sameTypeUnits.length
: null;
const avgDuration = sameTypeUnits.length > 0
? sameTypeUnits.reduce((s, u) => s + (u.finishedAt - u.startedAt), 0) / sameTypeUnits.length
: null;
const totals = units.length > 0 ? getProjectTotals(units) : null;
const budgetRemaining = prefs?.budget_ceiling && totals
? prefs.budget_ceiling - totals.cost
: null;
const lines = [
`Dry-run preview:`,
``,
` Next unit: ${nextType}`,
` ID: ${nextId}`,
` Milestone: ${mid}: ${midTitle}`,
` Phase: ${state.phase}`,
` Est. cost: ${avgCost !== null ? `${formatCost(avgCost)} (avg of ${sameTypeUnits.length} similar)` : "unknown (first of this type)"}`,
` Est. duration: ${avgDuration !== null ? formatDuration(avgDuration) : "unknown"}`,
` Spent so far: ${totals ? formatCost(totals.cost) : "$0"}`,
` Budget left: ${budgetRemaining !== null ? formatCost(budgetRemaining) : "no ceiling set"}`,
];
if (state.progress) {
const p = state.progress;
lines.push(` Progress: ${p.tasks?.done ?? 0}/${p.tasks?.total ?? "?"} tasks, ${p.slices?.done ?? 0}/${p.slices?.total ?? "?"} slices`);
}
ctx.ui.notify(lines.join("\n"), "info");
}
// ─── Branch cleanup handler ──────────────────────────────────────────────────
async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: string): Promise<void> {
const { execFileSync } = await import("node:child_process");
let branches: string[];
try {
const output = execFileSync("git", ["branch", "--list", "gsd/*"], { cwd: basePath, timeout: 10000, encoding: "utf-8" });
branches = output.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean);
} catch {
ctx.ui.notify("No GSD branches found.", "info");
return;
}
if (branches.length === 0) {
ctx.ui.notify("No GSD branches to clean up.", "info");
return;
}
let mainBranch: string;
try {
mainBranch = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], { cwd: basePath, timeout: 5000, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] })
.trim().replace("origin/", "");
} catch {
mainBranch = "main";
}
let merged: string[];
try {
const output = execFileSync("git", ["branch", "--merged", mainBranch, "--list", "gsd/*"], { cwd: basePath, timeout: 10000, encoding: "utf-8" });
merged = output.split("\n").map(b => b.trim()).filter(Boolean);
} catch {
merged = [];
}
if (merged.length === 0) {
ctx.ui.notify(`${branches.length} GSD branches found, none are merged into ${mainBranch} yet.`, "info");
return;
}
let deleted = 0;
for (const branch of merged) {
try {
execFileSync("git", ["branch", "-d", branch], { cwd: basePath, timeout: 5000, stdio: "ignore" });
deleted++;
} catch { /* skip branches that can't be deleted */ }
}
ctx.ui.notify(`Cleaned up ${deleted} merged branches. ${branches.length - deleted} remain.`, "success");
}
// ─── Snapshot cleanup handler ─────────────────────────────────────────────────
async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: string): Promise<void> {
const { execFileSync } = await import("node:child_process");
let refs: string[];
try {
const output = execFileSync("git", ["for-each-ref", "refs/gsd/snapshots/", "--format=%(refname)"], { cwd: basePath, timeout: 10000, encoding: "utf-8" });
refs = output.split("\n").filter(Boolean);
} catch {
ctx.ui.notify("No snapshot refs found.", "info");
return;
}
if (refs.length === 0) {
ctx.ui.notify("No snapshot refs to clean up.", "info");
return;
}
const byLabel = new Map<string, string[]>();
for (const ref of refs) {
const parts = ref.split("/");
const label = parts.slice(0, -1).join("/");
if (!byLabel.has(label)) byLabel.set(label, []);
byLabel.get(label)!.push(ref);
}
let pruned = 0;
for (const [, labelRefs] of byLabel) {
const sorted = labelRefs.sort();
for (const old of sorted.slice(0, -5)) {
try {
execFileSync("git", ["update-ref", "-d", old], { cwd: basePath, timeout: 5000, stdio: "ignore" });
pruned++;
} catch { /* skip */ }
}
}
ctx.ui.notify(`Pruned ${pruned} old snapshot refs. ${refs.length - pruned} remain.`, "success");
}

View file

@ -31,7 +31,8 @@ export type DoctorIssueCode =
| "orphaned_auto_worktree"
| "stale_milestone_branch"
| "corrupt_merge_state"
| "tracked_runtime_files";
| "tracked_runtime_files"
| "legacy_slice_branches";
export interface DoctorIssue {
severity: DoctorSeverity;
@ -642,6 +643,28 @@ async function checkGitHealth(
} catch {
// git ls-files failed — skip
}
// ── Legacy slice branches ──────────────────────────────────────────────
try {
const sliceBranches = execSync('git branch --format="%(refname:short)" --list "gsd/*/*"', {
cwd: basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
}).trim();
if (sliceBranches) {
const branchList = sliceBranches.split("\n").map(b => b.trim()).filter(Boolean);
issues.push({
severity: "info",
code: "legacy_slice_branches",
scope: "project",
unitId: "project",
message: `${branchList.length} legacy slice branch(es) found: ${branchList.slice(0, 3).join(", ")}${branchList.length > 3 ? "..." : ""}. These are no longer used (branchless architecture). Delete with: git branch -D ${branchList.join(" ")}`,
fixable: false,
});
}
} catch {
// git branch list failed — skip
}
}
export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string; fixLevel?: "task" | "all" }): Promise<DoctorReport> {

View file

@ -0,0 +1,100 @@
// GSD Extension — Session/Milestone Export
// Generate shareable reports of milestone work in JSON or markdown format.
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
import { writeFileSync, mkdirSync } from "node:fs";
import { join, basename } from "node:path";
import {
getLedger, getProjectTotals, aggregateByPhase, aggregateBySlice,
aggregateByModel, formatCost, formatTokenCount,
} from "./metrics.js";
import type { UnitMetrics } from "./metrics.js";
import { gsdRoot } from "./paths.js";
import { formatDuration } from "./history.js";
/**
* Export session/milestone data to JSON or markdown.
*/
export async function handleExport(args: string, ctx: ExtensionCommandContext, basePath: string): Promise<void> {
const format = args.includes("--json") ? "json" : "markdown";
const ledger = getLedger();
let units: UnitMetrics[];
if (ledger && ledger.units.length > 0) {
units = ledger.units;
} else {
const { loadLedgerFromDisk } = await import("./metrics.js");
const diskLedger = loadLedgerFromDisk(basePath);
if (!diskLedger || diskLedger.units.length === 0) {
ctx.ui.notify("Nothing to export — no units executed yet.", "info");
return;
}
units = diskLedger.units;
}
const projectName = basename(basePath);
const exportDir = gsdRoot(basePath);
mkdirSync(exportDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
if (format === "json") {
const report = {
exportedAt: new Date().toISOString(),
project: projectName,
totals: getProjectTotals(units),
byPhase: aggregateByPhase(units),
bySlice: aggregateBySlice(units),
byModel: aggregateByModel(units),
units,
};
const outPath = join(exportDir, `export-${timestamp}.json`);
writeFileSync(outPath, JSON.stringify(report, null, 2) + "\n", "utf-8");
ctx.ui.notify(`Exported to ${outPath}`, "success");
} else {
const totals = getProjectTotals(units);
const phases = aggregateByPhase(units);
const slices = aggregateBySlice(units);
const md = [
`# GSD Session Report — ${projectName}`,
``,
`**Generated**: ${new Date().toISOString()}`,
`**Units completed**: ${totals.units}`,
`**Total cost**: ${formatCost(totals.cost)}`,
`**Total tokens**: ${formatTokenCount(totals.tokens.total)}`,
`**Total duration**: ${formatDuration(totals.duration)}`,
`**Tool calls**: ${totals.toolCalls}`,
``,
`## Cost by Phase`,
``,
`| Phase | Units | Cost | Tokens | Duration |`,
`|-------|-------|------|--------|----------|`,
...phases.map(p =>
`| ${p.phase} | ${p.units} | ${formatCost(p.cost)} | ${formatTokenCount(p.tokens.total)} | ${formatDuration(p.duration)} |`,
),
``,
`## Cost by Slice`,
``,
`| Slice | Units | Cost | Tokens | Duration |`,
`|-------|-------|------|--------|----------|`,
...slices.map(s =>
`| ${s.sliceId} | ${s.units} | ${formatCost(s.cost)} | ${formatTokenCount(s.tokens.total)} | ${formatDuration(s.duration)} |`,
),
``,
`## Unit History`,
``,
`| Type | ID | Model | Cost | Tokens | Duration |`,
`|------|-----|-------|------|--------|----------|`,
...units.map(u =>
`| ${u.type} | ${u.id} | ${u.model.replace(/^claude-/, "")} | ${formatCost(u.cost)} | ${formatTokenCount(u.tokens.total)} | ${formatDuration(u.finishedAt - u.startedAt)} |`,
),
``,
].join("\n");
const outPath = join(exportDir, `export-${timestamp}.md`);
writeFileSync(outPath, md, "utf-8");
ctx.ui.notify(`Exported to ${outPath}`, "success");
}
}

View file

@ -83,77 +83,6 @@ export function abortAndReset(cwd: string): AbortAndResetResult {
return { cleaned };
}
/**
* Wrap a merge operation with self-healing retry logic.
*
* Calls `mergeFn()`. On failure:
* - If conflicted files exist (via `git diff --diff-filter=U`), re-throws
* as MergeConflictError immediately no retry for real code conflicts.
* - Otherwise, runs `abortAndReset(cwd)`, retries `mergeFn()` once.
* - On second failure, throws the error.
*
* @param cwd - Working directory for git operations
* @param mergeFn - Synchronous function that performs the merge
* @returns The return value of `mergeFn()`
*/
export function withMergeHeal<T>(cwd: string, mergeFn: () => T): T {
try {
return mergeFn();
} catch (firstError) {
// Check for real code conflicts — escalate immediately, no retry
try {
const conflictOutput = execSync("git diff --name-only --diff-filter=U", {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
if (conflictOutput.length > 0) {
const conflictedFiles = conflictOutput.split("\n").filter(Boolean);
// If the original error is already a MergeConflictError, re-throw as-is
if (firstError instanceof MergeConflictError) {
throw firstError;
}
throw new MergeConflictError(
conflictedFiles,
"merge",
"unknown",
"unknown",
);
}
} catch (diffErr) {
// If diffErr is a MergeConflictError we just created/re-threw, propagate it
if (diffErr instanceof MergeConflictError) throw diffErr;
// Otherwise git diff itself failed — proceed with retry
}
// No real conflict detected — try abort+reset+retry once
abortAndReset(cwd);
// Retry
return mergeFn();
}
}
/**
* Recover a failed checkout by resetting first, then checking out.
*
* Performs `git reset --hard HEAD` then `git checkout <targetBranch>`.
* If checkout still fails after reset, throws with context.
*/
export function recoverCheckout(cwd: string, targetBranch: string): void {
execSync("git reset --hard HEAD", { cwd, stdio: "pipe" });
try {
execSync(`git checkout ${targetBranch}`, { cwd, stdio: "pipe" });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(
`recoverCheckout failed: could not checkout '${targetBranch}' after reset. ${msg}`,
);
}
}
/** Known git error patterns mapped to user-friendly messages. */
const ERROR_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
{

View file

@ -14,16 +14,13 @@ import { join, sep } from "node:path";
import {
detectWorktreeName,
getSliceBranchName,
SLICE_BRANCH_RE,
} from "./worktree.js";
import {
nativeGetCurrentBranch,
nativeDetectMainBranch,
nativeBranchExists,
nativeHasMergeConflicts,
nativeHasChanges,
nativeCommitCountBetween,
} from "./native-git-bridge.js";
// ─── Types ─────────────────────────────────────────────────────────────────
@ -37,8 +34,6 @@ export interface GitPreferences {
commit_type?: string;
main_branch?: string;
merge_strategy?: "squash" | "merge";
isolation?: "worktree" | "branch";
merge_to_main?: "milestone" | "slice";
}
export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/;
@ -48,12 +43,6 @@ export interface CommitOptions {
allowEmpty?: boolean;
}
export interface MergeSliceResult {
branch: string;
mergedCommitMessage: string;
deletedBranch: boolean;
}
/**
* Thrown when a slice merge hits code conflicts in non-.gsd files.
* The working tree is left in a conflicted state (no reset) so the
@ -106,22 +95,8 @@ export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [
".gsd/metrics.json",
".gsd/completed-units.json",
".gsd/STATE.md",
];
/**
* GSD planning artifact paths that must be force-added even when .gsd/
* is in .gitignore. These are durable planning files that the agent writes
* and that must survive squash-merges to main.
*
* `git add --force` is a no-op when the path doesn't exist or has no
* changes, so this list is safe to apply unconditionally.
*/
const GSD_DURABLE_PATHS: readonly string[] = [
".gsd/milestones/",
".gsd/DECISIONS.md",
".gsd/QUEUE.md",
".gsd/PROJECT.md",
".gsd/REQUIREMENTS.md",
".gsd/gsd.db",
".gsd/DISCUSSION-MANIFEST.json",
];
// ─── Integration Branch Metadata ───────────────────────────────────────────
@ -157,13 +132,11 @@ export function readIntegrationBranch(basePath: string, milestoneId: string): st
* Persist the integration branch for a milestone.
*
* Called when auto-mode starts on a milestone. Records the branch the user
* was on at that point, so that slice branches merge back to it instead of
* the repo's default branch. Idempotent when the branch matches; updates
* the record when the user starts from a different branch.
* was on at that point, so the milestone worktree merges back to the correct
* branch. Idempotent when the branch matches; updates the record when the
* user starts from a different branch.
*
* The file is committed immediately so it survives branch switches the
* pre-switch auto-commit excludes `.gsd/` to avoid merge conflicts, and
* uncommitted `.gsd/` files are discarded during checkout.
* The file is committed immediately so the metadata is persisted in git.
*/
export function writeIntegrationBranch(basePath: string, milestoneId: string, branch: string): void {
// Don't record slice branches as the integration target
@ -190,12 +163,9 @@ export function writeIntegrationBranch(basePath: string, milestoneId: string, br
existing.integrationBranch = branch;
writeFileSync(metaFile, JSON.stringify(existing, null, 2) + "\n", "utf-8");
// Commit immediately — .gsd/ files are discarded during branch switches
// (ensureSliceBranch excludes .gsd/ from pre-switch auto-commit and runs
// git checkout -- .gsd/ to prevent checkout conflicts). Without this
// commit, the metadata would be lost on the first branch switch.
// Commit immediately so the metadata is persisted in git.
try {
runGit(basePath, ["add", "--force", metaFile]);
runGit(basePath, ["add", metaFile]);
runGit(basePath, ["commit", "--no-verify", "-F", "-"], {
input: `chore(${milestoneId}): record integration branch`,
});
@ -332,20 +302,6 @@ export class GitServiceImpl {
// error handling is needed per-path.
this.git(["add", "-A"]);
// Force-add GSD planning artifacts that live under .gsd/ but may be
// blocked by a .gsd/ gitignore pattern. `git add -A` respects .gitignore,
// so new files (CONTEXT.md, SUMMARY.md, PLAN.md, etc.) in gitignored
// directories are silently skipped. Without this force-add, planning
// artifacts are never committed — they exist on disk but not in git.
// Squash-merges then delete them on main because they appear as "removed
// relative to main" during the merge.
//
// Only force-add durable planning paths — runtime paths are excluded
// by the reset step below.
for (const durablePath of GSD_DURABLE_PATHS) {
this.git(["add", "--force", "--", durablePath], { allowFailure: true });
}
for (const exclusion of allExclusions) {
this.git(["reset", "HEAD", "--", exclusion], { allowFailure: true });
}
@ -446,140 +402,8 @@ export class GitServiceImpl {
}
/** True if currently on a GSD slice branch. */
isOnSliceBranch(): boolean {
const current = this.getCurrentBranch();
return SLICE_BRANCH_RE.test(current);
}
/** Returns the slice branch name if on one, null otherwise. */
getActiveSliceBranch(): string | null {
try {
const current = this.getCurrentBranch();
return SLICE_BRANCH_RE.test(current) ? current : null;
} catch {
return null;
}
}
// ─── Branch Lifecycle ──────────────────────────────────────────────────
/**
* Check if a local branch exists. Native libgit2 when available, execSync fallback.
*/
private branchExists(branch: string): boolean {
return nativeBranchExists(this.basePath, branch);
}
/**
* 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 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.
*
* Returns true if the branch was newly created.
*/
ensureSliceBranch(milestoneId: string, sliceId: string): boolean {
const wtName = detectWorktreeName(this.basePath);
const branch = getSliceBranchName(milestoneId, sliceId, wtName);
const current = this.getCurrentBranch();
if (current === branch) return false;
let created = false;
if (!this.branchExists(branch)) {
// Fetch from remote before creating a new branch (best-effort).
const remotes = this.git(["remote"], { allowFailure: true });
if (remotes) {
const remote = this.prefs.remote ?? "origin";
const fetchResult = this.git(["fetch", "--prune", remote], { allowFailure: true });
if (fetchResult === "" && remotes.split("\n").includes(remote)) {
// Check if local is behind upstream (informational only)
const behind = this.git(
["rev-list", "--count", "HEAD..@{upstream}"],
{ allowFailure: true },
);
if (behind && parseInt(behind, 10) > 0) {
console.error(`GitService: local branch is ${behind} commit(s) behind upstream`);
}
}
}
// Branch from current when it's a normal working branch (not a slice).
// 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]);
created = true;
} else {
// Branch exists — check it's not checked out in another worktree
const worktreeList = this.git(["worktree", "list", "--porcelain"]);
if (worktreeList.includes(`branch refs/heads/${branch}`)) {
throw new Error(
`Branch "${branch}" is already in use by another worktree. ` +
`Remove that worktree first, or switch it to a different branch.`,
);
}
}
// Auto-commit dirty state via smart staging before checkout.
// Exclude .gsd/ to prevent merge conflicts when both branches modify planning artifacts.
this.autoCommit("pre-switch", current, [".gsd/"]);
// Discard uncommitted .gsd/ changes so checkout doesn't fail.
// Two-step approach handles both tracked and untracked runtime files:
// 1. `checkout --` reverts tracked .gsd/ files to their HEAD versions.
// 2. `clean -fdx` removes untracked runtime files that the target branch has
// tracked — e.g., when a prior cleanup commit removed STATE.md from the
// current branch's HEAD but the target branch still has it committed.
this.git(["checkout", "--", ".gsd/"], { allowFailure: true });
this.discardUntrackedRuntimeFiles();
this.git(["checkout", branch]);
return created;
}
/**
* Switch to the integration branch, auto-committing dirty state via smart staging first.
*/
switchToMain(): void {
const mainBranch = this.getMainBranch();
const current = this.getCurrentBranch();
if (current === mainBranch) return;
// Exclude .gsd/ to prevent merge conflicts when both branches modify planning artifacts.
this.autoCommit("pre-switch", current, [".gsd/"]);
// Discard uncommitted .gsd/ changes so checkout doesn't fail.
// Two-step approach handles both tracked and untracked runtime files.
this.git(["checkout", "--", ".gsd/"], { allowFailure: true });
this.discardUntrackedRuntimeFiles();
this.git(["checkout", mainBranch]);
}
/**
* Remove untracked runtime files from the working tree.
*
* Complements `git checkout -- .gsd/` (which only handles tracked files).
* Runtime files can end up untracked after a cleanup commit removes them
* from the current branch's HEAD but the target branch may still have
* them committed. Without this step, `git checkout` fails with:
* "The following untracked working tree files would be overwritten by checkout"
*
* `git clean -fdx` is safe here because:
* - Only removes *untracked* files (tracked files are untouched)
* - Targets only the specific runtime paths listed in RUNTIME_EXCLUSION_PATHS
* - These files are always regenerated by GSD on the next run
*/
private discardUntrackedRuntimeFiles(): void {
this.git(["clean", "-fdx", "--", ...RUNTIME_EXCLUSION_PATHS], { allowFailure: true });
}
// ─── S05 Features ─────────────────────────────────────────────────────
/**
@ -644,253 +468,6 @@ export class GitServiceImpl {
// ─── Merge ─────────────────────────────────────────────────────────────
/**
* Build a rich squash-commit message with a task list from branch commits.
*
* Format:
* type(scope): title
*
* Tasks:
* - commit subject 1
* - commit subject 2
*
* Branch: gsd/M001/S01
*/
private buildRichCommitMessage(
commitType: string,
milestoneId: string,
sliceId: string,
sliceTitle: string,
mainBranch: string,
branch: string,
): string {
const subject = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
// Collect branch commit subjects
const logOutput = this.git(
["log", "--oneline", "--format=%s", `${mainBranch}..${branch}`],
{ allowFailure: true },
);
if (!logOutput) return subject;
const subjects = logOutput.split("\n").filter(Boolean);
const MAX_ENTRIES = 20;
const truncated = subjects.length > MAX_ENTRIES;
const displayed = truncated ? subjects.slice(0, MAX_ENTRIES) : subjects;
const taskLines = displayed.map(s => `- ${s}`).join("\n");
const truncationLine = truncated ? `\n- ... and ${subjects.length - MAX_ENTRIES} more` : "";
return `${subject}\n\nTasks:\n${taskLines}${truncationLine}\n\nBranch: ${branch}`;
}
/**
* 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 integration branch. Uses `inferCommitType(sliceTitle)`
* for the conventional commit type instead of hardcoding `feat`.
*
* Throws when:
* - Not currently on the integration branch
* - The slice branch does not exist
* - The slice branch has no commits ahead of the integration branch
*/
mergeSliceToMain(milestoneId: string, sliceId: string, sliceTitle: string): MergeSliceResult {
const mainBranch = this.getMainBranch();
const current = this.getCurrentBranch();
if (current !== mainBranch) {
throw new Error(
`mergeSliceToMain must be called from the main branch ("${mainBranch}"), ` +
`but currently on "${current}"`,
);
}
const wtName = detectWorktreeName(this.basePath);
const branch = getSliceBranchName(milestoneId, sliceId, wtName);
if (!this.branchExists(branch)) {
throw new Error(
`Slice branch "${branch}" does not exist. Nothing to merge.`,
);
}
// Check commits ahead — native libgit2 revwalk when available
const aheadCount = nativeCommitCountBetween(this.basePath, mainBranch, branch);
if (aheadCount === 0) {
throw new Error(
`Slice branch "${branch}" has no commits ahead of "${mainBranch}". Nothing to merge.`,
);
}
// Snapshot the branch HEAD before merge (gated on prefs)
// We need to save the ref while the branch still exists
this.createSnapshot(branch);
// Build rich commit message before squash (needs branch history)
const commitType = inferCommitType(sliceTitle);
const message = this.buildRichCommitMessage(
commitType, milestoneId, sliceId, sliceTitle, mainBranch, branch,
);
// Pull latest main before merging to avoid conflicts from remote changes
this.git(["pull", "--rebase", "origin", mainBranch], { allowFailure: true });
// Untrack runtime files that may have been manually committed (e.g. via `gsd queue`)
// to prevent merge conflicts on files that belong in .gitignore (#189)
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
this.git(["rm", "--cached", "-r", "--ignore-unmatch", exclusion], { allowFailure: true });
}
const untrackDiff = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
if (untrackDiff && untrackDiff.trim()) {
this.git(["commit", "--no-verify", "-m", "chore: untrack .gsd/ runtime files before merge"], { allowFailure: true });
}
// Merge slice branch — strategy is configurable via git.merge_strategy
// preference. Default: "squash" (preserves existing behavior).
// "merge" uses --no-ff which is more resilient to conflicts from
// long-lived branches or frequently-changing .gsd/* artifacts.
const strategy = this.prefs.merge_strategy ?? "squash";
const mergeArgs = strategy === "merge"
? ["merge", "--no-ff", "-m", message, branch]
: ["merge", "--squash", branch];
try {
this.git(mergeArgs);
} catch (mergeError) {
// Check if conflicts can be auto-resolved (#189, #218)
//
// ─── BRANCH-MODE ONLY (D038) ────────────────────────────────────────
// The conflict resolution logic below applies ONLY when git.isolation = "branch".
// In worktree isolation mode, each milestone works in its own worktree directory
// so merge conflicts between slice branches and main are handled differently
// (worktree teardown merges via worktree-manager). This block is never reached
// in worktree mode because mergeSliceToMain is only called from the branch-mode
// code path. If you're modifying this logic, verify the isolation mode first.
// ─────────────────────────────────────────────────────────────────────
const conflicted = this.git(["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
if (conflicted) {
const conflictedFiles = conflicted.split("\n").filter(Boolean);
const isRuntimeConflict = (f: string) =>
RUNTIME_EXCLUSION_PATHS.some(excl => f.startsWith(excl.replace(/\/$/, "")));
const runtimeConflicts = conflictedFiles.filter(isRuntimeConflict);
const gsdConflicts = conflictedFiles.filter(f => f.startsWith(".gsd/") && !isRuntimeConflict(f));
const otherConflicts = conflictedFiles.filter(
f => !isRuntimeConflict(f) && !f.startsWith(".gsd/"),
);
let resolvedAny = false;
if (runtimeConflicts.length > 0) {
// Runtime conflicts: take theirs and remove from index
for (const f of runtimeConflicts) {
this.git(["checkout", "--theirs", "--", f], { allowFailure: true });
this.git(["rm", "--cached", "--ignore-unmatch", f], { allowFailure: true });
}
resolvedAny = true;
}
if (gsdConflicts.length > 0) {
// Non-runtime .gsd/ conflicts (DECISIONS.md, REQUIREMENTS.md, ROADMAP.md, etc.):
// The slice branch has the authoritative .gsd/ state since the LLM just finished
// updating these artifacts during complete-slice. Take theirs (the slice branch).
for (const f of gsdConflicts) {
this.git(["checkout", "--theirs", "--", f], { allowFailure: true });
}
resolvedAny = true;
}
if (resolvedAny) {
this.git(["add", "-A"], { allowFailure: true });
// Re-check remaining conflicts after auto-resolving runtime and .gsd/ files
const remaining = this.git(["diff", "--name-only", "--diff-filter=U"], {
allowFailure: true,
});
if (remaining) {
const remainingFiles = remaining
.split("\n")
.filter(Boolean)
.filter(f => !isRuntimeConflict(f) && !f.startsWith(".gsd/"));
if (remainingFiles.length > 0) {
// Non-runtime, non-.gsd/ conflicts: leave working tree in conflicted state and throw
// MergeConflictError so the caller can dispatch a fix-merge session.
throw new MergeConflictError(remainingFiles, strategy, branch, mainBranch);
}
}
// No remaining non-runtime, non-.gsd/ conflicts — let the merge proceed
} else {
// No runtime or .gsd/ conflicts to auto-resolve; throw with original conflicted files
// so the caller can dispatch a fix-merge session.
throw new MergeConflictError(otherConflicts.length ? otherConflicts : conflictedFiles, strategy, branch, mainBranch);
}
} else {
// No conflicted files detected but merge still failed — reset and throw
this.git(["reset", "--hard", "HEAD"], { allowFailure: true });
const msg = mergeError instanceof Error ? mergeError.message : String(mergeError);
throw new Error(
`${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" failed. ` +
`Working tree has been reset to a clean state. ` +
`Resolve manually: git checkout ${mainBranch} && git merge ${strategy === "merge" ? "--no-ff" : "--squash"} ${branch}\n` +
`Original error: ${msg}`,
);
}
}
// Strip runtime files from the merge result before committing (#302).
// This replaces the old approach of checking out the slice branch to
// untrack runtime files pre-merge, which failed when the working tree
// had uncommitted .gsd/ changes that blocked the checkout.
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
this.git(["rm", "--cached", "-r", "--ignore-unmatch", exclusion], { allowFailure: true });
}
if (strategy === "squash") {
// After stripping runtime files, there may be nothing left to commit.
// This happens when the only changes in the slice were runtime artifacts.
const stagedDiff = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
if (stagedDiff?.trim()) {
this.git(["commit", "--no-verify", "-F", "-"], { input: message });
} else {
// Nothing to commit — clean up the squash-merge state
this.git(["reset", "HEAD"], { allowFailure: true });
}
} else {
// --no-ff already committed; amend to include runtime file removal
const runtimeDiff = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
if (runtimeDiff?.trim()) {
this.git(["commit", "--amend", "--no-edit", "--no-verify"]);
}
}
// Delete the merged branch
this.git(["branch", "-D", branch]);
// Auto-push to remote if enabled
if (this.prefs.auto_push === true) {
const remote = this.prefs.remote ?? "origin";
const pushResult = this.git(["push", remote, mainBranch], { allowFailure: true });
if (pushResult === "") {
// push succeeded (empty stdout is normal) or failed silently
// Verify by checking if remote is reachable — the allowFailure handles errors
}
}
return {
branch,
mergedCommitMessage: `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`,
deletedBranch: true,
};
}
}
// ─── Commit Type Inference ─────────────────────────────────────────────────

View file

@ -14,8 +14,7 @@ import { execSync } from "node:child_process";
* Patterns that are always correct regardless of project type.
* No one ever wants these tracked.
*/
const BASELINE_PATTERNS = [
// ── GSD runtime (not source artifacts) ──
const GSD_RUNTIME_PATTERNS = [
".gsd/activity/",
".gsd/runtime/",
".gsd/worktrees/",
@ -23,7 +22,15 @@ const BASELINE_PATTERNS = [
".gsd/metrics.json",
".gsd/completed-units.json",
".gsd/STATE.md",
".gsd/gsd.db",
".gsd/DISCUSSION-MANIFEST.json",
".gsd/milestones/**/*-CONTINUE.md",
".gsd/milestones/**/continue.md",
] as const;
const BASELINE_PATTERNS = [
// ── GSD runtime (not source artifacts — planning files are tracked) ──
...GSD_RUNTIME_PATTERNS,
// ── OS junk ──
".DS_Store",
@ -80,6 +87,28 @@ export function ensureGitignore(basePath: string): boolean {
existing = readFileSync(gitignorePath, "utf-8");
}
// Self-heal: remove blanket ".gsd/" lines from pre-v2.14.0 projects.
// The blanket ignore prevented planning artifacts (.gsd/milestones/) from
// being tracked in git, causing artifacts to vanish in worktrees and
// triggering loop detection failures. Replace with explicit runtime-only
// ignores so planning files are tracked naturally.
let modified = false;
const lines = existing.split("\n");
const filteredLines = lines.filter(line => {
const trimmed = line.trim();
// Remove standalone ".gsd/" lines (blanket ignore) but keep specific
// .gsd/ subpath patterns like ".gsd/activity/" or ".gsd/auto.lock"
if (trimmed === ".gsd/" || trimmed === ".gsd") {
modified = true;
return false;
}
return true;
});
if (modified) {
existing = filteredLines.join("\n");
writeFileSync(gitignorePath, existing, "utf-8");
}
// Parse existing lines (trimmed, ignoring comments and blanks)
const existingLines = new Set(
existing
@ -91,7 +120,7 @@ export function ensureGitignore(basePath: string): boolean {
// Find patterns not yet present
const missing = BASELINE_PATTERNS.filter((p) => !existingLines.has(p));
if (missing.length === 0) return false;
if (missing.length === 0) return modified;
// Build the block to append
const block = [
@ -117,8 +146,7 @@ export function ensureGitignore(basePath: string): boolean {
* Only removes from the index (`--cached`), never from disk. Idempotent.
*/
export function untrackRuntimeFiles(basePath: string): void {
// The GSD runtime paths are the first 7 entries in BASELINE_PATTERNS
const runtimePaths = BASELINE_PATTERNS.slice(0, 7);
const runtimePaths = GSD_RUNTIME_PATTERNS;
for (const pattern of runtimePaths) {
// Use -r for directory patterns (trailing slash), strip the slash for the command

View file

@ -700,7 +700,7 @@ export async function showSmartEntry(
): Promise<void> {
const stepMode = options?.step;
// ── Ensure git repo exists — GSD needs it for branch-per-slice ──────
// ── Ensure git repo exists — GSD needs it for worktree isolation ──────
try {
execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" });
} catch {

View file

@ -0,0 +1,162 @@
// GSD Extension — Session History View
// Human-readable display of past auto-mode unit executions.
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
import {
getLedger, getProjectTotals, formatCost, formatTokenCount,
aggregateBySlice, aggregateByPhase, aggregateByModel, loadLedgerFromDisk,
} from "./metrics.js";
import type { UnitMetrics } from "./metrics.js";
/**
* Show recent unit execution history with cost, tokens, and duration.
*/
export async function handleHistory(args: string, ctx: ExtensionCommandContext, basePath: string): Promise<void> {
const ledger = getLedger();
// If ledger is null (metrics not initialized from auto-mode), try loading from disk
let units: UnitMetrics[];
if (ledger && ledger.units.length > 0) {
units = ledger.units;
} else {
const diskLedger = loadLedgerFromDisk(basePath);
if (!diskLedger || diskLedger.units.length === 0) {
ctx.ui.notify("No history — no units have been executed yet.", "info");
return;
}
units = diskLedger.units;
}
const parsedLimit = parseInt(args.replace(/--\w+/g, "").trim(), 10);
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20;
const showCost = args.includes("--cost");
const showPhase = args.includes("--phase");
const showModel = args.includes("--model");
if (showCost) {
return showCostBreakdown(units, ctx);
}
if (showPhase) {
return showPhaseBreakdown(units, ctx);
}
if (showModel) {
return showModelBreakdown(units, ctx);
}
const display = units.slice(-limit).reverse();
const totals = getProjectTotals(units);
const lines: string[] = [
`Last ${display.length} of ${units.length} units | Total: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens`,
"",
padRight("Time", 14) + padRight("Type", 20) + padRight("ID", 16) + padRight("Model", 14) + padRight("Cost", 10) + padRight("Tokens", 10) + "Duration",
"─".repeat(98),
];
for (const u of display) {
lines.push(
padRight(formatRelativeTime(u.finishedAt), 14) +
padRight(u.type, 20) +
padRight(truncate(u.id, 15), 16) +
padRight(shortModel(u.model), 14) +
padRight(formatCost(u.cost), 10) +
padRight(formatTokenCount(u.tokens.total), 10) +
formatDuration(u.finishedAt - u.startedAt),
);
}
ctx.ui.notify(lines.join("\n"), "info");
}
function showCostBreakdown(units: UnitMetrics[], ctx: ExtensionCommandContext): void {
const slices = aggregateBySlice(units);
const lines = [
"Cost by slice:",
"",
padRight("Slice", 16) + padRight("Units", 8) + padRight("Cost", 10) + "Tokens",
"─".repeat(50),
];
for (const s of slices) {
lines.push(
padRight(s.sliceId, 16) +
padRight(String(s.units), 8) +
padRight(formatCost(s.cost), 10) +
formatTokenCount(s.tokens.total),
);
}
ctx.ui.notify(lines.join("\n"), "info");
}
function showPhaseBreakdown(units: UnitMetrics[], ctx: ExtensionCommandContext): void {
const phases = aggregateByPhase(units);
const lines = [
"Cost by phase:",
"",
padRight("Phase", 16) + padRight("Units", 8) + padRight("Cost", 10) + padRight("Tokens", 10) + "Duration",
"─".repeat(60),
];
for (const p of phases) {
lines.push(
padRight(p.phase, 16) +
padRight(String(p.units), 8) +
padRight(formatCost(p.cost), 10) +
padRight(formatTokenCount(p.tokens.total), 10) +
formatDuration(p.duration),
);
}
ctx.ui.notify(lines.join("\n"), "info");
}
function showModelBreakdown(units: UnitMetrics[], ctx: ExtensionCommandContext): void {
const models = aggregateByModel(units);
const lines = [
"Cost by model:",
"",
padRight("Model", 24) + padRight("Units", 8) + padRight("Cost", 10) + "Tokens",
"─".repeat(56),
];
for (const m of models) {
lines.push(
padRight(shortModel(m.model), 24) +
padRight(String(m.units), 8) +
padRight(formatCost(m.cost), 10) +
formatTokenCount(m.tokens.total),
);
}
ctx.ui.notify(lines.join("\n"), "info");
}
// ─── Formatting helpers ──────────────────────────────────────────────────────
export function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const secs = Math.floor(ms / 1000);
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
const remSecs = secs % 60;
if (mins < 60) return `${mins}m ${remSecs}s`;
const hours = Math.floor(mins / 60);
const remMins = mins % 60;
return `${hours}h ${remMins}m`;
}
function formatRelativeTime(timestamp: number): string {
const diff = Date.now() - timestamp;
if (diff < 60_000) return "just now";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
}
function shortModel(model: string): string {
return model.replace(/^claude-/, "").replace(/^anthropic\//, "");
}
function truncate(s: string, maxLen: number): string {
return s.length > maxLen ? s.slice(0, maxLen - 1) + "…" : s;
}
function padRight(s: string, len: number): string {
return s.length >= len ? s.slice(0, len) : s + " ".repeat(len - s.length);
}

View file

@ -347,6 +347,23 @@ function metricsPath(base: string): string {
return join(gsdRoot(base), "metrics.json");
}
/**
* Load ledger from disk without initializing in-memory state.
* Used by history/export commands outside of auto-mode.
*/
export function loadLedgerFromDisk(base: string): MetricsLedger | null {
try {
const raw = readFileSync(metricsPath(base), "utf-8");
const parsed = JSON.parse(raw);
if (parsed.version === 1 && Array.isArray(parsed.units)) {
return parsed as MetricsLedger;
}
} catch {
// File doesn't exist or is corrupt
}
return null;
}
function loadLedger(base: string): MetricsLedger {
try {
const raw = readFileSync(metricsPath(base), "utf-8");

View file

@ -0,0 +1,88 @@
// GSD Extension — Desktop Notification Helper
// Cross-platform desktop notifications for auto-mode events.
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { execFileSync } from "node:child_process";
import type { NotificationPreferences } from "./types.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
export type NotifyLevel = "info" | "success" | "warning" | "error";
export type NotificationKind = "complete" | "error" | "budget" | "milestone" | "attention";
interface NotificationCommand {
file: string;
args: string[];
}
/**
* Send a native desktop notification. Non-blocking, non-fatal.
* macOS: osascript, Linux: notify-send, Windows: skipped.
*/
export function sendDesktopNotification(
title: string,
message: string,
level: NotifyLevel = "info",
kind: NotificationKind = "complete",
): void {
if (!shouldSendDesktopNotification(kind)) return;
try {
const command = buildDesktopNotificationCommand(process.platform, title, message, level);
if (!command) return;
execFileSync(command.file, command.args, { timeout: 3000, stdio: "ignore" });
} catch {
// Non-fatal — desktop notifications are best-effort
}
}
export function shouldSendDesktopNotification(
kind: NotificationKind,
preferences: NotificationPreferences | undefined = loadEffectiveGSDPreferences()?.preferences.notifications,
): boolean {
if (preferences?.enabled === false) return false;
switch (kind) {
case "error":
return preferences?.on_error ?? true;
case "budget":
return preferences?.on_budget ?? true;
case "milestone":
return preferences?.on_milestone ?? true;
case "attention":
return preferences?.on_attention ?? true;
case "complete":
default:
return preferences?.on_complete ?? true;
}
}
export function buildDesktopNotificationCommand(
platform: NodeJS.Platform,
title: string,
message: string,
level: NotifyLevel = "info",
): NotificationCommand | null {
const normalizedTitle = normalizeNotificationText(title);
const normalizedMessage = normalizeNotificationText(message);
if (platform === "darwin") {
const sound = level === "error" ? 'sound name "Basso"' : 'sound name "Glass"';
const script = `display notification "${escapeAppleScript(normalizedMessage)}" with title "${escapeAppleScript(normalizedTitle)}" ${sound}`;
return { file: "osascript", args: ["-e", script] };
}
if (platform === "linux") {
const urgency = level === "error" ? "critical" : level === "warning" ? "normal" : "low";
return { file: "notify-send", args: ["-u", urgency, normalizedTitle, normalizedMessage] };
}
return null;
}
function normalizeNotificationText(s: string): string {
return s.replace(/\r?\n/g, " ").trim();
}
function escapeAppleScript(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}

View file

@ -3,7 +3,7 @@ import { homedir } from "node:os";
import { isAbsolute, join } from "node:path";
import { getAgentDir } from "@gsd/pi-coding-agent";
import type { GitPreferences } from "./git-service.js";
import type { PostUnitHookConfig, PreDispatchHookConfig } from "./types.js";
import type { PostUnitHookConfig, PreDispatchHookConfig, BudgetEnforcementMode, NotificationPreferences } from "./types.js";
import { VALID_BRANCH_NAME } from "./git-service.js";
const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md");
@ -92,6 +92,9 @@ export interface GSDPreferences {
uat_dispatch?: boolean;
unique_milestone_ids?: boolean;
budget_ceiling?: number;
budget_enforcement?: BudgetEnforcementMode;
context_pause_threshold?: number;
notifications?: NotificationPreferences;
remote_questions?: RemoteQuestionsConfig;
git?: GitPreferences;
post_unit_hooks?: PostUnitHookConfig[];
@ -296,6 +299,9 @@ export function renderPreferencesForSystemPrompt(preferences: GSDPreferences, re
if (validated.errors.length > 0) {
lines.push("- Validation: some preference values were ignored because they were invalid.");
}
for (const warning of validated.warnings) {
lines.push(`- Deprecation: ${warning}`);
}
preferences = validated.preferences;
@ -641,8 +647,10 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
export function validatePreferences(preferences: GSDPreferences): {
preferences: GSDPreferences;
errors: string[];
warnings: string[];
} {
const errors: string[] = [];
const warnings: string[] = [];
const validated: GSDPreferences = {};
if (preferences.version !== undefined) {
@ -729,7 +737,7 @@ export function validatePreferences(preferences: GSDPreferences): {
const knownUnitTypes = new Set([
"research-milestone", "plan-milestone", "research-slice", "plan-slice",
"execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
"run-uat", "fix-merge", "complete-milestone",
"run-uat", "complete-milestone",
]);
for (const hook of preferences.post_unit_hooks) {
if (!hook || typeof hook !== "object") {
@ -795,7 +803,7 @@ export function validatePreferences(preferences: GSDPreferences): {
const knownUnitTypes = new Set([
"research-milestone", "plan-milestone", "research-slice", "plan-slice",
"execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
"run-uat", "fix-merge", "complete-milestone",
"run-uat", "complete-milestone",
]);
const validActions = new Set(["modify", "skip", "replace"]);
for (const hook of preferences.pre_dispatch_hooks) {
@ -909,21 +917,13 @@ export function validatePreferences(preferences: GSDPreferences): {
errors.push("git.main_branch must be a valid branch name (alphanumeric, _, -, /, .)");
}
}
// Deprecated: isolation and merge_to_main are ignored (branchless architecture).
// Emit warnings so users know to remove them from preferences.
if (g.isolation !== undefined) {
const validIsolation = new Set(["worktree", "branch"]);
if (typeof g.isolation === "string" && validIsolation.has(g.isolation)) {
git.isolation = g.isolation as "worktree" | "branch";
} else {
errors.push("git.isolation must be one of: worktree, branch");
}
warnings.push("git.isolation is deprecated — worktree isolation is now always enabled. Remove this setting.");
}
if (g.merge_to_main !== undefined) {
const validMergeToMain = new Set(["milestone", "slice"]);
if (typeof g.merge_to_main === "string" && validMergeToMain.has(g.merge_to_main)) {
git.merge_to_main = g.merge_to_main as "milestone" | "slice";
} else {
errors.push("git.merge_to_main must be one of: milestone, slice");
}
warnings.push("git.merge_to_main is deprecated — milestone-level merge is now always used. Remove this setting.");
}
if (Object.keys(git).length > 0) {
@ -931,7 +931,7 @@ export function validatePreferences(preferences: GSDPreferences): {
}
}
return { preferences: validated, errors };
return { preferences: validated, errors, warnings };
}
function mergeStringLists(base?: unknown, override?: unknown): string[] | undefined {

View file

@ -91,13 +91,19 @@ Do not count the reflection step as a question round. Rounds start after reflect
## Depth Verification
Before moving to the wrap-up gate, present a structured depth summary to the user via `ask_user_questions`. This is a checkpoint — show what you captured across the depth checklist dimensions, using the user's own terminology and framing.
Before moving to the wrap-up gate, present a structured depth summary as a checkpoint.
The question should summarize: what you understood them to be building, what shaped your understanding most (their emphasis, constraints, concerns), and any areas where you're least confident in your understanding. Frame it as: "Before we move to planning, here's what I captured — did I get the depth right?"
**Print the summary as normal chat text first** — this is where the formatting renders properly. Structure the summary across the depth checklist dimensions using the user's own terminology and framing. Cover: what you understood them to be building, what shaped your understanding most (their emphasis, constraints, concerns), and any areas where you're least confident in your understanding.
**Convention:** The question ID must contain `depth_verification` (e.g., `depth_verification_summary`). This naming convention enables downstream mechanical detection of this step.
**Then** use `ask_user_questions` with a short confirmation question — NOT the summary itself. The question field is designed for single sentences, not multi-paragraph summaries.
Offer two options: "Yes, you got it (Recommended)" and "Not quite — let me clarify." If they clarify, absorb the correction and re-verify.
**Convention:** The question ID must contain `depth_verification` (e.g., `depth_verification_confirm`). This naming convention enables downstream mechanical detection of this step.
Example flow:
1. Print in chat: the full depth summary with markdown formatting (headers, bold, bullets)
2. Call `ask_user_questions` with: header "Depth Check", question "Did I capture the depth right?", options "Yes, you got it (Recommended)" and "Not quite — let me clarify"
If they clarify, absorb the correction and re-verify.
## Wrap-up Gate
@ -215,6 +221,20 @@ Once the user confirms the milestone split:
5. Write a full `CONTEXT.md` for the primary milestone (the one discussed in depth).
6. Write a `ROADMAP.md` for **only the primary milestone** — detail-planning later milestones now is waste because the codebase will change. Include requirement coverage and a milestone definition of done.
#### MANDATORY: depends_on Frontmatter in CONTEXT.md
Every CONTEXT.md for a milestone that depends on other milestones MUST have YAML frontmatter with `depends_on`. The auto-mode state machine reads this field to determine execution order — without it, milestones may execute out of order or in parallel when they shouldn't.
```yaml
---
depends_on: [M001, M002]
---
# M003: Title
```
If a milestone has no dependencies, omit the frontmatter. The dependency chain from the milestone confirmation gate MUST be reflected in each CONTEXT.md frontmatter. Do NOT rely on QUEUE.md or PROJECT.md for dependency tracking — the state machine only reads CONTEXT.md frontmatter.
#### Phase 3: Sequential readiness gate for remaining milestones
For each remaining milestone **one at a time, in sequence**, use `ask_user_questions` to assess readiness. Present three options:

View file

@ -82,7 +82,13 @@ Determine where the new milestones should go in the overall sequence. Consider d
Once the user is satisfied, in a single pass for **each** new milestone (starting from {{nextId}}):
1. `mkdir -p .gsd/milestones/<ID>/slices`
2. Write `.gsd/milestones/<ID>/<ID>-CONTEXT.md` — use the **Context** output template below. Capture intent, scope, risks, constraints, integration points, and relevant requirements. Mark the status as "Queued — pending auto-mode execution."
2. Write `.gsd/milestones/<ID>/<ID>-CONTEXT.md` — use the **Context** output template below. Capture intent, scope, risks, constraints, integration points, and relevant requirements. Mark the status as "Queued — pending auto-mode execution." **If this milestone depends on other milestones, add YAML frontmatter with `depends_on`:**
```yaml
---
depends_on: [M001, M002]
---
```
The auto-mode state machine reads this field to enforce execution order. Without it, milestones may execute out of order. List the exact milestone IDs (including any suffix like `-0zjrg0`) from the dependency chain discussed with the user.
Then, after all milestone directories and context files are written:

View file

@ -29,7 +29,7 @@ import {
resolveGsdRootFile,
gsdRoot,
} from './paths.js';
import { getActiveSliceBranch } from './worktree.js';
import { milestoneIdSort, findMilestoneIds } from './guided-flow.js';
import { nativeBatchParseGsdFiles, type BatchParsedFile } from './native-parser-bridge.js';
@ -438,8 +438,6 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
};
}
const activeBranch = getActiveSliceBranch(basePath);
// Check if the slice has a plan
const planFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "PLAN");
const slicePlanContent = planFile ? await cachedLoadFile(planFile) : null;
@ -453,7 +451,7 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
recentDecisions: [],
blockers: [],
nextAction: `Plan slice ${activeSlice.id} (${activeSlice.title}).`,
activeBranch: activeBranch ?? undefined,
registry,
requirements,
progress: {
@ -480,7 +478,7 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
recentDecisions: [],
blockers: [],
nextAction: `All tasks done in ${activeSlice.id}. Write slice summary and complete slice.`,
activeBranch: activeBranch ?? undefined,
registry,
requirements,
progress: {
@ -501,7 +499,7 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
recentDecisions: [],
blockers: [],
nextAction: `Slice ${activeSlice.id} has a plan file but no tasks. Add tasks to the plan.`,
activeBranch: activeBranch ?? undefined,
registry,
requirements,
progress: {
@ -547,7 +545,7 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
recentDecisions: [],
blockers: [`Task ${blockerTaskId} discovered a blocker requiring slice replan`],
nextAction: `Task ${blockerTaskId} reported blocker_discovered. Replan slice ${activeSlice.id} before continuing.`,
activeBranch: activeBranch ?? undefined,
activeWorkspace: undefined,
registry,
requirements,
@ -578,7 +576,6 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
nextAction: hasInterrupted
? `Resume interrupted work on ${activeTask.id}: ${activeTask.title} in slice ${activeSlice.id}. Read continue.md first.`
: `Execute ${activeTask.id}: ${activeTask.title} in slice ${activeSlice.id}.`,
activeBranch: activeBranch ?? undefined,
registry,
requirements,
progress: {

View file

@ -4,7 +4,6 @@
**Active Slice:** {{sliceId}}: {{sliceTitle}}
**Active Task:** {{taskId}}: {{taskTitle}}
**Phase:** {{phase}}
**Slice Branch:** {{activeBranch}}
**Active Workspace:** {{activeWorkspace}}
**Next Action:** {{nextAction}}
**Last Updated:** {{date}}

View file

@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getBudgetAlertLevel,
getBudgetEnforcementAction,
getNewBudgetAlertLevel,
} from "../auto.js";
test("getBudgetAlertLevel returns the expected threshold bucket", () => {
assert.equal(getBudgetAlertLevel(0.10), 0);
assert.equal(getBudgetAlertLevel(0.75), 75);
assert.equal(getBudgetAlertLevel(0.89), 75);
assert.equal(getBudgetAlertLevel(0.90), 90);
assert.equal(getBudgetAlertLevel(1.00), 100);
});
test("getNewBudgetAlertLevel only emits once per threshold", () => {
assert.equal(getNewBudgetAlertLevel(0, 0.74), null);
assert.equal(getNewBudgetAlertLevel(0, 0.75), 75);
assert.equal(getNewBudgetAlertLevel(75, 0.80), null);
assert.equal(getNewBudgetAlertLevel(75, 0.90), 90);
assert.equal(getNewBudgetAlertLevel(90, 0.95), null);
assert.equal(getNewBudgetAlertLevel(90, 1.0), 100);
assert.equal(getNewBudgetAlertLevel(100, 1.2), null);
});
test("getBudgetEnforcementAction maps the configured ceiling behavior", () => {
assert.equal(getBudgetEnforcementAction("warn", 0.99), "none");
assert.equal(getBudgetEnforcementAction("warn", 1.0), "warn");
assert.equal(getBudgetEnforcementAction("pause", 1.0), "pause");
assert.equal(getBudgetEnforcementAction("halt", 1.0), "halt");
});

View file

@ -1,282 +0,0 @@
/**
* auto-worktree-merge.test.ts Integration tests for mergeSliceToMilestone.
*
* Covers: --no-ff merge topology, rich commit messages, slice branch deletion,
* zero-commit error, real code conflicts, .gsd/ non-conflict in worktree mode.
* All tests use real git operations in temp repos.
*/
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import {
createAutoWorktree,
teardownAutoWorktree,
mergeSliceToMilestone,
} from "../auto-worktree.ts";
import { MergeConflictError } from "../git-service.ts";
import { getSliceBranchName } from "../worktree.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
function run(cmd: string, cwd: string): string {
return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
}
function createTempRepo(): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-merge-test-")));
run("git init", dir);
run("git config user.email test@test.com", dir);
run("git config user.name Test", dir);
writeFileSync(join(dir, "README.md"), "# test\n");
mkdirSync(join(dir, ".gsd"), { recursive: true });
writeFileSync(join(dir, ".gsd", "STATE.md"), "# State\n");
run("git add .", dir);
run("git commit -m init", dir);
run("git branch -M main", dir);
return dir;
}
/** Create a slice branch in the worktree, add commits, return branch name. */
function setupSliceBranch(
wtPath: string,
milestoneId: string,
sliceId: string,
commits: Array<{ file: string; content: string; message: string }>,
): string {
// Detect worktree name for branch naming
const normalizedPath = wtPath.replaceAll("\\", "/");
const marker = "/.gsd/worktrees/";
const idx = normalizedPath.indexOf(marker);
const worktreeName = idx !== -1 ? normalizedPath.slice(idx + marker.length).split("/")[0] : null;
const sliceBranch = getSliceBranchName(milestoneId, sliceId, worktreeName);
run(`git checkout -b ${sliceBranch}`, wtPath);
for (const c of commits) {
writeFileSync(join(wtPath, c.file), c.content);
run("git add .", wtPath);
run(`git commit -m "${c.message}"`, wtPath);
}
return sliceBranch;
}
async function main(): Promise<void> {
const savedCwd = process.cwd();
const tempDirs: string[] = [];
function freshRepo(): string {
const d = createTempRepo();
tempDirs.push(d);
return d;
}
try {
// ─── Test 1: Single slice --no-ff merge ────────────────────────────
console.log("\n=== single slice --no-ff merge ===");
{
const repo = freshRepo();
const wtPath = createAutoWorktree(repo, "M003");
const sliceBranch = setupSliceBranch(wtPath, "M003", "S01", [
{ file: "a.ts", content: "const a = 1;\n", message: "add a.ts" },
{ file: "b.ts", content: "const b = 2;\n", message: "add b.ts" },
{ file: "c.ts", content: "const c = 3;\n", message: "add c.ts" },
]);
run("git checkout milestone/M003", wtPath);
const result = mergeSliceToMilestone(repo, "M003", "S01", "Add core files");
// Verify we're back on milestone branch
const branch = run("git branch --show-current", wtPath);
assertEq(branch, "milestone/M003", "back on milestone branch after merge");
// Verify merge topology via git log --graph
const log = run("git log --oneline --graph", wtPath);
assertTrue(log.includes("* "), "merge commit visible in graph (asterisk with two parents)");
assertTrue(log.includes("add a.ts"), "slice commit 'add a.ts' visible");
assertTrue(log.includes("add b.ts"), "slice commit 'add b.ts' visible");
assertTrue(log.includes("add c.ts"), "slice commit 'add c.ts' visible");
// Verify commit message format
assertMatch(result.mergedCommitMessage, /feat\(M003\/S01\)/, "commit message has conventional format");
assertTrue(result.mergedCommitMessage.includes("Add core files"), "commit message includes slice title");
// Verify slice branch deleted
assertTrue(result.deletedBranch, "slice branch deleted");
const branches = run("git branch", wtPath);
assertTrue(!branches.includes(sliceBranch), "slice branch no longer in git branch list");
teardownAutoWorktree(repo, "M003");
}
// ─── Test 2: Two sequential slices ─────────────────────────────────
console.log("\n=== two sequential slices ===");
{
const repo = freshRepo();
const wtPath = createAutoWorktree(repo, "M003");
// Slice S01
setupSliceBranch(wtPath, "M003", "S01", [
{ file: "s1.ts", content: "export const s1 = 1;\n", message: "s1 work" },
]);
run("git checkout milestone/M003", wtPath);
mergeSliceToMilestone(repo, "M003", "S01", "First slice");
// Slice S02
setupSliceBranch(wtPath, "M003", "S02", [
{ file: "s2.ts", content: "export const s2 = 2;\n", message: "s2 work" },
]);
run("git checkout milestone/M003", wtPath);
mergeSliceToMilestone(repo, "M003", "S02", "Second slice");
// Verify two merge boundaries
const log = run("git log --oneline --graph", wtPath);
const mergeLines = log.split("\n").filter(l => l.includes("* "));
assertTrue(mergeLines.length >= 2, "two distinct merge commits in graph");
assertTrue(log.includes("s1 work"), "S01 commit visible");
assertTrue(log.includes("s2 work"), "S02 commit visible");
teardownAutoWorktree(repo, "M003");
}
// ─── Test 3: Zero commits throws ───────────────────────────────────
console.log("\n=== zero commits throws ===");
{
const repo = freshRepo();
const wtPath = createAutoWorktree(repo, "M003");
// Create slice branch with no commits ahead
const normalizedPath = wtPath.replaceAll("\\", "/");
const marker = "/.gsd/worktrees/";
const idx = normalizedPath.indexOf(marker);
const worktreeName = idx !== -1 ? normalizedPath.slice(idx + marker.length).split("/")[0] : null;
const sliceBranch = getSliceBranchName("M003", "S01", worktreeName);
run(`git checkout -b ${sliceBranch}`, wtPath);
// No commits — immediately try to merge
run(`git checkout milestone/M003`, wtPath);
let threw = false;
try {
mergeSliceToMilestone(repo, "M003", "S01", "Empty slice");
} catch (err) {
threw = true;
assertTrue(
err instanceof Error && err.message.includes("no commits ahead"),
"error message mentions no commits ahead",
);
}
assertTrue(threw, "mergeSliceToMilestone throws on zero commits");
teardownAutoWorktree(repo, "M003");
}
// ─── Test 4: Real code conflict throws MergeConflictError ──────────
console.log("\n=== real code conflict throws MergeConflictError ===");
{
const repo = freshRepo();
const wtPath = createAutoWorktree(repo, "M003");
// Add a file on milestone branch
writeFileSync(join(wtPath, "shared.ts"), "// version 1\n");
run("git add .", wtPath);
run('git commit -m "add shared.ts"', wtPath);
// Create slice branch, modify same file differently
const normalizedPath = wtPath.replaceAll("\\", "/");
const marker = "/.gsd/worktrees/";
const idx = normalizedPath.indexOf(marker);
const worktreeName = idx !== -1 ? normalizedPath.slice(idx + marker.length).split("/")[0] : null;
const sliceBranch = getSliceBranchName("M003", "S01", worktreeName);
run(`git checkout -b ${sliceBranch}`, wtPath);
writeFileSync(join(wtPath, "shared.ts"), "// slice version\nexport const x = 1;\n");
run("git add .", wtPath);
run('git commit -m "slice edit shared.ts"', wtPath);
// Modify same file on milestone branch
run("git checkout milestone/M003", wtPath);
writeFileSync(join(wtPath, "shared.ts"), "// milestone version\nexport const y = 2;\n");
run("git add .", wtPath);
run('git commit -m "milestone edit shared.ts"', wtPath);
// Go back to milestone branch for merge call
run("git checkout milestone/M003", wtPath);
let caught: MergeConflictError | null = null;
try {
mergeSliceToMilestone(repo, "M003", "S01", "Conflicting slice");
} catch (err) {
if (err instanceof MergeConflictError) {
caught = err;
} else {
throw err;
}
}
assertTrue(caught !== null, "MergeConflictError thrown on conflict");
if (caught) {
assertTrue(caught.conflictedFiles.includes("shared.ts"), "conflictedFiles includes shared.ts");
assertEq(caught.strategy, "merge", "strategy is merge");
assertTrue(caught.branch.includes("S01"), "branch includes S01");
}
// Clean up conflict state before teardown
run("git merge --abort || true", wtPath);
run("git checkout milestone/M003", wtPath);
teardownAutoWorktree(repo, "M003");
}
// ─── Test 5: .gsd/ changes don't conflict ─────────────────────────
console.log("\n=== .gsd/ changes don't conflict ===");
{
const repo = freshRepo();
const wtPath = createAutoWorktree(repo, "M003");
// The .gsd/ directory in worktrees is local — it's not shared via git
// between the main repo and the worktree. So modifications to .gsd/
// files in both branches shouldn't cause conflicts because .gsd/ is
// in the main repo's tree but the worktree has its own working copy.
//
// In the worktree, .gsd/ IS tracked (inherited from main). But since
// slice branches diverge from milestone branch, .gsd/ changes on both
// can conflict. The key insight: in real auto-mode, .gsd/ changes only
// happen on the milestone branch (planning artifacts), not on slice
// branches (which only have code changes). So we test that code-only
// slice commits merge cleanly even when milestone has .gsd/ changes.
// Add a .gsd/ change on milestone branch
writeFileSync(join(wtPath, ".gsd", "STATE.md"), "# Updated State\nactive: M003\n");
run("git add .", wtPath);
run('git commit -m "update .gsd/STATE.md on milestone"', wtPath);
// Create slice branch with code-only changes
setupSliceBranch(wtPath, "M003", "S01", [
{ file: "feature.ts", content: "export const feature = true;\n", message: "add feature" },
]);
run("git checkout milestone/M003", wtPath);
// Merge should succeed — no .gsd/ conflict since slice didn't touch .gsd/
const result = mergeSliceToMilestone(repo, "M003", "S01", "Feature slice");
assertTrue(result.branch.includes("S01"), ".gsd/ no-conflict merge succeeded");
assertTrue(result.deletedBranch, "slice branch deleted after .gsd/-safe merge");
// Verify feature file exists after merge
assertTrue(existsSync(join(wtPath, "feature.ts")), "feature.ts present after merge");
teardownAutoWorktree(repo, "M003");
}
} finally {
process.chdir(savedCwd);
for (const d of tempDirs) {
if (existsSync(d)) rmSync(d, { recursive: true, force: true });
}
}
report();
}
main();

View file

@ -14,7 +14,6 @@ import { execSync } from "node:child_process";
import {
createAutoWorktree,
mergeMilestoneToMain,
mergeSliceToMilestone,
getAutoWorktreeOriginalBase,
} from "../auto-worktree.ts";
import { getSliceBranchName } from "../worktree.ts";
@ -71,7 +70,9 @@ function addSliceToMilestone(
run(`git commit -m "${c.message}"`, wtPath);
}
run(`git checkout milestone/${milestoneId}`, wtPath);
mergeSliceToMilestone(repo, milestoneId, sliceId, sliceTitle);
run(`git merge --no-ff ${sliceBranch} -m "feat(${milestoneId}/${sliceId}): ${sliceTitle}"`, wtPath);
// Clean up the slice branch
run(`git branch -d ${sliceBranch}`, wtPath);
}
async function main(): Promise<void> {

View file

@ -6,17 +6,14 @@
*/
import { execSync } from "node:child_process";
import { existsSync, mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rmSync } from "node:fs";
import assert from "node:assert/strict";
import {
abortAndReset,
withMergeHeal,
recoverCheckout,
formatGitError,
MergeConflictError,
} from "../git-self-heal.js";
// ─── Helpers ─────────────────────────────────────────────────────────
@ -105,107 +102,6 @@ console.log("── abortAndReset ──");
}
}
// ─── withMergeHeal ───────────────────────────────────────────────────
console.log("── withMergeHeal ──");
// Test: transient failure succeeds on retry
{
const dir = makeTempRepo();
try {
let callCount = 0;
const result = withMergeHeal(dir, () => {
callCount++;
if (callCount === 1) throw new Error("transient git error");
return "success";
});
assert.strictEqual(result, "success", "should return mergeFn result on retry");
assert.strictEqual(callCount, 2, "should have called mergeFn twice");
console.log(" ✓ transient failure succeeds on retry");
} finally {
cleanup(dir);
}
}
// Test: real conflict escalates immediately (no retry)
{
const dir = makeTempRepo();
try {
// Set up a real merge conflict
execSync("git checkout -b conflict-branch", { cwd: dir, stdio: "pipe" });
writeFileSync(join(dir, "conflict.txt"), "branch A\n");
execSync("git add -A && git commit -m \"branch A\"", { cwd: dir, stdio: "pipe" });
execSync("git checkout main", { cwd: dir, stdio: "pipe" });
writeFileSync(join(dir, "conflict.txt"), "branch B\n");
execSync("git add -A && git commit -m \"branch B\"", { cwd: dir, stdio: "pipe" });
let callCount = 0;
try {
withMergeHeal(dir, () => {
callCount++;
// Actually perform the conflicting merge
execSync("git merge conflict-branch", { cwd: dir, stdio: "pipe" });
});
assert.fail("should have thrown MergeConflictError");
} catch (err) {
assert.ok(err instanceof MergeConflictError, `should throw MergeConflictError, got ${(err as Error).constructor.name}`);
assert.strictEqual(callCount, 1, "should NOT retry on real conflict");
}
console.log(" ✓ real conflict escalates immediately without retry");
} finally {
cleanup(dir);
}
}
// ─── recoverCheckout ─────────────────────────────────────────────────
console.log("── recoverCheckout ──");
// Test: dirty index recovery
{
const dir = makeTempRepo();
try {
// Create a branch to checkout to
execSync("git checkout -b target-branch", { cwd: dir, stdio: "pipe" });
execSync("git checkout main", { cwd: dir, stdio: "pipe" });
// Dirty the index
writeFileSync(join(dir, "README.md"), "dirty changes\n");
execSync("git add README.md", { cwd: dir, stdio: "pipe" });
// Normal checkout would complain about dirty index
recoverCheckout(dir, "target-branch");
const branch = execSync("git branch --show-current", { cwd: dir, encoding: "utf-8" }).trim();
assert.strictEqual(branch, "target-branch", "should be on target branch after recovery");
console.log(" ✓ recovers checkout with dirty index");
} finally {
cleanup(dir);
}
}
// Test: non-existent branch throws with context
{
const dir = makeTempRepo();
try {
try {
recoverCheckout(dir, "nonexistent-branch");
assert.fail("should have thrown");
} catch (err) {
assert.ok((err as Error).message.includes("recoverCheckout failed"), "should include context in error");
assert.ok((err as Error).message.includes("nonexistent-branch"), "should mention branch name");
}
console.log(" ✓ throws with context for non-existent branch");
} finally {
cleanup(dir);
}
}
// ─── formatGitError ──────────────────────────────────────────────────
console.log("── formatGitError ──");

View file

@ -13,7 +13,6 @@ import {
writeIntegrationBranch,
type GitPreferences,
type CommitOptions,
type MergeSliceResult,
type PreMergeCheckResult,
} from "../git-service.ts";
import { createTestContext } from './test-helpers.ts';
@ -195,8 +194,8 @@ async function main(): Promise<void> {
assertEq(
RUNTIME_EXCLUSION_PATHS.length,
7,
"exactly 7 runtime exclusion paths"
9,
"exactly 9 runtime exclusion paths"
);
const expectedPaths = [
@ -207,6 +206,8 @@ async function main(): Promise<void> {
".gsd/metrics.json",
".gsd/completed-units.json",
".gsd/STATE.md",
".gsd/gsd.db",
".gsd/DISCUSSION-MANIFEST.json",
];
assertEq(
@ -261,10 +262,8 @@ async function main(): Promise<void> {
// These are compile-time checks — if we got here, the types import fine
const _prefs: GitPreferences = { auto_push: true, remote: "origin" };
const _opts: CommitOptions = { message: "test" };
const _result: MergeSliceResult = { branch: "main", mergedCommitMessage: "msg", deletedBranch: false };
assertTrue(true, "GitPreferences type exported and usable");
assertTrue(true, "CommitOptions type exported and usable");
assertTrue(true, "MergeSliceResult type exported and usable");
// Cleanup T01 temp dir
rmSync(tempDir, { recursive: true, force: true });
@ -534,7 +533,7 @@ async function main(): Promise<void> {
return dir;
}
// ─── getCurrentBranch / isOnSliceBranch / getActiveSliceBranch ─────────
// ─── getCurrentBranch ────────────────────────────────────────────────
console.log("\n=== Branch queries ===");
@ -542,21 +541,13 @@ async function main(): Promise<void> {
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// On main
assertEq(svc.getCurrentBranch(), "main", "getCurrentBranch returns main on main branch");
assertEq(svc.isOnSliceBranch(), false, "isOnSliceBranch returns false on main");
assertEq(svc.getActiveSliceBranch(), null, "getActiveSliceBranch returns null on main");
// Create and checkout a slice branch manually
run("git checkout -b gsd/M001/S01", repo);
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "getCurrentBranch returns slice branch name");
assertEq(svc.isOnSliceBranch(), true, "isOnSliceBranch returns true on slice branch");
assertEq(svc.getActiveSliceBranch(), "gsd/M001/S01", "getActiveSliceBranch returns branch name on slice branch");
// Non-slice feature branch
run("git checkout -b feature/foo", repo);
assertEq(svc.isOnSliceBranch(), false, "isOnSliceBranch returns false on non-slice branch");
assertEq(svc.getActiveSliceBranch(), null, "getActiveSliceBranch returns null on non-slice branch");
assertEq(svc.getCurrentBranch(), "feature/foo", "getCurrentBranch returns feature branch name");
rmSync(repo, { recursive: true, force: true });
}
@ -591,486 +582,8 @@ async function main(): Promise<void> {
rmSync(repo, { recursive: true, force: true });
}
// ─── ensureSliceBranch: creates and checks out ────────────────────────
console.log("\n=== ensureSliceBranch ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
const created = svc.ensureSliceBranch("M001", "S01");
assertEq(created, true, "ensureSliceBranch returns true on first call (branch created)");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "ensureSliceBranch checks out the slice branch");
rmSync(repo, { recursive: true, force: true });
}
// ─── ensureSliceBranch: idempotent ────────────────────────────────────
console.log("\n=== ensureSliceBranch: idempotent ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
svc.ensureSliceBranch("M001", "S01");
const secondCall = svc.ensureSliceBranch("M001", "S01");
assertEq(secondCall, false, "ensureSliceBranch returns false when already on the branch");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "still on slice branch after idempotent call");
rmSync(repo, { recursive: true, force: true });
}
// ─── ensureSliceBranch: from non-main working branch inherits artifacts ──
console.log("\n=== ensureSliceBranch: from non-main inherits artifacts ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// Create a feature branch with planning artifacts
run("git checkout -b developer", repo);
createFile(repo, ".gsd/milestones/M001/M001-ROADMAP.md", "# Roadmap");
run("git add -A", repo);
run('git commit -m "add roadmap"', repo);
// ensureSliceBranch from this non-main, non-slice branch
const created = svc.ensureSliceBranch("M001", "S01");
assertEq(created, true, "branch created from non-main working branch");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "checked out to slice branch");
// The roadmap from developer branch should be present
const logOutput = run("git log --oneline", repo);
assertTrue(logOutput.includes("add roadmap"), "slice branch inherits artifacts from working branch");
rmSync(repo, { recursive: true, force: true });
}
// ─── ensureSliceBranch: from another slice branch falls back to main ──
console.log("\n=== ensureSliceBranch: from slice branch falls back to main ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// Create file only on main
createFile(repo, "main-only.txt", "from main");
run("git add -A", repo);
run('git commit -m "main-only file"', repo);
// Create and check out S01
svc.ensureSliceBranch("M001", "S01");
// Add a file only on S01
createFile(repo, "s01-only.txt", "from s01");
run("git add -A", repo);
run('git commit -m "S01 work"', repo);
// Now create S02 from S01 — should fall back to main
const created = svc.ensureSliceBranch("M001", "S02");
assertEq(created, true, "S02 branch created from S01 (fell back to main)");
assertEq(svc.getCurrentBranch(), "gsd/M001/S02", "on S02 branch");
// S02 should NOT have the S01-only file (it branched from main)
const showFiles = run("git ls-files", repo);
assertTrue(!showFiles.includes("s01-only.txt"), "S02 does not have S01-only files (branched from main)");
assertTrue(showFiles.includes("main-only.txt"), "S02 has main files");
rmSync(repo, { recursive: true, force: true });
}
// ─── ensureSliceBranch: auto-commits dirty files via smart staging ────
console.log("\n=== ensureSliceBranch: auto-commits with smart staging ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// Create dirty files: both real and runtime
createFile(repo, "src/feature.ts", "export const y = 2;");
createFile(repo, ".gsd/activity/session.jsonl", "session data");
createFile(repo, ".gsd/STATE.md", "# Current State");
createFile(repo, ".gsd/metrics.json", '{"tasks":1}');
// ensureSliceBranch should auto-commit before checkout
svc.ensureSliceBranch("M001", "S01");
// The auto-commit on main should have src/feature.ts but NOT runtime files
run("git checkout main", repo);
const showStat = run("git show --stat --format= HEAD", repo);
assertTrue(showStat.includes("src/feature.ts"), "auto-commit includes real files");
assertTrue(!showStat.includes(".gsd/activity"), "auto-commit excludes .gsd/activity/ (smart staging)");
assertTrue(!showStat.includes("STATE.md"), "auto-commit excludes .gsd/STATE.md (smart staging)");
assertTrue(!showStat.includes("metrics.json"), "auto-commit excludes .gsd/metrics.json (smart staging)");
rmSync(repo, { recursive: true, force: true });
}
// ─── ensureSliceBranch: tracked STATE.md + dirty (regression: "local changes overwritten") ─
//
// Reproduces: "error: Your local changes to the following files would be overwritten
// by checkout: .gsd/STATE.md" that occurred in gsd auto when STATE.md was historically
// committed to the repo (before it was added to .gitignore).
console.log("\n=== ensureSliceBranch: tracked STATE.md + dirty (checkout conflict regression) ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// Simulate historical state: STATE.md was committed before gitignore was configured
createFile(repo, ".gsd/STATE.md", "# State v1");
run("git add -f .gsd/STATE.md", repo);
run('git commit -m "add state (pre-gitignore)"', repo);
// STATE.md gets modified during runtime (dirty)
createFile(repo, ".gsd/STATE.md", "# State v2 (modified at runtime)");
// ensureSliceBranch must not fail with "local changes would be overwritten"
svc.ensureSliceBranch("M001", "S01");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "checked out slice branch despite tracked+dirty STATE.md");
rmSync(repo, { recursive: true, force: true });
}
// ─── ensureSliceBranch: untracked STATE.md blocks checkout (regression: cleanup-commit edge case) ─
//
// Reproduces: "The following untracked working tree files would be overwritten by checkout:
// .gsd/STATE.md" when the smartStage cleanup commit removes STATE.md from the current
// branch's HEAD but the target branch was already created from the old HEAD (so it still
// has STATE.md tracked). Without discardUntrackedRuntimeFiles(), the untracked STATE.md
// on disk would block the checkout.
console.log("\n=== ensureSliceBranch: untracked runtime files blocked by target branch (cleanup-commit edge case) ===");
{
const repo = initBranchTestRepo();
// Simulate: STATE.md is tracked in main's HEAD (historical state)
createFile(repo, ".gsd/STATE.md", "# State original");
run("git add -f .gsd/STATE.md", repo);
run('git commit -m "initial with tracked STATE.md"', repo);
// Simulate what smartStage one-time cleanup does: remove STATE.md from index and commit.
// This leaves STATE.md on disk but removes it from main's HEAD.
run("git rm --cached .gsd/STATE.md", repo);
run('git commit -m "chore: untrack runtime files"', repo);
// STATE.md exists on disk (modified) but is now untracked in main's HEAD
createFile(repo, ".gsd/STATE.md", "# State modified after cleanup");
// Create slice branch — this is what ensureSliceBranch does internally but we
// simulate a GitServiceImpl that has already done the cleanup commit.
// The slice branch is created from the OLD HEAD (before cleanup commit) so it HAS
// STATE.md tracked. Without discardUntrackedRuntimeFiles(), the checkout would fail.
run("git branch gsd/M001/S01 HEAD~1", repo); // branch from HEAD~1 = the commit that had STATE.md
// Now use GitServiceImpl to switch to the already-existing slice branch
const svc = new GitServiceImpl(repo);
// ensureSliceBranch must succeed despite the untracked STATE.md on disk
// conflicting with the tracked STATE.md in the target branch
svc.ensureSliceBranch("M001", "S01");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "checked out slice branch (untracked runtime file removed before checkout)");
rmSync(repo, { recursive: true, force: true });
}
// ─── switchToMain: tracked STATE.md + dirty (regression) ─────────────
console.log("\n=== switchToMain: tracked STATE.md + dirty (checkout conflict regression) ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// Track STATE.md on main (historical pre-gitignore state)
createFile(repo, ".gsd/STATE.md", "# State on main");
run("git add -f .gsd/STATE.md", repo);
run('git commit -m "add state (pre-gitignore)"', repo);
// Create slice branch (inherits STATE.md from main)
svc.ensureSliceBranch("M001", "S01");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "on slice branch before switchToMain");
// Modify STATE.md on slice branch (runtime update)
createFile(repo, ".gsd/STATE.md", "# State updated on slice branch");
// switchToMain must not fail with "local changes would be overwritten"
svc.switchToMain();
assertEq(svc.getCurrentBranch(), svc.getMainBranch(), "back on main after switchToMain despite tracked+dirty STATE.md");
rmSync(repo, { recursive: true, force: true });
}
// ─── switchToMain ─────────────────────────────────────────────────────
console.log("\n=== switchToMain ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// Switch to a slice branch first
svc.ensureSliceBranch("M001", "S01");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "on slice branch before switchToMain");
// Create dirty files
createFile(repo, "src/work.ts", "work in progress");
createFile(repo, ".gsd/activity/log.jsonl", "activity log");
createFile(repo, ".gsd/runtime/state.json", '{"running":true}');
svc.switchToMain();
assertEq(svc.getCurrentBranch(), "main", "switchToMain switches to main");
// Verify the auto-commit on the slice branch used smart staging
const sliceLog = run("git log gsd/M001/S01 --oneline -1", repo);
assertTrue(sliceLog.includes("pre-switch"), "auto-commit message includes pre-switch");
// Check that the auto-commit on the slice branch excluded runtime files
const showStat = run("git log gsd/M001/S01 -1 --format= --stat", repo);
assertTrue(showStat.includes("src/work.ts"), "switchToMain auto-commit includes real files");
assertTrue(!showStat.includes(".gsd/activity"), "switchToMain auto-commit excludes .gsd/activity/");
assertTrue(!showStat.includes(".gsd/runtime"), "switchToMain auto-commit excludes .gsd/runtime/");
rmSync(repo, { recursive: true, force: true });
}
// ─── switchToMain: idempotent when already on main ─────────────────────
console.log("\n=== switchToMain: idempotent ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
assertEq(svc.getCurrentBranch(), "main", "already on main");
svc.switchToMain(); // Should not throw
assertEq(svc.getCurrentBranch(), "main", "still on main after idempotent switchToMain");
// Verify no extra commits were created
const logCount = run("git rev-list --count HEAD", repo);
assertEq(logCount, "1", "no extra commits from idempotent switchToMain");
rmSync(repo, { recursive: true, force: true });
}
// ─── mergeSliceToMain: full lifecycle with feat ─────────────────────────
console.log("\n=== mergeSliceToMain: full lifecycle ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// Create and switch to slice branch
svc.ensureSliceBranch("M001", "S01");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "on slice branch for merge test");
// Do work on the slice branch
createFile(repo, "src/feature.ts", "export const feature = true;");
svc.commit({ message: "add feature module" });
// Switch to main and merge
svc.switchToMain();
const result = svc.mergeSliceToMain("M001", "S01", "Implement user authentication");
assertEq(result.mergedCommitMessage, "feat(M001/S01): Implement user authentication", "merge commit message uses feat type");
assertEq(result.deletedBranch, true, "branch was deleted");
assertEq(result.branch, "gsd/M001/S01", "result includes branch name");
// Verify commit is on main
const log = run("git log --oneline -1", repo);
assertTrue(log.includes("feat(M001/S01): Implement user authentication"), "merge commit visible in git log");
// Verify the file is on main
const files = run("git ls-files", repo);
assertTrue(files.includes("src/feature.ts"), "merged file exists on main");
// Verify slice branch is deleted
const branches = run("git branch", repo);
assertTrue(!branches.includes("gsd/M001/S01"), "slice branch deleted after merge");
rmSync(repo, { recursive: true, force: true });
}
// ─── mergeSliceToMain: fix type ───────────────────────────────────────
console.log("\n=== mergeSliceToMain: fix type ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
svc.ensureSliceBranch("M001", "S02");
createFile(repo, "src/bugfix.ts", "// fixed");
svc.commit({ message: "fix the bug" });
svc.switchToMain();
const result = svc.mergeSliceToMain("M001", "S02", "Fix broken config");
assertTrue(result.mergedCommitMessage.startsWith("fix("), "merge commit starts with fix(");
assertEq(result.mergedCommitMessage, "fix(M001/S02): Fix broken config", "fix merge commit message correct");
rmSync(repo, { recursive: true, force: true });
}
// ─── mergeSliceToMain: docs type ──────────────────────────────────────
console.log("\n=== mergeSliceToMain: docs type ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
svc.ensureSliceBranch("M001", "S03");
createFile(repo, "docs/guide.md", "# Guide");
svc.commit({ message: "write docs" });
svc.switchToMain();
const result = svc.mergeSliceToMain("M001", "S03", "Docs update");
assertTrue(result.mergedCommitMessage.startsWith("docs("), "merge commit starts with docs(");
assertEq(result.mergedCommitMessage, "docs(M001/S03): Docs update", "docs merge commit message correct");
rmSync(repo, { recursive: true, force: true });
}
// ─── mergeSliceToMain: refactor type ──────────────────────────────────
console.log("\n=== mergeSliceToMain: refactor type ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
svc.ensureSliceBranch("M001", "S04");
createFile(repo, "src/refactored.ts", "// cleaner");
svc.commit({ message: "restructure modules" });
svc.switchToMain();
const result = svc.mergeSliceToMain("M001", "S04", "Refactor state management");
assertTrue(result.mergedCommitMessage.startsWith("refactor("), "merge commit starts with refactor(");
assertEq(result.mergedCommitMessage, "refactor(M001/S04): Refactor state management", "refactor merge commit message correct");
rmSync(repo, { recursive: true, force: true });
}
// ─── mergeSliceToMain: error — not on main ────────────────────────────
console.log("\n=== mergeSliceToMain: error cases ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// Create a slice branch with a commit
svc.ensureSliceBranch("M001", "S01");
createFile(repo, "src/work.ts", "work");
svc.commit({ message: "slice work" });
// Try to merge while still on the slice branch
let threw = false;
try {
svc.mergeSliceToMain("M001", "S01", "Some feature");
} catch (e) {
threw = true;
const msg = (e as Error).message;
assertTrue(msg.includes("must be called from the main branch"), "error mentions main branch requirement");
assertTrue(msg.includes("gsd/M001/S01"), "error includes current branch name");
}
assertTrue(threw, "mergeSliceToMain throws when not on main");
rmSync(repo, { recursive: true, force: true });
}
// ─── mergeSliceToMain: error — branch doesn't exist ───────────────────
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
let threw = false;
try {
svc.mergeSliceToMain("M001", "S99", "Nonexistent");
} catch (e) {
threw = true;
const msg = (e as Error).message;
assertTrue(msg.includes("does not exist"), "error mentions branch does not exist");
assertTrue(msg.includes("gsd/M001/S99"), "error includes missing branch name");
}
assertTrue(threw, "mergeSliceToMain throws when branch doesn't exist");
rmSync(repo, { recursive: true, force: true });
}
// ─── mergeSliceToMain: error — no commits ahead ───────────────────────
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// Create slice branch but don't add any commits
svc.ensureSliceBranch("M001", "S01");
// Switch back to main without committing anything on the slice branch
svc.switchToMain();
let threw = false;
try {
svc.mergeSliceToMain("M001", "S01", "Empty slice");
} catch (e) {
threw = true;
const msg = (e as Error).message;
assertTrue(msg.includes("no commits ahead"), "error mentions no commits ahead");
assertTrue(msg.includes("gsd/M001/S01"), "error includes branch name");
}
assertTrue(threw, "mergeSliceToMain throws when no commits ahead");
rmSync(repo, { recursive: true, force: true });
}
// ─── mergeSliceToMain: auto-resolve .gsd/ planning artifact conflicts ──
console.log("\n=== mergeSliceToMain: auto-resolve .gsd/ planning conflicts ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// Create a .gsd/ planning artifact on main (simulates reassess-roadmap)
createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Original decision\n");
run("git add -A", repo);
run('git commit -m "add decisions on main"', repo);
// Create slice branch and modify the same .gsd/ file differently
svc.ensureSliceBranch("M001", "S01");
createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Original decision\n- D002: New decision from slice\n");
createFile(repo, "src/feature.ts", "export const x = 1;");
run("git add -A", repo);
run('git commit -m "slice work with .gsd/ changes"', repo);
// Back on main, modify the same .gsd/ file to create a conflict
svc.switchToMain();
createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Updated decision on main\n");
run("git add -A", repo);
run('git commit -m "update decisions on main"', repo);
// Merge should auto-resolve .gsd/ conflicts by taking theirs (slice branch)
const result = svc.mergeSliceToMain("M001", "S01", "Feature with .gsd/ conflicts");
assertEq(result.deletedBranch, true, ".gsd/ conflict auto-resolved: branch deleted");
// Verify the merge succeeded and src file is present
assertTrue(existsSync(join(repo, "src/feature.ts")), ".gsd/ conflict auto-resolved: src file merged");
rmSync(repo, { recursive: true, force: true });
}
// ═══════════════════════════════════════════════════════════════════════
// S05: Enhanced features — merge guards, snapshots, auto-push, rich commits
// S05: Enhanced features — snapshots, pre-merge checks
// ═══════════════════════════════════════════════════════════════════════
// ─── createSnapshot: prefs enabled ─────────────────────────────────────
@ -1081,12 +594,12 @@ async function main(): Promise<void> {
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo, { snapshots: true });
// Create a slice branch with a commit
svc.ensureSliceBranch("M001", "S01");
// Create a branch with a commit
run("git checkout -b gsd/M001/S01", repo);
createFile(repo, "src/snap.ts", "snapshot me");
svc.commit({ message: "snapshot test commit" });
// Create snapshot ref for this slice branch
// Create snapshot ref for this branch
svc.createSnapshot("gsd/M001/S01");
// Verify ref exists under refs/gsd/snapshots/
@ -1104,7 +617,7 @@ async function main(): Promise<void> {
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo, { snapshots: false });
svc.ensureSliceBranch("M001", "S01");
run("git checkout -b gsd/M001/S01", repo);
createFile(repo, "src/no-snap.ts", "no snapshot");
svc.commit({ message: "no snapshot commit" });
@ -1201,222 +714,6 @@ async function main(): Promise<void> {
rmSync(repo, { recursive: true, force: true });
}
// ─── Rich commit message ──────────────────────────────────────────────
console.log("\n=== mergeSliceToMain: rich commit message ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo, { pre_merge_check: false });
svc.ensureSliceBranch("M001", "S01");
// Make 3 distinct commits on the slice branch
createFile(repo, "src/auth.ts", "export const auth = true;");
svc.commit({ message: "add auth module" });
createFile(repo, "src/login.ts", "export const login = true;");
svc.commit({ message: "add login page" });
createFile(repo, "src/session.ts", "export const session = true;");
svc.commit({ message: "add session handling" });
svc.switchToMain();
const result = svc.mergeSliceToMain("M001", "S01", "Implement user authentication");
// Inspect the full commit body on main
const commitBody = run("git log -1 --format=%B", repo);
// Rich commit should have the subject line
assertTrue(commitBody.includes("feat(M001/S01): Implement user authentication"),
"rich commit has conventional subject line");
// Rich commit body should include task list with commit subjects
assertTrue(commitBody.includes("add auth module"),
"rich commit body includes first commit subject");
assertTrue(commitBody.includes("add login page"),
"rich commit body includes second commit subject");
assertTrue(commitBody.includes("add session handling"),
"rich commit body includes third commit subject");
// Rich commit body should include Branch: line for forensics
assertTrue(commitBody.includes("Branch:"),
"rich commit body includes Branch: line");
assertTrue(commitBody.includes("gsd/M001/S01"),
"rich commit body Branch: line includes slice branch name");
rmSync(repo, { recursive: true, force: true });
}
// ─── Auto-push: enabled ───────────────────────────────────────────────
console.log("\n=== Auto-push: enabled ===");
{
// Create a bare remote repo
const bareDir = mkdtempSync(join(tmpdir(), "gsd-git-bare-"));
run("git init --bare -b main", bareDir);
// Create local repo and add the bare as remote
const repo = initBranchTestRepo();
run(`git remote add origin ${bareDir}`, repo);
run("git push -u origin main", repo);
const svc = new GitServiceImpl(repo, { auto_push: true, pre_merge_check: false });
svc.ensureSliceBranch("M001", "S01");
createFile(repo, "src/pushed.ts", "export const pushed = true;");
svc.commit({ message: "work to push" });
svc.switchToMain();
svc.mergeSliceToMain("M001", "S01", "Add pushed feature");
// Verify the remote has the merge commit
const remoteLog = run(`git --git-dir=${bareDir} log --oneline -1`, bareDir);
assertTrue(remoteLog.includes("Add pushed feature"),
"auto-push: remote has the merge commit when auto_push is true");
rmSync(repo, { recursive: true, force: true });
rmSync(bareDir, { recursive: true, force: true });
}
// ─── Auto-push: disabled ──────────────────────────────────────────────
console.log("\n=== Auto-push: disabled ===");
{
const bareDir = mkdtempSync(join(tmpdir(), "gsd-git-bare-"));
run("git init --bare -b main", bareDir);
const repo = initBranchTestRepo();
run(`git remote add origin ${bareDir}`, repo);
run("git push -u origin main", repo);
// auto_push explicitly false (or omitted — same behavior)
const svc = new GitServiceImpl(repo, { auto_push: false, pre_merge_check: false });
svc.ensureSliceBranch("M001", "S01");
createFile(repo, "src/not-pushed.ts", "export const notPushed = true;");
svc.commit({ message: "work not pushed" });
svc.switchToMain();
svc.mergeSliceToMain("M001", "S01", "Add unpushed feature");
// Remote should NOT have the new merge commit — still at the initial push
const remoteLog = run(`git --git-dir=${bareDir} log --oneline`, bareDir);
assertTrue(!remoteLog.includes("Add unpushed feature"),
"auto-push: remote does NOT have merge commit when auto_push is false");
rmSync(repo, { recursive: true, force: true });
rmSync(bareDir, { recursive: true, force: true });
}
// ─── Remote fetch before branching: with remote ────────────────────────
console.log("\n=== Remote fetch: with remote ===");
{
const bareDir = mkdtempSync(join(tmpdir(), "gsd-git-bare-"));
run("git init --bare -b main", bareDir);
const repo = initBranchTestRepo();
run(`git remote add origin ${bareDir}`, repo);
run("git push -u origin main", repo);
// Add a commit to the remote via a temporary clone
const cloneDir = mkdtempSync(join(tmpdir(), "gsd-git-clone-"));
run(`git clone ${bareDir} ${cloneDir}`, cloneDir);
run('git config user.name "Remote Dev"', cloneDir);
run('git config user.email "remote@example.com"', cloneDir);
createFile(cloneDir, "remote-file.txt", "from remote");
run("git add -A", cloneDir);
run('git commit -m "remote commit"', cloneDir);
run("git push origin main", cloneDir);
// ensureSliceBranch should fetch before creating the branch — no crash
const svc = new GitServiceImpl(repo);
let noError = true;
try {
svc.ensureSliceBranch("M001", "S01");
} catch {
noError = false;
}
assertTrue(noError, "ensureSliceBranch succeeds when remote has new commits (fetch runs)");
rmSync(repo, { recursive: true, force: true });
rmSync(bareDir, { recursive: true, force: true });
rmSync(cloneDir, { recursive: true, force: true });
}
// ─── Remote fetch before branching: without remote ─────────────────────
console.log("\n=== Remote fetch: without remote ===");
{
const repo = initBranchTestRepo();
// No remote configured — ensureSliceBranch should not crash
const svc = new GitServiceImpl(repo);
let noError = true;
try {
svc.ensureSliceBranch("M001", "S01");
} catch {
noError = false;
}
assertTrue(noError, "ensureSliceBranch succeeds when no remote is configured");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "branch created even without remote");
rmSync(repo, { recursive: true, force: true });
}
// ─── Facade prefs: mergeSliceToMain creates snapshot when prefs set ────
console.log("\n=== Facade prefs: snapshot via merge with prefs ===");
{
const repo = initBranchTestRepo();
// Simulate facade behavior: GitServiceImpl with snapshots:true should
// create a snapshot ref during mergeSliceToMain
const svc = new GitServiceImpl(repo, { snapshots: true, pre_merge_check: false });
svc.ensureSliceBranch("M001", "S01");
createFile(repo, "src/facade-test.ts", "facade");
svc.commit({ message: "facade test commit" });
svc.switchToMain();
svc.mergeSliceToMain("M001", "S01", "Facade snapshot test");
// After merge, a snapshot ref should exist (created before merge)
const refs = run("git for-each-ref refs/gsd/snapshots/", repo);
assertTrue(refs.includes("refs/gsd/snapshots/"), "mergeSliceToMain creates snapshot when prefs.snapshots is true");
assertTrue(refs.includes("gsd/M001/S01"), "snapshot ref references the slice branch name");
rmSync(repo, { recursive: true, force: true });
}
// ─── Facade prefs: no snapshot when prefs omit snapshots ───────────────
console.log("\n=== Facade prefs: no snapshot when prefs omit snapshots ===");
{
const repo = initBranchTestRepo();
// Default prefs — snapshots not enabled
const svc = new GitServiceImpl(repo, { pre_merge_check: false });
svc.ensureSliceBranch("M001", "S01");
createFile(repo, "src/no-facade-snap.ts", "no facade snap");
svc.commit({ message: "no facade snapshot" });
svc.switchToMain();
svc.mergeSliceToMain("M001", "S01", "No snapshot test");
// No snapshot ref should exist
const refs = run("git for-each-ref refs/gsd/snapshots/", repo);
assertEq(refs, "", "no snapshot ref when snapshots pref is not set");
rmSync(repo, { recursive: true, force: true });
}
// ─── VALID_BRANCH_NAME regex ──────────────────────────────────────────
console.log("\n=== VALID_BRANCH_NAME regex ===");
@ -1628,62 +925,6 @@ async function main(): Promise<void> {
rmSync(repo, { recursive: true, force: true });
}
// ─── End-to-end: feature branch workflow ──────────────────────────────
console.log("\n=== End-to-end: feature branch workflow ===");
{
const repo = initBranchTestRepo();
// Simulate: user creates feature branch and starts GSD
run("git checkout -b f-123-new-thing", repo);
createFile(repo, "setup.txt", "initial setup");
run("git add -A", repo);
run('git commit -m "initial feature setup"', repo);
// Record integration branch (this is what auto.ts does at startup)
writeIntegrationBranch(repo, "M001", "f-123-new-thing");
// Create GitServiceImpl with milestone set
const svc = new GitServiceImpl(repo);
svc.setMilestoneId("M001");
// Verify getMainBranch returns the feature branch, not "main"
assertEq(svc.getMainBranch(), "f-123-new-thing", "e2e: getMainBranch returns feature branch");
// Create slice branch — should branch from f-123-new-thing (current)
svc.ensureSliceBranch("M001", "S01");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "e2e: slice branch created");
// The slice branch should have the feature branch's commit
const log = run("git log --oneline", repo);
assertTrue(log.includes("initial feature setup"), "e2e: slice branch inherits feature branch content");
// Do work on the slice branch
createFile(repo, "src/feature.ts", "export const feature = true;");
svc.commit({ message: "feat: add feature module" });
// switchToMain should go to feature branch
svc.switchToMain();
assertEq(svc.getCurrentBranch(), "f-123-new-thing", "e2e: switchToMain goes to feature branch, not main");
// mergeSliceToMain should merge into feature branch
const result = svc.mergeSliceToMain("M001", "S01", "Add feature module");
assertEq(result.mergedCommitMessage, "feat(M001/S01): Add feature module", "e2e: merge commit message correct");
assertEq(svc.getCurrentBranch(), "f-123-new-thing", "e2e: after merge, still on feature branch");
// The feature branch should have the merged work
const files = run("git ls-files", repo);
assertTrue(files.includes("src/feature.ts"), "e2e: merged file exists on feature branch");
// Main should NOT have the merged work
run("git checkout main", repo);
const mainFiles = run("git ls-files", repo);
assertTrue(!mainFiles.includes("src/feature.ts"), "e2e: main does NOT have merged work — it stays on the feature branch");
rmSync(repo, { recursive: true, force: true });
}
// ─── Per-milestone isolation: different milestones, different targets ──
console.log("\n=== Integration branch: per-milestone isolation ===");

View file

@ -280,119 +280,6 @@ function cleanup(base: string): void {
}
}
// ═══ verifyExpectedArtifact: fix-merge ════════════════════════════════════════
/** Create a real git repo for fix-merge tests */
function createGitBase(): string {
const base = mkdtempSync(join(tmpdir(), "gsd-fixmerge-test-"));
execSync("git init -b main", { cwd: base, stdio: "ignore" });
execSync("git config user.email test@test.com", { cwd: base, stdio: "ignore" });
execSync("git config user.name Test", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "README.md"), "init\n", "utf-8");
execSync("git add -A && git commit -m init", { cwd: base, stdio: "ignore" });
// Create .gsd structure for the fixture
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
return base;
}
{
console.log("\n=== verifyExpectedArtifact: fix-merge — clean repo returns true ===");
const base = createGitBase();
try {
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assertTrue(result === true, "clean repo should verify as true");
} finally {
cleanup(base);
}
}
{
console.log("\n=== verifyExpectedArtifact: fix-merge — MERGE_HEAD present returns false ===");
const base = createGitBase();
try {
writeFileSync(join(base, ".git", "MERGE_HEAD"), "abc123\n", "utf-8");
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assertTrue(result === false, "MERGE_HEAD present should return false");
} finally {
cleanup(base);
}
}
{
console.log("\n=== verifyExpectedArtifact: fix-merge — SQUASH_MSG present returns false ===");
const base = createGitBase();
try {
writeFileSync(join(base, ".git", "SQUASH_MSG"), "squash msg\n", "utf-8");
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assertTrue(result === false, "SQUASH_MSG present should return false");
} finally {
cleanup(base);
}
}
{
console.log("\n=== verifyExpectedArtifact: fix-merge — real UU conflict returns false ===");
const base = createGitBase();
try {
// Create a conflict: modify same file on two branches
writeFileSync(join(base, "conflict.txt"), "main content\n", "utf-8");
execSync('git add -A && git commit -m "main change"', { cwd: base, stdio: "ignore" });
execSync("git checkout -b feature", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "conflict.txt"), "feature content\n", "utf-8");
execSync('git add -A && git commit -m "feature change"', { cwd: base, stdio: "ignore" });
execSync("git checkout main", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "conflict.txt"), "different main content\n", "utf-8");
execSync('git add -A && git commit -m "diverge"', { cwd: base, stdio: "ignore" });
try { execSync("git merge feature", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ }
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assertTrue(result === false, "UU conflict should return false");
} finally {
execSync("git reset --hard HEAD", { cwd: base, stdio: "ignore" });
cleanup(base);
}
}
{
console.log("\n=== verifyExpectedArtifact: fix-merge — real DU conflict returns false ===");
const base = createGitBase();
try {
writeFileSync(join(base, "deleted.txt"), "content\n", "utf-8");
execSync('git add -A && git commit -m "add file"', { cwd: base, stdio: "ignore" });
execSync("git checkout -b feature2", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "deleted.txt"), "modified on feature\n", "utf-8");
execSync('git add -A && git commit -m "modify on feature"', { cwd: base, stdio: "ignore" });
execSync("git checkout main", { cwd: base, stdio: "ignore" });
execSync("git rm deleted.txt", { cwd: base, stdio: "ignore" });
execSync('git commit -m "delete on main"', { cwd: base, stdio: "ignore" });
try { execSync("git merge feature2", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ }
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assertTrue(result === false, "DU conflict should return false");
} finally {
execSync("git reset --hard HEAD", { cwd: base, stdio: "ignore" });
cleanup(base);
}
}
{
console.log("\n=== verifyExpectedArtifact: fix-merge — real AA conflict returns false ===");
const base = createGitBase();
try {
execSync("git checkout -b branch-a", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "both.txt"), "branch-a content\n", "utf-8");
execSync('git add -A && git commit -m "add on branch-a"', { cwd: base, stdio: "ignore" });
execSync("git checkout main", { cwd: base, stdio: "ignore" });
execSync("git checkout -b branch-b", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "both.txt"), "branch-b content\n", "utf-8");
execSync('git add -A && git commit -m "add on branch-b"', { cwd: base, stdio: "ignore" });
try { execSync("git merge branch-a", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ }
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assertTrue(result === false, "AA conflict should return false");
} finally {
execSync("git reset --hard HEAD", { cwd: base, stdio: "ignore" });
cleanup(base);
}
}
// ═══ verifyExpectedArtifact: complete-slice roadmap check ════════════════════
// Regression for #indefinite-hang: complete-slice must verify roadmap [x] or
// the idempotency skip loops forever after a crash that wrote SUMMARY+UAT but

View file

@ -6,7 +6,7 @@
* Uses real filesystem and git fixtures no mocking.
*/
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
@ -16,12 +16,8 @@ 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';
import { clearPathCache } from '../paths.ts';
import { createTestContext } from './test-helpers.ts';
@ -481,84 +477,22 @@ Built the legacy feature successfully.
}
}
// ─── Group 6: Branch operations with new-format IDs ─────────────────
console.log('\n=== Group 6: Branch operations with new-format IDs ===');
// ─── Group 6: Branch name helpers with new-format IDs ───────────────
console.log('\n=== Group 6: Branch name helpers 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
// Test getSliceBranchName with new-format ID
assertEq(
getSliceBranchName('M001-abc123', 'S01'),
'gsd/M001-abc123/S01',
'G6: getSliceBranchName returns gsd/M001-abc123/S01',
);
**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');
assertTrue(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');
assertTrue(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');
assertTrue(merge.deletedBranch, 'G6: merge deleted the slice branch');
// Verify the merged content exists on main
const content = readFileSync(join(base, 'feature.txt'), 'utf-8');
assertTrue(content.includes('new feature from slice'), 'G6: merged content on main');
// Verify branch is gone
const branches = run('git branch', base);
assertTrue(!branches.includes('gsd/M001-abc123/S01'), 'G6: slice branch deleted after merge');
} finally {
cleanup(base);
}
// Test parseSliceBranch with new-format branch name
const parsed = parseSliceBranch('gsd/M001-abc123/S01');
assertTrue(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)');
}
// ─── Summary ──────────────────────────────────────────────────────────

View file

@ -1,107 +0,0 @@
/**
* isolation-resolver.test.ts -- Tests for shouldUseWorktreeIsolation resolver.
*
* Tests three resolution paths:
* 1. Explicit git.isolation preference overrides everything
* 2. Legacy detection: existing gsd/*\/* branches = branch mode
* 3. Default: new project = worktree mode
*/
import { mkdtempSync, writeFileSync, rmSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { shouldUseWorktreeIsolation } from "../auto-worktree.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, report } = createTestContext();
function run(command: string, cwd: string): string {
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
}
function createTempRepo(): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "iso-resolver-test-")));
run("git init", dir);
run("git config user.email test@test.com", dir);
run("git config user.name Test", dir);
writeFileSync(join(dir, "README.md"), "# test\n");
run("git add .", dir);
run("git commit -m init", dir);
run("git branch -M main", dir);
return dir;
}
async function main(): Promise<void> {
const savedCwd = process.cwd();
console.log("\n=== shouldUseWorktreeIsolation ===");
// Test 1: New project with no gsd branches → defaults to worktree (true)
{
const dir = createTempRepo();
try {
const result = shouldUseWorktreeIsolation(dir);
assertEq(result, true, "new project defaults to worktree isolation");
} finally {
process.chdir(savedCwd);
rmSync(dir, { recursive: true, force: true });
}
}
// Test 2: Legacy project with gsd/*/* branches → returns false (branch mode)
{
const dir = createTempRepo();
try {
// Create a legacy gsd/*/* branch
run("git checkout -b gsd/M001/S01", dir);
writeFileSync(join(dir, "slice.md"), "# S01\n");
run("git add .", dir);
run("git commit -m \"slice work\"", dir);
run("git checkout main", dir);
const result = shouldUseWorktreeIsolation(dir);
assertEq(result, false, "legacy project with gsd branches → branch mode");
} finally {
process.chdir(savedCwd);
rmSync(dir, { recursive: true, force: true });
}
}
// Test 3: Explicit preference override -- isolation: "worktree"
{
const dir = createTempRepo();
try {
// Create legacy branches that would normally trigger branch mode
run("git checkout -b gsd/M001/S01", dir);
writeFileSync(join(dir, "slice.md"), "# S01\n");
run("git add .", dir);
run("git commit -m \"slice work\"", dir);
run("git checkout main", dir);
const result = shouldUseWorktreeIsolation(dir, { isolation: "worktree" });
assertEq(result, true, "explicit isolation: worktree overrides legacy detection");
} finally {
process.chdir(savedCwd);
rmSync(dir, { recursive: true, force: true });
}
}
// Test 4: Explicit preference override -- isolation: "branch"
{
const dir = createTempRepo();
try {
// No legacy branches -- would normally default to worktree
const result = shouldUseWorktreeIsolation(dir, { isolation: "branch" });
assertEq(result, false, "explicit isolation: branch overrides default");
} finally {
process.chdir(savedCwd);
rmSync(dir, { recursive: true, force: true });
}
}
report();
}
main();

View file

@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildDesktopNotificationCommand,
shouldSendDesktopNotification,
} from "../notifications.js";
import type { NotificationPreferences } from "../types.js";
test("shouldSendDesktopNotification honors granular preferences", () => {
const prefs: NotificationPreferences = {
enabled: true,
on_complete: false,
on_error: true,
on_budget: false,
on_milestone: true,
on_attention: false,
};
assert.equal(shouldSendDesktopNotification("complete", prefs), false);
assert.equal(shouldSendDesktopNotification("error", prefs), true);
assert.equal(shouldSendDesktopNotification("budget", prefs), false);
assert.equal(shouldSendDesktopNotification("milestone", prefs), true);
assert.equal(shouldSendDesktopNotification("attention", prefs), false);
});
test("shouldSendDesktopNotification disables all categories when notifications are disabled", () => {
const prefs: NotificationPreferences = { enabled: false, on_error: true, on_milestone: true };
assert.equal(shouldSendDesktopNotification("error", prefs), false);
assert.equal(shouldSendDesktopNotification("milestone", prefs), false);
});
test("buildDesktopNotificationCommand uses argument arrays for macOS notifications", () => {
const command = buildDesktopNotificationCommand(
"darwin",
`Bob's "Milestone"`,
`Budget!\nPath: C:\\temp`,
"error",
);
assert.ok(command);
assert.equal(command.file, "osascript");
assert.deepEqual(command.args.slice(0, 1), ["-e"]);
assert.match(command.args[1], /Bob's \\"Milestone\\"/);
assert.match(command.args[1], /Budget! Path: C:\\\\temp/);
assert.doesNotMatch(command.args[1], /\n/);
});
test("buildDesktopNotificationCommand preserves literal shell characters on linux", () => {
const command = buildDesktopNotificationCommand(
"linux",
`Bob's $PATH !`,
"line 1\nline 2",
"warning",
);
assert.ok(command);
assert.deepEqual(command, {
file: "notify-send",
args: ["-u", "normal", `Bob's $PATH !`, "line 1 line 2"],
});
});
test("buildDesktopNotificationCommand skips unsupported platforms", () => {
assert.equal(buildDesktopNotificationCommand("win32", "Title", "Message"), null);
});

View file

@ -1,353 +0,0 @@
/**
* Tests for orphaned completed slice branch detection.
*
* Verifies the git operations and detection logic that mergeOrphanedSliceBranches
* in auto.ts relies on without importing auto.ts (which requires @gsd/pi-coding-agent).
* Uses execSync directly and roadmap-slices.ts (no pi-coding-agent dep) to replicate
* the detection logic.
*/
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { execSync, execFileSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { relMilestoneFile } from "../paths.ts";
import { parseRoadmapSlices } from "../roadmap-slices.ts";
// Inline SLICE_BRANCH_RE and parseSliceBranch to avoid importing worktree.ts,
// which transitively imports preferences.ts → @gsd/pi-coding-agent (not available in tests).
const SLICE_BRANCH_RE = /^gsd\/(?:([a-zA-Z0-9_-]+)\/)?(M\d+)\/(S\d+)$/;
function parseSliceBranch(
branchName: string,
): { worktreeName: string | null; milestoneId: string; sliceId: string } | null {
const match = branchName.match(SLICE_BRANCH_RE);
if (!match) return null;
return { worktreeName: match[1] ?? null, milestoneId: match[2]!, sliceId: match[3]! };
}
let passed = 0;
let failed = 0;
function assert(condition: boolean, message: string): void {
if (condition) {
passed++;
} else {
failed++;
console.error(` FAIL: ${message}`);
}
}
function assertEq<T>(actual: T, expected: T, message: string): void {
if (JSON.stringify(actual) === JSON.stringify(expected)) {
passed++;
} else {
failed++;
console.error(
` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
function run(command: string, cwd: string): string {
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
}
function git(base: string, args: string[]): string {
try {
return execFileSync("git", args, {
cwd: base,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
}).trim();
} catch {
return "";
}
}
/**
* Replicate the core orphan-detection logic from mergeOrphanedSliceBranches
* in auto.ts using only paths.ts + roadmap-slices.ts + execSync (no pi-coding-agent deps).
* Returns a list of orphaned branch descriptors.
*/
function detectOrphanedSliceBranches(base: string): Array<{
branch: string;
milestoneId: string;
sliceId: string;
sliceTitle: string;
}> {
const orphans: Array<{
branch: string;
milestoneId: string;
sliceId: string;
sliceTitle: string;
}> = [];
const branchListRaw = git(base, ["branch", "--list", "gsd/*/*", "--format=%(refname:short)"]);
if (!branchListRaw) return orphans;
const branches = branchListRaw.split("\n").map(b => b.trim()).filter(Boolean);
for (const branch of branches) {
const parsed = parseSliceBranch(branch);
// Skip worktree-namespaced branches
if (!parsed || parsed.worktreeName) continue;
const { milestoneId, sliceId } = parsed;
// Skip if already merged (no commits ahead of main)
const aheadCount = git(base, ["rev-list", "--count", `main..${branch}`]);
if (!aheadCount || aheadCount === "0") continue;
// Read roadmap from the slice branch
const roadmapRelPath = relMilestoneFile(base, milestoneId, "ROADMAP");
const roadmapContent = git(base, ["show", `${branch}:${roadmapRelPath}`]);
if (!roadmapContent) continue;
const slices = parseRoadmapSlices(roadmapContent);
const sliceEntry = slices.find(s => s.id === sliceId);
if (!sliceEntry?.done) continue;
orphans.push({
branch,
milestoneId,
sliceId,
sliceTitle: sliceEntry.title || sliceId,
});
}
return orphans;
}
// ─── Setup helpers ─────────────────────────────────────────────────────────
function initRepo(): string {
const repo = mkdtempSync(join(tmpdir(), "gsd-orphan-test-"));
run("git init -b main", repo);
run("git config user.email test@example.com", repo);
run("git config user.name Test", repo);
return repo;
}
function writeBaseArtifacts(repo: string): void {
mkdirSync(join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), {
recursive: true,
});
writeFileSync(
join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
[
"# M001: Demo",
"",
"## Slices",
"- [ ] **S01: First Slice** `risk:low` `depends:[]`",
" > After this: feature works",
"",
].join("\n"),
);
writeFileSync(
join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
"# S01: First Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [x] **T01: Task** `est:5m`\n do it\n",
);
run("git add .", repo);
run('git commit -m "chore: milestone base"', repo);
}
function writeCompletedArtifactsOnBranch(repo: string): void {
writeFileSync(
join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
[
"# M001: Demo",
"",
"## Slices",
"- [x] **S01: First Slice** `risk:low` `depends:[]`",
" > After this: feature works",
"",
].join("\n"),
);
writeFileSync(
join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"),
"# S01: First Slice\n\nDone.\n",
);
writeFileSync(
join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md"),
"# UAT\n\nPassed.\n",
);
run("git add .", repo);
run('git commit -m "feat(M001/S01): complete-slice"', repo);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
console.log("\n=== parseSliceBranch: plain branch ===");
{
const parsed = parseSliceBranch("gsd/M001/S01");
assert(parsed !== null, "plain branch parsed");
assertEq(parsed?.milestoneId, "M001", "milestone ID extracted");
assertEq(parsed?.sliceId, "S01", "slice ID extracted");
assertEq(parsed?.worktreeName, null, "no worktree name for plain branch");
}
console.log("\n=== parseSliceBranch: worktree-namespaced branch ===");
{
const parsed = parseSliceBranch("gsd/wt1/M001/S01");
assert(parsed !== null, "worktree branch parsed");
assertEq(parsed?.milestoneId, "M001", "milestone ID extracted from worktree branch");
assertEq(parsed?.sliceId, "S01", "slice ID extracted from worktree branch");
assertEq(parsed?.worktreeName, "wt1", "worktree name extracted");
}
console.log("\n=== parseSliceBranch: non-slice branch not matched ===");
{
assert(parseSliceBranch("main") === null, "main branch not matched");
assert(parseSliceBranch("gsd/M001") === null, "bare milestone branch not matched");
assert(!SLICE_BRANCH_RE.test("gsd/M001"), "bare milestone branch not matched by regex");
assert(SLICE_BRANCH_RE.test("gsd/M001/S01"), "standard slice branch matched by regex");
}
console.log("\n=== orphan detection: no slice branches ===");
{
const repo = initRepo();
writeBaseArtifacts(repo);
const orphans = detectOrphanedSliceBranches(repo);
assertEq(orphans.length, 0, "no orphans when no slice branches exist");
rmSync(repo, { recursive: true, force: true });
}
console.log("\n=== orphan detection: slice branch not done ===");
{
const repo = initRepo();
writeBaseArtifacts(repo);
run("git checkout -b gsd/M001/S01", repo);
writeFileSync(
join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-RESEARCH.md"),
"# Research\n",
);
run("git add .", repo);
run('git commit -m "feat: research"', repo);
run("git checkout main", repo);
const orphans = detectOrphanedSliceBranches(repo);
assertEq(orphans.length, 0, "incomplete slice branch is not reported as orphan");
rmSync(repo, { recursive: true, force: true });
}
console.log("\n=== orphan detection: completed slice branch (orphaned) ===");
{
const repo = initRepo();
writeBaseArtifacts(repo);
run("git checkout -b gsd/M001/S01", repo);
writeCompletedArtifactsOnBranch(repo);
// Return to main without merging — this is the orphaned branch scenario
run("git checkout main", repo);
const orphans = detectOrphanedSliceBranches(repo);
assertEq(orphans.length, 1, "completed but unmerged branch detected as orphan");
assertEq(orphans[0]?.branch, "gsd/M001/S01", "correct branch name reported");
assertEq(orphans[0]?.milestoneId, "M001", "correct milestone ID");
assertEq(orphans[0]?.sliceId, "S01", "correct slice ID");
assertEq(orphans[0]?.sliceTitle, "First Slice", "correct slice title");
rmSync(repo, { recursive: true, force: true });
}
console.log("\n=== orphan detection: already merged branch is not orphan ===");
{
const repo = initRepo();
writeBaseArtifacts(repo);
run("git checkout -b gsd/M001/S01", repo);
writeCompletedArtifactsOnBranch(repo);
run("git checkout main", repo);
run("git merge --squash gsd/M001/S01", repo);
run('git commit -m "feat(M001/S01): merge"', repo);
run("git branch -D gsd/M001/S01", repo);
const orphans = detectOrphanedSliceBranches(repo);
assertEq(orphans.length, 0, "already-merged branch is not detected as orphan");
rmSync(repo, { recursive: true, force: true });
}
console.log("\n=== orphan detection: worktree-namespaced branch is skipped ===");
{
const repo = initRepo();
writeBaseArtifacts(repo);
// gsd/wt1/M001/S01 — worktree-namespaced branches are managed by the worktree
// manager and must not be merged by the main-tree orphan check.
run("git checkout -b gsd/wt1/M001/S01", repo);
writeCompletedArtifactsOnBranch(repo);
run("git checkout main", repo);
const orphans = detectOrphanedSliceBranches(repo);
assertEq(orphans.length, 0, "worktree-namespaced branch not detected by main-tree orphan check");
rmSync(repo, { recursive: true, force: true });
}
console.log("\n=== orphan detection: relMilestoneFile resolves roadmap path for git show ===");
{
const repo = initRepo();
writeBaseArtifacts(repo);
run("git checkout -b gsd/M001/S01", repo);
writeCompletedArtifactsOnBranch(repo);
run("git checkout main", repo);
// Simulate what mergeOrphanedSliceBranches does: read roadmap from branch
const roadmapRelPath = relMilestoneFile(repo, "M001", "ROADMAP");
const roadmapOnBranch = git(repo, ["show", `gsd/M001/S01:${roadmapRelPath}`]);
assert(roadmapOnBranch.length > 0, "roadmap readable from orphaned branch via git show");
const slices = parseRoadmapSlices(roadmapOnBranch);
const s01 = slices.find(s => s.id === "S01");
assert(s01?.done === true, "slice marked done on orphaned branch");
rmSync(repo, { recursive: true, force: true });
}
console.log("\n=== orphan merge: squash-merge resolves orphan, artifacts appear on main ===");
{
const repo = initRepo();
writeBaseArtifacts(repo);
run("git checkout -b gsd/M001/S01", repo);
writeCompletedArtifactsOnBranch(repo);
run("git checkout main", repo);
const orphansBefore = detectOrphanedSliceBranches(repo);
assertEq(orphansBefore.length, 1, "orphan detected before merge");
// Perform squash-merge (as mergeOrphanedSliceBranches does via mergeSliceToMain)
run("git merge --squash gsd/M001/S01", repo);
run('git commit -m "feat(M001/S01): recover orphaned branch"', repo);
run("git branch -D gsd/M001/S01", repo);
// Verify artifacts are now on main
assert(
existsSync(
join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"),
),
"SUMMARY merged to main after orphan recovery",
);
assert(
existsSync(join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md")),
"UAT merged to main after orphan recovery",
);
// Orphan no longer detected after merge + branch delete
const orphansAfter = detectOrphanedSliceBranches(repo);
assertEq(orphansAfter.length, 0, "no orphans after merge and branch deletion");
rmSync(repo, { recursive: true, force: true });
}
console.log(`\nResults: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);

View file

@ -1,5 +1,6 @@
/**
* preferences-git.test.ts Validates git.isolation and git.merge_to_main preference fields.
* preferences-git.test.ts Validates that deprecated git.isolation and
* git.merge_to_main preference fields produce deprecation warnings.
*/
import { createTestContext } from "./test-helpers.ts";
@ -8,78 +9,56 @@ import { validatePreferences } from "../preferences.ts";
const { assertEq, assertTrue, report } = createTestContext();
async function main(): Promise<void> {
console.log("\n=== git.isolation validation ===");
console.log("\n=== git.isolation deprecated ===");
// Valid values
// Any value produces a deprecation warning
{
const { preferences, errors } = validatePreferences({ git: { isolation: "worktree" } });
assertEq(errors.length, 0, "isolation: worktree — no errors");
assertEq(preferences.git?.isolation, "worktree", "isolation: worktree — value preserved");
const { warnings } = validatePreferences({ git: { isolation: "worktree" } });
assertTrue(warnings.length > 0, "isolation: worktree — produces deprecation warning");
assertTrue(warnings[0].includes("deprecated"), "isolation: worktree — warning mentions deprecated");
}
{
const { preferences, errors } = validatePreferences({ git: { isolation: "branch" } });
assertEq(errors.length, 0, "isolation: branch — no errors");
assertEq(preferences.git?.isolation, "branch", "isolation: branch — value preserved");
const { warnings } = validatePreferences({ git: { isolation: "branch" } });
assertTrue(warnings.length > 0, "isolation: branch — produces deprecation warning");
assertTrue(warnings[0].includes("deprecated"), "isolation: branch — warning mentions deprecated");
}
// Invalid values
// Undefined passes through without warning
{
const { errors } = validatePreferences({ git: { isolation: "invalid" } });
assertTrue(errors.length > 0, "isolation: invalid — produces error");
assertTrue(errors[0].includes("isolation"), "isolation: invalid — error mentions isolation");
}
{
const { errors } = validatePreferences({ git: { isolation: 42 } });
assertTrue(errors.length > 0, "isolation: number — produces error");
}
// Undefined passes through
{
const { preferences, errors } = validatePreferences({ git: { auto_push: true } });
assertEq(errors.length, 0, "isolation: undefined — no errors");
const { preferences, warnings } = validatePreferences({ git: { auto_push: true } });
assertEq(warnings.length, 0, "isolation: undefined — no warnings");
assertEq(preferences.git?.isolation, undefined, "isolation: undefined — not set");
}
console.log("\n=== git.merge_to_main validation ===");
console.log("\n=== git.merge_to_main deprecated ===");
// Valid values
// Any value produces a deprecation warning
{
const { preferences, errors } = validatePreferences({ git: { merge_to_main: "milestone" } });
assertEq(errors.length, 0, "merge_to_main: milestone — no errors");
assertEq(preferences.git?.merge_to_main, "milestone", "merge_to_main: milestone — value preserved");
const { warnings } = validatePreferences({ git: { merge_to_main: "milestone" } });
assertTrue(warnings.length > 0, "merge_to_main: milestone — produces deprecation warning");
assertTrue(warnings[0].includes("deprecated"), "merge_to_main: milestone — warning mentions deprecated");
}
{
const { preferences, errors } = validatePreferences({ git: { merge_to_main: "slice" } });
assertEq(errors.length, 0, "merge_to_main: slice — no errors");
assertEq(preferences.git?.merge_to_main, "slice", "merge_to_main: slice — value preserved");
const { warnings } = validatePreferences({ git: { merge_to_main: "slice" } });
assertTrue(warnings.length > 0, "merge_to_main: slice — produces deprecation warning");
assertTrue(warnings[0].includes("deprecated"), "merge_to_main: slice — warning mentions deprecated");
}
// Invalid values
// Undefined passes through without warning
{
const { errors } = validatePreferences({ git: { merge_to_main: "invalid" } });
assertTrue(errors.length > 0, "merge_to_main: invalid — produces error");
assertTrue(errors[0].includes("merge_to_main"), "merge_to_main: invalid — error mentions merge_to_main");
}
{
const { errors } = validatePreferences({ git: { merge_to_main: false } });
assertTrue(errors.length > 0, "merge_to_main: boolean — produces error");
}
// Undefined passes through
{
const { preferences, errors } = validatePreferences({ git: { auto_push: true } });
assertEq(errors.length, 0, "merge_to_main: undefined — no errors");
const { preferences, warnings } = validatePreferences({ git: { auto_push: true } });
assertEq(warnings.length, 0, "merge_to_main: undefined — no warnings");
assertEq(preferences.git?.merge_to_main, undefined, "merge_to_main: undefined — not set");
}
console.log("\n=== both fields together ===");
console.log("\n=== both deprecated fields together ===");
{
const { preferences, errors } = validatePreferences({
const { warnings } = validatePreferences({
git: { isolation: "worktree", merge_to_main: "slice" },
});
assertEq(errors.length, 0, "both fields valid — no errors");
assertEq(preferences.git?.isolation, "worktree", "isolation preserved");
assertEq(preferences.git?.merge_to_main, "slice", "merge_to_main preserved");
assertEq(warnings.length, 2, "both deprecated fields — 2 warnings");
assertTrue(warnings.some(w => w.includes("isolation")), "one warning mentions isolation");
assertTrue(warnings.some(w => w.includes("merge_to_main")), "one warning mentions merge_to_main");
}
report();

View file

@ -0,0 +1,136 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
extractCommitShas,
findCommitsForUnit,
handleUndo,
uncheckTaskInPlan,
} from "../undo.js";
function makeTempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), `${prefix}-`));
}
test("handleUndo without --force only warns and leaves completed units intact", async () => {
const base = makeTempDir("gsd-undo-confirm");
try {
mkdirSync(join(base, ".gsd"), { recursive: true });
writeFileSync(
join(base, ".gsd", "completed-units.json"),
JSON.stringify(["execute-task/M001/S01/T01"]),
"utf-8",
);
const notifications: Array<{ message: string; level: string }> = [];
const ctx = {
ui: {
notify(message: string, level: string) {
notifications.push({ message, level });
},
},
};
await handleUndo("", ctx as any, {} as any, base);
assert.equal(notifications.length, 1);
assert.equal(notifications[0]?.level, "warning");
assert.match(notifications[0]?.message ?? "", /Run \/gsd undo --force to confirm\./);
assert.deepEqual(
JSON.parse(readFileSync(join(base, ".gsd", "completed-units.json"), "utf-8")),
["execute-task/M001/S01/T01"],
);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test("uncheckTaskInPlan flips a checked task back to unchecked", () => {
const base = makeTempDir("gsd-undo-plan");
try {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
mkdirSync(sliceDir, { recursive: true });
const planFile = join(sliceDir, "S01-PLAN.md");
writeFileSync(
planFile,
[
"# Slice Plan",
"",
"- [x] **T01**: Ship the feature",
"- [ ] **T02**: Follow-up",
].join("\n"),
"utf-8",
);
assert.equal(uncheckTaskInPlan(base, "M001", "S01", "T01"), true);
assert.match(readFileSync(planFile, "utf-8"), /- \[ \] \*\*T01\*\*: Ship the feature/);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test("findCommitsForUnit reads the newest matching activity log and dedupes SHAs", () => {
const base = makeTempDir("gsd-undo-activity");
try {
const activityDir = join(base, ".gsd", "activity");
mkdirSync(activityDir, { recursive: true });
writeFileSync(
join(activityDir, "2026-03-14-execute-task-M001-S01-T01.jsonl"),
`${JSON.stringify({
message: {
content: [
{ type: "tool_result", content: "[main abc1234] old commit" },
],
},
})}\n`,
"utf-8",
);
writeFileSync(
join(activityDir, "2026-03-15-execute-task-M001-S01-T01.jsonl"),
[
JSON.stringify({
message: {
content: [
{ type: "tool_result", content: "[main deadbee] new commit\n[main cafe123] another commit" },
{ type: "tool_result", content: "[main deadbee] duplicate commit" },
],
},
}),
"{not-json}",
].join("\n"),
"utf-8",
);
assert.deepEqual(
findCommitsForUnit(activityDir, "execute-task", "M001/S01/T01"),
["deadbee", "cafe123"],
);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test("extractCommitShas returns unique commit hashes from git output blocks", () => {
const content = [
"[main abc1234] first commit",
"[feature deadbeef] second commit",
"[main abc1234] duplicate commit",
].join("\n");
assert.deepEqual(extractCommitShas(content), ["abc1234", "deadbeef"]);
});
test("extractCommitShas ignores malformed commit tokens", () => {
const content = [
"[main abc1234; touch /tmp/pwned] not a real sha token",
"[main not-a-sha] ignored",
"[main 1234567] valid",
].join("\n");
assert.deepEqual(extractCommitShas(content), ["1234567"]);
});

View file

@ -1,17 +1,15 @@
/**
* worktree-e2e.test.ts -- End-to-end tests for worktree-isolated git flow.
*
* Covers 5 cross-cutting groups not tested by individual slice tests:
* Covers cross-cutting groups not tested by individual slice tests:
* 1. Full lifecycle chain (create -> slice commits -> merge to milestone -> merge to main)
* 2. Preference gating (shouldUseWorktreeIsolation with overrides)
* 3. merge_to_main mode resolution (getMergeToMainMode)
* 4. Self-heal in merge context (abortAndReset, withMergeHeal)
* 5. Doctor detection of orphaned worktrees
* 2. Self-heal: abortAndReset cleans up failed merges
* 3. Doctor detection of orphaned worktrees
*/
import {
mkdtempSync, mkdirSync, writeFileSync, rmSync,
existsSync, realpathSync, readFileSync,
existsSync, realpathSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@ -20,11 +18,9 @@ import { execSync } from "node:child_process";
import {
createAutoWorktree,
mergeMilestoneToMain,
mergeSliceToMilestone,
shouldUseWorktreeIsolation,
} from "../auto-worktree.ts";
import { getSliceBranchName } from "../worktree.ts";
import { abortAndReset, withMergeHeal, MergeConflictError } from "../git-self-heal.ts";
import { abortAndReset } from "../git-self-heal.ts";
import { runGSDDoctor } from "../doctor.ts";
import { createTestContext } from "./test-helpers.ts";
@ -60,11 +56,11 @@ function makeRoadmap(
}
function addSliceToMilestone(
repo: string,
_repo: string,
wtPath: string,
milestoneId: string,
sliceId: string,
sliceTitle: string,
_sliceTitle: string,
commits: Array<{ file: string; content: string; message: string }>,
): void {
const normalizedPath = wtPath.replaceAll("\\", "/");
@ -81,7 +77,7 @@ function addSliceToMilestone(
run(`git commit -m "${c.message}"`, wtPath);
}
run(`git checkout milestone/${milestoneId}`, wtPath);
mergeSliceToMilestone(repo, milestoneId, sliceId, sliceTitle);
run(`git merge --no-ff ${sliceBranch} -m "merge ${sliceId}"`, wtPath);
}
async function main(): Promise<void> {
@ -144,59 +140,10 @@ async function main(): Promise<void> {
}
// ================================================================
// Group 2: Preference gating (shouldUseWorktreeIsolation)
// ================================================================
console.log("\n=== Preference gating ===");
{
const repo = createTempRepo();
tempDirs.push(repo);
// Override to branch mode
const branchResult = shouldUseWorktreeIsolation(repo, { isolation: "branch" });
assertEq(branchResult, false, "isolation=branch returns false");
// Override to worktree mode
const wtResult = shouldUseWorktreeIsolation(repo, { isolation: "worktree" });
assertEq(wtResult, true, "isolation=worktree returns true");
// Default (no legacy branches) returns true
const defaultResult = shouldUseWorktreeIsolation(repo);
assertEq(defaultResult, true, "new project defaults to worktree (true)");
}
// ================================================================
// Group 3: merge_to_main mode resolution
// ================================================================
console.log("\n=== merge_to_main mode ===");
{
// getMergeToMainMode reads from loadEffectiveGSDPreferences — test via legacy branch detection
// Instead, test that the function returns the default "milestone" when no prefs set
// (Cannot inject overridePrefs — function signature doesn't accept them)
// We verify the shouldUseWorktreeIsolation override path handles legacy detection
const repo = createTempRepo();
tempDirs.push(repo);
// Create a legacy gsd/*/* branch to test legacy detection
run("git checkout -b gsd/M001/S01", repo);
writeFileSync(join(repo, "legacy.txt"), "legacy\n");
run("git add .", repo);
run("git commit -m legacy", repo);
run("git checkout main", repo);
const legacyResult = shouldUseWorktreeIsolation(repo);
assertEq(legacyResult, false, "legacy gsd branches detected -> branch mode");
// Explicit worktree override wins over legacy detection
const overrideResult = shouldUseWorktreeIsolation(repo, { isolation: "worktree" });
assertEq(overrideResult, true, "explicit worktree override wins over legacy");
}
// ================================================================
// Group 4: Self-heal (abortAndReset, withMergeHeal)
// Group 2: Self-heal (abortAndReset)
// ================================================================
console.log("\n=== Self-heal ===");
{
// 4a: abortAndReset cleans up MERGE_HEAD
const repo = createTempRepo();
tempDirs.push(repo);
@ -218,36 +165,9 @@ async function main(): Promise<void> {
assertTrue(!existsSync(join(repo, ".git", "MERGE_HEAD")), "MERGE_HEAD removed after abort");
assertTrue(abortResult.cleaned.length > 0, "abortAndReset reports cleaned items");
}
{
// 4b: withMergeHeal throws MergeConflictError for real conflicts
const repo = createTempRepo();
tempDirs.push(repo);
run("git checkout -b conflict-branch", repo);
writeFileSync(join(repo, "file.txt"), "branch version\n");
run("git add .", repo);
run("git commit -m branch-ver", repo);
run("git checkout main", repo);
writeFileSync(join(repo, "file.txt"), "main version\n");
run("git add .", repo);
run("git commit -m main-ver", repo);
let caughtError: unknown = null;
try {
withMergeHeal(repo, () => {
execSync("git merge conflict-branch", { cwd: repo, stdio: "pipe" });
});
} catch (e) {
caughtError = e;
}
assertTrue(caughtError instanceof MergeConflictError, "withMergeHeal throws MergeConflictError");
if (caughtError instanceof MergeConflictError) {
assertTrue(caughtError.conflictedFiles.length > 0, "MergeConflictError has conflictedFiles");
}
}
// ================================================================
// Group 5: Doctor detects orphaned worktrees
// Group 3: Doctor detects orphaned worktrees
// Skip on Windows: git worktree path resolution in temp dirs uses
// UNC/8.3 forms that don't match after normalization.
// ================================================================

View file

@ -4,8 +4,6 @@
* Tests the full lifecycle of GSD operations inside a worktree:
* - Branch namespacing (gsd/<wt>/<M>/<S> instead of gsd/<M>/<S>)
* - getMainBranch returns worktree/<name> inside a worktree
* - switchToMain goes to worktree/<name>, not main
* - mergeSliceToMain merges into worktree/<name>
* - Parallel worktrees don't conflict on branch names
* - State derivation works correctly inside worktrees
*/
@ -19,21 +17,15 @@ import {
createWorktree,
listWorktrees,
removeWorktree,
worktreePath,
worktreeBranchName,
} from "../worktree-manager.ts";
import {
detectWorktreeName,
ensureSliceBranch,
getActiveSliceBranch,
getCurrentBranch,
getMainBranch,
getSliceBranchName,
isOnSliceBranch,
mergeSliceToMain,
switchToMain,
autoCommitCurrentBranch,
SLICE_BRANCH_RE,
} from "../worktree.ts";
import { deriveState } from "../state.ts";
@ -104,21 +96,20 @@ async function main(): Promise<void> {
console.log("\n=== Worktree initial branch ===");
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "worktree starts on its own branch");
// ── ensureSliceBranch inside worktree ──────────────────────────────────────
console.log("\n=== ensureSliceBranch in worktree ===");
const created = ensureSliceBranch(wt.path, "M001", "S01");
assertTrue(created, "slice branch created");
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "worktree-namespaced slice branch");
assertTrue(isOnSliceBranch(wt.path), "isOnSliceBranch returns true");
assertEq(getActiveSliceBranch(wt.path), "gsd/alpha/M001/S01", "getActiveSliceBranch returns namespaced branch");
// ── Verify branch name helper ──────────────────────────────────────────────
console.log("\n=== getSliceBranchName with worktree ===");
assertEq(getSliceBranchName("M001", "S01", "alpha"), "gsd/alpha/M001/S01", "explicit worktree param");
assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "no worktree param = plain branch");
// ── Slice branch creation and detection inside worktree ────────────────────
console.log("\n=== Slice branch in worktree ===");
const sliceBranch = getSliceBranchName("M001", "S01", "alpha");
run(`git checkout -b ${sliceBranch}`, wt.path);
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "worktree-namespaced slice branch");
assertTrue(SLICE_BRANCH_RE.test(getCurrentBranch(wt.path)), "slice branch regex matches namespaced branch");
// ── Do work on slice branch, then merge to worktree branch ─────────────────
console.log("\n=== Work and merge slice in worktree ===");
@ -126,14 +117,12 @@ async function main(): Promise<void> {
run("git add .", wt.path);
run('git commit -m "feat: add feature"', wt.path);
// switchToMain should go to worktree/alpha, NOT main
switchToMain(wt.path);
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "switchToMain goes to worktree branch, not main");
// Checkout worktree base branch and merge slice branch
run("git checkout worktree/alpha", wt.path);
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "back on worktree branch");
// mergeSliceToMain should merge into worktree/alpha
const merge = mergeSliceToMain(wt.path, "M001", "S01", "First");
assertEq(merge.branch, "gsd/alpha/M001/S01", "merged the namespaced branch");
assertTrue(merge.deletedBranch, "slice branch deleted after merge");
run(`git merge --no-ff ${sliceBranch} -m "feat(M001/S01): First"`, wt.path);
run(`git branch -d ${sliceBranch}`, wt.path);
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "still on worktree branch after merge");
assertTrue(readFileSync(join(wt.path, "feature.txt"), "utf-8").includes("new feature"), "merge brought feature to worktree branch");
@ -144,36 +133,19 @@ async function main(): Promise<void> {
// ── Second slice in same worktree ──────────────────────────────────────────
console.log("\n=== Second slice in worktree ===");
const created2 = ensureSliceBranch(wt.path, "M001", "S02");
assertTrue(created2, "S02 branch created");
const sliceBranch2 = getSliceBranchName("M001", "S02", "alpha");
run(`git checkout -b ${sliceBranch2}`, wt.path);
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S02", "on S02 namespaced branch");
writeFileSync(join(wt.path, "feature2.txt"), "second feature\n", "utf-8");
run("git add .", wt.path);
run('git commit -m "feat: add feature 2"', wt.path);
switchToMain(wt.path);
const merge2 = mergeSliceToMain(wt.path, "M001", "S02", "Second");
assertEq(merge2.branch, "gsd/alpha/M001/S02", "S02 merge correct");
run("git checkout worktree/alpha", wt.path);
run(`git merge --no-ff ${sliceBranch2} -m "feat(M001/S02): Second"`, wt.path);
run(`git branch -d ${sliceBranch2}`, wt.path);
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "back on worktree branch");
// ── Main tree can still do its own slice work independently ────────────────
console.log("\n=== Main tree independent slice work ===");
assertEq(getCurrentBranch(base), "main", "main tree still on main");
const mainCreated = ensureSliceBranch(base, "M001", "S01");
assertTrue(mainCreated, "main tree can create S01 branch (no conflict with worktree)");
assertEq(getCurrentBranch(base), "gsd/M001/S01", "main tree on plain branch name");
writeFileSync(join(base, "main-feature.txt"), "main work\n", "utf-8");
run("git add .", base);
run('git commit -m "feat: main work"', base);
switchToMain(base);
assertEq(getCurrentBranch(base), "main", "main tree switchToMain goes to main");
const mainMerge = mergeSliceToMain(base, "M001", "S01", "First");
assertEq(mainMerge.branch, "gsd/M001/S01", "main tree merge uses plain branch");
// ── Parallel worktrees don't conflict ──────────────────────────────────────
console.log("\n=== Parallel worktrees ===");
@ -181,13 +153,13 @@ async function main(): Promise<void> {
assertEq(getMainBranch(wt2.path), "worktree/beta", "second worktree has its own base branch");
// Both worktrees can create S01 branches without conflict
const betaCreated = ensureSliceBranch(wt2.path, "M001", "S01");
assertTrue(betaCreated, "beta worktree can create S01");
const betaBranch = getSliceBranchName("M001", "S01", "beta");
run(`git checkout -b ${betaBranch}`, wt2.path);
assertEq(getCurrentBranch(wt2.path), "gsd/beta/M001/S01", "beta has its own namespaced branch");
// Alpha worktree can re-create S01 too (it was already merged+deleted earlier)
const alphaReCreated = ensureSliceBranch(wt.path, "M001", "S01");
assertTrue(alphaReCreated, "alpha worktree can re-create S01");
const alphaReBranch = getSliceBranchName("M001", "S01", "alpha");
run(`git checkout -b ${alphaReBranch}`, wt.path);
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "alpha re-created S01");
// Both exist simultaneously
@ -199,7 +171,7 @@ async function main(): Promise<void> {
console.log("\n=== State derivation in worktree ===");
// Switch alpha back to its base so deriveState sees milestone files
switchToMain(wt.path);
run("git checkout worktree/alpha", wt.path);
const state = await deriveState(wt.path);
assertTrue(state.activeMilestone !== null, "worktree has active milestone");
assertEq(state.activeMilestone?.id, "M001", "correct milestone");
@ -207,7 +179,8 @@ async function main(): Promise<void> {
// ── autoCommitCurrentBranch in worktree ────────────────────────────────────
console.log("\n=== autoCommitCurrentBranch in worktree ===");
ensureSliceBranch(wt2.path, "M001", "S01"); // re-checkout if needed
// Re-checkout the beta slice branch
run(`git checkout ${betaBranch}`, wt2.path);
writeFileSync(join(wt2.path, "dirty.txt"), "uncommitted\n", "utf-8");
const commitMsg = autoCommitCurrentBranch(wt2.path, "execute-task", "M001/S01/T01");
assertTrue(commitMsg !== null, "auto-commit works in worktree");
@ -217,8 +190,8 @@ async function main(): Promise<void> {
console.log("\n=== Cleanup ===");
// Switch worktrees back to their base branches before removal
switchToMain(wt.path);
switchToMain(wt2.path);
run("git checkout worktree/alpha", wt.path);
run("git checkout worktree/beta", wt2.path);
removeWorktree(base, "alpha", { deleteBranch: true });
removeWorktree(base, "beta", { deleteBranch: true });
assertEq(listWorktrees(base).length, 0, "all worktrees removed");

View file

@ -1,4 +1,4 @@
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
@ -7,21 +7,14 @@ import {
autoCommitCurrentBranch,
captureIntegrationBranch,
detectWorktreeName,
ensureSliceBranch,
getActiveSliceBranch,
getCurrentBranch,
getMainBranch,
getSliceBranchName,
isOnSliceBranch,
mergeSliceToMain,
parseSliceBranch,
setActiveMilestoneId,
SLICE_BRANCH_RE,
switchToMain,
} from "../worktree.ts";
import { readIntegrationBranch } from "../git-service.ts";
import { deriveState } from "../state.ts";
import { indexWorkspace } from "../workspace-index.ts";
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
@ -41,27 +34,6 @@ run("git add .", base);
run('git commit -m "chore: init"', base);
async function main(): Promise<void> {
console.log("\n=== ensureSliceBranch ===");
const created = ensureSliceBranch(base, "M001", "S01");
assertTrue(created, "branch created on first ensure");
assertEq(getCurrentBranch(base), "gsd/M001/S01", "switched to slice branch");
console.log("\n=== idempotent ensure ===");
const secondCreate = ensureSliceBranch(base, "M001", "S01");
assertEq(secondCreate, false, "branch not recreated on second ensure");
assertEq(getCurrentBranch(base), "gsd/M001/S01", "still on slice branch");
console.log("\n=== getActiveSliceBranch ===");
assertEq(getActiveSliceBranch(base), "gsd/M001/S01", "getActiveSliceBranch returns current slice branch");
console.log("\n=== state surfaces active branch ===");
const state = await deriveState(base);
assertEq(state.activeBranch, "gsd/M001/S01", "state exposes active branch");
console.log("\n=== workspace index surfaces branch ===");
const index = await indexWorkspace(base);
const slice = index.milestones[0]?.slices[0];
assertEq(slice?.branch, "gsd/M001/S01", "workspace index exposes branch");
console.log("\n=== autoCommitCurrentBranch ===");
// Clean — should return null
@ -75,56 +47,6 @@ async function main(): Promise<void> {
assertTrue(dirtyResult!.includes("M001/S01/T01"), "commit message includes unit id");
assertEq(run("git status --short", base), "", "repo is clean after auto-commit");
console.log("\n=== switchToMain ===");
switchToMain(base);
assertEq(getCurrentBranch(base), "main", "switched back to main");
assertEq(getActiveSliceBranch(base), null, "getActiveSliceBranch returns null on main");
console.log("\n=== mergeSliceToMain ===");
// Switch back to slice, make a change, switch to main, merge
ensureSliceBranch(base, "M001", "S01");
writeFileSync(join(base, "README.md"), "hello from slice\n", "utf-8");
run("git add README.md", base);
run('git commit -m "feat: slice change"', base);
switchToMain(base);
const merge = mergeSliceToMain(base, "M001", "S01", "Slice One");
assertEq(merge.branch, "gsd/M001/S01", "merge reports branch");
assertEq(getCurrentBranch(base), "main", "still on main after merge");
assertTrue(readFileSync(join(base, "README.md"), "utf-8").includes("slice"), "main got squashed content");
assertTrue(merge.deletedBranch, "branch was deleted");
// Verify branch is actually gone
const branches = run("git branch", base);
assertTrue(!branches.includes("gsd/M001/S01"), "slice branch no longer exists");
console.log("\n=== switchToMain auto-commits dirty files ===");
// Set up S02
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S02", "tasks"), { recursive: true });
writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [
"# M001: Demo", "", "## Slices",
"- [x] **S01: Slice One** `risk:low` `depends:[]`", " > Done",
"- [ ] **S02: Slice Two** `risk:low` `depends:[]`", " > Demo 2",
].join("\n") + "\n", "utf-8");
run("git add .", base);
run('git commit -m "chore: add S02"', base);
ensureSliceBranch(base, "M001", "S02");
writeFileSync(join(base, "feature.txt"), "new feature\n", "utf-8");
// Don't commit — switchToMain should auto-commit
switchToMain(base);
assertEq(getCurrentBranch(base), "main", "switched to main despite dirty files");
// Verify the commit happened on the slice branch
ensureSliceBranch(base, "M001", "S02");
assertTrue(readFileSync(join(base, "feature.txt"), "utf-8").includes("new feature"), "dirty file was committed on slice branch");
switchToMain(base);
// Now merge S02
const mergeS02 = mergeSliceToMain(base, "M001", "S02", "Slice Two");
assertTrue(readFileSync(join(base, "feature.txt"), "utf-8").includes("new feature"), "main got feature from auto-committed branch");
assertEq(mergeS02.deletedBranch, true, "S02 branch deleted");
console.log("\n=== getSliceBranchName ===");
assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "branch name format correct");
assertEq(getSliceBranchName("M001", "S01", null), "gsd/M001/S01", "null worktree = plain branch");
@ -161,90 +83,8 @@ async function main(): Promise<void> {
assertEq(detectWorktreeName("/projects/myapp/.gsd/worktrees/feature-auth"), "feature-auth", "detects worktree name");
assertEq(detectWorktreeName("/projects/myapp/.gsd/worktrees/my-wt/subdir"), "my-wt", "detects worktree with subdir");
// ── Regression: slice branch from non-main working branch ───────────
// Reproduces the bug where planning artifacts committed to a working
// branch (e.g. "developer") are lost when the slice branch is created
// from "main" which doesn't have them.
console.log("\n=== ensureSliceBranch from non-main working branch ===");
const base2 = mkdtempSync(join(tmpdir(), "gsd-branch-base-test-"));
run("git init -b main", base2);
run('git config user.name "Pi Test"', base2);
run('git config user.email "pi@example.com"', base2);
writeFileSync(join(base2, "README.md"), "hello\n", "utf-8");
run("git add .", base2);
run('git commit -m "chore: init"', base2);
// Create a "developer" branch with planning artifacts (like the real scenario)
run("git checkout -b developer", base2);
mkdirSync(join(base2, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
writeFileSync(join(base2, ".gsd", "milestones", "M001", "M001-CONTEXT.md"), "# M001 Context\nGoal: fix eslint\n", "utf-8");
writeFileSync(join(base2, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [
"# M001: ESLint Cleanup", "", "## Slices",
"- [ ] **S01: Config Fix** `risk:low` `depends:[]`", " > Fix config",
].join("\n") + "\n", "utf-8");
run("git add .", base2);
run('git commit -m "docs(M001): context and roadmap"', base2);
// Verify main does NOT have the artifacts
const mainRoadmap = run("git show main:.gsd/milestones/M001/M001-ROADMAP.md 2>&1 || echo MISSING", base2);
assertTrue(mainRoadmap.includes("MISSING") || mainRoadmap.includes("does not exist"), "main branch lacks roadmap");
// Now create slice branch from developer — should inherit artifacts
assertEq(getCurrentBranch(base2), "developer", "on developer branch before ensure");
const created3 = ensureSliceBranch(base2, "M001", "S01");
assertTrue(created3, "slice branch created from developer");
assertEq(getCurrentBranch(base2), "gsd/M001/S01", "switched to slice branch");
// The critical assertion: planning artifacts must exist on the slice branch
assertTrue(existsSync(join(base2, ".gsd", "milestones", "M001", "M001-ROADMAP.md")), "roadmap exists on slice branch");
assertTrue(existsSync(join(base2, ".gsd", "milestones", "M001", "M001-CONTEXT.md")), "context exists on slice branch");
// Verify deriveState sees the correct phase (not pre-planning)
const state2 = await deriveState(base2);
assertEq(state2.phase, "planning", "deriveState sees planning phase on slice branch");
assertTrue(state2.activeSlice !== null, "active slice found");
assertEq(state2.activeSlice!.id, "S01", "active slice is S01");
rmSync(base2, { recursive: true, force: true });
// ── Slice branch from another slice branch falls back to main ───────
console.log("\n=== ensureSliceBranch from slice branch falls back to main ===");
const base3 = mkdtempSync(join(tmpdir(), "gsd-branch-chain-test-"));
run("git init -b main", base3);
run('git config user.name "Pi Test"', base3);
run('git config user.email "pi@example.com"', base3);
mkdirSync(join(base3, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
mkdirSync(join(base3, ".gsd", "milestones", "M001", "slices", "S02", "tasks"), { recursive: true });
writeFileSync(join(base3, "README.md"), "hello\n", "utf-8");
writeFileSync(join(base3, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [
"# M001: Demo", "", "## Slices",
"- [ ] **S01: First** `risk:low` `depends:[]`", " > first",
"- [ ] **S02: Second** `risk:low` `depends:[]`", " > second",
].join("\n") + "\n", "utf-8");
run("git add .", base3);
run('git commit -m "chore: init"', base3);
ensureSliceBranch(base3, "M001", "S01");
assertEq(getCurrentBranch(base3), "gsd/M001/S01", "on S01 slice branch");
// Creating S02 while on S01 should NOT chain from S01 — should use main
const created4 = ensureSliceBranch(base3, "M001", "S02");
assertTrue(created4, "S02 branch created");
assertEq(getCurrentBranch(base3), "gsd/M001/S02", "switched to S02");
// S02 should be based on main, not on gsd/M001/S01
const s02Base = run("git merge-base main gsd/M001/S02", base3);
const mainHead = run("git rev-parse main", base3);
assertEq(s02Base, mainHead, "S02 is based on main, not on S01 slice branch");
rmSync(base3, { recursive: true, force: true });
// ═══════════════════════════════════════════════════════════════════════
// Integration branch — facade-level tests
//
// These exercise the same codepath auto.ts uses:
// captureIntegrationBranch() → setActiveMilestoneId() → getMainBranch()
// → switchToMain() → mergeSliceToMain()
// ═══════════════════════════════════════════════════════════════════════
// ── captureIntegrationBranch on a feature branch ──────────────────────
@ -273,43 +113,6 @@ async function main(): Promise<void> {
rmSync(repo, { recursive: true, force: true });
}
// ── captureIntegrationBranch is idempotent on same lineage ──────────
console.log("\n=== captureIntegrationBranch: idempotent ===");
{
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-idem-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
run("git config user.email 'pi@example.com'", repo);
writeFileSync(join(repo, "README.md"), "init\n");
run("git add -A && git commit -m init", repo);
run("git checkout -b f-first", repo);
captureIntegrationBranch(repo, "M001");
setActiveMilestoneId(repo, "M001");
assertEq(readIntegrationBranch(repo, "M001"), "f-first",
"first capture records f-first");
// Capture again on the same branch (simulates restart/resume) — should NOT overwrite
captureIntegrationBranch(repo, "M001");
assertEq(readIntegrationBranch(repo, "M001"), "f-first",
"second capture on same branch does not overwrite");
// After creating a slice branch (which inherits the metadata commit),
// capture should still be idempotent
ensureSliceBranch(repo, "M001", "S01");
// Now on gsd/M001/S01 — capture should be no-op (slice branch rejected)
captureIntegrationBranch(repo, "M001");
switchToMain(repo);
assertEq(readIntegrationBranch(repo, "M001"), "f-first",
"capture from slice branch is no-op, original preserved");
assertEq(getCurrentBranch(repo), "f-first",
"switchToMain returns to feature branch, confirming integration branch works");
rmSync(repo, { recursive: true, force: true });
}
// ── captureIntegrationBranch skips slice branches ─────────────────────
console.log("\n=== captureIntegrationBranch: skips slice branches ===");
@ -359,234 +162,6 @@ async function main(): Promise<void> {
rmSync(repo, { recursive: true, force: true });
}
// ── Full multi-slice lifecycle on a feature branch ────────────────────
//
// Simulates what auto.ts does: start on feature branch, capture it,
// create S01, work, merge S01 back to feature branch, then S02 branches
// from feature branch (not main), works, merges to feature branch.
// Main stays untouched throughout.
console.log("\n=== Multi-slice lifecycle on feature branch ===");
{
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-multi-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
run("git config user.email 'pi@example.com'", repo);
writeFileSync(join(repo, "README.md"), "base\n");
run("git add -A && git commit -m init", repo);
// User creates feature branch
run("git checkout -b feature/big-change", repo);
writeFileSync(join(repo, "setup.txt"), "feature setup\n");
run('git add -A && git commit -m "feat: initial setup"', repo);
// auto.ts startup: capture + set milestone
captureIntegrationBranch(repo, "M001");
setActiveMilestoneId(repo, "M001");
assertEq(getMainBranch(repo), "feature/big-change",
"multi: getMainBranch returns feature branch");
// ── S01 lifecycle ──────────────────────────────────────────────────
ensureSliceBranch(repo, "M001", "S01");
assertEq(getCurrentBranch(repo), "gsd/M001/S01", "multi: on S01");
// Verify S01 has feature branch content
assertTrue(existsSync(join(repo, "setup.txt")),
"multi: S01 inherited feature branch content");
writeFileSync(join(repo, "s01-work.txt"), "s01 output\n");
run('git add -A && git commit -m "feat(S01): work"', repo);
switchToMain(repo);
assertEq(getCurrentBranch(repo), "feature/big-change",
"multi: switchToMain goes to feature branch");
const s01merge = mergeSliceToMain(repo, "M001", "S01", "First slice");
assertEq(getCurrentBranch(repo), "feature/big-change",
"multi: after S01 merge, on feature branch");
assertTrue(existsSync(join(repo, "s01-work.txt")),
"multi: S01 work merged to feature branch");
assertTrue(s01merge.deletedBranch, "multi: S01 branch deleted");
// Main should NOT have S01 work
run("git stash", repo); // stash any .gsd changes
run("git checkout main", repo);
assertTrue(!existsSync(join(repo, "s01-work.txt")),
"multi: main does NOT have S01 work");
run("git checkout feature/big-change", repo);
run("git stash pop || true", repo);
// ── S02 lifecycle ──────────────────────────────────────────────────
// S02 should branch from feature/big-change which now has S01's work
ensureSliceBranch(repo, "M001", "S02");
assertEq(getCurrentBranch(repo), "gsd/M001/S02", "multi: on S02");
// S02 should have S01's merged output (branched from feature branch)
assertTrue(existsSync(join(repo, "s01-work.txt")),
"multi: S02 has S01 output (inherited via feature branch)");
writeFileSync(join(repo, "s02-work.txt"), "s02 output\n");
run('git add -A && git commit -m "feat(S02): work"', repo);
switchToMain(repo);
assertEq(getCurrentBranch(repo), "feature/big-change",
"multi: switchToMain goes to feature branch after S02");
const s02merge = mergeSliceToMain(repo, "M001", "S02", "Second slice");
assertEq(getCurrentBranch(repo), "feature/big-change",
"multi: after S02 merge, on feature branch");
assertTrue(existsSync(join(repo, "s02-work.txt")),
"multi: S02 work merged to feature branch");
assertTrue(existsSync(join(repo, "s01-work.txt")),
"multi: S01 work still on feature branch after S02 merge");
assertTrue(s02merge.deletedBranch, "multi: S02 branch deleted");
// Final check: main still untouched
run("git stash", repo);
run("git checkout main", repo);
assertTrue(!existsSync(join(repo, "s01-work.txt")),
"multi: main still lacks S01 work at end");
assertTrue(!existsSync(join(repo, "s02-work.txt")),
"multi: main still lacks S02 work at end");
assertEq(readFileSync(join(repo, "README.md"), "utf-8").trim(), "base",
"multi: main README unchanged");
rmSync(repo, { recursive: true, force: true });
}
// ── Resume scenario: milestone ID re-set after restart ────────────────
//
// Simulates crash + restart: the cached GitServiceImpl is lost, but the
// metadata file persists on disk. Re-calling setActiveMilestoneId should
// restore integration branch resolution.
console.log("\n=== Resume: milestone ID re-set restores integration branch ===");
{
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-resume-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
run("git config user.email 'pi@example.com'", repo);
writeFileSync(join(repo, "README.md"), "init\n");
run("git add -A && git commit -m init", repo);
run("git checkout -b my-feature", repo);
captureIntegrationBranch(repo, "M001");
setActiveMilestoneId(repo, "M001");
// Create a slice and do some work
ensureSliceBranch(repo, "M001", "S01");
writeFileSync(join(repo, "work.txt"), "wip\n");
run('git add -A && git commit -m "wip"', repo);
// Simulate "restart" — clear milestone ID (fresh service instance)
setActiveMilestoneId(repo, null);
assertEq(getMainBranch(repo), "main",
"resume: getMainBranch returns main when milestone cleared");
// Re-set milestone ID (what auto.ts does on resume)
setActiveMilestoneId(repo, "M001");
assertEq(getMainBranch(repo), "my-feature",
"resume: getMainBranch returns feature branch after re-set");
// Full lifecycle still works after resume
switchToMain(repo);
assertEq(getCurrentBranch(repo), "my-feature",
"resume: switchToMain goes to feature branch after re-set");
const result = mergeSliceToMain(repo, "M001", "S01", "Resume slice");
assertEq(getCurrentBranch(repo), "my-feature",
"resume: merge lands on feature branch after re-set");
assertTrue(existsSync(join(repo, "work.txt")),
"resume: merged work exists on feature branch");
rmSync(repo, { recursive: true, force: true });
}
// ── Backward compat: no metadata file, plain main workflow ────────────
//
// Simulates existing projects that were created before this feature.
// No metadata file exists, milestone ID is set — getMainBranch should
// still return "main" and the entire slice lifecycle works unchanged.
console.log("\n=== Backward compat: no metadata, main workflow ===");
{
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-compat-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
run("git config user.email 'pi@example.com'", repo);
writeFileSync(join(repo, "README.md"), "init\n");
run("git add -A && git commit -m init", repo);
// Set milestone but DON'T capture integration branch (simulates old project)
setActiveMilestoneId(repo, "M001");
assertEq(getMainBranch(repo), "main",
"compat: getMainBranch returns main without metadata");
// Full lifecycle on main still works
ensureSliceBranch(repo, "M001", "S01");
writeFileSync(join(repo, "feature.txt"), "new\n");
run('git add -A && git commit -m "feat: work"', repo);
switchToMain(repo);
assertEq(getCurrentBranch(repo), "main",
"compat: switchToMain goes to main");
const result = mergeSliceToMain(repo, "M001", "S01", "Compat slice");
assertEq(getCurrentBranch(repo), "main",
"compat: merge lands on main");
assertTrue(existsSync(join(repo, "feature.txt")),
"compat: merged work exists on main");
assertTrue(result.deletedBranch, "compat: branch deleted");
rmSync(repo, { recursive: true, force: true });
}
// ── ensureSliceBranch from another slice with integration branch ──────
//
// When on gsd/M001/S01 and creating S02, the code falls back to
// getMainBranch() (not the current slice). With integration branch set,
// S02 should branch from the feature branch.
console.log("\n=== ensureSliceBranch: S02 from S01 uses integration branch as base ===");
{
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-chain-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
run("git config user.email 'pi@example.com'", repo);
writeFileSync(join(repo, "README.md"), "init\n");
run("git add -A && git commit -m init", repo);
run("git checkout -b dev-branch", repo);
writeFileSync(join(repo, "dev-only.txt"), "from dev\n");
run('git add -A && git commit -m "dev setup"', repo);
captureIntegrationBranch(repo, "M001");
setActiveMilestoneId(repo, "M001");
// Create S01 (from dev-branch)
ensureSliceBranch(repo, "M001", "S01");
writeFileSync(join(repo, "s01.txt"), "s01\n");
run('git add -A && git commit -m "s01 work"', repo);
// While on S01, create S02 — should fall back to integration branch
ensureSliceBranch(repo, "M001", "S02");
assertEq(getCurrentBranch(repo), "gsd/M001/S02", "chain: on S02");
// S02 should be based on dev-branch (the integration branch)
assertTrue(existsSync(join(repo, "dev-only.txt")),
"chain: S02 has dev-branch content");
assertTrue(!existsSync(join(repo, "s01.txt")),
"chain: S02 does NOT have S01 content (not chained from S01)");
rmSync(repo, { recursive: true, force: true });
}
rmSync(base, { recursive: true, force: true });
report();
}

View file

@ -175,7 +175,6 @@ export interface GSDState {
recentDecisions: string[];
blockers: string[];
nextAction: string;
activeBranch?: string;
activeWorkspace?: string;
registry: MilestoneRegistryEntry[];
requirements?: RequirementCounts;
@ -235,6 +234,19 @@ export interface HookDispatchResult {
unitId: string;
}
// ─── Budget & Notification Types ──────────────────────────────────────────
export type BudgetEnforcementMode = 'warn' | 'pause' | 'halt';
export interface NotificationPreferences {
enabled?: boolean; // default true
on_complete?: boolean; // notify on each unit completion
on_error?: boolean; // notify on errors
on_budget?: boolean; // notify on budget thresholds
on_milestone?: boolean; // notify when milestone finishes
on_attention?: boolean; // notify when manual attention needed
}
// ─── Pre-Dispatch Hook Types ──────────────────────────────────────────────
export interface PreDispatchHookConfig {

View file

@ -0,0 +1,219 @@
// GSD Extension — Undo Last Unit
// Rollback the most recent completed unit: revert git, remove state, uncheck plans.
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { ExtensionCommandContext, ExtensionAPI } from "@gsd/pi-coding-agent";
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { deriveState, invalidateStateCache } from "./state.js";
import { gsdRoot, resolveTasksDir, resolveSlicePath, buildTaskFileName } from "./paths.js";
import { sendDesktopNotification } from "./notifications.js";
/**
* Undo the last completed unit: revert git commits, remove from completed-units,
* delete summary artifacts, and uncheck the task in PLAN.
*/
export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi: ExtensionAPI, basePath: string): Promise<void> {
const force = args.includes("--force");
// 1. Load completed-units.json
const completedKeysFile = join(gsdRoot(basePath), "completed-units.json");
if (!existsSync(completedKeysFile)) {
ctx.ui.notify("Nothing to undo — no completed units found.", "info");
return;
}
let keys: string[];
try {
keys = JSON.parse(readFileSync(completedKeysFile, "utf-8"));
} catch {
ctx.ui.notify("Nothing to undo — completed-units.json is corrupt.", "warning");
return;
}
if (keys.length === 0) {
ctx.ui.notify("Nothing to undo — no completed units.", "info");
return;
}
// Get the last completed unit
const lastKey = keys[keys.length - 1];
const sepIdx = lastKey.indexOf("/");
const unitType = sepIdx >= 0 ? lastKey.slice(0, sepIdx) : lastKey;
const unitId = sepIdx >= 0 ? lastKey.slice(sepIdx + 1) : lastKey;
if (!force) {
ctx.ui.notify(
`Will undo: ${unitType} (${unitId})\n` +
`This will:\n` +
` - Remove from completed-units.json\n` +
` - Delete summary artifacts\n` +
` - Uncheck task in PLAN (if execute-task)\n` +
` - Attempt to revert associated git commits\n\n` +
`Run /gsd undo --force to confirm.`,
"warning",
);
return;
}
// 2. Remove from completed-units.json
keys = keys.filter(k => k !== lastKey);
writeFileSync(completedKeysFile, JSON.stringify(keys), "utf-8");
// 3. Delete summary artifact
const parts = unitId.split("/");
let summaryRemoved = false;
if (parts.length === 3) {
// Task-level: M001/S01/T01
const [mid, sid, tid] = parts;
const tasksDir = resolveTasksDir(basePath, mid, sid);
if (tasksDir) {
const summaryFile = join(tasksDir, buildTaskFileName(tid, "SUMMARY"));
if (existsSync(summaryFile)) {
unlinkSync(summaryFile);
summaryRemoved = true;
}
}
} else if (parts.length === 2) {
// Slice-level: M001/S01
const [mid, sid] = parts;
const slicePath = resolveSlicePath(basePath, mid, sid);
if (slicePath) {
// Try common summary filenames
for (const suffix of ["SUMMARY", "COMPLETE"]) {
const candidates = findFileWithPrefix(slicePath, sid, suffix);
for (const f of candidates) {
unlinkSync(f);
summaryRemoved = true;
}
}
}
}
// 4. Uncheck task in PLAN if execute-task
let planUpdated = false;
if (unitType === "execute-task" && parts.length === 3) {
const [mid, sid, tid] = parts;
planUpdated = uncheckTaskInPlan(basePath, mid, sid, tid);
}
// 5. Try to revert git commits from activity log
let commitsReverted = 0;
const activityDir = join(gsdRoot(basePath), "activity");
if (existsSync(activityDir)) {
const commits = findCommitsForUnit(activityDir, unitType, unitId);
if (commits.length > 0) {
for (const sha of commits.reverse()) {
try {
execFileSync("git", ["revert", "--no-commit", sha], { cwd: basePath, timeout: 10000, stdio: "ignore" });
commitsReverted++;
} catch {
// Revert conflict or already reverted — skip
try { execFileSync("git", ["revert", "--abort"], { cwd: basePath, timeout: 5000, stdio: "ignore" }); } catch { /* no-op */ }
break;
}
}
}
}
// 6. Re-derive state
invalidateStateCache();
await deriveState(basePath);
// Build result message
const results: string[] = [`Undone: ${unitType} (${unitId})`];
results.push(` - Removed from completed-units.json`);
if (summaryRemoved) results.push(` - Deleted summary artifact`);
if (planUpdated) results.push(` - Unchecked task in PLAN`);
if (commitsReverted > 0) {
results.push(` - Reverted ${commitsReverted} commit(s) (staged, not committed)`);
results.push(` Review with 'git diff --cached' then 'git commit' or 'git reset HEAD'`);
}
ctx.ui.notify(results.join("\n"), "success");
sendDesktopNotification("GSD", `Undone: ${unitType} (${unitId})`, "info", "complete");
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
export function uncheckTaskInPlan(basePath: string, mid: string, sid: string, tid: string): boolean {
const slicePath = resolveSlicePath(basePath, mid, sid);
if (!slicePath) return false;
// Find the PLAN file
const planCandidates = findFileWithPrefix(slicePath, sid, "PLAN");
if (planCandidates.length === 0) return false;
const planFile = planCandidates[0];
let content = readFileSync(planFile, "utf-8");
// Match checked task line: - [x] **T01** or - [x] T01:
const regex = new RegExp(`^(\\s*-\\s*)\\[x\\](\\s*\\**${tid}\\**[:\\s])`, "mi");
if (regex.test(content)) {
content = content.replace(regex, "$1[ ]$2");
writeFileSync(planFile, content, "utf-8");
return true;
}
return false;
}
function findFileWithPrefix(dir: string, prefix: string, suffix: string): string[] {
try {
const files = readdirSync(dir);
return files
.filter(f => f.includes(suffix) && (f.startsWith(prefix) || f.startsWith(`${prefix}-`)))
.map(f => join(dir, f));
} catch {
return [];
}
}
export function findCommitsForUnit(activityDir: string, unitType: string, unitId: string): string[] {
const safeUnitId = unitId.replace(/\//g, "-");
const commits: string[] = [];
try {
const files = readdirSync(activityDir)
.filter(f => f.includes(unitType) && f.includes(safeUnitId) && f.endsWith(".jsonl"))
.sort()
.reverse();
if (files.length === 0) return [];
// Parse the most recent activity log for this unit
const content = readFileSync(join(activityDir, files[0]), "utf-8");
for (const line of content.split("\n")) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
// Look for tool results containing git commit output
if (entry?.message?.content) {
const blocks = Array.isArray(entry.message.content) ? entry.message.content : [];
for (const block of blocks) {
if (block.type === "tool_result" && typeof block.content === "string") {
for (const sha of extractCommitShas(block.content)) {
if (!commits.includes(sha)) {
commits.push(sha);
}
}
}
}
}
} catch { /* malformed JSON line — skip */ }
}
} catch { /* activity dir issues — skip */ }
return commits;
}
export function extractCommitShas(content: string): string[] {
const commits: string[] = [];
for (const match of content.matchAll(/\[[\w/.-]+\s+([a-f0-9]{7,40})\]/g)) {
const sha = match[1];
if (sha && !commits.includes(sha)) {
commits.push(sha);
}
}
return commits;
}

View file

@ -1,18 +1,15 @@
/**
* GSD Slice Branch Management Thin Facade
* GSD Worktree Utilities
*
* Simple branch-per-slice workflow. No worktrees, no registry.
* Runtime state (metrics, activity, lock, STATE.md) is gitignored
* so branch switches are clean.
* Pure utility functions for worktree name detection, legacy branch name
* parsing, and integration branch capture.
*
* All git-mutation functions delegate to GitServiceImpl from git-service.ts.
* Pure utility functions (detectWorktreeName, getSliceBranchName, parseSliceBranch,
* SLICE_BRANCH_RE) remain standalone.
* SLICE_BRANCH_RE) remain standalone for backwards compatibility.
*
* Flow:
* 1. ensureSliceBranch() create + checkout slice branch
* 2. agent does work, commits
* 3. mergeSliceToMain() checkout integration branch, squash-merge, delete slice branch
* Branchless architecture: all work commits sequentially on the milestone branch.
* Pure utility functions (detectWorktreeName, getSliceBranchName, parseSliceBranch,
* SLICE_BRANCH_RE) remain for backwards compatibility with legacy branches.
*/
import { sep } from "node:path";
@ -20,8 +17,6 @@ import { sep } from "node:path";
import { GitServiceImpl, writeIntegrationBranch } from "./git-service.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
// Re-export MergeSliceResult from the canonical source (D014 — type-only re-export)
export type { MergeSliceResult } from "./git-service.js";
export { MergeConflictError } from "./git-service.js";
// ─── Lazy GitServiceImpl Cache ─────────────────────────────────────────────
@ -140,19 +135,6 @@ export function getCurrentBranch(basePath: string): string {
return getService(basePath).getCurrentBranch();
}
/**
* Ensure the slice branch exists and is checked out.
* Creates the branch from the current branch if it's not a slice branch,
* otherwise from main. This preserves planning artifacts (CONTEXT, ROADMAP,
* etc.) that were committed on the working branch which may differ from
* the repo's default branch (e.g. `developer` vs `main`).
* When inside a worktree, the branch is namespaced to avoid conflicts.
* Returns true if the branch was newly created.
*/
export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId: string): boolean {
return getService(basePath).ensureSliceBranch(milestoneId, sliceId);
}
/**
* Auto-commit any dirty files in the current working tree.
* Returns the commit message used, or null if already clean.
@ -163,44 +145,4 @@ export function autoCommitCurrentBranch(
return getService(basePath).autoCommit(unitType, unitId);
}
/**
* 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 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,
): import("./git-service.ts").MergeSliceResult {
return getService(basePath).mergeSliceToMain(milestoneId, sliceId, sliceTitle);
}
// ─── Query Functions (delegate to GitServiceImpl) ──────────────────────────
/**
* Check if we're currently on a slice branch (not main).
* Handles both plain (gsd/M001/S01) and worktree-namespaced (gsd/wt/M001/S01) branches.
*/
export function isOnSliceBranch(basePath: string): boolean {
const current = getCurrentBranch(basePath);
return SLICE_BRANCH_RE.test(current);
}
/**
* Get the active slice branch name, or null if on main.
* Handles both plain and worktree-namespaced branch patterns.
*/
export function getActiveSliceBranch(basePath: string): string | null {
try {
const current = getCurrentBranch(basePath);
return SLICE_BRANCH_RE.test(current) ? current : null;
} catch {
return null;
}
}