Skip to content

Allow zero-repo manifests - #13

Merged
col merged 18 commits into
mainfrom
feat/zero-repo-manifests
Aug 25, 2026
Merged

col merged 18 commits into
mainfrom
feat/zero-repo-manifests

Conversation

@col

@col col commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Spec only. No behaviour change yet — the implementation lands on this branch next.

Why

A manifest with an empty repo list is rejected today:

[{"repos", "must contain at least one entry"}]

That rule assumes every task starts from existing code. An agent whose job is to create a new project from scratch — scaffold it, git init it, 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/initialise and /api/prepare. repos stays 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 the length === 0 rejection.
  • validate.ts:191crossFieldRepoErrors requires exactly one primary: true only when repos is non-empty. Without this, removing the length check achieves nothing: zero repos means zero primaries, so primaries !== 1 fires 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 pure resolveWorkingDirectory(manifest, workspaceRoot), returning the primary repo's dest or workspaceRoot when 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:104workspaceRoot is only ever created as a side effect of git clone creating its destination's parents. With no repos nothing creates it, so BootDeps gains an ensureWorkspace dep (mkdirSync recursive), called unconditionally. It's injected rather than a direct mkdirSync because task-run.test.ts uses workspaceRoot: "/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 validate and validatePrepare currently 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, the credentials/github_token rules are untouched, and a non-array or missing repos is still a 400.

Full spec: docs/superpowers/specs/2026-08-24-zero-repo-manifests-design.md

🤖 Generated with Claude Code

col and others added 5 commits August 24, 2026 15:02
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>
@col

col commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

✅ Task 1 of 6 — validation accepts an empty repo list

74a2d1d, 7d9443b

Both blocking rules relaxed in packages/core/src/manifest/validate.ts:

  • validateRepos no longer rejects repos.length === 0.
  • crossFieldRepoErrors skips the exactly-one-primary rule for an empty list only. The dest-uniqueness check stays unguarded — already a no-op on [], and nesting it would imply otherwise.

repos remains required and must still be a list; the is required and must be a list branches are untouched. Both routes changed together via the shared functions, so the initialise/prepare parity invariant holds.

Tests: 254 passing in packages/core, typecheck green across all 4 packages.

Three things worth recording, since two were defects in the plan rather than the code:

  1. The plan had an unworkable assertion. Step 5 routed the new empty-repos prepare check through the errorsOf test helper, which does if (r.ok) throw — so asserting toEqual([]) on a now-valid manifest could never pass. The implementer hit it, stopped, and reported it instead of bending the test to go green. Fixed in c524f44; the assertion now calls validatePrepare directly with a comment explaining the asymmetry.

  2. A comment that misdescribed its own guard. The first draft said the primary rule was "about which of SEVERAL repos the agent runs in", which reads as though a single-repo list is exempt. It isn't — the guard is repos.length > 0, so one repo must still mark itself primary. Reworded: the rule names the agent's working directory, it doesn't break a tie between candidates.

  3. A duplicate test removed. The registry routing block already had an exactly-one-primary test byte-identical to the one added here. Kept the copy that sits beside the empty-list case it constrains and dropped the older one, which is also thematically out of place among the routing tests. Test count is 254 rather than 255 for that reason — no coverage lost, the two assertions were identical.

Next: Task 2, extracting resolveWorkingDirectory.

col and others added 3 commits August 24, 2026 15:42
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>
@col

col commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

✅ Task 2 of 6 — resolveWorkingDirectory

a35a3ac, ce95669, 0edb655

New exported pure function in packages/core/src/task-run.ts:

resolveWorkingDirectory(manifest: WorkspaceManifest, workspaceRoot: string): string

Returns the primary repo's destination, the workspace root when repos is empty, or throws StepError("cloning", …) when a non-empty list has no primary — preserving the existing guard's message byte-for-byte so the swap in Task 4 changes nothing an operator sees.

Add-only by design. syncRepos still has its own primaryDest accumulator and nothing calls the new function outside tests yet. The duplication is deliberate; Task 4 removes the old copy. Splitting it this way keeps the refactor reviewable separately from the behaviour change.

Tests: 258 passing in packages/core, typecheck green across all 4 packages.

Two review findings worth recording:

  1. Dropped an as any the plan itself specified. My plan's test helper cast a partial manifest with as any. Spec review checked whether the cast was hiding a mismatch — it wasn't — but also verified a real WorkspaceManifest return type compiles cleanly, so I took it (ce95669). The cast would have gone on compiling if WorkspaceManifest gained a required field, exercising the function against a shape it no longer receives.

  2. Added the one realistic untested input shape (0edb655). validateRepos permits a dest with subdirectories — it rejects only absolute paths, .. segments, and anything resolving to the workspace root — so dest: "team/svc" is real input. The resolver's tests only used flat destinations.

Quality review also raised two things that turned out to be correct as written, recorded so they don't get "fixed" later:

  • find((r) => r.primary) here vs. === true in crossFieldRepoErrors is a difference in input trust, not inconsistency. Validation operates on Record<string, unknown> where primary is genuinely unknown; the resolver operates on RepoSpec[] where it is a real boolean.
  • The "cloning" step label on a function that does no cloning is intentional — it matches the guard being replaced, and names the boot lifecycle phase the call sits in rather than the operation.

Next: Task 3, the ensureWorkspace dep.

col and others added 2 commits August 24, 2026 15:52
… 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>
@col

col commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

✅ Task 3 of 6 — ensureWorkspace

dd203b2, 89526dc

BootDeps gains a required member:

ensureWorkspace: (dir: string) => void;

Called unconditionally at the top of syncRepos — idempotent where git clone would have created the directory anyway, load-bearing where there are no repos to create it. A failure becomes StepError("cloning", …), so it reaches operators as instance.error_message like every other filesystem fault in that step. defaultBootDeps() implements it as mkdirSync(dir, { recursive: true }).

Injected rather than a bare mkdirSync because both test suites use workspaceRoot: "/home/user/workspace" — a path that cannot be created on macOS, so a direct call would break the suite on any dev machine.

Tests: 260 passing in packages/core, typecheck green across all 4 packages. Both BootDeps literals in the tree were updated; grep + typecheck confirm there are no others.

One follow-up during review (89526dc): the failure test originally asserted only state === "failed", but "cloning" labels two distinct failures in syncRepos — the mkdir and a git sync error — so it would still have passed if the mkdir throw were swallowed and the sync failed in its place. Now pinned to error?.step and the message.

Two design questions were raised and both resolved as correct-as-written:

  • No mode on the mkdirSync. writeCredentialConfig uses mode: 0o700 because that directory holds a live token and it re-chmods unconditionally since mode only applies at creation. The workspace root holds cloned repos, not secrets; process umask is right, and tightening it would be borrowing a pattern from an unrelated threat model.
  • Required rather than optional member. This is a breaking change for anyone constructing BootDeps directly, which is deliberate and will be called out in the changeset — a silently-defaulted no-op would mean the workspace quietly fails to exist for exactly the zero-repo case this branch adds.

Next: Task 4, wiring the resolver in and reducing syncRepos to cloning only.

col and others added 3 commits August 24, 2026 16:04
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>
@col

col commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

✅ Task 4 of 6 — resolve the working directory outside the clone loop

81d3e88, 13656b8, 8e76bf7

syncRepos now returns Promise<void> and only clones. boot and prepareWorkspace get the working directory from resolveWorkingDirectory, called before the clone — so a manifest with no primary repo fails having done nothing rather than after pulling every repo over the network. Unreachable through the public API (validation rejects it), so no reachable behaviour changes.

Verified equivalent for existing manifests: the old code assigned primaryDest on every matching iteration, the new code uses .find(). Validation guarantees exactly one primary in a non-empty list, so they agree regardless of the primary's position.

Tests: 261 passing in packages/core; build and typecheck green across all 4 packages.

Three things came out of review that changed the shape of the result:

  1. The refactor quietly dropped a compiler-enforced guarantee (8e76bf7). runSetup used to take the destination that only a completed syncRepos could return — so clone-before-setup was enforced by the type system, not by convention. Splitting resolution out removed that data dependency and left two call sites trusted to keep the order, with no comment saying they had to. Setup commands are mise install / npm ci against a tree that has to exist, so that ordering is a correctness property. Now extracted into materialiseWorkspace, which performs resolve → sync → setup and returns the working directory: a caller cannot obtain one without having awaited the whole sequence. That also collapses the three-line sequence boot and prepareWorkspace had in common.

  2. primaryDest had leaked across package boundaries (13656b8). EngineAdapter.buildAgentConfig's parameter was still named primaryDest in the core interface and both engine adapters, but it now receives the workspace root when there are no repos — so the name asserted something untrue at a published seam. Renamed to workingDirectory (which both engines' inner buildAgentConfig already used), with a doc note that it need not be a repository. Two config/build.ts doc comments describing the cwd as "the primary repo" corrected too. grep primaryDest across src is now empty.

  3. A test name that overclaimed. "resolves the working directory before cloning, not after" couldn't actually observe that, since the fixture has a single always-resolvable primary. Renamed to what it pins: ensure → clone → setup ordering, and setup receiving the resolved cwd.

⚠️ Pre-existing flake, unrelated to this branch. npm test -- --force intermittently fails creds/throng-creds.test.ts > gh shim > runs gh with a freshly fetched token… with Test timed out in 5000ms. It's a subprocess-spawning test losing a race against vitest's default 5s timeout when all 4 package suites run concurrently. I confirmed it fails the same way at 75a1c6d, the commit this branch started from, so it predates this work and I've left it alone. npx vitest run packages/core is a clean signal. Worth a testTimeout bump or a serial marker in a separate change if you want CI to stop flaking.

Next: Task 5, the zero-repo end-to-end tests.

col and others added 2 commits August 24, 2026 16:22
…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>
@col

col commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

✅ Task 5 of 6 — zero-repo behaviour, end to end

b536e0f, 910aac5

Tests only — every file in the diff is a .test.ts.

task-run.test.ts:

  • A zero-repo initialise reaches ready, syncOrClone is never called, ensureWorkspace gets the workspace root, and buildAgentConfig receives the workspace root as the working directory.
  • Setup commands on a zero-repo manifest run in the workspace root.
  • A zero-repo prepare reaches prepared, runs setup in the workspace root, and still wipes credentials.

control/server.test.ts: HTTP-level 202s for repos: [] on both routes, plus the rename flagged back in Task 1 — "bad manifest → 400 list" sends { repos: [] } and still 400s, but now because agent is missing rather than because the list is empty, so the old name read as evidence the empty list was still rejected.

Tests: 266 passing in packages/core, typecheck green. All three zero-repo tests passed first time, which is the useful signal here: Tasks 1–4 actually deliver the feature, not just the pieces.

These tests were mutation-tested, not just read. Spec review removed the if (manifest.repos.length === 0) return workspaceRoot; branch from resolveWorkingDirectory and confirmed all three fail (lifecycle lands on failed, the mocks never see the workspace root), then restored it. So they genuinely pin the behaviour rather than passing incidentally. I verified the tree was left clean afterwards.

Follow-up in 910aac5: the three tests were bare, next to neighbours in the same file that carry paragraph-length rationale — and these are the tests the feature rests on. Each now explains what it pins and why it's separate: why ensureWorkspace is re-asserted for the empty case specifically (it's the case BootDeps.ensureWorkspace exists for — a future if (repos.length > 0) around that call would break only this path), why the setup cwd is a distinct property from the agent starting, and why the prepare path can't inherit either from the boot tests since both reach the new branch through the shared materialiseWorkspace. The prepare test was also renamed — it asserted the setup cwd without saying so.

Two review points I accepted rather than acted on:

  • expect.anything() for buildAgentConfig's manifest argument. The subject of that test is the second argument; asserting manifest contents would add coupling without confidence.
  • No HTTP-level test for a missing or non-array repos. Still covered directly in validate.test.ts, and the route is a thin pass-through to validate with no logic of its own on that path.

Next: Task 6, the changeset.

col and others added 2 commits August 24, 2026 16:33
… 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>
@col

col commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

✅ Task 6 of 6 — changeset · and a real bug the final review caught

3a58e8a, 88783e4

.changeset/zero-repo-manifests.md, @throng/agent-core minor. All nine factual claims in it were checked against source rather than taken on trust — the exported resolveWorkingDirectory, syncRepos returning nothing, ensureWorkspace being required and unconditional, defaultBootDeps supplying mkdirSync(dir, { recursive: true }), dest: "." still rejected, absent repos still is required on both routes, settingSources: ["project"], and the parameter rename across the interface and both adapters.


🐛 The final review found a real defect, in a place five per-task reviews couldn't see

throng-agent/src/integration/boot.test.ts builds its own BootDeps literal, and it never got ensureWorkspace when Task 3 added it. Consequences:

  • Every boot in that test died at the cloning step with this.deps.ensureWorkspace is not a function.
  • The test still reported green. Its failure guard was expect(error?.step).not.toBe("boot") — and "boot" is not a value StepError ever carries. The guard passed for every failure, including ones the fakes exist to make impossible.
  • npm run typecheck cannot catch it: throng-agent/tsconfig.json:5 excludes src/**/*.test.ts.

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 packages throng-agent-claude throng-agent-codex — it omits throng-agent. Fixed in the plan too, so the record doesn't reproduce the error.

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 (agent, plugins) stays tolerated — that's what the loose assertion is for — but a bootstrap-step failure now fails. Verified by mutation: removing the fake again produces expected [ 'credentials', 'cloning', 'setup' ] to not include 'cloning' instead of a silent pass.


Final state

Check Result
npm run build 4/4 tasks ✅
npm run typecheck 7/7 tasks ✅
packages/core 266 tests ✅
throng-agent-claude 87 ✅
throng-agent-codex 22 ✅
throng-agent 6 ✅

The final review also confirmed the security invariant holds on the new path: a zero-repo prepare still wipes credentials before prepared is set, so no snapshot is taken with a live credential on its filesystem. ensureWorkspace introduces no traversal concern — it only ever receives the config-derived workspaceRoot, never user input.

⚠️ One pre-existing flake remains, deliberately untouched. creds/throng-creds.test.ts > gh shim > runs gh with a freshly fetched token… times out at vitest's default 5s under 4 concurrent package suites. It fails identically at 75a1c6d, this branch's base, so it predates this work and fixing it here would mix concerns. It will flake CI on this PR. Worth a testTimeout bump or a serial marker in its own change.

Marking ready for review.

@col
col marked this pull request as ready for review August 24, 2026 06:42
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>
@col
col merged commit c45ad8b into main Aug 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant