From ed47018496901410822ea8f8ad6b18a3e31ff8e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Sun, 15 Mar 2026 13:21:58 -0600 Subject: [PATCH 01/23] refactor(git): branchless worktree architecture (#506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminate slice branches — all work commits sequentially on milestone/ within auto-mode worktrees. No branch creation, switching, or merging within a worktree. Planning artifacts (.gsd/milestones/) tracked in git properly instead of being blanket-gitignored then force-added. Removes ~2,600 lines: ensureSliceBranch, switchToMain, mergeSliceToMain, mergeSliceToMilestone, shouldUseWorktreeIsolation, getMergeToMainMode, withMergeHeal, recoverCheckout, fix-merge dispatch/labels, and associated tests. Adds legacy_slice_branches doctor check, deprecation warnings for git.isolation and git.merge_to_main preferences. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 29 +- ...DR-001-branchless-worktree-architecture.md | 279 +++++++ docs/PRD-branchless-worktree-architecture.md | 383 +++++++++ src/resources/extensions/gsd/auto-worktree.ts | 209 +---- src/resources/extensions/gsd/auto.ts | 375 +-------- src/resources/extensions/gsd/doctor.ts | 25 +- src/resources/extensions/gsd/git-self-heal.ts | 71 -- src/resources/extensions/gsd/git-service.ts | 387 +-------- src/resources/extensions/gsd/gitignore.ts | 14 +- src/resources/extensions/gsd/preferences.ts | 27 +- .../gsd/tests/auto-worktree-merge.test.ts | 282 ------- .../auto-worktree-milestone-merge.test.ts | 5 +- .../gsd/tests/git-self-heal.test.ts | 106 +-- .../extensions/gsd/tests/git-service.test.ts | 769 +----------------- .../gsd/tests/idle-recovery.test.ts | 113 --- .../integration-mixed-milestones.test.ts | 96 +-- .../gsd/tests/isolation-resolver.test.ts | 107 --- .../gsd/tests/orphaned-branch.test.ts | 353 -------- .../gsd/tests/preferences-git.test.ts | 79 +- .../extensions/gsd/tests/worktree-e2e.test.ts | 100 +-- .../gsd/tests/worktree-integration.test.ts | 83 +- .../extensions/gsd/tests/worktree.test.ts | 427 +--------- src/resources/extensions/gsd/worktree.ts | 40 +- 23 files changed, 879 insertions(+), 3480 deletions(-) create mode 100644 docs/ADR-001-branchless-worktree-architecture.md create mode 100644 docs/PRD-branchless-worktree-architecture.md delete mode 100644 src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts delete mode 100644 src/resources/extensions/gsd/tests/isolation-resolver.test.ts delete mode 100644 src/resources/extensions/gsd/tests/orphaned-branch.test.ts diff --git a/.gitignore b/.gitignore index 82161d724..cdea9257c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/ADR-001-branchless-worktree-architecture.md b/docs/ADR-001-branchless-worktree-architecture.md new file mode 100644 index 000000000..478dade24 --- /dev/null +++ b/docs/ADR-001-branchless-worktree-architecture.md @@ -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/`). 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/`. 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/` +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 -- ` (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 :.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//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 diff --git a/docs/PRD-branchless-worktree-architecture.md b/docs/PRD-branchless-worktree-architecture.md new file mode 100644 index 000000000..4c511353c --- /dev/null +++ b/docs/PRD-branchless-worktree-architecture.md @@ -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/`. + +### 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/` +5. Build commit message with milestone summary + slice manifest +6. `git commit` +7. Optional: `git push` +8. `removeWorktree()` + `git branch -D milestone/` + +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//` 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. diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index e45ae0544..d06d25449 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -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 { @@ -238,117 +185,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 +252,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 +269,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) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 3dfe517a0..aef0fb752 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -74,17 +74,13 @@ import { execSync, execFileSync } from "node:child_process"; import { autoCommitCurrentBranch, captureIntegrationBranch, - ensureSliceBranch, 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 +90,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"; @@ -485,119 +478,6 @@ async function selfHealRuntimeRecords(base: string, ctx: ExtensionContext): Prom } } -/** - * 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, -): Promise { - // List all local gsd// 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 +505,7 @@ 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)) { + if (currentMilestoneId && originalBasePath && !isInAutoWorktree(basePath)) { try { const existingWtPath = getAutoWorktreePath(originalBasePath, currentMilestoneId); if (existingWtPath) { @@ -789,7 +669,7 @@ 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. originalBasePath = base; - if (currentMilestoneId && shouldUseWorktreeIsolation(base)) { + if (currentMilestoneId) { try { const existingWtPath = getAutoWorktreePath(base, currentMilestoneId); if (existingWtPath) { @@ -855,12 +735,6 @@ 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); @@ -1230,7 +1104,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 +1120,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 +1143,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 ""; } } @@ -1601,9 +1472,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 +1483,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"; @@ -1813,7 +1543,7 @@ async function dispatchNextUnit( } 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"); @@ -1902,7 +1632,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; @@ -3193,45 +2923,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 \``, - `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 +3090,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 +3496,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 +3518,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 +3625,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; } diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index 16cfd4a58..ae03220c7 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -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 { diff --git a/src/resources/extensions/gsd/git-self-heal.ts b/src/resources/extensions/gsd/git-self-heal.ts index 513f54b6f..305d01034 100644 --- a/src/resources/extensions/gsd/git-self-heal.ts +++ b/src/resources/extensions/gsd/git-self-heal.ts @@ -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(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 `. - * 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 }> = [ { diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 6db4694f0..9dd22eede 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -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 ─────────────────────────────────────────── @@ -190,10 +165,7 @@ 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, ["commit", "--no-verify", "-F", "-"], { @@ -332,20 +304,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 }); } @@ -470,98 +428,6 @@ export class GitServiceImpl { 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. * @@ -644,253 +510,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 ───────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/gitignore.ts b/src/resources/extensions/gsd/gitignore.ts index 8626bc6af..008ce7dcd 100644 --- a/src/resources/extensions/gsd/gitignore.ts +++ b/src/resources/extensions/gsd/gitignore.ts @@ -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", @@ -117,8 +124,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 diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 2f06c7154..1b3d9eabc 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -296,6 +296,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 +644,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 +734,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 +800,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 +914,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 +928,7 @@ export function validatePreferences(preferences: GSDPreferences): { } } - return { preferences: validated, errors }; + return { preferences: validated, errors, warnings }; } function mergeStringLists(base?: unknown, override?: unknown): string[] | undefined { diff --git a/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts b/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts deleted file mode 100644 index 78e137628..000000000 --- a/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +++ /dev/null @@ -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 { - 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(); diff --git a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts index 034bbf118..af6e64e13 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts @@ -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 { diff --git a/src/resources/extensions/gsd/tests/git-self-heal.test.ts b/src/resources/extensions/gsd/tests/git-self-heal.test.ts index 6c6e2ecad..58bf81d59 100644 --- a/src/resources/extensions/gsd/tests/git-self-heal.test.ts +++ b/src/resources/extensions/gsd/tests/git-self-heal.test.ts @@ -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 ──"); diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index 68ed0ec20..eaba65c7c 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -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 { 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 { ".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 { // 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 }); @@ -591,486 +590,8 @@ async function main(): Promise { 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 +602,12 @@ async function main(): Promise { 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 +625,7 @@ async function main(): Promise { 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 +722,6 @@ async function main(): Promise { 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 +933,6 @@ async function main(): Promise { 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 ==="); diff --git a/src/resources/extensions/gsd/tests/idle-recovery.test.ts b/src/resources/extensions/gsd/tests/idle-recovery.test.ts index 4f63dcb99..8c52f2a3f 100644 --- a/src/resources/extensions/gsd/tests/idle-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/idle-recovery.test.ts @@ -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 diff --git a/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts b/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts index 7e16581eb..1cbdba021 100644 --- a/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +++ b/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts @@ -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 ────────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/tests/isolation-resolver.test.ts b/src/resources/extensions/gsd/tests/isolation-resolver.test.ts deleted file mode 100644 index ff455ef38..000000000 --- a/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +++ /dev/null @@ -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 { - 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(); diff --git a/src/resources/extensions/gsd/tests/orphaned-branch.test.ts b/src/resources/extensions/gsd/tests/orphaned-branch.test.ts deleted file mode 100644 index 31ef1a877..000000000 --- a/src/resources/extensions/gsd/tests/orphaned-branch.test.ts +++ /dev/null @@ -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(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); diff --git a/src/resources/extensions/gsd/tests/preferences-git.test.ts b/src/resources/extensions/gsd/tests/preferences-git.test.ts index 616cc9f9c..802a75f7c 100644 --- a/src/resources/extensions/gsd/tests/preferences-git.test.ts +++ b/src/resources/extensions/gsd/tests/preferences-git.test.ts @@ -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 { - 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(); diff --git a/src/resources/extensions/gsd/tests/worktree-e2e.test.ts b/src/resources/extensions/gsd/tests/worktree-e2e.test.ts index 6fab1e7e7..b621a43a4 100644 --- a/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-e2e.test.ts @@ -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 { @@ -144,59 +140,10 @@ async function main(): Promise { } // ================================================================ - // 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 { 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. // ================================================================ diff --git a/src/resources/extensions/gsd/tests/worktree-integration.test.ts b/src/resources/extensions/gsd/tests/worktree-integration.test.ts index e2cf0cc1a..2d4bcdb4a 100644 --- a/src/resources/extensions/gsd/tests/worktree-integration.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-integration.test.ts @@ -4,8 +4,6 @@ * Tests the full lifecycle of GSD operations inside a worktree: * - Branch namespacing (gsd/// instead of gsd//) * - getMainBranch returns worktree/ inside a worktree - * - switchToMain goes to worktree/, not main - * - mergeSliceToMain merges into worktree/ * - Parallel worktrees don't conflict on branch names * - State derivation works correctly inside worktrees */ @@ -19,20 +17,15 @@ import { createWorktree, listWorktrees, removeWorktree, - worktreePath, - worktreeBranchName, } from "../worktree-manager.ts"; import { detectWorktreeName, - ensureSliceBranch, - getActiveSliceBranch, getCurrentBranch, getMainBranch, getSliceBranchName, isOnSliceBranch, - mergeSliceToMain, - switchToMain, + getActiveSliceBranch, autoCommitCurrentBranch, } from "../worktree.ts"; @@ -104,21 +97,21 @@ async function main(): Promise { 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(isOnSliceBranch(wt.path), "isOnSliceBranch returns true"); + assertEq(getActiveSliceBranch(wt.path), "gsd/alpha/M001/S01", "getActiveSliceBranch returns namespaced branch"); + // ── Do work on slice branch, then merge to worktree branch ───────────────── console.log("\n=== Work and merge slice in worktree ==="); @@ -126,14 +119,12 @@ async function main(): Promise { 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 +135,19 @@ async function main(): Promise { // ── 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 +155,13 @@ async function main(): Promise { 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 +173,7 @@ async function main(): Promise { 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 +181,8 @@ async function main(): Promise { // ── 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 +192,8 @@ async function main(): Promise { 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"); diff --git a/src/resources/extensions/gsd/tests/worktree.test.ts b/src/resources/extensions/gsd/tests/worktree.test.ts index 69de83435..f2ac55d9b 100644 --- a/src/resources/extensions/gsd/tests/worktree.test.ts +++ b/src/resources/extensions/gsd/tests/worktree.test.ts @@ -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,16 @@ 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 +36,12 @@ run("git add .", base); run('git commit -m "chore: init"', base); async function main(): Promise { - 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 on main ==="); + assertEq(getActiveSliceBranch(base), null, "getActiveSliceBranch returns null on main"); - 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=== isOnSliceBranch on main ==="); + assertEq(isOnSliceBranch(base), false, "isOnSliceBranch returns false on main"); console.log("\n=== autoCommitCurrentBranch ==="); // Clean — should return null @@ -75,56 +55,6 @@ async function main(): Promise { 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 +91,8 @@ async function main(): Promise { 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 +121,6 @@ async function main(): Promise { 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 +170,6 @@ async function main(): Promise { 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(); } diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 1a4e65d5c..a215657a5 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -9,10 +9,9 @@ * Pure utility functions (detectWorktreeName, getSliceBranchName, parseSliceBranch, * SLICE_BRANCH_RE) remain standalone. * - * 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 +19,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 +137,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,24 +147,6 @@ 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) ────────────────────────── /** From e6d55f8aafb48672e669b7c5449374a4e41cfb12 Mon Sep 17 00:00:00 2001 From: Flux Labs Date: Sun, 15 Mar 2026 14:33:43 -0500 Subject: [PATCH 02/23] Perf/gsd startup speed (#497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add startup performance analysis and optimization plan Profiled GSD CLI startup finding 2.2s for --version and ~3.8s for interactive mode. Identified 5 root causes with measured timings and created a phased optimization plan targeting <0.2s for --version and ~0.8s for interactive startup. * perf: speed up GSD startup with lazy loading and fast paths - Fast-path --version/-v and --help/-h in loader.ts before importing any heavy dependencies (2.2s → 0.15s, 14x faster) - Lazy-load undici (~200ms) only when HTTP_PROXY env vars are set - Skip initResources cpSync when managed-resources.json version matches current GSD version (~128ms saved per launch) - Lazy-load Mistral SDK (~369ms) on first API call instead of startup - Lazy-load Google GenAI SDK (~186ms) on first API call instead of startup - Parallelize extension loading with Promise.all() instead of sequential for-loop --------- Co-authored-by: TÂCHES --- .plans/startup-performance.md | 157 ++++++++++++++++++ packages/pi-ai/src/providers/google.ts | 28 +++- packages/pi-ai/src/providers/mistral.ts | 16 +- .../src/core/extensions/loader.ts | 22 ++- src/loader.ts | 58 ++++++- src/resource-loader.ts | 24 ++- 6 files changed, 272 insertions(+), 33 deletions(-) create mode 100644 .plans/startup-performance.md diff --git a/.plans/startup-performance.md b/.plans/startup-performance.md new file mode 100644 index 000000000..417f6b411 --- /dev/null +++ b/.plans/startup-performance.md @@ -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** | diff --git a/packages/pi-ai/src/providers/google.ts b/packages/pi-ai/src/providers/google.ts index 991d5c90d..d1f42ed05 100644 --- a/packages/pi-ai/src/providers/google.ts +++ b/packages/pi-ai/src/providers/google.ts @@ -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 { + 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, -): GoogleGenAI { +): Promise { const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record } = {}; 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, }); diff --git a/packages/pi-ai/src/providers/mistral.ts b/packages/pi-ai/src/providers/mistral.ts index 95d3f839e..a7a495a3a 100644 --- a/packages/pi-ai/src/providers/mistral.ts +++ b/packages/pi-ai/src/providers/mistral.ts @@ -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 { + 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, }); diff --git a/packages/pi-coding-agent/src/core/extensions/loader.ts b/packages/pi-coding-agent/src/core/extensions/loader.ts index 8e9cdebe9..e6c16d569 100644 --- a/packages/pi-coding-agent/src/core/extensions/loader.ts +++ b/packages/pi-coding-agent/src/core/extensions/loader.ts @@ -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 { - 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); } } diff --git a/src/loader.ts b/src/loader.ts index 5301d25d5..5bf3e5611 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -1,7 +1,52 @@ #!/usr/bin/env node +// GSD Startup Loader +// Copyright (c) 2026 Jeremy McSpadden 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 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 Override model (e.g. claude-opus-4-6)\n') + process.stdout.write(' --no-session Disable session persistence\n') + process.stdout.write(' --extension Load additional extension\n') + process.stdout.write(' --tools 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. diff --git a/src/resource-loader.ts b/src/resource-loader.ts index 676e52979..31c4ae528 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -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)) { From dda9d713f2691fe40ba9d2b2c2fd8e06b579e0da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Sun, 15 Mar 2026 13:42:41 -0600 Subject: [PATCH 03/23] chore: remove dead code from branchless refactor (#510) Remove methods, fields, and comments left over after the branchless worktree architecture landed: - git-service.ts: delete isOnSliceBranch, getActiveSliceBranch, branchExists (private), discardUntrackedRuntimeFiles (private); update writeIntegrationBranch comment and remove --force flag - worktree.ts: delete isOnSliceBranch, getActiveSliceBranch exports; update module doc from "branch-per-slice" to "worktree utilities" - state.ts: remove activeBranch computation (always null) - types.ts: remove GSDState.activeBranch field - templates/state.md: remove "Slice Branch" line - auto.ts, guided-flow.ts: update "branch-per-slice" comments - Tests updated to match Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/auto.ts | 2 +- src/resources/extensions/gsd/git-service.ts | 52 ++----------------- src/resources/extensions/gsd/guided-flow.ts | 2 +- src/resources/extensions/gsd/state.ts | 13 ++--- .../extensions/gsd/templates/state.md | 1 - .../extensions/gsd/tests/git-service.test.ts | 12 +---- .../gsd/tests/worktree-integration.test.ts | 6 +-- .../extensions/gsd/tests/worktree.test.ts | 8 --- src/resources/extensions/gsd/types.ts | 1 - src/resources/extensions/gsd/worktree.ts | 32 ++---------- 10 files changed, 20 insertions(+), 109 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index aef0fb752..a65c16ae3 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -553,7 +553,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 { diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 9dd22eede..966ef6d3e 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -132,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 @@ -167,7 +165,7 @@ export function writeIntegrationBranch(basePath: string, milestoneId: string, br // 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`, }); @@ -404,48 +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); - } - - /** - * 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 ───────────────────────────────────────────────────── /** diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index fddd76d6f..4d6dbd33c 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -700,7 +700,7 @@ export async function showSmartEntry( ): Promise { 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 { diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 6d15b1c5b..0cc4b6bc5 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -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 { }; } - 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 { 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 { 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 { 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 { 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 { 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: { diff --git a/src/resources/extensions/gsd/templates/state.md b/src/resources/extensions/gsd/templates/state.md index 35c000b1c..76ce20ee7 100644 --- a/src/resources/extensions/gsd/templates/state.md +++ b/src/resources/extensions/gsd/templates/state.md @@ -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}} diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index eaba65c7c..51989d732 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -533,7 +533,7 @@ async function main(): Promise { return dir; } - // ─── getCurrentBranch / isOnSliceBranch / getActiveSliceBranch ───────── + // ─── getCurrentBranch ──────────────────────────────────────────────── console.log("\n=== Branch queries ==="); @@ -541,21 +541,13 @@ async function main(): Promise { 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 }); } diff --git a/src/resources/extensions/gsd/tests/worktree-integration.test.ts b/src/resources/extensions/gsd/tests/worktree-integration.test.ts index 2d4bcdb4a..5d153eec1 100644 --- a/src/resources/extensions/gsd/tests/worktree-integration.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-integration.test.ts @@ -24,9 +24,8 @@ import { getCurrentBranch, getMainBranch, getSliceBranchName, - isOnSliceBranch, - getActiveSliceBranch, autoCommitCurrentBranch, + SLICE_BRANCH_RE, } from "../worktree.ts"; import { deriveState } from "../state.ts"; @@ -109,8 +108,7 @@ async function main(): Promise { 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(isOnSliceBranch(wt.path), "isOnSliceBranch returns true"); - assertEq(getActiveSliceBranch(wt.path), "gsd/alpha/M001/S01", "getActiveSliceBranch returns namespaced 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 ───────────────── diff --git a/src/resources/extensions/gsd/tests/worktree.test.ts b/src/resources/extensions/gsd/tests/worktree.test.ts index f2ac55d9b..a84570ea5 100644 --- a/src/resources/extensions/gsd/tests/worktree.test.ts +++ b/src/resources/extensions/gsd/tests/worktree.test.ts @@ -7,11 +7,9 @@ import { autoCommitCurrentBranch, captureIntegrationBranch, detectWorktreeName, - getActiveSliceBranch, getCurrentBranch, getMainBranch, getSliceBranchName, - isOnSliceBranch, parseSliceBranch, setActiveMilestoneId, SLICE_BRANCH_RE, @@ -37,12 +35,6 @@ run('git commit -m "chore: init"', base); async function main(): Promise { - console.log("\n=== getActiveSliceBranch on main ==="); - assertEq(getActiveSliceBranch(base), null, "getActiveSliceBranch returns null on main"); - - console.log("\n=== isOnSliceBranch on main ==="); - assertEq(isOnSliceBranch(base), false, "isOnSliceBranch returns false on main"); - console.log("\n=== autoCommitCurrentBranch ==="); // Clean — should return null const cleanResult = autoCommitCurrentBranch(base, "execute-task", "M001/S01/T01"); diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index e394b90b3..c119a7393 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -175,7 +175,6 @@ export interface GSDState { recentDecisions: string[]; blockers: string[]; nextAction: string; - activeBranch?: string; activeWorkspace?: string; registry: MilestoneRegistryEntry[]; requirements?: RequirementCounts; diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index a215657a5..6ab512c71 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -1,13 +1,11 @@ /** - * 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. * * Branchless architecture: all work commits sequentially on the milestone branch. * Pure utility functions (detectWorktreeName, getSliceBranchName, parseSliceBranch, @@ -147,26 +145,4 @@ export function autoCommitCurrentBranch( return getService(basePath).autoCommit(unitType, unitId); } -// ─── 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; - } -} From 27d07a35d74a4dd48f862136b7fd24e6491a241a Mon Sep 17 00:00:00 2001 From: deseltrus <101901449+deseltrus@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:55:07 +0100 Subject: [PATCH 04/23] fix(discuss): enforce depends_on frontmatter in multi-milestone CONTEXT.md (#507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-milestone discussion flow writes CONTEXT.md files for each milestone but never adds depends_on YAML frontmatter. The QUEUE.md documents the dependency chain, but the auto-mode state machine reads dependencies from CONTEXT.md frontmatter only — not from QUEUE.md. Without frontmatter, milestones execute in filesystem order regardless of their actual dependency chain, causing out-of-order execution. Fix: Added MANDATORY depends_on documentation to both discuss.md (Phase 2, after primary milestone) and queue.md (output section). Instructs the LLM to write frontmatter with the exact milestone IDs from the dependency chain confirmed during the milestone split gate. Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: TÂCHES --- src/resources/extensions/gsd/prompts/discuss.md | 14 ++++++++++++++ src/resources/extensions/gsd/prompts/queue.md | 8 +++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/prompts/discuss.md b/src/resources/extensions/gsd/prompts/discuss.md index fef9176b8..e6f4ff3c4 100644 --- a/src/resources/extensions/gsd/prompts/discuss.md +++ b/src/resources/extensions/gsd/prompts/discuss.md @@ -215,6 +215,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: diff --git a/src/resources/extensions/gsd/prompts/queue.md b/src/resources/extensions/gsd/prompts/queue.md index f53f11078..08bf5b4c6 100644 --- a/src/resources/extensions/gsd/prompts/queue.md +++ b/src/resources/extensions/gsd/prompts/queue.md @@ -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//slices` -2. Write `.gsd/milestones//-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//-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: From f59301e4ba5c01fffbb13160844f31e4d76191a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Sun, 15 Mar 2026 13:56:56 -0600 Subject: [PATCH 05/23] fix(auto): prevent nested worktree creation inside existing worktrees (#511) * fix(auto): prevent nested worktree creation inside existing worktrees When auto-mode starts inside a manual worktree (e.g., /worktree memory-db), it unconditionally created an auto-worktree for the milestone, nesting .gsd/worktrees/M001 inside the existing worktree. This caused GSD to chdir into the inner worktree, read state from the wrong repo, and report "All milestones complete" or loop on artifact verification. Add detectWorktreeName() guard to both the start and resume paths: if already inside a worktree, skip auto-worktree creation and work directly on the current branch. Co-Authored-By: Claude Opus 4.6 (1M context) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/resources/extensions/gsd/auto.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index a65c16ae3..bb1227a92 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -69,11 +69,13 @@ import { getProjectTotals, formatCost, formatTokenCount, } from "./metrics.js"; import { dirname, join } from "node:path"; +import { sep as pathSep } from "node:path"; import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; import { execSync, execFileSync } from "node:child_process"; import { autoCommitCurrentBranch, captureIntegrationBranch, + detectWorktreeName, getCurrentBranch, getMainBranch, MergeConflictError, @@ -505,7 +507,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)) { + // 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) { @@ -668,8 +671,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) { + + 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) { From 59698978af376ae9ac54a1dc9aefdc64595cadec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Sun, 15 Mar 2026 14:06:56 -0600 Subject: [PATCH 06/23] fix(auto): stop re-running finished tasks after session restart (#513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(auto): prevent infinite re-dispatch when completion key is missing Root cause: When a task completed successfully on the first attempt, the idempotency key was never persisted to completed-units.json. The persistence logic (persistCompletedKey) only triggered at the retry threshold (MAX_UNIT_DISPATCHES=3). After session restart, the key was missing and auto-mode re-dispatched the same task endlessly. Evidence: M008/S01/T01 was dispatched 15+ times over 3.5 hours. T01-SUMMARY.md existed, S01-PLAN.md marked T01 as [x], but completed-units.json had no execute-task/M008/S01/T01 entry. Fix: Added fallback artifact check before dispatch. If the expected artifact already exists on disk but the completion key is missing, the key is repaired (persisted + added to in-memory set) and the unit is skipped. This catches the gap between the closeout-based persistence (which requires the NEXT dispatch to fire) and the retry-threshold persistence (which requires MAX attempts). Also fixes guided-flow-escape.test.ts: added missing cache invalidation after rmSync (clearPathCache + invalidateStateCache). Co-Authored-By: Claude Opus 4.6 (1M context) * fix(auto): prevent TUI freeze on cascading skip-dispatches When multiple completed tasks are skipped in sequence (T01 artifact fallback → T02 idempotency skip → T03 dispatch), the recursive dispatchNextUnit calls can freeze the TUI. Fix: invalidateStateCache() after key repair so deriveState returns the correct next task, and use setTimeout(50ms) instead of setImmediate to yield more generously to the event loop between cascading skips. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(auto): systematic hardening of dispatch recovery pipeline Five fixes addressing the 20 failure modes identified in the auto-mode dispatch loop audit: 1. Stale runtime record cleanup: selfHealRuntimeRecords now clears records older than 1h with phase=dispatched (crash orphans), and also persists completion keys for records with existing artifacts. 2. Recursion depth limit: _skipDepth counter prevents TUI freeze when many completed units are skipped in cascade. After MAX_SKIP_DEPTH (20) skips, yields 200ms to the event loop before continuing. 3. Atomic completed-units.json writes: persistCompletedKey now uses tmp file + renameSync to prevent partial writes on crash. 4. Skip depth tracking on both skip paths (idempotency check at L1815 and artifact fallback at L1844) with setTimeout(50ms) between skips. 5. Self-heal now also repairs missing completion keys when artifact exists, closing the gap where crash between completion and closeout leaves the key unwritten. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(auto): add reentrancy guard to dispatchNextUnit itself The _handlingAgentEnd boolean only guards calls from agent_end hooks. Direct calls from watchdog timers, step wizard, and crash recovery can still race with an in-progress dispatch. Added _dispatching guard that blocks concurrent external calls while allowing recursive skip calls (_skipDepth > 0). Cleared on stopAuto. Audit confirmed: double watchdog (#11) already prevented by existing clearDispatchGapWatchdog in startDispatchGapWatchdog + catch/return. Counter cleanup (#16) already handled by unitDispatchCount.clear() in startAuto before selfHealRuntimeRecords. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(auto): final hardening for unattended multi-milestone runs Three fixes from paranoid stress-test audit: 1. Git index.lock cleanup: Remove stale .git/index.lock (>60s old) at auto-start. A crash during git commit/merge leaves this file behind, blocking ALL subsequent git operations with no recovery. 2. Stub summary for complete-milestone: If the LLM fails to write a milestone SUMMARY after MAX_UNIT_DISPATCHES attempts, generate a stub summary to unblock the pipeline. Without this, auto-mode loops forever in "completing-milestone" phase. 3. Pre-flight queue validation: At auto-start with multiple milestones, scan for CONTEXT-DRAFT.md files (will pause for discussion) and report milestone count. Gives the user early visibility into what will happen during the run. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: deseltrus Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/auto.ts | 143 +++++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 7 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index bb1227a92..faeacdc81 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -70,7 +70,7 @@ import { } from "./metrics.js"; import { dirname, join } from "node:path"; import { sep as pathSep } from "node:path"; -import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; +import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, renameSync, statSync } from "node:fs"; import { execSync, execFileSync } from "node:child_process"; import { autoCommitCurrentBranch, @@ -117,7 +117,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); } } @@ -355,6 +358,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(); @@ -463,17 +468,35 @@ 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 @@ -755,6 +778,43 @@ export async function startAuto( // 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); } @@ -1431,6 +1491,16 @@ 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, @@ -1442,6 +1512,22 @@ async function dispatchNextUnit( 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; + + // 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 @@ -1821,10 +1907,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. @@ -1837,6 +1923,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}`; @@ -1924,6 +2030,29 @@ 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); From c372c4d87c238feacf978ae1636570fad03fbad8 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sun, 15 Mar 2026 14:34:08 -0600 Subject: [PATCH 07/23] docs: update README for branchless worktree architecture --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0040ca909..f14071a0f 100644 --- a/README.md +++ b/README.md @@ -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/` 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 From 60e1abee6f39888fc23315c78e1e1b51262af49b Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sun, 15 Mar 2026 14:35:34 -0600 Subject: [PATCH 08/23] docs: update changelog for v2.14.0 --- CHANGELOG.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a465f2b..0932e67de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [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/` 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 +635,8 @@ 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.0...HEAD +[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 From 8737b509c19b778a9c68a7983a747abc5b7969fd Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sun, 15 Mar 2026 14:35:52 -0600 Subject: [PATCH 09/23] 2.14.0 --- native/npm/darwin-arm64/package.json | 2 +- native/npm/darwin-x64/package.json | 2 +- native/npm/linux-arm64-gnu/package.json | 2 +- native/npm/linux-x64-gnu/package.json | 2 +- native/npm/win32-x64-msvc/package.json | 2 +- package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/native/npm/darwin-arm64/package.json b/native/npm/darwin-arm64/package.json index 2010c6379..b0c1a97ed 100644 --- a/native/npm/darwin-arm64/package.json +++ b/native/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-darwin-arm64", - "version": "2.13.1", + "version": "2.14.0", "description": "GSD native engine binary for macOS ARM64", "os": [ "darwin" diff --git a/native/npm/darwin-x64/package.json b/native/npm/darwin-x64/package.json index f0202c4ca..ef7ac8ccf 100644 --- a/native/npm/darwin-x64/package.json +++ b/native/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-darwin-x64", - "version": "2.13.1", + "version": "2.14.0", "description": "GSD native engine binary for macOS Intel", "os": [ "darwin" diff --git a/native/npm/linux-arm64-gnu/package.json b/native/npm/linux-arm64-gnu/package.json index 56b72b63e..2e4ca3789 100644 --- a/native/npm/linux-arm64-gnu/package.json +++ b/native/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-linux-arm64-gnu", - "version": "2.13.1", + "version": "2.14.0", "description": "GSD native engine binary for Linux ARM64 (glibc)", "os": [ "linux" diff --git a/native/npm/linux-x64-gnu/package.json b/native/npm/linux-x64-gnu/package.json index 58d815262..1bdfd0d93 100644 --- a/native/npm/linux-x64-gnu/package.json +++ b/native/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-linux-x64-gnu", - "version": "2.13.1", + "version": "2.14.0", "description": "GSD native engine binary for Linux x64 (glibc)", "os": [ "linux" diff --git a/native/npm/win32-x64-msvc/package.json b/native/npm/win32-x64-msvc/package.json index aad127b1b..1bca7fb8a 100644 --- a/native/npm/win32-x64-msvc/package.json +++ b/native/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-win32-x64-msvc", - "version": "2.13.1", + "version": "2.14.0", "description": "GSD native engine binary for Windows x64 (MSVC)", "os": [ "win32" diff --git a/package.json b/package.json index 46e596bef..dfa3f821b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gsd-pi", - "version": "2.13.1", + "version": "2.14.0", "description": "GSD — Get Shit Done coding agent", "license": "MIT", "repository": { From e0c9dc638e0ee84bc58ba5931de48d1daf5cebec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Sun, 15 Mar 2026 15:09:32 -0600 Subject: [PATCH 10/23] fix(auto): quiet diagnostic noise in auto-mode warnings (#514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downgrade internal recovery machinery to info/verbose-only so users only see warnings when action is needed: - "Dispatch gap detected" → verbose-only info (recovery is automatic) - "Model not found, trying fallback" → verbose-only info - "Failed to set model, trying fallback" → verbose-only info - "Could not set any preferred model" → deleted (redundant) - "New session cancelled" → info (user action, not error) - "Unexpected phase" → info with doctor suggestion - "No command context" → info with restart suggestion Kept as warnings (user-actionable): - Budget ceiling, blockers, prior slice incomplete, pre-flight, no context, stub summary, model ambiguity, all fallbacks exhausted Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/auto.ts | 35 +++++++++++----------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index faeacdc81..ece8f8cab 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -335,10 +335,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); @@ -1507,7 +1509,7 @@ async function dispatchNextUnit( ): Promise { 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; } @@ -1861,7 +1863,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; } } @@ -2181,7 +2183,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; } @@ -2287,7 +2289,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; } @@ -2303,25 +2305,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 From dd9d8373045588e178da4f364ea2455b409e965d Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sun, 15 Mar 2026 15:11:01 -0600 Subject: [PATCH 11/23] docs: update changelog for v2.14.1 --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0932e67de..7c494a502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [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 @@ -635,7 +641,8 @@ 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.14.0...HEAD +[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.14.1...HEAD +[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 From 484524b528be4e98611617a706d6d23285281894 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sun, 15 Mar 2026 15:11:01 -0600 Subject: [PATCH 12/23] 2.14.1 --- native/npm/darwin-arm64/package.json | 2 +- native/npm/darwin-x64/package.json | 2 +- native/npm/linux-arm64-gnu/package.json | 2 +- native/npm/linux-x64-gnu/package.json | 2 +- native/npm/win32-x64-msvc/package.json | 2 +- package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/native/npm/darwin-arm64/package.json b/native/npm/darwin-arm64/package.json index b0c1a97ed..97b43716b 100644 --- a/native/npm/darwin-arm64/package.json +++ b/native/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-darwin-arm64", - "version": "2.14.0", + "version": "2.14.1", "description": "GSD native engine binary for macOS ARM64", "os": [ "darwin" diff --git a/native/npm/darwin-x64/package.json b/native/npm/darwin-x64/package.json index ef7ac8ccf..3e012ad80 100644 --- a/native/npm/darwin-x64/package.json +++ b/native/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-darwin-x64", - "version": "2.14.0", + "version": "2.14.1", "description": "GSD native engine binary for macOS Intel", "os": [ "darwin" diff --git a/native/npm/linux-arm64-gnu/package.json b/native/npm/linux-arm64-gnu/package.json index 2e4ca3789..3d05fbf48 100644 --- a/native/npm/linux-arm64-gnu/package.json +++ b/native/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-linux-arm64-gnu", - "version": "2.14.0", + "version": "2.14.1", "description": "GSD native engine binary for Linux ARM64 (glibc)", "os": [ "linux" diff --git a/native/npm/linux-x64-gnu/package.json b/native/npm/linux-x64-gnu/package.json index 1bdfd0d93..1536590e6 100644 --- a/native/npm/linux-x64-gnu/package.json +++ b/native/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-linux-x64-gnu", - "version": "2.14.0", + "version": "2.14.1", "description": "GSD native engine binary for Linux x64 (glibc)", "os": [ "linux" diff --git a/native/npm/win32-x64-msvc/package.json b/native/npm/win32-x64-msvc/package.json index 1bca7fb8a..444590f85 100644 --- a/native/npm/win32-x64-msvc/package.json +++ b/native/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-win32-x64-msvc", - "version": "2.14.0", + "version": "2.14.1", "description": "GSD native engine binary for Windows x64 (MSVC)", "os": [ "win32" diff --git a/package.json b/package.json index dfa3f821b..b0917477b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gsd-pi", - "version": "2.14.0", + "version": "2.14.1", "description": "GSD — Get Shit Done coding agent", "license": "MIT", "repository": { From e759af8e2a6ef40dea9c800935d39fcae65b84cb Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sun, 15 Mar 2026 15:20:45 -0600 Subject: [PATCH 13/23] fix(auto): reset _dispatching flag + improve discuss depth verification UX - auto.ts: wrap dispatchNextUnit body in try/finally to always reset _dispatching to false. Without this, the reentrancy guard permanently blocked all subsequent dispatches after the first one, causing the dispatch gap watchdog to fire and auto-mode to stall. - discuss.md: render depth summary as chat text (where markdown renders) then use ask_user_questions for the short confirmation only. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/auto.ts | 5 ++++- src/resources/extensions/gsd/prompts/discuss.md | 14 ++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index ece8f8cab..4bd601695 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -1520,7 +1520,7 @@ async function dispatchNextUnit( 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. @@ -2425,6 +2425,9 @@ async function dispatchNextUnit( ); await pauseAuto(ctx, pi); } + } finally { + _dispatching = false; + } } // ─── Skill Discovery ────────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/prompts/discuss.md b/src/resources/extensions/gsd/prompts/discuss.md index e6f4ff3c4..d66ca2932 100644 --- a/src/resources/extensions/gsd/prompts/discuss.md +++ b/src/resources/extensions/gsd/prompts/discuss.md @@ -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 From cc22920c2ea21375efcc53cfa2efaefc3c01fb79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Sun, 15 Mar 2026 15:21:04 -0600 Subject: [PATCH 14/23] fix(gitignore): self-heal blanket .gsd/ ignore from pre-v2.14.0 projects (#515) ensureGitignore() now detects and removes standalone ".gsd/" lines that blanket-ignore the entire directory. Replaces with explicit runtime-only patterns so .gsd/milestones/ planning artifacts are tracked in git. Without this, existing projects keep the old blanket ignore forever. New worktrees start with zero planning state because artifacts aren't in git, causing auto-mode to re-execute completed work. Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/gitignore.ts | 24 ++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/gitignore.ts b/src/resources/extensions/gsd/gitignore.ts index 008ce7dcd..afde88d66 100644 --- a/src/resources/extensions/gsd/gitignore.ts +++ b/src/resources/extensions/gsd/gitignore.ts @@ -87,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 @@ -98,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 = [ From 4465b5ea766c9b012e3acda1ec282d67b6414987 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sun, 15 Mar 2026 15:22:41 -0600 Subject: [PATCH 15/23] docs: update changelog for v2.14.2 --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c494a502..92da621bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [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 @@ -641,7 +648,8 @@ 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.14.1...HEAD +[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.14.2...HEAD +[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 From b333d450a3f7d61ce61c68edb1ec5ca0bf4caea0 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sun, 15 Mar 2026 15:22:41 -0600 Subject: [PATCH 16/23] 2.14.2 --- native/npm/darwin-arm64/package.json | 2 +- native/npm/darwin-x64/package.json | 2 +- native/npm/linux-arm64-gnu/package.json | 2 +- native/npm/linux-x64-gnu/package.json | 2 +- native/npm/win32-x64-msvc/package.json | 2 +- package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/native/npm/darwin-arm64/package.json b/native/npm/darwin-arm64/package.json index 97b43716b..af984f77b 100644 --- a/native/npm/darwin-arm64/package.json +++ b/native/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-darwin-arm64", - "version": "2.14.1", + "version": "2.14.2", "description": "GSD native engine binary for macOS ARM64", "os": [ "darwin" diff --git a/native/npm/darwin-x64/package.json b/native/npm/darwin-x64/package.json index 3e012ad80..c16559ef4 100644 --- a/native/npm/darwin-x64/package.json +++ b/native/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-darwin-x64", - "version": "2.14.1", + "version": "2.14.2", "description": "GSD native engine binary for macOS Intel", "os": [ "darwin" diff --git a/native/npm/linux-arm64-gnu/package.json b/native/npm/linux-arm64-gnu/package.json index 3d05fbf48..829bef792 100644 --- a/native/npm/linux-arm64-gnu/package.json +++ b/native/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-linux-arm64-gnu", - "version": "2.14.1", + "version": "2.14.2", "description": "GSD native engine binary for Linux ARM64 (glibc)", "os": [ "linux" diff --git a/native/npm/linux-x64-gnu/package.json b/native/npm/linux-x64-gnu/package.json index 1536590e6..2a0e31303 100644 --- a/native/npm/linux-x64-gnu/package.json +++ b/native/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-linux-x64-gnu", - "version": "2.14.1", + "version": "2.14.2", "description": "GSD native engine binary for Linux x64 (glibc)", "os": [ "linux" diff --git a/native/npm/win32-x64-msvc/package.json b/native/npm/win32-x64-msvc/package.json index 444590f85..115f9ff9b 100644 --- a/native/npm/win32-x64-msvc/package.json +++ b/native/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-win32-x64-msvc", - "version": "2.14.1", + "version": "2.14.2", "description": "GSD native engine binary for Windows x64 (MSVC)", "os": [ "win32" diff --git a/package.json b/package.json index b0917477b..37e54901a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gsd-pi", - "version": "2.14.1", + "version": "2.14.2", "description": "GSD — Get Shit Done coding agent", "license": "MIT", "repository": { From 5ae8cf88515ad5fa298194a7c0bea32d306aed6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Sun, 15 Mar 2026 15:33:05 -0600 Subject: [PATCH 17/23] fix(auto): copy planning artifacts into new auto-worktrees (#516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-worktrees are fresh git checkouts — untracked .gsd/ files don't carry over. Projects with the old blanket .gsd/ gitignore have planning artifacts on disk but not in git. When createAutoWorktree makes a new worktree, the milestones/, DECISIONS.md, REQUIREMENTS.md etc are missing, causing auto-mode to loop on plan-slice (plan file not found in worktree). Copy .gsd/ planning artifacts from the source repo into the new worktree after git worktree add. Skips runtime files and the worktrees/ dir. Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/auto-worktree.ts | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index d06d25449..df0efb87c 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -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 { @@ -90,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 { @@ -107,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. From 13fe8b2c848bd7a2e93020529e9e3411fa8df8d7 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sun, 15 Mar 2026 15:34:13 -0600 Subject: [PATCH 18/23] docs: update changelog for v2.14.3 --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92da621bb..21ecc6e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [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 @@ -648,7 +653,8 @@ 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.14.2...HEAD +[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.14.3...HEAD +[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 From ce78c2a7d5cda4509e938336ba344738f3d88fac Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sun, 15 Mar 2026 15:34:13 -0600 Subject: [PATCH 19/23] 2.14.3 --- native/npm/darwin-arm64/package.json | 2 +- native/npm/darwin-x64/package.json | 2 +- native/npm/linux-arm64-gnu/package.json | 2 +- native/npm/linux-x64-gnu/package.json | 2 +- native/npm/win32-x64-msvc/package.json | 2 +- package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/native/npm/darwin-arm64/package.json b/native/npm/darwin-arm64/package.json index af984f77b..d2c49ac78 100644 --- a/native/npm/darwin-arm64/package.json +++ b/native/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-darwin-arm64", - "version": "2.14.2", + "version": "2.14.3", "description": "GSD native engine binary for macOS ARM64", "os": [ "darwin" diff --git a/native/npm/darwin-x64/package.json b/native/npm/darwin-x64/package.json index c16559ef4..e21cca79d 100644 --- a/native/npm/darwin-x64/package.json +++ b/native/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-darwin-x64", - "version": "2.14.2", + "version": "2.14.3", "description": "GSD native engine binary for macOS Intel", "os": [ "darwin" diff --git a/native/npm/linux-arm64-gnu/package.json b/native/npm/linux-arm64-gnu/package.json index 829bef792..f1de77099 100644 --- a/native/npm/linux-arm64-gnu/package.json +++ b/native/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-linux-arm64-gnu", - "version": "2.14.2", + "version": "2.14.3", "description": "GSD native engine binary for Linux ARM64 (glibc)", "os": [ "linux" diff --git a/native/npm/linux-x64-gnu/package.json b/native/npm/linux-x64-gnu/package.json index 2a0e31303..fb18e613d 100644 --- a/native/npm/linux-x64-gnu/package.json +++ b/native/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-linux-x64-gnu", - "version": "2.14.2", + "version": "2.14.3", "description": "GSD native engine binary for Linux x64 (glibc)", "os": [ "linux" diff --git a/native/npm/win32-x64-msvc/package.json b/native/npm/win32-x64-msvc/package.json index 115f9ff9b..305bc25c7 100644 --- a/native/npm/win32-x64-msvc/package.json +++ b/native/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-win32-x64-msvc", - "version": "2.14.2", + "version": "2.14.3", "description": "GSD native engine binary for Windows x64 (MSVC)", "os": [ "win32" diff --git a/package.json b/package.json index 37e54901a..2249d7966 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gsd-pi", - "version": "2.14.2", + "version": "2.14.3", "description": "GSD — Get Shit Done coding agent", "license": "MIT", "repository": { From 8882ce484e0d531611d64ab49115cb3694d83f98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Sun, 15 Mar 2026 15:45:15 -0600 Subject: [PATCH 20/23] fix(session): update cwd on newSession to reflect worktree chdir (#517) When auto-mode creates a worktree and chdir's into it, the Node process cwd changes but AgentSession._cwd stays frozen at the original path. Every newSession() builds a system prompt telling the LLM "Current working directory: /original/path", so the LLM cd's back there and writes files to the wrong location. Update _cwd = process.cwd() at the start of newSession() so the system prompt reflects the actual working directory after chdir. Co-authored-by: Claude Opus 4.6 (1M context) --- packages/pi-coding-agent/src/core/agent-session.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/pi-coding-agent/src/core/agent-session.ts b/packages/pi-coding-agent/src/core/agent-session.ts index 6fc9a9853..2e8fac03a 100644 --- a/packages/pi-coding-agent/src/core/agent-session.ts +++ b/packages/pi-coding-agent/src/core/agent-session.ts @@ -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 = []; From 5662c5fba8984505884b66d1b6a29b7b4bcdb866 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sun, 15 Mar 2026 15:46:16 -0600 Subject: [PATCH 21/23] docs: update changelog for v2.14.4 --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ecc6e6e..b48ce6f55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ 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 @@ -653,7 +658,8 @@ 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.14.3...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 From b873f8112f7580dff11ec4ef0c792948e8ba15fd Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sun, 15 Mar 2026 15:46:16 -0600 Subject: [PATCH 22/23] 2.14.4 --- native/npm/darwin-arm64/package.json | 2 +- native/npm/darwin-x64/package.json | 2 +- native/npm/linux-arm64-gnu/package.json | 2 +- native/npm/linux-x64-gnu/package.json | 2 +- native/npm/win32-x64-msvc/package.json | 2 +- package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/native/npm/darwin-arm64/package.json b/native/npm/darwin-arm64/package.json index d2c49ac78..5fbead5da 100644 --- a/native/npm/darwin-arm64/package.json +++ b/native/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-darwin-arm64", - "version": "2.14.3", + "version": "2.14.4", "description": "GSD native engine binary for macOS ARM64", "os": [ "darwin" diff --git a/native/npm/darwin-x64/package.json b/native/npm/darwin-x64/package.json index e21cca79d..1bba41646 100644 --- a/native/npm/darwin-x64/package.json +++ b/native/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-darwin-x64", - "version": "2.14.3", + "version": "2.14.4", "description": "GSD native engine binary for macOS Intel", "os": [ "darwin" diff --git a/native/npm/linux-arm64-gnu/package.json b/native/npm/linux-arm64-gnu/package.json index f1de77099..0c8f04b9c 100644 --- a/native/npm/linux-arm64-gnu/package.json +++ b/native/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-linux-arm64-gnu", - "version": "2.14.3", + "version": "2.14.4", "description": "GSD native engine binary for Linux ARM64 (glibc)", "os": [ "linux" diff --git a/native/npm/linux-x64-gnu/package.json b/native/npm/linux-x64-gnu/package.json index fb18e613d..5e6c4a8be 100644 --- a/native/npm/linux-x64-gnu/package.json +++ b/native/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-linux-x64-gnu", - "version": "2.14.3", + "version": "2.14.4", "description": "GSD native engine binary for Linux x64 (glibc)", "os": [ "linux" diff --git a/native/npm/win32-x64-msvc/package.json b/native/npm/win32-x64-msvc/package.json index 305bc25c7..b0074d7b0 100644 --- a/native/npm/win32-x64-msvc/package.json +++ b/native/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-win32-x64-msvc", - "version": "2.14.3", + "version": "2.14.4", "description": "GSD native engine binary for Windows x64 (MSVC)", "os": [ "win32" diff --git a/package.json b/package.json index 2249d7966..2dd2c9a89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gsd-pi", - "version": "2.14.3", + "version": "2.14.4", "description": "GSD — Get Shit Done coding agent", "license": "MIT", "repository": { From 7bef5a8f8dcdc3c21884e0d7f3cf246faccd822e Mon Sep 17 00:00:00 2001 From: Flux Labs Date: Sun, 15 Mar 2026 17:28:04 -0500 Subject: [PATCH 23/23] =?UTF-8?q?feat:=20QOL=20improvements=20=E2=80=94=20?= =?UTF-8?q?8=20new=20commands,=20budget=20enforcement,=20notifications=20(?= =?UTF-8?q?#441)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add QOL commands — pause, history, undo, skip, export, cleanup, dry-run, budget enforcement, notifications Add 8 new /gsd subcommands and enhance auto-mode with budget enforcement, context monitoring, and desktop notifications. New commands: - /gsd pause — graceful pause (finish current unit, then stop) - /gsd history [N] [--cost|--phase|--model] — view unit execution history - /gsd undo [--force] — rollback last completed unit (revert git + state) - /gsd skip — mark unit complete without executing - /gsd next --dry-run — preview next unit with estimated cost/duration - /gsd export [--json|--markdown] — generate session report - /gsd cleanup branches — delete merged GSD branches - /gsd cleanup snapshots — prune old snapshot refs Auto-mode enhancements: - Budget enforcement with 3 modes (warn/pause/halt) and threshold alerts at 75%/90%/100% - Context window monitoring with auto-pause when approaching limits - Desktop notifications (macOS osascript, Linux notify-send) on milestone complete, blocked, loop detected New preferences: - budget_enforcement: warn | pause | halt (default: pause) - context_pause_threshold: number (% context window, 0 to disable) - notifications.enabled: boolean New files: notifications.ts, history.ts, undo.ts, export.ts Modified: commands.ts, auto.ts, types.ts, preferences.ts, metrics.ts * fix: harden qol notifications and undo paths * fix: finish qol review follow-ups --------- Co-authored-by: TÂCHES --- src/resources/extensions/gsd/auto.ts | 97 +++++- src/resources/extensions/gsd/commands.ts | 313 +++++++++++++++++- src/resources/extensions/gsd/export.ts | 100 ++++++ src/resources/extensions/gsd/history.ts | 162 +++++++++ src/resources/extensions/gsd/metrics.ts | 17 + src/resources/extensions/gsd/notifications.ts | 88 +++++ src/resources/extensions/gsd/preferences.ts | 5 +- .../gsd/tests/auto-budget-alerts.test.ts | 33 ++ .../gsd/tests/notifications.test.ts | 67 ++++ .../extensions/gsd/tests/undo.test.ts | 136 ++++++++ src/resources/extensions/gsd/types.ts | 13 + src/resources/extensions/gsd/undo.ts | 219 ++++++++++++ 12 files changed, 1233 insertions(+), 17 deletions(-) create mode 100644 src/resources/extensions/gsd/export.ts create mode 100644 src/resources/extensions/gsd/history.ts create mode 100644 src/resources/extensions/gsd/notifications.ts create mode 100644 src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts create mode 100644 src/resources/extensions/gsd/tests/notifications.test.ts create mode 100644 src/resources/extensions/gsd/tests/undo.test.ts create mode 100644 src/resources/extensions/gsd/undo.ts diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 4bd601695..c057985fb 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -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, @@ -186,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; @@ -207,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 @@ -410,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; @@ -670,6 +698,7 @@ export async function startAuto( basePath = base; unitDispatchCount.clear(); unitRecoveryCount.clear(); + lastBudgetAlertLevel = 0; unitLifetimeDispatches.clear(); completedKeySet.clear(); loadPersistedKeys(base, completedKeySet); @@ -1546,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(); @@ -1565,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; } @@ -1646,7 +1677,6 @@ 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) { try { @@ -1666,7 +1696,7 @@ async function dispatchNextUnit( ); } } - + sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone"); await stopAuto(ctx, pi); return; } @@ -1678,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; } @@ -1686,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; } @@ -2058,6 +2132,7 @@ async function dispatchNextUnit( 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", diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 7aefa0270..1c130f7f9 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -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 , or /gsd remote [slack|discord|status|disconnect].`, + `Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|discuss|history|undo|skip |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 { + if (!unitArg) { + ctx.ui.notify("Usage: /gsd skip (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 { + 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 { + 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 { + 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(); + 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"); +} diff --git a/src/resources/extensions/gsd/export.ts b/src/resources/extensions/gsd/export.ts new file mode 100644 index 000000000..d799da718 --- /dev/null +++ b/src/resources/extensions/gsd/export.ts @@ -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 + +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 { + 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"); + } +} diff --git a/src/resources/extensions/gsd/history.ts b/src/resources/extensions/gsd/history.ts new file mode 100644 index 000000000..3fa80d3a2 --- /dev/null +++ b/src/resources/extensions/gsd/history.ts @@ -0,0 +1,162 @@ +// GSD Extension — Session History View +// Human-readable display of past auto-mode unit executions. +// Copyright (c) 2026 Jeremy McSpadden + +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 { + 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); +} diff --git a/src/resources/extensions/gsd/metrics.ts b/src/resources/extensions/gsd/metrics.ts index 84c5e72d0..767f15356 100644 --- a/src/resources/extensions/gsd/metrics.ts +++ b/src/resources/extensions/gsd/metrics.ts @@ -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"); diff --git a/src/resources/extensions/gsd/notifications.ts b/src/resources/extensions/gsd/notifications.ts new file mode 100644 index 000000000..579db6ae8 --- /dev/null +++ b/src/resources/extensions/gsd/notifications.ts @@ -0,0 +1,88 @@ +// GSD Extension — Desktop Notification Helper +// Cross-platform desktop notifications for auto-mode events. +// Copyright (c) 2026 Jeremy McSpadden + +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, '\\"'); +} diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 1b3d9eabc..f44078da0 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -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[]; diff --git a/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts b/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts new file mode 100644 index 000000000..b4f93847f --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts @@ -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"); +}); diff --git a/src/resources/extensions/gsd/tests/notifications.test.ts b/src/resources/extensions/gsd/tests/notifications.test.ts new file mode 100644 index 000000000..f889ab2b0 --- /dev/null +++ b/src/resources/extensions/gsd/tests/notifications.test.ts @@ -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); +}); diff --git a/src/resources/extensions/gsd/tests/undo.test.ts b/src/resources/extensions/gsd/tests/undo.test.ts new file mode 100644 index 000000000..6aee92930 --- /dev/null +++ b/src/resources/extensions/gsd/tests/undo.test.ts @@ -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"]); +}); diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index c119a7393..52a50d7d4 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -234,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 { diff --git a/src/resources/extensions/gsd/undo.ts b/src/resources/extensions/gsd/undo.ts new file mode 100644 index 000000000..41b909e37 --- /dev/null +++ b/src/resources/extensions/gsd/undo.ts @@ -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 + +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 { + 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; +}