Skip to content
Closed
101 changes: 101 additions & 0 deletions memory/CARDS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Scope cards — cook-artifact-lifecycle (FE-883)

Execution queue for `cook-artifact-lifecycle` (FE-883, branch
`ka/fe-883-orchestrator-improvements`, on FE-864).

**Reality check (corrected after basing on FE-864, the current seam):** the
brownfield git-merge composer already exists — `run-artifact.ts` (commit
871ef087): `commitSliceWorktree` + `foldSliceBranches` do a real `git merge-tree`
3-way fold of per-slice branches in dependency order, fail-closed on conflicts,
pure plumbing (I135-K preserved). It was deliberately left **unwired** pending "a
live-run check of the dependency-seed interaction". So FE-883 is *wire the
existing composer*, not *build it*.

This matches the Slice-1 spike decision (2026-06-18): git-merge for brownfield
(common ancestor → real 3-way), file-copy union for greenfield (no common
ancestor), elevate collisions to a first-class outcome.

---

## Slice 1 — wire the run-artifact composer into the live path

Status: **in progress.**

### Sub-steps

```
✓ 1a (done, commit 2357f941) — composer correct under dependency-seeding. The
deferred "live-run check" failed: a dependent slice extending a dep-seeded file
false-conflicted because slice branches share no inter-slice ancestry. Fix:
commit each slice recording its dependency commits as parents, so the fold's
merge-base is the dependency. Regression test added; unfaithful happy-path test
corrected. (epic-sandbox-merge.ts file-copy untouched.)

✓ mechanism (commits fadb1b52, 5e1d8d32) — proved + factored the fold so both
1b and 1c can use it: foldToCommit (fold N slice commits onto a base, fail-closed,
no ref write) + materializeFoldedWorktree (fold + `git worktree add --detach`,
rework-safe). Tests pin: 3-way merge of different-hunk edits to one file keeps
both; the fold materializes on disk in a verify worktree.

✓ 1c DECISION (2026-06-18): verify against the folded tree (option i). One
composition path → the tree verified == the tree shipped; no verify≠ship gap on
same-file edits. The worktree-checkout unknown is de-risked by materializeFoldedWorktree.

✓ 1b/1c INTEGRATION (done, commit d92ce38b) — engine wired end-to-end:
- net-compiler verify-epic: brownfield uses materializeEpicVerifyTree (commit
slices dep-order → fold → detached worktree at __epic__/<epicId>/ → relink
node_modules); fold conflict → fail the epic (passed:false report → fail sibling).
Greenfield keeps the file-copy union.
- cook-cli promotion: brownfield calls harvestCookRun; fold conflicts → fatal run
outcome. I135-K preserved (all plumbing).
- commitSliceWorktree made idempotent so promotion reuses the commits verify made.
- Stale epic-sandbox-merge.ts TODO updated; SPEC I124-K amended (plan.mode fork).
- Full orchestrator suite green (672). Single-slice brownfield-smoke exercises the
engine plumbing; a *multi-slice* end-to-end engine test is still a gap to add.

○ 1d (remaining) — retire the now-dead promoteBrownfieldRun + BrownfieldPromoteOptions.
Blocked on rewriting the landCookBranch test fixture (repoWithPromotedCook uses
promoteBrownfieldRun to build a promoted branch — rebuild it via harvestCookRun or
a plain commit). mergeSlicesIntoEpicSandbox STAYS (it is the greenfield composer).
```

### Acceptance Criteria (slice-level)

```
✓ dep-seed — a dependent slice extending a dep-seeded file folds clean (done, 1a)
○ brownfield-3way — two brownfield slices editing different hunks of the same
pre-existing file both survive promotion (the file-copy union drops one)
○ brownfield-conflict — a true overlapping-hunk conflict surfaces as a fatal run
outcome, not a buried event field
○ checkout-untouched — promotion still never touches the user's branch / tree /
index (I135-K)
○ greenfield-unchanged — serial-greenfield shared-tree + parallel-greenfield
file-copy paths preserved
```

### Verification Approach

```
- Inner: run-artifact.test.ts (done), promote-run.test.ts, epic-sandbox-merge.test.ts
- Middle: brownfield-smoke.integration.test.ts — seeded repo, overlapping slices
- Outer: dogfood a multi-slice brownfield cook with an intentional file overlap
```

---

## Slice 2 — worktree + branch GC / lifecycle (light) — `next` after Slice 1

A finished run reclaims its worktrees + `brunch/{run,slice}/*` refs instead of the
operator-owned cleanup `worktree.ts` documents. Ref-set depends on Slice 1's final
branch topology, so scope after it lands. Keep-on-failure for inspection; promoted
artifact survives GC.

## Slice 3 — per-slice build-cache write isolation (candidate)

May instead be an FE-879 follow-on (FE-879 owns `SHAREABLE_TOP_LEVEL_ENTRIES`).
Decide ownership before scoping.

## Out of scope (noted)

- Sync `git worktree add` serialization (`epic-sandbox-merge.ts:288`) — perf, not
correctness; FE-879 laziness already bounds worktree count.
16 changes: 16 additions & 0 deletions memory/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen

### Parallel / Low-conflict

- `cook-artifact-lifecycle` — **FE-883**, branch `ka/fe-883-orchestrator-improvements` (on FE-864). Wire the already-built `run-artifact.ts` git-merge composer (871ef087) into the live promotion/verify path, replacing the file-copy union; then worktree/branch GC. Slice 1a (composer correct under dep-seeding) landed; wiring + GC remain. Execution queue in `memory/CARDS.md`.
- `first-run-provider-setup` — provider/key UX and runtime seam can progress independently of semantic-stack work.
- `workspace-gitignore-assist` — small workspace hygiene surface with low overlap.
- `productized-web-research` — waits on prompt/context scenario substrate for probe quality, but can remain separate from semantic schema work.
Expand Down Expand Up @@ -475,6 +476,21 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen
- **Traceability:** Requirement 49; D166-K (extend to brownfield), A49; cook-codebase-mode promotion follow-on.
- **Design docs:** `docs/design/orchestrator.md`; SPEC §A49.

### cook-artifact-lifecycle

- **Name:** Cook artifact lifecycle — wire the git-merge composer + worktree/branch GC
- **Linear:** FE-883
- **Kind:** structural (Slice 1 amends I124-K on wiring); hardening
- **Status:** in progress (2026-06-18) — branch `ka/fe-883-orchestrator-improvements` on FE-864; queue in `memory/CARDS.md`. Slice 1a landed (`2357f941`).
- **Objective:** Wire the already-built `run-artifact.ts` composer (`commitSliceWorktree` + `foldSliceBranches`, 871ef087) into cook's live promotion/verify path, replacing the file-copy union (`mergeSlicesIntoEpicSandbox` / `promoteBrownfieldRun` cpSync) whose declaration-order last-slice-wins silently drops same-file/different-hunk edits. Brownfield only (common ancestor → real 3-way); greenfield keeps the file-copy union (no common ancestor). Then GC of run worktrees + `brunch/{run,slice}/*` refs.
- **Slice 1a (done, `2357f941`):** resolved the dependency-seed interaction the composer was left unwired for — slice commits now record their dependency commits as parents so the fold's merge-base is the dependency (a dep-seeded file reads as an edit, not an add/add false conflict). Regression test + unfaithful happy-path test corrected. Composer correctness only; still unwired.
- **Remaining:** 1b wire into promotion + cook-cli (preserve I135-K), 1c verify-epic consistency (design fork: verify against the folded tree vs keep file-copy + elevate collisions), 1d delete the superseded file-copy path + the stale `epic-sandbox-merge.ts:226` TODO. Then Slice 2 (GC).
- **Main risk (1a) — RESOLVED:** see Slice 1a. Open: 1c is a design fork on verify-epic consistency.
- **Acceptance / verification:** see `memory/CARDS.md`.
- **Depends on:** `brownfield-promotion` FE-877 (done), the FE-864 composer 871ef087 (done), FE-879 lazy/shared-`node_modules` (done).
- **Traceability:** Requirement 49; I124-K (file-copy → fork on `plan.mode` on wiring), I135-K (promotion checkout-untouched, preserved); A49. Precursor to Horizon `parallel-merge-conflict-reconciliation`.
- **Design docs:** `docs/design/orchestrator.md`; SPEC §A49.

### brunch-ship

- **Name:** Brunch ship — one-shot autonomous spec→feature wrapper
Expand Down
2 changes: 1 addition & 1 deletion memory/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ Each invariant is a formalization candidate: the property is stated in human lan
| 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 `<cwd>/.brunch/cook/runs/<runId>/worktree/`. Slice layout follows policy (D165-K): serial greenfield runs all slices in the single shared run tree (verify-epic in place, no `__epic__`); parallel greenfield and brownfield isolate per slice and merge into `__epic__/<epicId>/`. Brownfield clones the cwd repo and preserves the source repo's HEAD and tracked-file state byte-identically; greenfield never clones the source. | worktree.test.ts, brownfield-smoke.integration.test.ts, engine-contract.test.ts | Requirement 49; D159-K, D164-K, D165-K |
| I124-K | Epic verification runs against a freshly-rebuilt `<parentSandboxDir>/__epic__/<epicId>/` 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 |
| I124-K | Epic verification runs against a freshly-rebuilt `<parentSandboxDir>/__epic__/<epicId>/` dir holding the merge of its completed slices' worktrees; per-slice worktrees are not mutated by the merge. **The composer forks on `plan.mode` (FE-883):** **brownfield** composes by a `git merge-tree` 3-way fold of committed per-slice branches in dependency order (slice commits carry their dependency commits as parents so dep-seeded files merge as edits, not add/add) — different-hunk edits to one file both survive, and a real conflict is **fail-closed** (the epic fails / promotion halts, not silent last-slice-wins). This is the *same* fold promotion ships, so the verified tree equals the shipped tree. **Greenfield** keeps the file-copy union (no common ancestor for a 3-way): later slices in declaration order win on path collisions, reported via the `epic-sandbox-merged` event. | epic-sandbox-merge.test.ts, run-artifact.test.ts, engine-contract.test.ts | Requirement 49; D159-K (FE-883) |
| 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) |
| I126-K | The cook evaluator observes, never produces: `evaluate-done` runs with read-only tools (`toolsForAction('evaluate-done') === 'read'`) so it cannot mutate the sandbox during evaluation, and per-slice `done` reflects real execution of the slice's verification targets — ≥1 target and every target passing via the shared `runVerification` seam (one `TestRunner`; `evaluate-done`, `verify-epic`, and the net `run-tests` path share it — FE-872 unification; `evaluateVerificationTargets` / private `runTest` deleted) — rather than an LLM verdict. | pi-actions.test.ts, engine-contract.test.ts, brownfield-smoke.integration.test.ts | Requirements 46–50; D161-K (FE-813) |
| I127-K | Brunch's Petrinaut stream markings are count-only (`Marking = Record<PlaceId, number>`): the static reducer and the live bus produce per-place token counts in each firing's arc-scoped consume/produce delta (A99; `initialState` is the single full marking), with no `TokenColour[]` arm. The wire `NetDefinition` is plain-graph (no `colorId` or other SDCPN fields), so slice/colour identity has no wire carrier — identity fold only. The projected definition validates against the mirrored `brunchNetDefinitionSchema` under `.strict()`. | petrinaut-stream-export.test.ts (arc-scoped delta oracle + strict-schema validation), petrinaut-stream-bus.test.ts (replay-equivalence) | Requirement 48; D162-K, D163-K (FE-819) |
Expand Down
76 changes: 46 additions & 30 deletions src/orchestrator/src/cook-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';

import { cookBannerLines, cookSummaryLines } from './cook-report.js';
import { type CookFinishLand, cookBannerLines, cookFinishLines, cookSummaryLines } from './cook-report.js';
import { createOrchestrator } from './engine.js';
import { type MergeConflict, mergeCompletedSlicesIntoTree } from './epic-sandbox-merge.js';
import { FileReportSink } from './file-report-sink.js';
Expand All @@ -15,7 +15,8 @@ import { createPiActions } from './pi-actions.js';
import { loadPlan } from './plan-loader.js';
import type { CookBus } from './presenter.js';
import { resolveToolchain } from './project-profile.js';
import { landCookBranch, promoteBrownfieldRun, promoteGreenfieldRun } from './promote-run.js';
import { landCookBranch, promoteGreenfieldRun } from './promote-run.js';
import { harvestCookRun } from './run-artifact.js';
import { brunchRef } from './run-refs.js';
import { parseSpecId, resolveLatestSpecPlanPath, specPlanPath, specsRootDir } from './spec-plan-paths.js';
import { ToolchainTestRunner } from './test-runner.js';
Expand Down Expand Up @@ -564,44 +565,53 @@ export async function runCook(opts: CookOptions, bus: CookBus): Promise<void> {
line('');
} else {
try {
const source = promotionSourceDir({
sliceLayout,
sandboxDir,
runDir,
plan,
completedSliceIds: result.slices.filter((s) => s.status === 'completed').map((s) => s.sliceId),
});
for (const c of source.conflicts) {
line(` ! merge conflict on ${c.path} (slices ${c.slices.join(', ')}; kept ${c.winner})`);
}
const promoted = promoting(`promoting → ${brunchRef.run(runId)}`, () =>
promoteBrownfieldRun({
const completedSliceIds = result.slices
.filter((s) => s.status === 'completed')
.map((s) => s.sliceId);
// Compose by git merge-tree fold (FE-883): per-slice history, fail-closed
// on real conflicts, all plumbing (the user's checkout is never touched).
const artifact = promoting(`promoting → ${brunchRef.run(runId)}`, () =>
harvestCookRun({
sourceDir: sandbox.sourceDir,
sourceTreeDir: source.dir,
parentSandboxDir: sandboxDir,
runId,
plan,
completedSliceIds,
}),
);
if (artifact.conflicts.length > 0) {
for (const c of artifact.conflicts) {
line(` ✗ merge conflict in slice ${c.sliceId} on ${c.paths.join(', ')}`);
}
line(
` ✗ promotion halted at ${artifact.branch} @ ${artifact.head.slice(0, 8)} — resolve the conflict and re-run`,
);
line('');
recordCookExitStatus(false);
return;
Comment thread
cursor[bot] marked this conversation as resolved.
}
let land: CookFinishLand | undefined;
if (opts.landBranch) {
const landed = promoting(`landing → ${promoted.branch} into the active branch`, () =>
const landed = promoting(`landing → ${artifact.branch} into the active branch`, () =>
landCookBranch({ sourceDir: sandbox.sourceDir, runId }),
);
if (landed.kind === 'landed') {
line(` ✓ promoted + landed ${promoted.branch} onto ${landed.branch} (${landed.mode})`);
land = { kind: 'landed', branch: landed.branch, mode: landed.mode };
} else if (landed.kind === 'refused') {
line(
` ✓ promoted → ${promoted.branch} @ ${promoted.commit.slice(0, 8)} (not landed: working tree ${landed.reason}; merge it when ready)`,
);
land = { kind: 'refused', reason: landed.reason };
} else {
line(
` ✓ promoted → ${promoted.branch} @ ${promoted.commit.slice(0, 8)} (not landed: merge conflict on ${landed.branch}; resolve with \`git merge ${promoted.branch}\`)`,
);
land = { kind: 'conflict', branch: landed.branch };
}
} else {
line(
` ✓ promoted → ${promoted.branch} @ ${promoted.commit.slice(0, 8)} (merge it into your branch when ready)`,
);
}
line('');
for (const l of cookFinishLines({
shape: 'brownfield',
dir: sandbox.sourceDir,
branch: artifact.branch,
commit: artifact.head,
...(land ? { land } : {}),
})) {
line(l);
}
} catch (err) {
const reason = `promotion failed: ${err instanceof Error ? err.message : String(err)}`;
line(` ✗ ${reason}`);
Expand Down Expand Up @@ -635,8 +645,14 @@ export async function runCook(opts: CookOptions, bus: CookBus): Promise<void> {
force: opts.force,
}),
);
line(` ✓ promoted → ${promoted.target} (${promoted.branch} @ ${promoted.commit.slice(0, 8)})`);
line('');
for (const l of cookFinishLines({
shape: 'greenfield',
dir: promoted.target,
branch: promoted.branch,
commit: promoted.commit,
})) {
line(l);
}
} catch (err) {
const reason = `promotion failed: ${err instanceof Error ? err.message : String(err)}`;
line(` ✗ ${reason}`);
Expand Down
Loading
Loading