@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,487 @@
1
+ // The SECOND 3rd-party plan adapter — and the first with the AUGMENT posture. A perk-owned shim
2
+ // that enables `@plannotator/pi-extension` as a REAL, selectable plan provider: unlike the tombell
3
+ // adapter (REPLACE posture — perk's plan surface fully vacates), plannotator AUGMENTS perk's plan
4
+ // flow. perk's `/plan` mode, authoring injection, and read-only gate STAY (the plan installer
5
+ // skips only the `--plan` flag + `Ctrl+Alt+P` shortcut — the two real registration collisions).
6
+ //
7
+ // INJECTION + BRIDGE ONLY: the `plan_review` TOOL lives in `pi/v1/plan.ts` (perk's
8
+ // backend-neutral review door); this module is the injection-only adapter shape. It owns
9
+ // (1) the plannotator review-step authoring context (injected while the gate is active AND
10
+ // plannotator is selected — THREE content flavors, one customType, each once-only:
11
+ // scan-dedup'd on the flavor's marker: the plan bridge context, the objective flavor when the
12
+ // stage is `objective-author` or `objective-save` (both objective stages route to the objective
13
+ // review arm), or the gist flavor when the stage is `gist-author`) and (2) the pure
14
+ // event-bus bridge
15
+ // (`requestPlannotatorPlanReview`; `createPlannotatorBridge` is its thin structural wrapper)
16
+ // that the review door dispatches to when plannotator is the selected plan provider and the
17
+ // plan-review browser open (plannotatorHandoff.ts) launches. The bridge speaks plannotator's
18
+ // published `plannotator:request` event API (in-process `pi.events` bus); the decision wait is
19
+ // a per-review `plannotator:review-result` listener disposed via the unsubscribe pi's
20
+ // `EventBus.on` returns.
21
+ //
22
+ // Foreign handshake/decision values are contained unknown input, never invented verdicts. The
23
+ // result listener is installed BEFORE the request is emitted (an early-decision buffer bridges
24
+ // the handshake gap), so no status catch-up query exists.
25
+ //
26
+ // INERT BY DEFAULT. The shim is ALWAYS registered in index.ts but the injection fires only when
27
+ // the resolved `[providers] plan` selection is `plannotator-plan` (read fresh per-event, same
28
+ // shape as the plan installer / tombell adapter). On any other selection the context handler
29
+ // only strips its own stale marker — zero behavior change on the default path.
30
+ //
31
+ // INVARIANTS HELD: never calls `setActiveTools`, never registers a `tool_call` handler, never
32
+ // restamps `cache.plan-ref.provider` (stays `"github"`). The adapter is INJECTION-ONLY again
33
+ // (Invariant 1: composes, never owns) — the review tool, the `approvalSave` composition, and the
34
+ // gate exit all live behind the plan installer's seams; the injection's gate-active check reads
35
+ // the persisted `perk:workflow-state.mode`, the gate's own state twin.
36
+ //
37
+ // EVENT ENVELOPE (pinned against `@plannotator/pi-extension@0.20.0`, `plannotator-events.ts`):
38
+ // request — pi.events.emit("plannotator:request", { requestId, action: "plan-review",
39
+ // payload: { planContent, origin? }, respond }) // respond = in-payload callback
40
+ // handshake — respond({ status: "handled", result: { status: "pending", reviewId } })
41
+ // | respond({ status: "unavailable", error? }) | respond({ status: "error", error })
42
+ // decision — pi.events.on("plannotator:review-result", { reviewId, approved, feedback?, ... })
43
+ //
44
+ // DIRECT EDITS FEEDBACK FORMAT (pinned against plannotator `packages/editor/directEdits.ts`,
45
+ // `buildDirectEditsSection` / `composeFeedbackWithDirectEdits`, at v0.26.1). The browser's
46
+ // direct-edit mode arrives as PROSE inside the existing `feedback` string, never a new envelope
47
+ // field: `# Direct Edits\n` + blank line + a one-sentence preamble (two wording variants — never
48
+ // couple to it) + blank line + a ```diff fence containing
49
+ // `createTwoFilesPatch('plan.md (original)', 'plan.md (edited)', base, edited, undefined,
50
+ // undefined, { context: 3 }).trimEnd()` against the exact bytes perk submitted. The section is
51
+ // composed FIRST; non-sentinel annotation feedback follows after `\n\n---\n\n`; edits-only
52
+ // feedback is just the section. `extractDirectEdits` below parses it strictly (fail-open — a
53
+ // null degrades to today's verbatim behavior).
54
+
55
+ import { randomUUID } from "node:crypto";
56
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
57
+ import { GIST_AUTHOR_STAGE } from "../../../authoring/gist/draft.ts";
58
+ import {
59
+ OBJECTIVE_AUTHOR_STAGE,
60
+ OBJECTIVE_SAVE_STAGE,
61
+ } from "../../../authoring/objective/prose.ts";
62
+ import { REFINE_STAGE } from "../../../authoring/refinement/context.ts";
63
+ import { render } from "../../../substrate/prompts.ts";
64
+ import { rebuildWorkflowState } from "../../../substrate/workflowState.ts";
65
+ import { ACTIVITY_BROWSER_REVIEW, type ActivitySink } from "../../../surfaces/surfaces.ts";
66
+ import { installInjectedContext, isPlanGuidanceStage } from "../contextInjection.ts";
67
+ import type { ReviewOutcome } from "../reviewOutcome.ts";
68
+ import { isPlannotatorPlanSelected } from "./selection.ts";
69
+
70
+ /** The plannotator plan-adapter bridge customType (distinct from the `perk:plan-context`). */
71
+ export const PLAN_ADAPTER_PLANNOTATOR_CONTEXT_TYPE = "perk:plan-adapter-plannotator";
72
+ const PLAN_ADAPTER_PLANNOTATOR_MARKER = "[PLAN ADAPTER: PLANNOTATOR]";
73
+ const OBJECTIVE_ADAPTER_PLANNOTATOR_MARKER = "[OBJECTIVE ADAPTER: PLANNOTATOR]";
74
+ const GIST_ADAPTER_PLANNOTATOR_MARKER = "[GIST ADAPTER: PLANNOTATOR]";
75
+ const REFINEMENT_ADAPTER_PLANNOTATOR_MARKER = "[REFINEMENT ADAPTER: PLANNOTATOR]";
76
+
77
+ /**
78
+ * The handshake timeout for plannotator's immediate `respond` callback (mirrors plannotator's own
79
+ * `PLANNOTATOR_TIMEOUT_MS = 5_000`). Overridable for tests via PERK_PLANNOTATOR_HANDSHAKE_MS.
80
+ */
81
+ export const PLANNOTATOR_HANDSHAKE_TIMEOUT_MS = 5_000;
82
+
83
+ function handshakeTimeoutMs(): number {
84
+ const raw = Number(process.env.PERK_PLANNOTATOR_HANDSHAKE_MS ?? "");
85
+ return Number.isFinite(raw) && raw > 0 ? raw : PLANNOTATOR_HANDSHAKE_TIMEOUT_MS;
86
+ }
87
+
88
+ /**
89
+ * The augment-posture bridge prompt: perk's plan-authoring discipline plus the plannotator review
90
+ * step. Prompting, NOT enforcement (perk's own gate is the read-only authority). Durable anchors
91
+ * only — mirrors PLAN_AUTHORING_CONTEXT, which is also injected (perk's plan mode stays).
92
+ */
93
+ export const PLAN_ADAPTER_PLANNOTATOR_CONTEXT = render("contexts/adapters/plannotator-plan.md", {
94
+ marker: PLAN_ADAPTER_PLANNOTATOR_MARKER,
95
+ });
96
+
97
+ /**
98
+ * The objective flavor of the bridge prompt, injected in an objective-authoring session
99
+ * (stage `objective-author` or `objective-save`) instead of the plan flavor. An APPROVED review auto-saves the
100
+ * objective via the `objectiveApprovalSave` seam; `/objective-save` is the manual failsafe on
101
+ * the skipped/unavailable arms.
102
+ */
103
+ export const OBJECTIVE_ADAPTER_PLANNOTATOR_CONTEXT = render(
104
+ "contexts/adapters/plannotator-objective.md",
105
+ { marker: OBJECTIVE_ADAPTER_PLANNOTATOR_MARKER },
106
+ );
107
+
108
+ /**
109
+ * The gist flavor of the bridge prompt, injected in a `gist-author` session instead of the
110
+ * plan/objective flavors. The review surface renders the gist draft (title + scope line +
111
+ * prose); an approval carrying `# Direct Edits` does NOT auto-save — the model folds the diff
112
+ * into the matching `gist_draft` fields and re-reviews (contracts.md §8.23's gist arm).
113
+ */
114
+ /**
115
+ * The refinement flavor of the bridge prompt, injected in an `objective-refine` session. The
116
+ * review surface renders the (draft, context) pair; header hunks are bound metadata (a new
117
+ * grounding pass), Markdown hunks fold into one `objective_refinement_draft` rewrite
118
+ * (contracts.md §8.23's refinement arm).
119
+ */
120
+ export const REFINEMENT_ADAPTER_PLANNOTATOR_CONTEXT = render(
121
+ "contexts/adapters/plannotator-refinement.md",
122
+ { marker: REFINEMENT_ADAPTER_PLANNOTATOR_MARKER },
123
+ );
124
+
125
+ export const GIST_ADAPTER_PLANNOTATOR_CONTEXT = render("contexts/adapters/plannotator-gist.md", {
126
+ marker: GIST_ADAPTER_PLANNOTATOR_MARKER,
127
+ });
128
+
129
+ // ------------------------------------------------------------------ the event-bus bridge core
130
+
131
+ /** The minimal `pi.events` surface the bridge needs (mirrors pi's EventBus, whose `on` returns an unsubscribe function). */
132
+ export interface PlannotatorBus {
133
+ emit(channel: string, data: unknown): void;
134
+ on(channel: string, handler: (data: unknown) => void): () => void;
135
+ }
136
+
137
+ export interface PlannotatorTimers {
138
+ setTimeout(callback: () => void, ms: number): ReturnType<typeof setTimeout>;
139
+ clearTimeout(handle: ReturnType<typeof setTimeout>): void;
140
+ }
141
+ const defaultTimers: PlannotatorTimers = {
142
+ setTimeout: (callback, ms) => globalThis.setTimeout(callback, ms),
143
+ clearTimeout: (handle) => globalThis.clearTimeout(handle),
144
+ };
145
+
146
+ /** Plannotator's immediate `respond` handshake payload (pinned envelope, see header). */
147
+ interface HandshakeResponse {
148
+ status?: string;
149
+ error?: string;
150
+ result?: { status?: string; reviewId?: string };
151
+ }
152
+
153
+ /**
154
+ * Narrow the foreign `respond` payload to the load-bearing handshake fields — contained: an
155
+ * adversarial getter/malformed shape degrades to `{}` (the downstream "invalid response" arm),
156
+ * never a throw; well-typed fields pass through byte-identically.
157
+ */
158
+ function parseHandshakeResponse(response: unknown): HandshakeResponse {
159
+ try {
160
+ if (typeof response !== "object" || response === null || Array.isArray(response)) return {};
161
+ const record = response as Record<string, unknown>;
162
+ const status = record.status;
163
+ const error = record.error;
164
+ const result = record.result;
165
+ let narrowedResult: { status?: string; reviewId?: string } | undefined;
166
+ if (typeof result === "object" && result !== null && !Array.isArray(result)) {
167
+ const r = result as Record<string, unknown>;
168
+ const resultStatus = r.status;
169
+ const reviewId = r.reviewId;
170
+ narrowedResult = {
171
+ ...(typeof resultStatus === "string" ? { status: resultStatus } : {}),
172
+ ...(typeof reviewId === "string" ? { reviewId } : {}),
173
+ };
174
+ }
175
+ return {
176
+ ...(typeof status === "string" ? { status } : {}),
177
+ ...(typeof error === "string" ? { error } : {}),
178
+ ...(narrowedResult !== undefined ? { result: narrowedResult } : {}),
179
+ };
180
+ } catch {
181
+ return {};
182
+ }
183
+ }
184
+
185
+ type Decision = { reviewId: string; approved: boolean; feedback?: string };
186
+
187
+ /**
188
+ * Narrow a foreign `plannotator:review-result` payload to the load-bearing decision fields —
189
+ * contained: an adversarial getter/malformed shape degrades to `null` (ignored, the wait
190
+ * continues), never a throw. `approved` must be an actual boolean: a decision is a human
191
+ * verdict, so a missing/mistyped approval field makes the whole payload malformed (ignored) —
192
+ * it must never coerce into a DENY that completes a live review.
193
+ */
194
+ function parseReviewDecision(data: unknown): Decision | null {
195
+ try {
196
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
197
+ const record = data as Record<string, unknown>;
198
+ const reviewId = record.reviewId;
199
+ if (typeof reviewId !== "string" || !reviewId.trim()) return null;
200
+ const approved = record.approved;
201
+ if (typeof approved !== "boolean") return null;
202
+ const feedback = record.feedback;
203
+ return {
204
+ reviewId,
205
+ approved,
206
+ ...(typeof feedback === "string" && feedback.trim() ? { feedback } : {}),
207
+ };
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * The pure, offline-testable plan-review bridge (the ergonomic mirror of
215
+ * `requestPlannotatorCodeReview` in plannotatorHandoff.ts): subscribe to
216
+ * `plannotator:review-result` FIRST, emit ONE `plannotator:request` with `action: "plan-review"`,
217
+ * await the bounded `respond` handshake, then await the human decision on the listener (no
218
+ * timeout — the reviewer takes as long as they take), honoring a turn abort so an interrupted
219
+ * session never leaks a wedged promise.
220
+ *
221
+ * Subscribe-before-emit closes the handshake gap: a decision emitted synchronously inside (or
222
+ * right after) the `respond` callback — before the handshake promise resolves — lands in the
223
+ * `early` buffer; once the handshake yields the `reviewId`, the buffer is scanned for the first
224
+ * matching decision (which completes the review) and discarded. No status catch-up query is
225
+ * needed and none is emitted. Every exit (completion, abort, handshake failure/timeout) removes
226
+ * the listener and clears the timer.
227
+ */
228
+ export async function requestPlannotatorPlanReview(
229
+ bus: PlannotatorBus,
230
+ plan: string,
231
+ signal?: AbortSignal,
232
+ timers: PlannotatorTimers = defaultTimers,
233
+ ): Promise<ReviewOutcome> {
234
+ if (signal?.aborted) return { status: "aborted" };
235
+
236
+ const requestId = randomUUID();
237
+
238
+ // 1. The result listener — installed BEFORE the request goes out. Until the handshake attaches
239
+ // a reviewId, decisions are buffered; afterwards a live match completes the wait.
240
+ let reviewId: string | null = null;
241
+ const early: Decision[] = [];
242
+ let settleDecision: ((outcome: ReviewOutcome) => void) | null = null;
243
+ let unsubscribe: (() => void) | undefined;
244
+ const dispose = (): void => {
245
+ const release = unsubscribe;
246
+ unsubscribe = undefined;
247
+ try {
248
+ release?.();
249
+ } catch {
250
+ // A throwing unsubscribe must not mask the outcome being delivered.
251
+ }
252
+ };
253
+ try {
254
+ unsubscribe = bus.on("plannotator:review-result", (data) => {
255
+ const decision = parseReviewDecision(data);
256
+ if (decision === null) return;
257
+ if (reviewId === null) {
258
+ early.push(decision);
259
+ return;
260
+ }
261
+ if (decision.reviewId !== reviewId || settleDecision === null) return;
262
+ settleDecision({ status: "completed", ...decision });
263
+ });
264
+ } catch {
265
+ return { status: "unavailable", warning: "plannotator result subscription failed" };
266
+ }
267
+
268
+ // 2. The bounded handshake.
269
+ let handshakeSettled = false;
270
+ let respondResolve: (response: HandshakeResponse | "timeout" | "aborted") => void = () => {};
271
+ const handshake = new Promise<HandshakeResponse | "timeout" | "aborted">((resolve) => {
272
+ respondResolve = (response) => {
273
+ if (handshakeSettled) return;
274
+ handshakeSettled = true;
275
+ resolve(response);
276
+ };
277
+ });
278
+ const timer = timers.setTimeout(() => respondResolve("timeout"), handshakeTimeoutMs());
279
+ const onHandshakeAbort = (): void => respondResolve("aborted");
280
+ signal?.addEventListener("abort", onHandshakeAbort, { once: true });
281
+ const settleHandshake = (): void => {
282
+ timers.clearTimeout(timer);
283
+ signal?.removeEventListener("abort", onHandshakeAbort);
284
+ };
285
+ try {
286
+ bus.emit("plannotator:request", {
287
+ requestId,
288
+ action: "plan-review",
289
+ payload: { planContent: plan, origin: "perk" },
290
+ respond: (response: unknown) => {
291
+ if (!handshakeSettled) respondResolve(parseHandshakeResponse(response));
292
+ },
293
+ });
294
+ } catch {
295
+ settleHandshake();
296
+ dispose();
297
+ return { status: "unavailable", warning: "plannotator review request failed" };
298
+ }
299
+ const response = await handshake;
300
+ settleHandshake();
301
+
302
+ const fail = (outcome: ReviewOutcome): ReviewOutcome => {
303
+ dispose();
304
+ return outcome;
305
+ };
306
+ if (response === "aborted") return fail({ status: "aborted" });
307
+ if (response === "timeout") {
308
+ return fail({
309
+ status: "unavailable",
310
+ warning: "plannotator did not respond to the review request (handshake timeout)",
311
+ });
312
+ }
313
+ if (response?.status !== "handled") {
314
+ const detail = response?.error ? `: ${response.error}` : "";
315
+ return fail({
316
+ status: "unavailable",
317
+ warning: `plannotator reported ${response?.status ?? "an invalid response"}${detail}`,
318
+ });
319
+ }
320
+ const id = response.result?.reviewId;
321
+ if (response.result?.status !== "pending" || typeof id !== "string" || !id.trim()) {
322
+ return fail({
323
+ status: "unavailable",
324
+ warning: "plannotator handshake returned no pending reviewId",
325
+ });
326
+ }
327
+ // A usable ID wins the handshake race: preserve pending even if abort followed respond.
328
+ if (signal?.aborted) return fail({ status: "aborted" });
329
+
330
+ // 3. Attach the id: an early decision for THIS review completes immediately; the rest of the
331
+ // buffer (other reviews' decisions) is discarded.
332
+ reviewId = id;
333
+ const buffered = early.find((decision) => decision.reviewId === id);
334
+ early.length = 0;
335
+ if (buffered !== undefined) return fail({ status: "completed", ...buffered });
336
+
337
+ // 4. Await the live decision (or the abort). Either exit disposes the listener.
338
+ return await new Promise<ReviewOutcome>((resolve) => {
339
+ let settled = false;
340
+ const finish = (outcome: ReviewOutcome): void => {
341
+ if (settled) return;
342
+ settled = true;
343
+ settleDecision = null;
344
+ dispose();
345
+ signal?.removeEventListener("abort", onAbort);
346
+ resolve(outcome);
347
+ };
348
+ const onAbort = (): void => finish({ status: "aborted" });
349
+ settleDecision = finish;
350
+ signal?.addEventListener("abort", onAbort, { once: true });
351
+ });
352
+ }
353
+
354
+ /**
355
+ * Create the plannotator bridge over an event bus — the thin structural slice
356
+ * (`{ review(plan, signal) }`) injected into the review door; the body lives in
357
+ * `requestPlannotatorPlanReview`. The `activity` wait spans the whole blocking review (begun
358
+ * before delegating, ended in `finally` — decision, abort or unavailable alike): every warm
359
+ * arm (plan, objective, gist, refinement) reviews through here, so this one site covers them.
360
+ */
361
+ export function createPlannotatorBridge(
362
+ bus: PlannotatorBus,
363
+ activity: ActivitySink,
364
+ ): {
365
+ review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome>;
366
+ } {
367
+ return {
368
+ async review(plan, signal) {
369
+ const end = activity(ACTIVITY_BROWSER_REVIEW);
370
+ try {
371
+ return await requestPlannotatorPlanReview(bus, plan, signal);
372
+ } finally {
373
+ end();
374
+ }
375
+ },
376
+ };
377
+ }
378
+
379
+ // ------------------------------------------------------------------ Direct Edits extraction
380
+
381
+ const DIRECT_EDITS_HEADING = "# Direct Edits";
382
+ const DIFF_FENCE_OPEN = "```diff\n";
383
+ const REMAINDER_SEPARATOR = "\n\n---\n\n";
384
+
385
+ /**
386
+ * Whether `feedback` OPENS with the Direct Edits heading (plan-review feedback composes the
387
+ * section first — a heading anywhere else is quoted prose, not a section). Callers pair this
388
+ * with `extractDirectEdits`: heading present but extraction null means the section was seen but
389
+ * could not be honored (the fail-open ladder's loud-warning arm).
390
+ */
391
+ export function hasDirectEditsHeading(feedback: string): boolean {
392
+ return feedback === DIRECT_EDITS_HEADING || feedback.startsWith(`${DIRECT_EDITS_HEADING}\n`);
393
+ }
394
+
395
+ /**
396
+ * Strictly extract the Direct Edits unified diff from a plannotator review-result `feedback`
397
+ * string (the format pin lives in the module header). Returns the fence body as `diff` plus the
398
+ * annotation `remainder` after the section (one leading `\n\n---\n\n` separator stripped;
399
+ * `undefined` when blank). Null means "no extractable Direct Edits section" — both the
400
+ * no-section case AND a present-heading-but-unparseable body (callers distinguish the two via
401
+ * `hasDirectEditsHeading`). The preamble prose between the heading and the fence is skipped
402
+ * without inspecting its wording (plannotator ships two variants).
403
+ */
404
+ export function extractDirectEdits(feedback: string): { diff: string; remainder?: string } | null {
405
+ if (!hasDirectEditsHeading(feedback)) return null;
406
+ const openIdx = feedback.indexOf(`\n${DIFF_FENCE_OPEN}`, DIRECT_EDITS_HEADING.length);
407
+ if (openIdx === -1) return null;
408
+ const bodyStart = openIdx + 1 + DIFF_FENCE_OPEN.length;
409
+ // The closing fence is the first line that is exactly ``` — unambiguous inside the body,
410
+ // because every diff body line carries a prefix char (` `/`-`/`+`/`\`/`@`), so no body line
411
+ // can start with a backtick.
412
+ let close = -1;
413
+ let searchFrom = bodyStart;
414
+ while (close === -1) {
415
+ const idx = feedback.indexOf("\n```", searchFrom);
416
+ if (idx === -1) return null;
417
+ const after = feedback[idx + 4];
418
+ if (after === undefined || after === "\n") {
419
+ close = idx;
420
+ } else {
421
+ searchFrom = idx + 4;
422
+ }
423
+ }
424
+ const diff = feedback.slice(bodyStart, close);
425
+ if (diff.trim() === "") return null;
426
+ let rest = feedback.slice(close + 4);
427
+ if (rest.startsWith(REMAINDER_SEPARATOR)) rest = rest.slice(REMAINDER_SEPARATOR.length);
428
+ return { diff, remainder: rest.trim() === "" ? undefined : rest };
429
+ }
430
+
431
+ // ----------------------------------------------------------------------------- registration
432
+
433
+ /**
434
+ * Install the plannotator plan adapter: the augment-posture authoring-context injection, inert
435
+ * unless `[providers] plan = "plannotator-plan"`. INJECTION-ONLY (Invariant 1: composes, never
436
+ * owns) — the `plan_review` tool lives in the plan installer (the backend-neutral review door),
437
+ * which dispatches to this module's bridge when plannotator is selected; the adapter itself
438
+ * never arbitrates tools and needs no gating.
439
+ */
440
+ export function installPlannotatorPlanAdapter(pi: ExtensionAPI, runnerChild: () => boolean): void {
441
+ // Inject the bridge context while the read-only gate is active AND plannotator is selected.
442
+ // Four content flavors, one customType, dispatched on the stage: an objective-authoring
443
+ // session (BOTH objective stages: `plan_review` routes objective-author AND objective-save to
444
+ // the objective review arm) gets the objective flavor (the review surface renders the
445
+ // objective draft), a gist-author session gets the gist flavor (the rendered gist draft), an
446
+ // `objective-refine` session gets the refinement flavor (the review surface renders the
447
+ // (draft, context) pair), and every other stage `isPlanGuidanceStage` admits gets the plan
448
+ // flavor (so the `audit` door — and a runner child, fenced in the shared helper — get
449
+ // nothing). Every flavor sits behind the same gate check: a refinement stage left on the
450
+ // branch after the approved save exited the gate selects nothing. The gate signal is the
451
+ // persisted `perk:workflow-state.mode` (the gate's state twin) — never the gate itself.
452
+ //
453
+ // Once-only PER FLAVOR: the dedup key is the SELECTED flavor's marker (not the shared
454
+ // customType), so a stage change still delivers the missing flavor while a prior copy of
455
+ // another flavor sits on the branch. Retention follows selection: the shared helper removes
456
+ // obsolete sibling flavors (a warm `/objective-refine` after a plan-mode turn leaves only the
457
+ // refinement flavor directing the model) and every owned copy once nothing is selected.
458
+ installInjectedContext(
459
+ pi,
460
+ {
461
+ customType: PLAN_ADAPTER_PLANNOTATOR_CONTEXT_TYPE,
462
+ flavors: {
463
+ [PLAN_ADAPTER_PLANNOTATOR_MARKER]: () => PLAN_ADAPTER_PLANNOTATOR_CONTEXT,
464
+ [OBJECTIVE_ADAPTER_PLANNOTATOR_MARKER]: () => OBJECTIVE_ADAPTER_PLANNOTATOR_CONTEXT,
465
+ [GIST_ADAPTER_PLANNOTATOR_MARKER]: () => GIST_ADAPTER_PLANNOTATOR_CONTEXT,
466
+ [REFINEMENT_ADAPTER_PLANNOTATOR_MARKER]: () => REFINEMENT_ADAPTER_PLANNOTATOR_CONTEXT,
467
+ },
468
+ select: (ctx, branch) => {
469
+ if (!isPlannotatorPlanSelected(ctx.cwd)) return null;
470
+ const state = rebuildWorkflowState(branch);
471
+ if (state.mode !== "read-only") return null;
472
+ switch (state.stage) {
473
+ case OBJECTIVE_AUTHOR_STAGE:
474
+ case OBJECTIVE_SAVE_STAGE:
475
+ return OBJECTIVE_ADAPTER_PLANNOTATOR_MARKER;
476
+ case GIST_AUTHOR_STAGE:
477
+ return GIST_ADAPTER_PLANNOTATOR_MARKER;
478
+ case REFINE_STAGE:
479
+ return REFINEMENT_ADAPTER_PLANNOTATOR_MARKER;
480
+ default:
481
+ return isPlanGuidanceStage(state.stage) ? PLAN_ADAPTER_PLANNOTATOR_MARKER : null;
482
+ }
483
+ },
484
+ },
485
+ runnerChild,
486
+ );
487
+ }