diff --git a/memory/PLAN.md b/memory/PLAN.md index 76172525..92b02668 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -60,7 +60,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen 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). 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__//`, 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`)* 7. `brownfield-promotion` — **(landed — FE-877, `promoteBrownfieldRun`)** commit a completed brownfield cook result onto the repo's own `cook/` 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/` 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. -8. `brunch-ship` — one-shot `brunch serve ` wrapper (prep → recipe → cook → taste → plate), no manual steps. Arc 1 capstone. +8. `brunch-ship` — **(landed — FE-878, `brunch serve`)** one-shot `brunch serve ` = `plan ` then `cook --spec=` (cook reads the plan just emitted), no manual steps. Pure glue, no new orchestration: serve's `--out` is the *promote* target → cook (brownfield auto-promotes via FE-877 regardless), `--profile` stamps the plan, petrinaut/policy/retry flags forward to cook, `--verbose` to both; a failed plan short-circuits (nothing cooked). Testable units `parseServeArgs` + `runServe` (stages injected); db/snapshot wiring stays in `cli.ts`. Cook's `dir` is threaded from the resolved launch cwd (the dir the plan was written to) — `runCook` reads `opts.dir` raw, so serve must supply it rather than rely on the `parseCookArgs`-only default (R46). **Closes Arc 1.** **Runtime umbrella + semantic substrate:** @@ -487,6 +487,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **Depends on:** `brunch-detect`, `integration-oracle`, `brownfield-promotion`; `cook-mode-from-spec` (FE-826, done). - **Traceability:** Requirements 46–50. - **Design docs:** `docs/design/orchestrator.md`. +- **Presentation seam (sub-slices, ride under FE-878 — no separate issue/branch):** the `serve`/`cook`/`plan` CLI grows a full-screen Ink TUI (brunch wordmark header in the brand gradient, kitchen-brigade phase tracker, live activity panel). Design (ln-design 2026-06-16): a thin `emit(CookEvent)` boundary → pure `reduce(events)→RunState` → PendingActivity-centric Ink presenter; `selectPresenter` picks `ink`/`plain`/`silent` by env; `reports.jsonl` stays the durable medium (CookEvent is ephemeral). The brigade names stay phase labels, not commands. Oracle per **SPEC I136-K**. **Slices 1a + 1b (done)** — seam foundation (`presenter.ts` + `presenter/`) + CLI wiring; both `plan` and `cook` surfaces migrated to `emit(CookEvent)`, elapsed timer moved to a presenter-owned **injected clock** (seeded by `cook-start`); `pi-actions` console-free; verified end-to-end. **Slice 2a (done)** — Ink presenter is real (brunch wordmark header, monotonic brigade tracker, bounded activity log, renders to stderr); shared `format.ts`/`clock.ts` + `RunStore` + pure `nextPhase`; verified via reduce/store units + ink-testing-library frames + the server build. **Slice 2b (done)** — the dead-air fix: `activity-start/progress/end` events bracket the four waits (pi sessions self-bracket in `runPi` with a KB heartbeat; test-run/probe via `withActivity`; promotion in `cook-cli`), a `pending` map in `RunStore`, and an Ink `PendingPanel` (live spinner + label + elapsed + detail). The presentation seam is complete. Residual: spinner freezes during the synchronous `spawnSync` test run; real-terminal walkthrough is outer-loop debt. ### interactive-recovery diff --git a/memory/SPEC.md b/memory/SPEC.md index b9381f13..e262a30d 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -216,8 +216,9 @@ Brunch operates inside a **workspace**: the cwd-backed software context whose lo 163. **The Petrinaut actual-mode wire definition is plain-graph, not SDCPN** (FE-819) — Petrinaut's "actual/live" Brunch route narrowed to a `.strict()` schema accepting only `{version?, meta?, title?, places[id,name,x?,y?], transitions[id,name,inputArcs,outputArcs,x?,y?]}` (`brunchNetDefinitionSchema`); it supplies SDCPN defaults itself (`normalizeBrunchDefinition`) with extensions disabled. Brunch's `projectNetDefinition` therefore emits the slim shape and drops `types` plus every SDCPN-only place/transition field (`colorId`, `dynamicsEnabled`, `differentialEquationId`, `lambdaType`, `lambdaCode`, `transitionKernelCode`) — under `.strict()` these would be rejected, not ignored. Consequence: colour-fold slice identity is not expressible on this interface (identity fold only) until the standardized Brunch/Petrinaut protocol is owned in Petrinaut Core. The schema is mirrored in-repo (`petrinaut-brunch-contract-schema.ts`) as the projection oracle so a Petrinaut-side tightening fails a brunch test. Depends on: Requirement 48; D162-K. 164. **Greenfield/brownfield is spec-derived plan truth, not plan location** — the emitted `plan.yaml` carries `mode` (`Plan.mode`) from `specification.mode`; `brunch cook` reads `plan.mode` to choose the worktree strategy. The resolver splits into `resolveCookPlan` (locate the plan path) + `resolveSandboxPlan` (mode-driven worktree decision: greenfield → empty worktree; brownfield → clone the cwd repo + clean-tree gate, brownfield-only). Reverses the earlier location-keyed reading of Requirement 50 — a spec-emitted greenfield plan no longer clones the cwd. Authored/legacy plans without a `mode` load as greenfield. Depends on: Requirements 46, 49, 50; A65 (greenfield/brownfield grounding posture). (FE-826) 165. **Cook slice layout is policy-selected** (FE-827) — `OrchestratorInput.sliceLayout` is `'shared'` only for **serial greenfield** (all slices accrete into the single run sandbox; verify-epic runs in place; no per-slice dirs, no `__epic__` merge) and `'per-slice'` otherwise. Per-slice means git worktrees for brownfield and plain dirs for parallel greenfield, both merged into `__epic__//` for verification. The shared tree trades the per-slice dependency-correctness oracle and parallelism for one directly-usable, in-place-verified tree; parallel greenfield keeps isolation (race-safe) at the cost of a merge. `runCook` derives the layout; there is no policy refusal (greenfield parallel is allowed). Depends on: Requirements 46, 49; D164-K. -166. **Greenfield promotion-back is opt-in, completed-gated, and never silent** (FE-827) — `brunch cook --out=` promotes a greenfield run's tree into the target only when the run completed (`result.status === 'completed'`); halted/brownfield runs promote nothing (the run artifact stays inspectable). Landing is commit-on-branch: empty target → `git init` + commit on `main`; existing repo → commit on a `cook/` branch (the user's branch untouched); a non-empty target is refused unless `--force`. The promotion source follows the layout (D165-K): shared → the run sandbox; per-slice (parallel) → a whole-plan merge of all completed slices (declaration-order-wins, collisions reported). Closes the cook output-promotion gap for greenfield; brownfield promotion (git-merge chain) remains a follow-on. Depends on: Requirements 46, 49; D164-K, D165-K. +166. **Greenfield promotion-back is opt-in, completed-gated, and never silent** (FE-827) — `brunch cook --out=` promotes a greenfield run's tree into the target only when the run completed (`result.status === 'completed'`); halted/brownfield runs promote nothing (the run artifact stays inspectable). Landing is commit-on-branch: empty target → `git init` + commit on `main`; existing repo → commit on a `cook/` branch (the user's branch untouched); a non-empty target is refused unless `--force`. The promotion source follows the layout (D165-K): shared → the run sandbox; per-slice (parallel) → a whole-plan merge of all completed slices (declaration-order-wins, collisions reported). Closes the cook output-promotion gap for greenfield; brownfield promotion landed separately as decision 168 (FE-877). Depends on: Requirements 46, 49; D164-K, D165-K. 167. **The emitter guarantees cook-executability through a self-contained `PlanContract` + deterministic repair, separate from intent projection** (FE-829) — `brunch plan` gates its output on a producer-agnostic `PlanContract` that checks the schema-checkable executability invariants (I129-K), plus a deterministic repair loop that fixes the **mechanical class** (Kahn cycle-break; mint a missing verification target; **synthesize an `integration-test` seam on every multi-slice epic** so the per-epic merge runs and composition is proven) while surfacing the **design class** (uncovered requirement; shared file with no declared join owner) as typed warnings rather than silently inventing or dropping scope. This splits today's reconciliation ("always repair, never check") into detect-then-repair, makes "is this plan cook-executable?" one reusable predicate that also validates hand-authored fixtures, and directly closes the FE-800 integration-blind / "green checks, no assembled artifact" gap. Slice 1 (contract + repair; no LLM; no file/decomposition authoring) does **not** touch D160-K. **Slice-1 refinement (2026-06-09):** the reusable-predicate goal collided with the read-only reference fixtures — two of them carry intentionally bare multi-slice epics (their `core` and `pipeline` epics) — so the seam invariant is enforced through **two `checkPlan` profiles**: `base` (default, for authored/producer-input plans) reports the missing seam as a *warning*; `emitted` (for `brunch plan` output) reports it as an *error*. `repairPlan` always synthesizes the seam regardless of profile, so emitted plans pass `emitted` while fixtures pass `base` unmodified. Implemented as `plan-contract.ts` (`checkPlan`/`repairPlan`) + a shared `plan-graph.ts` Kahn helper (reused by `reconcilePlan` so the two cycle-break policies cannot drift) + a `project-profile.ts` `Toolchain` descriptor that *derives* verification targets (`sliceTarget`/`epicTarget`) instead of hardcoding `tests/.test.ts`. Depends on: Requirements 46–50; A97, D158-K, D161-K; establishes I129-K. See Future Direction §Cook plan generation for the build-architect arc and the deferred D160-K amendment. +168. **Brownfield promotion is automatic and plumbing-only; `brunch serve` is the one-shot capstone** (FE-877, FE-878) — a completed brownfield cook auto-commits its composed tree onto the repo's own `cook/` branch (the branch the CoW sandbox already created from `HEAD`) via git plumbing (`commit-tree` + CAS `update-ref`, throwaway index + external work-tree), so the user's active branch, working tree, and index are never touched; merging stays the user's call. `--out` is therefore greenfield-only — for brownfield it is ignored with a warning. `brunch serve ` = `plan ` then `cook --spec=` (cook reads the just-emitted plan; serve threads the resolved launch cwd as cook's `dir` because `runCook` reads `opts.dir` raw — the launch-cwd default lives only in `parseCookArgs`, R46); serve's `--out` is the greenfield promote target, petrinaut/policy/retry flags forward to cook, and a failed plan short-circuits (nothing cooked). Pure glue — no new orchestration; the testable units are `parseServeArgs` + `runServe` (stages injected) with db/snapshot wiring in `cli.ts`. **Closes Arc 1.** Depends on: Requirements 46, 49; decision 166; establishes I135-K. (FE-877, FE-878) #### Provider, prompt/context, and agent substrate @@ -270,15 +271,17 @@ Each invariant is a formalization candidate: the property is stated in human lan | 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/`. 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__//`. 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 `/__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) | -| 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 `evaluateVerificationTargets` — 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) | +| 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`): 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) | -| I128-K | Greenfield promotion-back never silently overwrites and never promotes an incomplete run: `brunch cook --out` lands a tree only when the run completed, refuses a non-empty target without `--force`, and always lands as a git commit (init+commit on `main` for an empty target, or a `cook/` branch in an existing repo). The promotion source follows the slice layout — the run sandbox (serial) or a whole-plan merge of completed slices (parallel). | promote-run.test.ts, cook-cli.test.ts (`promotionSourceDir`) | Requirements 46, 49; D166-K (FE-827) | +| I128-K | Greenfield promotion-back never silently overwrites and never promotes an incomplete run: `brunch cook --out` lands a tree only when the run completed, refuses a non-empty target without `--force`, and always lands as a git commit (init+commit on `main` for an empty target, or a `cook/` branch in an existing repo). The promotion source follows the slice layout — the run sandbox (serial) or a whole-plan merge of completed slices (parallel). `--out` is **greenfield-only**: a brownfield run auto-promotes onto `cook/` and ignores `--out` with a warning (I135-K). | promote-run.test.ts, cook-cli.test.ts (`promotionSourceDir`) | Requirements 46, 49; D166-K (FE-827) | +| I135-K | Brownfield promotion never touches the user's checkout: `promoteBrownfieldRun` lands a completed brownfield cook's composed tree as one commit on the repo's existing `cook/` branch via git plumbing (`read-tree` base → `add -A` against an external work-tree → `write-tree` → `commit-tree -p base` → CAS `update-ref`) under a throwaway `GIT_INDEX_FILE`, so the active branch, working tree, and index are unchanged and gitignored deps don't land. Auto-runs on a completed brownfield run (no `--out` needed); a missing `cook/` branch throws. | promote-run.test.ts (lands on cook/ with parent=base; main/HEAD/working-tree/index untouched; tracked-deletion; real linked-worktree topology) | Requirements 46, 49; decision 168 (FE-877) | | I130-K | No tech stack is hardcoded in the cook harness: the `test-writer` prompt names no framework, and the runner (`ToolchainTestRunner`) plus the cook task builders (`sliceTestTask`/`epicVerifyTask`) take the test command and conventions from the `Toolchain` resolved from `plan.profile` (`resolveToolchain`, bun default). `brunch cook` and `brunch plan` resolve the same profile, so emitted targets and the runner that executes them agree. | project-profile.test.ts, test-runner.test.ts (toolchain command honored), pi-actions.test.ts (task builders carry conventions + prompt has no hardcoded stack) | SPEC §Future Direction Cook plan generation; D161-K, D167-K (FE-829 slice 2, FE-813) | | I129-K | Every plan `brunch plan` emits satisfies the schema-checkable executability contract: `depends_on` is acyclic over existing slice ids, every slice has ≥1 verification target, every slice belongs to an epic, every requirement is covered or explicitly non-buildable, and every multi-slice epic carries an `integration-test` verification target. `checkPlan` is total and pure with **two profiles**: `base` (default) treats a multi-slice epic missing its seam as a *warning* so authored/reference plans pass `check` unmodified, while `emitted` escalates it to an *error*; `repairPlan` always synthesizes the seam, so `brunch plan` output satisfies the strict `emitted` profile. Every deterministic repair is surfaced as a typed warning; `check(repair(plan))` is accepted under `emitted`. File-disjointness / join-ownership is out of scope until a `Slice.writes` field lands. | plan-contract.test.ts, plan-emitter.test.ts (FE-829 slice 1) | Requirements 46–50; D167-K; A100-K (FE-829) | | I133-K | `brunch plan` is a build-ARCHITECT: a single schema-constrained LLM call (`architectPlan`) AUTHORS a decomposed, file-disjoint slice set — scaffold + per-behaviour slices + a join slice that is the sole writer of shared coordination files — each carrying `writes` (file ownership) and `derivedFrom` (requirement provenance; never persisted on the emitted `Plan`). Per D160-K (amended) the architect does **no host introspection** and authors **no test content**: verification targets are synthesized deterministically by `materializeArchitectedPlan` (toolchain-derived) and the cook agent writes tests at run time (A98). The materializer is pure: it filters unknown requirement refs (slices kept), drops self/dangling deps, breaks cycles (shared Kahn policy), resolves epic membership from `slice.epic_id`, appends each requirement's criteria into the derived slice's definition prose, and emits a coverage sidecar. The emitter gates the authored plan with `repairPlan` + `checkPlan` (`emitted` profile + generalized requirement-provenance coverage); **if authoring throws/parses-malformed OR the authored plan is uncovered/contract-failing, it falls back to a deterministic projection plan** (`reconcilePlan(projected, ∅)` + repair — no second LLM call) and surfaces one `architect-failed-fallback-to-projection` warning. A surviving `file-write-conflict` is surfaced (never silently shipped). Deterministic plumbing only is verified; decomposition QUALITY is deferred to the slice-5 eval harness + opt-in real-LLM smoke. Supersedes the slice-3 enrichment stage (`planExecutionOrdering`, I131-K) on the mainline. | plan-architect.test.ts (schema/prompt/failure), plan-materialize.test.ts (coverage/provenance/dep-clean/purity), plan-emitter.test.ts (authored happy path, fallback on throw + uncovered + malformed, file-write-conflict surfaced, toolchain), plan-runner.test.ts | Requirements 46–50; A97, A98, A100-K; D160-K (amended), D167-K; supersedes I131-K (FE-829 slice 4B) | | I134-K | `brunch plan`'s authored output has a deterministic acceptance oracle, `evaluatePlanShape` (`plan-eval.ts`), separate from the emitter mainline (it is an outer-loop scorer, not wired into emission). It returns a `PlanEvalReport` with an **explicit, narrow `verdict` gate** — `reject` iff any `error`-severity finding under the strict `emitted` contract profile, OR any `file-write-conflict`, OR any slice missing a `writes` declaration — never a score threshold, so the non-deterministic architect cannot game a scalar into acceptance. Alongside the gate it reports a graded structural-feature vector (`metrics`) measured against the SHARED fixture-design principles (docs/design/orchestrator-demo-fixtures.md), **not** against any fixture's ids/paths/counts: verification coverage, integration-seam coverage, writes coverage, single-writer, transitively-redundant-dependency penalty, slice sharpness, dependency signal. `overall` is a weighted mean for trending only (soft heuristics half-weight). The three reference fixtures are the self-test: each must `accept` and score `overall === 1`, which required refreshing them (added `writes` to every slice; added the previously-missing integration seam to the `core` and `pipeline` epics of two reference fixtures — they now satisfy their own stated principle #2 under the strict `emitted` profile). | plan-eval.test.ts (fixture self-test scores 1; write-conflict/missing-seam/missing-writes/dangling-dep → reject; redundant-edge + flatten + over-broad-slice graded lower; monotonicity) | Requirements 46–50; A100-K; D167-K; I129-K, I132-K, I133-K (FE-829 slice 5) | | I132-K | `Slice.writes?: string[]` declares the repo-relative POSIX file paths a slice exclusively mutates (exact paths only — no globs/directories), and `checkPlan` enforces single-writer-per-file: a path declared by ≥2 slices is a `file-write-conflict` — a **design-class warning** (never an error, never auto-repaired), since resolving it changes decomposition/ownership. Duplicate paths within one slice are deduped first and never self-conflict. A "join slice" is the sole writer of a shared coordination file that `depends_on` the slices it joins — not a multi-writer exception. `repairPlan` preserves `writes` verbatim and never moves ownership or synthesizes a join slice; `loadPlan` round-trips the field (absent → undefined). Emitter/LLM authoring of `writes` + requirement decomposition + join synthesis is deferred (D160-K amendment + slice-5 eval). | plan-contract.test.ts (disjoint accepted, overlap warns, intra-slice dup no-false-positive, repair preserves), plan-loader.test.ts (writes round-trip) | Requirements 46–50; A98, A100-K; D160-K (amended), D167-K (FE-829 slice 4) | | I131-K | **Retired (FE-829 post-slice-5)** — `planExecutionOrdering` and its whole `plan-llm-planning.ts` module (+ test) are deleted, having been superseded on the mainline by the authoring architect (I133-K, slice 4B). The only load-bearing survivor, the `PlanningEnrichment` type (reconcile's deterministic-fallback input contract), now lives in `plan-reconciliation.ts` next to its consumer; the duplicate `RunModel` type consolidated onto `plan-architect.ts`. The Zod `planningEnrichmentSchema` and `defaultRunModel` went with the deleted function. Historical record (the enrichment-over-projected-slices stage): the slice-3 planner only classified/grouped/ordered the existing `req-*` slices — it never invented, split, merged, renamed, or removed them — and was prompt-enriched with per-slice criteria + `projectPlanningContext` relation edges + the inlined reference-fixture exemplars. That enrichment seam was never validated for model quality and is fully replaced by `architectPlan` (I133-K) + the slice-5 eval harness (I134-K). | plan-planning-context.test.ts (edge lifting/ownership/dedupe — the surviving context seam); architect + eval coverage per I133-K / I134-K | Requirements 46–50; A97; D167-K; superseded by I133-K, retired post-I134-K (FE-829 slice 3 → retired) | +| I136-K | **FE-878 presentation seam.** All `serve`/`cook`/`plan` terminal output flows through one `emit(CookEvent)` boundary, never direct `console.*`/`log()` outside `presenter/`; the orchestrator never imports the renderer. A pure `selectPresenter({command,isTTY,ci,reporterFlag})` chooses the backend — `plain` (CI / non-TTY / default), `silent` (`agent` mode), `ink` (interactive TTY; falls back to plain until slice 2). `PlainPresenter` reproduces pre-refactor stderr **byte-identically**; for the cook surface this is made deterministic by an **injected clock** (the presenter owns the elapsed/duration timer) plus a redaction normalizer for absolute paths and `runId`. The bus fans out synchronously and **swallows a thrown presenter** (`emitWarning`) so presentation can never abort a run; **stdout stays empty / JSONL-only**. Behavior-preserving — no `*-started`/activity instrumentation and no live Ink rendering (slice 2). **Slices 1a + 1b (done):** seam foundation (`presenter.ts` root + `presenter/{events,bus,select,plain,silent}.ts`) + CLI wiring; **both surfaces migrated** — `plan` (`plan-runner`) and `cook` (`cook-cli` banner/summary/promotion/petrinaut via a `line` passthrough arm + `pi-actions` per-action progress as structured `action`/`verbose` arms). The elapsed timer moved off `pi-actions`' module-level `Date.now()` into the presenter's **injected clock**, seeded by a `cook-start` event. `pi-actions` is now console-free; `cook-cli`'s only residual `console.error` is the injectable Petrinaut-setup default, which the cook path overrides with a bus-backed `log`. **Slice 2a (done):** the `ink` backend is real (no longer a plain fallback) — formatting consolidated into a shared `format.ts` + `clock.ts` (used by both backends so log bodies can't drift), a `RunStore` folds the event stream into `{phase, lines}`, a pure monotonic `nextPhase` projects the brigade tracker (coarse, from post-hoc events; precise in-flight transitions are 2b), and the Ink `App` (brunch-wordmark header in the brand gradient + brigade strip with `✓/◐/○` marks + bounded activity log) renders to **stderr**. **Slice 2b (done):** the dead-air fix — `activity-start`/`activity-progress`/`activity-end` events; the four long waits are bracketed (the three agent sessions self-bracket inside `runPi` with a throttled KB heartbeat; the test-run + probe waits use a `withActivity` helper; promotion brackets in `cook-cli`), always closing via `finally`. `RunStore` tracks a `pending` map; the Ink `PendingPanel` shows a live spinner + label + elapsed + detail (a tick interval runs only while pending is non-empty). Plain/CI renders one `⋯` start line per wait. The seam is now complete across all three commands and both backends. **Lifecycle:** the bus creator owns disposal — entry points run through `withCookBus(command, fn)`, which builds the bus and `dispose()`s it (unmounting Ink) in `finally`, so the TUI can't be left mounted and hang the process (ln-review finding). | bus.test.ts (fan-out + error isolation), presenter.test.ts (withCookBus disposes on success + throw), select.test.ts (decision table), plain.test.ts (byte-exact plan + cook arms incl. injected-clock elapsed + activity start-line), plan-runner.test.ts (golden stderr via capturing bus), brownfield-smoke.integration.test.ts (cook end-to-end through the bus), phase.test.ts (monotonic brigade), run-store.test.ts (event fold + pending map + stable snapshot), ink/app.test.tsx (frame: egg + active phase + activity + pending panel), pi-actions.test.ts (balanced activity start/end incl. on session failure), cook-report.test.ts (banner + completion-summary golden — the cook line strings are pure-tested, ln-review #3) | Requirements 46–50; D156-K (reports.jsonl stays the durable medium; CookEvent is ephemeral presentation only) (FE-878) | ## Future Direction Register @@ -451,6 +454,7 @@ Every meaningful code change should pass `npm run fix` in the inner loop and `np | Middle | Prompt/context golden and classifier corpora | Prompt/context output remains inspectable and regressable as prompts evolve. | Requirements 40, 41; A84, A88; I112, I114 | | Middle | Context-snapshot replay and handle-refresh oracles | Turn-level snapshots replay unchanged after graph edits; active handles re-snapshot only when changeset-backed item versions advance. | Requirement 45; A95; D154; I120 | | Middle | Structured context-builder assertions plus selected golden renderings | Item-list, neighborhood, and economic whole-graph snapshots contain required ids, sections, relation/provenance signals, and stable rendering boundaries without overfitting prose. | Requirements 40, 45; A84, A95; I112, I120 | +| Middle | Differential / golden-master with injected clock + path/runId redaction | The `serve`/`cook`/`plan` presentation refactor preserves stderr byte-for-byte; output stays behind the `emit(CookEvent)` boundary and off stdout. | Requirements 46–50; I136-K | | Outer | Fixture-backed manual walkthroughs | Phase transitions, export, resume, graph view, and waiting states feel legible. | Requirements 5, 13–15, 33 | | Outer | Brownfield and scenario-quality review | Generated questions/bundles are useful, grounded, honest about tradeoffs, and not overconfident. | Requirements 3, 16, 20; A67, A68, A90, A91 | | Outer | Dense cascade/reconciliation walkthroughs | Users can understand and resolve downstream graph impact without skipping necessary judgment. | A48, A88, I113, I114 | @@ -471,11 +475,14 @@ Every meaningful code change should pass `npm run fix` in the inner loop and `np | LLM classifier correctness and determinism | Proposals never auto-apply; re-run exists; corpora/goldens grow from failures. | Substantive items are mislabeled as auto-confirm or repeated runs diverge materially. | | Economic whole-graph snapshot quality | Structured assertions plus one selected golden rendering fixture; human review for whether compact context is useful. | Secondary-chat answers show missing authority/provenance/relation context or snapshots become too large for routine prompts. | | Context-handle refresh before real item versions | Defer handle freshness semantics until `changeset-ledger` supplies real item versions rather than blessing a temporary content fingerprint. | `chat-context-provision` is pulled before changeset-backed item versions exist. | +| Frozen spinner during a synchronous test run | Slice 2b brackets every wait, but `test-runner` uses blocking `spawnSync`, so the spinner can't animate (only the label + start-elapsed show) while a ≤60s test runs; the async pi session animates fine. | The test-run wait becomes a felt pain point — then move `test-runner` to an async spawn so the event loop can tick. | +| Real-terminal Ink *visual* behavior (resize, Ctrl-C, escape codes) | Teardown is now wired + tested (`withCookBus` disposes the bus → unmounts Ink in `finally`; ln-review caught that nothing disposed it before). Frames are unit-tested via ink-testing-library and bundled in the build; what's left is purely visual — not yet walked through in a live terminal. | A manual `brunch cook`/`serve` run shows visual/resize glitches, or before relying on the TUI for a demo. | ### Design Notes - Context-handle freshness should wait for real item versions from `changeset-ledger`; do not bless a temporary content/edge fingerprint as the durable refresh oracle. - Economic whole-graph snapshot verification should pair structured JSON assertions for required sections/counts/ids with a small number of golden renderings and human review, rather than treating exact prose as the primary oracle. +- The CLI presentation refactor's byte-identical golden oracle depends on making non-determinism injectable rather than masked: the elapsed/duration clock is a presenter-owned dependency (deterministic in tests, reused by the slice-2 Ink tests), and only paths/`runId` are redacted by a small normalizer. Avoid normalizing the timer itself — that would let the normalizer, the thing under test, mask real drift. ### Acceptance Criteria diff --git a/package-lock.json b/package-lock.json index cb16e042..432b0c9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "drizzle-orm": "^0.45.2", "embla-carousel-react": "^8.6.0", "express": "^5.2.1", + "ink": "^7.0.6", "lucide-react": "^1.8.0", "md-pen": "^1.2.0", "motion": "^12.38.0", @@ -76,6 +77,7 @@ "code-inspector-plugin": "^1.5.1", "drizzle-kit": "^0.31.10", "happy-dom": "^20.8.9", + "ink-testing-library": "^4.0.0", "oxfmt": "^0.43.0", "oxlint": "^1.58.0", "oxlint-tsgolint": "^0.19.0", @@ -249,6 +251,46 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", + "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@antfu/install-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", @@ -1514,7 +1556,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -10631,6 +10673,21 @@ "string-width": "^4.1.0" } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -10788,6 +10845,18 @@ "dev": true, "license": "MIT" }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/axe-core": { "version": "4.11.2", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.2.tgz", @@ -11639,6 +11708,65 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.0.tgz", + "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -11707,6 +11835,18 @@ "dev": true, "license": "MIT" }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/code-inspector-plugin": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/code-inspector-plugin/-/code-inspector-plugin-1.5.1.tgz", @@ -11841,6 +11981,15 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -13613,6 +13762,18 @@ "node": ">=6" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -13676,6 +13837,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", + "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esast-util-from-estree": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", @@ -15420,6 +15591,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -15432,6 +15615,236 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ink": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ink/-/ink-7.0.6.tgz", + "integrity": "sha512-/KG651f+LHln9gumb5ltieFqzNGJdhX1b/WwsCUd2Py7Htuk9KUzyFrk25ugmzjXyDneXSoXD3cm4ql4dWFGsQ==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.3.0", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.3", + "auto-bind": "^5.0.1", + "chalk": "^5.6.2", + "cli-boxes": "^4.0.1", + "cli-cursor": "^4.0.0", + "cli-truncate": "^6.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.45.1", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^9.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.2.0", + "terminal-size": "^4.0.1", + "type-fest": "^5.5.0", + "widest-line": "^6.0.0", + "wrap-ansi": "^10.0.0", + "ws": "^8.20.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": ">=19.2.0", + "react": ">=19.2.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-testing-library": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", + "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/ink/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ink/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ink/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ink/node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "license": "MIT", + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/ink/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/ink/node_modules/widest-line": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", + "license": "MIT", + "dependencies": { + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/wrap-ansi": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -15598,6 +16011,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-in-ssh": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", @@ -17946,7 +18374,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -18900,6 +19327,15 @@ "node": ">= 0.8" } }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -19617,6 +20053,21 @@ "license": "MIT", "peer": true }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -20868,6 +21319,49 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/slice-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -20972,6 +21466,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -21237,7 +21752,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -21303,6 +21817,18 @@ "node": ">=6" } }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/throttleit": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", @@ -22022,7 +22548,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", - "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -23507,6 +24032,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 5d4980b6..db1350a9 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "drizzle-orm": "^0.45.2", "embla-carousel-react": "^8.6.0", "express": "^5.2.1", + "ink": "^7.0.6", "lucide-react": "^1.8.0", "md-pen": "^1.2.0", "motion": "^12.38.0", @@ -118,6 +119,7 @@ "code-inspector-plugin": "^1.5.1", "drizzle-kit": "^0.31.10", "happy-dom": "^20.8.9", + "ink-testing-library": "^4.0.0", "oxfmt": "^0.43.0", "oxlint": "^1.58.0", "oxlint-tsgolint": "^0.19.0", diff --git a/src/orchestrator/src/cook-cli.ts b/src/orchestrator/src/cook-cli.ts index 580f2048..585dfe4a 100644 --- a/src/orchestrator/src/cook-cli.ts +++ b/src/orchestrator/src/cook-cli.ts @@ -2,6 +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 { createOrchestrator } from './engine.js'; import { type MergeConflict, mergeCompletedSlicesIntoTree } from './epic-sandbox-merge.js'; import { FileReportSink } from './file-report-sink.js'; @@ -12,6 +13,7 @@ import { createPetrinautStreamBus, type PetrinautStreamBus } from './petrinaut-s import { createPetrinautStreamServer, type PetrinautStreamServer } from './petrinaut-stream-server.js'; 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 { promoteBrownfieldRun, promoteGreenfieldRun } from './promote-run.js'; import { parseSpecId, resolveLatestSpecPlanPath, specPlanPath, specsRootDir } from './spec-plan-paths.js'; @@ -401,7 +403,16 @@ function isCleanGitWorkingTree(dir: string): GitWorkingTreeCheck { return { kind: 'dirty', status }; } -export async function runCook(opts: CookOptions): Promise { +export async function runCook(opts: CookOptions, bus: CookBus): Promise { + const line = (text: string) => bus.emit({ kind: 'line', text }); + const promoting = (label: string, fn: () => T): T => { + bus.emit({ kind: 'activity-start', id: 'promote', label }); + try { + return fn(); + } finally { + bus.emit({ kind: 'activity-end', id: 'promote' }); + } + }; const launchCwd = process.env.BRUNCH_LAUNCH_CWD || process.cwd(); // Streaming pre-flight happens before any cook side effect (banner, plan @@ -416,7 +427,7 @@ export async function runCook(opts: CookOptions): Promise { env: { PETRINAUT_URL: process.env.PETRINAUT_URL }, }); if ('error' in resolvedUrl) { - console.error(resolvedUrl.error); + line(resolvedUrl.error); process.exit(1); } petrinautUrl = resolvedUrl.url; @@ -425,7 +436,7 @@ export async function runCook(opts: CookOptions): Promise { const resolved = resolveCookPlan(opts.dir, opts.specId); if (resolved.kind === 'error') { - console.error(resolved.message); + line(resolved.message); process.exit(1); } @@ -434,7 +445,7 @@ export async function runCook(opts: CookOptions): Promise { // Worktree strategy follows the plan's spec-derived mode, not its location. const sandbox = resolveSandboxPlan(plan.mode, resolved.sourceDir); if (sandbox.kind === 'error') { - console.error(sandbox.message); + line(sandbox.message); process.exit(1); } @@ -451,15 +462,16 @@ export async function runCook(opts: CookOptions): Promise { const epicCount = plan.epics.length; const sliceCount = plan.slices.length; - console.error(''); - console.error(` brunch cook`); - console.error(` ──────────────────────────────────────`); - console.error(` policy ${opts.policy}`); - console.error(` plan ${epicCount} epics, ${sliceCount} slices`); - console.error(` retries ${opts.maxRetries}`); - console.error(` sandbox ${sandboxDir}`); - console.error(` reports ${reportsPath}`); - console.error(''); + for (const l of cookBannerLines({ + policy: opts.policy, + epicCount, + sliceCount, + maxRetries: opts.maxRetries, + sandboxDir, + reportsPath, + })) { + line(l); + } const reports = new FileReportSink(reportsPath); const toolchain = resolveToolchain(plan.profile); @@ -468,7 +480,15 @@ export async function runCook(opts: CookOptions): Promise { const engine = createOrchestrator(opts.policy); const runStart = Date.now(); - const actions = createPiActions({ verbose: opts.verbose, runStart, toolchain, testRunner }); + // Seed the presenter's elapsed clock; per-action progress carries no + // pre-formatted timing — the presenter owns it (I136-K). + bus.emit({ kind: 'cook-start', runStart }); + const actions = createPiActions({ + verbose: opts.verbose, + emit: (event) => bus.emit(event), + toolchain, + testRunner, + }); // Stand up the live-stream setup handle when streaming is enabled. // Auto-open is suppressed by `--no-petrinaut-open` or CI. @@ -478,6 +498,7 @@ export async function runCook(opts: CookOptions): Promise { petrinautUrl, shouldOpen: opts.petrinautOpen && !process.env.CI, openUrl: defaultOpenUrl, + log: (text) => line(text), ...(streamPort !== undefined ? { port: streamPort } : {}), }) : undefined; @@ -504,43 +525,31 @@ export async function runCook(opts: CookOptions): Promise { const duration = fmtDuration(Date.now() - runStart); const ok = result.status === 'completed'; - console.error(''); - console.error(` ──────────────────────────────────────`); - console.error( - ` ${ok ? '✓' : '✗'} ${result.status}${result.reason ? ` — ${result.reason}` : ''} (${duration})`, - ); - for (const warning of result.warnings) { - console.error(` ! ${warning}`); - } - console.error(''); - - for (const e of result.epics) { - const icon = e.status === 'completed' ? '✓' : '✗'; - const slices = result.slices.filter( - (s) => plan.slices.find((ps) => ps.id === s.sliceId)?.epic_id === e.epicId, - ); - const sliceSummary = slices - .map((s) => `${s.status === 'completed' ? '✓' : '✗'} ${s.sliceId}`) - .join(' '); - console.error(` ${icon} ${e.epicId}`); - console.error(` ${sliceSummary}`); + for (const l of cookSummaryLines({ + status: result.status, + ...(result.reason ? { reason: result.reason } : {}), + duration, + warnings: result.warnings, + epics: result.epics, + slices: result.slices, + planSlices: plan.slices, + reportCount: result.reports.length, + reportsPath, + })) { + line(l); } - console.error(''); - console.error(` ${result.reports.length} events → ${reportsPath}`); - console.error(''); - // Brownfield promotion is automatic (the result already lives on the repo's // own `cook/` branch); greenfield promotion is opt-in via --out. A run // that did not complete promotes nothing — the artifact stays inspectable. if (sandbox.kind === 'codebase') { if (opts.outDir) { - console.error(` ! --out is ignored for brownfield; the result lands on cook/${runId} in the repo`); - console.error(''); + line(` ! --out is ignored for brownfield; the result lands on cook/${runId} in the repo`); + line(''); } if (!ok) { - console.error(` ! run did not complete — nothing promoted. Artifact: ${sandboxDir}`); - console.error(''); + line(` ! run did not complete — nothing promoted. Artifact: ${sandboxDir}`); + line(''); } else { try { const source = promotionSourceDir({ @@ -551,30 +560,30 @@ export async function runCook(opts: CookOptions): Promise { completedSliceIds: result.slices.filter((s) => s.status === 'completed').map((s) => s.sliceId), }); for (const c of source.conflicts) { - console.error( - ` ! merge conflict on ${c.path} (slices ${c.slices.join(', ')}; kept ${c.winner})`, - ); + line(` ! merge conflict on ${c.path} (slices ${c.slices.join(', ')}; kept ${c.winner})`); } - const promoted = promoteBrownfieldRun({ - sourceDir: sandbox.sourceDir, - sourceTreeDir: source.dir, - runId, - }); - console.error( + const promoted = promoting(`promoting → cook/${runId}`, () => + promoteBrownfieldRun({ + sourceDir: sandbox.sourceDir, + sourceTreeDir: source.dir, + runId, + }), + ); + line( ` ✓ promoted → ${promoted.branch} @ ${promoted.commit.slice(0, 8)} (merge it into your branch when ready)`, ); - console.error(''); + line(''); } catch (err) { - console.error(` ✗ promotion failed: ${err instanceof Error ? err.message : String(err)}`); - console.error(''); + line(` ✗ promotion failed: ${err instanceof Error ? err.message : String(err)}`); + line(''); recordCookExitStatus(false); return; } } } else if (opts.outDir) { if (!ok) { - console.error(` ! run did not complete — nothing promoted. Artifact: ${sandboxDir}`); - console.error(''); + line(` ! run did not complete — nothing promoted. Artifact: ${sandboxDir}`); + line(''); } else { try { const source = promotionSourceDir({ @@ -585,23 +594,21 @@ export async function runCook(opts: CookOptions): Promise { completedSliceIds: result.slices.filter((s) => s.status === 'completed').map((s) => s.sliceId), }); for (const c of source.conflicts) { - console.error( - ` ! merge conflict on ${c.path} (slices ${c.slices.join(', ')}; kept ${c.winner})`, - ); + line(` ! merge conflict on ${c.path} (slices ${c.slices.join(', ')}; kept ${c.winner})`); } - const promoted = promoteGreenfieldRun({ - sandboxDir: source.dir, - target: opts.outDir, - runId, - force: opts.force, - }); - console.error( - ` ✓ promoted → ${promoted.target} (${promoted.branch} @ ${promoted.commit.slice(0, 8)})`, + const promoted = promoting(`promoting → ${opts.outDir}`, () => + promoteGreenfieldRun({ + sandboxDir: source.dir, + target: opts.outDir!, + runId, + force: opts.force, + }), ); - console.error(''); + line(` ✓ promoted → ${promoted.target} (${promoted.branch} @ ${promoted.commit.slice(0, 8)})`); + line(''); } catch (err) { - console.error(` ✗ promotion failed: ${err instanceof Error ? err.message : String(err)}`); - console.error(''); + line(` ✗ promotion failed: ${err instanceof Error ? err.message : String(err)}`); + line(''); recordCookExitStatus(false); return; } diff --git a/src/orchestrator/src/cook-report.test.ts b/src/orchestrator/src/cook-report.test.ts new file mode 100644 index 00000000..0eaf7984 --- /dev/null +++ b/src/orchestrator/src/cook-report.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { cookBannerLines, cookSummaryLines } from './cook-report.js'; + +describe('cookBannerLines', () => { + it('renders the cook banner block byte-for-byte', () => { + expect( + cookBannerLines({ + policy: 'serial', + epicCount: 2, + sliceCount: 5, + maxRetries: 3, + sandboxDir: '/runs/abc/worktree', + reportsPath: '/runs/abc/reports.jsonl', + }), + ).toEqual([ + '', + ' brunch cook', + ' ──────────────────────────────────────', + ' policy serial', + ' plan 2 epics, 5 slices', + ' retries 3', + ' sandbox /runs/abc/worktree', + ' reports /runs/abc/reports.jsonl', + '', + ]); + }); +}); + +describe('cookSummaryLines', () => { + it('renders a completed run with its epic/slice tree and event count', () => { + expect( + cookSummaryLines({ + status: 'completed', + duration: '1m02s', + warnings: [], + epics: [{ epicId: 'api', status: 'completed' }], + slices: [ + { sliceId: 'login', status: 'completed' }, + { sliceId: 'logout', status: 'completed' }, + ], + planSlices: [ + { id: 'login', epic_id: 'api' }, + { id: 'logout', epic_id: 'api' }, + ], + reportCount: 12, + reportsPath: '/runs/abc/reports.jsonl', + }), + ).toEqual([ + '', + ' ──────────────────────────────────────', + ' ✓ completed (1m02s)', + '', + ' ✓ api', + ' ✓ login ✓ logout', + '', + ' 12 events → /runs/abc/reports.jsonl', + '', + ]); + }); + + it('renders a halted run with its reason and warnings, and per-epic/slice failure marks', () => { + expect( + cookSummaryLines({ + status: 'halted', + reason: 'budget exhausted', + duration: '8.4s', + warnings: ['retry budget hit on login'], + epics: [{ epicId: 'api', status: 'halted' }], + slices: [ + { sliceId: 'login', status: 'failed' }, + { sliceId: 'logout', status: 'completed' }, + ], + planSlices: [ + { id: 'login', epic_id: 'api' }, + { id: 'logout', epic_id: 'api' }, + ], + reportCount: 7, + reportsPath: '/r.jsonl', + }), + ).toEqual([ + '', + ' ──────────────────────────────────────', + ' ✗ halted — budget exhausted (8.4s)', + ' ! retry budget hit on login', + '', + ' ✗ api', + ' ✗ login ✓ logout', + '', + ' 7 events → /r.jsonl', + '', + ]); + }); +}); diff --git a/src/orchestrator/src/cook-report.ts b/src/orchestrator/src/cook-report.ts new file mode 100644 index 00000000..c4fd9eb7 --- /dev/null +++ b/src/orchestrator/src/cook-report.ts @@ -0,0 +1,63 @@ +// Pure line-builders for `brunch cook`'s banner and completion summary. +// +// Extracted from runCook so the exact text is golden-testable without booting +// the engine (ln-review #3 — the strings had no oracle). runCook feeds these +// lines to the presentation bus. + +export type CookBannerInput = { + policy: string; + epicCount: number; + sliceCount: number; + maxRetries: number; + sandboxDir: string; + reportsPath: string; +}; + +export function cookBannerLines(input: CookBannerInput): string[] { + return [ + '', + ' brunch cook', + ' ──────────────────────────────────────', + ` policy ${input.policy}`, + ` plan ${input.epicCount} epics, ${input.sliceCount} slices`, + ` retries ${input.maxRetries}`, + ` sandbox ${input.sandboxDir}`, + ` reports ${input.reportsPath}`, + '', + ]; +} + +export type CookSummaryInput = { + status: string; + reason?: string; + duration: string; + warnings: string[]; + epics: { epicId: string; status: string }[]; + slices: { sliceId: string; status: string }[]; + planSlices: { id: string; epic_id: string }[]; + reportCount: number; + reportsPath: string; +}; + +export function cookSummaryLines(input: CookSummaryInput): string[] { + const ok = input.status === 'completed'; + const lines: string[] = [ + '', + ' ──────────────────────────────────────', + ` ${ok ? '✓' : '✗'} ${input.status}${input.reason ? ` — ${input.reason}` : ''} (${input.duration})`, + ]; + for (const warning of input.warnings) lines.push(` ! ${warning}`); + lines.push(''); + + for (const epic of input.epics) { + const icon = epic.status === 'completed' ? '✓' : '✗'; + const sliceSummary = input.slices + .filter((s) => input.planSlices.find((ps) => ps.id === s.sliceId)?.epic_id === epic.epicId) + .map((s) => `${s.status === 'completed' ? '✓' : '✗'} ${s.sliceId}`) + .join(' '); + lines.push(` ${icon} ${epic.epicId}`, ` ${sliceSummary}`); + } + + lines.push('', ` ${input.reportCount} events → ${input.reportsPath}`, ''); + return lines; +} diff --git a/src/orchestrator/src/pi-actions.test.ts b/src/orchestrator/src/pi-actions.test.ts index 8bf1312e..acc3325d 100644 --- a/src/orchestrator/src/pi-actions.test.ts +++ b/src/orchestrator/src/pi-actions.test.ts @@ -13,6 +13,7 @@ import { sliceTestTask, toolsForAction, } from './pi-actions.js'; +import type { CookEvent } from './presenter/events.js'; import { brunchProfile, bunProfile } from './project-profile.js'; import { InMemoryReportSink } from './report-sink.js'; import type { ActionContext, Epic, Plan, ProbeGrounder, Slice, TestResult, TestRunner } from './types.js'; @@ -121,6 +122,34 @@ describe('evaluate-done / verify-epic share the runner seam — failureKind is v expect(payload.passed).toBe(false); expect(payload.failureKind).toBe('infra'); }); + + it('brackets the test-run wait with a balanced activity-start/end', async () => { + const events: CookEvent[] = []; + const actions = createPiActions({ + testRunner: fakeRunner({ passed: true, output: 'ok' }), + emit: (e) => events.push(e), + }); + await actions['evaluate-done']!(ctx(new InMemoryReportSink())); + + const starts = events.filter((e) => e.kind === 'activity-start'); + const ends = events.filter((e) => e.kind === 'activity-end'); + expect(starts).toHaveLength(1); + expect(ends).toHaveLength(1); + expect((ends[0] as { id: string }).id).toBe((starts[0] as { id: string }).id); + }); + + it('closes the pi-session activity even when the session fails (finally)', async () => { + process.env.ANTHROPIC_API_KEY ??= 'test-key-unused-fake-session'; + const events: CookEvent[] = []; + const createSession = (async () => { + throw new Error('session boom'); + }) as unknown as SessionFactory; + const actions = createPiActions({ createSession, emit: (e) => events.push(e) }); + + await expect(actions['write-tests']!(ctx(new InMemoryReportSink()))).rejects.toThrow(); + expect(events.filter((e) => e.kind === 'activity-start')).toHaveLength(1); + expect(events.filter((e) => e.kind === 'activity-end')).toHaveLength(1); + }); }); describe('verify-epic integration oracle (FE-876) — reachability folds into the epic verdict', () => { diff --git a/src/orchestrator/src/pi-actions.ts b/src/orchestrator/src/pi-actions.ts index 577bf571..a114a384 100644 --- a/src/orchestrator/src/pi-actions.ts +++ b/src/orchestrator/src/pi-actions.ts @@ -14,6 +14,7 @@ import { } from '@earendil-works/pi-coding-agent'; import { buildProbeSpec, runProbe } from './app-probe.js'; +import type { CookEvent } from './presenter/events.js'; import { defaultToolchain, type Toolchain } from './project-profile.js'; import { createReport } from './report-helpers.js'; import { sliceLabel } from './slice-label.js'; @@ -38,27 +39,30 @@ const promptsDir = __dirname.includes('dist') // Logging // --------------------------------------------------------------------------- -let t0 = 0; let _verbose = false; - -function elapsed(): string { - const s = ((Date.now() - t0) / 1000).toFixed(1); - return `${s}s`.padStart(7); -} +// Presentation boundary. Per-action progress flows to the CookBus as +// CookEvents; the presenter owns formatting (and the elapsed clock — +// I136-K). Defaults to a no-op so unit tests that ignore output run clean. +let _emit: (event: CookEvent) => void = () => {}; function log(icon: string, msg: string): void { - console.error(` ${elapsed()} ${icon} ${msg}`); + _emit({ kind: 'action', icon, message: msg }); } function logVerbose(output: string): void { if (!_verbose) return; - const trimmed = output.trim(); - if (!trimmed) return; - console.error(''); - for (const line of trimmed.split('\n')) { - console.error(` │ ${line}`); + // The presenter trims and skips blank output. + _emit({ kind: 'verbose', text: output }); +} + +/** Bracket a wait so it shows as a live pending activity; always closes. */ +async function withActivity(id: string, label: string, fn: () => Promise): Promise { + _emit({ kind: 'activity-start', id, label }); + try { + return await fn(); + } finally { + _emit({ kind: 'activity-end', id }); } - console.error(''); } // --------------------------------------------------------------------------- @@ -161,6 +165,9 @@ async function runPi( const timeoutMs = deps.timeoutMs ?? PI_TIMEOUT_MS; const maxOutput = deps.maxOutput ?? PI_MAX_OUTPUT; const start = Date.now(); + // Open a live wait so the (up to 5-minute) agent session isn't dead air. + _emit({ kind: 'activity-start', id: opts.label, label: opts.label }); + let heartbeatKb = 0; const isolatedDir = createAgentDir(); let cleanedAgentDir = false; @@ -216,6 +223,12 @@ async function runPi( } captured += delta; capturedBytes += deltaBytes; + // Throttled heartbeat — every 2 KB — so the spinner shows progress, not churn. + const kb = Math.floor(capturedBytes / 1024); + if (kb >= heartbeatKb + 2) { + heartbeatKb = kb; + _emit({ kind: 'activity-progress', id: opts.label, detail: `${kb} KB` }); + } } }); @@ -229,6 +242,9 @@ async function runPi( unsubscribe?.(); session?.dispose(); cleanupAgentDir(); + // Always close the wait — even on timeout / overflow / prompt error — so + // the spinner can never hang. + _emit({ kind: 'activity-end', id: opts.label }); } if (timedOut) throw piTimeoutError(timeoutMs); @@ -289,7 +305,8 @@ export function epicVerifyTask(epic: Epic, toolchain: Toolchain): string { export function createPiActions(opts?: { verbose?: boolean; - runStart?: number; + /** Presentation sink. Per-action progress is emitted as CookEvents; defaults to no-op. */ + emit?: (event: CookEvent) => void; toolchain?: Toolchain; testRunner?: TestRunner; /** Inject the agent-session factory (tests stub it so no real session runs). */ @@ -303,7 +320,7 @@ export function createPiActions(opts?: { groundProbe?: ProbeGrounder; }): ActionHandlers { _verbose = opts?.verbose ?? false; - t0 = opts?.runStart ?? Date.now(); + _emit = opts?.emit ?? (() => {}); const toolchain = opts?.toolchain ?? defaultToolchain; const testRunner = opts?.testRunner ?? new ToolchainTestRunner(toolchain); const groundProbe = opts?.groundProbe; @@ -313,10 +330,10 @@ export function createPiActions(opts?: { 'evaluate-done': async (ctx: ActionContext) => { const label = sliceLabel(ctx.slice); log('?', `evaluate ${label}`); - const { done, failureKind, results } = await runVerification( - ctx.slice.verification, - testRunner, - ctx.sandboxDir, + const { done, failureKind, results } = await withActivity( + `verify ${label}`, + `running tests · ${label}`, + () => runVerification(ctx.slice.verification, testRunner, ctx.sandboxDir), ); for (const r of results) { logVerbose(r.output); @@ -398,7 +415,9 @@ export function createPiActions(opts?: { done: testsPassed, failureKind, results, - } = await runVerification(ctx.epic.verification, testRunner, ctx.sandboxDir); + } = await withActivity(`verify-epic ${ctx.epic.id}`, `running tests · ${ctx.epic.id}`, () => + runVerification(ctx.epic.verification, testRunner, ctx.sandboxDir), + ); for (const r of results) { logVerbose(r.output); log(r.passed ? '✓' : '✗', `verify ${r.target}`); @@ -415,7 +434,13 @@ export function createPiActions(opts?: { if (testsPassed) { try { const target = await resolveProbeTarget(ctx.epic, ctx.sandboxDir, groundProbe); - if (target) probe = await runProbe(await buildProbeSpec(target), ctx.sandboxDir); + if (target) { + probe = await withActivity( + `probe ${ctx.epic.id}`, + `probing reachability · ${ctx.epic.id}`, + async () => runProbe(await buildProbeSpec(target), ctx.sandboxDir), + ); + } } catch (err) { probe = { kind: 'infra', reachable: false, output: `probe grounding failed: ${String(err)}` }; } diff --git a/src/orchestrator/src/presenter.test.ts b/src/orchestrator/src/presenter.test.ts new file mode 100644 index 00000000..19b35a4d --- /dev/null +++ b/src/orchestrator/src/presenter.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { withCookBus } from './presenter.js'; +import { CookBus } from './presenter/bus.js'; + +describe('withCookBus', () => { + it('runs the work with a bus and disposes it afterward', async () => { + const dispose = vi.spyOn(CookBus.prototype, 'dispose'); + let seen: CookBus | undefined; + + await withCookBus('cook', async (bus) => { + seen = bus; + }); + + expect(seen).toBeInstanceOf(CookBus); + expect(dispose).toHaveBeenCalledTimes(1); + dispose.mockRestore(); + }); + + it('disposes the bus even when the work throws', async () => { + const dispose = vi.spyOn(CookBus.prototype, 'dispose'); + + await expect( + withCookBus('plan', async () => { + throw new Error('work boom'); + }), + ).rejects.toThrow('work boom'); + + expect(dispose).toHaveBeenCalledTimes(1); + dispose.mockRestore(); + }); +}); diff --git a/src/orchestrator/src/presenter.ts b/src/orchestrator/src/presenter.ts new file mode 100644 index 00000000..a3c3d6a2 --- /dev/null +++ b/src/orchestrator/src/presenter.ts @@ -0,0 +1,64 @@ +// Public entry point for the CLI presentation seam. +// +// The orchestrator emits `CookEvent`s to a `CookBus`; a presenter chosen +// by environment renders them. `reports.jsonl` stays the durable medium +// (D156-K) — CookEvents are ephemeral presentation only. External callers +// import from here; only this root reaches into `presenter/`. + +import { CookBus } from './presenter/bus.js'; +import type { Presenter } from './presenter/events.js'; +import { InkPresenter } from './presenter/ink/ink-presenter.js'; +import { PlainPresenter } from './presenter/plain.js'; +import { type PresenterCommand, type PresenterKind, selectPresenter } from './presenter/select.js'; +import { SilentPresenter } from './presenter/silent.js'; + +export { CookBus } from './presenter/bus.js'; +export type { CookEvent, Presenter } from './presenter/events.js'; +export { PlainPresenter } from './presenter/plain.js'; +export { SilentPresenter } from './presenter/silent.js'; +export { + type PresenterCommand, + type PresenterKind, + type SelectPresenterEnv, + selectPresenter, +} from './presenter/select.js'; + +export function makePresenter(kind: PresenterKind, command: PresenterCommand): Presenter { + if (kind === 'silent') return new SilentPresenter(); + if (kind === 'ink') return new InkPresenter(command); + return new PlainPresenter(); +} + +/** + * Own the bus lifecycle for one command run: build it, run the work, and + * always dispose it (which unmounts the Ink app) — even on throw. Entry points + * use this instead of scattering create/dispose, so the TUI can never be left + * mounted and hang the process. + */ +export async function withCookBus( + command: PresenterCommand, + fn: (bus: CookBus) => Promise, +): Promise { + const bus = createCookBus(command); + try { + await fn(bus); + } finally { + await bus.dispose(); + } +} + +/** Build a bus with the environment-selected presenter subscribed. */ +export function createCookBus( + command: PresenterCommand, + env: { isTTY?: boolean; ci?: boolean; reporterFlag?: PresenterKind } = {}, +): CookBus { + const kind = selectPresenter({ + command, + isTTY: env.isTTY ?? Boolean(process.stderr.isTTY), + ci: env.ci ?? Boolean(process.env.CI), + ...(env.reporterFlag ? { reporterFlag: env.reporterFlag } : {}), + }); + const bus = new CookBus(); + bus.subscribe(makePresenter(kind, command)); + return bus; +} diff --git a/src/orchestrator/src/presenter/bus.test.ts b/src/orchestrator/src/presenter/bus.test.ts new file mode 100644 index 00000000..b7e360f1 --- /dev/null +++ b/src/orchestrator/src/presenter/bus.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CookBus } from './bus.js'; +import type { CookEvent, Presenter } from './events.js'; + +function recorder(): Presenter & { events: CookEvent[] } { + const events: CookEvent[] = []; + return { events, onEvent: (e) => events.push(e), dispose: () => {} }; +} + +const ev: CookEvent = { kind: 'plan-start', specId: 2, outDir: '/x' }; + +describe('CookBus', () => { + it('fans every event out to all subscribed presenters in order', () => { + const a = recorder(); + const b = recorder(); + const bus = new CookBus(); + bus.subscribe(a); + bus.subscribe(b); + + bus.emit(ev); + bus.emit({ kind: 'plan-written', path: '/p', epics: 1, slices: 2 }); + + expect(a.events.map((e) => e.kind)).toEqual(['plan-start', 'plan-written']); + expect(b.events).toEqual(a.events); + }); + + it('isolates a throwing presenter so it cannot abort the run or starve siblings', () => { + const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {}); + const boom: Presenter = { + onEvent: () => { + throw new Error('render-boom'); + }, + dispose: () => {}, + }; + const ok = recorder(); + const bus = new CookBus(); + bus.subscribe(boom); + bus.subscribe(ok); + + expect(() => bus.emit(ev)).not.toThrow(); + expect(ok.events).toEqual([ev]); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('disposes every presenter, swallowing dispose errors', async () => { + const disposed: string[] = []; + const boom: Presenter = { + onEvent: () => {}, + dispose: () => { + throw new Error('dispose-boom'); + }, + }; + const ok: Presenter = { onEvent: () => {}, dispose: () => void disposed.push('ok') }; + const bus = new CookBus(); + bus.subscribe(boom); + bus.subscribe(ok); + + await expect(bus.dispose()).resolves.toBeUndefined(); + expect(disposed).toEqual(['ok']); + }); +}); diff --git a/src/orchestrator/src/presenter/bus.ts b/src/orchestrator/src/presenter/bus.ts new file mode 100644 index 00000000..8320e406 --- /dev/null +++ b/src/orchestrator/src/presenter/bus.ts @@ -0,0 +1,36 @@ +// Synchronous fan-out from the orchestrator to its presenters. +// +// One producer, many presenters. A presenter that throws must never +// abort the run or starve its siblings — failures are downgraded to a +// process warning. Exactly one event type, one consumer shape: a bespoke +// class is clearer here than a generic EventEmitter. + +import type { CookEvent, Presenter } from './events.js'; + +export class CookBus { + private readonly presenters: Presenter[] = []; + + subscribe(presenter: Presenter): void { + this.presenters.push(presenter); + } + + emit(event: CookEvent): void { + for (const presenter of this.presenters) { + try { + presenter.onEvent(event); + } catch (err) { + process.emitWarning(`presenter failed on "${event.kind}": ${String(err)}`); + } + } + } + + async dispose(): Promise { + for (const presenter of this.presenters) { + try { + await presenter.dispose(); + } catch { + // A presenter that fails to tear down must not mask the run's outcome. + } + } + } +} diff --git a/src/orchestrator/src/presenter/clock.test.ts b/src/orchestrator/src/presenter/clock.test.ts new file mode 100644 index 00000000..eabbd15e --- /dev/null +++ b/src/orchestrator/src/presenter/clock.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { formatElapsed } from './clock.js'; + +describe('formatElapsed', () => { + it('renders whole seconds under a minute — no decimals', () => { + expect(formatElapsed(0)).toBe('0s'); + expect(formatElapsed(2500)).toBe('2s'); // floors, doesn't round + expect(formatElapsed(18_600)).toBe('18s'); + expect(formatElapsed(59_900)).toBe('59s'); + }); + + it('renders m:ss at and beyond a minute', () => { + expect(formatElapsed(60_000)).toBe('1m00s'); + expect(formatElapsed(62_000)).toBe('1m02s'); + expect(formatElapsed(305_000)).toBe('5m05s'); + }); + + it('clamps negatives to 0s', () => { + expect(formatElapsed(-100)).toBe('0s'); + }); +}); diff --git a/src/orchestrator/src/presenter/clock.ts b/src/orchestrator/src/presenter/clock.ts new file mode 100644 index 00000000..1fd0d03b --- /dev/null +++ b/src/orchestrator/src/presenter/clock.ts @@ -0,0 +1,37 @@ +// Elapsed-since-cook-start clock, owned by a presenter (I136-K). +// +// `now` is injectable so goldens and frame tests are deterministic. +// Format matches the pre-refactor `elapsed()`: one decimal second, +// right-padded to 7 columns. + +export interface ElapsedClock { + seed(runStart: number): void; + elapsed(): string; +} + +/** + * Human elapsed for a live, ticking indicator: whole seconds under a minute, + * `m:ss` above. Deliberately coarse — no decimals — so a fast re-render loop + * doesn't make the number flicker. + */ +export function formatElapsed(ms: number): string { + const total = Math.max(0, Math.floor(ms / 1000)); + if (total < 60) return `${total}s`; + const minutes = Math.floor(total / 60); + const seconds = total % 60; + return `${minutes}m${String(seconds).padStart(2, '0')}s`; +} + +export function createElapsedClock(now: () => number = () => Date.now()): ElapsedClock { + let runStart: number | undefined; + return { + seed(rs) { + runStart = rs; + }, + elapsed() { + if (runStart === undefined) runStart = now(); + const seconds = ((now() - runStart) / 1000).toFixed(1); + return `${seconds}s`.padStart(7); + }, + }; +} diff --git a/src/orchestrator/src/presenter/events.ts b/src/orchestrator/src/presenter/events.ts new file mode 100644 index 00000000..6be9e662 --- /dev/null +++ b/src/orchestrator/src/presenter/events.ts @@ -0,0 +1,37 @@ +// The presentation event stream for `plan` / `cook` / `serve`. +// +// This is the single boundary the orchestrator emits to; presenters +// (plain / ink / silent) consume it. It is *ephemeral* — `reports.jsonl` +// remains the durable communication medium (D156-K); a CookEvent never +// carries durable truth, only what the user should see happen. +// +// The union grows arm-by-arm as surfaces are migrated off direct +// `console.error`. Slice 1 covers the existing post-hoc output; live +// `activity-start`/`activity-end` waits are slice 2. + +export type CookEvent = + // --- plan surface --- + | { kind: 'plan-start'; specId: number; outDir: string } + | { kind: 'plan-written'; path: string; epics: number; slices: number } + | { kind: 'plan-warnings'; messages: string[] } + // --- cook surface --- + // Seeds the presenter's elapsed clock; renders nothing itself. + | { kind: 'cook-start'; runStart: number } + // A per-action progress line; the presenter prepends elapsed-since-cook-start. + | { kind: 'action'; icon: string; message: string } + // Raw agent output, shown only when the emit site is in verbose mode. + | { kind: 'verbose'; text: string } + // A pre-formatted line rendered verbatim (banner / summary / promotion blocks). + | { kind: 'line'; text: string } + // --- live waits (slice 2b) --- + // Opens a pending activity: a long wait the user should see in progress. + | { kind: 'activity-start'; id: string; label: string } + // Updates the in-flight detail of an open activity (e.g. a pi token heartbeat). + | { kind: 'activity-progress'; id: string; detail: string } + // Closes the activity; the wait is over. + | { kind: 'activity-end'; id: string }; + +export interface Presenter { + onEvent(event: CookEvent): void; + dispose(): void | Promise; +} diff --git a/src/orchestrator/src/presenter/format.ts b/src/orchestrator/src/presenter/format.ts new file mode 100644 index 00000000..5dc04935 --- /dev/null +++ b/src/orchestrator/src/presenter/format.ts @@ -0,0 +1,40 @@ +// CookEvent → display lines. The single formatting authority, shared by the +// plain backend (writes each line to stderr) and the Ink backend (accumulates +// them into the activity log), so the two can never drift. `cook-start` seeds +// the clock and yields no lines. + +import type { ElapsedClock } from './clock.js'; +import type { CookEvent } from './events.js'; + +const RULE = ' ──────────────────────────────────────'; + +export function formatCookEvent(event: CookEvent, clock: ElapsedClock): string[] { + switch (event.kind) { + case 'plan-start': + return ['', ' brunch plan', RULE, ` spec ${event.specId}`, ` out ${event.outDir}`, '']; + case 'plan-written': + return [` ✓ plan ${event.path}`, ` ${event.epics} epics, ${event.slices} slices`, '']; + case 'plan-warnings': + if (event.messages.length === 0) return []; + return [` ${event.messages.length} warnings:`, ...event.messages.map((m) => ` ! ${m}`), '']; + case 'cook-start': + clock.seed(event.runStart); + return []; + case 'action': + return [` ${clock.elapsed()} ${event.icon} ${event.message}`]; + case 'verbose': { + const trimmed = event.text.trim(); + if (!trimmed) return []; + return ['', ...trimmed.split('\n').map((line) => ` │ ${line}`), '']; + } + case 'line': + return [event.text]; + case 'activity-start': + // Plain/CI can't animate; a single line breaks the silence at wait start. + return [` ${clock.elapsed()} ⋯ ${event.label}`]; + case 'activity-progress': + case 'activity-end': + // Live-only: the Ink panel reflects these; the existing completion log marks the end. + return []; + } +} diff --git a/src/orchestrator/src/presenter/ink/app.test.tsx b/src/orchestrator/src/presenter/ink/app.test.tsx new file mode 100644 index 00000000..667af51b --- /dev/null +++ b/src/orchestrator/src/presenter/ink/app.test.tsx @@ -0,0 +1,67 @@ +import { render } from 'ink-testing-library'; +import { describe, expect, it } from 'vitest'; + +import { RunStore } from '../run-store.js'; +import { App } from './app.js'; + +async function tick() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('Ink App', () => { + it('renders the wordmark header, brigade tracker, and recent activity', async () => { + const store = new RunStore('cook', () => 0); + const { lastFrame } = render(); + + store.push({ kind: 'cook-start', runStart: 0 }); + store.push({ kind: 'action', icon: '▸', message: 'tests slice-1' }); + await tick(); + + const frame = lastFrame() ?? ''; + // Wordmark header + command. + expect(frame).toContain('brunch'); + expect(frame).toContain('cook'); + // Brigade tracker shows every phase, with cook active (◐) once cooking. + expect(frame).toContain('prep'); + expect(frame).toContain('cook ◐'); + // Activity log carries the formatted action line. + expect(frame).toContain('tests slice-1'); + }); + + it('marks earlier phases done and reflects promotion as plate', async () => { + const store = new RunStore('serve', () => 0); + const { lastFrame } = render(); + + store.push({ kind: 'cook-start', runStart: 0 }); + store.push({ kind: 'line', text: ' ✓ promoted → cook/abc @ 1234abcd' }); + await tick(); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('plate ◐'); + expect(frame).toContain('cook ✓'); + expect(frame).toContain('promoted'); + }); + + it('shows a pending activity with label + elapsed + detail, and clears it on end', async () => { + let clock = 1000; + const store = new RunStore('cook', () => clock); + const { lastFrame } = render( clock} />); + + store.push({ kind: 'activity-start', id: 'tests:slice-1', label: 'agent writing tests' }); + store.push({ kind: 'activity-progress', id: 'tests:slice-1', detail: '8 KB' }); + clock = 3500; // 2.5s elapsed + await tick(); + + let frame = lastFrame() ?? ''; + expect(frame).toContain('agent writing tests'); + expect(frame).toContain('2s'); // whole seconds — no jittery decimal + expect(frame).not.toContain('2.5s'); + expect(frame).toContain('8 KB'); + + store.push({ kind: 'activity-end', id: 'tests:slice-1' }); + await tick(); + + frame = lastFrame() ?? ''; + expect(frame).not.toContain('agent writing tests'); + }); +}); diff --git a/src/orchestrator/src/presenter/ink/app.tsx b/src/orchestrator/src/presenter/ink/app.tsx new file mode 100644 index 00000000..d67ba031 --- /dev/null +++ b/src/orchestrator/src/presenter/ink/app.tsx @@ -0,0 +1,105 @@ +// The full-screen Ink view: brunch wordmark header, brigade phase tracker, and a +// bounded live activity log. A thin projection of RunStore — all folding +// lives in the store + the pure phase tracker, so this stays declarative. + +import { Box, Text } from 'ink'; +import { useEffect, useState, useSyncExternalStore } from 'react'; + +import { formatElapsed } from '../clock.js'; +import { BRIGADE, type BrigadePhase } from '../phase.js'; +import type { PendingActivity, RunStore } from '../run-store.js'; +import { BRUNCH_WORDMARK } from './wordmark.js'; + +const LOG_TAIL = 15; +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +const TICK_MS = 250; + +function Header({ command }: { command: string }) { + return ( + + {BRUNCH_WORDMARK.map(({ ch, color }) => ( + + {ch} + + ))} + {command} + + ); +} + +const STATUS_ICON = { done: '✓', active: '◐', pending: '○' } as const; + +function Brigade({ phase }: { phase: BrigadePhase }) { + const active = BRIGADE.indexOf(phase); + return ( + + {BRIGADE.map((p, i) => { + const status = i < active ? 'done' : i === active ? 'active' : 'pending'; + const color = status === 'active' ? 'cyan' : status === 'done' ? 'green' : 'gray'; + return ( + + {p} {STATUS_ICON[status]} + {i < BRIGADE.length - 1 ? ' ' : ''} + + ); + })} + + ); +} + +function ActivityLog({ lines }: { lines: string[] }) { + return ( + + {lines.slice(-LOG_TAIL).map((line, i) => ( + {line === '' ? ' ' : line} + ))} + + ); +} + +function PendingPanel({ + pending, + now, + frame, +}: { + pending: PendingActivity[]; + now: () => number; + frame: string; +}) { + if (pending.length === 0) return null; + return ( + + {pending.map((a) => ( + + {frame} {a.label} · {formatElapsed(now() - a.startedAt)} + {a.detail ? ` · ${a.detail}` : ''} + + ))} + + ); +} + +export function App({ store, now = () => Date.now() }: { store: RunStore; now?: () => number }) { + const state = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); + + // Tick only while something is pending, so the spinner/elapsed advance even + // between events; the interval is torn down as soon as the waits clear. + const [tick, setTick] = useState(0); + const hasPending = state.pending.length > 0; + useEffect(() => { + if (!hasPending) return; + const id = setInterval(() => setTick((t) => t + 1), TICK_MS); + return () => clearInterval(id); + }, [hasPending]); + + return ( + +
+ + + + + + + ); +} diff --git a/src/orchestrator/src/presenter/ink/ink-presenter.tsx b/src/orchestrator/src/presenter/ink/ink-presenter.tsx new file mode 100644 index 00000000..ae9e02c8 --- /dev/null +++ b/src/orchestrator/src/presenter/ink/ink-presenter.tsx @@ -0,0 +1,29 @@ +// The interactive full-screen backend. Renders the Ink App to STDERR (stdout +// stays reserved), feeding it from a RunStore. Thin glue: state + formatting +// live in RunStore / format / phase, all unit-tested without a terminal. + +import { render } from 'ink'; + +import type { CookEvent, Presenter } from '../events.js'; +import { RunStore } from '../run-store.js'; +import { App } from './app.js'; + +export class InkPresenter implements Presenter { + private readonly store: RunStore; + private readonly instance: ReturnType; + + constructor(command: string, now: () => number = () => Date.now()) { + this.store = new RunStore(command, now); + // Render to stderr so stdout stays clean for piping / agent JSONL. + this.instance = render(, { stdout: process.stderr }); + } + + onEvent(event: CookEvent): void { + this.store.push(event); + } + + async dispose(): Promise { + this.instance.unmount(); + await this.instance.waitUntilExit(); + } +} diff --git a/src/orchestrator/src/presenter/ink/wordmark.ts b/src/orchestrator/src/presenter/ink/wordmark.ts new file mode 100644 index 00000000..fc793939 --- /dev/null +++ b/src/orchestrator/src/presenter/ink/wordmark.ts @@ -0,0 +1,12 @@ +// The "brunch" wordmark for the TUI header, tinted with the brunch.ai brand +// gradient (HASH blue → indigo → violet, from the product mark). One hex per +// letter, left to right. The plain/CI backend stays untinted. + +export const BRUNCH_WORDMARK: readonly { ch: string; color: string }[] = [ + { ch: 'b', color: '#00BBFF' }, + { ch: 'r', color: '#0080FF' }, + { ch: 'u', color: '#0046FF' }, + { ch: 'n', color: '#3A36FF' }, + { ch: 'c', color: '#5424FF' }, + { ch: 'h', color: '#6D2BF6' }, +]; diff --git a/src/orchestrator/src/presenter/phase.test.ts b/src/orchestrator/src/presenter/phase.test.ts new file mode 100644 index 00000000..e3a78a81 --- /dev/null +++ b/src/orchestrator/src/presenter/phase.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import type { CookEvent } from './events.js'; +import { type BrigadePhase, nextPhase } from './phase.js'; + +function walk(events: CookEvent[]): BrigadePhase { + return events.reduce((phase, event) => nextPhase(phase, event), 'prep'); +} + +describe('nextPhase', () => { + it('lights recipe at plan-start and cook at cook-start', () => { + expect(nextPhase('prep', { kind: 'plan-start', specId: 1, outDir: '/x' })).toBe('recipe'); + expect(nextPhase('prep', { kind: 'cook-start', runStart: 0 })).toBe('cook'); + }); + + it('advances to taste on an epic/verify action and to plate on a promotion line', () => { + expect(nextPhase('cook', { kind: 'action', icon: '▸', message: 'verify api-auth' })).toBe('taste'); + expect(nextPhase('cook', { kind: 'action', icon: '●', message: 'epic api-auth → PASS' })).toBe( + 'taste', + ); + expect(nextPhase('taste', { kind: 'line', text: ' ✓ promoted → cook/abc @ 1234abcd' })).toBe('plate'); + }); + + it('never regresses to an earlier phase', () => { + // A per-slice action after taste must not pull the tracker back to cook. + expect(nextPhase('taste', { kind: 'action', icon: '▸', message: 'tests slice-2' })).toBe('taste'); + expect(nextPhase('plate', { kind: 'cook-start', runStart: 0 })).toBe('plate'); + }); + + it('walks a full cook run prep → cook → taste → plate', () => { + expect( + walk([ + { kind: 'cook-start', runStart: 0 }, + { kind: 'action', icon: '▸', message: 'tests slice-1' }, + { kind: 'action', icon: '▸', message: 'verify api-auth' }, + { kind: 'line', text: ' ✓ promoted → cook/abc @ 1234abcd' }, + ]), + ).toBe('plate'); + }); +}); diff --git a/src/orchestrator/src/presenter/phase.ts b/src/orchestrator/src/presenter/phase.ts new file mode 100644 index 00000000..e74b1e0b --- /dev/null +++ b/src/orchestrator/src/presenter/phase.ts @@ -0,0 +1,35 @@ +// The kitchen-brigade phase tracker — a pure, monotonic projection of the +// CookEvent stream. The brigade names are phase labels, not commands +// (PLAN.md): detect→prep, plan→recipe, orchestrate→cook, verify→taste, +// promote→plate, ship→serve. +// +// Slice 2a derives the phase coarsely from the post-hoc event vocabulary; +// precise in-flight transitions arrive with the activity-start signals in +// slice 2b. The tracker never regresses. + +import type { CookEvent } from './events.js'; + +export type BrigadePhase = 'prep' | 'recipe' | 'cook' | 'taste' | 'plate' | 'serve'; + +export const BRIGADE: readonly BrigadePhase[] = ['prep', 'recipe', 'cook', 'taste', 'plate', 'serve']; + +export function nextPhase(current: BrigadePhase, event: CookEvent): BrigadePhase { + const target = phaseFor(event); + if (!target) return current; + return BRIGADE.indexOf(target) > BRIGADE.indexOf(current) ? target : current; +} + +function phaseFor(event: CookEvent): BrigadePhase | undefined { + switch (event.kind) { + case 'plan-start': + return 'recipe'; + case 'cook-start': + return 'cook'; + case 'action': + return /^(verify|epic)/.test(event.message) ? 'taste' : undefined; + case 'line': + return event.text.includes('promoted') ? 'plate' : undefined; + default: + return undefined; + } +} diff --git a/src/orchestrator/src/presenter/plain.test.ts b/src/orchestrator/src/presenter/plain.test.ts new file mode 100644 index 00000000..d78b5dd6 --- /dev/null +++ b/src/orchestrator/src/presenter/plain.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; + +import type { CookEvent } from './events.js'; +import { PlainPresenter } from './plain.js'; + +function render(events: CookEvent[]): string[] { + const lines: string[] = []; + const presenter = new PlainPresenter({ log: (line) => lines.push(line) }); + for (const event of events) presenter.onEvent(event); + return lines; +} + +/** Render with a fake clock so elapsed values are deterministic (I136-K). */ +function renderTimed(events: CookEvent[], nowValues: number[]): string[] { + const lines: string[] = []; + let i = 0; + const presenter = new PlainPresenter({ + log: (line) => lines.push(line), + now: () => nowValues[Math.min(i++, nowValues.length - 1)]!, + }); + for (const event of events) presenter.onEvent(event); + return lines; +} + +describe('PlainPresenter — plan surface', () => { + it('renders the plan banner byte-for-byte', () => { + expect(render([{ kind: 'plan-start', specId: 2, outDir: '/tmp/x' }])).toEqual([ + '', + ' brunch plan', + ' ──────────────────────────────────────', + ' spec 2', + ' out /tmp/x', + '', + ]); + }); + + it('renders the plan-written summary', () => { + expect(render([{ kind: 'plan-written', path: '/p/plan.yaml', epics: 1, slices: 2 }])).toEqual([ + ' ✓ plan /p/plan.yaml', + ' 1 epics, 2 slices', + '', + ]); + }); + + it('renders a warnings block with the printed count and one line per message', () => { + expect( + render([{ kind: 'plan-warnings', messages: ['cycle-break-dropped-edge: a→b', 'orphan: c'] }]), + ).toEqual([' 2 warnings:', ' ! cycle-break-dropped-edge: a→b', ' ! orphan: c', '']); + }); + + it('emits nothing for an empty warnings set', () => { + expect(render([{ kind: 'plan-warnings', messages: [] }])).toEqual([]); + }); +}); + +describe('PlainPresenter — cook surface', () => { + it('renders a verbatim line and nothing for cook-start', () => { + expect( + render([ + { kind: 'cook-start', runStart: 0 }, + { kind: 'line', text: ' brunch cook' }, + ]), + ).toEqual([' brunch cook']); + }); + + it('prepends elapsed-since-cook-start to an action line, padded like the original', () => { + // runStart 1000ms; clock reads 2500ms at the action → 1.5s elapsed. + expect( + renderTimed( + [ + { kind: 'cook-start', runStart: 1000 }, + { kind: 'action', icon: '▸', message: 'tests slice-1' }, + ], + [2500], + ), + ).toEqual([' 1.5s ▸ tests slice-1']); + }); + + it('keeps an inline duration in the action message untouched', () => { + expect( + renderTimed( + [ + { kind: 'cook-start', runStart: 0 }, + { kind: 'action', icon: '✓', message: 'write-tests (0.3s)' }, + ], + [12_300], + ), + ).toEqual([' 12.3s ✓ write-tests (0.3s)']); + }); + + it('renders the verbose block with a left border, blank-padded', () => { + expect(render([{ kind: 'verbose', text: 'line one\nline two' }])).toEqual([ + '', + ' │ line one', + ' │ line two', + '', + ]); + }); + + it('skips a verbose block whose text is blank', () => { + expect(render([{ kind: 'verbose', text: ' \n ' }])).toEqual([]); + }); +}); diff --git a/src/orchestrator/src/presenter/plain.ts b/src/orchestrator/src/presenter/plain.ts new file mode 100644 index 00000000..9a718672 --- /dev/null +++ b/src/orchestrator/src/presenter/plain.ts @@ -0,0 +1,33 @@ +// Line-oriented presenter: CookEvent → stderr lines. +// +// The default backend (CI / non-TTY / piped) and the behavior-preserving +// reference: it reproduces the pre-refactor output of `plan` / `cook` / +// `serve` byte-for-byte. Formatting lives in `format.ts` (shared with the +// Ink backend); this class only owns the clock and the line sink, which +// defaults to `console.error` (stderr — stdout is reserved). + +import { createElapsedClock, type ElapsedClock } from './clock.js'; +import type { CookEvent, Presenter } from './events.js'; +import { formatCookEvent } from './format.js'; + +export type PlainPresenterOptions = { + log?: (line: string) => void; + /** Clock for the elapsed prefix; injectable for deterministic goldens (I136-K). */ + now?: () => number; +}; + +export class PlainPresenter implements Presenter { + private readonly log: (line: string) => void; + private readonly clock: ElapsedClock; + + constructor(options: PlainPresenterOptions = {}) { + this.log = options.log ?? ((line) => console.error(line)); + this.clock = createElapsedClock(options.now); + } + + onEvent(event: CookEvent): void { + for (const line of formatCookEvent(event, this.clock)) this.log(line); + } + + dispose(): void {} +} diff --git a/src/orchestrator/src/presenter/run-store.test.ts b/src/orchestrator/src/presenter/run-store.test.ts new file mode 100644 index 00000000..b7cda75d --- /dev/null +++ b/src/orchestrator/src/presenter/run-store.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { RunStore } from './run-store.js'; + +describe('RunStore', () => { + it('folds cook events into phase + formatted lines, using the injected clock', () => { + const store = new RunStore('cook', () => 1500); + store.push({ kind: 'cook-start', runStart: 1000 }); + store.push({ kind: 'action', icon: '▸', message: 'tests slice-1' }); + + const state = store.getSnapshot(); + expect(state.command).toBe('cook'); + expect(state.phase).toBe('cook'); + // 1500 - 1000 = 0.5s, formatted exactly like the plain backend. + expect(state.lines).toEqual([' 0.5s ▸ tests slice-1']); + }); + + it('advances the brigade phase on a promotion line', () => { + const store = new RunStore('serve', () => 0); + store.push({ kind: 'cook-start', runStart: 0 }); + store.push({ kind: 'line', text: ' ✓ promoted → cook/abc @ 1234abcd' }); + expect(store.getSnapshot().phase).toBe('plate'); + }); + + it('keeps a stable snapshot reference and does not notify on a no-op event', () => { + const store = new RunStore('cook', () => 0); + store.push({ kind: 'cook-start', runStart: 0 }); + const before = store.getSnapshot(); + + let notified = 0; + store.subscribe(() => notified++); + // A second cook-start adds no lines and cannot advance the phase. + store.push({ kind: 'cook-start', runStart: 0 }); + + expect(store.getSnapshot()).toBe(before); + expect(notified).toBe(0); + }); + + it('notifies subscribers when state changes', () => { + const store = new RunStore('cook', () => 0); + let notified = 0; + store.subscribe(() => notified++); + store.push({ kind: 'line', text: ' brunch cook' }); + expect(notified).toBe(1); + }); + + it('tracks pending activities: start adds, progress updates detail, end removes', () => { + let clock = 1000; + const store = new RunStore('cook', () => clock); + store.push({ kind: 'activity-start', id: 'tests:slice-1', label: 'agent writing tests' }); + + let pending = store.getSnapshot().pending; + expect(pending).toHaveLength(1); + expect(pending[0]).toMatchObject({ id: 'tests:slice-1', label: 'agent writing tests', startedAt: 1000 }); + + store.push({ kind: 'activity-progress', id: 'tests:slice-1', detail: '8 KB' }); + expect(store.getSnapshot().pending[0]).toMatchObject({ detail: '8 KB' }); + + clock = 5000; + store.push({ kind: 'activity-end', id: 'tests:slice-1' }); + expect(store.getSnapshot().pending).toHaveLength(0); + }); + + it('does not put activity events into the scrolling line log', () => { + const store = new RunStore('cook', () => 0); + store.push({ kind: 'activity-start', id: 'a', label: 'booting app' }); + store.push({ kind: 'activity-end', id: 'a' }); + expect(store.getSnapshot().lines).toEqual([]); + }); +}); diff --git a/src/orchestrator/src/presenter/run-store.ts b/src/orchestrator/src/presenter/run-store.ts new file mode 100644 index 00000000..81e9add8 --- /dev/null +++ b/src/orchestrator/src/presenter/run-store.ts @@ -0,0 +1,79 @@ +// Observable run state for the Ink backend. +// +// Folds the CookEvent stream into { phase, lines } using the SAME formatter +// as the plain backend (so log bodies can't drift) and the pure brigade +// tracker. Exposes the subscribe/getSnapshot pair `useSyncExternalStore` +// needs; the snapshot identity is stable between no-op events. + +import { createElapsedClock, type ElapsedClock } from './clock.js'; +import type { CookEvent } from './events.js'; +import { formatCookEvent } from './format.js'; +import { type BrigadePhase, nextPhase } from './phase.js'; + +const MAX_LINES = 500; + +export interface PendingActivity { + id: string; + label: string; + detail?: string; + startedAt: number; +} + +export interface RunState { + command: string; + phase: BrigadePhase; + lines: string[]; + pending: PendingActivity[]; +} + +export class RunStore { + private state: RunState; + private readonly clock: ElapsedClock; + private readonly listeners = new Set<() => void>(); + + constructor( + command: string, + private readonly now: () => number = () => Date.now(), + ) { + this.clock = createElapsedClock(now); + this.state = { command, phase: 'prep', lines: [], pending: [] }; + } + + push(event: CookEvent): void { + if (event.kind === 'activity-start') { + this.commit({ + pending: [...this.state.pending, { id: event.id, label: event.label, startedAt: this.now() }], + }); + return; + } + if (event.kind === 'activity-progress') { + this.commit({ + pending: this.state.pending.map((a) => (a.id === event.id ? { ...a, detail: event.detail } : a)), + }); + return; + } + if (event.kind === 'activity-end') { + this.commit({ pending: this.state.pending.filter((a) => a.id !== event.id) }); + return; + } + + const added = formatCookEvent(event, this.clock); + const phase = nextPhase(this.state.phase, event); + if (added.length === 0 && phase === this.state.phase) return; + this.commit({ phase, lines: [...this.state.lines, ...added].slice(-MAX_LINES) }); + } + + private commit(patch: Partial): void { + this.state = { ...this.state, ...patch }; + for (const listener of this.listeners) listener(); + } + + getSnapshot = (): RunState => this.state; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; +} diff --git a/src/orchestrator/src/presenter/select.test.ts b/src/orchestrator/src/presenter/select.test.ts new file mode 100644 index 00000000..e5478f63 --- /dev/null +++ b/src/orchestrator/src/presenter/select.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { selectPresenter } from './select.js'; + +describe('selectPresenter', () => { + it('honors an explicit reporter flag over every environment signal', () => { + expect(selectPresenter({ command: 'cook', isTTY: true, ci: false, reporterFlag: 'plain' })).toBe('plain'); + expect(selectPresenter({ command: 'agent', isTTY: false, ci: true, reporterFlag: 'ink' })).toBe('ink'); + expect(selectPresenter({ command: 'serve', isTTY: false, ci: true, reporterFlag: 'silent' })).toBe( + 'silent', + ); + }); + + it('forces silent for agent mode so stdout stays JSONL-clean', () => { + expect(selectPresenter({ command: 'agent', isTTY: true, ci: false })).toBe('silent'); + }); + + it('falls back to plain in CI or when stderr is not a TTY', () => { + expect(selectPresenter({ command: 'cook', isTTY: false, ci: false })).toBe('plain'); + expect(selectPresenter({ command: 'serve', isTTY: true, ci: true })).toBe('plain'); + expect(selectPresenter({ command: 'plan', isTTY: false, ci: true })).toBe('plain'); + }); + + it('selects the ink TUI only on an interactive non-CI TTY', () => { + expect(selectPresenter({ command: 'cook', isTTY: true, ci: false })).toBe('ink'); + expect(selectPresenter({ command: 'serve', isTTY: true, ci: false })).toBe('ink'); + expect(selectPresenter({ command: 'plan', isTTY: true, ci: false })).toBe('ink'); + }); +}); diff --git a/src/orchestrator/src/presenter/select.ts b/src/orchestrator/src/presenter/select.ts new file mode 100644 index 00000000..f53a547d --- /dev/null +++ b/src/orchestrator/src/presenter/select.ts @@ -0,0 +1,26 @@ +// Which presenter renders a CLI run, chosen from command + environment. +// +// Pure so the decision is testable without a real TTY. `ink` is the +// interactive full-screen TUI (slice 2); `plain` is line-oriented for +// CI / non-TTY / piped output; `silent` keeps stdout clean for the +// `agent` JSONL protocol. An explicit `--reporter` flag overrides the +// environment entirely. (`json` is intentionally not modeled yet — no +// consumer exists; add it when one does.) + +export type PresenterKind = 'ink' | 'plain' | 'silent'; + +export type PresenterCommand = 'plan' | 'cook' | 'serve' | 'agent'; + +export type SelectPresenterEnv = { + command: PresenterCommand; + isTTY: boolean; + ci: boolean; + reporterFlag?: PresenterKind; +}; + +export function selectPresenter(env: SelectPresenterEnv): PresenterKind { + if (env.reporterFlag) return env.reporterFlag; + if (env.command === 'agent') return 'silent'; + if (env.ci || !env.isTTY) return 'plain'; + return 'ink'; +} diff --git a/src/orchestrator/src/presenter/silent.ts b/src/orchestrator/src/presenter/silent.ts new file mode 100644 index 00000000..1121c718 --- /dev/null +++ b/src/orchestrator/src/presenter/silent.ts @@ -0,0 +1,9 @@ +// Renders nothing. Used for `brunch agent`, whose stdout is the JSONL +// protocol and must never carry presentation noise. + +import type { CookEvent, Presenter } from './events.js'; + +export class SilentPresenter implements Presenter { + onEvent(_event: CookEvent): void {} + dispose(): void {} +} diff --git a/src/server/cli.ts b/src/server/cli.ts index c909d671..00f133fc 100644 --- a/src/server/cli.ts +++ b/src/server/cli.ts @@ -20,6 +20,45 @@ const launchCwd = process.env.BRUNCH_LAUNCH_CWD || process.cwd(); loadLocalEnvFile(launchCwd); +/** + * Shared completed-spec gate for the spec-driven commands (`plan`, `serve`): + * parse → open the project DB → assert the spec exists and is planning-ready → + * run the command body → always close the DB. Parsing is passed as a thunk so a + * parse error is reported through the same `Failed to run brunch ` + * channel and exit code as the spec/DB errors. Keeps the two commands from + * drifting on the gate while leaving each command's parsing and body its own. + */ +async function withCompletedSpec( + command: string, + parse: () => O, + run: ( + opts: O, + ctx: { + project: ReturnType; + snapshot: ReturnType; + }, + ) => Promise, +): Promise { + let db: ReturnType | undefined; + try { + const opts = parse(); + const project = resolveBrunchProject(launchCwd); + db = createDb(project.dbPath); + if (!getSpecification(db, opts.specificationId)) { + throw new Error(`specification ${opts.specificationId} not found`); + } + const snapshot = buildCompletedSpecSnapshot(db, opts.specificationId); + assertCompletedSpecReadyForPlanning(db, opts.specificationId, snapshot); + await run(opts, { project, snapshot }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Failed to run brunch ${command}: ${message}`); + process.exit(1); + } finally { + db?.$client.close(); + } +} + if (rawArgs[0] === '--version' || rawArgs[0] === '-V') { const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json'); const { version } = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }; @@ -38,6 +77,9 @@ if (args.has('--help') || args.has('-h') || args.has('help')) { console.log( ' plan [flags] Emit .brunch/cook/specs//plan.yaml from a completed specification.', ); + console.log( + ' serve [flags] One shot: plan then cook a completed specification (no manual steps).', + ); console.log(''); console.log('Environment:'); console.log(' ANTHROPIC_API_KEY Required. Brunch will not start without it; it powers the'); @@ -98,39 +140,63 @@ exitIfAnthropicApiKeyMissing(); if (rawArgs[0] === 'cook') { const { parseCookArgs, runCook } = await import('../orchestrator/src/cook-cli.js'); + const { withCookBus } = await import('../orchestrator/src/presenter.js'); const opts = parseCookArgs(rawArgs.slice(1)); - runCook(opts).catch((error) => { + // withCookBus disposes the bus (unmounts the Ink app) in finally so the TTY run exits. + await withCookBus('cook', (bus) => runCook(opts, bus)).catch((error) => { console.error('Failed to run brunch cook:', error); process.exit(1); }); +} else if (rawArgs[0] === 'serve') { + const { runPlan } = await import('./plan-runner.js'); + const { runCook } = await import('../orchestrator/src/cook-cli.js'); + const { parseServeArgs, runServe } = await import('./serve-runner.js'); + const { withCookBus } = await import('../orchestrator/src/presenter.js'); + await withCookBus('serve', (bus) => + withCompletedSpec( + 'serve', + () => parseServeArgs(rawArgs.slice(1)), + async (opts, { project, snapshot }) => { + // Cook runs against the same dir the plan was written to (launchCwd); see + // serveCookOptions — runCook reads opts.dir raw, so serve must thread it. + await runServe(opts, launchCwd, { + plan: () => + runPlan({ + specificationId: opts.specificationId, + snapshot, + outDir: launchCwd, + verbose: opts.verbose, + profile: opts.profile, + // Brownfield detection reads the launch cwd (the user's repo); greenfield ignores it. + repoDir: project.cwd, + bus, + }), + cook: (cookOpts) => runCook(cookOpts, bus), + }); + }, + ), + ); } else if (rawArgs[0] === 'plan') { const { parsePlanArgs, runPlan } = await import('./plan-runner.js'); - let db: ReturnType | undefined; - try { - const opts = parsePlanArgs(rawArgs.slice(1), launchCwd); - const project = resolveBrunchProject(launchCwd); - db = createDb(project.dbPath); - if (!getSpecification(db, opts.specificationId)) { - throw new Error(`specification ${opts.specificationId} not found`); - } - const snapshot = buildCompletedSpecSnapshot(db, opts.specificationId); - assertCompletedSpecReadyForPlanning(db, opts.specificationId, snapshot); - await runPlan({ - specificationId: opts.specificationId, - snapshot, - outDir: opts.outDir, - verbose: opts.verbose, - profile: opts.profile, - // Brownfield detection reads the launch cwd (the user's repo); greenfield ignores it. - repoDir: project.cwd, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Failed to run brunch plan: ${message}`); - process.exit(1); - } finally { - db?.$client.close(); - } + const { withCookBus } = await import('../orchestrator/src/presenter.js'); + await withCookBus('plan', (bus) => + withCompletedSpec( + 'plan', + () => parsePlanArgs(rawArgs.slice(1), launchCwd), + async (opts, { project, snapshot }) => { + await runPlan({ + specificationId: opts.specificationId, + snapshot, + outDir: opts.outDir, + verbose: opts.verbose, + profile: opts.profile, + // Brownfield detection reads the launch cwd (the user's repo); greenfield ignores it. + repoDir: project.cwd, + bus, + }); + }, + ), + ); } else if (rawArgs[0] === 'agent') { const project = resolveBrunchProject(launchCwd); const db = createDb(project.dbPath); diff --git a/src/server/plan-runner.test.ts b/src/server/plan-runner.test.ts index 1c75341b..d4344947 100644 --- a/src/server/plan-runner.test.ts +++ b/src/server/plan-runner.test.ts @@ -15,9 +15,19 @@ import { parse as parseYaml } from 'yaml'; import type { ArchitectDraft, RunModel } from '../orchestrator/src/plan-architect.js'; import type { CompletedSpecSnapshot } from '../orchestrator/src/plan-projection.js'; +import { CookBus } from '../orchestrator/src/presenter/bus.js'; +import { PlainPresenter } from '../orchestrator/src/presenter/plain.js'; import type { Plan } from '../orchestrator/src/types.js'; import { parsePlanArgs, runPlan } from './plan-runner.js'; +/** A bus wired to a capturing PlainPresenter — the golden stderr stream. */ +function captureBus(): { bus: CookBus; lines: string[] } { + const lines: string[] = []; + const bus = new CookBus(); + bus.subscribe(new PlainPresenter({ log: (line) => lines.push(line) })); + return { bus, lines }; +} + describe('parsePlanArgs', () => { it('parses , --out=, --verbose', () => { const opts = parsePlanArgs(['2', '--out=/tmp/x', '--verbose']); @@ -130,7 +140,7 @@ describe('runPlan', () => { it('writes .brunch/cook/plan.yaml and hides synthesis events at default verbosity', async () => { const { snapshot, dir, runModel } = makeRunWithCycle(); - const stderrLines: string[] = []; + const { bus, lines: stderrLines } = captureBus(); await runPlan({ specificationId: 2, @@ -138,7 +148,7 @@ describe('runPlan', () => { outDir: dir, verbose: false, runModel, - log: (line) => stderrLines.push(line), + bus, }); const planPath = join(dir, '.brunch', 'cook', 'specs', '2', 'plan.yaml'); @@ -163,7 +173,7 @@ describe('runPlan', () => { verbose: false, profile: 'node-vitest', runModel, - log: () => {}, + bus: new CookBus(), }); const planPath = join(dir, '.brunch', 'cook', 'specs', '2', 'plan.yaml'); @@ -173,7 +183,7 @@ describe('runPlan', () => { it('shows synthesis events when --verbose is set', async () => { const { snapshot, dir, runModel } = makeRunWithCycle(); - const stderrLines: string[] = []; + const { bus, lines: stderrLines } = captureBus(); await runPlan({ specificationId: 2, @@ -181,7 +191,7 @@ describe('runPlan', () => { outDir: dir, verbose: true, runModel, - log: (line) => stderrLines.push(line), + bus, }); expect(stderrLines.some((line) => line.includes('cycle-break-dropped-edge'))).toBe(true); @@ -200,7 +210,7 @@ describe('runPlan', () => { const runModel: RunModel = async () => { throw new Error('llm-boom'); }; - const stderrLines: string[] = []; + const { bus, lines: stderrLines } = captureBus(); await runPlan({ specificationId: 2, @@ -208,7 +218,7 @@ describe('runPlan', () => { outDir: dir, verbose: false, runModel, - log: (line) => stderrLines.push(line), + bus, }); const planPath = join(dir, '.brunch', 'cook', 'specs', '2', 'plan.yaml'); diff --git a/src/server/plan-runner.ts b/src/server/plan-runner.ts index 51b2d06e..5cf8732d 100644 --- a/src/server/plan-runner.ts +++ b/src/server/plan-runner.ts @@ -22,6 +22,7 @@ import { type EmitterWarning, } from '../orchestrator/src/plan-emitter.js'; import type { CompletedSpecSnapshot } from '../orchestrator/src/plan-projection.js'; +import type { CookBus } from '../orchestrator/src/presenter/bus.js'; import { parseProfileId, type ProfileId } from '../orchestrator/src/project-profile.js'; import { parseSpecId, specPlanPath } from '../orchestrator/src/spec-plan-paths.js'; @@ -80,19 +81,14 @@ export type RunPlanArgs = { repoDir?: string; /** Injectable LLM seam. Defaults to the production anthropic adapter via the emitter. */ runModel?: RunModel; - /** Injectable stderr writer. Defaults to `console.error`. */ - log?: (line: string) => void; + /** Presentation boundary. The orchestrator emits CookEvents; a presenter renders them. */ + bus: CookBus; }; export async function runPlan(args: RunPlanArgs): Promise { - const log = args.log ?? ((line: string) => console.error(line)); + const { bus } = args; - log(''); - log(' brunch plan'); - log(' ──────────────────────────────────────'); - log(` spec ${args.specificationId}`); - log(` out ${args.outDir}`); - log(''); + bus.emit({ kind: 'plan-start', specId: args.specificationId, outDir: args.outDir }); const result = await emitPlanFromSnapshot(args.snapshot, { ...(args.runModel ? { runModel: args.runModel } : {}), @@ -110,21 +106,18 @@ export async function runPlan(args: RunPlanArgs): Promise { mkdirSync(dirname(planPath), { recursive: true }); writeFileSync(planPath, stringifyYaml(result.plan)); - log(` ✓ plan ${planPath}`); - log(` ${result.plan.epics.length} epics, ${result.plan.slices.length} slices`); - log(''); + bus.emit({ + kind: 'plan-written', + path: planPath, + epics: result.plan.epics.length, + slices: result.plan.slices.length, + }); // Audit-weight display: failure + transformation always; synthesis // only when --verbose. The header counts only what we print so the // number on screen matches the lines below it. const printed = result.warnings.filter((warning) => shouldPrint(warning, args.verbose)); - if (printed.length > 0) { - log(` ${printed.length} warnings:`); - for (const warning of printed) { - log(` ! ${formatEmitterWarning(warning)}`); - } - log(''); - } + bus.emit({ kind: 'plan-warnings', messages: printed.map((warning) => formatEmitterWarning(warning)) }); } function shouldPrint(warning: EmitterWarning, verbose: boolean): boolean { diff --git a/src/server/serve-runner.test.ts b/src/server/serve-runner.test.ts new file mode 100644 index 00000000..5df7d900 --- /dev/null +++ b/src/server/serve-runner.test.ts @@ -0,0 +1,133 @@ +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import type { CookOptions } from '../orchestrator/src/cook-cli.js'; +import { parseServeArgs, runServe, serveCookOptions } from './serve-runner.js'; + +describe('parseServeArgs', () => { + it('requires a positive integer specId', () => { + expect(() => parseServeArgs([])).toThrow(/Missing /); + expect(() => parseServeArgs(['0'])).toThrow(/positive integer/); + expect(() => parseServeArgs(['abc'])).toThrow(/positive integer/); + expect(parseServeArgs(['7']).specificationId).toBe(7); + }); + + it('maps the flags it owns and rejects unknown ones', () => { + const opts = parseServeArgs([ + '12', + '--out=dist', + '--force', + '--profile=node-vitest', + '--policy=parallel', + '--max-retries=5', + '--petrinaut-stream', + '--petrinaut-url=https://x/brunch', + '--petrinaut-lanes=mechanical', + '--petrinaut-fold=color', + '--no-petrinaut-open', + '--verbose', + ]); + expect(opts).toMatchObject({ + specificationId: 12, + outDir: 'dist', + force: true, + profile: 'node-vitest', + policy: 'parallel', + maxRetries: 5, + petrinautStream: true, + petrinautUrl: 'https://x/brunch', + petrinautLanes: 'mechanical', + petrinautFold: 'color', + petrinautOpen: false, + verbose: true, + }); + expect(() => parseServeArgs(['1', '--nope'])).toThrow(/Unknown flag/); + expect(() => parseServeArgs(['1', '2'])).toThrow(/Unexpected positional/); + }); + + it('rejects petrinaut companion flags unless streaming is enabled', () => { + expect(() => parseServeArgs(['1', '--petrinaut-url=https://x/brunch'])).toThrow( + /--petrinaut-url requires --petrinaut-stream/, + ); + expect(() => parseServeArgs(['1', '--no-petrinaut-open'])).toThrow( + /--no-petrinaut-open requires --petrinaut-stream/, + ); + }); + + it('defaults the optional flags', () => { + const opts = parseServeArgs(['3']); + expect(opts).toMatchObject({ + outDir: undefined, + force: false, + profile: undefined, + policy: 'serial', + maxRetries: 3, + petrinautStream: false, + petrinautOpen: true, + verbose: false, + }); + }); +}); + +describe('serveCookOptions', () => { + it('sets specId so cook reads the just-emitted plan, and forwards --out as the promote target', () => { + const cook = serveCookOptions( + parseServeArgs(['9', '--out=out', '--force', '--policy=parallel']), + '/proj', + ); + expect(cook.specId).toBe(9); + expect(cook.outDir).toBe(resolve('/proj', 'out')); + expect(cook.force).toBe(true); + expect(cook.policy).toBe('parallel'); + // cook reads opts.dir raw (no launch-cwd default — that's parseCookArgs only), + // so serve must thread the resolved dir the plan was written to, not ''. + expect(cook.dir).toBe('/proj'); + }); + + it('leaves absolute --out paths absolute', () => { + const cook = serveCookOptions(parseServeArgs(['9', '--out=/tmp/out']), '/proj'); + expect(cook.outDir).toBe('/tmp/out'); + }); + + it('omits outDir when serve had none (brownfield promotes automatically)', () => { + const cook = serveCookOptions(parseServeArgs(['9']), '/proj'); + expect(cook.outDir).toBeUndefined(); + }); +}); + +describe('runServe', () => { + it('plans then cooks, passing the mapped cook options', async () => { + const calls: string[] = []; + let cookSaw: CookOptions | undefined; + await runServe(parseServeArgs(['4', '--out=dist']), '/proj', { + plan: async () => { + calls.push('plan'); + }, + cook: async (o) => { + calls.push('cook'); + cookSaw = o; + }, + }); + expect(calls).toEqual(['plan', 'cook']); + expect(cookSaw?.specId).toBe(4); + expect(cookSaw?.outDir).toBe(resolve('/proj', 'dist')); + // cook runs against the same dir the plan was written to. + expect(cookSaw?.dir).toBe('/proj'); + }); + + it('does not cook if planning fails', async () => { + let cooked = false; + await expect( + runServe(parseServeArgs(['4']), '/proj', { + plan: async () => { + throw new Error('plan boom'); + }, + cook: async () => { + cooked = true; + }, + }), + ).rejects.toThrow(/plan boom/); + expect(cooked).toBe(false); + }); +}); diff --git a/src/server/serve-runner.ts b/src/server/serve-runner.ts new file mode 100644 index 00000000..09079863 --- /dev/null +++ b/src/server/serve-runner.ts @@ -0,0 +1,166 @@ +// `brunch serve ` — the Arc-1 capstone: one shot from a completed spec +// to a promoted cook result, no manual steps. It is pure glue over the existing +// `brunch plan` and `brunch cook` paths: emit the plan, then cook it. The only +// real logic here is arg parsing + the flag→stage mapping (serve's `--out` is +// the *promote* target → cook; `--profile` stamps the plan), so those are the +// testable units; the db/snapshot wiring stays in `cli.ts`. + +import { resolve } from 'node:path'; + +import type { CookOptions } from '../orchestrator/src/cook-cli.js'; +import { parseProfileId, type ProfileId } from '../orchestrator/src/project-profile.js'; + +export type ServeOptions = { + specificationId: number; + /** Greenfield promote target (→ cook `--out`); brownfield promotes automatically. */ + outDir?: string; + force: boolean; + /** Toolchain profile override; stamped into the emitted plan. */ + profile?: ProfileId; + verbose: boolean; + // Petrinaut + execution flags, forwarded to cook. + petrinautStream: boolean; + petrinautUrl?: string; + petrinautLanes: 'both' | 'mechanical'; + petrinautFold: 'color' | 'identity'; + petrinautOpen: boolean; + policy: 'serial' | 'parallel'; + maxRetries: number; +}; + +const USAGE = + 'Usage: brunch serve [--out=] [--force] [--profile=] [--policy=serial|parallel] [--max-retries=] [--petrinaut-stream] [--petrinaut-url=] [--petrinaut-lanes=both|mechanical] [--petrinaut-fold=color|identity] [--no-petrinaut-open] [--verbose]'; + +export function parseServeArgs(args: string[]): ServeOptions { + let specIdRaw: string | undefined; + let outDir: string | undefined; + let force = false; + let profile: ProfileId | undefined; + let verbose = false; + let petrinautStream = false; + let petrinautUrl: string | undefined; + let petrinautLanes: 'both' | 'mechanical' = 'both'; + let petrinautFold: 'color' | 'identity' = 'identity'; + let petrinautOpen = true; + let policy: 'serial' | 'parallel' = 'serial'; + let maxRetries = 3; + let sawPetrinautUrl = false; + let sawNoPetrinautOpen = false; + + for (const arg of args) { + if (arg.startsWith('--out=')) { + outDir = arg.slice('--out='.length); + } else if (arg === '--force') { + force = true; + } else if (arg.startsWith('--profile=')) { + profile = parseProfileId(arg.slice('--profile='.length)); + } else if (arg.startsWith('--policy=')) { + const val = arg.slice('--policy='.length); + if (val !== 'serial' && val !== 'parallel') + throw new Error(`Unknown policy: ${val}. Use serial or parallel.`); + policy = val; + } else if (arg.startsWith('--max-retries=')) { + const parsed = Number.parseInt(arg.slice('--max-retries='.length), 10); + if (!Number.isFinite(parsed) || parsed < 0) + throw new Error(`Invalid --max-retries value. Must be a non-negative integer.`); + maxRetries = parsed; + } else if (arg === '--petrinaut-stream') { + petrinautStream = true; + } else if (arg.startsWith('--petrinaut-url=')) { + petrinautUrl = arg.slice('--petrinaut-url='.length); + sawPetrinautUrl = true; + } else if (arg.startsWith('--petrinaut-lanes=')) { + const val = arg.slice('--petrinaut-lanes='.length); + if (val !== 'both' && val !== 'mechanical') + throw new Error(`Unknown --petrinaut-lanes value: ${val}. Use both or mechanical.`); + petrinautLanes = val; + } else if (arg.startsWith('--petrinaut-fold=')) { + const val = arg.slice('--petrinaut-fold='.length); + if (val !== 'color' && val !== 'identity') + throw new Error(`Unknown --petrinaut-fold value: ${val}. Use color or identity.`); + petrinautFold = val; + } else if (arg === '--no-petrinaut-open') { + petrinautOpen = false; + sawNoPetrinautOpen = true; + } else if (arg === '--verbose' || arg === '-v') { + verbose = true; + } else if (arg.startsWith('-')) { + throw new Error(`Unknown flag "${arg}". ${USAGE}`); + } else if (specIdRaw === undefined) { + specIdRaw = arg; + } else { + throw new Error(`Unexpected positional argument "${arg}". ${USAGE}`); + } + } + + if (specIdRaw === undefined) throw new Error(`Missing . ${USAGE}`); + const specificationId = Number.parseInt(specIdRaw, 10); + if (!Number.isInteger(specificationId) || specificationId <= 0) { + throw new Error(`Invalid "${specIdRaw}": expected a positive integer. ${USAGE}`); + } + if (sawPetrinautUrl && !petrinautStream) { + throw new Error('--petrinaut-url requires --petrinaut-stream'); + } + if (sawNoPetrinautOpen && !petrinautStream) { + throw new Error('--no-petrinaut-open requires --petrinaut-stream'); + } + + return { + specificationId, + outDir, + force, + profile, + verbose, + petrinautStream, + petrinautUrl, + petrinautLanes, + petrinautFold, + petrinautOpen, + policy, + maxRetries, + }; +} + +/** + * Map serve options to the cook stage. `specId` is set so cook reads the plan + * just emitted (not an auto-picked older one); serve's `--out` becomes cook's + * greenfield promote target (brownfield promotes automatically regardless). + * + * `cookDir` is the resolved launch cwd the plan was written under. `runCook` + * reads `opts.dir` raw — the launch-cwd default lives only in `parseCookArgs`, + * which serve bypasses — so cook would otherwise resolve the plan path against + * `process.cwd()` and clone `''` for brownfield. Threading the same dir the plan + * used keeps the two stages pointed at one directory (SPEC R46). + */ +export function serveCookOptions(opts: ServeOptions, cookDir: string): CookOptions { + return { + dir: cookDir, + policy: opts.policy, + maxRetries: opts.maxRetries, + verbose: opts.verbose, + petrinautFold: opts.petrinautFold, + petrinautLanes: opts.petrinautLanes, + petrinautStream: opts.petrinautStream, + ...(opts.petrinautUrl ? { petrinautUrl: opts.petrinautUrl } : {}), + petrinautOpen: opts.petrinautOpen, + ...(opts.outDir ? { outDir: resolve(cookDir, opts.outDir) } : {}), + force: opts.force, + specId: opts.specificationId, + }; +} + +/** + * Sequence the two stages: emit the plan, then cook it. Cook only runs if + * planning succeeded — a failed plan short-circuits with nothing cooked. Both + * stages are injected so the db/snapshot/agent side effects stay in `cli.ts` + * and this orchestration is unit-testable. `cookDir` is the resolved launch cwd + * the plan was written under, threaded into the cook options. + */ +export async function runServe( + opts: ServeOptions, + cookDir: string, + deps: { plan: () => Promise; cook: (cookOpts: CookOptions) => Promise }, +): Promise { + await deps.plan(); + await deps.cook(serveCookOptions(opts, cookDir)); +}