@mgiles/perk 2.3.0 → 3.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 (145) hide show
  1. package/README.md +1 -1
  2. package/extension/adapters/planAdapterPlannotator.ts +135 -91
  3. package/extension/adapters/planAdapterTombell.ts +5 -3
  4. package/extension/doors/address.ts +235 -37
  5. package/extension/doors/annotationPush.ts +924 -0
  6. package/extension/doors/auditWaveTools.ts +352 -0
  7. package/extension/doors/ciExecutor.ts +220 -55
  8. package/extension/doors/commitCompact.ts +98 -10
  9. package/extension/doors/draftReviewWaveTools.ts +412 -0
  10. package/extension/doors/dreamWaveTools.ts +475 -0
  11. package/extension/doors/harvestWaveTools.ts +279 -0
  12. package/extension/doors/land.ts +1 -0
  13. package/extension/doors/learn.ts +24 -6
  14. package/extension/doors/learnFactory.ts +33 -7
  15. package/extension/doors/lifecycleGates.ts +1 -1
  16. package/extension/doors/objectiveReviewBrowser.ts +537 -0
  17. package/extension/doors/objectiveStack.ts +1143 -0
  18. package/extension/doors/planReviewBrowser.ts +475 -0
  19. package/extension/doors/plannotatorHandoff.ts +120 -49
  20. package/extension/doors/prReview.ts +180 -63
  21. package/extension/doors/prReviewBrowser.ts +31 -23
  22. package/extension/doors/prReviewDynamic.ts +57 -30
  23. package/extension/doors/prReviewTerminal.ts +6 -11
  24. package/extension/doors/reviewWaveTools.ts +397 -0
  25. package/extension/doors/selfcheck.ts +1 -1
  26. package/extension/doors/submit.ts +82 -7
  27. package/extension/factories/gistAuthor.ts +4 -4
  28. package/extension/factories/gistDraft.ts +1 -1
  29. package/extension/factories/gistSave.ts +2 -2
  30. package/extension/factories/objective.ts +7 -7
  31. package/extension/factories/objectiveAuthor.ts +5 -4
  32. package/extension/factories/objectiveDraft.ts +153 -30
  33. package/extension/factories/objectiveDreamReport.ts +347 -0
  34. package/extension/factories/objectivePlan.ts +163 -9
  35. package/extension/factories/objectiveSave.ts +90 -2
  36. package/extension/factories/planMode.ts +5 -3
  37. package/extension/factories/planReview.ts +304 -46
  38. package/extension/factories/planTitle.ts +27 -9
  39. package/extension/hunkFeedback/inbox.ts +389 -0
  40. package/extension/hunkFeedback/perkFeedback.ts +364 -0
  41. package/extension/hunkFeedback/receiver.ts +296 -0
  42. package/extension/hunkFeedback/store.ts +533 -0
  43. package/extension/index.ts +169 -57
  44. package/extension/substrate/agentScratch.ts +171 -0
  45. package/extension/substrate/bindingDelivery.ts +9 -11
  46. package/extension/substrate/cache.ts +166 -23
  47. package/extension/substrate/coldDoor.ts +2 -3
  48. package/extension/substrate/command.ts +9 -6
  49. package/extension/substrate/config.ts +41 -12
  50. package/extension/substrate/git.ts +95 -2
  51. package/extension/substrate/providers.ts +4 -33
  52. package/extension/substrate/resources.ts +11 -0
  53. package/extension/substrate/result.ts +16 -7
  54. package/extension/substrate/sessionData.ts +8 -6
  55. package/extension/substrate/sessionPointers.ts +4 -5
  56. package/extension/substrate/structuredOutput.ts +78 -19
  57. package/extension/substrate/toolGating.ts +179 -23
  58. package/extension/substrate/workflowState.ts +50 -2
  59. package/extension/surfaces/footerProvider.ts +6 -6
  60. package/extension/surfaces/report.ts +38 -12
  61. package/extension/surfaces/surfaces.ts +213 -275
  62. package/extension/vendor/btw/btw.ts +94 -17
  63. package/extension/waves/adversarialReviewWave.ts +181 -0
  64. package/extension/waves/auditWave.ts +312 -0
  65. package/extension/waves/draftReviewWave.ts +205 -0
  66. package/extension/waves/dreamReducerWave.ts +700 -0
  67. package/extension/waves/dreamReport.ts +1494 -0
  68. package/extension/waves/dreamWave.ts +927 -0
  69. package/extension/waves/harvestWave.ts +399 -0
  70. package/extension/waves/memoryAdapter.ts +14 -1
  71. package/extension/waves/objectiveExplorerWave.ts +120 -0
  72. package/extension/waves/ponytail.ts +104 -0
  73. package/extension/waves/prReviewDynamicWave.ts +383 -72
  74. package/extension/waves/prReviewWave.ts +175 -25
  75. package/extension/waves/reportWave.ts +503 -107
  76. package/extension/waves/reviewClassifierWave.ts +109 -0
  77. package/extension/waves/rpcAdapter.ts +65 -2
  78. package/extension/worker/readOnlySession.ts +4 -5
  79. package/extension/worker/worker.ts +71 -93
  80. package/extension/workerMain.ts +4 -6
  81. package/package.json +18 -5
  82. package/prompts/README.md +19 -0
  83. package/prompts/_fixtures/live.yaml +115 -65
  84. package/prompts/commit-and-compact-continuation.md +13 -0
  85. package/prompts/common/resume-advisory.md +1 -0
  86. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  87. package/prompts/contexts/adapters/plannotator-objective.md +8 -3
  88. package/prompts/contexts/adapters/plannotator-plan.md +8 -3
  89. package/prompts/contexts/gist-authoring.md +6 -20
  90. package/prompts/contexts/objective-authoring.md +6 -18
  91. package/prompts/stages/address/action.md +3 -14
  92. package/prompts/stages/address/preview.md +2 -13
  93. package/prompts/stages/audit.md +18 -0
  94. package/prompts/stages/conflict-resolution.md +1 -1
  95. package/prompts/stages/gist-author/seed.md +4 -4
  96. package/prompts/stages/gist-save.md +1 -1
  97. package/prompts/stages/implement.md +3 -1
  98. package/prompts/stages/learn-code.md +1 -1
  99. package/prompts/stages/learn-docs.md +2 -2
  100. package/prompts/stages/learn-dream.md +10 -0
  101. package/prompts/stages/learn-harvest.md +15 -0
  102. package/prompts/stages/learn-orchestrate.md +1 -1
  103. package/prompts/stages/objective-author/adopt.md +5 -3
  104. package/prompts/stages/objective-author/file.md +5 -4
  105. package/prompts/stages/objective-author/seed.md +5 -4
  106. package/prompts/stages/objective-land.md +7 -0
  107. package/prompts/stages/objective-plan/guidance.md +2 -13
  108. package/prompts/stages/objective-plan/seed.md +5 -12
  109. package/prompts/stages/objective-recover.md +8 -0
  110. package/prompts/stages/objective-replan.md +4 -2
  111. package/prompts/stages/objective-review-browser.md +9 -0
  112. package/prompts/stages/objective-sync.md +6 -0
  113. package/prompts/stages/plan-from/adopt.md +2 -2
  114. package/prompts/stages/plan-from/file.md +2 -2
  115. package/prompts/stages/plan-review-browser.md +9 -0
  116. package/prompts/stages/pr-review-browser/active.md +6 -14
  117. package/prompts/stages/pr-review-browser/foreign.md +6 -14
  118. package/prompts/stages/pr-review-dynamic.md +6 -6
  119. package/prompts/stages/pr-review-terminal/active.md +7 -15
  120. package/prompts/stages/pr-review-terminal/foreign.md +7 -15
  121. package/prompts/stages/pr-review-terminal/local.md +1 -1
  122. package/prompts/stages/pr-review.md +5 -5
  123. package/prompts/stages/replan.md +3 -3
  124. package/shared/README.md +2 -2
  125. package/shared/bindings.yaml +15 -0
  126. package/shared/contracts-history.md +11 -0
  127. package/shared/contracts.md +5373 -281
  128. package/shared/providers.yaml +27 -60
  129. package/shared/registry.yaml +42 -18
  130. package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
  131. package/shared/schemas/outputs/objective-doctor.schema.json +487 -0
  132. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  133. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  134. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  135. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  136. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  137. package/shared/schemas/outputs/pr-land.schema.json +3 -3
  138. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  139. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  140. package/extension/checkpoints/checkpoints.ts +0 -550
  141. package/extension/checkpoints/planSteps.ts +0 -108
  142. package/extension/doors/askUser.ts +0 -198
  143. package/prompts/common/output-schemas/objective-explorer.md +0 -36
  144. package/prompts/common/output-schemas/review-classifier.md +0 -47
  145. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -1,40 +1,41 @@
1
1
  // The perk surfaces module. The one module that owns perk's UI
2
- // vocabulary per the TUI charter (`docs/design/tui-charter.md` §3–§5): the composed `perk` status
3
- // slot + widget slot keys, footer identity marks, the §5 glyph + theming vocabulary, the §4 height
4
- // bounds, the `createPerkStatus` composed-status handle + `setStandingWidget` widget setter, and
5
- // the pure format helpers the standing surfaces render with. The notify seam itself stays in
6
- // `report.ts` (re-exported here so "the surfaces module" is surfaces.ts + report.ts for the
7
- // surfaces guard).
2
+ // vocabulary per the TUI charter (`docs/design/tui-charter.md` §3–§5): the single-value `perk`
3
+ // status slot key, footer identity marks, the §5 glyph + theming vocabulary, the §4 height
4
+ // bounds, the `createPerkStatus` status handle, and the pure format helpers the standing
5
+ // surfaces render with. The notify seam itself stays in `report.ts` (re-exported here so "the
6
+ // surfaces module" is surfaces.ts + report.ts for the surfaces guard).
8
7
  //
9
- // Composed status (charter §6 D2): perk presents ONE footer status under the single
10
- // `perk` slot — the objective + checkpoints segments composed in fixed charter order (objective
11
- // first), joined with two spaces. The per-feature status slots (`perk-checkpoints`,
12
- // `perk-objective`) and the `perk-objective` widget are retired (D8 sanctioned). The perk-owned
13
- // footer (`perkFooter`/`installPerkFooter` below) lifted this composition; the
14
- // composed `perk` status slot keeps publishing — it is the RPC-visible surface (setFooter is an
15
- // RPC no-op). A regression guard enforces this composition.
8
+ // Perk status (charter §6 D2): perk presents ONE footer status under the single
9
+ // `perk` slot — the single-value objective segment (the checkpoint substrate is retired, so
10
+ // there is no composition step). The per-feature status slots and widgets are retired (D8
11
+ // sanctioned). The perk-owned footer (`perkFooter`/`installPerkFooter` below) renders the value
12
+ // directly; the `perk` status slot keeps publishing — it is the RPC-visible surface (setFooter
13
+ // is an RPC no-op).
16
14
 
17
15
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
16
+ import type { ReportDetailSink } from "./report.ts";
18
17
 
19
18
  // `Key` is keybinding vocabulary (`pi.registerShortcut(Key.ctrlAlt("p"), …)`), not rich UI —
20
19
  // re-exported so pi-tui imports stay structurally confined to the surfaces module (the
21
20
  // surfacesGuard pi-tui import rule) without allowlisting the shortcut-registering modules.
22
21
  export { Key } from "@earendil-works/pi-tui";
23
22
  // Re-exports: the notify seam stays in report.ts; surfaces.ts is the one import for UI vocabulary.
24
- export { type ReportTarget, report, type Severity } from "./report.ts";
23
+ export {
24
+ attachReportDetailSink,
25
+ type ReportDetailSink,
26
+ type ReportTarget,
27
+ report,
28
+ type Severity,
29
+ } from "./report.ts";
25
30
 
26
31
  // --- standing-surface slot keys (charter §2) ---
27
- // The ONE composed status slot (D2): all perk status segments render under this key.
32
+ // The ONE perk status slot (D2): perk's single status value renders under this key.
28
33
  export const STATUS_SLOT_PERK = "perk";
29
- // NOTE: same string as providers.ts PERK_CHECKPOINTS_PROVIDER_ID but a different concept
30
- // (UI slot vs provider id) — deliberately NOT merged.
31
- export const WIDGET_SLOT_CHECKPOINTS = "perk-checkpoints";
32
34
 
33
35
  // --- footer identity marks (charter §5 / D3: emoji are footer-only identity, 2 cells wide) ---
34
- export const MARK_CHECKPOINTS = "📋";
35
36
  export const MARK_OBJECTIVE = "🎯";
36
37
 
37
- // --- glyph vocabulary (charter §5 / D3) — data only; themed rendering lives in the widget/footer builders below ---
38
+ // --- glyph vocabulary (charter §5 / D3) — charter-law data, pinned by tests ---
38
39
  export type GlyphKind = "done" | "current" | "pending" | "warning" | "failure";
39
40
  export const GLYPHS: Record<GlyphKind, { glyph: string; themeColor: string }> = {
40
41
  done: { glyph: "✓", themeColor: "success" },
@@ -44,10 +45,9 @@ export const GLYPHS: Record<GlyphKind, { glyph: string; themeColor: string }> =
44
45
  failure: { glyph: "✗", themeColor: "error" },
45
46
  };
46
47
 
47
- // --- height bounds (charter §4 / D1/D8) — enforced by the report() notify budget and the footer/widget builders ---
48
+ // --- height bounds (charter §4 / D1/D8) — enforced by the report() notify budget and the footer builder ---
48
49
  export const NOTIFY_MAX_LINES = 1;
49
50
  export const FOOTER_MAX_LINES = 1;
50
- export const CHECKPOINTS_WIDGET_MAX_LINES = 4;
51
51
 
52
52
  // --- the standing-surface setter -----------------------------------------------------------------
53
53
 
@@ -61,48 +61,18 @@ export interface ThemeLike {
61
61
  }
62
62
 
63
63
  /**
64
- * A standing-widget component factory in pi's `setWidget` factory shape: invoked with the live
65
- * `(tui, theme)`, returns a component whose `render(width)` computes themed lines **per call**
66
- * (the D10 stateless-render pattern never cache themed strings). NOTE: pi's RPC mode drops
67
- * factory widgets (only string[] forwards) — an accepted trade-off recorded in contracts.md.
68
- */
69
- export type StandingWidgetFactory = (
70
- tui: unknown,
71
- theme: ThemeLike,
72
- ) => { render(width: number): string[]; invalidate(): void };
73
-
74
- /**
75
- * The minimal headless-aware surface the standing setters need. `ExtensionContext` satisfies it;
76
- * tests fake it (the same minimal-structural-interface recipe as report.ts's `ReportTarget` — see
64
+ * The minimal headless-aware surface the status handle needs — the `perk` status slot is the one
65
+ * standing surface it serves. `ExtensionContext` satisfies it; tests fake it (the same
66
+ * minimal-structural-interface recipe as report.ts's `ReportTarget` see
77
67
  * `docs/learned/pi/extension-seams.md`).
78
68
  */
79
69
  export interface StandingTarget {
80
70
  hasUI: boolean;
81
71
  ui: {
82
72
  setStatus(slot: string, value: string | undefined): void;
83
- setWidget(
84
- slot: string,
85
- value: string[] | StandingWidgetFactory | undefined,
86
- options?: { placement?: "aboveEditor" | "belowEditor" },
87
- ): void;
88
73
  };
89
74
  }
90
75
 
91
- /** Set (or clear, with undefined) a standing widget slot; no-op headless. */
92
- export function setStandingWidget(
93
- target: StandingTarget,
94
- slot: string,
95
- widget: string[] | StandingWidgetFactory | undefined,
96
- options?: { placement?: "aboveEditor" | "belowEditor" },
97
- ): void {
98
- if (!target.hasUI) return;
99
- if (options?.placement) {
100
- target.ui.setWidget(slot, widget, { placement: options.placement });
101
- } else {
102
- target.ui.setWidget(slot, widget);
103
- }
104
- }
105
-
106
76
  // --- the working-message seam (vendored `whimsical`; charter §6 permitted text-only) ------------
107
77
 
108
78
  /**
@@ -128,61 +98,44 @@ export function setWorkingMessage(target: WorkingMessageTarget, message?: string
128
98
  target.ui.setWorkingMessage(message);
129
99
  }
130
100
 
131
- // --- the composed perk status (charter D2) --------------------------------------------
132
-
133
- /** A named segment of the composed `perk` status. Order is fixed by `PERK_SEGMENT_ORDER` (D2). */
134
- export type PerkSegmentKey = "objective" | "checkpoints";
135
-
136
- /** The charter D2 segment order: objective first, checkpoints second — never insertion order. */
137
- export const PERK_SEGMENT_ORDER = ["objective", "checkpoints"] as const;
138
-
139
- /** The two-space segment separator: `🎯 251 · 12.3k tok · 5m 📋 3/7 · ▸4`. */
140
- const PERK_SEGMENT_SEPARATOR = " ";
101
+ // --- the perk status (charter D2) --------------------------------------------
141
102
 
142
103
  /**
143
- * The composed-status handle: each controller publishes its segment text through `set`, and the
144
- * handle recomposes + republishes the single `perk` status slot. The footer reads
145
- * segments back via `get` and repaints via `subscribe` — the slot's `setStatus` dual-publish is
146
- * deliberate (RPC clients see the slot; setFooter is an RPC no-op).
104
+ * The single-value status handle: the objective publisher sets the one value through `set`, and
105
+ * the handle republishes the single `perk` status slot. The footer reads the value back via `get`
106
+ * and repaints via `subscribe` — the slot's `setStatus` dual-publish is deliberate (RPC clients
107
+ * see the slot; setFooter is an RPC no-op).
147
108
  */
148
109
  export interface PerkStatusHandle {
149
- /** Set (or clear, with undefined) one segment; recomposes the slot. No-op headless. */
150
- set(target: StandingTarget, segment: PerkSegmentKey, text: string | undefined): void;
151
- /** The current text of one segment (undefined when unset). */
152
- get(segment: PerkSegmentKey): string | undefined;
110
+ /** Set (or clear, with undefined) the one value; publishes the slot. No-op headless. */
111
+ set(target: StandingTarget, text: string | undefined): void;
112
+ /** The current text (undefined when unset). */
113
+ get(): string | undefined;
153
114
  /**
154
- * Subscribe to recompositions: the listener fires after every headful `set` (headless `set`
115
+ * Subscribe to publishes: the listener fires after every headful `set` (headless `set`
155
116
  * calls are full no-ops, so nothing fires). Returns an unsubscribe.
156
117
  */
157
118
  subscribe(listener: () => void): () => void;
158
119
  }
159
120
 
160
121
  /**
161
- * Create the composed `perk` status handle (one per extension instance — created in index.ts and
162
- * passed to the controllers; no hidden module state). Headless calls are full no-ops (never touch
163
- * the segment map, so headless-era text can't resurrect in a later headful render). The composed
164
- * line is the ordered present segments joined with two spaces; an empty composition clears the
165
- * slot. No width handling: pi's footer truncates the joined status line itself.
122
+ * Create the single-value `perk` status handle (one per extension instance — created in index.ts
123
+ * and passed to the objective publisher; no hidden module state). Headless calls are full no-ops
124
+ * (never record the text, so headless-era text can't resurrect in a later headful render).
125
+ * `undefined` clears the slot. No width handling: pi's footer truncates the status line itself.
166
126
  */
167
127
  export function createPerkStatus(): PerkStatusHandle {
168
- const segments = new Map<PerkSegmentKey, string>();
128
+ let value: string | undefined;
169
129
  const listeners = new Set<() => void>();
170
130
  return {
171
- set(target, segment, text) {
131
+ set(target, text) {
172
132
  if (!target.hasUI) return;
173
- if (text === undefined) {
174
- segments.delete(segment);
175
- } else {
176
- segments.set(segment, text);
177
- }
178
- const composed = PERK_SEGMENT_ORDER.filter((key) => segments.has(key))
179
- .map((key) => segments.get(key) as string)
180
- .join(PERK_SEGMENT_SEPARATOR);
181
- target.ui.setStatus(STATUS_SLOT_PERK, composed === "" ? undefined : composed);
133
+ value = text;
134
+ target.ui.setStatus(STATUS_SLOT_PERK, text);
182
135
  for (const listener of listeners) listener();
183
136
  },
184
- get(segment) {
185
- return segments.get(segment);
137
+ get() {
138
+ return value;
186
139
  },
187
140
  subscribe(listener) {
188
141
  listeners.add(listener);
@@ -194,18 +147,16 @@ export function createPerkStatus(): PerkStatusHandle {
194
147
  // --- the perk-owned footer (charter D2) -----------------------------------------------
195
148
 
196
149
  /**
197
- * The raw material for one composed footer line. Left group (charter order 1–3): `identity`,
198
- * `objective`, `checkpoints` — the segments render verbatim (they carry their own 🎯/📋 marks).
150
+ * The raw material for one composed footer line. Left group (charter order 1–2): `identity`,
151
+ * `objective` — the segment renders verbatim (it carries its own 🎯 mark).
199
152
  * Right group (charter order 4, 5, +context, 6): `branch`, `model`, `thinking`, `cache`,
200
153
  * `context`, `guests` — right-aligned, non-segment system text dim-themed.
201
154
  */
202
155
  export interface FooterParts {
203
156
  /** e.g. `perk v0.0.1` — standing identity (D7), dim. */
204
157
  identity: string;
205
- /** The 🎯 objective segment, verbatim (`handle.get("objective")`). */
158
+ /** The 🎯 objective segment, verbatim (`handle.get()`). */
206
159
  objective?: string;
207
- /** The 📋 checkpoints segment, verbatim (`handle.get("checkpoints")`). */
208
- checkpoints?: string;
209
160
  /** Git branch (dim); omitted when not in a repo. */
210
161
  branch?: string;
211
162
  /** Model id (dim); omitted when no model. */
@@ -231,28 +182,45 @@ export interface UsageEntryLike {
231
182
  role?: string;
232
183
  usage?: { input: number; cacheRead: number; cacheWrite: number };
233
184
  };
185
+ /** Entry-level usage on `branch_summary`/`compaction` entries (pi 0.81.0 usage accounting). */
186
+ usage?: { input: number; cacheRead: number; cacheWrite: number };
234
187
  }
235
188
 
236
189
  /**
237
190
  * 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`).
191
+ * an exact local mirror of pi 0.84.1's default-footer `CH` computation (pi's cache-stats helpers
192
+ * are unexported; the `sanitizeGuestStatus` reimplementation precedent). Includes pi's display
193
+ * gate: returns `null` unless the session shows cache activity total cacheRead or cacheWrite
194
+ * > 0 summed over assistant messages, `toolResult` messages carrying `usage`, and
195
+ * `branch_summary`/`compaction` entries' entry-level `usage` AND the latest usage-bearing
196
+ * assistant message has prompt tokens > 0 (a trailing zero-prompt-token assistant message resets
197
+ * the rate, exactly like pi's `undefined`). The CH VALUE always comes from the latest
198
+ * usage-bearing assistant message; the non-assistant entries only widen the activity gate.
243
199
  */
244
200
  export function latestCacheHitRate(entries: readonly UsageEntryLike[]): number | null {
245
201
  let totalCacheRead = 0;
246
202
  let totalCacheWrite = 0;
247
203
  let latest: number | null = null;
248
204
  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;
205
+ if (entry.type === "message" && entry.message?.role === "assistant") {
206
+ const usage = entry.message.usage;
207
+ if (usage === undefined) continue;
208
+ totalCacheRead += usage.cacheRead;
209
+ totalCacheWrite += usage.cacheWrite;
210
+ const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
211
+ latest = promptTokens > 0 ? (usage.cacheRead / promptTokens) * 100 : null;
212
+ } else if (entry.type === "message" && entry.message?.role === "toolResult") {
213
+ const usage = entry.message.usage;
214
+ if (usage === undefined) continue;
215
+ totalCacheRead += usage.cacheRead;
216
+ totalCacheWrite += usage.cacheWrite;
217
+ } else if (
218
+ (entry.type === "branch_summary" || entry.type === "compaction") &&
219
+ entry.usage !== undefined
220
+ ) {
221
+ totalCacheRead += entry.usage.cacheRead;
222
+ totalCacheWrite += entry.usage.cacheWrite;
223
+ }
256
224
  }
257
225
  if (totalCacheRead <= 0 && totalCacheWrite <= 0) return null;
258
226
  return latest;
@@ -279,11 +247,11 @@ function formatContextSegment(
279
247
  }
280
248
 
281
249
  /**
282
- * Compose THE one footer line (FOOTER_MAX_LINES = 1): left group = identity + objective +
283
- * checkpoints (two-space-joined, charter order); right group = branch + model + context + guests
250
+ * Compose THE one footer line (FOOTER_MAX_LINES = 1): left group = identity + objective
251
+ * (two-space-joined, charter order); right group = branch + model + context + guests
284
252
  * (two-space-joined), right-aligned with ≥2 spaces of padding. When the line exceeds `width`,
285
253
  * whole segments drop in the extended D9 order — guests (rightmost-first) → thinking → model →
286
- * branch → cache → context → checkpoints; `identity` and `objective` are NEVER dropped — then
254
+ * branch → cache → context; `identity` and `objective` are NEVER dropped — then
287
255
  * `truncateToWidth` as the last resort (ANSI- and 2-cell-emoji-aware).
288
256
  */
289
257
  export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: number): string {
@@ -294,12 +262,10 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
294
262
  branch: true,
295
263
  cache: true,
296
264
  context: true,
297
- checkpoints: true,
298
265
  };
299
266
  const compose = (): string => {
300
267
  const left = [theme.fg("dim", parts.identity)];
301
268
  if (parts.objective !== undefined) left.push(parts.objective);
302
- if (keep.checkpoints && parts.checkpoints !== undefined) left.push(parts.checkpoints);
303
269
  const right: string[] = [];
304
270
  if (keep.branch && parts.branch !== undefined) right.push(theme.fg("dim", parts.branch));
305
271
  if (keep.model && parts.model !== undefined) right.push(theme.fg("dim", parts.model));
@@ -309,9 +275,9 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
309
275
  right.push(formatContextSegment(parts.context, theme));
310
276
  }
311
277
  for (const guest of keep.guests) right.push(theme.fg("dim", guest));
312
- const leftText = left.join(PERK_SEGMENT_SEPARATOR);
278
+ const leftText = left.join(" ");
313
279
  if (right.length === 0) return leftText;
314
- const rightText = right.join(PERK_SEGMENT_SEPARATOR);
280
+ const rightText = right.join(" ");
315
281
  const padding = Math.max(2, width - visibleWidth(leftText) - visibleWidth(rightText));
316
282
  return `${leftText}${" ".repeat(padding)}${rightText}`;
317
283
  };
@@ -323,7 +289,6 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
323
289
  else if (keep.branch) keep.branch = false;
324
290
  else if (keep.cache) keep.cache = false;
325
291
  else if (keep.context) keep.context = false;
326
- else if (keep.checkpoints) keep.checkpoints = false;
327
292
  else break; // identity + objective only — nothing left to drop
328
293
  line = compose();
329
294
  }
@@ -364,10 +329,16 @@ export type PerkFooterFactory = (
364
329
  /**
365
330
  * The perk-owned footer factory (charter D2): replaces pi's default footer wholesale with one
366
331
  * line in the intended split layout. `render` gathers everything live per call (D10 stateless
367
- * render): segments via the handle, branch/guests via `footerData` (excluding perk's own
368
- * `STATUS_SLOT_PERK` — the slot keeps publishing for RPC, but the footer renders the segments
332
+ * render): the objective value via the handle, branch/guests via `footerData` (excluding perk's
333
+ * own `STATUS_SLOT_PERK` — the slot keeps publishing for RPC, but the footer renders the value
369
334
  * directly), model/cache/context via the deps closures. Reactivity (the D2 contract): repaints on
370
- * every handle recompose and on branch change; `dispose` detaches both.
335
+ * every handle recompose and on branch change; `dispose` detaches both. Lifecycle (pi ≥ 0.84,
336
+ * verified at 0.84.1): `setExtensionFooter` disposes a replaced factory's component, and pi's
337
+ * `resetExtensionUI` restores the built-in footer (disposing this one) on /reload and before
338
+ * session replacement — so installing a fresh factory per headful `session_start` leaks nothing.
339
+ *
340
+ * Two DELIBERATE divergences from pi's default footer remain: no `(auto)` context suffix
341
+ * (auto-compact state is not extension-readable) and no cost/`(sub)` segment (charter scope).
371
342
  */
372
343
  export function perkFooter(deps: PerkFooterDeps): PerkFooterFactory {
373
344
  return (tui, theme, footerData) => {
@@ -382,8 +353,7 @@ export function perkFooter(deps: PerkFooterDeps): PerkFooterFactory {
382
353
  const rate = deps.getCacheHitRate();
383
354
  const parts: FooterParts = {
384
355
  identity: deps.identity,
385
- objective: deps.status.get("objective"),
386
- checkpoints: deps.status.get("checkpoints"),
356
+ objective: deps.status.get(),
387
357
  branch: footerData.getGitBranch() ?? undefined,
388
358
  model: deps.getModelId() ?? undefined,
389
359
  thinking: deps.getThinkingLevel() ?? undefined,
@@ -404,7 +374,10 @@ export function perkFooter(deps: PerkFooterDeps): PerkFooterFactory {
404
374
  };
405
375
  }
406
376
 
407
- /** Install the perk-owned footer (sole-owner law, D2); no-op headless. */
377
+ /**
378
+ * Install the perk-owned footer (sole-owner law, D2); no-op headless. Safe to call on every
379
+ * headful `session_start`: pi ≥ 0.84 disposes the replaced factory (see `perkFooter`).
380
+ */
408
381
  export function installPerkFooter(
409
382
  target: { hasUI: boolean; ui: { setFooter(factory: PerkFooterFactory | undefined): void } },
410
383
  deps: PerkFooterDeps,
@@ -413,107 +386,15 @@ export function installPerkFooter(
413
386
  target.ui.setFooter(perkFooter(deps));
414
387
  }
415
388
 
416
- // --- format helpers (relocated from checkpoints.ts / objective.ts, verbatim) --------------------
417
- // Structural parameter types (not CheckpointState/CheckpointStep imports) keep surfaces.ts
418
- // dependency-free and avoid an import cycle with the surface controllers.
419
-
420
- export interface ProgressStep {
421
- step: number;
422
- text: string;
423
- completed: boolean;
424
- }
425
-
426
- export interface ProgressState {
427
- steps: ProgressStep[];
428
- /** The in-progress step number, or `null`. */
429
- current: number | null;
430
- }
431
-
432
- /** The `done/total` checkpoint progress summary (with `· ▸n` when a step is current). */
433
- export function progressLine(state: ProgressState): string {
434
- const done = state.steps.filter((s) => s.completed).length;
435
- const base = `${done}/${state.steps.length}`;
436
- return state.current != null ? `${base} · ▸${state.current}` : base;
437
- }
438
-
439
- /** The `GLYPHS` kind for a step: done if completed, current if it IS the current step, else pending. */
440
- export function stepGlyphKind(state: ProgressState, s: ProgressStep): GlyphKind {
441
- if (s.completed) return "done";
442
- if (s.step === state.current) return "current";
443
- return "pending";
444
- }
445
-
446
- /** A windowed progress item: a visible step, or an elision marker for the hidden steps. */
447
- export type ProgressWindowItem =
448
- | { kind: "step"; step: ProgressStep }
449
- | { kind: "elision"; hidden: number; side: "earlier" | "later" };
450
-
451
- /**
452
- * The D1 sliding window: at most `cap` step items (elision markers extra). For `n ≤ cap` all
453
- * steps show with no markers. Otherwise the window anchors on the current step (`current == null`
454
- * ⟹ all complete ⟹ anchor at the end) sitting second when possible — one earlier step above,
455
- * the rest below — with `… +N earlier` / `… +N later` markers for the hidden steps.
456
- */
457
- export function windowProgress(state: ProgressState, cap: number): ProgressWindowItem[] {
458
- const n = state.steps.length;
459
- if (n <= cap) return state.steps.map((step) => ({ kind: "step", step }));
460
- const anchorIdx =
461
- state.current != null
462
- ? Math.max(
463
- state.steps.findIndex((s) => s.step === state.current),
464
- 0,
465
- )
466
- : n - 1;
467
- const start = Math.min(Math.max(anchorIdx - 1, 0), n - cap);
468
- const items: ProgressWindowItem[] = [];
469
- if (start > 0) items.push({ kind: "elision", hidden: start, side: "earlier" });
470
- for (const step of state.steps.slice(start, start + cap)) items.push({ kind: "step", step });
471
- const later = n - start - cap;
472
- if (later > 0) items.push({ kind: "elision", hidden: later, side: "later" });
473
- return items;
474
- }
475
-
476
- /**
477
- * The themed checkpoints-widget lines (charter D1/D3/D9/D10): the `windowProgress` window mapped
478
- * to `✓/▸/○ <n>. <text>` lines colored per the §5 table (completed text muted, elision markers
479
- * dim), every line width-truncated via pi-tui's `truncateToWidth` (ANSI- and wide-glyph-aware).
480
- * Pure per call — call it inside a component's `render()` so theming stays live (D10).
481
- */
482
- export function renderProgressLines(
483
- state: ProgressState,
484
- theme: ThemeLike,
485
- width: number,
486
- ): string[] {
487
- return windowProgress(state, CHECKPOINTS_WIDGET_MAX_LINES).map((item) => {
488
- if (item.kind === "elision") {
489
- return truncateToWidth(theme.fg("dim", `… +${item.hidden} ${item.side}`), width);
490
- }
491
- return renderStepLine(state, item.step, theme, width);
492
- });
493
- }
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
- }
389
+ // --- format helpers --------------------------------------------------------------------------
512
390
 
391
+ /** Pi 0.84.1's default-footer `formatTokens` tiers, mirrored exactly (`200000` → `200k`). */
513
392
  function formatTokens(tokens: number): string {
514
393
  if (tokens < 1000) return `${tokens}`;
515
- // Whole-k values render bare (`200k`, matching pi's footer); fractional keep one decimal.
516
- return `${(tokens / 1000).toFixed(1).replace(/\.0$/, "")}k`;
394
+ if (tokens < 10_000) return `${(tokens / 1000).toFixed(1)}k`;
395
+ if (tokens < 1_000_000) return `${Math.round(tokens / 1000)}k`;
396
+ if (tokens < 10_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
397
+ return `${Math.round(tokens / 1_000_000)}M`;
517
398
  }
518
399
 
519
400
  function formatElapsed(ms: number): string {
@@ -525,17 +406,31 @@ function formatElapsed(ms: number): string {
525
406
  return `${hr}h${min % 60}m`;
526
407
  }
527
408
 
528
- /** A compact one-line budget summary (e.g. `12.3k tok · 5m`). */
409
+ /** A compact one-line budget summary (e.g. `12k tok · 5m`, `1.2M tok · 2h5m` past 1M). */
529
410
  export function formatBudgetLine(args: { tokens: number; elapsedMs: number }): string {
530
411
  return `${formatTokens(args.tokens)} tok · ${formatElapsed(args.elapsedMs)}`;
531
412
  }
532
413
 
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.
414
+ // --- display-only transcript entry renderers -----------------------------------------------------
415
+ // The audit §2.3 verdict (docs/design/pi-adoption-audit.md): perk's custom-entry families are
416
+ // display-only. Most render as durable one-line transition markers; report detail is the generic
417
+ // full-diagnostic family and always renders every logical row. Renderer BODIES live here (a
418
+ // transcript renderer IS a rich-UI surface the surfaces module owns); registration is wiring at the
419
+ // feature modules via the `registerTranscriptRenderer` seam below. Renderers are an
420
+ // interactive-TUI-only concern (never invoked in json/RPC mode), so registration is inert-safe
421
+ // everywhere.
422
+
423
+ /** The generic display-only transcript entry carrying complete multiline warm-command reports. */
424
+ export const REPORT_DETAIL_TYPE = "perk:report-detail";
425
+
426
+ export interface ReportDetailEntryHost {
427
+ appendEntry(customType: string, data?: unknown): void;
428
+ }
429
+
430
+ /** Create the sink attached to each warm-command context by `registerPerkCommand`. */
431
+ export function createReportDetailSink(host: ReportDetailEntryHost): ReportDetailSink {
432
+ return (text, severity) => host.appendEntry(REPORT_DETAIL_TYPE, { text, severity });
433
+ }
539
434
 
540
435
  /** Structural slice of pi's `CustomEntry` — the only field the marker renderers read. */
541
436
  export interface TranscriptEntryLike {
@@ -585,15 +480,13 @@ export function registerTranscriptRenderer(
585
480
 
586
481
  /**
587
482
  * 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).
483
+ * human-requested scrollback and renders its full detail unbounded (the whole btw answer).
590
484
  */
591
485
  export const TRANSCRIPT_MARKER_MAX_LINES = 1;
592
486
 
593
487
  /**
594
488
  * 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.
489
+ * dim, D9-truncated. Emoji stay footer-only (D3).
597
490
  */
598
491
  function markerLine(scope: string, message: string, theme: ThemeLike, width: number): string {
599
492
  return truncateToWidth(theme.fg("dim", `perk: ${scope} — ${message}`), width);
@@ -605,44 +498,103 @@ function asRecord(value: unknown): Record<string, unknown> | null {
605
498
  return value as Record<string, unknown>;
606
499
  }
607
500
 
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);
501
+ function asPlainRecord(value: unknown): Record<string, unknown> | null {
502
+ const record = asRecord(value);
611
503
  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;
504
+ const prototype = Object.getPrototypeOf(record);
505
+ if (prototype !== Object.prototype && prototype !== null) return null;
506
+ return record;
507
+ }
508
+
509
+ function skipControlString(text: string, start: number, osc: boolean): number {
510
+ let index = start;
511
+ while (index < text.length) {
512
+ const code = text.charCodeAt(index);
513
+ if ((osc && code === 0x07) || code === 0x9c) return index + 1;
514
+ if (code === 0x1b && text.charCodeAt(index + 1) === 0x5c) return index + 2;
515
+ index += 1;
516
+ }
517
+ return index;
518
+ }
519
+
520
+ function skipControlSequence(text: string, start: number): number {
521
+ let index = start;
522
+ while (index < text.length) {
523
+ const code = text.charCodeAt(index);
524
+ index += 1;
525
+ if (code >= 0x40 && code <= 0x7e) break;
526
+ }
527
+ return index;
528
+ }
529
+
530
+ /** Strip terminal controls from the display projection; the persisted report text stays exact. */
531
+ function stripTerminalControls(text: string): string {
532
+ let clean = "";
533
+ let index = 0;
534
+ while (index < text.length) {
535
+ const code = text.charCodeAt(index);
536
+ if (code === 0x1b) {
537
+ const next = text.charCodeAt(index + 1);
538
+ if (next === 0x5b) {
539
+ index = skipControlSequence(text, index + 2);
540
+ } else if (next === 0x5d) {
541
+ index = skipControlString(text, index + 2, true);
542
+ } else if (next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) {
543
+ index = skipControlString(text, index + 2, false);
544
+ } else {
545
+ index += 1;
546
+ while (index < text.length) {
547
+ const part = text.charCodeAt(index);
548
+ if (part < 0x20 || part > 0x2f) break;
549
+ index += 1;
550
+ }
551
+ const final = text.charCodeAt(index);
552
+ if (final >= 0x30 && final <= 0x7e) index += 1;
553
+ }
554
+ continue;
555
+ }
556
+ if (code === 0x9b) {
557
+ index = skipControlSequence(text, index + 1);
558
+ continue;
559
+ }
560
+ if (code === 0x9d) {
561
+ index = skipControlString(text, index + 1, true);
562
+ continue;
624
563
  }
625
- decoded.push({ step: step.step, text: step.text, completed: step.completed });
564
+ if (code === 0x90 || code === 0x98 || code === 0x9e || code === 0x9f) {
565
+ index = skipControlString(text, index + 1, false);
566
+ continue;
567
+ }
568
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) {
569
+ index += 1;
570
+ continue;
571
+ }
572
+ clean += text[index];
573
+ index += 1;
626
574
  }
627
- return decoded;
575
+ return clean;
628
576
  }
629
577
 
630
578
  /**
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.
579
+ * `perk:report-detail` full diagnostic renderer. Unlike collapsed transition markers, report detail
580
+ * always renders every logical row; the expanded flag does not alter it. The first row carries the
581
+ * live severity color and continuation rows are dim. Blank rows remain blank.
636
582
  */
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 };
583
+ export const reportDetailEntryRenderer: TranscriptRenderer = (entry, _options, theme) => {
584
+ const data = asPlainRecord(entry.data);
585
+ if (data === null) return undefined;
586
+ const text = data.text;
587
+ const severity = data.severity;
588
+ if (typeof text !== "string" || text.trim().length === 0) return undefined;
589
+ if (severity !== "info" && severity !== "warning" && severity !== "error") return undefined;
641
590
  return {
642
591
  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))];
592
+ return text.split(/\r\n|\n|\r/).map((line, index) => {
593
+ const safeLine = stripTerminalControls(line);
594
+ if (safeLine.length === 0) return "";
595
+ const color = index === 0 && severity !== "info" ? severity : "dim";
596
+ return truncateToWidth(theme.fg(color, safeLine), width);
597
+ });
646
598
  },
647
599
  };
648
600
  };
@@ -769,17 +721,3 @@ export const btwThreadResetEntryRenderer: TranscriptRenderer = (entry, options,
769
721
  },
770
722
  };
771
723
  };
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
- }