@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
@@ -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`, `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. */
@@ -206,12 +210,54 @@ export interface FooterParts {
206
210
  branch?: string;
207
211
  /** Model id (dim); omitted when no model. */
208
212
  model?: string;
213
+ /** The session thinking level (dim; e.g. `high`/`off`); omitted when there is no model. */
214
+ thinking?: string;
215
+ /** The prompt-cache-hit segment, e.g. `CH42.3%` (dim; right group); omitted until cache activity. */
216
+ cache?: string;
209
217
  /** Context usage — rendered `<pct>%/<window>` (dim; warning >70, error >90; `?` when null). */
210
218
  context?: { percent: number | null; contextWindow: number };
211
219
  /** Guest extension statuses (dim), pre-sorted by slot key; sanitized here. */
212
220
  guests: string[];
213
221
  }
214
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
+
215
261
  /** Pi's `sanitizeStatusText` behavior, reimplemented locally (pi does not export it). */
216
262
  function sanitizeGuestStatus(text: string): string {
217
263
  return text
@@ -236,15 +282,17 @@ function formatContextSegment(
236
282
  * Compose THE one footer line (FOOTER_MAX_LINES = 1): left group = identity + objective +
237
283
  * checkpoints (two-space-joined, charter order); right group = branch + model + context + guests
238
284
  * (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
- * as the last resort (ANSI- and 2-cell-emoji-aware).
285
+ * whole segments drop in the extended D9 order — guests (rightmost-first) → thinkingmodel
286
+ * branch → cache → context → checkpoints; `identity` and `objective` are NEVER dropped — then
287
+ * `truncateToWidth` as the last resort (ANSI- and 2-cell-emoji-aware).
242
288
  */
243
289
  export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: number): string {
244
290
  const keep = {
245
291
  guests: parts.guests.map((g) => sanitizeGuestStatus(g)),
246
292
  model: true,
293
+ thinking: true,
247
294
  branch: true,
295
+ cache: true,
248
296
  context: true,
249
297
  checkpoints: true,
250
298
  };
@@ -255,6 +303,8 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
255
303
  const right: string[] = [];
256
304
  if (keep.branch && parts.branch !== undefined) right.push(theme.fg("dim", parts.branch));
257
305
  if (keep.model && parts.model !== undefined) right.push(theme.fg("dim", parts.model));
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));
258
308
  if (keep.context && parts.context !== undefined) {
259
309
  right.push(formatContextSegment(parts.context, theme));
260
310
  }
@@ -268,8 +318,10 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
268
318
  let line = compose();
269
319
  while (visibleWidth(line) > width) {
270
320
  if (keep.guests.length > 0) keep.guests.pop();
321
+ else if (keep.thinking) keep.thinking = false;
271
322
  else if (keep.model) keep.model = false;
272
323
  else if (keep.branch) keep.branch = false;
324
+ else if (keep.cache) keep.cache = false;
273
325
  else if (keep.context) keep.context = false;
274
326
  else if (keep.checkpoints) keep.checkpoints = false;
275
327
  else break; // identity + objective only — nothing left to drop
@@ -293,6 +345,8 @@ export interface PerkFooterDeps {
293
345
  identity: string;
294
346
  status: PerkStatusHandle;
295
347
  getModelId(): string | null;
348
+ getThinkingLevel(): string | null;
349
+ getCacheHitRate(): number | null;
296
350
  getContext(): { percent: number | null; contextWindow: number } | null;
297
351
  }
298
352
 
@@ -312,7 +366,7 @@ export type PerkFooterFactory = (
312
366
  * line in the intended split layout. `render` gathers everything live per call (D10 stateless
313
367
  * render): segments via the handle, branch/guests via `footerData` (excluding perk's own
314
368
  * `STATUS_SLOT_PERK` — the slot keeps publishing for RPC, but the footer renders the segments
315
- * 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
316
370
  * every handle recompose and on branch change; `dispose` detaches both.
317
371
  */
318
372
  export function perkFooter(deps: PerkFooterDeps): PerkFooterFactory {
@@ -325,12 +379,15 @@ export function perkFooter(deps: PerkFooterDeps): PerkFooterFactory {
325
379
  .filter(([key]) => key !== STATUS_SLOT_PERK)
326
380
  .sort(([a], [b]) => a.localeCompare(b))
327
381
  .map(([, text]) => text);
382
+ const rate = deps.getCacheHitRate();
328
383
  const parts: FooterParts = {
329
384
  identity: deps.identity,
330
385
  objective: deps.status.get("objective"),
331
386
  checkpoints: deps.status.get("checkpoints"),
332
387
  branch: footerData.getGitBranch() ?? undefined,
333
388
  model: deps.getModelId() ?? undefined,
389
+ thinking: deps.getThinkingLevel() ?? undefined,
390
+ cache: rate === null ? undefined : `CH${rate.toFixed(1)}%`,
334
391
  context: deps.getContext() ?? undefined,
335
392
  guests,
336
393
  };
@@ -431,14 +488,28 @@ export function renderProgressLines(
431
488
  if (item.kind === "elision") {
432
489
  return truncateToWidth(theme.fg("dim", `… +${item.hidden} ${item.side}`), width);
433
490
  }
434
- const kind = stepGlyphKind(state, item.step);
435
- const glyph = theme.fg(GLYPHS[kind].themeColor, GLYPHS[kind].glyph);
436
- const text = `${item.step.step}. ${item.step.text}`;
437
- const line = `${glyph} ${kind === "done" ? theme.fg("muted", text) : text}`;
438
- return truncateToWidth(line, width);
491
+ return renderStepLine(state, item.step, theme, width);
439
492
  });
440
493
  }
441
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
+
442
513
  function formatTokens(tokens: number): string {
443
514
  if (tokens < 1000) return `${tokens}`;
444
515
  // Whole-k values render bare (`200k`, matching pi's footer); fractional keep one decimal.
@@ -458,3 +529,257 @@ function formatElapsed(ms: number): string {
458
529
  export function formatBudgetLine(args: { tokens: number; elapsedMs: number }): string {
459
530
  return `${formatTokens(args.tokens)} tok · ${formatElapsed(args.elapsedMs)}`;
460
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
  /**