@gavin-hu/my-pi-agent 0.12.0 → 0.13.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 (153) hide show
  1. package/README.md +28 -21
  2. package/README.zh-CN.md +30 -22
  3. package/extensions/bytefly/README.md +139 -67
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +36 -6
  7. package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
  8. package/extensions/bytefly/footer/config.ts +7 -1
  9. package/extensions/bytefly/footer/delegation.ts +100 -0
  10. package/extensions/bytefly/footer/register.ts +13 -2
  11. package/extensions/bytefly/footer/render/lines.ts +2 -0
  12. package/extensions/bytefly/footer/render/segments.ts +27 -0
  13. package/extensions/bytefly/footer/types.ts +11 -0
  14. package/extensions/bytefly/index.ts +4 -3
  15. package/extensions/bytefly/notify/config.ts +2 -0
  16. package/extensions/bytefly/notify/format.ts +19 -1
  17. package/extensions/bytefly/notify/register.ts +37 -13
  18. package/extensions/bytefly/notify/types.ts +2 -0
  19. package/extensions/bytefly/panel/commands.ts +34 -13
  20. package/extensions/bytefly/panel/settings.ts +98 -16
  21. package/extensions/bytefly/update/cache.ts +73 -0
  22. package/extensions/bytefly/update/format.ts +65 -10
  23. package/extensions/bytefly/update/register.ts +99 -13
  24. package/extensions/bytefly/update/state.ts +13 -1
  25. package/extensions/document/README.md +22 -12
  26. package/extensions/document/config.ts +4 -0
  27. package/extensions/document/deadline.ts +61 -0
  28. package/extensions/document/tool.ts +13 -2
  29. package/extensions/file-browse/README.md +12 -8
  30. package/extensions/file-browse/client.ts +6 -7
  31. package/extensions/file-browse/files.ts +0 -4
  32. package/extensions/file-browse/git.ts +9 -10
  33. package/extensions/file-browse/html.ts +17 -7
  34. package/extensions/file-browse/router.ts +24 -15
  35. package/extensions/goal/README.md +7 -1
  36. package/extensions/goal/schema.ts +6 -2
  37. package/extensions/goal/types.ts +6 -2
  38. package/extensions/job/README.md +16 -12
  39. package/extensions/job/runtime.ts +23 -6
  40. package/extensions/job/status.ts +4 -2
  41. package/extensions/memory/README.md +55 -13
  42. package/extensions/memory/commands.ts +20 -2
  43. package/extensions/memory/doctor.ts +137 -0
  44. package/extensions/memory/format.ts +24 -7
  45. package/extensions/memory/runtime.ts +49 -0
  46. package/extensions/memory/store.ts +31 -0
  47. package/extensions/memory/tui.ts +136 -12
  48. package/extensions/plan/README.md +60 -29
  49. package/extensions/plan/commands.ts +67 -8
  50. package/extensions/plan/index.ts +35 -11
  51. package/extensions/plan/plan-index.ts +234 -0
  52. package/extensions/plan/plan-text.ts +95 -0
  53. package/extensions/plan/plans.ts +90 -1
  54. package/extensions/plan/review.ts +12 -9
  55. package/extensions/plan/runtime.ts +72 -27
  56. package/extensions/plan/steps.ts +35 -25
  57. package/extensions/plan/todos.ts +24 -2
  58. package/extensions/plan/tools.ts +124 -24
  59. package/extensions/plan/tui.ts +7 -28
  60. package/extensions/plan/types.ts +9 -0
  61. package/extensions/plan/web-page.ts +2 -2
  62. package/extensions/plan/web.ts +12 -11
  63. package/extensions/rewind/README.md +28 -8
  64. package/extensions/rewind/commands.ts +1 -1
  65. package/extensions/rewind/format.ts +4 -4
  66. package/extensions/rewind/git.ts +56 -18
  67. package/extensions/rewind/index.ts +3 -5
  68. package/extensions/rewind/path-picker.ts +198 -0
  69. package/extensions/rewind/paths.ts +128 -0
  70. package/extensions/rewind/restore.ts +69 -28
  71. package/extensions/rewind/rewind.ts +80 -3
  72. package/extensions/rewind/runtime.ts +13 -5
  73. package/extensions/rewind/snapshot-run.ts +5 -2
  74. package/extensions/rewind/types.ts +2 -0
  75. package/extensions/subagent/README.md +7 -1
  76. package/extensions/subagent/accounting.ts +34 -0
  77. package/extensions/subagent/index.ts +23 -1
  78. package/extensions/subagent/stream.ts +2 -0
  79. package/extensions/subagent/types.ts +2 -0
  80. package/extensions/watch/README.md +8 -3
  81. package/extensions/watch/index.ts +19 -0
  82. package/extensions/web-access/README.md +43 -13
  83. package/extensions/web-access/charset.ts +58 -0
  84. package/extensions/web-access/fetch/find.ts +11 -7
  85. package/extensions/web-access/fetch/format.ts +64 -18
  86. package/extensions/web-access/fetch/page.ts +5 -1
  87. package/extensions/web-access/fetch/tool.ts +19 -18
  88. package/extensions/web-access/http.ts +49 -8
  89. package/extensions/web-access/search/facts.ts +38 -0
  90. package/extensions/web-access/search/tool.ts +13 -0
  91. package/extensions/web-access/text.ts +50 -11
  92. package/extensions/worktree/README.md +74 -15
  93. package/extensions/worktree/config.ts +11 -0
  94. package/extensions/worktree/exit.ts +35 -3
  95. package/extensions/worktree/gaps.ts +74 -0
  96. package/extensions/worktree/guard.ts +50 -4
  97. package/extensions/worktree/handoff.ts +55 -0
  98. package/extensions/worktree/index.ts +12 -2
  99. package/extensions/worktree/runtime.ts +20 -0
  100. package/extensions/worktree/status.ts +15 -2
  101. package/extensions/worktree/tools.ts +3 -2
  102. package/lib/README.md +10 -8
  103. package/lib/events.ts +86 -1
  104. package/lib/goal-state.ts +95 -0
  105. package/lib/requirements.ts +282 -62
  106. package/lib/tui/README.md +91 -0
  107. package/lib/tui/glyphs.ts +1 -0
  108. package/lib/web/layout.ts +12 -2
  109. package/package.json +5 -3
  110. package/themes/bytefly-dark.json +1 -1
  111. package/assets/chrome.png +0 -0
  112. package/assets/file-browse.png +0 -0
  113. package/assets/job.png +0 -0
  114. package/assets/plan-review.png +0 -0
  115. package/assets/question.png +0 -0
  116. package/assets/rewind.png +0 -0
  117. package/assets/todo.png +0 -0
  118. package/assets/tool-renderings.png +0 -0
  119. package/assets/tools/ask_user_question-cancelled.png +0 -0
  120. package/assets/tools/ask_user_question.png +0 -0
  121. package/assets/tools/bash.png +0 -0
  122. package/assets/tools/edit.png +0 -0
  123. package/assets/tools/enter_plan_mode.png +0 -0
  124. package/assets/tools/enter_worktree.png +0 -0
  125. package/assets/tools/exit_plan_mode.png +0 -0
  126. package/assets/tools/exit_worktree.png +0 -0
  127. package/assets/tools/find.png +0 -0
  128. package/assets/tools/goal.png +0 -0
  129. package/assets/tools/grep.png +0 -0
  130. package/assets/tools/job.png +0 -0
  131. package/assets/tools/list_worktrees.png +0 -0
  132. package/assets/tools/ls.png +0 -0
  133. package/assets/tools/memory-long-subject.png +0 -0
  134. package/assets/tools/memory.png +0 -0
  135. package/assets/tools/notice.png +0 -0
  136. package/assets/tools/open_path.png +0 -0
  137. package/assets/tools/prune_worktrees.png +0 -0
  138. package/assets/tools/read.png +0 -0
  139. package/assets/tools/read_doc-running.png +0 -0
  140. package/assets/tools/read_doc.png +0 -0
  141. package/assets/tools/run_doctor.png +0 -0
  142. package/assets/tools/send_wechat.png +0 -0
  143. package/assets/tools/set_plan_steps.png +0 -0
  144. package/assets/tools/subagent.png +0 -0
  145. package/assets/tools/todo.png +0 -0
  146. package/assets/tools/turn-rule.png +0 -0
  147. package/assets/tools/user-message.png +0 -0
  148. package/assets/tools/watch.png +0 -0
  149. package/assets/tools/web_fetch-rejected.png +0 -0
  150. package/assets/tools/web_fetch.png +0 -0
  151. package/assets/tools/web_search.png +0 -0
  152. package/assets/tools/write.png +0 -0
  153. package/assets/tools/write_plan.png +0 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The plan text as a review shows and compares it (pure).
3
+ *
4
+ * Two needs pull in the same direction. A plan is model-authored text, so it is
5
+ * neutralized before it reaches a terminal or a page; and a review has to compare
6
+ * what it shows against what the previous review showed. Both start from the same
7
+ * normalization — control characters stripped, tabs expanded, stored feedback
8
+ * removed — so the digest a review writes and the digest a later review compares
9
+ * cannot disagree about a plan that did not change.
10
+ *
11
+ * The baseline is one digest per heading section rather than the previous plan
12
+ * text, so it can live in a session entry without copying the plan file into the
13
+ * branch on every review.
14
+ */
15
+
16
+ import { createHash } from "node:crypto";
17
+ import { stripControlChars } from "../../lib/format.ts";
18
+ import { stripFeedback } from "./feedback.ts";
19
+ import { extractPlanSections } from "./steps.ts";
20
+
21
+ /** Spaces a tab expands to; the terminal's own tab stops are not width-modelled. */
22
+ const TAB_WIDTH = 4;
23
+
24
+ /** One heading section's identity and content digest. */
25
+ export interface SectionDigest {
26
+ /** Plain heading text, or `""` for the content before the first heading. */
27
+ title: string;
28
+ /** SHA-256 of the section's normalized text, heading included. */
29
+ digest: string;
30
+ }
31
+
32
+ /**
33
+ * Replace control characters (including ESC and the C1 block) and expand tabs
34
+ * so model-authored plan text cannot drive the terminal. Newlines are kept, so
35
+ * callers can still render or split the text line by line.
36
+ */
37
+ export function sanitizePlanText(text: string): string {
38
+ return stripControlChars(text.replace(/\t/g, " ".repeat(TAB_WIDTH)));
39
+ }
40
+
41
+ /**
42
+ * The text a review compares: terminal-safe, with tabs expanded and stored
43
+ * feedback removed. A review comment a browser wrote into the file is a note to
44
+ * the model, not an edit, so it never reads as a changed section.
45
+ */
46
+ export function reviewText(content: string): string {
47
+ return stripFeedback(sanitizePlanText(content));
48
+ }
49
+
50
+ /** Digest one section's text; the heading line is part of it. */
51
+ function digestOf(text: string): string {
52
+ return createHash("sha256").update(text.trim()).digest("hex");
53
+ }
54
+
55
+ /** One digest per heading section, in document order, for a persisted baseline. */
56
+ export function sectionDigests(content: string): SectionDigest[] {
57
+ return extractPlanSections(reviewText(content)).map((section) => ({
58
+ title: section.title,
59
+ digest: digestOf(section.text),
60
+ }));
61
+ }
62
+
63
+ /** Whether two baselines describe the same content, section for section. */
64
+ export function sameSections(a: readonly SectionDigest[], b: readonly SectionDigest[]): boolean {
65
+ if (a.length !== b.length) return false;
66
+ return a.every((section, index) => section.title === b[index].title && section.digest === b[index].digest);
67
+ }
68
+
69
+ /**
70
+ * Per-section change flags for a second review: `boolean[]` aligned with
71
+ * `extractPlanSections(reviewText(current))`, so duplicate headings are flagged
72
+ * by occurrence rather than by title. An absent baseline flags nothing, which is
73
+ * what a first review of a plan looks like; `stripFeedback` only ever drops
74
+ * quote-marker lines, so the flags stay aligned with the sections a screen
75
+ * renders from the same content.
76
+ */
77
+ export function changedSections(baseline: readonly SectionDigest[] | undefined, current: string): boolean[] {
78
+ const sections = extractPlanSections(reviewText(current));
79
+ if (baseline === undefined) return sections.map(() => false);
80
+
81
+ const before = new Map<string, string[]>();
82
+ for (const section of baseline) {
83
+ const digests = before.get(section.title);
84
+ if (digests) digests.push(section.digest);
85
+ else before.set(section.title, [section.digest]);
86
+ }
87
+
88
+ const seen = new Map<string, number>();
89
+ return sections.map((section) => {
90
+ const index = seen.get(section.title) ?? 0;
91
+ seen.set(section.title, index + 1);
92
+ const prior = before.get(section.title)?.[index];
93
+ return prior === undefined || prior !== digestOf(section.text);
94
+ });
95
+ }
@@ -23,12 +23,14 @@ import {
23
23
  readFileSync,
24
24
  readdirSync,
25
25
  renameSync,
26
+ statSync,
26
27
  unlinkSync,
27
28
  writeFileSync,
28
29
  } from "node:fs";
29
30
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
30
31
  import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
32
  import { repoRootFor } from "../../lib/git/index.ts";
33
+ import { sanitizeLine } from "../../lib/format.ts";
32
34
  import { isInside, isInsideReal } from "../../lib/path.ts";
33
35
  import { formatFeedback, type PlanAnnotation } from "./feedback.ts";
34
36
  import { extractPlanSteps, matchesStepText, setStepStatuses, type StepUpdate } from "./steps.ts";
@@ -53,6 +55,23 @@ export interface WrittenPlan extends StoredPlan {
53
55
  title: string;
54
56
  }
55
57
 
58
+ /** One row of the plan index: enough to choose a plan without opening it. */
59
+ export interface PlanSummary {
60
+ /** Absolute path. */
61
+ path: string;
62
+ /** Path relative to the working directory, when it is inside it. */
63
+ relativePath: string;
64
+ /** The plan's `# title`, or a title derived from the file name. */
65
+ title: string;
66
+ /** File modification time, ms since the epoch. */
67
+ modified: number;
68
+ bytes: number;
69
+ /** Steps done and total, or undefined for a file the store would not have written. */
70
+ steps?: { done: number; total: number };
71
+ /** True when the file is over {@link MAX_PLAN_BYTES} and was listed without a read. */
72
+ oversize?: boolean;
73
+ }
74
+
56
75
  export interface WritePlanInput {
57
76
  /** Short title; drives the file-name slug. */
58
77
  title: string;
@@ -70,6 +89,11 @@ export interface PlanStore {
70
89
  read(cwd: string, planPath: string): Promise<StoredPlan | undefined>;
71
90
  /** The newest plan file in the plans directory, or undefined when there is none. */
72
91
  latest(cwd: string): Promise<StoredPlan | undefined>;
92
+ /**
93
+ * Every plan in the directory, newest first. The directory is the index, so
94
+ * this is one `readdir`, one `stat` per file, and one bounded read per plan.
95
+ */
96
+ index(cwd: string): Promise<PlanSummary[]>;
73
97
  /** Set the given top-level steps' checkboxes in an existing plan. Throws when the file or any step is invalid. */
74
98
  setSteps(cwd: string, planPath: string, updates: StepUpdate[]): Promise<StoredPlan>;
75
99
  /** Replace the plan's stored browser feedback. Throws when the file is missing or outside the plans directory. */
@@ -342,5 +366,70 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
342
366
  return newest ? read(cwd, newest.path) : undefined;
343
367
  };
344
368
 
345
- return { dirFor, write, read, latest, setSteps, saveFeedback };
369
+ /**
370
+ * Every plan in the directory, newest first: the directory is the index.
371
+ *
372
+ * One `readdir`, one `lstat`/`stat` per entry, and one `readFileSync` per plan
373
+ * that fits {@link MAX_PLAN_BYTES}. A larger file is listed as `oversize`
374
+ * without being read — the store would never have written it, and pulling it
375
+ * into memory to print one row is not worth the risk. A symlinked entry, a
376
+ * dotfile, or a file that vanishes mid-walk is simply not a plan.
377
+ */
378
+ const index = async (cwd: string): Promise<PlanSummary[]> => {
379
+ const dir = await dirFor(cwd);
380
+ let names: string[];
381
+ try {
382
+ names = readdirSync(dir);
383
+ } catch {
384
+ return [];
385
+ }
386
+
387
+ const summaries: PlanSummary[] = [];
388
+ for (const name of names) {
389
+ const path = join(dir, name);
390
+ if (!isPlanFileName(path)) continue;
391
+ let info: ReturnType<typeof statSync>;
392
+ try {
393
+ // lstat, not stat: a symlink is never a plan.
394
+ info = lstatSync(path);
395
+ } catch {
396
+ continue;
397
+ }
398
+ if (!info.isFile()) continue;
399
+ if (!isContainedPlan(dir, path)) continue;
400
+
401
+ const summary: PlanSummary = {
402
+ path,
403
+ relativePath: relativeTo(cwd, path),
404
+ title: planTitle(name),
405
+ modified: info.mtimeMs,
406
+ bytes: info.size,
407
+ };
408
+ if (info.size > MAX_PLAN_BYTES) {
409
+ summaries.push({ ...summary, oversize: true });
410
+ continue;
411
+ }
412
+ try {
413
+ const content = readFileSync(path, "utf-8");
414
+ const heading = /^ {0,3}#\s+(.+)$/m.exec(content)?.[1];
415
+ const title = heading === undefined ? "" : sanitizeLine(heading).trim();
416
+ const steps = extractPlanSteps(content);
417
+ summaries.push({
418
+ ...summary,
419
+ ...(title ? { title } : {}),
420
+ steps: { done: steps.filter((step) => step.status === "completed").length, total: steps.length },
421
+ });
422
+ } catch {
423
+ // An unreadable plan is still a file the user may want to see; list the
424
+ // row from its name and leave the content-derived fields out.
425
+ summaries.push(summary);
426
+ }
427
+ }
428
+ // Newest first, with the name (which carries the stamp) as the tie break —
429
+ // the same order `latest` picks from.
430
+ summaries.sort((a, b) => b.modified - a.modified || (a.path < b.path ? 1 : a.path > b.path ? -1 : 0));
431
+ return summaries;
432
+ };
433
+
434
+ return { dirFor, write, read, latest, index, setSteps, saveFeedback };
346
435
  }
@@ -18,6 +18,7 @@ import { askHuman } from "../../lib/interaction.ts";
18
18
  import { FULL_SCREEN_OVERLAY } from "../../lib/tui/chrome.ts";
19
19
  import { feedbackSummary, type PlanAnnotation } from "./feedback.ts";
20
20
  import type { PlanStore, StoredPlan } from "./plans.ts";
21
+ import type { SectionDigest } from "./plan-text.ts";
21
22
  import { extractPlanSteps } from "./steps.ts";
22
23
  import { PlanViewComponent, type PlanViewAction } from "./tui.ts";
23
24
  import type { PlanReviewResult, PlanReviewSession, PlanWebReview } from "./web.ts";
@@ -47,7 +48,9 @@ export type ReviewDecision =
47
48
 
48
49
  /** What the previous review showed, so this one can mark what changed. */
49
50
  export interface ReviewBaselines {
50
- reviewed(path: string): string | undefined;
51
+ /** The sections the last review of `path` showed, when this branch has one. */
52
+ reviewed(path: string): SectionDigest[] | undefined;
53
+ /** Remember what a review just showed, as a session entry on this branch. */
51
54
  markReviewed(path: string, content: string): void;
52
55
  }
53
56
 
@@ -106,7 +109,7 @@ function planReviewDigest(content: string): string {
106
109
  async function askPlanReview(
107
110
  ctx: ExtensionToolContext,
108
111
  plan: StoredPlan,
109
- previous: string | undefined,
112
+ baseline: SectionDigest[] | undefined,
110
113
  ): Promise<PlanReviewChoice> {
111
114
  let sentFullText = false;
112
115
  for (;;) {
@@ -116,7 +119,7 @@ async function askPlanReview(
116
119
  (tui, theme, _keybindings, done) =>
117
120
  new PlanViewComponent({
118
121
  plan,
119
- previous,
122
+ baseline,
120
123
  theme,
121
124
  onClose: (action, refinement) => done({ action, refinement }),
122
125
  requestRender: () => tui.requestRender(),
@@ -156,8 +159,8 @@ async function askPlanReview(
156
159
  export interface BrowserReviewOptions {
157
160
  /** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
158
161
  canApprove: boolean;
159
- /** The content the previous review showed, so the page can mark changed sections. */
160
- previous?: string;
162
+ /** The section digests the previous review showed, for the changed markers. */
163
+ baseline?: SectionDigest[];
161
164
  /** Aborting the turn closes the session as cancelled. */
162
165
  signal?: AbortSignal;
163
166
  /** Called once the server is listening and the browser was opened. */
@@ -176,7 +179,7 @@ export async function runBrowserReview(
176
179
  ): Promise<PlanReviewResult | undefined> {
177
180
  if (options.signal?.aborted) return { status: "cancelled" };
178
181
  const session = await web
179
- .start(cwd, plan, { canApprove: options.canApprove, previous: options.previous, signal: options.signal })
182
+ .start(cwd, plan, { canApprove: options.canApprove, baseline: options.baseline, signal: options.signal })
180
183
  .catch(() => undefined);
181
184
  if (!session) return undefined;
182
185
  options.onOpened?.(session);
@@ -200,8 +203,8 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
200
203
  if (signal?.aborted) return { action: "keep", plan };
201
204
  // Read the baseline before the screen and mark it after, so what the next
202
205
  // review compares against is the content this one actually showed.
203
- const previous = baselines.reviewed(plan.path);
204
- const choice = await askPlanReview(ctx, plan, previous);
206
+ const baseline = baselines.reviewed(plan.path);
207
+ const choice = await askPlanReview(ctx, plan, baseline);
205
208
  baselines.markReviewed(plan.path, plan.content);
206
209
 
207
210
  if (choice.action === "approve") return { action: "approve", plan, comments: [] };
@@ -218,7 +221,7 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
218
221
 
219
222
  const result = await runBrowserReview(web, ctx.cwd, plan, {
220
223
  canApprove: true,
221
- previous,
224
+ baseline,
222
225
  signal,
223
226
  onOpened: (session) => ctx.ui.notify(`Plan review: opened ${session.url}`, "info"),
224
227
  });
@@ -6,7 +6,9 @@
6
6
  * (present the plan for approval) and `write_plan` (save the plan artifact under
7
7
  * `.pi/plans`). Disabling restores exactly what it hid. The enabled flag is
8
8
  * persisted as a custom session entry so `/resume` and `/tree` follow the
9
- * branch, and it is also mirrored into the footer status.
9
+ * branch, and it is also mirrored into the footer status. The review baseline
10
+ * travels the same way, as one digest per heading section, so the changed-section
11
+ * markers survive a resume too.
10
12
  *
11
13
  * `write_plan` is the one sanctioned write while planning. The read-only guard
12
14
  * in `index.ts` permits a control tool only when the registered tool is this
@@ -23,7 +25,8 @@ import type { ReadOnlyPolicy } from "../../lib/policy.ts";
23
25
  import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
24
26
  import { paint } from "../../lib/tui/tokens.ts";
25
27
  import { createPlanStore, type PlanStore } from "./plans.ts";
26
- import type { PlanModeEntry } from "./types.ts";
28
+ import { sameSections, sectionDigests, type SectionDigest } from "./plan-text.ts";
29
+ import type { PlanModeEntry, ReviewBaselineEntry } from "./types.ts";
27
30
 
28
31
  /** Tool the model calls to leave plan mode after approval. */
29
32
  export const EXIT_TOOL = "exit_plan_mode";
@@ -33,8 +36,10 @@ export const ENTER_TOOL = "enter_plan_mode";
33
36
  export const WRITE_PLAN_TOOL = "write_plan";
34
37
  /** Tool the model calls to keep an approved plan's steps current. */
35
38
  export const SET_PLAN_STEPS_TOOL = "set_plan_steps";
36
- /** Custom-entry type used for persistence. */
39
+ /** Custom-entry type used for plan-mode state. */
37
40
  export const STATE_TYPE = "plan-mode";
41
+ /** Custom-entry type used for a plan's review baseline. */
42
+ export const BASELINE_TYPE = "plan-baseline";
38
43
 
39
44
  /** Longest plan-file basename shown in the footer chip. */
40
45
  const STATUS_NAME_LENGTH = 28;
@@ -58,27 +63,60 @@ export interface PlanRuntime {
58
63
  lastPlanPath(): string | undefined;
59
64
  /** Remember a written plan and repaint the footer. */
60
65
  setLastPlan(ctx: ExtensionContext, path: string): void;
61
- /** The content a review last presented for `path`, for the changed markers. */
62
- reviewed(path: string): string | undefined;
63
- /** Remember what a review just presented; memory only, keyed by resolved path. */
66
+ /** The sections the last review of `path` showed, restored with the branch. */
67
+ reviewed(path: string): SectionDigest[] | undefined;
68
+ /** Remember what a review just showed, as a session entry on this branch. */
64
69
  markReviewed(path: string, content: string): void;
65
70
  }
66
71
 
67
- /** The last plan-mode entry on the branch, with the tools it hid. */
68
- function readPersistedState(ctx: ExtensionContext): PlanModeEntry | undefined {
69
- let state: PlanModeEntry | undefined;
72
+ /** What the branch carries: the last plan-mode state, and a baseline per plan. */
73
+ interface BranchState {
74
+ mode: PlanModeEntry | undefined;
75
+ baselines: Map<string, SectionDigest[]>;
76
+ }
77
+
78
+ /** Well-formed `{ title, digest }` pairs from untrusted branch data, or undefined. */
79
+ function readSections(value: unknown): SectionDigest[] | undefined {
80
+ if (!Array.isArray(value)) return undefined;
81
+ const sections: SectionDigest[] = [];
82
+ for (const raw of value) {
83
+ const section = raw as { title?: unknown; digest?: unknown } | null | undefined;
84
+ if (typeof section?.title !== "string" || typeof section.digest !== "string") return undefined;
85
+ sections.push({ title: section.title, digest: section.digest });
86
+ }
87
+ return sections;
88
+ }
89
+
90
+ /**
91
+ * The last plan-mode state and the last review baseline per plan on the branch.
92
+ * One pass, because reading the branch walks the whole session.
93
+ */
94
+ function readBranchState(ctx: ExtensionContext): BranchState {
95
+ let mode: PlanModeEntry | undefined;
96
+ const baselines = new Map<string, SectionDigest[]>();
70
97
  for (const raw of ctx.sessionManager.getBranch()) {
71
98
  const entry = raw as { type?: string; customType?: string; data?: unknown };
72
- if (entry.type !== "custom" || entry.customType !== STATE_TYPE) continue;
73
- const data = entry.data as { enabled?: unknown; removed?: unknown } | null | undefined;
74
- if (typeof data?.enabled !== "boolean") continue;
75
- // Branch data is untrusted; keep only string tool names.
76
- const removed = Array.isArray(data.removed)
77
- ? data.removed.filter((name): name is string => typeof name === "string")
78
- : undefined;
79
- state = removed ? { enabled: data.enabled, removed } : { enabled: data.enabled };
99
+ if (entry.type !== "custom") continue;
100
+ if (entry.customType === STATE_TYPE) {
101
+ const data = entry.data as { enabled?: unknown; removed?: unknown } | null | undefined;
102
+ if (typeof data?.enabled !== "boolean") continue;
103
+ // Branch data is untrusted; keep only string tool names.
104
+ const removed = Array.isArray(data.removed)
105
+ ? data.removed.filter((name): name is string => typeof name === "string")
106
+ : undefined;
107
+ mode = removed ? { enabled: data.enabled, removed } : { enabled: data.enabled };
108
+ continue;
109
+ }
110
+ if (entry.customType !== BASELINE_TYPE) continue;
111
+ const data = entry.data as { path?: unknown; sections?: unknown } | null | undefined;
112
+ if (typeof data?.path !== "string") continue;
113
+ const sections = readSections(data.sections);
114
+ // Last entry per path wins, the same rule the mode entry follows. The key is
115
+ // canonical, so a path recorded on another platform or relative to another cwd
116
+ // still answers a lookup for the plan it names.
117
+ if (sections) baselines.set(resolve(data.path), sections);
80
118
  }
81
- return state;
119
+ return { mode, baselines };
82
120
  }
83
121
 
84
122
  /** Compare two paths, resolving symlinks when possible so `/tmp` and `/private/tmp` match. */
@@ -107,10 +145,10 @@ export function createPlanRuntime(
107
145
  ): PlanRuntime {
108
146
  let enabled = false;
109
147
  let lastPlanPath: string | undefined;
110
- // The content each plan path last showed in a review, so the next review can
111
- // mark what changed. Memory only: a resumed session has no baseline and shows
112
- // no markers, which is better than claiming everything changed.
113
- const reviewedPlans = new Map<string, string>();
148
+ // The section digests each plan path last showed in a review, so the next review
149
+ // can mark what changed. Persisted with the branch, so `/resume` and `/tree`
150
+ // compare against the review this branch actually showed.
151
+ const reviewedPlans = new Map<string, SectionDigest[]>();
114
152
  // Tools plan mode deactivated, so disabling restores exactly those and does
115
153
  // not re-activate a tool another extension intentionally hid.
116
154
  let removedForPlan: string[] = [];
@@ -177,11 +215,12 @@ export function createPlanRuntime(
177
215
  toggle: (ctx) => setEnabled(!enabled, ctx, true),
178
216
  restore: (ctx) => {
179
217
  lastPlanPath = undefined;
180
- // The baseline belongs to the branch that was reviewed: a resumed or
181
- // tree-restored session starts without one rather than marking every section
182
- // changed against a review this branch never showed.
218
+ const branch = readBranchState(ctx);
219
+ // The baseline belongs to the branch that was reviewed: a plan this branch
220
+ // never showed has no digest here, so nothing looks changed until it does.
183
221
  reviewedPlans.clear();
184
- const persisted = readPersistedState(ctx);
222
+ for (const [path, sections] of branch.baselines) reviewedPlans.set(path, sections);
223
+ const persisted = branch.mode;
185
224
  // `--plan` starts a fresh session in plan mode; an explicit persisted
186
225
  // choice (including a later disable) wins over the launch flag.
187
226
  const fromFlag = persisted === undefined && pi.getFlag("plan") === true;
@@ -206,7 +245,13 @@ export function createPlanRuntime(
206
245
  },
207
246
  reviewed: (path) => reviewedPlans.get(resolve(path)),
208
247
  markReviewed: (path, content) => {
209
- reviewedPlans.set(resolve(path), content);
248
+ const key = resolve(path);
249
+ const sections = sectionDigests(content);
250
+ const previous = reviewedPlans.get(key);
251
+ // A screen shown again over the same content must not grow the branch.
252
+ if (previous && sameSections(previous, sections)) return;
253
+ reviewedPlans.set(key, sections);
254
+ pi.appendEntry(BASELINE_TYPE, { path: key, sections } satisfies ReviewBaselineEntry);
210
255
  },
211
256
  };
212
257
  }
@@ -5,7 +5,9 @@
5
5
  * guided to write, the top-level steps that seed `todo` and drive the review
6
6
  * gauge, the heading sections the review screen navigates, and the in-place
7
7
  * checkbox edit that keeps the file current after approval. All of it is pure so
8
- * the tools, the store, and the renderer agree on one parser.
8
+ * the tools, the store, and the renderer agree on one parser. The review's
9
+ * section comparison lives in `plan-text.ts`, which owns the text normalization
10
+ * the digest is computed over.
9
11
  *
10
12
  * Only top-level list items become steps: nested sub-bullets are notes, and the
11
13
  * shallowest list level is treated as the plan. When the plan has a `Steps`
@@ -224,6 +226,38 @@ function elide(content: string): string {
224
226
  return content.length > MAX_STEP_LENGTH ? `${content.slice(0, MAX_STEP_LENGTH - 1).trimEnd()}…` : content;
225
227
  }
226
228
 
229
+ /** A list marker with an optional checkbox, at any indent. */
230
+ const LIST_PREFIX = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s*)?/;
231
+
232
+ /**
233
+ * The plan's `Verification` section as one trailing checklist item, or
234
+ * `undefined` when the plan has none.
235
+ *
236
+ * The section is prose ("commands and checks"), and a todo item is one line, so
237
+ * the lines are stripped of list markers and inline emphasis and joined with
238
+ * `; `, prefixed with `Verify:` and elided like a long step. The item is
239
+ * deliberately *not* a plan step: it is not in the `Steps` section, so it must
240
+ * not count against the step cap or appear in `set_plan_steps` numbering.
241
+ *
242
+ * A plan with no `Steps` heading feeds its shallowest list as steps (the older
243
+ * rule), so that list already covers the verification bullets; appending a
244
+ * summary of them would duplicate them, and there is nothing to carry.
245
+ */
246
+ export function verificationItem(plan: string): PlanStep | undefined {
247
+ if (stepsSection(plan) === undefined) return undefined;
248
+ const section = extractPlanSections(plan).find(
249
+ (candidate) => candidate.level > 0 && candidate.title.toLowerCase() === "verification",
250
+ );
251
+ if (!section) return undefined;
252
+ const items = section.text
253
+ .split("\n")
254
+ .slice(1)
255
+ .map((line) => plainText(line.replace(LIST_PREFIX, "")))
256
+ .filter(Boolean);
257
+ if (items.length === 0) return undefined;
258
+ return { content: elide(`Verify: ${items.join("; ")}`), status: "pending" };
259
+ }
260
+
227
261
  /** Top-level steps from a plan, in order. */
228
262
  export function extractPlanSteps(plan: string): PlanStep[] {
229
263
  return topLevelItems(plan).map((item) => ({ content: elide(item.step.content), status: item.step.status }));
@@ -294,30 +328,6 @@ export function setStepStatuses(plan: string, updates: StepUpdate[]): string | u
294
328
  return lines.join("\n");
295
329
  }
296
330
 
297
- /**
298
- * Per-section change flags for a second review: `boolean[]` aligned with
299
- * `extractPlanSections(current)`, so duplicate headings are flagged by
300
- * occurrence rather than by title. Revision snapshots are the caller's to
301
- * provide; strip stored feedback first so a review comment does not look like
302
- * an edit.
303
- */
304
- export function changedSections(previous: string, current: string): boolean[] {
305
- const before = new Map<string, string[]>();
306
- for (const section of extractPlanSections(previous)) {
307
- const texts = before.get(section.title);
308
- if (texts) texts.push(section.text.trim());
309
- else before.set(section.title, [section.text.trim()]);
310
- }
311
-
312
- const seen = new Map<string, number>();
313
- return extractPlanSections(current).map((section) => {
314
- const index = seen.get(section.title) ?? 0;
315
- seen.set(section.title, index + 1);
316
- const prior = before.get(section.title)?.[index];
317
- return prior === undefined || prior !== section.text.trim();
318
- });
319
- }
320
-
321
331
  /**
322
332
  * Sections of a plan, split at heading lines outside code fences. The content
323
333
  * before the first heading is a section titled `""`. Pi's `Markdown` renderer
@@ -23,6 +23,11 @@ export interface TodoMerge {
23
23
  kept: number;
24
24
  /** Plan steps left out because the list reached its cap. */
25
25
  deferred: number;
26
+ /**
27
+ * What happened to the trailing checklist item (the plan's verification
28
+ * summary): appended after the steps, already present, or left out by the cap.
29
+ */
30
+ trailing?: "added" | "skipped" | "deferred";
26
31
  }
27
32
 
28
33
  /** Fallback list cap when the registered todo tool does not advertise one. */
@@ -38,8 +43,12 @@ function contentKey(content: string): string {
38
43
  * keep their status, a legacy in-progress item without an `activeForm` gets its
39
44
  * content as one so the todo tool accepts it, and the appended steps stop at
40
45
  * `max` items — the rest are counted as deferred, never dropped.
46
+ *
47
+ * `trailing` is the plan's verification summary: it is appended after every step
48
+ * (still subject to the same duplicate and cap rules) but is not counted as a
49
+ * step, because it is not one.
41
50
  */
42
- export function mergeTodos(existing: ExistingTodo[], steps: PlanStep[], max: number): TodoMerge {
51
+ export function mergeTodos(existing: ExistingTodo[], steps: PlanStep[], max: number, trailing?: PlanStep): TodoMerge {
43
52
  const limit = Number.isInteger(max) && max > 0 ? max : DEFAULT_TODO_MAX;
44
53
  const todos: ExistingTodo[] = existing.map((todo) =>
45
54
  todo.status === "in_progress" && !todo.activeForm ? { ...todo, activeForm: todo.content } : todo,
@@ -63,5 +72,18 @@ export function mergeTodos(existing: ExistingTodo[], steps: PlanStep[], max: num
63
72
  todos.push({ content: step.content, status: step.status });
64
73
  added += 1;
65
74
  }
66
- return { todos, added, skipped, kept: existing.length, deferred };
75
+
76
+ let trailingOutcome: TodoMerge["trailing"];
77
+ if (trailing) {
78
+ const key = contentKey(trailing.content);
79
+ if (!key) trailingOutcome = undefined;
80
+ else if (seen.has(key)) trailingOutcome = "skipped";
81
+ else if (todos.length >= limit) trailingOutcome = "deferred";
82
+ else {
83
+ todos.push({ content: trailing.content, status: trailing.status });
84
+ trailingOutcome = "added";
85
+ }
86
+ }
87
+
88
+ return { todos, added, skipped, kept: existing.length, deferred, trailing: trailingOutcome };
67
89
  }