diff --git a/.gitignore b/.gitignore
index 1ed6282a..e94bdd56 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,7 +35,6 @@ dist-ssr
bun.lock
.notes.md
.brunch/
-.cook/
brunch.db*
todo.txt
diff --git a/docs/design/orchestrator.md b/docs/design/orchestrator.md
index 1f42acb5..f298e959 100644
--- a/docs/design/orchestrator.md
+++ b/docs/design/orchestrator.md
@@ -221,9 +221,9 @@ Cook decides between **fixture mode** (greenfield) and **codebase mode** (brownf
| Plan location | Mode | Worktree behavior | POC status |
|---|---|---|---|
| `
/plan.yaml` | Fixture (greenfield) | Empty worktree | Implemented |
-| `/.cook/plan.yaml` | Codebase (brownfield) | Worktree seeded from `` | Reserved; seed implementation deferred |
+| `/.brunch/cook/plan.yaml` | Codebase (brownfield) | Worktree seeded from `` | Reserved; seed implementation deferred |
-Naming intuition: a **fixture** *is* a plan with supporting artifacts (`plan.yaml` at root, like a manifest); a **codebase** *has* a plan as configuration (`.cook/plan.yaml`, like `.eslintrc` or `.github/`).
+Naming intuition: a **fixture** *is* a plan with supporting artifacts (`plan.yaml` at root, like a manifest); a **codebase** *has* a plan as configuration (`.brunch/cook/plan.yaml`, alongside other brunch workspace state).
The plan may declare `mode: greenfield | brownfield` to override the default inferred from location.
@@ -231,13 +231,13 @@ POC implements fixture mode end-to-end; codebase mode returns a structured "not
## 8. Worktree isolation
-Each run gets an isolated worktree at `/.cook/runs//worktree/`, where `` is the directory the user invoked `brunch cook` from (not the fixture/plan directory). Reports land alongside at `/.cook/runs//reports.jsonl`. Agents write freely inside the worktree; the fixture directory (``) and the invoking repo are never mutated. No commits, no pushes. Recovery = throw the worktree away and start a new run.
+Each run gets an isolated worktree at `/.brunch/cook/runs//worktree/`, where `` is the directory the user invoked `brunch cook` from (not the fixture/plan directory). Reports land alongside at `/.brunch/cook/runs//reports.jsonl`. Agents write freely inside the worktree; the fixture directory (``) and the invoking repo are never mutated. No commits, no pushes. Recovery = throw the worktree away and start a new run.
The run location is cwd-scoped rather than fixture-scoped so that:
- **Fixtures stay pristine.** Checked-in fixture directories (e.g. `fixtures/txt/`) contain only `plan.yaml` and are byte-identical before and after a run.
- **No path traversal.** Because the worktree is not a descendant of the fixture dir, agents cannot accidentally read or write fixture-level files.
-- **Easy cleanup.** `rm -rf .cook/runs/` in the invoking directory clears all run history. `.cook/` is gitignored at the repo level.
+- **Easy cleanup.** `rm -rf .brunch/cook/runs/` in the invoking directory clears all run history. `.brunch/` is gitignored at the repo level.
`--worktree ` overrides the default location for explicit pinning.
@@ -284,8 +284,8 @@ The design above is the target shape. The POC builds a deliberate subset and def
| Design element | Full design | POC posture |
|---|---|---|
| **Action dispatch** | `ActionRegistry` registers handlers by name; engines look up by name; new actions (e.g. `lint`, `human-review`, `research`) register without engine surgery. | Inline handler dispatch per engine (e.g. a record literal or switch). Promote to a real registry when a 3rd action type lands. |
-| **Plan resolver** | Dual-mode by plan location: `/plan.yaml` → fixture (greenfield); `/.cook/plan.yaml` → codebase (brownfield). | Fixture mode only. CLI takes `` directly; codebase branch is documented here, not coded. |
-| **Brownfield seed** | When codebase mode is used and `/.git` exists, prefer `git worktree add`; otherwise filtered copy (`rsync` excluding `.git`, `node_modules`, `dist`, `.cook/runs/`). | Not implemented. Greenfield-only execution; `mkdir` creates an empty worktree. |
+| **Plan resolver** | Dual-mode by plan location: `/plan.yaml` → fixture (greenfield); `/.brunch/cook/plan.yaml` → codebase (brownfield). | Fixture mode only. CLI takes `` directly; codebase branch is documented here, not coded. |
+| **Brownfield seed** | When codebase mode is used and `/.git` exists, prefer `git worktree add`; otherwise filtered copy (`rsync` excluding `.git`, `node_modules`, `dist`, `.brunch/cook/runs/`). | Not implemented. Greenfield-only execution; `mkdir` creates an empty worktree. |
| **Token-pointer discipline** | Universal rule: tokens between transitions carry only `{ reportId, sliceId, epicId }` pointers; all event content lives in `reports.jsonl`. Applied across both engines. | Petrinet engine enforces this internally (it's a hard constraint of the substrate). Procedural engine is free to pass data through normal function calls — each engine handles its own state shape, the shared seam is just inputs and outputs. |
| **Layer 2 adapter tests** | Per-engine internal tests (net compilation / solver / transition firing for petrinet; topo sort / inner-loop state transitions / retry counter for procedural). | Optional. Defer until a debugging need surfaces. Layer 1 (contract) + Layer 3 (integration) are mandatory; Layer 2 is added if and when it pays for itself. |
| **Streaming UX formatting** | Compact per-event lines like `[slice-1 ▸ test-writer] tests-written → 3 files`. | Implemented: elapsed timing, icons (▸/✓/✗/●/○), structured header/footer, `--verbose` for raw pi output. JSON stays in `reports.jsonl` only. |
@@ -315,4 +315,4 @@ Full comparison table in the POC summary doc.
| **report** | One structured event line in `reports.jsonl`. Carries the durable content; tokens carry only pointers to reports. |
| **worktree** | Isolated filesystem location where agents write during a run. Per-run; ephemeral. |
| **fixture mode** | Greenfield execution: plan at `/plan.yaml`, empty worktree. POC default. |
-| **codebase mode** | Brownfield execution: plan at `/.cook/plan.yaml`, worktree seeded from ``. Reserved, not implemented in POC. |
+| **codebase mode** | Brownfield execution: plan at `/.brunch/cook/plan.yaml`, worktree seeded from ``. Reserved, not implemented in POC. |
diff --git a/docs/praxis/orchestration-guide.md b/docs/praxis/orchestration-guide.md
new file mode 100644
index 00000000..002c022e
--- /dev/null
+++ b/docs/praxis/orchestration-guide.md
@@ -0,0 +1,123 @@
+# Orchestration guide — cook on brunch
+
+Run `brunch cook` against the brunch repo in codebase (brownfield) mode.
+
+## Pre-flight
+
+```sh
+which pi && pi --version # pi >= 0.74
+npm run build # dist/ fresh
+git status --porcelain --untracked-files=no # must be empty
+```
+
+`.brunch/` is already gitignored, so cook artifacts won't appear in `git status`.
+
+## Author the plan
+
+Cook reads ONE file: `.brunch/cook/plan.yaml`.
+
+**Target shape: read it from a spec-graph projection.** The intended long-term path is `petri-graph-compilation` (blocked on `intent-graph-semantics` / FE-700): cook compiles its net directly from workspace plan-graph nodes + relation policy, no `plan.yaml` step at all. The plan-graph projection becomes the source of truth; `.brunch/cook/plan.yaml` either disappears or becomes a derived artifact emitted by the compiler.
+
+**Not done yet.** Until `petri-graph-compilation` lands, the bridge from spec/frontier to cook plan is manual. Two interim mechanisms:
+
+### A. `/ln-scope`-then-translate (most disciplined interim)
+
+Run `/ln-scope` on a `memory/PLAN.md` frontier to produce a scope card (Target Behavior + Acceptance + Verification). Translate the scope card to YAML by hand — usually 15–30 lines, 2–5 minutes. The scope card is the human-readable contract you can verify before spending pi tokens.
+
+### B. One-shot pi translation (cheap interim)
+
+Extract the frontier section and ask pi for YAML:
+
+```sh
+FRONTIER=""
+awk "/^### $FRONTIER\$/,/^### /" memory/PLAN.md | head -n -1 > /tmp/f.md
+pi -p --no-session --provider anthropic --model claude-haiku-4-5 \
+ --tools "read,write" \
+ "Translate /tmp/f.md into .brunch/cook/plan.yaml. One epic, one slice per
+ Acceptance line (max 2). Each slice needs a verification.target pointing
+ at a real bun-test file. Definitions name exact file + change + constraint.
+ Output only YAML." > .brunch/cook/plan.yaml
+```
+
+Always review — pi hallucinates file paths.
+
+### C. Hand-author (escape hatch)
+
+For one-off experiments or when no frontier exists:
+
+```yaml
+epics:
+ - id:
+ summary:
+ depends_on: []
+ verification: []
+
+slices:
+ - id:
+ epic_id:
+ definition: |
+ Modify `` in ``:
+ -
+ -
+ Do not modify .
+ depends_on: []
+ verification:
+ - kind: unit-test
+ target:
+```
+
+### Discipline (applies to all three)
+
+- Every slice needs a real `verification.target` (an existing test file) or `bun test` halts with no output → retry exhaustion.
+- Definitions name exact file + exact change + exact constraint. Vague slices halt or short-circuit.
+- 1–2 slices per run; more triggers more disk usage even with CoW.
+
+## Cook
+
+```sh
+node --env-file=.env bin/brunch.js cook "$(pwd)" --policy=serial --max-retries=1
+```
+
+`"$(pwd)"` (absolute path) is required — relative `.` resolves against brunch's packageRoot in the spawned CLI, not your shell pwd.
+
+## Inspect
+
+```sh
+RUN=$(ls -t .brunch/cook/runs/ | head -1)
+
+# Source byte-identical (brownfield invariant)
+git diff HEAD --stat # empty
+git status --porcelain --untracked-files=no # empty
+
+# Modification lives in the slice worktree, not on the cook branch as a commit
+diff -r src/ ".brunch/cook/runs/$RUN/worktree//src/" | head
+cat ".brunch/cook/runs/$RUN/reports.jsonl"
+```
+
+## Promote (manual)
+
+```sh
+cp -R ".brunch/cook/runs/$RUN/worktree/__epic__//." .
+git status # review and commit normally
+```
+
+No automatic `git merge cook/` yet — that's the deferred `cook-artifact-lifecycle` frontier.
+
+## Cleanup
+
+```sh
+RUN_ID=$(basename "$(ls -td .brunch/cook/runs/*/ | head -1)")
+git worktree remove --force ".brunch/cook/runs/$RUN_ID/worktree"
+git branch -D "cook/$RUN_ID"
+git branch --list "cook-slice/$RUN_ID/*" | xargs -n1 git branch -D
+rm -rf ".brunch/cook/runs/$RUN_ID"
+rm -f .brunch/cook/plan.yaml
+```
+
+Periodic stragglers: `git worktree prune` + `git branch --list 'cook*' | xargs -n1 git branch -D`.
+
+## Known limitations
+
+- **Pi evaluator may short-circuit.** Pi has `read,write,edit,bash` even during `evaluate-done` and may fix the file during evaluation rather than going through write-tests → write-code → run-tests. Non-deterministic.
+- **No commit on the cook branch.** Modification is in untracked subdirs of the cook branch's worktree, not committed. Promotion is manual `cp -R`.
+- **Plan vs frontier mismatch.** `.brunch/cook/plan.yaml` is orchestrator runtime, not planning vocabulary. `/ln-scope` or pi-assisted translation is the bridge.
diff --git a/memory/PLAN.md b/memory/PLAN.md
index 1206cc7d..58ca61a7 100644
--- a/memory/PLAN.md
+++ b/memory/PLAN.md
@@ -30,10 +30,16 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen
### Recently Completed
+- `cook-codebase-mode` — brownfield resolver + git-worktree-based sandbox init for `brunch cook `. Slice 1 consolidated paths under `.brunch/cook/`; slice 2 implemented the resolver + clean-tree gate + parent `git worktree add` + per-slice parent-population (file-copy with `.git` / sibling-slice / `__epic__/` exclusion). Slice 3 refactored per-slice population into a **hybrid mechanism**: tracked content arrives via real `git worktree add` on a slice-level branch (`cook-slice//`, sibling namespace to avoid ref-hierarchy collision with the parent `cook/` branch); untracked/gitignored content (`node_modules/`, `dist/`, etc.) arrives via CoW copy (`cp -c` on macOS APFS, `cp --reflink=auto` on Linux btrfs/xfs/zfs, `cpSync` fallback). Solves the over-copy problem (~90% disk savings on CoW filesystems) while preserving runtime-deps presence so pi-actions can run `npm test`/`bun test` against the slice worktree. Verified by 2026-05-26 outer-loop smoke against a tmpdir git repo + real pi (source-byte-identical isolation, in-place file modification). Two follow-on findings remain.
- `petri-declarative-routing` (FE-747) — `HandlerDescriptor` branching transitions now carry typed `Guard` predicates (`always`, `reportFieldTruthy`); `wireHandlers` consumes them via `evalGuard`; new `enumerateCandidateOutputs(transition)` exposes the topology-derived output-place set per transition. Establishes I125-K. Structural prerequisite for `petri-simulation-oracle` (Phase 4) and any static analysis; FE-700-independent. Halt paths and token transforms remain runtime concerns (separate follow-on slices). Follows FE-745.
- `petri-epic-verification-merge` — `verify-epic` now runs against a freshly-merged `/__epic__//` built from completed slice worktrees (declaration-order wins on path collisions; conflicts surfaced via `epic-sandbox-merged` event). Unblocks multi-slice `cook` runs. Follows FE-743.
- `petri-parallel-execution` (FE-743) — parallel firing policy, shared resource pool tokens, worktree-per-slice isolation. Decision gate passed: parallel measurably beats serial on wall clock for multi-slice plans. Follows `petri-semantic-lanes` (FE-738).
+#### Follow-ons surfaced by the 2026-05-26 cook-codebase-mode smoke
+
+- **pi-actions evaluate-done collapses the TDD workflow** — `pi-actions.ts:70` passes `--tools read,write,edit,bash` to every action including `evaluate-done`. Real pi fixed the buggy file *during evaluation* and reported `done: true` on the first call; write-tests / write-code / run-tests never executed. Affects both modes but is more visible in brownfield. Either restrict evaluator tools to `read` or accept this as the intended pi-as-agent behavior. Worth its own frontier.
+- **`cook-artifact-lifecycle` frontier (proposed, not yet authored)** — slice 3's hybrid mechanism creates real slice branches (`cook-slice//`) but never commits to them; the cook branch (`cook/`) still has HEAD === source HEAD and the modification lives in untracked subdirs of the cook branch's working tree. To close the loop: (a) commit slice work to the slice branch on slice completion, (b) replace `mergeSlicesIntoEpicSandbox`'s file-copy with `git merge` of slice branches into an epic branch surfacing real conflicts (today's file-copy is silent last-slice-wins), (c) merge epic branches back to `cook/` so `git merge cook/` from main becomes the promotion path. Pairs with worktree + branch GC story. ~2-3 days of structural work; slice 3 set up the substrate (real branches per slice) so this frontier can land cleanly on top.
+
### Next
1. `intent-graph-semantics` — highest-coordination semantic substrate after FE-705 reconciliation.
@@ -137,6 +143,29 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen
- **Traceability:** Requirements 46, 47, 48; D156-K (Phase-3-prep refinement of FE-738 HandlerDescriptor design); candidate new invariant on build: "Topology output-place candidates are fully declared in `HandlerDescriptor`; `wireHandlers` introduces no new output places at fire time."
- **Design docs:** `docs/next/architecture/plan-graph-petri-orchestration.md` §6 (transition contracts), §10 (prototypes); umbrella H-6476.
+### cook-codebase-mode
+
+- **Name:** Cook codebase-mode — brownfield resolver for `brunch cook` against an existing repo
+- **Linear:** unassigned in this plan snapshot
+- **Kind:** structural
+- **Status:** done (2026-05-26) — slice 1 (path consolidation) + slice 2 (resolver + git worktree + per-slice population) shipped; outer-loop smoke with real pi confirmed in-place file modification and source-byte-identical isolation. Three follow-on findings tracked in `Recently Completed` rather than re-opening this frontier.
+- **Objective:** Implement the SPEC §D50 reserved dual-mode resolver. When `/.brunch/cook/plan.yaml` exists, `brunch cook ` loads that plan and runs slices against a worktree initialized from the cwd repo (modifying existing code) rather than an empty worktree (generating from scratch). The source branch in `` remains untouched; agent commits live on a per-run cook branch the user can review or discard. Existing greenfield fixture-mode path stays unchanged.
+- **Why now / unlocks:** Today brunch's orchestrator only runs on greenfield fixtures — pi-actions generate code from scratch in fresh worktrees. Real software work is brownfield: agents modify existing code. Without codebase mode, `cook` cannot operate on a user's actual project, so the orchestrator stays a fixture-only substrate even though Petri Phases 0–2 are committed and FE-747 declarative routing has landed. Codebase mode is the smallest step from "orchestrator-as-substrate" to "orchestrator-as-product."
+- **Adoption decision (2026-05-26):** **Build native now.** Extend brunch's existing `worktree.ts` + `epic-sandbox-merge.ts` to support codebase mode using direct `git worktree add` calls; keep `pi-actions.ts` `spawnSync('pi', ...)` as-is. Sandcastle adoption is **deferred** — see "Future direction" below.
+- **Open design questions (resolve during scope):**
+ - **Clean working tree gate:** Refuse to brownfield-run if `` has uncommitted changes? Likely yes — auto-stash risks losing user work. Brunch-level invariant: "source branch byte-identical before and after."
+ - **Branch naming:** Sandbox worktree branches off HEAD as `cook/`? Or user-controlled via a flag? `cook/` is the safe default; flag is the escape hatch.
+ - **Per-slice worktree mechanism:** `git worktree add -b cook// cook/` per slice off the run-level branch. `epic-sandbox-merge.ts` file-copy semantics need to either (a) continue working over `git worktree`-populated sliceDir contents and accept the known over-copy, or (b) migrate to a `git merge` of slice branches into an epic branch. Pick (a) for the first slice; (b) is a follow-on optimization.
+ - **Pi inside a non-empty worktree:** `pi-actions.ts:runPi` passes `cwd: opts.sandboxDir`. Confirm pi tools (read/write/edit/bash) behave correctly against pre-existing code (almost certainly yes, but worth one smoke test).
+- **Future direction — sandcastle adoption (deferred, revisit when project evolution warrants):**
+ - **Spike on 2026-05-26 confirmed the hybrid path is technically viable.** `@ai-hero/sandcastle` (v0.5.12) exposes `createWorktree({ branchStrategy: 'merge-to-head' })` decoupled from agent invocation, exports a built-in `pi` agent provider, and supports `noSandbox()` (no Docker requirement). The hybrid v2 path (sandcastle worktree + sandcastle pi provider) would eliminate brunch's `pi-actions.ts spawnSync` boilerplate and retire `epic-sandbox-merge.ts`'s file-copy over-copy problem via git branch-merge.
+ - **Why deferred now:** Too many integration issues at this stage — sandcastle is pre-1.0 (v0.5.12), pulls in Effect/effect-platform as runtime deps (~300KB), would require migrating brunch's Petri orchestrator to compose with sandcastle's worktree lifecycle, and locks in sandcastle's branch-naming + close-merge semantics. Premature adoption risks coupling brunch to an evolving upstream API before brunch's own brownfield needs are settled.
+ - **Triggering criteria to revisit:** (a) sandcastle ships 1.0 with stable API; OR (b) brunch's native epic-merge over-copy becomes a measurable performance bottleneck; OR (c) brunch needs container-isolation paths (Docker/Vercel) for security or remote-execution reasons; OR (d) Effect-based runtime dependency becomes attractive for unrelated reasons. None of these are true today.
+- **Acceptance:** (1) `brunch cook ` with `/.brunch/cook/plan.yaml` no longer exits with "not yet implemented." (2) Top-level sandbox worktree initialized via `git worktree add` of cwd repo on branch `cook/`. (3) Per-slice worktrees branch off the run-level branch. (4) Slices execute against pre-populated worktrees; `pi-actions.ts` unchanged — pi-tools operate on existing code. (5) Source branch in `` is byte-identical before and after a cook run (success or failure). (6) Cook runs leave a discoverable artifact (the `cook/` branch) for the user to review or discard. (7) Greenfield fixture-mode behavior is unchanged (empty worktree, generate-from-scratch); only the run output path moves from `/.cook/runs/` to `/.brunch/cook/runs/` per the SPEC §D50 / §A49 consolidation. All affected tests and fixture paths are updated. (8) `epic-sandbox-merge.ts` continues to work — over-copy accepted as a known follow-on optimization, flagged in code comments.
+- **Verification:** `brownfield-smoke.integration.test.ts` constructs a seeded git repo in tmpdir at test setup (NOT committed under `fixtures/` — nested `.git/` creates submodule weirdness), authors a `.brunch/cook/plan.yaml` carrying one slice that modifies an existing file, runs engine.run with fake actions, asserts (a) source branch unchanged, (b) modification landed in the slice worktree, (c) parent worktree is on `cook/`. CLI unit tests pin `resolveCookMode` + clean-tree gate. `worktree.test.ts` + `epic-sandbox-merge.test.ts` pin the codebase-mode seam components. Existing greenfield tests untouched.
+- **Traceability:** SPEC §D50 (reserved codebase-mode resolver); §A49 (worktree isolation at `/.brunch/cook/runs//worktree/`); Requirement 49.
+- **Design docs:** SPEC §D50 + §A49; `docs/next/architecture/plan-graph-petri-orchestration.md` (worktree section).
+
### continuous-workspace
- **Name:** Continuous workspace / phase-addressable interview surface (Conversational Workspace Runtime — Track 1)
diff --git a/memory/SPEC.md b/memory/SPEC.md
index c4cafc1c..8c54c503 100644
--- a/memory/SPEC.md
+++ b/memory/SPEC.md
@@ -101,8 +101,8 @@ Brunch operates inside a **workspace**: the cwd-backed software context whose lo
46. `brunch cook ` takes a plan YAML (epics → slices) and executes it end-to-end by dispatching agents through a name-keyed `ActionRegistry`.
47. Two engines (`proc` and `petri`) implement the same `Orchestrator` interface and must pass the same contract test suite.
48. `reports.jsonl` is the communication medium: tokens carry only pointers, all event content lives in the append-only log.
-49. Each run gets worktree isolation at `/.cook/runs//worktree/` (cwd-scoped, not fixture-scoped); fixture directory and source repo stay untouched.
-50. Dual-mode CLI resolver: `/plan.yaml` = fixture (greenfield), `/.cook/plan.yaml` = codebase (brownfield, reserved).
+49. Each run gets worktree isolation at `/.brunch/cook/runs//worktree/` (cwd-scoped, not fixture-scoped); fixture directory and source repo stay untouched.
+50. Dual-mode CLI resolver: `/plan.yaml` = fixture (greenfield), `/.brunch/cook/plan.yaml` = codebase (brownfield, reserved). Cook state consolidates under the existing `.brunch/` workspace convention rather than a peer `.cook/` directory.
#### Provider / agent substrate
@@ -205,7 +205,7 @@ Brunch operates inside a **workspace**: the cwd-backed software context whose lo
156. **`reports.jsonl` is the communication medium, not just audit log** — tokens carry only `{ reportId, sliceId, epicId }` pointers; transitions communicate by appending/reading lines. The net stays narrow because the log is rich. POC: petri engine enforces token-pointer discipline internally; proc engine is free to pass data through normal function calls — the shared seam is inputs and outputs. Depends on: Requirement 48.
157. **Action dispatch is name-keyed and extensible** — engines orchestrate which action fires when; handlers own how. POC uses inline dispatch per engine; promote to a real `ActionRegistry` when a 3rd action type lands. Depends on: Requirement 46.
158. **Plan model is two-level (epics → slices), no milestones in POC** — schema is provisional pending canonical brunch plan emission. Forward-compatible for intent/design/oracle pointers.
-159. **Worktree isolation per run** — agents write freely inside `/.cook/runs//worktree/` (cwd-scoped, not fixture-scoped); fixture dir and source repo untouched. Fixtures stay byte-identical before and after a run. Depends on: Requirement 49.
+159. **Worktree isolation per run** — agents write freely inside `/.brunch/cook/runs//worktree/` (cwd-scoped, not fixture-scoped); fixture dir and source repo untouched. Fixtures stay byte-identical before and after a run. Depends on: Requirement 49.
#### Provider, prompt/context, and agent substrate
@@ -255,7 +255,7 @@ Each invariant is a formalization candidate: the property is stated in human lan
| I120 | Secondary chats remain conversational process containers, not workflow or semantic truth: inline rendering, collapse/reload state, turn-level context snapshot replay, and item-version-gated stale-handle refresh may organize discussion, but accepted mutations still flow through Brunch-owned handlers and changesets. | planned: chat-runtime, context-provision, changeset/app tests | Requirement 45; A94, A95; D143, D149, D153, D154 |
| I121-K | Both orchestrator engines (`proc` and `petri`) pass the same contract test suite with identical observable behavior. | contract tests with fake agents/runner | Requirements 46, 47; D155-K |
| I122-K | Orchestrator event content lives in `reports.jsonl`; petri engine tokens carry only `{ reportId, sliceId, epicId }` pointers. Proc engine may pass data through normal function calls — the shared seam is inputs and outputs. | contract tests | Requirement 48; D156-K |
-| I123-K | Worktree isolation holds — fixture directory and source repo are never mutated by an orchestrator run; worktree is cwd-scoped at `/.cook/runs//worktree/`. | integration tests, worktree.test.ts | Requirement 49; D159-K |
+| I123-K | Worktree isolation holds — fixture directory and source repo are never mutated by an orchestrator run; worktree is cwd-scoped at `/.brunch/cook/runs//worktree/`. Codebase mode preserves the source repo's HEAD and tracked-file state byte-identically. | worktree.test.ts, brownfield-smoke.integration.test.ts | Requirement 49; D159-K |
| I124-K | Epic verification runs against a freshly-rebuilt `/__epic__//` dir holding the deterministic merge of its completed slices' worktrees (later slices in plan declaration order overwrite earlier ones on path collisions; collisions are reported via the `epic-sandbox-merged` event). Per-slice worktrees are not mutated by the merge. | epic-sandbox-merge.test.ts, engine-contract.test.ts | Requirement 49; D159-K |
| I125-K | Topology output-place candidates are fully declared in `HandlerDescriptor` via typed `Guard` predicates; `wireHandlers` introduces no new output places at fire time. Pure consumers can enumerate the reachable output-place set per transition from topology data alone via `enumerateCandidateOutputs(transition)`. Halt paths (budget exhaustion, verify-epic failure) and token transforms (reportId attach, retry/rework count propagation) remain runtime concerns and are explicitly not covered by this invariant. | topology.test.ts, engine-contract.test.ts | Requirements 46, 47, 48; D155-K (FE-747) |
@@ -368,10 +368,10 @@ Detailed card styling, typography tokens, and legacy layout minutiae are impleme
| **plan (orchestrator)** | YAML file describing epics + slices with definitions, dependencies, and verifications. The orchestrator's input. |
| **action (orchestrator)** | A handler in the `ActionRegistry` (e.g. `write-tests`, `write-code`, `run-tests`). Engines look up by name. |
| **report** | One structured event line in `reports.jsonl`. Carries durable content; tokens carry only pointers. |
-| **worktree (orchestrator)** | Isolated filesystem location where agents write during a run. Per-run; ephemeral. Cwd-scoped (`/.cook/runs//worktree/`), not fixture-scoped. |
+| **worktree (orchestrator)** | Isolated filesystem location where agents write during a run. Per-run; ephemeral. Cwd-scoped (`/.brunch/cook/runs//worktree/`), not fixture-scoped. |
| **fixture (orchestrator)** | Packaged test scenario for the orchestrator (plan + supporting artifacts). Used to test `cook` itself. |
| **fixture mode** | Greenfield execution: plan at `/plan.yaml`, empty worktree. POC default. |
-| **codebase mode** | Brownfield execution: plan at `/.cook/plan.yaml`, worktree seeded from ``. Designed but not implemented in POC. |
+| **codebase mode** | Brownfield execution: plan at `/.brunch/cook/plan.yaml`, worktree seeded from ``. Designed but not implemented in POC. |
## Verification Design
diff --git a/src/orchestrator/src/brownfield-smoke.integration.test.ts b/src/orchestrator/src/brownfield-smoke.integration.test.ts
new file mode 100644
index 00000000..e6149d7c
--- /dev/null
+++ b/src/orchestrator/src/brownfield-smoke.integration.test.ts
@@ -0,0 +1,219 @@
+// End-to-end smoke test for `cook-codebase-mode` slice 2.
+//
+// Constructs a tmpdir git repo with seeded files + a `.brunch/cook/plan.yaml`
+// carrying a 1-slice plan, then runs the orchestrator with FAKE actions that
+// mutate a pre-existing file. Verifies:
+// - the source branch in the source repo is byte-identical before/after,
+// - the cook artifact (slice worktree) contains the modification.
+//
+// The "fixture" lives as a test-setup function rather than committed under
+// `fixtures/brownfield-smoke/` because nesting a real `.git/` inside the
+// brunch repo creates submodule weirdness.
+//
+// Out of scope: real pi invocation (covered by manual outer-loop smoke later).
+
+import { execFileSync } from 'node:child_process';
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { resolveCookMode } from './cook-cli.js';
+import { createOrchestrator } from './engine.js';
+import { loadPlan } from './plan-loader.js';
+import { InMemoryReportSink } from './report-sink.js';
+import type { ActionContext, ActionHandlers, TestRunner } from './types.js';
+import { createSandbox } from './worktree.js';
+
+describe('brownfield smoke — 1-slice 1-epic codebase mode', () => {
+ const dirs: string[] = [];
+ afterEach(() => {
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
+ dirs.length = 0;
+ });
+
+ function makeSeededRepo(): string {
+ const dir = mkdtempSync(join(tmpdir(), 'brownfield-smoke-'));
+ dirs.push(dir);
+ execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
+ execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir });
+ execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
+ writeFileSync(join(dir, '.gitignore'), '.brunch/\n');
+ writeFileSync(join(dir, 'README.md'), '# original\n');
+ writeFileSync(join(dir, 'src.txt'), 'hello\n');
+ execFileSync('git', ['add', '.'], { cwd: dir });
+ execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: dir });
+
+ mkdirSync(join(dir, '.brunch', 'cook'), { recursive: true });
+ writeFileSync(
+ join(dir, '.brunch', 'cook', 'plan.yaml'),
+ [
+ 'epics:',
+ ' - id: smoke',
+ ' summary: smoke epic',
+ ' depends_on: []',
+ ' verification: []',
+ 'slices:',
+ ' - id: modify-src',
+ ' epic_id: smoke',
+ ' definition: append modified line to src.txt',
+ ' depends_on: []',
+ ' verification:',
+ ' - kind: unit-test',
+ ' target: src.txt',
+ '',
+ ].join('\n'),
+ );
+
+ return dir;
+ }
+
+ function makeFakeActions(reports: InMemoryReportSink): ActionHandlers {
+ let evalCalls = 0;
+ return {
+ // First eval returns NO (forces write-tests → write-code → run-tests),
+ // second eval returns YES (slice done).
+ 'evaluate-done': async (ctx: ActionContext) => {
+ evalCalls++;
+ const done = evalCalls >= 2;
+ const id = `rpt-eval-${ctx.slice.id}-${evalCalls}`;
+ reports.append({
+ id,
+ ts: new Date().toISOString(),
+ epicId: ctx.epic.id,
+ sliceId: ctx.slice.id,
+ actor: 'evaluator',
+ event: 'eval-done',
+ payload: { done },
+ });
+ return id;
+ },
+ 'write-tests': async (ctx: ActionContext) => {
+ // The slice "tests" the modification by reading src.txt; create a
+ // throwaway test file in the slice dir.
+ writeFileSync(join(ctx.sandboxDir, 'src.test.txt'), 'placeholder\n');
+ const id = `rpt-wt-${ctx.slice.id}`;
+ reports.append({
+ id,
+ ts: new Date().toISOString(),
+ epicId: ctx.epic.id,
+ sliceId: ctx.slice.id,
+ actor: 'test-writer',
+ event: 'tests-written',
+ payload: {},
+ });
+ return id;
+ },
+ 'write-code': async (ctx: ActionContext) => {
+ // The pre-existing src.txt (seeded from cwd) is mutated in-place.
+ const srcPath = join(ctx.sandboxDir, 'src.txt');
+ const before = readFileSync(srcPath, 'utf8');
+ writeFileSync(srcPath, before + 'modified\n');
+ const id = `rpt-wc-${ctx.slice.id}`;
+ reports.append({
+ id,
+ ts: new Date().toISOString(),
+ epicId: ctx.epic.id,
+ sliceId: ctx.slice.id,
+ actor: 'code-writer',
+ event: 'code-written',
+ payload: { srcPath },
+ });
+ return id;
+ },
+ 'assess-semantic': async (ctx: ActionContext) => {
+ const id = `rpt-sem-${ctx.slice.id}`;
+ reports.append({
+ id,
+ ts: new Date().toISOString(),
+ epicId: ctx.epic.id,
+ sliceId: ctx.slice.id,
+ actor: 'semantic-assessor',
+ event: 'semantic-assessed',
+ payload: { satisfied: true },
+ });
+ return id;
+ },
+ };
+ }
+
+ const fakeTestRunner: TestRunner = {
+ async run() {
+ return { passed: true, output: 'fake ok' };
+ },
+ };
+
+ it('source repo is byte-identical and cook artifact contains the modification', async () => {
+ const source = makeSeededRepo();
+
+ // Resolve via the same path runCook uses.
+ const resolved = resolveCookMode(source);
+ expect(resolved.mode).toBe('codebase');
+ if (resolved.mode !== 'codebase') throw new Error('unreachable');
+
+ const plan = loadPlan(resolved.planPath);
+ // baseDir = source (cwd-scoped per SPEC §A49).
+ const sandbox = createSandbox(source, undefined, {
+ mode: 'codebase',
+ sourceDir: resolved.sourceDir,
+ });
+
+ const sourceHeadBefore = execFileSync('git', ['rev-parse', 'HEAD'], {
+ cwd: source,
+ encoding: 'utf8',
+ }).trim();
+
+ const reports = new InMemoryReportSink();
+ const actions = makeFakeActions(reports);
+
+ const engine = createOrchestrator('serial');
+ const result = await engine.run({
+ plan,
+ sandboxDir: sandbox.sandboxDir,
+ actions,
+ reports,
+ testRunner: fakeTestRunner,
+ policy: { maxRetries: 3 },
+ sandboxMode: 'codebase',
+ runId: sandbox.runId,
+ });
+
+ expect(result.status).toBe('completed');
+
+ // Source branch byte-identical: HEAD unchanged, no uncommitted changes
+ // to tracked files (the `.brunch/` ignore keeps cook artifacts invisible
+ // to `git status` on tracked content).
+ const sourceHeadAfter = execFileSync('git', ['rev-parse', 'HEAD'], {
+ cwd: source,
+ encoding: 'utf8',
+ }).trim();
+ expect(sourceHeadAfter).toBe(sourceHeadBefore);
+ const trackedStatus = execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], {
+ cwd: source,
+ encoding: 'utf8',
+ });
+ expect(trackedStatus).toBe('');
+
+ // Modification landed: the slice worktree contains the mutated src.txt.
+ const sliceDir = join(sandbox.sandboxDir, 'modify-src');
+ expect(existsSync(sliceDir)).toBe(true);
+ const modified = readFileSync(join(sliceDir, 'src.txt'), 'utf8');
+ expect(modified).toBe('hello\nmodified\n');
+
+ // The parent worktree (the git worktree of source HEAD) was on cook/.
+ const parentBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
+ cwd: sandbox.sandboxDir,
+ encoding: 'utf8',
+ }).trim();
+ expect(parentBranch).toBe(`cook/${sandbox.runId}`);
+
+ // The slice worktree is a real git worktree on its slice-level branch
+ // (sibling namespace cook-slice/ to avoid ref-hierarchy collision with cook/).
+ const sliceBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
+ cwd: sliceDir,
+ encoding: 'utf8',
+ }).trim();
+ expect(sliceBranch).toBe(`cook-slice/${sandbox.runId}/modify-src`);
+ });
+});
diff --git a/src/orchestrator/src/cook-cli.test.ts b/src/orchestrator/src/cook-cli.test.ts
index d67f12ee..af265d62 100644
--- a/src/orchestrator/src/cook-cli.test.ts
+++ b/src/orchestrator/src/cook-cli.test.ts
@@ -1,6 +1,11 @@
-import { describe, expect, it } from 'vitest';
+import { execFileSync } from 'node:child_process';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
-import { parseCookArgs } from './cook-cli.js';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { parseCookArgs, resolveCookMode } from './cook-cli.js';
describe('parseCookArgs', () => {
it('parses dir only', () => {
@@ -39,3 +44,88 @@ describe('parseCookArgs', () => {
expect(parseCookArgs(['./f', '-v']).verbose).toBe(true);
});
});
+
+describe('resolveCookMode', () => {
+ const dirs: string[] = [];
+ afterEach(() => {
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
+ dirs.length = 0;
+ });
+
+ function makeTmpDir(prefix = 'cook-resolve-'): string {
+ const d = mkdtempSync(join(tmpdir(), prefix));
+ dirs.push(d);
+ return d;
+ }
+
+ function initCleanGitRepo(dir: string): void {
+ execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
+ execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir });
+ execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
+ writeFileSync(join(dir, 'README.md'), 'seed\n');
+ execFileSync('git', ['add', '.'], { cwd: dir });
+ execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: dir });
+ }
+
+ it('resolves fixture mode when /plan.yaml exists', () => {
+ const d = makeTmpDir();
+ writeFileSync(join(d, 'plan.yaml'), 'epics: []\nslices: []\n');
+
+ const result = resolveCookMode(d);
+ expect(result.mode).toBe('fixture');
+ if (result.mode === 'fixture') {
+ expect(result.planPath).toBe(join(d, 'plan.yaml'));
+ }
+ });
+
+ it('resolves codebase mode when /.brunch/cook/plan.yaml exists and git working tree is clean', () => {
+ const d = makeTmpDir();
+ initCleanGitRepo(d);
+ mkdirSync(join(d, '.brunch', 'cook'), { recursive: true });
+ writeFileSync(join(d, '.brunch', 'cook', 'plan.yaml'), 'epics: []\nslices: []\n');
+
+ const result = resolveCookMode(d);
+ expect(result.mode).toBe('codebase');
+ if (result.mode === 'codebase') {
+ expect(result.planPath).toBe(join(d, '.brunch', 'cook', 'plan.yaml'));
+ expect(result.sourceDir).toBe(d);
+ }
+ });
+
+ it('refuses codebase mode when working tree has uncommitted changes', () => {
+ const d = makeTmpDir();
+ initCleanGitRepo(d);
+ mkdirSync(join(d, '.brunch', 'cook'), { recursive: true });
+ writeFileSync(join(d, '.brunch', 'cook', 'plan.yaml'), 'epics: []\nslices: []\n');
+ // Introduce dirty state: modify the committed README
+ writeFileSync(join(d, 'README.md'), 'modified\n');
+
+ const result = resolveCookMode(d);
+ expect(result.mode).toBe('error');
+ if (result.mode === 'error') {
+ expect(result.message).toMatch(/uncommitted|dirty|working tree/i);
+ }
+ });
+
+ it('refuses codebase mode when is not a git repo', () => {
+ const d = makeTmpDir();
+ mkdirSync(join(d, '.brunch', 'cook'), { recursive: true });
+ writeFileSync(join(d, '.brunch', 'cook', 'plan.yaml'), 'epics: []\nslices: []\n');
+
+ const result = resolveCookMode(d);
+ expect(result.mode).toBe('error');
+ if (result.mode === 'error') {
+ expect(result.message).toMatch(/git/i);
+ }
+ });
+
+ it('returns error when no plan found at either location', () => {
+ const d = makeTmpDir();
+
+ const result = resolveCookMode(d);
+ expect(result.mode).toBe('error');
+ if (result.mode === 'error') {
+ expect(result.message).toMatch(/plan/i);
+ }
+ });
+});
diff --git a/src/orchestrator/src/cook-cli.ts b/src/orchestrator/src/cook-cli.ts
index fc5ee7ec..476afd08 100644
--- a/src/orchestrator/src/cook-cli.ts
+++ b/src/orchestrator/src/cook-cli.ts
@@ -1,3 +1,4 @@
+import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
@@ -59,22 +60,77 @@ function fmtDuration(ms: number): string {
return `${m}m ${rem.toFixed(0)}s`;
}
-export async function runCook(opts: CookOptions): Promise {
- const planPath = join(opts.dir, 'plan.yaml');
- if (!existsSync(planPath)) {
- const codebasePlanPath = join(opts.dir, '.cook', 'plan.yaml');
- if (existsSync(codebasePlanPath)) {
- console.error('Codebase mode (brownfield) is not yet implemented.');
- console.error('POC supports fixture mode only: place plan.yaml at the root of .');
- process.exit(1);
+export type ResolvedCookMode =
+ | { mode: 'fixture'; planPath: string }
+ | { mode: 'codebase'; planPath: string; sourceDir: string }
+ | { mode: 'error'; message: string };
+
+/**
+ * Resolve cook's run mode by inspecting ``:
+ * - `/plan.yaml` exists → fixture mode (greenfield).
+ * - `/.brunch/cook/plan.yaml` → codebase mode (brownfield); requires
+ * `` to be a git repo with a clean
+ * working tree.
+ * - neither → error.
+ *
+ * Pure function — no process exits, no side effects beyond filesystem reads.
+ */
+export function resolveCookMode(dir: string): ResolvedCookMode {
+ const fixturePath = join(dir, 'plan.yaml');
+ if (existsSync(fixturePath)) {
+ return { mode: 'fixture', planPath: fixturePath };
+ }
+
+ const codebasePath = join(dir, '.brunch', 'cook', 'plan.yaml');
+ if (existsSync(codebasePath)) {
+ const gitCheck = isCleanGitWorkingTree(dir);
+ if (gitCheck.kind === 'not-git') {
+ return { mode: 'error', message: `Codebase mode requires to be a git repo: ${dir}` };
+ }
+ if (gitCheck.kind === 'dirty') {
+ return {
+ mode: 'error',
+ message: `Codebase mode refuses to run against an uncommitted working tree:\n${gitCheck.status}`,
+ };
}
- console.error(`No plan found at ${planPath}`);
+ return { mode: 'codebase', planPath: codebasePath, sourceDir: dir };
+ }
+
+ return { mode: 'error', message: `No plan found at ${fixturePath} or ${codebasePath}` };
+}
+
+type GitWorkingTreeCheck = { kind: 'clean' } | { kind: 'dirty'; status: string } | { kind: 'not-git' };
+
+function isCleanGitWorkingTree(dir: string): GitWorkingTreeCheck {
+ // `--untracked-files=no` so a user authoring `/.brunch/cook/plan.yaml`
+ // (which is untracked by definition) does not trip the gate. The gate only
+ // refuses on modified or staged tracked files — the things cook could lose.
+ const result = spawnSync('git', ['status', '--porcelain', '--untracked-files=no'], {
+ cwd: dir,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ if (result.status !== 0) {
+ return { kind: 'not-git' };
+ }
+ const status = result.stdout.trim();
+ if (status === '') return { kind: 'clean' };
+ return { kind: 'dirty', status };
+}
+
+export async function runCook(opts: CookOptions): Promise {
+ const resolved = resolveCookMode(opts.dir);
+ if (resolved.mode === 'error') {
+ console.error(resolved.message);
process.exit(1);
}
- const plan = loadPlan(planPath);
+ const plan = loadPlan(resolved.planPath);
const launchCwd = process.env.BRUNCH_LAUNCH_CWD || process.cwd();
- const { sandboxDir, runDir } = createSandbox(launchCwd);
+ const { sandboxDir, runDir, runId } =
+ resolved.mode === 'codebase'
+ ? createSandbox(launchCwd, undefined, { mode: 'codebase', sourceDir: resolved.sourceDir })
+ : createSandbox(launchCwd);
const reportsPath = join(runDir, 'reports.jsonl');
const epicCount = plan.epics.length;
@@ -105,6 +161,8 @@ export async function runCook(opts: CookOptions): Promise {
reports,
testRunner,
policy: { maxRetries: opts.maxRetries },
+ sandboxMode: resolved.mode === 'codebase' ? 'codebase' : 'fixture',
+ runId,
});
const duration = fmtDuration(Date.now() - runStart);
diff --git a/src/orchestrator/src/cow-copy.ts b/src/orchestrator/src/cow-copy.ts
new file mode 100644
index 00000000..5955a200
--- /dev/null
+++ b/src/orchestrator/src/cow-copy.ts
@@ -0,0 +1,42 @@
+import { spawnSync } from 'node:child_process';
+import { cpSync, existsSync, readdirSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+
+/**
+ * Copy `src` to `dest` using copy-on-write when the host supports it,
+ * falling back to a regular recursive `cpSync` otherwise. Lazy at the block
+ * level on APFS (macOS) and reflink-capable filesystems (Linux btrfs/xfs/etc.),
+ * so large gitignored content like `node_modules/` costs ~zero disk on the
+ * first copy.
+ */
+export function cowCopy(src: string, dest: string): void {
+ const flag = process.platform === 'darwin' ? '-c' : process.platform === 'linux' ? '--reflink=auto' : null;
+ if (flag) {
+ const result = spawnSync('cp', [flag, '-R', src, dest], { stdio: ['ignore', 'pipe', 'pipe'] });
+ if (result.status === 0) return;
+ // Fall through to cpSync on any failure (unsupported filesystem, missing
+ // flag in the host cp, etc.) — correctness is preserved at the cost of disk.
+ }
+ cpSync(src, dest, { dereference: false, recursive: true });
+}
+
+/**
+ * CoW-copy top-level entries from `sourceDir` that are absent in `destDir`
+ * (untracked/gitignored dirs like `node_modules/`, `dist/`). Skips names in
+ * `exclude` and entries already present in the destination (typically tracked
+ * files materialized by `git worktree add`).
+ */
+export function copyMissingTopLevelEntries(
+ sourceDir: string,
+ destDir: string,
+ exclude: ReadonlySet = new Set(['.git']),
+): void {
+ const source = resolve(sourceDir);
+ const dest = resolve(destDir);
+ for (const entry of readdirSync(source)) {
+ if (exclude.has(entry)) continue;
+ const destPath = join(dest, entry);
+ if (existsSync(destPath)) continue;
+ cowCopy(join(source, entry), destPath);
+ }
+}
diff --git a/src/orchestrator/src/epic-sandbox-merge.test.ts b/src/orchestrator/src/epic-sandbox-merge.test.ts
index 24856ff6..7f3b5bf0 100644
--- a/src/orchestrator/src/epic-sandbox-merge.test.ts
+++ b/src/orchestrator/src/epic-sandbox-merge.test.ts
@@ -1,3 +1,4 @@
+import { execFileSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
@@ -8,13 +9,14 @@ import {
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
-import { join } from 'node:path';
+import { dirname, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
epicIdsForEpicVerifyMerge,
mergeSlicesIntoEpicSandbox,
+ seedSliceFromParentWorktree,
seedSliceSandboxFromDeps,
sliceIdsForEpicVerifyMerge,
} from './epic-sandbox-merge.js';
@@ -191,6 +193,129 @@ describe('seedSliceSandboxFromDeps', () => {
});
});
+describe('seedSliceFromParentWorktree', () => {
+ const dirs: string[] = [];
+ afterEach(() => {
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
+ dirs.length = 0;
+ });
+
+ const singleSlicePlan: Plan = {
+ epics: [{ id: 'e1', summary: '', depends_on: [], verification: [] }],
+ slices: [{ id: 'only', epic_id: 'e1', definition: '', depends_on: [], verification: [] }],
+ };
+
+ /**
+ * Create a tmp dir initialised as a git worktree of a fresh repo at HEAD,
+ * mimicking the structure cook produces via createSandbox in codebase mode:
+ * the "parent" is itself a `git worktree add` of a separate "source" repo,
+ * checked out on a `cook/` branch.
+ */
+ function makeGitParentWorktree(runId: string): {
+ parent: string;
+ source: string;
+ addUntracked: (relPath: string, content: string) => void;
+ } {
+ const source = mkdtempSync(join(tmpdir(), 'cook-source-'));
+ dirs.push(source);
+ execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: source });
+ execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: source });
+ execFileSync('git', ['config', 'user.name', 'Test'], { cwd: source });
+ writeFileSync(join(source, 'README.md'), '# project\n');
+ mkdirSync(join(source, 'src'));
+ writeFileSync(join(source, 'src', 'a.ts'), 'export const a = 1;\n');
+ execFileSync('git', ['add', '.'], { cwd: source });
+ execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: source });
+
+ const runDir = mkdtempSync(join(tmpdir(), 'cook-run-'));
+ dirs.push(runDir);
+ const parent = join(runDir, 'worktree');
+ execFileSync('git', ['worktree', 'add', '-q', '-b', `cook/${runId}`, parent, 'HEAD'], { cwd: source });
+
+ return {
+ parent,
+ source,
+ addUntracked: (relPath, content) => {
+ const abs = join(parent, relPath);
+ mkdirSync(dirname(abs), { recursive: true });
+ writeFileSync(abs, content);
+ },
+ };
+ }
+
+ it('tracked content arrives via git worktree checkout', () => {
+ const { parent } = makeGitParentWorktree('r1');
+
+ const sliceDir = seedSliceFromParentWorktree(parent, 'only', singleSlicePlan, 'r1');
+
+ expect(sliceDir).toBe(join(parent, 'only'));
+ expect(readFileSync(join(sliceDir, 'README.md'), 'utf8')).toBe('# project\n');
+ expect(readFileSync(join(sliceDir, 'src/a.ts'), 'utf8')).toBe('export const a = 1;\n');
+ });
+
+ it('untracked content arrives via CoW copy from the parent', () => {
+ const { parent, addUntracked } = makeGitParentWorktree('r2');
+ // Simulate node_modules / generated artifacts present in the parent
+ // worktree but NOT tracked by git.
+ addUntracked('node_modules/dep/index.js', 'module.exports = 1;\n');
+ addUntracked('dist/bundle.js', 'console.log("bundle");\n');
+
+ const sliceDir = seedSliceFromParentWorktree(parent, 'only', singleSlicePlan, 'r2');
+
+ expect(readFileSync(join(sliceDir, 'node_modules/dep/index.js'), 'utf8')).toBe('module.exports = 1;\n');
+ expect(readFileSync(join(sliceDir, 'dist/bundle.js'), 'utf8')).toBe('console.log("bundle");\n');
+ });
+
+ it('slice worktree is checked out on a slice-level cook branch', () => {
+ const { parent } = makeGitParentWorktree('r3');
+
+ const sliceDir = seedSliceFromParentWorktree(parent, 'only', singleSlicePlan, 'r3');
+
+ const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
+ cwd: sliceDir,
+ encoding: 'utf8',
+ }).trim();
+ expect(branch).toBe('cook-slice/r3/only');
+ });
+
+ it('excludes sibling slice subdirs from the untracked copy', () => {
+ const { parent, addUntracked } = makeGitParentWorktree('r4');
+ const planTwo: Plan = {
+ epics: [{ id: 'e1', summary: '', depends_on: [], verification: [] }],
+ slices: [
+ { id: 'first', epic_id: 'e1', definition: '', depends_on: [], verification: [] },
+ { id: 'second', epic_id: 'e1', definition: '', depends_on: [], verification: [] },
+ ],
+ };
+ addUntracked('first/already-cooked.txt', 'first slice output\n');
+
+ const sliceDir = seedSliceFromParentWorktree(parent, 'second', planTwo, 'r4');
+
+ expect(existsSync(join(sliceDir, 'first'))).toBe(false);
+ });
+
+ it('excludes __epic__ reserved dir from the untracked copy', () => {
+ const { parent, addUntracked } = makeGitParentWorktree('r5');
+ addUntracked('__epic__/e1/leftover.txt', 'leftover\n');
+
+ const sliceDir = seedSliceFromParentWorktree(parent, 'only', singleSlicePlan, 'r5');
+
+ expect(existsSync(join(sliceDir, '__epic__'))).toBe(false);
+ });
+
+ it('rejects slice ids that collide with top-level repo entries', () => {
+ const { parent } = makeGitParentWorktree('r6');
+ const plan: Plan = {
+ epics: [{ id: 'e1', summary: '', depends_on: [], verification: [] }],
+ slices: [{ id: 'src', epic_id: 'e1', definition: '', depends_on: [], verification: [] }],
+ };
+
+ expect(() => seedSliceFromParentWorktree(parent, 'src', plan, 'r6')).toThrow(
+ 'Slice id "src" collides with an existing entry in the parent worktree',
+ );
+ });
+});
+
describe('mergeSlicesIntoEpicSandbox', () => {
const dirs: string[] = [];
afterEach(() => {
diff --git a/src/orchestrator/src/epic-sandbox-merge.ts b/src/orchestrator/src/epic-sandbox-merge.ts
index e026e7ab..627ea18f 100644
--- a/src/orchestrator/src/epic-sandbox-merge.ts
+++ b/src/orchestrator/src/epic-sandbox-merge.ts
@@ -4,9 +4,11 @@
// on the same path and the collision is reported. Source worktrees are not
// mutated. The verify dir is rebuilt fresh on every call.
+import { execFileSync } from 'node:child_process';
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
+import { copyMissingTopLevelEntries } from './cow-copy.js';
import type { Plan, Slice } from './types.js';
export type MergeConflict = {
@@ -173,6 +175,75 @@ function pruneEmptyDirs(rootDir: string, dir: string = rootDir): void {
}
}
+function assertSliceWorktreePathAvailable(parentSandboxDir: string, sliceId: string): void {
+ const sliceDir = resolveSliceWorktreeDir(parentSandboxDir, sliceId);
+ if (existsSync(sliceDir)) {
+ throw new Error(`Slice id "${sliceId}" collides with an existing entry in the parent worktree`);
+ }
+}
+
+/**
+ * Codebase-mode seed: prepare the per-slice worktree as a real `git worktree`
+ * checked out on a slice-level branch (`cook-slice//`) off
+ * the run-level cook branch, then CoW-copy any untracked/gitignored content
+ * from the parent worktree (e.g. `node_modules/`, `dist/`) so pi-actions can
+ * run `npm test` / `bun test` / build steps that depend on runtime deps.
+ *
+ * The slice branches live in a sibling namespace `cook-slice/` rather than
+ * nested under `cook//` because git refs are leaf-or-directory: with
+ * `cook/` already a leaf branch, `cook//` would fail
+ * with "cannot lock ref ... 'refs/heads/cook/' exists."
+ *
+ * Excluded from the untracked CoW step:
+ * - sibling slice subdirs (other entries in `plan.slices`)
+ * - the `__epic__/` reserved merge dir
+ * - `.git` (the parent's worktree pointer; the new worktree gets its own)
+ * - any entry already created by `git worktree add` (tracked content)
+ *
+ * Returns the slice sandbox path. NOT safe to re-invoke against an existing
+ * slice worktree — `git worktree add` would fail with "already exists." The
+ * caller must remove the prior worktree first if re-seeding.
+ *
+ * TODO(cook-artifact-lifecycle follow-on, separate frontier): the slice branch
+ * exists but is never committed to. After this lands, a future frontier should
+ * add slice-completion commits, replace `mergeSlicesIntoEpicSandbox`'s file-copy
+ * with a git merge of slice branches into an epic branch, and surface real
+ * merge conflicts (today's file-copy is silent last-slice-wins). That work
+ * earns the "discoverable cook artifact" criterion via `git merge cook/`
+ * promotion semantics.
+ */
+export function seedSliceFromParentWorktree(
+ parentSandboxDir: string,
+ sliceId: string,
+ plan: Plan,
+ runId: string,
+): string {
+ assertSliceWorktreePathAvailable(parentSandboxDir, sliceId);
+ const sliceDir = resolveSliceWorktreeDir(parentSandboxDir, sliceId);
+
+ // 1. Real git worktree: tracked content arrives via git checkout, slice
+ // branch is `cook//` off the parent worktree's HEAD
+ // (which is the run-level `cook/` branch). Shares the source
+ // repo's `.git/` object database via hardlinks — no full git copy.
+ execFileSync(
+ 'git',
+ ['worktree', 'add', '--quiet', '-b', `cook-slice/${runId}/${sliceId}`, sliceDir, 'HEAD'],
+ {
+ cwd: parentSandboxDir,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ },
+ );
+
+ // 2. CoW-copy whatever's in the parent worktree but NOT in the slice
+ // worktree yet — i.e. untracked / gitignored content (`node_modules/`,
+ // `dist/`, etc.) that pi-actions might need at runtime.
+ const excludedNames = new Set(['.git', EPIC_MERGE_SEGMENT]);
+ for (const s of plan.slices) excludedNames.add(s.id);
+ copyMissingTopLevelEntries(parentSandboxDir, sliceDir, excludedNames);
+
+ return sliceDir;
+}
+
/** Copy completed dependency slice worktrees into `slice`'s sandbox (plan order). */
export function seedSliceSandboxFromDeps(
parentSandboxDir: string,
diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts
index ebf746a5..a0a826e3 100644
--- a/src/orchestrator/src/net-compiler.ts
+++ b/src/orchestrator/src/net-compiler.ts
@@ -10,6 +10,7 @@ import { mkdirSync } from 'node:fs';
import {
mergeSlicesIntoEpicSandbox,
resolveSliceWorktreeDir,
+ seedSliceFromParentWorktree,
seedSliceSandboxFromDeps,
sliceIdsForEpicVerifyMerge,
} from './epic-sandbox-merge.js';
@@ -333,9 +334,19 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput,
net.addPlace(place);
}
- // Create per-slice sandbox directories (parallel-safe; deps seeded at fire time)
+ // Create per-slice sandbox directories (parallel-safe; deps seeded at fire time).
+ // In codebase mode, seed each slice dir with the parent worktree's contents
+ // (the source repo's HEAD via `git worktree add`) so pi-actions can modify
+ // existing code instead of writing into an empty dir.
for (const slice of plan.slices) {
- mkdirSync(resolveSliceWorktreeDir(input.sandboxDir, slice.id), { recursive: true });
+ if (input.sandboxMode === 'codebase') {
+ if (!input.runId) {
+ throw new Error('codebase mode requires input.runId (used to name slice-level git branches)');
+ }
+ seedSliceFromParentWorktree(input.sandboxDir, slice.id, plan, input.runId);
+ } else {
+ mkdirSync(resolveSliceWorktreeDir(input.sandboxDir, slice.id), { recursive: true });
+ }
}
// Register transitions with wired fire handlers
diff --git a/src/orchestrator/src/types.ts b/src/orchestrator/src/types.ts
index f964e44d..bfbca2b6 100644
--- a/src/orchestrator/src/types.ts
+++ b/src/orchestrator/src/types.ts
@@ -97,6 +97,18 @@ export type OrchestratorInput = {
reports: ReportSink;
testRunner: TestRunner;
policy: RunPolicy;
+ /**
+ * 'fixture' (default): per-slice worktrees are created empty. Greenfield.
+ * 'codebase': per-slice worktrees are real `git worktree`s on slice-level
+ * branches (`cook//`) off the run-level cook branch,
+ * with untracked/gitignored content CoW-copied from the parent. Brownfield.
+ */
+ sandboxMode?: 'fixture' | 'codebase';
+ /**
+ * Required in `codebase` mode: the run id used to name slice-level branches
+ * (`cook//`). Unused in fixture mode.
+ */
+ runId?: string;
};
export type EpicOutcome = {
diff --git a/src/orchestrator/src/worktree.test.ts b/src/orchestrator/src/worktree.test.ts
index 07b5869f..0ab4f869 100644
--- a/src/orchestrator/src/worktree.test.ts
+++ b/src/orchestrator/src/worktree.test.ts
@@ -1,6 +1,7 @@
-import { existsSync, mkdtempSync, rmSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
-import { join } from 'node:path';
+import { dirname, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
@@ -13,14 +14,14 @@ describe('createSandbox', () => {
dirs.length = 0;
});
- it('creates sandbox under baseDir/.cook/runs//worktree/', () => {
+ it('creates sandbox under baseDir/.brunch/cook/runs//worktree/', () => {
const baseDir = mkdtempSync(join(tmpdir(), 'cook-wt-'));
dirs.push(baseDir);
const info = createSandbox(baseDir, 'test-run-1');
expect(info.runId).toBe('test-run-1');
- expect(info.runDir).toBe(join(baseDir, '.cook', 'runs', 'test-run-1'));
- expect(info.sandboxDir).toBe(join(baseDir, '.cook', 'runs', 'test-run-1', 'worktree'));
+ expect(info.runDir).toBe(join(baseDir, '.brunch', 'cook', 'runs', 'test-run-1'));
+ expect(info.sandboxDir).toBe(join(baseDir, '.brunch', 'cook', 'runs', 'test-run-1', 'worktree'));
expect(existsSync(info.sandboxDir)).toBe(true);
});
@@ -40,9 +41,102 @@ describe('createSandbox', () => {
createSandbox(baseDir, 'isolated-run');
- // Fixture dir must not have a .cook/ directory
- expect(existsSync(join(fixtureDir, '.cook'))).toBe(false);
+ // Fixture dir must not have a .brunch/cook/ run output
+ expect(existsSync(join(fixtureDir, '.brunch', 'cook'))).toBe(false);
// Base dir must have it
- expect(existsSync(join(baseDir, '.cook', 'runs', 'isolated-run', 'worktree'))).toBe(true);
+ expect(existsSync(join(baseDir, '.brunch', 'cook', 'runs', 'isolated-run', 'worktree'))).toBe(true);
+ });
+});
+
+describe('createSandbox — codebase mode', () => {
+ const dirs: string[] = [];
+ afterEach(() => {
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
+ dirs.length = 0;
+ });
+
+ function makeTmpDir(prefix: string): string {
+ const d = mkdtempSync(join(tmpdir(), prefix));
+ dirs.push(d);
+ return d;
+ }
+
+ function initSeededGitRepo(dir: string): void {
+ execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
+ execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir });
+ execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
+ writeFileSync(join(dir, 'README.md'), '# seed\n');
+ writeFileSync(join(dir, 'src.txt'), 'hello\n');
+ execFileSync('git', ['add', '.'], { cwd: dir });
+ execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: dir });
+ }
+
+ it('creates a git worktree of sourceDir on a cook/ branch', () => {
+ const baseDir = makeTmpDir('cook-base-');
+ const sourceDir = makeTmpDir('cook-src-');
+ initSeededGitRepo(sourceDir);
+
+ const info = createSandbox(baseDir, 'codebase-run-1', { mode: 'codebase', sourceDir });
+
+ expect(info.runId).toBe('codebase-run-1');
+ expect(existsSync(info.sandboxDir)).toBe(true);
+ // Worktree contents mirror sourceDir HEAD
+ expect(readFileSync(join(info.sandboxDir, 'README.md'), 'utf8')).toBe('# seed\n');
+ expect(readFileSync(join(info.sandboxDir, 'src.txt'), 'utf8')).toBe('hello\n');
+ });
+
+ it('worktree is checked out on branch cook/', () => {
+ const baseDir = makeTmpDir('cook-base-');
+ const sourceDir = makeTmpDir('cook-src-');
+ initSeededGitRepo(sourceDir);
+
+ const info = createSandbox(baseDir, 'branch-test', { mode: 'codebase', sourceDir });
+
+ const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
+ cwd: info.sandboxDir,
+ encoding: 'utf8',
+ }).trim();
+ expect(branch).toBe('cook/branch-test');
+ });
+
+ it('CoW-copies untracked top-level dirs from sourceDir into the parent worktree', () => {
+ const baseDir = makeTmpDir('cook-base-');
+ const sourceDir = makeTmpDir('cook-src-');
+ initSeededGitRepo(sourceDir);
+ const depFile = join(sourceDir, 'node_modules', 'dep', 'index.js');
+ mkdirSync(dirname(depFile), { recursive: true });
+ writeFileSync(depFile, 'module.exports = 1;\n');
+
+ const info = createSandbox(baseDir, 'untracked-copy', { mode: 'codebase', sourceDir });
+
+ expect(readFileSync(join(info.sandboxDir, 'node_modules', 'dep', 'index.js'), 'utf8')).toBe(
+ 'module.exports = 1;\n',
+ );
+ });
+
+ it('source branch in sourceDir is byte-identical after worktree creation', () => {
+ const baseDir = makeTmpDir('cook-base-');
+ const sourceDir = makeTmpDir('cook-src-');
+ initSeededGitRepo(sourceDir);
+
+ const sourceHeadBefore = execFileSync('git', ['rev-parse', 'HEAD'], {
+ cwd: sourceDir,
+ encoding: 'utf8',
+ }).trim();
+
+ createSandbox(baseDir, 'isolation-test', { mode: 'codebase', sourceDir });
+
+ const sourceHeadAfter = execFileSync('git', ['rev-parse', 'HEAD'], {
+ cwd: sourceDir,
+ encoding: 'utf8',
+ }).trim();
+ expect(sourceHeadAfter).toBe(sourceHeadBefore);
+
+ // No uncommitted changes either
+ const status = execFileSync('git', ['status', '--porcelain'], {
+ cwd: sourceDir,
+ encoding: 'utf8',
+ });
+ expect(status).toBe('');
});
});
diff --git a/src/orchestrator/src/worktree.ts b/src/orchestrator/src/worktree.ts
index 7c22cc39..3d08e447 100644
--- a/src/orchestrator/src/worktree.ts
+++ b/src/orchestrator/src/worktree.ts
@@ -1,6 +1,9 @@
+import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
-import { mkdirSync } from 'node:fs';
-import { join } from 'node:path';
+import { existsSync, mkdirSync, rmSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+
+import { copyMissingTopLevelEntries } from './cow-copy.js';
export type SandboxInfo = {
runId: string;
@@ -8,14 +11,47 @@ export type SandboxInfo = {
sandboxDir: string;
};
+export type SandboxMode = 'fixture' | 'codebase';
+
+export type CreateSandboxOptions = { mode: 'fixture' } | { mode: 'codebase'; sourceDir: string };
+
/**
- * Create an isolated run directory under `baseDir/.cook/runs//`.
+ * Create an isolated run directory under `baseDir/.brunch/cook/runs//`.
* `baseDir` should be cwd (not the fixture directory) so fixtures stay pristine.
+ *
+ * - **fixture mode (default):** the sandbox worktree is an empty directory.
+ * - **codebase mode:** the sandbox worktree is a `git worktree add` of
+ * `opts.sourceDir` on a fresh branch `cook/`. The source branch in
+ * `sourceDir` is left untouched; agent commits land on the cook branch.
*/
-export function createSandbox(baseDir: string, runId?: string): SandboxInfo {
+export function createSandbox(
+ baseDir: string,
+ runId?: string,
+ opts: CreateSandboxOptions = { mode: 'fixture' },
+): SandboxInfo {
const id = runId ?? randomUUID();
- const runDir = join(baseDir, '.cook', 'runs', id);
+ const runDir = join(baseDir, '.brunch', 'cook', 'runs', id);
const sandboxDir = join(runDir, 'worktree');
- mkdirSync(sandboxDir, { recursive: true });
+
+ if (opts.mode === 'codebase') {
+ // git worktree add requires the target path NOT to exist; ensure parent
+ // exists, then let git create the worktree dir itself.
+ mkdirSync(dirname(sandboxDir), { recursive: true });
+ if (existsSync(sandboxDir)) {
+ rmSync(sandboxDir, { recursive: true, force: true });
+ }
+ const branch = `cook/${id}`;
+ execFileSync('git', ['worktree', 'add', '-b', branch, sandboxDir, 'HEAD'], {
+ cwd: opts.sourceDir,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ // `git worktree add` only materializes tracked files; CoW-copy untracked /
+ // gitignored top-level dirs (e.g. `node_modules/`) from the source cwd so
+ // slice seeding and pi-actions see the same runtime deps as the developer tree.
+ copyMissingTopLevelEntries(opts.sourceDir, sandboxDir);
+ } else {
+ mkdirSync(sandboxDir, { recursive: true });
+ }
+
return { runId: id, runDir, sandboxDir };
}