@mgiles/perk 2.2.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 (129) 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 +238 -24
  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 +203 -22
  19. package/extension/doors/prReviewBrowser.ts +31 -23
  20. package/extension/doors/prReviewDynamic.ts +256 -0
  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 +86 -10
  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 -8
  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 +116 -44
  41. package/extension/substrate/cache.ts +75 -22
  42. package/extension/substrate/coldDoor.ts +2 -3
  43. package/extension/substrate/config.ts +42 -14
  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/terminalLaunch.ts +1 -1
  52. package/extension/substrate/toolGating.ts +206 -26
  53. package/extension/substrate/workflowState.ts +6 -0
  54. package/extension/surfaces/footerProvider.ts +6 -6
  55. package/extension/surfaces/surfaces.ts +98 -282
  56. package/extension/vendor/btw/btw.ts +57 -12
  57. package/extension/waves/adversarialReviewWave.ts +164 -0
  58. package/extension/waves/auditWave.ts +312 -0
  59. package/extension/waves/draftReviewWave.ts +189 -0
  60. package/extension/waves/harvestWave.ts +399 -0
  61. package/extension/waves/learnWave.ts +155 -0
  62. package/extension/waves/memoryAdapter.ts +139 -0
  63. package/extension/waves/objectiveExplorerWave.ts +120 -0
  64. package/extension/waves/prReviewDynamicWave.ts +696 -0
  65. package/extension/waves/prReviewWave.ts +274 -0
  66. package/extension/waves/reportWave.ts +749 -0
  67. package/extension/waves/reviewClassifierWave.ts +109 -0
  68. package/extension/waves/rpcAdapter.ts +264 -0
  69. package/extension/worker/readOnlySession.ts +3 -3
  70. package/extension/worker/worker.ts +71 -93
  71. package/extension/workerMain.ts +4 -6
  72. package/package.json +20 -5
  73. package/prompts/README.md +19 -0
  74. package/prompts/_fixtures/live.yaml +71 -72
  75. package/prompts/common/resume-advisory.md +1 -0
  76. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  77. package/prompts/contexts/adapters/plannotator-objective.md +1 -2
  78. package/prompts/contexts/adapters/plannotator-plan.md +1 -2
  79. package/prompts/contexts/gist-authoring.md +6 -20
  80. package/prompts/contexts/objective-authoring.md +6 -18
  81. package/prompts/stages/address/action.md +4 -4
  82. package/prompts/stages/address/preview.md +3 -3
  83. package/prompts/stages/audit.md +18 -0
  84. package/prompts/stages/conflict-resolution.md +1 -1
  85. package/prompts/stages/gist-author/seed.md +4 -4
  86. package/prompts/stages/gist-save.md +1 -1
  87. package/prompts/stages/implement.md +3 -1
  88. package/prompts/stages/learn-code.md +1 -1
  89. package/prompts/stages/learn-docs.md +2 -2
  90. package/prompts/stages/learn-harvest.md +15 -0
  91. package/prompts/stages/learn-orchestrate.md +7 -5
  92. package/prompts/stages/objective-author/adopt.md +5 -3
  93. package/prompts/stages/objective-author/file.md +5 -4
  94. package/prompts/stages/objective-author/seed.md +5 -4
  95. package/prompts/stages/objective-land.md +7 -0
  96. package/prompts/stages/objective-plan/guidance.md +2 -2
  97. package/prompts/stages/objective-plan/seed.md +5 -1
  98. package/prompts/stages/objective-recover.md +8 -0
  99. package/prompts/stages/objective-replan.md +4 -2
  100. package/prompts/stages/objective-review-browser.md +9 -0
  101. package/prompts/stages/objective-sync.md +6 -0
  102. package/prompts/stages/plan-from/adopt.md +2 -2
  103. package/prompts/stages/plan-from/file.md +2 -2
  104. package/prompts/stages/plan-review-browser.md +9 -0
  105. package/prompts/stages/pr-review-browser/active.md +6 -6
  106. package/prompts/stages/pr-review-browser/foreign.md +6 -6
  107. package/prompts/stages/pr-review-dynamic.md +7 -0
  108. package/prompts/stages/pr-review-terminal/active.md +7 -7
  109. package/prompts/stages/pr-review-terminal/foreign.md +7 -7
  110. package/prompts/stages/pr-review.md +7 -6
  111. package/prompts/stages/replan.md +3 -3
  112. package/shared/README.md +2 -2
  113. package/shared/bindings.yaml +15 -0
  114. package/shared/contracts-history.md +11 -0
  115. package/shared/contracts.md +3713 -227
  116. package/shared/providers.yaml +27 -60
  117. package/shared/registry.yaml +32 -8
  118. package/shared/schemas/outputs/objective-doctor.schema.json +449 -0
  119. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  120. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  121. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  122. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  123. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  124. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  125. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  126. package/extension/checkpoints/checkpoints.ts +0 -550
  127. package/extension/checkpoints/planSteps.ts +0 -108
  128. package/extension/doors/askUser.ts +0 -198
  129. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -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.2.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,11 +50,18 @@
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"
61
+ },
62
+ "allowScripts": {
63
+ "@google/genai@1.52.0": true,
64
+ "protobufjs@7.6.4": true,
65
+ "protobufjs@7.6.2": true
51
66
  }
52
67
  }
package/prompts/README.md CHANGED
@@ -18,6 +18,25 @@ Templates are rendered by jinja2 (Python) and a vendored TS subset (the extensio
18
18
  are loaded by explicit name through the resolver — never by scanning the directory, so
19
19
  this README is a durable doc, not a template.
20
20
 
21
+ The workflow-first inventory of these templates alongside skills, agent definitions, tool
22
+ contracts, injected contexts, and code-owned guidance lives in the generated
23
+ [`docs/design/prose-prompt-map.md`](../docs/design/prose-prompt-map.md). Its authored graph is the
24
+ source for capability relationships, session-shape assemblies, concerns, and generated lineage.
25
+
26
+ ## Layering — one statement of contract
27
+
28
+ A compact, **non-normative** summary of the layering rule for prompt authors — the canonical
29
+ rule is [`shared/contracts.md` §8.57](../shared/contracts.md). Per stage, each contract
30
+ statement has exactly one canonical carrier; every other surface points at it, never restates
31
+ it. The **launch statement** (classified by delivery call site — cold seed, warm guidance turn,
32
+ headless primer — never by template path) states the flow once per session shape; **injected
33
+ contexts** carry live state + pointers, never a restatement; **adapter blocks** carry only the
34
+ provider-surface delta; the **bound skill** is the read-on-demand detail tier. One named
35
+ exception: the `plan` stage launches idle, so its mode context
36
+ (`contexts/plan-authoring.md`) is its designated flow carrier. Per-template byte budgets are
37
+ CI-enforced by `tests/test_prompt_surface_budgets.py` (constants + derivation beside the
38
+ checks; the canonical rule stays §8.57).
39
+
21
40
  ## Frozen template grammar
22
41
 
23
42
  The templates use a deliberately tiny, **frozen** subset of jinja syntax — the canonical