@mgiles/perk 3.2.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 (202) 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 +365 -380
  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 -311
  49. package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +53 -33
  50. package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
  51. package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +146 -114
  52. package/extension/{doors/stackReviewBrowser.ts → pi/v1/codeReview/stack.ts} +62 -29
  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 +154 -267
  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 +123 -61
  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/pi/v1/lifecycleGates.ts +127 -0
  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/{factories/objectivePlan.ts → pi/v1/objectivePlanning.ts} +328 -533
  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 +73 -27
  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 -21
  112. package/extension/substrate/git.ts +88 -6
  113. package/extension/substrate/modelVisible.ts +53 -0
  114. package/extension/substrate/prompts.ts +22 -0
  115. package/extension/substrate/registry.ts +2 -0
  116. package/extension/substrate/resolverLease.ts +5 -4
  117. package/extension/substrate/sessionData.ts +85 -152
  118. package/extension/substrate/toolGating.ts +263 -84
  119. package/extension/substrate/unifiedDiff.ts +1 -1
  120. package/extension/substrate/workflowState.ts +178 -163
  121. package/extension/substrate/worktreeResolverLock.ts +261 -0
  122. package/extension/surfaces/surfaces.ts +79 -27
  123. package/extension/waves/adversarialReviewWave.ts +87 -46
  124. package/extension/waves/blockedReports.ts +59 -0
  125. package/extension/waves/draftReviewWave.ts +42 -42
  126. package/extension/waves/laneIdentity.ts +77 -0
  127. package/extension/waves/objectiveExplorerWave.ts +24 -24
  128. package/extension/waves/prReviewWave.ts +89 -77
  129. package/extension/waves/reportWave.ts +438 -578
  130. package/extension/waves/reviewClassifierWave.ts +22 -22
  131. package/extension/waves/rpcAdapter.ts +100 -15
  132. package/extension/waves/scoutWave.ts +192 -0
  133. package/extension/waves/transport.ts +480 -0
  134. package/extension/worker/sdkAdapter.ts +494 -0
  135. package/extension/worker/stageExecution.ts +679 -0
  136. package/extension/workerMain.ts +18 -19
  137. package/package.json +6 -4
  138. package/prompts/_fixtures/live.yaml +43 -18
  139. package/prompts/contexts/adapters/plannotator-gist.md +6 -0
  140. package/prompts/contexts/adapters/plannotator-objective.md +6 -0
  141. package/prompts/contexts/adapters/plannotator-plan.md +8 -1
  142. package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
  143. package/prompts/contexts/objective-refinement.md +17 -0
  144. package/prompts/contexts/read-only.md +1 -1
  145. package/prompts/stages/conflict-resolution-continuation.md +9 -6
  146. package/prompts/stages/conflict-resolution.md +4 -4
  147. package/prompts/stages/objective-plan/guidance.md +2 -2
  148. package/prompts/stages/objective-plan/seed.md +9 -1
  149. package/prompts/stages/objective-reconcile-ready.md +1 -1
  150. package/prompts/stages/objective-reconcile.md +1 -1
  151. package/prompts/stages/objective-refine/seed.md +18 -0
  152. package/prompts/stages/objective-review-browser.md +4 -4
  153. package/prompts/stages/objective-sync.md +1 -1
  154. package/prompts/stages/plan-review-browser.md +4 -4
  155. package/prompts/stages/pr-review-browser/active.md +3 -4
  156. package/prompts/stages/pr-review-browser/foreign.md +3 -4
  157. package/prompts/stages/pr-review-terminal/active.md +3 -3
  158. package/prompts/stages/pr-review-terminal/foreign.md +3 -3
  159. package/prompts/stages/pr-review.md +3 -3
  160. package/prompts/stages/stack-review-browser/stack.md +5 -6
  161. package/shared/README.md +8 -0
  162. package/shared/bindings.yaml +3 -3
  163. package/shared/contracts.md +2601 -506
  164. package/shared/fixtures/issues-table.json +130 -0
  165. package/shared/registry.yaml +13 -0
  166. package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
  167. package/shared/schemas/outputs/objective-stack-status.schema.json +6 -1
  168. package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
  169. package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
  170. package/extension/adapters/planAdapterPlannotator.ts +0 -362
  171. package/extension/doors/auditWaveTools.ts +0 -352
  172. package/extension/doors/ciExecutor.ts +0 -756
  173. package/extension/doors/commitCompact.ts +0 -251
  174. package/extension/doors/dreamWaveTools.ts +0 -489
  175. package/extension/doors/learn.ts +0 -668
  176. package/extension/doors/lifecycleGates.ts +0 -207
  177. package/extension/doors/objectiveStack.ts +0 -1543
  178. package/extension/doors/prReviewDynamic.ts +0 -276
  179. package/extension/doors/ready.ts +0 -279
  180. package/extension/doors/submit.ts +0 -373
  181. package/extension/doors/submitPrReview.ts +0 -505
  182. package/extension/factories/gistAuthor.ts +0 -94
  183. package/extension/factories/gistDraft.ts +0 -265
  184. package/extension/factories/gistSave.ts +0 -251
  185. package/extension/factories/implementHere.ts +0 -116
  186. package/extension/factories/objectiveAuthor.ts +0 -98
  187. package/extension/factories/objectiveDraft.ts +0 -466
  188. package/extension/factories/objectiveSave.ts +0 -366
  189. package/extension/factories/planDraft.ts +0 -140
  190. package/extension/factories/planMode.ts +0 -205
  191. package/extension/factories/planReview.ts +0 -1237
  192. package/extension/factories/planSave.ts +0 -604
  193. package/extension/factories/planTitle.ts +0 -141
  194. package/extension/substrate/structuredOutput.ts +0 -202
  195. package/extension/waves/auditWave.ts +0 -312
  196. package/extension/waves/harvestWave.ts +0 -399
  197. package/extension/waves/learnWave.ts +0 -155
  198. package/extension/waves/memoryAdapter.ts +0 -139
  199. package/extension/waves/prReviewDynamicWave.ts +0 -777
  200. package/extension/worker/readOnlySession.ts +0 -294
  201. package/extension/worker/worker.ts +0 -899
  202. package/prompts/stages/pr-review-dynamic.md +0 -7
@@ -0,0 +1,812 @@
1
+ // The v1 Pi installer for the plan feature (module-contracts.md's named-installer shape):
2
+ // `installPlanBindings` owns EVERY plan registration — perk-owned plan mode (the `/plan`
3
+ // command, `Ctrl+Alt+P` shortcut, `--plan` flag, and the plan-authoring context hook pair, with
4
+ // the three-tier provider deferral), the `plan_draft`/`plan_save` tools, the `/plan-save` and
5
+ // `/implement-here` commands, and the `plan_review` registration — registration metadata
6
+ // pinned by the suite's registration-parity tests. The feature logic lives in `authoring/plan/`; this module decodes at
7
+ // the tool boundary, builds the provider/backend/gate adapters (`planSaveDepsFor` — the ONE
8
+ // production composition point every plan-save surface AND the review door consume), constructs
9
+ // the warm-door Result envelopes (the byte-stable message assembly), and places the
10
+ // feature-owned prose units in Pi fields.
11
+ //
12
+ // PLAN MODE (the toggle surface over the read-only gate): grounded in pi's official
13
+ // `examples/extensions/plan-mode/` recipe, but perk adopts ONLY the read-only authoring half —
14
+ // there is no in-session "execution mode" flip (perk separates plan from implement). The
15
+ // plan-authoring injection dedups on the COMPACTION-ACTIVE window (contracts §8.31 — the gist
16
+ // precedent): a live copy suppresses re-injection; compaction dropping it re-injects next turn.
17
+ //
18
+ // REGISTRATION-TIME DEFERRAL, THREE-TIER. The plan-mode surface resolves the plan provider id
19
+ // once at install time and branches:
20
+ // - `perk-plan` (and the fail-safe error path) → register EVERYTHING (the default path is the
21
+ // hard guarantee, zero behavior change).
22
+ // - `plannotator-plan` (AUGMENT posture) → register everything EXCEPT the `--plan` flag, the
23
+ // `Ctrl+Alt+P` shortcut, and the `--plan` session_start handler: `@plannotator/pi-extension`
24
+ // also registers that flag + shortcut, and duplicate flag/shortcut registration is the known
25
+ // potentially-fatal Pi behavior — plannotator owns `--plan`/`Ctrl+Alt+P` exclusively while
26
+ // perk keeps `/plan`, the authoring injection, and the read-only gate (plannotator augments
27
+ // perk's plan flow via the providers/plannotator.ts `plan_review` bridge; it does not
28
+ // replace it).
29
+ // - any other foreign id (tombell, REPLACE posture) → register NOTHING of the mode surface;
30
+ // the foreign package owns `/plan`/`Ctrl+Alt+P`/`--plan` unambiguously (Pi suffixes
31
+ // duplicate command names, so handler-time deferral alone is insufficient once the foreign
32
+ // package is loaded).
33
+ //
34
+ // SEAM-SHARED SUBSTRATE. `savePlan`/the `plan_save` tool/`/plan-save`/the read-only gate
35
+ // are the produced-contract landing for the PLAN seam (`adapter-architecture.md` Invariant 1) —
36
+ // the adapter bridges a foreign plan surface *to* `plan_save`/`cache.plan-ref`/the gate, so
37
+ // they must stay always-registered. They do NOT defer when a foreign `[providers] plan` is
38
+ // selected — only perk's own authoring surface (the mode tier above) steps aside.
39
+
40
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
41
+ import { PLAN_DRAFT_ARTIFACT, revisePlanDraft } from "../../authoring/plan/draft.ts";
42
+ import {
43
+ PLAN_CONTEXT_TYPE,
44
+ PLAN_MARKER,
45
+ planAuthoringContextContent,
46
+ } from "../../authoring/plan/prose.ts";
47
+ import {
48
+ type ObjectiveNodeLink,
49
+ type PlanBackend,
50
+ planApprovalSave,
51
+ type SavePlanOutcome,
52
+ savePlan,
53
+ } from "../../authoring/plan/save.ts";
54
+ import { extractPlanMarkdown, resolvePlanSource } from "../../authoring/plan/source.ts";
55
+ import { openBranchWorkflowSession } from "../../session/branchWorkflowSession.ts";
56
+ import type { PlanRef } from "../../substrate/cache.ts";
57
+ import {
58
+ booleanField,
59
+ type ColdJson,
60
+ nullableStringField,
61
+ objectField,
62
+ runColdDoor,
63
+ stringField,
64
+ } from "../../substrate/coldDoor.ts";
65
+ import { registerPerkCommand } from "../../substrate/command.ts";
66
+ import { loadPerkConfig } from "../../substrate/config.ts";
67
+ // Re-resolved here (not imported from providers/selection.ts) would drift — import the probe
68
+ // and compare against the provider registry ids at install time.
69
+ import { PERK_PLAN_PROVIDER_ID, PLANNOTATOR_PLAN_PROVIDER_ID } from "../../substrate/providers.ts";
70
+ import { failFor, ok } from "../../substrate/result.ts";
71
+ import { captureSessionPointer } from "../../substrate/sessionPointers.ts";
72
+ import type { ToolGating } from "../../substrate/toolGating.ts";
73
+ import { idArrayParam, paramsOf, stringParam } from "../../substrate/toolParams.ts";
74
+ import { branchOf, rebuildWorkflowState } from "../../substrate/workflowState.ts";
75
+ import { report, type Severity } from "../../surfaces/report.ts";
76
+ // `Key` via the surfaces re-export (keybinding vocabulary, not rich UI) — keeps pi-tui imports
77
+ // structurally confined to the surfaces module (the surfacesGuard pi-tui import rule).
78
+ import { type ActivityHandle, Key } from "../../surfaces/surfaces.ts";
79
+ import { installInjectedContext, isPlanGuidanceStage } from "./contextInjection.ts";
80
+ import { type DraftReviewSlot, recordSaveOutcome } from "./draftReview.ts";
81
+ import { isRefinementSession, refinementStageRefusal } from "./objectiveRefinement.ts";
82
+ import {
83
+ type ApprovalSaveOutcome,
84
+ executePlanReview,
85
+ type PlanReviewV1Deps,
86
+ runImplementHereCommand,
87
+ type SaveResult,
88
+ } from "./planReview.ts";
89
+ import { createPlannotatorBridge } from "./providers/plannotator.ts";
90
+ import { resolvedPlanProviderId } from "./providers/selection.ts";
91
+ import type { WaveLaunch } from "./review.ts";
92
+
93
+ // ------------------------------------------------------------------- the tool-boundary decode
94
+
95
+ /**
96
+ * Decode unknown `plan_draft` tool-call params (the tool-boundary seam). `plan` absent decodes to
97
+ * `""` (so the core's `invalid_input` arm owns the empty-plan message); present-but-mistyped →
98
+ * null (strict-fail `bad_input`). Decode-before-side-effect.
99
+ */
100
+ export function decodePlanDraftParams(params: unknown): { plan: string } | null {
101
+ const p = paramsOf(params);
102
+ if (p === null) return null;
103
+ const plan = stringParam(p, "plan");
104
+ if (plan === null) return null;
105
+ return { plan: plan ?? "" };
106
+ }
107
+
108
+ /** The decoded `plan_save` tool params (snake_case, as the schema declares them). */
109
+ interface PlanSaveParams {
110
+ plan?: string;
111
+ title?: string;
112
+ objective_id?: string;
113
+ node_id?: string;
114
+ consumed_learn?: string[];
115
+ }
116
+
117
+ /**
118
+ * Decode unknown `plan_save` tool-call params (the tool-boundary seam). `plan` is
119
+ * optional (the validated plan-draft artifact is preferred) — absent decodes to
120
+ * `undefined`, but present-but-mistyped → null (strict-fail); the optional fields likewise.
121
+ */
122
+ export function decodePlanSaveParams(params: unknown): PlanSaveParams | null {
123
+ const p = paramsOf(params);
124
+ if (p === null) return null;
125
+ const plan = stringParam(p, "plan");
126
+ if (plan === null) return null;
127
+ const title = stringParam(p, "title");
128
+ const objectiveId = stringParam(p, "objective_id");
129
+ const nodeId = stringParam(p, "node_id");
130
+ // Opaque string ids (§8.21); numbers are coerced — the learn-docs guidance renders bare
131
+ // numeric ids on GitHub, so the model may echo them un-quoted.
132
+ const consumedLearn = idArrayParam(p, "consumed_learn");
133
+ if (title === null || objectiveId === null || nodeId === null || consumedLearn === null) {
134
+ return null;
135
+ }
136
+ return {
137
+ plan: plan ?? undefined,
138
+ title,
139
+ objective_id: objectiveId,
140
+ node_id: nodeId,
141
+ consumed_learn: consumedLearn,
142
+ };
143
+ }
144
+
145
+ // -------------------------------------------------------------- the cold-door backend adapter
146
+
147
+ /**
148
+ * The decoded `perk plan save --json` payload slice the warm door consumes. Decode policy
149
+ * (`docs/learned/workflow/cold-door-client.md`: strict iff appended to workflow-state): only
150
+ * `plan_ref` is strict. The rendered `issue.id`/`url` are DERIVED from the strict ref — the cold
151
+ * door constructs the ref from the issue (`pr_id == issue.id`, `url == issue.url`), so they are
152
+ * byte-identical by construction; `existed` and `objective_node` are advisory.
153
+ */
154
+ interface PlanSavePayload {
155
+ issue: { id: string; url: string; existed: boolean | undefined };
156
+ plan_ref: PlanRef;
157
+ cached?: boolean;
158
+ updated?: boolean;
159
+ objective_node: ObjectiveNodeLink | null;
160
+ }
161
+
162
+ /**
163
+ * Fully strict `plan_ref` decode — a half-formed ref appended to workflow-state would poison
164
+ * `planRefsEqual` and every downstream consumer, so any miss → null → bad_output.
165
+ */
166
+ function decodePlanRef(payload: ColdJson): PlanRef | null {
167
+ const ref = objectField(payload, "plan_ref");
168
+ if (ref === undefined) return null;
169
+ const provider = stringField(ref, "provider");
170
+ const prId = stringField(ref, "pr_id");
171
+ const url = stringField(ref, "url");
172
+ const labels = ref.labels;
173
+ const objectiveId = nullableStringField(ref, "objective_id");
174
+ if (
175
+ provider === undefined ||
176
+ prId === undefined ||
177
+ url === undefined ||
178
+ !Array.isArray(labels) ||
179
+ !labels.every((l) => typeof l === "string") ||
180
+ objectiveId === undefined
181
+ ) {
182
+ return null;
183
+ }
184
+ // `base` stays Python-owned for all behavior; carrying it keeps the workflow-state
185
+ // `active_plan_ref` copy byte-consistent with the cold door's `--json` plan_ref. Parity-only +
186
+ // lenient: a present null/string is carried, an absent/mistyped value is simply omitted (never a
187
+ // decode failure — legacy plan-refs lack the field).
188
+ const base = nullableStringField(ref, "base");
189
+ return { provider, pr_id: prId, url, labels, objective_id: objectiveId, base };
190
+ }
191
+
192
+ /** Validate the optional `objective_node` sub-object; malformed → null (advisory, never fatal). */
193
+ function decodeObjectiveNode(payload: ColdJson): ObjectiveNodeLink | null {
194
+ const node = objectField(payload, "objective_node");
195
+ if (node === undefined) return null;
196
+ const linked = booleanField(node, "linked");
197
+ const name = nullableStringField(node, "node");
198
+ const status = nullableStringField(node, "status");
199
+ const error = nullableStringField(node, "error");
200
+ if (linked === undefined || name === undefined || status === undefined || error === undefined) {
201
+ return null;
202
+ }
203
+ return { linked, node: name, status, error };
204
+ }
205
+
206
+ /**
207
+ * Narrow the `perk plan save --json` success payload. Strict ONLY on `plan_ref` (malformed →
208
+ * bad_output — it is appended to workflow-state, where a half-formed ref would poison
209
+ * `planRefsEqual`). The rendered issue id/url are derived from the strict ref instead of decoded
210
+ * independently — the cold door builds the ref FROM the issue, so they are byte-identical by
211
+ * construction; this makes any `issue` sub-object shape change (e.g. a `number`→`id`
212
+ * rename under CLI↔extension version skew) skew-harmless. `existed` and
213
+ * `objective_node` are advisory — the plan genuinely saved, so the success report must survive
214
+ * them. With `plan_ref` the only strict field, `bad_output` is reachable only for a payload whose
215
+ * persistence would corrupt workflow-state.
216
+ */
217
+ function decodePlanSave(payload: ColdJson): PlanSavePayload | null {
218
+ const ref = decodePlanRef(payload);
219
+ if (ref === null) return null;
220
+ const issue = objectField(payload, "issue");
221
+ const existed = issue === undefined ? undefined : booleanField(issue, "existed");
222
+ return {
223
+ issue: { id: ref.pr_id, url: ref.url, existed },
224
+ plan_ref: ref,
225
+ cached: booleanField(payload, "cached"),
226
+ updated: booleanField(payload, "updated"),
227
+ objective_node: decodeObjectiveNode(payload),
228
+ };
229
+ }
230
+
231
+ /**
232
+ * The production `PlanBackend` over the Python cold door (`perk plan save --json` via the
233
+ * shared cold-door client; the plan markdown rides the run-scratch stdin channel). Argv
234
+ * assembly byte-identical to what the save always emitted; `runId: null` omits `--run-id`
235
+ * (an identity-less save keeps working).
236
+ */
237
+ function coldDoorPlanBackend(pi: ExtensionAPI, ctx: ExtensionContext): PlanBackend {
238
+ return {
239
+ async save(req) {
240
+ const args = ["plan", "save", "--json"];
241
+ if (req.runId !== null && req.runId !== "") args.push("--run-id", req.runId);
242
+ // An explicit title; when absent the cold door derives it (`plan.derive_title`).
243
+ if (req.title) args.push("--title", req.title);
244
+ // The plan→objective link. The objective plan-factory passes the active objective
245
+ // number; non-objective plans omit it (unchanged behavior).
246
+ if (req.objectiveId) args.push("--objective-id", req.objectiveId);
247
+ // The objective plan factory passes the node id alongside the objective id; the cold
248
+ // door commits the node→plan backlink + `in_progress` advance atomically. Non-factory
249
+ // plans omit it (unchanged behavior).
250
+ if (req.nodeId) args.push("--node-id", req.nodeId);
251
+ // The learn-docs factory passes the consumed perk:learn issue numbers; docs plans land
252
+ // them (close + label perk:consolidated). Non-factory plans omit it (unchanged behavior).
253
+ if (req.consumedLearn && req.consumedLearn.length > 0) {
254
+ args.push("--consumed-learn", req.consumedLearn.join(","));
255
+ }
256
+ const r = await runColdDoor<PlanSavePayload>(pi, ctx, args, {
257
+ label: "perk plan save",
258
+ decode: decodePlanSave,
259
+ stdin: { flag: "--plan-file", content: req.plan, filename: "plan.md" },
260
+ });
261
+ if (!r.ok) return { status: "failed", message: r.message, errorType: r.errorType };
262
+ return {
263
+ status: "saved",
264
+ ref: r.data.plan_ref,
265
+ existed: r.data.issue.existed ?? null,
266
+ updated: r.data.updated ?? false,
267
+ cached: r.data.cached ?? false,
268
+ nodeLink: r.data.objective_node,
269
+ };
270
+ },
271
+ };
272
+ }
273
+
274
+ // ---------------------------------------------------------------- the byte-stable rendering
275
+
276
+ /**
277
+ * Render a feature `SavePlanOutcome` as the warm-door SaveResult — the ONE message-assembly
278
+ * site every plan-save surface shares (tool, command, approval seam, review arm). Byte-stable:
279
+ * verb/existed, the node-link suffix (all THREE outcomes — a failed advance is a non-fatal
280
+ * sub-step but must be VISIBLE, the §8.3 "surfaced, never swallowed" intent), the source suffix
281
+ * + param-mismatch flag. The outcome's `linkage`/`claimClear` seam results are deliberately NOT
282
+ * rendered (append/read-back failures stay loud through the seam's report() path exactly as
283
+ * always); a `failed` outcome reports through the `failFor` seam (same scope, same bytes).
284
+ */
285
+ function renderSavePlanOutcome(ctx: ExtensionContext, save: SavePlanOutcome): SaveResult {
286
+ if (save.status === "failed") {
287
+ return failFor(ctx, "plan-save")(save.message, save.errorType);
288
+ }
289
+ const ref = save.ref;
290
+ const verb = save.existed ? "Updated" : "Saved";
291
+ const nodeLink = save.nodeLink;
292
+ // Render all THREE node-link outcomes (the silent-partial-failure fix). Both surfaces render
293
+ // content[0].text, so this one site fixes the tool path (the model relays it) and the command
294
+ // path (the user sees the notify) at once.
295
+ let linkSuffix = "";
296
+ if (nodeLink?.linked === true) {
297
+ linkSuffix = ` · linked objective node ${nodeLink.node} → in_progress`;
298
+ } else if (nodeLink && nodeLink.linked === false) {
299
+ linkSuffix = ` · ⚠ objective node ${nodeLink.node} NOT advanced — re-run /plan-save to retry${
300
+ nodeLink.error ? ` (${nodeLink.error})` : ""
301
+ }`;
302
+ }
303
+ // Surface NON-param sources in the message (param-path success messages stay
304
+ // byte-stable); a differing ignored param is visibly flagged, never silent.
305
+ let sourceSuffix = "";
306
+ if (save.source === "plan-draft" || save.source === "transcript") {
307
+ sourceSuffix =
308
+ save.source === "plan-draft"
309
+ ? " · plan source: plan-draft artifact"
310
+ : " · plan source: transcript";
311
+ if (save.paramMismatch) {
312
+ sourceSuffix += " (⚠ differing plan param ignored — the validated artifact was saved)";
313
+ }
314
+ }
315
+ return ok(
316
+ `${verb} plan #${ref.pr_id} → ${ref.url}${sourceSuffix}${linkSuffix}`,
317
+ {
318
+ issue: { id: ref.pr_id, url: ref.url },
319
+ plan_ref: ref,
320
+ cached: save.cached,
321
+ existed: save.existed,
322
+ updated: save.updated,
323
+ objective_node: nodeLink,
324
+ plan_source: save.source,
325
+ },
326
+ { terminate: true },
327
+ );
328
+ }
329
+
330
+ // ------------------------------------------------------------------------ the composition seam
331
+
332
+ /**
333
+ * Build the full production dependency bag every plan-save surface consumes (the `plan_save`
334
+ * tool, `/plan-save`, `approvalSave`, AND the `plan_review` registration — ONE composition
335
+ * point, so no reverse edges and no duplicated composition): the branch-backed session, the
336
+ * cold-door `PlanBackend`, the D1a gate slice over `gating`, the best-effort planning-pointer
337
+ * capture (§8.35 — no-ops on absent identity), the transcript scrape thunk, and the byte-stable
338
+ * save rendering.
339
+ */
340
+ export function planSaveDepsFor(
341
+ pi: ExtensionAPI,
342
+ ctx: ExtensionContext,
343
+ gating: ToolGating,
344
+ ): PlanReviewV1Deps {
345
+ const session = openBranchWorkflowSession(pi, ctx);
346
+ return {
347
+ session,
348
+ backend: coldDoorPlanBackend(pi, ctx),
349
+ gate: { isActive: () => gating.isActive(), exit: () => gating.exit(ctx) },
350
+ capturePlanningPointer: () => {
351
+ // Capture the planning session pointer (contracts.md §8.35): this planning run self-keys
352
+ // by its own run_id into the shared main checkout, so a later/other session can resolve
353
+ // it cross-run. Best-effort + non-fatal (the carrier warns + returns false; a successful
354
+ // save must stand — and it no-ops on a blank/absent run id, the identity-less arm).
355
+ captureSessionPointer({
356
+ cwd: ctx.cwd,
357
+ runId: session.runId ?? "",
358
+ klass: "planning",
359
+ site: "main",
360
+ // Optional-chained: best-effort, and some side-session fakes have no getSessionFile.
361
+ sessionFile: ctx.sessionManager.getSessionFile?.(),
362
+ });
363
+ },
364
+ transcript: () => extractPlanMarkdown(branchOf(ctx)),
365
+ renderSave: (save) => renderSavePlanOutcome(ctx, save),
366
+ };
367
+ }
368
+
369
+ /**
370
+ * The shared approval→save orchestration seam, adapter-composed: an APPROVED review outcome
371
+ * (the `plan_review` door — the plannotator bridge AND the first-party in-TUI editor review;
372
+ * the `/plan-review-browser` door's decision routing) and the manual `/plan-save` failsafe all
373
+ * run THIS. Flow: artifact-first plan resolution (the reviewed plan text is the explicit
374
+ * fallback, the transcript scrape last) → `savePlan` (warm node-link recovery inside) → gate
375
+ * exit on a successful save while read-only (the D1a pattern). The returned rendered
376
+ * `SaveResult` keeps `terminate: true` for tool-path callers.
377
+ */
378
+ export async function approvalSave(
379
+ pi: ExtensionAPI,
380
+ ctx: ExtensionContext,
381
+ gating: ToolGating,
382
+ opts: { reviewedPlan?: string; title?: string } = {},
383
+ ): Promise<ApprovalSaveOutcome> {
384
+ const deps = planSaveDepsFor(pi, ctx, gating);
385
+ const outcome = await planApprovalSave(deps, opts);
386
+ if (outcome.status === "no-plan") return { status: "no-plan" };
387
+ return {
388
+ status: outcome.status,
389
+ result: deps.renderSave(outcome.result),
390
+ gateExited: outcome.gateExited,
391
+ };
392
+ }
393
+
394
+ // ------------------------------------------------------------------------------ the installer
395
+
396
+ /**
397
+ * Install every plan Pi binding. Hook order is the frozen composition sequence — the plan-mode
398
+ * hook pair registers FIRST inside this installer (index.ts calls this at the slot the mode
399
+ * surface always held; the tombell/plannotator adapters follow); every tool/command
400
+ * registration is name-keyed and order-insensitive. `status` is the perk status handle's
401
+ * activity slice the plannotator arm's browser-wait activity rides. `wave` is the injected
402
+ * wave-launch deps (index.ts composes them from the door open cores); absent ⇒ the chooser never
403
+ * appears and every review path is byte-stable.
404
+ */
405
+ export function installPlanBindings(
406
+ pi: ExtensionAPI,
407
+ gating: ToolGating,
408
+ reviews: DraftReviewSlot,
409
+ runnerChild: () => boolean,
410
+ status: ActivityHandle,
411
+ wave?: WaveLaunch,
412
+ ): void {
413
+ installPlanMode(pi, gating, runnerChild);
414
+
415
+ // ------------------------------------------------------------------- the plan_draft tool
416
+ // The working-draft file tool: the first session-data PRODUCER and the narrow structural
417
+ // read-only-gate carve-out (session data dir only). The tool takes NO path/name parameter —
418
+ // the artifact name is the fixed constant and the bytes flow through the session seam, so
419
+ // allowlisting its name in READ_ONLY_TOOLS (toolGating.ts) is safe.
420
+ pi.registerTool({
421
+ name: "plan_draft",
422
+ label: "Plan draft",
423
+ description:
424
+ "Write (or overwrite) the working plan draft to the session data dir and record its " +
425
+ "provenance pointer. The only sanctioned write surface while read-only. NOT a save — " +
426
+ "plan_save//plan-save still persist the plan to GitHub.",
427
+ promptSnippet: "Persist the working plan draft to the session data dir (full rewrite)",
428
+ // Registration prose stays INLINE (not a prose.ts constant): the prose-review workbench
429
+ // edits these arrays through the TypeScript source adapter, which needs literal in-place
430
+ // values — an identifier indirection is an unsupported source shape there.
431
+ promptGuidelines: [
432
+ "Call plan_draft to persist the current working draft as you author or revise the plan; pass the FULL plan markdown each time (it rewrites the whole draft).",
433
+ "plan_draft never saves to GitHub and never ends the turn — plan_save//plan-save remain the canonical save surface.",
434
+ ],
435
+ executionMode: "sequential",
436
+ parameters: {
437
+ type: "object",
438
+ additionalProperties: false,
439
+ required: ["plan"],
440
+ properties: {
441
+ plan: {
442
+ type: "string",
443
+ description: "The full working-plan markdown (rewrites the whole draft).",
444
+ },
445
+ },
446
+ },
447
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
448
+ const decoded = decodePlanDraftParams(params);
449
+ if (decoded === null) {
450
+ return failFor(
451
+ ctx,
452
+ "plan-draft",
453
+ "plan_draft",
454
+ )("plan_draft needs { plan: string } per the tool schema", "bad_input");
455
+ }
456
+ const fail = failFor(ctx, "plan-draft");
457
+ const revised = revisePlanDraft(decoded, openBranchWorkflowSession(pi, ctx));
458
+ switch (revised.status) {
459
+ case "revised":
460
+ case "unchanged":
461
+ // A byte-identical rewrite short-circuits interior-side; the rendered result is
462
+ // computed from identical content either way, so the surface stays byte-stable.
463
+ return ok(`Plan draft written → ${revised.receipt.path} (${revised.receipt.digest})`, {
464
+ name: PLAN_DRAFT_ARTIFACT,
465
+ path: revised.receipt.path,
466
+ digest: revised.receipt.digest,
467
+ bytes: revised.bytes,
468
+ run_id: revised.receipt.runId,
469
+ });
470
+ case "rejected":
471
+ return fail(
472
+ revised.problem,
473
+ revised.reason === "blank_plan"
474
+ ? "invalid_input"
475
+ : revised.reason === "no_identity"
476
+ ? "no_run_id"
477
+ : "write_failed",
478
+ );
479
+ case "unverified":
480
+ return fail(revised.problem, "write_failed");
481
+ }
482
+ },
483
+ });
484
+
485
+ // ------------------------------------------------- the plan_save tool + /plan-save command
486
+ pi.registerTool({
487
+ name: "plan_save",
488
+ label: "Save plan",
489
+ description:
490
+ "Persist the current plan to GitHub as the canonical perk plan and link this session to it. " +
491
+ "Terminating: ends the turn on save. Call only when the plan is decision-complete.",
492
+ promptSnippet: "Save the decision-complete plan to GitHub (terminates the turn)",
493
+ promptGuidelines: [
494
+ "Use plan_save only after the plan is decision-complete and the user has agreed; it creates the canonical GitHub plan and ends the turn.",
495
+ "Keep the working draft current with plan_draft — the validated plan-draft artifact is what plan_save saves; the `plan` parameter is only a fallback when no draft exists. Never reference line numbers — use durable anchors (function names, behavioral descriptions, structural locations).",
496
+ "Pass plan_save's consumed_learn (the gathered perk:learn issue ids) only from the learned-docs factory — it links the issues the docs plan consolidates so /land closes + labels them.",
497
+ "When saving an objective-factory plan, pass plan_save BOTH objective_id and node_id — this links the node to the plan and advances it planning → in_progress (no separate backlink call).",
498
+ ],
499
+ executionMode: "sequential",
500
+ parameters: {
501
+ type: "object",
502
+ additionalProperties: false,
503
+ properties: {
504
+ plan: {
505
+ type: "string",
506
+ description:
507
+ "Optional — the validated plan-draft.md artifact is preferred when present; this " +
508
+ "param is the fallback for sessions that never wrote a draft (no line-number " +
509
+ "references).",
510
+ },
511
+ title: {
512
+ type: "string",
513
+ description: "Optional issue title (defaults to the plan's first heading).",
514
+ },
515
+ objective_id: {
516
+ type: "string",
517
+ description:
518
+ "Optional objective issue number to link this plan to (the objective plan factory " +
519
+ "passes the active objective; omit for a standalone plan).",
520
+ },
521
+ node_id: {
522
+ type: "string",
523
+ description:
524
+ "Objective node id to commit on save — the objective plan factory passes it with " +
525
+ "`objective_id` (links the node and advances it to `in_progress`); omit for a " +
526
+ "standalone plan.",
527
+ },
528
+ consumed_learn: {
529
+ type: "array",
530
+ items: { type: ["string", "number"] },
531
+ description:
532
+ "Optional perk:learn issue ids this docs plan consumes (the learned-docs factory " +
533
+ "passes the gathered ids; omit for a standalone plan). /land closes + labels them.",
534
+ },
535
+ },
536
+ },
537
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
538
+ const decoded = decodePlanSaveParams(params);
539
+ if (decoded === null) {
540
+ // The `label` arg matters: this handler-level closure renders "plan_save failed: …" while
541
+ // the save's internal failFor(ctx, "plan-save") stays as-is.
542
+ return failFor(
543
+ ctx,
544
+ "plan-save",
545
+ "plan_save",
546
+ )("plan_save needs { plan: string, … } per the tool schema", "bad_input");
547
+ }
548
+ // A refinement session never saves a plan — independent of the gate (a human toggle
549
+ // never makes an old plan draft routable here).
550
+ if (isRefinementSession(branchOf(ctx))) {
551
+ return failFor(
552
+ ctx,
553
+ "plan-save",
554
+ "plan_save",
555
+ )(refinementStageRefusal("plan_save"), "wrong_stage");
556
+ }
557
+ const deps = planSaveDepsFor(pi, ctx, gating);
558
+ // No read-only fail-fast here (D1a): the `plan_save` TOOL is structurally unreachable
559
+ // while read-only (the read-only allowlist excludes it), so reaching this handler means
560
+ // the gate is already off; the `/plan-save` COMMAND is allowed to run while read-only and
561
+ // exits the gate on a successful save (the read-only → read-write boundary in one gesture).
562
+ const src = resolvePlanSource(
563
+ {
564
+ draft: (() => {
565
+ const read = deps.session.readArtifact(PLAN_DRAFT_ARTIFACT);
566
+ return read.status === "found" ? read.content : null;
567
+ })(),
568
+ ...(decoded.plan !== undefined ? { explicit: decoded.plan } : {}),
569
+ transcript: deps.transcript,
570
+ },
571
+ "save",
572
+ );
573
+ if (src === null) {
574
+ return failFor(
575
+ ctx,
576
+ "plan-save",
577
+ "plan_save",
578
+ )(
579
+ "no plan to save — write the working draft with plan_draft, or pass the plan parameter",
580
+ "invalid_input",
581
+ );
582
+ }
583
+ const outcome = await savePlan(
584
+ {
585
+ plan: src.plan,
586
+ source: src.source,
587
+ paramMismatch: src.paramMismatch,
588
+ ...(decoded.title !== undefined ? { title: decoded.title } : {}),
589
+ ...(decoded.objective_id !== undefined ? { objectiveId: decoded.objective_id } : {}),
590
+ ...(decoded.node_id !== undefined ? { nodeId: decoded.node_id } : {}),
591
+ ...(decoded.consumed_learn !== undefined
592
+ ? { consumedLearn: decoded.consumed_learn }
593
+ : {}),
594
+ },
595
+ deps,
596
+ );
597
+ // The manual save never consults the latch (it IS the deliberate retry) but reports into it.
598
+ recordSaveOutcome(reviews, "plan", {
599
+ confirmed: outcome.status === "saved",
600
+ ...(outcome.status === "failed" ? { detail: outcome.message } : {}),
601
+ });
602
+ return deps.renderSave(outcome);
603
+ },
604
+ });
605
+
606
+ registerPerkCommand(pi, "plan-save", {
607
+ description:
608
+ "Save the latest proposed plan to GitHub — the manual failsafe for the approval→save flow " +
609
+ "(the read-only → read-write boundary).",
610
+ handler: async (args, ctx) => {
611
+ if (isRefinementSession(branchOf(ctx))) {
612
+ report(ctx, "plan-save", "warning", refinementStageRefusal("/plan-save"));
613
+ return;
614
+ }
615
+ const title = args.trim() || undefined;
616
+ // The manual-failsafe invocation of the shared approval→save seam. Artifact-first
617
+ // (no explicit param on the command path ⇒ paramMismatch is always false); the D1a gate exit
618
+ // lives in the seam. (The tool path never exits the gate — it is structurally unreachable
619
+ // while read-only.)
620
+ const outcome = await approvalSave(pi, ctx, gating, { title });
621
+ if (outcome.status === "no-plan") {
622
+ report(
623
+ ctx,
624
+ "plan-save",
625
+ "warning",
626
+ "no plan to save; write a draft with plan_draft, propose a plan, or call the plan_save tool.",
627
+ { alsoLog: true },
628
+ );
629
+ return;
630
+ }
631
+ // The manual save never consults the latch (it IS the deliberate retry) but reports into it.
632
+ recordSaveOutcome(reviews, "plan", {
633
+ confirmed: outcome.status === "saved",
634
+ ...(outcome.result.details.ok ? {} : { detail: outcome.result.details.error }),
635
+ });
636
+ // Severity reflects a failed objective-node advance: not-ok → error; saved-but-link-failed →
637
+ // warning; otherwise info. A failed node-link never blocks the gate exit above (the plan was
638
+ // saved) — but it MUST surface (the silent-partial-failure fix), in headless runs too.
639
+ const result = outcome.result;
640
+ const message = result.content[0]?.text ?? "plan-save done";
641
+ // `SaveResult` flows through `approvalSave` concretely — `details.ok` narrows the union,
642
+ // so the node-link severity read is typed (no assertion).
643
+ const details = result.details;
644
+ const severity: Severity = !details.ok
645
+ ? "error"
646
+ : details.objective_node?.linked === false
647
+ ? "warning"
648
+ : "info";
649
+ report(ctx, "plan-save", severity, message);
650
+ },
651
+ });
652
+
653
+ // -------------------------------------------------------------- the /implement-here command
654
+ // The sanctioned "implement here" exit from plan authoring (contracts.md §8.23): the
655
+ // read-only gate comes off WITHOUT saving to the issue backend. Human-only by construction:
656
+ // the two surfaces are the first-party review's 4th verdict (planReview.ts) and this command
657
+ // — no model tool exists, so the model can never choose to skip the backend on its own (the
658
+ // /btw posture). Deliberately OUTSIDE the PR lifecycle: no issue, no plan-ref, no branch —
659
+ // /submit, /address, and /land all key off `cache.plan-ref` and stay inapplicable. The
660
+ // plan-draft artifact is left untouched, so /plan-save can still create the canonical issue.
661
+ registerPerkCommand(pi, "implement-here", {
662
+ description:
663
+ "Exit plan mode WITHOUT saving an issue and implement the current plan draft in this " +
664
+ "session (the human-owned lightweight path).",
665
+ // The handler body lives in planReview.ts (next to the seam it composes — and so the
666
+ // sendUserMessage call sites stay out of this installer file, whose registration prose the
667
+ // prose-review workbench edits through the whole-file-validating TypeScript adapter).
668
+ handler: async (_args, ctx) => runImplementHereCommand(pi, ctx, gating, reviews),
669
+ });
670
+
671
+ // ---------------------------------------------------------------- the plan_review tool
672
+ // perk's universal review door. In READ_ONLY_TOOLS so it is callable INSIDE plan mode (the
673
+ // whole point — review happens before the gate ever comes off). Fail-open everywhere:
674
+ // headless / dismissed / backend-unavailable all soft-skip so authoring never wedges.
675
+ pi.registerTool({
676
+ name: "plan_review",
677
+ label: "Plan review",
678
+ description:
679
+ "Present the plan to the configured review surface — the Plannotator browser UI when " +
680
+ "selected, otherwise perk's in-TUI editor review — and wait for the human decision. " +
681
+ "Reviews the validated plan-draft artifact (keep it current with plan_draft); on approval " +
682
+ "the plan is auto-saved and the turn terminates. On deny, revise per the returned " +
683
+ "feedback, rewrite the draft with plan_draft, and call again. On the Plannotator surface " +
684
+ "the human may first opt into a streamed reviewer wave — the call then returns immediately " +
685
+ 'with wave guidance (status "wave_launched") to follow in the same turn, and the browser ' +
686
+ "decision routes back automatically. No-op skip when the session is headless or the " +
687
+ "review is dismissed.",
688
+ promptSnippet: "Request a human review of the working plan draft",
689
+ promptGuidelines: [
690
+ "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.",
691
+ "Call plan_review only when the plan is decision-complete.",
692
+ "On a DENIED review, revise per the feedback, rewrite the draft with plan_draft, then call plan_review again.",
693
+ "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.",
694
+ "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.",
695
+ "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).",
696
+ ],
697
+ executionMode: "sequential",
698
+ parameters: {
699
+ type: "object",
700
+ additionalProperties: false,
701
+ properties: {
702
+ plan: {
703
+ type: "string",
704
+ description:
705
+ "Optional — the validated plan-draft.md artifact is preferred when present; this " +
706
+ "param is the fallback for sessions that never wrote a draft.",
707
+ },
708
+ },
709
+ },
710
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
711
+ // Per call: the activity wait binds the live `ctx` (the bridge itself is a thin object).
712
+ const bridge = createPlannotatorBridge(pi.events, (text) => status.beginActivity(ctx, text));
713
+ return executePlanReview(
714
+ pi,
715
+ ctx,
716
+ gating,
717
+ bridge,
718
+ reviews,
719
+ planSaveDepsFor(pi, ctx, gating),
720
+ params,
721
+ signal,
722
+ wave,
723
+ );
724
+ },
725
+ });
726
+ }
727
+
728
+ // -------------------------------------------------------------------------------- plan mode
729
+
730
+ /**
731
+ * The perk-owned plan-mode toggle surface over the read-only gate: idempotent enter/exit (the
732
+ * gate tracks its own on/off transition), fail-safe-headless (notify when UI, else stderr), and
733
+ * the plan-authoring context injection. Three-tier registration branch (see the module header):
734
+ * full registration for the reference (fail-safe default), a PARTIAL vacate (skip only `--plan`
735
+ * + `Ctrl+Alt+P`) under the augment-posture plannotator selection, and a full vacate under any
736
+ * other foreign selection (tombell).
737
+ */
738
+ function installPlanMode(pi: ExtensionAPI, gating: ToolGating, runnerChild: () => boolean): void {
739
+ const providerId = resolvedPlanProviderId(process.cwd());
740
+ const plannotatorSelected = providerId === PLANNOTATOR_PLAN_PROVIDER_ID;
741
+ if (providerId !== PERK_PLAN_PROVIDER_ID && !plannotatorSelected) return;
742
+
743
+ if (!plannotatorSelected) {
744
+ pi.registerFlag("plan", {
745
+ description: "Start in perk plan mode (read-only exploration + plan authoring).",
746
+ type: "boolean",
747
+ default: false,
748
+ });
749
+ }
750
+
751
+ function announce(ctx: ExtensionContext, on: boolean): void {
752
+ const message = on
753
+ ? "plan mode ON — read-only exploration; author the plan, then review with plan_review (approval auto-saves; /plan-save is the manual failsafe)."
754
+ : "plan mode OFF — full tool access restored.";
755
+ report(ctx, "plan-mode", "info", message);
756
+ }
757
+
758
+ function toggle(ctx: ExtensionContext): void {
759
+ if (gating.isActive()) {
760
+ gating.exit(ctx);
761
+ announce(ctx, false);
762
+ } else {
763
+ gating.enter(ctx);
764
+ announce(ctx, true);
765
+ }
766
+ }
767
+
768
+ registerPerkCommand(pi, "plan", {
769
+ description: "Toggle perk plan mode (read-only exploration + plan authoring).",
770
+ handler: async (_args, ctx) => toggle(ctx),
771
+ });
772
+
773
+ if (!plannotatorSelected) {
774
+ pi.registerShortcut(Key.ctrlAlt("p"), {
775
+ description: "Toggle perk plan mode",
776
+ handler: async (ctx) => toggle(ctx),
777
+ });
778
+
779
+ // `--plan` cold start: enter read-only on session_start when the flag is set and the gate is
780
+ // off. (index.ts's session_start already syncs the gate from the rebuilt `mode`; this layers
781
+ // the flag on top for ad-hoc `pi --plan` interactive starts — the cold plan door drives
782
+ // read-only via the handoff `mode`, not this flag.) Skipped under the plannotator selection
783
+ // along with the flag itself (the flag no longer exists on perk's side).
784
+ pi.on("session_start", async (_event, ctx) => {
785
+ if (pi.getFlag("plan") === true && !gating.isActive()) {
786
+ gating.enter(ctx);
787
+ }
788
+ });
789
+ }
790
+
791
+ // Inject the plan-authoring context while the read-only gate is active (display:false) for
792
+ // every stage no other authoring context owns (`isPlanGuidanceStage` over the FULL-branch
793
+ // stage): the stage-less warm `/plan`, the cold `plan`/`objective-plan` claims, a worktree stage
794
+ // with `/plan` toggled on. The objective/gist/refinement installers own their stages, so a
795
+ // plan-mode turn followed by a warm `/objective-refine` retires the plan context there
796
+ // (selection is also the retention decision — the shared helper strips the owned custom once
797
+ // selection turns null). The runner fence lives in the shared helper.
798
+ installInjectedContext(
799
+ pi,
800
+ {
801
+ customType: PLAN_CONTEXT_TYPE,
802
+ flavors: {
803
+ [PLAN_MARKER]: (ctx) => planAuthoringContextContent(loadPerkConfig(ctx.cwd).planAuthoring),
804
+ },
805
+ select: (_ctx, branch) =>
806
+ gating.isActive() && isPlanGuidanceStage(rebuildWorkflowState(branch).stage)
807
+ ? PLAN_MARKER
808
+ : null,
809
+ },
810
+ runnerChild,
811
+ );
812
+ }