@mgiles/perk 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +5 -0
  2. package/extension/authoring/gist/draft.ts +198 -0
  3. package/extension/authoring/gist/prose.ts +46 -0
  4. package/extension/authoring/gist/review.ts +133 -0
  5. package/extension/authoring/gist/save.ts +118 -0
  6. package/extension/authoring/objective/draft.ts +345 -0
  7. package/extension/{factories/objectiveDreamReport.ts → authoring/objective/dreamReportGate.ts} +74 -131
  8. package/extension/authoring/objective/planning.ts +124 -0
  9. package/extension/authoring/objective/prose.ts +103 -0
  10. package/extension/authoring/objective/review.ts +128 -0
  11. package/extension/authoring/objective/save.ts +224 -0
  12. package/extension/authoring/plan/draft.ts +84 -0
  13. package/extension/authoring/plan/prose.ts +41 -0
  14. package/extension/authoring/plan/review.ts +269 -0
  15. package/extension/authoring/plan/save.ts +256 -0
  16. package/extension/authoring/plan/source.ts +82 -0
  17. package/extension/authoring/refinement/context.ts +468 -0
  18. package/extension/authoring/refinement/draft.ts +261 -0
  19. package/extension/authoring/refinement/prose.ts +79 -0
  20. package/extension/authoring/refinement/review.ts +111 -0
  21. package/extension/authoring/refinement/save.ts +119 -0
  22. package/extension/authoring/review/approvalGate.ts +34 -0
  23. package/extension/authoring/review/draftContext.ts +68 -0
  24. package/extension/codeReview/automated.ts +352 -0
  25. package/extension/codeReview/submission.ts +229 -0
  26. package/extension/delivery/address.ts +295 -0
  27. package/extension/delivery/ci.ts +355 -0
  28. package/extension/delivery/commitCompact.ts +93 -0
  29. package/extension/delivery/conflictResolution.ts +247 -0
  30. package/extension/delivery/ready.ts +193 -0
  31. package/extension/delivery/stackConflict.ts +361 -0
  32. package/extension/delivery/stackObjective.ts +16 -0
  33. package/extension/delivery/stackReconcile.ts +165 -0
  34. package/extension/delivery/submit.ts +171 -0
  35. package/extension/index.ts +369 -359
  36. package/extension/learning/analystWave.ts +324 -0
  37. package/extension/learning/audit.ts +667 -0
  38. package/extension/learning/capture.ts +92 -0
  39. package/extension/learning/containment.ts +104 -0
  40. package/extension/{waves/dreamWave.ts → learning/dream.ts} +112 -94
  41. package/extension/learning/dreamAnalysis.ts +435 -0
  42. package/extension/{waves/dreamReducerWave.ts → learning/dreamReducer.ts} +46 -41
  43. package/extension/{waves → learning}/dreamReport.ts +35 -31
  44. package/extension/learning/harvest.ts +491 -0
  45. package/extension/learning/prose.ts +66 -0
  46. package/extension/learning/routing.ts +79 -0
  47. package/extension/pi/v1/bashScanTimeout.ts +64 -0
  48. package/extension/{doors/prReview.ts → pi/v1/codeReview/automated.ts} +215 -310
  49. package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +125 -57
  50. package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
  51. package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +169 -116
  52. package/extension/pi/v1/codeReview/stack.ts +606 -0
  53. package/extension/pi/v1/codeReview/submit.ts +354 -0
  54. package/extension/{doors/prReviewTerminal.ts → pi/v1/codeReview/terminal.ts} +32 -27
  55. package/extension/pi/v1/contextEvidence.ts +80 -0
  56. package/extension/pi/v1/contextInjection.ts +207 -0
  57. package/extension/{doors → pi/v1/delivery}/address.ts +163 -265
  58. package/extension/pi/v1/delivery/ci.ts +570 -0
  59. package/extension/pi/v1/delivery/commitCompact.ts +201 -0
  60. package/extension/pi/v1/delivery/conflictResolverEngine.ts +425 -0
  61. package/extension/{doors → pi/v1/delivery}/land.ts +128 -60
  62. package/extension/pi/v1/delivery/ready.ts +322 -0
  63. package/extension/pi/v1/delivery/stackConflictResolver.ts +172 -0
  64. package/extension/pi/v1/delivery/stackDrive.ts +120 -0
  65. package/extension/pi/v1/delivery/stackLand.ts +223 -0
  66. package/extension/pi/v1/delivery/stackRecover.ts +265 -0
  67. package/extension/pi/v1/delivery/stackStatus.ts +237 -0
  68. package/extension/pi/v1/delivery/stackSync.ts +658 -0
  69. package/extension/pi/v1/delivery/submit.ts +389 -0
  70. package/extension/pi/v1/delivery/submitConflict.ts +186 -0
  71. package/extension/pi/v1/draftReview.ts +431 -0
  72. package/extension/{doors → pi/v1}/draftReviewWaveTools.ts +141 -151
  73. package/extension/pi/v1/gist.ts +794 -0
  74. package/extension/pi/v1/learning/audit.ts +186 -0
  75. package/extension/pi/v1/learning/dream.ts +207 -0
  76. package/extension/{doors/learnFactory.ts → pi/v1/learning/factory.ts} +18 -65
  77. package/extension/{doors/harvestWaveTools.ts → pi/v1/learning/harvest.ts} +46 -100
  78. package/extension/pi/v1/learning/learn.ts +585 -0
  79. package/extension/{doors → pi/v1}/lifecycleGates.ts +14 -59
  80. package/extension/{factories → pi/v1}/objective.ts +53 -33
  81. package/extension/pi/v1/objectiveAuthoring.ts +672 -0
  82. package/extension/pi/v1/objectiveDreamGate.ts +160 -0
  83. package/extension/pi/v1/objectivePlanning.ts +776 -0
  84. package/extension/pi/v1/objectiveRefinement.ts +1320 -0
  85. package/extension/pi/v1/objectiveReview.ts +451 -0
  86. package/extension/{doors → pi/v1}/objectiveReviewBrowser.ts +259 -172
  87. package/extension/pi/v1/plan.ts +812 -0
  88. package/extension/pi/v1/planReview.ts +820 -0
  89. package/extension/{doors → pi/v1}/planReviewBrowser.ts +228 -152
  90. package/extension/{doors/annotationPush.ts → pi/v1/providers/annotations.ts} +158 -89
  91. package/extension/pi/v1/providers/plannotator.ts +487 -0
  92. package/extension/{doors → pi/v1/providers}/plannotatorHandoff.ts +152 -34
  93. package/extension/pi/v1/providers/selection.ts +43 -0
  94. package/extension/{adapters/planAdapterTombell.ts → pi/v1/providers/tombell.ts} +43 -72
  95. package/extension/pi/v1/review.ts +538 -0
  96. package/extension/pi/v1/reviewOutcome.ts +9 -0
  97. package/extension/pi/v1/scoutWave.ts +318 -0
  98. package/extension/{doors → pi/v1}/selfcheck.ts +4 -4
  99. package/extension/session/branchWorkflowSession.ts +60 -0
  100. package/extension/session/lifecycle.ts +644 -0
  101. package/extension/session/lifecycleGates.ts +64 -0
  102. package/extension/session/saveDestination.ts +87 -0
  103. package/extension/session/workflowSession.ts +971 -0
  104. package/extension/substrate/agentScratch.ts +27 -54
  105. package/extension/substrate/bashScanTimeout.ts +181 -0
  106. package/extension/substrate/bindingDelivery.ts +38 -30
  107. package/extension/substrate/bindings.ts +4 -5
  108. package/extension/substrate/cache.ts +64 -12
  109. package/extension/substrate/childRestrictions.ts +39 -0
  110. package/extension/substrate/coldDoor.ts +17 -1
  111. package/extension/substrate/config.ts +157 -19
  112. package/extension/substrate/git.ts +88 -6
  113. package/extension/substrate/modelVisible.ts +53 -0
  114. package/extension/substrate/paths.ts +2 -7
  115. package/extension/substrate/prompts.ts +22 -0
  116. package/extension/substrate/registry.ts +2 -0
  117. package/extension/substrate/resolverLease.ts +364 -0
  118. package/extension/substrate/sessionData.ts +85 -152
  119. package/extension/substrate/toolGating.ts +279 -84
  120. package/extension/substrate/unifiedDiff.ts +1 -1
  121. package/extension/substrate/workflowState.ts +191 -166
  122. package/extension/substrate/worktreeResolverLock.ts +261 -0
  123. package/extension/surfaces/surfaces.ts +79 -27
  124. package/extension/waves/adversarialReviewWave.ts +103 -48
  125. package/extension/waves/blockedReports.ts +59 -0
  126. package/extension/waves/draftReviewWave.ts +42 -42
  127. package/extension/waves/laneIdentity.ts +77 -0
  128. package/extension/waves/objectiveExplorerWave.ts +24 -24
  129. package/extension/waves/prReviewWave.ts +89 -77
  130. package/extension/waves/reportWave.ts +438 -578
  131. package/extension/waves/reviewClassifierWave.ts +22 -22
  132. package/extension/waves/rpcAdapter.ts +100 -15
  133. package/extension/waves/scoutWave.ts +192 -0
  134. package/extension/waves/transport.ts +480 -0
  135. package/extension/worker/sdkAdapter.ts +494 -0
  136. package/extension/worker/stageExecution.ts +679 -0
  137. package/extension/workerMain.ts +18 -19
  138. package/package.json +6 -4
  139. package/prompts/_fixtures/live.yaml +98 -10
  140. package/prompts/contexts/adapters/plannotator-gist.md +6 -0
  141. package/prompts/contexts/adapters/plannotator-objective.md +6 -0
  142. package/prompts/contexts/adapters/plannotator-plan.md +8 -1
  143. package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
  144. package/prompts/contexts/adapters/tombell-plan.md +4 -0
  145. package/prompts/contexts/objective-refinement.md +17 -0
  146. package/prompts/contexts/plan-authoring.md +6 -5
  147. package/prompts/contexts/read-only.md +1 -1
  148. package/prompts/stages/conflict-resolution-continuation.md +9 -0
  149. package/prompts/stages/conflict-resolution.md +4 -4
  150. package/prompts/stages/objective-author/adopt.md +1 -1
  151. package/prompts/stages/objective-author/file.md +1 -1
  152. package/prompts/stages/objective-author/seed.md +1 -1
  153. package/prompts/stages/objective-plan/guidance.md +2 -2
  154. package/prompts/stages/objective-plan/seed.md +9 -1
  155. package/prompts/stages/objective-reconcile-ready.md +7 -0
  156. package/prompts/stages/objective-reconcile.md +1 -1
  157. package/prompts/stages/objective-refine/seed.md +18 -0
  158. package/prompts/stages/objective-review-browser.md +4 -4
  159. package/prompts/stages/objective-sync.md +1 -1
  160. package/prompts/stages/plan-review-browser.md +4 -4
  161. package/prompts/stages/pr-review-browser/active.md +3 -4
  162. package/prompts/stages/pr-review-browser/foreign.md +3 -4
  163. package/prompts/stages/pr-review-terminal/active.md +3 -3
  164. package/prompts/stages/pr-review-terminal/foreign.md +3 -3
  165. package/prompts/stages/pr-review.md +3 -3
  166. package/prompts/stages/stack-review/cold.md +1 -0
  167. package/prompts/stages/stack-review-browser/stack.md +22 -0
  168. package/shared/README.md +8 -3
  169. package/shared/bindings.yaml +6 -3
  170. package/shared/contracts.md +4499 -2147
  171. package/shared/fixtures/issues-table.json +130 -0
  172. package/shared/registry.yaml +29 -1
  173. package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
  174. package/shared/schemas/outputs/objective-stack-status.schema.json +178 -2
  175. package/shared/schemas/outputs/pr-ready.schema.json +110 -2
  176. package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
  177. package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
  178. package/extension/adapters/planAdapterPlannotator.ts +0 -362
  179. package/extension/doors/auditWaveTools.ts +0 -352
  180. package/extension/doors/ciExecutor.ts +0 -756
  181. package/extension/doors/commitCompact.ts +0 -251
  182. package/extension/doors/dreamWaveTools.ts +0 -475
  183. package/extension/doors/learn.ts +0 -655
  184. package/extension/doors/objectiveStack.ts +0 -1143
  185. package/extension/doors/prReviewDynamic.ts +0 -276
  186. package/extension/doors/ready.ts +0 -87
  187. package/extension/doors/submit.ts +0 -347
  188. package/extension/doors/submitPrReview.ts +0 -408
  189. package/extension/factories/gistAuthor.ts +0 -94
  190. package/extension/factories/gistDraft.ts +0 -265
  191. package/extension/factories/gistSave.ts +0 -251
  192. package/extension/factories/implementHere.ts +0 -116
  193. package/extension/factories/objectiveAuthor.ts +0 -98
  194. package/extension/factories/objectiveDraft.ts +0 -466
  195. package/extension/factories/objectivePlan.ts +0 -975
  196. package/extension/factories/objectiveSave.ts +0 -363
  197. package/extension/factories/planDraft.ts +0 -140
  198. package/extension/factories/planMode.ts +0 -205
  199. package/extension/factories/planReview.ts +0 -1237
  200. package/extension/factories/planSave.ts +0 -604
  201. package/extension/factories/planTitle.ts +0 -141
  202. package/extension/substrate/structuredOutput.ts +0 -202
  203. package/extension/waves/auditWave.ts +0 -312
  204. package/extension/waves/harvestWave.ts +0 -399
  205. package/extension/waves/learnWave.ts +0 -155
  206. package/extension/waves/memoryAdapter.ts +0 -139
  207. package/extension/waves/prReviewDynamicWave.ts +0 -777
  208. package/extension/worker/readOnlySession.ts +0 -294
  209. package/extension/worker/worker.ts +0 -899
  210. package/prompts/stages/pr-review-dynamic.md +0 -7
  211. package/shared/contracts-history.md +0 -605
@@ -0,0 +1,820 @@
1
+ // The `plan_review` door's execute paths — the backend-neutral review dispatcher and the PLAN
2
+ // arm's adapter over the `authoring/plan/` feature. perk's UNIVERSAL plan-review surface: the
3
+ // model calls ONE tool (registered by `pi/v1/plan.ts`, which injects the production dependency
4
+ // bag built by `planSaveDepsFor` — the ONE composition point, so this module never imports
5
+ // `plan.ts` back); this module dispatches to the configured review backend.
6
+ // Plannotator-selected → the event-bus bridge (`createPlannotatorBridge`,
7
+ // providers/plannotator.ts — the AUGMENT-posture path, byte-stable); ANY other selection
8
+ // (perk-plan, tombell, unknown ids) → the FIRST-PARTY in-TUI editor review
9
+ // (`runFirstPartyReview`, pi/v1/review.ts): display the draft in pi's built-in `ctx.ui.editor`
10
+ // dialog (scrollable, Ctrl+G opens the user's external $EDITOR), write optional human edits
11
+ // back to the draft BEFORE the verdict (a failed write-back aborts the review fail-open), then an
12
+ // approve/deny/skip `ctx.ui.select` verdict — on the plan arm with a 4th "Implement here — no
13
+ // issue saved" option (§8.23; suppressed in objective-node planning sessions) — with deny
14
+ // feedback via a second editor dialog.
15
+ //
16
+ // REVIEW SEMANTICS (file-first, approval auto-saves): the review runs while the session is still
17
+ // read-only (the tool is in READ_ONLY_TOOLS — review happens before the gate ever comes off).
18
+ // The reviewed plan resolves FILE-FIRST via `resolvePlanSource` (the validated `plan-draft.md`
19
+ // artifact wins; the `plan` param is the fallback; the transcript scrape is NEVER reviewed — an
20
+ // approval would auto-save scraped conversation bytes, so no draft + no param soft-skips with a
21
+ // `plan_draft` redirect). An APPROVED outcome (either backend) wires into the shared
22
+ // `planApprovalSave` seam (authoring/plan/save.ts): auto-save → D1a gate exit → terminating
23
+ // result, node link recovered from the `objective_node_claim` carrier inside `savePlan`. A DENY
24
+ // returns feedback and directs a `plan_draft` rewrite + re-review. Plannotator's browser
25
+ // "Direct Edits" (a `# Direct Edits` unified diff opening the feedback) are translated INTO the
26
+ // feature's typed `PlanReviewOutcome` at the reviewer adapter below and handled feature-side:
27
+ // the PLAN arm mechanically applies an approved diff (strict apply → draft write-back → save
28
+ // the edited bytes; any failure falls open to the verbatim save + a loud warning); DENY stays
29
+ // model-mediated. Strict on deny, FAIL-OPEN everywhere else: headless / dismissed (Esc anywhere
30
+ // = skip, mirroring ask_user_question's dismissal — deny is always explicit) /
31
+ // backend-unavailable all soft-skip so plan authoring never wedges — those arms keep the
32
+ // present-the-plan + human-`/plan-save` discipline (the manual failsafe).
33
+ //
34
+ // THE LAUNCH CHOOSER (plannotator arms only, §8.23): on an eligible round (injected `WaveLaunch`
35
+ // deps present, plannotator loaded, the review source a validated draft artifact) the tool first
36
+ // asks the human — "Browser review + reviewer wave" vs "Browser review only" — before anything
37
+ // launches. The wave choice collects an optional trimmed custom angle, delegates to the door's
38
+ // guidance-returning open core (openPlanReviewSurface / openObjectiveReviewSurface, injected —
39
+ // never imported: doors value-import the review arms), and returns the NON-terminating
40
+ // `wave_launched` result carrying the door guidance verbatim; the browser decision then routes
41
+ // through the door's background decision task. Esc ⇒ the plain flavor (never a cancel); abort
42
+ // outranks every dialog result AND the awaited opener (an interrupted turn never reports a
43
+ // launched wave); a null opener return (synchronous port-pick failure, loudly reported in the
44
+ // core) falls open to the plain blocking review in the same call.
45
+ //
46
+ // THE OBJECTIVE ARM routes to `pi/v1/objectiveReview.ts`; THE GIST ARM routes to
47
+ // `pi/v1/gist.ts`'s `runGistReviewV1` — direct sibling imports (the injected-arm indirection
48
+ // died with the factories home; `pi/v1` siblings import directly, and none of them import this
49
+ // module back).
50
+ //
51
+ // THE GUARDS (`draftReview.ts`, §8.23): EVERY arm opens the activation's current-review slot at
52
+ // entry (superseding whatever review any other surface had open) and, on a completed verdict,
53
+ // runs the decision ladder — superseded / unconfirmed-save latch / reviewed-bytes / destination
54
+ // fence — before the feature completion saves anything. Every save result reports into the latch.
55
+ //
56
+ // INVARIANTS HELD: never calls `setActiveTools`, never registers a `tool_call` handler, never
57
+ // restamps `cache.plan-ref.provider`. The door composes the gate AND the save EXCLUSIVELY
58
+ // through the feature seams (Invariant 1: composes, never owns).
59
+
60
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
61
+ import { GIST_AUTHOR_STAGE } from "../../authoring/gist/draft.ts";
62
+ import { OBJECTIVE_AUTHOR_STAGE, OBJECTIVE_SAVE_STAGE } from "../../authoring/objective/prose.ts";
63
+ import { resumePlanDraft, revisePlanDraft } from "../../authoring/plan/draft.ts";
64
+ import {
65
+ completePlanReview,
66
+ type PlanDraftReviewer,
67
+ type PlanReviewOutcome,
68
+ type ReviewPlanDraftResult,
69
+ } from "../../authoring/plan/review.ts";
70
+ import type {
71
+ ObjectiveNodeLink,
72
+ PlanApprovalSaveDeps,
73
+ SavePlanOutcome,
74
+ } from "../../authoring/plan/save.ts";
75
+ import { type PlanSource, resolvePlanSource } from "../../authoring/plan/source.ts";
76
+ import { REFINE_STAGE } from "../../authoring/refinement/context.ts";
77
+ import { openBranchWorkflowSession } from "../../session/branchWorkflowSession.ts";
78
+ import { bindingSuffix } from "../../substrate/bindingDelivery.ts";
79
+ import type { PlanRef } from "../../substrate/cache.ts";
80
+ import type { Result } from "../../substrate/result.ts";
81
+ import type { ToolGating } from "../../substrate/toolGating.ts";
82
+ import { paramsOf, stringParam } from "../../substrate/toolParams.ts";
83
+ import { branchOf, rebuildWorkflowState } from "../../substrate/workflowState.ts";
84
+ import { report } from "../../surfaces/report.ts";
85
+ import {
86
+ checkDraftReviewDecision,
87
+ type DecisionCheck,
88
+ type DraftReviewSlot,
89
+ destinationChangedResult,
90
+ type OpenDraftReview,
91
+ openRefusedResult,
92
+ type ReviewSource,
93
+ recordSaveOutcome,
94
+ saveUnconfirmedResult,
95
+ staleApprovalResult,
96
+ supersededReviewResult,
97
+ withDraftChangedNote,
98
+ } from "./draftReview.ts";
99
+ import { runGistReviewV1 } from "./gist.ts";
100
+ import {
101
+ isRefinementSession,
102
+ refinementStageRefusal,
103
+ runRefinementReviewV1,
104
+ } from "./objectiveRefinement.ts";
105
+ import { executeObjectiveReview } from "./objectiveReview.ts";
106
+ import { extractDirectEdits, hasDirectEditsHeading } from "./providers/plannotator.ts";
107
+ import { isPlannotatorPlanSelected } from "./providers/selection.ts";
108
+ import {
109
+ approvedSubjectSaveResult,
110
+ chooseReviewLaunch,
111
+ type DraftReviewBridge,
112
+ PLAN_SUBJECT,
113
+ type ReviewOutcome,
114
+ runFirstPartyReview,
115
+ skipResult,
116
+ subjectReviewOutcomeResult,
117
+ type ToolResult,
118
+ VERDICT_IMPLEMENT_HERE,
119
+ verdictsFor,
120
+ type WaveLaunch,
121
+ waveLaunchedResult,
122
+ } from "./review.ts";
123
+
124
+ /** The review bridge slice the installer builds over the plannotator event bus. */
125
+ export type PlanReviewBridge = DraftReviewBridge;
126
+
127
+ /** The warm-door ok-arm fields — the `details` surface doubles as branch-safe persisted state. */
128
+ export interface PlanSaveOk {
129
+ /** `issue.id` is the opaque string issue id (GitHub "42", Linear "ENG-123") — §8.21. */
130
+ issue: { id: string; url: string };
131
+ plan_ref: PlanRef;
132
+ cached: boolean;
133
+ existed: boolean | null;
134
+ updated: boolean;
135
+ objective_node: ObjectiveNodeLink | null;
136
+ plan_source: PlanSource | null;
137
+ }
138
+
139
+ /**
140
+ * The rendered save result every plan-save surface returns (AgentToolResult has no `isError`;
141
+ * failure is signaled via `details.ok`). Declared HERE with the `renderSave` port it types (the
142
+ * concrete shape must stay visible through `approvalSave` — callers narrow `details.ok` and read
143
+ * `objective_node` WITHOUT assertions); the one production renderer lives in `plan.ts`.
144
+ */
145
+ export type SaveResult = Result<PlanSaveOk>;
146
+
147
+ /**
148
+ * The injected production dependency bag (built by `plan.ts`'s `planSaveDepsFor` — the ONE
149
+ * composition point; declared HERE so `plan.ts` imports the port and the graph stays acyclic):
150
+ * the feature approval-save deps plus the installer-owned save rendering (its message assembly
151
+ * lives with the save surfaces in `plan.ts`).
152
+ */
153
+ export interface PlanReviewV1Deps extends PlanApprovalSaveDeps {
154
+ /** Render a feature `SavePlanOutcome` as the warm-door SaveResult (byte-stable messages). */
155
+ renderSave(save: SavePlanOutcome): SaveResult;
156
+ }
157
+
158
+ // ---------------------------------------------------------------------- plan-flavor mappers
159
+
160
+ /**
161
+ * Map a non-approved review outcome into the model-facing tool result (exported for the offline
162
+ * tests) — the plan flavor of `subjectReviewOutcomeResult`. The execute path routes approved
163
+ * outcomes to `approvedSaveResult` first, so `completed` renders DENIED here.
164
+ */
165
+ export function reviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
166
+ return subjectReviewOutcomeResult(PLAN_SUBJECT, outcome);
167
+ }
168
+
169
+ /**
170
+ * The approval→save orchestration outcome as the ADAPTER renders it: the feature's
171
+ * `PlanApprovalSaveOutcome` with the save already rendered as the warm-door Result (the browser
172
+ * door consumes this shape via the `approvalSave` seam in `plan.ts`).
173
+ */
174
+ export type ApprovalSaveOutcome =
175
+ | { status: "no-plan" }
176
+ | { status: "saved" | "save-failed"; result: SaveResult; gateExited: boolean };
177
+
178
+ /**
179
+ * Map an APPROVED review outcome + the `approvalSave` outcome into the model-facing tool result
180
+ * (exported for the offline tests) — the plan flavor of `approvedSubjectSaveResult`. `edited`
181
+ * flags that human edits were written back to the draft pre-verdict (the first-party editor, or
182
+ * the plannotator Direct Edits auto-apply), so the saved bytes carry them. `directEditsFailed`
183
+ * (plannotator-only, optional — absent keeps every existing call site byte-stable) flags a
184
+ * Direct Edits section that could not be honored: the plan saved verbatim, a loud warning added.
185
+ */
186
+ export function approvedSaveResult(
187
+ outcome: Extract<ReviewOutcome, { status: "completed" }>,
188
+ save: ApprovalSaveOutcome,
189
+ opts: { paramMismatch: boolean; edited?: boolean; directEditsFailed?: boolean },
190
+ ): ToolResult {
191
+ return approvedSubjectSaveResult(
192
+ PLAN_SUBJECT,
193
+ outcome,
194
+ save.status === "no-plan" ? { status: "no-source" } : save,
195
+ opts,
196
+ );
197
+ }
198
+
199
+ // ------------------------------------------------------------------- the implement-here seam
200
+
201
+ /** The core instruction text (exported for the offline content pins). */
202
+ export const IMPLEMENT_HERE_GUIDANCE = `The human chose IMPLEMENT HERE: implement the reviewed plan directly in this session — no plan issue was created and none will be.
203
+
204
+ - The read-only gate is off: make the plan's edits now, in this checkout.
205
+ - Run the checks the plan calls for before declaring done.
206
+ - Do NOT commit, branch, or push unless the user explicitly asks — git gestures stay with the human.
207
+ - perk's lifecycle doors (/submit, /land, /learn) do not apply — there is no plan issue or plan-ref.
208
+ - The plan draft artifact is untouched: /plan-save can still create the canonical issue later.`;
209
+
210
+ /** The one info line every implement-here gate exit reports (the seam + the review arm share it). */
211
+ const IMPLEMENT_HERE_EXIT_NOTICE =
212
+ "plan mode off — implementing here; no issue saved (draft intact; /plan-save can still create it)";
213
+
214
+ /**
215
+ * Build the implement-here instruction text. When `editedPlan` is set (review-path human edits
216
+ * were written back to the draft pre-verdict), the final reviewed bytes are inlined so the model
217
+ * implements THOSE, not its stale in-context version. Always appends the Mechanism-B skill-binding
218
+ * suffix for `command:implement-here` (a `[[bindings]]` hook; delivers nothing by default).
219
+ */
220
+ export function implementHereGuidance(cwd: string, opts: { editedPlan?: string }): string {
221
+ const edited =
222
+ opts.editedPlan === undefined
223
+ ? ""
224
+ : `\n\nThe human edited the plan during review; implement THESE final bytes:\n\n${opts.editedPlan}`;
225
+ return `${IMPLEMENT_HERE_GUIDANCE}${edited}${bindingSuffix(cwd, "command:implement-here")}`;
226
+ }
227
+
228
+ /**
229
+ * The gate-exit-WITHOUT-save seam — the no-save sibling of `planApprovalSave`'s D1a arm. If the
230
+ * gate is active, exit it and report one info line; otherwise a no-op. Keeps Invariant 1:
231
+ * callers (the review arm's implement-here verdict, the `/implement-here` command) compose the
232
+ * gate through this seam, never own it.
233
+ */
234
+ export function implementHereExit(
235
+ ctx: ExtensionContext,
236
+ gating: ToolGating,
237
+ ): { gateExited: boolean } {
238
+ if (!gating.isActive()) return { gateExited: false };
239
+ gating.exit(ctx);
240
+ report(ctx, "implement-here", "info", IMPLEMENT_HERE_EXIT_NOTICE);
241
+ return { gateExited: true };
242
+ }
243
+
244
+ /**
245
+ * Map an IMPLEMENT-HERE review outcome + the gate-exit outcome into the model-facing
246
+ * tool result (exported for the offline tests). NON-terminating on purpose — the model continues
247
+ * the turn and implements immediately. The text is the implement-here guidance; when the human
248
+ * edited the plan during review (`edited`), the final reviewed bytes are inlined so the model
249
+ * implements THOSE, not its stale in-context version (the draft write-back already happened
250
+ * pre-verdict). Nothing is saved: no issue, no plan-ref, the draft artifact intact (§8.23).
251
+ */
252
+ export function implementHereResult(
253
+ outcome: Extract<ReviewOutcome, { status: "implement-here" }>,
254
+ exit: { gateExited: boolean },
255
+ opts: { cwd: string; plan: string; edited: boolean },
256
+ ): ToolResult {
257
+ return {
258
+ content: [
259
+ {
260
+ type: "text",
261
+ text: implementHereGuidance(opts.cwd, { editedPlan: opts.edited ? opts.plan : undefined }),
262
+ },
263
+ ],
264
+ details: {
265
+ ok: true,
266
+ status: "implement-here",
267
+ saved: false,
268
+ gateExited: exit.gateExited,
269
+ reviewId: outcome.reviewId,
270
+ ...(opts.edited ? { edited: true } : {}),
271
+ },
272
+ };
273
+ }
274
+
275
+ /**
276
+ * The `/implement-here` command body (the registration stays in `plan.ts`; the handler lives
277
+ * HERE, next to the seam it composes — and so the `sendUserMessage` call sites stay out of the
278
+ * installer file, whose registration prose the prose-review workbench edits through the
279
+ * TypeScript source adapter's whole-file validation). Three arms: (1) an objective-node
280
+ * planning session refuses — an implement-here would strand the node in `planning` (the claim
281
+ * is only cleared by a node-linked save or a non-planning transition); gate untouched, nothing
282
+ * injected. (2) Nothing to exit — the command's meaning is *exiting plan mode without saving*.
283
+ * (3) Gate off → instruct the model. No inlined plan: the model authored the draft in its own
284
+ * context (the review-path edited-bytes inlining is `implementHereResult`'s arm).
285
+ */
286
+ export async function runImplementHereCommand(
287
+ pi: ExtensionAPI,
288
+ ctx: ExtensionContext,
289
+ gating: ToolGating,
290
+ slot: DraftReviewSlot,
291
+ ): Promise<void> {
292
+ // A refinement session has no plan to implement: refuse before any effect — the gate toggle
293
+ // never makes an old plan draft routable.
294
+ if (isRefinementSession(branchOf(ctx))) {
295
+ report(ctx, "implement-here", "warning", refinementStageRefusal("/implement-here"));
296
+ return;
297
+ }
298
+ // The claim read rides the session seam (the one workflow-state owner) — the command has no
299
+ // injected deps bag, so it opens the branch-backed session the production composition uses.
300
+ if (openBranchWorkflowSession(pi, ctx).nodeClaim() !== null) {
301
+ report(
302
+ ctx,
303
+ "implement-here",
304
+ "warning",
305
+ "this is an objective-node planning session — a node-linked plan must be saved " +
306
+ "(the node advance and backlink depend on it). Use plan_review / /plan-save instead.",
307
+ );
308
+ return;
309
+ }
310
+ if (!gating.isActive()) {
311
+ report(
312
+ ctx,
313
+ "implement-here",
314
+ "warning",
315
+ "not in plan mode — nothing to exit; just ask the model to implement.",
316
+ );
317
+ return;
318
+ }
319
+ // Exiting plan mode without saving retires whatever review is open: a later browser decision
320
+ // for it is ignored (loudly) rather than saving a plan the human chose not to save.
321
+ slot.supersede();
322
+ implementHereExit(ctx, gating);
323
+ const message = implementHereGuidance(ctx.cwd, {});
324
+ if (ctx.isIdle()) {
325
+ pi.sendUserMessage(message);
326
+ } else {
327
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
328
+ }
329
+ }
330
+
331
+ /**
332
+ * The defensive refusal arm (the feature's gate-safety invariant surfacing): an implement-here
333
+ * verdict reached the execute path in an objective-node planning session — a node-linked plan
334
+ * must save (the node advance and backlink depend on it). Loud, NON-terminating: nothing saved,
335
+ * the gate untouched.
336
+ */
337
+ function implementHereRefusedResult(): ToolResult {
338
+ const error =
339
+ "this is an objective-node planning session — a node-linked plan must be saved " +
340
+ "(the node advance and backlink depend on it)";
341
+ return {
342
+ content: [
343
+ {
344
+ type: "text",
345
+ text:
346
+ `WARNING: implement-here refused — ${error}. Nothing was saved and the session stays ` +
347
+ "read-only; approve the plan via plan_review or ask the user to run /plan-save.",
348
+ },
349
+ ],
350
+ details: {
351
+ ok: false,
352
+ error,
353
+ error_type: "implement_here_refused",
354
+ status: "skipped",
355
+ reason: "implement_here_refused",
356
+ },
357
+ };
358
+ }
359
+
360
+ // -------------------------------------------------------------------- the reviewer adapters
361
+
362
+ /** Rebuild the door's completed outcome from carried facts (the mappers consume it). */
363
+ function completedOutcome(
364
+ approved: boolean,
365
+ carried: { feedback?: string; reviewId?: string },
366
+ ): Extract<ReviewOutcome, { status: "completed" }> {
367
+ return {
368
+ status: "completed",
369
+ approved,
370
+ ...(carried.feedback !== undefined ? { feedback: carried.feedback } : {}),
371
+ reviewId: carried.reviewId ?? "",
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Translate a bridge outcome (`ReviewOutcome`) into the feature's `PlanReviewOutcome` —
377
+ * provider vocabulary dies HERE: an approval whose feedback opens with `# Direct Edits`
378
+ * becomes `approvedDirectEdits` (extractable diff + remainder) or `approvedEditsUnparseable`
379
+ * (seen heading, unextractable body — the fail-open-but-loud arm); DENY passes the raw
380
+ * feedback (diff included) through for the model-mediated `plan_draft` rewrite.
381
+ */
382
+ function planOutcomeOf(outcome: ReviewOutcome): PlanReviewOutcome {
383
+ switch (outcome.status) {
384
+ case "completed": {
385
+ const carried = {
386
+ ...(outcome.reviewId !== "" ? { reviewId: outcome.reviewId } : {}),
387
+ };
388
+ if (!outcome.approved) {
389
+ return {
390
+ status: "denied",
391
+ ...(outcome.feedback !== undefined ? { feedback: outcome.feedback } : {}),
392
+ ...carried,
393
+ };
394
+ }
395
+ if (outcome.feedback !== undefined) {
396
+ const section = extractDirectEdits(outcome.feedback);
397
+ if (section !== null) {
398
+ return {
399
+ status: "approvedDirectEdits",
400
+ diff: section.diff,
401
+ ...(section.remainder !== undefined ? { remainder: section.remainder } : {}),
402
+ rawFeedback: outcome.feedback,
403
+ ...carried,
404
+ };
405
+ }
406
+ if (hasDirectEditsHeading(outcome.feedback)) {
407
+ return { status: "approvedEditsUnparseable", rawFeedback: outcome.feedback, ...carried };
408
+ }
409
+ return { status: "approved", feedback: outcome.feedback, ...carried };
410
+ }
411
+ return { status: "approved", ...carried };
412
+ }
413
+ case "implement-here":
414
+ // Unreachable from the bridge (its browser envelope returns only approve/deny) — kept
415
+ // total; the feature's allowImplementHere routing owns the safety either way.
416
+ return { status: "implementHere", reviewId: outcome.reviewId };
417
+ case "unavailable":
418
+ return { status: "unavailable", warning: outcome.warning };
419
+ case "aborted":
420
+ return { status: "aborted" };
421
+ case "dismissed":
422
+ return { status: "dismissed" };
423
+ }
424
+ }
425
+
426
+ /**
427
+ * The first-party reviewer adapter: the in-TUI editor review with the draft write-back bound to
428
+ * the session seam (edits land BEFORE the verdict — a failed write-back is the `unavailable`
429
+ * abort inside the core). The 4th verdict (implement-here, the no-save exit) is offered UNLESS
430
+ * this is an objective-node planning session — a node-linked plan must save (the node advance
431
+ * and backlink depend on it), so the claim suppresses it back to the 3-option select (the UX
432
+ * layer; the feature's `allowImplementHere` refusal is the structural backstop).
433
+ */
434
+ function firstPartyPlanReviewer(
435
+ ctx: ExtensionContext,
436
+ deps: PlanReviewV1Deps,
437
+ nodeClaimed: boolean,
438
+ ): PlanDraftReviewer {
439
+ return {
440
+ async review(plan, signal) {
441
+ const fp = await runFirstPartyReview({
442
+ ui: ctx.ui,
443
+ plan,
444
+ writeDraft: (text) => {
445
+ const written = revisePlanDraft({ plan: text }, deps.session);
446
+ return written.status === "revised" || written.status === "unchanged";
447
+ },
448
+ ...(signal !== undefined ? { signal } : {}),
449
+ ...(nodeClaimed
450
+ ? {}
451
+ : {
452
+ verdicts: { ...verdictsFor(PLAN_SUBJECT), implementHere: VERDICT_IMPLEMENT_HERE },
453
+ }),
454
+ });
455
+ return { outcome: planOutcomeOf(fp.outcome), plan: fp.plan, edited: fp.edited };
456
+ },
457
+ };
458
+ }
459
+
460
+ /** Whether a feature outcome is an approval (the arms whose completion saves). */
461
+ function isApproval(outcome: PlanReviewOutcome): boolean {
462
+ return (
463
+ outcome.status === "approved" ||
464
+ outcome.status === "approvedDirectEdits" ||
465
+ outcome.status === "approvedEditsUnparseable"
466
+ );
467
+ }
468
+
469
+ /** The reviewer feedback an approval carries (for the no-save results' DATA suffix). */
470
+ function approvalFeedback(outcome: PlanReviewOutcome): string | undefined {
471
+ switch (outcome.status) {
472
+ case "approved":
473
+ case "denied":
474
+ return outcome.feedback;
475
+ case "approvedDirectEdits":
476
+ case "approvedEditsUnparseable":
477
+ return outcome.rawFeedback;
478
+ default:
479
+ return undefined;
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Render a non-`proceed` ladder verdict as the plan arm's tool result (nothing saved; the gate
485
+ * untouched). Shared by the Plannotator arm, the first-party arm and the browser door.
486
+ */
487
+ export function planGuardResult(
488
+ check: Exclude<DecisionCheck, { kind: "proceed" }>,
489
+ review: OpenDraftReview,
490
+ feedback: string | undefined,
491
+ ): ToolResult {
492
+ switch (check.kind) {
493
+ case "superseded":
494
+ return supersededReviewResult("plan");
495
+ case "save-unconfirmed":
496
+ return saveUnconfirmedResult("plan", review.runId, check.detail, feedback);
497
+ case "stale-approval":
498
+ return staleApprovalResult("plan", check.reviewedDigest, feedback);
499
+ case "destination-changed":
500
+ return destinationChangedResult("plan", check.changed, feedback);
501
+ }
502
+ }
503
+
504
+ /** Report a completed plan review's save result into the unconfirmed-save latch. */
505
+ function recordPlanSaveOutcome(slot: DraftReviewSlot, result: ReviewPlanDraftResult): void {
506
+ switch (result.status) {
507
+ case "approvedSaved":
508
+ recordSaveOutcome(slot, "plan", { confirmed: true });
509
+ return;
510
+ case "approvedSaveFailed":
511
+ recordSaveOutcome(slot, "plan", { confirmed: false, detail: result.save.result.message });
512
+ return;
513
+ default:
514
+ // Denials, Direct Edits and the no-plan arm never reach the backend — no attempt to confirm.
515
+ return;
516
+ }
517
+ }
518
+
519
+ // ------------------------------------------------------------------------- the execute paths
520
+
521
+ /** The plan arm's no-source soft skip (byte-stable redirect). */
522
+ function noPlanResult(): ToolResult {
523
+ return {
524
+ content: [
525
+ {
526
+ type: "text",
527
+ text:
528
+ "no plan to review — write the working draft with plan_draft (or pass the plan " +
529
+ "param), then call plan_review again.",
530
+ },
531
+ ],
532
+ details: {
533
+ ok: false,
534
+ error: "no plan to review — write the draft with plan_draft first",
535
+ error_type: "no_plan",
536
+ status: "skipped",
537
+ reason: "no_plan",
538
+ },
539
+ };
540
+ }
541
+
542
+ /**
543
+ * The plan arm: headless skip → file-first resolution skip (`no_plan`) → the launch chooser
544
+ * (plannotator + drafts-only eligibility, abort-outranks-everything ordering) → the slot open
545
+ * (every arm; the source names how the reviewed bytes were obtained) → reviewer dispatch: the
546
+ * Plannotator bridge judges the reviewed bytes verbatim; the first-party editor review writes
547
+ * human edits back before the verdict → the decision ladder on a completed verdict → the
548
+ * feature completion (`completePlanReview`: the Direct-Edits apply ladder and the D1a approval
549
+ * save) → result rendering, with the save reported into the latch.
550
+ * (No `pi`/`gating` parameters: every effect rides `ctx` or the injected deps bag — the gate
551
+ * is `deps.gate`, composed once in `plan.ts`.)
552
+ */
553
+ export async function runPlanReviewV1(
554
+ ctx: ExtensionContext,
555
+ bridge: PlanReviewBridge,
556
+ slot: DraftReviewSlot,
557
+ deps: PlanReviewV1Deps,
558
+ plan: string | undefined,
559
+ signal?: AbortSignal,
560
+ wave?: WaveLaunch,
561
+ ): Promise<ToolResult> {
562
+ // 1. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
563
+ if (!ctx.hasUI) return skipResult();
564
+ // 2. File-first resolution: artifact → param, NEVER transcript — an approval
565
+ // auto-saves the reviewed bytes, and scraped conversation bytes must never be those.
566
+ const src = resolvePlanSource(
567
+ {
568
+ draft: resumePlanDraft(deps.session),
569
+ ...(plan !== undefined ? { explicit: plan } : {}),
570
+ },
571
+ "review",
572
+ );
573
+ if (src === null) return noPlanResult();
574
+ const sig = signal ?? ctx.signal;
575
+ // The claim read rides the injected session (the seam owns workflow-state reads — another
576
+ // backing must never disagree with a raw branch read here).
577
+ const nodeClaimed = deps.session.nodeClaim() !== null;
578
+ const sourceTier = src.source === "plan-draft" ? "plan-draft" : "param";
579
+ const completion = { source: sourceTier, paramMismatch: src.paramMismatch } as const;
580
+ // 3. Backend dispatch: plannotator-selected → the event-bus bridge; ANY other selection
581
+ // (perk-plan, tombell, unknown ids) → the first-party in-TUI editor review.
582
+ if (isPlannotatorPlanSelected(ctx.cwd)) {
583
+ // The launch chooser (contracts.md §8.23): every eligible round the human picks with/without
584
+ // the streamed reviewer wave BEFORE anything launches. Eligibility is drafts-only — the wave
585
+ // door reviews and stale-guards the validated artifact, so a param-tier source keeps the
586
+ // plain path (silently: there is no forced mode to warn about; the `wave === undefined` arm
587
+ // is defensive/test-only and behaves identically).
588
+ if (wave?.present() && src.source === "plan-draft") {
589
+ const choice = await chooseReviewLaunch(ctx.ui, "Plan", sig);
590
+ if (choice.launch === "aborted") return reviewOutcomeResult({ status: "aborted" });
591
+ if (choice.launch === "wave") {
592
+ const guidance = await wave.plan(ctx, {
593
+ draft: src.plan,
594
+ ...(choice.custom !== undefined ? { custom: choice.custom } : {}),
595
+ });
596
+ // Abort outranks the opener result too: a turn interrupted during the awaited open must
597
+ // never report a successful launch (the door's own bridge abort handling settles the
598
+ // background tasks and clears the primed surfaces).
599
+ if (sig?.aborted) return reviewOutcomeResult({ status: "aborted" });
600
+ if (guidance !== null) return waveLaunchedResult(PLAN_SUBJECT, guidance);
601
+ // null = the synchronous port-pick failure (already loudly reported inside the core) —
602
+ // fall open to the plain blocking review in the same call: the review never wedges.
603
+ }
604
+ }
605
+ if (sig?.aborted) return reviewOutcomeResult({ status: "aborted" });
606
+ const source: ReviewSource = src.source === "plan-draft" ? "artifact" : "parameter";
607
+ const opened = slot.open(ctx, { subject: "plan", source, raw: src.plan, markdown: src.plan });
608
+ if (!opened.ok) return openRefusedResult(opened);
609
+ const review = opened.review;
610
+ // Browser edits arrive as the Direct Edits diff ON the outcome (applied feature-side); the
611
+ // reviewed bytes ride through unchanged.
612
+ const outcome = await bridge.review(review.markdown, sig);
613
+ if (sig?.aborted) return reviewOutcomeResult({ status: "aborted" });
614
+ if (outcome.status === "implement-here") return implementHereRefusedResult();
615
+ if (outcome.status !== "completed") return reviewOutcomeResult(outcome);
616
+ const check = checkDraftReviewDecision(
617
+ slot,
618
+ ctx,
619
+ review,
620
+ outcome.approved ? "save" : "revision",
621
+ );
622
+ if (check.kind !== "proceed") return planGuardResult(check, review, outcome.feedback);
623
+ return withDraftChangedNote(
624
+ await completePlanReviewV1(ctx, slot, deps, review.markdown, outcome, completion),
625
+ check.draftChanged,
626
+ );
627
+ }
628
+
629
+ // The first-party arm: the human's own edit write-back is the one legitimate draft change
630
+ // during a modal review, so the source is `editor` (no byte compare); the destination fence
631
+ // and the latch still apply to an approval.
632
+ const opened = slot.open(ctx, {
633
+ subject: "plan",
634
+ source: "editor",
635
+ raw: src.plan,
636
+ markdown: src.plan,
637
+ });
638
+ if (!opened.ok) return openRefusedResult(opened);
639
+ const review = opened.review;
640
+ if (sig?.aborted) return reviewOutcomeResult({ status: "aborted" });
641
+ const reviewed = await firstPartyPlanReviewer(ctx, deps, nodeClaimed).review(src.plan, sig);
642
+ if (sig?.aborted) return reviewOutcomeResult({ status: "aborted" });
643
+ if (isApproval(reviewed.outcome) || reviewed.outcome.status === "denied") {
644
+ const check = checkDraftReviewDecision(
645
+ slot,
646
+ ctx,
647
+ review,
648
+ isApproval(reviewed.outcome) ? "save" : "revision",
649
+ );
650
+ if (check.kind !== "proceed")
651
+ return planGuardResult(check, review, approvalFeedback(reviewed.outcome));
652
+ }
653
+ const result = await completePlanReview(
654
+ { ...deps, allowImplementHere: !nodeClaimed },
655
+ reviewed,
656
+ completion,
657
+ );
658
+ recordPlanSaveOutcome(slot, result);
659
+ return renderPlanReviewResult(ctx, deps, result);
660
+ }
661
+
662
+ /** Render the feature's review result as the model-facing tool result (byte-stable texts). */
663
+ export function renderPlanReviewResult(
664
+ ctx: ExtensionContext,
665
+ deps: PlanReviewV1Deps,
666
+ result: ReviewPlanDraftResult,
667
+ ): ToolResult {
668
+ switch (result.status) {
669
+ case "noPlan":
670
+ return noPlanResult();
671
+ case "approvedSaved":
672
+ return approvedSaveResult(
673
+ completedOutcome(true, result),
674
+ {
675
+ status: "saved",
676
+ result: deps.renderSave(result.save.result),
677
+ gateExited: result.save.gateExited,
678
+ },
679
+ {
680
+ paramMismatch: result.paramMismatch,
681
+ edited: result.edited,
682
+ directEditsFailed: result.directEditsFailed,
683
+ },
684
+ );
685
+ case "approvedSaveFailed":
686
+ return approvedSaveResult(
687
+ completedOutcome(true, result),
688
+ {
689
+ status: "save-failed",
690
+ result: deps.renderSave(result.save.result),
691
+ gateExited: false,
692
+ },
693
+ {
694
+ paramMismatch: result.paramMismatch,
695
+ edited: result.edited,
696
+ directEditsFailed: result.directEditsFailed,
697
+ },
698
+ );
699
+ case "approvedNoPlan":
700
+ // Defensively unreachable (the reviewed source is always non-blank) — the save-failed
701
+ // shape with the no-source error, never a throw.
702
+ return approvedSaveResult(
703
+ completedOutcome(true, result),
704
+ { status: "no-plan" },
705
+ {
706
+ paramMismatch: result.paramMismatch,
707
+ edited: result.edited,
708
+ directEditsFailed: result.directEditsFailed,
709
+ },
710
+ );
711
+ case "implementHere": {
712
+ // The feature exited the gate through the seam; surface the one info line the manual
713
+ // `/implement-here` exit always reported (byte-identical notice, same scope/severity).
714
+ if (result.gateExited) report(ctx, "implement-here", "info", IMPLEMENT_HERE_EXIT_NOTICE);
715
+ return implementHereResult(
716
+ { status: "implement-here", reviewId: result.reviewId },
717
+ { gateExited: result.gateExited },
718
+ { cwd: ctx.cwd, plan: result.plan, edited: result.edited },
719
+ );
720
+ }
721
+ case "implementHereRefused":
722
+ return implementHereRefusedResult();
723
+ case "denied":
724
+ return reviewOutcomeResult(completedOutcome(false, result));
725
+ case "dismissed":
726
+ return reviewOutcomeResult({ status: "dismissed" });
727
+ case "aborted":
728
+ return reviewOutcomeResult({ status: "aborted" });
729
+ case "unavailable":
730
+ return reviewOutcomeResult({ status: "unavailable", warning: result.warning });
731
+ }
732
+ }
733
+
734
+ /**
735
+ * The `plan_review` execute core — the STAGE DISPATCHER (exported for the offline tests). Arm
736
+ * order: param decode → the objective arm (`executeObjectiveReview` — the rendered objective
737
+ * draft is the review subject in BOTH objective-authoring stages) → the gist arm
738
+ * (`runGistReviewV1` — the rendered gist draft) → the refinement arm (`runRefinementReviewV1`
739
+ * — the rendered (draft, context) pair) → the plan arm (`runPlanReviewV1`).
740
+ */
741
+ export async function executePlanReview(
742
+ pi: ExtensionAPI,
743
+ ctx: ExtensionContext,
744
+ gating: ToolGating,
745
+ bridge: PlanReviewBridge,
746
+ slot: DraftReviewSlot,
747
+ deps: PlanReviewV1Deps,
748
+ params: unknown,
749
+ signal?: AbortSignal,
750
+ wave?: WaveLaunch,
751
+ ): Promise<ToolResult> {
752
+ // Tool-boundary decode, in this tool's native fail-open vocabulary: a MISTYPED
753
+ // `plan` (or non-object params) skip-shapes (`reason: "bad_input"`) without reviewing; an
754
+ // ABSENT `plan` proceeds — the validated draft artifact is the preferred source.
755
+ const p = paramsOf(params);
756
+ const plan = p === null ? null : stringParam(p, "plan");
757
+ if (plan === null) {
758
+ return {
759
+ content: [
760
+ {
761
+ type: "text",
762
+ text: "plan_review takes { plan?: string } — omit it (the plan-draft artifact is preferred) or pass a string.",
763
+ },
764
+ ],
765
+ details: {
766
+ ok: false,
767
+ error: "plan must be a string",
768
+ error_type: "bad_input",
769
+ status: "skipped",
770
+ reason: "bad_input",
771
+ },
772
+ };
773
+ }
774
+ // An objective-authoring session (objective-author OR objective-save — both stages'
775
+ // working draft is the objective draft, and neither carries `plan_draft`) → the objective
776
+ // review arm: the rendered objective draft is the sole review source; a well-typed `plan`
777
+ // param is ignored here — the plan-arm fallthrough could otherwise review/save an
778
+ // unrelated plan param from an objective session. A gist-author session likewise routes to
779
+ // the gist arm (the rendered gist draft).
780
+ const launchedStage = rebuildWorkflowState(branchOf(ctx)).stage;
781
+ if (launchedStage === OBJECTIVE_AUTHOR_STAGE || launchedStage === OBJECTIVE_SAVE_STAGE) {
782
+ return executeObjectiveReview(pi, ctx, gating, bridge, slot, signal ?? ctx.signal, wave);
783
+ }
784
+ if (launchedStage === GIST_AUTHOR_STAGE) {
785
+ return runGistReviewV1(pi, ctx, gating, bridge, slot, signal ?? ctx.signal);
786
+ }
787
+ // A refinement session routes to the refinement arm BEFORE the plan arm: the validated
788
+ // (draft, context) pair is the sole source; a well-typed `plan` param is ignored (the plan
789
+ // fallthrough could otherwise review/save an unrelated plan from a refinement session).
790
+ if (launchedStage === REFINE_STAGE) {
791
+ return runRefinementReviewV1(pi, ctx, gating, bridge, slot, signal ?? ctx.signal);
792
+ }
793
+ return runPlanReviewV1(ctx, bridge, slot, deps, plan, signal, wave);
794
+ }
795
+
796
+ /**
797
+ * The plan completion shared by the Plannotator tool arm and the browser door: a completed
798
+ * bridge outcome over the reviewed bytes → the feature completion (Direct-Edits apply ladder,
799
+ * D1a approval save; implement-here never allowed here — the bridge cannot produce it) → the
800
+ * latch record → the rendered tool result. The caller has already run the decision ladder.
801
+ */
802
+ export async function completePlanReviewV1(
803
+ ctx: ExtensionContext,
804
+ slot: DraftReviewSlot,
805
+ deps: PlanReviewV1Deps,
806
+ markdown: string,
807
+ outcome: Extract<ReviewOutcome, { status: "completed" }>,
808
+ completion: { source: "plan-draft" | "param"; paramMismatch: boolean } = {
809
+ source: "plan-draft",
810
+ paramMismatch: false,
811
+ },
812
+ ): Promise<ToolResult> {
813
+ const result = await completePlanReview(
814
+ { ...deps, allowImplementHere: false },
815
+ { outcome: planOutcomeOf(outcome), plan: markdown, edited: false },
816
+ completion,
817
+ );
818
+ recordPlanSaveOutcome(slot, result);
819
+ return renderPlanReviewResult(ctx, deps, result);
820
+ }