// The warm `/plan-review-browser` door: the summonable streaming draft review — from a // plan-authoring session the human summons a plannotator PLAN-REVIEW browser on the working // plan draft, a draft-reviewer wave streams phrase-anchored findings into that browser via // `push_annotations`, and the browser decision routes through the shared seams: APPROVE → the // decision ladder → the shared plan completion (`completePlanReviewV1`: the Direct-Edits // mechanical apply, `planApprovalSave`); DENY → a model-mediated `plan_draft` revision round. // Plannotator always, no provider dispatch (the surface-named command IS the selection — the // `/pr-review-browser` precedent); only the plannotator PRESENCE probe gates it. // // ARTIFACT-FIRST, DRAFTS ONLY: the reviewed bytes are the validated `plan-draft.md` artifact — // no param tier, no transcript tier (the review-surface law, tightened to drafts-only: an // approval auto-saves the reviewed bytes). Stage-gated to the three registry stages whose // STAGE_TOOLS carry `plan_draft` ({plan, save, objective-plan} — every session where the plan // draft is the working draft); anything else refuses loudly. // // THE GUARDS (`draftReview.ts`, contracts.md §8.23): the door opens the activation's // current-review slot at entry (superseding any other open review — blocking or browser) and // runs every completed browser decision through the decision ladder: a superseded review's // decision is ignored loudly; an APPROVE over a moved draft, a moved save destination, or a // latched unconfirmed save saves nothing and tells the model why; a DENY over a moved draft // proceeds with a one-line note. Nothing is persisted — a browser decision does not survive a // Pi restart (the human re-runs the door). // // THE BACKGROUND OPEN: the plan server's URL is deterministic the moment the port is picked // (the preset-PLANNOTATOR_PORT mechanism — see plannotatorHandoff.ts), so the handler starts // `startPlannotatorPlanReview`, primes BOTH companion surfaces (the `push_annotations` plan-mode // annotation surface + the draft-review wave context), injects the guidance IMMEDIATELY, and // ends its turn. The readiness poll is observed in a background task (ready → info; never-ready // → a loud degrade clearing both surfaces); the human DECISION is awaited in a second background // task (open-ended — exactly the model-called `plan_review` bridge semantics; a turn abort // settles `aborted` via the existing bridge abort handling) and routes the outcome. // // THE COMPANION TOOLS: the reviewer fan-out is the globally registered // `start_draft_review_wave` / `collect_draft_review_wave` pair (door-primed inputs — the model // picks only the angles), and the annotation delivery is the globally registered // `push_annotations` PRIMED BY THIS DOOR in plan mode. The door registers no tools of its own. // // Accepted edges (the /pr-review-browser posture — noted, not engineered around): // - concurrent double-open: a second /plan-review-browser re-primes both surfaces and takes the // slot (a new review supersedes everything); the FIRST bridge's later decision is ignored // loudly by the ladder, its `finally` leaves the second session's surfaces alone, and its // readiness observer is fenced too (a superseded review's observer neither announces nor // degrades — it can never clear the second session's surfaces or flip its door session). // - an early human decision mid-wave is authoritative — the save proceeds; the cleared surface // makes any late `push_annotations` refuse `no_surface`; a still-pending wave stays // collectable (the wave module's timeout is the orphan insurance). import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { PLAN_DRAFT_ARTIFACT } from "../../authoring/plan/draft.ts"; import { clearDraftReviewContext, type DraftReviewWaveState, primeDraftReviewContext, } from "../../authoring/review/draftContext.ts"; import { openBranchWorkflowSession } from "../../session/branchWorkflowSession.ts"; import { bindingSuffix } from "../../substrate/bindingDelivery.ts"; import { registerPerkCommand } from "../../substrate/command.ts"; import { interceptConsoleError } from "../../substrate/consoleCapture.ts"; import { render } from "../../substrate/prompts.ts"; import type { ToolGating } from "../../substrate/toolGating.ts"; import { branchOf, rebuildWorkflowState } from "../../substrate/workflowState.ts"; import { type ReportTarget, report } from "../../surfaces/report.ts"; import type { ActivityHandle } from "../../surfaces/surfaces.ts"; import { checkDraftReviewDecision, type DraftReviewSlot, injectDraftReviewResult, type OpenDraftReview, SUPERSEDED_DECISION_WARNING, withDraftChangedNote, } from "./draftReview.ts"; import { planSaveDepsFor } from "./plan.ts"; import { completePlanReviewV1, planGuardResult } from "./planReview.ts"; import { type AnnotationState, clearAnnotationSurface, primeAnnotationSurface, resumeAnnotationDelivery, } from "./providers/annotations.ts"; import { plannotatorPresent, type RespondSink, type StartBrowserDeps, type StartedSurface, startPlannotatorPlanReview, } from "./providers/plannotatorHandoff.ts"; import { type ReviewOutcome, type ToolResult, untrustedReviewFeedback } from "./review.ts"; /** The door's report scope — also the `command:` binding trigger id. */ const SCOPE = "plan-review-browser"; /** * The stage gate: the three registry stages whose STAGE_TOOLS carry `plan_draft` — every * session where the plan draft is the working draft. Other/absent stage → loud refusal. */ const DRAFT_STAGES: ReadonlySet = new Set(["plan", "save", "objective-plan"]); // ------------------------------------------------------------------------ guidance /** * The seed guidance the door injects (the perk-plan-review-browser skill pointer rides the * skill-binding suffix — command:plan-review-browser — not hardcoded here). Pure + exported for * offline tests. One arm (no foreign/active split): `custom` renders the primed custom-lane * note when the human supplied a custom-angle definition. */ export function planReviewBrowserGuidance(opts: { custom?: string }): string { return render("stages/plan-review-browser.md", { custom: opts.custom ?? "" }); } // ------------------------------------------------------------------------ the background open /** * The degrade notice injected when the browser never comes up — the model surfaces the wave's * findings in-session for the human, and the human falls back to `plan_review`/`/plan-save`. */ const DEGRADE_NOTICE = "The plannotator plan-review browser is unavailable (the review server never became ready) — " + "degrade in-session: surface the draft-review wave's findings in your reply for the human. " + "Both door surfaces are cleared — `push_annotations` now refuses (`no_surface`) and the " + "draft-review context is gone. The human decides the next step: `plan_review` (the in-session " + "review door) or `/plan-save` (the manual failsafe)."; /** * One door open's shared token carrying its liveness AND its currency. Liveness: the degrade * arm flips `degraded` and the decision task refuses to route a later bridge decision through * the save path — without it, a readiness false-negative (endpoint/version drift while the * browser is actually open) could let a post-degrade approval auto-save and exit the gate AFTER * the human already followed the fallback path. Currency: `current` is the review's * `isCurrent` — the observer consults it before announcing readiness and before degrading, so a * review superseded by a newer open never disturbs the newer one's surfaces. */ export interface PlanReviewDoorSession { degraded: boolean; readonly current: () => boolean; } /** * Observe the readiness poll in the background (the plan flavor of `observeBrowserReadiness`; * the handler has already injected the guidance and ended): `ready` → an info note plus one * annotation-delivery continuation if the still-current surface has held or in-flight work; * `aborted` → no-op; `bridge_settled` → await the bridge — a completed/aborted outcome * returns silently (the decision task routes them) while `unavailable` falls through to the * degrade; `timeout` → degrade. Degrade = a loud error report PLUS the degrade notice injected * to the model (idle → immediate, streaming → followUp), both door surfaces cleared (the * annotation surface + the threaded `draftReview` state's context — idempotent beside the * decision task's clears), AND the door session marked `degraded` so the still-live decision * task ignores any later bridge decision (loudly — never a silent late save). A superseded * review's observer is inert — it neither announces readiness nor degrades, so a still-starting * review A can never clear the surfaces or flip the session of the review B that replaced it * (`session.current` is consulted after every await; `session` is required so a forgotten * token can never silently unfence). Structural param slices keep it offline-testable; * exported for the door tests. */ export async function observePlanReviewReadiness( pi: RespondSink, ctx: ReportTarget & Pick, started: StartedSurface, draftReview: DraftReviewWaveState, annotations: AnnotationState, session: PlanReviewDoorSession, ): Promise { const surface = annotations.surface; const state = await started.readiness; // A superseded review's observer is inert: no announce, no delivery resume, no degrade. if (!session.current()) return; if (state === "ready") { report(ctx, SCOPE, "info", `plannotator is up at ${started.url} — browser opening`); resumeAnnotationDelivery(annotations, surface, pi, ctx); return; } if (state === "aborted") return; // the turn was interrupted — no-op if (state === "bridge_settled") { const out = await started.bridgePromise; // The decision task routes a settled outcome; the bridge wait can outlast a superseding // open, so re-check currency before degrading. if (out.status !== "unavailable" || !session.current()) return; } report( ctx, SCOPE, "error", `the plannotator plan-review server did not become ready at ${started.url} — the browser ` + "review is unavailable", { alsoLog: true }, ); if (ctx.isIdle()) { pi.sendUserMessage(DEGRADE_NOTICE); } else { pi.sendUserMessage(DEGRADE_NOTICE, { deliverAs: "followUp" }); } // Consistent with "surface findings in-session": a post-degrade push_annotations refuses // loudly (`no_surface`) and a post-degrade start_draft_review_wave refuses // `no_draft_context`. Idempotent beside the decision task's clears. The session flag makes // the degrade authoritative for the decision task too — a later bridge decision is ignored. clearAnnotationSurface(annotations); clearDraftReviewContext(draftReview); session.degraded = true; } /** The model-facing DENY revision result (the feedback delimited as untrusted DATA). */ function planRevisionResult(out: Extract): ToolResult { const feedback = out.feedback ? `\n\nReviewer feedback:\n${untrustedReviewFeedback(out.feedback)}` : ""; return { content: [ { type: "text", text: "The human DENIED the plan in the browser review — revise the working draft with " + "plan_draft per this feedback; the human re-runs /plan-review-browser (or you call " + `plan_review) for the next round.${feedback}`, }, ], details: { ok: true, status: "denied", subject: "plan" }, }; } /** * Route the settled browser decision back into the session (the decision task's core; exported * for the door tests — pure over the injected pi/ctx/gating/slot slices): * * - `aborted` → no-op (the turn was interrupted); * - `unavailable` → a loud error report (the readiness observer's degrade arm owns the model * notice — never inject it twice); * - `completed` → the decision ladder (`checkDraftReviewDecision`, effect `save` on APPROVE, * `revision` on DENY): `superseded` → one TUI warning, nothing injected, nothing saved; * `save-unconfirmed` / `stale-approval` / `destination-changed` → an error report AND the * matching fixed model text injected (nothing saved, gate untouched); `proceed` on APPROVE → * the shared plan completion (Direct-Edits mechanical apply → `planApprovalSave` → the latch * record) with its text reported (info on saved, error on save-failed) AND injected; `proceed` * on DENY → the model-mediated revision round (the feedback delimited as untrusted DATA), * prefixed with `DRAFT_CHANGED_NOTE` when the draft moved; * - `dismissed`/`implement-here` → defensively unreachable (the plannotator bridge never * produces them) — no-op. */ export async function routePlanReviewDecision( pi: ExtensionAPI, ctx: ExtensionContext, gating: ToolGating, slot: DraftReviewSlot, out: ReviewOutcome, review: OpenDraftReview, ): Promise { if (out.status === "unavailable") { report(ctx, SCOPE, "error", out.warning, { alsoLog: true }); return; } if (out.status !== "completed") return; // aborted (+ the bridge-unreachable arms) — no-op const check = checkDraftReviewDecision(slot, ctx, review, out.approved ? "save" : "revision"); if (check.kind === "superseded") { report(ctx, SCOPE, "warning", SUPERSEDED_DECISION_WARNING); return; } if (check.kind !== "proceed") { const result = planGuardResult(check, review, out.feedback); report(ctx, SCOPE, "error", result.content[0]?.text.split("\n")[0] ?? check.kind, { alsoLog: true, }); injectDraftReviewResult(pi, ctx, result); return; } if (out.approved) { // APPROVE: the shared completion (byte-identical to plan_review's plannotator arm) — the // claim carrier (`objective_node_claim`) recovery rides `planApprovalSave`→`savePlan`. const result = await completePlanReviewV1( ctx, slot, planSaveDepsFor(pi, ctx, gating), review.markdown, out, ); if (result.details.ok === true && result.details.saved === true) { report(ctx, SCOPE, "info", "plan APPROVED in the browser — saved"); } else { // save-failed (+ the defensively-unreachable no-plan arm): loud, gate left on; the // composed text names the check-the-backend step and the /plan-save deliberate retry. report( ctx, SCOPE, "error", "plan APPROVED in the browser but the auto-save did not confirm — the session stays " + "read-only and automatic saves are paused; check the backend for this run id, then " + "run /plan-save (the deliberate retry)", { alsoLog: true }, ); } injectDraftReviewResult(pi, ctx, withDraftChangedNote(result, check.draftChanged)); return; } // DENY: model-mediated revise round (contracts.md §8.23) — no auto re-open. The feedback is // passed through verbatim (Direct Edits diff included) but DELIMITED as untrusted DATA. report(ctx, SCOPE, "info", "plan DENIED in the browser — feedback routed for a revision round"); injectDraftReviewResult( pi, ctx, withDraftChangedNote(planRevisionResult(out), check.draftChanged), ); } /** * The guidance-returning open core: open the current-review slot (a refusal is reported and * returns `null` — nothing launched), start the plan-review browser, prime BOTH companion * surfaces the moment the port is picked (the URL is deterministic — see the header note), * observe readiness and the human decision in background tasks, and RETURN the composed * guidance string (template + the `command:plan-review-browser` binding suffix) — the caller * decides how to deliver it (the door wrapper injects it via `sendUserMessage`; `plan_review`'s * wave arm returns it as a non-terminating tool result, contracts.md §8.23). Returns `null` on * the synchronous port-pick failure arm — loudly reported here, then CALLER-handled: the door * wrapper simply injects nothing (the report already spoke), while `plan_review`'s wave arm — * the one fallback caller — falls open to the plain blocking review. While * plannotator sets up, its in-process `console.error` chatter re-routes through the TUI-safe * report() seam (the debounce restores once setup goes quiet, with the `finally` as a * backstop). `deps` is the injectable browser-open seam (tests drive a fake port * picker/probe/clock). */ export async function openPlanReviewSurface( pi: ExtensionAPI, ctx: ExtensionContext, gating: ToolGating, opts: { draft: string; custom?: string }, draftReview: DraftReviewWaveState, annotations: AnnotationState, slot: DraftReviewSlot, status: ActivityHandle, deps: StartBrowserDeps = {}, ): Promise { // The slot open FIRST: no browser launches for a review that could never be routed (no run // identity, the wrong subject, an unverifiable destination). const opened = slot.open(ctx, { subject: "plan", source: "artifact", raw: opts.draft, markdown: opts.draft, }); if (!opened.ok) { report(ctx, SCOPE, "error", `cannot open the browser review: ${opened.detail}`, { alsoLog: true, }); return null; } const review = opened.review; let started: StartedSurface; try { started = await startPlannotatorPlanReview( pi.events, { plan: opts.draft, signal: ctx.signal, activity: (text) => status.beginActivity(ctx, text), }, deps, ); } catch (error) { const detail = error instanceof Error ? error.message : String(error); report( ctx, SCOPE, "error", `could not pick a free local port for the plannotator plan-review server: ${detail}`, { alsoLog: true }, ); return null; } // Prime BOTH companion surfaces the moment the port is picked: push_annotations serves this // browser session in plan mode, and the draft-review wave reviews exactly the browsed bytes // (reviewed bytes == browsed bytes == wave bytes). Priming resets any pending wave — a new // browser session supersedes everything (the accepted double-open edge in the header). primeAnnotationSurface(annotations, { mode: "plan", url: started.url }); primeDraftReviewContext(draftReview, { draftType: "plan", draft: opts.draft, ...(opts.custom !== undefined ? { custom: opts.custom } : {}), }); // The shared door-session token: the observer's degrade arm flips `degraded` so the decision // task never routes a post-degrade decision through the save path (a readiness false-negative // must not let a late approval auto-save after the human followed the fallback), and // `current` fences the observer itself to this review (a superseding open must not be // degraded by this one's late timeout). const session: PlanReviewDoorSession = { degraded: false, current: review.isCurrent }; void observePlanReviewReadiness(pi, ctx, started, draftReview, annotations, session); // The decision task: the wait is open-ended (exactly the model-called `plan_review` bridge // semantics — a turn abort settles `aborted` via the bridge's abort handling). void (async () => { const interceptor = interceptConsoleError((line) => report(ctx, SCOPE, "info", line), { // plannotator can pause up to ~4s between setup lines — keep the quiet window above that. quietMs: 6000, }); try { const out = await started.bridgePromise; if (session.degraded) { // The review already degraded (surfaces cleared, the fallback announced) — a late // decision is ignored LOUDLY, never routed into a stale/duplicate save. if (out.status === "completed") { report( ctx, SCOPE, "warning", "a browser decision arrived after the review degraded — ignored (nothing saved); " + "re-run /plan-review-browser to review the current draft", ); } return; } await routePlanReviewDecision(pi, ctx, gating, slot, out, review); } finally { // The browser session is over — drop both surfaces so a late push refuses (`no_surface`) // and a late wave start refuses (`no_draft_context`), but ONLY while this review is still // the current one: a superseding open re-primed the surfaces for ITS session. Idempotent // beside the degrade-arm clears; an early decision mid-wave leaves a still-pending wave // collectable. if (review.isCurrent()) { clearAnnotationSurface(annotations); clearDraftReviewContext(draftReview); } interceptor.restore(); } })(); report( ctx, SCOPE, "info", opts.custom !== undefined ? `working plan draft → plannotator browser review + draft reviewers (custom lane: ${opts.custom}) → APPROVE auto-saves / DENY returns feedback` : "working plan draft → plannotator browser review + draft reviewers → APPROVE auto-saves / DENY returns feedback", ); return ( planReviewBrowserGuidance({ ...(opts.custom !== undefined ? { custom: opts.custom } : {}) }) + bindingSuffix(ctx.cwd, `command:${SCOPE}`) ); } /** * The door-facing open: the thin `sendUserMessage` wrapper over `openPlanReviewSurface` — the * command handler's delivery is the guidance injection; a `null` core return (slot refusal or * port-pick failure, already loudly reported) injects nothing. */ export async function openPlanReviewAndGuide( pi: ExtensionAPI, ctx: ExtensionContext, gating: ToolGating, opts: { draft: string; custom?: string }, draftReview: DraftReviewWaveState, annotations: AnnotationState, slot: DraftReviewSlot, status: ActivityHandle, deps: StartBrowserDeps = {}, ): Promise { const guidance = await openPlanReviewSurface( pi, ctx, gating, opts, draftReview, annotations, slot, status, deps, ); if (guidance !== null) pi.sendUserMessage(guidance); } // ------------------------------------------------------------------------ registration /** Register the warm `/plan-review-browser` command (no tools — the companions are global). */ export function registerPlanReviewBrowser( pi: ExtensionAPI, gating: ToolGating, draftReview: DraftReviewWaveState, annotations: AnnotationState, slot: DraftReviewSlot, status: ActivityHandle, ): void { registerPerkCommand(pi, SCOPE, { description: "Review the working plan draft human-in-the-loop in the plannotator browser UI: draft " + "reviewers stream findings into the browser; APPROVE auto-saves, DENY returns feedback " + "for revision. Any argument text defines an extra custom review angle.", handler: async (args, ctx: ExtensionContext) => { // Entry gates, in order — nothing executed on refusal, each a loud error. if (!ctx.hasUI) { report( ctx, SCOPE, "error", "/plan-review-browser requires an interactive session — the plannotator browser " + "surface and the human are constitutive", ); return; } if (!plannotatorPresent(pi)) { report( ctx, SCOPE, "error", "the plannotator extension is not loaded (its /plannotator-review command was not " + "found) — select the plannotator plan provider (`[providers] plan = " + '"plannotator-plan"`), run `perk init`, then restart pi', ); return; } const stage = rebuildWorkflowState(branchOf(ctx)).stage; if (stage === undefined || !DRAFT_STAGES.has(stage)) { report( ctx, SCOPE, "error", "/plan-review-browser only runs inside a plan-authoring session (stage plan, save, " + "or objective-plan) — the door reviews the working plan draft", ); return; } // The draft resolve, artifact ONLY: no param tier, no transcript tier (the review-surface // law tightened to drafts-only — an approval auto-saves the reviewed bytes). const artifact = openBranchWorkflowSession(pi, ctx).readArtifact(PLAN_DRAFT_ARTIFACT); if (artifact.status !== "found" || artifact.content.trim().length === 0) { report( ctx, SCOPE, "error", "no working plan draft — write it with plan_draft, then re-run /plan-review-browser", ); return; } // The entire trimmed arg string is the optional custom-angle definition (no parse-failure // arm — any text is a valid lens definition). const custom = (args ?? "").trim(); await openPlanReviewAndGuide( pi, ctx, gating, { draft: artifact.content, ...(custom.length > 0 ? { custom } : {}), }, draftReview, annotations, slot, status, ); }, }); }