// 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 diet carries `objective_draft` // ({objective-author, objective-save}); anything else refuses loudly. // // THE BROWSER OPERATION (`browserOperation.ts`, contracts.md §8.23) — the plan door's twin: the // door opens through the activation's browser-operation owner (the `plan-review` bridge carries // the rendered objective as `planContent` — arbitrary markdown bytes), which owns the start, the // priming of BOTH companion surfaces (`mode: "plan"` annotations + the `draftType: "objective"` // wave context), readiness and decision observation, console interception and cleanup; the door // injects its guidance immediately and supplies only its subject hooks (the degrade notice, // `review.isCurrent` as the applicability fence, the decision router). Opening the door again // REPLACES the earlier browser review (its tab is marked detached); a turn abort or a session // reload/replacement detaches it the same way; a blocking-review supersession leaves the tab open // but the ladder ignores its verdict loudly. An early human decision mid-wave is authoritative — // the save proceeds, a still-pending wave stays collectable. The door registers no tools of its // own — the companions (`start_draft_review_wave`/`collect_draft_review_wave`/`push_annotations`) // are global. import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { decodeObjectiveDraft, OBJECTIVE_DRAFT_ARTIFACT, renderObjectiveDraft, } from "../../authoring/objective/draft.ts"; import { clearDraftReviewContext, DRAFT_REVIEW_DOOR_PRIMES, 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 { render } from "../../substrate/prompts.ts"; import type { ToolGating } from "../../substrate/toolGating.ts"; import { branchOf, rebuildWorkflowState } from "../../substrate/workflowState.ts"; import { report } from "../../surfaces/report.ts"; import { type BrowserOperationOwner, type BrowserOperationSpec, settleReviewOutcome, } from "./browserOperation.ts"; import { checkDraftReviewDecision, type DraftReviewSlot, injectDraftReviewResult, type OpenDraftReview, SUPERSEDED_DECISION_WARNING, withDraftChangedNote, } from "./draftReview.ts"; import { completeObjectiveReviewV1, objectiveEffectOf, objectiveGuardResult, } from "./objectiveReview.ts"; import { plannotatorPresent, startPlannotatorPlanReview } from "./providers/plannotatorHandoff.ts"; import { WAVE_ARRIVAL_NOTICE } from "./providers/waveStatus.ts"; import { type DoorOpenResult, 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 diet carries `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 browser operation /** * 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)."; /** 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 a DECIDED browser verdict back into the session (the owner's `onDecision`; exported for * the door tests — pure over the injected pi/ctx/gating/slot slices). The bridge's * `unavailable`/`aborted` arms never reach here (the owner degrades/detaches them). 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. */ export async function routeObjectiveReviewDecision( pi: ExtensionAPI, ctx: ExtensionContext, gating: ToolGating, slot: DraftReviewSlot, out: Extract, review: OpenDraftReview, ): Promise { 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: open the current-review slot with the RAW artifact bytes as * the reviewed bytes (a refusal is reported and returns `null` — no browser launches for a * review that could never be routed), open the plan-review browser on the RENDERED objective * draft through the activation's browser-operation owner (which retires any earlier browser * review first, starts the handoff, primes BOTH companion surfaces and observes readiness + the * human decision in the background), 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) as `launched`. Nothing launched is * CALLER-handled: `unlaunched` (a slot refusal or the port-pick failure, already reported * loudly) → the door wrapper injects nothing, while `plan_review`'s wave arm falls open to the * plain blocking review; `retired` (a newer open took over before this one started) → nothing * injected and never a fallback. `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. */ export async function openObjectiveReviewSurface( pi: ExtensionAPI, ctx: ExtensionContext, gating: ToolGating, opts: { rendered: string; artifactRaw: string; custom?: string }, draftReview: DraftReviewWaveState, slot: DraftReviewSlot, owner: BrowserOperationOwner, ): 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 { kind: "unlaunched" }; } const review = opened.review; const spec: BrowserOperationSpec = { scope: SCOPE, // The plan-review bridge sends arbitrary string bytes as `planContent` — the rendered // objective rides it unchanged (no plan-specific validation). start: (launch) => startPlannotatorPlanReview( pi.events, { plan: opts.rendered, signal: launch.signal, activity: launch.activity }, launch.deps, ), // Phrase-anchored plan mode: the rendered-objective findings reuse it as-is. mode: "plan", primes: DRAFT_REVIEW_DOOR_PRIMES, // The wave reviews exactly the browsed bytes (all the RENDERED markdown); priming resets any // pending wave. companion: { prime: () => primeDraftReviewContext(draftReview, { draftType: "objective", draft: opts.rendered, ...(opts.custom !== undefined ? { custom: opts.custom } : {}), }), clear: () => clearDraftReviewContext(draftReview), }, applicable: review.isCurrent, degradeNotice: DEGRADE_NOTICE, settle: settleReviewOutcome, onDecision: async (out) => { // `settleReviewOutcome` routes only `completed` here; the narrowing keeps the router typed. if (out.status === "completed") { await routeObjectiveReviewDecision(pi, ctx, gating, slot, out, review); } }, }; const launched = await owner.open(ctx, spec); if (launched.kind === "failed") return { kind: "unlaunched" }; if (launched.kind === "retired") return { kind: "retired" }; 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") + WAVE_ARRIVAL_NOTICE, ); return { kind: "launched", guidance: 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; an unlaunched core result (slot * refusal or port-pick failure, already loudly reported) or a retired one (a newer open took * over) injects nothing. */ export async function openObjectiveReviewAndGuide( pi: ExtensionAPI, ctx: ExtensionContext, gating: ToolGating, opts: { rendered: string; artifactRaw: string; custom?: string }, draftReview: DraftReviewWaveState, slot: DraftReviewSlot, owner: BrowserOperationOwner, ): Promise { const opened = await openObjectiveReviewSurface(pi, ctx, gating, opts, draftReview, slot, owner); if (opened.kind === "launched") pi.sendUserMessage(opened.guidance); } // ------------------------------------------------------------------------ registration /** * Register the warm `/objective-review-browser` command (no tools — the companions are * global). `owner` is the activation's browser-operation owner (it carries the port selection). */ export function registerObjectiveReviewBrowser( pi: ExtensionAPI, gating: ToolGating, draftReview: DraftReviewWaveState, slot: DraftReviewSlot, owner: BrowserOperationOwner, ): 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, slot, owner, ); }, }); }