Skip to content

Commit a0ebbda

Browse files
FE-877: brownfield promotion — commit the cook result onto cook/<runId> via git plumbing
Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019ecb9a-9a08-733b-833d-76885fc8243a
1 parent 8b72f34 commit a0ebbda

4 files changed

Lines changed: 270 additions & 13 deletions

File tree

memory/PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen
5959
4. `dogfood-spike` (ln-spike) — **(done — 2026-06-16)** ran a real brownfield cook (hand-authored 2-slice plan: feature + wiring, `node:http` app) against a throwaway git repo. **Verdict:** chain works end-to-end (CoW worktree, clean-tree gate, per-slice→`__epic__` merge composed the wiring, TDD red/green, working branch untouched); the agent wired the feature reachable and **self-authored a genuine boot-and-probe** integration test (imports the real entry, `listen(0)`, `http.get('/health')`, asserts not-404). Orphan did **not** reproduce — but reachability was **agent-discretion, not enforced** → confirms the *value* of `integration-oracle`/`app-runtime-probe` (independent, unshortcuttable reachability). Two refinements surfaced: the probe should own the boot mechanism (the agent had to invent a `.js→.ts` resolve hook), and dep-install was unexercised (zero-dep app). Bonus: the `Cannot find module` TDD red was handled as a test-red (not infra) — validates FE-872 slice 1 live.
6060
5. `app-runtime-probe` — **(slices 1–2 landed — FE-875, `runProbe` + `buildProbeSpec`)** build + boot + exercise the host app; the concrete reachability mechanism `integration-oracle` depends on (without it, "reachable" collapses back to "a test that imports the module"). Slice 1: boot + HTTP probe + reachable/not-reachable/infra classification + teardown. Slice 2: harness-owned `ProbeSpec` resolution — `buildProbeSpec(ProbeTarget)` allocates a free ephemeral port and assembles ready/feature URLs from boot-argv + *paths*, so a hardcoded port can't collide under parallel cook (the boot test's hand-rolled port dance is now the production primitive it dogfoods). Stays off the dispatch seam: argv + paths are inputs cook-time grounding will supply; the harness owns only the port pick + URL/env assembly (loopback-only; best-effort ephemeral port with an acknowledged TOCTOU window, no retry framework). Every probe HTTP call (readiness poll + feature request) carries a per-call `AbortSignal.timeout` so a server that accepts a connection but never responds can't hang the probe (and the cook) past the deadline; timeouts are overridable for tests. Remaining: mode-awareness, integration-oracle gating (where the `ProbeTarget` argv/paths come from = `integration-oracle` #6).
6161
6. `integration-oracle` — **(Half A + Half B seam landed — FE-876)** oracle asserts product reachability via `app-runtime-probe`. Half A (off-seam): `Epic.probe?: ProbeTarget` folds a `runProbe` result into the `verify-epic` verdict — after slices merge into `__epic__/<epicId>/`, the epic is `done` only when tests pass **and** the feature is reachable; `not-reachable` is the FE-800 orphan, `infra` is a harness fault. Probe gated behind tests passing (never boot a known-broken build); absent → unchanged unit verdict; reachability rides the existing `report.passed` routing. Half B seam: host-blind `Epic.reachability?: ReachabilityIntent` (architect-emittable, D160-K) + an injectable `ProbeGrounder` (`createPiActions({ groundProbe })`) that cook-time-resolves intent → concrete `ProbeTarget` by reading the worktree; `verify-epic` resolves via `probe ?? ground(reachability)`, a grounder that throws is an `infra` fault (visible, not a silent pass), intent without a grounder is an inert no-op. **Remaining (dispatch seam, lands atomically with the pi-harness contract):** the production `ProbeGrounder` (an `execute`-mode agent that reads the worktree) + architect emission of `reachability` intent — deferred together so intent is enforced the moment it's emitted (avoids perturbing the 3 reference fixtures). Runs in the FE-738 semantic lane. Promotes FE-800's integration-blind follow-on to a frontier. *(grounder impl depends on `agent-extension-host`)*
62-
7. `brownfield-promotion` — commit/merge the cook result into the user's checkout; extends FE-827's greenfield promotion to brownfield.
62+
7. `brownfield-promotion`**(landed — FE-877, `promoteBrownfieldRun`)** commit a completed brownfield cook result onto the repo's own `cook/<runId>` branch as one reviewable commit; extends FE-827's greenfield promotion to brownfield and closes the cook-codebase-mode follow-on (the result no longer sits uncommitted in the worktree). Git plumbing only (`commit-tree` + CAS `update-ref`, parent = the existing `cook/<runId>` base, throwaway index + external work-tree), so the user's active branch, working tree, and index are never touched; gitignored deps don't land. Reuses `promotionSourceDir` to compose the tree across slice layouts. Auto-runs on a completed brownfield cook (no `--out` needed); merging into the working branch stays the **user's** call. Unblocks FE-872's brownfield dep-delta capture.
6363
8. `brunch-ship` — one-shot `brunch serve <specId>` wrapper (prep → recipe → cook → taste → plate), no manual steps. Arc 1 capstone.
6464

6565
**Runtime umbrella + semantic substrate:**

src/orchestrator/src/cook-cli.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { createPetrinautStreamServer, type PetrinautStreamServer } from './petri
1313
import { createPiActions } from './pi-actions.js';
1414
import { loadPlan } from './plan-loader.js';
1515
import { resolveToolchain } from './project-profile.js';
16-
import { promoteGreenfieldRun } from './promote-run.js';
16+
import { promoteBrownfieldRun, promoteGreenfieldRun } from './promote-run.js';
1717
import { parseSpecId, resolveLatestSpecPlanPath, specPlanPath, specsRootDir } from './spec-plan-paths.js';
1818
import { ToolchainTestRunner } from './test-runner.js';
1919
import type { Plan, PlanMode } from './types.js';
@@ -530,13 +530,49 @@ export async function runCook(opts: CookOptions): Promise<void> {
530530
console.error(` ${result.reports.length} events → ${reportsPath}`);
531531
console.error('');
532532

533-
// Promotion-back is opt-in via --out and greenfield-only; a run that did
534-
// not complete promotes nothing (the artifact stays inspectable).
535-
if (opts.outDir) {
536-
if (sandbox.kind === 'codebase') {
537-
console.error(` ! --out promotion is greenfield-only; brownfield output stays at ${sandboxDir}`);
533+
// Brownfield promotion is automatic (the result already lives on the repo's
534+
// own `cook/<runId>` branch); greenfield promotion is opt-in via --out. A run
535+
// that did not complete promotes nothing — the artifact stays inspectable.
536+
if (sandbox.kind === 'codebase') {
537+
if (opts.outDir) {
538+
console.error(` ! --out is ignored for brownfield; the result lands on cook/${runId} in the repo`);
538539
console.error('');
539-
} else if (!ok) {
540+
}
541+
if (!ok) {
542+
console.error(` ! run did not complete — nothing promoted. Artifact: ${sandboxDir}`);
543+
console.error('');
544+
} else {
545+
try {
546+
const source = promotionSourceDir({
547+
sliceLayout,
548+
sandboxDir,
549+
runDir,
550+
plan,
551+
completedSliceIds: result.slices.filter((s) => s.status === 'completed').map((s) => s.sliceId),
552+
});
553+
for (const c of source.conflicts) {
554+
console.error(
555+
` ! merge conflict on ${c.path} (slices ${c.slices.join(', ')}; kept ${c.winner})`,
556+
);
557+
}
558+
const promoted = promoteBrownfieldRun({
559+
sourceDir: sandbox.sourceDir,
560+
sourceTreeDir: source.dir,
561+
runId,
562+
});
563+
console.error(
564+
` ✓ promoted → ${promoted.branch} @ ${promoted.commit.slice(0, 8)} (merge it into your branch when ready)`,
565+
);
566+
console.error('');
567+
} catch (err) {
568+
console.error(` ✗ promotion failed: ${err instanceof Error ? err.message : String(err)}`);
569+
console.error('');
570+
recordCookExitStatus(false);
571+
return;
572+
}
573+
}
574+
} else if (opts.outDir) {
575+
if (!ok) {
540576
console.error(` ! run did not complete — nothing promoted. Artifact: ${sandboxDir}`);
541577
console.error('');
542578
} else {

src/orchestrator/src/promote-run.test.ts

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { join } from 'node:path';
55

66
import { afterEach, describe, expect, it } from 'vitest';
77

8-
import { promoteGreenfieldRun } from './promote-run.js';
8+
import { promoteBrownfieldRun, promoteGreenfieldRun } from './promote-run.js';
99

1010
const dirs: string[] = [];
1111
const GIT_TEST_TIMEOUT_MS = 20_000;
@@ -205,3 +205,153 @@ describe('promoteGreenfieldRun', () => {
205205
GIT_TEST_TIMEOUT_MS,
206206
);
207207
});
208+
209+
describe('promoteBrownfieldRun', () => {
210+
const id = ['-c', 'user.name=t', '-c', 'user.email=t@e'];
211+
212+
// A user repo on `main` with a base commit, plus a cook/<runId> branch at the
213+
// same base (as `git worktree add -b cook/<runId> … HEAD` would create).
214+
function userRepo(): { dir: string; baseHead: string } {
215+
const dir = mkdtempSync(join(tmpdir(), 'cook-userrepo-'));
216+
dirs.push(dir);
217+
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
218+
writeFileSync(join(dir, 'app.ts'), 'export const v = 1;\n');
219+
writeFileSync(join(dir, '.gitignore'), 'node_modules/\n');
220+
execFileSync('git', ['add', '.'], { cwd: dir });
221+
execFileSync('git', [...id, 'commit', '-q', '-m', 'base'], { cwd: dir });
222+
execFileSync('git', ['branch', 'cook/r1'], { cwd: dir });
223+
const baseHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim();
224+
return { dir, baseHead };
225+
}
226+
227+
// The composed cook result: a full tree (base + the cook delta).
228+
function composedTree(): string {
229+
const d = mkdtempSync(join(tmpdir(), 'cook-composed-'));
230+
dirs.push(d);
231+
writeFileSync(join(d, 'app.ts'), 'export const v = 2;\n'); // modified
232+
writeFileSync(join(d, 'feature.ts'), 'export const f = true;\n'); // added
233+
writeFileSync(join(d, '.gitignore'), 'node_modules/\n');
234+
mkdirSync(join(d, 'node_modules'));
235+
writeFileSync(join(d, 'node_modules', 'dep.js'), 'junk\n'); // gitignored — must not land
236+
return d;
237+
}
238+
239+
it(
240+
'commits the composed tree onto cook/<runId>, leaving the active branch and working tree untouched',
241+
() => {
242+
const { dir, baseHead } = userRepo();
243+
const tree = composedTree();
244+
const branchesBefore = execFileSync('git', ['branch', '--list'], { cwd: dir, encoding: 'utf8' });
245+
246+
const result = promoteBrownfieldRun({ sourceDir: dir, sourceTreeDir: tree, runId: 'r1' });
247+
248+
// cook/r1 advanced by one commit on top of the base.
249+
expect(result.branch).toBe('cook/r1');
250+
expect(result.commit).not.toBe(baseHead);
251+
const parent = execFileSync('git', ['rev-parse', 'cook/r1^'], { cwd: dir, encoding: 'utf8' }).trim();
252+
expect(parent).toBe(baseHead);
253+
254+
// The commit's tree carries the delta — and not the gitignored deps.
255+
const files = execFileSync('git', ['ls-tree', '-r', '--name-only', 'cook/r1'], {
256+
cwd: dir,
257+
encoding: 'utf8',
258+
});
259+
expect(files).toContain('feature.ts');
260+
expect(files).toContain('app.ts');
261+
expect(files).not.toContain('node_modules');
262+
const appAtCook = execFileSync('git', ['show', 'cook/r1:app.ts'], { cwd: dir, encoding: 'utf8' });
263+
expect(appAtCook).toContain('v = 2');
264+
265+
// The user's active branch (main), HEAD, working tree, and index are untouched.
266+
expect(execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim()).toBe(
267+
baseHead,
268+
);
269+
expect(
270+
execFileSync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(),
271+
).toBe('main');
272+
expect(readFileSync(join(dir, 'app.ts'), 'utf8')).toContain('v = 1');
273+
expect(existsSync(join(dir, 'feature.ts'))).toBe(false);
274+
expect(execFileSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8' })).toBe('');
275+
// Only cook/r1 moved — no stray branches.
276+
expect(execFileSync('git', ['branch', '--list'], { cwd: dir, encoding: 'utf8' })).toBe(branchesBefore);
277+
},
278+
GIT_TEST_TIMEOUT_MS,
279+
);
280+
281+
it('throws when the cook/<runId> branch is absent (must be created by the worktree)', () => {
282+
const { dir } = userRepo();
283+
const tree = composedTree();
284+
expect(() => promoteBrownfieldRun({ sourceDir: dir, sourceTreeDir: tree, runId: 'missing' })).toThrow(
285+
/cook\/missing/,
286+
);
287+
});
288+
289+
it(
290+
'works in the real linked-worktree topology — the live sandbox worktree is left to be discarded, the main checkout untouched',
291+
() => {
292+
// Mirror production: cook/r1 exists *because* a linked worktree checked it out.
293+
const dir = mkdtempSync(join(tmpdir(), 'cook-userrepo-'));
294+
dirs.push(dir);
295+
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
296+
writeFileSync(join(dir, 'app.ts'), 'export const v = 1;\n');
297+
writeFileSync(join(dir, '.gitignore'), 'node_modules/\n');
298+
execFileSync('git', ['add', '.'], { cwd: dir });
299+
execFileSync('git', [...id, 'commit', '-q', '-m', 'base'], { cwd: dir });
300+
const wt = join(dir, 'wt');
301+
execFileSync('git', ['worktree', 'add', '-q', '-b', 'cook/r1', wt, 'HEAD'], { cwd: dir });
302+
const baseHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim();
303+
304+
const result = promoteBrownfieldRun({ sourceDir: dir, sourceTreeDir: composedTree(), runId: 'r1' });
305+
306+
// Only cook/r1 moved (one commit on the base).
307+
expect(execFileSync('git', ['rev-parse', 'cook/r1^'], { cwd: dir, encoding: 'utf8' }).trim()).toBe(
308+
baseHead,
309+
);
310+
expect(execFileSync('git', ['show', 'cook/r1:app.ts'], { cwd: dir, encoding: 'utf8' })).toContain(
311+
'v = 2',
312+
);
313+
// The main checkout is wholly untouched.
314+
expect(execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim()).toBe(
315+
baseHead,
316+
);
317+
expect(
318+
execFileSync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(),
319+
).toBe('main');
320+
expect(readFileSync(join(dir, 'app.ts'), 'utf8')).toContain('v = 1');
321+
// tracked files untouched (the linked `wt/` dir is an expected untracked entry).
322+
expect(
323+
execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], {
324+
cwd: dir,
325+
encoding: 'utf8',
326+
}),
327+
).toBe('');
328+
expect(result.commit).not.toBe(baseHead);
329+
},
330+
GIT_TEST_TIMEOUT_MS,
331+
);
332+
333+
it('stages tracked deletions — a file removed in the composed tree is removed in the cook commit', () => {
334+
const dir = mkdtempSync(join(tmpdir(), 'cook-userrepo-'));
335+
dirs.push(dir);
336+
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
337+
writeFileSync(join(dir, 'keep.ts'), 'keep\n');
338+
writeFileSync(join(dir, 'old.ts'), 'remove me\n');
339+
execFileSync('git', ['add', '.'], { cwd: dir });
340+
execFileSync('git', [...id, 'commit', '-q', '-m', 'base'], { cwd: dir });
341+
execFileSync('git', ['branch', 'cook/r1'], { cwd: dir });
342+
343+
// Composed tree drops old.ts.
344+
const tree = mkdtempSync(join(tmpdir(), 'cook-composed-'));
345+
dirs.push(tree);
346+
writeFileSync(join(tree, 'keep.ts'), 'keep\n');
347+
348+
promoteBrownfieldRun({ sourceDir: dir, sourceTreeDir: tree, runId: 'r1' });
349+
350+
const files = execFileSync('git', ['ls-tree', '-r', '--name-only', 'cook/r1'], {
351+
cwd: dir,
352+
encoding: 'utf8',
353+
});
354+
expect(files).toContain('keep.ts');
355+
expect(files).not.toContain('old.ts');
356+
});
357+
});

src/orchestrator/src/promote-run.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { execFileSync } from 'node:child_process';
2-
import { cpSync, existsSync, mkdirSync, readdirSync, realpathSync } from 'node:fs';
3-
import { basename, isAbsolute, relative, resolve } from 'node:path';
2+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
45

56
export type PromoteResult = { target: string; branch: string; commit: string };
67

@@ -11,8 +12,16 @@ export type PromoteOptions = {
1112
force: boolean;
1213
};
1314

14-
function git(args: string[], cwd: string): string {
15-
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
15+
export type BrownfieldPromoteOptions = {
16+
/** The user's repo root the brownfield cook ran against (a worktree of it). */
17+
sourceDir: string;
18+
/** The composed final tree to land (from `promotionSourceDir`). */
19+
sourceTreeDir: string;
20+
runId: string;
21+
};
22+
23+
function git(args: string[], cwd: string, env?: NodeJS.ProcessEnv): string {
24+
return execFileSync('git', args, { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
1625
}
1726

1827
// Deterministic committer so promotion never depends on (or mutates) global git config.
@@ -110,3 +119,65 @@ export function promoteGreenfieldRun(opts: PromoteOptions): PromoteResult {
110119
const commit = git(['rev-parse', 'HEAD'], target);
111120
return { target, branch, commit };
112121
}
122+
123+
/**
124+
* Land a completed *brownfield* run's composed tree onto the `cook/<runId>`
125+
* branch of the user's repo as one reviewable commit — the brownfield analogue
126+
* of `promoteGreenfieldRun`. The brownfield sandbox was created with
127+
* `git worktree add -b cook/<runId> … HEAD`, so the branch already exists at the
128+
* base the run started from; this commits the result on top of it via plumbing
129+
* (`commit-tree` + compare-and-swap `update-ref`) using a throwaway index and an
130+
* external work-tree, so the user's real working tree, index, and active branch
131+
* are never touched. Merging `cook/<runId>` into the working branch stays the
132+
* user's call — promotion never freelances into it.
133+
*/
134+
export function promoteBrownfieldRun(opts: BrownfieldPromoteOptions): PromoteResult {
135+
const sourceDir = resolve(opts.sourceDir);
136+
const sourceTreeDir = resolve(opts.sourceTreeDir);
137+
const branch = `cook/${opts.runId}`;
138+
const ref = `refs/heads/${branch}`;
139+
140+
// The branch must already exist (the sandbox branched it from HEAD); its tip is
141+
// the parent we commit on top of and the CAS expected-value for update-ref.
142+
let parent: string;
143+
try {
144+
parent = git(['rev-parse', '--verify', ref], sourceDir);
145+
} catch {
146+
throw new Error(
147+
`Brownfield promotion expects an existing ${branch} branch in ${sourceDir} (created by the cook worktree).`,
148+
);
149+
}
150+
151+
// Absolute git dir so a throwaway index + external work-tree can target the
152+
// user's object store without depending on cwd.
153+
const gitDir = resolve(sourceDir, git(['rev-parse', '--git-dir'], sourceDir));
154+
const tmp = mkdtempSync(join(tmpdir(), 'brunch-promote-'));
155+
const env: NodeJS.ProcessEnv = { ...process.env, GIT_INDEX_FILE: join(tmp, 'index') };
156+
const plumb = ['--git-dir', gitDir, '--work-tree', sourceTreeDir];
157+
try {
158+
// Seed the index from the base, then stage the composed tree as the delta —
159+
// adds, modifications, and deletions, all relative to the base commit.
160+
git([...plumb, 'read-tree', parent], sourceDir, env);
161+
git([...plumb, 'add', '-A'], sourceDir, env);
162+
const tree = git(['--git-dir', gitDir, 'write-tree'], sourceDir, env);
163+
const commit = git(
164+
[
165+
...COMMIT_IDENTITY,
166+
'--git-dir',
167+
gitDir,
168+
'commit-tree',
169+
tree,
170+
'-p',
171+
parent,
172+
'-m',
173+
`cook: ${opts.runId}`,
174+
],
175+
sourceDir,
176+
env,
177+
);
178+
git(['--git-dir', gitDir, 'update-ref', ref, commit, parent], sourceDir, env);
179+
return { target: sourceDir, branch, commit };
180+
} finally {
181+
rmSync(tmp, { recursive: true, force: true });
182+
}
183+
}

0 commit comments

Comments
 (0)