@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,341 @@
1
+ // The warm `/pr-review-browser` door: the BROWSER entry into human-in-the-loop adversarial PR
2
+ // review — plannotator always, no provider dispatch (the surface-named command IS the selection).
3
+ //
4
+ // Three modes, keyed off the arg parse + the active-PR resolution ladder (the parse is IMPORTED
5
+ // from prReviewTerminal.ts — one function ⇒ identical arg semantics by construction):
6
+ // foreign — `/pr-review-browser <pr|url> [focus]`: the detached `perk pr review checkout`, the
7
+ // browser opened in the background on the PR URL, the async adversarial-reviewer
8
+ // fan-out with per-angle annotation waves streamed to the local plannotator server.
9
+ // active — `/pr-review-browser [focus]` from a plan worktree whose branch HAS a PR: the same
10
+ // flow re-homed to the human's own worktree (no checkout, no cleanup).
11
+ // local — no PR yet (`perk pr url` → `no_pr`): the absorbed pre-PR since-base browser review
12
+ // ({cwd, diffType: "since-base", defaultBranch: the plan-ref base}) — NO reviewers,
13
+ // no guidance injection, no port dance (no waves to stream); the door ends
14
+ // immediately and the single respond routes back later.
15
+ //
16
+ // THE BACKGROUND OPEN: the server URL is deterministic the moment the port is picked
17
+ // (plannotator reads `PLANNOTATOR_PORT` at bind time — see plannotatorHandoff.ts), so in the PR
18
+ // modes the handler starts `startPlannotatorBrowser`, injects the mode guidance IMMEDIATELY, and
19
+ // ends its turn — no blocking readiness poll in the handler. The readiness promise is observed
20
+ // in a background task: ready → an info note; timeout / an error-or-unavailable bridge settle →
21
+ // a loud error plus a degrade notice injected to the model (findings render in-session; posting
22
+ // unchanged). The guidance's wave discipline is hold-and-accumulate: a refused POST before any
23
+ // door failure notice means "not up yet", never a degrade.
24
+ //
25
+ // THE POSTING FLIP (contracts §8.4): plannotator's native platform-posting is THE GitHub path —
26
+ // the human posts inline comments + APPROVE/COMMENT directly from the UI. Perk composes nothing
27
+ // by default; `submit_pr_review` (gates unchanged) is used ONLY for a request-changes verdict
28
+ // (the UI cannot post it) or on the human's explicit request. The door registers NO tools — the
29
+ // annotation waves are agent-driven HTTP per the guidance, and perk-side posting reuses
30
+ // `submit_pr_review` (registered by `registerSubmitPrReview`).
31
+
32
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
33
+ import { bindingSuffix } from "../substrate/bindingDelivery.ts";
34
+ import { runColdDoor } from "../substrate/coldDoor.ts";
35
+ import { registerPerkCommand } from "../substrate/command.ts";
36
+ import { loadPerkConfig } from "../substrate/config.ts";
37
+ import { interceptConsoleError } from "../substrate/consoleCapture.ts";
38
+ import { render } from "../substrate/prompts.ts";
39
+ import { type ReportTarget, report } from "../surfaces/report.ts";
40
+ import { type CheckoutOk, decodeCheckout } from "./hunkHandoff.ts";
41
+ import {
42
+ decodePrUrl,
43
+ LOCAL_REVIEW_DIFF_TYPE,
44
+ plannotatorPresent,
45
+ planRefBaseOf,
46
+ type RespondSink,
47
+ requestPlannotatorCodeReview,
48
+ resolveReviewTarget,
49
+ routeBrowserRespond,
50
+ routePrReviewOutcome,
51
+ type StartedBrowser,
52
+ startPlannotatorBrowser,
53
+ } from "./plannotatorHandoff.ts";
54
+ import { parseReviewDoorArgs } from "./prReviewTerminal.ts";
55
+
56
+ /** The door's report scope — also the `command:<id>` binding trigger id. */
57
+ const SCOPE = "pr-review-browser";
58
+
59
+ // ------------------------------------------------------------------------ guidance
60
+
61
+ /** The per-mode guidance selector input (the local mode injects no guidance at all). */
62
+ export interface PrReviewBrowserGuidanceOpts {
63
+ mode: "foreign" | "active";
64
+ pr: number;
65
+ prUrl: string;
66
+ worktree: string;
67
+ url: string;
68
+ model?: string;
69
+ directive?: string;
70
+ }
71
+
72
+ /**
73
+ * The seed guidance the door injects (the perk-pr-review-browser skill pointer rides the
74
+ * skill-binding suffix — command:pr-review-browser — not hardcoded here). Pure + exported for
75
+ * offline tests.
76
+ * Mode selects the arm file under `prompts/stages/pr-review-browser/` (the bodies differ
77
+ * load-bearingly: foreign carries the untrusted-checkout framing + cleanup; active re-homes to
78
+ * the human's worktree with neither). The local mode has no template — the door injects nothing.
79
+ */
80
+ export function prReviewBrowserGuidance(opts: PrReviewBrowserGuidanceOpts): string {
81
+ return render(`stages/pr-review-browser/${opts.mode}.md`, {
82
+ pr: String(opts.pr),
83
+ pr_url: opts.prUrl,
84
+ worktree: opts.worktree,
85
+ url: opts.url,
86
+ model: opts.model ?? "",
87
+ directive: opts.directive ?? "",
88
+ });
89
+ }
90
+
91
+ // ------------------------------------------------------------------------ the background open
92
+
93
+ /**
94
+ * The degrade notice injected when the browser never comes up — the model renders the findings
95
+ * in-session and runs the same triage conversationally; the posting contract is unchanged.
96
+ */
97
+ const DEGRADE_NOTICE =
98
+ "The plannotator browser review is unavailable (the review server never became ready) — " +
99
+ "degrade in-session: render the reviewers' reconciled findings as a table in your reply and " +
100
+ "run the same triage loop conversationally. Posting is unchanged: perk composes nothing by " +
101
+ "default; `submit_pr_review` (dry-run first; gates unchanged) only for a request-changes " +
102
+ "verdict or on the human's explicit request.";
103
+
104
+ /**
105
+ * Observe the readiness poll in the background (the handler has already injected the guidance
106
+ * and ended): ready → an info note naming the URL; timeout / an error-or-unavailable bridge
107
+ * settle → a loud error report PLUS the degrade notice injected to the model (idle → immediate,
108
+ * streaming → followUp). A bridge that settled `handled` (the human finished before the poll saw
109
+ * the server) or `aborted` routes via the respond routing alone — no degrade. Structural param
110
+ * slices keep it offline-testable; exported for the door tests.
111
+ */
112
+ export async function observeBrowserReadiness(
113
+ pi: RespondSink,
114
+ ctx: ReportTarget & Pick<ExtensionContext, "isIdle">,
115
+ started: StartedBrowser,
116
+ ): Promise<void> {
117
+ const state = await started.readiness;
118
+ if (state === "ready") {
119
+ report(ctx, SCOPE, "info", `plannotator is up at ${started.url} — browser opening`);
120
+ return;
121
+ }
122
+ if (state === "aborted") return; // the turn was interrupted — no-op
123
+ if (state === "bridge_settled") {
124
+ const out = await started.bridgePromise;
125
+ if (out.status === "handled" || out.status === "aborted") return;
126
+ }
127
+ report(
128
+ ctx,
129
+ SCOPE,
130
+ "error",
131
+ `the plannotator review server did not become ready at ${started.url} — the browser ` +
132
+ "review is unavailable",
133
+ { alsoLog: true },
134
+ );
135
+ if (ctx.isIdle()) {
136
+ pi.sendUserMessage(DEGRADE_NOTICE);
137
+ } else {
138
+ pi.sendUserMessage(DEGRADE_NOTICE, { deliverAs: "followUp" });
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The shared PR-mode arm (foreign + active): start the browser open in the background, inject
144
+ * the mode guidance immediately (the URL is deterministic once the port is picked), and return —
145
+ * the readiness observation and the bridge respond both ride background tasks. While plannotator
146
+ * sets up, its in-process `console.error` chatter re-routes through the TUI-safe report() seam
147
+ * (the debounce restores once setup goes quiet, with the `finally` as a backstop).
148
+ */
149
+ async function openBrowserAndGuide(
150
+ pi: ExtensionAPI,
151
+ ctx: ExtensionContext,
152
+ opts: Omit<PrReviewBrowserGuidanceOpts, "url">,
153
+ ): Promise<void> {
154
+ let started: StartedBrowser;
155
+ try {
156
+ started = await startPlannotatorBrowser(pi.events, {
157
+ prUrl: opts.prUrl,
158
+ cwd: ctx.cwd,
159
+ signal: ctx.signal,
160
+ });
161
+ } catch (error) {
162
+ const detail = error instanceof Error ? error.message : String(error);
163
+ report(
164
+ ctx,
165
+ SCOPE,
166
+ "error",
167
+ `could not pick a free local port for the plannotator review server: ${detail}`,
168
+ { alsoLog: true },
169
+ );
170
+ return;
171
+ }
172
+
173
+ void observeBrowserReadiness(pi, ctx, started);
174
+
175
+ void (async () => {
176
+ const interceptor = interceptConsoleError((line) => report(ctx, SCOPE, "info", line), {
177
+ // plannotator can pause up to ~4s between setup lines — keep the quiet window above that.
178
+ quietMs: 6000,
179
+ });
180
+ try {
181
+ const out = await started.bridgePromise;
182
+ routeBrowserRespond(pi, ctx, out, SCOPE);
183
+ } finally {
184
+ interceptor.restore();
185
+ }
186
+ })();
187
+
188
+ pi.sendUserMessage(
189
+ prReviewBrowserGuidance({ ...opts, url: started.url }) +
190
+ bindingSuffix(ctx.cwd, `command:${SCOPE}`),
191
+ );
192
+ }
193
+
194
+ // ------------------------------------------------------------------------ registration
195
+
196
+ /** Register the warm `/pr-review-browser` command (no tools — posting rides submit_pr_review). */
197
+ export function registerPrReviewBrowser(pi: ExtensionAPI): void {
198
+ registerPerkCommand(pi, SCOPE, {
199
+ description:
200
+ "Review a PR human-in-the-loop in the plannotator browser UI: no arg reviews the active " +
201
+ "worktree's PR (or, pre-PR, opens a since-base browser review); a PR number/URL reviews " +
202
+ "that foreign PR. Any other text is a focus note for the reviewers. You post to GitHub " +
203
+ "from the browser.",
204
+ handler: async (args, ctx: ExtensionContext) => {
205
+ // Entry gates, in order — nothing executed on refusal, each a loud error.
206
+ const parsed = parseReviewDoorArgs(args ?? "");
207
+ if (parsed === null) {
208
+ report(ctx, SCOPE, "error", "usage: /pr-review-browser [pr number|url] [focus note]");
209
+ return;
210
+ }
211
+ if (!ctx.hasUI) {
212
+ report(
213
+ ctx,
214
+ SCOPE,
215
+ "error",
216
+ "/pr-review-browser requires an interactive session — the plannotator browser " +
217
+ "surface and the human are constitutive",
218
+ );
219
+ return;
220
+ }
221
+ if (!plannotatorPresent(pi)) {
222
+ report(
223
+ ctx,
224
+ SCOPE,
225
+ "error",
226
+ "the plannotator extension is not loaded (its /plannotator-review command was not " +
227
+ "found) — select the plannotator plan provider (`[providers] plan = " +
228
+ '"plannotator-plan"`), run `perk init`, then restart pi',
229
+ );
230
+ return;
231
+ }
232
+
233
+ const config = loadPerkConfig(ctx.cwd);
234
+ const model = config.subagents["adversarial-reviewer"] ?? "";
235
+
236
+ if (parsed.mode === "foreign") {
237
+ // The foreign arm: the detached checkout, then the background browser open.
238
+ const checkout = await runColdDoor<CheckoutOk>(
239
+ pi,
240
+ ctx,
241
+ ["pr", "review", "checkout", "--pr", String(parsed.pr), "--json"],
242
+ { label: "perk pr review checkout", decode: decodeCheckout },
243
+ );
244
+ if (!checkout.ok) {
245
+ report(
246
+ ctx,
247
+ SCOPE,
248
+ "error",
249
+ `perk pr review checkout failed (${checkout.errorType}): ${checkout.message}`,
250
+ { alsoLog: true },
251
+ );
252
+ return;
253
+ }
254
+ report(
255
+ ctx,
256
+ SCOPE,
257
+ "info",
258
+ parsed.directive
259
+ ? `PR #${parsed.pr} → adversarial reviewers (focus: ${parsed.directive}) → plannotator browser triage → you post from the browser`
260
+ : `PR #${parsed.pr} → adversarial reviewers → plannotator browser triage → you post from the browser`,
261
+ );
262
+ await openBrowserAndGuide(pi, ctx, {
263
+ mode: "foreign",
264
+ pr: parsed.pr,
265
+ prUrl: checkout.data.url,
266
+ worktree: checkout.data.path,
267
+ model,
268
+ directive: parsed.directive,
269
+ });
270
+ return;
271
+ }
272
+
273
+ // The active arm: resolve the worktree's own PR via the shared ladder.
274
+ const r = await runColdDoor<{ number: number; url: string }>(
275
+ pi,
276
+ ctx,
277
+ ["pr", "url", "--json"],
278
+ { label: "perk pr url", decode: decodePrUrl },
279
+ );
280
+ const target = resolveReviewTarget(r, planRefBaseOf(ctx.cwd));
281
+ if (target.mode === "fail") {
282
+ report(
283
+ ctx,
284
+ SCOPE,
285
+ "error",
286
+ `${target.message} (${target.errorType}) — pass a PR number/URL, or run from a plan ` +
287
+ "worktree",
288
+ );
289
+ return;
290
+ }
291
+
292
+ if (target.mode === "pr") {
293
+ report(
294
+ ctx,
295
+ SCOPE,
296
+ "info",
297
+ parsed.directive
298
+ ? `PR #${target.number} (active worktree) → adversarial reviewers (focus: ${parsed.directive}) → plannotator browser triage → you post from the browser`
299
+ : `PR #${target.number} (active worktree) → adversarial reviewers → plannotator browser triage → you post from the browser`,
300
+ );
301
+ await openBrowserAndGuide(pi, ctx, {
302
+ mode: "active",
303
+ pr: target.number,
304
+ prUrl: target.prUrl,
305
+ worktree: ctx.cwd,
306
+ model,
307
+ directive: parsed.directive,
308
+ });
309
+ return;
310
+ }
311
+
312
+ // The local / pre-PR mode (the absorbed since-base browser review): no reviewers, no
313
+ // guidance, no port dance (no waves to stream — no endpoint needed). The bridge runs in
314
+ // the background and the single respond routes back via routePrReviewOutcome.
315
+ report(
316
+ ctx,
317
+ SCOPE,
318
+ "info",
319
+ `No PR yet — opening plannotator local review (since-base vs ${target.defaultBranch ?? "repo default"}) …`,
320
+ );
321
+ void (async () => {
322
+ const interceptor = interceptConsoleError((line) => report(ctx, SCOPE, "info", line), {
323
+ // plannotator can pause up to ~4s between setup lines — keep the quiet window
324
+ // comfortably above that so the debounce doesn't restore mid-setup.
325
+ quietMs: 6000,
326
+ });
327
+ try {
328
+ const out = await requestPlannotatorCodeReview(pi.events, {
329
+ cwd: ctx.cwd,
330
+ diffType: LOCAL_REVIEW_DIFF_TYPE,
331
+ defaultBranch: target.defaultBranch,
332
+ signal: ctx.signal,
333
+ });
334
+ routePrReviewOutcome(pi, ctx, out, SCOPE);
335
+ } finally {
336
+ interceptor.restore();
337
+ }
338
+ })();
339
+ },
340
+ });
341
+ }
@@ -0,0 +1,267 @@
1
+ // The warm `/pr-review-terminal` door: the TERMINAL entry into human-in-the-loop adversarial PR
2
+ // review — hunk always, no provider dispatch (the surface-named command IS the selection).
3
+ //
4
+ // Three modes, keyed off the arg parse + the active-PR resolution ladder:
5
+ // foreign — `/pr-review-terminal <pr|url> [focus]`: the detached `perk pr review checkout`,
6
+ // the R7 handoff, the full adversarial-reviewer flow (async fan-out + live findings
7
+ // streaming per the injected guidance).
8
+ // active — `/pr-review-terminal [focus]` from a plan worktree whose branch HAS a PR: the same
9
+ // flow re-homed to the human's own worktree (no checkout, no cleanup) on the local
10
+ // since-base diff (`sinceBaseSha` — best-effort fetch, then merge-base).
11
+ // local — no PR yet (`perk pr url` → `no_pr`): a surface-only since-base review — hunk is
12
+ // launched, NO reviewers are spawned and NOTHING posts to GitHub; the guidance is a
13
+ // minimal notes read-back loop.
14
+ // Every launch carries `--agent-notes` so pushed findings are visible in hunk immediately.
15
+ //
16
+ // The door registers NO tools — posting reuses `submit_pr_review` (registered by
17
+ // `registerSubmitPrReview`), whose gate ladder (contracts §8.4) applies unchanged.
18
+
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import { bindingSuffix } from "../substrate/bindingDelivery.ts";
21
+ import { runColdDoor } from "../substrate/coldDoor.ts";
22
+ import { registerPerkCommand } from "../substrate/command.ts";
23
+ import { loadPerkConfig } from "../substrate/config.ts";
24
+ import { sinceBaseSha } from "../substrate/git.ts";
25
+ import { render } from "../substrate/prompts.ts";
26
+ import { report } from "../surfaces/report.ts";
27
+ import {
28
+ type CheckoutOk,
29
+ decodeCheckout,
30
+ HUNK_INSTALL_HINT,
31
+ handleHunkLaunch,
32
+ hunkPresent,
33
+ parseReviewArgs,
34
+ } from "./hunkHandoff.ts";
35
+ import { decodePrUrl, planRefBaseOf, resolveReviewTarget } from "./plannotatorHandoff.ts";
36
+
37
+ /** The door's report scope — also the `command:<id>` binding trigger id. */
38
+ const SCOPE = "pr-review-terminal";
39
+
40
+ // ------------------------------------------------------------------------ arg parse
41
+
42
+ /** A parsed review-door invocation: foreign (a PR arg) or active (everything else). */
43
+ export type ReviewDoorArgs =
44
+ | { mode: "foreign"; pr: number; directive: string }
45
+ | { mode: "active"; directive: string };
46
+
47
+ /**
48
+ * Parse the review-door args (shared by BOTH doors — the browser door imports this, so one
49
+ * function ⇒ identical grammar by construction) — both tokens optional:
50
+ * - empty/whitespace → active mode, no focus;
51
+ * - a leading PR number/URL (the shared PR-token grammar) → foreign mode (+ optional focus);
52
+ * - a leading `http(s)://` token that FAILS the PR parse → null (usage error: a mistyped PR URL
53
+ * never silently becomes a focus note);
54
+ * - anything else → active mode with the whole string as the focus note.
55
+ */
56
+ export function parseReviewDoorArgs(args: string): ReviewDoorArgs | null {
57
+ const trimmed = args.trim();
58
+ if (trimmed.length === 0) return { mode: "active", directive: "" };
59
+ const parsed = parseReviewArgs(trimmed);
60
+ if (parsed !== null) return { mode: "foreign", pr: parsed.pr, directive: parsed.directive };
61
+ const first = trimmed.split(/\s+/, 1)[0] ?? "";
62
+ if (/^https?:\/\//.test(first)) return null;
63
+ return { mode: "active", directive: trimmed };
64
+ }
65
+
66
+ // ------------------------------------------------------------------------ guidance
67
+
68
+ /** The per-mode guidance selector input (the local arm has no PR and threads no directive). */
69
+ export type PrReviewTerminalGuidanceOpts =
70
+ | {
71
+ mode: "foreign" | "active";
72
+ pr: number;
73
+ worktree: string;
74
+ baseSha: string;
75
+ model?: string;
76
+ directive?: string;
77
+ }
78
+ | { mode: "local"; worktree: string; baseSha: string };
79
+
80
+ /**
81
+ * The seed guidance the door injects (the perk-pr-review-terminal skill pointer rides the
82
+ * skill-binding suffix — command:pr-review-terminal — not hardcoded here). Pure + exported for
83
+ * offline tests.
84
+ * Mode selects the arm file under `prompts/stages/pr-review-terminal/` (the bodies differ
85
+ * load-bearingly: foreign carries the untrusted-checkout framing + cleanup; active re-homes to
86
+ * the human's worktree with neither; local is the reviewers-skipped surface-only note).
87
+ */
88
+ export function prReviewTerminalGuidance(opts: PrReviewTerminalGuidanceOpts): string {
89
+ if (opts.mode === "local") {
90
+ return render("stages/pr-review-terminal/local.md", {
91
+ worktree: opts.worktree,
92
+ base_sha: opts.baseSha,
93
+ });
94
+ }
95
+ return render(`stages/pr-review-terminal/${opts.mode}.md`, {
96
+ pr: String(opts.pr),
97
+ worktree: opts.worktree,
98
+ base_sha: opts.baseSha,
99
+ model: opts.model ?? "",
100
+ directive: opts.directive ?? "",
101
+ });
102
+ }
103
+
104
+ // ------------------------------------------------------------------------ registration
105
+
106
+ /** Register the warm `/pr-review-terminal` command (no tools — posting rides submit_pr_review). */
107
+ export function registerPrReviewTerminal(pi: ExtensionAPI): void {
108
+ registerPerkCommand(pi, SCOPE, {
109
+ description:
110
+ "Review a PR human-in-the-loop in the hunk terminal TUI: no arg reviews the active " +
111
+ "worktree's PR (or, pre-PR, opens a since-base hunk review); a PR number/URL reviews " +
112
+ "that foreign PR. Any other text is a focus note for the reviewers.",
113
+ handler: async (args, ctx: ExtensionContext) => {
114
+ // Entry gates, in order — nothing executed on refusal, each a loud error.
115
+ const parsed = parseReviewDoorArgs(args ?? "");
116
+ if (parsed === null) {
117
+ report(ctx, SCOPE, "error", "usage: /pr-review-terminal [pr number|url] [focus note]");
118
+ return;
119
+ }
120
+ if (!ctx.hasUI) {
121
+ report(
122
+ ctx,
123
+ SCOPE,
124
+ "error",
125
+ "/pr-review-terminal requires an interactive session — the hunk surface and the " +
126
+ "human triage are constitutive",
127
+ );
128
+ return;
129
+ }
130
+ if (!(await hunkPresent(pi, ctx))) {
131
+ report(
132
+ ctx,
133
+ SCOPE,
134
+ "error",
135
+ `the \`hunk\` review CLI is not available — install it: ${HUNK_INSTALL_HINT}`,
136
+ );
137
+ return;
138
+ }
139
+
140
+ const config = loadPerkConfig(ctx.cwd);
141
+ const model = config.subagents["adversarial-reviewer"] ?? "";
142
+
143
+ if (parsed.mode === "foreign") {
144
+ // The foreign arm: detached checkout + handoff.
145
+ const checkout = await runColdDoor<CheckoutOk>(
146
+ pi,
147
+ ctx,
148
+ ["pr", "review", "checkout", "--pr", String(parsed.pr), "--json"],
149
+ { label: "perk pr review checkout", decode: decodeCheckout },
150
+ );
151
+ if (!checkout.ok) {
152
+ report(
153
+ ctx,
154
+ SCOPE,
155
+ "error",
156
+ `perk pr review checkout failed (${checkout.errorType}): ${checkout.message}`,
157
+ { alsoLog: true },
158
+ );
159
+ return;
160
+ }
161
+ report(
162
+ ctx,
163
+ SCOPE,
164
+ "info",
165
+ parsed.directive
166
+ ? `PR #${parsed.pr} → adversarial reviewers (focus: ${parsed.directive}) → hunk triage → curated post`
167
+ : `PR #${parsed.pr} → adversarial reviewers → hunk triage → curated post`,
168
+ );
169
+ // 12 hex chars: the full sha wraps in the TUI and a wrapped paste runs a bare
170
+ // `hunk diff`; git resolves 12 chars unambiguously (the first dogfood's lesson).
171
+ const baseSha = checkout.data.base_sha.slice(0, 12);
172
+ const hunkCmd = `hunk diff ${baseSha} --agent-notes`;
173
+ const launchLine = `cd ${checkout.data.path} && ${hunkCmd}`;
174
+ await handleHunkLaunch(pi, ctx, {
175
+ cwd: checkout.data.path,
176
+ hunkCmd,
177
+ launchLine,
178
+ scope: SCOPE,
179
+ });
180
+ pi.sendUserMessage(
181
+ prReviewTerminalGuidance({
182
+ mode: "foreign",
183
+ pr: parsed.pr,
184
+ worktree: checkout.data.path,
185
+ baseSha,
186
+ model,
187
+ directive: parsed.directive,
188
+ }) + bindingSuffix(ctx.cwd, `command:${SCOPE}`),
189
+ );
190
+ return;
191
+ }
192
+
193
+ // The active arm: resolve the worktree's own PR via the shared active-PR ladder.
194
+ const r = await runColdDoor<{ number: number; url: string }>(
195
+ pi,
196
+ ctx,
197
+ ["pr", "url", "--json"],
198
+ { label: "perk pr url", decode: decodePrUrl },
199
+ );
200
+ const planRefBase = planRefBaseOf(ctx.cwd);
201
+ const target = resolveReviewTarget(r, planRefBase);
202
+ if (target.mode === "fail") {
203
+ report(
204
+ ctx,
205
+ SCOPE,
206
+ "error",
207
+ `${target.message} (${target.errorType}) — pass a PR number/URL, or run from a plan ` +
208
+ "worktree",
209
+ );
210
+ return;
211
+ }
212
+
213
+ // The since-base merge-base (best-effort fetch first): the sha hunk diffs the working tree
214
+ // against. On the local arm the target threads the plan-ref's pinned base; on the PR arm
215
+ // the plan-ref base is read the same way (null ⇒ repo default via origin/HEAD).
216
+ const fullSha = sinceBaseSha(
217
+ ctx.cwd,
218
+ target.mode === "local" ? target.defaultBranch : planRefBase,
219
+ );
220
+ if (fullSha === null) {
221
+ report(
222
+ ctx,
223
+ SCOPE,
224
+ "error",
225
+ "could not resolve the since-base merge-base — pass a PR number/URL instead",
226
+ );
227
+ return;
228
+ }
229
+ const baseSha = fullSha.slice(0, 12);
230
+
231
+ if (target.mode === "pr") {
232
+ report(
233
+ ctx,
234
+ SCOPE,
235
+ "info",
236
+ parsed.directive
237
+ ? `PR #${target.number} (active worktree) → adversarial reviewers (focus: ${parsed.directive}) → hunk triage → curated post`
238
+ : `PR #${target.number} (active worktree) → adversarial reviewers → hunk triage → curated post`,
239
+ );
240
+ } else {
241
+ report(
242
+ ctx,
243
+ SCOPE,
244
+ "info",
245
+ "no PR yet — since-base review in hunk; reviewers skipped (no PR to review)",
246
+ );
247
+ }
248
+
249
+ const hunkCmd = `hunk diff ${baseSha} --agent-notes`;
250
+ const launchLine = `cd ${ctx.cwd} && ${hunkCmd}`;
251
+ await handleHunkLaunch(pi, ctx, { cwd: ctx.cwd, hunkCmd, launchLine, scope: SCOPE });
252
+
253
+ const guidance =
254
+ target.mode === "pr"
255
+ ? prReviewTerminalGuidance({
256
+ mode: "active",
257
+ pr: target.number,
258
+ worktree: ctx.cwd,
259
+ baseSha,
260
+ model,
261
+ directive: parsed.directive,
262
+ })
263
+ : prReviewTerminalGuidance({ mode: "local", worktree: ctx.cwd, baseSha });
264
+ pi.sendUserMessage(guidance + bindingSuffix(ctx.cwd, `command:${SCOPE}`));
265
+ },
266
+ });
267
+ }