@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,679 @@
1
+ // The confined stage-execution seam (`runStage`) — the headless stage-drive primitive.
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 the adapter's `StageEvent.freshTokens`.
18
+ //
19
+ // CONFINEMENT: this seam's caller surface carries no SDK shapes. Every `@earendil-works` import
20
+ // AND the session-drive mechanics (construction, raw events, prompt/abort) live in the private
21
+ // `./sdkAdapter.ts` — the seam drives the session only through the adapter's drive-session
22
+ // handle, whose listener receives the perk-owned `StageEvent` union (the adapter translates raw
23
+ // SDK events at the boundary; ALL policy folding — budget counters, terminal capture, outcome —
24
+ // happens here on perk shapes). The one opaque model input (`StageRunOptions.model`) is the
25
+ // adapter-minted nominal `WorkerModelSelection` (see the adapter header for the exact — narrow —
26
+ // opacity guarantee: an import-edge ban plus nominal minting, nothing stronger). `workerMain.ts`
27
+ // imports ONLY this seam (guard Rule F) and carries zero SDK imports.
28
+
29
+ import { appendFileSync } from "node:fs";
30
+ import { env } from "node:process";
31
+ import { ensureRunScratch, type PlanRef, readPlanRef, runEventsPath } from "../substrate/cache.ts";
32
+ import { capForModel } from "../substrate/modelVisible.ts";
33
+ import { planReadInstruction, render } from "../substrate/prompts.ts";
34
+ import { captureSessionPointer } from "../substrate/sessionPointers.ts";
35
+ import { rebuildWorkflowState } from "../substrate/workflowState.ts";
36
+ import {
37
+ createDriveSession,
38
+ type DriveRuntimeLike,
39
+ type DriveSessionHandle,
40
+ defaultCreateRuntime,
41
+ resolveAuth,
42
+ type StageEvent,
43
+ type WorkerModelSelection,
44
+ } from "./sdkAdapter.ts";
45
+
46
+ // Re-exports so `workerMain.ts` imports ONLY the seam (guard Rule F), plus the transitive type
47
+ // closure of `runStage`'s signature: the `StageRunDeps.createRuntime` fake-construction types and
48
+ // the type-only `StageRunOptions.model` token (`WorkerModelSelection` minting stays
49
+ // adapter-owned — tests that mint import the class from `sdkAdapter.ts`).
50
+ export type {
51
+ DriveEvent,
52
+ DriveRuntimeLike,
53
+ DriveSessionLike,
54
+ WorkerModelSelection,
55
+ } from "./sdkAdapter.ts";
56
+ export { resolveWorkerModel } from "./sdkAdapter.ts";
57
+
58
+ // --- contract types (additive-stable; §B of docs/design/headless-worker.md) ---------------------
59
+
60
+ /** The two read-write stages with `doors.cold_remote: true` (shared/registry.yaml). */
61
+ export type DriveStage = "implement" | "address";
62
+
63
+ /** Terminal run status (audit §B outcome shape). */
64
+ export type RunStatus = "completed" | "failed" | "aborted" | "budget_exhausted";
65
+
66
+ /** The first-of terminal signal that ended the drive (audit §B). */
67
+ export type TerminalSignal =
68
+ | "submit_tool"
69
+ | "address_resolved"
70
+ | "agent_idle_incomplete"
71
+ | "budget"
72
+ | "external_abort"
73
+ | "model_error";
74
+
75
+ /** The budget/timeout watchdog inputs (Gap 2). */
76
+ export interface DriveBudget {
77
+ maxTurns: number;
78
+ maxTokens: number;
79
+ wallClockMs: number;
80
+ }
81
+
82
+ /**
83
+ * The structured run outcome (audit §B). **Additive-stable**: later fields may be added; existing
84
+ * fields keep their meaning. Never thrown — `runStage` always resolves with one of these.
85
+ */
86
+ export interface RunOutcome {
87
+ run_id: string;
88
+ stage: DriveStage;
89
+ status: RunStatus;
90
+ terminal_signal: TerminalSignal;
91
+ pr: { number: number; url: string } | null;
92
+ budget: { turns: number; tokens: number; elapsed_ms: number };
93
+ error: { type: string; message: string; summary: string } | null;
94
+ }
95
+
96
+ // --- structured run-event stream (§8.12) ----------------------------------------------
97
+
98
+ /**
99
+ * The structured run-event stream (contracts §8.12). A small, JSON-serializable,
100
+ * **additive-stable** discriminated union keyed on `kind` (distinct from `DriveEvent.type`). Every
101
+ * event carries a monotonic `seq` (0-based) and `t` (elapsed ms, same basis as
102
+ * `RunOutcome.budget.elapsed_ms`). Future nodes may add variants/fields; existing ones keep
103
+ * meaning — including deprecated variants that are no longer emitted (see `step_marker`).
104
+ */
105
+ export type RunEvent =
106
+ | { kind: "run_started"; seq: number; t: number; run_id: string; stage: DriveStage }
107
+ // DEPRECATED — never emitted: the `[WIP:n]`/`[DONE:n]` marker protocol died with the
108
+ // checkpoints removal. Kept for additive-stable grammar — historical `events.ndjson` files
109
+ // may carry the variant (contracts §8.12).
110
+ | { kind: "step_marker"; seq: number; t: number; marker: "wip" | "done"; step: number }
111
+ | {
112
+ kind: "tool_outcome";
113
+ seq: number;
114
+ t: number;
115
+ tool: string;
116
+ ok: boolean;
117
+ summary: string | null;
118
+ }
119
+ | { kind: "run_finished"; seq: number; t: number; outcome: RunOutcome };
120
+
121
+ /** The injectable delivery seam: default = a run-scoped NDJSON file sink; tests inject an array. */
122
+ export type RunEventSink = (event: RunEvent) => void;
123
+
124
+ /** Distributive `Omit` so each `RunEvent` variant keeps its own fields when `seq`/`t` are stamped. */
125
+ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
126
+ type RunEventInput = DistributiveOmit<RunEvent, "seq" | "t">;
127
+
128
+ /** Per-event free-text cap (route-don't-relay): events carry the narrative, not raw tool payloads. */
129
+ const EVENT_SUMMARY_CAP = 2 * 1024;
130
+
131
+ export interface StageRunOptions {
132
+ /** Absolute path to the already-positioned worktree (Gap 7). */
133
+ worktree: string;
134
+ stage: DriveStage;
135
+ /** The seeded first prompt (see `initialPromptFor`). */
136
+ initialPrompt: string;
137
+ /**
138
+ * The one opaque model input: an adapter-minted nominal `WorkerModelSelection` (from
139
+ * `resolveWorkerModel`). Absent ⇒ the adapter builds a default-runtime selection and the SDK's
140
+ * own default resolution picks the model at session creation (settings `defaultModel` → pi's
141
+ * per-provider defaults → first available — Gap 5). Never pre-pinned here: `getAvailable()`
142
+ * sorts alphabetically, so `[0]` is the *oldest* model of the first provider (a since-removed
143
+ * `claude-3-5-haiku` date-pin 404'd a whole remote drive).
144
+ */
145
+ model?: WorkerModelSelection;
146
+ budget: DriveBudget;
147
+ /** External cancellation; OR'd with the budget watchdog. */
148
+ signal?: AbortSignal;
149
+ }
150
+
151
+ /**
152
+ * The offline seam. `createRuntime` overrides the production runtime factory so tests drive
153
+ * synthetic sessions; `now` injects the clock for deterministic `elapsed_ms`.
154
+ */
155
+ export interface StageRunDeps {
156
+ createRuntime?: (opts: StageRunOptions) => Promise<DriveRuntimeLike>;
157
+ now?: () => number;
158
+ /** The structured run-event sink. Absent ⇒ the default run-scoped NDJSON file sink. */
159
+ eventSink?: RunEventSink;
160
+ }
161
+
162
+ /** The natural-idle terminal classification (before watchdog/abort overrides). */
163
+ interface TerminalVerdict {
164
+ status: RunStatus;
165
+ terminal_signal: TerminalSignal;
166
+ pr: { number: number; url: string } | null;
167
+ errorType: string | null;
168
+ errorMessage: string | null;
169
+ }
170
+
171
+ // --- pure helpers (module-local policy fold) -----------------------------------------------------
172
+
173
+ /** Mutable counters/captures the drive's policy fold accumulates over the run. */
174
+ interface DriveCounters {
175
+ turns: number;
176
+ tokens: number;
177
+ /** Latest submit-bearing evidence (standalone submit or the nested finalizer submit). */
178
+ submitDetails: Record<string, unknown> | null;
179
+ finalizeDetails: Record<string, unknown> | null;
180
+ modelError: { message: string } | null;
181
+ }
182
+
183
+ function freshCounters(): DriveCounters {
184
+ return { turns: 0, tokens: 0, submitDetails: null, finalizeDetails: null, modelError: null };
185
+ }
186
+
187
+ /**
188
+ * Fold one perk-owned drive event into the running counters (pure) — the seam's policy fold over
189
+ * the adapter's translated `StageEvent` union. Counts turns and fresh-work tokens (the sum is
190
+ * adapter-computed; see `StageEvent`'s `freshTokens` doc for the reasoning-subset exclusion),
191
+ * captures the `submit`/`finalize_address` terminal tool details, and records a post-acceptance
192
+ * model error.
193
+ */
194
+ function applyStageEvent(counters: DriveCounters, event: StageEvent): void {
195
+ if (event.kind === "turn_ended") {
196
+ counters.turns += 1;
197
+ counters.tokens += event.freshTokens;
198
+ return;
199
+ }
200
+ if (event.kind === "tool_ended") {
201
+ if (event.tool === "submit") counters.submitDetails = event.details;
202
+ else if (event.tool === "finalize_address") {
203
+ counters.finalizeDetails = event.details;
204
+ // A finalizer carries the submit that immediately preceded resolution. Recording it into the
205
+ // same latest-evidence slot means a later standalone submit naturally supersedes it after a
206
+ // conflict-resolver re-drive.
207
+ const nestedSubmit = event.details?.submit;
208
+ if (nestedSubmit && typeof nestedSubmit === "object" && !Array.isArray(nestedSubmit)) {
209
+ // The finalizer only exposes this nested block after submit succeeded; restore the
210
+ // success marker stripped from its nested public shape so a later failed standalone
211
+ // submit cannot accidentally satisfy the address completion predicate.
212
+ counters.submitDetails = { ok: true, ...(nestedSubmit as Record<string, unknown>) };
213
+ }
214
+ }
215
+ return;
216
+ }
217
+ counters.modelError = { message: event.message };
218
+ }
219
+
220
+ /**
221
+ * The one abort verdict — shared by the entry sample, the pre-prompt sample and the mid-drive
222
+ * `classify()` arm so all three abort routes yield a byte-identical outcome.
223
+ */
224
+ const EXTERNAL_ABORT_VERDICT: TerminalVerdict = {
225
+ status: "aborted",
226
+ terminal_signal: "external_abort",
227
+ pr: null,
228
+ errorType: "external_abort",
229
+ errorMessage: "drive aborted by external signal.",
230
+ };
231
+
232
+ /** True when the budget watchdog should trip from the current counters. */
233
+ function budgetTripped(counters: DriveCounters, budget: DriveBudget): boolean {
234
+ return counters.turns >= budget.maxTurns || counters.tokens >= budget.maxTokens;
235
+ }
236
+
237
+ /**
238
+ * Classify a natural-idle terminal from the captured state (pure). `modelError` wins (post-
239
+ * acceptance error, §B #4); else the stage success predicate:
240
+ * - implement: a successful `submit` carrying a `pr` → completed/submit_tool;
241
+ * - address: `finalize_address` ok, `last_review_batch` appended, and the latest submit-bearing
242
+ * evidence is successful and not definitively unmergeable → completed/address_resolved;
243
+ * - otherwise the agent went idle without completing the stage → failed/agent_idle_incomplete.
244
+ */
245
+ function evaluateTerminal(args: {
246
+ stage: DriveStage;
247
+ submitDetails: Record<string, unknown> | null;
248
+ finalizeDetails: Record<string, unknown> | null;
249
+ lastReviewBatchPresent: boolean;
250
+ modelError: { message: string } | null;
251
+ }): TerminalVerdict {
252
+ if (args.modelError !== null) {
253
+ return {
254
+ status: "failed",
255
+ terminal_signal: "model_error",
256
+ pr: null,
257
+ errorType: "model_error",
258
+ errorMessage: args.modelError.message,
259
+ };
260
+ }
261
+
262
+ if (args.stage === "implement") {
263
+ const pr = extractPr(args.submitDetails);
264
+ if (args.submitDetails?.ok === true && pr !== null) {
265
+ // Completion additionally requires the submit to be mergeable: a definitively-
266
+ // unmergeable PR (merge conflicts unresolved) is NOT done. `mergeable === true`/`null`/
267
+ // absent all allow completion (fail-open); only a definitive `false` blocks it. On the
268
+ // happy path the resolver follow-up turns re-submit, overwriting submitDetails with a
269
+ // mergeable result, so the natural-idle classification then passes.
270
+ if (args.submitDetails.mergeable === false) {
271
+ return {
272
+ status: "failed",
273
+ terminal_signal: "agent_idle_incomplete",
274
+ pr: null,
275
+ errorType: "incomplete",
276
+ errorMessage:
277
+ "implement drive went idle with an unmergeable PR (merge conflicts unresolved).",
278
+ };
279
+ }
280
+ return {
281
+ status: "completed",
282
+ terminal_signal: "submit_tool",
283
+ pr,
284
+ errorType: null,
285
+ errorMessage: null,
286
+ };
287
+ }
288
+ return {
289
+ status: "failed",
290
+ terminal_signal: "agent_idle_incomplete",
291
+ pr: null,
292
+ errorType: "incomplete",
293
+ errorMessage: "implement drive went idle without an opened PR (no successful submit).",
294
+ };
295
+ }
296
+
297
+ // address. `applyEvent` keeps submitDetails as the latest submit-bearing evidence: the nested
298
+ // finalizer submit first, then a later standalone submit from the conflict-resolution re-drive.
299
+ const nestedSubmit = args.finalizeDetails?.submit;
300
+ const fallbackSubmit: Record<string, unknown> | null =
301
+ nestedSubmit && typeof nestedSubmit === "object" && !Array.isArray(nestedSubmit)
302
+ ? { ok: true, ...(nestedSubmit as Record<string, unknown>) }
303
+ : null;
304
+ const effectiveSubmit = args.submitDetails ?? fallbackSubmit;
305
+ if (
306
+ args.finalizeDetails?.ok === true &&
307
+ args.lastReviewBatchPresent &&
308
+ effectiveSubmit?.ok === true &&
309
+ effectiveSubmit.mergeable !== false
310
+ ) {
311
+ return {
312
+ status: "completed",
313
+ terminal_signal: "address_resolved",
314
+ pr: null,
315
+ errorType: null,
316
+ errorMessage: null,
317
+ };
318
+ }
319
+ return {
320
+ status: "failed",
321
+ terminal_signal: "agent_idle_incomplete",
322
+ pr: null,
323
+ errorType: "incomplete",
324
+ errorMessage:
325
+ "address drive went idle without fully finalizing feedback " +
326
+ "(publication, thread resolution, and last_review_batch are required).",
327
+ };
328
+ }
329
+
330
+ /**
331
+ * The post-bind preflight rule (pure): the stage's terminating perk tool must be registered —
332
+ * `implement` → `submit`, `address` → `finalize_address`. Returns the required tool name
333
+ * when absent, else `null`. Deliberately does NOT require the `subagent` tool for `address` — the
334
+ * subagent-under-worker live smoke stays the §8.11 carried risk.
335
+ */
336
+ function missingTerminatingTool(stage: DriveStage, toolNames: string[]): string | null {
337
+ const required = stage === "implement" ? "submit" : "finalize_address";
338
+ return toolNames.includes(required) ? null : required;
339
+ }
340
+
341
+ /** Pull a `{ number, url }` PR from a captured `submit` details block; null when malformed. */
342
+ function extractPr(
343
+ details: Record<string, unknown> | null,
344
+ ): { number: number; url: string } | null {
345
+ if (!details || typeof details.pr !== "object" || details.pr === null) return null;
346
+ const pr = details.pr as { number?: unknown; url?: unknown };
347
+ if (typeof pr.number === "number" && typeof pr.url === "string") {
348
+ return { number: pr.number, url: pr.url };
349
+ }
350
+ return null;
351
+ }
352
+
353
+ /**
354
+ * Compose the final `RunOutcome` (pure). `run_id` is read from `PERK_RUN_ID` (inherited from
355
+ * positioning, Gap 7), overridable for tests. On a non-completed status the `error` block carries a
356
+ * capped `error.summary` (route-don't-relay discipline); a completed status has `error: null`.
357
+ */
358
+ function assembleOutcome(args: {
359
+ stage: DriveStage;
360
+ verdict: TerminalVerdict;
361
+ budget: { turns: number; tokens: number; elapsed_ms: number };
362
+ runId?: string;
363
+ }): RunOutcome {
364
+ const { verdict } = args;
365
+ const error =
366
+ verdict.status === "completed" || verdict.errorMessage === null
367
+ ? null
368
+ : {
369
+ type: verdict.errorType ?? "error",
370
+ message: verdict.errorMessage,
371
+ summary: capForModel(verdict.errorMessage).shown,
372
+ };
373
+ return {
374
+ run_id: args.runId ?? env.PERK_RUN_ID ?? "",
375
+ stage: args.stage,
376
+ status: verdict.status,
377
+ terminal_signal: verdict.terminal_signal,
378
+ pr: verdict.pr,
379
+ budget: args.budget,
380
+ error,
381
+ };
382
+ }
383
+
384
+ // --- run-event helpers (offline-testable) ---------------------------------------------
385
+
386
+ /**
387
+ * Compute a `tool_outcome` `{ tool, ok, summary }` from a translated `tool_ended` event (pure).
388
+ * `summary` is `null` on success and, on failure, a capped (route-don't-relay) rendering of the
389
+ * adapter's pre-cap error text — never the raw tool result. The `errorText` nullish fallback
390
+ * below is defensive only: the adapter always supplies `errorText` for failures (pinned in
391
+ * `sdkAdapter.test.ts`), so it is unreachable through the production path.
392
+ */
393
+ function toolOutcomeOf(event: Extract<StageEvent, { kind: "tool_ended" }>): {
394
+ tool: string;
395
+ ok: boolean;
396
+ summary: string | null;
397
+ } {
398
+ const summary = event.ok
399
+ ? null
400
+ : capForModel(event.errorText ?? `tool ${event.tool} failed`, EVENT_SUMMARY_CAP).shown;
401
+ return { tool: event.tool, ok: event.ok, summary };
402
+ }
403
+
404
+ /**
405
+ * The run-event emitter: owns the monotonic `seq` counter and stamps `t = max(0, now() - startMs)`
406
+ * (same basis as `RunOutcome.budget.elapsed_ms`). Fail-soft: a throwing injected sink is caught and
407
+ * swallowed so a broken sink never aborts the drive.
408
+ */
409
+ function createEventEmitter(sink: RunEventSink, now: () => number, startMs: number) {
410
+ let seq = 0;
411
+ return {
412
+ emit(event: RunEventInput): void {
413
+ const full = { ...event, seq: seq++, t: Math.max(0, now() - startMs) } as RunEvent;
414
+ try {
415
+ sink(full);
416
+ } catch (err) {
417
+ console.error(`perk worker: run-event sink threw — ${String(err)}`);
418
+ }
419
+ },
420
+ };
421
+ }
422
+
423
+ /**
424
+ * The default run-event sink: a fail-soft NDJSON appender to `runEventsPath(worktree, runId)`. A
425
+ * **no-op when `runId` is empty** (keeps the offline drive tests, which set no `PERK_RUN_ID`,
426
+ * write-free). Each append is wrapped so a write error logs and is swallowed.
427
+ */
428
+ function defaultEventSink(worktree: string, runId: string): RunEventSink {
429
+ if (!runId) return () => {};
430
+ let ensured = false;
431
+ const path = runEventsPath(worktree, runId);
432
+ return (event: RunEvent): void => {
433
+ try {
434
+ if (!ensured) {
435
+ ensureRunScratch(worktree, runId);
436
+ ensured = true;
437
+ }
438
+ appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8");
439
+ } catch (err) {
440
+ console.error(`perk worker: run-event sink write failed — ${String(err)}`);
441
+ }
442
+ };
443
+ }
444
+
445
+ /**
446
+ * Re-derive the stage's initial prompt from the plan-ref — the TS twin of
447
+ * `perk/run/launch.py._implement_prompt`/`_address_prompt`. INVARIANT: textual parity with the Python
448
+ * plane (asserted reciprocally in `stageExecution.test.ts` + `tests/test_worker_prompt_parity.py`). No
449
+ * skill-binding suffix is appended here: in the driven session the bindings arrive via Mechanism A
450
+ * (bindingDelivery.ts injects the handoff stage's render because this prompt carries no
451
+ * `BINDING_HEADER`) — content byte-identical to the cold door's suffix (contracts.md §8.38).
452
+ * Returns `null` when there is no plan-ref (nothing to prime).
453
+ *
454
+ * The implement primer's wording lives in the canonical template `prompts/stages/implement.md`,
455
+ * rendered by the shared seam (contracts.md §8.31); branching stays in code — only the `read_cmd`
456
+ * var differs. This implement output is byte-identical to the warm `implementHandoffPrompt`.
457
+ *
458
+ * The `address` wording lives in the shared canonical template `prompts/stages/address/action.md`
459
+ * rendered via the cross-plane render seam (contracts.md §8.31); the worker has no preview path
460
+ * (preview is a warm/cold flag only), so it always renders the action body. The classify step is
461
+ * the `classify_review_feedback` tool, which reads the configured classifier model at execute
462
+ * time — nothing model-shaped rides the prompt.
463
+ */
464
+ function initialPromptFor(stage: DriveStage, planRef: PlanRef | null): string | null {
465
+ if (planRef === null) return null;
466
+ const provider = String(planRef.provider ?? "");
467
+ const prId = String(planRef.pr_id ?? "");
468
+ const url = String(planRef.url ?? "");
469
+ if (stage === "implement") {
470
+ const readCmd = planReadInstruction(provider, prId, url);
471
+ return render("stages/implement.md", { provider, pr_id: prId, url, read_cmd: readCmd });
472
+ }
473
+ // address
474
+ return render("stages/address/action.md", { provider, pr_id: prId, url });
475
+ }
476
+
477
+ // --- the drive primitive ------------------------------------------------------------------------
478
+
479
+ /**
480
+ * Drive one stage to terminal and return a structured `RunOutcome` — never throws (fail-soft like
481
+ * the delivery submit operation). Seeds `initialPrompt`, races the driving `prompt()` against the budget watchdog and
482
+ * the external `signal`, classifies the terminal at idle, and disposes the adapter handle in
483
+ * `finally` (guarded — a cleanup error can never replace the computed outcome). All session
484
+ * mechanics go through the adapter's drive-session handle; policy stays here.
485
+ *
486
+ * The `run_started` + `run_finished` pair is emitted at entry / at every exit, so each path —
487
+ * pre-aborted, `runtime_init`, `no_model`, the preflight, a pre-prompt abort, the drive — is a
488
+ * well-formed zero-or-more-turn stream. Auth/model resolution, runtime construction and the bind
489
+ * all run inside the outcome boundary: the catch arm keys its `error.type` on the bind boundary
490
+ * (`runtime_init` before the session is bound, `drive_error` after). The external `signal` is
491
+ * SAMPLED at entry and again immediately before `prompt()` (an idle session has nothing to abort,
492
+ * so an aborted sample returns `aborted` directly) and SUBSCRIBED only for the drive — the
493
+ * listener is registered synchronously after the pre-prompt sample because `AbortSignal` never
494
+ * replays an earlier abort to a late listener.
495
+ */
496
+ export async function runStage(
497
+ opts: StageRunOptions,
498
+ deps: StageRunDeps = {},
499
+ ): Promise<RunOutcome> {
500
+ const now = deps.now ?? Date.now;
501
+ const startMs = now();
502
+ const counters = freshCounters();
503
+ const elapsed = (): number => Math.max(0, now() - startMs);
504
+
505
+ // Structured run-event stream: resolve the sink + run_id once, build the emitter, emit
506
+ // `run_started` once at entry, and route every terminal exit through `finish` so exactly one
507
+ // `run_started` + `run_finished` pair is emitted per drive.
508
+ const runId = env.PERK_RUN_ID ?? "";
509
+ const sink = deps.eventSink ?? defaultEventSink(opts.worktree, runId);
510
+ const emitter = createEventEmitter(sink, now, startMs);
511
+ const finish = (verdict: TerminalVerdict): RunOutcome => {
512
+ const outcome = assembleOutcome({
513
+ stage: opts.stage,
514
+ verdict,
515
+ budget: { turns: counters.turns, tokens: counters.tokens, elapsed_ms: elapsed() },
516
+ });
517
+ emitter.emit({ kind: "run_finished", outcome });
518
+ return outcome;
519
+ };
520
+ emitter.emit({ kind: "run_started", run_id: runId, stage: opts.stage });
521
+
522
+ // The entry sample: nothing is resolved, constructed or bound for an already-aborted signal.
523
+ if (opts.signal?.aborted) return finish(EXTERNAL_ABORT_VERDICT);
524
+
525
+ let terminationReason: "natural" | "budget" | "abort" = "natural";
526
+ let settled = false;
527
+ let bound = false;
528
+ let handle: DriveSessionHandle | null = null;
529
+ const listener = (event: StageEvent): void => {
530
+ applyStageEvent(counters, event);
531
+ if (event.kind === "turn_ended") {
532
+ if (budgetTripped(counters, opts.budget)) trip("budget");
533
+ } else if (event.kind === "tool_ended") {
534
+ const o = toolOutcomeOf(event);
535
+ emitter.emit({ kind: "tool_outcome", tool: o.tool, ok: o.ok, summary: o.summary });
536
+ }
537
+ };
538
+
539
+ function trip(reason: "budget" | "abort"): void {
540
+ if (settled) return;
541
+ if (terminationReason === "natural") terminationReason = reason;
542
+ handle?.abort();
543
+ }
544
+
545
+ const onSignal = (): void => trip("abort");
546
+
547
+ try {
548
+ let runtime: DriveRuntimeLike;
549
+ if (deps.createRuntime) runtime = await deps.createRuntime(opts);
550
+ else {
551
+ // Auth/model resolution is a production-path concern only: an injected runtime factory
552
+ // (tests) never touches the default `ModelRuntime.create` (no host file reads).
553
+ const resolved = await resolveAuth(opts.model);
554
+ if (resolved === null) {
555
+ return finish({
556
+ status: "failed",
557
+ terminal_signal: "model_error",
558
+ pr: null,
559
+ errorType: "no_model",
560
+ errorMessage:
561
+ "no model available — set an API key (e.g. ANTHROPIC_API_KEY) or pass a model.",
562
+ });
563
+ }
564
+ runtime = await defaultCreateRuntime(opts.worktree, resolved);
565
+ }
566
+ handle = createDriveSession(runtime, listener);
567
+ await handle.bind();
568
+ bound = true;
569
+
570
+ // Terminating-tool preflight (presence-gated on the session's extension runner): disk
571
+ // discovery has a silent-zero arm — a missing/unparseable `.pi/settings.json` or an
572
+ // unresolvable local-path package yields ZERO extension tools without throwing — so fail
573
+ // fast (zero turns) instead of burning the whole budget on a drive that can never call its
574
+ // terminating tool. Reuses the `model_error` terminal signal with a distinct `error.type`
575
+ // (the `no_model` precedent).
576
+ const toolNames = handle.registeredToolNames();
577
+ if (toolNames !== null) {
578
+ const missing = missingTerminatingTool(opts.stage, toolNames);
579
+ if (missing !== null) {
580
+ return finish({
581
+ status: "failed",
582
+ terminal_signal: "model_error",
583
+ pr: null,
584
+ errorType: "no_extension_tools",
585
+ errorMessage:
586
+ `perk extension tools did not register — the ${opts.stage} stage's terminating ` +
587
+ `tool \`${missing}\` is missing. Check the worktree's .pi/settings.json packages ` +
588
+ "list (perk init converges it); construction diagnostics are on stderr.",
589
+ });
590
+ }
591
+ }
592
+
593
+ // Implementation/worker session pointer (contracts.md §8.35): the headless drive records the
594
+ // inner driven session's file under THIS run id into the shared main checkout (the worktree's
595
+ // `mainCheckoutRoot`), labelled `.worker` by capture site. The inner session's own
596
+ // `session_start` records the matching `.main`. Best-effort + non-fatal (carrier warns).
597
+ if (opts.stage === "implement") {
598
+ captureSessionPointer({
599
+ cwd: opts.worktree,
600
+ runId,
601
+ klass: "implementation",
602
+ site: "worker",
603
+ sessionFile: handle.sessionFile(),
604
+ });
605
+ }
606
+
607
+ // The pre-prompt sample, then the drive-only subscription with no `await` in between (no
608
+ // check-then-subscribe window). Budget/abort wiring (Gap 2): wall-clock timer + external
609
+ // signal both trip → handle.abort().
610
+ if (opts.signal?.aborted) return finish(EXTERNAL_ABORT_VERDICT);
611
+ const timer = setTimeout(() => trip("budget"), opts.budget.wallClockMs);
612
+ opts.signal?.addEventListener("abort", onSignal, { once: true });
613
+
614
+ try {
615
+ await handle.prompt(opts.initialPrompt);
616
+ } finally {
617
+ clearTimeout(timer);
618
+ opts.signal?.removeEventListener("abort", onSignal);
619
+ settled = true;
620
+ }
621
+
622
+ // Defensive rebind (Gap 1): the happy path never replaces the session; a replacement is loud.
623
+ if (await handle.rebindIfReplaced()) {
624
+ console.error("perk worker: unexpected mid-drive session replacement — rebinding listener.");
625
+ }
626
+
627
+ const verdict = classify(opts, counters, terminationReason, () => handle?.workflowBranch());
628
+ return finish(verdict);
629
+ } catch (err) {
630
+ const message = err instanceof Error ? err.message : String(err);
631
+ return finish({
632
+ status: "failed",
633
+ terminal_signal: "model_error",
634
+ pr: null,
635
+ errorType: bound ? "drive_error" : "runtime_init",
636
+ errorMessage: bound
637
+ ? `headless drive failed: ${message}`
638
+ : `worker runtime initialization failed: ${message}`,
639
+ });
640
+ } finally {
641
+ if (handle) await handle.dispose();
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Pick the terminal verdict: watchdog/abort override the natural-idle classification. `branch` is
647
+ * a thunk so the workflow branch is read only on the natural path (exactly as before the seam).
648
+ */
649
+ function classify(
650
+ opts: StageRunOptions,
651
+ counters: DriveCounters,
652
+ terminationReason: "natural" | "budget" | "abort",
653
+ branch: () => unknown[] | undefined,
654
+ ): TerminalVerdict {
655
+ if (terminationReason === "budget") {
656
+ return {
657
+ status: "budget_exhausted",
658
+ terminal_signal: "budget",
659
+ pr: null,
660
+ errorType: "budget",
661
+ errorMessage: "budget exhausted (turns/tokens/wall-clock) — drive aborted.",
662
+ };
663
+ }
664
+ if (terminationReason === "abort") return EXTERNAL_ABORT_VERDICT;
665
+ const lastReviewBatchPresent =
666
+ rebuildWorkflowState((branch() ?? []) as never).last_review_batch != null;
667
+ return evaluateTerminal({
668
+ stage: opts.stage,
669
+ submitDetails: counters.submitDetails,
670
+ finalizeDetails: counters.finalizeDetails,
671
+ lastReviewBatchPresent,
672
+ modelError: counters.modelError,
673
+ });
674
+ }
675
+
676
+ /** Convenience: re-derive the initial prompt for a prepared worktree (reads its `cache.plan-ref`). */
677
+ export function initialPromptForWorktree(worktree: string, stage: DriveStage): string | null {
678
+ return initialPromptFor(stage, readPlanRef(worktree));
679
+ }