// The warm `/address` door (the review loop). Classify-then-act: a spawned read-only child // (the borrowed `pi-subagents` engine running perk's `perk.review-classifier` agent) fetches + // classifies the PR feedback in ISOLATION, so the verbose GitHub JSON never enters this session; // the PARENT applies fixes (judgment + edits stay here) and resolves the threads through this // deterministic batched op. // // `resolve_review_threads` is the mechanical half: it DELEGATES the GitHub mutation to the Python // cold door (`perk pr resolve-threads` — mutations canonical in Python) via the shared cold-door // client (`runColdDoor` — the batch rides the run-scratch stdin channel), then // appends `last_review_batch` to `perk:workflow-state`. Never throws (soft `details.ok`, mirrors // submitPr). import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { bindingSuffix } from "../substrate/bindingDelivery.ts"; import { type PlanRef, readPlanRef } from "../substrate/cache.ts"; import { booleanField, type ColdJson, nullableStringField, runColdDoor, stringField, } from "../substrate/coldDoor.ts"; import { registerPerkCommand } from "../substrate/command.ts"; import { loadPerkConfig } from "../substrate/config.ts"; import { render } from "../substrate/prompts.ts"; import { failFor, ok, type Result } from "../substrate/result.ts"; import { arrayParam, numberParam, objectParam, paramsOf, stringParam, type ToolParams, } from "../substrate/toolParams.ts"; import { appendWorkflowState, branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts"; import { report } from "../surfaces/report.ts"; interface ThreadInput { thread_id: string; comment?: string; } interface ResolveCounts { actionable?: number; informational?: number; praise?: number; question?: number; } interface ResolveParams { threads: ThreadInput[]; pr?: number; counts?: ResolveCounts; } /** The four known `counts` keys (recorded into workflow-state — strict-decoded). */ const COUNT_KEYS = ["actionable", "informational", "praise", "question"] as const; /** Decode the optional `counts` object; null = present-but-mistyped (a key or the object). */ function decodeCounts(p: ToolParams): ResolveCounts | undefined | null { const raw = objectParam(p, "counts"); if (raw === undefined) return undefined; if (raw === null) return null; const counts: ResolveCounts = {}; for (const key of COUNT_KEYS) { const value = numberParam(raw, key); if (value === null) return null; if (value !== undefined) counts[key] = value; } return counts; } /** * Decode unknown tool-call params into `ResolveParams` (the tool-boundary seam). * `threads` absent or non-array decodes to `[]` (so the existing empty-batch `bad_input` arm * fires); any malformed ROW → null — whole-batch refusal, since resolving a guessed subset of * threads is a durable GitHub mutation. `pr`/`counts` mistyped → null (recorded state). */ export function decodeResolveParams(params: unknown): ResolveParams | null { const p = paramsOf(params); if (p === null) return null; const rawThreads = arrayParam(p, "threads"); const threads: ThreadInput[] = []; if (Array.isArray(rawThreads)) { for (const item of rawThreads) { const row = paramsOf(item); if (row === null) return null; const threadId = stringParam(row, "thread_id"); const comment = stringParam(row, "comment"); if (typeof threadId !== "string" || comment === null) return null; threads.push({ thread_id: threadId, comment }); } } const pr = numberParam(p, "pr"); if (pr === null) return null; const counts = decodeCounts(p); if (counts === null) return null; return { threads, pr, counts }; } /** One per-thread outcome row from the cold door's batch result. */ export interface ThreadResultRow { thread_id: string; success: boolean; comment_added: boolean; error?: string | null; } /** The ok-arm fields. */ export interface ResolveOk { results: ThreadResultRow[]; resolved_thread_ids: string[]; } /** The partial-failure branch carries the per-thread detail on the fail arm too. */ export interface ResolveFailExtras { results?: ThreadResultRow[]; resolved_thread_ids?: string[]; } export type ResolveResult = Result; /** * Narrow the cold door's `results` array to per-thread rows. Strict per row on `thread_id`, * `success`, `comment_added`; lenient on the report-only `error` (wrong-typed coerces to null). * Any malformed row → null (uncertainty ⇒ no half-rendered partial table). */ function decodeRows(payload: ColdJson): ThreadResultRow[] | null { const raw = payload.results; if (!Array.isArray(raw)) return null; const rows: ThreadResultRow[] = []; for (const item of raw) { if (typeof item !== "object" || item === null || Array.isArray(item)) return null; const row = item as ColdJson; const threadId = stringField(row, "thread_id"); const success = booleanField(row, "success"); const commentAdded = booleanField(row, "comment_added"); if (threadId === undefined || success === undefined || commentAdded === undefined) return null; rows.push({ thread_id: threadId, success, comment_added: commentAdded, error: nullableStringField(row, "error") ?? null, }); } return rows; } /** * Resolve a batch of review threads (the parent's mechanical resolve step). Delegates to the Python * cold door; returns a soft result (never throws). On success, records `last_review_batch`. */ export async function resolveReviewThreads( pi: ExtensionAPI, ctx: ExtensionContext, params: ResolveParams, ): Promise { const fail = failFor(ctx, "address", "resolve_review_threads"); const threads = Array.isArray(params?.threads) ? params.threads : []; if (threads.length === 0) { return fail("no threads to resolve (pass { threads: [{thread_id, comment?}] })", "bad_input"); } const batch = threads.map((t) => ({ thread_id: t.thread_id, comment: t.comment ?? null })); const r = await runColdDoor(pi, ctx, ["pr", "resolve-threads", "--json"], { label: "perk pr resolve-threads", decode: decodeRows, stdin: { flag: "--batch", content: `${JSON.stringify(batch, null, 2)}\n`, filename: `resolve-batch-${Date.now()}.json`, }, }); if (!r.ok) { // A partial/failed batch is loud-but-soft: surface the per-thread detail, do not throw. The // detail rides the failure envelope's payload; absent/malformed rows ⇒ plain fail (advisory // drop — never a half-rendered partial table). const rows = r.payload !== undefined ? decodeRows(r.payload) : null; if (r.payload === undefined || rows === null) return fail(r.message, r.errorType); const resolvedIds = rows.filter((row) => row.success).map((row) => row.thread_id); const failed = rows.filter((row) => !row.success).length; const error = stringField(r.payload, "message") ?? `${failed} thread(s) did not resolve`; report(ctx, "address", "error", error, { alsoLog: true }); return { content: [ { type: "text", text: `Resolved ${resolvedIds.length}/${rows.length} thread(s); ${failed} failed.`, }, ], details: { ok: false, error, error_type: stringField(r.payload, "error_type") ?? "partial_failure", results: rows, resolved_thread_ids: resolvedIds, }, }; } const results = r.data; const resolvedIds = results.filter((row) => row.success).map((row) => row.thread_id); // Record the batch (tier-3, best-effort-with-logging, idempotent, headless-safe). Strict // read-back via rebuild — loud-but-non-fatal, the resolve already succeeded. const recordedBatch = { pr: params.pr ?? null, counts: params.counts ?? null, resolved_thread_ids: resolvedIds, at: new Date().toISOString(), }; appendWorkflowState(pi, ctx, { data: { last_review_batch: recordedBatch }, field: "last_review_batch", expected: recordedBatch, scope: "address", failure: "last_review_batch read-back failed", }); return ok(`Resolved ${resolvedIds.length} review thread(s).`, { results, resolved_thread_ids: resolvedIds, }); } const TOOL_GUIDELINES = [ "Call resolve_review_threads only AFTER you have applied (and committed) fixes for the actionable items — it replies-then-resolves the threads you pass.", "Pass resolve_review_threads `threads` as [{thread_id, comment?}] using the thread_id values from the perk.review-classifier child's structured output; the optional comment is posted as a reply before resolving.", "Judgment and edits stay with you (the parent) — never delegate the fix; the classifier child that feeds resolve_review_threads is read-only and classification-only.", ]; /** Resolve the active plan-ref (worktree first, then the rebuilt workflow-state). The converged * address body carries the PR identity, so the warm door must resolve a ref — and `/address` * cannot function without one regardless (the classifier child's `perk pr feedback` hard-errors * `no_plan_ref`). Mirrors `doors/learn.ts`'s helper. */ function activePlanRef(ctx: ExtensionContext): PlanRef | null { const fromWorktree = readPlanRef(ctx.cwd); if (fromWorktree) return fromWorktree; try { const branch = branchOf(ctx); return (rebuildWorkflowState(branch).active_plan_ref as PlanRef | null) ?? null; } catch { return null; } } /** Inject the address-workflow guidance the model follows (the perk-address skill pointer is * delivered by the skill-binding suffix — not hardcoded here). When `model` is set, the * `perk.review-classifier` spawn carries an inline `model` override ([models.subagents] review-classifier); * otherwise the agent's frontmatter default is used. * * The wording lives in the shared canonical templates `prompts/stages/address/*` rendered via the * cross-plane render seam (contracts.md §8.31) — the warm door converges onto the SAME two * templates as the cold `_address_prompt` and the worker `initialPromptFor("address")`. Branching * stays in code: preview/action selects the template; classifier present/absent builds the * `model_clause` render var. */ export function addressGuidance(ref: PlanRef, preview: boolean, model?: string): string { const modelClause = model ? `, passing \`model: "${model}"\` on that call (the configured [models.subagents] review-classifier model)` : ""; const variables = { provider: ref.provider, pr_id: String(ref.pr_id), url: ref.url, model_clause: modelClause, }; return render(preview ? "stages/address/preview.md" : "stages/address/action.md", variables); } /** Register the warm door: the `resolve_review_threads` tool + the `/address` command. */ export function registerAddress(pi: ExtensionAPI): void { pi.registerTool({ name: "resolve_review_threads", label: "Resolve review threads", description: "Reply-then-resolve a batch of PR review threads after the actionable feedback is fixed. " + "Delegates the GitHub mutation to the perk cold door; records the batch in workflow-state.", promptSnippet: "Batch-resolve the addressed PR review threads", promptGuidelines: TOOL_GUIDELINES, executionMode: "sequential", parameters: { type: "object", additionalProperties: false, required: ["threads"], properties: { threads: { type: "array", description: "The threads to resolve.", items: { type: "object", additionalProperties: false, required: ["thread_id"], properties: { thread_id: { type: "string", description: "The GraphQL node id of the thread." }, comment: { type: "string", description: "Optional reply posted before resolving." }, }, }, }, pr: { type: "number", description: "Optional PR number, recorded in last_review_batch." }, counts: { type: "object", description: "Optional classification counts, recorded in last_review_batch.", additionalProperties: false, properties: { actionable: { type: "number" }, informational: { type: "number" }, praise: { type: "number" }, question: { type: "number" }, }, }, }, }, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const decoded = decodeResolveParams(params); if (decoded === null) { return failFor( ctx, "address", "resolve_review_threads", )("resolve_review_threads needs { threads: [{thread_id, comment?}] }", "bad_input"); } return resolveReviewThreads(pi, ctx, decoded); }, }); registerPerkCommand(pi, "address", { description: "Classify PR review feedback (isolated child) and resolve threads (submit → address). " + "Pass --preview to classify only (take no action).", handler: async (args, ctx) => { const preview = /(^|\s)--preview(\s|$)/.test(args ?? ""); // `/address` needs an active plan-ref (the converged body carries the PR identity, and the // classifier child's `perk pr feedback` hard-errors `no_plan_ref` without one). Mirror the // /implement guard: warn and send no guidance rather than dead-end downstream. const ref = activePlanRef(ctx); if (ref == null) { report( ctx, "address", "warning", "/address needs an active plan-ref — run `perk pr address` / after `/submit`.", ); return; } const model = loadPerkConfig(ctx.cwd).subagents["review-classifier"]; const guidance = addressGuidance(ref, preview, model); report( ctx, "address", "info", preview ? "--preview (classify only)" : "classify → fix → resolve", ); // Inject the address-workflow guidance as a user message so the model starts the loop. // `pi.sendUserMessage` always triggers a turn (the warm entry to the review loop). The // perk-address pointer rides the skill-binding suffix. pi.sendUserMessage(guidance + bindingSuffix(ctx.cwd, "stage:address")); }, }); }