From 60ff7b6c00066e7267cab2f161d0866c1ced7835 Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 15:02:40 +1000 Subject: [PATCH 01/18] docs: spec for zero-repo manifests 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) --- .../2026-08-24-zero-repo-manifests-design.md | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-24-zero-repo-manifests-design.md diff --git a/docs/superpowers/specs/2026-08-24-zero-repo-manifests-design.md b/docs/superpowers/specs/2026-08-24-zero-repo-manifests-design.md new file mode 100644 index 0000000..1eea4e7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-zero-repo-manifests-design.md @@ -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. From 92d9e9769abe5909c58bd246656d95bf641b8bbc Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 15:20:55 +1000 Subject: [PATCH 02/18] docs: implementation plan for zero-repo manifests 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) --- .../plans/2026-08-24-zero-repo-manifests.md | 792 ++++++++++++++++++ 1 file changed, 792 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-24-zero-repo-manifests.md diff --git a/docs/superpowers/plans/2026-08-24-zero-repo-manifests.md b/docs/superpowers/plans/2026-08-24-zero-repo-manifests.md new file mode 100644 index 0000000..4d56b2c --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-zero-repo-manifests.md @@ -0,0 +1,792 @@ +# Zero-Repo Manifests Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Accept `repos: []` on `/api/initialise` and `/api/prepare`, so a task can boot into an empty workspace for agents whose job is to create a project from scratch. + +**Architecture:** Two validation rules relax (the empty-array rejection, and exactly-one-primary becoming conditional on a non-empty list). Working-directory resolution moves out of the clone loop into an exported pure function that returns the workspace root when there are no repos. `BootDeps` gains an injected `ensureWorkspace` so the workspace root exists even when no `git clone` creates it. + +**Tech Stack:** TypeScript (ESM, NodeNext), Vitest, Turborepo, Changesets. Package: `@throng/agent-core` at `packages/core`. + +--- + +## Background an engineer needs + +**The two rules that block an empty list.** Removing only the length check achieves nothing. `validate.ts:213` rejects `repos.length === 0`, and `crossFieldRepoErrors` (`validate.ts:188-202`) then rejects it again because zero repos means zero repos with `primary: true`, and the rule demands exactly one. Both must change together or the caller trades one confusing 400 for another. + +**Why the working directory is load-bearing.** `syncRepos` currently accumulates `primaryDest` through the clone loop and throws if it is still `""` at the end. That value is the cwd for `setup_commands` *and* the `workingDirectory` passed to `adapter.buildAgentConfig`. With no repos there is no primary, so it needs to resolve to `workspaceRoot`. + +**Why `ensureWorkspace` is injected rather than a bare `mkdirSync`.** `packages/core/src/task-run.test.ts:18` builds fake deps with `workspaceRoot: "/home/user/workspace"`. That path does not exist on macOS and cannot be created there (`/home` is not writable). A direct `mkdirSync` inside `syncRepos` would make the existing suite fail on a developer machine. Every other side effect in `BootDeps` is injected for exactly this reason. + +**Running tests.** From the repo root: +- Whole suite: `npm test` +- One file: `npx vitest run packages/core/src/manifest/validate.test.ts` +- One test by name: `npx vitest run packages/core/src/task-run.test.ts -t "zero-repo"` + +Turborepo caches results, so a re-run with unchanged inputs prints `cached`. Add `--force` to `npm test` to genuinely re-execute. `npx vitest run` always executes. + +Only `npm install` / `npm ci` need a token (`GITHUB_TOKEN="$(gh auth token)" npm ci`). `npm test`, `npm run build`, `npm run typecheck` do not. + +--- + +## File Structure + +| File | Responsibility | Change | +|---|---|---| +| `packages/core/src/manifest/validate.ts` | Manifest shape and cross-field rules for both routes | Modify: `validateRepos` (drop length check), `crossFieldRepoErrors` (guard primary rule) | +| `packages/core/src/manifest/validate.test.ts` | Validation tests | Modify one assertion, add cases | +| `packages/core/src/task-run.ts` | Boot/prepare orchestration, `BootDeps` | Modify: add exported `resolveWorkingDirectory`, add `ensureWorkspace` to `BootDeps`, strip `primaryDest` out of `syncRepos` | +| `packages/core/src/task-run.test.ts` | Orchestration tests | Add `ensureWorkspace` to fakes, add zero-repo cases | +| `packages/core/src/control/server.ts` | `defaultBootDeps` wiring | Modify: implement `ensureWorkspace` | +| `packages/core/src/index.ts` | Package surface | Modify: export `resolveWorkingDirectory` | +| `.changeset/zero-repo-manifests.md` | Release note | Create | + +`resolveWorkingDirectory` lives in `task-run.ts` rather than a new file because it throws `StepError`, which is module-private there (`task-run.ts:316`), and because it is one small function tightly coupled to the boot sequence. It is exported so it can be unit-tested without constructing a `TaskRun`. + +--- + +### Task 1: Validation accepts an empty repo list + +**Files:** +- Modify: `packages/core/src/manifest/validate.ts:204-216` (`validateRepos`) and `:188-202` (`crossFieldRepoErrors`) +- Test: `packages/core/src/manifest/validate.test.ts` + +- [ ] **Step 1: Write the failing tests** + +In `packages/core/src/manifest/validate.test.ts`, add a new `describe` block after the `describe("validate (registry routing)", ...)` block that ends at line 61: + +```typescript +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. Dropping the primary rule outright rather than making it + // conditional would also pass the case above, and would let a real multi-repo + // manifest through with no primary — which syncRepos has no cwd for. + 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" }); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run packages/core/src/manifest/validate.test.ts -t "empty repo list"` + +Expected: FAIL. The two "accepts" cases fail because `validate`/`validatePrepare` return `ok: false` with a `repos` error. The three "still" cases pass already — they pin behaviour that must not regress. + +- [ ] **Step 3: Drop the length rejection** + +In `packages/core/src/manifest/validate.ts`, in `validateRepos`, delete this block (currently lines 213-216): + +```typescript + if (value.length === 0) { + errors.push({ field: "repos", reason: "must contain at least one entry" }); + return; + } +``` + +Leave the `undefined` and non-array branches above it exactly as they are — `repos` stays a required key, and a non-array is still a 400. + +- [ ] **Step 4: Make the primary rule conditional** + +In the same file, replace `crossFieldRepoErrors` (currently lines 187-202) with: + +```typescript +/** Rules that need every repo at once; run only after the per-field ones pass. */ +function crossFieldRepoErrors(repos: Array>): FieldError[] { + const errors: FieldError[] = []; + // Empty is legal: a task may start from a bare workspace, and an agent whose + // job is to create the project chooses the layout itself. The primary rule is + // about which of SEVERAL repos the agent runs in, so with none there is + // nothing for it to decide — see resolveWorkingDirectory, which answers + // "where does the agent run" with the workspace root in that case. + 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) { + errors.push({ field: "repos[].dest", reason: "dest values must be unique across repos" }); + } + return errors; +} +``` + +The dest-uniqueness check stays outside the guard: it is already a no-op on an empty array, and nesting it would imply otherwise. + +- [ ] **Step 5: Invert the stale prepare assertion** + +`packages/core/src/manifest/validate.test.ts:320` currently asserts that an empty list is an error on prepare. In the `it("applies the same repo rules as initialise", ...)` test, replace this line: + +```typescript + expect(errorsOf({ ...preparePayload, repos: [] }).map((e) => e.field)).toContain("repos"); +``` + +with: + +```typescript + expect(errorsOf({ ...preparePayload, repos: [] })).toEqual([]); +``` + +The surrounding two assertions in that test (all-`primary: false`, and an `http://` url) stay unchanged — they are still the point of the test. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `npx vitest run packages/core/src/manifest/validate.test.ts` + +Expected: PASS, all tests in the file. + +- [ ] **Step 7: Commit** + +```bash +git add packages/core/src/manifest/validate.ts packages/core/src/manifest/validate.test.ts +git commit -m "feat(core): accept an empty repos list on both routes" +``` + +--- + +### Task 2: `resolveWorkingDirectory` + +**Files:** +- Modify: `packages/core/src/task-run.ts` (add the function; do not wire it in yet) +- Modify: `packages/core/src/index.ts:2-8` (export it) +- Test: `packages/core/src/task-run.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Add to `packages/core/src/task-run.test.ts`. The existing import on line 6 is `import { TaskRun, type BootDeps } from "./task-run.js";` — change it to: + +```typescript +import { TaskRun, resolveWorkingDirectory, type BootDeps } from "./task-run.js"; +``` + +Then add this `describe` block at the end of the file: + +```typescript +describe("resolveWorkingDirectory", () => { + const root = "/home/user/workspace"; + const manifest = (repos: Array<{ url: string; ref: string; dest: string; primary: boolean }>) => + ({ repos, credentials: null, github_token: null, setup_commands: [] }) as any; + + it("returns the primary repo's destination", () => { + const m = manifest([ + { url: "https://x/a", ref: "main", dest: "a", primary: false }, + { url: "https://x/b", ref: "main", dest: "b", primary: true }, + ]); + expect(resolveWorkingDirectory(m, root)).toBe("/home/user/workspace/b"); + }); + + it("returns the workspace root when there are no repos", () => { + expect(resolveWorkingDirectory(manifest([]), root)).toBe(root); + }); + + // Unreachable through the public API — validation demands exactly one primary + // for a non-empty list — but the silent failure it prevents is bad: an empty + // cwd makes runSetupCommands run in the process's own directory and report + // success. + it("throws when a non-empty list has no primary", () => { + const m = manifest([{ url: "https://x/a", ref: "main", dest: "a", primary: false }]); + expect(() => resolveWorkingDirectory(m, root)).toThrow(/no repo was marked primary/); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run packages/core/src/task-run.test.ts -t "resolveWorkingDirectory"` + +Expected: FAIL at import — `resolveWorkingDirectory` is not exported from `./task-run.js`. + +- [ ] **Step 3: Write the implementation** + +In `packages/core/src/task-run.ts`, add this function immediately before the `class StepError` declaration at line 316: + +```typescript +/** + * Where the agent runs, and where `setup_commands` run: the primary repo's + * destination, or the workspace root when the manifest carries no repos. + * + * Separate from `syncRepos` — which only clones — because these are two + * questions, and only one of them has an answer that depends on the network + * having succeeded. Keeping the resolution pure also means the no-primary case + * below is a function contract rather than a loop invariant over a mutable + * accumulator, and it is testable without mocking git. + */ +export function resolveWorkingDirectory(manifest: WorkspaceManifest, workspaceRoot: string): string { + // An empty workspace is legal input: the agent's job may be to create the + // project. The workspace root is where cloned repos live, so a repository the + // agent creates there is in the layout a later task's manifest will expect. + if (manifest.repos.length === 0) return workspaceRoot; + + const primary = manifest.repos.find((r) => r.primary); + // Guards the seam rather than a reachable input: validation accepts exactly + // one primary for a non-empty list on both routes, so this cannot fire through + // the public API. It is here because the silent failure it prevents is bad — + // an empty working directory makes runSetupCommands run in the process's own + // working directory instead of the repo, and report success. + if (!primary) { + throw new StepError("cloning", "no repo was marked primary, so setup commands have nowhere to run"); + } + return join(workspaceRoot, primary.dest); +} +``` + +`join` is already imported at `task-run.ts:1`, and `WorkspaceManifest` at line 7. No new imports. + +- [ ] **Step 4: Export it from the package root** + +In `packages/core/src/index.ts`, the block starting at line 2 exports from `./task-run.js`. Add `resolveWorkingDirectory` to that export list, keeping the existing entries. For example, if the block reads: + +```typescript +export { + TaskRun, + type BootDeps, + ... +} from "./task-run.js"; +``` + +make it: + +```typescript +export { + TaskRun, + resolveWorkingDirectory, + type BootDeps, + ... +} from "./task-run.js"; +``` + +Read the existing block first and preserve every entry already there — do not retype it from memory. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npx vitest run packages/core/src/task-run.test.ts -t "resolveWorkingDirectory"` + +Expected: PASS, 3 tests. + +- [ ] **Step 6: Verify nothing else broke** + +Run: `npx vitest run packages/core && npm run typecheck` + +Expected: PASS. `syncRepos` still has its own copy of the logic at this point — that is intentional and gets removed in Task 4. + +- [ ] **Step 7: Commit** + +```bash +git add packages/core/src/task-run.ts packages/core/src/task-run.test.ts packages/core/src/index.ts +git commit -m "feat(core): add resolveWorkingDirectory" +``` + +--- + +### Task 3: `ensureWorkspace` dep + +**Files:** +- Modify: `packages/core/src/task-run.ts:11-22` (`BootDeps`) and `:231-233` (`syncRepos`) +- Modify: `packages/core/src/control/server.ts:95-106` (`defaultBootDeps`) +- Test: `packages/core/src/task-run.test.ts` + +- [ ] **Step 1: Write the failing test** + +In `packages/core/src/task-run.test.ts`, add `ensureWorkspace` to the fake deps factory (currently lines 11-21) so it reads: + +```typescript +function deps(over: Partial = {}): BootDeps { + return { + syncOrClone: vi.fn(async () => ({ ok: true, output: "" })), + runSetupCommands: vi.fn(async () => ({ ok: true })), + writeCredentialConfig: vi.fn(() => {}), + deleteCredentialConfig: vi.fn(() => {}), + injectGitIdentity: vi.fn(() => {}), + ensureWorkspace: vi.fn(() => {}), + workspaceRoot: "/home/user/workspace", + ...over, + }; +} +``` + +Then add this test inside the existing `describe("TaskRun", ...)` block: + +```typescript + it("ensures the workspace root exists before cloning", async () => { + const d = deps(); + const tr = new TaskRun(d, { claude: adapter() }); + await tr.initialise(okPayload); + await settle(); + expect(d.ensureWorkspace).toHaveBeenCalledWith("/home/user/workspace"); + }); + + it("fails the boot when the workspace cannot be created", async () => { + const d = deps({ + ensureWorkspace: vi.fn(() => { + throw new Error("EACCES: permission denied"); + }), + }); + const tr = new TaskRun(d, { claude: adapter() }); + await tr.initialise(okPayload); + await settle(); + const status = tr.lifecycle.status(); + expect(status.state).toBe("failed"); + expect(d.syncOrClone).not.toHaveBeenCalled(); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run packages/core/src/task-run.test.ts -t "workspace"` + +Expected: FAIL. The first test fails because `ensureWorkspace` is never called; TypeScript also reports `ensureWorkspace` is not a property of `BootDeps`. + +- [ ] **Step 3: Add the dep to the interface** + +In `packages/core/src/task-run.ts`, inside `interface BootDeps`, add this member immediately before `workspaceRoot`: + +```typescript + /** Creates `workspaceRoot` if it is absent. Recursive and idempotent. + * + * Needed because nothing else creates it: today the directory exists only as + * a side effect of `git clone` creating its destination's parents, so a + * manifest with no repos would point the agent at a path that is not there. + * Injected rather than called directly so the test suite's fake workspace + * root — a path that does not exist and cannot be created on a developer + * machine — stays inert. */ + ensureWorkspace: (dir: string) => void; +``` + +- [ ] **Step 4: Call it at the top of `syncRepos`** + +In the same file, in `syncRepos`, immediately after the `log.info(\`${phase} step: cloning repos\`, …)` call (line 233) and before `let primaryDest = "";`, insert: + +```typescript + // Unconditional, not guarded on an empty repo list: it is idempotent where + // `git clone` would have created the directory anyway, and load-bearing + // where there are no repos to create it. + try { + this.deps.ensureWorkspace(this.deps.workspaceRoot); + } catch (err) { + throw new StepError("cloning", err instanceof Error ? err.message : String(err)); + } +``` + +- [ ] **Step 5: Implement it in `defaultBootDeps`** + +In `packages/core/src/control/server.ts`, add `mkdirSync` to the `node:fs` imports. If the file has no `node:fs` import yet, add at the top: + +```typescript +import { mkdirSync } from "node:fs"; +``` + +Then in the object returned by `defaultBootDeps`, add before `workspaceRoot`: + +```typescript + ensureWorkspace: (dir) => mkdirSync(dir, { recursive: true }), +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `npx vitest run packages/core/src/task-run.test.ts` + +Expected: PASS, whole file. + +- [ ] **Step 7: Fix the other `BootDeps` construction site** + +`ensureWorkspace` is a required member, so every place that builds a `BootDeps` literal now fails to compile. There is one besides the test factory in Step 1: `packages/core/src/control/server.test.ts:8`. Add the member to it so the literal reads: + +```typescript +const bootDeps: BootDeps = { + syncOrClone: async () => ({ ok: true, output: "" }), + runSetupCommands: async () => ({ ok: true }), + writeCredentialConfig: () => {}, + deleteCredentialConfig: () => {}, + injectGitIdentity: () => {}, + ensureWorkspace: () => {}, + workspaceRoot: "/home/user/workspace", +}; +``` + +Then confirm there are no others: + +```bash +npm run typecheck +grep -rn "BootDeps" --include="*.ts" packages throng-agent-claude throng-agent-codex | grep -v node_modules +``` + +Expected: typecheck PASSes. If it still reports a missing `ensureWorkspace` somewhere, add `ensureWorkspace: () => {}` there too and re-run. + +- [ ] **Step 8: Commit** + +```bash +git add packages/core/src/task-run.ts packages/core/src/task-run.test.ts packages/core/src/control/server.ts +git commit -m "feat(core): create the workspace root via an injected ensureWorkspace dep" +``` + +--- + +### Task 4: Wire the resolver in and strip `primaryDest` out of `syncRepos` + +**Files:** +- Modify: `packages/core/src/task-run.ts:120-135` (`prepareWorkspace`), `:187-212` (`boot`), `:230-275` (`syncRepos`) +- Test: `packages/core/src/task-run.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add to `packages/core/src/task-run.test.ts`, inside `describe("TaskRun", ...)`: + +```typescript + it("resolves the working directory before cloning, not after", async () => { + const order: string[] = []; + const d = deps({ + ensureWorkspace: vi.fn(() => { + order.push("ensure"); + }), + syncOrClone: vi.fn(async () => { + order.push("clone"); + return { ok: true, output: "" }; + }), + runSetupCommands: vi.fn(async () => { + order.push("setup"); + return { ok: true }; + }), + }); + const tr = new TaskRun(d, { claude: adapter() }); + await tr.initialise({ ...okPayload, setup_commands: ["mise install"] }); + await settle(); + expect(order).toEqual(["ensure", "clone", "setup"]); + expect(d.runSetupCommands).toHaveBeenCalledWith("/home/user/workspace/y", ["mise install"]); + }); +``` + +- [ ] **Step 2: Run the test to verify current behaviour** + +Run: `npx vitest run packages/core/src/task-run.test.ts -t "before cloning"` + +Expected: PASS. This test pins behaviour that must survive the refactor — the observable order is unchanged. Its value is as a regression guard for Steps 3-5, which is why it is written first even though it is green. + +- [ ] **Step 3: Make `syncRepos` clone only** + +In `packages/core/src/task-run.ts`, change the `syncRepos` signature and body. Replace the doc comment and signature (lines 230-234): + +```typescript + /** Returns the primary repo's destination — where setup commands run. */ + private async syncRepos(manifest: WorkspaceManifest, phase: Phase = "boot"): Promise { +``` + +with: + +```typescript + /** Clones or resyncs every repo in the manifest. The working directory is + * resolved separately, by resolveWorkingDirectory. */ + private async syncRepos(manifest: WorkspaceManifest, phase: Phase = "boot"): Promise { +``` + +Delete the `let primaryDest = "";` line. Delete the `if (repo.primary) primaryDest = dest;` line at the end of the loop body. Delete the whole trailing block — the comment at lines 265-270 plus: + +```typescript + if (primaryDest === "") { + throw new StepError("cloning", "no repo was marked primary, so setup commands have nowhere to run"); + } + return primaryDest; +``` + +Keep the `ensureWorkspace` call from Task 3, the `log.info` calls, and the `syncOrClone` failure handling exactly as they are. The `primary: repo.primary` field in the per-repo `log.info` at line 241 stays — it is still useful diagnostics. + +- [ ] **Step 4: Call the resolver in `boot`** + +In `boot`, replace these two lines (190-191): + +```typescript + const primaryDest = await this.syncRepos(manifest); + await this.runSetup(manifest, primaryDest); +``` + +with: + +```typescript + // Before the clone, so a manifest with no primary repo fails having done + // nothing rather than after pulling every repo over the network. + const workingDirectory = resolveWorkingDirectory(manifest, this.deps.workspaceRoot); + await this.syncRepos(manifest); + await this.runSetup(manifest, workingDirectory); +``` + +Then update the two later uses in `boot` (lines 198-199) from `primaryDest` to `workingDirectory`: + +```typescript + const config = adapter.buildAgentConfig(manifest, workingDirectory); + log.info("boot step: starting A2A server", { workingDirectory }); +``` + +- [ ] **Step 5: Call the resolver in `prepareWorkspace`** + +In `prepareWorkspace`, replace these two lines (128-129): + +```typescript + const primaryDest = await this.syncRepos(manifest, "prepare"); + await this.runSetup(manifest, primaryDest, "prepare"); +``` + +with: + +```typescript + const workingDirectory = resolveWorkingDirectory(manifest, this.deps.workspaceRoot); + await this.syncRepos(manifest, "prepare"); + await this.runSetup(manifest, workingDirectory, "prepare"); +``` + +- [ ] **Step 6: Rename the `runSetup` parameter** + +`runSetup` (line 277) takes a parameter named `primaryDest`, which is now wrong — with no repos it is the workspace root. Rename it and the two uses in its body: + +```typescript + private async runSetup(manifest: WorkspaceManifest, cwd: string, phase: Phase = "boot"): Promise { + this.lifecycle.set("setup"); + // Setup commands run WITH working git and gh, because the credential config + // is already in place. See describeSetupFailure: their output is redacted + // before it leaves this process. + log.info(`${phase} step: running setup commands`, { count: manifest.setup_commands.length, cwd }); + const setup = await this.deps.runSetupCommands(cwd, manifest.setup_commands); +``` + +Leave the rest of the method body unchanged. + +- [ ] **Step 7: Run the tests and typecheck** + +Run: `npx vitest run packages/core && npm run typecheck` + +Expected: PASS. Every existing test keeps passing — this task changes structure, not behaviour, for any manifest with a primary repo. + +- [ ] **Step 8: Commit** + +```bash +git add packages/core/src/task-run.ts packages/core/src/task-run.test.ts +git commit -m "refactor(core): resolve the working directory outside the clone loop" +``` + +--- + +### Task 5: End-to-end zero-repo behaviour + +**Files:** +- Test: `packages/core/src/task-run.test.ts` +- Test: `packages/core/src/control/server.test.ts` + +No production code should change in this task. If a test here fails, the fix belongs in Tasks 1-4 — say so rather than patching the test to match. + +- [ ] **Step 1: Write the boot-path tests** + +Add to `packages/core/src/task-run.test.ts`, inside `describe("TaskRun", ...)`: + +```typescript + it("boots a zero-repo manifest and runs the agent in the workspace root", async () => { + const d = deps(); + const claude = adapter(); + const tr = new TaskRun(d, { claude }); + const r = await tr.initialise({ repos: [], agent: { platform: "claude" } }); + expect(r).toEqual({ ok: true, status: "booting" }); + await settle(); + expect(tr.lifecycle.status().state).toBe("ready"); + expect(d.syncOrClone).not.toHaveBeenCalled(); + expect(d.ensureWorkspace).toHaveBeenCalledWith("/home/user/workspace"); + expect(claude.buildAgentConfig).toHaveBeenCalledWith(expect.anything(), "/home/user/workspace"); + }); + + it("runs a zero-repo manifest's setup commands in the workspace root", async () => { + const d = deps(); + const tr = new TaskRun(d, { claude: adapter() }); + await tr.initialise({ repos: [], agent: { platform: "claude" }, setup_commands: ["mise install"] }); + await settle(); + expect(d.runSetupCommands).toHaveBeenCalledWith("/home/user/workspace", ["mise install"]); + }); + + it("prepares a zero-repo manifest and still wipes credentials", async () => { + const d = deps(); + const tr = new TaskRun(d, { claude: adapter() }); + const r = await tr.prepare({ repos: [], setup_commands: ["mise install"] }); + expect(r).toEqual({ ok: true, status: "booting" }); + await settle(); + expect(tr.lifecycle.status().state).toBe("prepared"); + expect(d.runSetupCommands).toHaveBeenCalledWith("/home/user/workspace", ["mise install"]); + expect(d.deleteCredentialConfig).toHaveBeenCalled(); + }); +``` + +- [ ] **Step 2: Run them** + +Run: `npx vitest run packages/core/src/task-run.test.ts -t "zero-repo"` + +Expected: PASS, 3 tests. If `prepare` is named differently on `TaskRun`, read the class and use the real method name — check with `grep -n "async prepare\|async initialise" packages/core/src/task-run.ts`. + +- [ ] **Step 3: Clarify the misleading existing test** + +`packages/core/src/control/server.test.ts:32` reads: + +```typescript + it("POST /api/initialise bad manifest → 400 list", async () => { + const res = await request(app()).post("/api/initialise").send({ repos: [] }); +``` + +This still passes — but for a different reason than before. The 400 now comes from the missing `agent` block, not the empty repo list. Rename it so the next reader is not misled into thinking an empty list is still rejected: + +```typescript + it("POST /api/initialise missing agent block → 400 list", async () => { + const res = await request(app()).post("/api/initialise").send({ repos: [] }); +``` + +Leave the body and assertions unchanged. + +- [ ] **Step 4: Add the HTTP-level tests** + +Add to `packages/core/src/control/server.test.ts`, inside `describe("control server", ...)`, after the existing `POST /api/initialise valid → 202 booting` test: + +```typescript + 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" }); + }); +``` + +Note that `app()` builds a fresh `TaskRun` per call, which matters because a `TaskRun` accepts only one initialise. If the prepare test fails on the response shape, read the existing `POST /api/prepare valid → 202 booting` test just below line 58 and match its assertions exactly — it is the authority on what that route returns. + +- [ ] **Step 5: Run the whole suite** + +Run: `npm test -- --force` + +Expected: PASS. `--force` bypasses the Turborepo cache so every package genuinely re-executes. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/src/task-run.test.ts packages/core/src/control/server.test.ts +git commit -m "test(core): cover zero-repo boot, prepare and routes" +``` + +--- + +### Task 6: Changeset + +**Files:** +- Create: `.changeset/zero-repo-manifests.md` + +- [ ] **Step 1: Write the changeset** + +`@throng/agent-core` gains behaviour and `BootDeps` gains a required member, so this is a `minor` with a breaking note for direct `BootDeps` constructors — the same shape as `.changeset/project-snapshots-prepare.md`. + +Create `.changeset/zero-repo-manifests.md`: + +```markdown +--- +"@throng/agent-core": minor +--- + +Accept an empty `repos` list on `/api/initialise` and `/api/prepare`, so a task can boot into an empty +workspace. Whether a task needs a repository is the consumer's decision: an agent asked to create a new +project from scratch — scaffold it, `git init` it, create the remote — has nothing to clone, and until +now could not be booted at all. + +`repos` remains a required key and must still be a list; only emptiness becomes legal. An absent `repos` +is still `{"repos", "is required"}`, so a control plane that drops the field fails loudly rather than +silently booting an agent with nothing checked out. The exactly-one-`primary` rule now applies only to a +non-empty list — with no repos there is nothing for it to choose between. Both routes change together, +because `validate` and `validatePrepare` share every repo rule precisely so a snapshot build and the +task boot that restores from it cannot disagree about what a repo list means; a zero-repo prepare is a +legitimate way to warm a toolchain cache into a snapshot from `setup_commands` alone. + +With no repos, the agent's working directory and the `setup_commands` cwd are the workspace root +(`$HOME/workspace`, or `WORKSPACE_DIR`). A repository the agent creates there lands in the same layout a +later task's manifest expects. For Claude, `settingSources: ["project"]` then finds no `CLAUDE.md` and no +`.claude/settings.json`, because there is no project yet. + +Working-directory resolution moves out of the clone loop into a new exported +`resolveWorkingDirectory(manifest, workspaceRoot)`, which returns the primary repo's destination or the +workspace root. It also now runs *before* the clone rather than after it, so a non-empty manifest with no +primary repo fails having done nothing instead of after fetching every repo over the network. No +reachable input changes: validation rejects such a manifest on both routes. + +Nothing else relaxes. `repos[].dest` still rejects `"."`, the `credentials` and `github_token` rules are +untouched, and a non-array `repos` is still a `400`. + +**Breaking (consumers constructing `BootDeps` directly):** `BootDeps` gains a required +`ensureWorkspace: (dir: string) => void`, called unconditionally before the clone loop. +`defaultBootDeps()` supplies `mkdirSync(dir, { recursive: true })`. It is needed because nothing else +creates the workspace root — the directory 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. It is a dep rather +than a direct `mkdirSync` so a caller can point the runtime at a workspace it manages itself, and so the +test suite's fake workspace root stays inert. +``` + +- [ ] **Step 2: Verify the changeset parses** + +Run: `npx changeset status` + +Expected: it lists `@throng/agent-core` as bumping `minor`. If the command errors on the frontmatter, fix the package name to match `packages/core/package.json`'s `name` field exactly. + +- [ ] **Step 3: Full verification** + +Run: `npm run build && npm run typecheck && npm test -- --force` + +Expected: all three PASS. + +- [ ] **Step 4: Commit** + +```bash +git add .changeset/zero-repo-manifests.md +git commit -m "chore: changeset for zero-repo manifests" +``` + +--- + +## Definition of done + +- `repos: []` is accepted on both routes; an absent or non-array `repos` is still a 400. +- A non-empty list still requires exactly one `primary: true`. +- A zero-repo initialise reaches `ready` with the agent's working directory set to the workspace root, and never calls `syncOrClone`. +- A zero-repo prepare reaches `prepared` and still wipes credentials. +- `ensureWorkspace` is called for every manifest, and a failure fails the boot at the `cloning` step. +- `resolveWorkingDirectory` is exported and unit-tested for all three cases. +- `npm run build`, `npm run typecheck` and `npm test -- --force` all pass. +- A changeset exists describing the behaviour change and the `BootDeps` break. From c524f4488965b83527c7ce9ad19bb03b691e2065 Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 15:24:39 +1000 Subject: [PATCH 03/18] docs: fix an unworkable assertion in the zero-repo plan 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) --- docs/superpowers/plans/2026-08-24-zero-repo-manifests.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-24-zero-repo-manifests.md b/docs/superpowers/plans/2026-08-24-zero-repo-manifests.md index 4d56b2c..28378b3 100644 --- a/docs/superpowers/plans/2026-08-24-zero-repo-manifests.md +++ b/docs/superpowers/plans/2026-08-24-zero-repo-manifests.md @@ -163,9 +163,14 @@ The dest-uniqueness check stays outside the guard: it is already a no-op on an e with: ```typescript - expect(errorsOf({ ...preparePayload, repos: [] })).toEqual([]); + // 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); ``` +The assertion must NOT go through `errorsOf`. That helper (line ~284) does `if (r.ok) throw new Error("expected validation to fail")`, so any form of `expect(errorsOf(…)).toEqual([])` throws before the matcher runs and can never pass. + The surrounding two assertions in that test (all-`primary: false`, and an `http://` url) stay unchanged — they are still the point of the test. - [ ] **Step 6: Run the tests to verify they pass** From 7d9443b47ac7fb0589706ffe88131ba70fdd6962 Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 15:24:46 +1000 Subject: [PATCH 04/18] feat(core): accept an empty repos list on both routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/core/src/manifest/validate.test.ts | 49 ++++++++++++++++++++- packages/core/src/manifest/validate.ts | 25 ++++++----- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/packages/core/src/manifest/validate.test.ts b/packages/core/src/manifest/validate.test.ts index 982e196..79684ad 100644 --- a/packages/core/src/manifest/validate.test.ts +++ b/packages/core/src/manifest/validate.test.ts @@ -59,6 +59,50 @@ 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. Dropping the primary rule outright rather than making it + // conditional would also pass the case above, and would let a real multi-repo + // manifest through with no primary — which syncRepos has no cwd for. + 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, env = {}) => { const r = validate({ ...okInput, ...input }, registry, env); @@ -317,7 +361,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" }] }) diff --git a/packages/core/src/manifest/validate.ts b/packages/core/src/manifest/validate.ts index 27ee05a..af0f502 100644 --- a/packages/core/src/manifest/validate.ts +++ b/packages/core/src/manifest/validate.ts @@ -187,12 +187,21 @@ function validateWorkspace(input: Record, errors: FieldError[]) /** Rules that need every repo at once; run only after the per-field ones pass. */ function crossFieldRepoErrors(repos: Array>): 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) { @@ -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" }); From 74a2d1de3b46b76b89a5350fce24cd77d639fd2a Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 15:39:42 +1000 Subject: [PATCH 05/18] test(core): consolidate the primary-repo rule into the repo-list tests 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) --- packages/core/src/manifest/validate.test.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/core/src/manifest/validate.test.ts b/packages/core/src/manifest/validate.test.ts index 79684ad..bdec3fb 100644 --- a/packages/core/src/manifest/validate.test.ts +++ b/packages/core/src/manifest/validate.test.ts @@ -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); @@ -66,9 +58,12 @@ describe("validate (empty repo list)", () => { if (r.ok) expect(r.manifest.repos).toEqual([]); }); - // The pair matters. Dropping the primary rule outright rather than making it - // conditional would also pass the case above, and would let a real multi-repo - // manifest through with no primary — which syncRepos has no cwd for. + // 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 syncRepos 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" } }, From a35a3ac55beae00f406f45259328a6a75221cf91 Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 15:42:02 +1000 Subject: [PATCH 06/18] feat(core): add resolveWorkingDirectory --- packages/core/src/index.ts | 1 + packages/core/src/task-run.test.ts | 29 ++++++++++++++++++++++++++++- packages/core/src/task-run.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0b2a294..8e29e22 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export { startControlServer, buildServer, createControlApp, defaultBootDeps } from "./control/server.js"; export { TaskRun, + resolveWorkingDirectory, type BootDeps, type BootAcceptance, type InitialiseResult, diff --git a/packages/core/src/task-run.test.ts b/packages/core/src/task-run.test.ts index 72241a8..01e37ca 100644 --- a/packages/core/src/task-run.test.ts +++ b/packages/core/src/task-run.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { deleteCredentialConfig, writeCredentialConfig } from "./creds/config.js"; -import { TaskRun, type BootDeps } from "./task-run.js"; +import { TaskRun, resolveWorkingDirectory, type BootDeps } from "./task-run.js"; import type { EngineAdapter, ServerHandle } from "./engine/adapter.js"; const handle: ServerHandle = { shutdown: vi.fn(async () => {}) }; @@ -470,3 +470,30 @@ describe("TaskRun.prepare credential wipe (real filesystem)", () => { expect(readdirSync(join(home, ".throng"))).toEqual([]); }); }); + +describe("resolveWorkingDirectory", () => { + const root = "/home/user/workspace"; + const manifest = (repos: Array<{ url: string; ref: string; dest: string; primary: boolean }>) => + ({ repos, credentials: null, github_token: null, setup_commands: [] }) as any; + + it("returns the primary repo's destination", () => { + const m = manifest([ + { url: "https://x/a", ref: "main", dest: "a", primary: false }, + { url: "https://x/b", ref: "main", dest: "b", primary: true }, + ]); + expect(resolveWorkingDirectory(m, root)).toBe("/home/user/workspace/b"); + }); + + it("returns the workspace root when there are no repos", () => { + expect(resolveWorkingDirectory(manifest([]), root)).toBe(root); + }); + + // Unreachable through the public API — validation demands exactly one primary + // for a non-empty list — but the silent failure it prevents is bad: an empty + // cwd makes runSetupCommands run in the process's own directory and report + // success. + it("throws when a non-empty list has no primary", () => { + const m = manifest([{ url: "https://x/a", ref: "main", dest: "a", primary: false }]); + expect(() => resolveWorkingDirectory(m, root)).toThrow(/no repo was marked primary/); + }); +}); diff --git a/packages/core/src/task-run.ts b/packages/core/src/task-run.ts index 5bdcfc4..c427ead 100644 --- a/packages/core/src/task-run.ts +++ b/packages/core/src/task-run.ts @@ -313,6 +313,34 @@ export class TaskRun { } } +/** + * Where the agent runs, and where `setup_commands` run: the primary repo's + * destination, or the workspace root when the manifest carries no repos. + * + * Separate from `syncRepos` — which only clones — because these are two + * questions, and only one of them has an answer that depends on the network + * having succeeded. Keeping the resolution pure also means the no-primary case + * below is a function contract rather than a loop invariant over a mutable + * accumulator, and it is testable without mocking git. + */ +export function resolveWorkingDirectory(manifest: WorkspaceManifest, workspaceRoot: string): string { + // An empty workspace is legal input: the agent's job may be to create the + // project. The workspace root is where cloned repos live, so a repository the + // agent creates there is in the layout a later task's manifest will expect. + if (manifest.repos.length === 0) return workspaceRoot; + + const primary = manifest.repos.find((r) => r.primary); + // Guards the seam rather than a reachable input: validation accepts exactly + // one primary for a non-empty list on both routes, so this cannot fire through + // the public API. It is here because the silent failure it prevents is bad — + // an empty working directory makes runSetupCommands run in the process's own + // working directory instead of the repo, and report success. + if (!primary) { + throw new StepError("cloning", "no repo was marked primary, so setup commands have nowhere to run"); + } + return join(workspaceRoot, primary.dest); +} + class StepError extends Error { constructor(readonly step: string, message: string) { super(message); From ce95669bd2b964dfe5d19bba259c8e8edc5564dc Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 15:45:35 +1000 Subject: [PATCH 07/18] test(core): type the resolveWorkingDirectory manifest helper 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) --- packages/core/src/task-run.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/core/src/task-run.test.ts b/packages/core/src/task-run.test.ts index 01e37ca..5289e85 100644 --- a/packages/core/src/task-run.test.ts +++ b/packages/core/src/task-run.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { deleteCredentialConfig, writeCredentialConfig } from "./creds/config.js"; import { TaskRun, resolveWorkingDirectory, type BootDeps } from "./task-run.js"; import type { EngineAdapter, ServerHandle } from "./engine/adapter.js"; +import type { RepoSpec, WorkspaceManifest } from "./manifest/types.js"; const handle: ServerHandle = { shutdown: vi.fn(async () => {}) }; @@ -473,8 +474,15 @@ describe("TaskRun.prepare credential wipe (real filesystem)", () => { describe("resolveWorkingDirectory", () => { const root = "/home/user/workspace"; - const manifest = (repos: Array<{ url: string; ref: string; dest: string; primary: boolean }>) => - ({ repos, credentials: null, github_token: null, setup_commands: [] }) as any; + // Typed rather than cast: if WorkspaceManifest gains a required field this + // stops compiling, which is the point. An `as any` here would keep building + // against a manifest shape the function no longer receives. + const manifest = (repos: RepoSpec[]): WorkspaceManifest => ({ + repos, + credentials: null, + github_token: null, + setup_commands: [], + }); it("returns the primary repo's destination", () => { const m = manifest([ From 0edb655a625aeee8f808b71cd12717da76b8109e Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 15:48:46 +1000 Subject: [PATCH 08/18] test(core): cover a nested dest in resolveWorkingDirectory 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) --- packages/core/src/task-run.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/core/src/task-run.test.ts b/packages/core/src/task-run.test.ts index 5289e85..9738dc1 100644 --- a/packages/core/src/task-run.test.ts +++ b/packages/core/src/task-run.test.ts @@ -496,6 +496,14 @@ describe("resolveWorkingDirectory", () => { expect(resolveWorkingDirectory(manifest([]), root)).toBe(root); }); + // validateRepos permits a dest with subdirectories — it rejects only absolute + // paths, ".." segments and anything resolving to the workspace root itself — + // so a nested dest is real input, not a hypothetical. + it("joins a nested dest under the workspace root", () => { + const m = manifest([{ url: "https://x/a", ref: "main", dest: "team/svc", primary: true }]); + expect(resolveWorkingDirectory(m, root)).toBe("/home/user/workspace/team/svc"); + }); + // Unreachable through the public API — validation demands exactly one primary // for a non-empty list — but the silent failure it prevents is bad: an empty // cwd makes runSetupCommands run in the process's own directory and report From dd203b2a6d8369cd287d73b6fff8794ccc7181fe Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 15:51:41 +1000 Subject: [PATCH 09/18] feat(core): create the workspace root via an injected ensureWorkspace 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. --- packages/core/src/control/server.test.ts | 1 + packages/core/src/control/server.ts | 2 ++ packages/core/src/task-run.test.ts | 23 +++++++++++++++++++++++ packages/core/src/task-run.ts | 17 +++++++++++++++++ 4 files changed, 43 insertions(+) diff --git a/packages/core/src/control/server.test.ts b/packages/core/src/control/server.test.ts index e8f44e8..949b490 100644 --- a/packages/core/src/control/server.test.ts +++ b/packages/core/src/control/server.test.ts @@ -11,6 +11,7 @@ const bootDeps: BootDeps = { writeCredentialConfig: () => {}, deleteCredentialConfig: () => {}, injectGitIdentity: () => {}, + ensureWorkspace: () => {}, workspaceRoot: "/home/user/workspace", }; const adapter: EngineAdapter = { diff --git a/packages/core/src/control/server.ts b/packages/core/src/control/server.ts index 4576ee6..5335039 100644 --- a/packages/core/src/control/server.ts +++ b/packages/core/src/control/server.ts @@ -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"; @@ -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"), diff --git a/packages/core/src/task-run.test.ts b/packages/core/src/task-run.test.ts index 9738dc1..d37b3c9 100644 --- a/packages/core/src/task-run.test.ts +++ b/packages/core/src/task-run.test.ts @@ -16,6 +16,7 @@ function deps(over: Partial = {}): BootDeps { writeCredentialConfig: vi.fn(() => {}), deleteCredentialConfig: vi.fn(() => {}), injectGitIdentity: vi.fn(() => {}), + ensureWorkspace: vi.fn(() => {}), workspaceRoot: "/home/user/workspace", ...over, }; @@ -83,6 +84,28 @@ describe("TaskRun", () => { await settle(); expect(tr.lifecycle.status().error?.step).toBe("plugins"); }); + + it("ensures the workspace root exists before cloning", async () => { + const d = deps(); + const tr = new TaskRun(d, { claude: adapter() }); + await tr.initialise(okPayload); + await settle(); + expect(d.ensureWorkspace).toHaveBeenCalledWith("/home/user/workspace"); + }); + + it("fails the boot when the workspace cannot be created", async () => { + const d = deps({ + ensureWorkspace: vi.fn(() => { + throw new Error("EACCES: permission denied"); + }), + }); + const tr = new TaskRun(d, { claude: adapter() }); + await tr.initialise(okPayload); + await settle(); + const status = tr.lifecycle.status(); + expect(status.state).toBe("failed"); + expect(d.syncOrClone).not.toHaveBeenCalled(); + }); }); describe("TaskRun credential ordering", () => { diff --git a/packages/core/src/task-run.ts b/packages/core/src/task-run.ts index c427ead..75b69fb 100644 --- a/packages/core/src/task-run.ts +++ b/packages/core/src/task-run.ts @@ -18,6 +18,15 @@ export interface BootDeps { * prepared workspace is snapshotted. */ deleteCredentialConfig: () => void; injectGitIdentity: (identity: UserIdentity) => void; + /** Creates `workspaceRoot` if it is absent. Recursive and idempotent. + * + * Needed because nothing else creates it: today the directory exists only as + * a side effect of `git clone` creating its destination's parents, so a + * manifest with no repos would point the agent at a path that is not there. + * Injected rather than called directly so the test suite's fake workspace + * root — a path that does not exist and cannot be created on a developer + * machine — stays inert. */ + ensureWorkspace: (dir: string) => void; workspaceRoot: string; } @@ -231,6 +240,14 @@ export class TaskRun { private async syncRepos(manifest: WorkspaceManifest, phase: Phase = "boot"): Promise { this.lifecycle.set("cloning"); log.info(`${phase} step: cloning repos`, { count: manifest.repos.length, workspace: this.deps.workspaceRoot }); + // Unconditional, not guarded on an empty repo list: it is idempotent where + // `git clone` would have created the directory anyway, and load-bearing + // where there are no repos to create it. + try { + this.deps.ensureWorkspace(this.deps.workspaceRoot); + } catch (err) { + throw new StepError("cloning", err instanceof Error ? err.message : String(err)); + } let primaryDest = ""; for (const repo of manifest.repos) { const dest = join(this.deps.workspaceRoot, repo.dest); From 89526dc4fa76c60c1223b9209c46b6f9011eaa10 Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 15:55:14 +1000 Subject: [PATCH 10/18] test(core): pin the workspace-creation failure to its step and message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- packages/core/src/task-run.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/task-run.test.ts b/packages/core/src/task-run.test.ts index d37b3c9..1f26ca0 100644 --- a/packages/core/src/task-run.test.ts +++ b/packages/core/src/task-run.test.ts @@ -104,6 +104,12 @@ describe("TaskRun", () => { await settle(); const status = tr.lifecycle.status(); expect(status.state).toBe("failed"); + // Pinned to the step and the message, not just "failed": `cloning` labels + // two different failures in syncRepos — this one and a git sync error — so + // without the message this would still pass if the mkdir throw were + // swallowed and the sync failed instead. + expect(status.error?.step).toBe("cloning"); + expect(status.error?.message).toContain("EACCES"); expect(d.syncOrClone).not.toHaveBeenCalled(); }); }); From 81d3e88cac59de6fa8e86432ea91aa34b8d4d19b Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 16:04:51 +1000 Subject: [PATCH 11/18] refactor(core): resolve the working directory outside the clone loop --- packages/core/src/task-run.test.ts | 22 +++++++++++++++++ packages/core/src/task-run.ts | 39 ++++++++++++------------------ 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/packages/core/src/task-run.test.ts b/packages/core/src/task-run.test.ts index 1f26ca0..6240317 100644 --- a/packages/core/src/task-run.test.ts +++ b/packages/core/src/task-run.test.ts @@ -112,6 +112,28 @@ describe("TaskRun", () => { expect(status.error?.message).toContain("EACCES"); expect(d.syncOrClone).not.toHaveBeenCalled(); }); + + it("resolves the working directory before cloning, not after", async () => { + const order: string[] = []; + const d = deps({ + ensureWorkspace: vi.fn(() => { + order.push("ensure"); + }), + syncOrClone: vi.fn(async () => { + order.push("clone"); + return { ok: true, output: "" }; + }), + runSetupCommands: vi.fn(async () => { + order.push("setup"); + return { ok: true }; + }), + }); + const tr = new TaskRun(d, { claude: adapter() }); + await tr.initialise({ ...okPayload, setup_commands: ["mise install"] }); + await settle(); + expect(order).toEqual(["ensure", "clone", "setup"]); + expect(d.runSetupCommands).toHaveBeenCalledWith("/home/user/workspace/y", ["mise install"]); + }); }); describe("TaskRun credential ordering", () => { diff --git a/packages/core/src/task-run.ts b/packages/core/src/task-run.ts index 75b69fb..7366ebd 100644 --- a/packages/core/src/task-run.ts +++ b/packages/core/src/task-run.ts @@ -134,8 +134,9 @@ export class TaskRun { // second copy of these methods: the shared call path is what keeps a // snapshot build and the task boot that restores from it from drifting. this.writeCredentials(manifest, "prepare"); - const primaryDest = await this.syncRepos(manifest, "prepare"); - await this.runSetup(manifest, primaryDest, "prepare"); + const workingDirectory = resolveWorkingDirectory(manifest, this.deps.workspaceRoot); + await this.syncRepos(manifest, "prepare"); + await this.runSetup(manifest, workingDirectory, "prepare"); // A security boundary, not tidiness — and specifically the DISK half of // one. Everything still on disk here is captured into an E2B-stored image @@ -196,16 +197,19 @@ export class TaskRun { private async boot(manifest: Manifest, adapter: EngineAdapter): Promise { try { this.writeCredentials(manifest); - const primaryDest = await this.syncRepos(manifest); - await this.runSetup(manifest, primaryDest); + // Before the clone, so a manifest with no primary repo fails having done + // nothing rather than after pulling every repo over the network. + const workingDirectory = resolveWorkingDirectory(manifest, this.deps.workspaceRoot); + await this.syncRepos(manifest); + await this.runSetup(manifest, workingDirectory); log.info("boot step: injecting engine credentials and commit identity"); adapter.injectCredentials(manifest); // Commit identity only. GitHub auth is no longer environment-based. this.deps.injectGitIdentity(manifest.user_identity); - const config = adapter.buildAgentConfig(manifest, primaryDest); - log.info("boot step: starting A2A server", { workingDirectory: primaryDest }); + const config = adapter.buildAgentConfig(manifest, workingDirectory); + log.info("boot step: starting A2A server", { workingDirectory }); try { this.serverHandle = await adapter.createA2AServer(config); } catch (err) { @@ -236,8 +240,9 @@ export class TaskRun { } } - /** Returns the primary repo's destination — where setup commands run. */ - private async syncRepos(manifest: WorkspaceManifest, phase: Phase = "boot"): Promise { + /** Clones or resyncs every repo in the manifest. The working directory is + * resolved separately, by resolveWorkingDirectory. */ + private async syncRepos(manifest: WorkspaceManifest, phase: Phase = "boot"): Promise { this.lifecycle.set("cloning"); log.info(`${phase} step: cloning repos`, { count: manifest.repos.length, workspace: this.deps.workspaceRoot }); // Unconditional, not guarded on an empty repo list: it is idempotent where @@ -248,7 +253,6 @@ export class TaskRun { } catch (err) { throw new StepError("cloning", err instanceof Error ? err.message : String(err)); } - let primaryDest = ""; for (const repo of manifest.repos) { const dest = join(this.deps.workspaceRoot, repo.dest); // `repos[].url` is whatever the caller sent, and the credential-in-URL @@ -277,27 +281,16 @@ export class TaskRun { ); } log.info("repo ready", { dest, ref: repo.ref }); - if (repo.primary) primaryDest = dest; } - // Guards the seam rather than a reachable input: validation accepts exactly - // one primary repo on every route today, so this cannot fire through the - // public API. It is here because the next caller of syncRepos evolves - // independently, and the silent failure it prevents is bad — an empty - // primaryDest makes runSetupCommands run in the process's working directory - // instead of the repo, and report success. - if (primaryDest === "") { - throw new StepError("cloning", "no repo was marked primary, so setup commands have nowhere to run"); - } - return primaryDest; } - private async runSetup(manifest: WorkspaceManifest, primaryDest: string, phase: Phase = "boot"): Promise { + private async runSetup(manifest: WorkspaceManifest, cwd: string, phase: Phase = "boot"): Promise { this.lifecycle.set("setup"); // Setup commands run WITH working git and gh, because the credential config // is already in place. See describeSetupFailure: their output is redacted // before it leaves this process. - log.info(`${phase} step: running setup commands`, { count: manifest.setup_commands.length, cwd: primaryDest }); - const setup = await this.deps.runSetupCommands(primaryDest, manifest.setup_commands); + log.info(`${phase} step: running setup commands`, { count: manifest.setup_commands.length, cwd }); + const setup = await this.deps.runSetupCommands(cwd, manifest.setup_commands); if (!setup.ok) { // `describeSetupFailure` carries the failing command, a decoded signal exit // (137 = OOM-killed, the common one) and the tail of its output — without From 13656b8029f919020554cfac21b4be707b278ab1 Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 16:10:49 +1000 Subject: [PATCH 12/18] refactor: rename buildAgentConfig's primaryDest to workingDirectory 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) --- packages/core/src/engine/adapter.ts | 8 ++++++-- packages/core/src/manifest/validate.test.ts | 4 ++-- throng-agent-claude/src/adapter.ts | 4 ++-- throng-agent-claude/src/config/build.ts | 5 +++-- throng-agent-codex/src/adapter.ts | 4 ++-- throng-agent-codex/src/config/build.ts | 5 +++-- 6 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/core/src/engine/adapter.ts b/packages/core/src/engine/adapter.ts index cc8953f..58a002d 100644 --- a/packages/core/src/engine/adapter.ts +++ b/packages/core/src/engine/adapter.ts @@ -31,8 +31,12 @@ export interface EngineAdapter { /** Inject the engine credential(s) into the process and run engine preflight. */ injectCredentials(manifest: Manifest): void; - /** Build the engine server config from the resolved manifest. */ - buildAgentConfig(manifest: Manifest, 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, workingDirectory: string): TConfig; /** Start the engine's A2A server. */ createA2AServer(config: TConfig): Promise; diff --git a/packages/core/src/manifest/validate.test.ts b/packages/core/src/manifest/validate.test.ts index bdec3fb..ef76358 100644 --- a/packages/core/src/manifest/validate.test.ts +++ b/packages/core/src/manifest/validate.test.ts @@ -61,8 +61,8 @@ describe("validate (empty repo list)", () => { // 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 syncRepos has no cwd - // for. Note this fires for a single-repo list too: `primary` names the + // 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( diff --git a/throng-agent-claude/src/adapter.ts b/throng-agent-claude/src/adapter.ts index 7cd4b64..b4dbc02 100644 --- a/throng-agent-claude/src/adapter.ts +++ b/throng-agent-claude/src/adapter.ts @@ -43,8 +43,8 @@ export class ClaudeEngineAdapter } } - buildAgentConfig(manifest: Manifest, primaryDest: string): Required { - return buildAgentConfig(manifest, primaryDest); + buildAgentConfig(manifest: Manifest, workingDirectory: string): Required { + return buildAgentConfig(manifest, workingDirectory); } async createA2AServer(config: Required): Promise { diff --git a/throng-agent-claude/src/config/build.ts b/throng-agent-claude/src/config/build.ts index f63c807..fa62889 100644 --- a/throng-agent-claude/src/config/build.ts +++ b/throng-agent-claude/src/config/build.ts @@ -5,8 +5,9 @@ import type { ResolvedClaudeAgent } from "../manifest/claude-agent.js"; /** * Builds a fully-defaulted a2a-claude config: a Throng base (name + full * host isolation + a deterministic bind) overlaid with the manifest's `agent` - * keys and the primary repo as the working directory. `resolveConfig(undefined, - * overrides)` applies a2a-claude's own DEFAULTS underneath our overrides. + * keys and the caller's working directory — the primary repo, or the workspace + * root when the manifest carries no repos. `resolveConfig(undefined, overrides)` + * applies a2a-claude's own DEFAULTS underneath our overrides. * * The `server` block is pinned explicitly (rather than left to a2a-claude's * env-var merge) so a container's HOSTNAME/PORT can never perturb the bind — diff --git a/throng-agent-codex/src/adapter.ts b/throng-agent-codex/src/adapter.ts index d4e2ffb..31db700 100644 --- a/throng-agent-codex/src/adapter.ts +++ b/throng-agent-codex/src/adapter.ts @@ -21,8 +21,8 @@ export class CodexEngineAdapter } } - buildAgentConfig(manifest: Manifest, primaryDest: string): Required { - return buildAgentConfig(manifest, primaryDest); + buildAgentConfig(manifest: Manifest, workingDirectory: string): Required { + return buildAgentConfig(manifest, workingDirectory); } async createA2AServer(config: Required): Promise { diff --git a/throng-agent-codex/src/config/build.ts b/throng-agent-codex/src/config/build.ts index bf423a5..df77193 100644 --- a/throng-agent-codex/src/config/build.ts +++ b/throng-agent-codex/src/config/build.ts @@ -5,8 +5,9 @@ import type { ResolvedCodexAgent } from "../manifest/codex-agent.js"; /** * Builds a fully-defaulted a2a-codex config: a Throng base (name + full host * isolation + a deterministic bind) overlaid with the manifest's `agent` keys - * and the primary repo as the working directory. `resolveConfig(undefined, - * overrides)` applies a2a-codex's own DEFAULTS underneath our overrides. + * and the caller's working directory — the primary repo, or the workspace root + * when the manifest carries no repos. `resolveConfig(undefined, overrides)` + * applies a2a-codex's own DEFAULTS underneath our overrides. * * The `server` block is pinned explicitly (rather than left to a2a-codex's * env-var merge) so a container's HOSTNAME/PORT can never perturb the bind — From 8e76bf7f53889d6b88a424709855ee1d755bb61c Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 16:20:14 +1000 Subject: [PATCH 13/18] refactor(core): restore the clone-before-setup guarantee in a signature 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) --- packages/core/src/task-run.test.ts | 6 +++- packages/core/src/task-run.ts | 45 ++++++++++++++++++++++++------ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/core/src/task-run.test.ts b/packages/core/src/task-run.test.ts index 6240317..c5e1eac 100644 --- a/packages/core/src/task-run.test.ts +++ b/packages/core/src/task-run.test.ts @@ -113,7 +113,11 @@ describe("TaskRun", () => { expect(d.syncOrClone).not.toHaveBeenCalled(); }); - it("resolves the working directory before cloning, not after", async () => { + // Named for what it can actually observe. With one always-resolvable primary + // repo the resolution is not separately visible — what this pins is that the + // three steps still happen in order and that setup gets the resolved cwd, + // which is the property the resolver extraction had to preserve. + it("ensures, clones, then runs setup in the resolved working directory", async () => { const order: string[] = []; const d = deps({ ensureWorkspace: vi.fn(() => { diff --git a/packages/core/src/task-run.ts b/packages/core/src/task-run.ts index 7366ebd..19f7c27 100644 --- a/packages/core/src/task-run.ts +++ b/packages/core/src/task-run.ts @@ -128,15 +128,17 @@ export class TaskRun { private async prepareWorkspace(manifest: WorkspaceManifest): Promise { try { - // Same three methods boot() calls, with only the log prefix differing — + // The same methods boot() calls, with only the log prefix differing — // "boot step: cloning repos" from a run that never boots an agent sends an // operator looking for the wrong thing. A defaulted parameter rather than a // second copy of these methods: the shared call path is what keeps a // snapshot build and the task boot that restores from it from drifting. + // + // The returned working directory is discarded here on purpose: a prepare + // builds a filesystem image and never starts an agent, so nothing after + // this point needs to know where one would have run. this.writeCredentials(manifest, "prepare"); - const workingDirectory = resolveWorkingDirectory(manifest, this.deps.workspaceRoot); - await this.syncRepos(manifest, "prepare"); - await this.runSetup(manifest, workingDirectory, "prepare"); + await this.materialiseWorkspace(manifest, "prepare"); // A security boundary, not tidiness — and specifically the DISK half of // one. Everything still on disk here is captured into an E2B-stored image @@ -197,11 +199,7 @@ export class TaskRun { private async boot(manifest: Manifest, adapter: EngineAdapter): Promise { try { this.writeCredentials(manifest); - // Before the clone, so a manifest with no primary repo fails having done - // nothing rather than after pulling every repo over the network. - const workingDirectory = resolveWorkingDirectory(manifest, this.deps.workspaceRoot); - await this.syncRepos(manifest); - await this.runSetup(manifest, workingDirectory); + const workingDirectory = await this.materialiseWorkspace(manifest); log.info("boot step: injecting engine credentials and commit identity"); adapter.injectCredentials(manifest); @@ -240,6 +238,35 @@ export class TaskRun { } } + /** + * Brings the workspace to the state an agent (or a snapshot) can be handed: + * decide where the agent runs, put the repos on disk, then run the setup + * commands there. Returns that working directory. + * + * One method rather than three calls at each of the two call sites, because + * the ORDER is a correctness property and this is the only place that has to + * be trusted to keep it. Setup commands must run after the clone — they are + * `mise install` and `npm ci` against a tree that has to exist — and until + * this refactor that was enforced by the type system: `runSetup` took the + * destination that only a completed `syncRepos` could return, so the two + * could not be reordered or interleaved without a compile error. Splitting + * resolution out removed that data dependency, and a comment is a weaker + * guarantee than a signature. Returning the directory from the method that + * also performs the steps restores it: a caller cannot obtain a working + * directory without having awaited the whole sequence. + * + * `phase` only labels the logs — a prepare run that never boots an agent must + * not emit "boot step: cloning repos" at an operator hunting a failure. + */ + private async materialiseWorkspace(manifest: WorkspaceManifest, phase: Phase = "boot"): Promise { + // Resolved before the clone, so a manifest with no primary repo fails having + // done nothing rather than after pulling every repo over the network. + const workingDirectory = resolveWorkingDirectory(manifest, this.deps.workspaceRoot); + await this.syncRepos(manifest, phase); + await this.runSetup(manifest, workingDirectory, phase); + return workingDirectory; + } + /** Clones or resyncs every repo in the manifest. The working directory is * resolved separately, by resolveWorkingDirectory. */ private async syncRepos(manifest: WorkspaceManifest, phase: Phase = "boot"): Promise { From b536e0f3bedb44f4fd839984276af660f880bf2c Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 16:22:40 +1000 Subject: [PATCH 14/18] test(core): cover zero-repo boot, prepare and routes --- packages/core/src/control/server.test.ts | 14 ++++++++++- packages/core/src/task-run.test.ts | 32 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/core/src/control/server.test.ts b/packages/core/src/control/server.test.ts index 949b490..05080d6 100644 --- a/packages/core/src/control/server.test.ts +++ b/packages/core/src/control/server.test.ts @@ -30,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); @@ -44,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"; diff --git a/packages/core/src/task-run.test.ts b/packages/core/src/task-run.test.ts index c5e1eac..5de6118 100644 --- a/packages/core/src/task-run.test.ts +++ b/packages/core/src/task-run.test.ts @@ -138,6 +138,38 @@ describe("TaskRun", () => { expect(order).toEqual(["ensure", "clone", "setup"]); expect(d.runSetupCommands).toHaveBeenCalledWith("/home/user/workspace/y", ["mise install"]); }); + + it("boots a zero-repo manifest and runs the agent in the workspace root", async () => { + const d = deps(); + const claude = adapter(); + const tr = new TaskRun(d, { claude }); + const r = await tr.initialise({ repos: [], agent: { platform: "claude" } }); + expect(r).toEqual({ ok: true, status: "booting" }); + await settle(); + expect(tr.lifecycle.status().state).toBe("ready"); + expect(d.syncOrClone).not.toHaveBeenCalled(); + expect(d.ensureWorkspace).toHaveBeenCalledWith("/home/user/workspace"); + expect(claude.buildAgentConfig).toHaveBeenCalledWith(expect.anything(), "/home/user/workspace"); + }); + + it("runs a zero-repo manifest's setup commands in the workspace root", async () => { + const d = deps(); + const tr = new TaskRun(d, { claude: adapter() }); + await tr.initialise({ repos: [], agent: { platform: "claude" }, setup_commands: ["mise install"] }); + await settle(); + expect(d.runSetupCommands).toHaveBeenCalledWith("/home/user/workspace", ["mise install"]); + }); + + it("prepares a zero-repo manifest and still wipes credentials", async () => { + const d = deps(); + const tr = new TaskRun(d, { claude: adapter() }); + const r = await tr.prepare({ repos: [], setup_commands: ["mise install"] }); + expect(r).toEqual({ ok: true, status: "booting" }); + await settle(); + expect(tr.lifecycle.status().state).toBe("prepared"); + expect(d.runSetupCommands).toHaveBeenCalledWith("/home/user/workspace", ["mise install"]); + expect(d.deleteCredentialConfig).toHaveBeenCalled(); + }); }); describe("TaskRun credential ordering", () => { From 910aac5db3addce8573bc190cdc860b164a64a95 Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 16:30:28 +1000 Subject: [PATCH 15/18] test(core): explain what the zero-repo tests pin, and why each is separate 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) --- packages/core/src/task-run.test.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/core/src/task-run.test.ts b/packages/core/src/task-run.test.ts index 5de6118..11a49f8 100644 --- a/packages/core/src/task-run.test.ts +++ b/packages/core/src/task-run.test.ts @@ -139,6 +139,13 @@ describe("TaskRun", () => { expect(d.runSetupCommands).toHaveBeenCalledWith("/home/user/workspace/y", ["mise install"]); }); + // The whole point of the feature: an agent asked to create a project from + // scratch has nothing to clone. `ensureWorkspace` is re-asserted here even + // though the one-repo test above already covers it, because this is the case + // BootDeps.ensureWorkspace exists for — with no repos nothing else creates + // the directory, so a future `if (repos.length > 0)` around that call would + // point the agent at a path that is not there and only this test would catch + // it. `syncOrClone` not being called is what proves no repo work happened. it("boots a zero-repo manifest and runs the agent in the workspace root", async () => { const d = deps(); const claude = adapter(); @@ -152,6 +159,11 @@ describe("TaskRun", () => { expect(claude.buildAgentConfig).toHaveBeenCalledWith(expect.anything(), "/home/user/workspace"); }); + // Separate from the test above because it pins a different property: not that + // the agent starts, but that setup commands get a cwd at all. This is the + // failure resolveWorkingDirectory's no-primary guard describes — an unresolved + // working directory makes runSetupCommands run wherever the process happens to + // be and report success, so "which directory" is the assertion that matters. it("runs a zero-repo manifest's setup commands in the workspace root", async () => { const d = deps(); const tr = new TaskRun(d, { claude: adapter() }); @@ -160,7 +172,16 @@ describe("TaskRun", () => { expect(d.runSetupCommands).toHaveBeenCalledWith("/home/user/workspace", ["mise install"]); }); - it("prepares a zero-repo manifest and still wipes credentials", async () => { + // A zero-repo prepare is the reason the empty list is legal on that route too: + // warming a toolchain cache into a snapshot from setup_commands alone. Both + // routes reach the new empty-repos branch through the shared + // materialiseWorkspace, so neither the cwd nor the credential wipe can be + // assumed safe from the boot-side tests. The wipe is a security boundary — a + // snapshot with a live credential on its filesystem is worse than no snapshot + // — and `prepared` is only reachable from the success path, so the two + // assertions together prove the success-path wipe rather than the catch-block + // one. + it("prepares a zero-repo manifest, running setup in the workspace root, and still wipes credentials", async () => { const d = deps(); const tr = new TaskRun(d, { claude: adapter() }); const r = await tr.prepare({ repos: [], setup_commands: ["mise install"] }); From 3a58e8a6e2be64e853088292a73950a7feddc88f Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 16:33:06 +1000 Subject: [PATCH 16/18] chore: changeset for zero-repo manifests --- .changeset/zero-repo-manifests.md | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .changeset/zero-repo-manifests.md diff --git a/.changeset/zero-repo-manifests.md b/.changeset/zero-repo-manifests.md new file mode 100644 index 0000000..3ecab84 --- /dev/null +++ b/.changeset/zero-repo-manifests.md @@ -0,0 +1,45 @@ +--- +"@throng/agent-core": minor +--- + +Accept an empty `repos` list on `/api/initialise` and `/api/prepare`, so a task can boot into an empty +workspace. Whether a task needs a repository is the consumer's decision: an agent asked to create a new +project from scratch — scaffold it, `git init` it, create the remote — has nothing to clone, and until +now could not be booted at all. + +`repos` remains a required key and must still be a list; only emptiness becomes legal. An absent `repos` +is still `{"repos", "is required"}`, so a control plane that drops the field fails loudly rather than +silently booting an agent with nothing checked out. The exactly-one-`primary` rule now applies only to a +non-empty list — with no repos there is nothing for it to choose between, though one repo must still +claim it, because `primary` is what names the directory the agent runs in. Both routes change together, +because `validate` and `validatePrepare` share every repo rule precisely so a snapshot build and the +task boot that restores from it cannot disagree about what a repo list means; a zero-repo prepare is a +legitimate way to warm a toolchain cache into a snapshot from `setup_commands` alone. + +With no repos, the agent's working directory and the `setup_commands` cwd are the workspace root +(`$HOME/workspace`, or `WORKSPACE_DIR`). A repository the agent creates there lands in the same layout a +later task's manifest expects. For Claude, `settingSources: ["project"]` then finds no `CLAUDE.md` and no +`.claude/settings.json`, because there is no project yet. + +Working-directory resolution moves out of the clone loop into a new exported +`resolveWorkingDirectory(manifest, workspaceRoot)`, which returns the primary repo's destination or the +workspace root. It also now runs *before* the clone rather than after it, so a non-empty manifest with no +primary repo fails having done nothing instead of after fetching every repo over the network. No +reachable input changes: validation rejects such a manifest on both routes. `syncRepos` correspondingly +becomes clone-only and returns nothing. + +Nothing else relaxes. `repos[].dest` still rejects `"."`, the `credentials` and `github_token` rules are +untouched, and a non-array `repos` is still a `400`. + +**Breaking (consumers constructing `BootDeps` directly):** `BootDeps` gains a required +`ensureWorkspace: (dir: string) => void`, called unconditionally before the clone loop. +`defaultBootDeps()` supplies `mkdirSync(dir, { recursive: true })`. It is needed because nothing else +creates the workspace root — the directory 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. It is a dep rather +than a direct `mkdirSync` so a caller can point the runtime at a workspace it manages itself, and so the +test suite's fake workspace root stays inert. + +`EngineAdapter.buildAgentConfig`'s second parameter is renamed `primaryDest` -> `workingDirectory`, in +the interface and in both bundled engine adapters. Not a breaking change — TypeScript compares +signatures structurally and ignores parameter names — but the old name now asserts something untrue: it +receives the workspace root when the manifest carries no repos, so it is not necessarily a repository. From 88783e4780a392b6049042ad861c1e55e5548a03 Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Mon, 24 Aug 2026 16:41:26 +1000 Subject: [PATCH 17/18] fix(throng-agent): give the integration boot test its ensureWorkspace fake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../plans/2026-08-24-zero-repo-manifests.md | 11 ++++++++--- throng-agent/src/integration/boot.test.ts | 12 +++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-08-24-zero-repo-manifests.md b/docs/superpowers/plans/2026-08-24-zero-repo-manifests.md index 28378b3..34161b8 100644 --- a/docs/superpowers/plans/2026-08-24-zero-repo-manifests.md +++ b/docs/superpowers/plans/2026-08-24-zero-repo-manifests.md @@ -433,7 +433,12 @@ Expected: PASS, whole file. - [ ] **Step 7: Fix the other `BootDeps` construction site** -`ensureWorkspace` is a required member, so every place that builds a `BootDeps` literal now fails to compile. There is one besides the test factory in Step 1: `packages/core/src/control/server.test.ts:8`. Add the member to it so the literal reads: +`ensureWorkspace` is a required member, so every place that builds a `BootDeps` literal must gain it. There are TWO besides the test factory in Step 1: + +- `packages/core/src/control/server.test.ts:8` +- `throng-agent/src/integration/boot.test.ts` — easy to miss, and typecheck will NOT catch it: `throng-agent/tsconfig.json` excludes `src/**/*.test.ts`. The boot there fails at runtime with `this.deps.ensureWorkspace is not a function`, which that test's loose assertion swallows as a pass. + +Add `ensureWorkspace: vi.fn(() => {})` to both. For `server.test.ts:8` the literal reads: ```typescript const bootDeps: BootDeps = { @@ -451,10 +456,10 @@ Then confirm there are no others: ```bash npm run typecheck -grep -rn "BootDeps" --include="*.ts" packages throng-agent-claude throng-agent-codex | grep -v node_modules +grep -rln "BootDeps" --include="*.ts" packages throng-agent throng-agent-claude throng-agent-codex | grep -v node_modules ``` -Expected: typecheck PASSes. If it still reports a missing `ensureWorkspace` somewhere, add `ensureWorkspace: () => {}` there too and re-run. +Note `throng-agent` in that list. Omitting it is exactly how the `boot.test.ts` site gets missed, and typecheck does not cover it. Expected: typecheck PASSes and every `BootDeps` literal the grep surfaces has the new member. Do not rely on typecheck alone. - [ ] **Step 8: Commit** diff --git a/throng-agent/src/integration/boot.test.ts b/throng-agent/src/integration/boot.test.ts index 7a0adf2..4513e77 100644 --- a/throng-agent/src/integration/boot.test.ts +++ b/throng-agent/src/integration/boot.test.ts @@ -27,6 +27,7 @@ function fakeDeps(): BootDeps { writeCredentialConfig: vi.fn(() => {}), deleteCredentialConfig: vi.fn(() => {}), injectGitIdentity: vi.fn(() => {}), + ensureWorkspace: vi.fn(() => {}), workspaceRoot: "/home/user/workspace", }; } @@ -74,10 +75,15 @@ describe("throng-agent boot routing", () => { await settle(); const state = tr.lifecycle.status().state; expect(["setup", "ready", "failed"]).toContain(state); - // If it reached "failed", it must be an engine/agent step (real SDK), never a - // routing/validation problem. + // If it reached "failed", it must be the real SDK's doing — never a step this + // test fakes. `not.toBe("boot")` used to stand here and was vacuous: "boot" + // is not a value StepError ever carries, so the guard passed for every + // failure, including ones the fakes are supposed to make impossible. It hid a + // real bug — when BootDeps gained `ensureWorkspace` and this fake was not + // updated, every boot died at "cloning" with "not a function" and the test + // still reported green. Naming the steps the fakes own is what gives it teeth. if (state === "failed") { - expect(tr.lifecycle.status().error?.step).not.toBe("boot"); + expect(["credentials", "cloning", "setup"]).not.toContain(tr.lifecycle.status().error?.step); } }); }); From d1f90c827fc340dc07570cf0ac5067332343d777 Mon Sep 17 00:00:00 2001 From: Colin Harris Date: Tue, 25 Aug 2026 16:03:19 +1000 Subject: [PATCH 18/18] chore: trim the zero-repo changeset to what a consumer needs 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) --- .changeset/zero-repo-manifests.md | 44 ++++++------------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/.changeset/zero-repo-manifests.md b/.changeset/zero-repo-manifests.md index 3ecab84..1f5c9fc 100644 --- a/.changeset/zero-repo-manifests.md +++ b/.changeset/zero-repo-manifests.md @@ -3,43 +3,15 @@ --- Accept an empty `repos` list on `/api/initialise` and `/api/prepare`, so a task can boot into an empty -workspace. Whether a task needs a repository is the consumer's decision: an agent asked to create a new -project from scratch — scaffold it, `git init` it, create the remote — has nothing to clone, and until -now could not be booted at all. +workspace — an agent asked to create a new project from scratch has nothing to clone. -`repos` remains a required key and must still be a list; only emptiness becomes legal. An absent `repos` -is still `{"repos", "is required"}`, so a control plane that drops the field fails loudly rather than -silently booting an agent with nothing checked out. The exactly-one-`primary` rule now applies only to a -non-empty list — with no repos there is nothing for it to choose between, though one repo must still -claim it, because `primary` is what names the directory the agent runs in. Both routes change together, -because `validate` and `validatePrepare` share every repo rule precisely so a snapshot build and the -task boot that restores from it cannot disagree about what a repo list means; a zero-repo prepare is a -legitimate way to warm a toolchain cache into a snapshot from `setup_commands` alone. +`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`). -With no repos, the agent's working directory and the `setup_commands` cwd are the workspace root -(`$HOME/workspace`, or `WORKSPACE_DIR`). A repository the agent creates there lands in the same layout a -later task's manifest expects. For Claude, `settingSources: ["project"]` then finds no `CLAUDE.md` and no -`.claude/settings.json`, because there is no project yet. - -Working-directory resolution moves out of the clone loop into a new exported -`resolveWorkingDirectory(manifest, workspaceRoot)`, which returns the primary repo's destination or the -workspace root. It also now runs *before* the clone rather than after it, so a non-empty manifest with no -primary repo fails having done nothing instead of after fetching every repo over the network. No -reachable input changes: validation rejects such a manifest on both routes. `syncRepos` correspondingly -becomes clone-only and returns nothing. - -Nothing else relaxes. `repos[].dest` still rejects `"."`, the `credentials` and `github_token` rules are -untouched, and a non-array `repos` is still a `400`. +New export: `resolveWorkingDirectory(manifest, workspaceRoot)`. **Breaking (consumers constructing `BootDeps` directly):** `BootDeps` gains a required -`ensureWorkspace: (dir: string) => void`, called unconditionally before the clone loop. -`defaultBootDeps()` supplies `mkdirSync(dir, { recursive: true })`. It is needed because nothing else -creates the workspace root — the directory 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. It is a dep rather -than a direct `mkdirSync` so a caller can point the runtime at a workspace it manages itself, and so the -test suite's fake workspace root stays inert. - -`EngineAdapter.buildAgentConfig`'s second parameter is renamed `primaryDest` -> `workingDirectory`, in -the interface and in both bundled engine adapters. Not a breaking change — TypeScript compares -signatures structurally and ignores parameter names — but the old name now asserts something untrue: it -receives the workspace root when the manifest carries no repos, so it is not necessarily a repository. +`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.