@mgiles/perk 1.0.1 → 1.1.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 (120) hide show
  1. package/README.md +7 -21
  2. package/extension/checkpoints/checkpoints.ts +2 -1
  3. package/extension/doors/address.ts +2 -1
  4. package/extension/doors/ciExecutor.ts +3 -2
  5. package/extension/doors/land.ts +2 -1
  6. package/extension/doors/learn.ts +239 -33
  7. package/extension/doors/learnCode.ts +100 -0
  8. package/extension/doors/learnDocs.ts +4 -3
  9. package/extension/doors/lifecycleGates.ts +2 -1
  10. package/extension/doors/prReview.ts +20 -35
  11. package/extension/doors/prReviewLocal.ts +229 -0
  12. package/extension/doors/ready.ts +2 -1
  13. package/extension/doors/selfcheck.ts +2 -1
  14. package/extension/doors/submit.ts +17 -19
  15. package/extension/factories/implementHere.ts +116 -0
  16. package/extension/factories/objective.ts +2 -1
  17. package/extension/factories/objectivePlan.ts +4 -24
  18. package/extension/factories/objectiveSave.ts +5 -15
  19. package/extension/factories/planMode.ts +5 -1
  20. package/extension/factories/planReview.ts +103 -11
  21. package/extension/factories/planSave.ts +16 -1
  22. package/extension/index.ts +42 -3
  23. package/extension/substrate/bindings.ts +1 -1
  24. package/extension/substrate/cache.ts +2 -2
  25. package/extension/substrate/command.ts +25 -0
  26. package/extension/substrate/config.ts +12 -13
  27. package/extension/substrate/consoleCapture.ts +90 -0
  28. package/extension/substrate/git.ts +34 -0
  29. package/extension/substrate/miniJinja.ts +480 -0
  30. package/extension/substrate/paths.ts +38 -0
  31. package/extension/substrate/prompts.ts +15 -24
  32. package/extension/substrate/providers.ts +1 -1
  33. package/extension/substrate/sessionData.ts +1 -1
  34. package/extension/substrate/sessionPointers.ts +155 -0
  35. package/extension/substrate/toolGating.ts +8 -0
  36. package/extension/surfaces/surfaces.ts +10 -3
  37. package/extension/worker/worker.ts +111 -25
  38. package/extension/workerMain.ts +5 -3
  39. package/package.json +1 -5
  40. package/prompts/README.md +56 -5
  41. package/prompts/_fixtures/cases.yaml +52 -131
  42. package/prompts/_fixtures/golden/cond_elif-a.txt +3 -0
  43. package/prompts/_fixtures/golden/cond_elif-b.txt +3 -0
  44. package/prompts/_fixtures/golden/cond_elif-c.txt +3 -0
  45. package/prompts/_fixtures/golden/cond_if-false.txt +3 -0
  46. package/prompts/_fixtures/golden/cond_if-true.txt +3 -0
  47. package/prompts/_fixtures/golden/cond_ops-1.txt +3 -0
  48. package/prompts/_fixtures/golden/cond_ops-2.txt +3 -0
  49. package/prompts/_fixtures/golden/no_trailing_nl.txt +2 -0
  50. package/prompts/_fixtures/golden/trailing_nl.txt +2 -0
  51. package/prompts/_fixtures/golden/trim_block.txt +3 -0
  52. package/prompts/_fixtures/golden/trim_inline.txt +1 -0
  53. package/prompts/_fixtures/live.yaml +281 -0
  54. package/prompts/_fixtures/templates/cond_elif.md +9 -0
  55. package/prompts/_fixtures/templates/cond_if.md +7 -0
  56. package/prompts/_fixtures/templates/cond_ops.md +3 -0
  57. package/prompts/_fixtures/templates/no_trailing_nl.md +2 -0
  58. package/prompts/_fixtures/templates/trailing_nl.md +2 -0
  59. package/prompts/_fixtures/templates/trim_block.md +5 -0
  60. package/prompts/_fixtures/templates/trim_inline.md +1 -0
  61. package/prompts/stages/conflict-resolution.md +4 -0
  62. package/prompts/stages/learn-code.md +8 -0
  63. package/prompts/stages/learn-docs.md +7 -6
  64. package/prompts/stages/learn-orchestrate.md +6 -0
  65. package/prompts/stages/learn.md +1 -1
  66. package/prompts/stages/objective-author/adopt.md +12 -0
  67. package/prompts/stages/objective-author/file.md +9 -0
  68. package/prompts/stages/objective-author/seed.md +9 -0
  69. package/prompts/stages/objective-plan/seed.md +2 -1
  70. package/prompts/stages/objective-reconcile.md +7 -0
  71. package/prompts/stages/objective-replan.md +14 -0
  72. package/prompts/stages/objective-save.md +9 -0
  73. package/prompts/stages/plan-from/adopt.md +10 -0
  74. package/prompts/stages/plan-from/file.md +9 -0
  75. package/prompts/stages/pr-review.md +6 -0
  76. package/prompts/stages/replan.md +13 -0
  77. package/prompts/stages/skills/create-from.md +15 -0
  78. package/prompts/stages/skills/create.md +9 -0
  79. package/prompts/stages/skills/refine.md +9 -0
  80. package/shared/README.md +7 -1
  81. package/shared/bindings.yaml +12 -0
  82. package/shared/contracts-history.md +167 -0
  83. package/shared/contracts.md +1307 -342
  84. package/shared/registry.yaml +3 -3
  85. package/shared/schemas/contracts/bindings.schema.json +38 -0
  86. package/shared/schemas/contracts/providers.schema.json +89 -0
  87. package/shared/schemas/contracts/registry.schema.json +98 -0
  88. package/shared/schemas/inputs/handoff-arg.schema.json +6 -0
  89. package/shared/schemas/inputs/resolve-threads-batch.schema.json +37 -0
  90. package/shared/schemas/inputs/review-post-batch.schema.json +84 -0
  91. package/shared/schemas/inputs/structured-roadmap-node.schema.json +102 -0
  92. package/shared/schemas/outputs/doctor-report.schema.json +236 -0
  93. package/shared/schemas/outputs/init-report.schema.json +419 -0
  94. package/shared/schemas/outputs/learn-capture.schema.json +90 -0
  95. package/shared/schemas/outputs/learn-skip.schema.json +59 -0
  96. package/shared/schemas/outputs/plan-save.schema.json +209 -0
  97. package/shared/schemas/outputs/pr-feedback.schema.json +334 -0
  98. package/shared/schemas/outputs/pr-land.schema.json +187 -0
  99. package/shared/schemas/outputs/pr-ready.schema.json +75 -0
  100. package/shared/schemas/outputs/pr-review-context.schema.json +86 -0
  101. package/shared/schemas/outputs/pr-submit.schema.json +147 -0
  102. package/prompts/_fixtures/golden/address-action-model.txt +0 -10
  103. package/prompts/_fixtures/golden/address-action.txt +0 -10
  104. package/prompts/_fixtures/golden/address-preview-model.txt +0 -6
  105. package/prompts/_fixtures/golden/address-preview.txt +0 -6
  106. package/prompts/_fixtures/golden/implement-github.txt +0 -8
  107. package/prompts/_fixtures/golden/learn-docs.txt +0 -8
  108. package/prompts/_fixtures/golden/learn-github.txt +0 -11
  109. package/prompts/_fixtures/golden/learn-linear.txt +0 -11
  110. package/prompts/_fixtures/golden/learn-no-ref.txt +0 -8
  111. package/prompts/_fixtures/golden/learn-other.txt +0 -8
  112. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +0 -8
  113. package/prompts/_fixtures/golden/objective-plan-guidance.txt +0 -8
  114. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +0 -20
  115. package/prompts/_fixtures/golden/objective-plan-seed.txt +0 -15
  116. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +0 -1
  117. package/prompts/_fixtures/golden/objective-read-linear.txt +0 -1
  118. package/prompts/_fixtures/golden/plan-read-github.txt +0 -1
  119. package/prompts/_fixtures/golden/plan-read-linear.txt +0 -1
  120. package/prompts/_fixtures/golden/plan-read-other.txt +0 -1
@@ -0,0 +1,155 @@
1
+ // The run-cache session-pointer carrier (contracts.md §8.35) — the capture side of the cross-run
2
+ // session-pointer record. The TS twin of perk/state/session_pointers.py; both planes read/write the
3
+ // same `session-pointers.json` (the cross-plane contract is the file).
4
+ //
5
+ // Each run writes only its OWN record, keyed by `run_id`, under the SHARED MAIN CHECKOUT
6
+ // (`mainCheckoutRoot(cwd)`) so a linked-worktree run and a later resolver agree on one location. A
7
+ // run fills only the slots it owns (planning runs → `planning.*`; implement runs →
8
+ // `implementation.*`); the four class/site slots are always present (null when unset) so this
9
+ // read-modify-write merges trivially and never clobbers a sibling write.
10
+ //
11
+ // Seam doctrine — best-effort + loud-but-non-fatal: every write is wrapped so a failure (unwritable
12
+ // root, bad disk) warns to stderr and returns false, NEVER throws. A capture failure must never
13
+ // wedge the save/launch/drive it rides on. Node builtins + cache.ts + git.ts only (loads under
14
+ // `node --test`).
15
+
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { basename, join } from "node:path";
18
+ import { runScratchDir } from "./cache.ts";
19
+ import { mainCheckoutRoot } from "./git.ts";
20
+
21
+ export const SESSION_POINTERS_FILE = "session-pointers.json";
22
+
23
+ /** One captured session pointer (a `main` or `worker` slot of a class). */
24
+ export interface SessionPointer {
25
+ /** The session-file basename (matches the `perk:workflow-state` stamp). */
26
+ pi_session_id: string;
27
+ /** The absolute path known at capture (informational). */
28
+ session_file: string;
29
+ /** The inherited parent session (fork/replacement provenance), else null. */
30
+ parent_pi_session_id: string | null;
31
+ /** ISO-8601 capture time. */
32
+ at: string;
33
+ }
34
+
35
+ /** The two capture sites of one session class (`main` = interior, `worker` = headless). */
36
+ export interface SessionClassPointers {
37
+ main: SessionPointer | null;
38
+ worker: SessionPointer | null;
39
+ }
40
+
41
+ /** A run's full session-pointer record (`session-pointers.json`). */
42
+ export interface SessionPointers {
43
+ run_id: string;
44
+ planning: SessionClassPointers;
45
+ implementation: SessionClassPointers;
46
+ }
47
+
48
+ export type SessionClass = "planning" | "implementation";
49
+ export type SessionSite = "main" | "worker";
50
+
51
+ /** The empty four-slot record for a run (all slots null until a capture fills one). */
52
+ function emptyRecord(runId: string): SessionPointers {
53
+ return {
54
+ run_id: runId,
55
+ planning: { main: null, worker: null },
56
+ implementation: { main: null, worker: null },
57
+ };
58
+ }
59
+
60
+ function sessionPointersPath(root: string, runId: string): string {
61
+ return join(runScratchDir(root, runId), SESSION_POINTERS_FILE);
62
+ }
63
+
64
+ /**
65
+ * Read a run's session-pointers record from the shared main checkout; `null` when absent or
66
+ * unparseable (best-effort — a corrupt record is treated as absent, never thrown). `root` is the
67
+ * MAIN checkout (resolve via `mainCheckoutRoot(cwd)` at the call site).
68
+ */
69
+ export function readSessionPointers(root: string, runId: string): SessionPointers | null {
70
+ const path = sessionPointersPath(root, runId);
71
+ if (!existsSync(path)) return null;
72
+ try {
73
+ return JSON.parse(readFileSync(path, "utf8")) as SessionPointers;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Record one session pointer into `<class>.<site>` of the run's record, under the shared main
81
+ * checkout (`mainCheckoutRoot(cwd)`). Read-modify-write: an existing record is loaded (or a fresh
82
+ * four-slot record minted), only the named slot is set, and the whole record is written back — so
83
+ * a planning write and an implementation write to the SAME run record never clobber each other.
84
+ * Best-effort: returns `true` on a successful write, `false` (with a stderr warning) on any
85
+ * failure. Never throws. Serialized byte-compatibly with the Python writer (key order +
86
+ * 2-space indent + trailing newline).
87
+ */
88
+ export function recordSessionPointer(
89
+ cwd: string,
90
+ runId: string,
91
+ klass: SessionClass,
92
+ site: SessionSite,
93
+ pointer: SessionPointer,
94
+ ): boolean {
95
+ if (!runId) return false;
96
+ const root = mainCheckoutRoot(cwd);
97
+ try {
98
+ const record = readSessionPointers(root, runId) ?? emptyRecord(runId);
99
+ // The run id is authoritative — a record read from disk keeps its own; a fresh one is minted
100
+ // with `runId`. (A mismatched on-disk run_id is left as-is; self-keying guarantees a match.)
101
+ record[klass][site] = pointer;
102
+ const dir = runScratchDir(root, runId);
103
+ mkdirSync(dir, { recursive: true });
104
+ writeFileSync(sessionPointersPath(root, runId), serialize(record), "utf8");
105
+ return true;
106
+ } catch (error) {
107
+ console.error(`perk: warning: could not record session pointer (${klass}.${site}): ${error}`);
108
+ return false;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Capture one session pointer into `<class>.<site>` from a session file path — the call-site
114
+ * convenience over `recordSessionPointer`. Derives `pi_session_id` from the basename and stamps
115
+ * `at`. A `null`/empty `sessionFile` or `runId` is a no-op (`false`) — best-effort, never throws.
116
+ */
117
+ export function captureSessionPointer(args: {
118
+ cwd: string;
119
+ runId: string;
120
+ klass: SessionClass;
121
+ site: SessionSite;
122
+ sessionFile: string | null | undefined;
123
+ parentSessionId?: string | null;
124
+ }): boolean {
125
+ const { cwd, runId, klass, site, sessionFile } = args;
126
+ if (!sessionFile || !runId) return false;
127
+ return recordSessionPointer(cwd, runId, klass, site, {
128
+ pi_session_id: basename(sessionFile),
129
+ session_file: sessionFile,
130
+ parent_pi_session_id: args.parentSessionId ?? null,
131
+ at: new Date().toISOString(),
132
+ });
133
+ }
134
+
135
+ /** Serialize a record byte-compatibly with the Python writer (explicit key order, null slots). */
136
+ function serialize(record: SessionPointers): string {
137
+ const slot = (p: SessionPointer | null): Record<string, unknown> | null =>
138
+ p === null
139
+ ? null
140
+ : {
141
+ pi_session_id: p.pi_session_id,
142
+ session_file: p.session_file,
143
+ parent_pi_session_id: p.parent_pi_session_id,
144
+ at: p.at,
145
+ };
146
+ const payload = {
147
+ run_id: record.run_id,
148
+ planning: { main: slot(record.planning.main), worker: slot(record.planning.worker) },
149
+ implementation: {
150
+ main: slot(record.implementation.main),
151
+ worker: slot(record.implementation.worker),
152
+ },
153
+ };
154
+ return `${JSON.stringify(payload, null, 2)}\n`;
155
+ }
@@ -33,6 +33,14 @@ export const READ_ONLY_TOOLS = [
33
33
  // working-objective artifact in the session data dir (fixed artifact name, seam-derived
34
34
  // path); the gate's edit/write/bash blocking is unchanged.
35
35
  "objective_draft",
36
+ // The objective_node carve-out: it never touches the worktree — it delegates a bounded,
37
+ // workflow-owned node transition to the canonical Python plane (`perk objective node`). Both
38
+ // objective-plan factory paths run gated (the cold door hands off `mode: read-only`; the warm
39
+ // `/objective-plan` enters the gate before seeding), and the factory loop's
40
+ // `objective_node_claim` carrier — which the approval-driven save's node-link recovery depends
41
+ // on — can only be written by calling this tool inside the gated session. Excluding it
42
+ // silently breaks the warm `/objective-plan` path: the plan saves unlinked.
43
+ "objective_node",
36
44
  // The `web` seam providers' research tools: the UNION of all known web-provider tool
37
45
  // names, allowlisted statically and inert when the package is absent (the plan_review precedent
38
46
  // — setActiveTools simply has nothing to enable). None mutate the repo — fetch_content's
@@ -192,7 +192,7 @@ export function createPerkStatus(): PerkStatusHandle {
192
192
  /**
193
193
  * The raw material for one composed footer line. Left group (charter order 1–3): `identity`,
194
194
  * `objective`, `checkpoints` — the segments render verbatim (they carry their own 🎯/📋 marks).
195
- * Right group (charter order 4, 5, +context, 6): `branch`, `model`, `context`, `guests` —
195
+ * Right group (charter order 4, 5, +context, 6): `branch`, `model`, `thinking`, `context`, `guests` —
196
196
  * right-aligned, non-segment system text dim-themed.
197
197
  */
198
198
  export interface FooterParts {
@@ -206,6 +206,8 @@ export interface FooterParts {
206
206
  branch?: string;
207
207
  /** Model id (dim); omitted when no model. */
208
208
  model?: string;
209
+ /** The session thinking level (dim; e.g. `high`/`off`); omitted when there is no model. */
210
+ thinking?: string;
209
211
  /** Context usage — rendered `<pct>%/<window>` (dim; warning >70, error >90; `?` when null). */
210
212
  context?: { percent: number | null; contextWindow: number };
211
213
  /** Guest extension statuses (dim), pre-sorted by slot key; sanitized here. */
@@ -236,14 +238,15 @@ function formatContextSegment(
236
238
  * Compose THE one footer line (FOOTER_MAX_LINES = 1): left group = identity + objective +
237
239
  * checkpoints (two-space-joined, charter order); right group = branch + model + context + guests
238
240
  * (two-space-joined), right-aligned with ≥2 spaces of padding. When the line exceeds `width`,
239
- * whole segments drop in the extended D9 order — guests (rightmost-first) → modelbranch
240
- * context → checkpoints; `identity` and `objective` are NEVER dropped — then `truncateToWidth`
241
+ * whole segments drop in the extended D9 order — guests (rightmost-first) → thinkingmodel
242
+ * branch → context → checkpoints; `identity` and `objective` are NEVER dropped — then `truncateToWidth`
241
243
  * as the last resort (ANSI- and 2-cell-emoji-aware).
242
244
  */
243
245
  export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: number): string {
244
246
  const keep = {
245
247
  guests: parts.guests.map((g) => sanitizeGuestStatus(g)),
246
248
  model: true,
249
+ thinking: true,
247
250
  branch: true,
248
251
  context: true,
249
252
  checkpoints: true,
@@ -255,6 +258,7 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
255
258
  const right: string[] = [];
256
259
  if (keep.branch && parts.branch !== undefined) right.push(theme.fg("dim", parts.branch));
257
260
  if (keep.model && parts.model !== undefined) right.push(theme.fg("dim", parts.model));
261
+ if (keep.thinking && parts.thinking !== undefined) right.push(theme.fg("dim", parts.thinking));
258
262
  if (keep.context && parts.context !== undefined) {
259
263
  right.push(formatContextSegment(parts.context, theme));
260
264
  }
@@ -268,6 +272,7 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
268
272
  let line = compose();
269
273
  while (visibleWidth(line) > width) {
270
274
  if (keep.guests.length > 0) keep.guests.pop();
275
+ else if (keep.thinking) keep.thinking = false;
271
276
  else if (keep.model) keep.model = false;
272
277
  else if (keep.branch) keep.branch = false;
273
278
  else if (keep.context) keep.context = false;
@@ -293,6 +298,7 @@ export interface PerkFooterDeps {
293
298
  identity: string;
294
299
  status: PerkStatusHandle;
295
300
  getModelId(): string | null;
301
+ getThinkingLevel(): string | null;
296
302
  getContext(): { percent: number | null; contextWindow: number } | null;
297
303
  }
298
304
 
@@ -331,6 +337,7 @@ export function perkFooter(deps: PerkFooterDeps): PerkFooterFactory {
331
337
  checkpoints: deps.status.get("checkpoints"),
332
338
  branch: footerData.getGitBranch() ?? undefined,
333
339
  model: deps.getModelId() ?? undefined,
340
+ thinking: deps.getThinkingLevel() ?? undefined,
334
341
  context: deps.getContext() ?? undefined,
335
342
  guests,
336
343
  };
@@ -14,7 +14,9 @@
14
14
  //
15
15
  // Inverse of `extension/worker/readOnlySession.ts`: that builds a fully-isolated READ-ONLY child (loads
16
16
  // 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
17
+ // real perk extension loaded from the worktree's `.pi/settings.json` (disk-layered settings:
18
+ // `SettingsManager.create(worktree, throwawayAgentDir)` resolves the managed project-tier
19
+ // `packages` list — perk + the borrowed set, the same package set as a warm session), with the
18
20
  // user-global tier locked out via a throwaway `agentDir`.
19
21
 
20
22
  import { appendFileSync, mkdtempSync } from "node:fs";
@@ -25,7 +27,6 @@ import type { Api, Model } from "@earendil-works/pi-ai";
25
27
  import {
26
28
  AuthStorage,
27
29
  type CreateAgentSessionRuntimeFactory,
28
- type CreateAgentSessionServicesOptions,
29
30
  createAgentSessionFromServices,
30
31
  createAgentSessionRuntime,
31
32
  createAgentSessionServices,
@@ -37,6 +38,7 @@ import { planReadInstruction } from "../doors/lifecycleGates.ts";
37
38
  import { ensureRunScratch, type PlanRef, readPlanRef, runEventsPath } from "../substrate/cache.ts";
38
39
  import { loadPerkConfig } from "../substrate/config.ts";
39
40
  import { render } from "../substrate/prompts.ts";
41
+ import { captureSessionPointer } from "../substrate/sessionPointers.ts";
40
42
  import { rebuildWorkflowState } from "../substrate/workflowState.ts";
41
43
  import { capForModel } from "./readOnlySession.ts";
42
44
 
@@ -115,7 +117,12 @@ export interface DriveStageOptions {
115
117
  stage: DriveStage;
116
118
  /** The seeded first prompt (see `initialPromptFor`). */
117
119
  initialPrompt: string;
118
- /** Explicit model; else the first available from the registry (Gap 5). */
120
+ /**
121
+ * Explicit model; else the SDK's own default resolution picks one at session creation
122
+ * (settings `defaultModel` → pi's per-provider defaults → first available — Gap 5). Never
123
+ * pre-pinned here: `getAvailable()` sorts alphabetically, so `[0]` is the *oldest* model of
124
+ * the first provider (a since-removed `claude-3-5-haiku` date-pin 404'd a whole remote drive).
125
+ */
119
126
  model?: Model<Api>;
120
127
  authStorage?: AuthStorage;
121
128
  modelRegistry?: ModelRegistry;
@@ -126,12 +133,11 @@ export interface DriveStageOptions {
126
133
 
127
134
  /**
128
135
  * 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`.
136
+ * overrides the production runtime factory so tests drive synthetic sessions; `now` injects the
137
+ * clock for deterministic `elapsed_ms`.
131
138
  */
132
139
  export interface DriveStageDeps {
133
140
  createRuntime?: (opts: DriveStageOptions) => Promise<DriveRuntimeLike>;
134
- resourceLoaderOptions?: CreateAgentSessionServicesOptions["resourceLoaderOptions"];
135
141
  now?: () => number;
136
142
  /** The structured run-event sink. Absent ⇒ the default run-scoped NDJSON file sink. */
137
143
  eventSink?: RunEventSink;
@@ -162,7 +168,13 @@ export interface DriveSessionLike {
162
168
  prompt(text: string): Promise<void>;
163
169
  abort(): Promise<void>;
164
170
  dispose(): void;
165
- sessionManager: { getBranch(): unknown[] };
171
+ sessionManager: { getBranch(): unknown[]; getSessionFile?(): string | null };
172
+ /**
173
+ * Optional (presence-gated): when the session exposes its extension runner, `driveStage`
174
+ * preflights the stage's terminating perk tool post-bind and fails fast (zero-turn
175
+ * `no_extension_tools`) instead of burning the budget on a tool-less session.
176
+ */
177
+ extensionRunner?: { getAllRegisteredTools(): { definition: { name: string } }[] };
166
178
  }
167
179
 
168
180
  /** The runtime surface (structurally satisfied by pi's `AgentSessionRuntime`). */
@@ -314,6 +326,17 @@ export function evaluateTerminal(args: {
314
326
  };
315
327
  }
316
328
 
329
+ /**
330
+ * The post-bind preflight rule (pure): the stage's terminating perk tool must be registered —
331
+ * `implement` → `submit`, `address` → `resolve_review_threads`. Returns the required tool name
332
+ * when absent, else `null`. Deliberately does NOT require the `subagent` tool for `address` — the
333
+ * subagent-under-worker live smoke stays the §8.11 carried risk.
334
+ */
335
+ export function missingTerminatingTool(stage: DriveStage, toolNames: string[]): string | null {
336
+ const required = stage === "implement" ? "submit" : "resolve_review_threads";
337
+ return toolNames.includes(required) ? null : required;
338
+ }
339
+
317
340
  /** Pull a `{ number, url }` PR from a captured `submit` details block; null when malformed. */
318
341
  function extractPr(
319
342
  details: Record<string, unknown> | null,
@@ -548,20 +571,24 @@ export function createBindManager(binding: unknown, listener: (event: DriveEvent
548
571
  /**
549
572
  * Build the asymmetric runtime: `cwd = worktree` (project tier — perk's `@mgiles/perk` extension via the
550
573
  * 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).
574
+ * (user-global tier OUT the throwaway dir has no `settings.json`, so the global tier is empty),
575
+ * env-var/registry auth+model (Gap 5). Settings are DISK-LAYERED (`SettingsManager.create` +
576
+ * `applyOverrides`, the SDK's sanctioned "with overrides" shape docs/sdk.md "Settings
577
+ * Management"): the project tier resolves the managed `packages` list, while the compaction-off/
578
+ * retry-off determinism overrides ride the merged view only (package resolution reads the
579
+ * per-scope raws — overrides cannot leak into it). Missing `npm:` packages auto-install into
580
+ * `.pi/npm` during the loader's reload (skipped under `PI_OFFLINE`); an install failure throws →
581
+ * `driveStage`'s catch arm → a loud `failed`/`drive_error`. No `tools` allowlist — read-write
582
+ * defaults + extension tools. The `createAgentSessionServices` factory builds the
583
+ * `DefaultResourceLoader` internally from `cwd`/`agentDir` (recipe correction #1).
554
584
  */
555
585
  async function defaultCreateRuntime(
556
586
  opts: DriveStageOptions,
557
- deps: DriveStageDeps,
558
- resolved: { authStorage: AuthStorage; modelRegistry: ModelRegistry; model: Model<Api> },
587
+ resolved: ResolvedAuth,
559
588
  ): Promise<DriveRuntimeLike> {
560
589
  const agentDir = mkdtempSync(join(tmpdir(), "perk-worker-agent-"));
561
- const settingsManager = SettingsManager.inMemory({
562
- compaction: { enabled: false },
563
- retry: { enabled: false },
564
- });
590
+ const settingsManager = SettingsManager.create(opts.worktree, agentDir);
591
+ settingsManager.applyOverrides({ compaction: { enabled: false }, retry: { enabled: false } });
565
592
  const factory: CreateAgentSessionRuntimeFactory = async (factoryOpts) => {
566
593
  const services = await createAgentSessionServices({
567
594
  cwd: factoryOpts.cwd,
@@ -569,14 +596,29 @@ async function defaultCreateRuntime(
569
596
  authStorage: resolved.authStorage,
570
597
  settingsManager,
571
598
  modelRegistry: resolved.modelRegistry,
572
- resourceLoaderOptions: deps.resourceLoaderOptions,
573
599
  });
574
600
  const result = await createAgentSessionFromServices({
575
601
  services,
576
602
  sessionManager: factoryOpts.sessionManager,
577
603
  sessionStartEvent: factoryOpts.sessionStartEvent,
604
+ // `undefined` ⇒ the SDK's initial-model resolution picks the model (see `resolveAuth`).
578
605
  model: resolved.model,
579
606
  });
607
+ // Name the model that will actually drive (the SDK may have picked it) — the remote step
608
+ // log is otherwise silent about it until a provider error.
609
+ const chosen = result.session.model;
610
+ console.error(
611
+ `perk worker: model ${chosen ? `${chosen.provider}/${chosen.id}` : "unresolved"}`,
612
+ );
613
+ // Loud construction diagnostics (the CAUSE behind a later `no_extension_tools` symptom):
614
+ // settings I/O errors and extension load errors are recorded, not raised, by the SDK —
615
+ // surfacing them is the app layer's job. Fail-soft reporting only; never throws.
616
+ for (const entry of result.extensionsResult.errors) {
617
+ console.error(`perk worker: extension load error — ${entry.path}: ${entry.error}`);
618
+ }
619
+ for (const entry of settingsManager.drainErrors()) {
620
+ console.error(`perk worker: settings error (${entry.scope}) — ${String(entry.error)}`);
621
+ }
580
622
  return { ...result, services, diagnostics: services.diagnostics };
581
623
  };
582
624
  const runtime = await createAgentSessionRuntime(factory, {
@@ -589,19 +631,25 @@ async function defaultCreateRuntime(
589
631
 
590
632
  // --- model/auth resolution (Gap 5) --------------------------------------------------------------
591
633
 
592
- interface ResolvedAuth {
634
+ export interface ResolvedAuth {
593
635
  authStorage: AuthStorage;
594
636
  modelRegistry: ModelRegistry;
595
- model: Model<Api>;
637
+ /** The EXPLICIT model only; `undefined` defers the pick to the SDK at session creation. */
638
+ model: Model<Api> | undefined;
596
639
  }
597
640
 
598
- /** Resolve auth + model; returns null (never throws) when no model is available. */
599
- function resolveAuth(opts: DriveStageOptions): ResolvedAuth | null {
641
+ /**
642
+ * Resolve auth; returns null (never throws) when no model is available at all. The model is NOT
643
+ * pre-pinned from the registry: an `undefined` model lets `createAgentSession` run its own
644
+ * initial-model resolution (settings `defaultModel` → pi's curated per-provider defaults → first
645
+ * available), which picks a current-generation model instead of the registry's
646
+ * alphabetically-first (= oldest) entry.
647
+ */
648
+ export function resolveAuth(opts: DriveStageOptions): ResolvedAuth | null {
600
649
  const authStorage = opts.authStorage ?? AuthStorage.create();
601
650
  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 };
651
+ if (!opts.model && modelRegistry.getAvailable().length === 0) return null;
652
+ return { authStorage, modelRegistry, model: opts.model };
605
653
  }
606
654
 
607
655
  // --- the drive primitive ------------------------------------------------------------------------
@@ -677,12 +725,50 @@ export async function driveStage(
677
725
  runtime = deps.createRuntime
678
726
  ? await deps.createRuntime(opts)
679
727
  : // biome-ignore lint/style/noNonNullAssertion: resolved is non-null on the production path.
680
- await defaultCreateRuntime(opts, deps, resolved!);
728
+ await defaultCreateRuntime(opts, resolved!);
681
729
 
682
730
  let boundSession = runtime.session;
683
731
  await bindManager.bind(boundSession);
684
732
  emitter.emit({ kind: "run_started", run_id: runId, stage: opts.stage });
685
733
 
734
+ // Terminating-tool preflight (presence-gated on `extensionRunner`): disk discovery has a
735
+ // silent-zero arm — a missing/unparseable `.pi/settings.json` or an unresolvable local-path
736
+ // package yields ZERO extension tools without throwing — so fail fast (zero turns) instead of
737
+ // burning the whole budget on a drive that can never call its terminating tool. Reuses the
738
+ // `model_error` terminal signal with a distinct `error.type` (the `no_model` precedent).
739
+ if (boundSession.extensionRunner) {
740
+ const toolNames = boundSession.extensionRunner
741
+ .getAllRegisteredTools()
742
+ .map((t) => t.definition.name);
743
+ const missing = missingTerminatingTool(opts.stage, toolNames);
744
+ if (missing !== null) {
745
+ return finish({
746
+ status: "failed",
747
+ terminal_signal: "model_error",
748
+ pr: null,
749
+ errorType: "no_extension_tools",
750
+ errorMessage:
751
+ `perk extension tools did not register — the ${opts.stage} stage's terminating ` +
752
+ `tool \`${missing}\` is missing. Check the worktree's .pi/settings.json packages ` +
753
+ "list (perk init converges it); construction diagnostics are on stderr.",
754
+ });
755
+ }
756
+ }
757
+
758
+ // Implementation/worker session pointer (contracts.md §8.35): the headless drive records the
759
+ // inner driven session's file under THIS run id into the shared main checkout (the worktree's
760
+ // `mainCheckoutRoot`), labelled `.worker` by capture site. The inner session's own
761
+ // `session_start` records the matching `.main`. Best-effort + non-fatal (carrier warns).
762
+ if (opts.stage === "implement") {
763
+ captureSessionPointer({
764
+ cwd: opts.worktree,
765
+ runId,
766
+ klass: "implementation",
767
+ site: "worker",
768
+ sessionFile: boundSession.sessionManager.getSessionFile?.() ?? null,
769
+ });
770
+ }
771
+
686
772
  // Budget/abort wiring (Gap 2): wall-clock timer + external signal both trip → session.abort().
687
773
  const timer = setTimeout(() => trip("budget"), opts.budget.wallClockMs);
688
774
  if (opts.signal) {
@@ -11,7 +11,7 @@
11
11
  import { argv, env, exit, stderr, stdout } from "node:process";
12
12
  import type { Api, Model } from "@earendil-works/pi-ai";
13
13
  import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
14
- import { runEventsPath } from "./substrate/cache.ts";
14
+ import { runEventsPath, workflowDir } from "./substrate/cache.ts";
15
15
  import {
16
16
  type DriveBudget,
17
17
  type DriveStage,
@@ -90,12 +90,14 @@ 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; `--model provider/id` else the SDK's
99
+ // default resolution at session creation (settings default → pi's per-provider defaults →
100
+ // first available).
99
101
  const authStorage = AuthStorage.create();
100
102
  const modelRegistry = ModelRegistry.create(authStorage);
101
103
  let model: Model<Api> | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mgiles/perk",
3
- "version": "1.0.1",
3
+ "version": "1.1.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": "*",
@@ -48,7 +45,6 @@
48
45
  "@earendil-works/pi-ai": "0.78.1",
49
46
  "@earendil-works/pi-coding-agent": "0.78.1",
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.