@mgiles/perk 1.0.1 → 2.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 (157) hide show
  1. package/README.md +67 -57
  2. package/extension/adapters/planAdapterPlannotator.ts +27 -41
  3. package/extension/adapters/planAdapterTombell.ts +15 -28
  4. package/extension/adapters/todoAdapterJuicesharp.ts +10 -13
  5. package/extension/checkpoints/checkpoints.ts +21 -13
  6. package/extension/doors/address.ts +6 -5
  7. package/extension/doors/askUser.ts +12 -8
  8. package/extension/doors/ciExecutor.ts +23 -15
  9. package/extension/doors/hunkHandoff.ts +202 -0
  10. package/extension/doors/land.ts +33 -10
  11. package/extension/doors/learn.ts +240 -34
  12. package/extension/doors/learnFactory.ts +144 -0
  13. package/extension/doors/lifecycleGates.ts +2 -1
  14. package/extension/doors/plannotatorHandoff.ts +509 -0
  15. package/extension/doors/prReview.ts +22 -37
  16. package/extension/doors/prReviewBrowser.ts +341 -0
  17. package/extension/doors/prReviewTerminal.ts +267 -0
  18. package/extension/doors/ready.ts +2 -1
  19. package/extension/doors/selfcheck.ts +240 -6
  20. package/extension/doors/submit.ts +37 -19
  21. package/extension/doors/submitPrReview.ts +408 -0
  22. package/extension/factories/implementHere.ts +116 -0
  23. package/extension/factories/objective.ts +17 -6
  24. package/extension/factories/objectiveAuthor.ts +15 -32
  25. package/extension/factories/objectiveDraft.ts +1 -1
  26. package/extension/factories/objectivePlan.ts +16 -34
  27. package/extension/factories/objectiveSave.ts +7 -17
  28. package/extension/factories/planMode.ts +24 -38
  29. package/extension/factories/planReview.ts +291 -177
  30. package/extension/factories/planSave.ts +23 -8
  31. package/extension/index.ts +117 -20
  32. package/extension/substrate/bindingDelivery.ts +32 -10
  33. package/extension/substrate/bindings.ts +4 -2
  34. package/extension/substrate/cache.ts +36 -9
  35. package/extension/substrate/clipboard.ts +81 -0
  36. package/extension/substrate/command.ts +25 -0
  37. package/extension/substrate/config.ts +98 -76
  38. package/extension/substrate/consoleCapture.ts +90 -0
  39. package/extension/substrate/git.ts +77 -0
  40. package/extension/substrate/miniJinja.ts +480 -0
  41. package/extension/substrate/paths.ts +38 -0
  42. package/extension/substrate/prompts.ts +15 -24
  43. package/extension/substrate/providers.ts +62 -8
  44. package/extension/substrate/sessionData.ts +1 -1
  45. package/extension/substrate/sessionPointers.ts +184 -0
  46. package/extension/substrate/structuredOutput.ts +3 -1
  47. package/extension/substrate/terminalLaunch.ts +178 -0
  48. package/extension/substrate/toolGating.ts +332 -73
  49. package/extension/substrate/toolParams.ts +7 -0
  50. package/extension/substrate/workflowState.ts +54 -2
  51. package/extension/surfaces/footerProvider.ts +8 -4
  52. package/extension/surfaces/surfaces.ts +338 -13
  53. package/extension/vendor/btw/btw.ts +10 -0
  54. package/extension/worker/readOnlySession.ts +19 -6
  55. package/extension/worker/worker.ts +187 -31
  56. package/extension/workerMain.ts +14 -13
  57. package/package.json +3 -7
  58. package/prompts/README.md +56 -5
  59. package/prompts/_fixtures/cases.yaml +52 -131
  60. package/prompts/_fixtures/golden/cond_elif-a.txt +3 -0
  61. package/prompts/_fixtures/golden/cond_elif-b.txt +3 -0
  62. package/prompts/_fixtures/golden/cond_elif-c.txt +3 -0
  63. package/prompts/_fixtures/golden/cond_if-false.txt +3 -0
  64. package/prompts/_fixtures/golden/cond_if-true.txt +3 -0
  65. package/prompts/_fixtures/golden/cond_ops-1.txt +3 -0
  66. package/prompts/_fixtures/golden/cond_ops-2.txt +3 -0
  67. package/prompts/_fixtures/golden/no_trailing_nl.txt +2 -0
  68. package/prompts/_fixtures/golden/trailing_nl.txt +2 -0
  69. package/prompts/_fixtures/golden/trim_block.txt +3 -0
  70. package/prompts/_fixtures/golden/trim_inline.txt +1 -0
  71. package/prompts/_fixtures/live.yaml +396 -0
  72. package/prompts/_fixtures/templates/cond_elif.md +9 -0
  73. package/prompts/_fixtures/templates/cond_if.md +7 -0
  74. package/prompts/_fixtures/templates/cond_ops.md +3 -0
  75. package/prompts/_fixtures/templates/no_trailing_nl.md +2 -0
  76. package/prompts/_fixtures/templates/trailing_nl.md +2 -0
  77. package/prompts/_fixtures/templates/trim_block.md +5 -0
  78. package/prompts/_fixtures/templates/trim_inline.md +1 -0
  79. package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
  80. package/prompts/contexts/adapters/plannotator-objective.md +7 -0
  81. package/prompts/contexts/adapters/plannotator-plan.md +6 -0
  82. package/prompts/contexts/adapters/tombell-plan.md +17 -0
  83. package/prompts/contexts/objective-authoring.md +20 -0
  84. package/prompts/contexts/plan-authoring.md +24 -0
  85. package/prompts/contexts/read-only.md +10 -0
  86. package/prompts/stages/conflict-resolution.md +4 -0
  87. package/prompts/stages/learn-code.md +8 -0
  88. package/prompts/stages/learn-docs.md +7 -6
  89. package/prompts/stages/learn-orchestrate.md +6 -0
  90. package/prompts/stages/learn.md +1 -1
  91. package/prompts/stages/objective-author/adopt.md +12 -0
  92. package/prompts/stages/objective-author/file.md +9 -0
  93. package/prompts/stages/objective-author/seed.md +9 -0
  94. package/prompts/stages/objective-plan/guidance.md +1 -1
  95. package/prompts/stages/objective-plan/seed.md +3 -2
  96. package/prompts/stages/objective-reconcile.md +7 -0
  97. package/prompts/stages/objective-replan.md +14 -0
  98. package/prompts/stages/objective-save.md +9 -0
  99. package/prompts/stages/plan-from/adopt.md +10 -0
  100. package/prompts/stages/plan-from/file.md +9 -0
  101. package/prompts/stages/pr-review-browser/active.md +11 -0
  102. package/prompts/stages/pr-review-browser/foreign.md +11 -0
  103. package/prompts/stages/pr-review-terminal/active.md +12 -0
  104. package/prompts/stages/pr-review-terminal/foreign.md +13 -0
  105. package/prompts/stages/pr-review-terminal/local.md +4 -0
  106. package/prompts/stages/pr-review.md +6 -0
  107. package/prompts/stages/replan.md +13 -0
  108. package/prompts/stages/skills/create-from.md +15 -0
  109. package/prompts/stages/skills/create.md +9 -0
  110. package/prompts/stages/skills/refine.md +9 -0
  111. package/shared/README.md +23 -13
  112. package/shared/bindings.yaml +22 -2
  113. package/shared/contracts-history.md +191 -0
  114. package/shared/contracts.md +2258 -1638
  115. package/shared/providers.yaml +8 -1
  116. package/shared/registry.yaml +10 -11
  117. package/shared/schemas/contracts/bindings.schema.json +38 -0
  118. package/shared/schemas/contracts/providers.schema.json +89 -0
  119. package/shared/schemas/contracts/registry.schema.json +98 -0
  120. package/shared/schemas/inputs/handoff-arg.schema.json +6 -0
  121. package/shared/schemas/inputs/resolve-threads-batch.schema.json +37 -0
  122. package/shared/schemas/inputs/review-post-batch.schema.json +84 -0
  123. package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
  124. package/shared/schemas/inputs/structured-roadmap-node.schema.json +102 -0
  125. package/shared/schemas/outputs/doctor-report.schema.json +236 -0
  126. package/shared/schemas/outputs/init-report.schema.json +419 -0
  127. package/shared/schemas/outputs/learn-capture.schema.json +90 -0
  128. package/shared/schemas/outputs/learn-skip.schema.json +59 -0
  129. package/shared/schemas/outputs/plan-save.schema.json +209 -0
  130. package/shared/schemas/outputs/pr-feedback.schema.json +334 -0
  131. package/shared/schemas/outputs/pr-land.schema.json +187 -0
  132. package/shared/schemas/outputs/pr-ready.schema.json +75 -0
  133. package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
  134. package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
  135. package/shared/schemas/outputs/pr-review-context.schema.json +86 -0
  136. package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
  137. package/shared/schemas/outputs/pr-submit.schema.json +147 -0
  138. package/extension/doors/learnDocs.ts +0 -99
  139. package/prompts/_fixtures/golden/address-action-model.txt +0 -10
  140. package/prompts/_fixtures/golden/address-action.txt +0 -10
  141. package/prompts/_fixtures/golden/address-preview-model.txt +0 -6
  142. package/prompts/_fixtures/golden/address-preview.txt +0 -6
  143. package/prompts/_fixtures/golden/implement-github.txt +0 -8
  144. package/prompts/_fixtures/golden/learn-docs.txt +0 -8
  145. package/prompts/_fixtures/golden/learn-github.txt +0 -11
  146. package/prompts/_fixtures/golden/learn-linear.txt +0 -11
  147. package/prompts/_fixtures/golden/learn-no-ref.txt +0 -8
  148. package/prompts/_fixtures/golden/learn-other.txt +0 -8
  149. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +0 -8
  150. package/prompts/_fixtures/golden/objective-plan-guidance.txt +0 -8
  151. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +0 -20
  152. package/prompts/_fixtures/golden/objective-plan-seed.txt +0 -15
  153. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +0 -1
  154. package/prompts/_fixtures/golden/objective-read-linear.txt +0 -1
  155. package/prompts/_fixtures/golden/plan-read-github.txt +0 -1
  156. package/prompts/_fixtures/golden/plan-read-linear.txt +0 -1
  157. package/prompts/_fixtures/golden/plan-read-other.txt +0 -1
@@ -12,24 +12,33 @@
12
12
  // /plan-body materialization, `run_id` mint) is the cold-door/runner's job and is a PREPARED-
13
13
  // WORKTREE input (audit Gap 7): the worker inherits `PERK_RUN_ID` from the env and never re-mints.
14
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
+ //
15
19
  // Inverse of `extension/worker/readOnlySession.ts`: that builds a fully-isolated READ-ONLY child (loads
16
20
  // nothing, `["read","grep","find","ls"]`); the worker is the OPPOSITE — read-write defaults + the
17
- // real perk extension loaded from the worktree's `.pi/settings.json` (cwd-discovery), with 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
18
24
  // user-global tier locked out via a throwaway `agentDir`.
19
25
 
20
26
  import { appendFileSync, mkdtempSync } from "node:fs";
21
27
  import { tmpdir } from "node:os";
22
28
  import { join } from "node:path";
23
29
  import { env } from "node:process";
24
- import type { Api, Model } from "@earendil-works/pi-ai";
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";
25
34
  import {
26
35
  AuthStorage,
27
36
  type CreateAgentSessionRuntimeFactory,
28
- type CreateAgentSessionServicesOptions,
29
37
  createAgentSessionFromServices,
30
38
  createAgentSessionRuntime,
31
39
  createAgentSessionServices,
32
40
  ModelRegistry,
41
+ resolveCliModel,
33
42
  SessionManager,
34
43
  SettingsManager,
35
44
  } from "@earendil-works/pi-coding-agent";
@@ -37,6 +46,7 @@ import { planReadInstruction } from "../doors/lifecycleGates.ts";
37
46
  import { ensureRunScratch, type PlanRef, readPlanRef, runEventsPath } from "../substrate/cache.ts";
38
47
  import { loadPerkConfig } from "../substrate/config.ts";
39
48
  import { render } from "../substrate/prompts.ts";
49
+ import { captureSessionPointer } from "../substrate/sessionPointers.ts";
40
50
  import { rebuildWorkflowState } from "../substrate/workflowState.ts";
41
51
  import { capForModel } from "./readOnlySession.ts";
42
52
 
@@ -115,8 +125,18 @@ export interface DriveStageOptions {
115
125
  stage: DriveStage;
116
126
  /** The seeded first prompt (see `initialPromptFor`). */
117
127
  initialPrompt: string;
118
- /** Explicit model; else the first available from the registry (Gap 5). */
128
+ /**
129
+ * Explicit model; else the SDK's own default resolution picks one at session creation
130
+ * (settings `defaultModel` → pi's per-provider defaults → first available — Gap 5). Never
131
+ * pre-pinned here: `getAvailable()` sorts alphabetically, so `[0]` is the *oldest* model of
132
+ * the first provider (a since-removed `claude-3-5-haiku` date-pin 404'd a whole remote drive).
133
+ */
119
134
  model?: Model<Api>;
135
+ /**
136
+ * Thinking level parsed from the `--model <pattern>:<level>` suffix (`resolveWorkerModel`).
137
+ * `undefined` ⇒ the SDK's settings-default resolution — unchanged behavior.
138
+ */
139
+ thinkingLevel?: ThinkingLevel;
120
140
  authStorage?: AuthStorage;
121
141
  modelRegistry?: ModelRegistry;
122
142
  budget: DriveBudget;
@@ -126,12 +146,11 @@ export interface DriveStageOptions {
126
146
 
127
147
  /**
128
148
  * The offline seam (mirrors `readOnlySession.test.ts`'s `runTask` injection). `createRuntime`
129
- * overrides the production runtime factory so tests drive synthetic sessions; `resourceLoaderOptions`
130
- * tunes the project-tier load; `now` injects the clock for deterministic `elapsed_ms`.
149
+ * overrides the production runtime factory so tests drive synthetic sessions; `now` injects the
150
+ * clock for deterministic `elapsed_ms`.
131
151
  */
132
152
  export interface DriveStageDeps {
133
153
  createRuntime?: (opts: DriveStageOptions) => Promise<DriveRuntimeLike>;
134
- resourceLoaderOptions?: CreateAgentSessionServicesOptions["resourceLoaderOptions"];
135
154
  now?: () => number;
136
155
  /** The structured run-event sink. Absent ⇒ the default run-scoped NDJSON file sink. */
137
156
  eventSink?: RunEventSink;
@@ -149,7 +168,14 @@ export interface DriveEvent {
149
168
  role?: string;
150
169
  stopReason?: string;
151
170
  errorMessage?: string;
152
- usage?: { input?: number; output?: number };
171
+ /**
172
+ * Assistant token usage. `reasoning` is a provider-reported breakdown that is a **subset of
173
+ * `output`** on every pi-ai provider that populates it (anthropic `thinking_tokens`, google
174
+ * `thoughtsTokenCount` folded into `output`, openai `reasoning_tokens` inside completion/
175
+ * output tokens — verified @ pi-ai 0.80.5), so it is deliberately EXCLUDED from the budget
176
+ * sum: adding it would double-count.
177
+ */
178
+ usage?: { input?: number; output?: number; reasoning?: number };
153
179
  /** Assistant text/content blocks (where `[WIP:n]`/`[DONE:n]` markers live). */
154
180
  content?: unknown;
155
181
  };
@@ -162,7 +188,13 @@ export interface DriveSessionLike {
162
188
  prompt(text: string): Promise<void>;
163
189
  abort(): Promise<void>;
164
190
  dispose(): void;
165
- sessionManager: { getBranch(): unknown[] };
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 } }[] };
166
198
  }
167
199
 
168
200
  /** The runtime surface (structurally satisfied by pi's `AgentSessionRuntime`). */
@@ -209,6 +241,10 @@ function detailsOf(result: unknown): Record<string, unknown> | null {
209
241
  * assistant token usage (the `sumAssistantTokens` pattern in objective.ts), captures the `submit`
210
242
  * /`resolve_review_threads` terminal tool details, and records a post-acceptance model error
211
243
  * (assistant `message_end` with `stopReason:"error"`, surfaced with retry off — audit §B #4).
244
+ *
245
+ * The token sum is `input + output` ONLY: `usage.reasoning` is a subset of `output` on every
246
+ * pi-ai provider that reports it (see the `DriveEvent.usage` doc), so summing it would
247
+ * double-count.
212
248
  */
213
249
  export function applyEvent(counters: DriveCounters, event: DriveEvent): void {
214
250
  if (event.type === "turn_end") {
@@ -314,6 +350,17 @@ export function evaluateTerminal(args: {
314
350
  };
315
351
  }
316
352
 
353
+ /**
354
+ * 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
356
+ * when absent, else `null`. Deliberately does NOT require the `subagent` tool for `address` — the
357
+ * subagent-under-worker live smoke stays the §8.11 carried risk.
358
+ */
359
+ export function missingTerminatingTool(stage: DriveStage, toolNames: string[]): string | null {
360
+ const required = stage === "implement" ? "submit" : "resolve_review_threads";
361
+ return toolNames.includes(required) ? null : required;
362
+ }
363
+
317
364
  /** Pull a `{ number, url }` PR from a captured `submit` details block; null when malformed. */
318
365
  function extractPr(
319
366
  details: Record<string, unknown> | null,
@@ -465,9 +512,11 @@ export function defaultEventSink(worktree: string, runId: string): RunEventSink
465
512
  /**
466
513
  * Re-derive the stage's initial prompt from the plan-ref — the TS twin of
467
514
  * `perk/run/launch.py._implement_prompt`/`_address_prompt`. INVARIANT: textual parity with the Python
468
- * plane (asserted reciprocally in `worker.test.ts` + `tests/test_worker_prompt_parity.py`); the
469
- * resolved skill-binding suffix is delivered by the cold door, not here. Returns
470
- * `null` when there is no plan-ref (nothing to prime).
515
+ * plane (asserted reciprocally in `worker.test.ts` + `tests/test_worker_prompt_parity.py`). No
516
+ * skill-binding suffix is appended here: in the driven session the bindings arrive via Mechanism A
517
+ * (bindingDelivery.ts injects the handoff stage's render because this prompt carries no
518
+ * `BINDING_HEADER`) — content byte-identical to the cold door's suffix (contracts.md §8.38).
519
+ * Returns `null` when there is no plan-ref (nothing to prime).
471
520
  *
472
521
  * The implement primer's wording lives in the canonical template `prompts/stages/implement.md`,
473
522
  * rendered by the shared seam (contracts.md §8.31); branching stays in code — only the `read_cmd`
@@ -493,7 +542,7 @@ export function initialPromptFor(
493
542
  }
494
543
  // address
495
544
  const modelClause = classifierModel
496
- ? `, passing \`model: "${classifierModel}"\` on that call (the configured [subagents] review-classifier model)`
545
+ ? `, passing \`model: "${classifierModel}"\` on that call (the configured [models.subagents] review-classifier model)`
497
546
  : "";
498
547
  return render("stages/address/action.md", {
499
548
  provider,
@@ -548,20 +597,24 @@ export function createBindManager(binding: unknown, listener: (event: DriveEvent
548
597
  /**
549
598
  * Build the asymmetric runtime: `cwd = worktree` (project tier — perk's `@mgiles/perk` extension via the
550
599
  * managed `.pi/settings.json`, the managed `AGENTS.md`/`APPEND_SYSTEM.md`) and `agentDir = throwaway`
551
- * (user-global tier OUT), compaction-off + retry-off settings, env-var/registry auth+model (Gap 5).
552
- * No `tools` allowlist — read-write defaults + extension tools. The `createAgentSessionServices`
553
- * factory builds the `DefaultResourceLoader` internally from `cwd`/`agentDir` (recipe correction #1).
600
+ * (user-global tier OUT the throwaway dir has no `settings.json`, so the global tier is empty),
601
+ * env-var/registry auth+model (Gap 5). Settings are DISK-LAYERED (`SettingsManager.create` +
602
+ * `applyOverrides`, the SDK's sanctioned "with overrides" shape docs/sdk.md "Settings
603
+ * Management"): the project tier resolves the managed `packages` list, while the compaction-off/
604
+ * retry-off determinism overrides ride the merged view only (package resolution reads the
605
+ * per-scope raws — overrides cannot leak into it). Missing `npm:` packages auto-install into
606
+ * `.pi/npm` during the loader's reload (skipped under `PI_OFFLINE`); an install failure throws →
607
+ * `driveStage`'s catch arm → a loud `failed`/`drive_error`. No `tools` allowlist — read-write
608
+ * defaults + extension tools. The `createAgentSessionServices` factory builds the
609
+ * `DefaultResourceLoader` internally from `cwd`/`agentDir` (recipe correction #1).
554
610
  */
555
611
  async function defaultCreateRuntime(
556
612
  opts: DriveStageOptions,
557
- deps: DriveStageDeps,
558
- resolved: { authStorage: AuthStorage; modelRegistry: ModelRegistry; model: Model<Api> },
613
+ resolved: ResolvedAuth,
559
614
  ): Promise<DriveRuntimeLike> {
560
615
  const agentDir = mkdtempSync(join(tmpdir(), "perk-worker-agent-"));
561
- const settingsManager = SettingsManager.inMemory({
562
- compaction: { enabled: false },
563
- retry: { enabled: false },
564
- });
616
+ const settingsManager = SettingsManager.create(opts.worktree, agentDir);
617
+ settingsManager.applyOverrides({ compaction: { enabled: false }, retry: { enabled: false } });
565
618
  const factory: CreateAgentSessionRuntimeFactory = async (factoryOpts) => {
566
619
  const services = await createAgentSessionServices({
567
620
  cwd: factoryOpts.cwd,
@@ -569,14 +622,31 @@ async function defaultCreateRuntime(
569
622
  authStorage: resolved.authStorage,
570
623
  settingsManager,
571
624
  modelRegistry: resolved.modelRegistry,
572
- resourceLoaderOptions: deps.resourceLoaderOptions,
573
625
  });
574
626
  const result = await createAgentSessionFromServices({
575
627
  services,
576
628
  sessionManager: factoryOpts.sessionManager,
577
629
  sessionStartEvent: factoryOpts.sessionStartEvent,
630
+ // `undefined` ⇒ the SDK's initial-model resolution picks the model (see `resolveAuth`);
631
+ // an `undefined` thinkingLevel likewise defers to the settings default.
578
632
  model: resolved.model,
633
+ thinkingLevel: opts.thinkingLevel,
579
634
  });
635
+ // Name the model that will actually drive (the SDK may have picked it) — the remote step
636
+ // log is otherwise silent about it until a provider error.
637
+ const chosen = result.session.model;
638
+ console.error(
639
+ `perk worker: model ${chosen ? `${chosen.provider}/${chosen.id}` : "unresolved"}`,
640
+ );
641
+ // Loud construction diagnostics (the CAUSE behind a later `no_extension_tools` symptom):
642
+ // settings I/O errors and extension load errors are recorded, not raised, by the SDK —
643
+ // surfacing them is the app layer's job. Fail-soft reporting only; never throws.
644
+ for (const entry of result.extensionsResult.errors) {
645
+ console.error(`perk worker: extension load error — ${entry.path}: ${entry.error}`);
646
+ }
647
+ for (const entry of settingsManager.drainErrors()) {
648
+ console.error(`perk worker: settings error (${entry.scope}) — ${String(entry.error)}`);
649
+ }
580
650
  return { ...result, services, diagnostics: services.diagnostics };
581
651
  };
582
652
  const runtime = await createAgentSessionRuntime(factory, {
@@ -589,19 +659,67 @@ async function defaultCreateRuntime(
589
659
 
590
660
  // --- model/auth resolution (Gap 5) --------------------------------------------------------------
591
661
 
592
- interface ResolvedAuth {
662
+ export interface ResolvedAuth {
593
663
  authStorage: AuthStorage;
594
664
  modelRegistry: ModelRegistry;
595
- model: Model<Api>;
665
+ /** The EXPLICIT model only; `undefined` defers the pick to the SDK at session creation. */
666
+ model: Model<Api> | undefined;
596
667
  }
597
668
 
598
- /** Resolve auth + model; returns null (never throws) when no model is available. */
599
- function resolveAuth(opts: DriveStageOptions): ResolvedAuth | null {
669
+ /**
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.
675
+ */
676
+ export function resolveAuth(opts: DriveStageOptions): ResolvedAuth | null {
600
677
  const authStorage = opts.authStorage ?? AuthStorage.create();
601
678
  const modelRegistry = opts.modelRegistry ?? ModelRegistry.create(authStorage);
602
- const model = opts.model ?? modelRegistry.getAvailable()[0];
603
- if (!model) return null;
604
- return { authStorage, modelRegistry, model };
679
+ if (!opts.model && modelRegistry.getAvailable().length === 0) return null;
680
+ return { authStorage, modelRegistry, model: opts.model };
681
+ }
682
+
683
+ /** What an explicit `--model` flag resolves to (a thin projection of `ResolveCliModelResult`). */
684
+ export interface ResolvedWorkerModel {
685
+ model: Model<Api> | undefined;
686
+ thinkingLevel: ThinkingLevel | undefined;
687
+ /** Non-fatal resolution diagnostic (e.g. an invalid `:thinking` suffix) — surface, continue. */
688
+ warning: string | undefined;
689
+ /** Fatal: the pattern resolved to no model — fail fast, never guess. */
690
+ error: string | undefined;
691
+ }
692
+
693
+ /**
694
+ * Resolve an explicit `--model` flag with pi's OWN CLI semantics (`resolveCliModel`): fuzzy
695
+ * matching, bare-id resolution, `provider/pattern`, and a `:thinking` suffix — the same chain the
696
+ * flag's string hits in an interactive pi launch, closing the warm/cold parity gap (cf.
697
+ * docs/learned/workflow/execution-path-parity.md). `raw` falsy ⇒ all-undefined (the SDK's own
698
+ * default resolution picks the model at session creation — see `resolveAuth`). A resolution that
699
+ * yields neither a model nor an error is normalized to the worker's not-found error.
700
+ */
701
+ export function resolveWorkerModel(
702
+ raw: string | undefined,
703
+ modelRegistry: ModelRegistry,
704
+ ): ResolvedWorkerModel {
705
+ if (!raw) {
706
+ return { model: undefined, thinkingLevel: undefined, warning: undefined, error: undefined };
707
+ }
708
+ const result = resolveCliModel({ cliModel: raw, modelRegistry });
709
+ if (result.model === undefined && result.error === undefined) {
710
+ return {
711
+ model: undefined,
712
+ thinkingLevel: undefined,
713
+ warning: result.warning,
714
+ error: `model '${raw}' not found in the registry.`,
715
+ };
716
+ }
717
+ return {
718
+ model: result.model,
719
+ thinkingLevel: result.thinkingLevel,
720
+ warning: result.warning,
721
+ error: result.error,
722
+ };
605
723
  }
606
724
 
607
725
  // --- the drive primitive ------------------------------------------------------------------------
@@ -677,12 +795,50 @@ export async function driveStage(
677
795
  runtime = deps.createRuntime
678
796
  ? await deps.createRuntime(opts)
679
797
  : // biome-ignore lint/style/noNonNullAssertion: resolved is non-null on the production path.
680
- await defaultCreateRuntime(opts, deps, resolved!);
798
+ await defaultCreateRuntime(opts, resolved!);
681
799
 
682
800
  let boundSession = runtime.session;
683
801
  await bindManager.bind(boundSession);
684
802
  emitter.emit({ kind: "run_started", run_id: runId, stage: opts.stage });
685
803
 
804
+ // Terminating-tool preflight (presence-gated on `extensionRunner`): disk discovery has a
805
+ // silent-zero arm — a missing/unparseable `.pi/settings.json` or an unresolvable local-path
806
+ // package yields ZERO extension tools without throwing — so fail fast (zero turns) instead of
807
+ // burning the whole budget on a drive that can never call its terminating tool. Reuses the
808
+ // `model_error` terminal signal with a distinct `error.type` (the `no_model` precedent).
809
+ if (boundSession.extensionRunner) {
810
+ const toolNames = boundSession.extensionRunner
811
+ .getAllRegisteredTools()
812
+ .map((t) => t.definition.name);
813
+ const missing = missingTerminatingTool(opts.stage, toolNames);
814
+ if (missing !== null) {
815
+ return finish({
816
+ status: "failed",
817
+ terminal_signal: "model_error",
818
+ pr: null,
819
+ errorType: "no_extension_tools",
820
+ errorMessage:
821
+ `perk extension tools did not register — the ${opts.stage} stage's terminating ` +
822
+ `tool \`${missing}\` is missing. Check the worktree's .pi/settings.json packages ` +
823
+ "list (perk init converges it); construction diagnostics are on stderr.",
824
+ });
825
+ }
826
+ }
827
+
828
+ // Implementation/worker session pointer (contracts.md §8.35): the headless drive records the
829
+ // inner driven session's file under THIS run id into the shared main checkout (the worktree's
830
+ // `mainCheckoutRoot`), labelled `.worker` by capture site. The inner session's own
831
+ // `session_start` records the matching `.main`. Best-effort + non-fatal (carrier warns).
832
+ if (opts.stage === "implement") {
833
+ captureSessionPointer({
834
+ cwd: opts.worktree,
835
+ runId,
836
+ klass: "implementation",
837
+ site: "worker",
838
+ sessionFile: boundSession.sessionManager.getSessionFile?.() ?? null,
839
+ });
840
+ }
841
+
686
842
  // Budget/abort wiring (Gap 2): wall-clock timer + external signal both trip → session.abort().
687
843
  const timer = setTimeout(() => trip("budget"), opts.budget.wallClockMs);
688
844
  if (opts.signal) {
@@ -9,15 +9,15 @@
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 type { Api, Model } from "@earendil-works/pi-ai";
13
12
  import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
14
- import { runEventsPath } from "./substrate/cache.ts";
13
+ import { runEventsPath, workflowDir } from "./substrate/cache.ts";
15
14
  import {
16
15
  type DriveBudget,
17
16
  type DriveStage,
18
17
  driveStage,
19
18
  initialPromptForWorktree,
20
19
  type RunOutcome,
20
+ resolveWorkerModel,
21
21
  } from "./worker/worker.ts";
22
22
 
23
23
  /** Documented defaults for the budget watchdog (overridable via flags). */
@@ -90,23 +90,23 @@ async function main(): Promise<number> {
90
90
  const initialPrompt = initialPromptForWorktree(parsed.worktree, parsed.stage);
91
91
  if (initialPrompt === null) {
92
92
  stderr.write(
93
- `perk worker: no plan-ref under ${parsed.worktree}/.pi/workflow — cannot seed the ${parsed.stage} prompt.\n`,
93
+ `perk worker: no plan-ref under ${workflowDir(parsed.worktree)} — cannot seed the ${parsed.stage} prompt.\n`,
94
94
  );
95
95
  return 2;
96
96
  }
97
97
 
98
- // Headless auth/model (Gap 5): env-var key resolution; `--model provider/id` else first available.
98
+ // Headless auth/model (Gap 5): env-var key resolution; an explicit `--model` resolves with
99
+ // pi's CLI semantics (fuzzy matching, `provider/pattern`, a `:thinking` suffix —
100
+ // `resolveWorkerModel`), else the SDK's default resolution at session creation (settings
101
+ // default → pi's per-provider defaults → first available) — the deferral is unchanged.
99
102
  const authStorage = AuthStorage.create();
100
103
  const modelRegistry = ModelRegistry.create(authStorage);
101
- let model: Model<Api> | undefined;
102
- if (parsed.model) {
103
- const [provider, ...rest] = parsed.model.split("/");
104
- model = modelRegistry.find(provider ?? "", rest.join("/"));
105
- if (!model) {
106
- stderr.write(`perk worker: model '${parsed.model}' not found in the registry.\n`);
107
- return 2;
108
- }
104
+ const resolved = resolveWorkerModel(parsed.model, modelRegistry);
105
+ if (resolved.error) {
106
+ stderr.write(`perk worker: ${resolved.error}\n`);
107
+ return 2;
109
108
  }
109
+ if (resolved.warning) stderr.write(`perk worker: ${resolved.warning}\n`);
110
110
 
111
111
  const controller = new AbortController();
112
112
  const onSignal = (): void => controller.abort();
@@ -119,7 +119,8 @@ async function main(): Promise<number> {
119
119
  worktree: parsed.worktree,
120
120
  stage: parsed.stage,
121
121
  initialPrompt,
122
- model,
122
+ model: resolved.model,
123
+ thinkingLevel: resolved.thinkingLevel,
123
124
  authStorage,
124
125
  modelRegistry,
125
126
  budget: parsed.budget,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mgiles/perk",
3
- "version": "1.0.1",
3
+ "version": "2.0.0",
4
4
  "description": "perk Pi extension (session interior) for the plan-oriented workflow.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,9 +34,6 @@
34
34
  "lint": "biome check extension",
35
35
  "format": "biome format --write extension"
36
36
  },
37
- "dependencies": {
38
- "nunjucks": "3.2.4"
39
- },
40
37
  "peerDependencies": {
41
38
  "@earendil-works/pi-ai": "*",
42
39
  "@earendil-works/pi-coding-agent": "*",
@@ -45,10 +42,9 @@
45
42
  },
46
43
  "devDependencies": {
47
44
  "@biomejs/biome": "2.4.16",
48
- "@earendil-works/pi-ai": "0.78.1",
49
- "@earendil-works/pi-coding-agent": "0.78.1",
45
+ "@earendil-works/pi-ai": "0.80.5",
46
+ "@earendil-works/pi-coding-agent": "0.80.5",
50
47
  "@types/node": "22.19.19",
51
- "@types/nunjucks": "3.2.6",
52
48
  "typescript": "6.0.3",
53
49
  "yaml": "2.9.0"
54
50
  }
package/prompts/README.md CHANGED
@@ -1,6 +1,14 @@
1
- # prompts — canonical cross-plane prompt templates
1
+ # prompts — perk's externalized prompt prose
2
2
 
3
- perk's prompt templates, authored once and **bundled into every build artifact**
3
+ This directory is the canonical home for **all** of perk's externalized prompt prose. A
4
+ template may be **cross-plane** (rendered in production by both planes) or **single-plane**
5
+ (rendered by only one plane — e.g. a warm-door-only or cold-door-only seed/guidance prompt);
6
+ either way it is authored within the frozen mini-jinja subset below and listed in
7
+ `_fixtures/live.yaml`, where it is rendered on **both** engines and asserted byte-equal
8
+ regardless of which plane consumes it in production (a free cross-engine portability guarantee,
9
+ costing nothing since the subset is shared).
10
+
11
+ The templates are authored once and **bundled into every build artifact**
4
12
  (the Python wheel as package data `perk/_prompts/`; the npm package under `prompts/`),
5
13
  exactly like `shared/`. Each plane locates this directory at runtime through its own
6
14
  resolver — `prompts_dir()` (`perk/_resources.py`) and `promptsDir()`
@@ -10,6 +18,49 @@ Templates are rendered by jinja2 (Python) and a vendored TS subset (the extensio
10
18
  are loaded by explicit name through the resolver — never by scanning the directory, so
11
19
  this README is a durable doc, not a template.
12
20
 
13
- The render seam, the frozen template-grammar spec, and the real prompt content land in
14
- later nodes; for now this file is the bundling/resolution probe that gives the directory
15
- tracked content.
21
+ ## Frozen template grammar
22
+
23
+ The templates use a deliberately tiny, **frozen** subset of jinja syntax — the canonical
24
+ "mini-jinja" surface. jinja2 is the reference engine; the extension renders the same subset. A
25
+ **cross-plane conformance guard** (`tests/test_prompt_grammar.py` +
26
+ `extension/substrate/promptGrammar.test.ts`) fails CI if any template uses a construct outside the
27
+ subset.
28
+
29
+ ## Render parity — two decoupled tiers
30
+
31
+ The two render seams (jinja2 on Python, the vendored mini-jinja on TS) are kept byte-identical by
32
+ two tiers that separate the frozen render **contract** from real prompt **prose**:
33
+
34
+ - **Tier A — contract snapshots.** `_fixtures/cases.yaml` lists purpose-built fixture templates
35
+ under `_fixtures/templates/` (one per render feature) with committed goldens under
36
+ `_fixtures/golden/` (jinja2's output). Both planes assert `render == golden`
37
+ (`tests/test_prompts.py` + `extension/substrate/prompts.test.ts`). These goldens change only when
38
+ the render contract changes — never when a real prompt's prose changes.
39
+ - **Tier B — live cross-engine equality.** `_fixtures/live.yaml` lists every real template with
40
+ representative vars and **no** golden. `tests/test_prompt_parity.py` renders each real template
41
+ with jinja2, shells out once to `extension/testing/renderLive.ts` (mini-jinja), and asserts the
42
+ two outputs are byte-equal — so editing a real prompt's prose touches no fixture. A coverage
43
+ guard asserts every real template is listed in `live.yaml`.
44
+
45
+ The subset is exactly four categories:
46
+
47
+ 1. **Variable substitution** — `{{ name }}`, where the contents are a single bare identifier
48
+ (`[A-Za-z_][A-Za-z0-9_]*`). Nothing else: no filters, no dotted access, no parentheses, no
49
+ literals, no operators.
50
+ 2. **Include** — `{% include "path/to/file.md" %}`, a double-quoted root-relative path only.
51
+ 3. **Conditionals** — `{% if cond %}` / `{% elif cond %}` / `{% else %}` / `{% endif %}`, where
52
+ `cond` uses only bare identifiers (truthiness), double-quoted string literals, `==`, and the
53
+ keywords `and` / `or` / `not`. For example: `{% if provider == "github" or provider == "linear" %}`,
54
+ `{% if not pr_id %}`.
55
+ 4. **Plain tags** — `{% %}` only. The whitespace-control markers `{%- … -%}` / `{{- … -}}` are
56
+ **not allowed**; tag-line stripping is handled by the render env's `trim_blocks` setting.
57
+
58
+ **Not allowed** (the guard fails on these): `{% for x in y %}` / `{% endfor %}`, `{% set %}`,
59
+ `{% macro %}` / `{% block %}` / `{% extends %}` / `{% raw %}`, `{# comments #}`, filters
60
+ (`{{ x | upper }}`), attribute access (`{{ user.name }}`), `!=` / `<` / `>`, `in`, `is`,
61
+ parentheses, and numeric literals.
62
+
63
+ This README is **excluded** from the guard's scan — it is documentation, never rendered, and the
64
+ out-of-subset examples above are shown deliberately as prose. The canonical spec lives in
65
+ [`shared/contracts.md` §8.31](../shared/contracts.md). Widening the subset is a deliberate
66
+ decision that amends §8.31 **and** both guards.