@mgiles/perk 1.0.1

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 (98) hide show
  1. package/README.md +105 -0
  2. package/extension/adapters/planAdapterPlannotator.ts +269 -0
  3. package/extension/adapters/planAdapterTombell.ts +147 -0
  4. package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
  5. package/extension/checkpoints/checkpoints.ts +542 -0
  6. package/extension/checkpoints/planSteps.ts +108 -0
  7. package/extension/doors/address.ts +360 -0
  8. package/extension/doors/askUser.ts +194 -0
  9. package/extension/doors/ciExecutor.ts +583 -0
  10. package/extension/doors/land.ts +222 -0
  11. package/extension/doors/learn.ts +235 -0
  12. package/extension/doors/learnDocs.ts +99 -0
  13. package/extension/doors/lifecycleGates.ts +171 -0
  14. package/extension/doors/prReview.ts +339 -0
  15. package/extension/doors/ready.ts +86 -0
  16. package/extension/doors/selfcheck.ts +155 -0
  17. package/extension/doors/submit.ts +253 -0
  18. package/extension/factories/objective.ts +240 -0
  19. package/extension/factories/objectiveAuthor.ts +114 -0
  20. package/extension/factories/objectiveDraft.ts +343 -0
  21. package/extension/factories/objectivePlan.ts +838 -0
  22. package/extension/factories/objectiveSave.ts +285 -0
  23. package/extension/factories/planDraft.ts +140 -0
  24. package/extension/factories/planMode.ts +214 -0
  25. package/extension/factories/planReview.ts +644 -0
  26. package/extension/factories/planSave.ts +589 -0
  27. package/extension/factories/planTitle.ts +123 -0
  28. package/extension/index.ts +459 -0
  29. package/extension/substrate/bindingDelivery.ts +199 -0
  30. package/extension/substrate/bindings.ts +180 -0
  31. package/extension/substrate/cache.ts +163 -0
  32. package/extension/substrate/coldDoor.ts +226 -0
  33. package/extension/substrate/config.ts +339 -0
  34. package/extension/substrate/miniYaml.ts +262 -0
  35. package/extension/substrate/prompts.ts +35 -0
  36. package/extension/substrate/providers.ts +177 -0
  37. package/extension/substrate/registry.ts +62 -0
  38. package/extension/substrate/resources.ts +41 -0
  39. package/extension/substrate/result.ts +72 -0
  40. package/extension/substrate/runId.ts +49 -0
  41. package/extension/substrate/sessionData.ts +229 -0
  42. package/extension/substrate/structuredOutput.ts +141 -0
  43. package/extension/substrate/toolGating.ts +400 -0
  44. package/extension/substrate/toolParams.ts +106 -0
  45. package/extension/substrate/workflowState.ts +233 -0
  46. package/extension/surfaces/footerProvider.ts +43 -0
  47. package/extension/surfaces/report.ts +34 -0
  48. package/extension/surfaces/surfaces.ts +460 -0
  49. package/extension/vendor/btw/btw.ts +964 -0
  50. package/extension/vendor/btw/core.ts +153 -0
  51. package/extension/vendor/whimsical/whimsical.ts +485 -0
  52. package/extension/worker/readOnlySession.ts +282 -0
  53. package/extension/worker/worker.ts +765 -0
  54. package/extension/workerMain.ts +150 -0
  55. package/package.json +55 -0
  56. package/prompts/README.md +15 -0
  57. package/prompts/_fixtures/cases.yaml +140 -0
  58. package/prompts/_fixtures/golden/address-action-model.txt +10 -0
  59. package/prompts/_fixtures/golden/address-action.txt +10 -0
  60. package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
  61. package/prompts/_fixtures/golden/address-preview.txt +6 -0
  62. package/prompts/_fixtures/golden/hello.txt +1 -0
  63. package/prompts/_fixtures/golden/implement-github.txt +8 -0
  64. package/prompts/_fixtures/golden/learn-docs.txt +8 -0
  65. package/prompts/_fixtures/golden/learn-github.txt +11 -0
  66. package/prompts/_fixtures/golden/learn-linear.txt +11 -0
  67. package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
  68. package/prompts/_fixtures/golden/learn-other.txt +8 -0
  69. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
  70. package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
  71. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
  72. package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
  73. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
  74. package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
  75. package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
  76. package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
  77. package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
  78. package/prompts/_fixtures/golden/with_include.txt +4 -0
  79. package/prompts/_fixtures/templates/_greeting.md +1 -0
  80. package/prompts/_fixtures/templates/hello.md +1 -0
  81. package/prompts/_fixtures/templates/with_include.md +4 -0
  82. package/prompts/common/objective-read/linear.md +1 -0
  83. package/prompts/common/plan-read/github.md +1 -0
  84. package/prompts/common/plan-read/linear.md +1 -0
  85. package/prompts/common/plan-read/other.md +1 -0
  86. package/prompts/stages/address/action.md +10 -0
  87. package/prompts/stages/address/preview.md +6 -0
  88. package/prompts/stages/implement.md +8 -0
  89. package/prompts/stages/learn-docs.md +8 -0
  90. package/prompts/stages/learn.md +21 -0
  91. package/prompts/stages/objective-plan/guidance.md +12 -0
  92. package/prompts/stages/objective-plan/seed.md +20 -0
  93. package/shared/README.md +29 -0
  94. package/shared/bindings.yaml +64 -0
  95. package/shared/contracts-history.md +403 -0
  96. package/shared/contracts.md +4172 -0
  97. package/shared/providers.yaml +221 -0
  98. package/shared/registry.yaml +199 -0
@@ -0,0 +1,644 @@
1
+ // The backend-neutral `plan_review` review door. perk's UNIVERSAL
2
+ // plan-review surface: the model calls ONE tool; this module dispatches to the configured review
3
+ // backend. Plannotator-selected → the event-bus bridge (`createPlannotatorBridge`,
4
+ // planAdapterPlannotator.ts — the AUGMENT-posture path, byte-stable); ANY other
5
+ // selection (perk-plan, tombell, unknown ids) → the FIRST-PARTY in-TUI editor review
6
+ // (`runFirstPartyReview`): display the draft in pi's built-in `ctx.ui.editor` dialog (scrollable,
7
+ // Ctrl+G opens the user's external $EDITOR), write optional human edits back to the draft via
8
+ // `writePlanDraft` BEFORE the verdict (reviewed bytes == artifact bytes == saved bytes — a failed
9
+ // write-back ABORTS the review fail-open, nothing saved), then a 3-option approve/deny/skip
10
+ // `ctx.ui.select` verdict, with deny feedback via a second editor dialog.
11
+ //
12
+ // REVIEW SEMANTICS (file-first, approval auto-saves): the review runs while the session is still
13
+ // read-only (the tool is in READ_ONLY_TOOLS — review happens before the gate ever comes off).
14
+ // The reviewed plan resolves FILE-FIRST via `resolvePlanSource` (the validated `plan-draft.md`
15
+ // artifact wins; the `plan` param is the fallback; the transcript scrape is NEVER reviewed — an
16
+ // approval would auto-save scraped conversation bytes, so no draft + no param soft-skips with a
17
+ // `plan_draft` redirect). An APPROVED outcome (either backend) wires into the shared
18
+ // `approvalSave` seam (planSave.ts): auto-save → D1a gate exit → terminating result,
19
+ // node link recovered from the `objective_node_claim` carrier inside `savePlan`. A DENY returns
20
+ // feedback and directs a `plan_draft` rewrite + re-review. Strict on deny, FAIL-OPEN everywhere
21
+ // else: headless / dismissed (Esc anywhere = skip, mirroring ask_user_question's dismissal — deny
22
+ // is always explicit) / backend-unavailable all soft-skip so plan authoring never wedges — those
23
+ // arms keep the present-the-plan + human-`/plan-save` discipline (the manual failsafe).
24
+ //
25
+ // `ctx.ui.editor` takes NO AbortSignal (unlike select/confirm/input) — `signal?.aborted` is
26
+ // checked between dialogs; an in-flight editor dialog survives a turn abort and its result is
27
+ // discarded (the aborted arm wins). Enter submits in the editor dialog (Shift+Enter = newline),
28
+ // so the dialog titles carry the key hints — pi renders no other affordance.
29
+ //
30
+ // THE OBJECTIVE ARM: an objective-author session (read-only, stage
31
+ // `objective-author`) routes through `executeObjectiveReview` instead of the plan path — the
32
+ // reviewed bytes are the RENDERED objective draft (`readObjectiveDraft` + `renderObjectiveDraft`,
33
+ // objectiveDraft.ts — never raw JSON, never the `plan` param, never the transcript; no draft
34
+ // soft-skips with `reason: "no_objective_draft"`). Dispatch mirrors the plan path (plannotator
35
+ // bridge or the first-party editor, VIEW-ONLY — edits are never written back; deny+feedback is
36
+ // the change channel). An APPROVED outcome wires into the `objectiveApprovalSave` seam
37
+ // (objectiveSave.ts): re-read the STRUCTURED artifact → `saveObjective` → D1a gate
38
+ // exit → a TERMINATING result; a failed save is non-terminating, leaves the gate read-only, and
39
+ // directs the human `/objective-save` failsafe.
40
+ //
41
+ // INVARIANTS HELD: never calls `setActiveTools`, never registers a `tool_call` handler, never
42
+ // restamps `cache.plan-ref.provider`. The door composes the gate AND the save EXCLUSIVELY
43
+ // through the `approvalSave` seam (Invariant 1: composes, never owns).
44
+
45
+ import { randomUUID } from "node:crypto";
46
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
47
+ import {
48
+ createPlannotatorBridge,
49
+ isPlannotatorPlanSelected,
50
+ } from "../adapters/planAdapterPlannotator.ts";
51
+ import type { ToolGating } from "../substrate/toolGating.ts";
52
+ import { paramsOf, stringParam } from "../substrate/toolParams.ts";
53
+ import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
54
+ import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
55
+ import { readObjectiveDraft, renderObjectiveDraft } from "./objectiveDraft.ts";
56
+ import { type ObjectiveApprovalSaveOutcome, objectiveApprovalSave } from "./objectiveSave.ts";
57
+ import { writePlanDraft } from "./planDraft.ts";
58
+ import { type ApprovalSaveOutcome, approvalSave, resolvePlanSource } from "./planSave.ts";
59
+
60
+ // ----------------------------------------------------------------------------- review outcomes
61
+
62
+ /**
63
+ * The review outcome a backend produces, mapped into a tool result below (also `details.status`).
64
+ * The `dismissed` arm is FIRST-PARTY ONLY (Esc anywhere = fail-open skip; the plannotator bridge
65
+ * never produces it).
66
+ */
67
+ export type ReviewOutcome =
68
+ | { status: "unavailable"; warning: string }
69
+ | { status: "aborted" }
70
+ | { status: "dismissed" }
71
+ | { status: "completed"; approved: boolean; feedback?: string; reviewId: string };
72
+
73
+ interface ToolResult {
74
+ content: { type: "text"; text: string }[];
75
+ details: Record<string, unknown>;
76
+ terminate?: boolean;
77
+ }
78
+
79
+ const SKIP_TEXT =
80
+ "no interactive review surface available — present the complete plan to the user in your next message.";
81
+
82
+ function skipResult(): ToolResult {
83
+ return { content: [{ type: "text", text: SKIP_TEXT }], details: { status: "skipped" } };
84
+ }
85
+
86
+ /**
87
+ * Map a non-approved review outcome into the model-facing tool result (exported for the offline
88
+ * tests). The `completed` case renders the DENIED text — the execute path routes approved
89
+ * outcomes to `approvedSaveResult` first, so callers only reach `completed` here with
90
+ * `approved: false` (kept total for safety). The `dismissed` arm renders as a skip — the human
91
+ * declined to decide, so the present-plan + `/plan-save` manual-failsafe discipline applies.
92
+ */
93
+ export function reviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
94
+ switch (outcome.status) {
95
+ case "unavailable":
96
+ return {
97
+ content: [
98
+ {
99
+ type: "text",
100
+ text:
101
+ `WARNING: ${outcome.warning} — no review performed. ` +
102
+ "Present the complete plan to the user in your next message instead.",
103
+ },
104
+ ],
105
+ details: { status: "unavailable" },
106
+ };
107
+ case "aborted":
108
+ return {
109
+ content: [{ type: "text", text: "plan review aborted (turn interrupted)." }],
110
+ details: { status: "aborted" },
111
+ };
112
+ case "dismissed":
113
+ return {
114
+ content: [
115
+ {
116
+ type: "text",
117
+ text:
118
+ "plan review dismissed — present the complete plan to the user; the human runs " +
119
+ "/plan-save (the manual failsafe).",
120
+ },
121
+ ],
122
+ details: { status: "skipped", reason: "dismissed" },
123
+ };
124
+ case "completed": {
125
+ const feedback = outcome.feedback ? `\n\nReviewer feedback:\n${outcome.feedback}` : "";
126
+ const text =
127
+ "plan DENIED — revise per this feedback, rewrite the working draft with plan_draft, " +
128
+ `then call plan_review again.${feedback}`;
129
+ return {
130
+ content: [{ type: "text", text }],
131
+ details: {
132
+ status: "completed",
133
+ approved: outcome.approved,
134
+ feedback: outcome.feedback ?? null,
135
+ reviewId: outcome.reviewId,
136
+ },
137
+ };
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Map an APPROVED review outcome + the `approvalSave` outcome into the model-facing tool result
144
+ * (exported for the offline tests). A successful save TERMINATES the turn (propagating the
145
+ * seam's `terminate: true` intent); a failed save is non-terminating, leaves the gate read-only,
146
+ * and directs the human `/plan-save` failsafe. Reviewer feedback is surfaced loudly as
147
+ * implementation guidance — the approved bytes were saved verbatim, never post-edited. `edited`
148
+ * (first-party only) flags that human edits were written back to the draft pre-verdict, so the
149
+ * saved bytes carry them. The `no-plan` arm is defensively unreachable (the reviewed plan is
150
+ * always non-blank) but maps to the save-failed shape rather than throwing.
151
+ */
152
+ export function approvedSaveResult(
153
+ outcome: Extract<ReviewOutcome, { status: "completed" }>,
154
+ save: ApprovalSaveOutcome,
155
+ opts: { paramMismatch: boolean; edited?: boolean },
156
+ ): ToolResult {
157
+ const feedback = outcome.feedback
158
+ ? "\n\nReviewer feedback (implementation guidance — the approved plan was saved verbatim):\n" +
159
+ outcome.feedback
160
+ : "";
161
+ const base = {
162
+ status: "completed",
163
+ approved: true,
164
+ reviewId: outcome.reviewId,
165
+ feedback: outcome.feedback ?? null,
166
+ ...(opts.edited === true ? { edited: true } : {}),
167
+ };
168
+ if (save.status === "saved") {
169
+ const saveText = save.result.content[0]?.text ?? "";
170
+ const mismatch = opts.paramMismatch
171
+ ? "\n\n⚠ differing plan param ignored — the validated draft was reviewed and saved."
172
+ : "";
173
+ const edited =
174
+ opts.edited === true ? " · human edits were written back to the draft and saved" : "";
175
+ return {
176
+ content: [
177
+ {
178
+ type: "text",
179
+ text: `plan APPROVED by reviewer.${feedback}\n\n${saveText}${edited}${mismatch}`,
180
+ },
181
+ ],
182
+ details: { ...base, saved: true, gateExited: save.gateExited, save: save.result.details },
183
+ terminate: true,
184
+ };
185
+ }
186
+ const error =
187
+ save.status === "no-plan"
188
+ ? "no plan source resolved"
189
+ : save.result.details.ok
190
+ ? "unknown save failure"
191
+ : save.result.details.error;
192
+ return {
193
+ content: [
194
+ {
195
+ type: "text",
196
+ text:
197
+ `plan APPROVED by reviewer, but the auto-save FAILED (${error}) — the session stays ` +
198
+ `read-only. Ask the user to run /plan-save (the manual failsafe) to retry.${feedback}`,
199
+ },
200
+ ],
201
+ details: {
202
+ ...base,
203
+ saved: false,
204
+ save: save.status === "no-plan" ? null : save.result.details,
205
+ },
206
+ };
207
+ }
208
+
209
+ // ----------------------------------------------------------------- the first-party review core
210
+
211
+ /** The minimal structural `ctx.ui` subset the first-party review needs (the askUser.ts recipe). */
212
+ export interface PlanReviewUI {
213
+ editor(title: string, prefill?: string): Promise<string | undefined>;
214
+ select(
215
+ title: string,
216
+ options: string[],
217
+ opts?: { signal?: AbortSignal },
218
+ ): Promise<string | undefined>;
219
+ }
220
+
221
+ /** The verdict options (plain text — charter D3: no emoji outside the footer). */
222
+ const VERDICT_APPROVE = "Approve — auto-save to GitHub";
223
+ const VERDICT_DENY = "Deny — send feedback for revision";
224
+ const VERDICT_SKIP = "Skip — decide later (manual /plan-save)";
225
+
226
+ const REVIEW_EDITOR_TITLE =
227
+ "Plan review — Enter: continue to verdict · Esc: skip · Ctrl+G: $EDITOR";
228
+ const DENY_FEEDBACK_TITLE = "Deny feedback (optional) — Enter to send";
229
+
230
+ /**
231
+ * The first-party in-TUI review core, pure over injected seams (the askUser.ts recipe) — fully
232
+ * offline-testable. Flow: (1) display the plan in the editor dialog (Esc = dismissed; the human
233
+ * may edit, incl. via Ctrl+G/$EDITOR); (2) a non-blank edit differing from the displayed plan is
234
+ * written back to the draft BEFORE the verdict (reviewed bytes == artifact bytes == saved bytes;
235
+ * a failed write-back aborts the review fail-open — never let an approval save a stale artifact;
236
+ * a blank edit result is treated as no-edit); (3) the 3-option verdict select (Esc/Skip =
237
+ * dismissed); (4) on deny, optional feedback via a second editor dialog. `ctx.ui.editor` takes no
238
+ * AbortSignal — `signal?.aborted` is checked before each dialog (the aborted arm wins).
239
+ *
240
+ * Presentation options (defaults preserve the plan-path behavior byte-for-byte):
241
+ * `editorTitle`/`verdicts` swap the displayed strings; `viewOnly: true` skips the write-back
242
+ * branch entirely — the editor output is used only for Esc/dismissed detection, `plan` is
243
+ * returned unchanged and `edited` stays false (deny+feedback is the change channel).
244
+ */
245
+ export async function runFirstPartyReview(args: {
246
+ ui: PlanReviewUI;
247
+ plan: string;
248
+ /** Bound to `writePlanDraft(pi, ctx, …).details.ok` by the execute path. */
249
+ writeDraft(plan: string): boolean;
250
+ signal?: AbortSignal;
251
+ editorTitle?: string;
252
+ verdicts?: { approve: string; deny: string; skip: string };
253
+ viewOnly?: boolean;
254
+ }): Promise<{ outcome: ReviewOutcome; plan: string; edited: boolean }> {
255
+ const { ui, writeDraft, signal } = args;
256
+ const editorTitle = args.editorTitle ?? REVIEW_EDITOR_TITLE;
257
+ const verdicts = args.verdicts ?? {
258
+ approve: VERDICT_APPROVE,
259
+ deny: VERDICT_DENY,
260
+ skip: VERDICT_SKIP,
261
+ };
262
+ let plan = args.plan;
263
+ let edited = false;
264
+ const result = (
265
+ outcome: ReviewOutcome,
266
+ ): { outcome: ReviewOutcome; plan: string; edited: boolean } => ({
267
+ outcome,
268
+ plan,
269
+ edited,
270
+ });
271
+
272
+ if (signal?.aborted) return result({ status: "aborted" });
273
+ const reviewed = await ui.editor(editorTitle, plan);
274
+ if (signal?.aborted) return result({ status: "aborted" });
275
+ if (reviewed === undefined) return result({ status: "dismissed" });
276
+
277
+ // Write human edits back to the draft BEFORE the verdict (blank = no-edit, review the original
278
+ // bytes). A failed write-back aborts the review fail-open — nothing saved. View-only reviews
279
+ // skip the branch entirely (the editor is display-only; deny+feedback is the change channel).
280
+ if (args.viewOnly !== true && reviewed !== plan && reviewed.trim().length > 0) {
281
+ if (!writeDraft(reviewed)) {
282
+ return result({
283
+ status: "unavailable",
284
+ warning:
285
+ "could not write the edited draft back to the session data dir — review aborted, " +
286
+ "nothing saved",
287
+ });
288
+ }
289
+ plan = reviewed;
290
+ edited = true;
291
+ }
292
+
293
+ if (signal?.aborted) return result({ status: "aborted" });
294
+ const verdict = await ui.select(
295
+ "Plan review verdict",
296
+ [verdicts.approve, verdicts.deny, verdicts.skip],
297
+ { signal },
298
+ );
299
+ if (signal?.aborted) return result({ status: "aborted" });
300
+ if (verdict === verdicts.approve) {
301
+ return result({ status: "completed", approved: true, reviewId: randomUUID() });
302
+ }
303
+ if (verdict === verdicts.deny) {
304
+ const feedback = await ui.editor(DENY_FEEDBACK_TITLE, "");
305
+ if (signal?.aborted) return result({ status: "aborted" });
306
+ return result({
307
+ status: "completed",
308
+ approved: false,
309
+ feedback: feedback?.trim() ? feedback : undefined,
310
+ reviewId: randomUUID(),
311
+ });
312
+ }
313
+ // Skip option, or the select dismissed (Esc) — fail-open skip.
314
+ return result({ status: "dismissed" });
315
+ }
316
+
317
+ // ------------------------------------------------------------------- the objective review arm
318
+
319
+ /** The objective-flavored verdict options (approval auto-saves). */
320
+ const OBJECTIVE_VERDICTS = {
321
+ approve: "Approve — auto-save to GitHub",
322
+ deny: "Deny — send feedback for revision",
323
+ skip: "Skip — decide later (manual /objective-save)",
324
+ };
325
+
326
+ const OBJECTIVE_REVIEW_EDITOR_TITLE =
327
+ "Objective review (view only — edits are not saved) — Enter: continue to verdict · Esc: skip · " +
328
+ "Ctrl+G: $EDITOR";
329
+
330
+ /**
331
+ * Map a non-approved objective review outcome into the model-facing tool result (exported for
332
+ * the offline tests) — the objective-flavored sibling of `reviewOutcomeResult`. Every arm
333
+ * carries `details.subject: "objective"`; the texts redirect to `objective_draft` /
334
+ * `/objective-save`. The `completed` case renders the DENIED text — the execute path routes
335
+ * approved outcomes to `approvedObjectiveSaveResult` first, so callers only reach `completed`
336
+ * here with `approved: false` (kept total for safety).
337
+ */
338
+ export function objectiveReviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
339
+ switch (outcome.status) {
340
+ case "unavailable":
341
+ return {
342
+ content: [
343
+ {
344
+ type: "text",
345
+ text:
346
+ `WARNING: ${outcome.warning} — no review performed. ` +
347
+ "Present the complete objective + structured roadmap to the user instead.",
348
+ },
349
+ ],
350
+ details: { status: "unavailable", subject: "objective" },
351
+ };
352
+ case "aborted":
353
+ return {
354
+ content: [{ type: "text", text: "objective review aborted (turn interrupted)." }],
355
+ details: { status: "aborted", subject: "objective" },
356
+ };
357
+ case "dismissed":
358
+ return {
359
+ content: [
360
+ {
361
+ type: "text",
362
+ text:
363
+ "objective review dismissed — present the complete objective + structured roadmap " +
364
+ "to the user; the human runs /objective-save (the manual failsafe).",
365
+ },
366
+ ],
367
+ details: { status: "skipped", reason: "dismissed", subject: "objective" },
368
+ };
369
+ case "completed": {
370
+ const feedback = outcome.feedback ? `\n\nReviewer feedback:\n${outcome.feedback}` : "";
371
+ return {
372
+ content: [
373
+ {
374
+ type: "text",
375
+ text:
376
+ "objective DENIED — revise per this feedback, rewrite the working draft with " +
377
+ `objective_draft, then call plan_review again.${feedback}`,
378
+ },
379
+ ],
380
+ details: {
381
+ status: "completed",
382
+ approved: outcome.approved,
383
+ feedback: outcome.feedback ?? null,
384
+ reviewId: outcome.reviewId,
385
+ subject: "objective",
386
+ },
387
+ };
388
+ }
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Map an APPROVED objective review outcome + the `objectiveApprovalSave` outcome into the
394
+ * model-facing tool result (exported for the offline tests) — the objective sibling of
395
+ * `approvedSaveResult` (no `paramMismatch`/`edited` opts: the objective path reviews only the
396
+ * rendered draft, view-only). A successful save TERMINATES the turn; a failed save is
397
+ * non-terminating, leaves the gate read-only, and directs the human `/objective-save` failsafe.
398
+ * The `no-draft` arm is defensively unreachable (the review just read the draft) but maps to
399
+ * the save-failed shape rather than throwing.
400
+ */
401
+ export function approvedObjectiveSaveResult(
402
+ outcome: Extract<ReviewOutcome, { status: "completed" }>,
403
+ save: ObjectiveApprovalSaveOutcome,
404
+ ): ToolResult {
405
+ const feedback = outcome.feedback
406
+ ? "\n\nReviewer feedback (implementation guidance — the approved objective was saved " +
407
+ `verbatim):\n${outcome.feedback}`
408
+ : "";
409
+ const base = {
410
+ status: "completed",
411
+ approved: true,
412
+ reviewId: outcome.reviewId,
413
+ feedback: outcome.feedback ?? null,
414
+ subject: "objective",
415
+ };
416
+ if (save.status === "saved") {
417
+ const saveText = save.result.content[0]?.text ?? "";
418
+ return {
419
+ content: [
420
+ { type: "text", text: `objective APPROVED by reviewer.${feedback}\n\n${saveText}` },
421
+ ],
422
+ details: { ...base, saved: true, gateExited: save.gateExited, save: save.result.details },
423
+ terminate: true,
424
+ };
425
+ }
426
+ const error =
427
+ save.status === "no-draft"
428
+ ? "no objective draft resolved"
429
+ : save.result.details.ok
430
+ ? "unknown save failure"
431
+ : save.result.details.error;
432
+ return {
433
+ content: [
434
+ {
435
+ type: "text",
436
+ text:
437
+ `objective APPROVED by reviewer, but the auto-save FAILED (${error}) — the session ` +
438
+ "stays read-only. Ask the user to run /objective-save (the manual failsafe) to " +
439
+ `retry.${feedback}`,
440
+ },
441
+ ],
442
+ details: {
443
+ ...base,
444
+ saved: false,
445
+ save: save.status === "no-draft" ? null : save.result.details,
446
+ },
447
+ };
448
+ }
449
+
450
+ /**
451
+ * The objective review arm, mirroring `executePlanReview`'s shape but
452
+ * with the rendered objective draft as the SOLE review source (never the `plan` param, never
453
+ * the transcript). First-party reviews run VIEW-ONLY (edits are never written back;
454
+ * deny+feedback is the change channel). An APPROVED outcome wires into the
455
+ * `objectiveApprovalSave` seam (re-read the STRUCTURED artifact → `saveObjective` → D1a gate
456
+ * exit → terminating); every other outcome maps via `objectiveReviewOutcomeResult`.
457
+ */
458
+ export async function executeObjectiveReview(
459
+ pi: ExtensionAPI,
460
+ ctx: ExtensionContext,
461
+ gating: ToolGating,
462
+ bridge: { review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> },
463
+ signal?: AbortSignal,
464
+ ): Promise<ToolResult> {
465
+ // 1. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
466
+ if (!ctx.hasUI) return skipResult();
467
+ // 2. The draft artifact is the sole review source — no draft → soft skip with the
468
+ // objective_draft redirect.
469
+ const draft = readObjectiveDraft(ctx);
470
+ if (draft === null) {
471
+ return {
472
+ content: [
473
+ {
474
+ type: "text",
475
+ text:
476
+ "no objective draft to review — write the working objective with objective_draft " +
477
+ "(prose + the structured roadmap), then call plan_review again.",
478
+ },
479
+ ],
480
+ details: { status: "skipped", reason: "no_objective_draft" },
481
+ };
482
+ }
483
+ // 3. The reviewed bytes are the RENDERED markdown (prose + roadmap table) — never raw JSON.
484
+ const rendered = renderObjectiveDraft(draft);
485
+ // 4. Backend dispatch (mirrors the plan path): plannotator-selected → the bridge; ANY other
486
+ // selection → the first-party editor, view-only.
487
+ const sig = signal ?? ctx.signal;
488
+ let outcome: ReviewOutcome;
489
+ if (isPlannotatorPlanSelected(ctx.cwd)) {
490
+ outcome = await bridge.review(rendered, sig);
491
+ } else {
492
+ const fp = await runFirstPartyReview({
493
+ ui: ctx.ui,
494
+ plan: rendered,
495
+ writeDraft: () => true, // unreachable under viewOnly — the branch is skipped
496
+ signal: sig,
497
+ editorTitle: OBJECTIVE_REVIEW_EDITOR_TITLE,
498
+ verdicts: OBJECTIVE_VERDICTS,
499
+ viewOnly: true,
500
+ });
501
+ outcome = fp.outcome;
502
+ }
503
+ // 5. An APPROVED decision (either backend) wires into the objectiveApprovalSave seam (the
504
+ // STRUCTURED artifact is re-read at save time — never the rendered bytes; auto-save → D1a
505
+ // gate exit → terminating result); everything else maps via objectiveReviewOutcomeResult.
506
+ // Approved-first routing: objectiveReviewOutcomeResult's completed case renders DENIED.
507
+ if (outcome.status === "completed" && outcome.approved) {
508
+ const save = await objectiveApprovalSave(pi, ctx, gating);
509
+ return approvedObjectiveSaveResult(outcome, save);
510
+ }
511
+ return objectiveReviewOutcomeResult(outcome);
512
+ }
513
+
514
+ // ------------------------------------------------------------------------- the execute core
515
+
516
+ /**
517
+ * The `plan_review` execute core, extracted pure-over-its-seams (the bridge, the gating, the
518
+ * ctx) so the resolution + dispatch + approved-save paths are unit-testable offline. Arm order:
519
+ * param decode → the objective-author arm (`executeObjectiveReview` — the rendered objective
520
+ * draft is the review subject there) → headless skip → file-first resolution → backend
521
+ * dispatch (plannotator-selected → the event-bus bridge; ANY other selection → the first-party
522
+ * in-TUI editor review) → approved → `approvalSave`.
523
+ */
524
+ export async function executePlanReview(
525
+ pi: ExtensionAPI,
526
+ ctx: ExtensionContext,
527
+ gating: ToolGating,
528
+ bridge: { review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> },
529
+ params: unknown,
530
+ signal?: AbortSignal,
531
+ ): Promise<ToolResult> {
532
+ // Tool-boundary decode, in this tool's native fail-open vocabulary: a MISTYPED
533
+ // `plan` (or non-object params) skip-shapes (`reason: "bad_input"`) without reviewing; an
534
+ // ABSENT `plan` proceeds — the validated draft artifact is the preferred source.
535
+ const p = paramsOf(params);
536
+ const plan = p === null ? null : stringParam(p, "plan");
537
+ if (plan === null) {
538
+ return {
539
+ content: [
540
+ {
541
+ type: "text",
542
+ text: "plan_review takes { plan?: string } — omit it (the plan-draft artifact is preferred) or pass a string.",
543
+ },
544
+ ],
545
+ details: { status: "skipped", reason: "bad_input" },
546
+ };
547
+ }
548
+ // 1. Objective-author session → the objective review arm: the rendered
549
+ // objective draft is the sole review source; a well-typed `plan` param is ignored here.
550
+ if (rebuildWorkflowState(branchOf(ctx)).stage === OBJECTIVE_AUTHOR_STAGE) {
551
+ return executeObjectiveReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
552
+ }
553
+ // 2. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
554
+ if (!ctx.hasUI) return skipResult();
555
+ // 3. File-first resolution: artifact → param, NEVER transcript — an approval
556
+ // auto-saves the reviewed bytes, and scraped conversation bytes must never be those.
557
+ const src = resolvePlanSource(ctx, plan);
558
+ if (src === null || src.source === "transcript") {
559
+ return {
560
+ content: [
561
+ {
562
+ type: "text",
563
+ text:
564
+ "no plan to review — write the working draft with plan_draft (or pass the plan " +
565
+ "param), then call plan_review again.",
566
+ },
567
+ ],
568
+ details: { status: "skipped", reason: "no_plan" },
569
+ };
570
+ }
571
+ // 4. Backend dispatch: plannotator-selected → the event-bus bridge; ANY other selection
572
+ // (perk-plan, tombell, unknown ids) → the first-party in-TUI editor review.
573
+ const sig = signal ?? ctx.signal;
574
+ let outcome: ReviewOutcome;
575
+ let reviewedPlan = src.plan;
576
+ let edited = false;
577
+ if (isPlannotatorPlanSelected(ctx.cwd)) {
578
+ outcome = await bridge.review(src.plan, sig);
579
+ } else {
580
+ const fp = await runFirstPartyReview({
581
+ ui: ctx.ui,
582
+ plan: src.plan,
583
+ writeDraft: (text) => writePlanDraft(pi, ctx, text).details.ok,
584
+ signal: sig,
585
+ });
586
+ outcome = fp.outcome;
587
+ reviewedPlan = fp.plan;
588
+ edited = fp.edited;
589
+ }
590
+ // 5. An APPROVED decision (either backend) wires into the approvalSave seam (auto-save → D1a
591
+ // gate exit → terminating result); everything else maps via reviewOutcomeResult.
592
+ if (outcome.status === "completed" && outcome.approved) {
593
+ const save = await approvalSave(pi, ctx, gating, { reviewedPlan });
594
+ return approvedSaveResult(outcome, save, { paramMismatch: src.paramMismatch, edited });
595
+ }
596
+ return reviewOutcomeResult(outcome);
597
+ }
598
+
599
+ // ----------------------------------------------------------------------------- registration
600
+
601
+ /**
602
+ * Register `plan_review` — perk's universal review door. In READ_ONLY_TOOLS so it is callable
603
+ * INSIDE plan mode (the whole point — review happens before the gate ever comes off). Fail-open
604
+ * everywhere: headless / dismissed / backend-unavailable all soft-skip so authoring never wedges.
605
+ */
606
+ export function registerPlanReview(pi: ExtensionAPI, gating: ToolGating): void {
607
+ const bridge = createPlannotatorBridge(pi.events);
608
+
609
+ pi.registerTool({
610
+ name: "plan_review",
611
+ label: "Plan review",
612
+ description:
613
+ "Present the plan to the configured review surface — the Plannotator browser UI when " +
614
+ "selected, otherwise perk's in-TUI editor review — and wait for the human decision. " +
615
+ "Reviews the validated plan-draft artifact (keep it current with plan_draft); on approval " +
616
+ "the plan is auto-saved and the turn terminates. On deny, revise per the returned " +
617
+ "feedback, rewrite the draft with plan_draft, and call again. No-op skip when the session " +
618
+ "is headless or the review is dismissed.",
619
+ promptSnippet: "Request a human review of the working plan draft",
620
+ promptGuidelines: [
621
+ "Keep the working draft current with plan_draft — the validated plan-draft artifact is what plan_review reviews AND auto-saves; the plan param is only a fallback when no draft exists.",
622
+ "Call plan_review only when the plan is decision-complete.",
623
+ "On a DENIED review, revise per the feedback, rewrite the draft with plan_draft, then call plan_review again.",
624
+ "On an APPROVED review, the plan is auto-saved and the turn ends — never re-dump the plan as a final message and never tell the user to run /plan-save; relay the save outcome instead.",
625
+ "If plan_review reports it was skipped or unavailable (headless, dismissed), fall back to presenting the complete plan; the human runs /plan-save (the manual failsafe).",
626
+ ],
627
+ executionMode: "sequential",
628
+ parameters: {
629
+ type: "object",
630
+ additionalProperties: false,
631
+ properties: {
632
+ plan: {
633
+ type: "string",
634
+ description:
635
+ "Optional — the validated plan-draft.md artifact is preferred when present; this " +
636
+ "param is the fallback for sessions that never wrote a draft.",
637
+ },
638
+ },
639
+ },
640
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
641
+ return executePlanReview(pi, ctx, gating, bridge, params, signal);
642
+ },
643
+ });
644
+ }