@mgiles/perk 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +68 -44
  2. package/extension/adapters/planAdapterPlannotator.ts +27 -41
  3. package/extension/adapters/planAdapterTombell.ts +15 -28
  4. package/extension/adapters/todoAdapterJuicesharp.ts +10 -13
  5. package/extension/checkpoints/checkpoints.ts +19 -12
  6. package/extension/doors/address.ts +4 -4
  7. package/extension/doors/askUser.ts +12 -8
  8. package/extension/doors/ciExecutor.ts +21 -14
  9. package/extension/doors/hunkHandoff.ts +202 -0
  10. package/extension/doors/land.ts +31 -9
  11. package/extension/doors/learn.ts +2 -2
  12. package/extension/doors/learnFactory.ts +144 -0
  13. package/extension/doors/plannotatorHandoff.ts +509 -0
  14. package/extension/doors/prReview.ts +4 -4
  15. package/extension/doors/prReviewBrowser.ts +341 -0
  16. package/extension/doors/prReviewTerminal.ts +267 -0
  17. package/extension/doors/selfcheck.ts +238 -5
  18. package/extension/doors/submit.ts +20 -0
  19. package/extension/doors/submitPrReview.ts +408 -0
  20. package/extension/factories/objective.ts +15 -5
  21. package/extension/factories/objectiveAuthor.ts +15 -32
  22. package/extension/factories/objectiveDraft.ts +1 -1
  23. package/extension/factories/objectivePlan.ts +12 -10
  24. package/extension/factories/objectiveSave.ts +2 -2
  25. package/extension/factories/planMode.ts +22 -40
  26. package/extension/factories/planReview.ts +213 -191
  27. package/extension/factories/planSave.ts +7 -7
  28. package/extension/index.ts +83 -25
  29. package/extension/substrate/bindingDelivery.ts +32 -10
  30. package/extension/substrate/bindings.ts +4 -2
  31. package/extension/substrate/cache.ts +34 -7
  32. package/extension/substrate/clipboard.ts +81 -0
  33. package/extension/substrate/config.ts +88 -65
  34. package/extension/substrate/git.ts +43 -0
  35. package/extension/substrate/paths.ts +1 -1
  36. package/extension/substrate/prompts.ts +2 -2
  37. package/extension/substrate/providers.ts +62 -8
  38. package/extension/substrate/sessionPointers.ts +35 -6
  39. package/extension/substrate/structuredOutput.ts +3 -1
  40. package/extension/substrate/terminalLaunch.ts +178 -0
  41. package/extension/substrate/toolGating.ts +330 -79
  42. package/extension/substrate/toolParams.ts +7 -0
  43. package/extension/substrate/workflowState.ts +54 -2
  44. package/extension/surfaces/footerProvider.ts +8 -4
  45. package/extension/surfaces/surfaces.ts +330 -12
  46. package/extension/vendor/btw/btw.ts +10 -0
  47. package/extension/worker/readOnlySession.ts +19 -6
  48. package/extension/worker/worker.ts +77 -7
  49. package/extension/workerMain.ts +12 -13
  50. package/package.json +3 -3
  51. package/prompts/_fixtures/live.yaml +117 -2
  52. package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
  53. package/prompts/contexts/adapters/plannotator-objective.md +7 -0
  54. package/prompts/contexts/adapters/plannotator-plan.md +6 -0
  55. package/prompts/contexts/adapters/tombell-plan.md +17 -0
  56. package/prompts/contexts/objective-authoring.md +20 -0
  57. package/prompts/contexts/plan-authoring.md +24 -0
  58. package/prompts/contexts/read-only.md +10 -0
  59. package/prompts/stages/conflict-resolution.md +1 -1
  60. package/prompts/stages/learn-code.md +1 -1
  61. package/prompts/stages/learn-docs.md +2 -2
  62. package/prompts/stages/learn-orchestrate.md +1 -1
  63. package/prompts/stages/objective-author/adopt.md +1 -1
  64. package/prompts/stages/objective-author/file.md +1 -1
  65. package/prompts/stages/objective-plan/guidance.md +1 -1
  66. package/prompts/stages/objective-plan/seed.md +1 -1
  67. package/prompts/stages/objective-reconcile.md +1 -1
  68. package/prompts/stages/objective-replan.md +1 -1
  69. package/prompts/stages/plan-from/adopt.md +2 -2
  70. package/prompts/stages/plan-from/file.md +2 -2
  71. package/prompts/stages/pr-review-browser/active.md +11 -0
  72. package/prompts/stages/pr-review-browser/foreign.md +11 -0
  73. package/prompts/stages/pr-review-terminal/active.md +12 -0
  74. package/prompts/stages/pr-review-terminal/foreign.md +13 -0
  75. package/prompts/stages/pr-review-terminal/local.md +4 -0
  76. package/prompts/stages/pr-review.md +1 -1
  77. package/prompts/stages/replan.md +2 -2
  78. package/prompts/stages/skills/create-from.md +1 -1
  79. package/prompts/stages/skills/create.md +1 -1
  80. package/prompts/stages/skills/refine.md +1 -1
  81. package/shared/README.md +22 -18
  82. package/shared/bindings.yaml +10 -2
  83. package/shared/contracts-history.md +24 -0
  84. package/shared/contracts.md +1442 -1787
  85. package/shared/providers.yaml +8 -1
  86. package/shared/registry.yaml +7 -8
  87. package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
  88. package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
  89. package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
  90. package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
  91. package/extension/doors/learnCode.ts +0 -100
  92. package/extension/doors/learnDocs.ts +0 -100
  93. package/extension/doors/prReviewLocal.ts +0 -229
@@ -0,0 +1,509 @@
1
+ // The plannotator browser-review substrate serving `/pr-review-browser`: the presence probe,
2
+ // the pinned `code-review` event envelope + annotation decode, the active-PR resolution ladder,
3
+ // the respond routing, and the composable browser-open core (port preset + readiness poll).
4
+ //
5
+ // pi exposes NO API for one extension to invoke another's slash command (`sendUserMessage` sends
6
+ // text to the model; `steer`/`followUp` ERROR on slash commands). So perk cannot literally call
7
+ // `/plannotator-review`. Instead it speaks plannotator's published `pi.events` API — a
8
+ // `plannotator:request` with `action: "code-review"` — which opens the EXACT same browser UI.
9
+ // Same in-process bus perk already uses for plan review (createPlannotatorBridge).
10
+ //
11
+ // EVENT ENVELOPE (pinned against `@plannotator/pi-extension@0.22.0`, `plannotator-events.ts` —
12
+ // byte-identical to 0.21.2, the original pin):
13
+ // request — pi.events.emit("plannotator:request", { requestId, action: "code-review",
14
+ // payload, respond }) // respond = in-payload callback
15
+ // payload — PR mode: { prUrl, cwd }
16
+ // — local mode: { cwd, diffType: "since-base", defaultBranch? }
17
+ // reply — respond({ status: "handled", result: { approved, feedback?, annotations?,
18
+ // exit? } })
19
+ // | respond({ status: "unavailable" | "error", error? })
20
+ // `result.annotations` items are plannotator `CodeAnnotation` objects — the content subset
21
+ // decoded here is `CodeReviewAnnotation` ({filePath, lineStart, lineEnd, side: "old"|"new"} +
22
+ // six optional string fields); `result.exit === true` is the "closed without feedback" arm
23
+ // (`/api/exit`). Both are consumed by the PR-mode respond routing (`respondMessage`); the
24
+ // pre-PR local mode's own routing (`routePrReviewOutcome`) branches on `exit` before the
25
+ // approved/feedback arms.
26
+ // Unlike plan-review there is NO handshake / no `reviewId` channel and no timeout: for code-review
27
+ // plannotator `await openCodeReview(...)` then responds ONCE with the final result.
28
+ //
29
+ // `"since-base"` is new in plannotator 0.22.0; older versions don't own that diff type and fall
30
+ // back to the reviewer's configured default diff — graceful degradation, no version detection.
31
+ // The requested diffType only sets the INITIAL view (the reviewer can switch from the header menu).
32
+ //
33
+ // SERVER ADDRESSING (why `startPlannotatorBrowser`'s env preset works): the pi extension runs
34
+ // plannotator's review server IN-PROCESS (`node:http`, not the standalone Bun binary), and its
35
+ // port resolution (`server/network.ts getServerPort()`) reads `PLANNOTATOR_PORT` at bind time —
36
+ // perk's extension and plannotator's server share one Node process, so an env var set here is
37
+ // read there. The core picks a free ephemeral port, presets the env var, emits the bridge
38
+ // request, polls `GET /api/diff` (a review-server-only route) for readiness, and ALWAYS restores
39
+ // the prior env value in a `finally` when the poll ends. Because the port is read at bind time,
40
+ // the server URL is KNOWN the moment the port is picked — before the server is up — which is
41
+ // what lets `/pr-review-browser` open the browser in the background and inject its guidance
42
+ // immediately. Concurrency caveat: a second plannotator server starting in the same process
43
+ // during the window would collide on the fixed port — rare and loud (EADDRINUSE → plannotator
44
+ // throws → the bridge settles error), never silent.
45
+
46
+ import { randomUUID } from "node:crypto";
47
+ import { createServer } from "node:net";
48
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
49
+ import type { PlannotatorBus } from "../adapters/planAdapterPlannotator.ts";
50
+ import { readPlanRef } from "../substrate/cache.ts";
51
+ import {
52
+ type ColdDoorResult,
53
+ type ColdJson,
54
+ numberField,
55
+ objectField,
56
+ stringField,
57
+ } from "../substrate/coldDoor.ts";
58
+ import { type ReportTarget, report } from "../surfaces/report.ts";
59
+
60
+ /** Plannotator's code-review slash command — its presence detects the extension is loaded. */
61
+ export const PLANNOTATOR_REVIEW_COMMAND = "plannotator-review";
62
+
63
+ /**
64
+ * The diff type forced for the no-PR local fallback: merge-base vs the base branch → working
65
+ * tree (plannotator ≥0.22.0; older versions fall back to their configured default diff). Forced —
66
+ * not left to the reviewer's default — because a perk worktree pre-submit is mostly committed
67
+ * work, so an `uncommitted` default would open a near-empty review.
68
+ */
69
+ export const LOCAL_REVIEW_DIFF_TYPE = "since-base";
70
+
71
+ /**
72
+ * The short, perk-authored triage suffix appended to feedback ONLY when the reviewer left
73
+ * annotations — mirrors plannotator's own "address these notes" routing, but perk-worded.
74
+ */
75
+ const TRIAGE_SUFFIX =
76
+ "\n\nTriage these review notes first: decide which are actionable, then address the actionable ones.";
77
+
78
+ /**
79
+ * One decoded plannotator annotation (the content subset of `CodeAnnotation` the PR-mode triage
80
+ * consumes). Perk-pushed external annotations return with their `source` badge set;
81
+ * human-authored ones carry no `source` — the authorship discriminator.
82
+ */
83
+ export interface CodeReviewAnnotation {
84
+ filePath: string;
85
+ lineStart: number;
86
+ lineEnd: number;
87
+ side: "old" | "new";
88
+ text?: string;
89
+ suggestedCode?: string;
90
+ type?: string;
91
+ scope?: string;
92
+ source?: string;
93
+ severity?: string;
94
+ }
95
+
96
+ /** The six pass-through-when-string optional `CodeReviewAnnotation` fields. */
97
+ const OPTIONAL_ANNOTATION_FIELDS = [
98
+ "text",
99
+ "suggestedCode",
100
+ "type",
101
+ "scope",
102
+ "source",
103
+ "severity",
104
+ ] as const;
105
+
106
+ /**
107
+ * Lenient per-item annotation decode (a triage/render-only consumer): an item is included iff
108
+ * `filePath` is a string and `lineStart`/`lineEnd` are numbers; `side` is `"old"` only when
109
+ * exactly `"old"`, else `"new"`; the optional fields carry through only when strings. Null =
110
+ * skip the item (it still counts toward `annotationCount`).
111
+ */
112
+ function decodeAnnotation(item: unknown): CodeReviewAnnotation | null {
113
+ if (typeof item !== "object" || item === null || Array.isArray(item)) return null;
114
+ const raw = item as Record<string, unknown>;
115
+ const filePath = raw.filePath;
116
+ const lineStart = raw.lineStart;
117
+ const lineEnd = raw.lineEnd;
118
+ if (typeof filePath !== "string") return null;
119
+ if (typeof lineStart !== "number" || typeof lineEnd !== "number") return null;
120
+ const annotation: CodeReviewAnnotation = {
121
+ filePath,
122
+ lineStart,
123
+ lineEnd,
124
+ side: raw.side === "old" ? "old" : "new",
125
+ };
126
+ for (const field of OPTIONAL_ANNOTATION_FIELDS) {
127
+ const value = raw[field];
128
+ if (typeof value === "string") annotation[field] = value;
129
+ }
130
+ return annotation;
131
+ }
132
+
133
+ /** Decode the reply's raw `annotations` array; malformed items are skipped (never the batch). */
134
+ function decodeAnnotations(raw: unknown): CodeReviewAnnotation[] {
135
+ if (!Array.isArray(raw)) return [];
136
+ const out: CodeReviewAnnotation[] = [];
137
+ for (const item of raw) {
138
+ const decoded = decodeAnnotation(item);
139
+ if (decoded !== null) out.push(decoded);
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /** The outcome of a plannotator code-review request — a small local discriminated union. */
145
+ export type CodeReviewOutcome =
146
+ | {
147
+ status: "handled";
148
+ approved: boolean;
149
+ feedback: string | undefined;
150
+ annotationCount: number;
151
+ annotations: CodeReviewAnnotation[];
152
+ exit: boolean;
153
+ }
154
+ | { status: "unavailable" | "error"; warning: string }
155
+ | { status: "aborted" };
156
+
157
+ /**
158
+ * Whether plannotator is loaded — detected by its `plannotator-review` command being registered
159
+ * (independent of the selected plan provider; code review is orthogonal to plan-review selection).
160
+ * `getCommands()` returns `SlashCommandInfo[]` whose `name` is the bare command name. The param
161
+ * is the structural `getCommands` slice so tool cores with minimal pi slices can call it.
162
+ */
163
+ export function plannotatorPresent(pi: Pick<ExtensionAPI, "getCommands">): boolean {
164
+ return pi.getCommands().some((c) => c.name === PLANNOTATOR_REVIEW_COMMAND);
165
+ }
166
+
167
+ /** Plannotator's `respond(...)` reply for a code-review request (pinned envelope, see header). */
168
+ interface CodeReviewResponse {
169
+ status?: string;
170
+ error?: string;
171
+ result?: { approved?: unknown; feedback?: unknown; annotations?: unknown; exit?: unknown };
172
+ }
173
+
174
+ /**
175
+ * The pure, offline-testable bridge: emit ONE `plannotator:request` with `action: "code-review"`
176
+ * and resolve when plannotator calls `respond(...)`. No handshake / no timeout (plannotator awaits
177
+ * `openCodeReview(...)` then responds once). Honors a turn abort. Pure over the bus → unit-testable
178
+ * with a fake plannotator listener.
179
+ */
180
+ export async function requestPlannotatorCodeReview(
181
+ bus: PlannotatorBus,
182
+ opts: {
183
+ cwd: string;
184
+ prUrl?: string;
185
+ diffType?: string;
186
+ defaultBranch?: string;
187
+ signal?: AbortSignal;
188
+ },
189
+ ): Promise<CodeReviewOutcome> {
190
+ if (opts.signal?.aborted) return { status: "aborted" };
191
+
192
+ // Build the payload conditionally — fields present ONLY when defined, so the PR-mode envelope
193
+ // stays shape-identical to the original `{ prUrl, cwd }` and an omitted `defaultBranch` lets
194
+ // plannotator auto-detect the repo default.
195
+ const payload: Record<string, unknown> = { cwd: opts.cwd };
196
+ if (opts.prUrl !== undefined) payload.prUrl = opts.prUrl;
197
+ if (opts.diffType !== undefined) payload.diffType = opts.diffType;
198
+ if (opts.defaultBranch !== undefined) payload.defaultBranch = opts.defaultBranch;
199
+
200
+ return await new Promise<CodeReviewOutcome>((resolve) => {
201
+ let settled = false;
202
+ const finish = (outcome: CodeReviewOutcome): void => {
203
+ if (settled) return;
204
+ settled = true;
205
+ opts.signal?.removeEventListener("abort", onAbort);
206
+ resolve(outcome);
207
+ };
208
+ const onAbort = (): void => finish({ status: "aborted" });
209
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
210
+
211
+ bus.emit("plannotator:request", {
212
+ requestId: randomUUID(),
213
+ action: "code-review",
214
+ payload,
215
+ respond: (raw: unknown) => {
216
+ const response = raw as CodeReviewResponse;
217
+ if (response?.status === "handled") {
218
+ const result = response.result ?? {};
219
+ const feedback =
220
+ typeof result.feedback === "string" && result.feedback.trim()
221
+ ? result.feedback
222
+ : undefined;
223
+ finish({
224
+ status: "handled",
225
+ approved: result.approved === true,
226
+ feedback,
227
+ annotationCount: Array.isArray(result.annotations) ? result.annotations.length : 0,
228
+ annotations: decodeAnnotations(result.annotations),
229
+ exit: result.exit === true,
230
+ });
231
+ return;
232
+ }
233
+ const status = response?.status === "error" ? "error" : "unavailable";
234
+ const detail = response?.error ? `: ${response.error}` : "";
235
+ finish({ status, warning: `plannotator reported ${response?.status ?? status}${detail}` });
236
+ },
237
+ });
238
+ });
239
+ }
240
+
241
+ // ------------------------------------------------------------------------ the active-PR ladder
242
+
243
+ /** Where a no-arg browser review points: the active PR, a local since-base review, or fail. */
244
+ export type ReviewTarget =
245
+ | { mode: "pr"; prUrl: string; number: number }
246
+ | { mode: "local"; defaultBranch: string | undefined }
247
+ | { mode: "fail"; message: string; errorType: string };
248
+
249
+ /**
250
+ * Resolve the review target from the `perk pr url` result (the pure, offline-testable core).
251
+ * `no_pr` — a plan worktree whose branch has no PR yet — falls back to the local review with the
252
+ * plan-ref's pinned base (null collapses to undefined: an omitted field means plannotator
253
+ * auto-detects the repo default, matching perk's `base: None ⇒ repo default` semantics). Every
254
+ * other fail arm (including `no_plan_ref`) passes through unchanged — the doors stay plan-scoped;
255
+ * arbitrary local review is plannotator's own `/plannotator-review` territory.
256
+ */
257
+ export function resolveReviewTarget(
258
+ r: ColdDoorResult<{ number: number; url: string }>,
259
+ planRefBase: string | null | undefined,
260
+ ): ReviewTarget {
261
+ if (r.ok) return { mode: "pr", prUrl: r.data.url, number: r.data.number };
262
+ if (r.errorType === "no_pr") return { mode: "local", defaultBranch: planRefBase ?? undefined };
263
+ return { mode: "fail", message: r.message, errorType: r.errorType };
264
+ }
265
+
266
+ /** Read the plan-ref's pinned base, swallowing read/parse errors (the doors must not throw). */
267
+ export function planRefBaseOf(cwd: string): string | undefined {
268
+ try {
269
+ return readPlanRef(cwd)?.base ?? undefined;
270
+ } catch {
271
+ return undefined;
272
+ }
273
+ }
274
+
275
+ /** Narrow the `perk pr url --json` success payload; strict on `pr.{number,url}`. */
276
+ export function decodePrUrl(payload: ColdJson): { number: number; url: string } | null {
277
+ const pr = objectField(payload, "pr");
278
+ if (pr === undefined) return null;
279
+ const number = numberField(pr, "number");
280
+ const url = stringField(pr, "url");
281
+ if (number === undefined || url === undefined) return null;
282
+ return { number, url };
283
+ }
284
+
285
+ // ------------------------------------------------------------------------ respond routing
286
+
287
+ /**
288
+ * Route the pre-PR local-mode outcome back into the session — mirrors plannotator's own routing.
289
+ * `exit` (closed without feedback) is checked BEFORE the no-feedback arm: an abandoned review
290
+ * must never report as "approved". Structural param slices keep it offline-testable; `scope` is
291
+ * the invoking door's report scope.
292
+ */
293
+ export function routePrReviewOutcome(
294
+ pi: Pick<ExtensionAPI, "sendUserMessage">,
295
+ ctx: ReportTarget & Pick<ExtensionContext, "isIdle">,
296
+ out: CodeReviewOutcome,
297
+ scope: string,
298
+ ): void {
299
+ if (out.status === "unavailable" || out.status === "error") {
300
+ report(ctx, scope, "error", out.warning, { alsoLog: true });
301
+ return;
302
+ }
303
+ if (out.status !== "handled") return; // aborted: the turn was interrupted — no-op
304
+
305
+ if (out.exit) {
306
+ report(ctx, scope, "info", "Code review closed without feedback.");
307
+ return;
308
+ }
309
+ if (out.feedback === undefined) {
310
+ report(ctx, scope, "info", "Code review approved — no changes requested.");
311
+ return;
312
+ }
313
+ const message = out.feedback + (out.annotationCount > 0 ? TRIAGE_SUFFIX : "");
314
+ // Inject the feedback as a real turn (the submit.ts driveConflictResolution pattern): an
315
+ // immediate turn when idle, else delivered after the current streaming batch.
316
+ if (ctx.isIdle()) {
317
+ pi.sendUserMessage(message);
318
+ } else {
319
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
320
+ }
321
+ }
322
+
323
+ /**
324
+ * The pure PR-mode respond → injection mapping (offline-testable). Null = nothing to inject (the
325
+ * non-handled arms route elsewhere: unavailable/error → report(); aborted → no-op).
326
+ *
327
+ * THE POSTING FLIP (contracts §8.4): plannotator's native platform-posting is THE GitHub path —
328
+ * perk composes nothing by default. `submit_pr_review` (gates unchanged) is offered ONLY for a
329
+ * request-changes verdict (the UI cannot post it) or on the human's explicit request; there is
330
+ * no read-back/dedupe step.
331
+ */
332
+ export function respondMessage(outcome: CodeReviewOutcome): string | null {
333
+ if (outcome.status !== "handled") return null;
334
+ if (outcome.exit) {
335
+ return (
336
+ "The human closed the plannotator review without submitting — ask them how they want " +
337
+ "to proceed."
338
+ );
339
+ }
340
+ if (outcome.approved && outcome.annotations.length === 0) {
341
+ return (
342
+ "The human approved the code review in plannotator (no annotations) — the review is " +
343
+ "complete. Perk posts nothing; offer `submit_pr_review` only if they explicitly ask " +
344
+ "(e.g. a request-changes verdict, which the UI cannot post)."
345
+ );
346
+ }
347
+ const parts: string[] = [outcome.feedback ?? "The plannotator review returned."];
348
+ if (outcome.annotations.length > 0) {
349
+ parts.push(`\`\`\`json\n${JSON.stringify(outcome.annotations, null, 2)}\n\`\`\``);
350
+ parts.push(
351
+ "These annotations are candidate comments: source-less ones are human-authored; " +
352
+ "`perk:*`-badged ones are your own findings returning. Perk composes nothing by " +
353
+ "default — ask the human what they want; `submit_pr_review` (dry-run repair loop + " +
354
+ "gates unchanged) ONLY for a request-changes verdict or on their explicit request.",
355
+ );
356
+ }
357
+ return parts.join("\n\n");
358
+ }
359
+
360
+ /** The minimal message sink `routeBrowserRespond` needs (an `ExtensionAPI` slice). */
361
+ export interface RespondSink {
362
+ sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp" }): void;
363
+ }
364
+
365
+ /**
366
+ * Route a settled PR-mode respond into the session (the idle-vs-streaming injection route),
367
+ * shared by `/pr-review-browser`'s PR modes. `scope` is the invoking surface's report scope.
368
+ */
369
+ export function routeBrowserRespond(
370
+ pi: RespondSink,
371
+ ctx: ReportTarget & Pick<ExtensionContext, "isIdle">,
372
+ out: CodeReviewOutcome,
373
+ scope: string,
374
+ ): void {
375
+ if (out.status === "unavailable" || out.status === "error") {
376
+ // Degrade-mid-flow: the flow continues in-session (findings table; posting unchanged).
377
+ report(ctx, scope, "error", out.warning, { alsoLog: true });
378
+ return;
379
+ }
380
+ const message = respondMessage(out);
381
+ if (message === null) return; // aborted: the turn was interrupted — no-op
382
+ if (ctx.isIdle()) {
383
+ pi.sendUserMessage(message);
384
+ } else {
385
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
386
+ }
387
+ }
388
+
389
+ // ------------------------------------------------------------------------ the browser-open core
390
+
391
+ /** The readiness-probe cadence: one `GET /api/diff` per second. */
392
+ export const READINESS_PROBE_INTERVAL_MS = 1_000;
393
+
394
+ /**
395
+ * The readiness budget — generous because plannotator's setup does real work before the server
396
+ * binds (the PR fetch + its own optional local checkout can be slow).
397
+ */
398
+ export const READINESS_PROBE_BUDGET_MS = 120_000;
399
+
400
+ /** Pick a free ephemeral port: `node:net` listen(0) → read → close (injectable for tests). */
401
+ export async function pickFreePort(): Promise<number> {
402
+ return await new Promise<number>((resolve, reject) => {
403
+ const server = createServer();
404
+ server.once("error", reject);
405
+ server.listen(0, "127.0.0.1", () => {
406
+ const address = server.address();
407
+ const port = typeof address === "object" && address !== null ? address.port : 0;
408
+ server.close(() => resolve(port));
409
+ });
410
+ });
411
+ }
412
+
413
+ /** The default readiness probe: `GET <url>/api/diff` — a review-server-only route. */
414
+ async function probeReviewServer(url: string, signal?: AbortSignal): Promise<boolean> {
415
+ try {
416
+ const response = await fetch(`${url}/api/diff`, { signal });
417
+ return response.ok;
418
+ } catch {
419
+ return false;
420
+ }
421
+ }
422
+
423
+ /** How the readiness poll ended (the browser-open core's observable outcome). */
424
+ export type BrowserReadiness = "ready" | "timeout" | "bridge_settled" | "aborted";
425
+
426
+ /** The injectable browser-open seams (tests drive a fake port picker / probe / clock). */
427
+ export interface StartBrowserDeps {
428
+ pickFreePort?: () => Promise<number>;
429
+ probe?: (url: string, signal?: AbortSignal) => Promise<boolean>;
430
+ intervalMs?: number;
431
+ budgetMs?: number;
432
+ sleep?: (ms: number) => Promise<void>;
433
+ }
434
+
435
+ /** A started browser open: the deterministic address + the two observable promises. */
436
+ export interface StartedBrowser {
437
+ url: string;
438
+ port: number;
439
+ bridgePromise: Promise<CodeReviewOutcome>;
440
+ readiness: Promise<BrowserReadiness>;
441
+ }
442
+
443
+ /**
444
+ * The composable browser-open core: pick a free port → save + preset `PLANNOTATOR_PORT` → emit
445
+ * the `code-review` bridge request (the PR-mode payload `{prUrl, cwd}` byte-for-byte —
446
+ * plannotator's defaults, including its own local checkout for Ask AI / Full-stack: deliberately
447
+ * NOT `useLocal: false`, the human chose the full surface) → return immediately with the
448
+ * deterministic `{url, port}` plus the two promises the caller observes: `bridgePromise` (the
449
+ * single respond) and `readiness` (the `GET /api/diff` poll — 1s cadence, 120s budget,
450
+ * attempt-counted so injected test clocks stay deterministic; stops early when the bridge
451
+ * settles first — an early error/unavailable respond means the server never comes — or the turn
452
+ * aborts). The prior env value is ALWAYS restored (delete if previously unset) in a `finally`
453
+ * when the poll ends: after the window the fixed port is released back to plannotator's own
454
+ * resolution (random port) for any later server. A port-pick failure throws — the caller owns
455
+ * its failure surface.
456
+ */
457
+ export async function startPlannotatorBrowser(
458
+ bus: PlannotatorBus,
459
+ opts: { prUrl: string; cwd: string; signal?: AbortSignal },
460
+ deps: StartBrowserDeps = {},
461
+ ): Promise<StartedBrowser> {
462
+ const pickPort = deps.pickFreePort ?? pickFreePort;
463
+ const probe = deps.probe ?? probeReviewServer;
464
+ const intervalMs = deps.intervalMs ?? READINESS_PROBE_INTERVAL_MS;
465
+ const budgetMs = deps.budgetMs ?? READINESS_PROBE_BUDGET_MS;
466
+ const sleep =
467
+ deps.sleep ?? ((ms: number) => new Promise<void>((r) => globalThis.setTimeout(r, ms)));
468
+
469
+ const port = await pickPort();
470
+ const url = `http://127.0.0.1:${port}`;
471
+
472
+ const priorPort = process.env.PLANNOTATOR_PORT;
473
+ process.env.PLANNOTATOR_PORT = String(port);
474
+
475
+ // Emit the bridge request while PLANNOTATOR_PORT is preset — plannotator's `listenOnPort`
476
+ // reads it at bind time.
477
+ let bridgeSettled = false;
478
+ const bridgePromise = requestPlannotatorCodeReview(bus, {
479
+ prUrl: opts.prUrl,
480
+ cwd: opts.cwd,
481
+ signal: opts.signal,
482
+ });
483
+ void bridgePromise.then(() => {
484
+ bridgeSettled = true;
485
+ });
486
+
487
+ const readiness = (async (): Promise<BrowserReadiness> => {
488
+ try {
489
+ const attempts = Math.max(1, Math.floor(budgetMs / intervalMs));
490
+ for (let i = 0; i < attempts; i++) {
491
+ // Abort first: an aborted turn also settles the bridge (as `aborted`), and the abort
492
+ // arm must win so the observer stays silent instead of degrading.
493
+ if (opts.signal?.aborted === true) return "aborted";
494
+ if (bridgeSettled) return "bridge_settled";
495
+ if (await probe(url, opts.signal)) return "ready";
496
+ await sleep(intervalMs);
497
+ }
498
+ return "timeout";
499
+ } finally {
500
+ if (priorPort === undefined) {
501
+ delete process.env.PLANNOTATOR_PORT;
502
+ } else {
503
+ process.env.PLANNOTATOR_PORT = priorPort;
504
+ }
505
+ }
506
+ })();
507
+
508
+ return { url, port, bridgePromise, readiness };
509
+ }
@@ -12,7 +12,7 @@
12
12
  // run-scratch stdin channel), then appends `last_pr_review` to `perk:workflow-state`. Never throws
13
13
  // (soft `details.ok`, mirrors resolveReviewThreads). This is documented in shared/contracts.md §8.3.
14
14
  //
15
- // The review model is configurable via `[subagents] pr-reviewer` in `.perk/config.toml`; because
15
+ // The review model is configurable via `[models.subagents] pr-reviewer` in `.perk/config.toml`; because
16
16
  // `subagents.agentOverrides` does NOT reach project agents, the warm command injects that model as a
17
17
  // per-call inline `model` override on EVERY reviewer spawn (the agent's frontmatter model is the
18
18
  // default).
@@ -208,8 +208,8 @@ export async function postPrReview(
208
208
 
209
209
  const TOOL_GUIDELINES = [
210
210
  "Call post_pr_review ONCE, after you have reconciled the angle-specialized reviewers' returned findings (union + dedupe) and derived the overall verdict (actionable if ANY reviewer was actionable, else clean).",
211
- "Pass the unioned findings as comments[] ({path, line, body}) with each line already anchored to a line in the diff — you never see the diff, so never re-anchor; pass the reviewers' lines straight through. A clean verdict must carry no comments.",
212
- "Judgment stays with you (the parent): the reviewer children are read-only and report-only — they never post. This tool posts the verdict-driven outcome (clean → 👍, actionable → an advisory COMMENT review) and records last_pr_review.",
211
+ "Pass post_pr_review the unioned findings as comments[] ({path, line, body}) with each line already anchored to a line in the diff — you never see the diff, so never re-anchor; pass the reviewers' lines straight through. A clean verdict must carry no comments.",
212
+ "Judgment stays with you (the parent): the reviewer children are read-only and report-only — they never post. post_pr_review posts the verdict-driven outcome (clean → 👍, actionable → an advisory COMMENT review) and records last_pr_review.",
213
213
  ];
214
214
 
215
215
  /**
@@ -301,7 +301,7 @@ export function registerPrReview(pi: ExtensionAPI): void {
301
301
  description:
302
302
  "Review the active PR via 2–3 angle-specialized fresh-context reviewers, reconcile their " +
303
303
  "findings, and post one verdict-driven outcome. The review model is configurable via " +
304
- "[subagents] pr-reviewer in .perk/config.toml. " +
304
+ "[models.subagents] pr-reviewer in .perk/config.toml. " +
305
305
  'Pass an optional free-form focus note (e.g. "have one reviewer focus on the dignified-python ' +
306
306
  'skill") to steer angle selection/emphasis.',
307
307
  handler: async (args, ctx: ExtensionContext) => {