@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
@@ -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.
@@ -1,18 +1,22 @@
1
- // The warm `submit_pr_review` tool — the agent-driven curated-posting surface shared by the two
2
- // PR-review doors (`/pr-review-terminal`, `/pr-review-browser`).
1
+ // The warm `submit_pr_review` tool — the agent-driven curated-posting surface shared by the
2
+ // PR-review doors (`/pr-review-terminal`, `/pr-review-browser`, `/stack-review-browser`).
3
3
  //
4
4
  // `submit_pr_review` implements the per-door posting contract (contracts §8.4): nothing perk-
5
5
  // driven reaches GitHub before the human triage; ALL perk-side posting flows through this tool
6
6
  // on every door (`gh` mutations and direct `perk pr review-submit` calls are forbidden); the
7
7
  // verdict lands last, atomically with the comments. On the terminal door this tool is the sole
8
- // posting path. On the browser door the human platform-posts from the plannotator UI — that
9
- // native posting IS the GitHub path; perk composes nothing by default and posts only what the
10
- // human explicitly hands it (typically a request-changes verdict, which the UI cannot post). It
11
- // delegates to the Python cold door (`perk pr review-submit` mutations canonical in Python)
12
- // via `runColdDoor` (the batch rides the run-scratch stdin channel), then appends `last_review`
13
- // to `perk:workflow-state`. The human gate splits: explicit conversational go-ahead ALWAYS
14
- // (pinned in the guidelines/skill); formal events (`approve`/`request-changes`) additionally get
15
- // the structural gate headless refuses, interactive raises a blocking `ctx.ui.confirm`.
8
+ // posting path. On the single-PR browser door the human platform-posts from the plannotator
9
+ // UI — that native posting IS the GitHub path; perk composes nothing by default and posts only
10
+ // what the human explicitly hands it (typically a request-changes verdict, which the UI cannot
11
+ // post). On the stack door the local-diff session has NO attached PR, so ALL posting is
12
+ // perk-side after triage: one real call per member PR, bottom→top, each under its own dry-run
13
+ // anchor validation. It delegates to the Python cold door (`perk pr review-submit` — mutations
14
+ // canonical in Python) via `runColdDoor` (the batch rides the run-scratch stdin channel), then
15
+ // appends `last_review` AND the accumulating `review_posts` ledger row to `perk:workflow-state`
16
+ // (the stack flow's resume authority — posted PRs are skipped, never replayed). The human gate
17
+ // splits: explicit conversational go-ahead ALWAYS (pinned in the guidelines/skill); formal
18
+ // events (`approve`/`request-changes`) additionally get the structural gate — headless refuses,
19
+ // interactive raises a blocking `ctx.ui.confirm`.
16
20
  // `dry_run` is the anchor-repair loop: no gates, no record, nothing posted.
17
21
 
18
22
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -34,7 +38,12 @@ import {
34
38
  stringParam,
35
39
  type ToolParams,
36
40
  } from "../substrate/toolParams.ts";
37
- import { appendWorkflowState, type EntrySink } from "../substrate/workflowState.ts";
41
+ import {
42
+ appendWorkflowState,
43
+ branchOf,
44
+ type EntrySink,
45
+ rebuildWorkflowState,
46
+ } from "../substrate/workflowState.ts";
38
47
  import type { Severity } from "../surfaces/report.ts";
39
48
 
40
49
  // ------------------------------------------------------------------------ params
@@ -55,6 +64,7 @@ export interface SubmitParams {
55
64
  body: string;
56
65
  comments?: SubmitComment[];
57
66
  dry_run?: boolean;
67
+ allow_repost?: boolean;
58
68
  }
59
69
 
60
70
  /** Decode the optional `comments` array; null = present-but-malformed (whole-batch refusal). */
@@ -87,7 +97,7 @@ function decodeSubmitComments(p: ToolParams): SubmitComment[] | undefined | null
87
97
  * malformed field ⇒ null (whole-batch refusal). `pr` must be an int; `event` exactly one of the
88
98
  * three flag spellings; `body` a string (EMPTY ALLOWED — the cold door owns the event-conditioned
89
99
  * body rule and reports `bad_batch`); each `comments` row strict on
90
- * path/line(int)/side(LEFT|RIGHT)/body; `dry_run` a boolean.
100
+ * path/line(int)/side(LEFT|RIGHT)/body; `dry_run` and `allow_repost` booleans.
91
101
  */
92
102
  export function decodeSubmitParams(params: unknown): SubmitParams | null {
93
103
  const p = paramsOf(params);
@@ -102,9 +112,12 @@ export function decodeSubmitParams(params: unknown): SubmitParams | null {
102
112
  if (comments === null) return null;
103
113
  const dryRun = booleanParam(p, "dry_run");
104
114
  if (dryRun === null) return null;
115
+ const allowRepost = booleanParam(p, "allow_repost");
116
+ if (allowRepost === null) return null;
105
117
  const result: SubmitParams = { pr, event, body };
106
118
  if (comments !== undefined) result.comments = comments;
107
119
  if (dryRun !== undefined) result.dry_run = dryRun;
120
+ if (allowRepost !== undefined) result.allow_repost = allowRepost;
108
121
  return result;
109
122
  }
110
123
 
@@ -167,6 +180,42 @@ function decodeInvalidAnchors(payload: ColdJson): InvalidAnchor[] | null {
167
180
  return rows;
168
181
  }
169
182
 
183
+ // ------------------------------------------------------------------------ the posting ledger
184
+
185
+ /** One `review_posts` ledger row: a REAL submission that reached GitHub. */
186
+ export interface ReviewPostRow {
187
+ pr: number;
188
+ event: string;
189
+ at: string;
190
+ }
191
+
192
+ /**
193
+ * Tolerant re-narrow of the rebuilt `review_posts` list (best-effort tier: a malformed row is
194
+ * dropped, never a refusal — the ledger only ever grows from this tool's own writes).
195
+ */
196
+ export function reviewPostsOf(raw: unknown): ReviewPostRow[] {
197
+ if (!Array.isArray(raw)) return [];
198
+ const rows: ReviewPostRow[] = [];
199
+ for (const item of raw) {
200
+ const row = paramsOf(item);
201
+ if (row === null) continue;
202
+ if (typeof row.pr !== "number" || !Number.isInteger(row.pr)) continue;
203
+ if (typeof row.event !== "string" || typeof row.at !== "string") continue;
204
+ rows.push({ pr: row.pr, event: row.event, at: row.at });
205
+ }
206
+ return rows;
207
+ }
208
+
209
+ /** Ledger equality for the read-back verification (order-sensitive — posting order matters). */
210
+ function reviewPostsEqual(rebuilt: unknown, expected: unknown): boolean {
211
+ const a = reviewPostsOf(rebuilt);
212
+ const b = reviewPostsOf(expected);
213
+ if (a.length !== b.length) return false;
214
+ return a.every(
215
+ (row, i) => row.pr === b[i]?.pr && row.event === b[i]?.event && row.at === b[i]?.at,
216
+ );
217
+ }
218
+
170
219
  /** Flag spelling → the REST wire spelling shown in the human confirm. */
171
220
  const WIRE_EVENT: Record<ReviewEvent, string> = {
172
221
  approve: "APPROVE",
@@ -209,6 +258,27 @@ export async function submitPrReview(
209
258
  const dryRun = params.dry_run === true;
210
259
  const commentCount = params.comments?.length ?? 0;
211
260
 
261
+ // The enforced resume guard (before the confirm AND the cold-door mutation): a PR that
262
+ // already has a review_posts ledger row in this session is a confirmed success — a repeat
263
+ // real post is refused unless explicitly deliberate. A ledger row can only be MISSING
264
+ // spuriously (best-effort tier), never present spuriously — so the guard refuses on
265
+ // presence and stays silent on absence (a missing row still means: verify posted-vs-pending
266
+ // against GitHub before re-posting).
267
+ if (!dryRun && params.allow_repost !== true) {
268
+ const prior = reviewPostsOf(rebuildWorkflowState(branchOf(ctx)).review_posts).filter(
269
+ (row) => row.pr === params.pr,
270
+ );
271
+ const last = prior.at(-1);
272
+ if (last !== undefined) {
273
+ return fail(
274
+ `a ${last.event} review was already posted to PR #${params.pr} in this session ` +
275
+ `(review_posts row at ${last.at}) — on a stack resume skip this member; pass ` +
276
+ "allow_repost: true only for a deliberate second review of the same PR",
277
+ "already_posted",
278
+ );
279
+ }
280
+ }
281
+
212
282
  if (!dryRun && params.event !== "comment") {
213
283
  if (!ctx.hasUI) {
214
284
  return fail(
@@ -305,6 +375,21 @@ export async function submitPrReview(
305
375
  failure: "last_review read-back failed",
306
376
  });
307
377
 
378
+ // The accumulating per-PR ledger (read-rebuild-append): ordered rows, one per real success —
379
+ // the stack flow's partial-outcome/resume authority. Same best-effort tier as last_review.
380
+ const posts: ReviewPostRow[] = [
381
+ ...reviewPostsOf(rebuildWorkflowState(branchOf(ctx)).review_posts),
382
+ { pr: record.pr, event: params.event, at: record.at },
383
+ ];
384
+ appendWorkflowState(pi, ctx, {
385
+ data: { review_posts: posts },
386
+ field: "review_posts",
387
+ expected: posts,
388
+ scope: "review",
389
+ failure: "review_posts read-back failed",
390
+ equals: reviewPostsEqual,
391
+ });
392
+
308
393
  let text =
309
394
  `submitted ${params.event} review to PR #${record.pr} ` +
310
395
  `(${data.comment_count ?? commentCount} inline comment(s))`;
@@ -320,10 +405,10 @@ export async function submitPrReview(
320
405
 
321
406
  const TOOL_GUIDELINES = [
322
407
  "Call submit_pr_review only after the human triage has settled the batch AND the human has explicitly approved posting — nothing reaches GitHub before triage.",
323
- "Validate first with dry_run: true and repair any reported anchors until validation passes; a dry-run never posts, never gates, and records nothing.",
324
- "Make ONE real call: comments + body + event land atomically in a single review — the verdict never lands before the comments.",
408
+ "Validate first with dry_run: true and repair any reported anchors until validation passes; a dry-run never posts, never gates, and records nothing. A stack review dry-runs ALL per-PR batches before ANY real post.",
409
+ "Make ONE real call per target PR: comments + body + event land atomically in a single review — the verdict never lands before the comments. A stack review posts one review per member PR, bottom→top; each real success appends a {pr, event, at} row to the review_posts workflow-state ledger. The tool ENFORCES skip-on-resume: a real post to a PR that already has a ledger row is refused (already_posted) unless allow_repost: true — a deliberate second review only. A MISSING row is not proof of no post (the ledger is best-effort): verify posted-vs-pending against GitHub before re-posting.",
325
410
  "Formal events (approve / request-changes) additionally raise a blocking in-TUI confirm; headless sessions refuse them (use event: comment or re-run interactively).",
326
- "All perk-side GitHub posting flows through this tool on both review doors — never post via gh or bash (direct perk pr review-submit calls are forbidden). On /pr-review-terminal this tool is the sole posting path; on /pr-review-browser the plannotator UI's native platform-posting is the human's own GitHub path, and perk posts only what the human explicitly hands it (typically a request-changes verdict).",
411
+ "All perk-side GitHub posting flows through this tool on every review door — never post via gh or bash (direct perk pr review-submit calls are forbidden). On /pr-review-terminal this tool is the sole posting path. On /pr-review-browser the plannotator UI's native platform-posting is the human's own GitHub path, and perk posts only what the human explicitly hands it (typically a request-changes verdict). On /stack-review-browser the local-diff session has NO attached PR, so ALL posting is perk-side after triage.",
327
412
  ];
328
413
 
329
414
  // ------------------------------------------------------------------------ registration
@@ -334,9 +419,12 @@ export function registerSubmitPrReview(pi: ExtensionAPI): void {
334
419
  name: "submit_pr_review",
335
420
  label: "Submit PR review",
336
421
  description:
337
- "Submit the human-curated review-door outcome to the foreign PR as ONE atomic review " +
338
- "(comments + body + event) via the perk cold door. dry_run validates the anchors without " +
339
- "posting (the repair loop); a real submission records last_review in workflow-state.",
422
+ "Submit the human-curated review-door outcome to the target PR as ONE atomic review " +
423
+ "(comments + body + event) via the perk cold door the posting surface of the " +
424
+ "/pr-review-terminal, /pr-review-browser, and /stack-review-browser doors (a stack " +
425
+ "review makes one real call per member PR). dry_run validates the anchors without " +
426
+ "posting (the repair loop); a real submission records last_review and appends the " +
427
+ "review_posts ledger row in workflow-state.",
340
428
  promptSnippet: "Submit the curated review batch to the PR",
341
429
  promptGuidelines: TOOL_GUIDELINES,
342
430
  executionMode: "sequential",
@@ -363,7 +451,9 @@ export function registerSubmitPrReview(pi: ExtensionAPI): void {
363
451
  type: "array",
364
452
  description:
365
453
  "The curated inline comments — human-authored or human-approved only, each anchored " +
366
- "to a line in the PR diff (never re-anchor a child's finding).",
454
+ "to a line in the PR diff. Single-PR mode: never re-anchor a child's finding. Stack " +
455
+ "mode: the parent re-anchors combined-diff findings into per-PR coordinates under " +
456
+ "the dry-run loop.",
367
457
  items: {
368
458
  type: "object",
369
459
  additionalProperties: false,
@@ -386,6 +476,13 @@ export function registerSubmitPrReview(pi: ExtensionAPI): void {
386
476
  "Validate the batch + anchors without posting (the anchor-repair loop). No gates, " +
387
477
  "no last_review record.",
388
478
  },
479
+ allow_repost: {
480
+ type: "boolean",
481
+ description:
482
+ "Deliberately post ANOTHER review to a PR that already has a review_posts ledger " +
483
+ "row in this session — the enforced resume guard refuses with already_posted " +
484
+ "otherwise. Never pass it to work around a stack-resume refusal.",
485
+ },
389
486
  },
390
487
  },
391
488
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -631,9 +631,10 @@ export function objectiveReadInstruction(
631
631
  * Fetch the objective's URL via `perk objective show <id> --json` (reading `objective.url`).
632
632
  * Lenient: returns "" on any failure / missing url — never throws (the seed prompt's step-1
633
633
  * `perk objective show <id>` step surfaces the URL anyway). Only called for the linear backend
634
- * (github needs no clause → no fetch).
634
+ * (github needs no clause → no fetch). Exported for the warm drives that compose the same
635
+ * backend-aware read clause (the ready-time reconcile drive in `doors/ready.ts`).
635
636
  */
636
- async function fetchObjectiveUrl(
637
+ export async function fetchObjectiveUrl(
637
638
  pi: ExtensionAPI,
638
639
  ctx: ExtensionContext,
639
640
  objectiveId: string,
@@ -680,7 +681,7 @@ export function reconcileGuidance(objective: string, backend = "github", url = "
680
681
  }
681
682
 
682
683
  const RECONCILE_TOOL_GUIDELINES = [
683
- "Call reconcile_objective only to rewrite the objective's Reconcilable prose region after a PR merged — the roadmap table and Immutable notes are never touched.",
684
+ "Call reconcile_objective only to rewrite the objective's Reconcilable prose region after a PR merged or after a stacked ready stamp (the ready-time pass) — the roadmap table and Immutable notes are never touched.",
684
685
  "Pass reconcile_objective the FULL replacement prose; it overwrites the marker-bounded Reconcilable region wholesale.",
685
686
  "Judgment + durable writes stay with you; skip reconcile_objective when nothing is stale (do not churn).",
686
687
  ];
@@ -688,6 +689,7 @@ const RECONCILE_TOOL_GUIDELINES = [
688
689
  const ADD_NODE_TOOL_GUIDELINES = [
689
690
  "Use add_objective_node SPARINGLY — only during reconciliation, when a genuine new unit of work emerged that wasn't planned: a deferred follow-up the PR flagged, an uncovered defect/gap, a missing prerequisite for a later node, or human-requested work from the engagement block.",
690
691
  "add_objective_node is only for genuinely-new, unplanned work — never to restate, rename, or re-scope an existing node (use objective_node's `description` for that).",
692
+ "Stacked objectives accept guarded `pending` tail-appends only — a refusal means the discovery is structural: route it to `perk objective replan`.",
691
693
  "Judgment + durable writes stay with you; add_objective_node delegates the write to the canonical Python plane.",
692
694
  ];
693
695
 
@@ -820,9 +822,13 @@ export function registerObjectivePlan(pi: ExtensionAPI, gating: ToolGating): voi
820
822
  label: "Reconcile objective prose",
821
823
  description:
822
824
  "Rewrite the objective's Reconcilable prose region (the marker-bounded prose in the " +
823
- "objective body) to reconcile it against a merged PR. The Mechanical roadmap table and any " +
824
- "Immutable notes are NEVER touched. Delegates the write to the perk cold door.",
825
- promptSnippet: "Reconcile the objective's Reconcilable prose region against the merged diff",
825
+ "objective body) to reconcile it against the pass's evidence a merged PR (post-land) or " +
826
+ "a stacked layer's pinned accepted diff range (the ready-time pass). The Mechanical " +
827
+ "roadmap table and any Immutable notes are NEVER touched. Delegates the write to the perk " +
828
+ "cold door.",
829
+ promptSnippet:
830
+ "Reconcile the objective's Reconcilable prose region against the pass's evidence " +
831
+ "(merged diff, or the ready-time pinned accepted range)",
826
832
  promptGuidelines: RECONCILE_TOOL_GUIDELINES,
827
833
  executionMode: "sequential",
828
834
  parameters: {
@@ -87,8 +87,11 @@ function decodeObjectiveCreate(payload: ColdJson): ObjectiveCreatePayload | null
87
87
  * `{input}` and the save stamps `generated_at`; the approval path passes the artifact block
88
88
  * through with its stored stamp AND stored parts — the stored parts are byte-compared against
89
89
  * the freshly re-rendered ones, so run-scratch drift or artifact tamper between draft-write
90
- * and save refuses `bad_state` (nothing saved, the gate stays on). The parts do NOT cross to
91
- * the Python plane yet companion persistence is deferred (explicit in §8.63, not silent).
90
+ * and save refuses `bad_state` (nothing saved, the gate stays on). On the dream arm the
91
+ * reviewed CANONICAL parts cross to the Python plane through the run-scoped
92
+ * `dream-report-transfer.json` handoff (§8.64) — staged atomically before the cold door (a
93
+ * write failure is the soft `scratch_failed` refusal; the door is not invoked) — and
94
+ * `perk objective create` re-validates the transfer and converges the companion idempotently.
92
95
  */
93
96
  export async function saveObjective(
94
97
  pi: ExtensionAPI,
@@ -35,6 +35,7 @@ import { registerPrReviewTerminal } from "./doors/prReviewTerminal.ts";
35
35
  import { registerReady } from "./doors/ready.ts";
36
36
  import { registerReviewWaveTools } from "./doors/reviewWaveTools.ts";
37
37
  import { registerSelfcheck } from "./doors/selfcheck.ts";
38
+ import { registerOpenStackReview, registerStackReviewBrowser } from "./doors/stackReviewBrowser.ts";
38
39
  import { registerSubmit } from "./doors/submit.ts";
39
40
  import { registerSubmitPrReview } from "./doors/submitPrReview.ts";
40
41
  import { registerGistAuthor } from "./factories/gistAuthor.ts";
@@ -260,6 +261,21 @@ export default function (pi: ExtensionAPI) {
260
261
  if (handoff === null || handoff.run_id !== decision.runId) {
261
262
  reportError(`handoff missing or mismatched for run ${decision.runId}`);
262
263
  } else {
264
+ // The objective-plan cold door's handoff_extra carries the node link
265
+ // (objective_id/node_id): persist it as the objective_node_claim so the implement-here
266
+ // exits are structurally suppressed in COLD objective-plan sessions too (the warm
267
+ // `objective_node` tool records the claim; a cold factory session never calls it — the
268
+ // door marked the node before launch). Blank/absent ids persist nothing; the claim
269
+ // clears on a successful node-linked save exactly as the warm-recorded one does.
270
+ const handoffObjective = handoff.objective_id;
271
+ const handoffNode = handoff.node_id;
272
+ const nodeClaim =
273
+ typeof handoffObjective === "string" &&
274
+ handoffObjective.trim() !== "" &&
275
+ typeof handoffNode === "string" &&
276
+ handoffNode.trim() !== ""
277
+ ? { objective: handoffObjective, node: handoffNode }
278
+ : undefined;
263
279
  const data: WorkflowState = {
264
280
  run_id: decision.runId,
265
281
  pi_session_id: currentSessionId ?? undefined,
@@ -268,6 +284,7 @@ export default function (pi: ExtensionAPI) {
268
284
  // Record the launched stage so the interior can tell e.g. objective-author from plan
269
285
  // (both are read-only) and inject the right authoring context (planMode vs objectiveAuthor).
270
286
  stage: handoff.stage,
287
+ ...(nodeClaim !== undefined ? { objective_node_claim: nodeClaim } : {}),
271
288
  };
272
289
  const okAppend = appendWorkflowState(pi, ctx, {
273
290
  data,
@@ -572,7 +589,9 @@ export default function (pi: ExtensionAPI) {
572
589
  registerSubmit(pi);
573
590
 
574
591
  // The warm `ready` door: the deliberate draft→ready review gate (submit keeps draft).
575
- registerReady(pi);
592
+ // Takes `gating`: the warm ready→reconcile continuation refuses (loudly) to drive the
593
+ // ready-time pass into a read-only session (contracts.md §8.66).
594
+ registerReady(pi, gating);
576
595
 
577
596
  // Warm doors: `land` merges + sets pending-learn; `learn` clears it (TS-only).
578
597
  registerLand(pi);
@@ -630,6 +649,12 @@ export default function (pi: ExtensionAPI) {
630
649
  // human's own platform-post from the UI, with `submit_pr_review` for request-changes only.
631
650
  registerPrReviewBrowser(pi);
632
651
 
652
+ // The warm `/stack-review-browser` door + its cold-launch twin (`open_stack_review`): the
653
+ // stacked-PR browser review over the combined base→top diff — one reviewer wave with
654
+ // `stack: true`, then judgment-routed per-PR posting through `submit_pr_review`.
655
+ registerStackReviewBrowser(pi);
656
+ registerOpenStackReview(pi);
657
+
633
658
  // The warm `/plan-review-browser` door: the summonable streaming draft review — the
634
659
  // plannotator plan-review browser on the working plan draft, draft reviewers streaming
635
660
  // phrase-anchored findings in; APPROVE auto-saves via the approvalSave seam, DENY returns a
@@ -58,8 +58,10 @@ export interface PerkConfig {
58
58
  * workflowScript call — a default flowing onto every lane, single-child runs included (as
59
59
  * /pr-review does); when a key is absent the agent's frontmatter `model` (in
60
60
  * `.pi/agents/perk/<name>.md`; the session-auditor's in its repo-local def) is the default.
61
- * (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
62
- * `applyBuiltinOverrides` applies only to builtins so this inline injection is the mechanism.)
61
+ * (Since pi-subagents 0.52, `subagents.agentOverrides` also reaches custom/project agents —
62
+ * but only as a frontmatter-sensitive fill that never displaces a field the def's own
63
+ * frontmatter sets; every perk def pins `model:` in frontmatter, so this inline workflow-level
64
+ * injection remains the mechanism.)
63
65
  * A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the
64
66
  * `"inherit"` sentinel (child inherits the parent session's model) — both resolved by
65
67
  * pi-subagents on the injected value (the last-colon segment counts as thinking only when it
@@ -1,8 +1,8 @@
1
1
  // perk-owned dot-directory path construction — the TS twin of perk/substrate/paths.py
2
2
  // (contracts.md §8.1).
3
3
  //
4
- // The sole construction site for the perk-owned config family (`config.toml`/`local.toml`) and
5
- // the perk dir. The config family now lives at `.perk/` (TS reads the target only — the legacy
4
+ // The sole construction site for the perk-owned config family (`config.toml`/`local.toml`).
5
+ // The config family now lives at `.perk/` (TS reads the target only — the legacy
6
6
  // `.pi/perk.toml` migration is Python-side). The workflow family lives in the established cache
7
7
  // seam (substrate/cache.ts's `workflowDir`); together these two modules own every perk-owned
8
8
  // dot-path on this plane.
@@ -17,11 +17,6 @@ import { join } from "node:path";
17
17
  export const CONFIG_FILENAME = "config.toml";
18
18
  export const LOCAL_CONFIG_FILENAME = "local.toml";
19
19
 
20
- /** The perk-owned dot-dir root (shared with Pi today). */
21
- export function perkDir(cwd: string): string {
22
- return join(cwd, ".pi");
23
- }
24
-
25
20
  /** The single config-family redirection point (the file helpers derive from it). */
26
21
  export function configDir(cwd: string): string {
27
22
  return join(cwd, ".perk");