// The warm `/objective-review-browser` door: the summonable streaming objective-draft review — // from an objective-authoring session the human summons a plannotator PLAN-REVIEW browser on // the RENDERED working objective draft (prose + roadmap table), a draft-reviewer wave streams // phrase-anchored findings into that browser via `push_annotations`, and the browser decision // routes through the shared objective seams: APPROVE → the decision ladder → the shared // objective completion (`completeObjectiveReviewV1`: `objectiveApprovalSave`, the D1a gate exit // riding the seam); DENY → a model-mediated `objective_draft` revision round. Plannotator // always, no provider dispatch (the surface-named command IS the selection — the // `/plan-review-browser` precedent); only the plannotator PRESENCE probe gates it. // // THE OBJECTIVE DIFFERENCE — Direct Edits NEVER auto-apply/auto-save (structural): the browser // patches the RENDERED markdown, but the save seam re-reads the STRUCTURED `{prose, roadmap}` // artifact — rendered-markdown edits (roadmap-table rows included) cannot be mechanically // folded back, so an approval whose feedback opens a Direct Edits section saves NOTHING and // returns one model-mediated revise round (fold the diff in via `objective_draft`, re-review // to confirm). The plan arm's mechanical Direct-Edits apply writes `plan-draft.md` and must never // run on this path. // // THE GUARDS (`draftReview.ts`, contracts.md §8.23): the door opens the activation's // current-review slot at entry with the RAW artifact bytes as the reviewed bytes (the // save-authoritative surface — it catches render-invisible changes like `base` or a node // `slug`/`pr`/`comment`) 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). // // ARTIFACT-FIRST, DRAFTS ONLY: the reviewed bytes are the RENDERED validated // `objective-draft.json` artifact (one seam read + `decodeObjectiveDraft` + `renderObjectiveDraft`) — no param // tier, no transcript tier, never raw JSON (the review-surface law; JSON is storage only). // Stage-gated to the two registry stages whose STAGE_TOOLS carry `objective_draft` // ({objective-author, objective-save}); anything else refuses loudly. // // THE BACKGROUND OPEN mirrors `planReviewBrowser.ts` byte-for-byte in shape: the plan server's // URL is deterministic the moment the port is picked, so the handler starts // `startPlannotatorPlanReview` (the `plan-review` bridge carries the rendered objective as // `planContent` — arbitrary markdown bytes), primes BOTH companion surfaces // (`mode: "plan"` annotations + the `draftType: "objective"` wave context), injects the // guidance IMMEDIATELY, and ends its turn. Readiness 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 and routes the outcome. The door registers no tools of its own — // the companions (`start_draft_review_wave`/`collect_draft_review_wave`/`push_annotations`) // are global. // // Accepted edges (the /pr-review-browser posture — noted, not engineered around): // - concurrent double-open: a second open 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 { decodeObjectiveDraft, OBJECTIVE_DRAFT_ARTIFACT, renderObjectiveDraft, } from "../../authoring/objective/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 { completeObjectiveReviewV1, objectiveEffectOf, objectiveGuardResult, } from "./objectiveReview.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 = "objective-review-browser"; /** * The stage gate: the two registry stages whose STAGE_TOOLS carry `objective_draft` — every * session where the objective draft is the working draft. Other/absent stage → loud refusal. */ const DRAFT_STAGES: ReadonlySet = new Set(["objective-author", "objective-save"]); // ------------------------------------------------------------------------ guidance /** * The seed guidance the door injects (the perk-objective-review-browser skill pointer rides * the skill-binding suffix — command:objective-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 objectiveReviewBrowserGuidance(opts: { custom?: string }): string { return render("stages/objective-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` (the in-session * review door) or `/objective-save` (the manual failsafe). */ 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 `/objective-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. A local twin of * the plan door's token on purpose — importing `PlanReviewDoorSession` here would mislead. */ export interface ObjectiveReviewDoorSession { degraded: boolean; readonly current: () => boolean; } /** * Observe the readiness poll in the background (the objective flavor of * `observePlanReviewReadiness`; 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 (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 observeObjectiveReviewReadiness( pi: RespondSink, ctx: ReportTarget & Pick, started: StartedSurface, draftReview: DraftReviewWaveState, annotations: AnnotationState, session: ObjectiveReviewDoorSession, ): 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 objectiveRevisionResult(out: Extract): ToolResult { const feedback = out.feedback ? `\n\nReviewer feedback:\n${untrustedReviewFeedback(out.feedback)}` : ""; return { content: [ { type: "text", text: "The human DENIED the objective in the browser review — revise the working draft with " + "objective_draft per this feedback; the human re-runs /objective-review-browser (or you " + `call plan_review) for the next round.${feedback}`, }, ], details: { ok: true, status: "denied", subject: "objective" }, }; } /** * 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`; the effect is `save` only for * an APPROVE WITHOUT a Direct-Edits heading — rendered-markdown edits cannot be folded back * into the structured `{prose, roadmap}` artifact mechanically, so that approval is a * no-save revise round, `revision`): `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` → the shared objective completion (`completeObjectiveReviewV1`: an approval * re-reads the STRUCTURED artifact → `saveObjective` → D1a gate exit → the latch record; * Direct Edits → the revise-round text) with its text reported (info on saved / revise, error * on save-failed) AND injected; on DENY the model-mediated revision round (the feedback * delimited as untrusted DATA) — each 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 routeObjectiveReviewDecision( 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 effect = objectiveEffectOf(out); const check = checkDraftReviewDecision(slot, ctx, review, effect); if (check.kind === "superseded") { report(ctx, SCOPE, "warning", SUPERSEDED_DECISION_WARNING); return; } if (check.kind !== "proceed") { const result = objectiveGuardResult(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 objective arm) — Direct // Edits is its no-save revise round; a plain approval re-reads the STRUCTURED artifact. const result = await completeObjectiveReviewV1(pi, ctx, gating, slot, out); if (effect === "revision") { report( ctx, SCOPE, "info", "objective APPROVED with direct browser edits — routed as a revise round (never " + "auto-saved)", ); } else if (result.details.ok === true && result.details.saved === true) { report(ctx, SCOPE, "info", "objective APPROVED in the browser — saved"); } else if (result.details.status === "refused-draft") { // Defensively unreachable behind the reviewed-bytes guard (identical raw bytes decode // deterministically), but rendered truthfully: rewrite + a FRESH review — never the // /objective-save failsafe (the composed text carries the same guidance). report( ctx, SCOPE, "error", "objective APPROVED in the browser but the working draft was invalid at save time — " + "NOTHING was saved; rewrite it with objective_draft and request a fresh review", { alsoLog: true }, ); } else { // save-failed (+ the defensively-unreachable no-draft arm): loud, gate left on; the // composed text names the check-the-backend step and the /objective-save deliberate retry. report( ctx, SCOPE, "error", "objective 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 /objective-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", "objective DENIED in the browser — feedback routed for a revision round", ); injectDraftReviewResult( pi, ctx, withDraftChangedNote(objectiveRevisionResult(out), check.draftChanged), ); } /** * The guidance-returning open core: start the plan-review browser on the RENDERED objective * draft, 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:objective-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). `rendered` is the reviewed markdown; `artifactRaw` is * the raw structured `objective-draft.json` bytes captured at open — the reviewed bytes the * slot digests and the ladder compares. The slot opens FIRST: no browser launches for a review * that could never be routed (a refusal is reported and returns `null`). */ export async function openObjectiveReviewSurface( pi: ExtensionAPI, ctx: ExtensionContext, gating: ToolGating, opts: { rendered: string; artifactRaw: string; custom?: string }, draftReview: DraftReviewWaveState, annotations: AnnotationState, slot: DraftReviewSlot, status: ActivityHandle, deps: StartBrowserDeps = {}, ): Promise { const opened = slot.open(ctx, { subject: "objective", source: "artifact", raw: opts.artifactRaw, markdown: opts.rendered, }); 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 { // The plan-review bridge sends arbitrary string bytes as `planContent` — the rendered // objective rides it unchanged (no plan-specific validation). started = await startPlannotatorPlanReview( pi.events, { plan: opts.rendered, 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 (phrase-anchored — the rendered-objective findings reuse it // as-is), and the draft-review wave reviews exactly the browsed bytes (reviewed bytes == // browsed bytes == wave bytes — all the RENDERED markdown). 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: "objective", draft: opts.rendered, ...(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: ObjectiveReviewDoorSession = { degraded: false, current: review.isCurrent }; void observeObjectiveReviewReadiness(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 /objective-review-browser to review the current draft", ); } return; } await routeObjectiveReviewDecision(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 objective draft → plannotator browser review + draft reviewers (custom lane: ${opts.custom}) → APPROVE auto-saves / DENY returns feedback` : "working objective draft → plannotator browser review + draft reviewers → APPROVE auto-saves / DENY returns feedback", ); return ( objectiveReviewBrowserGuidance({ ...(opts.custom !== undefined ? { custom: opts.custom } : {}), }) + bindingSuffix(ctx.cwd, `command:${SCOPE}`) ); } /** * The door-facing open: the thin `sendUserMessage` wrapper over `openObjectiveReviewSurface` — * 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 openObjectiveReviewAndGuide( pi: ExtensionAPI, ctx: ExtensionContext, gating: ToolGating, opts: { rendered: string; artifactRaw: string; custom?: string }, draftReview: DraftReviewWaveState, annotations: AnnotationState, slot: DraftReviewSlot, status: ActivityHandle, deps: StartBrowserDeps = {}, ): Promise { const guidance = await openObjectiveReviewSurface( pi, ctx, gating, opts, draftReview, annotations, slot, status, deps, ); if (guidance !== null) pi.sendUserMessage(guidance); } // ------------------------------------------------------------------------ registration /** Register the warm `/objective-review-browser` command (no tools — the companions are global). */ export function registerObjectiveReviewBrowser( pi: ExtensionAPI, gating: ToolGating, draftReview: DraftReviewWaveState, annotations: AnnotationState, slot: DraftReviewSlot, status: ActivityHandle, ): void { registerPerkCommand(pi, SCOPE, { description: "Review the working objective draft (prose + roadmap) human-in-the-loop in the " + "plannotator browser UI: draft reviewers stream findings into the browser; APPROVE " + "auto-saves the objective, 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", "/objective-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", "/objective-review-browser only runs inside an objective-authoring session (stage " + "objective-author or objective-save) — the door reviews the working objective 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). ONE // seam-validated read (digest-checked): its bytes are BOTH the decode input and the // reviewed bytes the slot digests (the save-authoritative surface), so there is no // check-to-open window between what was validated and what the approval compares against. const artifact = openBranchWorkflowSession(pi, ctx).readArtifact(OBJECTIVE_DRAFT_ARTIFACT); if (artifact.status === "invalid") { // Seam-level corruption (pointer-without-file, digest mismatch) is NOT absence — the // classified problem + rewrite guidance surface here, mirroring the decode arm below. report( ctx, SCOPE, "error", `the working objective draft is invalid: ${artifact.problem} — rewrite it with ` + "objective_draft, then re-run /objective-review-browser", ); return; } if (artifact.status !== "found" || artifact.content.trim().length === 0) { report( ctx, SCOPE, "error", "no working objective draft — write it with objective_draft (prose + the structured " + "roadmap), then re-run /objective-review-browser", ); return; } // Decode the SAME bytes (schema-checked), classified: `refused` carries the problem. const resumed = decodeObjectiveDraft(artifact.content); if (resumed.kind === "refused") { report( ctx, SCOPE, "error", `the working objective draft is invalid: ${resumed.problem} — rewrite it with ` + "objective_draft, then re-run /objective-review-browser", ); return; } const rendered = renderObjectiveDraft(resumed.draft); // 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 openObjectiveReviewAndGuide( pi, ctx, gating, { rendered, artifactRaw: artifact.content, ...(custom.length > 0 ? { custom } : {}), }, draftReview, annotations, slot, status, ); }, }); }