@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
@@ -1,1237 +0,0 @@
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 an approve/deny/skip
10
- // `ctx.ui.select` verdict — on the plan arm with a 4th "Implement here — no issue saved" option
11
- // (§8.23; suppressed in objective-node planning sessions) — with deny feedback via a second
12
- // editor dialog.
13
- //
14
- // REVIEW SEMANTICS (file-first, approval auto-saves): the review runs while the session is still
15
- // read-only (the tool is in READ_ONLY_TOOLS — review happens before the gate ever comes off).
16
- // The reviewed plan resolves FILE-FIRST via `resolvePlanSource` (the validated `plan-draft.md`
17
- // artifact wins; the `plan` param is the fallback; the transcript scrape is NEVER reviewed — an
18
- // approval would auto-save scraped conversation bytes, so no draft + no param soft-skips with a
19
- // `plan_draft` redirect). An APPROVED outcome (either backend) wires into the shared
20
- // `approvalSave` seam (planSave.ts): auto-save → D1a gate exit → terminating result,
21
- // node link recovered from the `objective_node_claim` carrier inside `savePlan`. A DENY returns
22
- // feedback and directs a `plan_draft` rewrite + re-review. Plannotator's browser "Direct Edits"
23
- // (a `# Direct Edits` unified diff opening the feedback) are handled asymmetrically per arm: the
24
- // PLAN arm mechanically applies an approved diff (strict apply → draft write-back → save the
25
- // edited bytes; any failure falls open to the verbatim save + a loud warning); the OBJECTIVE arm
26
- // cannot fold rendered-markdown edits into the structured draft, so an approve-with-edits SKIPS
27
- // the save and returns one model-mediated revise round; DENY stays model-mediated on both arms. Strict on deny, FAIL-OPEN everywhere
28
- // else: headless / dismissed (Esc anywhere = skip, mirroring ask_user_question's dismissal — deny
29
- // is always explicit) / backend-unavailable all soft-skip so plan authoring never wedges — those
30
- // arms keep the present-the-plan + human-`/plan-save` discipline (the manual failsafe).
31
- //
32
- // THE LAUNCH CHOOSER (plannotator arms only, §8.23): on an eligible round (injected `WaveLaunch`
33
- // deps present, plannotator loaded, the review source a validated draft artifact) the tool first
34
- // asks the human — "Browser review + reviewer wave" vs "Browser review only" — before anything
35
- // launches. The wave choice collects an optional trimmed custom angle, delegates to the door's
36
- // guidance-returning open core (openPlanReviewSurface / openObjectiveReviewSurface, injected —
37
- // never imported: doors value-import this module), and returns the NON-terminating
38
- // `wave_launched` result carrying the door guidance verbatim; the browser decision then routes
39
- // through the door's background decision task. Esc ⇒ the plain flavor (never a cancel); abort
40
- // outranks every dialog result AND the awaited opener (an interrupted turn never reports a
41
- // launched wave); a null opener return (synchronous port-pick failure, loudly reported in the
42
- // core) falls open to the plain blocking review in the same call.
43
- //
44
- // `ctx.ui.editor` takes NO AbortSignal (unlike select/confirm/input) — `signal?.aborted` is
45
- // checked between dialogs; an in-flight editor dialog survives a turn abort and its result is
46
- // discarded (the aborted arm wins). Enter submits in the editor dialog (Shift+Enter = newline),
47
- // so the dialog titles carry the key hints — pi renders no other affordance.
48
- //
49
- // THE OBJECTIVE ARM: an objective-authoring session (read-only, stage `objective-author` or
50
- // `objective-save` — the two stages whose working draft IS the objective draft; neither carries
51
- // `plan_draft`) routes through `executeObjectiveReview` instead of the plan path — the
52
- // reviewed bytes are the RENDERED objective draft (`readObjectiveDraft` + `renderObjectiveDraft`,
53
- // objectiveDraft.ts — never raw JSON, never the `plan` param, never the transcript; no draft
54
- // soft-skips with `reason: "no_objective_draft"`). Dispatch mirrors the plan path (plannotator
55
- // bridge or the first-party editor, VIEW-ONLY — edits are never written back; deny+feedback is
56
- // the change channel). An APPROVED outcome wires into the `objectiveApprovalSave` seam
57
- // (objectiveSave.ts): re-read the STRUCTURED artifact → `saveObjective` → D1a gate
58
- // exit → a TERMINATING result; a failed save is non-terminating, leaves the gate read-only, and
59
- // directs the human `/objective-save` failsafe.
60
- //
61
- // THE GIST ARM: a gist-author session (read-only, stage `gist-author`) routes through
62
- // `executeGistReview` the same way — the reviewed bytes are the RENDERED gist draft
63
- // (`readGistDraft` + `renderGistDraft`, gistDraft.ts), first-party VIEW-ONLY, implement-here
64
- // never offered, APPROVED → the `gistApprovalSave` seam (gistSave.ts), no draft soft-skips with
65
- // `reason: "no_gist_draft"`. Plannotator approve-with-Direct-Edits mirrors the objective arm:
66
- // the save seam re-reads the STRUCTURED gist artifact, so rendered edits cannot be folded back
67
- // mechanically — the arm SKIPS the save and returns one model-mediated revise round (fold each
68
- // hunk into the matching `gist_draft` field — title heading → `title`, `Scope:` line → `scope`,
69
- // prose → `prose` — then re-review to confirm).
70
- //
71
- // INVARIANTS HELD: never calls `setActiveTools`, never registers a `tool_call` handler, never
72
- // restamps `cache.plan-ref.provider`. The door composes the gate AND the save EXCLUSIVELY
73
- // through the `approvalSave` seam (Invariant 1: composes, never owns).
74
-
75
- import { randomUUID } from "node:crypto";
76
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
77
- import {
78
- createPlannotatorBridge,
79
- extractDirectEdits,
80
- hasDirectEditsHeading,
81
- isPlannotatorPlanSelected,
82
- } from "../adapters/planAdapterPlannotator.ts";
83
- import type { Result } from "../substrate/result.ts";
84
- import { readSessionArtifact } from "../substrate/sessionData.ts";
85
- import type { ToolGating } from "../substrate/toolGating.ts";
86
- import { paramsOf, stringParam } from "../substrate/toolParams.ts";
87
- import { applyUnifiedDiff } from "../substrate/unifiedDiff.ts";
88
- import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
89
- import { GIST_AUTHOR_STAGE } from "./gistAuthor.ts";
90
- import { readGistDraft, renderGistDraft } from "./gistDraft.ts";
91
- import { type GistApprovalSaveOutcome, gistApprovalSave } from "./gistSave.ts";
92
- import { implementHereExit, implementHereGuidance } from "./implementHere.ts";
93
- import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
94
- import {
95
- OBJECTIVE_DRAFT_ARTIFACT,
96
- readObjectiveDraft,
97
- renderObjectiveDraft,
98
- } from "./objectiveDraft.ts";
99
- import { readNodeClaim } from "./objectivePlan.ts";
100
- import {
101
- OBJECTIVE_SAVE_STAGE,
102
- type ObjectiveApprovalSaveOutcome,
103
- objectiveApprovalSave,
104
- } from "./objectiveSave.ts";
105
- import { writePlanDraft } from "./planDraft.ts";
106
- import { type ApprovalSaveOutcome, approvalSave, resolvePlanSource } from "./planSave.ts";
107
-
108
- // ----------------------------------------------------------------------------- review outcomes
109
-
110
- /**
111
- * The review outcome a backend produces, mapped into a tool result below (also `details.status`).
112
- * The `dismissed` arm is FIRST-PARTY ONLY (Esc anywhere = fail-open skip; the plannotator bridge
113
- * never produces it). The `implement-here` arm is first-party PLAN-arm only (the human chose the
114
- * no-save exit — contracts.md §8.23); the plannotator bridge never produces it (its browser
115
- * envelope returns only approve/deny) and the objective arm never offers it.
116
- */
117
- export type ReviewOutcome =
118
- | { status: "unavailable"; warning: string }
119
- | { status: "aborted" }
120
- | { status: "dismissed" }
121
- | { status: "implement-here"; reviewId: string }
122
- | { status: "completed"; approved: boolean; feedback?: string; reviewId: string };
123
-
124
- interface ToolResult {
125
- content: { type: "text"; text: string }[];
126
- details: Record<string, unknown>;
127
- terminate?: boolean;
128
- }
129
-
130
- /**
131
- * The subject descriptor parameterizing the shared renderer cores below — the plan and objective
132
- * review arms render the same outcome shapes, differing only in these fields. Module-private on
133
- * purpose: nothing outside this module needs it (both execute arms live here), and a third review
134
- * subject would land here too, constructing its own descriptor and reusing the cores.
135
- */
136
- interface ReviewSubject {
137
- /** The display noun in every rendered text ("plan" / "objective"). */
138
- noun: string;
139
- /** The lowercase present-the-work phrase (dismissed / implement-here arms). */
140
- present: string;
141
- /** The unavailable-arm phrase (the plan flavor appends "in your next message"). */
142
- presentUnavailable: string;
143
- /** Where an implement-here verdict "cannot" have come from (the defensive arm's text). */
144
- implementHereWhere: string;
145
- /** The draft-rewrite tool the DENIED text redirects to. */
146
- draftTool: string;
147
- /** The manual-failsafe slash command. */
148
- failsafeCmd: string;
149
- /** Extra keys merged into every details object ({} on the plan arm). */
150
- detailsExtra: Record<string, unknown>;
151
- /** The defensively-unreachable no-source save arm's error string. */
152
- noSourceError: string;
153
- }
154
-
155
- const PLAN_SUBJECT: ReviewSubject = {
156
- noun: "plan",
157
- present: "the complete plan to the user",
158
- presentUnavailable: "the complete plan to the user in your next message",
159
- implementHereWhere: "outside the execute path",
160
- draftTool: "plan_draft",
161
- failsafeCmd: "/plan-save",
162
- detailsExtra: {},
163
- noSourceError: "no plan source resolved",
164
- };
165
-
166
- const OBJECTIVE_SUBJECT: ReviewSubject = {
167
- noun: "objective",
168
- present: "the complete objective + structured roadmap to the user",
169
- presentUnavailable: "the complete objective + structured roadmap to the user",
170
- implementHereWhere: "on the objective path",
171
- draftTool: "objective_draft",
172
- failsafeCmd: "/objective-save",
173
- detailsExtra: { subject: "objective" },
174
- noSourceError: "no objective draft resolved",
175
- };
176
-
177
- const GIST_SUBJECT: ReviewSubject = {
178
- noun: "gist",
179
- present: "the complete gist to the user",
180
- presentUnavailable: "the complete gist to the user",
181
- implementHereWhere: "on the gist path",
182
- draftTool: "gist_draft",
183
- failsafeCmd: "/gist-save",
184
- detailsExtra: { subject: "gist" },
185
- noSourceError: "no gist draft resolved",
186
- };
187
-
188
- const SKIP_TEXT =
189
- "no interactive review surface available — present the complete plan to the user in your next message.";
190
-
191
- function skipResult(): ToolResult {
192
- return { content: [{ type: "text", text: SKIP_TEXT }], details: { ok: true, status: "skipped" } };
193
- }
194
-
195
- /**
196
- * The shared outcome-mapper core: map a non-approved review outcome into the model-facing tool
197
- * result for `subject`. The `completed` case renders the DENIED text — both execute paths route
198
- * approved outcomes to their approved-save mapper FIRST, so callers only reach `completed` here
199
- * with `approved: false` (kept total for safety; the `approved: outcome.approved` passthrough is
200
- * deliberately behavior-preserving — never hardcode `false`). The `dismissed` arm renders as a
201
- * skip — the human declined to decide, so the present-the-work + manual-failsafe discipline
202
- * applies.
203
- */
204
- function subjectReviewOutcomeResult(subject: ReviewSubject, outcome: ReviewOutcome): ToolResult {
205
- switch (outcome.status) {
206
- case "unavailable":
207
- return {
208
- content: [
209
- {
210
- type: "text",
211
- text:
212
- `WARNING: ${outcome.warning} — no review performed. ` +
213
- `Present ${subject.presentUnavailable} instead.`,
214
- },
215
- ],
216
- details: {
217
- ok: false,
218
- error: outcome.warning,
219
- error_type: "unavailable",
220
- status: "unavailable",
221
- ...subject.detailsExtra,
222
- },
223
- };
224
- case "aborted":
225
- return {
226
- content: [{ type: "text", text: `${subject.noun} review aborted (turn interrupted).` }],
227
- details: { ok: true, status: "aborted", ...subject.detailsExtra },
228
- };
229
- case "dismissed":
230
- return {
231
- content: [
232
- {
233
- type: "text",
234
- text:
235
- `${subject.noun} review dismissed — present ${subject.present}; the human runs ` +
236
- `${subject.failsafeCmd} (the manual failsafe).`,
237
- },
238
- ],
239
- details: { ok: true, status: "skipped", reason: "dismissed", ...subject.detailsExtra },
240
- };
241
- case "implement-here":
242
- // Defensively unreachable: the plan execute path routes implement-here to
243
- // implementHereResult FIRST (mirror the approved-first routing), and the objective arm
244
- // never offers the verdict. Map to a skip shape rather than throwing.
245
- return {
246
- content: [
247
- {
248
- type: "text",
249
- text:
250
- `implement-here verdict received ${subject.implementHereWhere} — nothing saved; ` +
251
- `present ${subject.present}.`,
252
- },
253
- ],
254
- details: { ok: true, status: "skipped", reason: "implement-here", ...subject.detailsExtra },
255
- };
256
- case "completed": {
257
- const feedback = outcome.feedback ? `\n\nReviewer feedback:\n${outcome.feedback}` : "";
258
- const text =
259
- `${subject.noun} DENIED — revise per this feedback, rewrite the working draft with ` +
260
- `${subject.draftTool}, then call plan_review again.${feedback}`;
261
- return {
262
- content: [{ type: "text", text }],
263
- details: {
264
- ok: true,
265
- status: "completed",
266
- approved: outcome.approved,
267
- feedback: outcome.feedback ?? null,
268
- reviewId: outcome.reviewId,
269
- ...subject.detailsExtra,
270
- },
271
- };
272
- }
273
- }
274
- }
275
-
276
- /**
277
- * Map a non-approved review outcome into the model-facing tool result (exported for the offline
278
- * tests) — the plan flavor of `subjectReviewOutcomeResult`. The execute path routes approved
279
- * outcomes to `approvedSaveResult` first, so `completed` renders DENIED here.
280
- */
281
- export function reviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
282
- return subjectReviewOutcomeResult(PLAN_SUBJECT, outcome);
283
- }
284
-
285
- /**
286
- * The normalized approval-save outcome the shared core consumes — each delegator maps its
287
- * subject-specific no-source discriminant (`no-plan` / `no-draft`) onto `no-source`. `result`
288
- * widens to `Result<object>`: the core reads only `content[0]?.text` and the `details.ok`
289
- * discriminant (+ `details.error` on the fail arm), passing `details` through opaquely.
290
- */
291
- type SubjectSaveOutcome =
292
- | { status: "no-source" }
293
- | { status: "saved" | "save-failed"; result: Result<object>; gateExited: boolean };
294
-
295
- /**
296
- * The shared approved-save mapper core: map an APPROVED review outcome + the approval-save
297
- * outcome into the model-facing tool result for `subject`. A successful save TERMINATES the turn
298
- * (propagating the seam's `terminate: true` intent); a failed save is non-terminating, leaves
299
- * the gate read-only, and directs the human manual failsafe. Reviewer feedback is surfaced
300
- * loudly as implementation guidance — the approved bytes were saved verbatim, never post-edited.
301
- * The `paramMismatch`/`edited`/`directEditsFailed` opts are plan-arm-only (their literals name
302
- * "plan"/"draft"): the objective delegator never passes opts, so the suffixes render empty and
303
- * `edited` never reaches its details. `directEditsFailed` (plannotator-only) flags that a Direct
304
- * Edits section was seen but could not be honored — the saved arm gains a loud warning that the
305
- * plan was saved WITHOUT the reviewer's edits, and details carry `direct_edits_applied: false`.
306
- * The `no-source` arm is defensively unreachable (the reviewed source is always
307
- * non-blank) but maps to the save-failed shape rather than throwing.
308
- */
309
- function approvedSubjectSaveResult(
310
- subject: ReviewSubject,
311
- outcome: Extract<ReviewOutcome, { status: "completed" }>,
312
- save: SubjectSaveOutcome,
313
- opts?: { paramMismatch?: boolean; edited?: boolean; directEditsFailed?: boolean },
314
- ): ToolResult {
315
- const feedback = outcome.feedback
316
- ? `\n\nReviewer feedback (implementation guidance — the approved ${subject.noun} was saved ` +
317
- `verbatim):\n${outcome.feedback}`
318
- : "";
319
- const base = {
320
- status: "completed",
321
- approved: true,
322
- reviewId: outcome.reviewId,
323
- feedback: outcome.feedback ?? null,
324
- ...subject.detailsExtra,
325
- ...(opts?.edited === true ? { edited: true } : {}),
326
- ...(opts?.directEditsFailed === true ? { direct_edits_applied: false } : {}),
327
- };
328
- if (save.status === "saved") {
329
- const saveText = save.result.content[0]?.text ?? "";
330
- const edited =
331
- opts?.edited === true ? " · human edits were written back to the draft and saved" : "";
332
- const mismatch =
333
- opts?.paramMismatch === true
334
- ? "\n\n⚠ differing plan param ignored — the validated draft was reviewed and saved."
335
- : "";
336
- const editsWarning =
337
- opts?.directEditsFailed === true
338
- ? "\n\n⚠ WARNING: the reviewer's Direct Edits could NOT be auto-applied — the plan was " +
339
- "saved WITHOUT them. The diff remains in the reviewer feedback above; apply it to the " +
340
- "plan issue manually or via a follow-up."
341
- : "";
342
- return {
343
- content: [
344
- {
345
- type: "text",
346
- text: `${subject.noun} APPROVED by reviewer.${feedback}\n\n${saveText}${edited}${mismatch}${editsWarning}`,
347
- },
348
- ],
349
- // `ok` sits per-branch, NOT in `base` — `base` is spread into the fail branch too.
350
- details: {
351
- ok: true,
352
- ...base,
353
- saved: true,
354
- gateExited: save.gateExited,
355
- save: save.result.details,
356
- },
357
- terminate: true,
358
- };
359
- }
360
- const error =
361
- save.status === "no-source"
362
- ? subject.noSourceError
363
- : save.result.details.ok
364
- ? "unknown save failure"
365
- : save.result.details.error;
366
- return {
367
- content: [
368
- {
369
- type: "text",
370
- text:
371
- `${subject.noun} APPROVED by reviewer, but the auto-save FAILED (${error}) — the ` +
372
- `session stays read-only. Ask the user to run ${subject.failsafeCmd} (the manual ` +
373
- `failsafe) to retry.${feedback}`,
374
- },
375
- ],
376
- details: {
377
- ok: false,
378
- error,
379
- error_type: "save_failed",
380
- ...base,
381
- saved: false,
382
- save: save.status === "no-source" ? null : save.result.details,
383
- },
384
- };
385
- }
386
-
387
- /**
388
- * Map an APPROVED review outcome + the `approvalSave` outcome into the model-facing tool result
389
- * (exported for the offline tests) — the plan flavor of `approvedSubjectSaveResult`. `edited`
390
- * flags that human edits were written back to the draft pre-verdict (the first-party editor, or
391
- * the plannotator Direct Edits auto-apply), so the saved bytes carry them. `directEditsFailed`
392
- * (plannotator-only, optional — absent keeps every existing call site byte-stable) flags a
393
- * Direct Edits section that could not be honored: the plan saved verbatim, a loud warning added.
394
- */
395
- export function approvedSaveResult(
396
- outcome: Extract<ReviewOutcome, { status: "completed" }>,
397
- save: ApprovalSaveOutcome,
398
- opts: { paramMismatch: boolean; edited?: boolean; directEditsFailed?: boolean },
399
- ): ToolResult {
400
- return approvedSubjectSaveResult(
401
- PLAN_SUBJECT,
402
- outcome,
403
- save.status === "no-plan" ? { status: "no-source" } : save,
404
- opts,
405
- );
406
- }
407
-
408
- /**
409
- * Map an IMPLEMENT-HERE review outcome + the `implementHereExit` outcome into the model-facing
410
- * tool result (exported for the offline tests). NON-terminating on purpose — the model continues
411
- * the turn and implements immediately. The text is the implement-here guidance; when the human
412
- * edited the plan during review (`edited`), the final reviewed bytes are inlined so the model
413
- * implements THOSE, not its stale in-context version (the draft write-back already happened
414
- * pre-verdict). Nothing is saved: no issue, no plan-ref, the draft artifact intact (§8.23).
415
- */
416
- export function implementHereResult(
417
- outcome: Extract<ReviewOutcome, { status: "implement-here" }>,
418
- exit: { gateExited: boolean },
419
- opts: { cwd: string; plan: string; edited: boolean },
420
- ): ToolResult {
421
- return {
422
- content: [
423
- {
424
- type: "text",
425
- text: implementHereGuidance(opts.cwd, { editedPlan: opts.edited ? opts.plan : undefined }),
426
- },
427
- ],
428
- details: {
429
- ok: true,
430
- status: "implement-here",
431
- saved: false,
432
- gateExited: exit.gateExited,
433
- reviewId: outcome.reviewId,
434
- ...(opts.edited ? { edited: true } : {}),
435
- },
436
- };
437
- }
438
-
439
- // ------------------------------------------------------ the plannotator Direct-Edits apply
440
-
441
- /**
442
- * The shared plannotator APPROVE mechanical-apply path (contracts.md §8.23): inspect an
443
- * APPROVED outcome's feedback for a `# Direct Edits` section and mechanically apply the
444
- * reviewer's diff to the exact bytes reviewed (`basePlan`), writing the patched bytes back to
445
- * the draft (reviewed bytes == artifact bytes == saved bytes). Consumed by `executePlanReview`'s
446
- * plannotator arm AND the `/plan-review-browser` door — one apply path, byte-identical
447
- * semantics:
448
- *
449
- * - only an `approved` outcome WITH feedback is inspected (anything else passes through
450
- * verbatim);
451
- * - a clean extract + apply + write-back swaps `reviewedPlan` to the patched bytes, sets
452
- * `edited: true`, and strips the applied section from the returned outcome's feedback (only
453
- * the annotation remainder survives — the applied diff must never render as "apply these
454
- * exact changes" guidance);
455
- * - a seen-but-unhonorable heading (or a failed apply / write-back) sets
456
- * `directEditsFailed: true` with the plan left verbatim (the caller renders the loud warning;
457
- * the diff stays in the surfaced feedback for a manual follow-up).
458
- */
459
- export function applyPlannotatorDirectEdits(
460
- pi: ExtensionAPI,
461
- ctx: ExtensionContext,
462
- outcome: Extract<ReviewOutcome, { status: "completed" }>,
463
- basePlan: string,
464
- ): {
465
- outcome: Extract<ReviewOutcome, { status: "completed" }>;
466
- reviewedPlan: string;
467
- edited: boolean;
468
- directEditsFailed: boolean;
469
- } {
470
- if (!outcome.approved || outcome.feedback === undefined) {
471
- return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: false };
472
- }
473
- const section = extractDirectEdits(outcome.feedback);
474
- if (section !== null) {
475
- const patched = applyUnifiedDiff(basePlan, section.diff);
476
- if (patched !== null && writePlanDraft(pi, ctx, patched).details.ok) {
477
- return {
478
- outcome: { ...outcome, feedback: section.remainder },
479
- reviewedPlan: patched,
480
- edited: true,
481
- directEditsFailed: false,
482
- };
483
- }
484
- return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: true };
485
- }
486
- if (hasDirectEditsHeading(outcome.feedback)) {
487
- return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: true };
488
- }
489
- return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: false };
490
- }
491
-
492
- // ------------------------------------------------------ the launch chooser (the wave arm)
493
-
494
- /**
495
- * The injected wave-launch deps (composed in index.ts from the door exports — structural on
496
- * purpose: this module imports NOTHING from door modules, avoiding the value-import cycle;
497
- * `planReviewBrowser.ts` already value-imports this module). `present` is the plannotator
498
- * presence probe (`plannotatorPresent(pi)` at the call site); `plan`/`objective` are the
499
- * guidance-returning door open cores (`openPlanReviewSurface` / `openObjectiveReviewSurface`) —
500
- * one open path, byte-identical door semantics (contracts.md §8.23). `null` from an opener is
501
- * the synchronous port-pick failure (already loudly reported inside the core) — the caller
502
- * falls open to the plain blocking review.
503
- */
504
- export interface WaveLaunch {
505
- present(): boolean;
506
- plan(ctx: ExtensionContext, opts: { draft: string; custom?: string }): Promise<string | null>;
507
- objective(
508
- ctx: ExtensionContext,
509
- opts: { rendered: string; artifactRaw: string; custom?: string },
510
- ): Promise<string | null>;
511
- }
512
-
513
- /** The minimal structural `ctx.ui` subset the launch chooser needs (both dialogs signal-aware). */
514
- export interface ReviewLaunchUI {
515
- select(
516
- title: string,
517
- options: string[],
518
- opts?: { signal?: AbortSignal },
519
- ): Promise<string | undefined>;
520
- input(
521
- title: string,
522
- placeholder?: string,
523
- opts?: { signal?: AbortSignal },
524
- ): Promise<string | undefined>;
525
- }
526
-
527
- /** The launch chooser's outcome: the review flavor (never a cancel), or the aborted turn. */
528
- export type ReviewLaunchChoice =
529
- | { launch: "plain" }
530
- | { launch: "wave"; custom?: string }
531
- | { launch: "aborted" };
532
-
533
- const LAUNCH_WAVE = "Browser review + reviewer wave";
534
- const LAUNCH_PLAIN = "Browser review only";
535
- const CUSTOM_ANGLE_TITLE = "Custom review angle (optional — Enter to skip)";
536
-
537
- /**
538
- * The launch chooser (pure over the injected ui slice — offline-testable): every eligible
539
- * plannotator round asks the human whether the browser review launches WITH the streamed
540
- * reviewer wave; the wave choice then asks for an optional custom review angle. Esc/dismiss
541
- * anywhere selects a FLAVOR, never cancels the review (Esc at the chooser ⇒ plain; Esc/blank at
542
- * the angle input ⇒ wave with no custom lane — the input is `.trim()`'d before blank detection,
543
- * the door handlers' exact discipline). ABORT OUTRANKS EVERYTHING: `signal?.aborted` is checked
544
- * at entry and re-checked immediately after each awaited dialog, BEFORE interpreting its result
545
- * (the `runFirstPartyReview` discipline) — a conforming caller can never launch a browser or
546
- * enter a blocking review after the turn was interrupted. No other return paths exist.
547
- */
548
- export async function chooseReviewLaunch(
549
- ui: ReviewLaunchUI,
550
- subjectNoun: string,
551
- signal?: AbortSignal,
552
- ): Promise<ReviewLaunchChoice> {
553
- if (signal?.aborted) return { launch: "aborted" };
554
- const picked = await ui.select(`${subjectNoun} review launch`, [LAUNCH_WAVE, LAUNCH_PLAIN], {
555
- signal,
556
- });
557
- if (signal?.aborted) return { launch: "aborted" }; // abort outranks Esc AND any selection
558
- if (picked !== LAUNCH_WAVE) return { launch: "plain" }; // Esc/dismiss = the plain flavor
559
- const raw = await ui.input(CUSTOM_ANGLE_TITLE, undefined, { signal });
560
- if (signal?.aborted) return { launch: "aborted" }; // abort outranks the input result too
561
- const custom = (raw ?? "").trim();
562
- return custom.length > 0 ? { launch: "wave", custom } : { launch: "wave" };
563
- }
564
-
565
- /**
566
- * The NON-terminating wave-launched result: the door core's guidance rides back verbatim as the
567
- * tool text (same templates, same binding suffix — the model behaves identically whether the
568
- * human summoned the door or chose the wave inside `plan_review`), and the human's browser
569
- * decision routes through the door's background decision task — never through this call.
570
- */
571
- function waveLaunchedResult(subject: ReviewSubject, guidance: string): ToolResult {
572
- return {
573
- content: [{ type: "text", text: guidance }],
574
- details: { ok: true, status: "wave_launched", ...subject.detailsExtra },
575
- };
576
- }
577
-
578
- // ----------------------------------------------------------------- the first-party review core
579
-
580
- /** The minimal structural `ctx.ui` subset the first-party review needs (the ciExecutor.ts pure-core + injected-fakes recipe). */
581
- export interface PlanReviewUI {
582
- editor(title: string, prefill?: string): Promise<string | undefined>;
583
- select(
584
- title: string,
585
- options: string[],
586
- opts?: { signal?: AbortSignal },
587
- ): Promise<string | undefined>;
588
- }
589
-
590
- /**
591
- * Derive a subject's verdict options (plain text — charter D3: no emoji outside the footer);
592
- * only the skip label's manual-failsafe command varies by subject. `VERDICT_IMPLEMENT_HERE`
593
- * stays a standalone constant on purpose — the no-save exit is plan-arm-only by contract
594
- * (§8.23), never part of the descriptor.
595
- */
596
- function verdictsFor(subject: ReviewSubject): { approve: string; deny: string; skip: string } {
597
- return {
598
- approve: "Approve — auto-save to GitHub",
599
- deny: "Deny — send feedback for revision",
600
- skip: `Skip — decide later (manual ${subject.failsafeCmd})`,
601
- };
602
- }
603
-
604
- /** The optional 4th verdict (plan arm only): the no-save implement-here exit (§8.23). */
605
- const VERDICT_IMPLEMENT_HERE = "Implement here — no issue saved";
606
-
607
- const REVIEW_EDITOR_TITLE =
608
- "Plan review — Enter: continue to verdict · Esc: skip · Ctrl+G: $EDITOR";
609
- const DENY_FEEDBACK_TITLE = "Deny feedback (optional) — Enter to send";
610
-
611
- /**
612
- * The first-party in-TUI review core, pure over injected seams (the ciExecutor.ts pure-core + injected-fakes recipe) — fully
613
- * offline-testable. Flow: (1) display the plan in the editor dialog (Esc = dismissed; the human
614
- * may edit, incl. via Ctrl+G/$EDITOR); (2) a non-blank edit differing from the displayed plan is
615
- * written back to the draft BEFORE the verdict (reviewed bytes == artifact bytes == saved bytes;
616
- * a failed write-back aborts the review fail-open — never let an approval save a stale artifact;
617
- * a blank edit result is treated as no-edit); (3) the 3-option verdict select (Esc/Skip =
618
- * dismissed); (4) on deny, optional feedback via a second editor dialog. `ctx.ui.editor` takes no
619
- * AbortSignal — `signal?.aborted` is checked before each dialog (the aborted arm wins).
620
- *
621
- * Presentation options (defaults preserve the plan-path behavior byte-for-byte):
622
- * `editorTitle`/`verdicts` swap the displayed strings; `verdicts.implementHere`, when present,
623
- * makes the verdict select 4 options — approve, implement-here, deny, skip (implement-here sits
624
- * adjacent to approve: both are "accept the plan" outcomes) — and selecting it returns the
625
- * `implement-here` outcome arm; `viewOnly: true` skips the write-back
626
- * branch entirely — the editor output is used only for Esc/dismissed detection, `plan` is
627
- * returned unchanged and `edited` stays false (deny+feedback is the change channel).
628
- */
629
- export async function runFirstPartyReview(args: {
630
- ui: PlanReviewUI;
631
- plan: string;
632
- /** Bound to `writePlanDraft(pi, ctx, …).details.ok` by the execute path. */
633
- writeDraft(plan: string): boolean;
634
- signal?: AbortSignal;
635
- editorTitle?: string;
636
- verdicts?: { approve: string; deny: string; skip: string; implementHere?: string };
637
- viewOnly?: boolean;
638
- }): Promise<{ outcome: ReviewOutcome; plan: string; edited: boolean }> {
639
- const { ui, writeDraft, signal } = args;
640
- const editorTitle = args.editorTitle ?? REVIEW_EDITOR_TITLE;
641
- const verdicts: { approve: string; deny: string; skip: string; implementHere?: string } =
642
- args.verdicts ?? verdictsFor(PLAN_SUBJECT);
643
- let plan = args.plan;
644
- let edited = false;
645
- const result = (
646
- outcome: ReviewOutcome,
647
- ): { outcome: ReviewOutcome; plan: string; edited: boolean } => ({
648
- outcome,
649
- plan,
650
- edited,
651
- });
652
-
653
- if (signal?.aborted) return result({ status: "aborted" });
654
- const reviewed = await ui.editor(editorTitle, plan);
655
- if (signal?.aborted) return result({ status: "aborted" });
656
- if (reviewed === undefined) return result({ status: "dismissed" });
657
-
658
- // Write human edits back to the draft BEFORE the verdict (blank = no-edit, review the original
659
- // bytes). A failed write-back aborts the review fail-open — nothing saved. View-only reviews
660
- // skip the branch entirely (the editor is display-only; deny+feedback is the change channel).
661
- if (args.viewOnly !== true && reviewed !== plan && reviewed.trim().length > 0) {
662
- if (!writeDraft(reviewed)) {
663
- return result({
664
- status: "unavailable",
665
- warning:
666
- "could not write the edited draft back to the session data dir — review aborted, " +
667
- "nothing saved",
668
- });
669
- }
670
- plan = reviewed;
671
- edited = true;
672
- }
673
-
674
- if (signal?.aborted) return result({ status: "aborted" });
675
- const options =
676
- verdicts.implementHere === undefined
677
- ? [verdicts.approve, verdicts.deny, verdicts.skip]
678
- : [verdicts.approve, verdicts.implementHere, verdicts.deny, verdicts.skip];
679
- const verdict = await ui.select("Plan review verdict", options, { signal });
680
- if (signal?.aborted) return result({ status: "aborted" });
681
- if (verdict === verdicts.approve) {
682
- return result({ status: "completed", approved: true, reviewId: randomUUID() });
683
- }
684
- if (verdicts.implementHere !== undefined && verdict === verdicts.implementHere) {
685
- return result({ status: "implement-here", reviewId: randomUUID() });
686
- }
687
- if (verdict === verdicts.deny) {
688
- const feedback = await ui.editor(DENY_FEEDBACK_TITLE, "");
689
- if (signal?.aborted) return result({ status: "aborted" });
690
- return result({
691
- status: "completed",
692
- approved: false,
693
- feedback: feedback?.trim() ? feedback : undefined,
694
- reviewId: randomUUID(),
695
- });
696
- }
697
- // Skip option, or the select dismissed (Esc) — fail-open skip.
698
- return result({ status: "dismissed" });
699
- }
700
-
701
- // ------------------------------------------------------------------- the objective review arm
702
-
703
- const OBJECTIVE_REVIEW_EDITOR_TITLE =
704
- "Objective review (view only — edits are not saved) — Enter: continue to verdict · Esc: skip · " +
705
- "Ctrl+G: $EDITOR";
706
-
707
- /**
708
- * Map a non-approved objective review outcome into the model-facing tool result (exported for
709
- * the offline tests) — the objective-flavored sibling of `reviewOutcomeResult`, delegating to
710
- * `subjectReviewOutcomeResult` with `OBJECTIVE_SUBJECT`. Every arm carries
711
- * `details.subject: "objective"`; the texts redirect to `objective_draft` / `/objective-save`.
712
- * The execute path routes approved outcomes to `approvedObjectiveSaveResult` first, so
713
- * `completed` renders DENIED here.
714
- */
715
- export function objectiveReviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
716
- return subjectReviewOutcomeResult(OBJECTIVE_SUBJECT, outcome);
717
- }
718
-
719
- /**
720
- * Map an APPROVED objective review outcome + the `objectiveApprovalSave` outcome into the
721
- * model-facing tool result (exported for the offline tests) — the objective sibling of
722
- * `approvedSaveResult`, delegating to `approvedSubjectSaveResult` with `OBJECTIVE_SUBJECT` and
723
- * no opts (the objective path reviews only the rendered draft, view-only — no
724
- * `paramMismatch`/`edited`).
725
- */
726
- export function approvedObjectiveSaveResult(
727
- outcome: Extract<ReviewOutcome, { status: "completed" }>,
728
- save: ObjectiveApprovalSaveOutcome,
729
- ): ToolResult {
730
- return approvedSubjectSaveResult(
731
- OBJECTIVE_SUBJECT,
732
- outcome,
733
- save.status === "no-draft" ? { status: "no-source" } : save,
734
- );
735
- }
736
-
737
- /**
738
- * The objective review arm, mirroring `executePlanReview`'s shape but
739
- * with the rendered objective draft as the SOLE review source (never the `plan` param, never
740
- * the transcript). First-party reviews run VIEW-ONLY (edits are never written back;
741
- * deny+feedback is the change channel). An APPROVED outcome wires into the
742
- * `objectiveApprovalSave` seam (re-read the STRUCTURED artifact → `saveObjective` → D1a gate
743
- * exit → terminating); every other outcome maps via `objectiveReviewOutcomeResult`. ONE
744
- * carve-out (plannotator only): an approval whose feedback opens a Direct Edits section SKIPS
745
- * the save — rendered-markdown edits cannot be folded back into the structured draft
746
- * mechanically — and returns a NON-terminating revise round with the gate untouched (fold the
747
- * diff in via `objective_draft`, re-review to confirm); perk never saves an objective the
748
- * reviewer explicitly edited away from.
749
- */
750
- export async function executeObjectiveReview(
751
- pi: ExtensionAPI,
752
- ctx: ExtensionContext,
753
- gating: ToolGating,
754
- bridge: { review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> },
755
- signal?: AbortSignal,
756
- wave?: WaveLaunch,
757
- ): Promise<ToolResult> {
758
- // 1. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
759
- if (!ctx.hasUI) return skipResult();
760
- // 2. The wave arm's stale-guard baseline is captured BEFORE the validated read below (the
761
- // objective door's fail-closed ordering): the rendered bytes always derive from a read at
762
- // or after this baseline, so a concurrent objective_draft write between the two reads makes
763
- // the browsed render NEWER than the baseline and routeObjectiveReviewDecision's existing
764
- // guard refuses the approval — the reverse order would fail open (approve unreviewed
765
- // bytes). Raw artifact bytes on purpose: the save-authoritative surface catches
766
- // render-invisible changes.
767
- const baseline = readSessionArtifact(ctx, OBJECTIVE_DRAFT_ARTIFACT);
768
- // 3. The draft artifact is the sole review source — no draft → soft skip with the
769
- // objective_draft redirect.
770
- const draft = readObjectiveDraft(ctx);
771
- if (draft === null) {
772
- return {
773
- content: [
774
- {
775
- type: "text",
776
- text:
777
- "no objective draft to review — write the working objective with objective_draft " +
778
- "(prose + the structured roadmap), then call plan_review again.",
779
- },
780
- ],
781
- details: {
782
- ok: false,
783
- error: "no objective draft to review — write it with objective_draft first",
784
- error_type: "no_objective_draft",
785
- status: "skipped",
786
- reason: "no_objective_draft",
787
- },
788
- };
789
- }
790
- // 4. The reviewed bytes are the RENDERED markdown (prose + roadmap table) — never raw JSON.
791
- const rendered = renderObjectiveDraft(draft);
792
- // 5. Backend dispatch (mirrors the plan path): plannotator-selected → the bridge; ANY other
793
- // selection → the first-party editor, view-only.
794
- const sig = signal ?? ctx.signal;
795
- let outcome: ReviewOutcome;
796
- if (isPlannotatorPlanSelected(ctx.cwd)) {
797
- // The launch chooser (contracts.md §8.23): every eligible round the human picks with/without
798
- // the streamed reviewer wave BEFORE anything launches. Eligibility is drafts-only — the wave
799
- // door stale-guards the raw artifact baseline, so a null baseline keeps the plain path
800
- // (silently: there is no forced mode to warn about).
801
- if (wave?.present() && baseline !== null) {
802
- const choice = await chooseReviewLaunch(ctx.ui, "Objective", sig);
803
- if (choice.launch === "aborted") return objectiveReviewOutcomeResult({ status: "aborted" });
804
- if (choice.launch === "wave") {
805
- const guidance = await wave.objective(ctx, {
806
- rendered,
807
- artifactRaw: baseline.content,
808
- ...(choice.custom !== undefined ? { custom: choice.custom } : {}),
809
- });
810
- // Abort outranks the opener result too: a turn interrupted during the awaited open must
811
- // never report a successful launch (the door's own bridge abort handling settles the
812
- // background tasks and clears the primed surfaces).
813
- if (sig?.aborted) return objectiveReviewOutcomeResult({ status: "aborted" });
814
- if (guidance !== null) return waveLaunchedResult(OBJECTIVE_SUBJECT, guidance);
815
- // null = the synchronous port-pick failure (already loudly reported inside the core) —
816
- // fall open to the plain blocking review in the same call: the review never wedges.
817
- }
818
- }
819
- outcome = await bridge.review(rendered, sig);
820
- // APPROVE + Direct Edits (browser edits of the RENDERED markdown), checked BEFORE the
821
- // approved-save routing (the approved-first discipline): the save seam re-reads the
822
- // STRUCTURED artifact, so rendered-markdown edits — roadmap-table rows included — cannot be
823
- // folded back without model judgment. Skip the save, keep the gate read-only, and route ONE
824
- // revise round: the model folds the diff into `objective_draft`, then re-reviews to confirm.
825
- // The heading check suffices (extraction success is irrelevant here — the diff goes to the
826
- // model verbatim either way).
827
- if (
828
- outcome.status === "completed" &&
829
- outcome.approved &&
830
- outcome.feedback !== undefined &&
831
- hasDirectEditsHeading(outcome.feedback)
832
- ) {
833
- return {
834
- content: [
835
- {
836
- type: "text",
837
- text:
838
- "objective APPROVED with direct browser edits — these cannot be auto-applied to " +
839
- "the structured draft, so nothing was saved. Fold the Direct Edits diff below into " +
840
- "the working draft with objective_draft (prose hunks → the prose; roadmap-table " +
841
- "hunks → the matching node fields), then call plan_review again to confirm.\n\n" +
842
- `Reviewer feedback:\n${outcome.feedback}`,
843
- },
844
- ],
845
- details: {
846
- ok: true,
847
- status: "revise",
848
- reason: "direct_edits",
849
- approved: true,
850
- feedback: outcome.feedback,
851
- reviewId: outcome.reviewId,
852
- subject: "objective",
853
- },
854
- };
855
- }
856
- } else {
857
- const fp = await runFirstPartyReview({
858
- ui: ctx.ui,
859
- plan: rendered,
860
- writeDraft: () => true, // unreachable under viewOnly — the branch is skipped
861
- signal: sig,
862
- editorTitle: OBJECTIVE_REVIEW_EDITOR_TITLE,
863
- verdicts: verdictsFor(OBJECTIVE_SUBJECT),
864
- viewOnly: true,
865
- });
866
- outcome = fp.outcome;
867
- }
868
- // 6. An APPROVED decision (either backend) wires into the objectiveApprovalSave seam (the
869
- // STRUCTURED artifact is re-read at save time — never the rendered bytes; auto-save → D1a
870
- // gate exit → terminating result); everything else maps via objectiveReviewOutcomeResult.
871
- // Approved-first routing: objectiveReviewOutcomeResult's completed case renders DENIED.
872
- if (outcome.status === "completed" && outcome.approved) {
873
- const save = await objectiveApprovalSave(pi, ctx, gating);
874
- return approvedObjectiveSaveResult(outcome, save);
875
- }
876
- return objectiveReviewOutcomeResult(outcome);
877
- }
878
-
879
- // ------------------------------------------------------------------------ the gist review arm
880
-
881
- const GIST_REVIEW_EDITOR_TITLE =
882
- "Gist review (view only — edits are not saved) — Enter: continue to verdict · Esc: skip · " +
883
- "Ctrl+G: $EDITOR";
884
-
885
- /**
886
- * Map a non-approved gist review outcome into the model-facing tool result (exported for the
887
- * offline tests) — the gist-flavored sibling of `objectiveReviewOutcomeResult`, delegating to
888
- * `subjectReviewOutcomeResult` with `GIST_SUBJECT`. Every arm carries `details.subject: "gist"`;
889
- * the texts redirect to `gist_draft` / `/gist-save`. The execute path routes approved outcomes
890
- * to `approvedGistSaveResult` first, so `completed` renders DENIED here.
891
- */
892
- export function gistReviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
893
- return subjectReviewOutcomeResult(GIST_SUBJECT, outcome);
894
- }
895
-
896
- /**
897
- * Map an APPROVED gist review outcome + the `gistApprovalSave` outcome into the model-facing
898
- * tool result (exported for the offline tests) — the gist sibling of
899
- * `approvedObjectiveSaveResult`, delegating to `approvedSubjectSaveResult` with `GIST_SUBJECT`
900
- * and no opts (the gist path reviews only the rendered draft, view-only — no
901
- * `paramMismatch`/`edited`).
902
- */
903
- export function approvedGistSaveResult(
904
- outcome: Extract<ReviewOutcome, { status: "completed" }>,
905
- save: GistApprovalSaveOutcome,
906
- ): ToolResult {
907
- return approvedSubjectSaveResult(
908
- GIST_SUBJECT,
909
- outcome,
910
- save.status === "no-draft" ? { status: "no-source" } : save,
911
- );
912
- }
913
-
914
- /**
915
- * The gist review arm, mirroring `executeObjectiveReview`'s shape with the rendered gist draft
916
- * as the SOLE review source (never the `plan` param, never the transcript). First-party reviews
917
- * run VIEW-ONLY (edits are never written back; deny+feedback is the change channel); the
918
- * implement-here verdict is never offered (a gist is not implementable — it has no strategy).
919
- * An APPROVED outcome wires into the `gistApprovalSave` seam (re-read the artifact → `saveGist`
920
- * → D1a gate exit → terminating); every other outcome maps via `gistReviewOutcomeResult`. ONE
921
- * carve-out (plannotator only, mirroring the objective arm): an approval whose feedback opens a
922
- * Direct Edits section SKIPS the save — rendered edits cannot be folded back into the
923
- * structured draft mechanically — and returns a NON-terminating revise round with the gate
924
- * untouched (fold each hunk into the matching `gist_draft` field, re-review to confirm); perk
925
- * never saves a gist the reviewer explicitly edited away from.
926
- */
927
- export async function executeGistReview(
928
- pi: ExtensionAPI,
929
- ctx: ExtensionContext,
930
- gating: ToolGating,
931
- bridge: { review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> },
932
- signal?: AbortSignal,
933
- ): Promise<ToolResult> {
934
- // 1. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
935
- if (!ctx.hasUI) return skipResult();
936
- // 2. The draft artifact is the sole review source — no draft → soft skip with the gist_draft
937
- // redirect.
938
- const draft = readGistDraft(ctx);
939
- if (draft === null) {
940
- return {
941
- content: [
942
- {
943
- type: "text",
944
- text:
945
- "no gist draft to review — write the working gist with gist_draft (the " +
946
- "statement-of-intent prose), then call plan_review again.",
947
- },
948
- ],
949
- details: {
950
- ok: false,
951
- error: "no gist draft to review — write it with gist_draft first",
952
- error_type: "no_gist_draft",
953
- status: "skipped",
954
- reason: "no_gist_draft",
955
- },
956
- };
957
- }
958
- // 3. The reviewed bytes are the RENDERED markdown (title + scope + prose) — never raw JSON.
959
- const rendered = renderGistDraft(draft);
960
- // 4. Backend dispatch (mirrors the objective path): plannotator-selected → the bridge; ANY
961
- // other selection → the first-party editor, view-only.
962
- const sig = signal ?? ctx.signal;
963
- let outcome: ReviewOutcome;
964
- if (isPlannotatorPlanSelected(ctx.cwd)) {
965
- outcome = await bridge.review(rendered, sig);
966
- // APPROVE + Direct Edits (browser edits of the RENDERED markdown), checked BEFORE the
967
- // approved-save routing (the approved-first discipline; mirrors the objective arm): the
968
- // save seam re-reads the STRUCTURED artifact, so rendered edits cannot be folded back
969
- // without model judgment. Skip the save, keep the gate read-only, and route ONE revise
970
- // round: the model folds each hunk into the matching `gist_draft` field, then re-reviews
971
- // to confirm. The heading check suffices (extraction success is irrelevant here — the diff
972
- // goes to the model verbatim either way).
973
- if (
974
- outcome.status === "completed" &&
975
- outcome.approved &&
976
- outcome.feedback !== undefined &&
977
- hasDirectEditsHeading(outcome.feedback)
978
- ) {
979
- return {
980
- content: [
981
- {
982
- type: "text",
983
- text:
984
- "gist APPROVED with direct browser edits — these cannot be auto-applied to the " +
985
- "structured draft, so nothing was saved. Fold each Direct Edits hunk below into " +
986
- "the matching gist_draft field (a `# <title>` heading hunk → title, a `Scope:` " +
987
- "line hunk → scope, prose hunks → prose), then call plan_review again to " +
988
- `confirm.\n\nReviewer feedback:\n${outcome.feedback}`,
989
- },
990
- ],
991
- details: {
992
- ok: true,
993
- status: "revise",
994
- reason: "direct_edits",
995
- approved: true,
996
- feedback: outcome.feedback,
997
- reviewId: outcome.reviewId,
998
- subject: "gist",
999
- },
1000
- };
1001
- }
1002
- } else {
1003
- const fp = await runFirstPartyReview({
1004
- ui: ctx.ui,
1005
- plan: rendered,
1006
- writeDraft: () => true, // unreachable under viewOnly — the branch is skipped
1007
- signal: sig,
1008
- editorTitle: GIST_REVIEW_EDITOR_TITLE,
1009
- verdicts: verdictsFor(GIST_SUBJECT),
1010
- viewOnly: true,
1011
- });
1012
- outcome = fp.outcome;
1013
- }
1014
- // 5. An APPROVED decision (either backend) wires into the gistApprovalSave seam (the artifact
1015
- // is re-read at save time — never the rendered bytes; auto-save → D1a gate exit →
1016
- // terminating result); everything else maps via gistReviewOutcomeResult. Approved-first
1017
- // routing: gistReviewOutcomeResult's completed case renders DENIED.
1018
- if (outcome.status === "completed" && outcome.approved) {
1019
- const save = await gistApprovalSave(pi, ctx, gating);
1020
- return approvedGistSaveResult(outcome, save);
1021
- }
1022
- return gistReviewOutcomeResult(outcome);
1023
- }
1024
-
1025
- // ------------------------------------------------------------------------- the execute core
1026
-
1027
- /**
1028
- * The `plan_review` execute core, extracted pure-over-its-seams (the bridge, the gating, the
1029
- * ctx) so the resolution + dispatch + approved-save paths are unit-testable offline. Arm order:
1030
- * param decode → the objective arm (`executeObjectiveReview` — the rendered objective draft is
1031
- * the review subject in BOTH objective-authoring stages) → headless skip → file-first
1032
- * resolution → backend
1033
- * dispatch (plannotator-selected → the event-bus bridge; ANY other selection → the first-party
1034
- * in-TUI editor review) → approved → `approvalSave`.
1035
- */
1036
- export async function executePlanReview(
1037
- pi: ExtensionAPI,
1038
- ctx: ExtensionContext,
1039
- gating: ToolGating,
1040
- bridge: { review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> },
1041
- params: unknown,
1042
- signal?: AbortSignal,
1043
- wave?: WaveLaunch,
1044
- ): Promise<ToolResult> {
1045
- // Tool-boundary decode, in this tool's native fail-open vocabulary: a MISTYPED
1046
- // `plan` (or non-object params) skip-shapes (`reason: "bad_input"`) without reviewing; an
1047
- // ABSENT `plan` proceeds — the validated draft artifact is the preferred source.
1048
- const p = paramsOf(params);
1049
- const plan = p === null ? null : stringParam(p, "plan");
1050
- if (plan === null) {
1051
- return {
1052
- content: [
1053
- {
1054
- type: "text",
1055
- text: "plan_review takes { plan?: string } — omit it (the plan-draft artifact is preferred) or pass a string.",
1056
- },
1057
- ],
1058
- details: {
1059
- ok: false,
1060
- error: "plan must be a string",
1061
- error_type: "bad_input",
1062
- status: "skipped",
1063
- reason: "bad_input",
1064
- },
1065
- };
1066
- }
1067
- // 1. An objective-authoring session (objective-author OR objective-save — both stages'
1068
- // working draft is the objective draft, and neither carries `plan_draft`) → the objective
1069
- // review arm: the rendered objective draft is the sole review source; a well-typed `plan`
1070
- // param is ignored here — the plan-arm fallthrough could otherwise review/save an
1071
- // unrelated plan param from an objective session. A gist-author session likewise routes to
1072
- // the gist arm (the rendered gist draft).
1073
- const launchedStage = rebuildWorkflowState(branchOf(ctx)).stage;
1074
- if (launchedStage === OBJECTIVE_AUTHOR_STAGE || launchedStage === OBJECTIVE_SAVE_STAGE) {
1075
- return executeObjectiveReview(pi, ctx, gating, bridge, signal ?? ctx.signal, wave);
1076
- }
1077
- if (launchedStage === GIST_AUTHOR_STAGE) {
1078
- return executeGistReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
1079
- }
1080
- // 2. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
1081
- if (!ctx.hasUI) return skipResult();
1082
- // 3. File-first resolution: artifact → param, NEVER transcript — an approval
1083
- // auto-saves the reviewed bytes, and scraped conversation bytes must never be those.
1084
- const src = resolvePlanSource(ctx, plan);
1085
- if (src === null || src.source === "transcript") {
1086
- return {
1087
- content: [
1088
- {
1089
- type: "text",
1090
- text:
1091
- "no plan to review — write the working draft with plan_draft (or pass the plan " +
1092
- "param), then call plan_review again.",
1093
- },
1094
- ],
1095
- details: {
1096
- ok: false,
1097
- error: "no plan to review — write the draft with plan_draft first",
1098
- error_type: "no_plan",
1099
- status: "skipped",
1100
- reason: "no_plan",
1101
- },
1102
- };
1103
- }
1104
- // 4. Backend dispatch: plannotator-selected → the event-bus bridge; ANY other selection
1105
- // (perk-plan, tombell, unknown ids) → the first-party in-TUI editor review.
1106
- const sig = signal ?? ctx.signal;
1107
- let outcome: ReviewOutcome;
1108
- let reviewedPlan = src.plan;
1109
- let edited = false;
1110
- let directEditsFailed = false;
1111
- if (isPlannotatorPlanSelected(ctx.cwd)) {
1112
- // The launch chooser (contracts.md §8.23): every eligible round the human picks with/without
1113
- // the streamed reviewer wave BEFORE anything launches. Eligibility is drafts-only — the wave
1114
- // door reviews and stale-guards the validated artifact, so a param-tier source keeps the
1115
- // plain path (silently: there is no forced mode to warn about; the `wave === undefined` arm
1116
- // is defensive/test-only and behaves identically).
1117
- if (wave?.present() && src.source === "plan-draft") {
1118
- const choice = await chooseReviewLaunch(ctx.ui, "Plan", sig);
1119
- if (choice.launch === "aborted") return reviewOutcomeResult({ status: "aborted" });
1120
- if (choice.launch === "wave") {
1121
- const guidance = await wave.plan(ctx, {
1122
- draft: src.plan,
1123
- ...(choice.custom !== undefined ? { custom: choice.custom } : {}),
1124
- });
1125
- // Abort outranks the opener result too: a turn interrupted during the awaited open must
1126
- // never report a successful launch (the door's own bridge abort handling settles the
1127
- // background tasks and clears the primed surfaces).
1128
- if (sig?.aborted) return reviewOutcomeResult({ status: "aborted" });
1129
- if (guidance !== null) return waveLaunchedResult(PLAN_SUBJECT, guidance);
1130
- // null = the synchronous port-pick failure (already loudly reported inside the core) —
1131
- // fall open to the plain blocking review in the same call: the review never wedges.
1132
- }
1133
- }
1134
- outcome = await bridge.review(src.plan, sig);
1135
- // APPROVE + Direct Edits (browser plan edits, contracts.md §8.23): mechanically apply the
1136
- // reviewer's diff via the shared helper (the first-party pre-verdict write-back, replayed
1137
- // here post-verdict because the bridge only reports the diff), and save the EDITED bytes.
1138
- // Every rung fails open to the verbatim path (never save bytes the artifact doesn't carry).
1139
- // DENY stays model-mediated — the feedback (diff included) passes through for the
1140
- // plan_draft rewrite.
1141
- if (outcome.status === "completed") {
1142
- const applied = applyPlannotatorDirectEdits(pi, ctx, outcome, src.plan);
1143
- outcome = applied.outcome;
1144
- reviewedPlan = applied.reviewedPlan;
1145
- edited = applied.edited;
1146
- directEditsFailed = applied.directEditsFailed;
1147
- }
1148
- } else {
1149
- // The 4th verdict (implement-here, the no-save exit) is offered UNLESS this is an
1150
- // objective-node planning session — a node-linked plan must save (the node advance and
1151
- // backlink depend on it), so the claim suppresses it back to the 3-option select.
1152
- const fp = await runFirstPartyReview({
1153
- ui: ctx.ui,
1154
- plan: src.plan,
1155
- writeDraft: (text) => writePlanDraft(pi, ctx, text).details.ok,
1156
- signal: sig,
1157
- verdicts:
1158
- readNodeClaim(ctx) === null
1159
- ? { ...verdictsFor(PLAN_SUBJECT), implementHere: VERDICT_IMPLEMENT_HERE }
1160
- : undefined,
1161
- });
1162
- outcome = fp.outcome;
1163
- reviewedPlan = fp.plan;
1164
- edited = fp.edited;
1165
- }
1166
- // 5. IMPLEMENT-HERE (first-party only) routes before the generic mapper (mirror the
1167
- // approved-first split): gate exit WITHOUT save through the implementHereExit seam → a
1168
- // NON-terminating result carrying the implement-now guidance.
1169
- if (outcome.status === "implement-here") {
1170
- const exit = implementHereExit(ctx, gating);
1171
- return implementHereResult(outcome, exit, { cwd: ctx.cwd, plan: reviewedPlan, edited });
1172
- }
1173
- // 6. An APPROVED decision (either backend) wires into the approvalSave seam (auto-save → D1a
1174
- // gate exit → terminating result); everything else maps via reviewOutcomeResult.
1175
- if (outcome.status === "completed" && outcome.approved) {
1176
- const save = await approvalSave(pi, ctx, gating, { reviewedPlan });
1177
- return approvedSaveResult(outcome, save, {
1178
- paramMismatch: src.paramMismatch,
1179
- edited,
1180
- directEditsFailed,
1181
- });
1182
- }
1183
- return reviewOutcomeResult(outcome);
1184
- }
1185
-
1186
- // ----------------------------------------------------------------------------- registration
1187
-
1188
- /**
1189
- * Register `plan_review` — perk's universal review door. In READ_ONLY_TOOLS so it is callable
1190
- * INSIDE plan mode (the whole point — review happens before the gate ever comes off). Fail-open
1191
- * everywhere: headless / dismissed / backend-unavailable all soft-skip so authoring never wedges.
1192
- * `wave` is the injected wave-launch deps (index.ts composes them from the door open cores);
1193
- * absent ⇒ the chooser never appears and every path is byte-stable.
1194
- */
1195
- export function registerPlanReview(pi: ExtensionAPI, gating: ToolGating, wave?: WaveLaunch): void {
1196
- const bridge = createPlannotatorBridge(pi.events);
1197
-
1198
- pi.registerTool({
1199
- name: "plan_review",
1200
- label: "Plan review",
1201
- description:
1202
- "Present the plan to the configured review surface — the Plannotator browser UI when " +
1203
- "selected, otherwise perk's in-TUI editor review — and wait for the human decision. " +
1204
- "Reviews the validated plan-draft artifact (keep it current with plan_draft); on approval " +
1205
- "the plan is auto-saved and the turn terminates. On deny, revise per the returned " +
1206
- "feedback, rewrite the draft with plan_draft, and call again. On the Plannotator surface " +
1207
- "the human may first opt into a streamed reviewer wave — the call then returns immediately " +
1208
- 'with wave guidance (status "wave_launched") to follow in the same turn, and the browser ' +
1209
- "decision routes back automatically. No-op skip when the session is headless or the " +
1210
- "review is dismissed.",
1211
- promptSnippet: "Request a human review of the working plan draft",
1212
- promptGuidelines: [
1213
- "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.",
1214
- "Call plan_review only when the plan is decision-complete.",
1215
- "On a DENIED review, revise per the feedback, rewrite the draft with plan_draft, then call plan_review again.",
1216
- "On an APPROVED plan_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.",
1217
- "On a wave_launched result (the human opted into the reviewer wave), follow the returned guidance in the same turn — launch the wave and relay its findings; the human's browser decision routes back automatically, so never re-call plan_review while that browser review is open.",
1218
- "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).",
1219
- ],
1220
- executionMode: "sequential",
1221
- parameters: {
1222
- type: "object",
1223
- additionalProperties: false,
1224
- properties: {
1225
- plan: {
1226
- type: "string",
1227
- description:
1228
- "Optional — the validated plan-draft.md artifact is preferred when present; this " +
1229
- "param is the fallback for sessions that never wrote a draft.",
1230
- },
1231
- },
1232
- },
1233
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1234
- return executePlanReview(pi, ctx, gating, bridge, params, signal, wave);
1235
- },
1236
- });
1237
- }