From b728836dc58f8484ece122c39ee438826ec04798 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 26 May 2026 18:59:36 +0200 Subject: [PATCH 1/5] =?UTF-8?q?FE-755:=20Cook=20codebase-mode=20=E2=80=94?= =?UTF-8?q?=20brownfield=20resolver=20for=20`brunch=20cook`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `brunch cook ` now runs against an existing repo. When `/.brunch/cook/plan.yaml` exists and the working tree's tracked files are clean, cook initializes the sandbox via `git worktree add -b cook/ HEAD` from the source repo, per-slice worktrees are seeded by file-copy from the parent (excluding `.git`, sibling slice dirs, and `__epic__/`), and pi-actions run unchanged against pre-existing code. The source branch in `` stays byte-identical. Also consolidates cook's filesystem footprint from `/.cook/` to `/.brunch/cook/` so all cook state lives under the existing `.brunch/` workspace convention. Acceptance criteria covered: - Resolver replaces the "not yet implemented" early-exit at cook-cli.ts:65-70 with a pure `resolveCookMode(dir)` discriminated union (fixture / codebase / error). - Clean-tree gate refuses brownfield runs with uncommitted tracked changes (`git status --porcelain --untracked-files=no`). - `createSandbox` gains a `CreateSandboxOptions` discriminated union; codebase mode invokes `git worktree add` on branch `cook/`. - `seedSliceFromParentWorktree` populates per-slice dirs from the parent worktree contents. - `OrchestratorInput.sandboxMode?: 'fixture' | 'codebase'` threads the mode through to net-compiler. - `pi-actions.ts` unchanged. Existing greenfield fixture-mode tests pass; new tests in cook-cli.test.ts (resolveCookMode), worktree.test.ts (codebase-mode createSandbox), epic-sandbox-merge.test.ts (seedSliceFromParentWorktree), plus a tmpdir+fake-actions brownfield-smoke.integration.test.ts that pins source-byte-identical isolation. Total: 1447/1447 tests pass. Known follow-ons (out of scope for this PR): - pi-actions evaluator currently gets full write tools, collapsing the TDD-shaped workflow when pi can satisfy the slice during evaluation. - No "promote cook artifact back" story — modification lives in untracked subdirs of the cook branch's worktree, not as commits. - Multi-slice brownfield over-copy (TODO in `seedSliceFromParentWorktree`) — slice 2's 1-slice fixture didn't exercise it. A 2026-05-26 spike evaluated `@ai-hero/sandcastle` for hybrid adoption. Technically viable (built-in pi provider, decoupled worktree primitives, noSandbox available) but deferred until sandcastle ships 1.0 or multi-slice over-copy becomes a measurable bottleneck. A 2026-05-26 outer-loop smoke against a tmpdir git repo with real pi confirmed in-place file modification and source-byte-identical isolation end-to-end. Updates SPEC §D50, §A49, §I123-K, §Lexicon (path consolidation) and PLAN.md `cook-codebase-mode` frontier definition (now Recently Completed with three follow-on findings). Co-Authored-By: Claude --- .gitignore | 1 - docs/design/orchestrator.md | 14 +- memory/PLAN.md | 30 +++ memory/SPEC.md | 12 +- .../src/brownfield-smoke.integration.test.ts | 210 ++++++++++++++++++ src/orchestrator/src/cook-cli.test.ts | 94 +++++++- src/orchestrator/src/cook-cli.ts | 79 ++++++- .../src/epic-sandbox-merge.test.ts | 91 ++++++++ src/orchestrator/src/epic-sandbox-merge.ts | 37 +++ src/orchestrator/src/net-compiler.ts | 12 +- src/orchestrator/src/types.ts | 6 + src/orchestrator/src/worktree.test.ts | 93 +++++++- src/orchestrator/src/worktree.ts | 42 +++- 13 files changed, 679 insertions(+), 42 deletions(-) create mode 100644 src/orchestrator/src/brownfield-smoke.integration.test.ts 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/memory/PLAN.md b/memory/PLAN.md index 1206cc7d..aa7bd944 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -30,10 +30,17 @@ 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 + `git worktree add` + per-slice parent-population (file-copy with `.git` / sibling-slice / `__epic__/` exclusion). 2026-05-26 outer-loop smoke against a tmpdir git repo + real pi confirmed source-byte-identical isolation and in-place file modification work. Three follow-on findings flagged below. - `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 promotion path is missing** — the cook branch (`cook/`) has HEAD === source HEAD; the modification lives in two **untracked subdirs** of the cook branch's working tree (`/worktree//` and `/worktree/__epic__//`). No "merge cook/ into HEAD" story today. Tied to slice-2 review finding 2 (worktree + branch GC). Worth a `cook-artifact-lifecycle` frontier. +- **Multi-slice brownfield over-copy** — already captured as a TODO in `seedSliceFromParentWorktree`; slice 2's 1-slice fixture didn't exercise it. Either real-`git worktree add` per slice off the run-level branch, or diff-based epic-merge. + ### Next 1. `intent-graph-semantics` — highest-coordination semantic substrate after FE-705 reconciliation. @@ -137,6 +144,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..8ac7f6d8 --- /dev/null +++ b/src/orchestrator/src/brownfield-smoke.integration.test.ts @@ -0,0 +1,210 @@ +// 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', + }); + + 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}`); + }); +}); 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..03ea80a1 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 } = + 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,7 @@ export async function runCook(opts: CookOptions): Promise { reports, testRunner, policy: { maxRetries: opts.maxRetries }, + sandboxMode: resolved.mode === 'codebase' ? 'codebase' : 'fixture', }); const duration = fmtDuration(Date.now() - runStart); diff --git a/src/orchestrator/src/epic-sandbox-merge.test.ts b/src/orchestrator/src/epic-sandbox-merge.test.ts index 24856ff6..895beaf3 100644 --- a/src/orchestrator/src/epic-sandbox-merge.test.ts +++ b/src/orchestrator/src/epic-sandbox-merge.test.ts @@ -15,6 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { epicIdsForEpicVerifyMerge, mergeSlicesIntoEpicSandbox, + seedSliceFromParentWorktree, seedSliceSandboxFromDeps, sliceIdsForEpicVerifyMerge, } from './epic-sandbox-merge.js'; @@ -191,6 +192,96 @@ 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: [] }], + }; + + it('copies parent worktree contents into the slice dir', () => { + const parent = mkdtempSync(join(tmpdir(), 'cook-parent-')); + dirs.push(parent); + // Parent worktree mimics a brownfield codebase content layout + mkdirSync(join(parent, 'src'), { recursive: true }); + writeFileSync(join(parent, 'README.md'), '# project\n'); + writeFileSync(join(parent, 'src', 'a.ts'), 'export const a = 1;\n'); + + const sliceDir = seedSliceFromParentWorktree(parent, 'only', singleSlicePlan); + + 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('excludes sibling slice subdirs from the seed', () => { + const parent = mkdtempSync(join(tmpdir(), 'cook-parent-')); + dirs.push(parent); + 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: [] }, + ], + }; + writeFileSync(join(parent, 'shared.txt'), 'shared\n'); + mkdirSync(join(parent, 'first'), { recursive: true }); + writeFileSync(join(parent, 'first', 'a.txt'), 'first work\n'); + + const sliceDir = seedSliceFromParentWorktree(parent, 'second', planTwo); + + // shared.txt should be present (it's parent-level) + expect(readFileSync(join(sliceDir, 'shared.txt'), 'utf8')).toBe('shared\n'); + // The 'first' slice dir must NOT be nested inside the 'second' slice dir + expect(existsSync(join(sliceDir, 'first'))).toBe(false); + }); + + it('excludes __epic__ reserved directory from the seed', () => { + const parent = mkdtempSync(join(tmpdir(), 'cook-parent-')); + dirs.push(parent); + mkdirSync(join(parent, '__epic__', 'e1'), { recursive: true }); + writeFileSync(join(parent, '__epic__', 'e1', 'leftover.txt'), 'leftover\n'); + writeFileSync(join(parent, 'kept.txt'), 'kept\n'); + + const sliceDir = seedSliceFromParentWorktree(parent, 'only', singleSlicePlan); + + expect(readFileSync(join(sliceDir, 'kept.txt'), 'utf8')).toBe('kept\n'); + expect(existsSync(join(sliceDir, '__epic__'))).toBe(false); + }); + + it('excludes .git from the seed (git worktree pointer files would break in a nested copy)', () => { + const parent = mkdtempSync(join(tmpdir(), 'cook-parent-')); + dirs.push(parent); + mkdirSync(join(parent, '.git'), { recursive: true }); + writeFileSync(join(parent, '.git', 'HEAD'), 'ref: refs/heads/cook/x\n'); + writeFileSync(join(parent, 'code.ts'), 'export {};\n'); + + const sliceDir = seedSliceFromParentWorktree(parent, 'only', singleSlicePlan); + + expect(readFileSync(join(sliceDir, 'code.ts'), 'utf8')).toBe('export {};\n'); + expect(existsSync(join(sliceDir, '.git'))).toBe(false); + }); + + it('does not nest the slice dir inside itself when re-seeding', () => { + const parent = mkdtempSync(join(tmpdir(), 'cook-parent-')); + dirs.push(parent); + writeFileSync(join(parent, 'a.txt'), 'a\n'); + + // First seed: creates parent/only/a.txt + seedSliceFromParentWorktree(parent, 'only', singleSlicePlan); + // Second seed should not nest parent/only/only/... + seedSliceFromParentWorktree(parent, 'only', singleSlicePlan); + + expect(existsSync(join(parent, 'only', 'only'))).toBe(false); + expect(readFileSync(join(parent, 'only', 'a.txt'), 'utf8')).toBe('a\n'); + }); +}); + 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..afc8c175 100644 --- a/src/orchestrator/src/epic-sandbox-merge.ts +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -173,6 +173,43 @@ function pruneEmptyDirs(rootDir: string, dir: string = rootDir): void { } } +/** + * Codebase-mode seed: copy the parent worktree's contents into the slice + * sandbox so that pi-actions run against pre-existing cwd code. The parent + * is the `git worktree add` of the source repo; its contents = source HEAD. + * + * Excluded from the seed: + * - sibling slice subdirs (other entries in `plan.slices`) + * - the `__epic__/` reserved merge dir + * - `.git` (the worktree pointer file/dir; copying it would break the + * pointer chain into the source repo) + * + * Returns the slice sandbox path. Safe to re-invoke. + * + * TODO(cook-codebase-mode follow-on): multi-slice brownfield runs file-copy + * the entire parent (cwd repo) into every slice worktree, producing O(slices * + * repoSize) on-disk duplication. Either migrate per-slice worktrees to real + * `git worktree add` calls off the run-level cook branch, or switch epic-merge + * to a diff-based mechanism so the per-slice copy can stay cheap. + */ +export function seedSliceFromParentWorktree(parentSandboxDir: string, sliceId: string, plan: Plan): string { + const sliceDir = resolveSliceWorktreeDir(parentSandboxDir, sliceId); + mkdirSync(sliceDir, { recursive: true }); + + const excludedNames = new Set(['.git', EPIC_MERGE_SEGMENT]); + for (const s of plan.slices) excludedNames.add(s.id); + + const parent = resolve(parentSandboxDir); + for (const entry of readdirSync(parent)) { + if (excludedNames.has(entry)) continue; + const src = join(parent, entry); + const dest = join(sliceDir, entry); + cpSync(src, dest, { dereference: false, recursive: true }); + } + + 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..863b7246 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,16 @@ 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') { + seedSliceFromParentWorktree(input.sandboxDir, slice.id, plan); + } 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..043c10d4 100644 --- a/src/orchestrator/src/types.ts +++ b/src/orchestrator/src/types.ts @@ -97,6 +97,12 @@ export type OrchestratorInput = { reports: ReportSink; testRunner: TestRunner; policy: RunPolicy; + /** + * 'fixture' (default): per-slice worktrees are created empty. Greenfield. + * 'codebase': per-slice worktrees are seeded from the parent worktree + * (which is itself a `git worktree add` of the source repo). Brownfield. + */ + sandboxMode?: 'fixture' | 'codebase'; }; export type EpicOutcome = { diff --git a/src/orchestrator/src/worktree.test.ts b/src/orchestrator/src/worktree.test.ts index 07b5869f..d47553f7 100644 --- a/src/orchestrator/src/worktree.test.ts +++ b/src/orchestrator/src/worktree.test.ts @@ -1,4 +1,5 @@ -import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -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,87 @@ 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('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..b7af364a 100644 --- a/src/orchestrator/src/worktree.ts +++ b/src/orchestrator/src/worktree.ts @@ -1,6 +1,7 @@ +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'; export type SandboxInfo = { runId: string; @@ -8,14 +9,43 @@ 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'], + }); + } else { + mkdirSync(sandboxDir, { recursive: true }); + } + return { runId: id, runDir, sandboxDir }; } From 8a9d9d4fd61614bf9f201e2ce5b294d5ea810462 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 27 May 2026 19:50:12 +0200 Subject: [PATCH 2/5] Per-slice git worktree + CoW for untracked content; praxis doc. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3 of FE-755 refactors per-slice worktree population into a hybrid mechanism. Tracked content arrives via `git worktree add` on a slice-level branch (`cook-slice//`); untracked/gitignored content (`node_modules/`, `dist/`, etc.) arrives via copy-on-write (`cp -c` on macOS APFS, `cp --reflink=auto` on Linux btrfs/xfs/zfs, recursive `cpSync` fallback). Solves the multi-slice over-copy problem flagged as a TODO in slice 2 (~90% disk savings on CoW filesystems) while keeping runtime deps present so pi-actions can run `npm test`/`bun test` against the slice worktree. Slice branches use the `cook-slice//` sibling namespace rather than nesting under `cook//` because git refs are leaf-or-directory: with `cook/` already a leaf, the nested form fails with "cannot lock ref ... 'refs/heads/cook/' exists." OrchestratorInput.runId threads through from cook-cli for slice-branch naming. pi-actions.ts unchanged. Test changes: - epic-sandbox-merge.test.ts: rewrites seedSliceFromParentWorktree tests around real git parents — tracked-via-git, untracked-via-CoW, slice-level branch checkout, sibling-slice exclusion, __epic__ exclusion. - brownfield-smoke.integration.test.ts: passes runId; asserts the slice worktree is on its cook-slice// branch. - Verify: 123 test files, 1449 tests pass; 0 errors. Also: docs/praxis/cook-brownfield.md — brunch-specific operational guide covering pre-flight, hand-authored vs ln-scope-derived plans, the absolute-path invocation gotcha, source-byte-identical verification, manual artifact promotion, and cleanup. PLAN.md updated: the multi-slice over-copy follow-on is retired (subsumed by this work). `cook-artifact-lifecycle` remains as a separate proposed frontier: commit slice work, replace mergeSlicesIntoEpicSandbox's file-copy with `git merge` of slice branches (surfaces real conflicts; today's behavior is silent last-slice-wins), and merge epic branches back to cook/ so `git merge cook/` becomes the promotion path. This slice sets up the substrate (real slice branches) so that frontier can land cleanly on top. Co-Authored-By: Claude --- docs/praxis/cook-brownfield.md | 118 ++++++++++++++++ memory/PLAN.md | 5 +- .../src/brownfield-smoke.integration.test.ts | 9 ++ src/orchestrator/src/cook-cli.ts | 3 +- .../src/epic-sandbox-merge.test.ts | 130 ++++++++++-------- src/orchestrator/src/epic-sandbox-merge.ts | 83 ++++++++--- src/orchestrator/src/net-compiler.ts | 5 +- src/orchestrator/src/types.ts | 10 +- 8 files changed, 286 insertions(+), 77 deletions(-) create mode 100644 docs/praxis/cook-brownfield.md diff --git a/docs/praxis/cook-brownfield.md b/docs/praxis/cook-brownfield.md new file mode 100644 index 00000000..def01733 --- /dev/null +++ b/docs/praxis/cook-brownfield.md @@ -0,0 +1,118 @@ +# Cook brownfield on brunch + +Run `brunch cook` against the brunch repo. + +## 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`. Two ways to produce it. + +### A. Hand-author + +```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:** + +- 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. + +### B. Generate from a `memory/PLAN.md` frontier + +Cook's plan format is the orchestrator runtime, not the planning vocabulary — frontiers don't map mechanically. + +Two bridges, both still manual review at the end: + +- **`/ln-scope` then translate.** Run the skill on a frontier to get a scope card (Target Behavior + Acceptance + Verification), then translate to YAML by hand. Most disciplined. +- **One-shot pi translation.** 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. + +Long-term answer: `petri-graph-compilation` (blocked on FE-700) compiles cook nets directly from workspace graph, no `plan.yaml` step. + +## 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 aa7bd944..58ca61a7 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -30,7 +30,7 @@ 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 + `git worktree add` + per-slice parent-population (file-copy with `.git` / sibling-slice / `__epic__/` exclusion). 2026-05-26 outer-loop smoke against a tmpdir git repo + real pi confirmed source-byte-identical isolation and in-place file modification work. Three follow-on findings flagged below. +- `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). @@ -38,8 +38,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen #### 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 promotion path is missing** — the cook branch (`cook/`) has HEAD === source HEAD; the modification lives in two **untracked subdirs** of the cook branch's working tree (`/worktree//` and `/worktree/__epic__//`). No "merge cook/ into HEAD" story today. Tied to slice-2 review finding 2 (worktree + branch GC). Worth a `cook-artifact-lifecycle` frontier. -- **Multi-slice brownfield over-copy** — already captured as a TODO in `seedSliceFromParentWorktree`; slice 2's 1-slice fixture didn't exercise it. Either real-`git worktree add` per slice off the run-level branch, or diff-based epic-merge. +- **`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 diff --git a/src/orchestrator/src/brownfield-smoke.integration.test.ts b/src/orchestrator/src/brownfield-smoke.integration.test.ts index 8ac7f6d8..e6149d7c 100644 --- a/src/orchestrator/src/brownfield-smoke.integration.test.ts +++ b/src/orchestrator/src/brownfield-smoke.integration.test.ts @@ -176,6 +176,7 @@ describe('brownfield smoke — 1-slice 1-epic codebase mode', () => { testRunner: fakeTestRunner, policy: { maxRetries: 3 }, sandboxMode: 'codebase', + runId: sandbox.runId, }); expect(result.status).toBe('completed'); @@ -206,5 +207,13 @@ describe('brownfield smoke — 1-slice 1-epic codebase mode', () => { 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.ts b/src/orchestrator/src/cook-cli.ts index 03ea80a1..476afd08 100644 --- a/src/orchestrator/src/cook-cli.ts +++ b/src/orchestrator/src/cook-cli.ts @@ -127,7 +127,7 @@ export async function runCook(opts: CookOptions): Promise { const plan = loadPlan(resolved.planPath); const launchCwd = process.env.BRUNCH_LAUNCH_CWD || process.cwd(); - const { sandboxDir, runDir } = + const { sandboxDir, runDir, runId } = resolved.mode === 'codebase' ? createSandbox(launchCwd, undefined, { mode: 'codebase', sourceDir: resolved.sourceDir }) : createSandbox(launchCwd); @@ -162,6 +162,7 @@ export async function runCook(opts: CookOptions): Promise { testRunner, policy: { maxRetries: opts.maxRetries }, sandboxMode: resolved.mode === 'codebase' ? 'codebase' : 'fixture', + runId, }); const duration = fmtDuration(Date.now() - runStart); diff --git a/src/orchestrator/src/epic-sandbox-merge.test.ts b/src/orchestrator/src/epic-sandbox-merge.test.ts index 895beaf3..0e67a6f3 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,7 +9,7 @@ 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'; @@ -204,24 +205,81 @@ describe('seedSliceFromParentWorktree', () => { slices: [{ id: 'only', epic_id: 'e1', definition: '', depends_on: [], verification: [] }], }; - it('copies parent worktree contents into the slice dir', () => { - const parent = mkdtempSync(join(tmpdir(), 'cook-parent-')); - dirs.push(parent); - // Parent worktree mimics a brownfield codebase content layout - mkdirSync(join(parent, 'src'), { recursive: true }); - writeFileSync(join(parent, 'README.md'), '# project\n'); - writeFileSync(join(parent, 'src', 'a.ts'), 'export const a = 1;\n'); + /** + * 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); + 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('excludes sibling slice subdirs from the seed', () => { - const parent = mkdtempSync(join(tmpdir(), 'cook-parent-')); - dirs.push(parent); + 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: [ @@ -229,57 +287,21 @@ describe('seedSliceFromParentWorktree', () => { { id: 'second', epic_id: 'e1', definition: '', depends_on: [], verification: [] }, ], }; - writeFileSync(join(parent, 'shared.txt'), 'shared\n'); - mkdirSync(join(parent, 'first'), { recursive: true }); - writeFileSync(join(parent, 'first', 'a.txt'), 'first work\n'); + addUntracked('first/already-cooked.txt', 'first slice output\n'); - const sliceDir = seedSliceFromParentWorktree(parent, 'second', planTwo); + const sliceDir = seedSliceFromParentWorktree(parent, 'second', planTwo, 'r4'); - // shared.txt should be present (it's parent-level) - expect(readFileSync(join(sliceDir, 'shared.txt'), 'utf8')).toBe('shared\n'); - // The 'first' slice dir must NOT be nested inside the 'second' slice dir expect(existsSync(join(sliceDir, 'first'))).toBe(false); }); - it('excludes __epic__ reserved directory from the seed', () => { - const parent = mkdtempSync(join(tmpdir(), 'cook-parent-')); - dirs.push(parent); - mkdirSync(join(parent, '__epic__', 'e1'), { recursive: true }); - writeFileSync(join(parent, '__epic__', 'e1', 'leftover.txt'), 'leftover\n'); - writeFileSync(join(parent, 'kept.txt'), 'kept\n'); + 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); + const sliceDir = seedSliceFromParentWorktree(parent, 'only', singleSlicePlan, 'r5'); - expect(readFileSync(join(sliceDir, 'kept.txt'), 'utf8')).toBe('kept\n'); expect(existsSync(join(sliceDir, '__epic__'))).toBe(false); }); - - it('excludes .git from the seed (git worktree pointer files would break in a nested copy)', () => { - const parent = mkdtempSync(join(tmpdir(), 'cook-parent-')); - dirs.push(parent); - mkdirSync(join(parent, '.git'), { recursive: true }); - writeFileSync(join(parent, '.git', 'HEAD'), 'ref: refs/heads/cook/x\n'); - writeFileSync(join(parent, 'code.ts'), 'export {};\n'); - - const sliceDir = seedSliceFromParentWorktree(parent, 'only', singleSlicePlan); - - expect(readFileSync(join(sliceDir, 'code.ts'), 'utf8')).toBe('export {};\n'); - expect(existsSync(join(sliceDir, '.git'))).toBe(false); - }); - - it('does not nest the slice dir inside itself when re-seeding', () => { - const parent = mkdtempSync(join(tmpdir(), 'cook-parent-')); - dirs.push(parent); - writeFileSync(join(parent, 'a.txt'), 'a\n'); - - // First seed: creates parent/only/a.txt - seedSliceFromParentWorktree(parent, 'only', singleSlicePlan); - // Second seed should not nest parent/only/only/... - seedSliceFromParentWorktree(parent, 'only', singleSlicePlan); - - expect(existsSync(join(parent, 'only', 'only'))).toBe(false); - expect(readFileSync(join(parent, 'only', 'a.txt'), 'utf8')).toBe('a\n'); - }); }); describe('mergeSlicesIntoEpicSandbox', () => { diff --git a/src/orchestrator/src/epic-sandbox-merge.ts b/src/orchestrator/src/epic-sandbox-merge.ts index afc8c175..7c9a4287 100644 --- a/src/orchestrator/src/epic-sandbox-merge.ts +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -4,6 +4,7 @@ // 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, spawnSync } from 'node:child_process'; import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; import { dirname, join, relative, resolve, sep } from 'node:path'; @@ -174,37 +175,87 @@ function pruneEmptyDirs(rootDir: string, dir: string = rootDir): void { } /** - * Codebase-mode seed: copy the parent worktree's contents into the slice - * sandbox so that pi-actions run against pre-existing cwd code. The parent - * is the `git worktree add` of the source repo; its contents = source HEAD. + * Platform-aware copy-on-write (reflink/clonefile) where supported; falls + * 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. + */ +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 }); +} + +/** + * 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 seed: + * Excluded from the untracked CoW step: * - sibling slice subdirs (other entries in `plan.slices`) * - the `__epic__/` reserved merge dir - * - `.git` (the worktree pointer file/dir; copying it would break the - * pointer chain into the source repo) + * - `.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. Safe to re-invoke. + * 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-codebase-mode follow-on): multi-slice brownfield runs file-copy - * the entire parent (cwd repo) into every slice worktree, producing O(slices * - * repoSize) on-disk duplication. Either migrate per-slice worktrees to real - * `git worktree add` calls off the run-level cook branch, or switch epic-merge - * to a diff-based mechanism so the per-slice copy can stay cheap. + * 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): string { +export function seedSliceFromParentWorktree( + parentSandboxDir: string, + sliceId: string, + plan: Plan, + runId: string, +): string { const sliceDir = resolveSliceWorktreeDir(parentSandboxDir, sliceId); - mkdirSync(sliceDir, { recursive: true }); + // 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); const parent = resolve(parentSandboxDir); for (const entry of readdirSync(parent)) { if (excludedNames.has(entry)) continue; - const src = join(parent, entry); const dest = join(sliceDir, entry); - cpSync(src, dest, { dereference: false, recursive: true }); + if (existsSync(dest)) continue; // already present from git worktree (tracked) + const src = join(parent, entry); + cowCopy(src, dest); } return sliceDir; diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts index 863b7246..a0a826e3 100644 --- a/src/orchestrator/src/net-compiler.ts +++ b/src/orchestrator/src/net-compiler.ts @@ -340,7 +340,10 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, // existing code instead of writing into an empty dir. for (const slice of plan.slices) { if (input.sandboxMode === 'codebase') { - seedSliceFromParentWorktree(input.sandboxDir, slice.id, plan); + 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 }); } diff --git a/src/orchestrator/src/types.ts b/src/orchestrator/src/types.ts index 043c10d4..bfbca2b6 100644 --- a/src/orchestrator/src/types.ts +++ b/src/orchestrator/src/types.ts @@ -99,10 +99,16 @@ export type OrchestratorInput = { policy: RunPolicy; /** * 'fixture' (default): per-slice worktrees are created empty. Greenfield. - * 'codebase': per-slice worktrees are seeded from the parent worktree - * (which is itself a `git worktree add` of the source repo). Brownfield. + * '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 = { From 7d77298035f3de5ff2dc5a103f0bd653e6792fe8 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 27 May 2026 19:54:13 +0200 Subject: [PATCH 3/5] Rename praxis doc to orchestration-guide + reframe plan-authoring section. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames docs/praxis/cook-brownfield.md → docs/praxis/orchestration-guide.md to make room for the doc to grow beyond pure brownfield-mode notes (e.g. greenfield fixture workflows, future graph-compiled plans). Restructures the "Author the plan" section to lead with the intended long-term target: read the plan from a spec-graph projection emitted by `petri-graph-compilation` (currently blocked on FE-700 intent-graph- semantics). Until that lands, two interim bridges remain — `/ln-scope`- then-translate (most disciplined) and one-shot pi translation (cheap) — plus hand-authoring as an escape hatch for one-off experiments. Co-Authored-By: Claude --- ...k-brownfield.md => orchestration-guide.md} | 59 ++++++++++--------- 1 file changed, 32 insertions(+), 27 deletions(-) rename docs/praxis/{cook-brownfield.md => orchestration-guide.md} (61%) diff --git a/docs/praxis/cook-brownfield.md b/docs/praxis/orchestration-guide.md similarity index 61% rename from docs/praxis/cook-brownfield.md rename to docs/praxis/orchestration-guide.md index def01733..002c022e 100644 --- a/docs/praxis/cook-brownfield.md +++ b/docs/praxis/orchestration-guide.md @@ -1,6 +1,6 @@ -# Cook brownfield on brunch +# Orchestration guide — cook on brunch -Run `brunch cook` against the brunch repo. +Run `brunch cook` against the brunch repo in codebase (brownfield) mode. ## Pre-flight @@ -14,9 +14,36 @@ git status --porcelain --untracked-files=no # must be empty ## Author the plan -Cook reads ONE file: `.brunch/cook/plan.yaml`. Two ways to produce it. +Cook reads ONE file: `.brunch/cook/plan.yaml`. -### A. Hand-author +**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: @@ -39,34 +66,12 @@ slices: target: ``` -**Discipline:** +### 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. -### B. Generate from a `memory/PLAN.md` frontier - -Cook's plan format is the orchestrator runtime, not the planning vocabulary — frontiers don't map mechanically. - -Two bridges, both still manual review at the end: - -- **`/ln-scope` then translate.** Run the skill on a frontier to get a scope card (Target Behavior + Acceptance + Verification), then translate to YAML by hand. Most disciplined. -- **One-shot pi translation.** 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. - -Long-term answer: `petri-graph-compilation` (blocked on FE-700) compiles cook nets directly from workspace graph, no `plan.yaml` step. - ## Cook ```sh From db848b1e76242ffcd9ea6ded7bf83edb05cd8937 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 27 May 2026 22:44:40 +0200 Subject: [PATCH 4/5] Reject codebase slice ids that collide with repo entries. Fail before git worktree add when a slice id matches an existing top-level path in the parent worktree (e.g. src/). Co-authored-by: Cursor --- src/orchestrator/src/epic-sandbox-merge.test.ts | 12 ++++++++++++ src/orchestrator/src/epic-sandbox-merge.ts | 9 ++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/orchestrator/src/epic-sandbox-merge.test.ts b/src/orchestrator/src/epic-sandbox-merge.test.ts index 0e67a6f3..7f3b5bf0 100644 --- a/src/orchestrator/src/epic-sandbox-merge.test.ts +++ b/src/orchestrator/src/epic-sandbox-merge.test.ts @@ -302,6 +302,18 @@ describe('seedSliceFromParentWorktree', () => { 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', () => { diff --git a/src/orchestrator/src/epic-sandbox-merge.ts b/src/orchestrator/src/epic-sandbox-merge.ts index 7c9a4287..75d258ec 100644 --- a/src/orchestrator/src/epic-sandbox-merge.ts +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -174,8 +174,14 @@ 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`); + } +} + /** - * Platform-aware copy-on-write (reflink/clonefile) where supported; falls * 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 @@ -228,6 +234,7 @@ export function seedSliceFromParentWorktree( plan: Plan, runId: string, ): string { + assertSliceWorktreePathAvailable(parentSandboxDir, sliceId); const sliceDir = resolveSliceWorktreeDir(parentSandboxDir, sliceId); // 1. Real git worktree: tracked content arrives via git checkout, slice From 3cc97a236181b5d7afc913908837f227081791b8 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 27 May 2026 23:13:07 +0200 Subject: [PATCH 5/5] Propagate untracked deps into codebase parent worktree. git worktree add only checks out tracked files; CoW-copy missing top-level entries from the source cwd when creating the parent sandbox so slice seeding inherits node_modules/dist-style artifacts. Extract cowCopy into cow-copy.ts with complete JSDoc and reuse for slice seed. Co-authored-by: Cursor --- src/orchestrator/src/cow-copy.ts | 42 ++++++++++++++++++++++ src/orchestrator/src/epic-sandbox-merge.ts | 30 ++-------------- src/orchestrator/src/worktree.test.ts | 19 ++++++++-- src/orchestrator/src/worktree.ts | 6 ++++ 4 files changed, 68 insertions(+), 29 deletions(-) create mode 100644 src/orchestrator/src/cow-copy.ts 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.ts b/src/orchestrator/src/epic-sandbox-merge.ts index 75d258ec..627ea18f 100644 --- a/src/orchestrator/src/epic-sandbox-merge.ts +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -4,10 +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, spawnSync } from 'node:child_process'; +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 = { @@ -181,23 +182,6 @@ function assertSliceWorktreePathAvailable(parentSandboxDir: string, sliceId: str } } -/** - * 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. - */ -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 }); -} - /** * Codebase-mode seed: prepare the per-slice worktree as a real `git worktree` * checked out on a slice-level branch (`cook-slice//`) off @@ -255,15 +239,7 @@ export function seedSliceFromParentWorktree( // `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); - - const parent = resolve(parentSandboxDir); - for (const entry of readdirSync(parent)) { - if (excludedNames.has(entry)) continue; - const dest = join(sliceDir, entry); - if (existsSync(dest)) continue; // already present from git worktree (tracked) - const src = join(parent, entry); - cowCopy(src, dest); - } + copyMissingTopLevelEntries(parentSandboxDir, sliceDir, excludedNames); return sliceDir; } diff --git a/src/orchestrator/src/worktree.test.ts b/src/orchestrator/src/worktree.test.ts index d47553f7..0ab4f869 100644 --- a/src/orchestrator/src/worktree.test.ts +++ b/src/orchestrator/src/worktree.test.ts @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process'; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +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'; @@ -99,6 +99,21 @@ describe('createSandbox — codebase mode', () => { 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-'); diff --git a/src/orchestrator/src/worktree.ts b/src/orchestrator/src/worktree.ts index b7af364a..3d08e447 100644 --- a/src/orchestrator/src/worktree.ts +++ b/src/orchestrator/src/worktree.ts @@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; +import { copyMissingTopLevelEntries } from './cow-copy.js'; + export type SandboxInfo = { runId: string; runDir: string; @@ -43,6 +45,10 @@ export function createSandbox( 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 }); }