@mgiles/perk 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/extension/authoring/gist/draft.ts +198 -0
- package/extension/authoring/gist/prose.ts +46 -0
- package/extension/authoring/gist/review.ts +133 -0
- package/extension/authoring/gist/save.ts +118 -0
- package/extension/authoring/objective/draft.ts +345 -0
- package/extension/{factories/objectiveDreamReport.ts → authoring/objective/dreamReportGate.ts} +74 -131
- package/extension/authoring/objective/planning.ts +124 -0
- package/extension/authoring/objective/prose.ts +103 -0
- package/extension/authoring/objective/review.ts +128 -0
- package/extension/authoring/objective/save.ts +224 -0
- package/extension/authoring/plan/draft.ts +84 -0
- package/extension/authoring/plan/prose.ts +41 -0
- package/extension/authoring/plan/review.ts +269 -0
- package/extension/authoring/plan/save.ts +256 -0
- package/extension/authoring/plan/source.ts +82 -0
- package/extension/authoring/refinement/context.ts +468 -0
- package/extension/authoring/refinement/draft.ts +261 -0
- package/extension/authoring/refinement/prose.ts +79 -0
- package/extension/authoring/refinement/review.ts +111 -0
- package/extension/authoring/refinement/save.ts +119 -0
- package/extension/authoring/review/approvalGate.ts +34 -0
- package/extension/authoring/review/draftContext.ts +68 -0
- package/extension/codeReview/automated.ts +352 -0
- package/extension/codeReview/submission.ts +229 -0
- package/extension/delivery/address.ts +295 -0
- package/extension/delivery/ci.ts +355 -0
- package/extension/delivery/commitCompact.ts +93 -0
- package/extension/delivery/conflictResolution.ts +247 -0
- package/extension/delivery/ready.ts +193 -0
- package/extension/delivery/stackConflict.ts +361 -0
- package/extension/delivery/stackObjective.ts +16 -0
- package/extension/delivery/stackReconcile.ts +165 -0
- package/extension/delivery/submit.ts +171 -0
- package/extension/index.ts +369 -359
- package/extension/learning/analystWave.ts +324 -0
- package/extension/learning/audit.ts +667 -0
- package/extension/learning/capture.ts +92 -0
- package/extension/learning/containment.ts +104 -0
- package/extension/{waves/dreamWave.ts → learning/dream.ts} +112 -94
- package/extension/learning/dreamAnalysis.ts +435 -0
- package/extension/{waves/dreamReducerWave.ts → learning/dreamReducer.ts} +46 -41
- package/extension/{waves → learning}/dreamReport.ts +35 -31
- package/extension/learning/harvest.ts +491 -0
- package/extension/learning/prose.ts +66 -0
- package/extension/learning/routing.ts +79 -0
- package/extension/pi/v1/bashScanTimeout.ts +64 -0
- package/extension/{doors/prReview.ts → pi/v1/codeReview/automated.ts} +215 -310
- package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +125 -57
- package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
- package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +169 -116
- package/extension/pi/v1/codeReview/stack.ts +606 -0
- package/extension/pi/v1/codeReview/submit.ts +354 -0
- package/extension/{doors/prReviewTerminal.ts → pi/v1/codeReview/terminal.ts} +32 -27
- package/extension/pi/v1/contextEvidence.ts +80 -0
- package/extension/pi/v1/contextInjection.ts +207 -0
- package/extension/{doors → pi/v1/delivery}/address.ts +163 -265
- package/extension/pi/v1/delivery/ci.ts +570 -0
- package/extension/pi/v1/delivery/commitCompact.ts +201 -0
- package/extension/pi/v1/delivery/conflictResolverEngine.ts +425 -0
- package/extension/{doors → pi/v1/delivery}/land.ts +128 -60
- package/extension/pi/v1/delivery/ready.ts +322 -0
- package/extension/pi/v1/delivery/stackConflictResolver.ts +172 -0
- package/extension/pi/v1/delivery/stackDrive.ts +120 -0
- package/extension/pi/v1/delivery/stackLand.ts +223 -0
- package/extension/pi/v1/delivery/stackRecover.ts +265 -0
- package/extension/pi/v1/delivery/stackStatus.ts +237 -0
- package/extension/pi/v1/delivery/stackSync.ts +658 -0
- package/extension/pi/v1/delivery/submit.ts +389 -0
- package/extension/pi/v1/delivery/submitConflict.ts +186 -0
- package/extension/pi/v1/draftReview.ts +431 -0
- package/extension/{doors → pi/v1}/draftReviewWaveTools.ts +141 -151
- package/extension/pi/v1/gist.ts +794 -0
- package/extension/pi/v1/learning/audit.ts +186 -0
- package/extension/pi/v1/learning/dream.ts +207 -0
- package/extension/{doors/learnFactory.ts → pi/v1/learning/factory.ts} +18 -65
- package/extension/{doors/harvestWaveTools.ts → pi/v1/learning/harvest.ts} +46 -100
- package/extension/pi/v1/learning/learn.ts +585 -0
- package/extension/{doors → pi/v1}/lifecycleGates.ts +14 -59
- package/extension/{factories → pi/v1}/objective.ts +53 -33
- package/extension/pi/v1/objectiveAuthoring.ts +672 -0
- package/extension/pi/v1/objectiveDreamGate.ts +160 -0
- package/extension/pi/v1/objectivePlanning.ts +776 -0
- package/extension/pi/v1/objectiveRefinement.ts +1320 -0
- package/extension/pi/v1/objectiveReview.ts +451 -0
- package/extension/{doors → pi/v1}/objectiveReviewBrowser.ts +259 -172
- package/extension/pi/v1/plan.ts +812 -0
- package/extension/pi/v1/planReview.ts +820 -0
- package/extension/{doors → pi/v1}/planReviewBrowser.ts +228 -152
- package/extension/{doors/annotationPush.ts → pi/v1/providers/annotations.ts} +158 -89
- package/extension/pi/v1/providers/plannotator.ts +487 -0
- package/extension/{doors → pi/v1/providers}/plannotatorHandoff.ts +152 -34
- package/extension/pi/v1/providers/selection.ts +43 -0
- package/extension/{adapters/planAdapterTombell.ts → pi/v1/providers/tombell.ts} +43 -72
- package/extension/pi/v1/review.ts +538 -0
- package/extension/pi/v1/reviewOutcome.ts +9 -0
- package/extension/pi/v1/scoutWave.ts +318 -0
- package/extension/{doors → pi/v1}/selfcheck.ts +4 -4
- package/extension/session/branchWorkflowSession.ts +60 -0
- package/extension/session/lifecycle.ts +644 -0
- package/extension/session/lifecycleGates.ts +64 -0
- package/extension/session/saveDestination.ts +87 -0
- package/extension/session/workflowSession.ts +971 -0
- package/extension/substrate/agentScratch.ts +27 -54
- package/extension/substrate/bashScanTimeout.ts +181 -0
- package/extension/substrate/bindingDelivery.ts +38 -30
- package/extension/substrate/bindings.ts +4 -5
- package/extension/substrate/cache.ts +64 -12
- package/extension/substrate/childRestrictions.ts +39 -0
- package/extension/substrate/coldDoor.ts +17 -1
- package/extension/substrate/config.ts +157 -19
- package/extension/substrate/git.ts +88 -6
- package/extension/substrate/modelVisible.ts +53 -0
- package/extension/substrate/paths.ts +2 -7
- package/extension/substrate/prompts.ts +22 -0
- package/extension/substrate/registry.ts +2 -0
- package/extension/substrate/resolverLease.ts +364 -0
- package/extension/substrate/sessionData.ts +85 -152
- package/extension/substrate/toolGating.ts +279 -84
- package/extension/substrate/unifiedDiff.ts +1 -1
- package/extension/substrate/workflowState.ts +191 -166
- package/extension/substrate/worktreeResolverLock.ts +261 -0
- package/extension/surfaces/surfaces.ts +79 -27
- package/extension/waves/adversarialReviewWave.ts +103 -48
- package/extension/waves/blockedReports.ts +59 -0
- package/extension/waves/draftReviewWave.ts +42 -42
- package/extension/waves/laneIdentity.ts +77 -0
- package/extension/waves/objectiveExplorerWave.ts +24 -24
- package/extension/waves/prReviewWave.ts +89 -77
- package/extension/waves/reportWave.ts +438 -578
- package/extension/waves/reviewClassifierWave.ts +22 -22
- package/extension/waves/rpcAdapter.ts +100 -15
- package/extension/waves/scoutWave.ts +192 -0
- package/extension/waves/transport.ts +480 -0
- package/extension/worker/sdkAdapter.ts +494 -0
- package/extension/worker/stageExecution.ts +679 -0
- package/extension/workerMain.ts +18 -19
- package/package.json +6 -4
- package/prompts/_fixtures/live.yaml +98 -10
- package/prompts/contexts/adapters/plannotator-gist.md +6 -0
- package/prompts/contexts/adapters/plannotator-objective.md +6 -0
- package/prompts/contexts/adapters/plannotator-plan.md +8 -1
- package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
- package/prompts/contexts/adapters/tombell-plan.md +4 -0
- package/prompts/contexts/objective-refinement.md +17 -0
- package/prompts/contexts/plan-authoring.md +6 -5
- package/prompts/contexts/read-only.md +1 -1
- package/prompts/stages/conflict-resolution-continuation.md +9 -0
- package/prompts/stages/conflict-resolution.md +4 -4
- package/prompts/stages/objective-author/adopt.md +1 -1
- package/prompts/stages/objective-author/file.md +1 -1
- package/prompts/stages/objective-author/seed.md +1 -1
- package/prompts/stages/objective-plan/guidance.md +2 -2
- package/prompts/stages/objective-plan/seed.md +9 -1
- package/prompts/stages/objective-reconcile-ready.md +7 -0
- package/prompts/stages/objective-reconcile.md +1 -1
- package/prompts/stages/objective-refine/seed.md +18 -0
- package/prompts/stages/objective-review-browser.md +4 -4
- package/prompts/stages/objective-sync.md +1 -1
- package/prompts/stages/plan-review-browser.md +4 -4
- package/prompts/stages/pr-review-browser/active.md +3 -4
- package/prompts/stages/pr-review-browser/foreign.md +3 -4
- package/prompts/stages/pr-review-terminal/active.md +3 -3
- package/prompts/stages/pr-review-terminal/foreign.md +3 -3
- package/prompts/stages/pr-review.md +3 -3
- package/prompts/stages/stack-review/cold.md +1 -0
- package/prompts/stages/stack-review-browser/stack.md +22 -0
- package/shared/README.md +8 -3
- package/shared/bindings.yaml +6 -3
- package/shared/contracts.md +4499 -2147
- package/shared/fixtures/issues-table.json +130 -0
- package/shared/registry.yaml +29 -1
- package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
- package/shared/schemas/outputs/objective-stack-status.schema.json +178 -2
- package/shared/schemas/outputs/pr-ready.schema.json +110 -2
- package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
- package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
- package/extension/adapters/planAdapterPlannotator.ts +0 -362
- package/extension/doors/auditWaveTools.ts +0 -352
- package/extension/doors/ciExecutor.ts +0 -756
- package/extension/doors/commitCompact.ts +0 -251
- package/extension/doors/dreamWaveTools.ts +0 -475
- package/extension/doors/learn.ts +0 -655
- package/extension/doors/objectiveStack.ts +0 -1143
- package/extension/doors/prReviewDynamic.ts +0 -276
- package/extension/doors/ready.ts +0 -87
- package/extension/doors/submit.ts +0 -347
- package/extension/doors/submitPrReview.ts +0 -408
- package/extension/factories/gistAuthor.ts +0 -94
- package/extension/factories/gistDraft.ts +0 -265
- package/extension/factories/gistSave.ts +0 -251
- package/extension/factories/implementHere.ts +0 -116
- package/extension/factories/objectiveAuthor.ts +0 -98
- package/extension/factories/objectiveDraft.ts +0 -466
- package/extension/factories/objectivePlan.ts +0 -975
- package/extension/factories/objectiveSave.ts +0 -363
- package/extension/factories/planDraft.ts +0 -140
- package/extension/factories/planMode.ts +0 -205
- package/extension/factories/planReview.ts +0 -1237
- package/extension/factories/planSave.ts +0 -604
- package/extension/factories/planTitle.ts +0 -141
- package/extension/substrate/structuredOutput.ts +0 -202
- package/extension/waves/auditWave.ts +0 -312
- package/extension/waves/harvestWave.ts +0 -399
- package/extension/waves/learnWave.ts +0 -155
- package/extension/waves/memoryAdapter.ts +0 -139
- package/extension/waves/prReviewDynamicWave.ts +0 -777
- package/extension/worker/readOnlySession.ts +0 -294
- package/extension/worker/worker.ts +0 -899
- package/prompts/stages/pr-review-dynamic.md +0 -7
- package/shared/contracts-history.md +0 -605
|
@@ -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
|
-
}
|