@mgiles/perk 3.1.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 (43) hide show
  1. package/extension/doors/address.ts +11 -0
  2. package/extension/doors/dreamWaveTools.ts +29 -15
  3. package/extension/doors/land.ts +6 -0
  4. package/extension/doors/learn.ts +16 -3
  5. package/extension/doors/lifecycleGates.ts +36 -1
  6. package/extension/doors/objectiveStack.ts +423 -23
  7. package/extension/doors/plannotatorHandoff.ts +80 -8
  8. package/extension/doors/prReview.ts +2 -1
  9. package/extension/doors/prReviewBrowser.ts +75 -27
  10. package/extension/doors/ready.ts +209 -17
  11. package/extension/doors/reviewWaveTools.ts +24 -3
  12. package/extension/doors/stackReviewBrowser.ts +573 -0
  13. package/extension/doors/submit.ts +36 -10
  14. package/extension/doors/submitPrReview.ts +116 -19
  15. package/extension/factories/objectivePlan.ts +12 -6
  16. package/extension/factories/objectiveSave.ts +5 -2
  17. package/extension/index.ts +26 -1
  18. package/extension/substrate/config.ts +4 -2
  19. package/extension/substrate/paths.ts +2 -7
  20. package/extension/substrate/resolverLease.ts +363 -0
  21. package/extension/substrate/toolGating.ts +16 -0
  22. package/extension/substrate/workflowState.ts +13 -3
  23. package/extension/waves/adversarialReviewWave.ts +16 -2
  24. package/package.json +1 -1
  25. package/prompts/_fixtures/live.yaml +63 -0
  26. package/prompts/contexts/adapters/tombell-plan.md +4 -0
  27. package/prompts/contexts/plan-authoring.md +6 -5
  28. package/prompts/stages/conflict-resolution-continuation.md +6 -0
  29. package/prompts/stages/conflict-resolution.md +1 -1
  30. package/prompts/stages/objective-author/adopt.md +1 -1
  31. package/prompts/stages/objective-author/file.md +1 -1
  32. package/prompts/stages/objective-author/seed.md +1 -1
  33. package/prompts/stages/objective-reconcile-ready.md +7 -0
  34. package/prompts/stages/objective-sync.md +1 -1
  35. package/prompts/stages/stack-review/cold.md +1 -0
  36. package/prompts/stages/stack-review-browser/stack.md +23 -0
  37. package/shared/README.md +0 -3
  38. package/shared/bindings.yaml +3 -0
  39. package/shared/contracts.md +2010 -1753
  40. package/shared/registry.yaml +16 -1
  41. package/shared/schemas/outputs/objective-stack-status.schema.json +172 -1
  42. package/shared/schemas/outputs/pr-ready.schema.json +110 -2
  43. package/shared/contracts-history.md +0 -605
@@ -372,6 +372,57 @@ export function respondMessage(outcome: CodeReviewOutcome): string | null {
372
372
  return parts.join("\n\n");
373
373
  }
374
374
 
375
+ /**
376
+ * The stack-flow respond → injection mapping (`/stack-review-browser`): the same arms as
377
+ * `respondMessage`, re-worded for the stack posting policy — a local-diff session has NO
378
+ * attached PR, so the browser posted nothing and ALL GitHub posting is perk-side after triage
379
+ * (per-PR, judgment-routed, human-approved). Returned annotations are treated as COMBINED-DIFF
380
+ * coordinates (stack base → top head).
381
+ */
382
+ export function stackRespondMessage(outcome: CodeReviewOutcome): string | null {
383
+ if (outcome.status !== "handled") return null;
384
+ if (outcome.exit) {
385
+ return (
386
+ "The human closed the plannotator review without submitting — ask them how they want " +
387
+ "to proceed."
388
+ );
389
+ }
390
+ if (outcome.approved && outcome.annotations.length === 0) {
391
+ return (
392
+ "The human approved the stack review in plannotator (no annotations) — the review is " +
393
+ "complete. This local-diff session has no attached PR, so nothing was posted from the " +
394
+ "browser: ask the human whether they want per-PR COMMENT reviews posted (the routing + " +
395
+ "per-PR posting protocol via `submit_pr_review`) or nothing — perk posts only what the " +
396
+ "human approves."
397
+ );
398
+ }
399
+ const parts: string[] = [outcome.feedback ?? "The plannotator stack review returned."];
400
+ if (outcome.annotations.length > 0) {
401
+ parts.push(`\`\`\`json\n${JSON.stringify(outcome.annotations, null, 2)}\n\`\`\``);
402
+ parts.push(
403
+ "These annotations are in COMBINED-DIFF coordinates (stack base → top head): " +
404
+ "source-less ones are human-authored; `perk:*`-badged ones are your own findings " +
405
+ "returning. This local-diff session has no attached PR — nothing was posted from the " +
406
+ "browser, so ALL GitHub posting is perk-side: run the routing + per-PR posting " +
407
+ "protocol from the guidance (route each finding to the PR that introduced it over the " +
408
+ "per-PR diffs, sanity-check each quoted context against the target PR's diff, dry-run " +
409
+ "ALL per-PR batches first, then post bottom→top via `submit_pr_review`) — posting only " +
410
+ "what the human approves.",
411
+ );
412
+ } else {
413
+ // Feedback without annotations still needs the stack posting framing — the human may
414
+ // expect their words to reach GitHub, and nothing was posted from the browser.
415
+ parts.push(
416
+ "No annotations came back with this feedback. This local-diff session has no attached " +
417
+ "PR — nothing was posted from the browser, so any GitHub posting stays perk-side: if " +
418
+ "the feedback warrants per-PR reviews, run the guidance's routing + per-PR posting " +
419
+ "protocol (dry-run ALL per-PR batches first, then post bottom→top via " +
420
+ "`submit_pr_review`) — posting only what the human approves.",
421
+ );
422
+ }
423
+ return parts.join("\n\n");
424
+ }
425
+
375
426
  /** The minimal message sink `routeBrowserRespond` needs (an `ExtensionAPI` slice). */
376
427
  export interface RespondSink {
377
428
  sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp" }): void;
@@ -379,20 +430,24 @@ export interface RespondSink {
379
430
 
380
431
  /**
381
432
  * Route a settled PR-mode respond into the session (the idle-vs-streaming injection route),
382
- * shared by `/pr-review-browser`'s PR modes. `scope` is the invoking surface's report scope.
433
+ * shared by `/pr-review-browser`'s PR modes and `/stack-review-browser`. `scope` is the
434
+ * invoking surface's report scope; `messageFor` is the injectable respond → message mapper
435
+ * (default `respondMessage` — the single-PR posting contract; the stack flow supplies
436
+ * `stackRespondMessage`).
383
437
  */
384
438
  export function routeBrowserRespond(
385
439
  pi: RespondSink,
386
440
  ctx: ReportTarget & Pick<ExtensionContext, "isIdle">,
387
441
  out: CodeReviewOutcome,
388
442
  scope: string,
443
+ messageFor: (outcome: CodeReviewOutcome) => string | null = respondMessage,
389
444
  ): void {
390
445
  if (out.status === "unavailable" || out.status === "error") {
391
446
  // Degrade-mid-flow: the flow continues in-session (findings table; posting unchanged).
392
447
  report(ctx, scope, "error", out.warning, { alsoLog: true });
393
448
  return;
394
449
  }
395
- const message = respondMessage(out);
450
+ const message = messageFor(out);
396
451
  if (message === null) return; // aborted: the turn was interrupted — no-op
397
452
  if (ctx.isIdle()) {
398
453
  pi.sendUserMessage(message);
@@ -539,18 +594,35 @@ async function startPlannotatorSurface<T>(
539
594
 
540
595
  /**
541
596
  * The composable code-review browser open: the engine with launch = the `code-review` bridge
542
- * request (the PR-mode payload `{prUrl, cwd}` byte-for-byte plannotator's defaults, including
543
- * its own local checkout for Ask AI / Full-stack: deliberately NOT `useLocal: false`, the human
544
- * chose the full surface) and the `/api/diff` readiness route (`bridgePromise` is the single
545
- * respond code-review has no handshake).
597
+ * request and the `/api/diff` readiness route (`bridgePromise` is the single respond
598
+ * code-review has no handshake). PR mode passes `{prUrl, cwd}` the payload stays
599
+ * byte-identical to the original shape because the optional local-mode fields (`diffType`,
600
+ * `defaultBranch`) render ONLY when defined (`requestPlannotatorCodeReview` builds the payload
601
+ * conditionally). The stack door supplies the local-mode trio
602
+ * `{cwd, diffType: "since-base", defaultBranch: "origin/<stack base>"}` instead of a PR URL.
603
+ * Plannotator's defaults are otherwise untouched (deliberately NOT `useLocal: false` — the
604
+ * human chose the full surface).
546
605
  */
547
606
  export async function startPlannotatorBrowser(
548
607
  bus: PlannotatorBus,
549
- opts: { prUrl: string; cwd: string; signal?: AbortSignal },
608
+ opts: {
609
+ cwd: string;
610
+ prUrl?: string;
611
+ diffType?: string;
612
+ defaultBranch?: string;
613
+ signal?: AbortSignal;
614
+ },
550
615
  deps: StartBrowserDeps = {},
551
616
  ): Promise<StartedBrowser> {
552
617
  return await startPlannotatorSurface(
553
- (signal) => requestPlannotatorCodeReview(bus, { prUrl: opts.prUrl, cwd: opts.cwd, signal }),
618
+ (signal) =>
619
+ requestPlannotatorCodeReview(bus, {
620
+ cwd: opts.cwd,
621
+ ...(opts.prUrl !== undefined ? { prUrl: opts.prUrl } : {}),
622
+ ...(opts.diffType !== undefined ? { diffType: opts.diffType } : {}),
623
+ ...(opts.defaultBranch !== undefined ? { defaultBranch: opts.defaultBranch } : {}),
624
+ ...(signal !== undefined ? { signal } : {}),
625
+ }),
554
626
  CODE_REVIEW_READINESS_PROBE_PATH,
555
627
  opts.signal,
556
628
  deps,
@@ -21,7 +21,8 @@
21
21
  // (soft `details.ok`, mirrors resolveReviewThreads). This is documented in shared/contracts.md §8.3.
22
22
  //
23
23
  // The review model is configurable via `[models.subagents] pr-reviewer` in `.perk/config.toml`; because
24
- // `subagents.agentOverrides` does NOT reach project agents, `run_pr_review_wave` applies that model
24
+ // an `agentOverrides` model can never displace a perk def's frontmatter-pinned `model:` (the ≥0.52
25
+ // custom-agent override path is a frontmatter-sensitive fill), `run_pr_review_wave` applies that model
25
26
  // as the wave's workflow-level `model` default applied to every lane (the agent's frontmatter model
26
27
  // is the default).
27
28
  //
@@ -45,6 +45,7 @@ import { type ReportTarget, report } from "../surfaces/report.ts";
45
45
  import { clearAnnotationSurface, primeAnnotationSurface } from "./annotationPush.ts";
46
46
  import { type CheckoutOk, decodeCheckout } from "./hunkHandoff.ts";
47
47
  import {
48
+ type CodeReviewOutcome,
48
49
  decodePrUrl,
49
50
  LOCAL_REVIEW_DIFF_TYPE,
50
51
  plannotatorPresent,
@@ -95,8 +96,9 @@ export function prReviewBrowserGuidance(opts: PrReviewBrowserGuidanceOpts): stri
95
96
  /**
96
97
  * The degrade notice injected when the browser never comes up — the model renders the findings
97
98
  * in-session and runs the same triage conversationally; the posting contract is unchanged.
99
+ * Exported as the `openReviewBrowserCore` default (the stack door supplies its own).
98
100
  */
99
- const DEGRADE_NOTICE =
101
+ export const DEGRADE_NOTICE =
100
102
  "The plannotator browser review is unavailable (the review server never became ready) — " +
101
103
  "degrade in-session: render the reviewers' reconciled findings as a table in your reply and " +
102
104
  "run the same triage loop conversationally. The annotation surface is cleared — " +
@@ -116,10 +118,13 @@ export async function observeBrowserReadiness(
116
118
  pi: RespondSink,
117
119
  ctx: ReportTarget & Pick<ExtensionContext, "isIdle">,
118
120
  started: StartedBrowser,
121
+ opts?: { scope?: string; degradeNotice?: string },
119
122
  ): Promise<void> {
123
+ const scope = opts?.scope ?? SCOPE;
124
+ const degradeNotice = opts?.degradeNotice ?? DEGRADE_NOTICE;
120
125
  const state = await started.readiness;
121
126
  if (state === "ready") {
122
- report(ctx, SCOPE, "info", `plannotator is up at ${started.url} — browser opening`);
127
+ report(ctx, scope, "info", `plannotator is up at ${started.url} — browser opening`);
123
128
  return;
124
129
  }
125
130
  if (state === "aborted") return; // the turn was interrupted — no-op
@@ -129,71 +134,95 @@ export async function observeBrowserReadiness(
129
134
  }
130
135
  report(
131
136
  ctx,
132
- SCOPE,
137
+ scope,
133
138
  "error",
134
139
  `the plannotator review server did not become ready at ${started.url} — the browser ` +
135
140
  "review is unavailable",
136
141
  { alsoLog: true },
137
142
  );
138
143
  if (ctx.isIdle()) {
139
- pi.sendUserMessage(DEGRADE_NOTICE);
144
+ pi.sendUserMessage(degradeNotice);
140
145
  } else {
141
- pi.sendUserMessage(DEGRADE_NOTICE, { deliverAs: "followUp" });
146
+ pi.sendUserMessage(degradeNotice, { deliverAs: "followUp" });
142
147
  }
143
148
  // Consistent with "render findings in-session": a post-degrade push_annotations refuses
144
149
  // loudly (`no_surface`). Idempotent beside the bridge-settle clear.
145
150
  clearAnnotationSurface();
146
151
  }
147
152
 
153
+ /** The parameterized browser-lifecycle core's options (see `openReviewBrowserCore`). */
154
+ export interface ReviewBrowserCoreOpts {
155
+ /** The invoking door's report scope. */
156
+ scope: string;
157
+ /** The `startPlannotatorBrowser` opts minus `signal` (the core threads `ctx.signal`). */
158
+ browserOpts: { cwd: string; prUrl?: string; diffType?: string; defaultBranch?: string };
159
+ /** The fully composed guidance to inject (binding suffix included by the caller). */
160
+ guidance: string;
161
+ /** The degrade notice for the browser-never-ready arm (default: the PR-mode notice). */
162
+ degradeNotice?: string;
163
+ /** The respond → injection mapper (default: `respondMessage` — the single-PR contract). */
164
+ respondMessageFor?: (outcome: CodeReviewOutcome) => string | null;
165
+ /**
166
+ * Whether the core injects `guidance` as a user message at the end (default true — the warm
167
+ * doors). The `open_stack_review` tool passes false and returns the guidance as its ok text
168
+ * instead (the tool result is the seeded session's delivery channel).
169
+ */
170
+ injectGuidance?: boolean;
171
+ }
172
+
148
173
  /**
149
- * The shared PR-mode arm (foreign + active): start the browser open in the background, inject
150
- * the mode guidance immediately (the URL is deterministic once the port is picked), and return
151
- * the readiness observation and the bridge respond both ride background tasks. While plannotator
152
- * sets up, its in-process `console.error` chatter re-routes through the TUI-safe report() seam
153
- * (the debounce restores once setup goes quiet, with the `finally` as a backstop).
174
+ * The full browser-lifecycle core, extracted from the PR-mode arm and parameterized for the
175
+ * stack door: start the browser open in the background, prime the annotation surface the
176
+ * moment the port is picked (push_annotations now serves this browser session), observe
177
+ * readiness in the background (degrade notice on never-ready), route the bridge respond
178
+ * through the injectable mapper, clear the surface on settle, and inject the guidance
179
+ * immediately (the URL is deterministic once the port is picked). While plannotator sets up,
180
+ * its in-process `console.error` chatter re-routes through the TUI-safe report() seam (the
181
+ * debounce restores once setup goes quiet, with the `finally` as a backstop).
182
+ *
183
+ * Accepted stale-clear edge (unchanged from the pre-extraction arm): a second browser door
184
+ * while this browser is still open re-primes (a new browser session supersedes everything),
185
+ * and THIS bridge's later settle would clear the second session's surface — rare and loud
186
+ * (the fixed-port EADDRINUSE caveat, contracts §8.4), noted, not engineered around.
154
187
  */
155
- async function openBrowserAndGuide(
188
+ export async function openReviewBrowserCore(
156
189
  pi: ExtensionAPI,
157
190
  ctx: ExtensionContext,
158
- opts: PrReviewBrowserGuidanceOpts,
159
- ): Promise<void> {
191
+ opts: ReviewBrowserCoreOpts,
192
+ ): Promise<boolean> {
160
193
  let started: StartedBrowser;
161
194
  try {
162
195
  started = await startPlannotatorBrowser(pi.events, {
163
- prUrl: opts.prUrl,
164
- cwd: ctx.cwd,
196
+ ...opts.browserOpts,
165
197
  signal: ctx.signal,
166
198
  });
167
199
  } catch (error) {
168
200
  const detail = error instanceof Error ? error.message : String(error);
169
201
  report(
170
202
  ctx,
171
- SCOPE,
203
+ opts.scope,
172
204
  "error",
173
205
  `could not pick a free local port for the plannotator review server: ${detail}`,
174
206
  { alsoLog: true },
175
207
  );
176
- return;
208
+ return false;
177
209
  }
178
210
 
179
- // Prime the annotation surface the moment the port is picked (the URL is deterministic — see
180
- // the background-open header note): push_annotations now serves this browser session. Accepted
181
- // stale-clear edge: a second /pr-review-browser while this browser is still open re-primes (a
182
- // new browser session supersedes everything), and THIS bridge's later settle would clear the
183
- // second session's surface — the overlap is already rare and loud (the fixed-port EADDRINUSE
184
- // caveat, contracts §8.4), so it is noted, not engineered around.
185
211
  primeAnnotationSurface({ mode: "review", url: started.url });
186
212
 
187
- void observeBrowserReadiness(pi, ctx, started);
213
+ void observeBrowserReadiness(pi, ctx, started, {
214
+ scope: opts.scope,
215
+ ...(opts.degradeNotice !== undefined ? { degradeNotice: opts.degradeNotice } : {}),
216
+ });
188
217
 
189
218
  void (async () => {
190
- const interceptor = interceptConsoleError((line) => report(ctx, SCOPE, "info", line), {
219
+ const interceptor = interceptConsoleError((line) => report(ctx, opts.scope, "info", line), {
191
220
  // plannotator can pause up to ~4s between setup lines — keep the quiet window above that.
192
221
  quietMs: 6000,
193
222
  });
194
223
  try {
195
224
  const out = await started.bridgePromise;
196
- routeBrowserRespond(pi, ctx, out, SCOPE);
225
+ routeBrowserRespond(pi, ctx, out, opts.scope, opts.respondMessageFor);
197
226
  } finally {
198
227
  // The browser session is over — drop the surface so a later push refuses (`no_surface`).
199
228
  clearAnnotationSurface();
@@ -201,7 +230,26 @@ async function openBrowserAndGuide(
201
230
  }
202
231
  })();
203
232
 
204
- pi.sendUserMessage(prReviewBrowserGuidance(opts) + bindingSuffix(ctx.cwd, `command:${SCOPE}`));
233
+ if (opts.injectGuidance !== false) pi.sendUserMessage(opts.guidance);
234
+ return true;
235
+ }
236
+
237
+ /**
238
+ * The shared PR-mode arm (foreign + active): the extracted core with this door's values —
239
+ * PR-mode browser opts, the mode guidance + binding suffix, and the default degrade notice /
240
+ * respond mapper (the byte-stability of the pre-extraction behavior is proven by this door's
241
+ * untouched tests).
242
+ */
243
+ async function openBrowserAndGuide(
244
+ pi: ExtensionAPI,
245
+ ctx: ExtensionContext,
246
+ opts: PrReviewBrowserGuidanceOpts,
247
+ ): Promise<void> {
248
+ await openReviewBrowserCore(pi, ctx, {
249
+ scope: SCOPE,
250
+ browserOpts: { prUrl: opts.prUrl, cwd: ctx.cwd },
251
+ guidance: prReviewBrowserGuidance(opts) + bindingSuffix(ctx.cwd, `command:${SCOPE}`),
252
+ });
205
253
  }
206
254
 
207
255
  // ------------------------------------------------------------------------ registration
@@ -1,10 +1,17 @@
1
- // The warm `/ready` door: the deliberate draft→ready review gate. The in-session twin
2
- // of the Python cold door (`perk pr ready`): a terminating tool + command that DELEGATE the GitHub
3
- // mark-ready (mutations canonical in Python). perk deliberately does NOT auto-publish on
4
- // submit; `/ready` is the explicit gesture that opens the PR for review. Mirrors `submit.ts`: write
5
- // nothing, delegate via `pi.exec`, surface the structured result, never throw.
1
+ // The warm `/ready` door: the deliberate ready gesture. The in-session twin of the Python
2
+ // cold door (`perk pr ready`): a terminating tool + command that DELEGATE the mechanics
3
+ // (mutations canonical in Python). For an incremental plan this is the review gate — perk
4
+ // deliberately does NOT auto-publish on submit; `/ready` is the explicit gesture that opens the
5
+ // draft PR for review. For a STACKED layer it is the deliberate HUMAN handoff made after review +
6
+ // address: it stamps the exact verified published head into the delivery journal (draft AND
7
+ // non-draft PRs — mark-ready mechanics first, then the journal append), and every successful
8
+ // stacked stamp continues into the ready-time reconcile pass (`driveReadyReconcile` — the warm
9
+ // continuation, contracts.md §8.66). Mirrors `submit.ts`: write nothing, delegate via `pi.exec`,
10
+ // surface the structured result, never throw.
6
11
 
7
12
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { fetchObjectiveUrl, objectiveReadInstruction } from "../factories/objectivePlan.ts";
14
+ import { bindingSuffix } from "../substrate/bindingDelivery.ts";
8
15
  import {
9
16
  booleanField,
10
17
  type ColdJson,
@@ -14,25 +21,92 @@ import {
14
21
  stringField,
15
22
  } from "../substrate/coldDoor.ts";
16
23
  import { registerPerkCommand } from "../substrate/command.ts";
24
+ import { resolveIssueBackendId } from "../substrate/config.ts";
25
+ import { render } from "../substrate/prompts.ts";
17
26
  import { failFor, ok, type Result } from "../substrate/result.ts";
27
+ import type { ToolGating } from "../substrate/toolGating.ts";
18
28
  import { report } from "../surfaces/report.ts";
19
29
 
20
- /** The ok-arm fields the structured `details` surface doubles as branch-safe persisted state. */
30
+ // The drive's strict evidence vocabulary (contracts.md §8.66), local on purpose: this is
31
+ // exact-evidence validation at the continuation boundary, NOT the lenient render vocabulary
32
+ // other stack surfaces use. Both diff-range endpoints must be the full 40-hex lowercase
33
+ // object id; ids must be marker-safe segments.
34
+ const READY_EVIDENCE_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
35
+ const READY_FULL_SHA_RE = /^[0-9a-f]{40}$/;
36
+
37
+ /** The stacked handoff cohort — decoded all-or-nothing (advisory detail, never half-rendered).
38
+ * Deliberately facts-only: the worker envelope's `reconcile_notice`/`reconcile_retry` are cold
39
+ * presentation strings — the warm door derives its own retry gesture from `plan`, so missing
40
+ * presentation data can never suppress an otherwise valid continuation. */
41
+ export interface ReadyHandoff {
42
+ objective: string;
43
+ node: string;
44
+ stamped_head: string;
45
+ stamp_advanced: boolean;
46
+ plan: string;
47
+ parent_checkpoint: string;
48
+ }
49
+
50
+ /** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state.
51
+ * `stacked` is the worker's own routing fact, passed through so a malformed cohort
52
+ * (`stacked === true`, `handoff` absent) is distinguishable from an incremental result. */
21
53
  export interface ReadyOk {
22
54
  pr: { number: number; url: string };
23
55
  was_draft?: boolean;
56
+ stacked?: boolean;
57
+ handoff?: ReadyHandoff;
24
58
  }
25
59
 
26
60
  export type ReadyResult = Result<ReadyOk>;
61
+ export type ReadyDetails = ReadyResult["details"];
27
62
 
28
- /** Narrow the `perk pr ready --json` success payload; strict on `pr`, lenient on the rest. */
63
+ /**
64
+ * Narrow the `perk pr ready --json` success payload; strict on `pr`, lenient on the rest.
65
+ *
66
+ * The stacked continuation decodes as ONE cohort: the handoff augmentation is attached only when
67
+ * `stacked === true` AND every cohort field decodes (the two continuation fields `plan` /
68
+ * `parent_checkpoint` included); a partial/wrong-typed cohort is validated-and-dropped whole
69
+ * (the worker's own success already proved the mechanics — advisory detail is never
70
+ * half-rendered), leaving the `stacked` passthrough as the visible mismatch signal. An absent
71
+ * `stacked` (an old worker) is the same no-augmentation arm.
72
+ */
29
73
  function decodeReady(payload: ColdJson): ReadyOk | null {
30
74
  const pr = objectField(payload, "pr");
31
75
  if (pr === undefined) return null;
32
76
  const number = numberField(pr, "number");
33
77
  const url = stringField(pr, "url");
34
78
  if (number === undefined || url === undefined) return null;
35
- return { pr: { number, url }, was_draft: booleanField(payload, "was_draft") };
79
+ const result: ReadyOk = {
80
+ pr: { number, url },
81
+ was_draft: booleanField(payload, "was_draft"),
82
+ stacked: booleanField(payload, "stacked"),
83
+ };
84
+ if (result.stacked === true) {
85
+ const objective = stringField(payload, "objective");
86
+ const node = stringField(payload, "node");
87
+ const stampedHead = stringField(payload, "stamped_head");
88
+ const stampAdvanced = booleanField(payload, "stamp_advanced");
89
+ const plan = stringField(payload, "plan");
90
+ const parentCheckpoint = stringField(payload, "parent_checkpoint");
91
+ if (
92
+ objective !== undefined &&
93
+ node !== undefined &&
94
+ stampedHead !== undefined &&
95
+ stampAdvanced !== undefined &&
96
+ plan !== undefined &&
97
+ parentCheckpoint !== undefined
98
+ ) {
99
+ result.handoff = {
100
+ objective,
101
+ node,
102
+ stamped_head: stampedHead,
103
+ stamp_advanced: stampAdvanced,
104
+ plan,
105
+ parent_checkpoint: parentCheckpoint,
106
+ };
107
+ }
108
+ }
109
+ return result;
36
110
  }
37
111
 
38
112
  /**
@@ -49,39 +123,157 @@ export async function markReady(pi: ExtensionAPI, ctx: ExtensionContext): Promis
49
123
  if (!r.ok) return fail(r.message, r.errorType);
50
124
 
51
125
  const verb = r.data.was_draft ? "Marked ready" : "Already ready";
52
- return ok(`${verb}: PR #${r.data.pr.number} is open for review.`, r.data, { terminate: true });
126
+ let message = `${verb}: PR #${r.data.pr.number} is open for review.`;
127
+ const handoff = r.data.handoff;
128
+ if (handoff !== undefined) {
129
+ // Stamp facts only — the continuation is announced by driveReadyReconcile, and only once
130
+ // its refusal arms (gate, cohort, evidence) have actually accepted the drive.
131
+ const stamped = handoff.stamp_advanced ? "Handoff stamped" : "Handoff already stamped";
132
+ message +=
133
+ ` ${stamped}: objective #${handoff.objective} node ${handoff.node} at ` +
134
+ `${handoff.stamped_head}.`;
135
+ }
136
+ return ok(message, r.data, { terminate: true });
137
+ }
138
+
139
+ /** One loud skipped-pass warning (the stamp itself stands; re-running `/ready` re-enters). */
140
+ function warnSkippedPass(ctx: ExtensionContext, reason: string, retry: string): void {
141
+ report(
142
+ ctx,
143
+ "ready",
144
+ "warning",
145
+ `ready-time reconcile pass not driven — ${reason}. The handoff stamp stands; re-run ${retry} to enter the pass.`,
146
+ );
147
+ }
148
+
149
+ /**
150
+ * After a successful stacked stamp, drive the session into the ready-time reconcile pass
151
+ * (contracts.md §8.66): inject the rendered `stages/objective-reconcile-ready.md` guidance —
152
+ * the warm twin of the cold wrapper's seeded launch. Fires on EVERY successful stacked stamp,
153
+ * `existed=true` re-stamps included (re-running `/ready` re-enters reconciliation). The
154
+ * refusal arms are LOUD, never silent: a gate-active session (the pass's write tools are
155
+ * gated off — a drive would dead-end), a malformed/mixed-version stacked cohort, and evidence
156
+ * failing the strict vocabulary all warn and skip; the stamp itself always stands.
157
+ * Incremental results and failures drive nothing, quietly.
158
+ */
159
+ export async function driveReadyReconcile(
160
+ pi: ExtensionAPI,
161
+ ctx: ExtensionContext,
162
+ gating: ToolGating,
163
+ details: ReadyDetails,
164
+ ): Promise<void> {
165
+ if (details.ok !== true) return;
166
+ const handoff = details.handoff;
167
+ if (handoff === undefined) {
168
+ if (details.stacked === true) {
169
+ // A successful stacked stamp whose continuation cohort failed to decode — a
170
+ // malformed/mixed-version envelope must never fail silent.
171
+ warnSkippedPass(
172
+ ctx,
173
+ "the worker reported a stacked stamp but its continuation facts were malformed " +
174
+ "(a mixed-version envelope?)",
175
+ "/ready",
176
+ );
177
+ }
178
+ return; // incremental / old worker: nothing to drive
179
+ }
180
+ const retry = `\`perk ready ${READY_EVIDENCE_ID_RE.test(handoff.plan) ? handoff.plan : "<plan>"}\``;
181
+ if (gating.isActive()) {
182
+ warnSkippedPass(
183
+ ctx,
184
+ "this session is read-only (the pass's write tools are gated off); exit the read-only " +
185
+ "session or run the pass from a terminal",
186
+ retry,
187
+ );
188
+ return;
189
+ }
190
+ const idsValid =
191
+ READY_EVIDENCE_ID_RE.test(handoff.objective) &&
192
+ READY_EVIDENCE_ID_RE.test(handoff.node) &&
193
+ READY_EVIDENCE_ID_RE.test(handoff.plan);
194
+ const shasValid =
195
+ READY_FULL_SHA_RE.test(handoff.stamped_head) &&
196
+ READY_FULL_SHA_RE.test(handoff.parent_checkpoint);
197
+ if (!idsValid || !shasValid || !Number.isInteger(details.pr.number)) {
198
+ warnSkippedPass(
199
+ ctx,
200
+ "the stamp evidence failed strict validation (ids marker-safe; both diff-range " +
201
+ "endpoints full 40-hex lowercase)",
202
+ retry,
203
+ );
204
+ return;
205
+ }
206
+ const backend = resolveIssueBackendId(ctx.cwd);
207
+ const url = backend === "linear" ? await fetchObjectiveUrl(pi, ctx, handoff.objective) : "";
208
+ const readClause = objectiveReadInstruction(backend, handoff.objective, url);
209
+ const message =
210
+ render("stages/objective-reconcile-ready.md", {
211
+ objective: handoff.objective,
212
+ node: handoff.node,
213
+ plan: handoff.plan,
214
+ pr: String(details.pr.number),
215
+ parent_checkpoint: handoff.parent_checkpoint,
216
+ stamped_head: handoff.stamped_head,
217
+ read_clause: readClause,
218
+ }) + bindingSuffix(ctx.cwd, "command:objective-reconcile");
219
+ // Announce the continuation only HERE — after every refusal arm has accepted the drive.
220
+ report(
221
+ ctx,
222
+ "ready",
223
+ "info",
224
+ `continuing into the ready-time reconcile pass — objective #${handoff.objective}, ` +
225
+ `pinned range ${handoff.parent_checkpoint}..${handoff.stamped_head}`,
226
+ );
227
+ if (ctx.isIdle()) {
228
+ // The `/ready` command path (idle): inject an immediate turn.
229
+ pi.sendUserMessage(message);
230
+ } else {
231
+ // The `ready` tool path (streaming): deliver after the terminating ready batch.
232
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
233
+ }
53
234
  }
54
235
 
55
236
  const TOOL_GUIDELINES = [
56
- "Call ready only when the PR is ready for human review; it marks the draft PR ready (the deliberate review gate). submit keeps the PR draft on purpose.",
57
- "ready operates on the active plan's worktree it takes no arguments; the PR is discovered from the local plan-ref's branch. Idempotent: an already-ready PR is success.",
237
+ "For an incremental plan, call ready only when the PR is ready for human review; it marks the draft PR ready (the deliberate review gate). submit keeps the PR draft on purpose.",
238
+ "For a STACKED plan, /ready is the deliberate HUMAN handoff made AFTER review + address: it stamps the exact verified published head into the delivery journal (draft and non-draft PRs alike), and the recorded stamp unblocks planning of the layer's direct dependents. Never call it as routine post-submit choreography — review happens on the draft layer PR; only invoke it when the human explicitly asks.",
239
+ "ready operates on the active plan's worktree — it takes no arguments; the PR is discovered from the local plan-ref's branch. Idempotent: an already-ready PR is success, and a re-run converges on the same stamp.",
240
+ "A failed stamp (error_type ready_stamp_failed) names its own remediation: the ambiguous/transient arms converge on re-run; deterministic failures need their named repair first.",
58
241
  ];
59
242
 
60
243
  /** Register the warm door: the `ready` terminating tool + the `/ready` command twin. */
61
- export function registerReady(pi: ExtensionAPI): void {
244
+ export function registerReady(pi: ExtensionAPI, gating: ToolGating): void {
62
245
  pi.registerTool({
63
246
  name: "ready",
64
247
  label: "Mark PR ready",
65
248
  description:
66
- "Mark the active plan's draft PR ready for review (the deliberate review gate). " +
67
- "Terminating: ends the turn. submit keeps the PR draft; ready is the explicit publish gesture.",
68
- promptSnippet: "Mark the draft PR ready for review (terminates the turn)",
249
+ "Ready the active plan's PR. Incremental: mark the draft PR ready for review (the " +
250
+ "deliberate review gate; submit keeps the PR draft). Stacked: the deliberate post-review " +
251
+ "HUMAN handoff — stamps the exact verified published head (draft and non-draft PRs); " +
252
+ "never routine post-submit choreography, never auto-run. Terminating: ends the turn.",
253
+ promptSnippet:
254
+ "Ready the PR: open the draft for review (incremental) or record the post-review " +
255
+ "handoff stamp (stacked; human-asked only). Terminates the turn.",
69
256
  promptGuidelines: TOOL_GUIDELINES,
70
257
  executionMode: "sequential",
71
258
  parameters: { type: "object", additionalProperties: false, properties: {} },
72
259
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
73
- return markReady(pi, ctx);
260
+ const result = await markReady(pi, ctx);
261
+ await driveReadyReconcile(pi, ctx, gating, result.details);
262
+ return result;
74
263
  },
75
264
  });
76
265
 
77
266
  registerPerkCommand(pi, "ready", {
78
- description: "Mark the active plan's draft PR ready for review (submit → ready).",
267
+ description:
268
+ "Ready the plan's PR: open the draft for review (incremental) or record the " +
269
+ "post-review handoff stamp (stacked).",
79
270
  handler: async (_args, ctx) => {
80
271
  const result = await markReady(pi, ctx);
81
272
  // Failure already reported loudly via failFor (the single error surface) — success only.
82
273
  if (result.details.ok) {
83
274
  report(ctx, "ready", "info", result.content[0]?.text ?? "ready done");
84
275
  }
276
+ await driveReadyReconcile(pi, ctx, gating, result.details);
85
277
  },
86
278
  });
87
279
  }