Skip to content

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:

  1. node scripts/new-spec.ts allocate <slug> — run from the shared tree. Validates the slug, computes the next number, and prints the branch name NNN-slug on stdout. Read-only: it makes no filesystem change and no git mutation (it reads git refs, but never checks out, branches, or commits).
  2. EnterWorktree name=NNN-slug — the native tool creates .claude/worktrees/NNN-slug on a new branch (base ref fresh = origin/main) and moves the session into it.
  3. node scripts/new-spec.ts scaffold <slug> <number> — run inside the worktree. Writes the four template files into specs/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/ on origin/main, via git 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}- from git for-each-ref --format='%(refname:short)' refs/heads refs/remotes/origin. This counts a parallel spec whose directory is not yet on origin/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 binds superpowers: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-tools gains 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 list nextSpecNumber consumes.
  • 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. Use unknown plus 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-error without 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" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.
  • TypeScript's default lib includes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), or document/window resolve everywhere, silently defeating profile isolation. A profile that needs DOM adds "DOM", "DOM.Iterable" on top of that floor.
  • experimentalDecorators and emitDecoratorMetadata are supported for typecheck only — tsc --noEmit accepts decorator syntax and resolves metadata types, but --noEmit never emits design:paramtypes at runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; see stack.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, 429 on 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.

PathChangeResponsibility
scripts/new-spec.tsmodifiedSplit into allocate/scaffold subcommands; add allocateSpec, writeScaffold, and a pure numbers-union combiner; remove git checkout -b and retire scaffoldSpec.
tests/workflow/scaffold.test.tsmodifiedRetire 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.tsmodifiedDrop '3.5' from the required-steps list (F6); no other change (step 0 stays allowlisted, F7).
.claude/commands/new-spec.mdmodifiedOrchestrate 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.mdmodifiedStep 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.mdmodified (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 exactly NNN-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 for nextSpecNumber. This is where the parallel-safety of SC1 is unit-tested.
  • allocateSpec({ slug, repoRoot }): { number: string; branch: string; dir: string } — new, shell. Reads origin/main spec dirs and NNN-* branch names, unions via collectSpecNumbers, 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): collectSpecNumbers unions 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 is 003, not 002. nextSpecNumber/substitutePlaceholders/divergesFromScaffold keep their tests.
  • Shell (throwaway repo with an origin): allocateSpec returns the right branch when an in-flight branch exists but its dir is not on origin/main; writeScaffold writes four substituted files; SC7 / R2 — after both run, git rev-parse --abbrev-ref HEAD is unchanged (no checkout happened); guards throw on an existing dir and an invalid slug.
  • Doc-assertion (CLAUDE.md/command text), the constitution.test.ts pattern: R6, R7 (commit+push-before-discovery stated), R8 (no step 3.5; using-git-worktrees at 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/main only — rejected: two parallel in-flight specs both branch from origin/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 add itself — rejected: using-git-worktrees forbids it when a native tool exists ("the #1 mistake"), and it creates phantom state the harness can't track. EnterWorktree owns creation.
  • A /finish-spec command for cleanup — rejected by the human on 2026-08-03 (R9): documented commands instead, no new gated code.

Risks ​

  • origin/main not present/fresh locally (e.g. a clone that never fetched) — allocateSpec's ls-tree origin/main would under-count. Mitigation: allocateSpec errors clearly if origin/main cannot be resolved, rather than silently numbering from an empty merged set. (A task covers this.)
  • Orchestration depends on the command doc being followed — the EnterWorktree call sits between two script runs and is issued by Claude, not the script. Mitigation: the command doc spells the three moves explicitly and the allowed-tools lists the worktree tool.
  • superpowers version float (research.md F9) — finishing-a-development-branch internals 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-flight NNN-* branches", matching this plan. No open questions remain.