@mgiles/perk 2.3.0 → 3.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 (128) hide show
  1. package/README.md +1 -1
  2. package/extension/adapters/planAdapterPlannotator.ts +132 -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/draftReviewWaveTools.ts +384 -0
  9. package/extension/doors/harvestWaveTools.ts +279 -0
  10. package/extension/doors/land.ts +1 -0
  11. package/extension/doors/learn.ts +24 -6
  12. package/extension/doors/learnFactory.ts +33 -7
  13. package/extension/doors/lifecycleGates.ts +1 -1
  14. package/extension/doors/objectiveReviewBrowser.ts +514 -0
  15. package/extension/doors/objectiveStack.ts +1143 -0
  16. package/extension/doors/planReviewBrowser.ts +453 -0
  17. package/extension/doors/plannotatorHandoff.ts +120 -49
  18. package/extension/doors/prReview.ts +26 -16
  19. package/extension/doors/prReviewBrowser.ts +31 -23
  20. package/extension/doors/prReviewDynamic.ts +25 -18
  21. package/extension/doors/prReviewTerminal.ts +6 -11
  22. package/extension/doors/reviewWaveTools.ts +374 -0
  23. package/extension/doors/selfcheck.ts +1 -1
  24. package/extension/doors/submit.ts +82 -7
  25. package/extension/factories/gistAuthor.ts +4 -4
  26. package/extension/factories/gistDraft.ts +1 -1
  27. package/extension/factories/gistSave.ts +2 -2
  28. package/extension/factories/objective.ts +7 -7
  29. package/extension/factories/objectiveAuthor.ts +5 -4
  30. package/extension/factories/objectiveDraft.ts +61 -6
  31. package/extension/factories/objectivePlan.ts +163 -9
  32. package/extension/factories/objectiveSave.ts +16 -1
  33. package/extension/factories/planMode.ts +5 -3
  34. package/extension/factories/planReview.ts +131 -36
  35. package/extension/factories/planTitle.ts +27 -9
  36. package/extension/hunkFeedback/inbox.ts +389 -0
  37. package/extension/hunkFeedback/perkFeedback.ts +364 -0
  38. package/extension/hunkFeedback/receiver.ts +296 -0
  39. package/extension/hunkFeedback/store.ts +533 -0
  40. package/extension/index.ts +109 -44
  41. package/extension/substrate/cache.ts +75 -22
  42. package/extension/substrate/coldDoor.ts +2 -3
  43. package/extension/substrate/config.ts +36 -12
  44. package/extension/substrate/git.ts +10 -0
  45. package/extension/substrate/providers.ts +4 -33
  46. package/extension/substrate/resources.ts +11 -0
  47. package/extension/substrate/result.ts +13 -5
  48. package/extension/substrate/sessionData.ts +3 -3
  49. package/extension/substrate/sessionPointers.ts +3 -3
  50. package/extension/substrate/structuredOutput.ts +78 -19
  51. package/extension/substrate/toolGating.ts +170 -23
  52. package/extension/substrate/workflowState.ts +6 -0
  53. package/extension/surfaces/footerProvider.ts +6 -6
  54. package/extension/surfaces/surfaces.ts +98 -282
  55. package/extension/vendor/btw/btw.ts +57 -12
  56. package/extension/waves/adversarialReviewWave.ts +164 -0
  57. package/extension/waves/auditWave.ts +312 -0
  58. package/extension/waves/draftReviewWave.ts +189 -0
  59. package/extension/waves/harvestWave.ts +399 -0
  60. package/extension/waves/memoryAdapter.ts +14 -1
  61. package/extension/waves/objectiveExplorerWave.ts +120 -0
  62. package/extension/waves/prReviewDynamicWave.ts +291 -61
  63. package/extension/waves/prReviewWave.ts +58 -13
  64. package/extension/waves/reportWave.ts +406 -106
  65. package/extension/waves/reviewClassifierWave.ts +109 -0
  66. package/extension/waves/rpcAdapter.ts +65 -2
  67. package/extension/worker/readOnlySession.ts +3 -3
  68. package/extension/worker/worker.ts +71 -93
  69. package/extension/workerMain.ts +4 -6
  70. package/package.json +15 -5
  71. package/prompts/README.md +19 -0
  72. package/prompts/_fixtures/live.yaml +66 -65
  73. package/prompts/common/resume-advisory.md +1 -0
  74. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  75. package/prompts/contexts/adapters/plannotator-objective.md +1 -2
  76. package/prompts/contexts/adapters/plannotator-plan.md +1 -2
  77. package/prompts/contexts/gist-authoring.md +6 -20
  78. package/prompts/contexts/objective-authoring.md +6 -18
  79. package/prompts/stages/address/action.md +3 -14
  80. package/prompts/stages/address/preview.md +2 -13
  81. package/prompts/stages/audit.md +18 -0
  82. package/prompts/stages/gist-author/seed.md +4 -4
  83. package/prompts/stages/gist-save.md +1 -1
  84. package/prompts/stages/implement.md +3 -1
  85. package/prompts/stages/learn-code.md +1 -1
  86. package/prompts/stages/learn-docs.md +2 -2
  87. package/prompts/stages/learn-harvest.md +15 -0
  88. package/prompts/stages/learn-orchestrate.md +1 -1
  89. package/prompts/stages/objective-author/adopt.md +5 -3
  90. package/prompts/stages/objective-author/file.md +5 -4
  91. package/prompts/stages/objective-author/seed.md +5 -4
  92. package/prompts/stages/objective-land.md +7 -0
  93. package/prompts/stages/objective-plan/guidance.md +2 -13
  94. package/prompts/stages/objective-plan/seed.md +5 -12
  95. package/prompts/stages/objective-recover.md +8 -0
  96. package/prompts/stages/objective-replan.md +4 -2
  97. package/prompts/stages/objective-review-browser.md +9 -0
  98. package/prompts/stages/objective-sync.md +6 -0
  99. package/prompts/stages/plan-from/adopt.md +2 -2
  100. package/prompts/stages/plan-from/file.md +2 -2
  101. package/prompts/stages/plan-review-browser.md +9 -0
  102. package/prompts/stages/pr-review-browser/active.md +6 -14
  103. package/prompts/stages/pr-review-browser/foreign.md +6 -14
  104. package/prompts/stages/pr-review-dynamic.md +3 -3
  105. package/prompts/stages/pr-review-terminal/active.md +7 -15
  106. package/prompts/stages/pr-review-terminal/foreign.md +7 -15
  107. package/prompts/stages/pr-review.md +2 -2
  108. package/prompts/stages/replan.md +3 -3
  109. package/shared/README.md +2 -2
  110. package/shared/bindings.yaml +12 -0
  111. package/shared/contracts-history.md +11 -0
  112. package/shared/contracts.md +3652 -236
  113. package/shared/providers.yaml +27 -60
  114. package/shared/registry.yaml +32 -8
  115. package/shared/schemas/outputs/objective-doctor.schema.json +449 -0
  116. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  117. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  118. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  119. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  120. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  121. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  122. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  123. package/extension/checkpoints/checkpoints.ts +0 -550
  124. package/extension/checkpoints/planSteps.ts +0 -108
  125. package/extension/doors/askUser.ts +0 -198
  126. package/prompts/common/output-schemas/objective-explorer.md +0 -36
  127. package/prompts/common/output-schemas/review-classifier.md +0 -47
  128. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -33,6 +33,12 @@ export interface WorkflowState {
33
33
  mode?: string;
34
34
  /** The registry stage id this run is acting on (recorded at cold claim from the handoff). */
35
35
  stage?: string;
36
+ /**
37
+ * The running @mgiles/perk version stamped when run identity is established (§8.3) —
38
+ * claim/fork/adopt/mint in session_start. The session-audit vintage layer's exact basis;
39
+ * omitted when only the perkVersion() failure sentinel is available. Best-effort tier.
40
+ */
41
+ perk_version?: string;
36
42
  predecessor?: string;
37
43
  active_plan_ref?: PlanRef | null;
38
44
  active_objective?: string | null;
@@ -1,16 +1,16 @@
1
- // The footer provider seam (INSTALL-SITE / runtime vacating). The footer is the SECOND interface
2
- // seam (no durable artifact to bridge), mirroring askuser but with a different vacating mechanism:
1
+ // The footer provider seam (INSTALL-SITE / runtime vacating). The footer is an interface
2
+ // seam (no durable artifact to bridge) with a runtime vacating mechanism:
3
3
  // perk installs its own footer (`installPerkFooter`) inside `index.ts`'s `session_start` event
4
4
  // handler, so under a foreign `[providers] footer` selection perk simply does NOT call
5
5
  // `installPerkFooter` — a runtime guard at that single install site, keyed off `ctx.cwd`. That
6
6
  // leaves the foreign footer (`pi-powerline-footer` or `pi-bar`) as the sole footer surface;
7
- // perk's objective/checkpoints progress still reaches it automatically via the already-publishing
8
- // composed `perk` `setStatus` slot (both foreign footers render extension statuses), so the
7
+ // perk's objective progress still reaches it automatically via the already-publishing
8
+ // single-value `perk` `setStatus` slot (both foreign footers render extension statuses), so the
9
9
  // adapter is vacate-only (`adapter: null`, no shim, no injected context).
10
10
  //
11
11
  // This module reads config (via `resolveProviders`/`loadPerkConfig`) so it does NOT belong in the
12
12
  // dependency-free `surfaces.ts`; it contains no rich-UI calls, so `surfacesGuard.test.ts` passes
13
- // it cleanly. Mirror of `askUser.ts`'s `resolvedAskUserProviderId` / `isPerkAskUserReferenceSelected`.
13
+ // it cleanly. Mirror of `planMode.ts`'s `resolvedPlanProviderId` / `isPerkPlanReferenceSelected`.
14
14
 
15
15
  import { loadPerkConfig } from "../substrate/config.ts";
16
16
  import {
@@ -22,7 +22,7 @@ import {
22
22
  /**
23
23
  * The resolved `[providers] footer` selection id for `cwd`. Fail-safe to the perk-footer
24
24
  * reference: any load/resolution failure returns the reference id so perk keeps installing its
25
- * own footer — the default path is the hard guarantee. Mirror of `resolvedAskUserProviderId`: the
25
+ * own footer — the default path is the hard guarantee. Mirror of `resolvedPlanProviderId`: the
26
26
  * catch narrows to genuine file-read/parse failures (the resolver is per-seam fail-open) and is
27
27
  * logged, never silent.
28
28
  */
@@ -1,18 +1,16 @@
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";
18
16
 
@@ -24,17 +22,13 @@ export { Key } from "@earendil-works/pi-tui";
24
22
  export { type ReportTarget, report, type Severity } from "./report.ts";
25
23
 
26
24
  // --- standing-surface slot keys (charter §2) ---
27
- // The ONE composed status slot (D2): all perk status segments render under this key.
25
+ // The ONE perk status slot (D2): perk's single status value renders under this key.
28
26
  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
27
 
33
28
  // --- footer identity marks (charter §5 / D3: emoji are footer-only identity, 2 cells wide) ---
34
- export const MARK_CHECKPOINTS = "📋";
35
29
  export const MARK_OBJECTIVE = "🎯";
36
30
 
37
- // --- glyph vocabulary (charter §5 / D3) — data only; themed rendering lives in the widget/footer builders below ---
31
+ // --- glyph vocabulary (charter §5 / D3) — charter-law data, pinned by tests ---
38
32
  export type GlyphKind = "done" | "current" | "pending" | "warning" | "failure";
39
33
  export const GLYPHS: Record<GlyphKind, { glyph: string; themeColor: string }> = {
40
34
  done: { glyph: "✓", themeColor: "success" },
@@ -44,10 +38,9 @@ export const GLYPHS: Record<GlyphKind, { glyph: string; themeColor: string }> =
44
38
  failure: { glyph: "✗", themeColor: "error" },
45
39
  };
46
40
 
47
- // --- height bounds (charter §4 / D1/D8) — enforced by the report() notify budget and the footer/widget builders ---
41
+ // --- height bounds (charter §4 / D1/D8) — enforced by the report() notify budget and the footer builder ---
48
42
  export const NOTIFY_MAX_LINES = 1;
49
43
  export const FOOTER_MAX_LINES = 1;
50
- export const CHECKPOINTS_WIDGET_MAX_LINES = 4;
51
44
 
52
45
  // --- the standing-surface setter -----------------------------------------------------------------
53
46
 
@@ -61,48 +54,18 @@ export interface ThemeLike {
61
54
  }
62
55
 
63
56
  /**
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
57
+ * The minimal headless-aware surface the status handle needs — the `perk` status slot is the one
58
+ * standing surface it serves. `ExtensionContext` satisfies it; tests fake it (the same
59
+ * minimal-structural-interface recipe as report.ts's `ReportTarget` see
77
60
  * `docs/learned/pi/extension-seams.md`).
78
61
  */
79
62
  export interface StandingTarget {
80
63
  hasUI: boolean;
81
64
  ui: {
82
65
  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
66
  };
89
67
  }
90
68
 
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
69
  // --- the working-message seam (vendored `whimsical`; charter §6 permitted text-only) ------------
107
70
 
108
71
  /**
@@ -128,61 +91,44 @@ export function setWorkingMessage(target: WorkingMessageTarget, message?: string
128
91
  target.ui.setWorkingMessage(message);
129
92
  }
130
93
 
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 = " ";
94
+ // --- the perk status (charter D2) --------------------------------------------
141
95
 
142
96
  /**
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).
97
+ * The single-value status handle: the objective publisher sets the one value through `set`, and
98
+ * the handle republishes the single `perk` status slot. The footer reads the value back via `get`
99
+ * and repaints via `subscribe` — the slot's `setStatus` dual-publish is deliberate (RPC clients
100
+ * see the slot; setFooter is an RPC no-op).
147
101
  */
148
102
  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;
103
+ /** Set (or clear, with undefined) the one value; publishes the slot. No-op headless. */
104
+ set(target: StandingTarget, text: string | undefined): void;
105
+ /** The current text (undefined when unset). */
106
+ get(): string | undefined;
153
107
  /**
154
- * Subscribe to recompositions: the listener fires after every headful `set` (headless `set`
108
+ * Subscribe to publishes: the listener fires after every headful `set` (headless `set`
155
109
  * calls are full no-ops, so nothing fires). Returns an unsubscribe.
156
110
  */
157
111
  subscribe(listener: () => void): () => void;
158
112
  }
159
113
 
160
114
  /**
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.
115
+ * Create the single-value `perk` status handle (one per extension instance — created in index.ts
116
+ * and passed to the objective publisher; no hidden module state). Headless calls are full no-ops
117
+ * (never record the text, so headless-era text can't resurrect in a later headful render).
118
+ * `undefined` clears the slot. No width handling: pi's footer truncates the status line itself.
166
119
  */
167
120
  export function createPerkStatus(): PerkStatusHandle {
168
- const segments = new Map<PerkSegmentKey, string>();
121
+ let value: string | undefined;
169
122
  const listeners = new Set<() => void>();
170
123
  return {
171
- set(target, segment, text) {
124
+ set(target, text) {
172
125
  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);
126
+ value = text;
127
+ target.ui.setStatus(STATUS_SLOT_PERK, text);
182
128
  for (const listener of listeners) listener();
183
129
  },
184
- get(segment) {
185
- return segments.get(segment);
130
+ get() {
131
+ return value;
186
132
  },
187
133
  subscribe(listener) {
188
134
  listeners.add(listener);
@@ -194,18 +140,16 @@ export function createPerkStatus(): PerkStatusHandle {
194
140
  // --- the perk-owned footer (charter D2) -----------------------------------------------
195
141
 
196
142
  /**
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).
143
+ * The raw material for one composed footer line. Left group (charter order 1–2): `identity`,
144
+ * `objective` — the segment renders verbatim (it carries its own 🎯 mark).
199
145
  * Right group (charter order 4, 5, +context, 6): `branch`, `model`, `thinking`, `cache`,
200
146
  * `context`, `guests` — right-aligned, non-segment system text dim-themed.
201
147
  */
202
148
  export interface FooterParts {
203
149
  /** e.g. `perk v0.0.1` — standing identity (D7), dim. */
204
150
  identity: string;
205
- /** The 🎯 objective segment, verbatim (`handle.get("objective")`). */
151
+ /** The 🎯 objective segment, verbatim (`handle.get()`). */
206
152
  objective?: string;
207
- /** The 📋 checkpoints segment, verbatim (`handle.get("checkpoints")`). */
208
- checkpoints?: string;
209
153
  /** Git branch (dim); omitted when not in a repo. */
210
154
  branch?: string;
211
155
  /** Model id (dim); omitted when no model. */
@@ -231,28 +175,45 @@ export interface UsageEntryLike {
231
175
  role?: string;
232
176
  usage?: { input: number; cacheRead: number; cacheWrite: number };
233
177
  };
178
+ /** Entry-level usage on `branch_summary`/`compaction` entries (pi 0.81.0 usage accounting). */
179
+ usage?: { input: number; cacheRead: number; cacheWrite: number };
234
180
  }
235
181
 
236
182
  /**
237
183
  * 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`).
184
+ * an exact local mirror of pi 0.84.1's default-footer `CH` computation (pi's cache-stats helpers
185
+ * are unexported; the `sanitizeGuestStatus` reimplementation precedent). Includes pi's display
186
+ * gate: returns `null` unless the session shows cache activity total cacheRead or cacheWrite
187
+ * > 0 summed over assistant messages, `toolResult` messages carrying `usage`, and
188
+ * `branch_summary`/`compaction` entries' entry-level `usage` AND the latest usage-bearing
189
+ * assistant message has prompt tokens > 0 (a trailing zero-prompt-token assistant message resets
190
+ * the rate, exactly like pi's `undefined`). The CH VALUE always comes from the latest
191
+ * usage-bearing assistant message; the non-assistant entries only widen the activity gate.
243
192
  */
244
193
  export function latestCacheHitRate(entries: readonly UsageEntryLike[]): number | null {
245
194
  let totalCacheRead = 0;
246
195
  let totalCacheWrite = 0;
247
196
  let latest: number | null = null;
248
197
  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;
198
+ if (entry.type === "message" && entry.message?.role === "assistant") {
199
+ const usage = entry.message.usage;
200
+ if (usage === undefined) continue;
201
+ totalCacheRead += usage.cacheRead;
202
+ totalCacheWrite += usage.cacheWrite;
203
+ const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
204
+ latest = promptTokens > 0 ? (usage.cacheRead / promptTokens) * 100 : null;
205
+ } else if (entry.type === "message" && entry.message?.role === "toolResult") {
206
+ const usage = entry.message.usage;
207
+ if (usage === undefined) continue;
208
+ totalCacheRead += usage.cacheRead;
209
+ totalCacheWrite += usage.cacheWrite;
210
+ } else if (
211
+ (entry.type === "branch_summary" || entry.type === "compaction") &&
212
+ entry.usage !== undefined
213
+ ) {
214
+ totalCacheRead += entry.usage.cacheRead;
215
+ totalCacheWrite += entry.usage.cacheWrite;
216
+ }
256
217
  }
257
218
  if (totalCacheRead <= 0 && totalCacheWrite <= 0) return null;
258
219
  return latest;
@@ -279,11 +240,11 @@ function formatContextSegment(
279
240
  }
280
241
 
281
242
  /**
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
243
+ * Compose THE one footer line (FOOTER_MAX_LINES = 1): left group = identity + objective
244
+ * (two-space-joined, charter order); right group = branch + model + context + guests
284
245
  * (two-space-joined), right-aligned with ≥2 spaces of padding. When the line exceeds `width`,
285
246
  * 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
247
+ * branch → cache → context; `identity` and `objective` are NEVER dropped — then
287
248
  * `truncateToWidth` as the last resort (ANSI- and 2-cell-emoji-aware).
288
249
  */
289
250
  export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: number): string {
@@ -294,12 +255,10 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
294
255
  branch: true,
295
256
  cache: true,
296
257
  context: true,
297
- checkpoints: true,
298
258
  };
299
259
  const compose = (): string => {
300
260
  const left = [theme.fg("dim", parts.identity)];
301
261
  if (parts.objective !== undefined) left.push(parts.objective);
302
- if (keep.checkpoints && parts.checkpoints !== undefined) left.push(parts.checkpoints);
303
262
  const right: string[] = [];
304
263
  if (keep.branch && parts.branch !== undefined) right.push(theme.fg("dim", parts.branch));
305
264
  if (keep.model && parts.model !== undefined) right.push(theme.fg("dim", parts.model));
@@ -309,9 +268,9 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
309
268
  right.push(formatContextSegment(parts.context, theme));
310
269
  }
311
270
  for (const guest of keep.guests) right.push(theme.fg("dim", guest));
312
- const leftText = left.join(PERK_SEGMENT_SEPARATOR);
271
+ const leftText = left.join(" ");
313
272
  if (right.length === 0) return leftText;
314
- const rightText = right.join(PERK_SEGMENT_SEPARATOR);
273
+ const rightText = right.join(" ");
315
274
  const padding = Math.max(2, width - visibleWidth(leftText) - visibleWidth(rightText));
316
275
  return `${leftText}${" ".repeat(padding)}${rightText}`;
317
276
  };
@@ -323,7 +282,6 @@ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: n
323
282
  else if (keep.branch) keep.branch = false;
324
283
  else if (keep.cache) keep.cache = false;
325
284
  else if (keep.context) keep.context = false;
326
- else if (keep.checkpoints) keep.checkpoints = false;
327
285
  else break; // identity + objective only — nothing left to drop
328
286
  line = compose();
329
287
  }
@@ -364,10 +322,16 @@ export type PerkFooterFactory = (
364
322
  /**
365
323
  * The perk-owned footer factory (charter D2): replaces pi's default footer wholesale with one
366
324
  * 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
325
+ * render): the objective value via the handle, branch/guests via `footerData` (excluding perk's
326
+ * own `STATUS_SLOT_PERK` — the slot keeps publishing for RPC, but the footer renders the value
369
327
  * 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.
328
+ * every handle recompose and on branch change; `dispose` detaches both. Lifecycle (pi ≥ 0.84,
329
+ * verified at 0.84.1): `setExtensionFooter` disposes a replaced factory's component, and pi's
330
+ * `resetExtensionUI` restores the built-in footer (disposing this one) on /reload and before
331
+ * session replacement — so installing a fresh factory per headful `session_start` leaks nothing.
332
+ *
333
+ * Two DELIBERATE divergences from pi's default footer remain: no `(auto)` context suffix
334
+ * (auto-compact state is not extension-readable) and no cost/`(sub)` segment (charter scope).
371
335
  */
372
336
  export function perkFooter(deps: PerkFooterDeps): PerkFooterFactory {
373
337
  return (tui, theme, footerData) => {
@@ -382,8 +346,7 @@ export function perkFooter(deps: PerkFooterDeps): PerkFooterFactory {
382
346
  const rate = deps.getCacheHitRate();
383
347
  const parts: FooterParts = {
384
348
  identity: deps.identity,
385
- objective: deps.status.get("objective"),
386
- checkpoints: deps.status.get("checkpoints"),
349
+ objective: deps.status.get(),
387
350
  branch: footerData.getGitBranch() ?? undefined,
388
351
  model: deps.getModelId() ?? undefined,
389
352
  thinking: deps.getThinkingLevel() ?? undefined,
@@ -404,7 +367,10 @@ export function perkFooter(deps: PerkFooterDeps): PerkFooterFactory {
404
367
  };
405
368
  }
406
369
 
407
- /** Install the perk-owned footer (sole-owner law, D2); no-op headless. */
370
+ /**
371
+ * Install the perk-owned footer (sole-owner law, D2); no-op headless. Safe to call on every
372
+ * headful `session_start`: pi ≥ 0.84 disposes the replaced factory (see `perkFooter`).
373
+ */
408
374
  export function installPerkFooter(
409
375
  target: { hasUI: boolean; ui: { setFooter(factory: PerkFooterFactory | undefined): void } },
410
376
  deps: PerkFooterDeps,
@@ -413,107 +379,15 @@ export function installPerkFooter(
413
379
  target.ui.setFooter(perkFooter(deps));
414
380
  }
415
381
 
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
- }
382
+ // --- format helpers --------------------------------------------------------------------------
512
383
 
384
+ /** Pi 0.84.1's default-footer `formatTokens` tiers, mirrored exactly (`200000` → `200k`). */
513
385
  function formatTokens(tokens: number): string {
514
386
  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`;
387
+ if (tokens < 10_000) return `${(tokens / 1000).toFixed(1)}k`;
388
+ if (tokens < 1_000_000) return `${Math.round(tokens / 1000)}k`;
389
+ if (tokens < 10_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
390
+ return `${Math.round(tokens / 1_000_000)}M`;
517
391
  }
518
392
 
519
393
  function formatElapsed(ms: number): string {
@@ -525,13 +399,13 @@ function formatElapsed(ms: number): string {
525
399
  return `${hr}h${min % 60}m`;
526
400
  }
527
401
 
528
- /** A compact one-line budget summary (e.g. `12.3k tok · 5m`). */
402
+ /** A compact one-line budget summary (e.g. `12k tok · 5m`, `1.2M tok · 2h5m` past 1M). */
529
403
  export function formatBudgetLine(args: { tokens: number; elapsedMs: number }): string {
530
404
  return `${formatTokens(args.tokens)} tok · ${formatElapsed(args.elapsedMs)}`;
531
405
  }
532
406
 
533
407
  // --- 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
408
+ // The audit §2.3 verdict (docs/design/pi-adoption-audit.md): perk's display-only custom-entry
535
409
  // families render as durable one-line transcript markers. Renderer BODIES live here (a transcript
536
410
  // renderer IS a rich-UI surface the surfaces module owns); registration is wiring at the feature
537
411
  // modules via the `registerTranscriptRenderer` seam below. Renderers are an interactive-TUI-only
@@ -585,15 +459,13 @@ export function registerTranscriptRenderer(
585
459
 
586
460
  /**
587
461
  * 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).
462
+ * human-requested scrollback and renders its full detail unbounded (the whole btw answer).
590
463
  */
591
464
  export const TRANSCRIPT_MARKER_MAX_LINES = 1;
592
465
 
593
466
  /**
594
467
  * 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.
468
+ * dim, D9-truncated. Emoji stay footer-only (D3).
597
469
  */
598
470
  function markerLine(scope: string, message: string, theme: ThemeLike, width: number): string {
599
471
  return truncateToWidth(theme.fg("dim", `perk: ${scope} — ${message}`), width);
@@ -605,48 +477,6 @@ function asRecord(value: unknown): Record<string, unknown> | null {
605
477
  return value as Record<string, unknown>;
606
478
  }
607
479
 
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
480
  /**
651
481
  * The first matching workflow-state field's marker message — a deliberately BOUNDED vocabulary
652
482
  * (the four headline fields + a SET `objective_node_claim`), extensible later. Bookkeeping deltas
@@ -769,17 +599,3 @@ export const btwThreadResetEntryRenderer: TranscriptRenderer = (entry, options,
769
599
  },
770
600
  };
771
601
  };
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
- }