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:
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 withcollectSpecNumbers is not a function. - [ ] GREEN: add to
scripts/new-spec.ts(nearnextSpecNumber):
/**
* 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 (returns002). 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 shellbeforeEach, and fake the remote-tracking ref locally (no network):
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):
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 andallocateSpec:
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
branchesfrom thecollectSpecNumberscall; 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.tsthat assertHEAD == branch(P3 #2: scaffold leaves the working branch at NNN-<slug>andSC4: opening a new feature takes one command) with writeScaffold tests:
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 oldscaffoldSpecimport still resolves. - [ ] GREEN: add
writeScaffoldtoscripts/new-spec.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
scaffoldSpecand itsgit checkout -bentirely, and remove the now-deadscaffoldSpecimport fromscaffold.test.ts. Rerunscaffold.test.ts— green. - [ ] REFACTOR: rewrite
main()to dispatch on a subcommand:
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 -bline and run the whole workflow suite — no test now asserts a checkout, so add/keep theR2 / SC7assertion 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']. Runconstitution.test.ts— theR2test now fails becauseCLAUDE.mdstill has a 3.5 row and still lacks the step-0 technique. - [ ] RED: add doc-assertion tests to
constitution.test.tsfor the new rules:
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 · Isolateline; add to step 1 that the spec is committed and pushed before discovery; add a Cleanup line documentinggit worktree remove .claude/worktrees/NNN-slug,git branch -D NNN-slug,git worktree pruneafter the PR merges. - Techniques table — set step 0's technique to
superpowers:using-git-worktrees; delete the3.5 · Isolaterow.
- Workflow section — rewrite step 0 to create the worktree and enter it; delete the
- [ ] GREEN: run
constitution.test.ts— green. ConfirmreadConstitution()word count stays under the 1200 cap (R13test) — the net change removes a row, so this holds. - [ ] Teeth: temporarily re-add the
3.5 · Isolaterow;R8fails. 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.mdto the three-move procedure, keeping the "don't startspec.mdthis turn" guard. Body:
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-toolsto permit both subcommands and the worktree tool:allowed-tools: Bash(node scripts/new-spec.ts *), EnterWorktreeand update thedescriptionto say "scaffold in an isolated worktree" rather than "switch to its branch". - [ ] Verify:
node scripts/new-spec.tswith 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 reffresh=origin/main; worktrees live under.claude/worktrees/(gitignored,.gitignore:24); cross-session cleanup isgit worktree remove+git branch -D+git worktree prunefrom the main tree;finishing-a-development-branchdoes 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: eachPn #m/SCnrow names its test from T1–T6, marking the behavioural stand-ins (SC1coexistence,SC3,SC5) human-verified.SC1's numbering half cites the T1/T2 tests;SC6cites theSC6 (proxy)test. - [ ] Run
pnpm lint && pnpm typecheck && pnpm test— all green (usesuperpowers:verification-before-completion; paste real output, no "should pass"). - [ ] Request review (
superpowers:requesting-code-review), thensuperpowers: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
scaffoldOnMainhelper in T2 is authored before T3'swriteScaffoldexists, 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-speccommand, by the human's decision on 2026-08-03. - T2 deviation (implementation discovery). The planned R5 branch-exists guard inside
allocateSpecis unreachable: numbering unions in allNNN-*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 byEnterWorktreefailing on an existing branch (research V1, manual/human-verified); its directory clause bywriteScaffold's guard (T3).allocateSpecinstead guards the reachable failure —origin/mainunresolvable — matching plan.md's Risk. → fold intospec.mdR5 note at T7.