Skip to content

Tasks 009 — Worktree at scaffold ​

Execution skill: superpowers:subagent-driven-development — one implementer per task, then a two-stage review (spec compliance, then code quality). superpowers:test-driven-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on any surprise rather than guessing.

Derived from plan.md (approved). Each task is small, independently verifiable, and reviewed as its own diff. Global Constraints in plan.md apply to every task and are not repeated per task.

All script commands assume node on PATH and run from the repo root. Tests run with npx vitest run <file>.


T1 — collectSpecNumbers unions merged dirs and in-flight branches ​

Satisfies: R3, and the numbering half of SC1 (a second parallel spec never reuses a number).

The pure seam. It normalises each entry to its basename (so origin/010-foo and specs/001-a both reduce to NNN-slug) and keeps only NNN-slug-shaped names, producing the list nextSpecNumber consumes.

  • [ ] RED: add to tests/workflow/scaffold.test.ts, in the pure-core block:
ts
describe('R3 / SC1 — unioning number sources', () => {
  it('SC1: an in-flight branch counts even when its dir is not yet on main', () => {
    // main has 001; 002 exists only as a branch (in flight). Next must be 003, not 002.
    expect(
      nextSpecNumber(collectSpecNumbers(['specs/001-a'], ['002-b'])),
    ).toBe('003');
  });

  it('R3: basenames are taken and non-spec entries dropped', () => {
    expect(
      collectSpecNumbers(
        ['specs/001-a', 'specs/_template'],
        ['main', 'origin/002-b', 'origin/main'],
      ),
    ).toEqual(['001-a', '002-b']);
  });
});
  • [ ] RED: run npx vitest run tests/workflow/scaffold.test.ts -t "unioning number sources" — fails with collectSpecNumbers is not a function.
  • [ ] GREEN: add to scripts/new-spec.ts (near nextSpecNumber):
ts
/**
 * The list `nextSpecNumber` consumes, unioned from the two places a spec number can live: merged spec
 * directories on `origin/main`, and the branches of specs still in flight. Each entry is reduced to its
 * basename first — `specs/001-a` and `origin/010-foo` both carry the `NNN-slug` at the end — and only
 * `NNN-slug`-shaped names survive, so `main` and `_template` fall away.
 */
export function collectSpecNumbers(
  specDirNames: readonly string[],
  branchNames: readonly string[],
): string[] {
  const basename = (entry: string): string => entry.slice(entry.lastIndexOf('/') + 1);

  return [...specDirNames, ...branchNames]
    .map(basename)
    .filter((name) => SPEC_DIRECTORY.test(name));
}
  • [ ] GREEN: rerun the test — passes.
  • [ ] Teeth: change [...specDirNames, ...branchNames] to [...specDirNames]; the SC1 test fails (returns 002). Restore.
  • [ ] Commit: specs: T1 union number sources for parallel-safe allocation.

Verified by: SC1: an in-flight branch counts even when its dir is not yet on main, R3: basenames are taken and non-spec entries dropped.


T2 — allocateSpec reads git and prints the branch, mutating nothing ​

Satisfies: R3 (allocate mode), R5 (branch guard), and SC7 at the allocate step (no checkout).

  • [ ] RED: add a describe block to tests/workflow/scaffold.test.ts. Reuse the shell beforeEach, and fake the remote-tracking ref locally (no network):
ts
describe('R3 / R5 — allocateSpec', () => {
  // origin/main is faked by pointing the remote-tracking ref at the initial commit; a spec created
  // only as a branch stands in for an in-flight parallel spec.
  function seedOriginMain(): void {
    git('update-ref', 'refs/remotes/origin/main', 'HEAD');
  }

  it('R3: allocate numbers from origin/main and returns the branch', () => {
    scaffoldOnMain('001-first'); // helper commits specs/001-first on main; see below
    seedOriginMain();

    const result = allocateSpec({ slug: 'second', repoRoot: workspace });

    expect(result.number).toBe('002');
    expect(result.branch).toBe('002-second');
    // No mutation: HEAD stays on main, no new branch was created.
    expect(git('rev-parse', '--abbrev-ref', 'HEAD')).toBe('main');
    expect(() => git('rev-parse', '--verify', 'refs/heads/002-second')).toThrow();
  });

  it('SC1: allocate skips a number taken only by an in-flight branch', () => {
    seedOriginMain(); // origin/main has no specs
    git('branch', '002-in-flight'); // a parallel spec, unmerged

    expect(allocateSpec({ slug: 'next', repoRoot: workspace }).number).toBe('003');
  });

  it('R5: allocate refuses when the target branch already exists', () => {
    seedOriginMain();
    git('branch', '001-taken');

    // Highest is 001 (branch) → next is 002, so force the collision directly:
    git('branch', '002-next');
    expect(() => allocateSpec({ slug: 'next', repoRoot: workspace })).toThrow(/already exists/);
  });
});

Add the scaffoldOnMain helper to the shell block (commits a real spec dir on main so ls-tree origin/main sees it):

ts
function scaffoldOnMain(dir: string): void {
  writeScaffold({ slug: dir.slice(4), number: dir.slice(0, 3), repoRoot: workspace });
  git('add', '.');
  git('commit', '-m', dir);
}

(T2 may lean on writeScaffold from T3 for this helper; if T2 lands first, inline a mkdirSync+writeFileSync of a single specs/<dir>/spec.md instead.)

  • [ ] RED: run the block — fails with allocateSpec is not a function.
  • [ ] GREEN: in scripts/new-spec.ts, add a git-lines reader and allocateSpec:
ts
function gitLines(repoRoot: string, ...args: string[]): string[] {
  return git(repoRoot, ...args)
    .split('\n')
    .map((line) => line.trim())
    .filter((line) => line.length > 0);
}

/**
 * Allocate the next spec number and branch name without touching the working tree or any branch.
 *
 * Numbering unions the two homes of a spec number so a parallel, still-unmerged spec cannot have its
 * number reissued: merged directories under `specs/` on `origin/main`, and every `NNN-*` branch. The
 * merged set is read from the same `origin/main` the worktree will branch from, so number and worktree
 * contents cannot disagree. A pre-existing target branch is refused here as belt-and-braces; the native
 * worktree tool would reject it too.
 */
export function allocateSpec({
  slug,
  repoRoot,
}: { slug: string; repoRoot: string }): { number: string; branch: string; dir: string } {
  if (!SLUG.test(slug)) {
    throw new Error(`Invalid slug "${slug}": use lowercase words separated by hyphens.`);
  }

  let mergedDirs: string[];
  try {
    mergedDirs = gitLines(repoRoot, 'ls-tree', '--name-only', 'origin/main', 'specs/');
  } catch {
    throw new Error(
      'Cannot resolve origin/main — fetch it before allocating a spec number.',
    );
  }
  const branches = gitLines(
    repoRoot,
    'for-each-ref',
    '--format=%(refname:short)',
    'refs/heads',
    'refs/remotes/origin',
  );

  const number = nextSpecNumber(collectSpecNumbers(mergedDirs, branches));
  const branch = `${number}-${slug}`;

  if (branchExists(repoRoot, branch)) {
    throw new Error(`Branch "${branch}" already exists — resume it rather than allocating over it.`);
  }

  return { number, branch, dir: join('specs', branch) };
}
  • [ ] GREEN: rerun — passes.
  • [ ] Teeth: drop branches from the collectSpecNumbers call; the SC1 in-flight test fails. Restore.
  • [ ] Commit: specs: T2 allocateSpec — parallel-safe number, no mutation.

Verified by: R3: allocate numbers from origin/main and returns the branch, SC1: allocate skips a number taken only by an in-flight branch, R5: allocate refuses when the target branch already exists.


T3 — writeScaffold writes the files; retire scaffoldSpec and the checkout ​

Satisfies: R2 (no checkout), R4 (scaffold mode), R5 (dir guard), SC4, SC7.

  • [ ] RED: replace the old shell tests in scaffold.test.ts that assert HEAD == branch (P3 #2: scaffold leaves the working branch at NNN-<slug> and SC4: opening a new feature takes one command) with writeScaffold tests:
ts
describe('R4 / R2 / SC4 — writeScaffold', () => {
  it('SC4: writes the four artifacts with placeholders substituted', () => {
    const result = writeScaffold({ slug: 'crafting-tree', number: '001', repoRoot: workspace });

    expect(result.files).toHaveLength(4);
    for (const artifact of ['spec.md', 'research.md', 'plan.md', 'tasks.md']) {
      expect(existsSync(join(workspace, result.dir, artifact)), `${artifact} missing`).toBe(true);
    }
    const spec = readFileSync(join(workspace, result.dir, 'spec.md'), 'utf8');
    expect(spec).toContain('# Spec 001 — ');
    expect(spec).not.toContain('NNN');
  });

  it('R2 / SC7: writing the scaffold performs no git operation', () => {
    const before = git('rev-parse', '--abbrev-ref', 'HEAD');
    writeScaffold({ slug: 'crafting-tree', number: '001', repoRoot: workspace });
    expect(git('rev-parse', '--abbrev-ref', 'HEAD')).toBe(before); // still main, no checkout
    expect(git('status', '--porcelain')).toContain('specs/001-crafting-tree'); // files are just untracked
  });

  it('R5: writeScaffold refuses to overwrite an existing spec directory', () => {
    writeScaffold({ slug: 'dupe', number: '001', repoRoot: workspace });
    expect(() => writeScaffold({ slug: 'dupe', number: '001', repoRoot: workspace })).toThrow(
      /already exists/,
    );
  });
});
  • [ ] RED: run scaffold.test.ts — new tests fail (writeScaffold is not a function); the old scaffoldSpec import still resolves.
  • [ ] GREEN: add writeScaffold to scripts/new-spec.ts:
ts
/**
 * Write the template artifacts into `specs/NNN-<slug>/`. Pure filesystem — no git. The number is passed
 * in (allocated earlier, before the worktree existed) rather than recomputed, so the directory name
 * matches the branch the worktree is already on. Refuses an existing directory, so a mistaken second
 * run cannot drop files into work already there.
 */
export function writeScaffold({
  slug,
  number,
  repoRoot,
}: { slug: string; number: string; repoRoot: string }): {
  dir: string;
  files: readonly string[];
} {
  if (!SLUG.test(slug)) {
    throw new Error(`Invalid slug "${slug}": use lowercase words separated by hyphens.`);
  }
  if (!/^\d{3}$/.test(number)) {
    throw new Error(`Invalid number "${number}": expected three digits.`);
  }

  const relativeDir = join('specs', `${number}-${slug}`);
  const targetDir = join(repoRoot, relativeDir);
  if (existsSync(targetDir)) {
    throw new Error(`${relativeDir} already exists.`);
  }

  const templateDir = join(repoRoot, 'specs', '_template');
  if (!existsSync(templateDir)) {
    throw new Error(`No template at ${templateDir}.`);
  }

  mkdirSync(targetDir, { recursive: true });
  const files = readdirSync(templateDir).filter((file) => file.endsWith('.md'));
  for (const file of files) {
    const body = readFileSync(join(templateDir, file), 'utf8');
    writeFileSync(join(targetDir, file), substitutePlaceholders(body, number, slug));
  }

  return { dir: relativeDir, files };
}
  • [ ] GREEN: delete scaffoldSpec and its git checkout -b entirely, and remove the now-dead scaffoldSpec import from scaffold.test.ts. Rerun scaffold.test.ts — green.
  • [ ] REFACTOR: rewrite main() to dispatch on a subcommand:
ts
function main(argv: readonly string[]): void {
  const [command, slug, number] = argv.slice(2);
  const repoRoot = process.cwd();

  if (command === 'allocate' && slug !== undefined) {
    console.log(allocateSpec({ slug, repoRoot }).branch); // stdout is the /new-spec contract
    return;
  }
  if (command === 'scaffold' && slug !== undefined && number !== undefined) {
    const { dir, files } = writeScaffold({ slug, number, repoRoot });
    console.log(`Created ${dir} (${files.join(', ')})`);
    return;
  }

  console.error('usage: node scripts/new-spec.ts (allocate <slug> | scaffold <slug> <number>)');
  process.exit(1);
}
  • [ ] Teeth: reintroduce a git checkout -b line and run the whole workflow suite — no test now asserts a checkout, so add/keep the R2 / SC7 assertion that HEAD is unchanged as the guard. Remove the checkout again.
  • [ ] Commit: specs: T3 writeScaffold; drop checkout and retire scaffoldSpec.

Verified by: SC4: writes the four artifacts with placeholders substituted, R2 / SC7: writing the scaffold performs no git operation, R5: writeScaffold refuses to overwrite an existing spec directory.


T4 — CLAUDE.md moves isolation to step 0; fix the constitution test ​

Satisfies: R1, R6, R7, R8, and the doc-assertion stand-ins for SC1/SC3/SC5/SC6.

  • [ ] RED: update tests/workflow/constitution.test.ts:95 — remove '3.5' from the required-steps array so the list is ['0','1','1.5','2','3','4','5','6']. Run constitution.test.ts — the R2 test now fails because CLAUDE.md still has a 3.5 row and still lacks the step-0 technique.
  • [ ] RED: add doc-assertion tests to constitution.test.ts for the new rules:
ts
describe('R8 / R7 — isolation at step 0, spec pushed before discovery', () => {
  it('R8: step 0 binds using-git-worktrees and step 3.5 is gone', () => {
    const bindings = parseBindings(readConstitution());
    const step0 = bindings.find((row) => row.step === '0');
    expect(step0?.techniques).toMatch(/using-git-worktrees/);
    expect(bindings.some((row) => row.step === '3.5')).toBe(false);
  });

  it('R7 (proxy): CLAUDE.md states the spec is committed and pushed before discovery', () => {
    expect(readConstitutionText()).toMatch(/push[^.]*before[^.]*discovery|before discovery[^.]*push/);
  });

  it('SC6 (proxy): CLAUDE.md documents the merge-time cleanup commands', () => {
    const text = readConstitution();
    expect(text).toContain('git worktree remove');
    expect(text).toContain('git branch -D');
  });
});
  • [ ] GREEN: edit CLAUDE.md:
    • Workflow section — rewrite step 0 to create the worktree and enter it; delete the 3.5 · Isolate line; add to step 1 that the spec is committed and pushed before discovery; add a Cleanup line documenting git worktree remove .claude/worktrees/NNN-slug, git branch -D NNN-slug, git worktree prune after the PR merges.
    • Techniques table — set step 0's technique to superpowers:using-git-worktrees; delete the 3.5 · Isolate row.
  • [ ] GREEN: run constitution.test.ts — green. Confirm readConstitution() word count stays under the 1200 cap (R13 test) — the net change removes a row, so this holds.
  • [ ] Teeth: temporarily re-add the 3.5 · Isolate row; R8 fails. Remove it.
  • [ ] Commit: specs: T4 CLAUDE.md isolation at step 0 + cleanup; fix constitution test.

Verified by: R8: step 0 binds using-git-worktrees and step 3.5 is gone, R7 (proxy): CLAUDE.md states the spec is committed and pushed before discovery, SC6 (proxy): CLAUDE.md documents the merge-time cleanup commands, plus the existing R2: CLAUDE.md maps every workflow step to a technique.


T5 — /new-spec orchestrates allocate → EnterWorktree → scaffold ​

Satisfies: R11, and R1/R6 at the command level.

  • [ ] Rewrite .claude/commands/new-spec.md to the three-move procedure, keeping the "don't start spec.md this turn" guard. Body:
markdown
Open feature `$1` (step 0). Three moves — do not start spec.md this turn.

1. Allocate the number (read-only, from the shared tree):
   `node scripts/new-spec.ts allocate "$1"` → prints `NNN-$1`.
2. Isolate: call EnterWorktree with `name` = that `NNN-$1`. The session moves into
   `.claude/worktrees/NNN-$1` on branch `NNN-$1`.
3. Scaffold inside the worktree:
   `node scripts/new-spec.ts scaffold "$1" NNN` (the number from move 1).

Then report the directory, branch, and worktree path, and stop. Step 1 uses
`superpowers:brainstorming`; wait for the human to ask before writing spec.md.
  • [ ] Update the frontmatter allowed-tools to permit both subcommands and the worktree tool: allowed-tools: Bash(node scripts/new-spec.ts *), EnterWorktree and update the description to say "scaffold in an isolated worktree" rather than "switch to its branch".
  • [ ] Verify: node scripts/new-spec.ts with no args prints the usage and exits non-zero (guards the contract the command depends on). No automated test asserts command-doc prose; this is a human-verified row.
  • [ ] Commit: specs: T5 /new-spec orchestrates worktree scaffold.

Verified by: manual run of /new-spec on a throwaway slug (human-verified), plus the usage-exit check.


T6 — Graduate the worktree facts to docs/architecture/ ​

Satisfies: step 6 / capture of research.md V1–V3, F9; keeps R9's mechanism durable.

  • [ ] Add a Worktrees section to docs/architecture/workflow-tooling.md: base ref fresh = origin/main; worktrees live under .claude/worktrees/ (gitignored, .gitignore:24); cross-session cleanup is git worktree remove + git branch -D + git worktree prune from the main tree; finishing-a-development-branch does not cover this layout (it cleans only .worktrees//worktrees/, and not on the push-and-PR path) — which is why cleanup is documented commands, not that skill. Date the section and cite superpowers 6.1.1.
  • [ ] Commit: specs: T6 graduate worktree facts to workflow-tooling doc.

Verified by: section present in docs/architecture/workflow-tooling.md (human-verified); docs/superpowers/ file count stays 0 (existing SC3 repo-invariant).


T7 — Fill traceability and pass the green gate ​

Satisfies: the Definition of Done — every criterion maps to a named test; typecheck + full suite green.

  • [ ] Fill spec.md's traceability table: each Pn #m / SCn row names its test from T1–T6, marking the behavioural stand-ins (SC1 coexistence, SC3, SC5) human-verified. SC1's numbering half cites the T1/T2 tests; SC6 cites the SC6 (proxy) test.
  • [ ] Run pnpm lint && pnpm typecheck && pnpm test — all green (use superpowers:verification-before-completion; paste real output, no "should pass").
  • [ ] Request review (superpowers:requesting-code-review), then superpowers:receiving-code-review.
  • [ ] Commit: specs: T7 traceability filled; suite green.

Verified by: pnpm test output; repo-invariants.test.ts SC5 (every named test exists) — note that test reads spec 001's table today, so 009's table is human-checked unless a row is added there.

Status transition (human): once the diff is reviewed, the human moves spec.md to implementedinside this branch, in the PR diff, before merge. The agent only transcribes that on the human's word.


Notes ​

Staging area for surprises. Move each into spec.md, research.md, or docs/ before closing — this section is not a home.

  • If the scaffoldOnMain helper in T2 is authored before T3's writeScaffold exists, inline a minimal single-file write instead of importing it, then simplify once T3 lands.
  • Cleanup (worktree removal after merge) is the documented commands in CLAUDE.md (R9) — no /finish-spec command, by the human's decision on 2026-08-03.
  • T2 deviation (implementation discovery). The planned R5 branch-exists guard inside allocateSpec is unreachable: numbering unions in all NNN-* branches, so the allocated number is always fresh and a same-number branch can never pre-exist. Dropped it rather than ship dead code. R5's branch clause is covered by EnterWorktree failing on an existing branch (research V1, manual/human-verified); its directory clause by writeScaffold's guard (T3). allocateSpec instead guards the reachable failure — origin/main unresolvable — matching plan.md's Risk. → fold into spec.md R5 note at T7.