Allow zero-repo manifests - #13
Conversation
Allow repos: [] on /api/initialise and /api/prepare so a task can start from an empty workspace, for agents whose job is to create a project from scratch. Covers the two validation rules that block it, extracting working directory resolution out of the clone loop, and creating the workspace root that git clone currently creates by side effect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six TDD tasks: relax the two validation rules, extract resolveWorkingDirectory, add the ensureWorkspace dep, wire the resolver in, cover the zero-repo paths end to end, and write the changeset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 1 Step 5 routed the empty-repos prepare assertion through errorsOf, which throws when validation succeeds, so the assertion could never pass. Assert on validatePrepare directly instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An agent whose job is to create a brand-new project from scratch has nothing to clone, so repos: [] is now legal on both /api/initialise and /api/prepare. validateRepos no longer rejects a zero-length list, and crossFieldRepoErrors skips the single-primary rule for an empty list only — one repo must still mark itself primary, because that is what names the directory the agent runs in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The registry-routing block carried an exactly-one-primary test identical to the one the new empty-repo-list block adds, and the routing block is not where repo-list rules belong. Keep the one that sits beside the empty-list case it constrains, and note that the rule fires for a single-repo list too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Task 1 of 6 — validation accepts an empty repo list
Both blocking rules relaxed in
Tests: 254 passing in Three things worth recording, since two were defects in the plan rather than the code:
Next: Task 2, extracting |
Replaces an `as any` cast with a real WorkspaceManifest return type. The cast was hiding nothing today, but it would go on compiling if the type gained a required field, testing the function against a shape it no longer receives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
validateRepos allows a dest with subdirectories, so this is real input rather than a hypothetical, and it was the one realistic shape the resolver's tests did not exercise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Task 2 of 6 —
|
… dep Nothing previously created workspaceRoot; it existed only as a side effect of git clone creating its destination's parents. A manifest with no repos would leave the agent pointed at a directory that does not exist. Injected (not a direct mkdirSync in TaskRun) so the test suite's fake workspace root, which does not exist and cannot be created on a dev machine, stays inert.
`cloning` is the step label for two failures in syncRepos — the mkdir and a git sync error — so asserting only `failed` left the test passing if the mkdir throw were swallowed and the sync failed in its place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Task 3 of 6 —
|
The parameter now receives the workspace root when the manifest carries no repos, so primaryDest names something it is no longer guaranteed to be. Both engines' inner buildAgentConfig already called it workingDirectory; this aligns the adapter seam and the EngineAdapter interface with them, and documents that it need not be a repository. Also corrects two doc comments that described the working directory as the primary repo, and one test comment still attributing the cwd to syncRepos rather than resolveWorkingDirectory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Splitting resolution out of syncRepos removed a data dependency that had been doing real work: runSetup took the destination only a completed syncRepos could return, so the two steps could not be reordered without a compile error. After the split they were held in order by convention at two call sites. materialiseWorkspace performs resolve/sync/setup and returns the working directory, so a caller cannot obtain one without having awaited the whole sequence. Also collapses the sequence boot and prepareWorkspace had in common. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Task 4 of 6 — resolve the working directory outside the clone loop
Verified equivalent for existing manifests: the old code assigned Tests: 261 passing in Three things came out of review that changed the shape of the result:
Next: Task 5, the zero-repo end-to-end tests. |
…arate These three are the primary evidence the feature works, but they were bare next to neighbours that explain themselves. Each now ties its assertions back to the production comment that motivates them: why ensureWorkspace is re-asserted for the empty case, why the setup cwd is a distinct property from the agent starting, and why the prepare path cannot inherit either from the boot tests. Also renames the prepare test, which asserted the setup cwd without saying so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Task 5 of 6 — zero-repo behaviour, end to end
Tests only — every file in the diff is a
Tests: 266 passing in These tests were mutation-tested, not just read. Spec review removed the Follow-up in Two review points I accepted rather than acted on:
Next: Task 6, the changeset. |
… fake The BootDeps literal in boot.test.ts was never updated when the dep was added, so every boot in that test died at the cloning step with "this.deps.ensureWorkspace is not a function" — and the test still reported green, because its failure guard asserted step !== "boot" and "boot" is not a value StepError ever carries. The integration test that exercises the real Claude adapter had been silently defeated. typecheck could not catch it: throng-agent/tsconfig.json excludes src/**/*.test.ts. Tightens the guard to name the steps the fakes actually own, so a bootstrap-step failure fails the test while a real-SDK failure stays tolerated. Verified by removing the fake again: the test now fails with "expected [...] to not include 'cloning'". Also corrects the plan's Step 7 verification grep, which listed packages and both engine packages but not throng-agent, which is how the site was missed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Task 6 of 6 — changeset · and a real bug the final review caught
🐛 The final review found a real defect, in a place five per-task reviews couldn't see
So the branch was, until this commit, shipping an integration test against the real Claude adapter that could no longer fail for the reason it exists. Root cause is mine: Task 3's verification grep in the plan reads Beyond adding the missing fake, the guard now names the steps the fakes actually own: expect(["credentials", "cloning", "setup"]).not.toContain(tr.lifecycle.status().error?.step);A real-SDK failure ( Final state
The final review also confirmed the security invariant holds on the new path: a zero-repo prepare still wipes credentials before
Marking ready for review. |
Keeps the behaviour change, the rules that did not move, the new working directory, the new export and the BootDeps break. Drops the rationale and the internal refactor detail, which belong in the PR and the commits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Spec only. No behaviour change yet — the implementation lands on this branch next.
Why
A manifest with an empty repo list is rejected today:
That rule assumes every task starts from existing code. An agent whose job is to create a new project from scratch — scaffold it,
git initit, create the remote — has no repo to clone and currently cannot be booted at all. Whether a task needs a repo is the consumer's call; the runtime's job is to make an empty workspace behave predictably.What the spec covers
repos: []becomes valid on both/api/initialiseand/api/prepare.reposstays a required key — an empty workspace is something the caller states explicitly, so a control-plane bug that drops the field still fails loudly.Four things have to change, not just the length check:
validate.ts:213— drop thelength === 0rejection.validate.ts:191—crossFieldRepoErrorsrequires exactly oneprimary: trueonly whenreposis non-empty. Without this, removing the length check achieves nothing: zero repos means zero primaries, soprimaries !== 1fires instead and the caller trades one confusing 400 for another.task-run.ts:271— working-directory resolution moves out of the clone loop into a pureresolveWorkingDirectory(manifest, workspaceRoot), returning the primary repo's dest orworkspaceRootwhen there are no repos. It also now runs before cloning, so a no-primary manifest fails with nothing done instead of after cloning everything.server.ts:104—workspaceRootis only ever created as a side effect ofgit clonecreating its destination's parents. With no repos nothing creates it, soBootDepsgains anensureWorkspacedep (mkdirSyncrecursive), called unconditionally. It's injected rather than a directmkdirSyncbecausetask-run.test.tsusesworkspaceRoot: "/home/user/workspace", a path that can't be created on a developer machine.Applying this to both routes keeps the shared-rule invariant that
validateandvalidatePreparecurrently maintain, so a snapshot build and the task boot that restores from it can't disagree about what a repo list means.Nothing else relaxes:
dest: "."stays rejected, thecredentials/github_tokenrules are untouched, and a non-array or missingreposis still a 400.Full spec:
docs/superpowers/specs/2026-08-24-zero-repo-manifests-design.md🤖 Generated with Claude Code