@mgiles/perk 3.0.0 → 3.2.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 (89) hide show
  1. package/extension/adapters/planAdapterPlannotator.ts +12 -9
  2. package/extension/doors/address.ts +11 -0
  3. package/extension/doors/commitCompact.ts +98 -10
  4. package/extension/doors/draftReviewWaveTools.ts +43 -15
  5. package/extension/doors/dreamWaveTools.ts +489 -0
  6. package/extension/doors/land.ts +6 -0
  7. package/extension/doors/learn.ts +16 -3
  8. package/extension/doors/lifecycleGates.ts +36 -1
  9. package/extension/doors/objectiveReviewBrowser.ts +36 -13
  10. package/extension/doors/objectiveStack.ts +424 -24
  11. package/extension/doors/planReviewBrowser.ts +30 -8
  12. package/extension/doors/plannotatorHandoff.ts +80 -8
  13. package/extension/doors/prReview.ts +158 -50
  14. package/extension/doors/prReviewBrowser.ts +75 -27
  15. package/extension/doors/prReviewDynamic.ts +33 -13
  16. package/extension/doors/ready.ts +209 -17
  17. package/extension/doors/reviewWaveTools.ts +61 -17
  18. package/extension/doors/stackReviewBrowser.ts +573 -0
  19. package/extension/doors/submit.ts +36 -10
  20. package/extension/doors/submitPrReview.ts +116 -19
  21. package/extension/factories/objectiveDraft.ts +95 -27
  22. package/extension/factories/objectiveDreamReport.ts +347 -0
  23. package/extension/factories/objectivePlan.ts +12 -6
  24. package/extension/factories/objectiveSave.ts +77 -1
  25. package/extension/factories/planReview.ts +173 -10
  26. package/extension/index.ts +88 -16
  27. package/extension/substrate/agentScratch.ts +171 -0
  28. package/extension/substrate/bindingDelivery.ts +9 -11
  29. package/extension/substrate/cache.ts +92 -2
  30. package/extension/substrate/command.ts +9 -6
  31. package/extension/substrate/config.ts +10 -3
  32. package/extension/substrate/git.ts +85 -2
  33. package/extension/substrate/paths.ts +2 -7
  34. package/extension/substrate/resolverLease.ts +363 -0
  35. package/extension/substrate/result.ts +3 -2
  36. package/extension/substrate/sessionData.ts +6 -4
  37. package/extension/substrate/sessionPointers.ts +3 -4
  38. package/extension/substrate/toolGating.ts +25 -0
  39. package/extension/substrate/workflowState.ts +57 -5
  40. package/extension/surfaces/report.ts +38 -12
  41. package/extension/surfaces/surfaces.ts +129 -7
  42. package/extension/vendor/btw/btw.ts +38 -6
  43. package/extension/waves/adversarialReviewWave.ts +34 -3
  44. package/extension/waves/draftReviewWave.ts +17 -1
  45. package/extension/waves/dreamReducerWave.ts +700 -0
  46. package/extension/waves/dreamReport.ts +1494 -0
  47. package/extension/waves/dreamWave.ts +927 -0
  48. package/extension/waves/harvestWave.ts +1 -1
  49. package/extension/waves/ponytail.ts +104 -0
  50. package/extension/waves/prReviewDynamicWave.ts +115 -34
  51. package/extension/waves/prReviewWave.ts +122 -17
  52. package/extension/waves/reportWave.ts +103 -7
  53. package/extension/worker/readOnlySession.ts +2 -3
  54. package/package.json +6 -3
  55. package/prompts/_fixtures/live.yaml +112 -0
  56. package/prompts/commit-and-compact-continuation.md +13 -0
  57. package/prompts/contexts/adapters/plannotator-objective.md +7 -1
  58. package/prompts/contexts/adapters/plannotator-plan.md +7 -1
  59. package/prompts/contexts/adapters/tombell-plan.md +4 -0
  60. package/prompts/contexts/plan-authoring.md +6 -5
  61. package/prompts/stages/conflict-resolution-continuation.md +6 -0
  62. package/prompts/stages/conflict-resolution.md +2 -2
  63. package/prompts/stages/learn-dream.md +10 -0
  64. package/prompts/stages/objective-author/adopt.md +1 -1
  65. package/prompts/stages/objective-author/file.md +1 -1
  66. package/prompts/stages/objective-author/seed.md +1 -1
  67. package/prompts/stages/objective-reconcile-ready.md +7 -0
  68. package/prompts/stages/objective-review-browser.md +1 -1
  69. package/prompts/stages/objective-sync.md +1 -1
  70. package/prompts/stages/plan-review-browser.md +1 -1
  71. package/prompts/stages/pr-review-browser/active.md +1 -1
  72. package/prompts/stages/pr-review-browser/foreign.md +1 -1
  73. package/prompts/stages/pr-review-dynamic.md +5 -5
  74. package/prompts/stages/pr-review-terminal/active.md +1 -1
  75. package/prompts/stages/pr-review-terminal/foreign.md +1 -1
  76. package/prompts/stages/pr-review-terminal/local.md +1 -1
  77. package/prompts/stages/pr-review.md +5 -5
  78. package/prompts/stages/stack-review/cold.md +1 -0
  79. package/prompts/stages/stack-review-browser/stack.md +23 -0
  80. package/shared/README.md +0 -3
  81. package/shared/bindings.yaml +6 -0
  82. package/shared/contracts.md +3784 -1851
  83. package/shared/registry.yaml +28 -13
  84. package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
  85. package/shared/schemas/outputs/objective-doctor.schema.json +39 -1
  86. package/shared/schemas/outputs/objective-stack-status.schema.json +172 -1
  87. package/shared/schemas/outputs/pr-land.schema.json +3 -3
  88. package/shared/schemas/outputs/pr-ready.schema.json +110 -2
  89. package/shared/contracts-history.md +0 -605
@@ -0,0 +1,573 @@
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 { bindingSuffix } from "../substrate/bindingDelivery.ts";
37
+ import { readHandoff } from "../substrate/cache.ts";
38
+ import { type ColdJson, runColdDoor } from "../substrate/coldDoor.ts";
39
+ import { registerPerkCommand } from "../substrate/command.ts";
40
+ import { render } from "../substrate/prompts.ts";
41
+ import { failFor, ok } from "../substrate/result.ts";
42
+ import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
43
+ import { report } from "../surfaces/report.ts";
44
+ import { type CheckoutOk, decodeCheckout, PR_URL_RE } from "./hunkHandoff.ts";
45
+ import {
46
+ LOCAL_REVIEW_DIFF_TYPE,
47
+ plannotatorPresent,
48
+ stackRespondMessage,
49
+ } from "./plannotatorHandoff.ts";
50
+ import { openReviewBrowserCore } from "./prReviewBrowser.ts";
51
+
52
+ /** The door's report scope — also the `command:<id>` binding trigger id. */
53
+ const SCOPE = "stack-review-browser";
54
+
55
+ // ------------------------------------------------------------------------ the target grammar
56
+
57
+ /** A parsed `/stack-review-browser` target: objective arm, chain arm, or the no-target ladder. */
58
+ export type StackReviewTarget =
59
+ | { kind: "objective"; id: string }
60
+ | { kind: "pr"; pr: number }
61
+ | { kind: "auto" };
62
+
63
+ export interface StackReviewArgs {
64
+ target: StackReviewTarget;
65
+ directive: string;
66
+ }
67
+
68
+ /** Extracts the issue number from a GitHub issue URL (the objective-id URL form). */
69
+ const ISSUE_URL_RE = /\/issues\/(\d+)(?:\/|$|#|\?)/;
70
+
71
+ /** A backend-native objective id (Linear's `ENG-123` shape — the Python `parse_objective_id`
72
+ * ident grammar, mirrored so an explicit target never silently degrades to a focus note). */
73
+ const NATIVE_ID_RE = /^[A-Za-z0-9]+-\d+$/;
74
+
75
+ /** Peel a Linear issue/project URL down to its opaque objective id (null = not one). */
76
+ function linearIdFromUrl(token: string): string | null {
77
+ let url: URL;
78
+ try {
79
+ url = new URL(token);
80
+ } catch {
81
+ return null;
82
+ }
83
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
84
+ const host = url.hostname;
85
+ if (host !== "linear.app" && !host.endsWith(".linear.app")) return null;
86
+ const segments = url.pathname.split("/").filter((s) => s !== "");
87
+ for (let i = 0; i < segments.length - 1; i++) {
88
+ const seg = segments[i];
89
+ const next = segments[i + 1];
90
+ if (next === undefined) break;
91
+ if (seg === "issue" && NATIVE_ID_RE.test(next)) return next;
92
+ if (seg === "project") return next;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Parse the explicit target grammar (pure, offline-tested). Bare numbers (and `#n`,
99
+ * backend-native ids like `ENG-123`, GitHub issue URLs, and Linear issue/project URLs — the
100
+ * Python `parse_objective_id` grammar) are OBJECTIVE ids by definition; the chain arm is
101
+ * `pr:<n>` or a PR URL. A first token that is none of these makes the WHOLE string the focus
102
+ * note (target absent — the ladder). Null only on a malformed `pr:` token (a usage failure,
103
+ * never silently a focus note).
104
+ */
105
+ export function parseStackReviewArgs(args: string): StackReviewArgs | null {
106
+ const trimmed = args.trim();
107
+ if (trimmed.length === 0) return { target: { kind: "auto" }, directive: "" };
108
+ const split = trimmed.match(/^(\S+)(?:\s+([\s\S]*))?$/);
109
+ const first = split?.[1] ?? "";
110
+ const rest = (split?.[2] ?? "").trim();
111
+ if (/^pr:/i.test(first)) {
112
+ const prToken = first.match(/^pr:(\d+)$/i);
113
+ if (prToken?.[1] === undefined) return null;
114
+ return { target: { kind: "pr", pr: Number(prToken[1]) }, directive: rest };
115
+ }
116
+ const prUrl = first.match(PR_URL_RE);
117
+ if (prUrl?.[1] !== undefined) {
118
+ return { target: { kind: "pr", pr: Number(prUrl[1]) }, directive: rest };
119
+ }
120
+ const bare = first.match(/^#?(\d+)$/);
121
+ if (bare?.[1] !== undefined) {
122
+ return { target: { kind: "objective", id: bare[1] }, directive: rest };
123
+ }
124
+ const issueUrl = first.match(ISSUE_URL_RE);
125
+ if (issueUrl?.[1] !== undefined) {
126
+ return { target: { kind: "objective", id: issueUrl[1] }, directive: rest };
127
+ }
128
+ const linearId = linearIdFromUrl(first);
129
+ if (linearId !== null) {
130
+ return { target: { kind: "objective", id: linearId }, directive: rest };
131
+ }
132
+ if (NATIVE_ID_RE.test(first)) {
133
+ return { target: { kind: "objective", id: first }, directive: rest };
134
+ }
135
+ return { target: { kind: "auto" }, directive: trimmed };
136
+ }
137
+
138
+ // ------------------------------------------------------------------------ the snapshot decode
139
+
140
+ /** One pinned stack-snapshot row (the checkout envelope's `stack[]` / the handoff's rows). */
141
+ export interface StackSnapshotRow {
142
+ pr: number;
143
+ url: string;
144
+ branch: string;
145
+ head_sha: string;
146
+ base_ref: string;
147
+ node_id: string | null;
148
+ plan_id: string | null;
149
+ }
150
+
151
+ /** The `perk pr review checkout --stack --json` ok-arm: the single-PR fields + the snapshot
152
+ * (`base_ref` IS the combined-diff/stack base on the stack envelope — no separate field). */
153
+ export interface StackCheckoutOk extends CheckoutOk {
154
+ stack: StackSnapshotRow[];
155
+ stack_notes: string[];
156
+ }
157
+
158
+ function decodeSnapshotRow(item: unknown): StackSnapshotRow | null {
159
+ if (typeof item !== "object" || item === null || Array.isArray(item)) return null;
160
+ const raw = item as Record<string, unknown>;
161
+ const { pr, url, branch, head_sha, base_ref, node_id, plan_id } = raw;
162
+ if (typeof pr !== "number" || !Number.isInteger(pr)) return null;
163
+ if (typeof url !== "string" || typeof branch !== "string") return null;
164
+ if (typeof head_sha !== "string" || typeof base_ref !== "string") return null;
165
+ if (node_id !== null && typeof node_id !== "string") return null;
166
+ if (plan_id !== null && typeof plan_id !== "string") return null;
167
+ return { pr, url, branch, head_sha, base_ref, node_id, plan_id };
168
+ }
169
+
170
+ function decodeSnapshotRows(raw: unknown): StackSnapshotRow[] | null {
171
+ if (!Array.isArray(raw) || raw.length === 0) return null;
172
+ const rows: StackSnapshotRow[] = [];
173
+ for (const item of raw) {
174
+ const row = decodeSnapshotRow(item);
175
+ if (row === null) return null;
176
+ rows.push(row);
177
+ }
178
+ return rows;
179
+ }
180
+
181
+ function decodeStringArray(raw: unknown): string[] | null {
182
+ if (!Array.isArray(raw)) return null;
183
+ return raw.every((n) => typeof n === "string") ? (raw as string[]) : null;
184
+ }
185
+
186
+ /** Strict decode of the `--stack` checkout envelope (the pinned snapshot the door reads). */
187
+ export function decodeStackCheckout(payload: ColdJson): StackCheckoutOk | null {
188
+ const base = decodeCheckout(payload);
189
+ if (base === null) return null;
190
+ const stack = decodeSnapshotRows(payload.stack);
191
+ const stackNotes = decodeStringArray(payload.stack_notes);
192
+ if (stack === null || stackNotes === null) return null;
193
+ return { ...base, stack, stack_notes: stackNotes };
194
+ }
195
+
196
+ // ------------------------------------------------------------------------ guidance
197
+
198
+ /** The guidance inputs (the pinned snapshot slice both entry paths render from). */
199
+ export interface StackReviewGuidanceOpts {
200
+ topPr: number;
201
+ checkout: string;
202
+ stackBase: string;
203
+ /** Ordered bottom→top. */
204
+ members: StackSnapshotRow[];
205
+ notes: string[];
206
+ directive?: string;
207
+ }
208
+
209
+ /**
210
+ * The seed guidance both entry paths share verbatim (the warm door injects it; the cold-launch
211
+ * tool returns it as ok text). Pure + exported for offline tests. The member table and notes
212
+ * are pre-rendered here (the frozen mini-jinja subset has no loops).
213
+ */
214
+ export function stackReviewGuidance(opts: StackReviewGuidanceOpts): string {
215
+ const table = opts.members
216
+ .map((member, index) => {
217
+ const node = member.node_id === null ? "" : ` · node ${member.node_id}`;
218
+ const plan = member.plan_id === null ? "" : ` · plan #${member.plan_id}`;
219
+ return (
220
+ `${index + 1}. PR #${member.pr} \`${member.branch}\` ← \`${member.base_ref}\`` +
221
+ `${node}${plan} — ${member.url}`
222
+ );
223
+ })
224
+ .join("\n");
225
+ const notes = opts.notes.map((note) => `- ${note}`).join("\n");
226
+ return render("stages/stack-review-browser/stack.md", {
227
+ top_pr: String(opts.topPr),
228
+ checkout: opts.checkout,
229
+ stack_base: opts.stackBase,
230
+ member_count: String(opts.members.length),
231
+ stack_table: table,
232
+ notes,
233
+ directive: opts.directive ?? "",
234
+ });
235
+ }
236
+
237
+ // ------------------------------------------------------------------------ the degrade notice
238
+
239
+ /**
240
+ * The stack degrade notice (browser never ready): findings render in-session and the triage
241
+ * runs conversationally; the routing + per-PR posting protocol is unchanged — it never
242
+ * depended on the browser.
243
+ */
244
+ export const STACK_DEGRADE_NOTICE =
245
+ "The plannotator browser review is unavailable (the review server never became ready) — " +
246
+ "degrade in-session: render the reviewers' reconciled findings as a table in your reply and " +
247
+ "run the same triage loop conversationally. The annotation surface is cleared — " +
248
+ "`push_annotations` now refuses (`no_surface`); render findings in-session. The routing + " +
249
+ "per-PR posting protocol is unchanged (it never depended on the browser): dry-run ALL " +
250
+ "per-PR batches first, then post bottom→top via `submit_pr_review` — only what the human " +
251
+ "approves.";
252
+
253
+ // ------------------------------------------------------------------------ the shared open
254
+
255
+ /** Open the stack browser session through the extracted lifecycle core (both entry paths). */
256
+ async function openStackBrowser(
257
+ pi: ExtensionAPI,
258
+ ctx: ExtensionContext,
259
+ opts: {
260
+ checkoutPath: string;
261
+ stackBaseRef: string;
262
+ guidance: string;
263
+ injectGuidance: boolean;
264
+ },
265
+ ): Promise<boolean> {
266
+ return await openReviewBrowserCore(pi, ctx, {
267
+ scope: SCOPE,
268
+ browserOpts: {
269
+ cwd: opts.checkoutPath,
270
+ diffType: LOCAL_REVIEW_DIFF_TYPE,
271
+ // The remote-tracking ref the checkout materialized — an explicit base plannotator
272
+ // trusts verbatim (a bare branch name would degrade to an empty HEAD diff).
273
+ defaultBranch: `origin/${opts.stackBaseRef}`,
274
+ },
275
+ guidance: opts.guidance,
276
+ degradeNotice: STACK_DEGRADE_NOTICE,
277
+ respondMessageFor: stackRespondMessage,
278
+ injectGuidance: opts.injectGuidance,
279
+ });
280
+ }
281
+
282
+ // ------------------------------------------------------------------------ the warm door
283
+
284
+ /** Register the warm `/stack-review-browser` command (posting rides submit_pr_review). */
285
+ export function registerStackReviewBrowser(pi: ExtensionAPI): void {
286
+ registerPerkCommand(pi, SCOPE, {
287
+ description:
288
+ "Review a whole PR stack human-in-the-loop in the plannotator browser UI over the " +
289
+ "combined diff: no arg reviews the session/plan-ref objective's delivery train; an " +
290
+ "objective id (42, #42, ENG-123) or issue/project URL targets that objective; pr:<n> or " +
291
+ "a PR URL walks the base-ref chain. Any other text is a focus note. Posting is " +
292
+ "perk-side, judgment-routed per member PR.",
293
+ handler: async (args, ctx: ExtensionContext) => {
294
+ // Entry gates, in order — nothing executed on refusal, each a loud error.
295
+ const parsed = parseStackReviewArgs(args ?? "");
296
+ if (parsed === null) {
297
+ report(
298
+ ctx,
299
+ SCOPE,
300
+ "error",
301
+ "usage: /stack-review-browser [objective id|issue URL|pr:<n>|PR URL] [focus note]",
302
+ );
303
+ return;
304
+ }
305
+ if (!ctx.hasUI) {
306
+ report(
307
+ ctx,
308
+ SCOPE,
309
+ "error",
310
+ "/stack-review-browser requires an interactive session — the plannotator browser " +
311
+ "surface and the human are constitutive",
312
+ );
313
+ return;
314
+ }
315
+ if (!plannotatorPresent(pi)) {
316
+ report(
317
+ ctx,
318
+ SCOPE,
319
+ "error",
320
+ "the plannotator extension is not loaded (its /plannotator-review command was not " +
321
+ "found) — select the plannotator plan provider (`[providers] plan = " +
322
+ '"plannotator-plan"`), run `perk init`, then restart pi',
323
+ );
324
+ return;
325
+ }
326
+
327
+ const argv = ["pr", "review", "checkout", "--stack"];
328
+ if (parsed.target.kind === "pr") {
329
+ argv.push("--pr", String(parsed.target.pr));
330
+ } else if (parsed.target.kind === "objective") {
331
+ argv.push("--objective", parsed.target.id);
332
+ } else {
333
+ // The no-target ladder: the session's active objective, passed EXPLICITLY; else the
334
+ // worker's own cache.plan-ref arm (bare --stack).
335
+ const active = rebuildWorkflowState(branchOf(ctx)).active_objective;
336
+ if (typeof active === "string" && active.trim() !== "") {
337
+ argv.push("--objective", active.trim());
338
+ }
339
+ }
340
+ argv.push("--json");
341
+
342
+ const checkout = await runColdDoor<StackCheckoutOk>(pi, ctx, argv, {
343
+ label: "perk pr review checkout --stack",
344
+ decode: decodeStackCheckout,
345
+ });
346
+ if (!checkout.ok) {
347
+ if (checkout.errorType === "no_objective") {
348
+ report(
349
+ ctx,
350
+ SCOPE,
351
+ "error",
352
+ "no stack target: pass an objective id / issue URL, pr:<n> / a PR URL, or run " +
353
+ "from a session/worktree linked to a stacked objective",
354
+ );
355
+ return;
356
+ }
357
+ report(
358
+ ctx,
359
+ SCOPE,
360
+ "error",
361
+ `perk pr review checkout --stack failed (${checkout.errorType}): ${checkout.message}`,
362
+ { alsoLog: true },
363
+ );
364
+ return;
365
+ }
366
+
367
+ const data = checkout.data;
368
+ report(
369
+ ctx,
370
+ SCOPE,
371
+ "info",
372
+ `stack of ${data.stack.length} PRs (base ${data.base_ref}, top #${data.pr})` +
373
+ (parsed.directive
374
+ ? ` → adversarial reviewers (focus: ${parsed.directive})`
375
+ : " → adversarial reviewers") +
376
+ " → plannotator browser triage → judgment-routed per-PR posting",
377
+ );
378
+ await openStackBrowser(pi, ctx, {
379
+ checkoutPath: data.path,
380
+ stackBaseRef: data.base_ref,
381
+ guidance:
382
+ stackReviewGuidance({
383
+ topPr: data.pr,
384
+ checkout: data.path,
385
+ stackBase: data.base_ref,
386
+ members: data.stack,
387
+ notes: data.stack_notes,
388
+ ...(parsed.directive ? { directive: parsed.directive } : {}),
389
+ }) + bindingSuffix(ctx.cwd, `command:${SCOPE}`),
390
+ injectGuidance: true,
391
+ });
392
+ },
393
+ });
394
+ }
395
+
396
+ // ------------------------------------------------------------------------ the cold-launch tool
397
+
398
+ /** The decoded `stack_review` launch binding (the launcher's `handoff_extra` blob) — exactly
399
+ * what the tool consumes: the pinned snapshot rows, the checkout path, the notes, and the
400
+ * focus. The top PR and the stack base are DERIVED from the ordered rows (last row's `pr`;
401
+ * first row's `base_ref`), never carried redundantly. */
402
+ export interface StackReviewBinding {
403
+ stack: StackSnapshotRow[];
404
+ checkout_path: string;
405
+ notes: string[];
406
+ focus: string | null;
407
+ }
408
+
409
+ /** The derived stack endpoints (the binding's rows are ordered bottom→top, never empty). */
410
+ export function bindingTopPr(binding: StackReviewBinding): number {
411
+ const top = binding.stack[binding.stack.length - 1];
412
+ return top === undefined ? 0 : top.pr;
413
+ }
414
+
415
+ export function bindingBaseRef(binding: StackReviewBinding): string {
416
+ return binding.stack[0]?.base_ref ?? "";
417
+ }
418
+
419
+ /**
420
+ * Strict decode of the handoff's `stack_review` blob; null on ANY drift (⇒ bad_state). Every
421
+ * field is REQUIRED — `stack` a non-empty row array, `checkout_path` a non-empty string,
422
+ * `notes` a string array, `focus` present as a string or null (the one normalization: a
423
+ * blank/whitespace-only focus string decodes to null — "no focus", matching the launcher's
424
+ * no-flag arm).
425
+ */
426
+ export function decodeStackReviewBinding(raw: unknown): StackReviewBinding | null {
427
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
428
+ const b = raw as Record<string, unknown>;
429
+ const stack = decodeSnapshotRows(b.stack);
430
+ const notes = decodeStringArray(b.notes);
431
+ if (stack === null || notes === null) return null;
432
+ if (typeof b.checkout_path !== "string" || b.checkout_path === "") return null;
433
+ if (!("focus" in b)) return null;
434
+ if (b.focus !== null && typeof b.focus !== "string") return null;
435
+ return {
436
+ stack,
437
+ checkout_path: b.checkout_path,
438
+ notes,
439
+ focus: typeof b.focus === "string" && b.focus.trim() !== "" ? b.focus : null,
440
+ };
441
+ }
442
+
443
+ /** Recover the launch binding: rebuilt workflow-state run_id → the run's handoff blob (the
444
+ * `audit_bundle_dir` recovery seam). Null when absent — i.e. in every session that is not a
445
+ * claimed `perk objective stack review` launch. */
446
+ export function stackReviewBindingOf(ctx: ExtensionContext): StackReviewBinding | null {
447
+ const runId = rebuildWorkflowState(branchOf(ctx)).run_id;
448
+ if (runId === undefined || runId === "") return null;
449
+ const raw = readHandoff(ctx.cwd, runId)?.stack_review;
450
+ if (raw === undefined) return null;
451
+ return decodeStackReviewBinding(raw);
452
+ }
453
+
454
+ const TOOL_GUIDELINES = [
455
+ "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.",
456
+ "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).",
457
+ "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.",
458
+ ];
459
+
460
+ /** The single-use latch (registration-scoped state, injectable for the execute-core tests). */
461
+ export interface OpenLatch {
462
+ opened: boolean;
463
+ }
464
+
465
+ /** The injectable browser-open seam (the execute-core tests force the failure arm). */
466
+ type StackBrowserOpen = typeof openStackBrowser;
467
+
468
+ /**
469
+ * The `open_stack_review` execute core (exported for direct tests — the `executeStartReviewWave`
470
+ * posture): every gate in registration order, then the shared browser open.
471
+ */
472
+ export async function executeOpenStackReview(
473
+ pi: ExtensionAPI,
474
+ ctx: ExtensionContext,
475
+ latch: OpenLatch,
476
+ open: StackBrowserOpen = openStackBrowser,
477
+ ): Promise<ReturnType<typeof ok> | ReturnType<ReturnType<typeof failFor>>> {
478
+ const fail = failFor(ctx, "open_stack_review");
479
+ if (!ctx.hasUI) {
480
+ return fail(
481
+ "open_stack_review requires an interactive session — the plannotator browser " +
482
+ "surface and the human are constitutive",
483
+ "headless",
484
+ );
485
+ }
486
+ if (latch.opened) {
487
+ return fail(
488
+ "the stack review browser was already opened in this session (single-use) — " +
489
+ "continue the flow from the earlier guidance",
490
+ "bad_state",
491
+ );
492
+ }
493
+ // The structural binding: no param exists, so the ONLY reachable snapshot is the one the
494
+ // cold door bound into this session's launch handoff.
495
+ const binding = stackReviewBindingOf(ctx);
496
+ if (binding === null) {
497
+ return fail(
498
+ "no stack_review binding in this session's launch state — open_stack_review runs " +
499
+ "only inside a perk objective stack review session",
500
+ "bad_state",
501
+ );
502
+ }
503
+ if (!existsSync(binding.checkout_path)) {
504
+ return fail(
505
+ `the stack checkout is missing at '${binding.checkout_path}' — re-run perk ` +
506
+ "objective stack review",
507
+ "bad_state",
508
+ );
509
+ }
510
+ if (!plannotatorPresent(pi)) {
511
+ return fail(
512
+ "the plannotator extension is not loaded (its /plannotator-review command was not " +
513
+ "found) — select the plannotator plan provider, run `perk init`, then restart pi",
514
+ "plannotator_missing",
515
+ );
516
+ }
517
+ const guidance = stackReviewGuidance({
518
+ topPr: bindingTopPr(binding),
519
+ checkout: binding.checkout_path,
520
+ stackBase: bindingBaseRef(binding),
521
+ members: binding.stack,
522
+ notes: binding.notes,
523
+ ...(binding.focus !== null ? { directive: binding.focus } : {}),
524
+ });
525
+ const started = await open(pi, ctx, {
526
+ checkoutPath: binding.checkout_path,
527
+ stackBaseRef: bindingBaseRef(binding),
528
+ guidance,
529
+ injectGuidance: false,
530
+ });
531
+ if (!started) {
532
+ return fail(
533
+ "could not start the plannotator review server (no free local port) — see the " +
534
+ "error report",
535
+ "browser_failed",
536
+ );
537
+ }
538
+ latch.opened = true;
539
+ return ok(guidance, {
540
+ top_pr: bindingTopPr(binding),
541
+ checkout_path: binding.checkout_path,
542
+ member_count: binding.stack.length,
543
+ });
544
+ }
545
+
546
+ /**
547
+ * Register the parameterless `open_stack_review` tool (the `run_audit_wave` posture) and reset
548
+ * its single-use latch (a fresh registration is a fresh session).
549
+ */
550
+ export function registerOpenStackReview(pi: ExtensionAPI): void {
551
+ const latch: OpenLatch = { opened: false };
552
+
553
+ pi.registerTool({
554
+ name: "open_stack_review",
555
+ label: "Open stack review",
556
+ description:
557
+ "Open the launch-bound stacked-PR browser review (the perk objective stack review " +
558
+ "session's ONE opener): starts the plannotator browser over the combined stack diff, " +
559
+ "primes the annotation surface, and returns the full flow guidance. No parameters: the " +
560
+ "stack snapshot comes only from the launch handoff. Single-use per session.",
561
+ promptSnippet: "Open the launch-bound stacked-PR browser review",
562
+ promptGuidelines: TOOL_GUIDELINES,
563
+ executionMode: "sequential",
564
+ parameters: {
565
+ type: "object",
566
+ additionalProperties: false,
567
+ properties: {},
568
+ },
569
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
570
+ return await executeOpenStackReview(pi, ctx, latch);
571
+ },
572
+ });
573
+ }
@@ -28,8 +28,14 @@ import { failFor, type OkDetails, ok, type Result } from "../substrate/result.ts
28
28
  import { captureSessionPointer } from "../substrate/sessionPointers.ts";
29
29
  import { appendWorkflowState, branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
30
30
  import { report } from "../surfaces/report.ts";
31
+ import { planningStageRefusal } from "./lifecycleGates.ts";
31
32
 
32
- /** The bounded conflict-resolution re-drive cap: drive the resolver at most this many times. */
33
+ /**
34
+ * The bounded conflict-resolution re-drive cap: drive the resolver at most this many times.
35
+ * The counter behind it (`conflict_resolution_attempts`) is SHARED with `/objective-sync`'s
36
+ * retained-continuation conflict drive (objectiveStack.ts) — submit- and sync-episode attempts
37
+ * deliberately share one bound, reset on any clean completion of either surface.
38
+ */
33
39
  export const CONFLICT_RESOLUTION_ATTEMPT_CAP = 2;
34
40
 
35
41
  /** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state. */
@@ -167,6 +173,11 @@ function isUnmergeable(details: SubmitDetails): details is OkDetails<SubmitOk> {
167
173
  export async function submitPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<SubmitResult> {
168
174
  const fail = failFor(ctx, "submit");
169
175
 
176
+ // Planning sessions never legitimately submit — the first check, before any cold-door
177
+ // delegation (a positioned stacked planning session's cwd binding is the PREDECESSOR).
178
+ const planningRefusal = planningStageRefusal(ctx, "submit");
179
+ if (planningRefusal !== null) return fail(planningRefusal, "planning_session");
180
+
170
181
  // Stamp this implement run id into the plan-header `impl_run_ids` linkage (contracts.md
171
182
  // §8.35) so a later/other session can resolve the implement session pointers cross-run.
172
183
  // Mirrors planSave's `--run-id` thread; covers the interactive /submit AND the headless worker
@@ -203,7 +214,7 @@ export async function submitPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise
203
214
  const conflicted = r.data.mergeable === false;
204
215
  // Reset the counter on every clean (or undetermined) submit — idempotent; keeps a later
205
216
  // independent conflict bounded fresh.
206
- if (r.data.mergeable !== false) resetConflictAttempts(pi, ctx);
217
+ if (r.data.mergeable !== false) resetConflictAttempts(pi, ctx, "submit");
207
218
  // Automatic-cascade facts supersede the generic stacked suffix. A malformed operation block was
208
219
  // dropped by the lenient decoder, so it falls back to the pre-existing stack wording.
209
220
  const deliverySuffix =
@@ -227,35 +238,47 @@ export async function submitPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise
227
238
  return ok(message, r.data, { terminate: true });
228
239
  }
229
240
 
230
- /** Reset `conflict_resolution_attempts` to 0 (idempotent: a no-op when already 0/absent). */
231
- function resetConflictAttempts(pi: ExtensionAPI, ctx: ExtensionContext): void {
241
+ /**
242
+ * Reset `conflict_resolution_attempts` to 0 (idempotent: a no-op when already 0/absent). The
243
+ * counter is shared across the two warm conflict drives, so `scope` names the TRUE resetting
244
+ * surface for failure reports — `/submit` passes "submit", the stack door "objective-sync".
245
+ */
246
+ export function resetConflictAttempts(
247
+ pi: ExtensionAPI,
248
+ ctx: ExtensionContext,
249
+ scope: string,
250
+ ): void {
232
251
  const attempts = rebuildWorkflowState(branchOf(ctx)).conflict_resolution_attempts ?? 0;
233
252
  if (attempts === 0) return;
234
253
  appendWorkflowState(pi, ctx, {
235
254
  data: { conflict_resolution_attempts: 0 },
236
255
  field: "conflict_resolution_attempts",
237
256
  expected: 0,
238
- scope: "submit",
257
+ scope,
239
258
  failure: "conflict_resolution_attempts reset read-back failed (expected 0)",
240
259
  });
241
260
  }
242
261
 
243
262
  /**
244
263
  * The follow-up guidance the warm `/submit` injects to dispatch the conflict-resolver (modeled on
245
- * `prReviewGuidance`). Pure + exported for offline tests. When `model` is set, the ONE
246
- * workflowScript call carries a workflow-level `model` default; otherwise the agent's default
247
- * model is used.
264
+ * `prReviewGuidance`). Pure + exported for offline tests. `worktree` is the plan worktree the
265
+ * child's task text pins with a concrete `cd <worktree>` command a dispatched child otherwise
266
+ * has no cwd guarantee and can run its commands outside the plan worktree. When `model` is set,
267
+ * the ONE workflowScript call carries a workflow-level `model` default; otherwise the agent's
268
+ * default model is used.
248
269
  */
249
270
  export function conflictResolutionGuidance(
250
271
  base: string,
251
272
  attempt: number,
252
273
  cap: number,
274
+ worktree: string,
253
275
  model?: string,
254
276
  ): string {
255
277
  return render("stages/conflict-resolution.md", {
256
278
  base,
257
279
  attempt: String(attempt),
258
280
  cap: String(cap),
281
+ worktree,
259
282
  model: model ?? "",
260
283
  });
261
284
  }
@@ -267,7 +290,8 @@ export function conflictResolutionGuidance(
267
290
  * `followUp` user message is a separate deliberate new turn. Short-circuits (sends nothing) unless
268
291
  * the submit succeeded with a definitively-unmergeable PR. Bounded by
269
292
  * `CONFLICT_RESOLUTION_ATTEMPT_CAP` via the `conflict_resolution_attempts` field: past the cap it
270
- * surfaces the unresolved conflict loudly instead of looping.
293
+ * surfaces the unresolved conflict loudly instead of looping. The counter is shared with
294
+ * `/objective-sync`'s retained-continuation conflict drive.
271
295
  */
272
296
  export function driveConflictResolution(
273
297
  pi: ExtensionAPI,
@@ -298,7 +322,9 @@ export function driveConflictResolution(
298
322
  });
299
323
  const model = subagentModel(ctx.cwd, "conflict-resolver");
300
324
  const message =
301
- conflictResolutionGuidance(base, next, CONFLICT_RESOLUTION_ATTEMPT_CAP, model) +
325
+ // `/submit` runs only in worktree-bound sessions (planning sessions are refused first), so
326
+ // the session cwd IS the plan worktree.
327
+ conflictResolutionGuidance(base, next, CONFLICT_RESOLUTION_ATTEMPT_CAP, ctx.cwd, model) +
302
328
  bindingSuffix(ctx.cwd, "command:submit");
303
329
  if (ctx.isIdle()) {
304
330
  // The `/submit` command path (idle): inject an immediate turn.