@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,494 @@
1
+ // The PRIVATE SDK adapter behind the stage-execution seam (contracts.md §8.11).
2
+ //
3
+ // Every `@earendil-works/*` import on the drive path lives HERE — construction, raw session
4
+ // events (translated at the boundary into the perk-owned `StageEvent` union), and prompt/abort
5
+ // ownership are adapter-confined so the seam (`stageExecution.ts`) carries no SDK vocabulary on
6
+ // its caller surface and folds policy (budget, terminal capture, outcome) over perk shapes only. The only production
7
+ // importer is the seam itself (enforced by `extension/importDirectionGuard.test.ts` Rule F);
8
+ // tests import this module deliberately (to mint `WorkerModelSelection` and drive the handle).
9
+ //
10
+ // The opacity contract (narrow, stated exactly): `WorkerModelSelection` is *nominal* —
11
+ // `#private` fields make structural forgery impossible — and is minted only here (production
12
+ // imports of this module are guard-banned outside the seam). SDK types still appear on this
13
+ // adapter-owned class surface; the caller-side guarantee is the import-edge ban plus nominal
14
+ // minting, nothing stronger.
15
+
16
+ import { mkdtempSync, rmSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ // pi-ai's `ModelThinkingLevel` (`"off" | minimal | … | xhigh`) is the union `resolveCliModel`
20
+ // returns and `createAgentSessionFromServices` accepts; the pi-coding-agent root does not
21
+ // re-export a thinking-level type (only `ThinkingLevelChangeEntry`).
22
+ import type { Api, Model, ModelThinkingLevel as ThinkingLevel } from "@earendil-works/pi-ai";
23
+ import {
24
+ type CreateAgentSessionRuntimeFactory,
25
+ createAgentSessionFromServices,
26
+ createAgentSessionRuntime,
27
+ createAgentSessionServices,
28
+ ModelRuntime,
29
+ resolveCliModel,
30
+ SessionManager,
31
+ SettingsManager,
32
+ } from "@earendil-works/pi-coding-agent";
33
+
34
+ // --- structural shapes (kept minimal so pure helpers stay offline-testable) ---------------------
35
+
36
+ /** The slice of an agent session event the worker reads (structural — see agent-session.d.ts). */
37
+ export interface DriveEvent {
38
+ type: string;
39
+ toolName?: string;
40
+ result?: unknown;
41
+ isError?: boolean;
42
+ message?: {
43
+ role?: string;
44
+ stopReason?: string;
45
+ errorMessage?: string;
46
+ /**
47
+ * Assistant token usage. `reasoning` is a provider-reported breakdown that is a **subset of
48
+ * `output`** on every pi-ai provider that populates it (anthropic `thinking_tokens`, google
49
+ * `thoughtsTokenCount` folded into `output`, openai `reasoning_tokens` inside completion/
50
+ * output tokens — verified @ pi-ai 0.80.5), so it is deliberately EXCLUDED from the budget
51
+ * sum: adding it would double-count.
52
+ */
53
+ usage?: { input?: number; output?: number; reasoning?: number };
54
+ };
55
+ }
56
+
57
+ /** The session surface the worker drives (structurally satisfied by pi's `AgentSession`). */
58
+ export interface DriveSessionLike {
59
+ bindExtensions(bindings: unknown): Promise<void>;
60
+ subscribe(listener: (event: DriveEvent) => void): () => void;
61
+ prompt(text: string): Promise<void>;
62
+ abort(): Promise<void>;
63
+ dispose(): void;
64
+ sessionManager: { getBranch(): unknown[]; getSessionFile?(): string | null };
65
+ /**
66
+ * Optional (presence-gated): when the session exposes its extension runner, the seam
67
+ * preflights the stage's terminating perk tool post-bind and fails fast (zero-turn
68
+ * `no_extension_tools`) instead of burning the budget on a tool-less session.
69
+ */
70
+ extensionRunner?: { getAllRegisteredTools(): { definition: { name: string } }[] };
71
+ }
72
+
73
+ /** The runtime surface (structurally satisfied by pi's `AgentSessionRuntime`). */
74
+ export interface DriveRuntimeLike {
75
+ readonly session: DriveSessionLike;
76
+ dispose(): Promise<void> | void;
77
+ }
78
+
79
+ /** Extract a tool's `details` object from a captured `tool_execution_end.result`; null if absent. */
80
+ function detailsOf(result: unknown): Record<string, unknown> | null {
81
+ if (result && typeof result === "object" && "details" in result) {
82
+ const details = (result as { details: unknown }).details;
83
+ if (details && typeof details === "object") return details as Record<string, unknown>;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ // --- the perk-owned drive-event union -------------------------------------------------------------
89
+
90
+ /**
91
+ * The perk-owned drive event union — the ONLY event vocabulary that crosses the handle boundary
92
+ * to the seam. The adapter translates raw SDK session events into these (`translateEvent`); all
93
+ * policy folding (budget counters, terminal capture, outcome classification) stays in the seam,
94
+ * so SDK event-shape churn is absorbed here and never reaches stage policy.
95
+ */
96
+ export type StageEvent =
97
+ | {
98
+ kind: "turn_ended";
99
+ /**
100
+ * Fresh-work tokens for the turn: assistant `input + output` ONLY. `usage.reasoning` is a
101
+ * provider-reported breakdown that is a **subset of `output`** on every pi-ai provider that
102
+ * populates it (anthropic `thinking_tokens`, google `thoughtsTokenCount` folded into
103
+ * `output`, openai `reasoning_tokens` inside completion/output tokens — verified @ pi-ai
104
+ * 0.80.5), so it is deliberately EXCLUDED: adding it would double-count.
105
+ */
106
+ freshTokens: number;
107
+ }
108
+ | {
109
+ kind: "tool_ended";
110
+ tool: string;
111
+ /** `details.ok` when the result carries the boolean, else `!isError`. */
112
+ ok: boolean;
113
+ /** The tool's structured `details` block (perk tools' result shape), null when absent. */
114
+ details: Record<string, unknown> | null;
115
+ /** Pre-cap error text for a failed tool (null when `ok`); the seam applies its cap. */
116
+ errorText: string | null;
117
+ }
118
+ | { kind: "model_errored"; message: string };
119
+
120
+ /** Best-effort error text for a failed tool (details.error | result string | a generic fallback). */
121
+ function toolErrorMessage(event: DriveEvent): string {
122
+ const details = detailsOf(event.result);
123
+ if (details && typeof details.error === "string" && details.error) return details.error;
124
+ if (typeof event.result === "string" && event.result) return event.result;
125
+ return `tool ${event.toolName ?? ""} failed`;
126
+ }
127
+
128
+ /**
129
+ * Translate one raw agent-session event into the perk-owned union (pure); `null` for event types
130
+ * the drive does not observe. This is the entire SDK-event vocabulary the drive consumes: turn
131
+ * completion (with the fresh-work token sum — the `sumAssistantTokens` pattern in objective.ts),
132
+ * tool completion (with the parsed `details` block and pre-cap error text), and a
133
+ * post-acceptance model error (assistant `message_end` with `stopReason:"error"`, surfaced with
134
+ * retry off — audit §B #4).
135
+ */
136
+ export function translateEvent(event: DriveEvent): StageEvent | null {
137
+ if (event.type === "turn_end") {
138
+ const usage = event.message?.usage;
139
+ const freshTokens = usage ? Math.max(0, usage.input ?? 0) + Math.max(0, usage.output ?? 0) : 0;
140
+ return { kind: "turn_ended", freshTokens };
141
+ }
142
+ if (event.type === "tool_execution_end") {
143
+ const details = detailsOf(event.result);
144
+ const ok = typeof details?.ok === "boolean" ? details.ok === true : !event.isError;
145
+ return {
146
+ kind: "tool_ended",
147
+ tool: event.toolName ?? "",
148
+ ok,
149
+ details,
150
+ errorText: ok ? null : toolErrorMessage(event),
151
+ };
152
+ }
153
+ if (
154
+ event.type === "message_end" &&
155
+ event.message?.role === "assistant" &&
156
+ event.message.stopReason === "error"
157
+ ) {
158
+ return { kind: "model_errored", message: event.message.errorMessage ?? "model error" };
159
+ }
160
+ return null;
161
+ }
162
+
163
+ // --- the drive-session handle --------------------------------------------------------------------
164
+
165
+ /** The binding the worker applies to every (re)bound session: headless (`hasUI === false`). */
166
+ function headlessBinding(): {
167
+ uiContext: undefined;
168
+ mode: "json";
169
+ onError: (err: unknown) => void;
170
+ } {
171
+ return {
172
+ uiContext: undefined,
173
+ mode: "json",
174
+ onError: (err: unknown) => console.error(`perk worker: extension error — ${String(err)}`),
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Create the drive-session handle over an already-created runtime — the seam's ONLY window onto
180
+ * the live session. Bind/subscribe, prompt, abort ownership, defensive rebind, and guarded
181
+ * disposal live behind it; the seam never touches `.session`/`.sessionManager`/`.extensionRunner`
182
+ * members directly. `listener` is the seam's policy fold and receives only the perk-owned
183
+ * `StageEvent` union — raw `DriveEvent`s are translated at this boundary and never cross it.
184
+ * Rebinding unsubscribes the prior raw listener first so events are never double-counted. The
185
+ * handle is private to the confined pair (seam ↔ adapter); the seam's fake-session injection
186
+ * seam (`deps.createRuntime` returning `DriveRuntimeLike`) is unchanged.
187
+ */
188
+ export function createDriveSession(
189
+ runtime: DriveRuntimeLike,
190
+ listener: (event: StageEvent) => void,
191
+ ) {
192
+ const binding = headlessBinding();
193
+ let bound: DriveSessionLike = runtime.session;
194
+ let unsubscribe: (() => void) | null = null;
195
+ let retainedAbort: Promise<void> | null = null;
196
+ const rawListener = (event: DriveEvent): void => {
197
+ const translated = translateEvent(event);
198
+ if (translated !== null) listener(translated);
199
+ };
200
+
201
+ async function bindTo(target: DriveSessionLike): Promise<void> {
202
+ if (unsubscribe) unsubscribe();
203
+ await target.bindExtensions(binding);
204
+ unsubscribe = target.subscribe(rawListener);
205
+ bound = target;
206
+ }
207
+
208
+ return {
209
+ /** Headless bind + subscribe on the runtime's current session. */
210
+ async bind(): Promise<void> {
211
+ await bindTo(runtime.session);
212
+ },
213
+ /** The single driving prompt. */
214
+ async prompt(text: string): Promise<void> {
215
+ await bound.prompt(text);
216
+ },
217
+ /**
218
+ * OWNED + IDEMPOTENT: fires `session.abort()` on the runtime's live session exactly once —
219
+ * the drive can trip repeatedly (every post-trip `turn_ended` re-calls this), but later
220
+ * calls are no-ops, so no abort work can outlive `dispose()`'s drain. The rejection has an
221
+ * owner: the logging catch attaches immediately (never unhandled), and the caught chain is
222
+ * retained so `dispose()` drains it before returning.
223
+ */
224
+ abort(): void {
225
+ if (retainedAbort !== null) return;
226
+ retainedAbort = runtime.session.abort().catch((err) => {
227
+ console.error(`perk worker: session.abort() rejected — ${String(err)}`);
228
+ });
229
+ },
230
+ /**
231
+ * The defensive-rebind arm: when the runtime replaced its session mid-drive, unsubscribe the
232
+ * prior listener, bind + subscribe the replacement, and return true (`false` = unchanged). A
233
+ * replacement is not expected on the happy path (the prompt instructs `/submit`, never
234
+ * `/implement`; `lifecycleGates.newSession` is `hasUI`-guarded; objective compaction is
235
+ * inert with no active objective) — the seam logs an observed rebind loudly.
236
+ */
237
+ async rebindIfReplaced(): Promise<boolean> {
238
+ if (runtime.session === bound) return false;
239
+ await bindTo(runtime.session);
240
+ return true;
241
+ },
242
+ /** Preflight read (null when the session exposes no `extensionRunner`). */
243
+ registeredToolNames(): string[] | null {
244
+ const runner = bound.extensionRunner;
245
+ if (!runner) return null;
246
+ return runner.getAllRegisteredTools().map((t) => t.definition.name);
247
+ },
248
+ /** §8.35 pointer-capture read. */
249
+ sessionFile(): string | null {
250
+ return bound.sessionManager.getSessionFile?.() ?? null;
251
+ },
252
+ /** `sessionManager.getBranch()` for the seam's terminal classification. */
253
+ workflowBranch(): unknown[] {
254
+ return bound.sessionManager.getBranch();
255
+ },
256
+ /**
257
+ * Guarded cleanup: unsubscribe (caught) → runtime dispose (caught) → drain the retained
258
+ * abort promise (caught). NEVER throws — a throwing unsubscribe or a rejecting
259
+ * `runtime.dispose()` can never replace the seam's already-computed `RunOutcome` (the
260
+ * never-throws contract, contracts.md §8.11, holds under adversarial fakes).
261
+ */
262
+ async dispose(): Promise<void> {
263
+ try {
264
+ unsubscribe?.();
265
+ } catch (err) {
266
+ console.error(`perk worker: listener unsubscribe threw — ${String(err)}`);
267
+ }
268
+ unsubscribe = null;
269
+ try {
270
+ await runtime.dispose();
271
+ } catch (err) {
272
+ console.error(`perk worker: runtime dispose failed — ${String(err)}`);
273
+ }
274
+ // Already a caught chain (see abort) — awaiting only drains it before return.
275
+ if (retainedAbort) await retainedAbort;
276
+ },
277
+ };
278
+ }
279
+
280
+ /**
281
+ * The handle's nameable type, derived from its sole factory (no duplicate interface to drift):
282
+ * the object literal above carries the per-method contracts.
283
+ */
284
+ export type DriveSessionHandle = ReturnType<typeof createDriveSession>;
285
+
286
+ // --- model/auth (Gap 5), unified around one nominal type ------------------------------------------
287
+
288
+ /**
289
+ * The opaque model input the seam's `StageRunOptions.model` carries. NOMINAL: the `#private`
290
+ * fields make structural forgery impossible — a selection is minted only by this adapter
291
+ * (`resolveWorkerModel`/`resolveAuth`) and by tests that import the adapter deliberately. The
292
+ * SDK-typed reads below are adapter-internal by the import-edge ban (Rule F); they appear on
293
+ * this adapter-owned surface only.
294
+ */
295
+ export class WorkerModelSelection {
296
+ // The ONE `#private` field supplies the nominal guarantee; the payload rides ordinary readonly
297
+ // fields. (Constructor parameter properties would be smaller still, but node's type-stripping
298
+ // test runner rejects non-erasable TS syntax.)
299
+ readonly #modelRuntime: ModelRuntime;
300
+ /** The EXPLICIT model only; `undefined` defers the pick to the SDK at session creation. */
301
+ readonly model: Model<Api> | undefined;
302
+ /**
303
+ * Thinking level parsed from the `--model <pattern>:<level>` suffix (`resolveWorkerModel`).
304
+ * `undefined` ⇒ the SDK's settings-default resolution — unchanged behavior.
305
+ */
306
+ readonly thinkingLevel: ThinkingLevel | undefined;
307
+
308
+ constructor(modelRuntime: ModelRuntime, model?: Model<Api>, thinkingLevel?: ThinkingLevel) {
309
+ this.#modelRuntime = modelRuntime;
310
+ this.model = model;
311
+ this.thinkingLevel = thinkingLevel;
312
+ }
313
+
314
+ /** The canonical model/auth runtime (pi 0.84 `ModelRuntime`). */
315
+ get modelRuntime(): ModelRuntime {
316
+ return this.#modelRuntime;
317
+ }
318
+ }
319
+
320
+ /** What a `--model` flag resolves to — discriminated so no contradictory state is expressible. */
321
+ export type ResolvedWorkerModel =
322
+ | { ok: true; selection: WorkerModelSelection; warning: string | undefined }
323
+ | { ok: false; error: string; warning: string | undefined };
324
+
325
+ /**
326
+ * Resolve an explicit `--model` flag with pi's OWN CLI semantics (`resolveCliModel`): fuzzy
327
+ * matching, bare-id resolution, `provider/pattern`, and a `:thinking` suffix — the same chain the
328
+ * flag's string hits in an interactive pi launch, closing the warm/cold parity gap (cf.
329
+ * docs/learned/workflow/execution-path-parity.md).
330
+ *
331
+ * `raw` absent **or `""`** ⇒ `ok: true` with a selection carrying only a default-created
332
+ * `ModelRuntime` (model/thinking undefined — the SDK's own initial-model resolution at session
333
+ * creation stays the default). The `""` ≡ omitted equivalence is deliberate: workerMain's flag
334
+ * grammar produces `""` for a bare `--model`, and the tolerance is pinned by a test. A
335
+ * resolution that yields neither a model nor an error is normalized to the worker's not-found
336
+ * error (`ok: false` — fail fast, never guess). `warning` is a non-fatal resolution diagnostic
337
+ * (e.g. an invalid `:thinking` suffix) — the caller surfaces it only when proceeding.
338
+ *
339
+ * The optional `modelRuntime` param is the test-injection seam (deterministic `stubRuntime`
340
+ * tests); `ModelRuntime.create()` runs only when it is absent.
341
+ */
342
+ export async function resolveWorkerModel(
343
+ raw: string | undefined,
344
+ modelRuntime?: ModelRuntime,
345
+ ): Promise<ResolvedWorkerModel> {
346
+ const runtime = modelRuntime ?? (await ModelRuntime.create());
347
+ if (!raw) {
348
+ return { ok: true, selection: new WorkerModelSelection(runtime), warning: undefined };
349
+ }
350
+ const result = resolveCliModel({ cliModel: raw, modelRuntime: runtime });
351
+ if (result.model === undefined && result.error === undefined) {
352
+ return {
353
+ ok: false,
354
+ error: `model '${raw}' not found in the registry.`,
355
+ warning: result.warning,
356
+ };
357
+ }
358
+ if (result.error !== undefined) {
359
+ return { ok: false, error: result.error, warning: result.warning };
360
+ }
361
+ return {
362
+ ok: true,
363
+ selection: new WorkerModelSelection(runtime, result.model, result.thinkingLevel),
364
+ warning: result.warning,
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Normalize the seam's optional model input for the production drive path; returns null (never
370
+ * throws a domain error) when no model is available at all. `selection` absent ⇒ a
371
+ * default-runtime selection (async because pi 0.84's `ModelRuntime.create` is async; the default
372
+ * creation stays offline — `allowModelNetwork` defaults false). `null` iff there is no explicit
373
+ * model AND `getAvailableSnapshot()` is empty — the `no_model` fail-fast, unchanged. The model is
374
+ * NOT pre-pinned from the runtime: an `undefined` model lets `createAgentSession` run its own
375
+ * initial-model resolution (settings `defaultModel` → pi's curated per-provider defaults → first
376
+ * available), which picks a current-generation model instead of the catalogue's
377
+ * alphabetically-first (= oldest) entry.
378
+ */
379
+ export async function resolveAuth(
380
+ selection: WorkerModelSelection | undefined,
381
+ ): Promise<WorkerModelSelection | null> {
382
+ const effective = selection ?? new WorkerModelSelection(await ModelRuntime.create());
383
+ if (!effective.model && effective.modelRuntime.getAvailableSnapshot().length === 0) return null;
384
+ return effective;
385
+ }
386
+
387
+ // --- the production runtime factory ---------------------------------------------------------------
388
+
389
+ /**
390
+ * Build the asymmetric runtime: `cwd = worktree` (project tier — perk's `@mgiles/perk` extension via the
391
+ * managed `.pi/settings.json`, the managed `AGENTS.md`/`APPEND_SYSTEM.md`) and `agentDir = throwaway`
392
+ * (user-global tier OUT — the throwaway dir has no `settings.json`, so the global tier is empty),
393
+ * env-var/registry auth+model (Gap 5). Settings are DISK-LAYERED (`SettingsManager.create` +
394
+ * `applyOverrides`, the SDK's sanctioned "with overrides" shape — docs/sdk.md "Settings
395
+ * Management"): the project tier resolves the managed `packages` list, while the compaction-off/
396
+ * retry-off determinism overrides ride the merged view only (package resolution reads the
397
+ * per-scope raws — overrides cannot leak into it). Missing `npm:` packages auto-install into
398
+ * `.pi/npm` during the loader's reload (skipped under `PI_OFFLINE`); an install failure throws →
399
+ * the seam's catch arm → a loud `failed`/`runtime_init`. No `tools` allowlist — read-write
400
+ * defaults + extension tools. The `createAgentSessionServices` factory builds the
401
+ * `DefaultResourceLoader` internally from `cwd`/`agentDir` (recipe correction #1).
402
+ *
403
+ * Adapter-owned inputs only (`worktree` + the nominal selection): no seam type appears in the
404
+ * signature, so a reverse seam←adapter type edge is impossible by construction. The throwaway
405
+ * `mkdtempSync` agentDir is best-effort removed (fail-soft `rm`; a removal failure logs and
406
+ * never affects the outcome) at exactly two moments — dispose, and a construction failure that
407
+ * would otherwise orphan it — the isolation invariant is untouched: no removal while the
408
+ * session lives.
409
+ */
410
+ export async function defaultCreateRuntime(
411
+ worktree: string,
412
+ selection: WorkerModelSelection,
413
+ ): Promise<DriveRuntimeLike> {
414
+ const agentDir = mkdtempSync(join(tmpdir(), "perk-worker-agent-"));
415
+ const removeAgentDir = (): void => {
416
+ try {
417
+ rmSync(agentDir, { recursive: true, force: true });
418
+ } catch (err) {
419
+ console.error(`perk worker: throwaway agentDir removal failed — ${String(err)}`);
420
+ }
421
+ };
422
+ try {
423
+ return await constructRuntime(worktree, selection, agentDir, removeAgentDir);
424
+ } catch (err) {
425
+ // Construction failed before the disposer-wrapping runtime existed — without this arm every
426
+ // failed worker invocation would leak its `perk-worker-agent-*` directory.
427
+ removeAgentDir();
428
+ throw err;
429
+ }
430
+ }
431
+
432
+ /** The construction body behind `defaultCreateRuntime`'s failure-cleanup guard. */
433
+ async function constructRuntime(
434
+ worktree: string,
435
+ selection: WorkerModelSelection,
436
+ agentDir: string,
437
+ removeAgentDir: () => void,
438
+ ): Promise<DriveRuntimeLike> {
439
+ const settingsManager = SettingsManager.create(worktree, agentDir);
440
+ settingsManager.applyOverrides({ compaction: { enabled: false }, retry: { enabled: false } });
441
+ const factory: CreateAgentSessionRuntimeFactory = async (factoryOpts) => {
442
+ const services = await createAgentSessionServices({
443
+ cwd: factoryOpts.cwd,
444
+ agentDir: factoryOpts.agentDir,
445
+ settingsManager,
446
+ modelRuntime: selection.modelRuntime,
447
+ });
448
+ const result = await createAgentSessionFromServices({
449
+ services,
450
+ sessionManager: factoryOpts.sessionManager,
451
+ sessionStartEvent: factoryOpts.sessionStartEvent,
452
+ // `undefined` ⇒ the SDK's initial-model resolution picks the model (see `resolveAuth`);
453
+ // an `undefined` thinkingLevel likewise defers to the settings default.
454
+ model: selection.model,
455
+ thinkingLevel: selection.thinkingLevel,
456
+ });
457
+ // Name the model that will actually drive (the SDK may have picked it) — the remote step
458
+ // log is otherwise silent about it until a provider error.
459
+ const chosen = result.session.model;
460
+ console.error(
461
+ `perk worker: model ${chosen ? `${chosen.provider}/${chosen.id}` : "unresolved"}`,
462
+ );
463
+ // Loud construction diagnostics (the CAUSE behind a later `no_extension_tools` symptom):
464
+ // settings I/O errors and extension load errors are recorded, not raised, by the SDK —
465
+ // surfacing them is the app layer's job. Fail-soft reporting only; never throws.
466
+ for (const entry of result.extensionsResult.errors) {
467
+ console.error(`perk worker: extension load error — ${entry.path}: ${entry.error}`);
468
+ }
469
+ for (const entry of settingsManager.drainErrors()) {
470
+ console.error(`perk worker: settings error (${entry.scope}) — ${String(entry.error)}`);
471
+ }
472
+ return { ...result, services, diagnostics: services.diagnostics };
473
+ };
474
+ const runtime = await createAgentSessionRuntime(factory, {
475
+ cwd: worktree,
476
+ agentDir,
477
+ sessionManager: SessionManager.create(worktree),
478
+ });
479
+ const inner = runtime as unknown as DriveRuntimeLike;
480
+ return {
481
+ get session(): DriveSessionLike {
482
+ return inner.session;
483
+ },
484
+ async dispose(): Promise<void> {
485
+ try {
486
+ await inner.dispose();
487
+ } finally {
488
+ // Close the throwaway-agentDir leak at the one safe moment (post-dispose); a removal
489
+ // failure logs and never affects the outcome.
490
+ removeAgentDir();
491
+ }
492
+ },
493
+ };
494
+ }