@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,794 @@
1
+ // The v1 Pi installer for the gist feature (module-contracts.md's named-installer shape):
2
+ // `installGistBindings` owns every gist registration — the `gist_draft`/`gist_save` tools, the
3
+ // `/gist-save` command, and the gist-authoring context hook pair — registration metadata
4
+ // pinned by the suite's registration-parity tests; `runGistReviewV1` is the injected `plan_review` gist arm. The feature
5
+ // logic lives in `authoring/gist/`; this module decodes at the tool boundary, builds the
6
+ // provider/backend/gate adapters, constructs the warm-door Result envelopes, and places the
7
+ // feature-owned prose units in Pi fields.
8
+ //
9
+ // Provider vocabulary is translated AT the adapter: plannotator's `# Direct Edits` feedback
10
+ // convention becomes the feature's `approvedDirectEdits` variant here, and the first-party editor review
11
+ // runs through the shared review-surface machinery (`pi/v1/review.ts` — the leaf every review
12
+ // arm composes; the review door's stage dispatcher imports this module's arm directly).
13
+ //
14
+ // The gist-authoring injection rides the shared `installInjectedContext` helper
15
+ // (pi/v1/contextInjection.ts — contracts §8.31 semantics): a live copy in the
16
+ // compaction-active window suppresses re-injection, and compaction dropping it from model
17
+ // context re-injects on the next turn.
18
+
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import {
21
+ GIST_AUTHOR_STAGE,
22
+ GIST_DRAFT_ARTIFACT,
23
+ GIST_SCOPES,
24
+ type GistScope,
25
+ renderGistDraft,
26
+ resumeGistDraft,
27
+ reviseGistDraft,
28
+ } from "../../authoring/gist/draft.ts";
29
+ import {
30
+ GIST_AUTHOR_CONTEXT_TYPE,
31
+ GIST_AUTHOR_MARKER,
32
+ GIST_DRAFT_TOOL_GUIDELINES,
33
+ GIST_SAVE_TOOL_GUIDELINES,
34
+ gistAuthoringContextContent,
35
+ } from "../../authoring/gist/prose.ts";
36
+ import {
37
+ completeGistReview,
38
+ type GistReviewOutcome,
39
+ type ReviewGistResult,
40
+ } from "../../authoring/gist/review.ts";
41
+ import {
42
+ type GistApprovalSaveOutcome,
43
+ type GistBackend,
44
+ gistApprovalSave,
45
+ type SaveGistOutcome,
46
+ saveGist,
47
+ } from "../../authoring/gist/save.ts";
48
+ import type { ApprovalGate } from "../../authoring/review/approvalGate.ts";
49
+ import { openBranchWorkflowSession } from "../../session/branchWorkflowSession.ts";
50
+ import type { WorkflowSession } from "../../session/workflowSession.ts";
51
+ import { bindingSuffix } from "../../substrate/bindingDelivery.ts";
52
+ import {
53
+ booleanField,
54
+ type ColdJson,
55
+ objectField,
56
+ runColdDoor,
57
+ stringField,
58
+ } from "../../substrate/coldDoor.ts";
59
+ import { registerPerkCommand } from "../../substrate/command.ts";
60
+ import { loadPerkConfig } from "../../substrate/config.ts";
61
+ import { render } from "../../substrate/prompts.ts";
62
+ import { failFor, ok, type Result } from "../../substrate/result.ts";
63
+ import type { ToolGating } from "../../substrate/toolGating.ts";
64
+ import { paramsOf, stringParam } from "../../substrate/toolParams.ts";
65
+ import { type BranchEntry, branchOf, rebuildWorkflowState } from "../../substrate/workflowState.ts";
66
+ import { report, type Severity } from "../../surfaces/report.ts";
67
+ import { installInjectedContext } from "./contextInjection.ts";
68
+ import {
69
+ checkDraftReviewDecision,
70
+ type DecisionCheck,
71
+ type DraftReviewSlot,
72
+ destinationChangedResult,
73
+ type OpenDraftReview,
74
+ openRefusedResult,
75
+ recordSaveOutcome,
76
+ saveUnconfirmedResult,
77
+ staleApprovalResult,
78
+ supersededReviewResult,
79
+ withDraftChangedNote,
80
+ } from "./draftReview.ts";
81
+ import { isRefinementSession, refinementStageRefusal } from "./objectiveRefinement.ts";
82
+ import { hasDirectEditsHeading } from "./providers/plannotator.ts";
83
+ import { isPlannotatorPlanSelected } from "./providers/selection.ts";
84
+ import {
85
+ approvedSubjectSaveResult,
86
+ type DraftReviewBridge,
87
+ type ReviewOutcome,
88
+ type ReviewSubject,
89
+ runFirstPartyReview,
90
+ skipResult,
91
+ subjectReviewOutcomeResult,
92
+ type ToolResult,
93
+ verdictsFor,
94
+ } from "./review.ts";
95
+
96
+ // ------------------------------------------------------------------- the tool-boundary decode
97
+
98
+ /** The decoded `gist_save` tool params (shared with `gist_draft`). */
99
+ export interface GistSaveParams {
100
+ prose: string;
101
+ title?: string;
102
+ scope?: GistScope;
103
+ }
104
+
105
+ /**
106
+ * Decode unknown `gist_save` tool-call params (the tool-boundary seam). `prose` absent decodes
107
+ * to `""` (so `saveGist`'s "no gist prose to save" `invalid_input` arm keeps owning that
108
+ * message) but present-but-mistyped → null (strict-fail); a present `scope` outside the enum is
109
+ * likewise a strict-fail (the schema already declares the enum — a bad value means a malformed
110
+ * call, never a silent default).
111
+ */
112
+ export function decodeGistSaveParams(params: unknown): GistSaveParams | null {
113
+ const p = paramsOf(params);
114
+ if (p === null) return null;
115
+ const prose = stringParam(p, "prose");
116
+ const title = stringParam(p, "title");
117
+ const scope = stringParam(p, "scope");
118
+ if (prose === null || title === null || scope === null) return null;
119
+ if (scope !== undefined && !(GIST_SCOPES as readonly string[]).includes(scope)) return null;
120
+ return { prose: prose ?? "", title, scope: scope as GistScope | undefined };
121
+ }
122
+
123
+ // -------------------------------------------------------------- the cold-door backend adapter
124
+
125
+ /** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state. */
126
+ export interface GistSaveOk {
127
+ /** `id` is the opaque string gist id (GitHub "7", Linear "ENG-7"/project id) — §8.21. */
128
+ gist: { id: string; url: string };
129
+ scope: string | null;
130
+ existed: boolean | null;
131
+ }
132
+
133
+ export type GistSaveResult = Result<GistSaveOk>;
134
+
135
+ /** The decoded `perk gist create --json` payload slice the warm door consumes. */
136
+ interface GistCreatePayload {
137
+ gist: { id: string; url: string; existed: boolean | undefined };
138
+ scope: string | undefined;
139
+ }
140
+
141
+ /** Narrow the `perk gist create --json` success payload; strict on `gist`. */
142
+ function decodeGistCreate(payload: ColdJson): GistCreatePayload | null {
143
+ const gist = objectField(payload, "gist");
144
+ if (gist === undefined) return null;
145
+ const id = stringField(gist, "id");
146
+ const url = stringField(gist, "url");
147
+ if (id === undefined || url === undefined) return null;
148
+ return {
149
+ gist: { id, url, existed: booleanField(gist, "existed") },
150
+ scope: stringField(payload, "scope"),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * The production `GistBackend` over the Python cold door (`perk gist create --json` via the
156
+ * shared cold-door client; the prose rides the run-scratch stdin channel). The cold door owns
157
+ * scope resolution beyond an explicit value (the launch handoff's pre-seeded `gist_scope`, else
158
+ * `plan`); `runId: null` omits `--run-id` (an identity-less save keeps working).
159
+ */
160
+ function coldDoorGistBackend(pi: ExtensionAPI, ctx: ExtensionContext): GistBackend {
161
+ return {
162
+ async save(req) {
163
+ const args = ["gist", "create", "--json"];
164
+ if (req.title) args.push("--title", req.title);
165
+ if (req.scope) args.push("--scope", req.scope);
166
+ if (req.runId !== null && req.runId !== "") args.push("--run-id", req.runId);
167
+ const r = await runColdDoor<GistCreatePayload>(pi, ctx, args, {
168
+ label: "perk gist create",
169
+ decode: decodeGistCreate,
170
+ stdin: { flag: "--body", content: req.prose, filename: "gist.md" },
171
+ });
172
+ if (!r.ok) return { status: "failed", message: r.message, errorType: r.errorType };
173
+ return {
174
+ status: "saved",
175
+ id: r.data.gist.id,
176
+ url: r.data.gist.url,
177
+ existed: r.data.gist.existed ?? null,
178
+ scope: r.data.scope ?? null,
179
+ };
180
+ },
181
+ };
182
+ }
183
+
184
+ /** The consumption pointer relayed after a save — the adoption door matching the saved scope. */
185
+ function consumptionHint(id: string, scope: string | undefined): string {
186
+ return scope === "objective"
187
+ ? `Consume with: perk objective author --from ${id}`
188
+ : `Consume with: perk plan from ${id}`;
189
+ }
190
+
191
+ /**
192
+ * Render a save outcome as the warm-door Result envelope (the ONE result-construction site for
193
+ * every gist save surface — tool, command relay, review arm). A `saved` outcome renders the
194
+ * created/found line + consumption hint and terminates; a `failed` outcome reports through the
195
+ * `failFor` seam (byte-identical to the failure the save always rendered).
196
+ */
197
+ function gistSaveResultOf(ctx: ExtensionContext, save: SaveGistOutcome): GistSaveResult {
198
+ if (save.status === "failed") {
199
+ return failFor(ctx, "gist-save")(save.message, save.errorType);
200
+ }
201
+ const verb = save.existed ? "Found existing" : "Saved";
202
+ return ok(
203
+ `${verb} gist ${save.id} → ${save.url}\n${consumptionHint(save.id, save.scope ?? undefined)}`,
204
+ {
205
+ gist: { id: save.id, url: save.url },
206
+ scope: save.scope,
207
+ existed: save.existed,
208
+ },
209
+ { terminate: true },
210
+ );
211
+ }
212
+
213
+ // ------------------------------------------------------------------------- adapter plumbing
214
+
215
+ /** Open the branch-backed session (always opens; `runId: null` is the identity-less arm). */
216
+ function openSession(pi: ExtensionAPI, ctx: ExtensionContext): WorkflowSession {
217
+ return openBranchWorkflowSession(pi, ctx);
218
+ }
219
+
220
+ /** The narrow gate slice the feature releases (D1a: exit only after a verified save). */
221
+ function gateFor(gating: ToolGating, ctx: ExtensionContext): ApprovalGate {
222
+ return { isActive: () => gating.isActive(), exit: () => gating.exit(ctx) };
223
+ }
224
+
225
+ /**
226
+ * The seed guidance the warm `/gist-save` injects to drive the save (the perk-gist-author skill
227
+ * pointer rides the skill-binding suffix — not hardcoded here). Pure + exported for offline
228
+ * tests.
229
+ */
230
+ export function gistSaveGuidance(title?: string): string {
231
+ const named = title?.trim() || "";
232
+ return render("stages/gist-save.md", { title: named });
233
+ }
234
+
235
+ /**
236
+ * Whether the current branch is a gist-author session (read-only gate AND stage match). (The
237
+ * runner fence lives in the shared injection helper.)
238
+ */
239
+ function isGistAuthoring(gating: ToolGating, branch: readonly BranchEntry[]): boolean {
240
+ return gating.isActive() && rebuildWorkflowState(branch).stage === GIST_AUTHOR_STAGE;
241
+ }
242
+
243
+ // ------------------------------------------------------------------------------ the installer
244
+
245
+ /**
246
+ * Install every gist Pi binding: the gist-authoring context hook pair (the frozen hooks-ordering
247
+ * slot index.ts calls this at), the `gist_draft` and `gist_save` tools, and the `/gist-save`
248
+ * command — registration metadata pinned by the registration-parity tests. Inert outside gist
249
+ * sessions; never throws.
250
+ */
251
+ export function installGistBindings(
252
+ pi: ExtensionAPI,
253
+ gating: ToolGating,
254
+ reviews: DraftReviewSlot,
255
+ runnerChild: () => boolean,
256
+ ): void {
257
+ // The gist-authoring context injection (display:false), keyed off (read-only gate AND stage
258
+ // === gist-author); the inject/strip mechanics (active-window dedup, selection-driven
259
+ // retention) and the runner fence live in the shared helper.
260
+ installInjectedContext(
261
+ pi,
262
+ {
263
+ customType: GIST_AUTHOR_CONTEXT_TYPE,
264
+ flavors: {
265
+ [GIST_AUTHOR_MARKER]: (ctx) =>
266
+ gistAuthoringContextContent(loadPerkConfig(ctx.cwd).planAuthoring),
267
+ },
268
+ select: (_ctx, branch) => (isGistAuthoring(gating, branch) ? GIST_AUTHOR_MARKER : null),
269
+ },
270
+ runnerChild,
271
+ );
272
+
273
+ pi.registerTool({
274
+ name: "gist_draft",
275
+ label: "Gist draft",
276
+ description:
277
+ "Write (or overwrite) the working gist draft — the statement-of-intent prose + an " +
278
+ "optional scope hint — to the session data dir and record its provenance pointer. The " +
279
+ "only sanctioned write surface while read-only. NOT a save — gist_save//gist-save still " +
280
+ "persist the gist to the issue backend.",
281
+ promptSnippet:
282
+ "Persist the working gist draft (statement-of-intent prose) to the session data dir (full rewrite)",
283
+ promptGuidelines: GIST_DRAFT_TOOL_GUIDELINES,
284
+ executionMode: "sequential",
285
+ parameters: {
286
+ type: "object",
287
+ additionalProperties: false,
288
+ required: ["prose"],
289
+ properties: {
290
+ prose: {
291
+ type: "string",
292
+ description:
293
+ "The gist prose (the problem-space intent: what we want, why it matters, what " +
294
+ "bounds it, and any high-level solution leanings — no implementation steps).",
295
+ },
296
+ title: {
297
+ type: "string",
298
+ description: "Optional gist title (defaults to the prose's first heading).",
299
+ },
300
+ scope: {
301
+ type: "string",
302
+ enum: [...GIST_SCOPES],
303
+ description:
304
+ "Optional consumption tier: plan (plan-sized intent) or objective (objective-sized).",
305
+ },
306
+ },
307
+ },
308
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
309
+ // The shared param contract: the same decode as `gist_save`, so the two cannot drift.
310
+ const decoded = decodeGistSaveParams(params);
311
+ if (decoded === null) {
312
+ return failFor(
313
+ ctx,
314
+ "gist-draft",
315
+ "gist_draft",
316
+ )(
317
+ "gist_draft needs { prose: string, scope?: plan|objective } per the tool schema",
318
+ "bad_input",
319
+ );
320
+ }
321
+ const fail = failFor(ctx, "gist-draft");
322
+ const revised = reviseGistDraft(decoded, openSession(pi, ctx));
323
+ switch (revised.status) {
324
+ case "revised":
325
+ case "unchanged":
326
+ // A byte-identical rewrite short-circuits interior-side; the rendered result is
327
+ // computed from identical content either way, so the surface stays byte-stable.
328
+ return ok(`Gist draft written → ${revised.receipt.path} (${revised.receipt.digest})`, {
329
+ name: GIST_DRAFT_ARTIFACT,
330
+ path: revised.receipt.path,
331
+ digest: revised.receipt.digest,
332
+ bytes: revised.bytes,
333
+ run_id: revised.receipt.runId,
334
+ });
335
+ case "rejected":
336
+ return fail(
337
+ revised.problem,
338
+ revised.reason === "blank_prose"
339
+ ? "invalid_input"
340
+ : revised.reason === "no_identity"
341
+ ? "no_run_id"
342
+ : "write_failed",
343
+ );
344
+ case "unverified":
345
+ return fail(revised.problem, "write_failed");
346
+ }
347
+ },
348
+ });
349
+
350
+ pi.registerTool({
351
+ name: "gist_save",
352
+ label: "Save gist",
353
+ description:
354
+ "Persist a drafted gist (a statement of intent) to the issue backend as a tracked " +
355
+ "perk:gist. Terminating: ends the turn on save. Call only when the gist says what it " +
356
+ "means.",
357
+ promptSnippet: "Save the converged gist to the issue backend (terminates the turn)",
358
+ promptGuidelines: GIST_SAVE_TOOL_GUIDELINES,
359
+ executionMode: "sequential",
360
+ parameters: {
361
+ type: "object",
362
+ additionalProperties: false,
363
+ required: ["prose"],
364
+ properties: {
365
+ prose: {
366
+ type: "string",
367
+ description:
368
+ "The gist prose (the problem-space intent: what we want, why it matters, what " +
369
+ "bounds it, and any high-level solution leanings — no implementation steps).",
370
+ },
371
+ title: {
372
+ type: "string",
373
+ description: "Optional gist title (defaults to the prose's first heading).",
374
+ },
375
+ scope: {
376
+ type: "string",
377
+ enum: [...GIST_SCOPES],
378
+ description:
379
+ "Optional consumption tier: plan (plan-sized intent) or objective (objective-sized).",
380
+ },
381
+ },
382
+ },
383
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
384
+ const decoded = decodeGistSaveParams(params);
385
+ if (decoded === null) {
386
+ return failFor(
387
+ ctx,
388
+ "gist-save",
389
+ "gist_save",
390
+ )(
391
+ "gist_save needs { prose: string, scope?: plan|objective } per the tool schema",
392
+ "bad_input",
393
+ );
394
+ }
395
+ // A refinement session never creates a gist — independent of tool visibility.
396
+ if (isRefinementSession(branchOf(ctx)))
397
+ return failFor(
398
+ ctx,
399
+ "gist-save",
400
+ "gist_save",
401
+ )(refinementStageRefusal("gist_save"), "wrong_stage");
402
+ // The manual save never consults the latch (it IS the deliberate retry) but reports into it.
403
+ const save = await saveGist(decoded, {
404
+ backend: coldDoorGistBackend(pi, ctx),
405
+ runId: openSession(pi, ctx).runId,
406
+ });
407
+ recordSaveOutcome(reviews, "gist", {
408
+ confirmed: save.status === "saved",
409
+ ...(save.status === "failed" ? { detail: save.message } : {}),
410
+ });
411
+ return gistSaveResultOf(ctx, save);
412
+ },
413
+ });
414
+
415
+ registerPerkCommand(pi, "gist-save", {
416
+ description:
417
+ "Save the working gist draft to the issue backend — the manual failsafe for the " +
418
+ "approval→save flow (artifact-first; drives the save only when no draft exists).",
419
+ handler: async (args, ctx) => {
420
+ if (isRefinementSession(branchOf(ctx))) {
421
+ report(ctx, "gist-save", "warning", refinementStageRefusal("/gist-save"));
422
+ return;
423
+ }
424
+ const title = args.trim() || undefined;
425
+ // The artifact-first manual-failsafe invocation of the shared approval→save seam (the D1a
426
+ // gate exit lives in the seam). The drive-the-session fallback covers draft-LESS
427
+ // sessions — gists have no transcript scrape by design, so a draftless session still
428
+ // needs a working save path.
429
+ // The session always opens (identity-optional): an identity-less session reads the
430
+ // draft `absent` → the no-draft fallback below, exactly the open-absent branch.
431
+ const session = openSession(pi, ctx);
432
+ const outcome = await gistApprovalSave(
433
+ { session, backend: coldDoorGistBackend(pi, ctx), gate: gateFor(gating, ctx) },
434
+ { title },
435
+ );
436
+ recordGistSaveOutcome(reviews, outcome);
437
+ if (outcome.status === "refused-draft") {
438
+ // Fail-closed stop: the command's own precondition is a VALID draft — no gate exit,
439
+ // no driven turn (those fallbacks are for draft-LESS sessions; driving a fresh
440
+ // model-authored save over a corrupted artifact would silently abandon its bytes).
441
+ report(
442
+ ctx,
443
+ "gist-save",
444
+ "error",
445
+ `the working gist draft is invalid: ${outcome.problem} — rewrite it with gist_draft, ` +
446
+ "then re-run /gist-save",
447
+ );
448
+ return;
449
+ }
450
+ if (outcome.status !== "no-draft") {
451
+ // Saved or save-failed: relay the save message (which carries the consumption hint).
452
+ const result = gistSaveResultOf(ctx, outcome.save);
453
+ const message = result.content[0]?.text ?? "gist-save done";
454
+ const severity: Severity = result.details.ok ? "info" : "error";
455
+ report(ctx, "gist-save", severity, message);
456
+ return;
457
+ }
458
+ // Exit the read-only gate so the gist_save tool (excluded from READ_ONLY_TOOLS) becomes
459
+ // reachable on the driven turn, then drive the turn (mirrors /objective-save).
460
+ if (gating.isActive()) gating.exit(ctx);
461
+ report(ctx, "gist-save", "info", "handing the save to the session");
462
+ // The perk-gist-author pointer rides the skill-binding suffix (D5) since a warm
463
+ // /gist-save outside a stage:gist-author session gets none from Mechanism A.
464
+ pi.sendUserMessage(gistSaveGuidance(title) + bindingSuffix(ctx.cwd, "stage:gist-author"));
465
+ },
466
+ });
467
+ }
468
+
469
+ /** Report a gist approval-save outcome into the unconfirmed-save latch. */
470
+ function recordGistSaveOutcome(slot: DraftReviewSlot, outcome: GistApprovalSaveOutcome): void {
471
+ switch (outcome.status) {
472
+ case "saved":
473
+ recordSaveOutcome(slot, "gist", { confirmed: true });
474
+ return;
475
+ case "save-failed":
476
+ recordSaveOutcome(slot, "gist", { confirmed: false, detail: outcome.save.message });
477
+ return;
478
+ case "no-draft":
479
+ case "refused-draft":
480
+ // Nothing reached the backend — no attempt to confirm.
481
+ return;
482
+ }
483
+ }
484
+
485
+ // ------------------------------------------------------------------------ the review arm
486
+
487
+ const GIST_SUBJECT: ReviewSubject = {
488
+ noun: "gist",
489
+ present: "the complete gist to the user",
490
+ presentUnavailable: "the complete gist to the user",
491
+ implementHereWhere: "on the gist path",
492
+ draftTool: "gist_draft",
493
+ failsafeCmd: "/gist-save",
494
+ detailsExtra: { subject: "gist" },
495
+ noSourceError: "no gist draft resolved",
496
+ };
497
+
498
+ const GIST_REVIEW_EDITOR_TITLE =
499
+ "Gist review (view only — edits are not saved) — Enter: continue to verdict · Esc: skip · " +
500
+ "Ctrl+G: $EDITOR";
501
+
502
+ /** The gist arm's soft skip when no reviewable draft exists (byte-stable redirect). */
503
+ function noGistDraftResult(): ToolResult {
504
+ return {
505
+ content: [
506
+ {
507
+ type: "text",
508
+ text:
509
+ "no gist draft to review — write the working gist with gist_draft (the " +
510
+ "statement-of-intent prose), then call plan_review again.",
511
+ },
512
+ ],
513
+ details: {
514
+ ok: false,
515
+ error: "no gist draft to review — write it with gist_draft first",
516
+ error_type: "no_gist_draft",
517
+ status: "skipped",
518
+ reason: "no_gist_draft",
519
+ },
520
+ };
521
+ }
522
+
523
+ /**
524
+ * Translate a review-door outcome (`ReviewOutcome`) into the feature's `GistReviewOutcome`.
525
+ * Plannotator's vocabulary is translated here: an approval whose feedback OPENS with the
526
+ * `# Direct Edits` heading becomes the `approvedDirectEdits` variant (the heading check
527
+ * suffices — the diff goes to the model verbatim either way; the variant requires the feedback,
528
+ * so the edits can never be dropped on the save path). The `implement-here` arm is unreachable
529
+ * on the gist path (neither reviewer offers it) and maps defensively to `dismissed`.
530
+ */
531
+ function gistOutcomeOf(outcome: ReviewOutcome): GistReviewOutcome {
532
+ switch (outcome.status) {
533
+ case "completed": {
534
+ const carried = {
535
+ ...(outcome.feedback !== undefined ? { feedback: outcome.feedback } : {}),
536
+ reviewId: outcome.reviewId,
537
+ };
538
+ if (outcome.approved) {
539
+ if (outcome.feedback !== undefined && hasDirectEditsHeading(outcome.feedback)) {
540
+ return { status: "approvedDirectEdits", feedback: outcome.feedback, ...carried };
541
+ }
542
+ return { status: "approved", ...carried };
543
+ }
544
+ return { status: "denied", ...carried };
545
+ }
546
+ case "unavailable":
547
+ return { status: "unavailable", warning: outcome.warning };
548
+ case "aborted":
549
+ return { status: "aborted" };
550
+ case "dismissed":
551
+ return { status: "dismissed" };
552
+ case "implement-here":
553
+ return { status: "dismissed" };
554
+ }
555
+ }
556
+
557
+ /**
558
+ * The first-party reviewer: the in-TUI editor review, VIEW-ONLY (3 verdicts). Returns the door
559
+ * vocabulary (`ReviewOutcome`) so both arms share one ladder + completion path.
560
+ */
561
+ async function firstPartyGistReview(
562
+ ctx: ExtensionContext,
563
+ rendered: string,
564
+ signal: AbortSignal | undefined,
565
+ ): Promise<ReviewOutcome> {
566
+ const fp = await runFirstPartyReview({
567
+ ui: ctx.ui,
568
+ plan: rendered,
569
+ writeDraft: () => true, // unreachable under viewOnly — the branch is skipped
570
+ signal,
571
+ editorTitle: GIST_REVIEW_EDITOR_TITLE,
572
+ verdicts: verdictsFor(GIST_SUBJECT),
573
+ viewOnly: true,
574
+ });
575
+ return fp.outcome;
576
+ }
577
+
578
+ /** Whether a completed bridge outcome's effect is a save (an approval without Direct Edits). */
579
+ function gistEffectOf(
580
+ outcome: Extract<ReviewOutcome, { status: "completed" }>,
581
+ ): "save" | "revision" {
582
+ return outcome.approved &&
583
+ !(outcome.feedback !== undefined && hasDirectEditsHeading(outcome.feedback))
584
+ ? "save"
585
+ : "revision";
586
+ }
587
+
588
+ /** Render a non-`proceed` ladder verdict as the gist arm's tool result (nothing saved). */
589
+ function gistGuardResult(
590
+ check: Exclude<DecisionCheck, { kind: "proceed" }>,
591
+ review: OpenDraftReview,
592
+ feedback: string | undefined,
593
+ ): ToolResult {
594
+ switch (check.kind) {
595
+ case "superseded":
596
+ return supersededReviewResult("gist");
597
+ case "save-unconfirmed":
598
+ return saveUnconfirmedResult("gist", review.runId, check.detail, feedback);
599
+ case "stale-approval":
600
+ return staleApprovalResult("gist", check.reviewedDigest, feedback);
601
+ case "destination-changed":
602
+ return destinationChangedResult("gist", check.changed, feedback);
603
+ }
604
+ }
605
+
606
+ /** Rebuild the door's completed outcome from the arm result (the mappers consume it). */
607
+ function completedOutcome(
608
+ approved: boolean,
609
+ carried: { feedback?: string; reviewId?: string },
610
+ ): Extract<ReviewOutcome, { status: "completed" }> {
611
+ return {
612
+ status: "completed",
613
+ approved,
614
+ ...(carried.feedback !== undefined ? { feedback: carried.feedback } : {}),
615
+ reviewId: carried.reviewId ?? "",
616
+ };
617
+ }
618
+
619
+ /**
620
+ * The `plan_review` gist arm: headless soft-skip, the validated draft artifact as the SOLE
621
+ * review source (absent identity or absent draft → the `no_gist_draft` skip), the slot open
622
+ * (the resume's raw bytes + the rendered markdown — one read serves both, so the baseline is
623
+ * never newer than what the human saw), reviewer dispatch (plannotator bridge or
624
+ * first-party view-only editor), the decision ladder on a completed verdict, and the shared
625
+ * completion — byte-stable with the review door's results. An approval carrying Direct Edits
626
+ * returns the NON-terminating revise round with NOTHING saved; a plain approval re-reads the
627
+ * artifact through `gistApprovalSave` (gate released only after the verified save).
628
+ */
629
+ export async function runGistReviewV1(
630
+ pi: ExtensionAPI,
631
+ ctx: ExtensionContext,
632
+ gating: ToolGating,
633
+ bridge: DraftReviewBridge,
634
+ slot: DraftReviewSlot,
635
+ signal?: AbortSignal,
636
+ ): Promise<ToolResult> {
637
+ // Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
638
+ if (!ctx.hasUI) return skipResult();
639
+ const sig = signal ?? ctx.signal;
640
+ if (sig?.aborted) return subjectReviewOutcomeResult(GIST_SUBJECT, { status: "aborted" });
641
+ // The session always opens (identity-optional): an identity-less session reads the draft
642
+ // `absent` → the same rendered redirect as before.
643
+ const session = openSession(pi, ctx);
644
+ const resumed = resumeGistDraft(session);
645
+ if (resumed.kind === "absent") return noGistDraftResult();
646
+ if (resumed.kind === "refused")
647
+ return renderGistReviewResult(ctx, { status: "refusedDraft", problem: resumed.problem });
648
+ const rendered = renderGistDraft(resumed.draft);
649
+ const plannotator = isPlannotatorPlanSelected(ctx.cwd);
650
+ const opened = slot.open(ctx, {
651
+ subject: "gist",
652
+ source: plannotator ? "artifact" : "editor",
653
+ raw: resumed.raw,
654
+ markdown: rendered,
655
+ });
656
+ if (!opened.ok) return openRefusedResult(opened);
657
+ const review = opened.review;
658
+ const outcome = plannotator
659
+ ? await bridge.review(rendered, sig)
660
+ : await firstPartyGistReview(ctx, rendered, sig);
661
+ if (sig?.aborted) return subjectReviewOutcomeResult(GIST_SUBJECT, { status: "aborted" });
662
+ if (outcome.status !== "completed") return subjectReviewOutcomeResult(GIST_SUBJECT, outcome);
663
+ const check = checkDraftReviewDecision(slot, ctx, review, gistEffectOf(outcome));
664
+ if (check.kind !== "proceed") return gistGuardResult(check, review, outcome.feedback);
665
+ return withDraftChangedNote(
666
+ await completeGistReviewV1(pi, ctx, gating, slot, outcome),
667
+ check.draftChanged,
668
+ );
669
+ }
670
+
671
+ export function renderGistReviewResult(
672
+ ctx: ExtensionContext,
673
+ result: ReviewGistResult,
674
+ ): ToolResult {
675
+ switch (result.status) {
676
+ case "noDraft":
677
+ return noGistDraftResult();
678
+ case "refusedDraft":
679
+ // Fail-closed soft skip (the `noGistDraftResult` shape): an invalid artifact is never
680
+ // reviewed — rewrite, then re-review. Gate untouched.
681
+ return {
682
+ content: [
683
+ {
684
+ type: "text",
685
+ text:
686
+ `the working gist draft is invalid: ${result.problem} — rewrite it with ` +
687
+ "gist_draft, then call plan_review again.",
688
+ },
689
+ ],
690
+ details: {
691
+ ok: false,
692
+ error: result.problem,
693
+ error_type: "bad_state",
694
+ status: "skipped",
695
+ reason: "gist_draft_refused",
696
+ subject: "gist",
697
+ },
698
+ };
699
+ case "directEditsRevise":
700
+ // The Direct-Edits carve-out (contracts §8.23's gist arm): rendered edits cannot be
701
+ // folded back into the structured draft mechanically — one model-mediated revise round,
702
+ // nothing saved, the gate untouched.
703
+ return {
704
+ content: [
705
+ {
706
+ type: "text",
707
+ text:
708
+ "gist APPROVED with direct browser edits — these cannot be auto-applied to the " +
709
+ "structured draft, so nothing was saved. Fold each Direct Edits hunk below into " +
710
+ "the matching gist_draft field (a `# <title>` heading hunk → title, a `Scope:` " +
711
+ "line hunk → scope, prose hunks → prose), then call plan_review again to " +
712
+ `confirm.\n\nReviewer feedback:\n${result.feedback}`,
713
+ },
714
+ ],
715
+ details: {
716
+ ok: true,
717
+ status: "revise",
718
+ reason: "direct_edits",
719
+ approved: true,
720
+ feedback: result.feedback,
721
+ reviewId: result.reviewId,
722
+ subject: "gist",
723
+ },
724
+ };
725
+ case "approvedSaved":
726
+ return approvedSubjectSaveResult(GIST_SUBJECT, completedOutcome(true, result), {
727
+ status: "saved",
728
+ result: gistSaveResultOf(ctx, result.save.save),
729
+ gateExited: result.save.gateExited,
730
+ });
731
+ case "approvedSaveFailed":
732
+ return approvedSubjectSaveResult(GIST_SUBJECT, completedOutcome(true, result), {
733
+ status: "save-failed",
734
+ result: gistSaveResultOf(ctx, result.save.save),
735
+ gateExited: false,
736
+ });
737
+ case "approvedNoDraft":
738
+ return approvedSubjectSaveResult(GIST_SUBJECT, completedOutcome(true, result), {
739
+ status: "no-source",
740
+ });
741
+ case "approvedRefusedDraft":
742
+ // The approval-time race: corrupted between the review read and the save re-read — the
743
+ // shared refused-draft arm (rewrite + a FRESH review, never /gist-save).
744
+ return approvedSubjectSaveResult(GIST_SUBJECT, completedOutcome(true, result), {
745
+ status: "refused-draft",
746
+ problem: result.problem,
747
+ });
748
+ case "denied":
749
+ return subjectReviewOutcomeResult(GIST_SUBJECT, completedOutcome(false, result));
750
+ case "dismissed":
751
+ return subjectReviewOutcomeResult(GIST_SUBJECT, { status: "dismissed" });
752
+ case "aborted":
753
+ return subjectReviewOutcomeResult(GIST_SUBJECT, { status: "aborted" });
754
+ case "unavailable":
755
+ return subjectReviewOutcomeResult(GIST_SUBJECT, {
756
+ status: "unavailable",
757
+ warning: result.warning,
758
+ });
759
+ }
760
+ }
761
+
762
+ /**
763
+ * The gist completion shared by both tool arms: a completed outcome → the feature completion
764
+ * (an approval re-reads the artifact through `gistApprovalSave`; Direct Edits is the no-save
765
+ * revise round) → the latch record → the rendered tool result. The caller has already run the
766
+ * decision ladder.
767
+ */
768
+ export async function completeGistReviewV1(
769
+ pi: ExtensionAPI,
770
+ ctx: ExtensionContext,
771
+ gating: ToolGating,
772
+ slot: DraftReviewSlot,
773
+ outcome: Extract<ReviewOutcome, { status: "completed" }>,
774
+ ): Promise<ToolResult> {
775
+ const result = await completeGistReview(gistOutcomeOf(outcome), () =>
776
+ gistApprovalSave({
777
+ session: openSession(pi, ctx),
778
+ backend: coldDoorGistBackend(pi, ctx),
779
+ gate: gateFor(gating, ctx),
780
+ }),
781
+ );
782
+ switch (result.status) {
783
+ case "approvedSaved":
784
+ recordSaveOutcome(slot, "gist", { confirmed: true });
785
+ break;
786
+ case "approvedSaveFailed":
787
+ recordSaveOutcome(slot, "gist", { confirmed: false, detail: result.save.save.message });
788
+ break;
789
+ default:
790
+ // Denials, Direct Edits, the no-draft and refused-draft arms never reach the backend.
791
+ break;
792
+ }
793
+ return renderGistReviewResult(ctx, result);
794
+ }