@mgiles/perk 3.1.0 → 3.3.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 (211) hide show
  1. package/README.md +5 -0
  2. package/extension/authoring/gist/draft.ts +198 -0
  3. package/extension/authoring/gist/prose.ts +46 -0
  4. package/extension/authoring/gist/review.ts +133 -0
  5. package/extension/authoring/gist/save.ts +118 -0
  6. package/extension/authoring/objective/draft.ts +345 -0
  7. package/extension/{factories/objectiveDreamReport.ts → authoring/objective/dreamReportGate.ts} +74 -131
  8. package/extension/authoring/objective/planning.ts +124 -0
  9. package/extension/authoring/objective/prose.ts +103 -0
  10. package/extension/authoring/objective/review.ts +128 -0
  11. package/extension/authoring/objective/save.ts +224 -0
  12. package/extension/authoring/plan/draft.ts +84 -0
  13. package/extension/authoring/plan/prose.ts +41 -0
  14. package/extension/authoring/plan/review.ts +269 -0
  15. package/extension/authoring/plan/save.ts +256 -0
  16. package/extension/authoring/plan/source.ts +82 -0
  17. package/extension/authoring/refinement/context.ts +468 -0
  18. package/extension/authoring/refinement/draft.ts +261 -0
  19. package/extension/authoring/refinement/prose.ts +79 -0
  20. package/extension/authoring/refinement/review.ts +111 -0
  21. package/extension/authoring/refinement/save.ts +119 -0
  22. package/extension/authoring/review/approvalGate.ts +34 -0
  23. package/extension/authoring/review/draftContext.ts +68 -0
  24. package/extension/codeReview/automated.ts +352 -0
  25. package/extension/codeReview/submission.ts +229 -0
  26. package/extension/delivery/address.ts +295 -0
  27. package/extension/delivery/ci.ts +355 -0
  28. package/extension/delivery/commitCompact.ts +93 -0
  29. package/extension/delivery/conflictResolution.ts +247 -0
  30. package/extension/delivery/ready.ts +193 -0
  31. package/extension/delivery/stackConflict.ts +361 -0
  32. package/extension/delivery/stackObjective.ts +16 -0
  33. package/extension/delivery/stackReconcile.ts +165 -0
  34. package/extension/delivery/submit.ts +171 -0
  35. package/extension/index.ts +369 -359
  36. package/extension/learning/analystWave.ts +324 -0
  37. package/extension/learning/audit.ts +667 -0
  38. package/extension/learning/capture.ts +92 -0
  39. package/extension/learning/containment.ts +104 -0
  40. package/extension/{waves/dreamWave.ts → learning/dream.ts} +112 -94
  41. package/extension/learning/dreamAnalysis.ts +435 -0
  42. package/extension/{waves/dreamReducerWave.ts → learning/dreamReducer.ts} +46 -41
  43. package/extension/{waves → learning}/dreamReport.ts +35 -31
  44. package/extension/learning/harvest.ts +491 -0
  45. package/extension/learning/prose.ts +66 -0
  46. package/extension/learning/routing.ts +79 -0
  47. package/extension/pi/v1/bashScanTimeout.ts +64 -0
  48. package/extension/{doors/prReview.ts → pi/v1/codeReview/automated.ts} +215 -310
  49. package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +125 -57
  50. package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
  51. package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +169 -116
  52. package/extension/pi/v1/codeReview/stack.ts +606 -0
  53. package/extension/pi/v1/codeReview/submit.ts +354 -0
  54. package/extension/{doors/prReviewTerminal.ts → pi/v1/codeReview/terminal.ts} +32 -27
  55. package/extension/pi/v1/contextEvidence.ts +80 -0
  56. package/extension/pi/v1/contextInjection.ts +207 -0
  57. package/extension/{doors → pi/v1/delivery}/address.ts +163 -265
  58. package/extension/pi/v1/delivery/ci.ts +570 -0
  59. package/extension/pi/v1/delivery/commitCompact.ts +201 -0
  60. package/extension/pi/v1/delivery/conflictResolverEngine.ts +425 -0
  61. package/extension/{doors → pi/v1/delivery}/land.ts +128 -60
  62. package/extension/pi/v1/delivery/ready.ts +322 -0
  63. package/extension/pi/v1/delivery/stackConflictResolver.ts +172 -0
  64. package/extension/pi/v1/delivery/stackDrive.ts +120 -0
  65. package/extension/pi/v1/delivery/stackLand.ts +223 -0
  66. package/extension/pi/v1/delivery/stackRecover.ts +265 -0
  67. package/extension/pi/v1/delivery/stackStatus.ts +237 -0
  68. package/extension/pi/v1/delivery/stackSync.ts +658 -0
  69. package/extension/pi/v1/delivery/submit.ts +389 -0
  70. package/extension/pi/v1/delivery/submitConflict.ts +186 -0
  71. package/extension/pi/v1/draftReview.ts +431 -0
  72. package/extension/{doors → pi/v1}/draftReviewWaveTools.ts +141 -151
  73. package/extension/pi/v1/gist.ts +794 -0
  74. package/extension/pi/v1/learning/audit.ts +186 -0
  75. package/extension/pi/v1/learning/dream.ts +207 -0
  76. package/extension/{doors/learnFactory.ts → pi/v1/learning/factory.ts} +18 -65
  77. package/extension/{doors/harvestWaveTools.ts → pi/v1/learning/harvest.ts} +46 -100
  78. package/extension/pi/v1/learning/learn.ts +585 -0
  79. package/extension/{doors → pi/v1}/lifecycleGates.ts +14 -59
  80. package/extension/{factories → pi/v1}/objective.ts +53 -33
  81. package/extension/pi/v1/objectiveAuthoring.ts +672 -0
  82. package/extension/pi/v1/objectiveDreamGate.ts +160 -0
  83. package/extension/pi/v1/objectivePlanning.ts +776 -0
  84. package/extension/pi/v1/objectiveRefinement.ts +1320 -0
  85. package/extension/pi/v1/objectiveReview.ts +451 -0
  86. package/extension/{doors → pi/v1}/objectiveReviewBrowser.ts +259 -172
  87. package/extension/pi/v1/plan.ts +812 -0
  88. package/extension/pi/v1/planReview.ts +820 -0
  89. package/extension/{doors → pi/v1}/planReviewBrowser.ts +228 -152
  90. package/extension/{doors/annotationPush.ts → pi/v1/providers/annotations.ts} +158 -89
  91. package/extension/pi/v1/providers/plannotator.ts +487 -0
  92. package/extension/{doors → pi/v1/providers}/plannotatorHandoff.ts +152 -34
  93. package/extension/pi/v1/providers/selection.ts +43 -0
  94. package/extension/{adapters/planAdapterTombell.ts → pi/v1/providers/tombell.ts} +43 -72
  95. package/extension/pi/v1/review.ts +538 -0
  96. package/extension/pi/v1/reviewOutcome.ts +9 -0
  97. package/extension/pi/v1/scoutWave.ts +318 -0
  98. package/extension/{doors → pi/v1}/selfcheck.ts +4 -4
  99. package/extension/session/branchWorkflowSession.ts +60 -0
  100. package/extension/session/lifecycle.ts +644 -0
  101. package/extension/session/lifecycleGates.ts +64 -0
  102. package/extension/session/saveDestination.ts +87 -0
  103. package/extension/session/workflowSession.ts +971 -0
  104. package/extension/substrate/agentScratch.ts +27 -54
  105. package/extension/substrate/bashScanTimeout.ts +181 -0
  106. package/extension/substrate/bindingDelivery.ts +38 -30
  107. package/extension/substrate/bindings.ts +4 -5
  108. package/extension/substrate/cache.ts +64 -12
  109. package/extension/substrate/childRestrictions.ts +39 -0
  110. package/extension/substrate/coldDoor.ts +17 -1
  111. package/extension/substrate/config.ts +157 -19
  112. package/extension/substrate/git.ts +88 -6
  113. package/extension/substrate/modelVisible.ts +53 -0
  114. package/extension/substrate/paths.ts +2 -7
  115. package/extension/substrate/prompts.ts +22 -0
  116. package/extension/substrate/registry.ts +2 -0
  117. package/extension/substrate/resolverLease.ts +364 -0
  118. package/extension/substrate/sessionData.ts +85 -152
  119. package/extension/substrate/toolGating.ts +279 -84
  120. package/extension/substrate/unifiedDiff.ts +1 -1
  121. package/extension/substrate/workflowState.ts +191 -166
  122. package/extension/substrate/worktreeResolverLock.ts +261 -0
  123. package/extension/surfaces/surfaces.ts +79 -27
  124. package/extension/waves/adversarialReviewWave.ts +103 -48
  125. package/extension/waves/blockedReports.ts +59 -0
  126. package/extension/waves/draftReviewWave.ts +42 -42
  127. package/extension/waves/laneIdentity.ts +77 -0
  128. package/extension/waves/objectiveExplorerWave.ts +24 -24
  129. package/extension/waves/prReviewWave.ts +89 -77
  130. package/extension/waves/reportWave.ts +438 -578
  131. package/extension/waves/reviewClassifierWave.ts +22 -22
  132. package/extension/waves/rpcAdapter.ts +100 -15
  133. package/extension/waves/scoutWave.ts +192 -0
  134. package/extension/waves/transport.ts +480 -0
  135. package/extension/worker/sdkAdapter.ts +494 -0
  136. package/extension/worker/stageExecution.ts +679 -0
  137. package/extension/workerMain.ts +18 -19
  138. package/package.json +6 -4
  139. package/prompts/_fixtures/live.yaml +98 -10
  140. package/prompts/contexts/adapters/plannotator-gist.md +6 -0
  141. package/prompts/contexts/adapters/plannotator-objective.md +6 -0
  142. package/prompts/contexts/adapters/plannotator-plan.md +8 -1
  143. package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
  144. package/prompts/contexts/adapters/tombell-plan.md +4 -0
  145. package/prompts/contexts/objective-refinement.md +17 -0
  146. package/prompts/contexts/plan-authoring.md +6 -5
  147. package/prompts/contexts/read-only.md +1 -1
  148. package/prompts/stages/conflict-resolution-continuation.md +9 -0
  149. package/prompts/stages/conflict-resolution.md +4 -4
  150. package/prompts/stages/objective-author/adopt.md +1 -1
  151. package/prompts/stages/objective-author/file.md +1 -1
  152. package/prompts/stages/objective-author/seed.md +1 -1
  153. package/prompts/stages/objective-plan/guidance.md +2 -2
  154. package/prompts/stages/objective-plan/seed.md +9 -1
  155. package/prompts/stages/objective-reconcile-ready.md +7 -0
  156. package/prompts/stages/objective-reconcile.md +1 -1
  157. package/prompts/stages/objective-refine/seed.md +18 -0
  158. package/prompts/stages/objective-review-browser.md +4 -4
  159. package/prompts/stages/objective-sync.md +1 -1
  160. package/prompts/stages/plan-review-browser.md +4 -4
  161. package/prompts/stages/pr-review-browser/active.md +3 -4
  162. package/prompts/stages/pr-review-browser/foreign.md +3 -4
  163. package/prompts/stages/pr-review-terminal/active.md +3 -3
  164. package/prompts/stages/pr-review-terminal/foreign.md +3 -3
  165. package/prompts/stages/pr-review.md +3 -3
  166. package/prompts/stages/stack-review/cold.md +1 -0
  167. package/prompts/stages/stack-review-browser/stack.md +22 -0
  168. package/shared/README.md +8 -3
  169. package/shared/bindings.yaml +6 -3
  170. package/shared/contracts.md +4499 -2147
  171. package/shared/fixtures/issues-table.json +130 -0
  172. package/shared/registry.yaml +29 -1
  173. package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
  174. package/shared/schemas/outputs/objective-stack-status.schema.json +178 -2
  175. package/shared/schemas/outputs/pr-ready.schema.json +110 -2
  176. package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
  177. package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
  178. package/extension/adapters/planAdapterPlannotator.ts +0 -362
  179. package/extension/doors/auditWaveTools.ts +0 -352
  180. package/extension/doors/ciExecutor.ts +0 -756
  181. package/extension/doors/commitCompact.ts +0 -251
  182. package/extension/doors/dreamWaveTools.ts +0 -475
  183. package/extension/doors/learn.ts +0 -655
  184. package/extension/doors/objectiveStack.ts +0 -1143
  185. package/extension/doors/prReviewDynamic.ts +0 -276
  186. package/extension/doors/ready.ts +0 -87
  187. package/extension/doors/submit.ts +0 -347
  188. package/extension/doors/submitPrReview.ts +0 -408
  189. package/extension/factories/gistAuthor.ts +0 -94
  190. package/extension/factories/gistDraft.ts +0 -265
  191. package/extension/factories/gistSave.ts +0 -251
  192. package/extension/factories/implementHere.ts +0 -116
  193. package/extension/factories/objectiveAuthor.ts +0 -98
  194. package/extension/factories/objectiveDraft.ts +0 -466
  195. package/extension/factories/objectivePlan.ts +0 -975
  196. package/extension/factories/objectiveSave.ts +0 -363
  197. package/extension/factories/planDraft.ts +0 -140
  198. package/extension/factories/planMode.ts +0 -205
  199. package/extension/factories/planReview.ts +0 -1237
  200. package/extension/factories/planSave.ts +0 -604
  201. package/extension/factories/planTitle.ts +0 -141
  202. package/extension/substrate/structuredOutput.ts +0 -202
  203. package/extension/waves/auditWave.ts +0 -312
  204. package/extension/waves/harvestWave.ts +0 -399
  205. package/extension/waves/learnWave.ts +0 -155
  206. package/extension/waves/memoryAdapter.ts +0 -139
  207. package/extension/waves/prReviewDynamicWave.ts +0 -777
  208. package/extension/worker/readOnlySession.ts +0 -294
  209. package/extension/worker/worker.ts +0 -899
  210. package/prompts/stages/pr-review-dynamic.md +0 -7
  211. package/shared/contracts-history.md +0 -605
@@ -0,0 +1,606 @@
1
+ // The warm `/stack-review-browser` door + the `open_stack_review` cold-launch tool: the BROWSER
2
+ // entry into human-in-the-loop adversarial review of an ENTIRE PR stack (contracts §8.4) — one
3
+ // plannotator session over the combined diff (stack base → top head), one reviewer wave over
4
+ // that combined diff (`start_review_wave` with `stack: true`), and the judgment-routed per-PR
5
+ // posting protocol through `submit_pr_review`.
6
+ //
7
+ // TARGET GRAMMAR (explicit, no error-conditioned fallback probing):
8
+ // /stack-review-browser [target] [focus note]
9
+ // where target is an objective id (`77` / `#77` / an issue URL — bare numbers are objective ids
10
+ // BY DEFINITION of the grammar), `pr:<n>` or a PR URL (the non-perk chain arm), or absent. The
11
+ // no-target ladder: the session's rebuilt workflow-state `active_objective` (passed explicitly
12
+ // as the objective id) → else the checkout worker with no id (its `cache.plan-ref` arm) → a
13
+ // `no_objective` failure is a typed usage refusal naming the explicit forms.
14
+ //
15
+ // THE COMBINED DIFF is rendered by plannotator itself: the cold checkout worker materializes a
16
+ // detached checkout of the TOP stack head, and the door opens plannotator in local mode with
17
+ // `{diffType: "since-base", defaultBranch: "origin/<stack base>"}` — the REMOTE-TRACKING ref the
18
+ // checkout actually materializes (plannotator trusts an explicit base verbatim and degrades a
19
+ // failed merge-base to HEAD, which would render an empty review — a bare branch name that only
20
+ // exists on the remote would do exactly that).
21
+ //
22
+ // THE POSTING CONTRACT (the delta from /pr-review-browser): a local-diff session has NO attached
23
+ // PR, so the browser has no platform-posting path — ALL GitHub posting is perk-side after the
24
+ // human triage, judgment-routed per member PR (dry-run ALL batches first, bottom→top, per-PR
25
+ // confirm for formal events). The stack respond mapper (`stackRespondMessage`) and the stack
26
+ // degrade notice both carry that framing.
27
+ //
28
+ // `open_stack_review` is the cold-launch twin (the `run_audit_wave` posture): NO parameters —
29
+ // the pinned stack snapshot comes ONLY from the `perk objective stack review` launch handoff
30
+ // (`stack_review`, recovered via the rebuilt workflow-state run_id), so no model-relayed path
31
+ // can aim the flow anywhere. Single-use per session; it runs the SAME extracted lifecycle core
32
+ // and returns the rendered stack.md guidance as its ok text.
33
+
34
+ import { existsSync } from "node:fs";
35
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
36
+ import { openBranchWorkflowSession } from "../../../session/branchWorkflowSession.ts";
37
+ import type { WorkflowSession } from "../../../session/workflowSession.ts";
38
+ import { bindingSuffix } from "../../../substrate/bindingDelivery.ts";
39
+ import { readHandoff } from "../../../substrate/cache.ts";
40
+ import { type ColdJson, runColdDoor } from "../../../substrate/coldDoor.ts";
41
+ import { registerPerkCommand } from "../../../substrate/command.ts";
42
+ import { render } from "../../../substrate/prompts.ts";
43
+ import { failFor, ok } from "../../../substrate/result.ts";
44
+ import { report } from "../../../surfaces/report.ts";
45
+ import type { ActivityHandle } from "../../../surfaces/surfaces.ts";
46
+ import type { AnnotationState } from "../providers/annotations.ts";
47
+ import {
48
+ LOCAL_REVIEW_DIFF_TYPE,
49
+ plannotatorPresent,
50
+ stackRespondMessage,
51
+ } from "../providers/plannotatorHandoff.ts";
52
+ import { openReviewBrowserCore } from "./browser.ts";
53
+ import { type CheckoutOk, decodeCheckout, PR_URL_RE } from "./checkout.ts";
54
+
55
+ /** The door's report scope — also the `command:<id>` binding trigger id. */
56
+ const SCOPE = "stack-review-browser";
57
+
58
+ // ------------------------------------------------------------------------ the target grammar
59
+
60
+ /** A parsed `/stack-review-browser` target: objective arm, chain arm, or the no-target ladder. */
61
+ export type StackReviewTarget =
62
+ | { kind: "objective"; id: string }
63
+ | { kind: "pr"; pr: number }
64
+ | { kind: "auto" };
65
+
66
+ export interface StackReviewArgs {
67
+ target: StackReviewTarget;
68
+ directive: string;
69
+ }
70
+
71
+ /** Extracts the issue number from a GitHub issue URL (the objective-id URL form). */
72
+ const ISSUE_URL_RE = /\/issues\/(\d+)(?:\/|$|#|\?)/;
73
+
74
+ /** A backend-native objective id (Linear's `ENG-123` shape — the Python `parse_objective_id`
75
+ * ident grammar, mirrored so an explicit target never silently degrades to a focus note). */
76
+ const NATIVE_ID_RE = /^[A-Za-z0-9]+-\d+$/;
77
+
78
+ /** Peel a Linear issue/project URL down to its opaque objective id (null = not one). */
79
+ function linearIdFromUrl(token: string): string | null {
80
+ let url: URL;
81
+ try {
82
+ url = new URL(token);
83
+ } catch {
84
+ return null;
85
+ }
86
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
87
+ const host = url.hostname;
88
+ if (host !== "linear.app" && !host.endsWith(".linear.app")) return null;
89
+ const segments = url.pathname.split("/").filter((s) => s !== "");
90
+ for (let i = 0; i < segments.length - 1; i++) {
91
+ const seg = segments[i];
92
+ const next = segments[i + 1];
93
+ if (next === undefined) break;
94
+ if (seg === "issue" && NATIVE_ID_RE.test(next)) return next;
95
+ if (seg === "project") return next;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Parse the explicit target grammar (pure, offline-tested). Bare numbers (and `#n`,
102
+ * backend-native ids like `ENG-123`, GitHub issue URLs, and Linear issue/project URLs — the
103
+ * Python `parse_objective_id` grammar) are OBJECTIVE ids by definition; the chain arm is
104
+ * `pr:<n>` or a PR URL. A first token that is none of these makes the WHOLE string the focus
105
+ * note (target absent — the ladder). Null only on a malformed `pr:` token (a usage failure,
106
+ * never silently a focus note).
107
+ */
108
+ export function parseStackReviewArgs(args: string): StackReviewArgs | null {
109
+ const trimmed = args.trim();
110
+ if (trimmed.length === 0) return { target: { kind: "auto" }, directive: "" };
111
+ const split = trimmed.match(/^(\S+)(?:\s+([\s\S]*))?$/);
112
+ const first = split?.[1] ?? "";
113
+ const rest = (split?.[2] ?? "").trim();
114
+ if (/^pr:/i.test(first)) {
115
+ const prToken = first.match(/^pr:(\d+)$/i);
116
+ if (prToken?.[1] === undefined) return null;
117
+ return { target: { kind: "pr", pr: Number(prToken[1]) }, directive: rest };
118
+ }
119
+ const prUrl = first.match(PR_URL_RE);
120
+ if (prUrl?.[1] !== undefined) {
121
+ return { target: { kind: "pr", pr: Number(prUrl[1]) }, directive: rest };
122
+ }
123
+ const bare = first.match(/^#?(\d+)$/);
124
+ if (bare?.[1] !== undefined) {
125
+ return { target: { kind: "objective", id: bare[1] }, directive: rest };
126
+ }
127
+ const issueUrl = first.match(ISSUE_URL_RE);
128
+ if (issueUrl?.[1] !== undefined) {
129
+ return { target: { kind: "objective", id: issueUrl[1] }, directive: rest };
130
+ }
131
+ const linearId = linearIdFromUrl(first);
132
+ if (linearId !== null) {
133
+ return { target: { kind: "objective", id: linearId }, directive: rest };
134
+ }
135
+ if (NATIVE_ID_RE.test(first)) {
136
+ return { target: { kind: "objective", id: first }, directive: rest };
137
+ }
138
+ return { target: { kind: "auto" }, directive: trimmed };
139
+ }
140
+
141
+ // ------------------------------------------------------------------------ the snapshot decode
142
+
143
+ /** One pinned stack-snapshot row (the checkout envelope's `stack[]` / the handoff's rows). */
144
+ export interface StackSnapshotRow {
145
+ pr: number;
146
+ url: string;
147
+ branch: string;
148
+ head_sha: string;
149
+ base_ref: string;
150
+ node_id: string | null;
151
+ plan_id: string | null;
152
+ }
153
+
154
+ /** The `perk pr review checkout --stack --json` ok-arm: the single-PR fields + the snapshot
155
+ * (`base_ref` IS the combined-diff/stack base on the stack envelope — no separate field). */
156
+ export interface StackCheckoutOk extends CheckoutOk {
157
+ stack: StackSnapshotRow[];
158
+ stack_notes: string[];
159
+ }
160
+
161
+ function decodeSnapshotRow(item: unknown): StackSnapshotRow | null {
162
+ if (typeof item !== "object" || item === null || Array.isArray(item)) return null;
163
+ const raw = item as Record<string, unknown>;
164
+ const { pr, url, branch, head_sha, base_ref, node_id, plan_id } = raw;
165
+ if (typeof pr !== "number" || !Number.isInteger(pr)) return null;
166
+ if (typeof url !== "string" || typeof branch !== "string") return null;
167
+ if (typeof head_sha !== "string" || typeof base_ref !== "string") return null;
168
+ if (node_id !== null && typeof node_id !== "string") return null;
169
+ if (plan_id !== null && typeof plan_id !== "string") return null;
170
+ return { pr, url, branch, head_sha, base_ref, node_id, plan_id };
171
+ }
172
+
173
+ function decodeSnapshotRows(raw: unknown): StackSnapshotRow[] | null {
174
+ if (!Array.isArray(raw) || raw.length === 0) return null;
175
+ const rows: StackSnapshotRow[] = [];
176
+ for (const item of raw) {
177
+ const row = decodeSnapshotRow(item);
178
+ if (row === null) return null;
179
+ rows.push(row);
180
+ }
181
+ return rows;
182
+ }
183
+
184
+ function decodeStringArray(raw: unknown): string[] | null {
185
+ if (!Array.isArray(raw)) return null;
186
+ return raw.every((n) => typeof n === "string") ? (raw as string[]) : null;
187
+ }
188
+
189
+ /** Strict decode of the `--stack` checkout envelope (the pinned snapshot the door reads). */
190
+ export function decodeStackCheckout(payload: ColdJson): StackCheckoutOk | null {
191
+ const base = decodeCheckout(payload);
192
+ if (base === null) return null;
193
+ const stack = decodeSnapshotRows(payload.stack);
194
+ const stackNotes = decodeStringArray(payload.stack_notes);
195
+ if (stack === null || stackNotes === null) return null;
196
+ return { ...base, stack, stack_notes: stackNotes };
197
+ }
198
+
199
+ // ------------------------------------------------------------------------ guidance
200
+
201
+ /** The guidance inputs (the pinned snapshot slice both entry paths render from). */
202
+ export interface StackReviewGuidanceOpts {
203
+ topPr: number;
204
+ checkout: string;
205
+ stackBase: string;
206
+ /** Ordered bottom→top. */
207
+ members: StackSnapshotRow[];
208
+ notes: string[];
209
+ directive?: string;
210
+ }
211
+
212
+ /**
213
+ * The seed guidance both entry paths share verbatim (the warm door injects it; the cold-launch
214
+ * tool returns it as ok text). Pure + exported for offline tests. The member table and notes
215
+ * are pre-rendered here (the frozen mini-jinja subset has no loops).
216
+ */
217
+ export function stackReviewGuidance(opts: StackReviewGuidanceOpts): string {
218
+ const table = opts.members
219
+ .map((member, index) => {
220
+ const node = member.node_id === null ? "" : ` · node ${member.node_id}`;
221
+ const plan = member.plan_id === null ? "" : ` · plan #${member.plan_id}`;
222
+ return (
223
+ `${index + 1}. PR #${member.pr} \`${member.branch}\` ← \`${member.base_ref}\`` +
224
+ `${node}${plan} — ${member.url}`
225
+ );
226
+ })
227
+ .join("\n");
228
+ const notes = opts.notes.map((note) => `- ${note}`).join("\n");
229
+ return render("stages/stack-review-browser/stack.md", {
230
+ top_pr: String(opts.topPr),
231
+ checkout: opts.checkout,
232
+ stack_base: opts.stackBase,
233
+ member_count: String(opts.members.length),
234
+ stack_table: table,
235
+ notes,
236
+ directive: opts.directive ?? "",
237
+ });
238
+ }
239
+
240
+ // ------------------------------------------------------------------------ the degrade notice
241
+
242
+ /**
243
+ * The stack degrade notice (browser never ready): findings render in-session and the triage
244
+ * runs conversationally; the routing + per-PR posting protocol is unchanged — it never
245
+ * depended on the browser.
246
+ */
247
+ export const STACK_DEGRADE_NOTICE =
248
+ "The plannotator browser review is unavailable (the review server never became ready) — " +
249
+ "degrade in-session: render the reviewers' reconciled findings as a table in your reply and " +
250
+ "run the same triage loop conversationally. The annotation surface is cleared — " +
251
+ "`push_annotations` now refuses (`no_surface`); render findings in-session. The routing + " +
252
+ "per-PR posting protocol is unchanged (it never depended on the browser): dry-run ALL " +
253
+ "per-PR batches first, then post bottom→top via `submit_pr_review` — only what the human " +
254
+ "approves.";
255
+
256
+ // ------------------------------------------------------------------------ the shared open
257
+
258
+ /** Open the stack browser session through the extracted lifecycle core (both entry paths). */
259
+ async function openStackBrowser(
260
+ pi: ExtensionAPI,
261
+ ctx: ExtensionContext,
262
+ annotations: AnnotationState,
263
+ status: ActivityHandle,
264
+ opts: {
265
+ checkoutPath: string;
266
+ stackBaseRef: string;
267
+ guidance: string;
268
+ injectGuidance: boolean;
269
+ },
270
+ ): Promise<boolean> {
271
+ return await openReviewBrowserCore(pi, ctx, annotations, status, {
272
+ scope: SCOPE,
273
+ browserOpts: {
274
+ cwd: opts.checkoutPath,
275
+ diffType: LOCAL_REVIEW_DIFF_TYPE,
276
+ // The remote-tracking ref the checkout materialized — an explicit base plannotator
277
+ // trusts verbatim (a bare branch name would degrade to an empty HEAD diff).
278
+ defaultBranch: `origin/${opts.stackBaseRef}`,
279
+ },
280
+ guidance: opts.guidance,
281
+ degradeNotice: STACK_DEGRADE_NOTICE,
282
+ respondMessageFor: stackRespondMessage,
283
+ injectGuidance: opts.injectGuidance,
284
+ });
285
+ }
286
+
287
+ // ------------------------------------------------------------------------ the warm door
288
+
289
+ /** Register the warm `/stack-review-browser` command (posting rides submit_pr_review). */
290
+ function registerStackReviewBrowser(
291
+ pi: ExtensionAPI,
292
+ annotations: AnnotationState,
293
+ status: ActivityHandle,
294
+ ): void {
295
+ registerPerkCommand(pi, SCOPE, {
296
+ description:
297
+ "Review a whole PR stack human-in-the-loop in the plannotator browser UI over the " +
298
+ "combined diff: no arg reviews the session/plan-ref objective's delivery train; an " +
299
+ "objective id (42, #42, ENG-123) or issue/project URL targets that objective; pr:<n> or " +
300
+ "a PR URL walks the base-ref chain. Any other text is a focus note. Posting is " +
301
+ "perk-side, judgment-routed per member PR.",
302
+ handler: async (args, ctx: ExtensionContext) => {
303
+ // Entry gates, in order — nothing executed on refusal, each a loud error.
304
+ const parsed = parseStackReviewArgs(args ?? "");
305
+ if (parsed === null) {
306
+ report(
307
+ ctx,
308
+ SCOPE,
309
+ "error",
310
+ "usage: /stack-review-browser [objective id|issue URL|pr:<n>|PR URL] [focus note]",
311
+ );
312
+ return;
313
+ }
314
+ if (!ctx.hasUI) {
315
+ report(
316
+ ctx,
317
+ SCOPE,
318
+ "error",
319
+ "/stack-review-browser requires an interactive session — the plannotator browser " +
320
+ "surface and the human are constitutive",
321
+ );
322
+ return;
323
+ }
324
+ if (!plannotatorPresent(pi)) {
325
+ report(
326
+ ctx,
327
+ SCOPE,
328
+ "error",
329
+ "the plannotator extension is not loaded (its /plannotator-review command was not " +
330
+ "found) — select the plannotator plan provider (`[providers] plan = " +
331
+ '"plannotator-plan"`), run `perk init`, then restart pi',
332
+ );
333
+ return;
334
+ }
335
+
336
+ const argv = ["pr", "review", "checkout", "--stack"];
337
+ if (parsed.target.kind === "pr") {
338
+ argv.push("--pr", String(parsed.target.pr));
339
+ } else if (parsed.target.kind === "objective") {
340
+ argv.push("--objective", parsed.target.id);
341
+ } else {
342
+ // The no-target ladder: the session's active objective (the seam's fail-open read),
343
+ // passed EXPLICITLY; else the worker's own cache.plan-ref arm (bare --stack).
344
+ const active = openBranchWorkflowSession(pi, ctx).activeObjective();
345
+ if (active !== null && active.trim() !== "") {
346
+ argv.push("--objective", active.trim());
347
+ }
348
+ }
349
+ argv.push("--json");
350
+
351
+ const checkout = await runColdDoor<StackCheckoutOk>(pi, ctx, argv, {
352
+ label: "perk pr review checkout --stack",
353
+ decode: decodeStackCheckout,
354
+ });
355
+ if (!checkout.ok) {
356
+ if (checkout.errorType === "no_objective") {
357
+ report(
358
+ ctx,
359
+ SCOPE,
360
+ "error",
361
+ "no stack target: pass an objective id / issue URL, pr:<n> / a PR URL, or run " +
362
+ "from a session/worktree linked to a stacked objective",
363
+ );
364
+ return;
365
+ }
366
+ report(
367
+ ctx,
368
+ SCOPE,
369
+ "error",
370
+ `perk pr review checkout --stack failed (${checkout.errorType}): ${checkout.message}`,
371
+ { alsoLog: true },
372
+ );
373
+ return;
374
+ }
375
+
376
+ const data = checkout.data;
377
+ report(
378
+ ctx,
379
+ SCOPE,
380
+ "info",
381
+ `stack of ${data.stack.length} PRs (base ${data.base_ref}, top #${data.pr})` +
382
+ (parsed.directive
383
+ ? ` → adversarial reviewers (focus: ${parsed.directive})`
384
+ : " → adversarial reviewers") +
385
+ " → plannotator browser triage → judgment-routed per-PR posting",
386
+ );
387
+ await openStackBrowser(pi, ctx, annotations, status, {
388
+ checkoutPath: data.path,
389
+ stackBaseRef: data.base_ref,
390
+ guidance:
391
+ stackReviewGuidance({
392
+ topPr: data.pr,
393
+ checkout: data.path,
394
+ stackBase: data.base_ref,
395
+ members: data.stack,
396
+ notes: data.stack_notes,
397
+ ...(parsed.directive ? { directive: parsed.directive } : {}),
398
+ }) + bindingSuffix(ctx.cwd, `command:${SCOPE}`),
399
+ injectGuidance: true,
400
+ });
401
+ },
402
+ });
403
+ }
404
+
405
+ // ------------------------------------------------------------------------ the cold-launch tool
406
+
407
+ /** The decoded `stack_review` launch binding (the launcher's `handoff_extra` blob) — exactly
408
+ * what the tool consumes: the pinned snapshot rows, the checkout path, the notes, and the
409
+ * focus. The top PR and the stack base are DERIVED from the ordered rows (last row's `pr`;
410
+ * first row's `base_ref`), never carried redundantly. */
411
+ export interface StackReviewBinding {
412
+ stack: StackSnapshotRow[];
413
+ checkout_path: string;
414
+ notes: string[];
415
+ focus: string | null;
416
+ }
417
+
418
+ /** The derived stack endpoints (the binding's rows are ordered bottom→top, never empty). */
419
+ export function bindingTopPr(binding: StackReviewBinding): number {
420
+ const top = binding.stack[binding.stack.length - 1];
421
+ return top === undefined ? 0 : top.pr;
422
+ }
423
+
424
+ export function bindingBaseRef(binding: StackReviewBinding): string {
425
+ return binding.stack[0]?.base_ref ?? "";
426
+ }
427
+
428
+ /**
429
+ * Strict decode of the handoff's `stack_review` blob; null on ANY drift (⇒ bad_state). Every
430
+ * field is REQUIRED — `stack` a non-empty row array, `checkout_path` a non-empty string,
431
+ * `notes` a string array, `focus` present as a string or null (the one normalization: a
432
+ * blank/whitespace-only focus string decodes to null — "no focus", matching the launcher's
433
+ * no-flag arm).
434
+ */
435
+ export function decodeStackReviewBinding(raw: unknown): StackReviewBinding | null {
436
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
437
+ const b = raw as Record<string, unknown>;
438
+ const stack = decodeSnapshotRows(b.stack);
439
+ const notes = decodeStringArray(b.notes);
440
+ if (stack === null || notes === null) return null;
441
+ if (typeof b.checkout_path !== "string" || b.checkout_path === "") return null;
442
+ if (!("focus" in b)) return null;
443
+ if (b.focus !== null && typeof b.focus !== "string") return null;
444
+ return {
445
+ stack,
446
+ checkout_path: b.checkout_path,
447
+ notes,
448
+ focus: typeof b.focus === "string" && b.focus.trim() !== "" ? b.focus : null,
449
+ };
450
+ }
451
+
452
+ /** Recover the launch binding: the session seam's run identity → the run's handoff blob (the
453
+ * `audit_bundle_dir` recovery seam). Null when absent — i.e. in every session that is not a
454
+ * claimed `perk objective stack review` launch. */
455
+ export function stackReviewBindingOf(
456
+ ctx: ExtensionContext,
457
+ session: WorkflowSession,
458
+ ): StackReviewBinding | null {
459
+ const runId = session.runId;
460
+ if (runId === null || runId === "") return null;
461
+ const raw = readHandoff(ctx.cwd, runId)?.stack_review;
462
+ if (raw === undefined) return null;
463
+ return decodeStackReviewBinding(raw);
464
+ }
465
+
466
+ const TOOL_GUIDELINES = [
467
+ "Call open_stack_review ONCE, with no arguments, inside the perk objective stack review session — the stack snapshot is bound to the session by the cold door (launch handoff), never passed by you.",
468
+ "Follow the returned guidance exactly: launch the reviewer wave with stack: true, stream findings via push_annotations, and run the judgment-routed per-PR posting protocol through submit_pr_review (dry-run ALL batches first, bottom→top, only what the human approves).",
469
+ "The tool is single-use per session; a bad_state failure means this session is not a stack-review launch (or the checkout is gone) — re-run perk objective stack review.",
470
+ ];
471
+
472
+ /** The single-use latch (registration-scoped state, injectable for the execute-core tests). */
473
+ export interface OpenLatch {
474
+ opened: boolean;
475
+ }
476
+
477
+ /** The injectable browser-open seam (the execute-core tests force the failure arm). */
478
+ type StackBrowserOpen = typeof openStackBrowser;
479
+
480
+ /**
481
+ * The `open_stack_review` execute core (exported for direct tests — the `executeStartReviewWave`
482
+ * posture): every gate in registration order, then the shared browser open.
483
+ */
484
+ export async function executeOpenStackReview(
485
+ pi: ExtensionAPI,
486
+ ctx: ExtensionContext,
487
+ latch: OpenLatch,
488
+ annotations: AnnotationState,
489
+ status: ActivityHandle,
490
+ open: StackBrowserOpen = openStackBrowser,
491
+ ): Promise<ReturnType<typeof ok> | ReturnType<ReturnType<typeof failFor>>> {
492
+ const fail = failFor(ctx, "open_stack_review");
493
+ if (!ctx.hasUI) {
494
+ return fail(
495
+ "open_stack_review requires an interactive session — the plannotator browser " +
496
+ "surface and the human are constitutive",
497
+ "headless",
498
+ );
499
+ }
500
+ if (latch.opened) {
501
+ return fail(
502
+ "the stack review browser was already opened in this session (single-use) — " +
503
+ "continue the flow from the earlier guidance",
504
+ "bad_state",
505
+ );
506
+ }
507
+ // The structural binding: no param exists, so the ONLY reachable snapshot is the one the
508
+ // cold door bound into this session's launch handoff (the branch session constructed at the
509
+ // execute site — identity through the seam, never a direct state read).
510
+ const binding = stackReviewBindingOf(ctx, openBranchWorkflowSession(pi, ctx));
511
+ if (binding === null) {
512
+ return fail(
513
+ "no stack_review binding in this session's launch state — open_stack_review runs " +
514
+ "only inside a perk objective stack review session",
515
+ "bad_state",
516
+ );
517
+ }
518
+ if (!existsSync(binding.checkout_path)) {
519
+ return fail(
520
+ `the stack checkout is missing at '${binding.checkout_path}' — re-run perk ` +
521
+ "objective stack review",
522
+ "bad_state",
523
+ );
524
+ }
525
+ if (!plannotatorPresent(pi)) {
526
+ return fail(
527
+ "the plannotator extension is not loaded (its /plannotator-review command was not " +
528
+ "found) — select the plannotator plan provider, run `perk init`, then restart pi",
529
+ "plannotator_missing",
530
+ );
531
+ }
532
+ const guidance = stackReviewGuidance({
533
+ topPr: bindingTopPr(binding),
534
+ checkout: binding.checkout_path,
535
+ stackBase: bindingBaseRef(binding),
536
+ members: binding.stack,
537
+ notes: binding.notes,
538
+ ...(binding.focus !== null ? { directive: binding.focus } : {}),
539
+ });
540
+ const started = await open(pi, ctx, annotations, status, {
541
+ checkoutPath: binding.checkout_path,
542
+ stackBaseRef: bindingBaseRef(binding),
543
+ guidance,
544
+ injectGuidance: false,
545
+ });
546
+ if (!started) {
547
+ return fail(
548
+ "could not start the plannotator review server (no free local port) — see the " +
549
+ "error report",
550
+ "browser_failed",
551
+ );
552
+ }
553
+ latch.opened = true;
554
+ return ok(guidance, {
555
+ top_pr: bindingTopPr(binding),
556
+ checkout_path: binding.checkout_path,
557
+ member_count: binding.stack.length,
558
+ });
559
+ }
560
+
561
+ /**
562
+ * Register the parameterless `open_stack_review` tool (the `run_audit_wave` posture) over a
563
+ * registration-owned single-use latch (a fresh activation is a fresh session).
564
+ */
565
+ function registerOpenStackReview(
566
+ pi: ExtensionAPI,
567
+ annotations: AnnotationState,
568
+ status: ActivityHandle,
569
+ ): void {
570
+ const latch: OpenLatch = { opened: false };
571
+
572
+ pi.registerTool({
573
+ name: "open_stack_review",
574
+ label: "Open stack review",
575
+ description:
576
+ "Open the launch-bound stacked-PR browser review (the perk objective stack review " +
577
+ "session's ONE opener): starts the plannotator browser over the combined stack diff, " +
578
+ "primes the annotation surface, and returns the full flow guidance. No parameters: the " +
579
+ "stack snapshot comes only from the launch handoff. Single-use per session.",
580
+ promptSnippet: "Open the launch-bound stacked-PR browser review",
581
+ promptGuidelines: TOOL_GUIDELINES,
582
+ executionMode: "sequential",
583
+ parameters: {
584
+ type: "object",
585
+ additionalProperties: false,
586
+ properties: {},
587
+ },
588
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
589
+ return await executeOpenStackReview(pi, ctx, latch, annotations, status);
590
+ },
591
+ });
592
+ }
593
+
594
+ /**
595
+ * Install the Delivery-train review surface: the warm `/stack-review-browser` door + its
596
+ * cold-launch twin (`open_stack_review`). Takes the threaded per-activation annotation state —
597
+ * both openers prime it through `openReviewBrowserCore`.
598
+ */
599
+ export function installStackReviewBindings(
600
+ pi: ExtensionAPI,
601
+ annotations: AnnotationState,
602
+ status: ActivityHandle,
603
+ ): void {
604
+ registerStackReviewBrowser(pi, annotations, status);
605
+ registerOpenStackReview(pi, annotations, status);
606
+ }