From f84ba966c66a3a70a100d43ae78eb4f5a989d57f Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Fri, 24 Jul 2026 12:38:02 +0200 Subject: [PATCH 1/3] FE-1265: Carry approved contracts into worker execution --- .changeset/steady-workers-carry.md | 5 + memory/PLAN.md | 10 +- memory/SPEC.md | 11 + src/.pi/extensions/__tests__/registry.test.ts | 19 +- .../execute-orchestrate-updates.test.ts | 1 + src/app/TOPOLOGY.md | 4 +- src/app/__tests__/agent-runner-port.test.ts | 122 +++++++- src/app/agent-runner-port.ts | 106 ++++++- .../__tests__/brunch-lane.test.ts | 9 + src/executor/TOPOLOGY.md | 11 +- src/executor/__tests__/orchestrate.test.ts | 92 +++++- src/executor/__tests__/plan-preview.test.ts | 4 +- src/executor/__tests__/plan-synthesis.test.ts | 48 ++- .../__tests__/run-supersession.test.ts | 33 +- src/executor/__tests__/run.test.ts | 133 +++++++- src/executor/__tests__/slice-execute.test.ts | 256 ++++++++++++++- src/executor/execution-public-packet.ts | 292 ++++++++++++++++++ src/executor/isolated-slice-operations.ts | 174 ++++++++++- src/executor/parallel-slice-batch/effects.ts | 2 + src/executor/plan-preview.ts | 2 + src/executor/run-supersession.ts | 1 + src/executor/run.ts | 27 ++ src/executor/slice-execute.ts | 2 + src/probes/executor-agent-runner-witness.ts | 10 +- src/rpc/__tests__/web-host.test.ts | 5 + src/rpc/methods/__tests__/execute.test.ts | 13 +- 26 files changed, 1354 insertions(+), 38 deletions(-) create mode 100644 .changeset/steady-workers-carry.md create mode 100644 src/executor/execution-public-packet.ts diff --git a/.changeset/steady-workers-carry.md b/.changeset/steady-workers-carry.md new file mode 100644 index 000000000..60100ac85 --- /dev/null +++ b/.changeset/steady-workers-carry.md @@ -0,0 +1,5 @@ +--- +"@hashintel/brunch": patch +--- + +Carry exact approved requirements and pinned public packets into scoped execution workers. diff --git a/memory/PLAN.md b/memory/PLAN.md index 665bfa30d..3c96e1822 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -117,7 +117,7 @@ Everything executor/orchestrator-shaped or Execute-mode-owned belongs to Kostand - `execution-comparison-tracer` ([FE-1230](https://linear.app/hash/issue/FE-1230/greenfield-execution-comparison-tracer)) — **active on `ka/fe-1230-independent-oracle-journeys`:** the frozen case, immutable-attempt contract, retained Brunch/Claude v1 pair, and versioned independent browser-oracle journeys are built. Next: restore the exact retained output paths, replay `petri-editor-browser-v2` unchanged against both, then review and promote the bounded evidence; no mutants, judging campaign, or repetitions in this frontier. - `end-to-end-comparison-tracer` ([FE-1239](https://linear.app/hash/issue/FE-1239/trace-elicitation-through-execution)) — **complete on `ka/fe-1239-end-to-end-comparison-tracer`, stacked on FE-1230:** two rigorous Petri-editor elicitation outputs crossed exact immutable handoffs into the staged 2×2 Brunch/Claude matrix; all four valid failed cells, unchanged-oracle evidence, and the validity-first requirement ledger are promoted. Definition and retained witness below. - `executor-slice-admission-parity` ([FE-1240](https://linear.app/hash/issue/FE-1240/prevent-invalid-scoped-slices-from-reaching-execution)) — **complete on `ka/fe-1240-slice-admission-parity`, restacked on `next` after FE-1239 landed:** incomplete scoped-slice worker context is rejected during deterministic plan admission, exact findings enter bounded repair before `slice_execute`, and the execution boundary remains fail-closed. Definition below. -- `executor-plan-coherence` ([FE-1250](https://linear.app/hash/issue/FE-1250/build-coherent-execution-plans)) — **implementation complete on `ka/fe-1250-coherent-execution-plans`, based on FE-1240 via `next`:** frontier-verified multi-slice epics now require one ordinary terminal member over every sibling, planner conduct establishes shared foundations, and workers preserve cumulative public contracts for the canonical harness. Fast verification passes; next is the separately authorized unchanged Petri comparison rerun. No new browser gate, durable plan kind, or executor lifecycle phase. Definition below. +- `executor-plan-coherence` ([FE-1250](https://linear.app/hash/issue/FE-1250/build-coherent-execution-plans)) — **I69-L worker-contract carry implementation complete on `ka/fe-1250-coherent-execution-plans`, based on FE-1240 via `next`:** workers now receive exact cumulative requirement bodies and a hash-verified target-visible packet while controller-only neighbors remain excluded. Deterministic tests and build pass; next is the explicitly authorized unchanged Petri outer witness. No new browser gate, durable plan kind, or executor lifecycle phase. Definition below. - `executor-slice-verification-repair` ([FE-1254](https://linear.app/hash/issue/FE-1254/repair-failed-slice-verification-before-halting)) — **implementation complete on `ka/fe-1254-slice-verification-repair`:** terminal failed slice verdicts now enter a finite cycle-qualified Petri repair ladder with crash-durable canonical repair context, grouped validated history, and serial/parallel authority-preserving recovery. The full suite, repository checks, and build pass; next is the owned unchanged Petri comparison rerun. Definition below. - `comparison-publication-workflow` ([FE-1251](https://linear.app/hash/issue/FE-1251/publish-traceable-comparison-reports)) — **active on `ka/fe-1251-comparison-publication`, stacked on FE-1250:** capture immutable controller and release provenance before comparison lanes start, then explicitly publish retained validity-first reports into the canonical Notion database through a guarded idempotent skill. Definition below. - `prospect-research-workspace-regression` ([FE-1253](https://linear.app/hash/issue/FE-1253/thin-prospect-research-into-a-regression-case)) — **active on `ka/fe-1253-prospect-research-e2e`, stacked on FE-1241:** retain the exploratory mission and calibrated browser/HTTP/SQLite/export oracle while deleting the unexecuted prospect study profile, requirement-ledger expansion, and scored-provider completion gate. @@ -448,15 +448,15 @@ Instrumentation experiments and far-horizon items. Each re-enters only via re-qu - **Branch:** `ka/fe-1250-coherent-execution-plans`, based on `next`. - **Kind:** bounded proving frontier — planner conduct and deterministic plan-shape admission over settled D126-L/D130-L and FE-1195 executor seams. - **Certainty:** proving. -- **Status:** implementation complete 2026-07-22; `npm run verify` passes (2531 tests, 2 skipped, build green). Outer evidence remains the unchanged frozen Petri comparison rerun. +- **Status:** implementation complete 2026-07-24, including I69-L worker-contract carry; the explicitly authorized unchanged frozen Petri rerun remains outer evidence and is not a merge gate. - **Objective:** make integration-sensitive multi-slice plans converge on one working result without adding a browser-specific executor gate or a new durable planning concept. - **Lights up:** committed scope context → dependency-aware candidate plan → ordinary terminal reconciliation slice → canonical slice verification → existing fan-in and epic verification. - **Stabilizes:** the model-planner/admission seam between FE-1240's complete worker context and FE-1195's integrated executor worktree. - **Depends on:** FE-1240 candidate-admission parity and the landed FE-1195 slice integration/epic verification topology; FE-1230/FE-1239 provide evaluation evidence only, not target-visible oracle material. -- **Acceptance:** an integration-sensitive multi-slice fixture produces an explicit shared foundation, dependency ordering, and one ordinary terminal slice that transitively depends on its sibling implementation work and carries the relevant requirement, criterion, design, and verification anchors; workers are told to preserve prior public-contract behavior and use the canonical authored harness cumulatively; the admitted plan lowers unchanged through preview and worker-request parsing; existing slice integration and epic verification execute the shape without a new transition or lifecycle phase; non-integrating plans are not forced to acquire ceremonial closure work. -- **Verification:** inner — planner prompt/candidate-validation regressions with a contrastive non-integrating plan; middle — synthesis → preview → request → existing integrated epic harness; outer — rerun the frozen Petri comparison unchanged after the product change, retaining its controller-owned browser oracle outside execution. +- **Acceptance:** an integration-sensitive multi-slice fixture produces an explicit shared foundation, dependency ordering, and one ordinary terminal slice that transitively depends on its sibling implementation work and carries exact cumulative approved requirement content plus the relevant criterion, design, verification, and target-visible packet anchors; workers are told to preserve prior public-contract behavior and use the canonical authored harness cumulatively; the admitted plan lowers unchanged through preview and worker-request parsing; existing slice integration and epic verification execute the shape without a new transition or lifecycle phase; non-integrating plans are not forced to acquire ceremonial closure work. +- **Verification:** inner — planner prompt/candidate-validation plus I69-L exact requirement-carry differentials that reject ids-only, paraphrase-loss, wrong-link, malformed/duplicate context, hash-divergent packet, symlink destination, and hidden-oracle rivals; middle — synthesis → preview → request → rendered sealed-worker brief plus cumulative terminal-slice integration and serial/parallel artifact parity through the existing epic harness; outer — one explicitly authorized rerun of the frozen Petri comparison unchanged, retaining its controller-owned browser oracle outside execution and outside the merge gate. See SPEC §Verification Design “I69-L worker-contract-carry”. - **Boundary:** no hidden-oracle exposure, inferred browser command, new plan-plane node, candidate command surface, execution-time plan repair, or FE-1241 comparison-framework change. -- **Current execution pointer:** none; the two-card scope is consumed. Re-enter only for the unchanged frozen Petri comparison rerun. +- **Current execution pointer:** none; the worker-contract-carry card is implemented. Re-enter only for an explicitly authorized unchanged frozen Petri comparison rerun. ### prospect-research-workspace-regression diff --git a/memory/SPEC.md b/memory/SPEC.md index 8283a737d..56960dfa6 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -394,6 +394,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I66-L | Specify elicitation style is exactly one active-branch, last-entry-wins value in `interrogate | disambiguate | propose`; it remains prompt-visible across kicks and resume/session switch/tree navigation until the user changes it. One-shot process moves and dismissal cannot overwrite it. Style-less new sessions and operational-mode switches may open the menu automatically; resume/session switch/tree/abort may not. | planned FE-1187 active-branch rivals, prompt-composition tests, menu-juncture negative space, and live resume/tree walkthrough; establishing frontier: `walkthrough-remediation-2` | D98-L, D109-L | | I67-L | During an approachable comparison run, the invoking Pi session is the sole simulated-user actor and no more than one comparison-harness interactive shell is live. No actor subprocess may own another interactive shell; the private mission remains outside every harness context/cwd; and every choice or approval remains operable through ordinary text when no custom question tool exists. | FE-1215 prompt/README review plus the focused stock-Pi, normal-width Brunch smoke are covered; the full Brunch + Claude run, report judgment, and revision-immutability proof belong to `saved-mission-comparison-witness` | D134-L; D70-L | | I68-L | Every Brunch agent turn has one current, role-owned control block and one capability-honest resource surface: changed runtime state replaces stale Brunch prompt content across every supported provider carrier; every advertised resource is either readable under the agent's actual grant or fully bundled into its task; and no prompt instructs an agent to use a missing tool/resource. Context provenance preserves the distinct ownership and lifetime of spec posture, elicitation style, asking agenda, origination continuity, and on-demand reads. | covered by the landed provider-carrier replacement matrix, production recorder → persisted trajectory → report projection for advertised/read/provider-visible resource states, background grant/bundle/no-tools tests, behavioral control-ownership tests, and topology/import contract checks | D135-L; D58-L, D90-L, D91-L, D98-L, D101-L, D102-L, D118-L | +| I69-L | Every scoped execution worker receives the exact approved content of each requirement its slice derives from, plus any target-visible public-contract material that governs that slice; graph reference codes or planner-authored paraphrase alone are insufficient. This content remains bounded to slice-linked target-visible truth and never includes controller-only oracle material. | structurally covered by exact title/body persistence, transitive dependency requirement resolution, unknown/duplicate/malformed-reference rejection, run-pinned hash-verified three-file packet staging, mutation/redaction/symlink rivals, sealed-worker brief rendering, synthesis/request integration, and serial/parallel artifact parity; the unchanged frozen Petri rerun remains outer evidence, not a merge gate | D126-L, D135-L; I58-L | ## Future Direction Register @@ -694,6 +695,8 @@ For agent-as-user evaluation, the primary behavioral claim is **consequential-fa **FE-1230 greenfield execution-comparison assessment (2026-07-20).** Observability is **partial**: build/test output, git trees, browser DOM/accessibility state, console errors, JSON downloads, Brunch run/Petri artifacts, and target-visible interactions are text-native, while ordinary visual hierarchy and drag feel remain human judgments; product-private reasoning and diagnostics are excluded from common evidence. Reproducibility is **partial**: the approved Petri-editor specification, empty-repository base, public automation contract, Opus 4.8 model, budgets, and hidden oracle bytes freeze before the first valid lane, but generated implementation shape and model conduct vary; three Brunch repetitions detect gross instability rather than reliability tails. Controllability is **partial**: the controller can install and run unchanged build, browser, reference-model, round-trip, and negative-space checks against fresh lane outputs, while provider availability and product-native planning remain external. A minimal public accessibility contract gives both lanes stable roles/names without revealing hidden scenarios or expected results. +**I69-L worker-contract-carry assessment (2026-07-24).** Observability is **high**: the execution snapshot, admitted plan, slice request, rendered worker brief, isolated worktree, and controller browser report are retained text artifacts. Reproducibility is **high** for lowering and **partial** for provider conduct: the frozen Petri packet and retained same-packet runs reproduce planner-paraphrase loss, while generated implementation behavior varies. Controllability is **high** for deterministic projection/request/brief and sealed-runner checks and **partial** for the provider-backed comparison. Exact context delivery is therefore a hard gate; an unchanged provider rerun remains outer evidence, not a merge gate. + **Brownfield execution comparison assessment (updated 2026-07-23).** Observability is **partial**: pinned trees, diffs, build/test output, controller verdicts, temporary-host Git state, browser DOM/accessibility state, request/abort evidence, interventions, cleanup, and retained attempts are text-native; visual hierarchy and provider conduct remain external. Reproducibility is **high for deterministic mechanics and unproven for campaigns**: frozen Brunch/Petrinaut packets, remote-free pinned snapshots, case-owned recipes/oracles, the fake optimizer, and contrastive rivals are stable, while generated implementation and model conduct vary. Controllability is **partial**: Brunch host landing uses disposable repositories and an independent Git model; Petrinaut uses focused builds and same-origin browser checks after lane termination. Lightweight tool restrictions prevent accidental contamination but do not establish adversarial isolation; reports must preserve that limitation. **FE-1253 prospect research full-stack assessment (2026-07-22).** Observability is **high for structural behavior**: browser accessibility, HTTP responses, SQLite rows, downloaded export bytes, bounded process output, and browser runtime requests are text-native; whether qualification rationales are persuasive remains qualitative. Reproducibility is **high mechanically**: each claim starts the exact public lifecycle with a fresh port, temporary database, copied deterministic fixture, and browser context; a known-good React/Node/TypeScript/SQLite implementation passes while focused wrong rivals prove sensitivity. Controllability is **high for the deterministic regression case**: lifecycle, local fixtures, independent journeys, reference-model comparison, cleanup, and runtime-request rejection are agent-controlled. No provider campaign or comparative claim is part of FE-1253. @@ -783,6 +786,9 @@ Dev-loop artifacts route to gitignored `.fixtures/scratch///`, res | Outer | **Brownfield live/qualitative evidence** | One fully provisioned HASH `/processes/draft` host/iframe smoke checks capability relay, optimizer proxying, iframe isolation, and adapter reality; one optional real-optimizer Petrinaut smoke checks live response compatibility. Identity-masked visual review judges Petrinaut hierarchy, clarity, and progress legibility; identity-masked code review judges both outputs. These are non-gating findings, are `not_assessable` when unavailable, and cannot override or repair common mechanical failures. | | Inner | **FE-1253 prospect full-stack regression contract** | Exact parsers and oracle-pack hashes freeze the React/Node/TypeScript/SQLite public packet, fixture files, closed oracle manifest, and implementation modules. Brunch can preserve the specification as one opaque settled input instead of applying Petri-specific parsing. | | Middle | **FE-1253 independent browser/API/SQLite differential** | A code-owned runner starts each candidate with a fresh database and local fixture, then compares accessible UI, HTTP results, SQLite state, and export bytes with a tiny controller reference model. Independent journeys cover approval-before-run, evidence qualification, deduplication provenance, suppression across rerun, reasoned override history, approved-only export, provider-failure honesty, and restart persistence; focused wrong rivals prove sensitivity. | +| Inner | **I69-L requirement-carry contract + paraphrase differential** | Exact slice-linked requirement bodies and the content-addressed target-visible packet identity survive graph snapshot → admitted plan → populated plan → slice request → rendered worker brief. IDs-only, planner-paraphrase, wrong-link, malformed/duplicate context, hash-divergent packet, symlink destination, and controller-oracle-leak rivals must fail; changing only planner summary prose cannot change the rendered requirement section. | +| Middle | **I69-L sealed-worker and cumulative terminal-slice tracer** | A real sealed `AgentRunnerPort` capture proves exact approved requirement content and the validated relative packet reference enter the worker task; independent exact-byte staging/redaction tests prove that reference resolves inside the sealed worktree. A scoped packet-bearing serial/parallel differential proves an integration-sensitive terminal slice receives cumulative prior requirements and equivalent artifacts rather than resetting behavior at each slice. | +| Outer | **I69-L unchanged frozen Petri comparison witness** | One explicitly authorized rerun of the unchanged Petri packet and controller-owned browser oracle checks that correctly delivered context survives real planner/worker conduct. This is a reality check, not a provider-reliability merge gate, and cannot repair a failed inner or middle oracle. | | Middle | **FE-1187 controlled provider conduct gate — paused** | On explicit re-entry, reconcile the extractor/oracle against the landed mixed-settlement contract, then run three fresh normalized-ingest samples. All must use free-text digest feedback, one bounded questionnaire when several questions exist, no combinatorial options, an honestly assigned per-node/per-edge review proposal when review is used, exact atomic settlement preservation, and no post-review mutation that completes or rewrites the approved proposal. Direct advisory mutation without review remains valid. Reports retain provider/model stamps and deterministic conduct markers; the stopped 2026-07-17 run is diagnostic and counts 0/3. | | Inner | **FE-1187 Impact Ledger golden + word-wrap-tolerant render-honesty** | Golden/inline snapshots at narrow/normal/wide widths lock populated-section-only canonical order, absence of empty heading/`None` pairs, per-node/per-edge settlement visibility, elision, `refs:` row shape, and the `obligation` fallback label for the borderless Impact Ledger renderer (D27-L/D131-L). `missingRenderedDetailsLeaves` is extended to reassemble `table`'s word-wrapped physical lines back into logical cells before leaf-presence checking, so a value silently split across wrapped rows cannot pass as "rendered" by accident. | | Middle | **FE-1187 Impact Ledger differential reference extractor** | A deliberately naive reference extractor (flat node/edge/term inventory, no styling or grouping) is compared against the ledger's code/connection inventory over the witnessed fixture plus hand-authored edge fixtures (empty group, single-node group, mixed-settlement group, term-only group, max-refs group). Proves item/status inventory completeness and empty-group omission independent of the "real" renderer's own logic. | @@ -853,11 +859,13 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | I47-L | Middle — restart/resume idempotence property tests (repeated boot does not duplicate seed/`worldUpdate`; dedupe derived from projection); … | | I48-L | Inner — seed CLI contract tests for target workspace resolution, seed-ref filtering, explicit all-seeds mode, `CommandExecutor`/change-log routing, and destination reporting. … | | I49-L | Middle (covered by `subagent-reconciliation` slice 4) — negative-space invariant over the code-owned op_mode→delegatable-set allowlist: spawnable agents per op_mode equal the allowlist; … | +| I69-L | Inner exact-content contract/differential with IDs-only, paraphrase, wrong-link, malformed/duplicate context, hash-divergent packet, symlink destination, and hidden-oracle rivals; middle sealed-worker capture plus cumulative terminal-slice integration and serial/parallel artifact parity; outer unchanged frozen Petri browser witness. | ### Design Notes - **Operator-led cross-product comparisons (FE-1215; D134-L remediation before later `saved-mission-comparison-witness` evidence).** The PM-facing comparison door is deliberately distinct from rigorous frozen-packet evaluation. One project Pi prompt conversationally creates or revises a rich private **agent-as-user mission** for a simulated user: their objective, context, priorities, preferences, constraints, knowledge, uncertainty, decision latitude, and conversational posture. The invoking top-level Pi agent receives that mission and directly performs the user's side of each interaction while driving exactly one comparison-harness subshell at a time; it must not spawn a Pi actor that opens another interactive shell. Each comparison harness receives only minimal visible framing plus the opening user message and subsequent mission-grounded answers. Harness selection and framing are run setup, not mission content. Ordinary conversational text is the portable baseline for operator choices and approvals; environment-specific structured-question tools are optional presentation only. Setup checks cover actual selected-harness prerequisites without synthetic actor/provider turns on every run. Editable missions live outside `.fixtures/` under `testing/comparisons/missions/`; each run snapshots the private mission, separately identified target-visible setup/interactions, and outputs under `.fixtures/runs/agent-as-user-comparison/` while temporary lane work stays in scratch. The readable operator report may expose the full private mission so elicitation can be compared against what each harness actually learned, but it keeps that baseline separate from target-visible evidence and declares no automatic winner or prescribed rubric. Because the top-level actor context spans sequential harnesses, this approachable workflow discloses order and does not claim the per-lane actor-process isolation required by rigorous frozen-packet studies; frozen reveal policies, matched budgets, blinding, fresh-per-lane actor sessions, structured adjudication, multi-run statistics, and scripted judges remain separate tools for focused improvement/regression claims. - **FE-1230 execution-comparison oracle boundary.** Execution cases are distinct from private elicitation missions: the human-approved specification and a minimal public runtime/accessibility contract are visible to every lane, while exact browser journeys, reference-model states, expected results, claim mapping, and adversarial fixtures remain controller-only and outside every lane cwd. The public contract requires a static production build at `dist/`, `npm run build`, `npm test`, and stable accessible roles/names for the canvas and named controls; it does not prescribe framework, source topology, implementation decomposition, test library, or internal state model. The versioned `petri-editor-browser-v2` suite tests/builds once, then runs every declared journey from a fresh browser context with public-only setup, per-journey runtime evidence, and non-blocking claim-linked verdicts that distinguish harness/setup failure from product assertion failure. Brunch stops at `promotion_prepared` and never lands. The retained first pair remains immutable; replay/promotion waits for its exact artifact paths. Mutants, masked/process judging, repetitions, and generalized campaign machinery are deferred until one valid end-to-end path exists. The tracer pins `anthropic/claude-opus-4-8` in both products, but same model does not imply equivalent hidden prompting or thinking controls. +- **I69-L worker-contract-carry oracle boundary.** Requirement identity is graph-native, so external heading renumbering is not itself a defect; the worker must receive the current graph code together with the exact approved requirement content. Planner definitions remain useful task summaries but are not an authority substitute. Public packet identity may be carried by a bounded content-addressed location rather than duplicating every packet byte in every prompt. Controller-only oracle journeys, expected states, and fixture paths remain excluded. Structural delivery and negative space gate merges; one unchanged Petri rerun checks provider reality without turning an n=1 model outcome into a reliability claim. - **Brownfield historical-replay oracle boundary (`brownfield-comparison-cases`).** Historical implementations bootstrap independent claims and focused rivals; matching code, decomposition, diff shape, or architecture is never a gate. Brunch replays only FE-1201's backend host-landing behavior through public `/brunch:land`, a controller-supplied settled session under `PI_OFFLINE=1`, and an independent temporary-repository Git model. Petrinaut replays only PR #9051's optimization UI in the full pinned HASH checkout; pre-existing optimizer backend/API capability is disclosed baseline. D136-L makes standalone `/optimization` the deterministic fake-provider gate after the one immutable install and closed focused builds; broad `/processes/draft` integration remains non-gating. D137-L creates the same pinned remote-free source plus exact packet for both executors and records its lightweight contamination ceiling. A merged-reference calibration is rerun only when the source, public contract, or oracle changes, not before every provider attempt. Real optimizer, fully provisioned host/iframe, repetitions, and masked reviews remain optional follow-up evidence. - **Shared-session-host convergence oracle (planned; `shared-session-host-tracer` → `shared-session-host-cutover`).** FE-1200's standalone proof is necessary but not sufficient for architectural replacement. The proving oracle must launch the production host as the sole owner of one writable sealed Pi runtime, attach a real TUI presentation and React observer/driver to the same durable target, exercise an ordinary turn plus one extension-owned structured ask and one TUI-only product interaction, detach/restart a client without ending the hosted runtime, and reject a duplicate writable open or conflicting driver. Durable settlement must converge through the same JSONL presentation, and browser traffic must remain Brunch semantic RPC/events rather than raw Pi RPC/events. Once A47-L retires, the cutover becomes a closed coverage sweep: every required TUI/web lifecycle, command/UI, exchange, transcript, graph-update, model/auth, and shutdown row has one host-owned path and a closure oracle; only then may `SessionEventRelay`, `brunch.sessionEvent`, `/rpc/driver`, and their sidecar harnesses be deleted. Human outer evidence must confirm that the TUI remains useful rather than becoming a thin degraded shell. - **Standalone-web compound oracle (2026-07-14; coverage completed 2026-07-15, `standalone-web-session-host`).** The initial five complementary oracles prove the host tracer: (1) inner RPC/host negative-space contracts require `(specId, sessionId)` on every lifecycle/driver/ask/event path and reject targetless fallback, duplicate writable opens, second drivers, and mismatched targets; (2) projection shape/malformed-detail tests keep JSONL and raw Pi/detail shapes behind the named semantic presentation; (3) a production-wired standalone-web browser journey, controlled only by a deterministic faux provider, asserts accessible hydration, streamed text, one `ask`, answer, and `agent_settled` states; (4) paired temporary web/TUI runs use a narrow declared normalizer to prove both live→settled→fresh JSONL hydration and equivalent Brunch binding/runtime/exchange semantics—no static JSONL golden; (5) a workbench manual checklist judges only stream cadence, busy/settled truthfulness, ask interaction, reload, and error feel. Browser cache/overlay loss is part of the middle-loop journey. The follow-on production-host concurrency differential retires A43-L with overlapping graph writes, asks, failure/recovery, target-local event sequences, reconnect, and separate JSONL readback; shared graph changes appear only through canonical `worldUpdate` continuity. The completed coverage pass adds projection no-loss/malformed tests for every required persisted ask terminal shape (including questionnaire read-back), React render/answer tests for free text and listed single/multi choices, headless schema-envelope questionnaire answering coverage (with no dedicated React questionnaire form), distinct candidate/review-set/digest production settlement/reconnect witnesses, concurrency/target isolation, and receipt-bearing review settlement. Live-provider conduct and process-restart survival remain explicitly deferred to their named later work; no second truth plane or raw Pi browser contract is introduced. @@ -895,6 +903,9 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | FE-1230 same-model non-equivalence | Pinning Opus 4.8 does not equalize product-owned system prompts, tool policy, context management, thinking controls, or provider wrappers. | Frame the result as a same-base-model workflow/product comparison, record exact product/provider/model/configuration versions, and keep process interpretation separate from mechanical outcome correctness. | | Brownfield historical-solution leakage | A target with later Git refs, remotes, GitHub/Linear/Notion access, or exact historical wording could retrieve the reference implementation and invalidate the replay. Package/dependency preparation may still need controlled network access. | D137-L materializes only the declared two-commit synthetic prefix (source identity root plus packet-only child), sanitizes missions, prepares selected dependencies controller-side, disables MCP/web/research surfaces, bounds filesystem tools, and denies target-command network. Admission adversarially rejects any third history, extra refs/remotes, path/symlink escape, weakened policy, reachable loopback/GitHub/Linear/Notion, unavailable isolation, or failing declared local checks; reachable solution sources invalidate the lane rather than reducing confidence. | | Brunch derived-slice reference mismatch | The selected backend-only FE-1201 mission intentionally excludes TUI/RPC/web changes from the historical PR, so no historical whole-diff equality or completeness claim is valid. | Freeze a fresh requirement registry and independent temp-Git model/property oracle for the derived backend contract; use the historical implementation only to bootstrap fixtures and wrong rivals. | +| I69-L worker adherence after correct delivery | Exact context makes the obligation available but cannot force a model to notice or follow it; one provider rerun does not establish reliability. | Keep delivery as the hard gate and the unchanged Petri rerun as outer evidence only. Revisit with repeated runs only after another correctly grounded contract miss or a decision that requires reliability evidence. | +| I69-L linked-context size | Exact slice-linked requirement bodies may become large enough to pressure the worker prompt even though unrelated graph history is excluded. | Preserve bounded slice linkage and packet references; add an explicit prompt-byte budget and truncation rival only when a measured prompt-size regression or provider limit appears. | +| I69-L arc-affordance quality | Browser role/name and interaction checks can prove contract behavior but not whether arc mode, drag guidance, direction, and cancellation feel obvious to a human. | Keep qualitative UX outside the worker-context gate. Re-enter with a structured manual arc walkthrough when a human finding reports ambiguity after mechanically conforming output. | | Brunch public-command oracle ceiling | `/brunch:land` is architecture-neutral and outcome-observable, but fresh sessions auto-kick a provider, settled-session/TUI setup can fail independently of candidate behavior, and a fixed temporary-Git fixture set cannot cover every repository topology. | Resume a controller-supplied settled session under `PI_OFFLINE=1`; separate `setup_failed` from `assertion_failed`; pair the black-box command journey with an independent full-range Git model and focused final-commit-only/bookkeeping rivals; make no reliability claim and add repository topologies only when a named failure or later campaign requires them. | | Petrinaut fake-provider, host-integration, and visual ceiling | The focused `/optimization` hard gate proves the UI/provider contract but bypasses HASH's authenticated host/iframe bridge; the real `/processes/draft` shell requires unrelated GraphQL/codegen and broad platform/toolchain preparation. A deterministic fake also cannot prove every live optimizer response, while functional browser/accessibility assertions cannot judge ordinary visual hierarchy or progress feel. | Keep the standalone fake-provider oracle as the common mechanical gate. Run one fully provisioned HASH host/iframe smoke, one optional real-backend smoke, and masked human visual/code review as non-gating outer evidence; mark unavailable integration evidence `not_assessable`. Promote recurring live-shape or bridge failures into focused controller fixtures only when they can remain architecture-neutral; never let qualitative review override a mechanical failure. | | Brownfield one-run portfolio ceiling | One Brunch and one Petrinaut 2×2 matrix broaden codebase/interface coverage but do not estimate reliability or establish an aggregate winner or causal advantage. | Report each case separately, retain `not_assessable`, and make no cross-case score, reliability, speed, or causal claim. Repetitions require a later named decision and separately frozen campaign. | diff --git a/src/.pi/extensions/__tests__/registry.test.ts b/src/.pi/extensions/__tests__/registry.test.ts index e11d52b24..09fdf5a30 100644 --- a/src/.pi/extensions/__tests__/registry.test.ts +++ b/src/.pi/extensions/__tests__/registry.test.ts @@ -2098,6 +2098,7 @@ describe('Brunch explicit Pi extension registry', () => { await writeFile( populatedPlanPath, JSON.stringify({ + scope_handoff_required: false, slices: [ { id: 'task-1', @@ -2170,7 +2171,11 @@ describe('Brunch explicit Pi extension registry', () => { const resultPath = join(runDir, 'agent-output', 'task-1', 'attempt-1', 'result.json'); await mkdir(dirname(requestPath), { recursive: true }); await mkdir(worktreeDir, { recursive: true }); - await writeFile(requestPath, JSON.stringify({ task: 'execute_slice' }), 'utf8'); + await writeFile( + requestPath, + JSON.stringify({ action: 'execute_slice', scopeHandoffRequired: false, task: 'execute_slice' }), + 'utf8', + ); await writeFile( metadataPath, JSON.stringify({ @@ -2243,7 +2248,11 @@ describe('Brunch explicit Pi extension registry', () => { const resultPath = join(runDir, 'agent-output', 'task-1', 'attempt-1', 'result.json'); await mkdir(dirname(requestPath), { recursive: true }); await mkdir(worktreeDir, { recursive: true }); - await writeFile(requestPath, JSON.stringify({ task: 'write proof' }), 'utf8'); + await writeFile( + requestPath, + JSON.stringify({ action: 'execute_slice', scopeHandoffRequired: false, task: 'write proof' }), + 'utf8', + ); await writeFile( metadataPath, JSON.stringify({ @@ -2296,7 +2305,11 @@ describe('Brunch explicit Pi extension registry', () => { const requestPath = join(runDir, 'agent-output', 'task-1', 'request.json'); await mkdir(dirname(requestPath), { recursive: true }); await mkdir(worktreeDir, { recursive: true }); - await writeFile(requestPath, JSON.stringify({ task: 'execute_slice' }), 'utf8'); + await writeFile( + requestPath, + JSON.stringify({ action: 'execute_slice', scopeHandoffRequired: false, task: 'execute_slice' }), + 'utf8', + ); await writeFile( metadataPath, JSON.stringify({ diff --git a/src/.pi/extensions/executor/__tests__/execute-orchestrate-updates.test.ts b/src/.pi/extensions/executor/__tests__/execute-orchestrate-updates.test.ts index 7caf1d16a..4a621d600 100644 --- a/src/.pi/extensions/executor/__tests__/execute-orchestrate-updates.test.ts +++ b/src/.pi/extensions/executor/__tests__/execute-orchestrate-updates.test.ts @@ -72,6 +72,7 @@ async function createDrivableRun(cwd: string, sliceIds: readonly string[] = ['t1 planFilePath(cwd, '42'), JSON.stringify({ mode: 'greenfield', + scope_handoff_required: false, epics: [{ id: 'e1', summary: 'E', depends_on: [], verification: [] }], slices: sliceIds.map((sliceId) => ({ id: sliceId, diff --git a/src/app/TOPOLOGY.md b/src/app/TOPOLOGY.md index 9e0dfb824..da6568a64 100644 --- a/src/app/TOPOLOGY.md +++ b/src/app/TOPOLOGY.md @@ -1,6 +1,6 @@ # app/ -SPEC decisions: D52-L, D111-L, D123-L, D132-L, D133-L, I58-L +SPEC decisions: D52-L, D111-L, D123-L, D132-L, D133-L, I58-L, I69-L ## Owns @@ -25,6 +25,8 @@ Current runtime support modules: Model recommendations and latency evidence live in [`docs/model-recommendations.md`](../../docs/model-recommendations.md). +`agent-runner-port.ts` renders I69-L worker context from the validated execution request: exact approved requirement title/body sections plus the relative target-visible packet path, packet digest, and declared file digests. It does not ask the worker to reconstruct requirement truth from graph ids or planner paraphrase, and controller-only oracle paths/bytes never enter the request. + ## Does not own - Provider/model registry policy or auth onboarding — Pi's native `/model` and `/login` surfaces. diff --git a/src/app/__tests__/agent-runner-port.test.ts b/src/app/__tests__/agent-runner-port.test.ts index 75a04645a..d9f07c1ef 100644 --- a/src/app/__tests__/agent-runner-port.test.ts +++ b/src/app/__tests__/agent-runner-port.test.ts @@ -56,7 +56,11 @@ async function repairFixture() { const requestPath = join(runDir, 'agent-output', 'task-1', 'request.json'); const resultPath = join(runDir, 'agent-output', 'task-1', 'attempt-2', 'result.json'); await mkdir(join(runDir, 'agent-output', 'task-1'), { recursive: true }); - await writeFile(requestPath, JSON.stringify({ definition: 'repair task' }), 'utf8'); + await writeFile( + requestPath, + JSON.stringify({ action: 'execute_slice', scopeHandoffRequired: false, definition: 'repair task' }), + 'utf8', + ); const target = { command: 'npm', args: ['run', 'verify'] }; const history = { 'task-1': [ @@ -134,6 +138,7 @@ async function createMultiRepairRun(cwd: string): Promise { path, JSON.stringify({ mode: 'greenfield', + scope_handoff_required: false, epics: [{ id: 'frontier-1', summary: 'Build feature', depends_on: [], verification: [] }], slices: [ { @@ -195,14 +200,35 @@ describe('createAgentRunnerPort', () => { const worktreeDir = await mkdtemp(join(tmpdir(), 'brunch-agent-worktree-')); const requestPath = join(worktreeDir, 'request.json'); const resultPath = join(worktreeDir, 'result.json'); + const packetFiles = [ + { path: 'public-contract.json', sha256: `sha256:${'b'.repeat(64)}` }, + { path: 'spec.md', sha256: `sha256:${'c'.repeat(64)}` }, + ]; + const packetSha256 = `sha256:${createHash('sha256') + .update(packetFiles.map((file) => `${file.path}:${file.sha256}\n`).join('')) + .digest('hex')}`; await writeFile( requestPath, JSON.stringify({ + action: 'execute_slice', + scopeHandoffRequired: true, scopeId: 'SCP1', definition: 'write proof', instruction: 'Satisfy the done criteria before returning.', criteria: [{ kind: 'criterion', target: 'worker proof exists' }], derivedFrom: ['REQ1'], + requirements: [ + { + itemId: 'REQ1', + title: 'Exact worker proof', + content: 'Create worker-proof.txt with the exact approved contents.', + }, + ], + publicPacket: { + path: '.brunch/execution-comparison/public', + packetSha256, + files: packetFiles, + }, designContext: [{ itemId: 'MOD1', content: 'worker proof module' }], verificationContext: [{ itemId: 'CH1', content: 'worker proof check' }], }), @@ -235,6 +261,15 @@ describe('createAgentRunnerPort', () => { expect(calls[0]!.task).toContain('Instruction:\nSatisfy the done criteria before returning.'); expect(calls[0]!.task).toContain('Done criteria:\n- criterion: worker proof exists'); expect(calls[0]!.task).toContain('Derived from requirements:\n- REQ1'); + expect(calls[0]!.task).toContain( + 'Approved requirements:\n[REQ1] Exact worker proof\nCreate worker-proof.txt with the exact approved contents.', + ); + expect(calls[0]!.task).toContain( + `Target-visible public packet:\n- path: .brunch/execution-comparison/public\n- sha256: ${packetSha256}`, + ); + expect(calls[0]!.task).toContain( + `- public-contract.json (sha256:${'b'.repeat(64)})\n- spec.md (sha256:${'c'.repeat(64)})`, + ); expect(calls[0]!.task).toContain('Design context:\n- [MOD1] worker proof module'); expect(calls[0]!.task).toContain('Verification context:\n- [CH1] worker proof check'); expect(calls[0]!.task).not.toContain('Execution request:'); @@ -274,6 +309,91 @@ describe('createAgentRunnerPort', () => { expect(calls).toEqual([]); }); + it('fails closed when a scoped request omits exact approved requirements', async () => { + const worktreeDir = await mkdtemp(join(tmpdir(), 'brunch-agent-malformed-scope-request-')); + const requestPath = join(worktreeDir, 'request.json'); + const resultPath = join(worktreeDir, 'result.json'); + await writeFile( + requestPath, + JSON.stringify({ + action: 'execute_slice', + scopeHandoffRequired: true, + derivedFrom: ['REQ1'], + requirements: [{ itemId: 'REQ1', title: 'Approved', content: 'Approved body' }], + definition: 'build it', + }), + 'utf8', + ); + const calls: string[] = []; + const port = createAgentRunnerPort({ + subagents: subagentDeps(async ({ definition }): Promise => { + calls.push(definition.name); + return { agent: definition.name, status: 'ok', text: 'should not run' }; + }), + }); + + await expect( + port.run({ + worktreeDir, + requestPath, + resultPath, + runId: 'run-1', + epicId: 'frontier-1', + sliceId: 'task-1', + cycle: 1, + runtime: { modelRegistry: {} }, + }), + ).resolves.toEqual({ + status: 'failed', + message: `AgentRunnerPort rejected malformed execution request at ${requestPath}.`, + }); + expect(calls).toEqual([]); + }); + + it('fails closed on duplicate scoped requirement authority', async () => { + const worktreeDir = await mkdtemp(join(tmpdir(), 'brunch-agent-duplicate-scope-request-')); + const requestPath = join(worktreeDir, 'request.json'); + const resultPath = join(worktreeDir, 'result.json'); + await writeFile( + requestPath, + JSON.stringify({ + action: 'execute_slice', + scopeHandoffRequired: true, + scopeId: 'SCP1', + derivedFrom: ['REQ1', 'REQ1'], + requirements: [ + { itemId: 'REQ1', title: 'Approved', content: 'Approved body' }, + { itemId: 'REQ1', title: 'Rival', content: 'Rival body' }, + ], + }), + 'utf8', + ); + const calls: string[] = []; + const port = createAgentRunnerPort({ + subagents: subagentDeps(async ({ definition }): Promise => { + calls.push(definition.name); + return { agent: definition.name, status: 'ok', text: 'should not run' }; + }), + }); + + await expect( + port.run({ + worktreeDir, + requestPath, + resultPath, + runId: 'run-1', + epicId: 'frontier-1', + sliceId: 'task-1', + cycle: 1, + runtime: { modelRegistry: {} }, + }), + ).resolves.toEqual({ + status: 'failed', + message: `AgentRunnerPort rejected malformed execution request at ${requestPath}.`, + }); + expect(calls).toEqual([]); + }); + it('renders bounded repair diagnostics as untrusted data without changing command authority', async () => { const fixture = await repairFixture(); let task = ''; diff --git a/src/app/agent-runner-port.ts b/src/app/agent-runner-port.ts index 1842ada96..7a67b8f77 100644 --- a/src/app/agent-runner-port.ts +++ b/src/app/agent-runner-port.ts @@ -8,6 +8,7 @@ import { } from '../.pi/extensions/subagents/index.js'; import { durableAtomicReplace } from '../executor/durable-file.js'; import type { AgentRunnerPort, AgentRunUpdate } from '../executor/execution-ports.js'; +import { validatePublicPacketReference } from '../executor/execution-public-packet.js'; import { sliceRepairProtocol, type SliceRepairContext } from '../executor/slice-repair-cycle.js'; export interface AgentRunnerPortOptions { @@ -43,6 +44,12 @@ export function createAgentRunnerPort(options: AgentRunnerPortOptions = {}): Age message: `AgentRunnerPort could not read execution request at ${args.requestPath}.`, }; } + if (!parseExecutionRequest(request)) { + return { + status: 'failed', + message: `AgentRunnerPort rejected malformed execution request at ${args.requestPath}.`, + }; + } let repairContext: SliceRepairContext | undefined; if (args.repairContext) { try { @@ -186,12 +193,28 @@ interface ExecutionRequestContextItem { readonly content?: string; } +interface ExecutionRequestRequirement { + readonly itemId?: string; + readonly title?: string; + readonly content?: string; +} + +interface ExecutionRequestPublicPacket { + readonly path?: string; + readonly packetSha256?: string; + readonly files?: readonly { readonly path?: string; readonly sha256?: string }[]; +} + interface ExecutionRequest { + readonly action?: string; + readonly scopeHandoffRequired?: boolean; readonly scopeId?: string; readonly definition?: string; readonly instruction?: string; readonly criteria?: readonly ExecutionRequestCriterion[]; readonly derivedFrom?: readonly string[]; + readonly requirements?: readonly ExecutionRequestRequirement[]; + readonly publicPacket?: ExecutionRequestPublicPacket; readonly designContext?: readonly ExecutionRequestContextItem[]; readonly verificationContext?: readonly ExecutionRequestContextItem[]; } @@ -208,6 +231,8 @@ function renderWorkerBrief(request: string): string { ...(parsedRequest.instruction ? ['Instruction:', parsedRequest.instruction] : []), ...renderCriterionLines(parsedRequest.criteria), ...renderListSection('Derived from requirements', parsedRequest.derivedFrom), + ...renderRequirementSection(parsedRequest.requirements), + ...renderPublicPacketSection(parsedRequest.publicPacket), ...renderContextSection('Design context', parsedRequest.designContext), ...renderContextSection('Verification context', parsedRequest.verificationContext), ]; @@ -215,14 +240,93 @@ function renderWorkerBrief(request: string): string { return lines.length > 0 ? lines.join('\n') : ['Execution request:', request].join('\n'); } +function renderRequirementSection(requirements: ExecutionRequest['requirements']): string[] { + const rendered = + requirements?.flatMap((requirement) => + typeof requirement?.itemId === 'string' && + typeof requirement?.title === 'string' && + typeof requirement?.content === 'string' + ? [`[${requirement.itemId}] ${requirement.title}\n${requirement.content}`] + : [], + ) ?? []; + return rendered.length > 0 ? ['Approved requirements:', ...rendered] : []; +} + +function renderPublicPacketSection(packet: ExecutionRequest['publicPacket']): string[] { + if (typeof packet?.path !== 'string' || typeof packet.packetSha256 !== 'string') return []; + const files = + packet.files?.flatMap((file) => + typeof file?.path === 'string' && typeof file?.sha256 === 'string' + ? [`- ${file.path} (${file.sha256})`] + : [], + ) ?? []; + return [ + 'Target-visible public packet:', + `- path: ${packet.path}`, + `- sha256: ${packet.packetSha256}`, + ...files, + ]; +} + function parseExecutionRequest(request: string): ExecutionRequest | undefined { try { - return JSON.parse(request) as ExecutionRequest; + const value: unknown = JSON.parse(request); + if (!isRecord(value)) return undefined; + const parsed = value as ExecutionRequest; + if (parsed.publicPacket !== undefined && !isPublicPacket(parsed.publicPacket)) return undefined; + if (parsed.action !== 'execute_slice' || typeof parsed.scopeHandoffRequired !== 'boolean') { + return undefined; + } + const scoped = parsed.scopeHandoffRequired === true || parsed.scopeId !== undefined; + if (parsed.scopeHandoffRequired === false && parsed.scopeId !== undefined) return undefined; + if (scoped) { + if ( + !isNonBlank(parsed.scopeId) || + !Array.isArray(parsed.derivedFrom) || + parsed.derivedFrom.length === 0 || + !parsed.derivedFrom.every(isNonBlank) || + !Array.isArray(parsed.requirements) || + parsed.requirements.length === 0 || + !parsed.requirements.every(isRequirement) + ) { + return undefined; + } + const requirementIds = new Set(parsed.requirements.map((requirement) => requirement.itemId)); + if ( + requirementIds.size !== parsed.requirements.length || + new Set(parsed.derivedFrom).size !== parsed.derivedFrom.length || + !parsed.derivedFrom.every((requirementId) => requirementIds.has(requirementId)) + ) { + return undefined; + } + } + return parsed; } catch { return undefined; } } +function isRequirement(value: unknown): value is Required { + return ( + isRecord(value) && + isNonBlank(value['itemId']) && + isNonBlank(value['title']) && + isNonBlank(value['content']) + ); +} + +function isPublicPacket(value: unknown): value is ExecutionRequestPublicPacket { + return validatePublicPacketReference(value) !== undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonBlank(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + function renderCriterionLines(criteria: ExecutionRequest['criteria']): string[] { const renderedCriteria = criteria?.flatMap((criterion) => diff --git a/src/dev/execution-comparison/__tests__/brunch-lane.test.ts b/src/dev/execution-comparison/__tests__/brunch-lane.test.ts index be48afb9c..4384b9f3e 100644 --- a/src/dev/execution-comparison/__tests__/brunch-lane.test.ts +++ b/src/dev/execution-comparison/__tests__/brunch-lane.test.ts @@ -151,6 +151,15 @@ describe('Brunch execution comparison lane adapter', () => { }); expect(graph.nodes.filter((node) => node.source === 'e2e-handoff [exact-spec]')).toHaveLength(1); expect(JSON.stringify(graph)).not.toMatch(/Petri-net|static browser/iu); + const publicContract = await readFile(join(prepared.publicDir, 'public-contract.json'), 'utf8'); + expect(publicContract).toBe(await readFile(join(prospectCaseDir, 'public-contract.json'), 'utf8')); + expect(publicContract).toContain('/api/health'); + expect(publicContract).toContain('/api/state'); + for (const table of ['projects', 'runs', 'prospects', 'provenance', 'suppressions', 'decisions']) { + expect(publicContract).toContain(`"${table}"`); + } + expect(publicContract).toContain('DATABASE_PATH'); + expect(publicContract).toContain('Research projects'); }); it('projects an opaque brownfield specification without Petri-specific execution wording', async () => { diff --git a/src/executor/TOPOLOGY.md b/src/executor/TOPOLOGY.md index b8159edf3..1e5373549 100644 --- a/src/executor/TOPOLOGY.md +++ b/src/executor/TOPOLOGY.md @@ -1,6 +1,6 @@ # executor/ — execute-mode projection contracts -SPEC decisions: D111-L (executor cutover over injected ports), D52-L (layer boundary), D112-L / FE-1125 (run driver), D126-L (scope handoff contract), D127-L (bounded parallel slice authority), D128-L (same-process run admission and epic effect claims), D129-L (canonical authored execution model and journal order), D130-L (plan-owned execution contract), I58-L (bounded execute-mode side effects). +SPEC decisions: D111-L (executor cutover over injected ports), D52-L (layer boundary), D112-L / FE-1125 (run driver), D126-L (scope handoff contract), D127-L (bounded parallel slice authority), D128-L (same-process run admission and epic effect claims), D129-L (canonical authored execution model and journal order), D130-L (plan-owned execution contract), I58-L (bounded execute-mode side effects), I69-L (exact worker requirement/public-contract context). ## Owns @@ -42,6 +42,7 @@ executor/ ├── promotion.ts petri-exported run -> run-local promotion (GitRunPromotionPort) + report ├── landing.ts promoted run -> user-confirmed host landing (GitHostLandPort) + landed lifecycle ├── isolated-slice-operations.ts shared request/attempt/stream/report/integration/completion mechanics +├── execution-public-packet.ts hash-validation, run-pinned exact bytes, and symlink-safe worker staging for target-visible comparison packets ├── observer-read.ts run bundle -> tolerant read-only RunSummary/RunDetail projections incl. requirements, reports, Petri runtime-event tail, derived replay projection, and worker/verify stream tails (consumed by rpc execute.*) ├── populate.ts worktree -> plan-only worktree population ├── report.ts source-copied run -> reports.jsonl initialization @@ -107,6 +108,8 @@ Journal/lifecycle parity replays both histories through the compiled topology an `ExecutionSpecSnapshot`, `ExecutionPlanOutline`, `ExecutablePlanDraft`, and `PlanPreview` are one structurally aligned v2 projection chain between graph truth and native execute-mode orchestration; each consuming transformation/artifact boundary rejects old versioned input rather than adapting it. Deterministic outline lowering and model-candidate lowering intentionally shape different source material, but both finish through `assembleExecutablePlanDraft`, which owns dependency ordering, epic `sliceIds`, schema version, mode, and the side-effect-free result. The snapshot excludes advisory nodes/edges. Plan-plane `frontier -> requirement` composition owns epic membership; frontier dependency owns epic ordering; positive criterion witness owns epic verification provenance. Requirement dependencies lower to slice `depends_on`, and the scheduler starts only slices whose declared dependencies are complete; unresolved or cyclic dependencies halt without advancing metadata. D126-L scope packages have exactly one frontier owner, a non-empty definition, and direct requirement, criterion, design, and verification anchors; incomplete packages, duplicate requirement ownership, and dependencies on unscoped requirements block plan production. Once scopes exist, outlines lower only committed scopes and never infer owners or unscoped tasks; without scopes, uncomposed requirements remain valid epic-less orphan slices, while multiple frontier membership rejects. Dependency edges with non-requirement endpoints remain package context rather than scheduler edges. Slice request enrichment from the populated plan validates every required scope field and fails closed, so scope context cannot silently degrade to an id-only worker request. The registered `execute_plan_file` path carries those authored facts into the persisted cook plan and rejects plans with no authored `execute.verify` recipe before writing. The plan-owned execution contract (FE-1197/D130-L) keeps elicited recipe provenance and detected manifest provenance separate: the snapshot projects settled `oracle/vv_method` nodes titled exactly `Project execution harness` into a dedicated authority field, and only that field resolves typed setup/build/verify actions. Decisions, constraints, invariants, differently titled verification nodes, advisory nodes, and detection remain context/evidence and never command authority. Multiple canonical harnesses fail closed. Production has no built-in ecosystem provider, default capability source, or inferred harness. Unknown or malformed authored capabilities block, and run creation re-admits the frozen contract before any effect. Every helper advances run metadata with at most one explicit, declared side effect (I58-L): plan/outline artifact writers touch only `.brunch/execution-reports`; cook helpers write only the declared files under `.brunch/cook` or the run worktree described per module below; agent/test/promotion effects are delegated to injected ports; port failure leaves run metadata unadvanced. The only direct subprocess exception is `empty_dir` run-local git repository initialization in `worktree.ts`, bounded to the run worktree and recorded as the worktree-create side effect. No helper mutates the graph, and host mutation is limited to the user-confirmed landing apply in `landing.ts`, reachable only through the `/brunch:land` command's confirm flow. Planner rounds publish lifecycle-only progress, stop after a fixed per-round deadline, and accept candidate material only through the sealed schema-backed output contract before executor-owned parsing and admission. `CandidatePlanSchema` is the sole raw-object acceptance authority and derives the candidate type; parsing adds only JSON-string transport handling and the intentional absent/null/blank `scopeId` normalization. Deterministic admission mirrors the populated-plan worker boundary: every scoped candidate slice must carry known requirement, criterion, design, and verification-machinery anchors, or exact slice-local findings enter bounded repair before a draft can be admitted. A multi-slice epic citing a criterion that verifies a frontier must also contain one ordinary member that carries those frontier criteria and transitively depends on every sibling; single-slice and requirement-only plans are unaffected. This uses the existing slice dependency, fan-in, and epic-verification topology rather than adding a reconciliation phase or durable plan kind. The later `plan_slice_invalid` guard remains authoritative for malformed or externally supplied populated plans. +I69-L strengthens the populated-plan worker boundary: request lowering resolves the exact approved requirement titles and bodies for the active slice plus its transitive slice dependencies, independently of planner summary prose. Unknown, duplicate, cyclic, or malformed requirement/slice references fail before workspace creation. When the target contains a content-addressed execution-comparison public packet, run creation verifies its manifest and file hashes and pins the exact validated bytes in run metadata; malformed packets create no run state. Each slice stages only those pinned `spec.md`, `public-contract.json`, and `packet-manifest.json` bytes into a symlink-safe destination, so later target mutation cannot change worker input and adjacent controller material is never copied. + `run-abandon.ts` is a bounded HITL replanning mutation: it marks an active run `abandoned` while preserving existing evidence paths and files. It refuses missing and already-terminal completed/promoted runs, and it never deletes worktrees, reports, Petri artifacts, promotion artifacts, or graph state. ## Cook plan preview compatibility @@ -117,7 +120,7 @@ Planner rounds publish lifecycle-only progress, stop after a fixed per-round dea | --- | --- | --- | | `scope_handoff_required` | Brunch extension | Plan-level corruption guard: when true, every active slice must retain its scope identity and complete worker context. Old runners ignore the extra field. | | `mode` | mapped | Comes from `ExecutionSpecSnapshot.mode` through the outline/draft chain. | -| `spec` | mapped | Derived from draft requirement ids/definitions and criterion verification targets; inert provenance only. | +| `spec` | mapped | Derived from exact draft requirement ids/titles/bodies and criterion verification targets; worker request lowering resolves this approved content rather than trusting slice paraphrase. | | `epics[].id`, `summary`, `depends_on`, `verification` | mapped | Authored frontier identity/dependencies and positive criterion-witness provenance map directly. | | `slices[].id`, `epic_id`, `definition`, `depends_on`, `verification`, `derived_from` | mapped | Draft task ids, requirement ids, dependency placeholders, and criterion targets map directly. | | `slices[].scope_id`, `design_context`, `verification_context` | mapped | The D126-L scope handoff preserves committed scope, design, and verification context into `plan.json`; the native worker-request path consumes these fields. | @@ -131,6 +134,8 @@ Planner rounds publish lifecycle-only progress, stop after a fixed per-round dea `run.ts` creates only metadata for a ready plan: `.brunch/cook/runs//run.json` with the selected spec id, plan path, `status:"created"`, and optional run-environment policy (`substrate` and `verifyTarget`). It accepts the first run-resource side effect but still does not create a worktree, Petri artifact, report log, promotion ref, or land branch. +For an execution-comparison workspace, that metadata also pins the exact already-validated target-visible packet bytes and digest. Discovery rejects symlinked packet ancestors/files and partial packet directories; an absent packet remains valid for ordinary runs, while a present malformed/hash-divergent packet returns `public_packet_invalid` before the run directory exists. `readRunMetadata` revalidates the complete pinned inventory, contents, manifest, and hashes after persistence, and superseding runs inherit the same pinned packet. + `run-freshness.ts` is a read-only replanning helper for existing runs. It reads `run.json`, reuses launch freshness/provenance checks against the current graph projection stamp, and reports whether the run is fresh, stale, missing provenance, missing its plan, blocked by projection, or missing entirely. It does not mutate run metadata, generate a new plan, or choose HITL recovery actions. `run-retry-eligibility.ts` combines run freshness with the run lifecycle status to classify whether the current step can be retried, a fresh plan can be regenerated before retry, a new run is required, or the run is terminal. It only returns allowed action names; it does not execute retries, mutate plans, or supersede runs. @@ -155,6 +160,8 @@ Planner rounds publish lifecycle-only progress, stop after a fixed per-round dea `slice-execute.ts` asks the injected `GitSliceIntegrationPort` to create one stable workspace at `slice-workspaces//worktree`, rooted at the run workspace's current HEAD, then creates the execution request under `agent-output//request.json` and records the workspace/base identity. It requires the active slice to be readable from the populated plan before advancing, then carries definition, verification criteria, and scope/design/verification context; missing or malformed plan data returns `plan_slice_invalid` with no side effects. A pre-existing path is reusable only when git reports the same common repository, the path is registered as that repository's linked worktree, and its HEAD still equals the current run base; integration rechecks repository identity and recorded-base ancestry. A foreign repository at the predictable path fails before agent dispatch and is never mutated. Retries reuse the already accepted workspace; there are no per-attempt worktrees. +Its I69-L request context also carries the exact cumulative approved requirements and, when present, a relative run-pinned public-packet reference. Requirement/context validation occurs before `GitSliceIntegrationPort.prepare`, so malformed populated-plan input produces `plan_slice_invalid` with no workspace side effect. Packet staging refuses symlinked/non-directory ancestors and non-identical existing files. It remains `.brunch` bookkeeping and therefore stays outside slice integration commits. + `agent-result.ts` runs the injected `AgentRunnerPort` in the active slice workspace. `run.json.sliceRepairHistory` groups legal agent/verify/reset epochs by contiguous repair cycle and rejects stale positional history. Result paths (`agent-output//attempt-/result.json`) and worker streams (`streams//agent-attempt-.jsonl`) use monotonic artifact ordinals across cycles and resets, independently of cycle-local Petri attempt numbers; the stable request and workspace are reused. `slice-repair-cycle.ts` owns the finite ladder shared by serial and D127-L parallel authority. Its independent defaults are three total repair cycles and three runner attempts per stage. Agent/verify/result/verdict/repair/integration identities are cycle-qualified; a failed non-final verdict has one arc into the next compiled cycle, while the final failed verdict has no repair arc. Runner exhaustion and explicit reset remain in the same cycle. The private subtree is imported only by this public root. diff --git a/src/executor/__tests__/orchestrate.test.ts b/src/executor/__tests__/orchestrate.test.ts index 8b53cd527..a1b01276b 100644 --- a/src/executor/__tests__/orchestrate.test.ts +++ b/src/executor/__tests__/orchestrate.test.ts @@ -136,6 +136,7 @@ function planJson( ): string { return JSON.stringify({ ...(options.includeMode === false ? {} : { mode: 'greenfield' }), + scope_handoff_required: false, epics: [{ id: 'frontier-1', summary: 'Build feature', depends_on: [], verification: [] }], slices: slices.map((slice, index) => { const sliceId = typeof slice === 'string' ? slice : slice.id; @@ -174,7 +175,11 @@ async function createRunAtCreatedWithPlan(cwd: string, plan: object): Promise { it('keeps serial and parallel isolated-slice artifacts and reports equivalent per slice', async () => { const serial = await mkdtemp(join(tmpdir(), 'brunch-slice-core-parity-serial-')); const parallel = await mkdtemp(join(tmpdir(), 'brunch-slice-core-parity-parallel-')); - await createRunAtCreated(serial, ['task-1', 'task-2']); - await createRunAtCreated(parallel, ['task-1', 'task-2']); + const scopedPlan = { + mode: 'greenfield', + scope_handoff_required: true, + spec: { + requirements: [ + { item_id: 'REQ1', title: 'Foundation', content: 'Preserve the exact foundation.' }, + { item_id: 'REQ2', title: 'Integration', content: 'Integrate the exact public behavior.' }, + ], + }, + epics: [{ id: 'frontier-1', summary: 'Build feature', depends_on: [], verification: [] }], + slices: [ + { + id: 'task-1', + scope_id: 'SCP1', + epic_id: 'frontier-1', + definition: 'Build foundation.', + depends_on: [], + verification: [{ kind: 'criterion', target: 'Foundation works.' }], + derived_from: ['REQ1'], + design_context: [{ item_id: 'MOD1', content: 'Feature module' }], + verification_context: [{ item_id: 'CH1', content: 'Feature check' }], + }, + { + id: 'task-2', + scope_id: 'SCP1', + epic_id: 'frontier-1', + definition: 'Integrate feature.', + depends_on: ['task-1'], + verification: [{ kind: 'criterion', target: 'Integration works.' }], + derived_from: ['REQ2'], + design_context: [{ item_id: 'MOD1', content: 'Feature module' }], + verification_context: [{ item_id: 'CH1', content: 'Feature check' }], + }, + ], + }; + for (const cwd of [serial, parallel]) { + const publicDir = join(cwd, '.brunch', 'execution-comparison', 'public'); + const specification = '# Exact parity specification\n'; + const contract = '{"schemaVersion":1}\n'; + const digest = (value: string) => `sha256:${createHash('sha256').update(value).digest('hex')}`; + const files = [ + { path: 'public-contract.json', sha256: digest(contract) }, + { path: 'spec.md', sha256: digest(specification) }, + ]; + await mkdir(publicDir, { recursive: true }); + await writeFile(join(publicDir, 'public-contract.json'), contract, 'utf8'); + await writeFile(join(publicDir, 'spec.md'), specification, 'utf8'); + await writeFile(join(publicDir, 'controller-oracle.json'), '{"hidden":true}\n', 'utf8'); + await writeFile( + join(publicDir, 'packet-manifest.json'), + JSON.stringify({ + schemaVersion: 1, + packetSha256: digest(files.map((file) => `${file.path}:${file.sha256}\n`).join('')), + files, + }), + 'utf8', + ); + await createRunAtCreatedWithPlan(cwd, scopedPlan); + } const parityPorts = () => fakePorts({ agentRunner: { @@ -5984,6 +6046,25 @@ describe('petriScheduler', () => { await expect(readFile(sliceExecutionRequestPath(serial, 'run-1', sliceId), 'utf8')).resolves.toBe( await readFile(sliceExecutionRequestPath(parallel, 'run-1', sliceId), 'utf8'), ); + for (const packetFile of ['packet-manifest.json', 'public-contract.json', 'spec.md']) { + const relativePacketPath = join('.brunch', 'execution-comparison', 'public', packetFile); + await expect( + readFile(join(sliceWorkspacePath(serial, 'run-1', sliceId), relativePacketPath), 'utf8'), + ).resolves.toBe( + await readFile(join(sliceWorkspacePath(parallel, 'run-1', sliceId), relativePacketPath), 'utf8'), + ); + } + expect( + await pathExists( + join( + sliceWorkspacePath(parallel, 'run-1', sliceId), + '.brunch', + 'execution-comparison', + 'public', + 'controller-oracle.json', + ), + ), + ).toBe(false); const serialAgentEvent = JSON.parse( await readFile(agentStreamPath(serial, 'run-1', sliceId, 1), 'utf8'), ) as Record; @@ -6003,6 +6084,11 @@ describe('petriScheduler', () => { '"event":"verify_stream"', ); } + const terminalRequest = JSON.parse( + await readFile(sliceExecutionRequestPath(parallel, 'run-1', 'task-2'), 'utf8'), + ) as { requirements: unknown[]; publicPacket: { path: string } }; + expect(terminalRequest.requirements).toHaveLength(2); + expect(terminalRequest.publicPacket.path).toBe('.brunch/execution-comparison/public'); const serialMetadata = await readRunMetadata(runMetadataPath(serial, 'run-1')); const parallelMetadata = await readRunMetadata(runMetadataPath(parallel, 'run-1')); expect(parallelMetadata?.sliceRepairHistory).toEqual(serialMetadata?.sliceRepairHistory); diff --git a/src/executor/__tests__/plan-preview.test.ts b/src/executor/__tests__/plan-preview.test.ts index bab8e01a7..05094d045 100644 --- a/src/executor/__tests__/plan-preview.test.ts +++ b/src/executor/__tests__/plan-preview.test.ts @@ -112,8 +112,8 @@ describe('previewPlan', () => { } as unknown as ExecutablePlanDraft); expect(preview.spec.requirements).toEqual([ - { item_id: 'REQ1', content: 'Render the feature entry point.' }, - { item_id: 'REQ2', content: 'Ship the keyboard shortcut.' }, + { item_id: 'REQ1', title: 'Build feature', content: 'Render the feature entry point.' }, + { item_id: 'REQ2', title: 'Add shortcut', content: 'Ship the keyboard shortcut.' }, ]); expect(preview.spec.criteria).toEqual([ { item_id: 'AC1', content: 'Feature entry is visible.', verifies: ['REQ1'] }, diff --git a/src/executor/__tests__/plan-synthesis.test.ts b/src/executor/__tests__/plan-synthesis.test.ts index 398c8f871..3ec4b88c4 100644 --- a/src/executor/__tests__/plan-synthesis.test.ts +++ b/src/executor/__tests__/plan-synthesis.test.ts @@ -128,13 +128,57 @@ describe('synthesizePlan', () => { ); expect(contexts).toEqual( - result.draft.slices.map((slice) => + result.draft.slices.map((slice, index) => expect.objectContaining({ status: 'ok', - requestContext: expect.objectContaining({ scopeId: slice.scopeId }), + requestContext: expect.objectContaining({ + scopeId: slice.scopeId, + requirements: + index === 0 + ? [expect.objectContaining({ itemId: 'REQ1', content: 'Build feature' })] + : [ + expect.objectContaining({ itemId: 'REQ1', content: 'Build feature' }), + expect.objectContaining({ itemId: 'REQ2', content: 'Wire feature' }), + ], + }), }), ), ); + const terminalContext = contexts[1]; + if (terminalContext?.status !== 'ok') throw new Error('expected terminal worker context'); + + const paraphrasedPath = join(cwd, 'paraphrased-plan.json'); + await writeFile( + paraphrasedPath, + JSON.stringify( + planFilePayload( + previewPlan( + { + ...result.draft, + slices: result.draft.slices.map((slice) => ({ + ...slice, + definition: `Planner summary changed for ${slice.id}.`, + })), + }, + { executionContract: result.executionContract }, + ), + ), + ), + 'utf8', + ); + const paraphrased = await readSliceRequestContext({ + cwd, + runId: 'admission-parity', + populatedPlanPath: paraphrasedPath, + sliceId: 'task-2', + }); + expect(paraphrased).toMatchObject({ + status: 'ok', + requestContext: { + definition: 'Planner summary changed for task-2.', + requirements: terminalContext.requestContext.requirements, + }, + }); }); it('feeds an unreconciled frontier-level epic back through bounded repair', async () => { diff --git a/src/executor/__tests__/run-supersession.test.ts b/src/executor/__tests__/run-supersession.test.ts index dc174cdbb..6626069e9 100644 --- a/src/executor/__tests__/run-supersession.test.ts +++ b/src/executor/__tests__/run-supersession.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { access, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -200,7 +201,32 @@ describe('createSupersedingRun', () => { it('creates a new linked run without mutating the previous run', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-run-supersede-create-')); await writePlan(cwd); - const previous = await writeRun(cwd, 'run-old'); + const specification = '# Spec\n'; + const contract = '{}\n'; + const files = [ + { path: 'public-contract.json' as const, sha256: digest(contract) }, + { path: 'spec.md' as const, sha256: digest(specification) }, + ]; + const packetSha256 = digest(files.map((file) => `${file.path}:${file.sha256}\n`).join('')); + const previous: RunMetadata = { + ...(await writeRun(cwd, 'run-old')), + publicPacket: { + reference: { + path: '.brunch/execution-comparison/public', + packetSha256, + files, + }, + contents: [ + { + path: 'packet-manifest.json', + value: JSON.stringify({ schemaVersion: 1, packetSha256, files }), + }, + { path: 'public-contract.json', value: contract }, + { path: 'spec.md', value: specification }, + ], + }, + }; + await writeFile(runMetadataPath(cwd, 'run-old'), `${JSON.stringify(previous, null, 2)}\n`, 'utf8'); const result = await createSupersedingRun({ cwd, @@ -235,6 +261,7 @@ describe('createSupersedingRun', () => { ); await expect(readRunMetadata(runMetadataPath(cwd, 'run-new'))).resolves.toMatchObject({ petriObservationPrepared: true, + publicPacket: previous.publicPacket, }); await expect(pathExists(petriPlanSnapshotPath(cwd, 'run-new'))).resolves.toBe(true); await expect(pathExists(petriNetPath(cwd, 'run-new'))).resolves.toBe(true); @@ -326,3 +353,7 @@ describe('createSupersedingRun', () => { await expect(readFile(runMetadataPath(cwd, 'run-new'), 'utf8')).resolves.toContain('"command": "npm"'); }); }); + +function digest(value: string): string { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} diff --git a/src/executor/__tests__/run.test.ts b/src/executor/__tests__/run.test.ts index be3fc4863..6bd941132 100644 --- a/src/executor/__tests__/run.test.ts +++ b/src/executor/__tests__/run.test.ts @@ -1,4 +1,5 @@ -import { access, mkdir, mkdtemp, readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { access, mkdir, mkdtemp, readdir, readFile, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -10,6 +11,7 @@ import { assertSafeRunId, createRun, persistRunMetadata, + readRunMetadata, runDirPath, runMetadataPath, type RunMetadata, @@ -139,6 +141,109 @@ describe('createRun', () => { }); await expect(readFile(runMetadataPath(cwd, 'run-1'), 'utf8')).resolves.toContain('abandoned'); }); + + it('rejects a changed target-visible public packet before creating run state', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-cook-run-invalid-packet-')); + await writeReadyPlan(cwd); + const publicDir = join(cwd, '.brunch', 'execution-comparison', 'public'); + const specification = '# Approved\n'; + const contract = '{"schemaVersion":1}\n'; + const files = [ + { path: 'public-contract.json', sha256: digest(contract) }, + { path: 'spec.md', sha256: digest(specification) }, + ]; + await mkdir(publicDir, { recursive: true }); + await writeFile(join(publicDir, 'public-contract.json'), contract, 'utf8'); + await writeFile(join(publicDir, 'spec.md'), `${specification}changed\n`, 'utf8'); + await writeFile( + join(publicDir, 'packet-manifest.json'), + JSON.stringify({ + schemaVersion: 1, + packetSha256: digest(files.map((file) => `${file.path}:${file.sha256}\n`).join('')), + files, + }), + 'utf8', + ); + + const result = await createRun({ cwd, specId: '42', runId: 'run-1', current }); + + expect(result).toEqual({ + status: 'public_packet_invalid', + runStatus: 'not_started', + runId: 'run-1', + message: 'Target-visible public packet file spec.md failed hashing.', + sideEffects: [], + }); + expect(await pathExists(runDirPath(cwd, 'run-1'))).toBe(false); + }); + + it('rejects a partial public packet directory instead of treating it as absent', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-cook-run-partial-packet-')); + await writeReadyPlan(cwd); + const publicDir = join(cwd, '.brunch', 'execution-comparison', 'public'); + await mkdir(publicDir, { recursive: true }); + await writeFile(join(publicDir, 'spec.md'), '# Spec\n', 'utf8'); + + const result = await createRun({ cwd, specId: '42', runId: 'run-1', current }); + + expect(result).toMatchObject({ + status: 'public_packet_invalid', + message: 'Target-visible public packet manifest is unreadable.', + sideEffects: [], + }); + expect(await pathExists(runDirPath(cwd, 'run-1'))).toBe(false); + }); + + it('rejects symlinked public packet source files', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-cook-run-source-symlink-')); + await writeReadyPlan(cwd); + const publicDir = join(cwd, '.brunch', 'execution-comparison', 'public'); + const outside = await mkdtemp(join(tmpdir(), 'brunch-cook-run-source-outside-')); + const specification = '# Outside\n'; + const contract = '{"schemaVersion":1}\n'; + const files = [ + { path: 'public-contract.json', sha256: digest(contract) }, + { path: 'spec.md', sha256: digest(specification) }, + ]; + await mkdir(publicDir, { recursive: true }); + await writeFile(join(publicDir, 'public-contract.json'), contract, 'utf8'); + await writeFile(join(outside, 'spec.md'), specification, 'utf8'); + await symlink(join(outside, 'spec.md'), join(publicDir, 'spec.md')); + await writeFile( + join(publicDir, 'packet-manifest.json'), + JSON.stringify({ + schemaVersion: 1, + packetSha256: digest(files.map((file) => `${file.path}:${file.sha256}\n`).join('')), + files, + }), + 'utf8', + ); + + const result = await createRun({ cwd, specId: '42', runId: 'run-1', current }); + + expect(result).toMatchObject({ + status: 'public_packet_invalid', + message: 'Target-visible public packet file spec.md is invalid.', + sideEffects: [], + }); + expect(await pathExists(runDirPath(cwd, 'run-1'))).toBe(false); + }); + + it('rejects symlinked public packet source ancestors', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-cook-run-source-ancestor-symlink-')); + await writeReadyPlan(cwd); + const outside = await mkdtemp(join(tmpdir(), 'brunch-cook-run-source-ancestor-outside-')); + await symlink(outside, join(cwd, '.brunch', 'execution-comparison')); + + const result = await createRun({ cwd, specId: '42', runId: 'run-1', current }); + + expect(result).toMatchObject({ + status: 'public_packet_invalid', + message: 'Target-visible public packet directory is invalid.', + sideEffects: [], + }); + expect(await pathExists(runDirPath(cwd, 'run-1'))).toBe(false); + }); }); describe('persistRunMetadata', () => { @@ -177,8 +282,34 @@ describe('persistRunMetadata', () => { expect(await readdir(dir)).toEqual(['run.json']); expect(await readdir(metadataPath)).toEqual(['occupied']); }); + + it('rejects corrupted persisted packet paths before any slice can stage them', async () => { + const dir = await mkdtemp(join(tmpdir(), 'brunch-cook-corrupt-packet-metadata-')); + const metadataPath = join(dir, 'run.json'); + await writeFile( + metadataPath, + JSON.stringify({ + ...metadata('created'), + publicPacket: { + reference: { + path: '../../escape', + packetSha256: `sha256:${'a'.repeat(64)}`, + files: [], + }, + contents: [], + }, + }), + 'utf8', + ); + + await expect(readRunMetadata(metadataPath)).resolves.toBeUndefined(); + }); }); +function digest(value: string): string { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + describe('assertSafeRunId', () => { it('accepts flat path-segment-safe run ids', () => { for (const runId of ['run-1', 'run_1', 'RUN.1', 'abc123']) { diff --git a/src/executor/__tests__/slice-execute.test.ts b/src/executor/__tests__/slice-execute.test.ts index 3b95d6182..27199cf89 100644 --- a/src/executor/__tests__/slice-execute.test.ts +++ b/src/executor/__tests__/slice-execute.test.ts @@ -1,4 +1,5 @@ -import { access, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { access, mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -34,6 +35,10 @@ async function createSliceStartedRun(cwd: string): Promise { planPath, JSON.stringify({ mode: 'greenfield', + scope_handoff_required: true, + spec: { + requirements: [{ item_id: 'REQ1', title: 'Build feature', content: 'Build exact feature.' }], + }, epics: [{ id: 'frontier-1', summary: 'Build feature', depends_on: [], verification: [] }], slices: [ { @@ -69,6 +74,14 @@ async function createSecondScopeSliceStartedRun(cwd: string): Promise { planPath, JSON.stringify({ mode: 'brownfield', + scope_handoff_required: true, + spec: { + requirements: [ + { item_id: 'REQ1', title: 'Wire feature', content: 'Wire the shared feature.' }, + { item_id: 'REQ2', title: 'Add shortcut', content: 'Ship the exact keyboard shortcut.' }, + { item_id: 'REQ3', title: 'Build foundation', content: 'Preserve the shared foundation.' }, + ], + }, epics: [ { id: 'F1', summary: 'Execution handoff', depends_on: [], verification: [] }, { @@ -189,12 +202,14 @@ describe('requestSliceExecution', () => { runId: 'run-1', sliceId: 'task-1', epicId: 'frontier-1', + scopeHandoffRequired: true, scopeId: 'SCP1', action: 'execute_slice', status: 'requested', definition: 'Build the first task.', criteria: [{ kind: 'criterion', target: 'Feature passes smoke.' }], derivedFrom: ['REQ1'], + requirements: [{ itemId: 'REQ1', title: 'Build feature', content: 'Build exact feature.' }], designContext: [{ itemId: 'MOD1', content: 'Canvas route module' }], verificationContext: [{ itemId: 'CH1', content: 'Canvas smoke test' }], instruction: 'Make the minimum change that satisfies every criterion.', @@ -237,18 +252,116 @@ describe('requestSliceExecution', () => { runId: 'run-1', sliceId: 'task-2', epicId: 'F1', + scopeHandoffRequired: true, scopeId: 'SCP1', action: 'execute_slice', status: 'requested', definition: 'Ship keyboard shortcut', criteria: [{ kind: 'criterion', target: 'Shortcut opens feature' }], derivedFrom: ['REQ2'], + requirements: [ + { itemId: 'REQ1', title: 'Wire feature', content: 'Wire the shared feature.' }, + { itemId: 'REQ2', title: 'Add shortcut', content: 'Ship the exact keyboard shortcut.' }, + { itemId: 'REQ3', title: 'Build foundation', content: 'Preserve the shared foundation.' }, + ], designContext: [{ itemId: 'MOD1', content: 'Feature module' }], verificationContext: [{ itemId: 'CH1', content: 'Feature smoke test' }], instruction: 'Make the minimum change that satisfies every criterion.', }); }); + it('stages only the verified target-visible public packet into the sealed slice workspace', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-slice-execute-public-packet-')); + const specification = '# Exact specification\n'; + const contract = '{"schemaVersion":1}\n'; + const { publicDir, files, packetSha256 } = await writePublicPacket(cwd, specification, contract); + await writeFile(join(publicDir, 'controller-oracle.json'), '{"hidden":true}\n', 'utf8'); + await createSliceStartedRun(cwd); + + const result = await requestSliceExecution({ + cwd, + runId: 'run-1', + gitSliceIntegration: createFakeGitSliceIntegrationPort(), + }); + + expect(result.status).toBe('slice_execution_requested'); + const request = JSON.parse( + await readFile(sliceExecutionRequestPath(cwd, 'run-1', 'task-1'), 'utf8'), + ) as Record; + expect(request['publicPacket']).toEqual({ + path: '.brunch/execution-comparison/public', + packetSha256, + files, + }); + const stagedDir = join( + sliceWorkspacePath(cwd, 'run-1', 'task-1'), + '.brunch', + 'execution-comparison', + 'public', + ); + await expect(readFile(join(stagedDir, 'spec.md'), 'utf8')).resolves.toBe(specification); + await expect(readFile(join(stagedDir, 'public-contract.json'), 'utf8')).resolves.toBe(contract); + await expect(readFile(join(stagedDir, 'packet-manifest.json'), 'utf8')).resolves.toContain(packetSha256); + expect(await pathExists(join(stagedDir, 'controller-oracle.json'))).toBe(false); + }); + + it('stages the run-pinned packet when the target packet changes after run creation', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-slice-execute-changed-packet-')); + const specification = '# Approved specification\n'; + const contract = '{"schemaVersion":1}\n'; + const original = await writePublicPacket(cwd, specification, contract); + await createSliceStartedRun(cwd); + await writePublicPacket(cwd, `${specification}changed\n`, contract); + + const result = await requestSliceExecution({ + cwd, + runId: 'run-1', + gitSliceIntegration: createFakeGitSliceIntegrationPort(), + }); + + expect(result.status).toBe('slice_execution_requested'); + const request = JSON.parse(await readFile(sliceExecutionRequestPath(cwd, 'run-1', 'task-1'), 'utf8')) as { + publicPacket: { packetSha256: string }; + }; + expect(request.publicPacket.packetSha256).toBe(original.packetSha256); + await expect( + readFile( + join( + sliceWorkspacePath(cwd, 'run-1', 'task-1'), + '.brunch', + 'execution-comparison', + 'public', + 'spec.md', + ), + 'utf8', + ), + ).resolves.toBe(specification); + }); + + it('refuses a symlinked packet destination without writing outside the slice worktree', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-slice-execute-packet-symlink-')); + await writePublicPacket(cwd, '# Approved\n', '{"schemaVersion":1}\n'); + await createSliceStartedRun(cwd); + const outside = await mkdtemp(join(tmpdir(), 'brunch-slice-packet-outside-')); + const gitSliceIntegration = createFakeGitSliceIntegrationPort({ + async prepare({ sliceWorktreeDir }) { + await mkdir(sliceWorktreeDir, { recursive: true }); + await writeFile(join(sliceWorktreeDir, '.git'), 'gitdir: /tmp/fake\n', 'utf8'); + await symlink(outside, join(sliceWorktreeDir, '.brunch')); + return { + status: 'prepared', + baseSha: 'base123', + sideEffects: [{ kind: 'git_worktree_add', path: sliceWorktreeDir, ref: 'base123' }], + }; + }, + }); + + await expect(requestSliceExecution({ cwd, runId: 'run-1', gitSliceIntegration })).rejects.toThrow( + 'public packet destination .brunch is not a regular directory', + ); + expect(await pathExists(join(outside, 'spec.md'))).toBe(false); + }); + it('rejects active slice ids that would escape the agent-output directory', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-slice-execute-unsafe-slice-')); const runDir = runDirPath(cwd, 'run-1'); @@ -282,7 +395,7 @@ describe('requestSliceExecution', () => { it('leaves run metadata unadvanced when the populated plan cannot supply the active slice brief', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-slice-execute-invalid-plan-')); await createSliceStartedRun(cwd); - await writeFile(populatedPlanPath(cwd, 'run-1'), '{"slices":[]}', 'utf8'); + await writeFile(populatedPlanPath(cwd, 'run-1'), '{"scope_handoff_required":false,"slices":[]}', 'utf8'); const result = await requestSliceExecution({ cwd, @@ -309,6 +422,10 @@ describe('requestSliceExecution', () => { await writeFile( populatedPlanPath(cwd, 'run-1'), JSON.stringify({ + scope_handoff_required: true, + spec: { + requirements: [{ item_id: 'REQ1', title: 'Build feature', content: 'Build exact feature.' }], + }, slices: [ { id: 'task-1', @@ -347,6 +464,9 @@ describe('requestSliceExecution', () => { populatedPlanPath(cwd, 'run-1'), JSON.stringify({ scope_handoff_required: true, + spec: { + requirements: [{ item_id: 'REQ1', title: 'Build feature', content: 'Build exact feature.' }], + }, slices: [ { id: 'task-1', @@ -382,6 +502,7 @@ describe('requestSliceExecution', () => { populatedPlanPath(cwd, 'run-1'), JSON.stringify({ scope_handoff_required: true, + spec: { requirements: [] }, slices: [ { id: 'task-1', @@ -411,4 +532,135 @@ describe('requestSliceExecution', () => { sideEffects: [], }); }); + + it('rejects a scoped slice when a derived requirement cannot be resolved exactly', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-slice-execute-unknown-requirement-')); + await createSliceStartedRun(cwd); + const plan = JSON.parse(await readFile(populatedPlanPath(cwd, 'run-1'), 'utf8')) as { + slices: Array<{ derived_from: string[] }>; + }; + plan.slices[0]!.derived_from = ['REQ-missing']; + await writeFile(populatedPlanPath(cwd, 'run-1'), JSON.stringify(plan), 'utf8'); + + const result = await requestSliceExecution({ + cwd, + runId: 'run-1', + gitSliceIntegration: createFakeGitSliceIntegrationPort(), + }); + + expect(result).toMatchObject({ + status: 'plan_slice_invalid', + runStatus: 'slice_started', + message: 'Scope slice task-1 cannot resolve exact requirement content for REQ-missing.', + sideEffects: [], + }); + expect(await pathExists(sliceExecutionRequestPath(cwd, 'run-1', 'task-1'))).toBe(false); + }); + + it('rejects duplicate requirement ids instead of selecting one body', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-slice-execute-duplicate-requirement-')); + await createSliceStartedRun(cwd); + const plan = JSON.parse(await readFile(populatedPlanPath(cwd, 'run-1'), 'utf8')) as { + spec: { requirements: Array<{ item_id: string; title: string; content: string }> }; + }; + plan.spec.requirements.push({ item_id: 'REQ1', title: 'Rival', content: 'Rival body' }); + await writeFile(populatedPlanPath(cwd, 'run-1'), JSON.stringify(plan), 'utf8'); + + const result = await requestSliceExecution({ + cwd, + runId: 'run-1', + gitSliceIntegration: createFakeGitSliceIntegrationPort(), + }); + + expect(result).toMatchObject({ + status: 'plan_slice_invalid', + message: 'Populated plan contains duplicate requirement ids.', + sideEffects: [], + }); + }); + + it('rejects malformed scope markers instead of treating scoped work as unscoped', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-slice-execute-malformed-scope-marker-')); + await createSliceStartedRun(cwd); + const plan = JSON.parse(await readFile(populatedPlanPath(cwd, 'run-1'), 'utf8')) as Record< + string, + unknown + >; + plan['scope_handoff_required'] = 'false'; + await writeFile(populatedPlanPath(cwd, 'run-1'), JSON.stringify(plan), 'utf8'); + + const result = await requestSliceExecution({ + cwd, + runId: 'run-1', + gitSliceIntegration: createFakeGitSliceIntegrationPort(), + }); + + expect(result).toMatchObject({ + status: 'plan_slice_invalid', + message: 'Populated plan has an invalid scope_handoff_required marker.', + sideEffects: [], + }); + }); + + it('rejects malformed slice arrays and duplicate requirement references', async () => { + const malformed = await mkdtemp(join(tmpdir(), 'brunch-slice-execute-malformed-array-')); + await createSliceStartedRun(malformed); + await writeFile( + populatedPlanPath(malformed, 'run-1'), + JSON.stringify({ scope_handoff_required: true, slices: {} }), + 'utf8', + ); + await expect( + requestSliceExecution({ + cwd: malformed, + runId: 'run-1', + gitSliceIntegration: createFakeGitSliceIntegrationPort(), + }), + ).resolves.toMatchObject({ + status: 'plan_slice_invalid', + message: 'Populated plan has malformed requirement or slice arrays.', + sideEffects: [], + }); + + const duplicate = await mkdtemp(join(tmpdir(), 'brunch-slice-execute-duplicate-reference-')); + await createSliceStartedRun(duplicate); + const plan = JSON.parse(await readFile(populatedPlanPath(duplicate, 'run-1'), 'utf8')) as { + slices: Array<{ derived_from: string[] }>; + }; + plan.slices[0]!.derived_from = ['REQ1', 'REQ1']; + await writeFile(populatedPlanPath(duplicate, 'run-1'), JSON.stringify(plan), 'utf8'); + await expect( + requestSliceExecution({ + cwd: duplicate, + runId: 'run-1', + gitSliceIntegration: createFakeGitSliceIntegrationPort(), + }), + ).resolves.toMatchObject({ + status: 'plan_slice_invalid', + message: 'Scope slice task-1 has duplicate dependency or requirement references at task-1.', + sideEffects: [], + }); + }); }); + +function digest(value: string): string { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +async function writePublicPacket(cwd: string, specification: string, contract: string) { + const publicDir = join(cwd, '.brunch', 'execution-comparison', 'public'); + const files = [ + { path: 'public-contract.json', sha256: digest(contract) }, + { path: 'spec.md', sha256: digest(specification) }, + ]; + const packetSha256 = digest(files.map((file) => `${file.path}:${file.sha256}\n`).join('')); + await mkdir(publicDir, { recursive: true }); + await writeFile(join(publicDir, 'spec.md'), specification, 'utf8'); + await writeFile(join(publicDir, 'public-contract.json'), contract, 'utf8'); + await writeFile( + join(publicDir, 'packet-manifest.json'), + JSON.stringify({ schemaVersion: 1, caseId: 'case-1', packetSha256, files }), + 'utf8', + ); + return { publicDir, files, packetSha256 }; +} diff --git a/src/executor/execution-public-packet.ts b/src/executor/execution-public-packet.ts new file mode 100644 index 000000000..e4cdd6e55 --- /dev/null +++ b/src/executor/execution-public-packet.ts @@ -0,0 +1,292 @@ +import { createHash } from 'node:crypto'; +import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export interface PublicPacketReference { + readonly path: '.brunch/execution-comparison/public'; + readonly packetSha256: string; + readonly files: readonly { readonly path: 'public-contract.json' | 'spec.md'; readonly sha256: string }[]; +} + +export interface PublicPacketMaterialization { + readonly reference: PublicPacketReference; + readonly contents: readonly { + readonly path: 'packet-manifest.json' | 'public-contract.json' | 'spec.md'; + readonly value: string; + }[]; +} + +export type PublicPacketReadResult = + | { readonly status: 'absent' } + | { readonly status: 'present'; readonly packet: PublicPacketMaterialization } + | { readonly status: 'invalid'; readonly message: string }; + +export async function readPublicPacket(cwd: string): Promise { + const sourceResult = await resolvePacketSource(cwd); + if (sourceResult.status !== 'present') return sourceResult; + const sourceDir = sourceResult.sourceDir; + let rawManifest: string; + try { + const manifestPath = join(sourceDir, 'packet-manifest.json'); + const manifest = await lstat(manifestPath); + if (!manifest.isFile() || manifest.isSymbolicLink()) { + return { status: 'invalid', message: 'Target-visible public packet manifest is invalid.' }; + } + rawManifest = await readFile(manifestPath, 'utf8'); + } catch { + return { status: 'invalid', message: 'Target-visible public packet manifest is unreadable.' }; + } + + let value: unknown; + try { + value = JSON.parse(rawManifest); + } catch { + return { status: 'invalid', message: 'Target-visible public packet manifest is malformed.' }; + } + if (!isRecord(value) || value['schemaVersion'] !== 1 || !isSha256(value['packetSha256'])) { + return { status: 'invalid', message: 'Target-visible public packet manifest is malformed.' }; + } + const manifestFiles = value['files']; + if (!Array.isArray(manifestFiles) || manifestFiles.length !== 2) { + return { status: 'invalid', message: 'Target-visible public packet file inventory is invalid.' }; + } + const files: PublicPacketReference['files'] = manifestFiles.flatMap((file) => { + if ( + !isRecord(file) || + (file['path'] !== 'public-contract.json' && file['path'] !== 'spec.md') || + !isSha256(file['sha256']) + ) { + return []; + } + return [{ path: file['path'], sha256: file['sha256'] }]; + }); + if ( + files.length !== 2 || + new Set(files.map((file) => file.path)).size !== 2 || + !files.some((file) => file.path === 'public-contract.json') || + !files.some((file) => file.path === 'spec.md') + ) { + return { status: 'invalid', message: 'Target-visible public packet file inventory is invalid.' }; + } + + const contents: { readonly path: 'public-contract.json' | 'spec.md'; readonly value: string }[] = []; + for (const file of files) { + let fileContents: string; + try { + const filePath = join(sourceDir, file.path); + const source = await lstat(filePath); + if (!source.isFile() || source.isSymbolicLink()) { + return { status: 'invalid', message: `Target-visible public packet file ${file.path} is invalid.` }; + } + fileContents = await readFile(filePath, 'utf8'); + } catch { + return { status: 'invalid', message: `Target-visible public packet file ${file.path} is unreadable.` }; + } + if (sha256(fileContents) !== file.sha256) { + return { status: 'invalid', message: `Target-visible public packet file ${file.path} failed hashing.` }; + } + contents.push({ path: file.path, value: fileContents }); + } + const packetSha256 = sha256(files.map((file) => `${file.path}:${file.sha256}\n`).join('')); + if (packetSha256 !== value['packetSha256']) { + return { status: 'invalid', message: 'Target-visible public packet identity failed hashing.' }; + } + return { + status: 'present', + packet: { + reference: { + path: '.brunch/execution-comparison/public', + packetSha256, + files, + }, + contents: [ + { + path: 'packet-manifest.json', + value: `${JSON.stringify({ schemaVersion: 1, packetSha256, files }, null, 2)}\n`, + }, + ...contents, + ], + }, + }; +} + +export async function materializePublicPacket(args: { + readonly packet: PublicPacketMaterialization; + readonly sliceWorktreeDir: string; +}): Promise< + readonly ( + | { readonly kind: 'mkdir'; readonly path: string } + | { readonly kind: 'write_file'; readonly path: string; readonly ifExists: 'overwrite' } + )[] +> { + const packet = validatePublicPacketMaterialization(args.packet); + if (!packet) throw new Error('pinned public packet metadata is invalid'); + const destination = await ensurePacketDirectory(args.sliceWorktreeDir); + const allowedFiles = new Set(packet.contents.map((file) => file.path)); + const unexpected = (await readdir(destination)).find( + (file) => !allowedFiles.has(file as PublicPacketMaterialization['contents'][number]['path']), + ); + if (unexpected) throw new Error(`public packet destination contains unexpected file ${unexpected}`); + const effects: ( + | { readonly kind: 'mkdir'; readonly path: string } + | { readonly kind: 'write_file'; readonly path: string; readonly ifExists: 'overwrite' } + )[] = [{ kind: 'mkdir', path: destination }]; + for (const file of packet.contents) { + const destinationFile = join(destination, file.path); + try { + const existing = await lstat(destinationFile); + if (!existing.isFile() || existing.isSymbolicLink()) { + throw new Error(`public packet destination ${file.path} is not a regular file`); + } + if ((await readFile(destinationFile, 'utf8')) !== file.value) { + throw new Error(`public packet destination ${file.path} differs from the pinned run packet`); + } + continue; + } catch (error) { + if ((error as { readonly code?: unknown }).code !== 'ENOENT') throw error; + } + await writeFile(destinationFile, file.value, { encoding: 'utf8', flag: 'wx' }); + effects.push({ kind: 'write_file', path: destinationFile, ifExists: 'overwrite' }); + } + return effects; +} + +export function validatePublicPacketMaterialization(value: unknown): PublicPacketMaterialization | undefined { + if (!isRecord(value) || !Array.isArray(value['contents'])) return undefined; + const reference = validatePublicPacketReference(value['reference']); + if (!reference) return undefined; + const files = reference.files; + const contents = value['contents']; + if ( + contents.length !== 3 || + !contents.every( + (file) => + isRecord(file) && + (file['path'] === 'packet-manifest.json' || + file['path'] === 'public-contract.json' || + file['path'] === 'spec.md') && + typeof file['value'] === 'string', + ) || + new Set(contents.map((file) => (file as { path: string }).path)).size !== 3 + ) { + return undefined; + } + const contentByPath = new Map( + contents.map((file) => [(file as { path: string }).path, (file as { value: string }).value]), + ); + for (const file of files as { path: 'public-contract.json' | 'spec.md'; sha256: string }[]) { + const contentsForFile = contentByPath.get(file.path); + if (contentsForFile === undefined || sha256(contentsForFile) !== file.sha256) return undefined; + } + const packetSha256 = sha256( + (files as { path: string; sha256: string }[]).map((file) => `${file.path}:${file.sha256}\n`).join(''), + ); + if (packetSha256 !== reference.packetSha256) return undefined; + let manifest: unknown; + try { + manifest = JSON.parse(contentByPath.get('packet-manifest.json') ?? ''); + } catch { + return undefined; + } + if ( + !isRecord(manifest) || + new Set(Object.keys(manifest)).size !== 3 || + !['files', 'packetSha256', 'schemaVersion'].every((key) => Object.hasOwn(manifest, key)) || + manifest['schemaVersion'] !== 1 || + manifest['packetSha256'] !== packetSha256 || + !Array.isArray(manifest['files']) || + manifest['files'].length !== files.length || + !manifest['files'].every( + (file, index) => + isRecord(file) && file['path'] === files[index]!.path && file['sha256'] === files[index]!.sha256, + ) + ) { + return undefined; + } + return value as unknown as PublicPacketMaterialization; +} + +export function validatePublicPacketReference(value: unknown): PublicPacketReference | undefined { + if ( + !isRecord(value) || + value['path'] !== '.brunch/execution-comparison/public' || + !isSha256(value['packetSha256']) || + !Array.isArray(value['files']) || + value['files'].length !== 2 + ) { + return undefined; + } + const files = value['files']; + if ( + !files.every( + (file) => + isRecord(file) && + (file['path'] === 'public-contract.json' || file['path'] === 'spec.md') && + isSha256(file['sha256']), + ) || + new Set(files.map((file) => (file as { path: string }).path)).size !== 2 + ) { + return undefined; + } + const packetSha256 = sha256( + (files as { path: string; sha256: string }[]).map((file) => `${file.path}:${file.sha256}\n`).join(''), + ); + return packetSha256 === value['packetSha256'] ? (value as unknown as PublicPacketReference) : undefined; +} + +async function resolvePacketSource( + cwd: string, +): Promise< + | { readonly status: 'absent' } + | { readonly status: 'present'; readonly sourceDir: string } + | { readonly status: 'invalid'; readonly message: string } +> { + let current = cwd; + for (const segment of ['.brunch', 'execution-comparison', 'public']) { + current = join(current, segment); + try { + const source = await lstat(current); + if (!source.isDirectory() || source.isSymbolicLink()) { + return { status: 'invalid', message: 'Target-visible public packet directory is invalid.' }; + } + } catch (error) { + return (error as { readonly code?: unknown }).code === 'ENOENT' + ? { status: 'absent' } + : { status: 'invalid', message: 'Target-visible public packet directory is unreadable.' }; + } + } + return { status: 'present', sourceDir: current }; +} + +async function ensurePacketDirectory(sliceWorktreeDir: string): Promise { + const root = await lstat(sliceWorktreeDir); + if (!root.isDirectory() || root.isSymbolicLink()) { + throw new Error('slice worktree root is not a regular directory'); + } + let current = sliceWorktreeDir; + for (const segment of ['.brunch', 'execution-comparison', 'public']) { + current = join(current, segment); + try { + const existing = await lstat(current); + if (!existing.isDirectory() || existing.isSymbolicLink()) { + throw new Error(`public packet destination ${segment} is not a regular directory`); + } + } catch (error) { + if ((error as { readonly code?: unknown }).code !== 'ENOENT') throw error; + await mkdir(current); + } + } + return current; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isSha256(value: unknown): value is string { + return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/u.test(value); +} + +function sha256(value: string): string { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} diff --git a/src/executor/isolated-slice-operations.ts b/src/executor/isolated-slice-operations.ts index 4e7402e74..dfa54b1be 100644 --- a/src/executor/isolated-slice-operations.ts +++ b/src/executor/isolated-slice-operations.ts @@ -11,6 +11,11 @@ import type { TestRunResult, VerifyTarget, } from './execution-ports.js'; +import { + materializePublicPacket, + type PublicPacketMaterialization, + type PublicPacketReference, +} from './execution-public-packet.js'; import type { ReadyStep } from './orchestrate-topology.js'; import { populatedPlanPath } from './populate.js'; import { @@ -111,10 +116,17 @@ export function sliceStartReport(args: { } export interface SliceRequestContext { + readonly scopeHandoffRequired: boolean; readonly scopeId?: string; readonly definition?: string; readonly criteria?: readonly { readonly kind: string; readonly target: string }[]; readonly derivedFrom?: readonly string[]; + readonly requirements?: readonly { + readonly itemId: string; + readonly title: string; + readonly content: string; + }[]; + readonly publicPacket?: PublicPacketReference; readonly designContext?: readonly { readonly itemId: string; readonly content: string }[]; readonly verificationContext?: readonly { readonly itemId: string; readonly content: string }[]; readonly instruction?: string; @@ -125,6 +137,7 @@ interface PlanSliceRequestShape { readonly definition?: string; readonly verification?: readonly { readonly kind?: string; readonly target?: string }[]; readonly derived_from?: readonly string[]; + readonly depends_on?: readonly string[]; readonly design_context?: readonly { readonly item_id?: string; readonly content?: string }[]; readonly verification_context?: readonly { readonly item_id?: string; readonly content?: string }[]; } @@ -133,24 +146,51 @@ export async function readSliceRequestContext(args: { readonly cwd: string; readonly runId: string; readonly populatedPlanPath?: string; + readonly publicPacket?: PublicPacketMaterialization; readonly sliceId: string; }): Promise< - | { readonly status: 'ok'; readonly requestContext: SliceRequestContext } + | { + readonly status: 'ok'; + readonly requestContext: SliceRequestContext; + readonly publicPacket?: PublicPacketMaterialization; + } | { readonly status: 'invalid'; readonly message: string } > { const planPath = args.populatedPlanPath ?? populatedPlanPath(args.cwd, args.runId); - let payload: { - readonly scope_handoff_required?: boolean; - readonly slices?: readonly ({ readonly id?: string } & PlanSliceRequestShape)[]; - }; + let parsed: unknown; try { - payload = JSON.parse(await readFile(planPath, 'utf8')) as typeof payload; + parsed = JSON.parse(await readFile(planPath, 'utf8')); } catch (error) { return { status: 'invalid', message: `Could not read populated plan for ${args.sliceId}: ${error instanceof Error ? error.message : String(error)}`, }; } + if (!isRecord(parsed)) { + return { status: 'invalid', message: 'Populated plan is not an object.' }; + } + const payload = parsed as { + readonly scope_handoff_required?: boolean; + readonly spec?: { + readonly requirements?: readonly { + readonly item_id?: string; + readonly title?: string; + readonly content?: string; + }[]; + }; + readonly slices?: readonly ({ readonly id?: string } & PlanSliceRequestShape)[]; + }; + if ( + (payload.spec !== undefined && !isRecord(payload.spec)) || + (payload.spec?.requirements !== undefined && + (!Array.isArray(payload.spec.requirements) || !payload.spec.requirements.every(isRecord))) || + (payload.slices !== undefined && (!Array.isArray(payload.slices) || !payload.slices.every(isRecord))) + ) { + return { status: 'invalid', message: 'Populated plan has malformed requirement or slice arrays.' }; + } + if (typeof payload.scope_handoff_required !== 'boolean') { + return { status: 'invalid', message: 'Populated plan has an invalid scope_handoff_required marker.' }; + } const slice = payload.slices?.find((candidate) => candidate.id === args.sliceId); if (!slice) { return { status: 'invalid', message: `Populated plan does not contain active slice ${args.sliceId}.` }; @@ -177,7 +217,18 @@ export async function readSliceRequestContext(args: { : [], ) : []; - if (payload.scope_handoff_required === true || typeof slice.scope_id === 'string') { + const carriesScopeContext = + slice.scope_id !== undefined || + slice.design_context !== undefined || + slice.verification_context !== undefined; + if (carriesScopeContext && payload.scope_handoff_required !== true) { + return { + status: 'invalid', + message: `Scope slice ${args.sliceId} disagrees with scope_handoff_required.`, + }; + } + const scoped = payload.scope_handoff_required === true; + if (scoped) { const missing = [ ...(!isNonBlank(slice.scope_id) ? ['scope_id'] : []), ...(!isNonBlank(slice.definition) ? ['definition'] : []), @@ -193,26 +244,125 @@ export async function readSliceRequestContext(args: { }; } } + const requirementIds = collectWorkerRequirementIds(slice, payload.slices ?? []); + if (requirementIds.status === 'invalid') { + return { status: 'invalid', message: `Scope slice ${args.sliceId} ${requirementIds.message}` }; + } + const requirementEntries = payload.spec?.requirements ?? []; + const requirementEntryIds = requirementEntries.flatMap((requirement) => + isNonBlank(requirement.item_id) ? [requirement.item_id] : [], + ); + if (new Set(requirementEntryIds).size !== requirementEntryIds.length) { + return { status: 'invalid', message: 'Populated plan contains duplicate requirement ids.' }; + } + const requirementsById = new Map( + requirementEntries.flatMap((requirement) => + isNonBlank(requirement.item_id) && isNonBlank(requirement.title) && isNonBlank(requirement.content) + ? [ + [ + requirement.item_id, + { + itemId: requirement.item_id, + title: requirement.title, + content: requirement.content, + }, + ] as const, + ] + : [], + ), + ); + const unresolved = requirementIds.ids.find((requirementId) => !requirementsById.has(requirementId)); + if (scoped && unresolved !== undefined) { + return { + status: 'invalid', + message: `Scope slice ${args.sliceId} cannot resolve exact requirement content for ${unresolved}.`, + }; + } + const requiredIds = new Set(requirementIds.ids); + const requirements = Array.from(requirementsById.values()).filter((requirement) => + requiredIds.has(requirement.itemId), + ); return { status: 'ok', requestContext: { + scopeHandoffRequired: scoped, ...(typeof slice.scope_id === 'string' ? { scopeId: slice.scope_id } : {}), ...(isNonBlank(slice.definition) ? { definition: slice.definition } : {}), ...(Array.isArray(slice.verification) ? { criteria } : {}), ...(Array.isArray(slice.derived_from) ? { derivedFrom } : {}), + ...(requirements.length > 0 ? { requirements } : {}), + ...(args.publicPacket ? { publicPacket: args.publicPacket.reference } : {}), ...(Array.isArray(slice.design_context) ? { designContext } : {}), ...(Array.isArray(slice.verification_context) ? { verificationContext } : {}), ...(criteria.length > 0 ? { instruction: 'Make the minimum change that satisfies every criterion.' } : {}), }, + ...(args.publicPacket ? { publicPacket: args.publicPacket } : {}), + }; +} + +function collectWorkerRequirementIds( + slice: { readonly id?: string } & PlanSliceRequestShape, + slices: readonly ({ readonly id?: string } & PlanSliceRequestShape)[], +): + | { readonly status: 'ok'; readonly ids: readonly string[] } + | { readonly status: 'invalid'; readonly message: string } { + const byId = new Map( + slices.flatMap((candidate) => (isNonBlank(candidate.id) ? [[candidate.id, candidate]] : [])), + ); + const sliceIds = slices.flatMap((candidate) => (isNonBlank(candidate.id) ? [candidate.id] : [])); + if (new Set(sliceIds).size !== sliceIds.length) { + return { status: 'invalid', message: 'contains duplicate slice ids.' }; + } + const ids = new Set(); + const visiting = new Set(); + const visited = new Set(); + + const visit = (candidate: { readonly id?: string } & PlanSliceRequestShape): string | undefined => { + if (!isNonBlank(candidate.id) || visited.has(candidate.id)) return undefined; + if (visiting.has(candidate.id)) return `has a cyclic slice dependency at ${candidate.id}.`; + if ( + (candidate.depends_on !== undefined && + (!Array.isArray(candidate.depends_on) || !candidate.depends_on.every(isNonBlank))) || + (candidate.derived_from !== undefined && + (!Array.isArray(candidate.derived_from) || !candidate.derived_from.every(isNonBlank))) + ) { + return `has malformed dependency or requirement references at ${candidate.id}.`; + } + if ( + new Set(candidate.depends_on ?? []).size !== (candidate.depends_on ?? []).length || + new Set(candidate.derived_from ?? []).size !== (candidate.derived_from ?? []).length + ) { + return `has duplicate dependency or requirement references at ${candidate.id}.`; + } + visiting.add(candidate.id); + for (const dependencyId of candidate.depends_on ?? []) { + const dependency = byId.get(dependencyId); + if (!dependency) return `depends on unknown slice ${dependencyId}.`; + const error = visit(dependency); + if (error) return error; + } + for (const requirementId of candidate.derived_from ?? []) { + if (isNonBlank(requirementId)) ids.add(requirementId); + } + visiting.delete(candidate.id); + visited.add(candidate.id); + return undefined; }; + + const error = visit(slice); + return error ? { status: 'invalid', message: error } : { status: 'ok', ids: Array.from(ids) }; } function isNonBlank(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + export async function prepareIsolatedSlice(args: { readonly runId: string; readonly sliceId: string; @@ -221,6 +371,7 @@ export async function prepareIsolatedSlice(args: { readonly sliceWorktreeDir: string; readonly requestPath: string; readonly requestContext?: SliceRequestContext; + readonly publicPacket?: PublicPacketMaterialization; readonly gitSliceIntegration: GitSliceIntegrationPort; readonly recordReport: SliceReportRecorder; }) { @@ -230,7 +381,14 @@ export async function prepareIsolatedSlice(args: { sliceId: args.sliceId, }); if (workspace.status === 'failed') return workspace; + let packetEffects: Awaited> = []; try { + if (args.publicPacket) { + packetEffects = await materializePublicPacket({ + packet: args.publicPacket, + sliceWorktreeDir: args.sliceWorktreeDir, + }); + } await mkdir(dirname(args.requestPath), { recursive: true }); await writeFile( args.requestPath, @@ -258,7 +416,7 @@ export async function prepareIsolatedSlice(args: { sliceId: args.sliceId, status: 'slice_execution_requested', }); - return workspace; + return { ...workspace, sideEffects: [...workspace.sideEffects, ...packetEffects] }; } export async function runIsolatedAgentAttempt(args: { diff --git a/src/executor/parallel-slice-batch/effects.ts b/src/executor/parallel-slice-batch/effects.ts index 507c2dd18..110d7f159 100644 --- a/src/executor/parallel-slice-batch/effects.ts +++ b/src/executor/parallel-slice-batch/effects.ts @@ -52,6 +52,7 @@ export async function executeIsolatedSlice(args: { cwd: ctx.cwd, runId: ctx.runId, ...(args.state.populatedPlanPath ? { populatedPlanPath: args.state.populatedPlanPath } : {}), + ...(args.state.publicPacket ? { publicPacket: args.state.publicPacket } : {}), sliceId: step.sliceId, }); if (sliceContext.status === 'invalid') { @@ -67,6 +68,7 @@ export async function executeIsolatedSlice(args: { sliceWorktreeDir: workspaceDir, requestPath: sliceExecutionRequestPath(ctx.cwd, ctx.runId, step.sliceId), requestContext: sliceContext.requestContext, + ...(sliceContext.publicPacket ? { publicPacket: sliceContext.publicPacket } : {}), gitSliceIntegration: ctx.ports.gitSliceIntegration, recordReport: async (event) => { await authority.fire(sliceTransitionId('slice_execute', step.sliceId)); diff --git a/src/executor/plan-preview.ts b/src/executor/plan-preview.ts index f476bda1c..7c5ffc240 100644 --- a/src/executor/plan-preview.ts +++ b/src/executor/plan-preview.ts @@ -9,6 +9,7 @@ export interface PlanPreviewVerificationTarget { export interface PlanPreviewSpecRequirement { readonly item_id: string; + readonly title?: string; readonly content: string; } @@ -136,6 +137,7 @@ function previewSpec(draft: ExecutablePlanDraft): PlanPreviewSpec { if (!requirements.has(requirement.itemId)) { requirements.set(requirement.itemId, { item_id: requirement.itemId, + ...('title' in requirement ? { title: requirement.title } : {}), content: requirement.content, }); } diff --git a/src/executor/run-supersession.ts b/src/executor/run-supersession.ts index 5b9d65b74..38798edf8 100644 --- a/src/executor/run-supersession.ts +++ b/src/executor/run-supersession.ts @@ -121,6 +121,7 @@ async function createSupersedingRunOwned(args: { supersedesRunId: args.previousRunId, ...(previous.substrate ? { substrate: previous.substrate } : {}), ...(previous.verifyTarget ? { verifyTarget: previous.verifyTarget } : {}), + ...(previous.publicPacket ? { publicPacket: previous.publicPacket } : {}), }; const created = await withRunExecutionAuthority({ diff --git a/src/executor/run.ts b/src/executor/run.ts index 87142377c..0e7f61d90 100644 --- a/src/executor/run.ts +++ b/src/executor/run.ts @@ -4,6 +4,11 @@ import { basename, dirname, join } from 'node:path'; import { BRUNCH_DIR } from '../constants.js'; import { durableAtomicReplace } from './durable-file.js'; import type { VerifyTarget } from './execution-ports.js'; +import { + readPublicPacket, + validatePublicPacketMaterialization, + type PublicPacketMaterialization, +} from './execution-public-packet.js'; import { prepareLaunch, type LaunchCurrentProjection, type LaunchResult } from './launch.js'; import { runExecutionActive, @@ -53,6 +58,7 @@ export interface RunMetadata { readonly verifyTarget?: VerifyTarget; readonly populatedPlanPath?: string; readonly populatedPlanProvenancePath?: string; + readonly publicPacket?: PublicPacketMaterialization; readonly sourcePolicy?: string; readonly sourcePolicyPath?: string; readonly sourceCopied?: boolean; @@ -143,6 +149,13 @@ export type RunCreateResult = readonly metadataPath: string; readonly sideEffects: readonly []; } + | { + readonly status: 'public_packet_invalid'; + readonly runStatus: 'not_started'; + readonly runId: string; + readonly message: string; + readonly sideEffects: readonly []; + } | { readonly status: 'created'; readonly runStatus: 'created'; @@ -196,6 +209,9 @@ export async function readRunMetadata(path: string): Promise appendFile(reportPath, `${JSON.stringify(event)}\n`, 'utf8'), }); diff --git a/src/probes/executor-agent-runner-witness.ts b/src/probes/executor-agent-runner-witness.ts index d1b8b6489..a995e0e6d 100644 --- a/src/probes/executor-agent-runner-witness.ts +++ b/src/probes/executor-agent-runner-witness.ts @@ -50,7 +50,15 @@ export async function runExecutorAgentRunnerWitness( const worktreeDir = await mkdtemp(join(tmpdir(), 'brunch-executor-agent-worker-')); const requestPath = join(worktreeDir, 'request.json'); const resultPath = join(worktreeDir, 'result.json'); - await writeFile(requestPath, JSON.stringify({ task: 'write worker-proof.txt' }), 'utf8'); + await writeFile( + requestPath, + JSON.stringify({ + action: 'execute_slice', + scopeHandoffRequired: false, + task: 'write worker-proof.txt', + }), + 'utf8', + ); const model = defaultBrunchFauxModel(); const provider = registerFauxProvider({ diff --git a/src/rpc/__tests__/web-host.test.ts b/src/rpc/__tests__/web-host.test.ts index 8b22ea6b7..cee8aef45 100644 --- a/src/rpc/__tests__/web-host.test.ts +++ b/src/rpc/__tests__/web-host.test.ts @@ -1190,6 +1190,7 @@ describe('web host', () => { planFilePath(cwd, '42'), JSON.stringify({ mode: 'greenfield', + scope_handoff_required: false, epics: [{ id: 'frontier-1', summary: 'Build feature', depends_on: [], verification: [] }], slices: [ { @@ -1273,6 +1274,7 @@ describe('web host', () => { planFilePath(cwd, '42'), JSON.stringify({ mode: 'greenfield', + scope_handoff_required: false, slices: [ { id: 'S2', definition: 'serial predecessor', depends_on: [], verification: [] }, ...['S3', 'S4', 'S5'].map((id) => ({ @@ -1464,6 +1466,7 @@ describe('web host', () => { planFilePath(cwd, '42'), JSON.stringify({ mode: 'greenfield', + scope_handoff_required: false, epics: [{ id: 'frontier-1', summary: 'Build feature', depends_on: [], verification: [] }], slices: [ { @@ -1547,6 +1550,7 @@ describe('web host', () => { planFilePath(cwd, '42'), JSON.stringify({ mode: 'greenfield', + scope_handoff_required: false, epics: [{ id: 'frontier-1', summary: 'Build feature', depends_on: [], verification: [] }], slices: [ { @@ -1634,6 +1638,7 @@ describe('web host', () => { planFilePath(cwd, '42'), JSON.stringify({ mode: 'greenfield', + scope_handoff_required: false, epics: [{ id: 'frontier-1', summary: 'Build feature', depends_on: [], verification: [] }], slices: [ { diff --git a/src/rpc/methods/__tests__/execute.test.ts b/src/rpc/methods/__tests__/execute.test.ts index e77f2785f..587f4e890 100644 --- a/src/rpc/methods/__tests__/execute.test.ts +++ b/src/rpc/methods/__tests__/execute.test.ts @@ -173,7 +173,7 @@ async function writePlan(cwd: string, specId = '42', graphLsn = 11): Promise { planPath, JSON.stringify({ mode: 'greenfield', + scope_handoff_required: false, epics: [{ id: 'epic-1', depends_on: [], verification: [] }], slices: [ { @@ -584,6 +585,7 @@ describe('execute.run', () => { planFilePath(cwd, '42'), JSON.stringify({ mode: 'greenfield', + scope_handoff_required: false, spec: { requirements: [ { item_id: 'REQ1', content: 'First parallel slice.' }, @@ -811,6 +813,7 @@ describe('execute.run', () => { planPath, `${JSON.stringify({ mode: 'greenfield', + scope_handoff_required: false, epics: [{ id: 'frontier-1' }, { id: 'frontier-2' }], slices: [ { id: 'task-1', epic_id: 'frontier-1', derived_from: ['REQ1'] }, @@ -851,7 +854,7 @@ describe('execute.run', () => { const planPath = join(cwd, 'plan.json'); await writeFile( planPath, - `${JSON.stringify({ mode: 'greenfield', slices: [{ id: 'task-1' }, { id: 'task-2' }] })}\n`, + `${JSON.stringify({ mode: 'greenfield', scope_handoff_required: false, slices: [{ id: 'task-1' }, { id: 'task-2' }] })}\n`, 'utf8', ); await writeRun(cwd, 'run-1', { planPath, status: 'slice_started', activeSliceId: 'task-1' }); @@ -1450,9 +1453,9 @@ describe('execute replanning methods', () => { spec: { spec_id: '42', requirements: [ - { item_id: 'REQ3', content: 'Build foundation' }, - { item_id: 'REQ1', content: 'Wire feature' }, - { item_id: 'REQ2', content: 'Ship keyboard shortcut' }, + { item_id: 'REQ3', title: 'Build foundation', content: 'Build foundation' }, + { item_id: 'REQ1', title: 'Wire feature', content: 'Wire feature' }, + { item_id: 'REQ2', title: 'Ship keyboard shortcut', content: 'Ship keyboard shortcut' }, ], criteria: [ { item_id: 'AC1', content: 'Feature is visible', verifies: ['REQ1'] }, From a16da65c8e6d7ff2350a7410967d3c798cd2c73c Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Fri, 24 Jul 2026 12:54:44 +0200 Subject: [PATCH 2/3] FE-1265: Update foreign-worktree fixture --- src/app/__tests__/git-slice-integration-port.slow.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/__tests__/git-slice-integration-port.slow.test.ts b/src/app/__tests__/git-slice-integration-port.slow.test.ts index 2f63e1593..d3490a262 100644 --- a/src/app/__tests__/git-slice-integration-port.slow.test.ts +++ b/src/app/__tests__/git-slice-integration-port.slow.test.ts @@ -155,6 +155,7 @@ describe('createGitSliceIntegrationPort', () => { planFilePath(cwd, '42'), JSON.stringify({ mode: 'greenfield', + scope_handoff_required: false, epics: [{ id: 'epic-1', depends_on: [], verification: [] }], slices: [{ id: 'task-1', epic_id: 'epic-1', definition: 'task', depends_on: [], verification: [] }], }), From 76d4f468283fce32e037ae9d263f19aeb8044e98 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Fri, 24 Jul 2026 13:05:23 +0200 Subject: [PATCH 3/3] FE-1265: Prefer user-facing PR test steps --- .agents/skills/linear-pr-cleanup/SKILL.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.agents/skills/linear-pr-cleanup/SKILL.md b/.agents/skills/linear-pr-cleanup/SKILL.md index f3e15519a..fc1835857 100644 --- a/.agents/skills/linear-pr-cleanup/SKILL.md +++ b/.agents/skills/linear-pr-cleanup/SKILL.md @@ -69,8 +69,10 @@ Explain the concrete change in clear domain terms. ## How to test -- `npm run verify` -- Add any manual checks or focused test commands used. +1. Open a workspace with a committed scope. +2. Start a build from that scope. +3. Confirm the proposed slices retain its design and verification context. +4. Edit the draft scope without committing it, then confirm another build still uses the committed version. ``` Rules: @@ -79,8 +81,11 @@ Rules: - Keep each section short. - Use accepted domain terminology consistently. - Do not paste the whole Linear description into the PR. -- Include actual verification commands if known. -- If testing was not run, say so directly. +- Write `How to test` as two to four numbered user steps with a recognizable starting state, clear actions, and observable outcomes. +- Prefer one complete happy path and one meaningful edge case over setup detail or implementation terminology. +- Do not repeat repository-wide commands already enforced by CI, such as `npm run verify`. +- If the change has no user-facing path, name the focused automated test and the behavior it proves instead. +- Do not invent testing. If user testing was not performed or the result is unknown, say so directly. - If the change involves architecture or a complex flow, add a small ASCII diagram under `What` when it improves clarity. - Prefer ASCII diagrams over Mermaid, images, or long prose. - Skip the diagram for straightforward changes.