@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
@@ -1,550 +0,0 @@
1
- // perk-owned checkpoints. The implement-session progress tracker: seed an ordered step list from
2
- // the plan body's `## Steps` numbered list, then advance it as the model emits `[DONE:n]` markers
3
- // in its turns. State lives in a dedicated `perk:checkpoint` session entry — kept OFF the shared
4
- // `perk:workflow-state` record (progress is high-churn; this avoids LWW-append smell).
5
- //
6
- // Opt-in + inert-by-default: perk plans are prose, so when no `## Steps` list is present the
7
- // checkpoint degrades to inert (no crash, no nagging). The `perk-plan` skill documents the optional
8
- // `## Steps` section as the forward path.
9
- //
10
- // The pure helpers (extractDoneSteps / markCompletedSteps + the step extractor) are perk-owned
11
- // copies of pi's official `examples/extensions/plan-mode/utils.ts`, adapted to key off `## Steps`
12
- // rather than plan-mode's `Plan:` header. Progress is surfaced headless-safe as the checkpoints
13
- // segment of the composed `perk` status (a `📋 done/total · ▸n` segment published through the shared
14
- // `PerkStatusHandle`) plus a themed `belowEditor` widget factory via `setStandingWidget` (stateless
15
- // render, lines windowed to ≤4 steps and width-truncated); `/checkpoints` notifies a one-line
16
- // summary. Accepted trade-off: pi's RPC mode drops factory widgets — the status chip +
17
- // `/checkpoints` remain the RPC-visible surfaces (recorded in shared/contracts.md).
18
- //
19
- // TODO-PROVIDER DEFERRAL. perk's checkpoints are the *reference* todo provider (`perk-checkpoints`).
20
- // They consume the resolved `[providers] todo` selection and **step the progress surface aside**
21
- // when a foreign todo provider is selected — the todo-seam mirror of planMode.ts's plan-seam
22
- // deferral. The four runtime surfaces guard on `isPerkCheckpointsReferenceSelected(ctx.cwd)` (read
23
- // fresh per-event, fail-safe to the reference): `session_start`/`session_tree`/`turn_end`
24
- // early-return **silently** (no seed, no advance, no render) so the foreign todo provider owns the
25
- // surface uncontested; `/checkpoints` **announces** the deferral headless-safe. Runtime deferral
26
- // only — registration-time vacating is the concrete foreign todo adapter's concern. Fail-safe: any
27
- // config-read error → treated as the reference → zero change on the default selection.
28
-
29
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
30
- import { readHandoff, readPlanBody } from "../substrate/cache.ts";
31
- import { registerPerkCommand } from "../substrate/command.ts";
32
- import { loadPerkConfig } from "../substrate/config.ts";
33
- import {
34
- loadProviders,
35
- PERK_CHECKPOINTS_PROVIDER_ID,
36
- resolveProviders,
37
- } from "../substrate/providers.ts";
38
- import {
39
- digestSessionData,
40
- readSessionArtifact,
41
- writeSessionArtifact,
42
- } from "../substrate/sessionData.ts";
43
- import type { BranchEntry } from "../substrate/workflowState.ts";
44
- import { branchCarries, branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
45
- import {
46
- checkpointEntryRenderer,
47
- MARK_CHECKPOINTS,
48
- type PerkStatusHandle,
49
- progressLine,
50
- registerTranscriptRenderer,
51
- renderCoarsePlanLines,
52
- renderProgressLines,
53
- report,
54
- setStandingWidget,
55
- type ThemeLike,
56
- WIDGET_SLOT_CHECKPOINTS,
57
- } from "../surfaces/surfaces.ts";
58
- import { generatePlanSteps } from "./planSteps.ts";
59
-
60
- /** The dedicated checkpoint session entry type. */
61
- export const CHECKPOINT_TYPE = "perk:checkpoint";
62
-
63
- /** The once-only generated-checklist context injection's custom message type. */
64
- export const STEPS_CONTEXT_TYPE = "perk:steps-context";
65
-
66
- /** The generated-steps session artifact (written via the accessor seam). */
67
- export const STEPS_ARTIFACT_NAME = "plan-steps.json";
68
-
69
- /**
70
- * The resolved `[providers] todo` selection id for `cwd`, read fresh per-event (no static state —
71
- * the same per-event-read shape `resolvedPlanProviderId` uses in planMode.ts). Fail-safe to the
72
- * perk-checkpoints reference: any load/resolution failure returns the reference id so perk's own
73
- * checkpoints keep working — the default path is the hard guarantee. The catch narrows to genuine
74
- * file-read/parse failures (the resolver is per-seam fail-open) and is logged, never silent.
75
- */
76
- export function resolvedTodoProviderId(cwd: string): string {
77
- try {
78
- return resolveProviders(loadPerkConfig(cwd).providers, loadProviders()).todo.id;
79
- } catch (error) {
80
- console.error(
81
- `perk: todo provider resolution failed — falling back to ${PERK_CHECKPOINTS_PROVIDER_ID}: ${error}`,
82
- );
83
- return PERK_CHECKPOINTS_PROVIDER_ID;
84
- }
85
- }
86
-
87
- /**
88
- * Whether perk's own checkpoints reference is the selected todo provider for `cwd`. When a foreign
89
- * todo provider is selected via `[providers] todo`, perk's progress surface steps aside (defers).
90
- */
91
- export function isPerkCheckpointsReferenceSelected(cwd: string): boolean {
92
- return resolvedTodoProviderId(cwd) === PERK_CHECKPOINTS_PROVIDER_ID;
93
- }
94
-
95
- export interface CheckpointStep {
96
- step: number;
97
- text: string;
98
- completed: boolean;
99
- }
100
-
101
- export interface CheckpointState {
102
- steps: CheckpointStep[];
103
- /** The in-progress step number (derived from `[WIP:n]` + completion), or `null`. */
104
- current: number | null;
105
- }
106
-
107
- /** A step list with no items is "inert" — no `## Steps` was found in the plan body. */
108
- export function isInert(state: CheckpointState): boolean {
109
- return state.steps.length === 0;
110
- }
111
-
112
- // --- pure helpers (perk-owned copies of plan-mode/utils.ts) -------------------------------------
113
-
114
- /** Extract `[DONE:n]` step numbers from a block of text (case-insensitive). */
115
- export function extractDoneSteps(text: string): number[] {
116
- const steps: number[] = [];
117
- for (const match of text.matchAll(/\[DONE:(\d+)\]/gi)) {
118
- const step = Number(match[1]);
119
- if (Number.isFinite(step)) steps.push(step);
120
- }
121
- return steps;
122
- }
123
-
124
- /** Mark steps named by `[DONE:n]` in `text` as completed (mutates `steps`); returns the count. */
125
- export function markCompletedSteps(text: string, steps: CheckpointStep[]): number {
126
- const done = extractDoneSteps(text);
127
- for (const n of done) {
128
- const item = steps.find((s) => s.step === n);
129
- if (item) item.completed = true;
130
- }
131
- return done.length;
132
- }
133
-
134
- /** Extract `[WIP:n]` step numbers from a block of text (case-insensitive). */
135
- export function extractWipSteps(text: string): number[] {
136
- const steps: number[] = [];
137
- for (const match of text.matchAll(/\[WIP:(\d+)\]/gi)) {
138
- const step = Number(match[1]);
139
- if (Number.isFinite(step)) steps.push(step);
140
- }
141
- return steps;
142
- }
143
-
144
- /** The last `[WIP:n]` in `text` whose step exists and is not completed, else `null`. */
145
- export function latestWipStep(text: string, steps: CheckpointStep[]): number | null {
146
- const wips = extractWipSteps(text);
147
- for (let i = wips.length - 1; i >= 0; i--) {
148
- const n = wips[i] as number;
149
- const item = steps.find((s) => s.step === n);
150
- if (item && !item.completed) return n;
151
- }
152
- return null;
153
- }
154
-
155
- /**
156
- * Derive the in-progress step: `preferred` if it names an existing incomplete step; else the
157
- * lowest-numbered incomplete step; else `null` (all complete / no steps).
158
- */
159
- export function computeCurrent(steps: CheckpointStep[], preferred: number | null): number | null {
160
- if (preferred != null) {
161
- const item = steps.find((s) => s.step === preferred);
162
- if (item && !item.completed) return preferred;
163
- }
164
- const lowest = steps.filter((s) => !s.completed).sort((a, b) => a.step - b.step)[0];
165
- return lowest ? lowest.step : null;
166
- }
167
-
168
- /**
169
- * Parse the plan body's `## Steps` numbered list into checkpoint steps. Returns `[]` when there is
170
- * no `## Steps` section (the inert path). Only a recognizable `<n>. text` / `<n>) text` list
171
- * under the header is parsed; the section ends at the next `## ` heading.
172
- */
173
- export function extractSteps(planBody: string | null | undefined): CheckpointStep[] {
174
- if (!planBody) return [];
175
- const lines = planBody.split(/\r?\n/);
176
- let inSection = false;
177
- const steps: CheckpointStep[] = [];
178
- for (const raw of lines) {
179
- const line = raw ?? "";
180
- const header = line.match(/^\s*(#{1,6})\s+(.*\S)\s*$/);
181
- if (header) {
182
- // Enter the section on a `## Steps`-style heading; leave it on any other heading.
183
- inSection = /^steps\b/i.test((header[2] ?? "").trim());
184
- continue;
185
- }
186
- if (!inSection) continue;
187
- const numbered = line.match(/^\s*(\d+)[.)]\s+(.+?)\s*$/);
188
- if (numbered) {
189
- const text = (numbered[2] ?? "").trim();
190
- if (text) steps.push({ step: steps.length + 1, text, completed: false });
191
- }
192
- }
193
- return steps;
194
- }
195
-
196
- // --- rebuild (the scan-after-marker discipline) -------------------------------------------------
197
-
198
- function isAssistantText(entry: {
199
- type?: string;
200
- message?: { role?: string; content?: unknown };
201
- }): string | null {
202
- if (entry.type !== "message" || entry.message?.role !== "assistant") return null;
203
- const content = entry.message.content;
204
- if (typeof content === "string") return content;
205
- if (Array.isArray(content)) {
206
- return content
207
- .map((b) => {
208
- const block = b as { type?: string; text?: string };
209
- return block.type === "text" && typeof block.text === "string" ? block.text : "";
210
- })
211
- .filter(Boolean)
212
- .join("\n");
213
- }
214
- return null;
215
- }
216
-
217
- /**
218
- * Rebuild checkpoint state from the branch. The latest `perk:checkpoint` entry is the **marker**
219
- * (its `steps` carry completion persisted on prior turns); we then re-fold `[DONE:n]` from assistant
220
- * messages **after** that marker only — so stale `[DONE:n]` from a previous execution can't
221
- * resurrect a step (a subtlety: stale `[DONE:n]` must not resurrect a step). No checkpoint entry ⟹ inert.
222
- */
223
- export function rebuildCheckpoint(branch: readonly BranchEntry[]): CheckpointState {
224
- let markerIdx = -1;
225
- let seed: CheckpointStep[] | null = null;
226
- for (let i = branch.length - 1; i >= 0; i--) {
227
- const e = branch[i] as BranchEntry & { data?: { steps?: CheckpointStep[] } };
228
- if (e?.type === "custom" && e.customType === CHECKPOINT_TYPE) {
229
- const stored = e.data?.steps;
230
- if (Array.isArray(stored)) {
231
- seed = stored.map((s) => ({ step: s.step, text: s.text, completed: !!s.completed }));
232
- markerIdx = i;
233
- }
234
- break;
235
- }
236
- }
237
- if (seed === null) return { steps: [], current: null };
238
-
239
- const after: string[] = [];
240
- for (let i = markerIdx + 1; i < branch.length; i++) {
241
- const text = isAssistantText(branch[i] as never);
242
- if (text) after.push(text);
243
- }
244
- const afterText = after.join("\n");
245
- markCompletedSteps(afterText, seed);
246
- const current = computeCurrent(seed, latestWipStep(afterText, seed));
247
- return { steps: seed, current };
248
- }
249
-
250
- // --- generated steps -----------------------------------------------------------------------------
251
-
252
- /**
253
- * Recomputed (never stored) generated-ness: a checkpoint state is "generated" iff it is non-inert
254
- * AND the current plan body parses to NO explicit `## Steps` — explicit steps could only have been
255
- * seeded from the body, so a non-inert state over a prose body must be LLM-derived.
256
- */
257
- export function isGeneratedState(cwd: string, state: CheckpointState): boolean {
258
- return !isInert(state) && extractSteps(readPlanBody(cwd)).length === 0;
259
- }
260
-
261
- /** Promote generated step texts to checkpoint steps (1-based, all incomplete). */
262
- function toCheckpointSteps(texts: string[]): CheckpointStep[] {
263
- return texts.map((text, i) => ({ step: i + 1, text, completed: false }));
264
- }
265
-
266
- /**
267
- * Read the generated-steps artifact through the accessor seam (provenance-pointer validated)
268
- * and return its steps — but only when its stored `plan_body_digest` matches the CURRENT plan
269
- * body (a replan/rematerialized body invalidates the cache). `null` on any refusal: no pointer,
270
- * digest mismatch (file or plan body), unparseable JSON, or an unusable steps shape.
271
- */
272
- function readGeneratedStepsArtifact(
273
- ctx: Parameters<typeof readSessionArtifact>[0],
274
- planBody: string,
275
- ): string[] | null {
276
- const artifact = readSessionArtifact(ctx, STEPS_ARTIFACT_NAME);
277
- if (artifact === null) return null;
278
- try {
279
- const parsed = JSON.parse(artifact.content) as { plan_body_digest?: unknown; steps?: unknown };
280
- if (parsed.plan_body_digest !== digestSessionData(planBody)) return null;
281
- const steps = parsed.steps;
282
- if (!Array.isArray(steps) || steps.length < 2) return null;
283
- if (!steps.every((s) => typeof s === "string" && s.length > 0)) return null;
284
- return steps as string[];
285
- } catch {
286
- return null;
287
- }
288
- }
289
-
290
- /** The hidden context message teaching the model the generated checklist's exact numbering. */
291
- function stepsContextContent(steps: readonly CheckpointStep[]): string {
292
- const lines = steps.map((s) => `${s.step}. ${s.text}`);
293
- return (
294
- "perk generated the following implementation checklist for this prose plan. Emit `[WIP:n]` " +
295
- "when you start step n and `[DONE:n]` when it completes, using EXACTLY these numbers:\n\n" +
296
- lines.join("\n")
297
- );
298
- }
299
-
300
- /**
301
- * Whether `e` is pi-core's stale-`ctx` compaction proxy error. During `session_compact` pi may
302
- * replace the running session out from under an in-flight event handler, invalidating the extension
303
- * runner's `ctx` proxy; the next read off it throws `/stale after session replacement/`. That is a
304
- * benign race (the dying session is discarded and the replacement session's `session_start`
305
- * re-renders), so the `session_compact` handler swallows it. Adapted from `@juicesharp/rpiv-todo`'s
306
- * `index.ts` `isStaleCtxError`.
307
- */
308
- export function isStaleCtxError(e: unknown): boolean {
309
- return /stale after session replacement/.test(String(e));
310
- }
311
-
312
- // --- the controller -----------------------------------------------------------------------------
313
-
314
- /** A coarse descriptor of the active plan when there is no `## Steps` checklist (the prose path). */
315
- interface CoarseDescriptor {
316
- stage: string;
317
- planId: string;
318
- }
319
-
320
- function coarseDescriptor(
321
- ctx: ExtensionContext,
322
- branch: readonly BranchEntry[],
323
- ): CoarseDescriptor | null {
324
- const wf = rebuildWorkflowState(branch);
325
- if (wf.active_plan_ref == null) return null;
326
- const stageRaw = wf.run_id != null ? readHandoff(ctx.cwd, wf.run_id)?.stage : undefined;
327
- const stage = typeof stageRaw === "string" && stageRaw ? stageRaw : "active";
328
- return { stage, planId: wf.active_plan_ref.pr_id };
329
- }
330
-
331
- /** Surface progress in the UI (headless-safe — the handle + widget setter no-op without UI). */
332
- function renderStatus(
333
- ctx: ExtensionContext,
334
- status: PerkStatusHandle,
335
- state: CheckpointState,
336
- branch: readonly BranchEntry[],
337
- ): void {
338
- if (isInert(state)) {
339
- // Coarse fallback: an active prose plan (no `## Steps`) still surfaces SOMETHING — the same
340
- // themed factory path as the steps widget (one dim line, stateless render, belowEditor).
341
- const coarse = coarseDescriptor(ctx, branch);
342
- if (coarse) {
343
- status.set(ctx, "checkpoints", `${MARK_CHECKPOINTS} ${coarse.stage}`);
344
- setStandingWidget(
345
- ctx,
346
- WIDGET_SLOT_CHECKPOINTS,
347
- (_tui: unknown, theme: ThemeLike) => ({
348
- render: (width: number) => renderCoarsePlanLines(coarse.planId, theme, width),
349
- invalidate: () => {},
350
- }),
351
- { placement: "belowEditor" },
352
- );
353
- } else {
354
- status.set(ctx, "checkpoints", undefined);
355
- setStandingWidget(ctx, WIDGET_SLOT_CHECKPOINTS, undefined);
356
- }
357
- return;
358
- }
359
- // Themed component factory: lines are computed inside render() per call — never cached — over
360
- // the freshly-rebuilt state snapshot; windowed + width-truncated in surfaces.ts.
361
- const snapshot = state;
362
- status.set(ctx, "checkpoints", `${MARK_CHECKPOINTS} ${progressLine(state)}`);
363
- setStandingWidget(
364
- ctx,
365
- WIDGET_SLOT_CHECKPOINTS,
366
- (_tui: unknown, theme: ThemeLike) => ({
367
- render: (width: number) => renderProgressLines(snapshot, theme, width),
368
- invalidate: () => {},
369
- }),
370
- { placement: "belowEditor" },
371
- );
372
- }
373
-
374
- /**
375
- * Register perk-owned checkpoints. Seeds on `session_start` from the plan body's `## Steps` (only in
376
- * an active workflow, and only once — a later session keeps the existing entry); rebuilds on
377
- * `session_start` AND `session_tree`; advances on `turn_end`; lists via `/checkpoints`.
378
- */
379
- export function registerCheckpoints(pi: ExtensionAPI, status: PerkStatusHandle): void {
380
- // Transcript marker for `perk:checkpoint` snapshots (audit §2.3): renderer body in surfaces.ts,
381
- // registration = wiring, feature-detect inside the seam (pre-0.80.4 hosts stay inert). No
382
- // todo-provider deferral here: entries exist only when perk's checkpoints appended them, so
383
- // rendering history stays correct under any later provider selection.
384
- registerTranscriptRenderer(pi, CHECKPOINT_TYPE, checkpointEntryRenderer);
385
-
386
- pi.on("session_start", async (_event, ctx) => {
387
- try {
388
- // Todo-provider deferral: when a foreign `[providers] todo` is selected, step the progress
389
- // surface aside silently (no seed, no render) — the foreign provider owns it.
390
- if (!isPerkCheckpointsReferenceSelected(ctx.cwd)) return;
391
- const branch = branchOf(ctx);
392
- const existing = rebuildCheckpoint(branch);
393
- // Seed once: only when there is no checkpoint yet, a workflow is active, and the plan body
394
- // carries a `## Steps` list. Otherwise stay inert (no entry appended).
395
- if (isInert(existing)) {
396
- const wf = rebuildWorkflowState(branch);
397
- const active = wf.active_plan_ref != null;
398
- const planBody = active ? readPlanBody(ctx.cwd) : null;
399
- let steps = active ? extractSteps(planBody) : [];
400
- // Generation branch: an active IMPLEMENT session over a prose plan body (no usable
401
- // `## Steps` — the extractor already maps malformed sections to []) asks the session model
402
- // for a bounded checklist. Artifact reuse first (the pointer-validated cache, keyed on the
403
- // plan-body digest); every failure falls through to the coarse fallback unchanged.
404
- if (steps.length === 0 && planBody !== null && wf.active_plan_ref != null) {
405
- const stageRaw = wf.run_id != null ? readHandoff(ctx.cwd, wf.run_id)?.stage : undefined;
406
- if (stageRaw === "implement") {
407
- let texts = readGeneratedStepsArtifact(ctx, planBody);
408
- if (texts === null) {
409
- texts = await generatePlanSteps(ctx, planBody);
410
- if (texts !== null) {
411
- // Best-effort persistence for reuse across reload/compaction: a failed artifact
412
- // write (already warned by the seam) never drops a successful generation.
413
- writeSessionArtifact(
414
- pi,
415
- ctx,
416
- STEPS_ARTIFACT_NAME,
417
- `${JSON.stringify(
418
- {
419
- plan_id: wf.active_plan_ref.pr_id,
420
- plan_body_digest: digestSessionData(planBody),
421
- steps: texts,
422
- },
423
- null,
424
- 2,
425
- )}\n`,
426
- );
427
- }
428
- }
429
- if (texts !== null) steps = toCheckpointSteps(texts);
430
- }
431
- }
432
- if (steps.length > 0) {
433
- pi.appendEntry(CHECKPOINT_TYPE, { steps });
434
- renderStatus(ctx, status, { steps, current: computeCurrent(steps, null) }, branch);
435
- return;
436
- }
437
- }
438
- renderStatus(ctx, status, existing, branch);
439
- } catch (error) {
440
- console.error(`perk: checkpoint seed/rebuild failed on session_start — ${error}`);
441
- }
442
- });
443
-
444
- pi.on("session_tree", async (_event, ctx) => {
445
- try {
446
- if (!isPerkCheckpointsReferenceSelected(ctx.cwd)) return;
447
- const branch = branchOf(ctx);
448
- renderStatus(ctx, status, rebuildCheckpoint(branch), branch);
449
- } catch (error) {
450
- console.error(`perk: checkpoint rebuild failed on session_tree — ${error}`);
451
- }
452
- });
453
-
454
- // session_compact re-render (adapted from `@juicesharp/rpiv-todo`): a compaction replaces the
455
- // session entries, so rebuild + re-render the progress surface (mirror `session_tree`: NO
456
- // re-seed). The catch arm diverges from the other handlers — a stale-`ctx` compaction race is
457
- // benign and swallowed silently (the replacement session's `session_start` re-renders); only
458
- // genuine replay bugs are logged per the log-not-throw convention.
459
- pi.on("session_compact", async (_event, ctx) => {
460
- try {
461
- if (!isPerkCheckpointsReferenceSelected(ctx.cwd)) return;
462
- const branch = branchOf(ctx);
463
- renderStatus(ctx, status, rebuildCheckpoint(branch), branch);
464
- } catch (error) {
465
- if (isStaleCtxError(error)) return;
466
- console.error(`perk: checkpoint rebuild failed on session_compact — ${error}`);
467
- }
468
- });
469
-
470
- pi.on("turn_end", async (event, ctx) => {
471
- try {
472
- if (!isPerkCheckpointsReferenceSelected(ctx.cwd)) return;
473
- const branch = branchOf(ctx);
474
- const state = rebuildCheckpoint(branch);
475
- if (isInert(state)) {
476
- // Coarse path: an active prose plan still surfaces a status (no entry to advance).
477
- renderStatus(ctx, status, state, branch);
478
- return;
479
- }
480
- const text = isAssistantText(event.message as never);
481
- if (text === null) {
482
- renderStatus(ctx, status, state, branch);
483
- return;
484
- }
485
- const advanced = markCompletedSteps(text, state.steps) > 0;
486
- // A WIP declared THIS turn wins; otherwise preserve the prior `current` (unless it completed).
487
- state.current = computeCurrent(
488
- state.steps,
489
- latestWipStep(text, state.steps) ?? state.current,
490
- );
491
- if (advanced) {
492
- // Persist the advanced completion as a new marker entry (carries completion forward).
493
- pi.appendEntry(CHECKPOINT_TYPE, { steps: state.steps });
494
- }
495
- // Always re-render: `current` can change without completion advancing.
496
- renderStatus(ctx, status, state, branch);
497
- } catch (error) {
498
- console.error(`perk: checkpoint advance failed on turn_end — ${error}`);
499
- }
500
- });
501
-
502
- // The once-only generated-checklist injection: when the seeded checkpoints are
503
- // LLM-generated (recomputed: non-inert over a prose plan body), inject the numbered list as a
504
- // hidden context message so `[WIP:n]`/`[DONE:n]` markers use exactly these numbers. Injected
505
- // custom messages persist to the branch, so the branch-carries-it guard makes this once-only
506
- // (and a rewind past the injection naturally re-injects). No strip handler: the steps stay
507
- // relevant for the whole implement session (there is no off state).
508
- pi.on("before_agent_start", async (_event, ctx) => {
509
- try {
510
- if (!isPerkCheckpointsReferenceSelected(ctx.cwd)) return;
511
- const branch = branchOf(ctx);
512
- if (branchCarries(branch, STEPS_CONTEXT_TYPE)) return;
513
- const state = rebuildCheckpoint(branch);
514
- if (isInert(state) || !isGeneratedState(ctx.cwd, state)) return;
515
- return {
516
- message: {
517
- customType: STEPS_CONTEXT_TYPE,
518
- content: stepsContextContent(state.steps),
519
- display: false,
520
- },
521
- };
522
- } catch (error) {
523
- console.error(`perk: steps-context injection failed on before_agent_start — ${error}`);
524
- }
525
- });
526
-
527
- registerPerkCommand(pi, "checkpoints", {
528
- description: "Show perk implementation checkpoints (read-only).",
529
- handler: async (_args, ctx) => {
530
- // Todo-provider deferral: announce the deferral headless-safe and step aside when a
531
- // foreign `[providers] todo` is selected (the surface-facing mirror of the silent handlers).
532
- if (!isPerkCheckpointsReferenceSelected(ctx.cwd)) {
533
- const deferral = `checkpoints deferred — a foreign todo provider (\`${resolvedTodoProviderId(
534
- ctx.cwd,
535
- )}\`) is selected via [providers] todo.`;
536
- report(ctx, "checkpoints", "info", deferral);
537
- return;
538
- }
539
- const state = rebuildCheckpoint(branchOf(ctx));
540
- // One line: `done/total · ▸n <current step text>`; the tail drops when no
541
- // step is current (all complete).
542
- const current = state.steps.find((s) => s.step === state.current);
543
- const generated = !isInert(state) && isGeneratedState(ctx.cwd, state) ? " (generated)" : "";
544
- const message = isInert(state)
545
- ? "no checkpoints — this plan has no `## Steps` list (checkpoints are inert)."
546
- : `${progressLine(state)}${current ? ` ${current.text}` : ""}${generated}`;
547
- report(ctx, "checkpoints", "info", message);
548
- },
549
- });
550
- }
@@ -1,108 +0,0 @@
1
- // Generated checkpoint TODOs for prose plans: the second consumer of the structured-output
2
- // substrate (the `planTitle.ts` idiom). When an implement session starts on a plan body that lacks
3
- // a usable `## Steps` numbered list, checkpoints ask the session model for a bounded, ordered
4
- // implementation checklist via a single structured tool call. Every failure mode (the offline
5
- // gate, no model, unresolved auth, a model error, no tool call, schema-invalid args, an unusable
6
- // sanitized list) returns `null`, so the caller's deterministic fallback (today's coarse prose
7
- // behavior) takes over — a session start is never failed by this path.
8
-
9
- import { Type } from "@earendil-works/pi-ai";
10
- import {
11
- completeStructured,
12
- type ModelAuthContext,
13
- resolveModelAuth,
14
- } from "../substrate/structuredOutput.ts";
15
-
16
- /** Bounds shared by the schema and the sanitizer. */
17
- const STEPS_MIN = 2;
18
- const STEPS_MAX = 12;
19
- const STEP_TEXT_MAX_CHARS = 200;
20
-
21
- /** The structured result schema: a bounded, ordered list of concise imperative steps. */
22
- const PlanStepsSchema = Type.Object({
23
- steps: Type.Array(Type.String({ minLength: 1, maxLength: STEP_TEXT_MAX_CHARS }), {
24
- minItems: STEPS_MIN,
25
- maxItems: STEPS_MAX,
26
- description:
27
- "Concise, ordered, imperative implementation steps covering the whole plan " +
28
- "(one short phrase per step, no numbering, no markdown).",
29
- }),
30
- });
31
-
32
- /** Cap the plan markdown handed to the model (steps need the plan, not an unbounded payload). */
33
- export const STEPS_INPUT_CHAR_CAP = 24_000;
34
-
35
- /**
36
- * Normalize raw model steps into a clean checkpoint list, or `null` when unusable. Pure: per item,
37
- * trims, strips a leading list marker the model may echo (`1.` / `1)` / `-` / `*`), collapses
38
- * internal whitespace/newlines to single spaces, truncates to ≤200 chars; drops empties; caps at
39
- * 12 items; returns `null` when fewer than 2 survive.
40
- */
41
- export function sanitizeSteps(raw: string[]): string[] | null {
42
- const out: string[] = [];
43
- for (const item of raw) {
44
- if (typeof item !== "string") continue;
45
- let s = item.trim();
46
- // Strip a leading echoed list marker: `1.`, `1)`, `-`, `*`.
47
- s = s.replace(/^(?:\d+[.)]|[-*])\s+/, "");
48
- s = s.replace(/\s+/g, " ").trim();
49
- if (!s) continue;
50
- if (s.length > STEP_TEXT_MAX_CHARS) s = s.slice(0, STEP_TEXT_MAX_CHARS).trim();
51
- out.push(s);
52
- if (out.length >= STEPS_MAX) break;
53
- }
54
- return out.length >= STEPS_MIN ? out : null;
55
- }
56
-
57
- /**
58
- * The deterministic offline gate. `PERK_NO_LLM` (set by the test harness, never by the production
59
- * `perk` CLI) disables step generation so tests stay fully offline regardless of ambient API keys —
60
- * the same gate as `llmTitlesEnabled`. Pure and unit-testable.
61
- */
62
- export function llmStepsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
63
- return !env.PERK_NO_LLM;
64
- }
65
-
66
- /**
67
- * Best-effort: generate an ordered implementation checklist for `planMarkdown` via the session
68
- * model, or `null` on any failure / when the gate is on. Never throws. Reports a genuine model
69
- * error (`console.error`) but stays non-fatal — the caller falls back to the coarse prose path.
70
- */
71
- export async function generatePlanSteps(
72
- ctx: ModelAuthContext,
73
- planMarkdown: string,
74
- signal?: AbortSignal,
75
- ): Promise<string[] | null> {
76
- if (!llmStepsEnabled()) return null;
77
-
78
- let auth: Awaited<ReturnType<typeof resolveModelAuth>>;
79
- try {
80
- auth = await resolveModelAuth(ctx);
81
- } catch {
82
- return null;
83
- }
84
- if (!auth.ok) return null; // silent: no model / no auth configured (fail-safe).
85
-
86
- const outcome = await completeStructured({
87
- model: auth.model,
88
- schema: PlanStepsSchema,
89
- toolName: "set_plan_steps",
90
- toolDescription: "Provide the ordered implementation checklist for the plan.",
91
- system:
92
- "You decompose software engineering plans into concise, ordered implementation checklists.",
93
- instruction:
94
- "Read this implementation plan and produce an ordered implementation checklist covering the " +
95
- "whole plan: 2–12 steps, each a single concise imperative phrase (no numbering, no markdown).",
96
- input: planMarkdown.slice(0, STEPS_INPUT_CHAR_CAP),
97
- apiKey: auth.apiKey,
98
- headers: auth.headers,
99
- signal,
100
- timeoutMs: 30_000,
101
- });
102
- if (!outcome.ok || !outcome.value) {
103
- // Report (don't swallow) a genuine model error; the session start still proceeds.
104
- if (!outcome.ok) console.error(`perk: plan-steps — ${outcome.error}`);
105
- return null;
106
- }
107
- return sanitizeSteps(outcome.value.steps);
108
- }