@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,431 @@
1
+ // The draft-review guards (contracts.md §8.23 "Draft-review guards"): one in-memory slot per
2
+ // activation shared by EVERY review surface (the blocking `plan_review` tool's Plannotator and
3
+ // first-party arms, both browser doors), plus the decision ladder a completed review runs
4
+ // through before its verdict has any effect.
5
+ //
6
+ // Four guards, nothing persisted:
7
+ // 1. reviewed-bytes — at decision time the live draft must equal the bytes the human saw
8
+ // (artifact-sourced reviews only); an APPROVE over a moved draft saves nothing, a DENY
9
+ // proceeds with a one-line note.
10
+ // 2. destination fence — at APPROVE the save destination (`session/saveDestination.ts`) must
11
+ // equal what it was at open; a change or an unverifiable capture saves nothing and asks for
12
+ // a fresh human approval.
13
+ // 3. current-review slot — opening a review on ANY surface supersedes the previous one; a
14
+ // decision from a superseded review is ignored loudly (a TUI warning, never an injection).
15
+ // 4. unconfirmed-save latch — after a save attempt that returned no typed receipt, automatic
16
+ // (approval-driven) saves pause for the rest of the activation; the manual save command is
17
+ // the deliberate human retry. Linear's create→marker crash window is why: a blind retry can
18
+ // duplicate an issue the retry cannot find.
19
+ //
20
+ // A browser decision does not survive a Pi restart — the human re-runs the door. No lock, no
21
+ // state file, no reconciliation procedure.
22
+
23
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
+ import { GIST_DRAFT_ARTIFACT } from "../../authoring/gist/draft.ts";
25
+ import { OBJECTIVE_DRAFT_ARTIFACT } from "../../authoring/objective/draft.ts";
26
+ import { PLAN_DRAFT_ARTIFACT } from "../../authoring/plan/draft.ts";
27
+ import { REFINEMENT_DRAFT_ARTIFACT } from "../../authoring/refinement/draft.ts";
28
+ import { openBranchWorkflowSession } from "../../session/branchWorkflowSession.ts";
29
+ import {
30
+ captureSaveDestination,
31
+ changedDestinationComponents,
32
+ type DestinationComponent,
33
+ type SaveDestination,
34
+ } from "../../session/saveDestination.ts";
35
+ import {
36
+ digestSessionData,
37
+ REFINEMENT_CONTEXT_ARTIFACT,
38
+ type WorkflowSession,
39
+ } from "../../session/workflowSession.ts";
40
+ import { type ToolResult, untrustedReviewFeedback } from "./review.ts";
41
+
42
+ // ------------------------------------------------------------------------------ the vocabulary
43
+
44
+ /** The reviewable draft kinds — the stage-derived subject `WorkflowSession.draftReviewContext` reports. */
45
+ export type DraftReviewSubject = "plan" | "objective" | "gist" | "refinement";
46
+
47
+ /** Subject → the session artifact whose raw bytes the reviewed-bytes guard compares. */
48
+ export const REVIEW_SUBJECT_ARTIFACTS: Readonly<Record<DraftReviewSubject, string>> = {
49
+ plan: PLAN_DRAFT_ARTIFACT,
50
+ objective: OBJECTIVE_DRAFT_ARTIFACT,
51
+ gist: GIST_DRAFT_ARTIFACT,
52
+ refinement: REFINEMENT_DRAFT_ARTIFACT,
53
+ };
54
+
55
+ /** Subject → the manual save command (the deliberate retry once the latch is set). */
56
+ export const MANUAL_SAVE_COMMANDS: Readonly<Record<DraftReviewSubject, string>> = {
57
+ plan: "/plan-save",
58
+ objective: "/objective-save",
59
+ gist: "/gist-save",
60
+ refinement: "/objective-refinement-save",
61
+ };
62
+
63
+ /**
64
+ * Where the reviewed bytes came from: `artifact` — the validated draft artifact (the doors and
65
+ * the Plannotator tool arm; the only source the byte compare applies to); `parameter` — the
66
+ * blocking tool's `plan` param (no artifact to compare against); `editor` — a first-party
67
+ * in-TUI review, where the human's own edit write-back legitimately changes the artifact before
68
+ * the verdict.
69
+ */
70
+ export type ReviewSource = "artifact" | "parameter" | "editor";
71
+
72
+ /** The token every review arm holds from `open` to decision. */
73
+ export interface OpenDraftReview {
74
+ readonly subject: DraftReviewSubject;
75
+ readonly runId: string;
76
+ readonly source: ReviewSource;
77
+ /** The exact reviewed bytes (artifact bytes / parameter text / editor text) — never the rendering. */
78
+ readonly raw: string;
79
+ /** What the reviewer was shown (equal to `raw` for markdown drafts). */
80
+ readonly markdown: string;
81
+ /** `digestSessionData(raw)`. */
82
+ readonly reviewedDigest: string;
83
+ /** Refinement only: the strict grounding-context artifact digest at open; null otherwise. */
84
+ readonly contextDigest: string | null;
85
+ readonly destination: SaveDestination;
86
+ /** Whether this review is still the activation's current one (no later `open`/`supersede`). */
87
+ isCurrent(): boolean;
88
+ }
89
+
90
+ export type OpenDraftReviewRefusal =
91
+ | "no-identity"
92
+ | "invalid-state"
93
+ | "subject-mismatch"
94
+ | "destination-unavailable";
95
+
96
+ export type OpenDraftReviewResult =
97
+ | { ok: true; review: OpenDraftReview }
98
+ | { ok: false; reason: OpenDraftReviewRefusal; detail: string };
99
+
100
+ export interface DraftReviewSnapshot {
101
+ subject: DraftReviewSubject;
102
+ source: ReviewSource;
103
+ raw: string;
104
+ markdown: string;
105
+ contextDigest?: string;
106
+ }
107
+
108
+ /** The activation-scoped slot: the current review token plus the unconfirmed-save latch. */
109
+ export interface DraftReviewSlot {
110
+ /** The Pi API the slot's session reads ride (the ladder re-reads through the same seams). */
111
+ readonly pi: ExtensionAPI;
112
+ readonly ports: DraftReviewPorts;
113
+ /**
114
+ * Open a review: verify the session identity and stage-derived subject, capture the save
115
+ * destination, and make this the activation's current review (superseding any other).
116
+ */
117
+ open(ctx: ExtensionContext, snapshot: DraftReviewSnapshot): OpenDraftReviewResult;
118
+ /** Clear the current review (e.g. `/implement-here` exiting plan mode without saving). */
119
+ supersede(): void;
120
+ /** Latch: a save attempt returned no typed receipt. First writer wins; nothing clears it. */
121
+ markUnconfirmed(subject: DraftReviewSubject, detail: string): void;
122
+ unconfirmed(): { subject: DraftReviewSubject; detail: string } | null;
123
+ }
124
+
125
+ export const DESTINATION_UNAVAILABLE_DETAIL =
126
+ "could not read the git remotes to determine the save destination";
127
+
128
+ /** Injectable seams (tests fence destinations without a real repo). */
129
+ export interface DraftReviewPorts {
130
+ session(pi: ExtensionAPI, ctx: ExtensionContext): WorkflowSession;
131
+ destination(
132
+ cwd: string,
133
+ nodeClaim: { objective: string; node: string } | null,
134
+ ): SaveDestination | null;
135
+ }
136
+
137
+ const PRODUCTION_PORTS: DraftReviewPorts = {
138
+ session: openBranchWorkflowSession,
139
+ destination: captureSaveDestination,
140
+ };
141
+
142
+ export function createDraftReviewSlot(
143
+ pi: ExtensionAPI,
144
+ ports: DraftReviewPorts = PRODUCTION_PORTS,
145
+ ): DraftReviewSlot {
146
+ let current: symbol | null = null;
147
+ let latch: { subject: DraftReviewSubject; detail: string } | null = null;
148
+ return {
149
+ pi,
150
+ ports,
151
+ open(ctx, snapshot) {
152
+ const context = ports.session(pi, ctx).draftReviewContext();
153
+ if (!context.ok) {
154
+ return {
155
+ ok: false,
156
+ reason: context.reason,
157
+ detail:
158
+ context.reason === "no-identity"
159
+ ? "this session has no run identity (run_id) to review under"
160
+ : "the session's workflow state could not be read",
161
+ };
162
+ }
163
+ if (context.subject !== snapshot.subject) {
164
+ return {
165
+ ok: false,
166
+ reason: "subject-mismatch",
167
+ detail: `this is a ${context.subject} session, not a ${snapshot.subject} session`,
168
+ };
169
+ }
170
+ const destination = ports.destination(ctx.cwd, context.warmNodeClaim);
171
+ if (destination === null) {
172
+ return {
173
+ ok: false,
174
+ reason: "destination-unavailable",
175
+ detail: DESTINATION_UNAVAILABLE_DETAIL,
176
+ };
177
+ }
178
+ const token = Symbol("draft-review");
179
+ current = token;
180
+ return {
181
+ ok: true,
182
+ review: {
183
+ subject: snapshot.subject,
184
+ runId: context.runId,
185
+ source: snapshot.source,
186
+ raw: snapshot.raw,
187
+ markdown: snapshot.markdown,
188
+ reviewedDigest: digestSessionData(snapshot.raw),
189
+ contextDigest: snapshot.contextDigest ?? null,
190
+ destination,
191
+ isCurrent: () => current === token,
192
+ },
193
+ };
194
+ },
195
+ supersede() {
196
+ current = null;
197
+ },
198
+ markUnconfirmed(subject, detail) {
199
+ if (latch === null) latch = { subject, detail };
200
+ },
201
+ unconfirmed() {
202
+ return latch;
203
+ },
204
+ };
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------- the decision ladder
208
+
209
+ export type DecisionCheck =
210
+ | { kind: "superseded" }
211
+ | { kind: "save-unconfirmed"; subject: DraftReviewSubject; detail: string }
212
+ | { kind: "stale-approval"; reviewedDigest: string }
213
+ | { kind: "destination-changed"; changed: DestinationComponent[] | "unverifiable" }
214
+ | { kind: "proceed"; draftChanged: boolean };
215
+
216
+ /**
217
+ * Run a completed review's decision through the guards, in this order: superseded (the slot
218
+ * moved on, or the live run id / subject differ); the latch (`save` only); the reviewed-bytes
219
+ * compare (`artifact` source only — refinement also compares its grounding-context digest;
220
+ * `save` + changed → `stale-approval`, `revision` + changed → proceed with the note flag); the
221
+ * destination fence (`save` only, EVERY source — first-party included). The slot the review
222
+ * came from supplies the latch and the ports.
223
+ */
224
+ export function checkDraftReviewDecision(
225
+ slot: DraftReviewSlot,
226
+ ctx: ExtensionContext,
227
+ review: OpenDraftReview,
228
+ effect: "save" | "revision",
229
+ ): DecisionCheck {
230
+ if (!review.isCurrent()) return { kind: "superseded" };
231
+ const session = slot.ports.session(slot.pi, ctx);
232
+ const live = session.draftReviewContext();
233
+ if (!live.ok || live.runId !== review.runId || live.subject !== review.subject)
234
+ return { kind: "superseded" };
235
+
236
+ if (effect === "save") {
237
+ const latched = slot.unconfirmed();
238
+ if (latched !== null) return { kind: "save-unconfirmed", ...latched };
239
+ }
240
+
241
+ let draftChanged = false;
242
+ if (review.source === "artifact") {
243
+ const read = session.readArtifact(REVIEW_SUBJECT_ARTIFACTS[review.subject]);
244
+ draftChanged = read.status !== "found" || read.content !== review.raw;
245
+ if (!draftChanged && review.subject === "refinement") {
246
+ const context = session.readArtifact(REFINEMENT_CONTEXT_ARTIFACT, { provenance: "strict" });
247
+ const digest = context.status === "found" ? digestSessionData(context.content) : null;
248
+ draftChanged = digest !== review.contextDigest;
249
+ }
250
+ if (draftChanged && effect === "save")
251
+ return { kind: "stale-approval", reviewedDigest: review.reviewedDigest };
252
+ }
253
+
254
+ if (effect === "save") {
255
+ const current = slot.ports.destination(ctx.cwd, live.warmNodeClaim);
256
+ if (current === null) return { kind: "destination-changed", changed: "unverifiable" };
257
+ const changed = changedDestinationComponents(review.destination, current);
258
+ if (changed.length > 0) return { kind: "destination-changed", changed };
259
+ }
260
+
261
+ return { kind: "proceed", draftChanged };
262
+ }
263
+
264
+ // ------------------------------------------------------------------------- the fixed model texts
265
+
266
+ function feedbackSuffix(feedback: string | undefined): string {
267
+ return feedback ? `\n\nReviewer feedback (DATA):\n${untrustedReviewFeedback(feedback)}` : "";
268
+ }
269
+
270
+ /** An APPROVE whose reviewed bytes no longer match the live draft: nothing saved. */
271
+ export function staleApprovalResult(
272
+ subject: DraftReviewSubject,
273
+ reviewedDigest: string,
274
+ feedback?: string,
275
+ ): ToolResult {
276
+ return {
277
+ content: [
278
+ {
279
+ type: "text",
280
+ text:
281
+ `The human APPROVED the ${subject}, but the working draft changed after the review ` +
282
+ `opened (reviewed digest ${reviewedDigest}). Nothing was saved and the session's mode ` +
283
+ `is unchanged. Call plan_review to review the current draft.${feedbackSuffix(feedback)}`,
284
+ },
285
+ ],
286
+ details: { ok: true, status: "stale-approval", subject, reviewed_digest: reviewedDigest },
287
+ };
288
+ }
289
+
290
+ /** An APPROVE whose save destination moved (or cannot be verified): nothing saved. */
291
+ export function destinationChangedResult(
292
+ subject: DraftReviewSubject,
293
+ changed: DestinationComponent[] | "unverifiable",
294
+ feedback?: string,
295
+ ): ToolResult {
296
+ const named = changed === "unverifiable" ? "could not be verified" : changed.join(", ");
297
+ return {
298
+ content: [
299
+ {
300
+ type: "text",
301
+ text:
302
+ `The human APPROVED the ${subject}, but the save destination changed while the review ` +
303
+ `was open (changed: ${named}). Nothing was saved; the working draft is unchanged and ` +
304
+ "still editable. Continue editing if needed, then call plan_review to open a fresh " +
305
+ "review against the current destination — a fresh human approval is required before " +
306
+ `any save.${feedbackSuffix(feedback)}`,
307
+ },
308
+ ],
309
+ details: { ok: true, status: "destination-changed", subject, changed },
310
+ };
311
+ }
312
+
313
+ /** An APPROVE while the unconfirmed-save latch is set: automatic saves are paused. */
314
+ export function saveUnconfirmedResult(
315
+ subject: DraftReviewSubject,
316
+ runId: string,
317
+ detail: string,
318
+ feedback?: string,
319
+ ): ToolResult {
320
+ return {
321
+ content: [
322
+ {
323
+ type: "text",
324
+ text:
325
+ `The human APPROVED the ${subject}, but an earlier save attempt in this session did ` +
326
+ `not confirm (${detail}), so automatic saves are paused for this session. Nothing new ` +
327
+ `was saved. Ask the human to check the issue backend for an existing ${subject} ` +
328
+ `carrying run id ${runId} before retrying — on Linear a partially completed create can ` +
329
+ `leave an issue the retry cannot find — then run ${MANUAL_SAVE_COMMANDS[subject]} (the ` +
330
+ `deliberate retry) or continue in the existing saved object.${feedbackSuffix(feedback)}`,
331
+ },
332
+ ],
333
+ details: { ok: false, error_type: "save_unconfirmed", status: "refused", subject },
334
+ };
335
+ }
336
+
337
+ /**
338
+ * A blocking review whose verdict arrived after a newer review superseded it (another surface
339
+ * opened, or `/implement-here` retired it): ignored; nothing saved.
340
+ */
341
+ export function supersededReviewResult(subject: DraftReviewSubject): ToolResult {
342
+ return {
343
+ content: [
344
+ {
345
+ type: "text",
346
+ text:
347
+ `This ${subject} review was superseded by a newer review before its decision arrived — ` +
348
+ "the decision was ignored and nothing was saved. Call plan_review again if a review is " +
349
+ "still wanted.",
350
+ },
351
+ ],
352
+ details: { ok: false, error_type: "review_superseded", status: "superseded", subject },
353
+ };
354
+ }
355
+
356
+ /** Prepended to a revision result's first text block when the draft moved during the review. */
357
+ export const DRAFT_CHANGED_NOTE =
358
+ "Note: the working draft changed after this review opened — weigh the feedback against the current draft.";
359
+
360
+ /** TUI `report()` only — never injected into the model's context. */
361
+ export const SUPERSEDED_DECISION_WARNING =
362
+ "a browser decision arrived for a superseded review — ignored; nothing was saved";
363
+
364
+ /** A slot `open` refusal rendered as the blocking tool's non-terminating result. */
365
+ export function openRefusedResult(
366
+ refusal: Extract<OpenDraftReviewResult, { ok: false }>,
367
+ ): ToolResult {
368
+ return {
369
+ content: [
370
+ {
371
+ type: "text",
372
+ text: `cannot open the review: ${refusal.detail} — fix the cause and call plan_review again`,
373
+ },
374
+ ],
375
+ details: {
376
+ ok: false,
377
+ error_type: "review_open_refused",
378
+ status: "refused",
379
+ reason: refusal.reason,
380
+ },
381
+ };
382
+ }
383
+
384
+ /** Prefix `DRAFT_CHANGED_NOTE` onto a result's first text block (a new object; the input is untouched). */
385
+ export function withDraftChangedNote(result: ToolResult, draftChanged: boolean): ToolResult {
386
+ if (!draftChanged) return result;
387
+ const [first, ...rest] = result.content;
388
+ if (first === undefined || first.type !== "text")
389
+ return { ...result, content: [{ type: "text", text: DRAFT_CHANGED_NOTE }, ...result.content] };
390
+ return {
391
+ ...result,
392
+ content: [{ ...first, text: `${DRAFT_CHANGED_NOTE}\n\n${first.text}` }, ...rest],
393
+ };
394
+ }
395
+
396
+ // ------------------------------------------------------------------------------- the effects
397
+
398
+ /**
399
+ * Deliver a browser decision's rendered result to the model: the text blocks joined with `\n`,
400
+ * as a fresh user message when the agent is idle, else queued as a follow-up.
401
+ */
402
+ export function injectDraftReviewResult(
403
+ pi: ExtensionAPI,
404
+ ctx: ExtensionContext,
405
+ result: ToolResult,
406
+ ): void {
407
+ const text = result.content.map((block) => block.text).join("\n");
408
+ if (ctx.isIdle()) pi.sendUserMessage(text);
409
+ else pi.sendUserMessage(text, { deliverAs: "followUp" });
410
+ }
411
+
412
+ /**
413
+ * The one place a completed save reports into the latch. `confirmed` is the subject's typed
414
+ * saved/approvedSaved arm; anything else (`save-failed`, a thrown backend call, an
415
+ * `unavailable` port) latches with the outcome's message. Called by every subject completion
416
+ * AND by the manual save tools/commands — a manual save that fails latches too (though manual
417
+ * saves never consult the latch: they ARE the deliberate retry).
418
+ */
419
+ export function recordSaveOutcome(
420
+ slot: DraftReviewSlot,
421
+ subject: DraftReviewSubject,
422
+ outcome: { confirmed: boolean; detail?: string },
423
+ ): void {
424
+ if (outcome.confirmed) return;
425
+ slot.markUnconfirmed(subject, outcome.detail?.trim() || "backend call did not return a receipt");
426
+ }
427
+
428
+ /** Read the first text block of a tool result (the save message a failed save carries). */
429
+ export function firstText(result: ToolResult): string | undefined {
430
+ return result.content[0]?.text;
431
+ }