@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
@@ -1,899 +0,0 @@
1
- // The headless stage-drive primitive (`driveStage`).
2
- //
3
- // Drives ONE read-write stage (`implement`/`address`) end-to-end on an already-prepared worktree,
4
- // running the SAME `@mgiles/perk` extension package, with a locked resource set, auto-compaction and
5
- // auto-retry off, and a budget/timeout watchdog. It seeds the stage's initial prompt, lets the
6
- // model work (calling perk's real tools), detects the stage's terminal signal, and returns a
7
- // structured `RunOutcome`. This implements the contract locked in
8
- // `docs/design/headless-worker.md` §B — the event-stream substrate and the e2e
9
- // harness consume.
10
- //
11
- // Scope here is the in-process drive primitive only. Positioning (worktree create, handoff/plan-ref
12
- // /plan-body materialization, `run_id` mint) is the cold-door/runner's job and is a PREPARED-
13
- // WORKTREE input (audit Gap 7): the worker inherits `PERK_RUN_ID` from the env and never re-mints.
14
- //
15
- // Budget semantics: `budget.tokens` counts FRESH WORK only — assistant `input + output` per
16
- // `turn_end`. Cache reads/writes and the provider `reasoning` breakdown (a subset of `output` in
17
- // pi-ai's normalization) are excluded by design; see `applyEvent`.
18
- //
19
- // Inverse of `extension/worker/readOnlySession.ts`: that builds a fully-isolated READ-ONLY child (loads
20
- // nothing, `["read","grep","find","ls"]`); the worker is the OPPOSITE — read-write defaults + the
21
- // real perk extension loaded from the worktree's `.pi/settings.json` (disk-layered settings:
22
- // `SettingsManager.create(worktree, throwawayAgentDir)` resolves the managed project-tier
23
- // `packages` list — perk + the borrowed set, the same package set as a warm session), with the
24
- // user-global tier locked out via a throwaway `agentDir`.
25
-
26
- import { appendFileSync, mkdtempSync } from "node:fs";
27
- import { tmpdir } from "node:os";
28
- import { join } from "node:path";
29
- import { env } from "node:process";
30
- // pi-ai's `ModelThinkingLevel` (`"off" | minimal | … | xhigh`) is the union `resolveCliModel`
31
- // returns and `createAgentSessionFromServices` accepts; the pi-coding-agent root does not
32
- // re-export a thinking-level type (only `ThinkingLevelChangeEntry`).
33
- import type { Api, Model, ModelThinkingLevel as ThinkingLevel } from "@earendil-works/pi-ai";
34
- import {
35
- type CreateAgentSessionRuntimeFactory,
36
- createAgentSessionFromServices,
37
- createAgentSessionRuntime,
38
- createAgentSessionServices,
39
- ModelRuntime,
40
- resolveCliModel,
41
- SessionManager,
42
- SettingsManager,
43
- } from "@earendil-works/pi-coding-agent";
44
- import { planReadInstruction } from "../doors/lifecycleGates.ts";
45
- import { ensureRunScratch, type PlanRef, readPlanRef, runEventsPath } from "../substrate/cache.ts";
46
- import { render } from "../substrate/prompts.ts";
47
- import { captureSessionPointer } from "../substrate/sessionPointers.ts";
48
- import { rebuildWorkflowState } from "../substrate/workflowState.ts";
49
- import { capForModel } from "./readOnlySession.ts";
50
-
51
- // --- contract types (additive-stable; §B of docs/design/headless-worker.md) ---------------------
52
-
53
- /** The two read-write stages with `doors.cold_remote: true` (shared/registry.yaml). */
54
- export type DriveStage = "implement" | "address";
55
-
56
- /** Terminal run status (audit §B outcome shape). */
57
- export type RunStatus = "completed" | "failed" | "aborted" | "budget_exhausted";
58
-
59
- /** The first-of terminal signal that ended the drive (audit §B). */
60
- export type TerminalSignal =
61
- | "submit_tool"
62
- | "address_resolved"
63
- | "agent_idle_incomplete"
64
- | "budget"
65
- | "external_abort"
66
- | "model_error";
67
-
68
- /** The budget/timeout watchdog inputs (Gap 2). */
69
- export interface DriveBudget {
70
- maxTurns: number;
71
- maxTokens: number;
72
- wallClockMs: number;
73
- }
74
-
75
- /**
76
- * The structured run outcome (audit §B). **Additive-stable**: later fields may be added; existing
77
- * fields keep their meaning. Never thrown — `driveStage` always resolves with one of these.
78
- */
79
- export interface RunOutcome {
80
- run_id: string;
81
- stage: DriveStage;
82
- status: RunStatus;
83
- terminal_signal: TerminalSignal;
84
- pr: { number: number; url: string } | null;
85
- budget: { turns: number; tokens: number; elapsed_ms: number };
86
- error: { type: string; message: string; summary: string } | null;
87
- }
88
-
89
- // --- structured run-event stream (§8.12) ----------------------------------------------
90
-
91
- /**
92
- * The structured run-event stream (contracts §8.12). A small, JSON-serializable,
93
- * **additive-stable** discriminated union keyed on `kind` (distinct from `DriveEvent.type`). Every
94
- * event carries a monotonic `seq` (0-based) and `t` (elapsed ms, same basis as
95
- * `RunOutcome.budget.elapsed_ms`). Future nodes may add variants/fields; existing ones keep
96
- * meaning — including deprecated variants that are no longer emitted (see `step_marker`).
97
- */
98
- export type RunEvent =
99
- | { kind: "run_started"; seq: number; t: number; run_id: string; stage: DriveStage }
100
- // DEPRECATED — never emitted: the `[WIP:n]`/`[DONE:n]` marker protocol died with the
101
- // checkpoints removal. Kept for additive-stable grammar — historical `events.ndjson` files
102
- // may carry the variant (contracts §8.12).
103
- | { kind: "step_marker"; seq: number; t: number; marker: "wip" | "done"; step: number }
104
- | {
105
- kind: "tool_outcome";
106
- seq: number;
107
- t: number;
108
- tool: string;
109
- ok: boolean;
110
- summary: string | null;
111
- }
112
- | { kind: "run_finished"; seq: number; t: number; outcome: RunOutcome };
113
-
114
- /** The injectable delivery seam: default = a run-scoped NDJSON file sink; tests inject an array. */
115
- export type RunEventSink = (event: RunEvent) => void;
116
-
117
- /** Distributive `Omit` so each `RunEvent` variant keeps its own fields when `seq`/`t` are stamped. */
118
- type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
119
- type RunEventInput = DistributiveOmit<RunEvent, "seq" | "t">;
120
-
121
- /** Per-event free-text cap (route-don't-relay): events carry the narrative, not raw tool payloads. */
122
- export const EVENT_SUMMARY_CAP = 2 * 1024;
123
-
124
- export interface DriveStageOptions {
125
- /** Absolute path to the already-positioned worktree (Gap 7). */
126
- worktree: string;
127
- stage: DriveStage;
128
- /** The seeded first prompt (see `initialPromptFor`). */
129
- initialPrompt: string;
130
- /**
131
- * Explicit model; else the SDK's own default resolution picks one at session creation
132
- * (settings `defaultModel` → pi's per-provider defaults → first available — Gap 5). Never
133
- * pre-pinned here: `getAvailable()` sorts alphabetically, so `[0]` is the *oldest* model of
134
- * the first provider (a since-removed `claude-3-5-haiku` date-pin 404'd a whole remote drive).
135
- */
136
- model?: Model<Api>;
137
- /**
138
- * Thinking level parsed from the `--model <pattern>:<level>` suffix (`resolveWorkerModel`).
139
- * `undefined` ⇒ the SDK's settings-default resolution — unchanged behavior.
140
- */
141
- thinkingLevel?: ThinkingLevel;
142
- /** The canonical model/auth runtime (pi 0.84 `ModelRuntime`); default-created when absent. */
143
- modelRuntime?: ModelRuntime;
144
- budget: DriveBudget;
145
- /** External cancellation; OR'd with the budget watchdog. */
146
- signal?: AbortSignal;
147
- }
148
-
149
- /**
150
- * The offline seam (mirrors `readOnlySession.test.ts`'s `runTask` injection). `createRuntime`
151
- * overrides the production runtime factory so tests drive synthetic sessions; `now` injects the
152
- * clock for deterministic `elapsed_ms`.
153
- */
154
- export interface DriveStageDeps {
155
- createRuntime?: (opts: DriveStageOptions) => Promise<DriveRuntimeLike>;
156
- now?: () => number;
157
- /** The structured run-event sink. Absent ⇒ the default run-scoped NDJSON file sink. */
158
- eventSink?: RunEventSink;
159
- }
160
-
161
- // --- structural shapes (kept minimal so pure helpers stay offline-testable) ---------------------
162
-
163
- /** The slice of an agent session event the worker reads (structural — see agent-session.d.ts). */
164
- export interface DriveEvent {
165
- type: string;
166
- toolName?: string;
167
- result?: unknown;
168
- isError?: boolean;
169
- message?: {
170
- role?: string;
171
- stopReason?: string;
172
- errorMessage?: string;
173
- /**
174
- * Assistant token usage. `reasoning` is a provider-reported breakdown that is a **subset of
175
- * `output`** on every pi-ai provider that populates it (anthropic `thinking_tokens`, google
176
- * `thoughtsTokenCount` folded into `output`, openai `reasoning_tokens` inside completion/
177
- * output tokens — verified @ pi-ai 0.80.5), so it is deliberately EXCLUDED from the budget
178
- * sum: adding it would double-count.
179
- */
180
- usage?: { input?: number; output?: number; reasoning?: number };
181
- };
182
- }
183
-
184
- /** The session surface the worker drives (structurally satisfied by pi's `AgentSession`). */
185
- export interface DriveSessionLike {
186
- bindExtensions(bindings: unknown): Promise<void>;
187
- subscribe(listener: (event: DriveEvent) => void): () => void;
188
- prompt(text: string): Promise<void>;
189
- abort(): Promise<void>;
190
- dispose(): void;
191
- sessionManager: { getBranch(): unknown[]; getSessionFile?(): string | null };
192
- /**
193
- * Optional (presence-gated): when the session exposes its extension runner, `driveStage`
194
- * preflights the stage's terminating perk tool post-bind and fails fast (zero-turn
195
- * `no_extension_tools`) instead of burning the budget on a tool-less session.
196
- */
197
- extensionRunner?: { getAllRegisteredTools(): { definition: { name: string } }[] };
198
- }
199
-
200
- /** The runtime surface (structurally satisfied by pi's `AgentSessionRuntime`). */
201
- export interface DriveRuntimeLike {
202
- readonly session: DriveSessionLike;
203
- dispose(): Promise<void> | void;
204
- }
205
-
206
- /** Mutable counters/captures the subscribe listener accumulates over the drive. */
207
- export interface DriveCounters {
208
- turns: number;
209
- tokens: number;
210
- /** Latest submit-bearing evidence (standalone submit or the nested finalizer submit). */
211
- submitDetails: Record<string, unknown> | null;
212
- finalizeDetails: Record<string, unknown> | null;
213
- modelError: { message: string } | null;
214
- }
215
-
216
- export function freshCounters(): DriveCounters {
217
- return { turns: 0, tokens: 0, submitDetails: null, finalizeDetails: null, modelError: null };
218
- }
219
-
220
- /** The natural-idle terminal classification (before watchdog/abort overrides). */
221
- export interface TerminalVerdict {
222
- status: RunStatus;
223
- terminal_signal: TerminalSignal;
224
- pr: { number: number; url: string } | null;
225
- errorType: string | null;
226
- errorMessage: string | null;
227
- }
228
-
229
- // --- pure helpers (offline-testable) ------------------------------------------------------------
230
-
231
- /** Extract a tool's `details` object from a captured `tool_execution_end.result`; null if absent. */
232
- function detailsOf(result: unknown): Record<string, unknown> | null {
233
- if (result && typeof result === "object" && "details" in result) {
234
- const details = (result as { details: unknown }).details;
235
- if (details && typeof details === "object") return details as Record<string, unknown>;
236
- }
237
- return null;
238
- }
239
-
240
- /**
241
- * Fold one agent-session event into the running counters (pure). Counts `turn_end` turns, sums
242
- * assistant token usage (the `sumAssistantTokens` pattern in objective.ts), captures the `submit`
243
- * /`finalize_address` terminal tool details, and records a post-acceptance model error
244
- * (assistant `message_end` with `stopReason:"error"`, surfaced with retry off — audit §B #4).
245
- *
246
- * The token sum is `input + output` ONLY: `usage.reasoning` is a subset of `output` on every
247
- * pi-ai provider that reports it (see the `DriveEvent.usage` doc), so summing it would
248
- * double-count.
249
- */
250
- export function applyEvent(counters: DriveCounters, event: DriveEvent): void {
251
- if (event.type === "turn_end") {
252
- counters.turns += 1;
253
- const usage = event.message?.usage;
254
- if (usage) counters.tokens += Math.max(0, usage.input ?? 0) + Math.max(0, usage.output ?? 0);
255
- return;
256
- }
257
- if (event.type === "tool_execution_end") {
258
- if (event.toolName === "submit") counters.submitDetails = detailsOf(event.result);
259
- else if (event.toolName === "finalize_address") {
260
- const details = detailsOf(event.result);
261
- counters.finalizeDetails = details;
262
- // A finalizer carries the submit that immediately preceded resolution. Recording it into the
263
- // same latest-evidence slot means a later standalone submit naturally supersedes it after a
264
- // conflict-resolver re-drive.
265
- const nestedSubmit = details?.submit;
266
- if (nestedSubmit && typeof nestedSubmit === "object" && !Array.isArray(nestedSubmit)) {
267
- // The finalizer only exposes this nested block after submit succeeded; restore the
268
- // success marker stripped from its nested public shape so a later failed standalone
269
- // submit cannot accidentally satisfy the address completion predicate.
270
- counters.submitDetails = { ok: true, ...(nestedSubmit as Record<string, unknown>) };
271
- }
272
- }
273
- return;
274
- }
275
- if (event.type === "message_end") {
276
- if (event.message?.role === "assistant" && event.message.stopReason === "error") {
277
- counters.modelError = { message: event.message.errorMessage ?? "model error" };
278
- }
279
- }
280
- }
281
-
282
- /** True when the budget watchdog should trip from the current counters. */
283
- export function budgetTripped(counters: DriveCounters, budget: DriveBudget): boolean {
284
- return counters.turns >= budget.maxTurns || counters.tokens >= budget.maxTokens;
285
- }
286
-
287
- /**
288
- * Classify a natural-idle terminal from the captured state (pure). `modelError` wins (post-
289
- * acceptance error, §B #4); else the stage success predicate:
290
- * - implement: a successful `submit` carrying a `pr` → completed/submit_tool;
291
- * - address: `finalize_address` ok, `last_review_batch` appended, and the latest submit-bearing
292
- * evidence is successful and not definitively unmergeable → completed/address_resolved;
293
- * - otherwise the agent went idle without completing the stage → failed/agent_idle_incomplete.
294
- */
295
- export function evaluateTerminal(args: {
296
- stage: DriveStage;
297
- submitDetails: Record<string, unknown> | null;
298
- finalizeDetails: Record<string, unknown> | null;
299
- lastReviewBatchPresent: boolean;
300
- modelError: { message: string } | null;
301
- }): TerminalVerdict {
302
- if (args.modelError !== null) {
303
- return {
304
- status: "failed",
305
- terminal_signal: "model_error",
306
- pr: null,
307
- errorType: "model_error",
308
- errorMessage: args.modelError.message,
309
- };
310
- }
311
-
312
- if (args.stage === "implement") {
313
- const pr = extractPr(args.submitDetails);
314
- if (args.submitDetails?.ok === true && pr !== null) {
315
- // Completion additionally requires the submit to be mergeable: a definitively-
316
- // unmergeable PR (merge conflicts unresolved) is NOT done. `mergeable === true`/`null`/
317
- // absent all allow completion (fail-open); only a definitive `false` blocks it. On the
318
- // happy path the resolver follow-up turns re-submit, overwriting submitDetails with a
319
- // mergeable result, so the natural-idle classification then passes.
320
- if (args.submitDetails.mergeable === false) {
321
- return {
322
- status: "failed",
323
- terminal_signal: "agent_idle_incomplete",
324
- pr: null,
325
- errorType: "incomplete",
326
- errorMessage:
327
- "implement drive went idle with an unmergeable PR (merge conflicts unresolved).",
328
- };
329
- }
330
- return {
331
- status: "completed",
332
- terminal_signal: "submit_tool",
333
- pr,
334
- errorType: null,
335
- errorMessage: null,
336
- };
337
- }
338
- return {
339
- status: "failed",
340
- terminal_signal: "agent_idle_incomplete",
341
- pr: null,
342
- errorType: "incomplete",
343
- errorMessage: "implement drive went idle without an opened PR (no successful submit).",
344
- };
345
- }
346
-
347
- // address. `applyEvent` keeps submitDetails as the latest submit-bearing evidence: the nested
348
- // finalizer submit first, then a later standalone submit from the conflict-resolution re-drive.
349
- const nestedSubmit = args.finalizeDetails?.submit;
350
- const fallbackSubmit: Record<string, unknown> | null =
351
- nestedSubmit && typeof nestedSubmit === "object" && !Array.isArray(nestedSubmit)
352
- ? { ok: true, ...(nestedSubmit as Record<string, unknown>) }
353
- : null;
354
- const effectiveSubmit = args.submitDetails ?? fallbackSubmit;
355
- if (
356
- args.finalizeDetails?.ok === true &&
357
- args.lastReviewBatchPresent &&
358
- effectiveSubmit?.ok === true &&
359
- effectiveSubmit.mergeable !== false
360
- ) {
361
- return {
362
- status: "completed",
363
- terminal_signal: "address_resolved",
364
- pr: null,
365
- errorType: null,
366
- errorMessage: null,
367
- };
368
- }
369
- return {
370
- status: "failed",
371
- terminal_signal: "agent_idle_incomplete",
372
- pr: null,
373
- errorType: "incomplete",
374
- errorMessage:
375
- "address drive went idle without fully finalizing feedback " +
376
- "(publication, thread resolution, and last_review_batch are required).",
377
- };
378
- }
379
-
380
- /**
381
- * The post-bind preflight rule (pure): the stage's terminating perk tool must be registered —
382
- * `implement` → `submit`, `address` → `finalize_address`. Returns the required tool name
383
- * when absent, else `null`. Deliberately does NOT require the `subagent` tool for `address` — the
384
- * subagent-under-worker live smoke stays the §8.11 carried risk.
385
- */
386
- export function missingTerminatingTool(stage: DriveStage, toolNames: string[]): string | null {
387
- const required = stage === "implement" ? "submit" : "finalize_address";
388
- return toolNames.includes(required) ? null : required;
389
- }
390
-
391
- /** Pull a `{ number, url }` PR from a captured `submit` details block; null when malformed. */
392
- function extractPr(
393
- details: Record<string, unknown> | null,
394
- ): { number: number; url: string } | null {
395
- if (!details || typeof details.pr !== "object" || details.pr === null) return null;
396
- const pr = details.pr as { number?: unknown; url?: unknown };
397
- if (typeof pr.number === "number" && typeof pr.url === "string") {
398
- return { number: pr.number, url: pr.url };
399
- }
400
- return null;
401
- }
402
-
403
- /**
404
- * Compose the final `RunOutcome` (pure). `run_id` is read from `PERK_RUN_ID` (inherited from
405
- * positioning, Gap 7), overridable for tests. On a non-completed status the `error` block carries a
406
- * capped `error.summary` (route-don't-relay discipline); a completed status has `error: null`.
407
- */
408
- export function assembleOutcome(args: {
409
- stage: DriveStage;
410
- verdict: TerminalVerdict;
411
- budget: { turns: number; tokens: number; elapsed_ms: number };
412
- runId?: string;
413
- }): RunOutcome {
414
- const { verdict } = args;
415
- const error =
416
- verdict.status === "completed" || verdict.errorMessage === null
417
- ? null
418
- : {
419
- type: verdict.errorType ?? "error",
420
- message: verdict.errorMessage,
421
- summary: capForModel(verdict.errorMessage).shown,
422
- };
423
- return {
424
- run_id: args.runId ?? env.PERK_RUN_ID ?? "",
425
- stage: args.stage,
426
- status: verdict.status,
427
- terminal_signal: verdict.terminal_signal,
428
- pr: verdict.pr,
429
- budget: args.budget,
430
- error,
431
- };
432
- }
433
-
434
- // --- run-event helpers (offline-testable) ---------------------------------------------
435
-
436
- /**
437
- * Compute a `tool_outcome` `{ tool, ok, summary }` from a `tool_execution_end` `DriveEvent` (pure).
438
- * `ok` = `details.ok === true` when the result carries a `details.ok` boolean, else `!isError`.
439
- * `summary` is `null` on success and, on failure, a capped (route-don't-relay) synthesis of the
440
- * tool's error message — never the raw tool result.
441
- */
442
- export function toolOutcomeOf(event: DriveEvent): {
443
- tool: string;
444
- ok: boolean;
445
- summary: string | null;
446
- } {
447
- const details = detailsOf(event.result);
448
- const ok = typeof details?.ok === "boolean" ? details.ok === true : !event.isError;
449
- let summary: string | null = null;
450
- if (!ok) {
451
- const raw = toolErrorMessage(event);
452
- summary = capForModel(raw, EVENT_SUMMARY_CAP).shown;
453
- }
454
- return { tool: event.toolName ?? "", ok, summary };
455
- }
456
-
457
- /** Best-effort error text for a failed tool (details.error | result string | a generic fallback). */
458
- function toolErrorMessage(event: DriveEvent): string {
459
- const details = detailsOf(event.result);
460
- if (details && typeof details.error === "string" && details.error) return details.error;
461
- if (typeof event.result === "string" && event.result) return event.result;
462
- return `tool ${event.toolName ?? ""} failed`;
463
- }
464
-
465
- /**
466
- * The run-event emitter: owns the monotonic `seq` counter and stamps `t = max(0, now() - startMs)`
467
- * (same basis as `RunOutcome.budget.elapsed_ms`). Fail-soft: a throwing injected sink is caught and
468
- * swallowed so a broken sink never aborts the drive.
469
- */
470
- export function createEventEmitter(sink: RunEventSink, now: () => number, startMs: number) {
471
- let seq = 0;
472
- return {
473
- emit(event: RunEventInput): void {
474
- const full = { ...event, seq: seq++, t: Math.max(0, now() - startMs) } as RunEvent;
475
- try {
476
- sink(full);
477
- } catch (err) {
478
- console.error(`perk worker: run-event sink threw — ${String(err)}`);
479
- }
480
- },
481
- };
482
- }
483
-
484
- /**
485
- * The default run-event sink: a fail-soft NDJSON appender to `runEventsPath(worktree, runId)`. A
486
- * **no-op when `runId` is empty** (keeps the offline drive tests, which set no `PERK_RUN_ID`,
487
- * write-free). Each append is wrapped so a write error logs and is swallowed.
488
- */
489
- export function defaultEventSink(worktree: string, runId: string): RunEventSink {
490
- if (!runId) return () => {};
491
- let ensured = false;
492
- const path = runEventsPath(worktree, runId);
493
- return (event: RunEvent): void => {
494
- try {
495
- if (!ensured) {
496
- ensureRunScratch(worktree, runId);
497
- ensured = true;
498
- }
499
- appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8");
500
- } catch (err) {
501
- console.error(`perk worker: run-event sink write failed — ${String(err)}`);
502
- }
503
- };
504
- }
505
-
506
- /**
507
- * Re-derive the stage's initial prompt from the plan-ref — the TS twin of
508
- * `perk/run/launch.py._implement_prompt`/`_address_prompt`. INVARIANT: textual parity with the Python
509
- * plane (asserted reciprocally in `worker.test.ts` + `tests/test_worker_prompt_parity.py`). No
510
- * skill-binding suffix is appended here: in the driven session the bindings arrive via Mechanism A
511
- * (bindingDelivery.ts injects the handoff stage's render because this prompt carries no
512
- * `BINDING_HEADER`) — content byte-identical to the cold door's suffix (contracts.md §8.38).
513
- * Returns `null` when there is no plan-ref (nothing to prime).
514
- *
515
- * The implement primer's wording lives in the canonical template `prompts/stages/implement.md`,
516
- * rendered by the shared seam (contracts.md §8.31); branching stays in code — only the `read_cmd`
517
- * var differs. This implement output is byte-identical to the warm `implementHandoffPrompt`.
518
- *
519
- * The `address` wording lives in the shared canonical template `prompts/stages/address/action.md`
520
- * rendered via the cross-plane render seam (contracts.md §8.31); the worker has no preview path
521
- * (preview is a warm/cold flag only), so it always renders the action body. The classify step is
522
- * the `classify_review_feedback` tool, which reads the configured classifier model at execute
523
- * time — nothing model-shaped rides the prompt.
524
- */
525
- export function initialPromptFor(stage: DriveStage, planRef: PlanRef | null): string | null {
526
- if (planRef === null) return null;
527
- const provider = String(planRef.provider ?? "");
528
- const prId = String(planRef.pr_id ?? "");
529
- const url = String(planRef.url ?? "");
530
- if (stage === "implement") {
531
- const readCmd = planReadInstruction(provider, prId, url);
532
- return render("stages/implement.md", { provider, pr_id: prId, url, read_cmd: readCmd });
533
- }
534
- // address
535
- return render("stages/address/action.md", { provider, pr_id: prId, url });
536
- }
537
-
538
- // --- bind / subscribe management (Gap 1) --------------------------------------------------------
539
-
540
- /** The binding the worker applies to every (re)bound session: headless (`hasUI === false`). */
541
- export function headlessBinding(): {
542
- uiContext: undefined;
543
- mode: "json";
544
- onError: (err: unknown) => void;
545
- } {
546
- return {
547
- uiContext: undefined,
548
- mode: "json",
549
- onError: (err: unknown) => console.error(`perk worker: extension error — ${String(err)}`),
550
- };
551
- }
552
-
553
- /**
554
- * Manage the bind+subscribe lifecycle across session replacement (Gap 1). `bind(target)` binds the
555
- * perk extension and attaches the terminal/budget listener; calling it again (after a runtime
556
- * replacement) unsubscribes the prior listener first so events are never double-counted. A mid-drive
557
- * replacement is not expected on the happy path (the prompt instructs `/submit`, never `/implement`;
558
- * `lifecycleGates.newSession` is `hasUI`-guarded; objective compaction is inert with no active
559
- * objective) — so an observed `rebind()` is a loud structured-log error.
560
- */
561
- export function createBindManager(binding: unknown, listener: (event: DriveEvent) => void) {
562
- let unsubscribe: (() => void) | null = null;
563
- return {
564
- async bind(target: DriveSessionLike): Promise<void> {
565
- if (unsubscribe) unsubscribe();
566
- await target.bindExtensions(binding);
567
- unsubscribe = target.subscribe(listener);
568
- },
569
- dispose(): void {
570
- if (unsubscribe) {
571
- unsubscribe();
572
- unsubscribe = null;
573
- }
574
- },
575
- };
576
- }
577
-
578
- // --- the production runtime factory -------------------------------------------------------------
579
-
580
- /**
581
- * Build the asymmetric runtime: `cwd = worktree` (project tier — perk's `@mgiles/perk` extension via the
582
- * managed `.pi/settings.json`, the managed `AGENTS.md`/`APPEND_SYSTEM.md`) and `agentDir = throwaway`
583
- * (user-global tier OUT — the throwaway dir has no `settings.json`, so the global tier is empty),
584
- * env-var/registry auth+model (Gap 5). Settings are DISK-LAYERED (`SettingsManager.create` +
585
- * `applyOverrides`, the SDK's sanctioned "with overrides" shape — docs/sdk.md "Settings
586
- * Management"): the project tier resolves the managed `packages` list, while the compaction-off/
587
- * retry-off determinism overrides ride the merged view only (package resolution reads the
588
- * per-scope raws — overrides cannot leak into it). Missing `npm:` packages auto-install into
589
- * `.pi/npm` during the loader's reload (skipped under `PI_OFFLINE`); an install failure throws →
590
- * `driveStage`'s catch arm → a loud `failed`/`drive_error`. No `tools` allowlist — read-write
591
- * defaults + extension tools. The `createAgentSessionServices` factory builds the
592
- * `DefaultResourceLoader` internally from `cwd`/`agentDir` (recipe correction #1).
593
- */
594
- async function defaultCreateRuntime(
595
- opts: DriveStageOptions,
596
- resolved: ResolvedAuth,
597
- ): Promise<DriveRuntimeLike> {
598
- const agentDir = mkdtempSync(join(tmpdir(), "perk-worker-agent-"));
599
- const settingsManager = SettingsManager.create(opts.worktree, agentDir);
600
- settingsManager.applyOverrides({ compaction: { enabled: false }, retry: { enabled: false } });
601
- const factory: CreateAgentSessionRuntimeFactory = async (factoryOpts) => {
602
- const services = await createAgentSessionServices({
603
- cwd: factoryOpts.cwd,
604
- agentDir: factoryOpts.agentDir,
605
- settingsManager,
606
- modelRuntime: resolved.modelRuntime,
607
- });
608
- const result = await createAgentSessionFromServices({
609
- services,
610
- sessionManager: factoryOpts.sessionManager,
611
- sessionStartEvent: factoryOpts.sessionStartEvent,
612
- // `undefined` ⇒ the SDK's initial-model resolution picks the model (see `resolveAuth`);
613
- // an `undefined` thinkingLevel likewise defers to the settings default.
614
- model: resolved.model,
615
- thinkingLevel: opts.thinkingLevel,
616
- });
617
- // Name the model that will actually drive (the SDK may have picked it) — the remote step
618
- // log is otherwise silent about it until a provider error.
619
- const chosen = result.session.model;
620
- console.error(
621
- `perk worker: model ${chosen ? `${chosen.provider}/${chosen.id}` : "unresolved"}`,
622
- );
623
- // Loud construction diagnostics (the CAUSE behind a later `no_extension_tools` symptom):
624
- // settings I/O errors and extension load errors are recorded, not raised, by the SDK —
625
- // surfacing them is the app layer's job. Fail-soft reporting only; never throws.
626
- for (const entry of result.extensionsResult.errors) {
627
- console.error(`perk worker: extension load error — ${entry.path}: ${entry.error}`);
628
- }
629
- for (const entry of settingsManager.drainErrors()) {
630
- console.error(`perk worker: settings error (${entry.scope}) — ${String(entry.error)}`);
631
- }
632
- return { ...result, services, diagnostics: services.diagnostics };
633
- };
634
- const runtime = await createAgentSessionRuntime(factory, {
635
- cwd: opts.worktree,
636
- agentDir,
637
- sessionManager: SessionManager.create(opts.worktree),
638
- });
639
- return runtime as unknown as DriveRuntimeLike;
640
- }
641
-
642
- // --- model/auth resolution (Gap 5) --------------------------------------------------------------
643
-
644
- export interface ResolvedAuth {
645
- modelRuntime: ModelRuntime;
646
- /** The EXPLICIT model only; `undefined` defers the pick to the SDK at session creation. */
647
- model: Model<Api> | undefined;
648
- }
649
-
650
- /**
651
- * Resolve auth; returns null (never throws a domain error) when no model is available at all.
652
- * The model is NOT pre-pinned from the runtime: an `undefined` model lets `createAgentSession`
653
- * run its own initial-model resolution (settings `defaultModel` → pi's curated per-provider
654
- * defaults → first available), which picks a current-generation model instead of the catalogue's
655
- * alphabetically-first (= oldest) entry. Async because pi 0.84's `ModelRuntime.create` is async
656
- * (the default creation stays offline — `allowModelNetwork` defaults false).
657
- */
658
- export async function resolveAuth(opts: DriveStageOptions): Promise<ResolvedAuth | null> {
659
- const modelRuntime = opts.modelRuntime ?? (await ModelRuntime.create());
660
- if (!opts.model && modelRuntime.getAvailableSnapshot().length === 0) return null;
661
- return { modelRuntime, model: opts.model };
662
- }
663
-
664
- /** What an explicit `--model` flag resolves to (a thin projection of `ResolveCliModelResult`). */
665
- export interface ResolvedWorkerModel {
666
- model: Model<Api> | undefined;
667
- thinkingLevel: ThinkingLevel | undefined;
668
- /** Non-fatal resolution diagnostic (e.g. an invalid `:thinking` suffix) — surface, continue. */
669
- warning: string | undefined;
670
- /** Fatal: the pattern resolved to no model — fail fast, never guess. */
671
- error: string | undefined;
672
- }
673
-
674
- /**
675
- * Resolve an explicit `--model` flag with pi's OWN CLI semantics (`resolveCliModel`): fuzzy
676
- * matching, bare-id resolution, `provider/pattern`, and a `:thinking` suffix — the same chain the
677
- * flag's string hits in an interactive pi launch, closing the warm/cold parity gap (cf.
678
- * docs/learned/workflow/execution-path-parity.md). `raw` falsy ⇒ all-undefined (the SDK's own
679
- * default resolution picks the model at session creation — see `resolveAuth`). A resolution that
680
- * yields neither a model nor an error is normalized to the worker's not-found error.
681
- */
682
- export function resolveWorkerModel(
683
- raw: string | undefined,
684
- modelRuntime: ModelRuntime,
685
- ): ResolvedWorkerModel {
686
- if (!raw) {
687
- return { model: undefined, thinkingLevel: undefined, warning: undefined, error: undefined };
688
- }
689
- const result = resolveCliModel({ cliModel: raw, modelRuntime });
690
- if (result.model === undefined && result.error === undefined) {
691
- return {
692
- model: undefined,
693
- thinkingLevel: undefined,
694
- warning: result.warning,
695
- error: `model '${raw}' not found in the registry.`,
696
- };
697
- }
698
- return {
699
- model: result.model,
700
- thinkingLevel: result.thinkingLevel,
701
- warning: result.warning,
702
- error: result.error,
703
- };
704
- }
705
-
706
- // --- the drive primitive ------------------------------------------------------------------------
707
-
708
- /**
709
- * Drive one stage to terminal and return a structured `RunOutcome` — never throws (fail-soft like
710
- * `submitPr`). Seeds `initialPrompt`, races the driving `prompt()` against the budget watchdog and
711
- * the external `signal`, classifies the terminal at idle, and disposes the runtime in `finally`.
712
- */
713
- export async function driveStage(
714
- opts: DriveStageOptions,
715
- deps: DriveStageDeps = {},
716
- ): Promise<RunOutcome> {
717
- const now = deps.now ?? Date.now;
718
- const startMs = now();
719
- const counters = freshCounters();
720
- const elapsed = (): number => Math.max(0, now() - startMs);
721
-
722
- // Structured run-event stream: resolve the sink + run_id once, build the emitter, and
723
- // route every terminal exit through `finish` so exactly one `run_finished` is emitted per drive.
724
- const runId = env.PERK_RUN_ID ?? "";
725
- const sink = deps.eventSink ?? defaultEventSink(opts.worktree, runId);
726
- const emitter = createEventEmitter(sink, now, startMs);
727
- const finish = (verdict: TerminalVerdict): RunOutcome => {
728
- const outcome = assembleOutcome({
729
- stage: opts.stage,
730
- verdict,
731
- budget: { turns: counters.turns, tokens: counters.tokens, elapsed_ms: elapsed() },
732
- });
733
- emitter.emit({ kind: "run_finished", outcome });
734
- return outcome;
735
- };
736
-
737
- // Auth/model resolution is a production-path concern only: with an injected runtime factory
738
- // (tests) the drive never touches the default `ModelRuntime.create` (no host file reads).
739
- const resolved = deps.createRuntime ? null : await resolveAuth(opts);
740
- if (resolved === null && !deps.createRuntime) {
741
- // A zero-turn run is still observable: emit a `run_started` + `run_finished` pair.
742
- emitter.emit({ kind: "run_started", run_id: runId, stage: opts.stage });
743
- return finish({
744
- status: "failed",
745
- terminal_signal: "model_error",
746
- pr: null,
747
- errorType: "no_model",
748
- errorMessage: "no model available — set an API key (e.g. ANTHROPIC_API_KEY) or pass a model.",
749
- });
750
- }
751
-
752
- let terminationReason: "natural" | "budget" | "abort" = "natural";
753
- let settled = false;
754
- let runtime: DriveRuntimeLike | null = null;
755
- const bindManager = createBindManager(headlessBinding(), (event) => {
756
- applyEvent(counters, event);
757
- if (event.type === "turn_end") {
758
- if (budgetTripped(counters, opts.budget)) trip("budget");
759
- } else if (event.type === "tool_execution_end") {
760
- const o = toolOutcomeOf(event);
761
- emitter.emit({ kind: "tool_outcome", tool: o.tool, ok: o.ok, summary: o.summary });
762
- }
763
- });
764
-
765
- function trip(reason: "budget" | "abort"): void {
766
- if (settled) return;
767
- if (terminationReason === "natural") terminationReason = reason;
768
- if (runtime) void runtime.session.abort();
769
- }
770
-
771
- const onSignal = (): void => trip("abort");
772
-
773
- try {
774
- runtime = deps.createRuntime
775
- ? await deps.createRuntime(opts)
776
- : // biome-ignore lint/style/noNonNullAssertion: resolved is non-null on the production path.
777
- await defaultCreateRuntime(opts, resolved!);
778
-
779
- let boundSession = runtime.session;
780
- await bindManager.bind(boundSession);
781
- emitter.emit({ kind: "run_started", run_id: runId, stage: opts.stage });
782
-
783
- // Terminating-tool preflight (presence-gated on `extensionRunner`): disk discovery has a
784
- // silent-zero arm — a missing/unparseable `.pi/settings.json` or an unresolvable local-path
785
- // package yields ZERO extension tools without throwing — so fail fast (zero turns) instead of
786
- // burning the whole budget on a drive that can never call its terminating tool. Reuses the
787
- // `model_error` terminal signal with a distinct `error.type` (the `no_model` precedent).
788
- if (boundSession.extensionRunner) {
789
- const toolNames = boundSession.extensionRunner
790
- .getAllRegisteredTools()
791
- .map((t) => t.definition.name);
792
- const missing = missingTerminatingTool(opts.stage, toolNames);
793
- if (missing !== null) {
794
- return finish({
795
- status: "failed",
796
- terminal_signal: "model_error",
797
- pr: null,
798
- errorType: "no_extension_tools",
799
- errorMessage:
800
- `perk extension tools did not register — the ${opts.stage} stage's terminating ` +
801
- `tool \`${missing}\` is missing. Check the worktree's .pi/settings.json packages ` +
802
- "list (perk init converges it); construction diagnostics are on stderr.",
803
- });
804
- }
805
- }
806
-
807
- // Implementation/worker session pointer (contracts.md §8.35): the headless drive records the
808
- // inner driven session's file under THIS run id into the shared main checkout (the worktree's
809
- // `mainCheckoutRoot`), labelled `.worker` by capture site. The inner session's own
810
- // `session_start` records the matching `.main`. Best-effort + non-fatal (carrier warns).
811
- if (opts.stage === "implement") {
812
- captureSessionPointer({
813
- cwd: opts.worktree,
814
- runId,
815
- klass: "implementation",
816
- site: "worker",
817
- sessionFile: boundSession.sessionManager.getSessionFile?.() ?? null,
818
- });
819
- }
820
-
821
- // Budget/abort wiring (Gap 2): wall-clock timer + external signal both trip → session.abort().
822
- const timer = setTimeout(() => trip("budget"), opts.budget.wallClockMs);
823
- if (opts.signal) {
824
- if (opts.signal.aborted) onSignal();
825
- else opts.signal.addEventListener("abort", onSignal, { once: true });
826
- }
827
-
828
- try {
829
- await runtime.session.prompt(opts.initialPrompt);
830
- } finally {
831
- clearTimeout(timer);
832
- opts.signal?.removeEventListener("abort", onSignal);
833
- settled = true;
834
- }
835
-
836
- // Defensive rebind (Gap 1): the happy path never replaces the session; a replacement is loud.
837
- if (runtime.session !== boundSession) {
838
- console.error("perk worker: unexpected mid-drive session replacement — rebinding listener.");
839
- boundSession = runtime.session;
840
- await bindManager.bind(boundSession);
841
- }
842
-
843
- const verdict = classify(opts, counters, terminationReason, boundSession);
844
- return finish(verdict);
845
- } catch (err) {
846
- const message = err instanceof Error ? err.message : String(err);
847
- return finish({
848
- status: "failed",
849
- terminal_signal: "model_error",
850
- pr: null,
851
- errorType: "drive_error",
852
- errorMessage: `headless drive failed: ${message}`,
853
- });
854
- } finally {
855
- bindManager.dispose();
856
- if (runtime) await runtime.dispose();
857
- }
858
- }
859
-
860
- /** Pick the terminal verdict: watchdog/abort override the natural-idle classification. */
861
- function classify(
862
- opts: DriveStageOptions,
863
- counters: DriveCounters,
864
- terminationReason: "natural" | "budget" | "abort",
865
- session: DriveSessionLike,
866
- ): TerminalVerdict {
867
- if (terminationReason === "budget") {
868
- return {
869
- status: "budget_exhausted",
870
- terminal_signal: "budget",
871
- pr: null,
872
- errorType: "budget",
873
- errorMessage: "budget exhausted (turns/tokens/wall-clock) — drive aborted.",
874
- };
875
- }
876
- if (terminationReason === "abort") {
877
- return {
878
- status: "aborted",
879
- terminal_signal: "external_abort",
880
- pr: null,
881
- errorType: "external_abort",
882
- errorMessage: "drive aborted by external signal.",
883
- };
884
- }
885
- const lastReviewBatchPresent =
886
- rebuildWorkflowState(session.sessionManager.getBranch() as never).last_review_batch != null;
887
- return evaluateTerminal({
888
- stage: opts.stage,
889
- submitDetails: counters.submitDetails,
890
- finalizeDetails: counters.finalizeDetails,
891
- lastReviewBatchPresent,
892
- modelError: counters.modelError,
893
- });
894
- }
895
-
896
- /** Convenience: re-derive the initial prompt for a prepared worktree (reads its `cache.plan-ref`). */
897
- export function initialPromptForWorktree(worktree: string, stage: DriveStage): string | null {
898
- return initialPromptFor(stage, readPlanRef(worktree));
899
- }