@mgiles/perk 2.3.0 → 3.0.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 (128) hide show
  1. package/README.md +1 -1
  2. package/extension/adapters/planAdapterPlannotator.ts +132 -91
  3. package/extension/adapters/planAdapterTombell.ts +5 -3
  4. package/extension/doors/address.ts +235 -37
  5. package/extension/doors/annotationPush.ts +924 -0
  6. package/extension/doors/auditWaveTools.ts +352 -0
  7. package/extension/doors/ciExecutor.ts +220 -55
  8. package/extension/doors/draftReviewWaveTools.ts +384 -0
  9. package/extension/doors/harvestWaveTools.ts +279 -0
  10. package/extension/doors/land.ts +1 -0
  11. package/extension/doors/learn.ts +24 -6
  12. package/extension/doors/learnFactory.ts +33 -7
  13. package/extension/doors/lifecycleGates.ts +1 -1
  14. package/extension/doors/objectiveReviewBrowser.ts +514 -0
  15. package/extension/doors/objectiveStack.ts +1143 -0
  16. package/extension/doors/planReviewBrowser.ts +453 -0
  17. package/extension/doors/plannotatorHandoff.ts +120 -49
  18. package/extension/doors/prReview.ts +26 -16
  19. package/extension/doors/prReviewBrowser.ts +31 -23
  20. package/extension/doors/prReviewDynamic.ts +25 -18
  21. package/extension/doors/prReviewTerminal.ts +6 -11
  22. package/extension/doors/reviewWaveTools.ts +374 -0
  23. package/extension/doors/selfcheck.ts +1 -1
  24. package/extension/doors/submit.ts +82 -7
  25. package/extension/factories/gistAuthor.ts +4 -4
  26. package/extension/factories/gistDraft.ts +1 -1
  27. package/extension/factories/gistSave.ts +2 -2
  28. package/extension/factories/objective.ts +7 -7
  29. package/extension/factories/objectiveAuthor.ts +5 -4
  30. package/extension/factories/objectiveDraft.ts +61 -6
  31. package/extension/factories/objectivePlan.ts +163 -9
  32. package/extension/factories/objectiveSave.ts +16 -1
  33. package/extension/factories/planMode.ts +5 -3
  34. package/extension/factories/planReview.ts +131 -36
  35. package/extension/factories/planTitle.ts +27 -9
  36. package/extension/hunkFeedback/inbox.ts +389 -0
  37. package/extension/hunkFeedback/perkFeedback.ts +364 -0
  38. package/extension/hunkFeedback/receiver.ts +296 -0
  39. package/extension/hunkFeedback/store.ts +533 -0
  40. package/extension/index.ts +109 -44
  41. package/extension/substrate/cache.ts +75 -22
  42. package/extension/substrate/coldDoor.ts +2 -3
  43. package/extension/substrate/config.ts +36 -12
  44. package/extension/substrate/git.ts +10 -0
  45. package/extension/substrate/providers.ts +4 -33
  46. package/extension/substrate/resources.ts +11 -0
  47. package/extension/substrate/result.ts +13 -5
  48. package/extension/substrate/sessionData.ts +3 -3
  49. package/extension/substrate/sessionPointers.ts +3 -3
  50. package/extension/substrate/structuredOutput.ts +78 -19
  51. package/extension/substrate/toolGating.ts +170 -23
  52. package/extension/substrate/workflowState.ts +6 -0
  53. package/extension/surfaces/footerProvider.ts +6 -6
  54. package/extension/surfaces/surfaces.ts +98 -282
  55. package/extension/vendor/btw/btw.ts +57 -12
  56. package/extension/waves/adversarialReviewWave.ts +164 -0
  57. package/extension/waves/auditWave.ts +312 -0
  58. package/extension/waves/draftReviewWave.ts +189 -0
  59. package/extension/waves/harvestWave.ts +399 -0
  60. package/extension/waves/memoryAdapter.ts +14 -1
  61. package/extension/waves/objectiveExplorerWave.ts +120 -0
  62. package/extension/waves/prReviewDynamicWave.ts +291 -61
  63. package/extension/waves/prReviewWave.ts +58 -13
  64. package/extension/waves/reportWave.ts +406 -106
  65. package/extension/waves/reviewClassifierWave.ts +109 -0
  66. package/extension/waves/rpcAdapter.ts +65 -2
  67. package/extension/worker/readOnlySession.ts +3 -3
  68. package/extension/worker/worker.ts +71 -93
  69. package/extension/workerMain.ts +4 -6
  70. package/package.json +15 -5
  71. package/prompts/README.md +19 -0
  72. package/prompts/_fixtures/live.yaml +66 -65
  73. package/prompts/common/resume-advisory.md +1 -0
  74. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  75. package/prompts/contexts/adapters/plannotator-objective.md +1 -2
  76. package/prompts/contexts/adapters/plannotator-plan.md +1 -2
  77. package/prompts/contexts/gist-authoring.md +6 -20
  78. package/prompts/contexts/objective-authoring.md +6 -18
  79. package/prompts/stages/address/action.md +3 -14
  80. package/prompts/stages/address/preview.md +2 -13
  81. package/prompts/stages/audit.md +18 -0
  82. package/prompts/stages/gist-author/seed.md +4 -4
  83. package/prompts/stages/gist-save.md +1 -1
  84. package/prompts/stages/implement.md +3 -1
  85. package/prompts/stages/learn-code.md +1 -1
  86. package/prompts/stages/learn-docs.md +2 -2
  87. package/prompts/stages/learn-harvest.md +15 -0
  88. package/prompts/stages/learn-orchestrate.md +1 -1
  89. package/prompts/stages/objective-author/adopt.md +5 -3
  90. package/prompts/stages/objective-author/file.md +5 -4
  91. package/prompts/stages/objective-author/seed.md +5 -4
  92. package/prompts/stages/objective-land.md +7 -0
  93. package/prompts/stages/objective-plan/guidance.md +2 -13
  94. package/prompts/stages/objective-plan/seed.md +5 -12
  95. package/prompts/stages/objective-recover.md +8 -0
  96. package/prompts/stages/objective-replan.md +4 -2
  97. package/prompts/stages/objective-review-browser.md +9 -0
  98. package/prompts/stages/objective-sync.md +6 -0
  99. package/prompts/stages/plan-from/adopt.md +2 -2
  100. package/prompts/stages/plan-from/file.md +2 -2
  101. package/prompts/stages/plan-review-browser.md +9 -0
  102. package/prompts/stages/pr-review-browser/active.md +6 -14
  103. package/prompts/stages/pr-review-browser/foreign.md +6 -14
  104. package/prompts/stages/pr-review-dynamic.md +3 -3
  105. package/prompts/stages/pr-review-terminal/active.md +7 -15
  106. package/prompts/stages/pr-review-terminal/foreign.md +7 -15
  107. package/prompts/stages/pr-review.md +2 -2
  108. package/prompts/stages/replan.md +3 -3
  109. package/shared/README.md +2 -2
  110. package/shared/bindings.yaml +12 -0
  111. package/shared/contracts-history.md +11 -0
  112. package/shared/contracts.md +3652 -236
  113. package/shared/providers.yaml +27 -60
  114. package/shared/registry.yaml +32 -8
  115. package/shared/schemas/outputs/objective-doctor.schema.json +449 -0
  116. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  117. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  118. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  119. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  120. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  121. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  122. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  123. package/extension/checkpoints/checkpoints.ts +0 -550
  124. package/extension/checkpoints/planSteps.ts +0 -108
  125. package/extension/doors/askUser.ts +0 -198
  126. package/prompts/common/output-schemas/objective-explorer.md +0 -36
  127. package/prompts/common/output-schemas/review-classifier.md +0 -47
  128. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -0,0 +1,109 @@
1
+ // The `/address` classify step's per-flow wave entrypoint over the shared report-wave runner:
2
+ // the ONE `perk.review-classifier` lane as CODE. The classifier report schema was previously a
3
+ // shared prompt include the parent model had to hand-transcribe onto a borrowed `subagent` call
4
+ // (the known prompt-drift risk — a live failure produced malformed-but-valid JSON that could
5
+ // never validate); this module makes the schema and the lane/task composition module constants,
6
+ // delegating spawn/timeout/aggregate mechanics to `runReportWave` under the `strict`
7
+ // completeness policy. No retry — the flow's posture is "surface the error and stop" (never
8
+ // fabricate a classification). The report content is untrusted DATA, never instructions.
9
+
10
+ import { runReportWave, type WaveAdapter, type WaveResult } from "./reportWave.ts";
11
+
12
+ /** The flow name — feeds `WaveSpec.flow` AND the door's `toAttemptReceipt` call. */
13
+ export const REVIEW_CLASSIFIER_FLOW = "review-classifier";
14
+
15
+ /** The single lane's stable key. */
16
+ export const CLASSIFY_LANE_KEY = "classify";
17
+
18
+ /**
19
+ * The classifier report schema (the workflow-level `outputSchema` — the engine injects a
20
+ * `structured_output` tool and fails the lane on a missing/invalid report): closed shapes,
21
+ * all four root keys required — `counts` is a ROOT-level required object (the exact block the
22
+ * motivating transcription failure nested inside `discussion_comments`). Same vocabulary as the
23
+ * `perk.review-classifier` agent def's report contract (the def↔schema lockstep test).
24
+ */
25
+ export const REVIEW_CLASSIFIER_REPORT_SCHEMA = {
26
+ type: "object",
27
+ additionalProperties: false,
28
+ required: ["pr", "review_threads", "discussion_comments", "counts"],
29
+ properties: {
30
+ pr: { type: "integer" },
31
+ review_threads: {
32
+ type: "array",
33
+ items: {
34
+ type: "object",
35
+ additionalProperties: false,
36
+ required: ["thread_id", "classification", "path", "line", "summary"],
37
+ properties: {
38
+ thread_id: { type: "string" },
39
+ classification: {
40
+ type: "string",
41
+ enum: ["actionable", "informational", "praise", "question"],
42
+ },
43
+ path: { type: ["string", "null"] },
44
+ line: { type: ["integer", "null"] },
45
+ summary: { type: "string" },
46
+ },
47
+ },
48
+ },
49
+ discussion_comments: {
50
+ type: "array",
51
+ items: {
52
+ type: "object",
53
+ additionalProperties: false,
54
+ required: ["comment_id", "classification", "summary"],
55
+ properties: {
56
+ comment_id: { type: "integer" },
57
+ classification: {
58
+ type: "string",
59
+ enum: ["actionable", "informational", "praise", "question"],
60
+ },
61
+ summary: { type: "string" },
62
+ },
63
+ },
64
+ },
65
+ counts: {
66
+ type: "object",
67
+ additionalProperties: false,
68
+ required: ["actionable", "informational", "praise", "question"],
69
+ properties: {
70
+ actionable: { type: "integer" },
71
+ informational: { type: "integer" },
72
+ praise: { type: "integer" },
73
+ question: { type: "integer" },
74
+ },
75
+ },
76
+ },
77
+ };
78
+
79
+ /**
80
+ * Run the review-classifier wave: ONE fresh-context `perk.review-classifier` lane with the fixed
81
+ * code-owned task (the child fetches the feedback itself via `perk pr feedback --json` — nothing
82
+ * model-relayed enters the task), `strict` completeness, no retry, module-default timeout.
83
+ * Returns the runner's `WaveResult` unchanged — the only projection lives in the door.
84
+ */
85
+ export async function runReviewClassifierWave(
86
+ adapter: WaveAdapter,
87
+ opts: { model?: string; timeoutMs?: number; signal?: AbortSignal } = {},
88
+ ): Promise<WaveResult> {
89
+ return await runReportWave(
90
+ adapter,
91
+ {
92
+ flow: REVIEW_CLASSIFIER_FLOW,
93
+ lanes: [
94
+ {
95
+ key: CLASSIFY_LANE_KEY,
96
+ label: CLASSIFY_LANE_KEY,
97
+ agent: "perk.review-classifier",
98
+ phase: "address",
99
+ task: "Fetch + classify the review feedback on this plan's PR.",
100
+ },
101
+ ],
102
+ outputSchema: REVIEW_CLASSIFIER_REPORT_SCHEMA,
103
+ completeness: "strict",
104
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
105
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
106
+ },
107
+ opts.signal,
108
+ );
109
+ }
@@ -2,7 +2,8 @@
2
2
  // in-process event bus (unit-testable offline with a fake bus + a fake RPC responder, exactly
3
3
  // like the plannotator bridge).
4
4
  //
5
- // ENVELOPE (pinned against pi-subagents 0.43.0, `src/extension/rpc.ts`): requests are emitted on
5
+ // ENVELOPE (pinned against pi-subagents 0.43.0, re-verified at 0.45.0; `src/extension/rpc.ts`):
6
+ // requests are emitted on
6
7
  // `subagents:rpc:v1:request` as `{version: 1, requestId, method, params?, source?}`; the reply
7
8
  // arrives once on `subagents:rpc:v1:reply:<requestId>` as
8
9
  // `{version, requestId, method?, success: true, data} | {…, success: false, error: {code, message}}`.
@@ -12,6 +13,14 @@
12
13
  // envelope is for). `pi-subagents` is not an allowed bare import (`bareImportGuard.test.ts`), so
13
14
  // its constants/types cannot be imported — the doctor `subagent-compat` probes are the drift
14
15
  // tripwire, and every pi-subagents bump warrants an adapter re-verify.
16
+ //
17
+ // COMPLETION PAYLOAD (source-read-derived, 0.45.0 `src/runs/background/result-watcher.ts` +
18
+ // `src/runs/foreground/subagent-executor.ts`): the async-complete event spreads the result-file
19
+ // data plus a normalized per-child `results` array; on workflow rows the `agent` field carries
20
+ // the workflow LANE KEY (the overloaded upstream field — mapped to `WaveChildReceipt.key` here,
21
+ // never exposed). Normalization is defensively output-free: `output`/`summary`/
22
+ // `structuredOutput` never enter a receipt child, unknown fields are ignored, and malformed
23
+ // rows are dropped without failing the wave (receipt absence degrades correlation only).
15
24
 
16
25
  import { randomUUID } from "node:crypto";
17
26
  import { readFileSync } from "node:fs";
@@ -19,6 +28,7 @@ import { join } from "node:path";
19
28
  import type {
20
29
  WaveAdapter,
21
30
  WaveBus,
31
+ WaveChildReceipt,
22
32
  WaveCompletion,
23
33
  WavePing,
24
34
  WaveRunHandle,
@@ -111,6 +121,49 @@ async function request(
111
121
  });
112
122
  }
113
123
 
124
+ /**
125
+ * Narrow one completion-payload `results` row's `artifactPaths` to its string-valued own
126
+ * properties; when none survive, fall back to the watcher-normalized `artifactPath` as
127
+ * `{ outputPath }`. Undefined when neither yields a path.
128
+ */
129
+ function narrowArtifactPaths(row: Record<string, unknown>): Record<string, string> | undefined {
130
+ if (isRecord(row.artifactPaths)) {
131
+ const paths: Record<string, string> = {};
132
+ for (const [key, value] of Object.entries(row.artifactPaths)) {
133
+ if (typeof value === "string") paths[key] = value;
134
+ }
135
+ if (Object.keys(paths).length > 0) return paths;
136
+ }
137
+ if (typeof row.artifactPath === "string" && row.artifactPath !== "") {
138
+ return { outputPath: row.artifactPath };
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ /**
144
+ * Narrow one `results` row into an output-free receipt child; null ⇒ the row is dropped (a
145
+ * malformed row never fails the wave). The upstream `agent` field carries the workflow lane key
146
+ * — it becomes `key`; `agent` is deliberately left unset (enriched from Perk-owned lane specs
147
+ * upstream). `output`/`summary`/`structuredOutput` and unknown fields are NEVER copied.
148
+ */
149
+ function narrowReceiptChild(row: unknown): WaveChildReceipt | null {
150
+ if (!isRecord(row)) return null;
151
+ const key = row.agent;
152
+ if (typeof key !== "string" || key === "") return null;
153
+ const artifactPaths = narrowArtifactPaths(row);
154
+ return {
155
+ key,
156
+ ...(typeof row.runId === "string" && row.runId !== "" ? { runId: row.runId } : {}),
157
+ ...(typeof row.success === "boolean" ? { success: row.success } : {}),
158
+ ...(row.outputState === "present" ||
159
+ row.outputState === "absent" ||
160
+ row.outputState === "unknown"
161
+ ? { outputState: row.outputState }
162
+ : {}),
163
+ ...(artifactPaths !== undefined ? { artifactPaths } : {}),
164
+ };
165
+ }
166
+
114
167
  /** Narrow a ping reply to the advertised async-complete channel; any miss ⇒ null (unavailable). */
115
168
  function narrowPing(data: unknown): WavePing | null {
116
169
  if (!isRecord(data)) return null;
@@ -166,10 +219,20 @@ export function createRpcWaveAdapter(bus: WaveBus): WaveAdapter {
166
219
  return bus.on(advertised.asyncCompleteEvent, (data) => {
167
220
  if (!isRecord(data)) return;
168
221
  // The payload spreads the result-file data: `id` is the async run id; `asyncDir` the
169
- // durable run directory. At least one is present on real payloads.
222
+ // durable run directory. At least one is present on real payloads. The observability
223
+ // fields (state/success/results) are optional — identity-only payloads stay valid.
224
+ const children = Array.isArray(data.results)
225
+ ? data.results.flatMap((row) => {
226
+ const child = narrowReceiptChild(row);
227
+ return child === null ? [] : [child];
228
+ })
229
+ : undefined;
170
230
  handler({
171
231
  ...(typeof data.id === "string" ? { asyncId: data.id } : {}),
172
232
  ...(typeof data.asyncDir === "string" ? { asyncDir: data.asyncDir } : {}),
233
+ ...(typeof data.state === "string" && data.state !== "" ? { state: data.state } : {}),
234
+ ...(typeof data.success === "boolean" ? { success: data.success } : {}),
235
+ ...(children !== undefined ? { children } : {}),
173
236
  });
174
237
  });
175
238
  },
@@ -19,7 +19,7 @@
19
19
  // loading perk's own extension into the child). The `no*` flags keep perk's machinery out of the
20
20
  // child and keep the path offline/deterministic. A custom loader is reloaded by the caller.
21
21
 
22
- import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
22
+ import { existsSync, mkdirSync, mkdtempSync } from "node:fs";
23
23
  import { tmpdir } from "node:os";
24
24
  import { join } from "node:path";
25
25
  import type { Api, Model } from "@earendil-works/pi-ai";
@@ -30,7 +30,7 @@ import {
30
30
  SessionManager,
31
31
  SettingsManager,
32
32
  } from "@earendil-works/pi-coding-agent";
33
- import { runScratchDir, scratchDir } from "../substrate/cache.ts";
33
+ import { atomicWriteFileSync, runScratchDir, scratchDir } from "../substrate/cache.ts";
34
34
 
35
35
  /**
36
36
  * The SDK-level read-only allowlist (no `bash`; stricter than the in-session READ_ONLY_TOOLS).
@@ -267,7 +267,7 @@ export async function runReadOnlyChild(
267
267
 
268
268
  // write → verify → pass-path: persist the full result, then confirm it landed.
269
269
  const scratchPath = resolveScratchPath(opts.cwd, opts.runId, opts.step);
270
- writeFileSync(scratchPath, output, "utf8");
270
+ atomicWriteFileSync(scratchPath, output);
271
271
  if (!existsSync(scratchPath)) {
272
272
  return failure("read-only child: scratch write could not be verified.", "scratch-verify");
273
273
  }
@@ -32,19 +32,17 @@ import { env } from "node:process";
32
32
  // re-export a thinking-level type (only `ThinkingLevelChangeEntry`).
33
33
  import type { Api, Model, ModelThinkingLevel as ThinkingLevel } from "@earendil-works/pi-ai";
34
34
  import {
35
- AuthStorage,
36
35
  type CreateAgentSessionRuntimeFactory,
37
36
  createAgentSessionFromServices,
38
37
  createAgentSessionRuntime,
39
38
  createAgentSessionServices,
40
- ModelRegistry,
39
+ ModelRuntime,
41
40
  resolveCliModel,
42
41
  SessionManager,
43
42
  SettingsManager,
44
43
  } from "@earendil-works/pi-coding-agent";
45
44
  import { planReadInstruction } from "../doors/lifecycleGates.ts";
46
45
  import { ensureRunScratch, type PlanRef, readPlanRef, runEventsPath } from "../substrate/cache.ts";
47
- import { loadPerkConfig } from "../substrate/config.ts";
48
46
  import { render } from "../substrate/prompts.ts";
49
47
  import { captureSessionPointer } from "../substrate/sessionPointers.ts";
50
48
  import { rebuildWorkflowState } from "../substrate/workflowState.ts";
@@ -94,10 +92,14 @@ export interface RunOutcome {
94
92
  * The structured run-event stream (contracts §8.12). A small, JSON-serializable,
95
93
  * **additive-stable** discriminated union keyed on `kind` (distinct from `DriveEvent.type`). Every
96
94
  * event carries a monotonic `seq` (0-based) and `t` (elapsed ms, same basis as
97
- * `RunOutcome.budget.elapsed_ms`). Future nodes may add variants/fields; existing ones keep meaning.
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`).
98
97
  */
99
98
  export type RunEvent =
100
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).
101
103
  | { kind: "step_marker"; seq: number; t: number; marker: "wip" | "done"; step: number }
102
104
  | {
103
105
  kind: "tool_outcome";
@@ -137,8 +139,8 @@ export interface DriveStageOptions {
137
139
  * `undefined` ⇒ the SDK's settings-default resolution — unchanged behavior.
138
140
  */
139
141
  thinkingLevel?: ThinkingLevel;
140
- authStorage?: AuthStorage;
141
- modelRegistry?: ModelRegistry;
142
+ /** The canonical model/auth runtime (pi 0.84 `ModelRuntime`); default-created when absent. */
143
+ modelRuntime?: ModelRuntime;
142
144
  budget: DriveBudget;
143
145
  /** External cancellation; OR'd with the budget watchdog. */
144
146
  signal?: AbortSignal;
@@ -176,8 +178,6 @@ export interface DriveEvent {
176
178
  * sum: adding it would double-count.
177
179
  */
178
180
  usage?: { input?: number; output?: number; reasoning?: number };
179
- /** Assistant text/content blocks (where `[WIP:n]`/`[DONE:n]` markers live). */
180
- content?: unknown;
181
181
  };
182
182
  }
183
183
 
@@ -207,13 +207,14 @@ export interface DriveRuntimeLike {
207
207
  export interface DriveCounters {
208
208
  turns: number;
209
209
  tokens: number;
210
+ /** Latest submit-bearing evidence (standalone submit or the nested finalizer submit). */
210
211
  submitDetails: Record<string, unknown> | null;
211
- resolveDetails: Record<string, unknown> | null;
212
+ finalizeDetails: Record<string, unknown> | null;
212
213
  modelError: { message: string } | null;
213
214
  }
214
215
 
215
216
  export function freshCounters(): DriveCounters {
216
- return { turns: 0, tokens: 0, submitDetails: null, resolveDetails: null, modelError: null };
217
+ return { turns: 0, tokens: 0, submitDetails: null, finalizeDetails: null, modelError: null };
217
218
  }
218
219
 
219
220
  /** The natural-idle terminal classification (before watchdog/abort overrides). */
@@ -239,7 +240,7 @@ function detailsOf(result: unknown): Record<string, unknown> | null {
239
240
  /**
240
241
  * Fold one agent-session event into the running counters (pure). Counts `turn_end` turns, sums
241
242
  * assistant token usage (the `sumAssistantTokens` pattern in objective.ts), captures the `submit`
242
- * /`resolve_review_threads` terminal tool details, and records a post-acceptance model error
243
+ * /`finalize_address` terminal tool details, and records a post-acceptance model error
243
244
  * (assistant `message_end` with `stopReason:"error"`, surfaced with retry off — audit §B #4).
244
245
  *
245
246
  * The token sum is `input + output` ONLY: `usage.reasoning` is a subset of `output` on every
@@ -255,8 +256,19 @@ export function applyEvent(counters: DriveCounters, event: DriveEvent): void {
255
256
  }
256
257
  if (event.type === "tool_execution_end") {
257
258
  if (event.toolName === "submit") counters.submitDetails = detailsOf(event.result);
258
- else if (event.toolName === "resolve_review_threads") {
259
- counters.resolveDetails = 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
+ }
260
272
  }
261
273
  return;
262
274
  }
@@ -276,13 +288,14 @@ export function budgetTripped(counters: DriveCounters, budget: DriveBudget): boo
276
288
  * Classify a natural-idle terminal from the captured state (pure). `modelError` wins (post-
277
289
  * acceptance error, §B #4); else the stage success predicate:
278
290
  * - implement: a successful `submit` carrying a `pr` → completed/submit_tool;
279
- * - address: `resolve_review_threads` ok AND `last_review_batch` appended completed/address_resolved;
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;
280
293
  * - otherwise the agent went idle without completing the stage → failed/agent_idle_incomplete.
281
294
  */
282
295
  export function evaluateTerminal(args: {
283
296
  stage: DriveStage;
284
297
  submitDetails: Record<string, unknown> | null;
285
- resolveSucceeded: boolean;
298
+ finalizeDetails: Record<string, unknown> | null;
286
299
  lastReviewBatchPresent: boolean;
287
300
  modelError: { message: string } | null;
288
301
  }): TerminalVerdict {
@@ -331,8 +344,20 @@ export function evaluateTerminal(args: {
331
344
  };
332
345
  }
333
346
 
334
- // address
335
- if (args.resolveSucceeded && args.lastReviewBatchPresent) {
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
+ ) {
336
361
  return {
337
362
  status: "completed",
338
363
  terminal_signal: "address_resolved",
@@ -346,18 +371,20 @@ export function evaluateTerminal(args: {
346
371
  terminal_signal: "agent_idle_incomplete",
347
372
  pr: null,
348
373
  errorType: "incomplete",
349
- errorMessage: "address drive went idle without resolving feedback (no last_review_batch).",
374
+ errorMessage:
375
+ "address drive went idle without fully finalizing feedback " +
376
+ "(publication, thread resolution, and last_review_batch are required).",
350
377
  };
351
378
  }
352
379
 
353
380
  /**
354
381
  * The post-bind preflight rule (pure): the stage's terminating perk tool must be registered —
355
- * `implement` → `submit`, `address` → `resolve_review_threads`. Returns the required tool name
382
+ * `implement` → `submit`, `address` → `finalize_address`. Returns the required tool name
356
383
  * when absent, else `null`. Deliberately does NOT require the `subagent` tool for `address` — the
357
384
  * subagent-under-worker live smoke stays the §8.11 carried risk.
358
385
  */
359
386
  export function missingTerminatingTool(stage: DriveStage, toolNames: string[]): string | null {
360
- const required = stage === "implement" ? "submit" : "resolve_review_threads";
387
+ const required = stage === "implement" ? "submit" : "finalize_address";
361
388
  return toolNames.includes(required) ? null : required;
362
389
  }
363
390
 
@@ -406,39 +433,6 @@ export function assembleOutcome(args: {
406
433
 
407
434
  // --- run-event helpers (offline-testable) ---------------------------------------------
408
435
 
409
- /**
410
- * Extract `[WIP:n]`/`[DONE:n]` markers from assistant text in **textual appearance order** (pure).
411
- * A single combined, case-insensitive regex so interleaved markers (`[WIP:2]` before `[DONE:1]` in
412
- * the same message) emit in that order — unlike checkpoints.ts's separate `extractWip/DoneSteps`
413
- * lists, which lose cross-marker order. Returns `[]` when there are no markers.
414
- */
415
- export function extractStepMarkers(text: string): { marker: "wip" | "done"; step: number }[] {
416
- const out: { marker: "wip" | "done"; step: number }[] = [];
417
- for (const m of text.matchAll(/\[(WIP|DONE):(\d+)\]/gi)) {
418
- const step = Number(m[2]);
419
- if (Number.isFinite(step)) {
420
- out.push({ marker: (m[1] ?? "").toLowerCase() === "done" ? "done" : "wip", step });
421
- }
422
- }
423
- return out;
424
- }
425
-
426
- /** Flatten a `DriveEvent`'s assistant `message.content` (string | `{type:'text',text}[]`) to text. */
427
- export function assistantText(event: DriveEvent): string {
428
- const content = event.message?.content;
429
- if (typeof content === "string") return content;
430
- if (Array.isArray(content)) {
431
- return content
432
- .map((b) => {
433
- const block = b as { type?: string; text?: string };
434
- return block.type === "text" && typeof block.text === "string" ? block.text : "";
435
- })
436
- .filter(Boolean)
437
- .join("\n");
438
- }
439
- return "";
440
- }
441
-
442
436
  /**
443
437
  * Compute a `tool_outcome` `{ tool, ok, summary }` from a `tool_execution_end` `DriveEvent` (pure).
444
438
  * `ok` = `details.ok === true` when the result carries a `details.ok` boolean, else `!isError`.
@@ -524,14 +518,11 @@ export function defaultEventSink(worktree: string, runId: string): RunEventSink
524
518
  *
525
519
  * The `address` wording lives in the shared canonical template `prompts/stages/address/action.md`
526
520
  * rendered via the cross-plane render seam (contracts.md §8.31); the worker has no preview path
527
- * (preview is a warm/cold flag only), so it always renders the action body. The classifier
528
- * present/absent split builds the `model_clause` render var in code.
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.
529
524
  */
530
- export function initialPromptFor(
531
- stage: DriveStage,
532
- planRef: PlanRef | null,
533
- classifierModel?: string,
534
- ): string | null {
525
+ export function initialPromptFor(stage: DriveStage, planRef: PlanRef | null): string | null {
535
526
  if (planRef === null) return null;
536
527
  const provider = String(planRef.provider ?? "");
537
528
  const prId = String(planRef.pr_id ?? "");
@@ -541,15 +532,7 @@ export function initialPromptFor(
541
532
  return render("stages/implement.md", { provider, pr_id: prId, url, read_cmd: readCmd });
542
533
  }
543
534
  // address
544
- const modelClause = classifierModel
545
- ? `, passing \`model: "${classifierModel}"\` on that call (the configured [models.subagents] review-classifier model)`
546
- : "";
547
- return render("stages/address/action.md", {
548
- provider,
549
- pr_id: prId,
550
- url,
551
- model_clause: modelClause,
552
- });
535
+ return render("stages/address/action.md", { provider, pr_id: prId, url });
553
536
  }
554
537
 
555
538
  // --- bind / subscribe management (Gap 1) --------------------------------------------------------
@@ -619,9 +602,8 @@ async function defaultCreateRuntime(
619
602
  const services = await createAgentSessionServices({
620
603
  cwd: factoryOpts.cwd,
621
604
  agentDir: factoryOpts.agentDir,
622
- authStorage: resolved.authStorage,
623
605
  settingsManager,
624
- modelRegistry: resolved.modelRegistry,
606
+ modelRuntime: resolved.modelRuntime,
625
607
  });
626
608
  const result = await createAgentSessionFromServices({
627
609
  services,
@@ -660,24 +642,23 @@ async function defaultCreateRuntime(
660
642
  // --- model/auth resolution (Gap 5) --------------------------------------------------------------
661
643
 
662
644
  export interface ResolvedAuth {
663
- authStorage: AuthStorage;
664
- modelRegistry: ModelRegistry;
645
+ modelRuntime: ModelRuntime;
665
646
  /** The EXPLICIT model only; `undefined` defers the pick to the SDK at session creation. */
666
647
  model: Model<Api> | undefined;
667
648
  }
668
649
 
669
650
  /**
670
- * Resolve auth; returns null (never throws) when no model is available at all. The model is NOT
671
- * pre-pinned from the registry: an `undefined` model lets `createAgentSession` run its own
672
- * initial-model resolution (settings `defaultModel` → pi's curated per-provider defaults → first
673
- * available), which picks a current-generation model instead of the registry's
674
- * alphabetically-first (= oldest) entry.
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).
675
657
  */
676
- export function resolveAuth(opts: DriveStageOptions): ResolvedAuth | null {
677
- const authStorage = opts.authStorage ?? AuthStorage.create();
678
- const modelRegistry = opts.modelRegistry ?? ModelRegistry.create(authStorage);
679
- if (!opts.model && modelRegistry.getAvailable().length === 0) return null;
680
- return { authStorage, modelRegistry, model: opts.model };
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 };
681
662
  }
682
663
 
683
664
  /** What an explicit `--model` flag resolves to (a thin projection of `ResolveCliModelResult`). */
@@ -700,12 +681,12 @@ export interface ResolvedWorkerModel {
700
681
  */
701
682
  export function resolveWorkerModel(
702
683
  raw: string | undefined,
703
- modelRegistry: ModelRegistry,
684
+ modelRuntime: ModelRuntime,
704
685
  ): ResolvedWorkerModel {
705
686
  if (!raw) {
706
687
  return { model: undefined, thinkingLevel: undefined, warning: undefined, error: undefined };
707
688
  }
708
- const result = resolveCliModel({ cliModel: raw, modelRegistry });
689
+ const result = resolveCliModel({ cliModel: raw, modelRuntime });
709
690
  if (result.model === undefined && result.error === undefined) {
710
691
  return {
711
692
  model: undefined,
@@ -753,7 +734,9 @@ export async function driveStage(
753
734
  return outcome;
754
735
  };
755
736
 
756
- const resolved = resolveAuth(opts);
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);
757
740
  if (resolved === null && !deps.createRuntime) {
758
741
  // A zero-turn run is still observable: emit a `run_started` + `run_finished` pair.
759
742
  emitter.emit({ kind: "run_started", run_id: runId, stage: opts.stage });
@@ -772,10 +755,6 @@ export async function driveStage(
772
755
  const bindManager = createBindManager(headlessBinding(), (event) => {
773
756
  applyEvent(counters, event);
774
757
  if (event.type === "turn_end") {
775
- // Emit this turn's `[WIP:n]`/`[DONE:n]` markers in textual appearance order (one event each).
776
- for (const m of extractStepMarkers(assistantText(event))) {
777
- emitter.emit({ kind: "step_marker", marker: m.marker, step: m.step });
778
- }
779
758
  if (budgetTripped(counters, opts.budget)) trip("budget");
780
759
  } else if (event.type === "tool_execution_end") {
781
760
  const o = toolOutcomeOf(event);
@@ -908,7 +887,7 @@ function classify(
908
887
  return evaluateTerminal({
909
888
  stage: opts.stage,
910
889
  submitDetails: counters.submitDetails,
911
- resolveSucceeded: counters.resolveDetails?.ok === true,
890
+ finalizeDetails: counters.finalizeDetails,
912
891
  lastReviewBatchPresent,
913
892
  modelError: counters.modelError,
914
893
  });
@@ -916,6 +895,5 @@ function classify(
916
895
 
917
896
  /** Convenience: re-derive the initial prompt for a prepared worktree (reads its `cache.plan-ref`). */
918
897
  export function initialPromptForWorktree(worktree: string, stage: DriveStage): string | null {
919
- const classifierModel = loadPerkConfig(worktree).subagents["review-classifier"];
920
- return initialPromptFor(stage, readPlanRef(worktree), classifierModel);
898
+ return initialPromptFor(stage, readPlanRef(worktree));
921
899
  }
@@ -9,7 +9,7 @@
9
9
  // stderr), and exits 0 on `completed` else non-zero. Runs as `.ts` under node 22 type-stripping.
10
10
 
11
11
  import { argv, env, exit, stderr, stdout } from "node:process";
12
- import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
12
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
13
13
  import { runEventsPath, workflowDir } from "./substrate/cache.ts";
14
14
  import {
15
15
  type DriveBudget,
@@ -99,9 +99,8 @@ async function main(): Promise<number> {
99
99
  // pi's CLI semantics (fuzzy matching, `provider/pattern`, a `:thinking` suffix —
100
100
  // `resolveWorkerModel`), else the SDK's default resolution at session creation (settings
101
101
  // default → pi's per-provider defaults → first available) — the deferral is unchanged.
102
- const authStorage = AuthStorage.create();
103
- const modelRegistry = ModelRegistry.create(authStorage);
104
- const resolved = resolveWorkerModel(parsed.model, modelRegistry);
102
+ const modelRuntime = await ModelRuntime.create();
103
+ const resolved = resolveWorkerModel(parsed.model, modelRuntime);
105
104
  if (resolved.error) {
106
105
  stderr.write(`perk worker: ${resolved.error}\n`);
107
106
  return 2;
@@ -121,8 +120,7 @@ async function main(): Promise<number> {
121
120
  initialPrompt,
122
121
  model: resolved.model,
123
122
  thinkingLevel: resolved.thinkingLevel,
124
- authStorage,
125
- modelRegistry,
123
+ modelRuntime,
126
124
  budget: parsed.budget,
127
125
  signal: controller.signal,
128
126
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mgiles/perk",
3
- "version": "2.3.0",
3
+ "version": "3.0.0",
4
4
  "description": "perk Pi extension (session interior) for the plan-oriented workflow.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -29,10 +29,18 @@
29
29
  "./extension/index.ts"
30
30
  ]
31
31
  },
32
+ "workspaces": [
33
+ "docs/site"
34
+ ],
32
35
  "scripts": {
33
36
  "typecheck": "tsc --noEmit",
34
- "lint": "biome check extension",
35
- "format": "biome format --write extension"
37
+ "lint": "biome check extension docs/site tools",
38
+ "format": "biome format --write extension docs/site tools",
39
+ "docs:dev": "npm run dev --workspace docs/site",
40
+ "docs:build": "npm run build --workspace docs/site",
41
+ "docs:preview": "npm run preview --workspace docs/site",
42
+ "docs:typecheck": "npm run typecheck --workspace docs/site",
43
+ "docs:check": "npm run check --workspace docs/site"
36
44
  },
37
45
  "peerDependencies": {
38
46
  "@earendil-works/pi-ai": "*",
@@ -42,10 +50,12 @@
42
50
  },
43
51
  "devDependencies": {
44
52
  "@biomejs/biome": "2.4.16",
45
- "@earendil-works/pi-ai": "0.80.5",
46
- "@earendil-works/pi-coding-agent": "0.80.5",
53
+ "@earendil-works/pi-ai": "0.84.1",
54
+ "@earendil-works/pi-coding-agent": "0.84.1",
55
+ "@earendil-works/pi-tui": "0.84.1",
47
56
  "@types/node": "22.19.19",
48
57
  "diff": "8.0.4",
58
+ "typebox": "1.3.7",
49
59
  "typescript": "6.0.3",
50
60
  "yaml": "2.9.0"
51
61
  },