@mgiles/perk 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +5 -0
  2. package/extension/authoring/gist/draft.ts +198 -0
  3. package/extension/authoring/gist/prose.ts +46 -0
  4. package/extension/authoring/gist/review.ts +133 -0
  5. package/extension/authoring/gist/save.ts +118 -0
  6. package/extension/authoring/objective/draft.ts +345 -0
  7. package/extension/{factories/objectiveDreamReport.ts → authoring/objective/dreamReportGate.ts} +74 -131
  8. package/extension/authoring/objective/planning.ts +124 -0
  9. package/extension/authoring/objective/prose.ts +103 -0
  10. package/extension/authoring/objective/review.ts +128 -0
  11. package/extension/authoring/objective/save.ts +224 -0
  12. package/extension/authoring/plan/draft.ts +84 -0
  13. package/extension/authoring/plan/prose.ts +41 -0
  14. package/extension/authoring/plan/review.ts +269 -0
  15. package/extension/authoring/plan/save.ts +256 -0
  16. package/extension/authoring/plan/source.ts +82 -0
  17. package/extension/authoring/refinement/context.ts +468 -0
  18. package/extension/authoring/refinement/draft.ts +261 -0
  19. package/extension/authoring/refinement/prose.ts +79 -0
  20. package/extension/authoring/refinement/review.ts +111 -0
  21. package/extension/authoring/refinement/save.ts +119 -0
  22. package/extension/authoring/review/approvalGate.ts +34 -0
  23. package/extension/authoring/review/draftContext.ts +68 -0
  24. package/extension/codeReview/automated.ts +352 -0
  25. package/extension/codeReview/submission.ts +229 -0
  26. package/extension/delivery/address.ts +295 -0
  27. package/extension/delivery/ci.ts +355 -0
  28. package/extension/delivery/commitCompact.ts +93 -0
  29. package/extension/delivery/conflictResolution.ts +247 -0
  30. package/extension/delivery/ready.ts +193 -0
  31. package/extension/delivery/stackConflict.ts +361 -0
  32. package/extension/delivery/stackObjective.ts +16 -0
  33. package/extension/delivery/stackReconcile.ts +165 -0
  34. package/extension/delivery/submit.ts +171 -0
  35. package/extension/index.ts +369 -359
  36. package/extension/learning/analystWave.ts +324 -0
  37. package/extension/learning/audit.ts +667 -0
  38. package/extension/learning/capture.ts +92 -0
  39. package/extension/learning/containment.ts +104 -0
  40. package/extension/{waves/dreamWave.ts → learning/dream.ts} +112 -94
  41. package/extension/learning/dreamAnalysis.ts +435 -0
  42. package/extension/{waves/dreamReducerWave.ts → learning/dreamReducer.ts} +46 -41
  43. package/extension/{waves → learning}/dreamReport.ts +35 -31
  44. package/extension/learning/harvest.ts +491 -0
  45. package/extension/learning/prose.ts +66 -0
  46. package/extension/learning/routing.ts +79 -0
  47. package/extension/pi/v1/bashScanTimeout.ts +64 -0
  48. package/extension/{doors/prReview.ts → pi/v1/codeReview/automated.ts} +215 -310
  49. package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +125 -57
  50. package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
  51. package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +169 -116
  52. package/extension/pi/v1/codeReview/stack.ts +606 -0
  53. package/extension/pi/v1/codeReview/submit.ts +354 -0
  54. package/extension/{doors/prReviewTerminal.ts → pi/v1/codeReview/terminal.ts} +32 -27
  55. package/extension/pi/v1/contextEvidence.ts +80 -0
  56. package/extension/pi/v1/contextInjection.ts +207 -0
  57. package/extension/{doors → pi/v1/delivery}/address.ts +163 -265
  58. package/extension/pi/v1/delivery/ci.ts +570 -0
  59. package/extension/pi/v1/delivery/commitCompact.ts +201 -0
  60. package/extension/pi/v1/delivery/conflictResolverEngine.ts +425 -0
  61. package/extension/{doors → pi/v1/delivery}/land.ts +128 -60
  62. package/extension/pi/v1/delivery/ready.ts +322 -0
  63. package/extension/pi/v1/delivery/stackConflictResolver.ts +172 -0
  64. package/extension/pi/v1/delivery/stackDrive.ts +120 -0
  65. package/extension/pi/v1/delivery/stackLand.ts +223 -0
  66. package/extension/pi/v1/delivery/stackRecover.ts +265 -0
  67. package/extension/pi/v1/delivery/stackStatus.ts +237 -0
  68. package/extension/pi/v1/delivery/stackSync.ts +658 -0
  69. package/extension/pi/v1/delivery/submit.ts +389 -0
  70. package/extension/pi/v1/delivery/submitConflict.ts +186 -0
  71. package/extension/pi/v1/draftReview.ts +431 -0
  72. package/extension/{doors → pi/v1}/draftReviewWaveTools.ts +141 -151
  73. package/extension/pi/v1/gist.ts +794 -0
  74. package/extension/pi/v1/learning/audit.ts +186 -0
  75. package/extension/pi/v1/learning/dream.ts +207 -0
  76. package/extension/{doors/learnFactory.ts → pi/v1/learning/factory.ts} +18 -65
  77. package/extension/{doors/harvestWaveTools.ts → pi/v1/learning/harvest.ts} +46 -100
  78. package/extension/pi/v1/learning/learn.ts +585 -0
  79. package/extension/{doors → pi/v1}/lifecycleGates.ts +14 -59
  80. package/extension/{factories → pi/v1}/objective.ts +53 -33
  81. package/extension/pi/v1/objectiveAuthoring.ts +672 -0
  82. package/extension/pi/v1/objectiveDreamGate.ts +160 -0
  83. package/extension/pi/v1/objectivePlanning.ts +776 -0
  84. package/extension/pi/v1/objectiveRefinement.ts +1320 -0
  85. package/extension/pi/v1/objectiveReview.ts +451 -0
  86. package/extension/{doors → pi/v1}/objectiveReviewBrowser.ts +259 -172
  87. package/extension/pi/v1/plan.ts +812 -0
  88. package/extension/pi/v1/planReview.ts +820 -0
  89. package/extension/{doors → pi/v1}/planReviewBrowser.ts +228 -152
  90. package/extension/{doors/annotationPush.ts → pi/v1/providers/annotations.ts} +158 -89
  91. package/extension/pi/v1/providers/plannotator.ts +487 -0
  92. package/extension/{doors → pi/v1/providers}/plannotatorHandoff.ts +152 -34
  93. package/extension/pi/v1/providers/selection.ts +43 -0
  94. package/extension/{adapters/planAdapterTombell.ts → pi/v1/providers/tombell.ts} +43 -72
  95. package/extension/pi/v1/review.ts +538 -0
  96. package/extension/pi/v1/reviewOutcome.ts +9 -0
  97. package/extension/pi/v1/scoutWave.ts +318 -0
  98. package/extension/{doors → pi/v1}/selfcheck.ts +4 -4
  99. package/extension/session/branchWorkflowSession.ts +60 -0
  100. package/extension/session/lifecycle.ts +644 -0
  101. package/extension/session/lifecycleGates.ts +64 -0
  102. package/extension/session/saveDestination.ts +87 -0
  103. package/extension/session/workflowSession.ts +971 -0
  104. package/extension/substrate/agentScratch.ts +27 -54
  105. package/extension/substrate/bashScanTimeout.ts +181 -0
  106. package/extension/substrate/bindingDelivery.ts +38 -30
  107. package/extension/substrate/bindings.ts +4 -5
  108. package/extension/substrate/cache.ts +64 -12
  109. package/extension/substrate/childRestrictions.ts +39 -0
  110. package/extension/substrate/coldDoor.ts +17 -1
  111. package/extension/substrate/config.ts +157 -19
  112. package/extension/substrate/git.ts +88 -6
  113. package/extension/substrate/modelVisible.ts +53 -0
  114. package/extension/substrate/paths.ts +2 -7
  115. package/extension/substrate/prompts.ts +22 -0
  116. package/extension/substrate/registry.ts +2 -0
  117. package/extension/substrate/resolverLease.ts +364 -0
  118. package/extension/substrate/sessionData.ts +85 -152
  119. package/extension/substrate/toolGating.ts +279 -84
  120. package/extension/substrate/unifiedDiff.ts +1 -1
  121. package/extension/substrate/workflowState.ts +191 -166
  122. package/extension/substrate/worktreeResolverLock.ts +261 -0
  123. package/extension/surfaces/surfaces.ts +79 -27
  124. package/extension/waves/adversarialReviewWave.ts +103 -48
  125. package/extension/waves/blockedReports.ts +59 -0
  126. package/extension/waves/draftReviewWave.ts +42 -42
  127. package/extension/waves/laneIdentity.ts +77 -0
  128. package/extension/waves/objectiveExplorerWave.ts +24 -24
  129. package/extension/waves/prReviewWave.ts +89 -77
  130. package/extension/waves/reportWave.ts +438 -578
  131. package/extension/waves/reviewClassifierWave.ts +22 -22
  132. package/extension/waves/rpcAdapter.ts +100 -15
  133. package/extension/waves/scoutWave.ts +192 -0
  134. package/extension/waves/transport.ts +480 -0
  135. package/extension/worker/sdkAdapter.ts +494 -0
  136. package/extension/worker/stageExecution.ts +679 -0
  137. package/extension/workerMain.ts +18 -19
  138. package/package.json +6 -4
  139. package/prompts/_fixtures/live.yaml +98 -10
  140. package/prompts/contexts/adapters/plannotator-gist.md +6 -0
  141. package/prompts/contexts/adapters/plannotator-objective.md +6 -0
  142. package/prompts/contexts/adapters/plannotator-plan.md +8 -1
  143. package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
  144. package/prompts/contexts/adapters/tombell-plan.md +4 -0
  145. package/prompts/contexts/objective-refinement.md +17 -0
  146. package/prompts/contexts/plan-authoring.md +6 -5
  147. package/prompts/contexts/read-only.md +1 -1
  148. package/prompts/stages/conflict-resolution-continuation.md +9 -0
  149. package/prompts/stages/conflict-resolution.md +4 -4
  150. package/prompts/stages/objective-author/adopt.md +1 -1
  151. package/prompts/stages/objective-author/file.md +1 -1
  152. package/prompts/stages/objective-author/seed.md +1 -1
  153. package/prompts/stages/objective-plan/guidance.md +2 -2
  154. package/prompts/stages/objective-plan/seed.md +9 -1
  155. package/prompts/stages/objective-reconcile-ready.md +7 -0
  156. package/prompts/stages/objective-reconcile.md +1 -1
  157. package/prompts/stages/objective-refine/seed.md +18 -0
  158. package/prompts/stages/objective-review-browser.md +4 -4
  159. package/prompts/stages/objective-sync.md +1 -1
  160. package/prompts/stages/plan-review-browser.md +4 -4
  161. package/prompts/stages/pr-review-browser/active.md +3 -4
  162. package/prompts/stages/pr-review-browser/foreign.md +3 -4
  163. package/prompts/stages/pr-review-terminal/active.md +3 -3
  164. package/prompts/stages/pr-review-terminal/foreign.md +3 -3
  165. package/prompts/stages/pr-review.md +3 -3
  166. package/prompts/stages/stack-review/cold.md +1 -0
  167. package/prompts/stages/stack-review-browser/stack.md +22 -0
  168. package/shared/README.md +8 -3
  169. package/shared/bindings.yaml +6 -3
  170. package/shared/contracts.md +4499 -2147
  171. package/shared/fixtures/issues-table.json +130 -0
  172. package/shared/registry.yaml +29 -1
  173. package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
  174. package/shared/schemas/outputs/objective-stack-status.schema.json +178 -2
  175. package/shared/schemas/outputs/pr-ready.schema.json +110 -2
  176. package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
  177. package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
  178. package/extension/adapters/planAdapterPlannotator.ts +0 -362
  179. package/extension/doors/auditWaveTools.ts +0 -352
  180. package/extension/doors/ciExecutor.ts +0 -756
  181. package/extension/doors/commitCompact.ts +0 -251
  182. package/extension/doors/dreamWaveTools.ts +0 -475
  183. package/extension/doors/learn.ts +0 -655
  184. package/extension/doors/objectiveStack.ts +0 -1143
  185. package/extension/doors/prReviewDynamic.ts +0 -276
  186. package/extension/doors/ready.ts +0 -87
  187. package/extension/doors/submit.ts +0 -347
  188. package/extension/doors/submitPrReview.ts +0 -408
  189. package/extension/factories/gistAuthor.ts +0 -94
  190. package/extension/factories/gistDraft.ts +0 -265
  191. package/extension/factories/gistSave.ts +0 -251
  192. package/extension/factories/implementHere.ts +0 -116
  193. package/extension/factories/objectiveAuthor.ts +0 -98
  194. package/extension/factories/objectiveDraft.ts +0 -466
  195. package/extension/factories/objectivePlan.ts +0 -975
  196. package/extension/factories/objectiveSave.ts +0 -363
  197. package/extension/factories/planDraft.ts +0 -140
  198. package/extension/factories/planMode.ts +0 -205
  199. package/extension/factories/planReview.ts +0 -1237
  200. package/extension/factories/planSave.ts +0 -604
  201. package/extension/factories/planTitle.ts +0 -141
  202. package/extension/substrate/structuredOutput.ts +0 -202
  203. package/extension/waves/auditWave.ts +0 -312
  204. package/extension/waves/harvestWave.ts +0 -399
  205. package/extension/waves/learnWave.ts +0 -155
  206. package/extension/waves/memoryAdapter.ts +0 -139
  207. package/extension/waves/prReviewDynamicWave.ts +0 -777
  208. package/extension/worker/readOnlySession.ts +0 -294
  209. package/extension/worker/worker.ts +0 -899
  210. package/prompts/stages/pr-review-dynamic.md +0 -7
  211. package/shared/contracts-history.md +0 -605
@@ -0,0 +1,354 @@
1
+ // The v1 Pi installer for the curated-submission flow: `installCuratedSubmissionBindings` owns
2
+ // the warm `submit_pr_review` tool — the agent-driven curated-posting surface shared by the
3
+ // PR-review doors (`/pr-review-terminal`, `/pr-review-browser`, `/stack-review-browser`).
4
+ // Registration metadata is pinned by the suite's registration-parity tests; the policy (gate ladder, resume guard, session
5
+ // records) lives in `codeReview/submission.ts` — this module decodes at the tool boundary,
6
+ // composes the `perk pr review-submit` cold-door `ReviewSubmitter` (mutations canonical in
7
+ // Python; the batch rides the run-scratch stdin channel), selects the `FormalEventGate` arm
8
+ // from `ctx.hasUI`, opens the branch `WorkflowSession` at the execute site, and renders the
9
+ // Result envelope (`kind` → the error_type vocabulary; success texts).
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ type CuratedSubmission,
14
+ type FormalEventGate,
15
+ type InvalidAnchor,
16
+ type ReviewSubmitOutcome,
17
+ type ReviewSubmitter,
18
+ type SubmitBatch,
19
+ type SubmitComment,
20
+ type SubmitCuratedOutcome,
21
+ type SubmitOk,
22
+ submitCuratedReview,
23
+ } from "../../../codeReview/submission.ts";
24
+ import { openBranchWorkflowSession } from "../../../session/branchWorkflowSession.ts";
25
+ import {
26
+ booleanField,
27
+ type ColdDoorCtx,
28
+ type ColdJson,
29
+ type ExecHost,
30
+ numberField,
31
+ runColdDoor,
32
+ stringField,
33
+ } from "../../../substrate/coldDoor.ts";
34
+ import { failFor, ok, type Result } from "../../../substrate/result.ts";
35
+ import {
36
+ arrayParam,
37
+ booleanParam,
38
+ numberParam,
39
+ paramsOf,
40
+ stringParam,
41
+ type ToolParams,
42
+ } from "../../../substrate/toolParams.ts";
43
+ import type { Severity } from "../../../surfaces/report.ts";
44
+
45
+ // ------------------------------------------------------------------------ params
46
+
47
+ /** Decode the optional `comments` array; null = present-but-malformed (whole-batch refusal). */
48
+ function decodeSubmitComments(p: ToolParams): SubmitComment[] | undefined | null {
49
+ const raw = arrayParam(p, "comments");
50
+ if (raw === undefined) return undefined;
51
+ if (raw === null) return null;
52
+ const comments: SubmitComment[] = [];
53
+ for (const item of raw) {
54
+ const row = paramsOf(item);
55
+ if (row === null) return null;
56
+ const path = stringParam(row, "path");
57
+ const line = numberParam(row, "line");
58
+ const side = stringParam(row, "side");
59
+ const body = stringParam(row, "body");
60
+ if (typeof path !== "string" || path.length === 0) return null;
61
+ if (typeof line !== "number" || !Number.isInteger(line)) return null;
62
+ if (side !== undefined && side !== "LEFT" && side !== "RIGHT") return null;
63
+ if (typeof body !== "string" || body.length === 0) return null;
64
+ const comment: SubmitComment = { path, line, body };
65
+ if (side !== undefined) comment.side = side;
66
+ comments.push(comment);
67
+ }
68
+ return comments;
69
+ }
70
+
71
+ /**
72
+ * Strict-decode unknown tool-call params straight into the normalized `CuratedSubmission`
73
+ * feature input (the tool-boundary seam — no intermediate wire DTO: the two boolean defaults
74
+ * are applied here, absent ⇒ false). Mirrors `decodePostParams`: submitting a guessed/partial
75
+ * review is a durable GitHub mutation, so ANY malformed field ⇒ null (whole-batch refusal).
76
+ * `pr` must be an int; `event` exactly one of the three flag spellings; `body` a string (EMPTY
77
+ * ALLOWED — the cold door owns the event-conditioned body rule and reports `bad_batch`); each
78
+ * `comments` row strict on path/line(int)/side(LEFT|RIGHT)/body; `dry_run` and `allow_repost`
79
+ * booleans.
80
+ */
81
+ export function decodeSubmitParams(params: unknown): CuratedSubmission | null {
82
+ const p = paramsOf(params);
83
+ if (p === null) return null;
84
+ const pr = numberParam(p, "pr");
85
+ if (typeof pr !== "number" || !Number.isInteger(pr)) return null;
86
+ const event = stringParam(p, "event");
87
+ if (event !== "approve" && event !== "request-changes" && event !== "comment") return null;
88
+ const body = stringParam(p, "body");
89
+ if (typeof body !== "string") return null;
90
+ const comments = decodeSubmitComments(p);
91
+ if (comments === null) return null;
92
+ const dryRun = booleanParam(p, "dry_run");
93
+ if (dryRun === null) return null;
94
+ const allowRepost = booleanParam(p, "allow_repost");
95
+ if (allowRepost === null) return null;
96
+ const result: CuratedSubmission = {
97
+ pr,
98
+ event,
99
+ body,
100
+ dryRun: dryRun === true,
101
+ allowRepost: allowRepost === true,
102
+ };
103
+ if (comments !== undefined) result.comments = comments;
104
+ return result;
105
+ }
106
+
107
+ // ------------------------------------------------------------------------ port productions
108
+
109
+ /** Narrow the cold door's `review-submit --json` payload to the fields the tool reports. */
110
+ function decodeSubmitResult(payload: ColdJson): SubmitOk {
111
+ return {
112
+ dry_run: booleanField(payload, "dry_run"),
113
+ pr: numberField(payload, "pr"),
114
+ event: stringField(payload, "event"),
115
+ mode: stringField(payload, "mode"),
116
+ comment_count: numberField(payload, "comment_count"),
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Strict re-narrow of the `bad_anchors` fail payload's `invalid[]` rows. Null on ANY drift —
122
+ * uncertainty renders as a plain fail, never a half table.
123
+ */
124
+ function decodeInvalidAnchors(payload: ColdJson): InvalidAnchor[] | null {
125
+ const raw = payload.invalid;
126
+ if (!Array.isArray(raw)) return null;
127
+ const rows: InvalidAnchor[] = [];
128
+ for (const item of raw) {
129
+ const row = paramsOf(item);
130
+ if (row === null) return null;
131
+ const index = row.index;
132
+ const path = row.path;
133
+ const line = row.line;
134
+ const side = row.side;
135
+ const reason = row.reason;
136
+ if (typeof index !== "number" || !Number.isInteger(index)) return null;
137
+ if (typeof path !== "string") return null;
138
+ if (typeof line !== "number" || !Number.isInteger(line)) return null;
139
+ if (typeof side !== "string") return null;
140
+ if (typeof reason !== "string") return null;
141
+ rows.push({ index, path, line, side, reason });
142
+ }
143
+ return rows;
144
+ }
145
+
146
+ /**
147
+ * The production `ReviewSubmitter`: the `perk pr review-submit` cold-door composition (the exact
148
+ * `--batch` shape: `{body, comments?}` — the event rides the flag). The adapter performs the
149
+ * strict `invalid[]` decode; decode drift ⇒ the `failed` arm.
150
+ */
151
+ export function createColdDoorReviewSubmitter(pi: ExecHost, ctx: ColdDoorCtx): ReviewSubmitter {
152
+ return {
153
+ async submit(batch: SubmitBatch): Promise<ReviewSubmitOutcome> {
154
+ const payload: Record<string, unknown> = { body: batch.body };
155
+ if (batch.comments !== undefined) payload.comments = batch.comments;
156
+ const r = await runColdDoor<SubmitOk>(
157
+ pi,
158
+ ctx,
159
+ [
160
+ "pr",
161
+ "review-submit",
162
+ "--pr",
163
+ String(batch.pr),
164
+ "--event",
165
+ batch.event,
166
+ ...(batch.dryRun ? ["--dry-run"] : []),
167
+ "--json",
168
+ ],
169
+ {
170
+ label: "perk pr review-submit",
171
+ decode: decodeSubmitResult,
172
+ stdin: {
173
+ flag: "--batch",
174
+ content: `${JSON.stringify(payload, null, 2)}\n`,
175
+ filename: `review-submit-${Date.now()}.json`,
176
+ },
177
+ },
178
+ );
179
+ if (r.ok) return { ok: true, data: r.data };
180
+ if (r.errorType === "bad_anchors" && r.payload !== undefined) {
181
+ const rows = decodeInvalidAnchors(r.payload);
182
+ if (rows !== null && rows.length > 0) {
183
+ return { ok: false, kind: "bad_anchors", invalid: rows, message: r.message };
184
+ }
185
+ }
186
+ return { ok: false, kind: "failed", message: r.message, errorType: r.errorType };
187
+ },
188
+ };
189
+ }
190
+
191
+ /**
192
+ * The minimal ctx slice the gate production needs — `ExtensionContext` satisfies it
193
+ * (compile-checked in the test). The dialog method is reachable ONLY through the interactive arm.
194
+ */
195
+ export interface SubmitGateCtx {
196
+ hasUI: boolean;
197
+ ui: {
198
+ notify(message: string, type?: Severity): void;
199
+ confirm(title: string, message: string): Promise<boolean>;
200
+ };
201
+ }
202
+
203
+ /** The production `FormalEventGate`: `ctx.hasUI` selects the arm; interactive wraps the dialog. */
204
+ export function formalEventGateFor(ctx: SubmitGateCtx): FormalEventGate {
205
+ return ctx.hasUI
206
+ ? { kind: "interactive", confirm: (question, summary) => ctx.ui.confirm(question, summary) }
207
+ : { kind: "headless" };
208
+ }
209
+
210
+ // ------------------------------------------------------------------------ result rendering
211
+
212
+ /** Map the enumerated feature outcome onto the tool Result envelope (texts pinned by tests). */
213
+ function renderSubmitOutcome(
214
+ fail: (message: string, errorType: string) => Result<SubmitOk>,
215
+ input: CuratedSubmission,
216
+ outcome: SubmitCuratedOutcome,
217
+ ): Result<SubmitOk> {
218
+ const commentCount = input.comments?.length ?? 0;
219
+ switch (outcome.kind) {
220
+ case "already_posted":
221
+ return fail(outcome.message, "already_posted");
222
+ case "headless_formal_event":
223
+ return fail(outcome.message, "headless_formal_event");
224
+ case "user_declined":
225
+ return fail(outcome.message, "user_declined");
226
+ case "bad_anchors":
227
+ return fail(outcome.message, "bad_anchors");
228
+ case "submit_failed":
229
+ return fail(outcome.message, outcome.errorType);
230
+ case "dry_run_ok": {
231
+ const n = outcome.data.comment_count ?? commentCount;
232
+ return ok(
233
+ `validated — ${n} inline comment(s), event ${input.event}; the batch is submittable`,
234
+ { ...outcome.data },
235
+ );
236
+ }
237
+ case "posted": {
238
+ let text =
239
+ `submitted ${input.event} review to PR #${outcome.record.pr} ` +
240
+ `(${outcome.data.comment_count ?? commentCount} inline comment(s))`;
241
+ if (outcome.data.mode === "review_folded") {
242
+ text +=
243
+ " — note: inline anchors rejected by GitHub; comments folded into the review body, " +
244
+ "event preserved";
245
+ } else if (outcome.data.mode === "comment_fallback") {
246
+ text += " — note: degraded to a discussion comment";
247
+ }
248
+ return ok(`${text}.`, { ...outcome.data });
249
+ }
250
+ }
251
+ }
252
+
253
+ const TOOL_GUIDELINES = [
254
+ "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.",
255
+ "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.",
256
+ "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.",
257
+ "Formal events (approve / request-changes) additionally raise a blocking in-TUI confirm; headless sessions refuse them (use event: comment or re-run interactively).",
258
+ "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.",
259
+ ];
260
+
261
+ // ------------------------------------------------------------------------ registration
262
+
263
+ /** Install the `submit_pr_review` tool (the review doors register no posting tools of their own). */
264
+ export function installCuratedSubmissionBindings(pi: ExtensionAPI): void {
265
+ pi.registerTool({
266
+ name: "submit_pr_review",
267
+ label: "Submit PR review",
268
+ description:
269
+ "Submit the human-curated review-door outcome to the target PR as ONE atomic review " +
270
+ "(comments + body + event) via the perk cold door — the posting surface of the " +
271
+ "/pr-review-terminal, /pr-review-browser, and /stack-review-browser doors (a stack " +
272
+ "review makes one real call per member PR). dry_run validates the anchors without " +
273
+ "posting (the repair loop); a real submission records last_review and appends the " +
274
+ "review_posts ledger row in workflow-state.",
275
+ promptSnippet: "Submit the curated review batch to the PR",
276
+ promptGuidelines: TOOL_GUIDELINES,
277
+ executionMode: "sequential",
278
+ parameters: {
279
+ type: "object",
280
+ additionalProperties: false,
281
+ required: ["pr", "event", "body"],
282
+ properties: {
283
+ pr: { type: "number", description: "The foreign PR number being reviewed." },
284
+ event: {
285
+ type: "string",
286
+ enum: ["approve", "request-changes", "comment"],
287
+ description:
288
+ "The review event, settled with the human during triage. Formal events " +
289
+ "(approve/request-changes) additionally raise a blocking confirm dialog.",
290
+ },
291
+ body: {
292
+ type: "string",
293
+ description:
294
+ "The overall review body (markdown). comment/request-changes require a non-empty " +
295
+ "body; unanchorable findings fold in here.",
296
+ },
297
+ comments: {
298
+ type: "array",
299
+ description:
300
+ "The curated inline comments — human-authored or human-approved only, each anchored " +
301
+ "to a line in the PR diff. Single-PR mode: never re-anchor a child's finding. Stack " +
302
+ "mode: the parent re-anchors combined-diff findings into per-PR coordinates under " +
303
+ "the dry-run loop.",
304
+ items: {
305
+ type: "object",
306
+ additionalProperties: false,
307
+ required: ["path", "line", "body"],
308
+ properties: {
309
+ path: { type: "string", description: "The changed file path." },
310
+ line: { type: "number", description: "A line present in the PR diff." },
311
+ side: {
312
+ type: "string",
313
+ enum: ["LEFT", "RIGHT"],
314
+ description: "The diff side the line anchors to (default RIGHT).",
315
+ },
316
+ body: { type: "string", description: "The comment (markdown)." },
317
+ },
318
+ },
319
+ },
320
+ dry_run: {
321
+ type: "boolean",
322
+ description:
323
+ "Validate the batch + anchors without posting (the anchor-repair loop). No gates, " +
324
+ "no last_review record.",
325
+ },
326
+ allow_repost: {
327
+ type: "boolean",
328
+ description:
329
+ "Deliberately post ANOTHER review to a PR that already has a review_posts ledger " +
330
+ "row in this session — the enforced resume guard refuses with already_posted " +
331
+ "otherwise. Never pass it to work around a stack-resume refusal.",
332
+ },
333
+ },
334
+ },
335
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
336
+ const fail = failFor(ctx, "review", "submit_pr_review");
337
+ const decoded = decodeSubmitParams(params);
338
+ if (decoded === null) {
339
+ return fail(
340
+ "submit_pr_review needs { pr: int, event: 'approve'|'request-changes'|'comment', " +
341
+ "body: string, comments?: [{path, line: int, side?: 'LEFT'|'RIGHT', body}], " +
342
+ "dry_run?: bool }",
343
+ "bad_input",
344
+ );
345
+ }
346
+ const outcome = await submitCuratedReview(decoded, {
347
+ submitter: createColdDoorReviewSubmitter(pi, ctx),
348
+ gate: formalEventGateFor(ctx),
349
+ session: openBranchWorkflowSession(pi, ctx),
350
+ });
351
+ return renderSubmitOutcome(fail, decoded, outcome);
352
+ },
353
+ });
354
+ }
@@ -5,27 +5,34 @@
5
5
  // foreign — `/pr-review-terminal <pr|url> [focus]`: the detached `perk pr review checkout`,
6
6
  // the R7 handoff, the full adversarial-reviewer flow (the fan-out rides the
7
7
  // globally registered `start_review_wave`/`collect_review_wave` tool pair; the
8
- // injected guidance holds the `subagent_wait` relay loop and the hunk sink).
8
+ // injected guidance yields between native wakes and owns the hunk sink).
9
9
  // active — `/pr-review-terminal [focus]` from a plan worktree whose branch HAS a PR: the same
10
10
  // flow re-homed to the human's own worktree (no checkout, no cleanup) on the local
11
- // since-base diff (`sinceBaseSha` — best-effort fetch, then merge-base).
11
+ // diff against the PR's current base (`sinceBaseSha` — best-effort fetch, then
12
+ // merge-base), never the plan-ref or repository default.
12
13
  // local — no PR yet (`perk pr url` → `no_pr`): a surface-only since-base review — hunk is
13
14
  // launched, NO reviewers are spawned and NOTHING posts to GitHub; the guidance is a
14
15
  // minimal notes read-back loop.
15
16
  // Every launch carries `--agent-notes` so pushed findings are visible in hunk immediately.
16
17
  // Hunk-sink mechanics and arg semantics are unchanged (`parseReviewDoorArgs` untouched).
17
18
  //
18
- // The door registers NO tools — the fan-out pair is registered globally
19
- // (`registerReviewWaveTools`) and posting reuses `submit_pr_review` (registered by
20
- // `registerSubmitPrReview`), whose gate ladder (contracts §8.4) applies unchanged.
19
+ // The door registers NO tools — the fan-out pair is installed globally
20
+ // (`installReviewWaveBindings`) and posting reuses `submit_pr_review` (installed by
21
+ // `installCuratedSubmissionBindings`), whose gate ladder (contracts §8.4) applies unchanged.
21
22
 
22
23
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
23
- import { bindingSuffix } from "../substrate/bindingDelivery.ts";
24
- import { runColdDoor } from "../substrate/coldDoor.ts";
25
- import { registerPerkCommand } from "../substrate/command.ts";
26
- import { sinceBaseSha } from "../substrate/git.ts";
27
- import { render } from "../substrate/prompts.ts";
28
- import { report } from "../surfaces/report.ts";
24
+ import { bindingSuffix } from "../../../substrate/bindingDelivery.ts";
25
+ import { runColdDoor } from "../../../substrate/coldDoor.ts";
26
+ import { registerPerkCommand } from "../../../substrate/command.ts";
27
+ import { sinceBaseSha } from "../../../substrate/git.ts";
28
+ import { render } from "../../../substrate/prompts.ts";
29
+ import { report } from "../../../surfaces/report.ts";
30
+ import {
31
+ decodePrUrl,
32
+ type PrUrl,
33
+ planRefBaseOf,
34
+ resolveReviewTarget,
35
+ } from "../providers/plannotatorHandoff.ts";
29
36
  import {
30
37
  type CheckoutOk,
31
38
  decodeCheckout,
@@ -33,8 +40,7 @@ import {
33
40
  handleHunkLaunch,
34
41
  hunkPresent,
35
42
  parseReviewArgs,
36
- } from "./hunkHandoff.ts";
37
- import { decodePrUrl, planRefBaseOf, resolveReviewTarget } from "./plannotatorHandoff.ts";
43
+ } from "./checkout.ts";
38
44
 
39
45
  /** The door's report scope — also the `command:<id>` binding trigger id. */
40
46
  const SCOPE = "pr-review-terminal";
@@ -103,8 +109,8 @@ export function prReviewTerminalGuidance(opts: PrReviewTerminalGuidanceOpts): st
103
109
 
104
110
  // ------------------------------------------------------------------------ registration
105
111
 
106
- /** Register the warm `/pr-review-terminal` command (no tools — posting rides submit_pr_review). */
107
- export function registerPrReviewTerminal(pi: ExtensionAPI): void {
112
+ /** Install the warm `/pr-review-terminal` command (no tools — posting rides submit_pr_review). */
113
+ export function installPrReviewTerminalBindings(pi: ExtensionAPI): void {
108
114
  registerPerkCommand(pi, SCOPE, {
109
115
  description:
110
116
  "Review a PR human-in-the-loop in the hunk terminal TUI: no arg reviews the active " +
@@ -187,14 +193,11 @@ export function registerPrReviewTerminal(pi: ExtensionAPI): void {
187
193
  }
188
194
 
189
195
  // The active arm: resolve the worktree's own PR via the shared active-PR ladder.
190
- const r = await runColdDoor<{ number: number; url: string }>(
191
- pi,
192
- ctx,
193
- ["pr", "url", "--json"],
194
- { label: "perk pr url", decode: decodePrUrl },
195
- );
196
- const planRefBase = planRefBaseOf(ctx.cwd);
197
- const target = resolveReviewTarget(r, planRefBase);
196
+ const r = await runColdDoor<PrUrl>(pi, ctx, ["pr", "url", "--json"], {
197
+ label: "perk pr url",
198
+ decode: decodePrUrl,
199
+ });
200
+ const target = resolveReviewTarget(r, planRefBaseOf(ctx.cwd));
198
201
  if (target.mode === "fail") {
199
202
  report(
200
203
  ctx,
@@ -207,18 +210,20 @@ export function registerPrReviewTerminal(pi: ExtensionAPI): void {
207
210
  }
208
211
 
209
212
  // The since-base merge-base (best-effort fetch first): the sha hunk diffs the working tree
210
- // against. On the local arm the target threads the plan-ref's pinned base; on the PR arm
211
- // the plan-ref base is read the same way (null ⇒ repo default via origin/HEAD).
213
+ // against. The PR's current base is authoritative; only the pre-PR local arm uses the
214
+ // plan-ref's pinned base (null ⇒ repo default via origin/HEAD).
212
215
  const fullSha = sinceBaseSha(
213
216
  ctx.cwd,
214
- target.mode === "local" ? target.defaultBranch : planRefBase,
217
+ target.mode === "pr" ? target.baseRef : target.defaultBranch,
215
218
  );
216
219
  if (fullSha === null) {
217
220
  report(
218
221
  ctx,
219
222
  SCOPE,
220
223
  "error",
221
- "could not resolve the since-base merge-base — pass a PR number/URL instead",
224
+ target.mode === "pr"
225
+ ? `could not resolve the since-base merge-base for PR #${target.number} against base branch '${target.baseRef}' — pass a PR number/URL instead`
226
+ : "could not resolve the since-base merge-base — pass a PR number/URL instead",
222
227
  );
223
228
  return;
224
229
  }
@@ -0,0 +1,80 @@
1
+ // Live-context evidence over Pi's OWN projection (contracts §8.31 semantics). Pi decides which
2
+ // session entries are still represented in model context (`sessionManager.buildContextEntries()`
3
+ // — the current leaf's compaction-aware entry list) and how each projects into a runtime message
4
+ // (`sessionEntryToContextMessages`); perk owns only the typed predicates over those native
5
+ // messages. There is no second traversal here: no compaction lookup, no kept-entry cutoff
6
+ // reconstruction, no retained-tail storage inspection — and no cache, registration, or
7
+ // persistent state.
8
+ //
9
+ // Evidence is TYPED, never serialized: a marker counts only when it rides user content (a cold
10
+ // launch's prompt) or the owning customType's custom content (a prior hidden injection). A
11
+ // compaction/branch summary quoting the marker, assistant/tool/bash output mentioning it, an
12
+ // unrelated custom, or plain `custom` state (`data.content`) is NOT a live delivery — Pi's
13
+ // converter keeps those roles distinguishable, so the predicate never has to guess from bytes.
14
+ //
15
+ // Full-branch history (`substrate/workflowState.ts::branchOf` + `branchCarries`) stays a separate
16
+ // authority: eligibility/state rebuilds and the strict once-per-selected-branch read-only marker
17
+ // read the whole branch; THIS leaf answers only "is the delivery live in model context now".
18
+ // Projection/conversion failures propagate — a read failure is never manufactured into an empty
19
+ // (and therefore falsely clean) projection; each consumer decides its own failure policy.
20
+
21
+ import {
22
+ type ExtensionContext,
23
+ sessionEntryToContextMessages,
24
+ } from "@earendil-works/pi-coding-agent";
25
+
26
+ /** The minimal structural read the projection needs; `ExtensionContext` satisfies it. */
27
+ export interface ContextProjectionSource {
28
+ sessionManager: Pick<ExtensionContext["sessionManager"], "buildContextEntries">;
29
+ }
30
+
31
+ /** Pi's native runtime message, exactly as its converter yields it (no perk message union). */
32
+ export type ContextMessage = ReturnType<typeof sessionEntryToContextMessages>[number];
33
+
34
+ /** The owner of a marker: the injected customType the marker's hidden copy rides under. */
35
+ export interface MarkerOwner {
36
+ customType: string;
37
+ marker: string;
38
+ }
39
+
40
+ /**
41
+ * The messages Pi currently projects into model context for the selected branch, BEFORE any
42
+ * extension `context` filter runs: one `buildContextEntries()` read, flattened through Pi's
43
+ * package-root converter. Returned unchanged (roles, shapes, and bytes are Pi's).
44
+ */
45
+ export function activeContextMessages(source: ContextProjectionSource): ContextMessage[] {
46
+ return source.sessionManager.buildContextEntries().flatMap(sessionEntryToContextMessages);
47
+ }
48
+
49
+ /**
50
+ * Whether a live delivery of `marker` is in `messages`: user content, or custom content whose
51
+ * `customType` is exactly the owner's. A string must contain the marker; an array needs ONE valid
52
+ * `{ type: "text", text: string }` part containing the whole marker — parts are never joined, so
53
+ * a marker split across parts is not evidence, and non-text/malformed parts are ignored. Nothing
54
+ * else (assistant/tool/bash output, other customs, summaries, metadata/`details`) ever counts.
55
+ */
56
+ export function contextCarriesMarker(
57
+ messages: readonly ContextMessage[],
58
+ owner: MarkerOwner,
59
+ ): boolean {
60
+ return messages.some((message) => {
61
+ if (message.role === "user") return contentCarries(message.content, owner.marker);
62
+ if (message.role === "custom") {
63
+ return (
64
+ message.customType === owner.customType && contentCarries(message.content, owner.marker)
65
+ );
66
+ }
67
+ return false;
68
+ });
69
+ }
70
+
71
+ /** Narrow runtime content check (session files are parsed unvalidated — trust shapes, not types). */
72
+ function contentCarries(content: unknown, marker: string): boolean {
73
+ if (typeof content === "string") return content.includes(marker);
74
+ if (!Array.isArray(content)) return false;
75
+ return content.some((part) => {
76
+ if (typeof part !== "object" || part === null) return false;
77
+ const { type, text } = part as { type?: unknown; text?: unknown };
78
+ return type === "text" && typeof text === "string" && text.includes(marker);
79
+ });
80
+ }