@mgiles/perk 1.1.0 → 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 (93) hide show
  1. package/README.md +68 -44
  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 +19 -12
  6. package/extension/doors/address.ts +4 -4
  7. package/extension/doors/askUser.ts +12 -8
  8. package/extension/doors/ciExecutor.ts +21 -14
  9. package/extension/doors/hunkHandoff.ts +202 -0
  10. package/extension/doors/land.ts +31 -9
  11. package/extension/doors/learn.ts +2 -2
  12. package/extension/doors/learnFactory.ts +144 -0
  13. package/extension/doors/plannotatorHandoff.ts +509 -0
  14. package/extension/doors/prReview.ts +4 -4
  15. package/extension/doors/prReviewBrowser.ts +341 -0
  16. package/extension/doors/prReviewTerminal.ts +267 -0
  17. package/extension/doors/selfcheck.ts +238 -5
  18. package/extension/doors/submit.ts +20 -0
  19. package/extension/doors/submitPrReview.ts +408 -0
  20. package/extension/factories/objective.ts +15 -5
  21. package/extension/factories/objectiveAuthor.ts +15 -32
  22. package/extension/factories/objectiveDraft.ts +1 -1
  23. package/extension/factories/objectivePlan.ts +12 -10
  24. package/extension/factories/objectiveSave.ts +2 -2
  25. package/extension/factories/planMode.ts +22 -40
  26. package/extension/factories/planReview.ts +213 -191
  27. package/extension/factories/planSave.ts +7 -7
  28. package/extension/index.ts +83 -25
  29. package/extension/substrate/bindingDelivery.ts +32 -10
  30. package/extension/substrate/bindings.ts +4 -2
  31. package/extension/substrate/cache.ts +34 -7
  32. package/extension/substrate/clipboard.ts +81 -0
  33. package/extension/substrate/config.ts +88 -65
  34. package/extension/substrate/git.ts +43 -0
  35. package/extension/substrate/paths.ts +1 -1
  36. package/extension/substrate/prompts.ts +2 -2
  37. package/extension/substrate/providers.ts +62 -8
  38. package/extension/substrate/sessionPointers.ts +35 -6
  39. package/extension/substrate/structuredOutput.ts +3 -1
  40. package/extension/substrate/terminalLaunch.ts +178 -0
  41. package/extension/substrate/toolGating.ts +330 -79
  42. package/extension/substrate/toolParams.ts +7 -0
  43. package/extension/substrate/workflowState.ts +54 -2
  44. package/extension/surfaces/footerProvider.ts +8 -4
  45. package/extension/surfaces/surfaces.ts +330 -12
  46. package/extension/vendor/btw/btw.ts +10 -0
  47. package/extension/worker/readOnlySession.ts +19 -6
  48. package/extension/worker/worker.ts +77 -7
  49. package/extension/workerMain.ts +12 -13
  50. package/package.json +3 -3
  51. package/prompts/_fixtures/live.yaml +117 -2
  52. package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
  53. package/prompts/contexts/adapters/plannotator-objective.md +7 -0
  54. package/prompts/contexts/adapters/plannotator-plan.md +6 -0
  55. package/prompts/contexts/adapters/tombell-plan.md +17 -0
  56. package/prompts/contexts/objective-authoring.md +20 -0
  57. package/prompts/contexts/plan-authoring.md +24 -0
  58. package/prompts/contexts/read-only.md +10 -0
  59. package/prompts/stages/conflict-resolution.md +1 -1
  60. package/prompts/stages/learn-code.md +1 -1
  61. package/prompts/stages/learn-docs.md +2 -2
  62. package/prompts/stages/learn-orchestrate.md +1 -1
  63. package/prompts/stages/objective-author/adopt.md +1 -1
  64. package/prompts/stages/objective-author/file.md +1 -1
  65. package/prompts/stages/objective-plan/guidance.md +1 -1
  66. package/prompts/stages/objective-plan/seed.md +1 -1
  67. package/prompts/stages/objective-reconcile.md +1 -1
  68. package/prompts/stages/objective-replan.md +1 -1
  69. package/prompts/stages/plan-from/adopt.md +2 -2
  70. package/prompts/stages/plan-from/file.md +2 -2
  71. package/prompts/stages/pr-review-browser/active.md +11 -0
  72. package/prompts/stages/pr-review-browser/foreign.md +11 -0
  73. package/prompts/stages/pr-review-terminal/active.md +12 -0
  74. package/prompts/stages/pr-review-terminal/foreign.md +13 -0
  75. package/prompts/stages/pr-review-terminal/local.md +4 -0
  76. package/prompts/stages/pr-review.md +1 -1
  77. package/prompts/stages/replan.md +2 -2
  78. package/prompts/stages/skills/create-from.md +1 -1
  79. package/prompts/stages/skills/create.md +1 -1
  80. package/prompts/stages/skills/refine.md +1 -1
  81. package/shared/README.md +22 -18
  82. package/shared/bindings.yaml +10 -2
  83. package/shared/contracts-history.md +24 -0
  84. package/shared/contracts.md +1442 -1787
  85. package/shared/providers.yaml +8 -1
  86. package/shared/registry.yaml +7 -8
  87. package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
  88. package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
  89. package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
  90. package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
  91. package/extension/doors/learnCode.ts +0 -100
  92. package/extension/doors/learnDocs.ts +0 -100
  93. package/extension/doors/prReviewLocal.ts +0 -229
@@ -16,6 +16,10 @@
16
16
 
17
17
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
18
18
 
19
+ // `Key` is keybinding vocabulary (`pi.registerShortcut(Key.ctrlAlt("p"), …)`), not rich UI —
20
+ // re-exported so pi-tui imports stay structurally confined to the surfaces module (the
21
+ // surfacesGuard pi-tui import rule) without allowlisting the shortcut-registering modules.
22
+ export { Key } from "@earendil-works/pi-tui";
19
23
  // Re-exports: the notify seam stays in report.ts; surfaces.ts is the one import for UI vocabulary.
20
24
  export { type ReportTarget, report, type Severity } from "./report.ts";
21
25
 
@@ -30,7 +34,7 @@ export const WIDGET_SLOT_CHECKPOINTS = "perk-checkpoints";
30
34
  export const MARK_CHECKPOINTS = "📋";
31
35
  export const MARK_OBJECTIVE = "🎯";
32
36
 
33
- // --- glyph vocabulary (charter §5 / D3) — data only; themed rendering binds in nodes 2.2/3.1 ---
37
+ // --- glyph vocabulary (charter §5 / D3) — data only; themed rendering lives in the widget/footer builders below ---
34
38
  export type GlyphKind = "done" | "current" | "pending" | "warning" | "failure";
35
39
  export const GLYPHS: Record<GlyphKind, { glyph: string; themeColor: string }> = {
36
40
  done: { glyph: "✓", themeColor: "success" },
@@ -40,7 +44,7 @@ export const GLYPHS: Record<GlyphKind, { glyph: string; themeColor: string }> =
40
44
  failure: { glyph: "✗", themeColor: "error" },
41
45
  };
42
46
 
43
- // --- height bounds (charter §4 / D1/D8) — enforcement lands in nodes 2.2/2.3/4.1 ---
47
+ // --- height bounds (charter §4 / D1/D8) — enforced by the report() notify budget and the footer/widget builders ---
44
48
  export const NOTIFY_MAX_LINES = 1;
45
49
  export const FOOTER_MAX_LINES = 1;
46
50
  export const CHECKPOINTS_WIDGET_MAX_LINES = 4;
@@ -192,8 +196,8 @@ export function createPerkStatus(): PerkStatusHandle {
192
196
  /**
193
197
  * The raw material for one composed footer line. Left group (charter order 1–3): `identity`,
194
198
  * `objective`, `checkpoints` — the segments render verbatim (they carry their own 🎯/📋 marks).
195
- * Right group (charter order 4, 5, +context, 6): `branch`, `model`, `thinking`, `context`, `guests` —
196
- * right-aligned, non-segment system text dim-themed.
199
+ * Right group (charter order 4, 5, +context, 6): `branch`, `model`, `thinking`, `cache`,
200
+ * `context`, `guests` — right-aligned, non-segment system text dim-themed.
197
201
  */
198
202
  export interface FooterParts {
199
203
  /** e.g. `perk v0.0.1` — standing identity (D7), dim. */
@@ -208,12 +212,52 @@ export interface FooterParts {
208
212
  model?: string;
209
213
  /** The session thinking level (dim; e.g. `high`/`off`); omitted when there is no model. */
210
214
  thinking?: string;
215
+ /** The prompt-cache-hit segment, e.g. `CH42.3%` (dim; right group); omitted until cache activity. */
216
+ cache?: string;
211
217
  /** Context usage — rendered `<pct>%/<window>` (dim; warning >70, error >90; `?` when null). */
212
218
  context?: { percent: number | null; contextWindow: number };
213
219
  /** Guest extension statuses (dim), pre-sorted by slot key; sanitized here. */
214
220
  guests: string[];
215
221
  }
216
222
 
223
+ /**
224
+ * A structural slice of pi's `SessionEntry` — only what `latestCacheHitRate` reads. Keeps
225
+ * surfaces.ts dependency-light (no pi imports; `SessionEntry[]` is assignable) — the same
226
+ * structural-mirror pattern as `FooterDataLike`/`ThemeLike`.
227
+ */
228
+ export interface UsageEntryLike {
229
+ type: string;
230
+ message?: {
231
+ role?: string;
232
+ usage?: { input: number; cacheRead: number; cacheWrite: number };
233
+ };
234
+ }
235
+
236
+ /**
237
+ * The prompt-cache-hit rate of the latest usage-bearing assistant message, as a percentage —
238
+ * an exact local mirror of pi's default-footer `CH` computation (pi's cache-stats helpers are
239
+ * unexported; the `sanitizeGuestStatus` reimplementation precedent). Includes pi's display gate:
240
+ * returns `null` unless the session shows cache activity (total cacheRead or cacheWrite > 0) AND
241
+ * the latest usage-bearing assistant message has prompt tokens > 0 (a trailing zero-prompt-token
242
+ * assistant message resets the rate, exactly like pi's `undefined`).
243
+ */
244
+ export function latestCacheHitRate(entries: readonly UsageEntryLike[]): number | null {
245
+ let totalCacheRead = 0;
246
+ let totalCacheWrite = 0;
247
+ let latest: number | null = null;
248
+ for (const entry of entries) {
249
+ if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
250
+ const usage = entry.message.usage;
251
+ if (usage === undefined) continue;
252
+ totalCacheRead += usage.cacheRead;
253
+ totalCacheWrite += usage.cacheWrite;
254
+ const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
255
+ latest = promptTokens > 0 ? (usage.cacheRead / promptTokens) * 100 : null;
256
+ }
257
+ if (totalCacheRead <= 0 && totalCacheWrite <= 0) return null;
258
+ return latest;
259
+ }
260
+
217
261
  /** Pi's `sanitizeStatusText` behavior, reimplemented locally (pi does not export it). */
218
262
  function sanitizeGuestStatus(text: string): string {
219
263
  return text
@@ -239,8 +283,8 @@ function formatContextSegment(
239
283
  * checkpoints (two-space-joined, charter order); right group = branch + model + context + guests
240
284
  * (two-space-joined), right-aligned with ≥2 spaces of padding. When the line exceeds `width`,
241
285
  * whole segments drop in the extended D9 order — guests (rightmost-first) → thinking → model →
242
- * branch → context → checkpoints; `identity` and `objective` are NEVER dropped — then `truncateToWidth`
243
- * as the last resort (ANSI- and 2-cell-emoji-aware).
286
+ * branch → cache → context → checkpoints; `identity` and `objective` are NEVER dropped — then
287
+ * `truncateToWidth` as the last resort (ANSI- and 2-cell-emoji-aware).
244
288
  */
245
289
  export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: number): string {
246
290
  const keep = {
@@ -248,6 +292,7 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
248
292
  model: true,
249
293
  thinking: true,
250
294
  branch: true,
295
+ cache: true,
251
296
  context: true,
252
297
  checkpoints: true,
253
298
  };
@@ -259,6 +304,7 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
259
304
  if (keep.branch && parts.branch !== undefined) right.push(theme.fg("dim", parts.branch));
260
305
  if (keep.model && parts.model !== undefined) right.push(theme.fg("dim", parts.model));
261
306
  if (keep.thinking && parts.thinking !== undefined) right.push(theme.fg("dim", parts.thinking));
307
+ if (keep.cache && parts.cache !== undefined) right.push(theme.fg("dim", parts.cache));
262
308
  if (keep.context && parts.context !== undefined) {
263
309
  right.push(formatContextSegment(parts.context, theme));
264
310
  }
@@ -275,6 +321,7 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
275
321
  else if (keep.thinking) keep.thinking = false;
276
322
  else if (keep.model) keep.model = false;
277
323
  else if (keep.branch) keep.branch = false;
324
+ else if (keep.cache) keep.cache = false;
278
325
  else if (keep.context) keep.context = false;
279
326
  else if (keep.checkpoints) keep.checkpoints = false;
280
327
  else break; // identity + objective only — nothing left to drop
@@ -299,6 +346,7 @@ export interface PerkFooterDeps {
299
346
  status: PerkStatusHandle;
300
347
  getModelId(): string | null;
301
348
  getThinkingLevel(): string | null;
349
+ getCacheHitRate(): number | null;
302
350
  getContext(): { percent: number | null; contextWindow: number } | null;
303
351
  }
304
352
 
@@ -318,7 +366,7 @@ export type PerkFooterFactory = (
318
366
  * line in the intended split layout. `render` gathers everything live per call (D10 stateless
319
367
  * render): segments via the handle, branch/guests via `footerData` (excluding perk's own
320
368
  * `STATUS_SLOT_PERK` — the slot keeps publishing for RPC, but the footer renders the segments
321
- * directly), model/context via the deps closures. Reactivity (the D2 contract): repaints on
369
+ * directly), model/cache/context via the deps closures. Reactivity (the D2 contract): repaints on
322
370
  * every handle recompose and on branch change; `dispose` detaches both.
323
371
  */
324
372
  export function perkFooter(deps: PerkFooterDeps): PerkFooterFactory {
@@ -331,6 +379,7 @@ export function perkFooter(deps: PerkFooterDeps): PerkFooterFactory {
331
379
  .filter(([key]) => key !== STATUS_SLOT_PERK)
332
380
  .sort(([a], [b]) => a.localeCompare(b))
333
381
  .map(([, text]) => text);
382
+ const rate = deps.getCacheHitRate();
334
383
  const parts: FooterParts = {
335
384
  identity: deps.identity,
336
385
  objective: deps.status.get("objective"),
@@ -338,6 +387,7 @@ export function perkFooter(deps: PerkFooterDeps): PerkFooterFactory {
338
387
  branch: footerData.getGitBranch() ?? undefined,
339
388
  model: deps.getModelId() ?? undefined,
340
389
  thinking: deps.getThinkingLevel() ?? undefined,
390
+ cache: rate === null ? undefined : `CH${rate.toFixed(1)}%`,
341
391
  context: deps.getContext() ?? undefined,
342
392
  guests,
343
393
  };
@@ -438,14 +488,28 @@ export function renderProgressLines(
438
488
  if (item.kind === "elision") {
439
489
  return truncateToWidth(theme.fg("dim", `… +${item.hidden} ${item.side}`), width);
440
490
  }
441
- const kind = stepGlyphKind(state, item.step);
442
- const glyph = theme.fg(GLYPHS[kind].themeColor, GLYPHS[kind].glyph);
443
- const text = `${item.step.step}. ${item.step.text}`;
444
- const line = `${glyph} ${kind === "done" ? theme.fg("muted", text) : text}`;
445
- return truncateToWidth(line, width);
491
+ return renderStepLine(state, item.step, theme, width);
446
492
  });
447
493
  }
448
494
 
495
+ /**
496
+ * ONE themed per-step line (`✓/▸/○ <n>. <text>`, §5 colors, D9-truncated) — shared by the
497
+ * checkpoints widget (`renderProgressLines`, windowed) and the checkpoint transcript marker's
498
+ * expanded view (all steps, unwindowed), so the two surfaces render steps identically.
499
+ */
500
+ function renderStepLine(
501
+ state: ProgressState,
502
+ step: ProgressStep,
503
+ theme: ThemeLike,
504
+ width: number,
505
+ ): string {
506
+ const kind = stepGlyphKind(state, step);
507
+ const glyph = theme.fg(GLYPHS[kind].themeColor, GLYPHS[kind].glyph);
508
+ const text = `${step.step}. ${step.text}`;
509
+ const line = `${glyph} ${kind === "done" ? theme.fg("muted", text) : text}`;
510
+ return truncateToWidth(line, width);
511
+ }
512
+
449
513
  function formatTokens(tokens: number): string {
450
514
  if (tokens < 1000) return `${tokens}`;
451
515
  // Whole-k values render bare (`200k`, matching pi's footer); fractional keep one decimal.
@@ -465,3 +529,257 @@ function formatElapsed(ms: number): string {
465
529
  export function formatBudgetLine(args: { tokens: number; elapsedMs: number }): string {
466
530
  return `${formatTokens(args.tokens)} tok · ${formatElapsed(args.elapsedMs)}`;
467
531
  }
532
+
533
+ // --- the transcript markers — display-only entry renderers ---------------------------------------
534
+ // The audit §2.3 verdict (docs/design/pi-adoption-audit.md): perk's four display-only custom-entry
535
+ // families render as durable one-line transcript markers. Renderer BODIES live here (a transcript
536
+ // renderer IS a rich-UI surface the surfaces module owns); registration is wiring at the feature
537
+ // modules via the `registerTranscriptRenderer` seam below. Renderers are an interactive-TUI-only
538
+ // concern (never invoked in json/RPC mode), so registration is inert-safe everywhere.
539
+
540
+ /** Structural slice of pi's `CustomEntry` — the only field the marker renderers read. */
541
+ export interface TranscriptEntryLike {
542
+ data?: unknown;
543
+ }
544
+
545
+ /** Structural mirror of pi's `EntryRenderOptions`. */
546
+ export interface EntryRenderOptionsLike {
547
+ expanded: boolean;
548
+ }
549
+
550
+ /**
551
+ * A transcript entry renderer, assignable to pi's `EntryRenderer<unknown>`: the params are
552
+ * structural supertypes of pi's (`CustomEntry`/`EntryRenderOptions`/`Theme`), and the returned
553
+ * object satisfies pi-tui's structural `Component` (`render(width): string[]`; `handleInput` is
554
+ * optional). `undefined` = render nothing (malformed/missing `data` stays invisible — exactly the
555
+ * pre-renderer behavior).
556
+ */
557
+ export type TranscriptRenderer = (
558
+ entry: TranscriptEntryLike,
559
+ options: EntryRenderOptionsLike,
560
+ theme: ThemeLike,
561
+ ) => { render(width: number): string[] } | undefined;
562
+
563
+ /**
564
+ * The minimal host surface the registration seam needs. The member is OPTIONAL and
565
+ * method-syntax (bivariant — the `PerkFooterFactory` recipe): pi ≥ 0.80.4's `ExtensionAPI`
566
+ * satisfies it; pre-0.80.4 hosts simply don't have the method.
567
+ */
568
+ export interface TranscriptRendererHost {
569
+ registerEntryRenderer?(customType: string, renderer: TranscriptRenderer): void;
570
+ }
571
+
572
+ /**
573
+ * The one sanctioned `registerEntryRenderer` call site (guard-confined) carrying the one typeof
574
+ * feature-detect: on a pre-0.80.4 host the method is absent (calling it would `TypeError`), so
575
+ * registration is a silent no-op and the entries stay invisible — exactly today's behavior.
576
+ */
577
+ export function registerTranscriptRenderer(
578
+ host: TranscriptRendererHost,
579
+ customType: string,
580
+ renderer: TranscriptRenderer,
581
+ ): void {
582
+ if (typeof host.registerEntryRenderer !== "function") return; // pre-0.80.4 host: inert
583
+ host.registerEntryRenderer(customType, renderer);
584
+ }
585
+
586
+ /**
587
+ * Charter budget: a COLLAPSED transcript marker is exactly one line. The expanded view is
588
+ * human-requested scrollback and renders its full detail unbounded (all checkpoint steps, the
589
+ * whole btw answer).
590
+ */
591
+ export const TRANSCRIPT_MARKER_MAX_LINES = 1;
592
+
593
+ /**
594
+ * The collapsed-marker grammar: the `report()` transition grammar `perk: <scope> — <message>`,
595
+ * dim, D9-truncated. Emoji stay footer-only (D3); themed §5 glyphs appear only in expanded
596
+ * checkpoint step lines.
597
+ */
598
+ function markerLine(scope: string, message: string, theme: ThemeLike, width: number): string {
599
+ return truncateToWidth(theme.fg("dim", `perk: ${scope} — ${message}`), width);
600
+ }
601
+
602
+ /** The three-clause object-shape guard: a plain (non-null, non-array) object or null. */
603
+ function asRecord(value: unknown): Record<string, unknown> | null {
604
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
605
+ return value as Record<string, unknown>;
606
+ }
607
+
608
+ /** Decode `{ steps }` from a `perk:checkpoint` entry: a non-empty array of valid steps, or null. */
609
+ function decodeCheckpointSteps(data: unknown): ProgressStep[] | null {
610
+ const record = asRecord(data);
611
+ if (record === null) return null;
612
+ const steps = record.steps;
613
+ if (!Array.isArray(steps) || steps.length === 0) return null;
614
+ const decoded: ProgressStep[] = [];
615
+ for (const raw of steps) {
616
+ const step = asRecord(raw);
617
+ if (step === null) return null;
618
+ if (
619
+ typeof step.step !== "number" ||
620
+ typeof step.text !== "string" ||
621
+ typeof step.completed !== "boolean"
622
+ ) {
623
+ return null;
624
+ }
625
+ decoded.push({ step: step.step, text: step.text, completed: step.completed });
626
+ }
627
+ return decoded;
628
+ }
629
+
630
+ /**
631
+ * `perk:checkpoint` marker. Collapsed: `perk: checkpoints — <done/total>`. Expanded: that line +
632
+ * one §5 glyph line per step — ALL steps, unwindowed (scrollback is human-requested, so the
633
+ * `CHECKPOINTS_WIDGET_MAX_LINES` standing budget does not apply). `current` is derived state that
634
+ * lives in checkpoints.ts; a historical marker renders with `current: null` (bare `done/total`,
635
+ * no `▸` step) — which keeps surfaces.ts free of checkpoint imports.
636
+ */
637
+ export const checkpointEntryRenderer: TranscriptRenderer = (entry, options, theme) => {
638
+ const steps = decodeCheckpointSteps(entry.data);
639
+ if (steps === null) return undefined;
640
+ const state: ProgressState = { steps, current: null };
641
+ return {
642
+ render(width) {
643
+ const collapsed = markerLine("checkpoints", progressLine(state), theme, width);
644
+ if (!options.expanded) return [collapsed];
645
+ return [collapsed, ...steps.map((step) => renderStepLine(state, step, theme, width))];
646
+ },
647
+ };
648
+ };
649
+
650
+ /**
651
+ * The first matching workflow-state field's marker message — a deliberately BOUNDED vocabulary
652
+ * (the four headline fields + a SET `objective_node_claim`), extensible later. Bookkeeping deltas
653
+ * (`session_artifacts`, `last_review*`, `conflict_resolution_attempts`, cleared node claims) stay
654
+ * invisible by returning null.
655
+ */
656
+ function workflowStateMessage(data: Record<string, unknown>): string | null {
657
+ if (typeof data.run_id === "string") {
658
+ if (typeof data.predecessor === "string") {
659
+ return `run ${data.run_id} · child of ${data.predecessor}`;
660
+ }
661
+ let message = `run ${data.run_id} claimed`;
662
+ if (typeof data.stage === "string") message += ` · stage ${data.stage}`;
663
+ if (typeof data.mode === "string") message += ` · ${data.mode}`;
664
+ return message;
665
+ }
666
+ if (typeof data.mode === "string") return `${data.mode} mode`;
667
+ // Key-presence check (not value truthiness): an explicit null means "cleared" here.
668
+ if (Object.hasOwn(data, "active_objective")) {
669
+ if (typeof data.active_objective === "string") {
670
+ return `objective ${data.active_objective} activated`;
671
+ }
672
+ if (data.active_objective === null) return "objective cleared";
673
+ }
674
+ const planRef = asRecord(data.active_plan_ref);
675
+ if (planRef !== null && typeof planRef.pr_id === "string") {
676
+ return `plan ${planRef.pr_id} linked`;
677
+ }
678
+ const claim = asRecord(data.objective_node_claim);
679
+ if (claim !== null && typeof claim.objective === "string" && typeof claim.node === "string") {
680
+ // A cleared claim (null) stays invisible — only the SET claim is a marker-worthy moment.
681
+ return `node ${claim.node} claimed for objective ${claim.objective}`;
682
+ }
683
+ return null;
684
+ }
685
+
686
+ /**
687
+ * `perk:workflow-state` marker: the first matching headline field renders (precedence:
688
+ * run claim/fork → mode flip → objective set/clear → plan link → node claim); bookkeeping-only
689
+ * deltas stay invisible. Expanded: the collapsed line + the raw delta as one dim JSON line
690
+ * (/learn-grade debuggability).
691
+ */
692
+ export const workflowStateEntryRenderer: TranscriptRenderer = (entry, options, theme) => {
693
+ const data = asRecord(entry.data);
694
+ if (data === null) return undefined;
695
+ const message = workflowStateMessage(data);
696
+ if (message === null) return undefined;
697
+ return {
698
+ render(width) {
699
+ const collapsed = markerLine("workflow", message, theme, width);
700
+ if (!options.expanded) return [collapsed];
701
+ return [collapsed, truncateToWidth(theme.fg("dim", JSON.stringify(data)), width)];
702
+ },
703
+ };
704
+ };
705
+
706
+ /**
707
+ * `perk:objective-budget` marker (`{ objective_id, activated_at }` activation entries).
708
+ * Collapsed: `perk: objective — <id> budget tracking started`. Expanded: + a dim activation
709
+ * timestamp line.
710
+ */
711
+ export const objectiveBudgetEntryRenderer: TranscriptRenderer = (entry, options, theme) => {
712
+ const data = asRecord(entry.data);
713
+ if (data === null) return undefined;
714
+ const objectiveId = data.objective_id;
715
+ const activatedAt = data.activated_at;
716
+ if (typeof objectiveId !== "string" || typeof activatedAt !== "string") return undefined;
717
+ return {
718
+ render(width) {
719
+ const collapsed = markerLine(
720
+ "objective",
721
+ `${objectiveId} budget tracking started`,
722
+ theme,
723
+ width,
724
+ );
725
+ if (!options.expanded) return [collapsed];
726
+ return [collapsed, truncateToWidth(theme.fg("dim", `activated at ${activatedAt}`), width)];
727
+ },
728
+ };
729
+ };
730
+
731
+ /**
732
+ * `btw-thread-entry` marker. Collapsed: `perk: btw — <first line of question>` (dim). Expanded:
733
+ * the question line accented + the answer split on newlines, each line dim + width-truncated —
734
+ * no wrapping (a bounded choice: the marker is a durable pointer; the `/btw` overlay remains the
735
+ * full reader).
736
+ */
737
+ export const btwThreadEntryRenderer: TranscriptRenderer = (entry, options, theme) => {
738
+ const data = asRecord(entry.data);
739
+ if (data === null) return undefined;
740
+ const question = data.question;
741
+ const answer = data.answer;
742
+ if (typeof question !== "string" || typeof answer !== "string") return undefined;
743
+ const headline = question.split("\n", 1)[0] ?? "";
744
+ return {
745
+ render(width) {
746
+ if (!options.expanded) return [markerLine("btw", headline, theme, width)];
747
+ return [
748
+ truncateToWidth(theme.fg("accent", `perk: btw — ${headline}`), width),
749
+ ...answer.split("\n").map((line) => truncateToWidth(theme.fg("dim", line), width)),
750
+ ];
751
+ },
752
+ };
753
+ };
754
+
755
+ /** `btw-thread-reset` marker: `perk: btw — thread reset` (+ a dim ISO timestamp line expanded). */
756
+ export const btwThreadResetEntryRenderer: TranscriptRenderer = (entry, options, theme) => {
757
+ const data = asRecord(entry.data);
758
+ if (data === null) return undefined;
759
+ const timestamp = data.timestamp;
760
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) return undefined;
761
+ return {
762
+ render(width) {
763
+ const collapsed = markerLine("btw", "thread reset", theme, width);
764
+ if (!options.expanded) return [collapsed];
765
+ return [
766
+ collapsed,
767
+ truncateToWidth(theme.fg("dim", new Date(timestamp).toISOString()), width),
768
+ ];
769
+ },
770
+ };
771
+ };
772
+
773
+ /**
774
+ * The coarse prose-plan widget line (relocated verbatim from checkpoints.ts's inline factory —
775
+ * its only pi-tui usage): one dim, D9-truncated line naming the active plan with no `## Steps`
776
+ * checklist.
777
+ */
778
+ export function renderCoarsePlanLines(planId: string, theme: ThemeLike, width: number): string[] {
779
+ return [
780
+ truncateToWidth(
781
+ theme.fg("dim", `Plan #${planId}: prose plan — no \`## Steps\` checklist`),
782
+ width,
783
+ ),
784
+ ];
785
+ }
@@ -57,6 +57,11 @@ type OverlayHandleLike = {
57
57
 
58
58
  import type { ToolGating } from "../../substrate/toolGating.ts";
59
59
  import { report } from "../../surfaces/report.ts";
60
+ import {
61
+ btwThreadEntryRenderer,
62
+ btwThreadResetEntryRenderer,
63
+ registerTranscriptRenderer,
64
+ } from "../../surfaces/surfaces.ts";
60
65
  import {
61
66
  extractEventAssistantText,
62
67
  extractText,
@@ -304,6 +309,11 @@ class BtwOverlay extends Container implements Focusable {
304
309
  }
305
310
 
306
311
  export function registerBtw(pi: ExtensionAPI, gating: ToolGating): void {
312
+ // Transcript markers for the btw thread entries (audit §2.3): renderer bodies in surfaces.ts,
313
+ // registration = wiring, feature-detect inside the seam (pre-0.80.4 hosts stay inert).
314
+ registerTranscriptRenderer(pi, BTW_ENTRY_TYPE, btwThreadEntryRenderer);
315
+ registerTranscriptRenderer(pi, BTW_RESET_TYPE, btwThreadResetEntryRenderer);
316
+
307
317
  let thread: BtwDetails[] = [];
308
318
  let pendingQuestion: string | null = null;
309
319
  let pendingAnswer = "";
@@ -80,25 +80,38 @@ export interface CapResult {
80
80
 
81
81
  /**
82
82
  * UTF-8-byte-safe truncation (subagent's byte-trim loop). Under cap ⇒ unchanged, truncated:false.
83
- * When truncated, appends a notice that points at the scratch file holding the full result.
84
- * Pure.
83
+ * When truncated, a notice points at the scratch file holding the full result; the notice sits at
84
+ * the cut edge (appended in head mode, prepended in tail mode) so a top-down reader immediately
85
+ * knows which side is missing.
86
+ *
87
+ * `keep` mirrors the SDK's truncateHead/truncateTail guidance: "head" (default) for
88
+ * model-authored summaries/handoffs where the beginning matters; "tail" for command/CI logs where
89
+ * failure summaries live at the end. Deliberately perk's own byte-only util (not the SDK's
90
+ * line-count-aware `truncateTail`): `CapResult`'s byte fields and the scratch-pointing notice are
91
+ * load-bearing in `ChildStructured`/`CiCheckResult`. Pure.
85
92
  */
86
93
  export function capForModel(
87
94
  text: string,
88
95
  cap: number = DEFAULT_MODEL_VISIBLE_CAP,
89
96
  scratchPath: string | null = null,
97
+ keep: "head" | "tail" = "head",
90
98
  ): CapResult {
91
99
  const bytesTotal = Buffer.byteLength(text, "utf8");
92
100
  if (bytesTotal <= cap) {
93
101
  return { shown: text, bytesTotal, bytesShown: bytesTotal, truncated: false };
94
102
  }
95
- let trimmed = text.slice(0, cap);
96
- while (Buffer.byteLength(trimmed, "utf8") > cap) trimmed = trimmed.slice(0, -1);
103
+ let trimmed = keep === "head" ? text.slice(0, cap) : text.slice(-cap);
104
+ while (Buffer.byteLength(trimmed, "utf8") > cap) {
105
+ trimmed = keep === "head" ? trimmed.slice(0, -1) : trimmed.slice(1);
106
+ }
97
107
  const bytesShown = Buffer.byteLength(trimmed, "utf8");
98
108
  const omitted = bytesTotal - bytesShown;
99
109
  const where = scratchPath ? ` Full output preserved at ${scratchPath}.` : "";
100
- const notice = `\n\n[Output truncated: ${omitted} bytes omitted.${where}]`;
101
- return { shown: `${trimmed}${notice}`, bytesTotal, bytesShown, truncated: true };
110
+ const shown =
111
+ keep === "head"
112
+ ? `${trimmed}\n\n[Output truncated: ${omitted} bytes omitted.${where}]`
113
+ : `[Output truncated: ${omitted} bytes omitted.${where}]\n\n${trimmed}`;
114
+ return { shown, bytesTotal, bytesShown, truncated: true };
102
115
  }
103
116
 
104
117
  /**
@@ -12,6 +12,10 @@
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
21
  // real perk extension loaded from the worktree's `.pi/settings.json` (disk-layered settings:
@@ -23,7 +27,10 @@ import { appendFileSync, mkdtempSync } from "node:fs";
23
27
  import { tmpdir } from "node:os";
24
28
  import { join } from "node:path";
25
29
  import { env } from "node:process";
26
- 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";
27
34
  import {
28
35
  AuthStorage,
29
36
  type CreateAgentSessionRuntimeFactory,
@@ -31,6 +38,7 @@ import {
31
38
  createAgentSessionRuntime,
32
39
  createAgentSessionServices,
33
40
  ModelRegistry,
41
+ resolveCliModel,
34
42
  SessionManager,
35
43
  SettingsManager,
36
44
  } from "@earendil-works/pi-coding-agent";
@@ -124,6 +132,11 @@ export interface DriveStageOptions {
124
132
  * the first provider (a since-removed `claude-3-5-haiku` date-pin 404'd a whole remote drive).
125
133
  */
126
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;
127
140
  authStorage?: AuthStorage;
128
141
  modelRegistry?: ModelRegistry;
129
142
  budget: DriveBudget;
@@ -155,7 +168,14 @@ export interface DriveEvent {
155
168
  role?: string;
156
169
  stopReason?: string;
157
170
  errorMessage?: string;
158
- 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 };
159
179
  /** Assistant text/content blocks (where `[WIP:n]`/`[DONE:n]` markers live). */
160
180
  content?: unknown;
161
181
  };
@@ -221,6 +241,10 @@ function detailsOf(result: unknown): Record<string, unknown> | null {
221
241
  * assistant token usage (the `sumAssistantTokens` pattern in objective.ts), captures the `submit`
222
242
  * /`resolve_review_threads` terminal tool details, and records a post-acceptance model error
223
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.
224
248
  */
225
249
  export function applyEvent(counters: DriveCounters, event: DriveEvent): void {
226
250
  if (event.type === "turn_end") {
@@ -488,9 +512,11 @@ export function defaultEventSink(worktree: string, runId: string): RunEventSink
488
512
  /**
489
513
  * Re-derive the stage's initial prompt from the plan-ref — the TS twin of
490
514
  * `perk/run/launch.py._implement_prompt`/`_address_prompt`. INVARIANT: textual parity with the Python
491
- * plane (asserted reciprocally in `worker.test.ts` + `tests/test_worker_prompt_parity.py`); the
492
- * resolved skill-binding suffix is delivered by the cold door, not here. Returns
493
- * `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).
494
520
  *
495
521
  * The implement primer's wording lives in the canonical template `prompts/stages/implement.md`,
496
522
  * rendered by the shared seam (contracts.md §8.31); branching stays in code — only the `read_cmd`
@@ -516,7 +542,7 @@ export function initialPromptFor(
516
542
  }
517
543
  // address
518
544
  const modelClause = classifierModel
519
- ? `, 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)`
520
546
  : "";
521
547
  return render("stages/address/action.md", {
522
548
  provider,
@@ -601,8 +627,10 @@ async function defaultCreateRuntime(
601
627
  services,
602
628
  sessionManager: factoryOpts.sessionManager,
603
629
  sessionStartEvent: factoryOpts.sessionStartEvent,
604
- // `undefined` ⇒ the SDK's initial-model resolution picks the model (see `resolveAuth`).
630
+ // `undefined` ⇒ the SDK's initial-model resolution picks the model (see `resolveAuth`);
631
+ // an `undefined` thinkingLevel likewise defers to the settings default.
605
632
  model: resolved.model,
633
+ thinkingLevel: opts.thinkingLevel,
606
634
  });
607
635
  // Name the model that will actually drive (the SDK may have picked it) — the remote step
608
636
  // log is otherwise silent about it until a provider error.
@@ -652,6 +680,48 @@ export function resolveAuth(opts: DriveStageOptions): ResolvedAuth | null {
652
680
  return { authStorage, modelRegistry, model: opts.model };
653
681
  }
654
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
+ };
723
+ }
724
+
655
725
  // --- the drive primitive ------------------------------------------------------------------------
656
726
 
657
727
  /**