Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
60ff7b6
docs: spec for zero-repo manifests
col Aug 24, 2026
92d9e97
docs: implementation plan for zero-repo manifests
col Aug 24, 2026
c524f44
docs: fix an unworkable assertion in the zero-repo plan
col Aug 24, 2026
7d9443b
feat(core): accept an empty repos list on both routes
col Aug 24, 2026
74a2d1d
test(core): consolidate the primary-repo rule into the repo-list tests
col Aug 24, 2026
a35a3ac
feat(core): add resolveWorkingDirectory
col Aug 24, 2026
ce95669
test(core): type the resolveWorkingDirectory manifest helper
col Aug 24, 2026
0edb655
test(core): cover a nested dest in resolveWorkingDirectory
col Aug 24, 2026
dd203b2
feat(core): create the workspace root via an injected ensureWorkspace…
col Aug 24, 2026
89526dc
test(core): pin the workspace-creation failure to its step and message
col Aug 24, 2026
81d3e88
refactor(core): resolve the working directory outside the clone loop
col Aug 24, 2026
13656b8
refactor: rename buildAgentConfig's primaryDest to workingDirectory
col Aug 24, 2026
8e76bf7
refactor(core): restore the clone-before-setup guarantee in a signature
col Aug 24, 2026
b536e0f
test(core): cover zero-repo boot, prepare and routes
col Aug 24, 2026
910aac5
test(core): explain what the zero-repo tests pin, and why each is sep…
col Aug 24, 2026
3a58e8a
chore: changeset for zero-repo manifests
col Aug 24, 2026
88783e4
fix(throng-agent): give the integration boot test its ensureWorkspace…
col Aug 24, 2026
d1f90c8
chore: trim the zero-repo changeset to what a consumer needs
col Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/zero-repo-manifests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@throng/agent-core": minor
---

Accept an empty `repos` list on `/api/initialise` and `/api/prepare`, so a task can boot into an empty
workspace — an agent asked to create a new project from scratch has nothing to clone.

`repos` is still required and must still be a list; only emptiness becomes legal. The
exactly-one-`primary` rule now applies only to a non-empty list. With no repos, the agent's working
directory and the `setup_commands` cwd are the workspace root (`$HOME/workspace`, or `WORKSPACE_DIR`).

New export: `resolveWorkingDirectory(manifest, workspaceRoot)`.

**Breaking (consumers constructing `BootDeps` directly):** `BootDeps` gains a required
`ensureWorkspace: (dir: string) => void`. Nothing else creates the workspace root — it has only ever
existed as a side effect of `git clone` creating its destination's parents, which no longer happens when
there are no repos. `defaultBootDeps()` supplies it.
802 changes: 802 additions & 0 deletions docs/superpowers/plans/2026-08-24-zero-repo-manifests.md

Large diffs are not rendered by default.

163 changes: 163 additions & 0 deletions docs/superpowers/specs/2026-08-24-zero-repo-manifests-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Zero-repo manifests

## Problem

A manifest with an empty repo list is rejected:

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

The rule assumes every task starts from existing code. That is not always true. An
agent asked to create a new project from scratch — scaffold it, `git init` it,
create the remote — has no repo to clone, and today it cannot be booted at all.

Whether a task needs a repo is the consumer's decision, not the runtime's. The
runtime's job is to make an empty workspace behave predictably.

## What changes

An empty `repos` array becomes valid input on `/api/initialise` and
`/api/prepare`. The agent's working directory in that case is the workspace root.

Applying it to both routes is deliberate. `validate` and `validatePrepare` share
every per-field and cross-field repo rule (`validateWorkspace`,
`crossFieldRepoErrors`) precisely so a snapshot build and the task boot that
restores from it cannot disagree about what a repo list means. Relaxing one route
only would make the two disagree for the first time, and the reward would be
small: a prepare with no repos but with `setup_commands` is a legitimate way to
warm a toolchain cache into a snapshot.

`repos` stays a **required** key. `repos: []` is accepted; omitting the field
remains a 400. An empty workspace is a thing the caller states, not a thing that
happens when a payload loses a field — a control-plane bug that drops `repos`
should still fail loudly rather than silently boot an agent with nothing checked
out.

Nothing else relaxes. `dest: "."` stays rejected, the `credentials` and
`github_token` rules are untouched, and a non-array `repos` is still a 400.

## Validation

`packages/core/src/manifest/validate.ts`.

`validateRepos` drops the `value.length === 0` rejection. The `undefined` and
non-array branches stay exactly as they are.

`crossFieldRepoErrors` requires exactly one `primary: true` **only when `repos`
is non-empty**; on an empty array it returns no errors. Without this the length
check's removal would achieve nothing — zero repos means zero primaries, so
`primaries !== 1` fires instead, and the caller trades one confusing 400 for
another.

The dest-uniqueness check and prepare's `rejectCredentialBearingRepoUrls` are
already no-ops on an empty array and need no change.

## Working directory

`packages/core/src/task-run.ts`.

Today `syncRepos` both clones and computes the primary repo's destination,
accumulating `primaryDest` through the clone loop and throwing if it is still
`""` at the end. That destination is two things: the cwd for `setup_commands`,
and the `workingDirectory` handed to `adapter.buildAgentConfig`.

Resolution moves into a pure function:

```ts
function resolveWorkingDirectory(manifest: WorkspaceManifest, workspaceRoot: string): string
```

- `repos` non-empty, one marked primary → `join(workspaceRoot, primary.dest)`
- `repos` empty → `workspaceRoot`
- `repos` non-empty, none primary → throws `StepError("cloning", …)`

The third case preserves the existing guard. Validation accepts exactly one
primary on both routes, so it is unreachable through the public API, but the
silent failure it prevents is bad: an empty working directory makes
`runSetupCommands` run in the process's cwd instead of the repo and report
success. The guard survives as a function contract rather than a loop invariant,
which is the point of extracting it — the empty-repos case makes the separation
between "clone the repos" and "decide where the agent runs" load-bearing rather
than cosmetic, and a pure function is testable without mocking git.

`syncRepos` loses the accumulator and its return value; it only clones. `boot`
and `prepareWorkspace` each call `resolveWorkingDirectory` and pass the result to
`runSetup` and (in `boot`) `buildAgentConfig`.

Resolution happens **before** `syncRepos`, not after. The old guard fired at the
end of the clone loop, so a manifest with no primary repo cloned everything over
the network and only then failed. Resolving first makes that a validation-shaped
failure with nothing done. It is a strict improvement and affects no reachable
input, since validation rejects such a manifest anyway.

`syncRepos` is still called on a zero-repo manifest. Its loop body simply does
not execute, so it logs the step, ensures the workspace, and returns.

## Workspace creation

Nothing creates `workspaceRoot` today. It exists as a side effect of `git clone`
creating its destination's parents. With no repos, nothing creates it, and the
agent would be pointed at a directory that does not exist.

`BootDeps` gains:

```ts
ensureWorkspace: (dir: string) => void;
```

`defaultBootDeps` implements it as `mkdirSync(dir, { recursive: true })`.
`syncRepos` calls it unconditionally, for every manifest, before the clone loop —
idempotent where `git clone` would have created the directory anyway, and
load-bearing where there are no repos. A failure is wrapped as
`StepError("cloning", …)`, matching how the other filesystem faults in that step
are reported.

It is an injected dep rather than a direct `mkdirSync` for the same reason every
other side effect in `BootDeps` is: `task-run.test.ts` uses
`workspaceRoot: "/home/user/workspace"`, a path that does not exist on a
developer machine and cannot be created there. A raw `mkdirSync` would make the
existing suite depend on the host filesystem.

## Behaviour with an empty workspace

- `syncOrClone` is never called; no network access during the clone step.
- `setup_commands` run with `workspaceRoot` as cwd. Still supported, and the
reason a zero-repo prepare is worth allowing.
- The agent's `workingDirectory` is `workspaceRoot`. For Claude,
`settingSources: ["project"]` then finds no `CLAUDE.md` and no
`.claude/settings.json`, because there is no project yet. That is correct, not
a degradation.
- An agent that creates a repository does so inside `workspaceRoot`, where
cloned repos live, so a subsequent task whose manifest names that repo finds
the layout it expects.
- Credential write and wipe, git identity injection, and the prepare lifecycle
are all unchanged — none of them reads `repos`.

## Testing

`manifest/validate.test.ts`

- Invert the assertion at line 320: `repos: []` is accepted on prepare rather
than producing a `repos` error.
- The initialise equivalent: a manifest with `repos: []` and a valid `agent`
block validates.
- An empty `repos` produces no `repos[].primary` error, while a non-empty array
with every entry `primary: false` still does. This pins the conditional in
`crossFieldRepoErrors` — the pair fails if the rule is dropped outright instead
of made conditional.
- Omitting `repos` entirely is still `{ field: "repos", reason: "is required" }`
on both routes.

`task-run.test.ts`

- Add `ensureWorkspace: vi.fn()` to the fake deps.
- A zero-repo initialise reaches `ready`, and `buildAgentConfig` receives
`workspaceRoot` as its working directory.
- `setup_commands` on a zero-repo manifest run with `workspaceRoot` as cwd.
- `syncOrClone` is never called for a zero-repo manifest.
- `ensureWorkspace` is called for both the zero-repo and the normal path.
- A zero-repo prepare reaches `prepared` and still wipes credentials.

`resolveWorkingDirectory` gets direct unit tests for all three cases, including
the throw.
15 changes: 14 additions & 1 deletion packages/core/src/control/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const bootDeps: BootDeps = {
writeCredentialConfig: () => {},
deleteCredentialConfig: () => {},
injectGitIdentity: () => {},
ensureWorkspace: () => {},
workspaceRoot: "/home/user/workspace",
};
const adapter: EngineAdapter<any, any> = {
Expand All @@ -29,7 +30,7 @@ describe("control server", () => {
it("GET /api/status → uninitialised", async () => {
expect((await request(app()).get("/api/status")).body.state).toBe("uninitialised");
});
it("POST /api/initialise bad manifest → 400 list", async () => {
it("POST /api/initialise missing agent block → 400 list", async () => {
const res = await request(app()).post("/api/initialise").send({ repos: [] });
expect(res.status).toBe(400);
expect(Array.isArray(res.body)).toBe(true);
Expand All @@ -43,6 +44,18 @@ describe("control server", () => {
expect(res.status).toBe(202);
expect(res.body).toEqual({ status: "booting" });
});

it("POST /api/initialise with repos: [] → 202 booting", async () => {
const res = await request(app()).post("/api/initialise").send({ repos: [], agent: { platform: "claude" } });
expect(res.status).toBe(202);
expect(res.body).toEqual({ status: "booting" });
});

it("POST /api/prepare with repos: [] → 202 booting", async () => {
const res = await request(app()).post("/api/prepare").send({ repos: [] });
expect(res.status).toBe(202);
expect(res.body).toEqual({ status: "booting" });
});
it("401 when THRONG_INIT_TOKEN set", async () => {
const prev = process.env.THRONG_INIT_TOKEN;
process.env.THRONG_INIT_TOKEN = "secret";
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/control/server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { mkdirSync } from "node:fs";
import type { Server } from "node:http";
import { join } from "node:path";
import express, { type Express, type NextFunction, type Request, type Response } from "express";
Expand Down Expand Up @@ -99,6 +100,7 @@ export function defaultBootDeps(): BootDeps {
writeCredentialConfig: (manifest) => writeCredentialConfig(manifest),
deleteCredentialConfig: () => deleteCredentialConfig(),
injectGitIdentity,
ensureWorkspace: (dir) => mkdirSync(dir, { recursive: true }),
// `||` rather than `??` so a blank WORKSPACE_DIR falls back instead of
// resolving every clone destination against "".
workspaceRoot: process.env.WORKSPACE_DIR || join(homeDir(), "workspace"),
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/engine/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,12 @@ export interface EngineAdapter<TAgent = unknown, TConfig = unknown> {
/** Inject the engine credential(s) into the process and run engine preflight. */
injectCredentials(manifest: Manifest<TAgent>): void;

/** Build the engine server config from the resolved manifest. */
buildAgentConfig(manifest: Manifest<TAgent>, primaryDest: string): TConfig;
/** Build the engine server config from the resolved manifest.
*
* `workingDirectory` is the primary repo's destination, or the workspace root
* when the manifest carries no repos — so it is not necessarily a repository.
* See resolveWorkingDirectory. */
buildAgentConfig(manifest: Manifest<TAgent>, workingDirectory: string): TConfig;

/** Start the engine's A2A server. */
createA2AServer(config: TConfig): Promise<ServerHandle>;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { startControlServer, buildServer, createControlApp, defaultBootDeps } from "./control/server.js";
export {
TaskRun,
resolveWorkingDirectory,
type BootDeps,
type BootAcceptance,
type InitialiseResult,
Expand Down
60 changes: 51 additions & 9 deletions packages/core/src/manifest/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,6 @@ describe("validate (registry routing)", () => {
expect(r.ok).toBe(false);
if (!r.ok) expect(r.errors.some((e) => e.field === "agent.platform")).toBe(true);
});
it("requires exactly one primary repo", () => {
const r = validate(
{ agent: { platform: "test" }, repos: [{ url: "https://x/y", ref: "main", dest: "y", primary: false }] },
registry,
);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.errors.some((e) => e.field === "repos[].primary")).toBe(true);
});
it("builds a manifest with resolved platform + selected adapter", () => {
const r = validate(okInput, registry);
expect(r.ok).toBe(true);
Expand All @@ -59,6 +51,53 @@ describe("validate (registry routing)", () => {
});
});

describe("validate (empty repo list)", () => {
it("accepts repos: [] with a valid agent block", () => {
const r = validate({ repos: [], agent: { platform: "test", model: "m" } }, registry);
expect(r.ok).toBe(true);
if (r.ok) expect(r.manifest.repos).toEqual([]);
});

// The pair matters, which is why the primary rule is pinned here rather than
// among the routing tests where it used to live. Dropping the rule outright
// instead of making it conditional would also pass the case above, and would
// let a real manifest through with no primary — which resolveWorkingDirectory
// has no cwd for. Note this fires for a single-repo list too: `primary` names the
// directory the agent runs in, so one repo must still claim it.
it("still requires exactly one primary when repos is non-empty", () => {
const r = validate(
{ repos: [{ url: "https://x/y", ref: "main", dest: "y", primary: false }], agent: { platform: "test" } },
registry,
);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.errors.some((e) => e.field === "repos[].primary")).toBe(true);
});

it("still requires the repos key to be present", () => {
const r = validate({ agent: { platform: "test", model: "m" } }, registry);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.errors).toContainEqual({ field: "repos", reason: "is required" });
});

it("still rejects a non-array repos", () => {
const r = validate({ repos: {}, agent: { platform: "test", model: "m" } }, registry);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.errors).toContainEqual({ field: "repos", reason: "must be a list" });
});

it("accepts repos: [] on prepare too", () => {
const r = validatePrepare({ repos: [] }, {});
expect(r.ok).toBe(true);
if (r.ok) expect(r.manifest.repos).toEqual([]);
});

it("still requires the repos key on prepare", () => {
const r = validatePrepare({}, {});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.errors).toContainEqual({ field: "repos", reason: "is required" });
});
});

describe("validate (github_token)", () => {
const tok = (input: Record<string, unknown>, env = {}) => {
const r = validate({ ...okInput, ...input }, registry, env);
Expand Down Expand Up @@ -317,7 +356,10 @@ describe("validatePrepare", () => {
});

it("applies the same repo rules as initialise", () => {
expect(errorsOf({ ...preparePayload, repos: [] }).map((e) => e.field)).toContain("repos");
// Direct, not via errorsOf: that helper throws when validation succeeds, and
// an empty repo list is now a success on this route just as it is on
// initialise. The parity is the point of the assertion.
expect(validatePrepare({ ...preparePayload, repos: [] }).ok).toBe(true);
expect(errorsOf({ repos: preparePayload.repos.map((r) => ({ ...r, primary: false })) }).map((e) => e.field))
.toContain("repos[].primary");
expect(errorsOf({ ...preparePayload, repos: [{ ...preparePayload.repos[0], url: "http://x/y" }] })
Expand Down
25 changes: 15 additions & 10 deletions packages/core/src/manifest/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,12 +187,21 @@ function validateWorkspace(input: Record<string, unknown>, errors: FieldError[])
/** Rules that need every repo at once; run only after the per-field ones pass. */
function crossFieldRepoErrors(repos: Array<Record<string, unknown>>): FieldError[] {
const errors: FieldError[] = [];
const primaries = repos.filter((r) => r.primary === true).length;
if (primaries !== 1) {
errors.push({
field: "repos[].primary",
reason: `exactly one repo must be marked primary: true (got ${primaries})`,
});
// Skipped for an empty list only, not relaxed. A task may legitimately start
// from a bare workspace — an agent asked to create the project picks the
// layout itself — and `primary` exists to name the directory the agent runs
// in, which with no repos is the workspace root instead (see
// resolveWorkingDirectory). One repo is still required to mark itself
// primary: the rule is about naming that directory, not about breaking a tie
// between several candidates.
if (repos.length > 0) {
const primaries = repos.filter((r) => r.primary === true).length;
if (primaries !== 1) {
errors.push({
field: "repos[].primary",
reason: `exactly one repo must be marked primary: true (got ${primaries})`,
});
}
}
const dests = repos.map((r) => r.dest);
if (new Set(dests).size !== dests.length) {
Expand All @@ -210,10 +219,6 @@ function validateRepos(value: unknown, errors: FieldError[]): void {
errors.push({ field: "repos", reason: "must be a list" });
return;
}
if (value.length === 0) {
errors.push({ field: "repos", reason: "must contain at least one entry" });
return;
}
value.forEach((repo, i) => {
if (!isObject(repo)) {
errors.push({ field: `repos[${i}]`, reason: "must be an object" });
Expand Down
Loading