Plan 009 — Worktree at scaffold
Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Goal
Move workspace isolation to the moment a spec is opened, so two specs can be in flight at once without the shared working tree ever changing branch; make the spec reach origin before discovery; and remove the worktree with documented commands once its PR merges. The scaffolding script stops switching branches and instead hands branch creation to the native worktree tool.
Approach
scripts/new-spec.ts splits into two subcommands wrapped around a native EnterWorktree call, and loses its git checkout -b:
node scripts/new-spec.ts allocate <slug>— run from the shared tree. Validates the slug, computes the next number, and prints the branch nameNNN-slugon stdout. Read-only: it makes no filesystem change and no git mutation (it reads git refs, but never checks out, branches, or commits).EnterWorktree name=NNN-slug— the native tool creates.claude/worktrees/NNN-slugon a new branch (base reffresh=origin/main) and moves the session into it.node scripts/new-spec.ts scaffold <slug> <number>— run inside the worktree. Writes the four template files intospecs/NNN-slug/with placeholders substituted. No git at all.
The /new-spec command document orchestrates these three moves (it is Claude, not a shell script, that issues the EnterWorktree call between the two script runs) and then reports the directory, branch, and worktree path — stopping without starting spec.md.
Numbering (the correctness heart of SC1). The number must be unique across every spec, including specs currently in flight that have not merged. Two sources, unioned:
- Merged specs — directory names under
specs/onorigin/main, viagit ls-tree --name-only origin/main -- specs/. This still counts a spec whose branch was already deleted by cleanup. - In-flight specs — branch short-names matching
^\d{3}-fromgit for-each-ref --format='%(refname:short)' refs/heads refs/remotes/origin. This counts a parallel spec whose directory is not yet onorigin/main.
nextSpecNumber takes the union and returns max + 1. Reading merged specs from the same origin/main ref that EnterWorktree branches from means the number and the worktree's contents cannot disagree (research.md V2); adding in-flight branches is what stops a second parallel spec from reusing the first's number (SC1). This corrects R3's wording, which currently says "derived from origin/main" — that alone reuses numbers across parallel specs; see Open questions.
CLAUDE.md, its command, and one test move in step with the script:
CLAUDE.md— step 0 gains the worktree and bindssuperpowers:using-git-worktrees; step 3.5 (Isolate) is deleted; step 1 states the spec is committed and pushed before discovery (and each later artifact likewise); a Cleanup step documents the merge-time commands..claude/commands/new-spec.md— rewritten to the three-move orchestration;allowed-toolsgains the worktree tool.tests/workflow/constitution.test.ts:95—'3.5'drops out of the required-steps list (F6).
Architecture
A pure core over strings, a thin git/fs shell over it — the existing split in new-spec.ts, kept.
allocate <slug> scaffold <slug> <number>
│ │
├─ collectExistingNumbers(repoRoot) ──┐ ├─ readTemplate(specs/_template)
│ git ls-tree origin/main specs/ │ ├─ substitutePlaceholders (pure, kept)
│ git for-each-ref NNN-* branches │ └─ write specs/NNN-slug/*.md (fs only)
│ │
└─ nextSpecNumber(union) (pure, kept)┘
→ prints "NNN-slug"- Pure, unit-tested without a repo:
nextSpecNumber(kept),substitutePlaceholders(kept),divergesFromScaffold(kept), and a new pure combiner that unions spec-dir names and branch names into the listnextSpecNumberconsumes. - Shell:
allocateSpec(git reads → number/branch),writeScaffold(fs writes). Neither mutates git.scaffoldSpec(the old one-call function) is retired.
Tech stack
No new dependencies. Node ≥ 22.18 running TypeScript directly (node scripts/new-spec.ts, unflagged type-stripping — docs/architecture/workflow-tooling.md). Vitest for the suite. System git for the ref reads. The native EnterWorktree tool for isolation (superpowers 6.1.1; base ref fresh).
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: encrypted at rest, never logged, never returned to the client.
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
File Structure
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
scripts/new-spec.ts | modified | Split into allocate/scaffold subcommands; add allocateSpec, writeScaffold, and a pure numbers-union combiner; remove git checkout -b and retire scaffoldSpec. |
tests/workflow/scaffold.test.ts | modified | Retire the two branch-HEAD assertions (P3 #2, SC4); add tests for allocation (union numbering incl. an in-flight branch), scaffold file-writing, the no-git-mutation guarantee, and the guards. |
tests/workflow/constitution.test.ts | modified | Drop '3.5' from the required-steps list (F6); no other change (step 0 stays allowlisted, F7). |
.claude/commands/new-spec.md | modified | Orchestrate allocate → EnterWorktree name=NNN-slug → scaffold → report; add the worktree tool to allowed-tools; keep the "don't start spec.md this turn" guard. |
CLAUDE.md | modified | Step 0 gains the worktree + using-git-worktrees; delete step 3.5; step 1 states commit+push-before-discovery; add the Cleanup step with the documented commands. |
docs/architecture/workflow-tooling.md | modified (step 6 / capture) | Graduate the worktree facts (base ref, .claude/worktrees/ location, cross-session cleanup, the finishing-a-development-branch gap) — a research.md graduation candidate; folded into the doc-touching task. |
Data & contracts
CLI (stdout is a contract — /new-spec parses it):
new-spec.ts allocate <slug>→ prints exactlyNNN-slug(one line), exit 0. Errors (invalid slug, branch already exists) → stderr + non-zero exit.new-spec.ts scaffold <slug> <number>→ prints the created files, exit 0. Errors (dir exists, bad number) → stderr + non-zero exit.
Exported functions (the test surface):
nextSpecNumber(existing: readonly string[]): string— unchanged.substitutePlaceholders(body, specNumber, slug): string— unchanged.divergesFromScaffold(content, templateBody, specNumber, slug): boolean— unchanged.collectSpecNumbers(specDirNames: readonly string[], branchNames: readonly string[]): string[]— new, pure. Filters both lists to^\d{3}-…and returns the combined dir-name-shaped list fornextSpecNumber. This is where the parallel-safety of SC1 is unit-tested.allocateSpec({ slug, repoRoot }): { number: string; branch: string; dir: string }— new, shell. Readsorigin/mainspec dirs andNNN-*branch names, unions viacollectSpecNumbers, guards, no mutation.writeScaffold({ slug, number, repoRoot }): { dir: string; files: readonly string[] }— new, shell. Writes the template files; no git.
Test strategy
Two layers, matching the file's existing split, plus the doc-assertion pattern the project already uses.
- Pure core (no repo):
collectSpecNumbersunions dirs + branches and de-duplicates by number — the case that proves SC1's numbering: given merged['001-a']and an in-flight branch['002-b'], the next number is003, not002.nextSpecNumber/substitutePlaceholders/divergesFromScaffoldkeep their tests. - Shell (throwaway repo with an
origin):allocateSpecreturns the right branch when an in-flight branch exists but its dir is not onorigin/main;writeScaffoldwrites four substituted files; SC7 / R2 — after both run,git rev-parse --abbrev-ref HEADis unchanged (no checkout happened); guards throw on an existing dir and an invalid slug. - Doc-assertion (
CLAUDE.md/command text), theconstitution.test.tspattern: R6, R7 (commit+push-before-discovery stated), R8 (no step 3.5;using-git-worktreesat step 0), R11 (orchestration stated), R1.
Criteria that cannot be unit-tested, and their stand-ins (research.md V5): SC1 worktrees coexisting, SC3 session cwd == worktree, SC5 pushed to origin, SC6 merge cleanup — each gets a (proxy) doc-assertion test plus a human-verified row in the traceability table. The numbering half of SC1 is unit-tested via collectSpecNumbers; only the "two worktrees on disk" half is human-verified.
Alternatives considered
- Number from
origin/mainonly — rejected: two parallel in-flight specs both branch fromorigin/main, so the second reuses the first's number, breaking SC1. The union with in-flight branches is the fix. - Number from branches only — rejected: cleanup deletes a merged spec's branch, so a merged-and-cleaned spec would stop being counted and its number could be reissued.
- Script runs
git worktree additself — rejected:using-git-worktreesforbids it when a native tool exists ("the #1 mistake"), and it creates phantom state the harness can't track.EnterWorktreeowns creation. - A
/finish-speccommand for cleanup — rejected by the human on 2026-08-03 (R9): documented commands instead, no new gated code.
Risks
origin/mainnot present/fresh locally (e.g. a clone that never fetched) —allocateSpec'sls-tree origin/mainwould under-count. Mitigation:allocateSpecerrors clearly iforigin/maincannot be resolved, rather than silently numbering from an empty merged set. (A task covers this.)- Orchestration depends on the command doc being followed — the
EnterWorktreecall sits between two script runs and is issued by Claude, not the script. Mitigation: the command doc spells the three moves explicitly and theallowed-toolslists the worktree tool. - superpowers version float (research.md F9) —
finishing-a-development-branchinternals could change; our cleanup is documented commands owned by this repo, so it does not depend on them.
Open questions
- R3 wording — resolved (human, 2026-08-03). R3 was updated to number from "merged specs on
origin/main∪ in-flightNNN-*branches", matching this plan. No open questions remain.