@gavin-hu/my-pi-agent 0.12.0 → 0.13.1

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 (167) hide show
  1. package/README.md +28 -21
  2. package/README.zh-CN.md +30 -22
  3. package/extensions/bytefly/README.md +162 -70
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +38 -6
  7. package/extensions/bytefly/footer/budget.ts +64 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +33 -5
  9. package/extensions/bytefly/footer/config.ts +7 -1
  10. package/extensions/bytefly/footer/delegation.ts +100 -0
  11. package/extensions/bytefly/footer/price/config.ts +16 -0
  12. package/extensions/bytefly/footer/price/types.ts +5 -0
  13. package/extensions/bytefly/footer/register.ts +46 -4
  14. package/extensions/bytefly/footer/render/lines.ts +2 -0
  15. package/extensions/bytefly/footer/render/segments.ts +27 -0
  16. package/extensions/bytefly/footer/types.ts +11 -0
  17. package/extensions/bytefly/index.ts +8 -5
  18. package/extensions/bytefly/notify/config.ts +2 -0
  19. package/extensions/bytefly/notify/format.ts +19 -1
  20. package/extensions/bytefly/notify/register.ts +52 -14
  21. package/extensions/bytefly/notify/types.ts +2 -0
  22. package/extensions/bytefly/panel/commands.ts +34 -13
  23. package/extensions/bytefly/panel/settings.ts +129 -16
  24. package/extensions/bytefly/update/cache.ts +73 -0
  25. package/extensions/bytefly/update/format.ts +65 -10
  26. package/extensions/bytefly/update/register.ts +99 -13
  27. package/extensions/bytefly/update/state.ts +13 -1
  28. package/extensions/document/README.md +22 -12
  29. package/extensions/document/config.ts +4 -0
  30. package/extensions/document/deadline.ts +61 -0
  31. package/extensions/document/tool.ts +13 -2
  32. package/extensions/file-browse/README.md +12 -8
  33. package/extensions/file-browse/client.ts +6 -7
  34. package/extensions/file-browse/files.ts +0 -4
  35. package/extensions/file-browse/git.ts +9 -10
  36. package/extensions/file-browse/html.ts +17 -7
  37. package/extensions/file-browse/router.ts +24 -15
  38. package/extensions/goal/README.md +7 -1
  39. package/extensions/goal/schema.ts +6 -2
  40. package/extensions/goal/types.ts +6 -2
  41. package/extensions/job/README.md +16 -12
  42. package/extensions/job/format.ts +4 -7
  43. package/extensions/job/runtime.ts +23 -6
  44. package/extensions/job/status.ts +4 -2
  45. package/extensions/memory/README.md +55 -13
  46. package/extensions/memory/commands.ts +20 -2
  47. package/extensions/memory/doctor.ts +137 -0
  48. package/extensions/memory/format.ts +24 -7
  49. package/extensions/memory/runtime.ts +49 -0
  50. package/extensions/memory/store.ts +31 -0
  51. package/extensions/memory/tui.ts +136 -12
  52. package/extensions/plan/README.md +60 -29
  53. package/extensions/plan/commands.ts +67 -8
  54. package/extensions/plan/index.ts +35 -11
  55. package/extensions/plan/plan-index.ts +234 -0
  56. package/extensions/plan/plan-text.ts +95 -0
  57. package/extensions/plan/plans.ts +90 -1
  58. package/extensions/plan/review.ts +12 -9
  59. package/extensions/plan/runtime.ts +72 -27
  60. package/extensions/plan/steps.ts +35 -25
  61. package/extensions/plan/todos.ts +24 -2
  62. package/extensions/plan/tools.ts +124 -24
  63. package/extensions/plan/tui.ts +7 -28
  64. package/extensions/plan/types.ts +9 -0
  65. package/extensions/plan/web-page.ts +2 -2
  66. package/extensions/plan/web.ts +12 -11
  67. package/extensions/rewind/README.md +28 -8
  68. package/extensions/rewind/commands.ts +1 -1
  69. package/extensions/rewind/format.ts +4 -4
  70. package/extensions/rewind/git.ts +56 -18
  71. package/extensions/rewind/index.ts +3 -5
  72. package/extensions/rewind/path-picker.ts +198 -0
  73. package/extensions/rewind/paths.ts +128 -0
  74. package/extensions/rewind/restore.ts +69 -28
  75. package/extensions/rewind/rewind.ts +80 -3
  76. package/extensions/rewind/runtime.ts +13 -5
  77. package/extensions/rewind/snapshot-run.ts +5 -2
  78. package/extensions/rewind/types.ts +2 -0
  79. package/extensions/subagent/README.md +7 -1
  80. package/extensions/subagent/accounting.ts +34 -0
  81. package/extensions/subagent/index.ts +23 -1
  82. package/extensions/subagent/stream.ts +2 -0
  83. package/extensions/subagent/types.ts +2 -0
  84. package/extensions/todo/nudge.ts +5 -1
  85. package/extensions/watch/README.md +19 -8
  86. package/extensions/watch/commands.ts +4 -3
  87. package/extensions/watch/config.ts +1 -1
  88. package/extensions/watch/format.ts +4 -7
  89. package/extensions/watch/index.ts +19 -0
  90. package/extensions/watch/runtime.ts +83 -12
  91. package/extensions/web-access/README.md +43 -13
  92. package/extensions/web-access/charset.ts +58 -0
  93. package/extensions/web-access/fetch/find.ts +11 -7
  94. package/extensions/web-access/fetch/format.ts +64 -18
  95. package/extensions/web-access/fetch/page.ts +5 -1
  96. package/extensions/web-access/fetch/tool.ts +19 -18
  97. package/extensions/web-access/http.ts +49 -8
  98. package/extensions/web-access/search/facts.ts +38 -0
  99. package/extensions/web-access/search/tool.ts +13 -0
  100. package/extensions/web-access/text.ts +50 -11
  101. package/extensions/worktree/README.md +81 -16
  102. package/extensions/worktree/config.ts +11 -0
  103. package/extensions/worktree/enter.ts +10 -3
  104. package/extensions/worktree/exit.ts +55 -3
  105. package/extensions/worktree/gaps.ts +74 -0
  106. package/extensions/worktree/git.ts +28 -0
  107. package/extensions/worktree/guard.ts +50 -4
  108. package/extensions/worktree/handoff.ts +55 -0
  109. package/extensions/worktree/index.ts +14 -2
  110. package/extensions/worktree/root-tools.ts +1 -1
  111. package/extensions/worktree/runtime.ts +25 -0
  112. package/extensions/worktree/status.ts +15 -2
  113. package/extensions/worktree/tools.ts +3 -2
  114. package/lib/README.md +14 -10
  115. package/lib/events.ts +86 -1
  116. package/lib/goal-state.ts +95 -0
  117. package/lib/requirements.ts +361 -62
  118. package/lib/tui/README.md +91 -0
  119. package/lib/tui/columns.ts +17 -0
  120. package/lib/tui/glyphs.ts +1 -0
  121. package/lib/tui/tool.ts +6 -2
  122. package/lib/web/layout.ts +12 -2
  123. package/package.json +5 -3
  124. package/themes/bytefly-dark.json +1 -1
  125. package/assets/chrome.png +0 -0
  126. package/assets/file-browse.png +0 -0
  127. package/assets/job.png +0 -0
  128. package/assets/plan-review.png +0 -0
  129. package/assets/question.png +0 -0
  130. package/assets/rewind.png +0 -0
  131. package/assets/todo.png +0 -0
  132. package/assets/tool-renderings.png +0 -0
  133. package/assets/tools/ask_user_question-cancelled.png +0 -0
  134. package/assets/tools/ask_user_question.png +0 -0
  135. package/assets/tools/bash.png +0 -0
  136. package/assets/tools/edit.png +0 -0
  137. package/assets/tools/enter_plan_mode.png +0 -0
  138. package/assets/tools/enter_worktree.png +0 -0
  139. package/assets/tools/exit_plan_mode.png +0 -0
  140. package/assets/tools/exit_worktree.png +0 -0
  141. package/assets/tools/find.png +0 -0
  142. package/assets/tools/goal.png +0 -0
  143. package/assets/tools/grep.png +0 -0
  144. package/assets/tools/job.png +0 -0
  145. package/assets/tools/list_worktrees.png +0 -0
  146. package/assets/tools/ls.png +0 -0
  147. package/assets/tools/memory-long-subject.png +0 -0
  148. package/assets/tools/memory.png +0 -0
  149. package/assets/tools/notice.png +0 -0
  150. package/assets/tools/open_path.png +0 -0
  151. package/assets/tools/prune_worktrees.png +0 -0
  152. package/assets/tools/read.png +0 -0
  153. package/assets/tools/read_doc-running.png +0 -0
  154. package/assets/tools/read_doc.png +0 -0
  155. package/assets/tools/run_doctor.png +0 -0
  156. package/assets/tools/send_wechat.png +0 -0
  157. package/assets/tools/set_plan_steps.png +0 -0
  158. package/assets/tools/subagent.png +0 -0
  159. package/assets/tools/todo.png +0 -0
  160. package/assets/tools/turn-rule.png +0 -0
  161. package/assets/tools/user-message.png +0 -0
  162. package/assets/tools/watch.png +0 -0
  163. package/assets/tools/web_fetch-rejected.png +0 -0
  164. package/assets/tools/web_fetch.png +0 -0
  165. package/assets/tools/web_search.png +0 -0
  166. package/assets/tools/write.png +0 -0
  167. package/assets/tools/write_plan.png +0 -0
@@ -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
  }
@@ -19,7 +19,8 @@ import { StringEnum } from "@earendil-works/pi-ai";
19
19
  import type { ExtensionAPI, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
20
20
  import { Type, type Static } from "typebox";
21
21
  import { sanitizeLine } from "../../lib/format.ts";
22
- import { TODO_TOOL } from "../../lib/tool-names.ts";
22
+ import { lastGoal } from "../../lib/goal-state.ts";
23
+ import { GOAL_TOOL, TODO_TOOL } from "../../lib/tool-names.ts";
23
24
  import { lastTodos } from "../../lib/todo-state.ts";
24
25
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
25
26
  import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
@@ -34,13 +35,15 @@ import {
34
35
  PLAN_OUTLINE,
35
36
  extractPlanSteps,
36
37
  planStepSummary,
38
+ verificationItem,
37
39
  type PlanStep,
38
- type PlanStepSummary,
39
40
  type StepUpdate,
40
41
  } from "./steps.ts";
42
+ import { basename } from "node:path";
43
+ import { planTitle, type StoredPlan } from "./plans.ts";
41
44
  import { DEFAULT_TODO_MAX, mergeTodos, type TodoMerge } from "./todos.ts";
42
45
  import { reviewPlan } from "./review.ts";
43
- import { sanitizePlanText } from "./tui.ts";
46
+ import { sanitizePlanText } from "./plan-text.ts";
44
47
  import type { EnterPlanModeDetails, ExitPlanModeDetails, SetPlanStepsDetails, WritePlanDetails } from "./types.ts";
45
48
  import { createPlanWebReview, type PlanWebReview } from "./web.ts";
46
49
 
@@ -116,20 +119,21 @@ function preview(plan: string): { body: string; more: number } {
116
119
  }
117
120
 
118
121
  /**
119
- * One sentence for `write_plan` when the step list is larger than approval can
120
- * track, so the model can shorten it instead of discovering the cap later.
122
+ * One sentence when a step is longer than the display cap. The text stays in the
123
+ * plan file either way; only the todo list and the approval listing shorten it.
121
124
  */
122
- function stepCapNote({ total, elided }: PlanStepSummary): string {
123
- const notes: string[] = [];
124
- if (total > MAX_STEPS) {
125
- notes.push(`the plan has ${total} steps and only the first ${MAX_STEPS} can be seeded or ticked`);
126
- }
127
- if (elided > 0) {
128
- notes.push(
129
- `${elided} step${elided === 1 ? " is" : "s are"} longer than ${MAX_STEP_LENGTH} characters and will be shortened in the todo list`,
130
- );
131
- }
132
- return notes.length > 0 ? ` Note: ${notes.join("; ")}.` : "";
125
+ function stepElisionNote(elided: number): string {
126
+ if (elided === 0) return "";
127
+ const plural = elided === 1 ? " is" : "s are";
128
+ return ` Note: ${elided} step${plural} longer than ${MAX_STEP_LENGTH} characters and will be shortened in the todo list.`;
129
+ }
130
+
131
+ /**
132
+ * The diagnosis both cap refusals share: a step list longer than approval can
133
+ * track. Each caller appends the fix that is available from where it stands.
134
+ */
135
+ function overCap(total: number): string {
136
+ return `The plan has ${total} top-level steps but approval tracks at most ${MAX_STEPS}.`;
133
137
  }
134
138
 
135
139
  /** "Step 2" or "Steps 1, 2" for a set of step numbers. */
@@ -171,11 +175,12 @@ async function seedTodos(
171
175
  plan: string,
172
176
  ): Promise<{ steps: PlanStep[]; merged?: TodoMerge; error?: string }> {
173
177
  const steps = extractPlanSteps(plan);
174
- if (steps.length === 0) return { steps };
175
- const merged = mergeTodos(lastTodos(ctx.sessionManager.getBranch()), steps, todoMaxItems(pi));
176
- // Nothing new to write: every step is already in the list (or the list is
177
- // full), so a whole-list rewrite would only churn the todo widget.
178
- if (merged.added === 0) return { steps, merged };
178
+ const verification = verificationItem(plan);
179
+ if (steps.length === 0 && !verification) return { steps };
180
+ const merged = mergeTodos(lastTodos(ctx.sessionManager.getBranch()), steps, todoMaxItems(pi), verification);
181
+ // Nothing new to write: every step (and the verification item) is already in
182
+ // the list, or the list is full, so a rewrite would only churn the widget.
183
+ if (merged.added === 0 && merged.trailing !== "added") return { steps, merged };
179
184
  const outcome = await ctx.executeTool(TODO_TOOL, { todos: merged.todos });
180
185
  if (!outcome.isError) return { steps, merged };
181
186
  // The nested call fails both when the tool is unavailable and when it rejects
@@ -185,6 +190,56 @@ async function seedTodos(
185
190
  return { steps, merged, error: firstLine ? sanitizeLine(firstLine) : undefined };
186
191
  }
187
192
 
193
+ /** What the goal offer did: `recorded` undefined when there was nothing to offer. */
194
+ interface GoalOffer {
195
+ objective: string;
196
+ recorded?: boolean;
197
+ error?: string;
198
+ }
199
+
200
+ /**
201
+ * Offer to record the approved plan as the session goal — only when the session
202
+ * has no goal at all (an active or achieved one is the user's, never replaced) and
203
+ * the session can ask. The objective is the plan's own `# title`, or the file-name
204
+ * slug; recording runs through the goal tool, so validation and the widget stay
205
+ * goal's, and no model turn is spent.
206
+ */
207
+ async function offerSessionGoal(ctx: ExtensionToolContext, plan: StoredPlan): Promise<GoalOffer> {
208
+ const objective = planObjective(plan);
209
+ if (!ctx.hasUI) return { objective };
210
+ // The branch already carries a goal: leave it alone, and say nothing.
211
+ if (lastGoal(ctx.sessionManager.getBranch()) !== null) return { objective };
212
+ const approved = await ctx.ui.confirm("Record this plan as the session goal?", objective);
213
+ if (!approved) return { objective };
214
+ const outcome = await ctx.executeTool(GOAL_TOOL, { objective });
215
+ if (!outcome.isError) return { objective, recorded: true };
216
+ const part = outcome.result?.content?.find((entry) => entry.type === "text");
217
+ const firstLine = part?.type === "text" ? part.text.split("\n", 1)[0] : undefined;
218
+ return { objective, recorded: false, error: firstLine ? sanitizeLine(firstLine) : undefined };
219
+ }
220
+
221
+ /** The plan's own title: its first `# heading`, else the file-name slug. */
222
+ function planObjective(plan: StoredPlan): string {
223
+ const heading = /^ {0,3}#\s+(.+)$/m.exec(plan.content)?.[1];
224
+ const title = heading === undefined ? "" : sanitizeLine(heading).trim();
225
+ return title || planTitle(basename(plan.path));
226
+ }
227
+
228
+ /**
229
+ * One sentence about the plan's verification item, or "" when the plan has none
230
+ * (or it was already in the list). The item is a todo, not a plan step, so it is
231
+ * ticked in the list rather than in the file.
232
+ */
233
+ function verificationNote(merged: TodoMerge | undefined): string {
234
+ if (merged?.trailing === "added") {
235
+ return "\nThe plan's verification section is the last todo item; tick it in the todo list, not in the file.";
236
+ }
237
+ if (merged?.trailing === "deferred") {
238
+ return "\nThe plan's verification section did not fit in the full todo list; keep it in mind.";
239
+ }
240
+ return "";
241
+ }
242
+
188
243
  /** What the merge did, for the approval message; `steps` is the plan's step count. */
189
244
  function mergeSummary(merged: TodoMerge, steps: PlanStep[]): string {
190
245
  const total = steps.length;
@@ -287,7 +342,8 @@ export function registerTools(
287
342
  "Save the plan you designed as a markdown file under .pi/plans (project root, or the agent directory outside a " +
288
343
  "repository). Write the complete plan, not a summary, in this outline so it is consistent and scannable:\n\n" +
289
344
  `${PLAN_OUTLINE}\n\n` +
290
- "Returns the path to pass to exit_plan_mode; plan_path overwrites an existing file when refining.",
345
+ "Returns the path to pass to exit_plan_mode; plan_path overwrites an existing file when refining. " +
346
+ `A plan with more than ${MAX_STEPS} top-level steps in its Steps section is refused, so approval never drops steps it cannot track.`,
291
347
  promptSnippet: "Save the designed plan as a markdown file under .pi/plans.",
292
348
  promptGuidelines: [
293
349
  "While plan mode is active, write the full plan with write_plan, then call exit_plan_mode with the returned path.",
@@ -308,6 +364,21 @@ export function registerTools(
308
364
  };
309
365
  }
310
366
  const args = params as WritePlanArgs;
367
+ const summary = planStepSummary(args.content);
368
+ if (summary.total > MAX_STEPS) {
369
+ // Refuse before writing: a note after the fact arrives once the file exists and
370
+ // the review has listed every step, so the overflow is already lost.
371
+ return {
372
+ content: [
373
+ {
374
+ type: "text",
375
+ text: `${overCap(summary.total)} Nothing was written; group the steps (one outcome each, sub-bullets for detail) and call write_plan again.`,
376
+ },
377
+ ],
378
+ details: undefined,
379
+ isError: true,
380
+ };
381
+ }
311
382
  try {
312
383
  const file = await runtime.plans.write(ctx.cwd, {
313
384
  title: args.title,
@@ -319,7 +390,7 @@ export function registerTools(
319
390
  content: [
320
391
  {
321
392
  type: "text",
322
- text: `Wrote the plan to ${file.relativePath}. Call exit_plan_mode with plan_path: ${file.path}${stepCapNote(planStepSummary(args.content))}`,
393
+ text: `Wrote the plan to ${file.relativePath}. Call exit_plan_mode with plan_path: ${file.path}${stepElisionNote(summary.elided)}`,
323
394
  },
324
395
  ],
325
396
  details: { path: file.path, relativePath: file.relativePath, bytes: file.bytes } satisfies WritePlanDetails,
@@ -394,6 +465,27 @@ export function registerTools(
394
465
  };
395
466
  }
396
467
 
468
+ const total = planStepSummary(file.content).total;
469
+ if (total > MAX_STEPS) {
470
+ // A hand-edited or older plan file can still be over the cap; refuse it here
471
+ // too, so approval never seeds a list it cannot hold in full.
472
+ return {
473
+ content: [
474
+ {
475
+ type: "text",
476
+ text: `${overCap(total)} It was not presented for approval; call write_plan with plan_path to group the steps, then exit_plan_mode again.`,
477
+ },
478
+ ],
479
+ details: {
480
+ approved: false,
481
+ plan: file.content,
482
+ planPath: file.path,
483
+ relativePath: file.relativePath,
484
+ } satisfies ExitPlanModeDetails,
485
+ isError: true,
486
+ };
487
+ }
488
+
397
489
  if (!ctx.hasUI) {
398
490
  return {
399
491
  content: [
@@ -464,6 +556,7 @@ export function registerTools(
464
556
 
465
557
  runtime.disable(ctx);
466
558
  const { steps, merged, error } = await seedTodos(pi, ctx, decision.plan.content);
559
+ const goal = await offerSessionGoal(ctx, decision.plan);
467
560
  const listing =
468
561
  steps.length > 0
469
562
  ? `\n\n${steps
@@ -478,6 +571,13 @@ export function registerTools(
478
571
  : merged
479
572
  ? mergeSummary(merged, steps)
480
573
  : ` Could not read the todo list. Record these steps with the todo tool before you start, and tick them off with ${SET_PLAN_STEPS_TOOL}.`;
574
+ const verification = verificationNote(merged);
575
+ const goalNote =
576
+ goal.recorded === undefined
577
+ ? ""
578
+ : goal.recorded
579
+ ? ` Recorded the session goal: ${goal.objective}.`
580
+ : ` Could not record the session goal: ${goal.error ?? "unknown error"}. Use the goal tool to set it.`;
481
581
  const browserNote = decision.comments.length
482
582
  ? `\n\nThe user approved from the browser review with these comments:\n${feedbackSummary(decision.comments)}`
483
583
  : "";
@@ -485,7 +585,7 @@ export function registerTools(
485
585
  content: [
486
586
  {
487
587
  type: "text",
488
- text: `Plan approved. Plan mode is off and write access is restored. The plan file ${decision.plan.relativePath} is the source of truth; follow its steps.${browserNote}${tail}${listing}`,
588
+ text: `Plan approved. Plan mode is off and write access is restored. The plan file ${decision.plan.relativePath} is the source of truth; follow its steps.${browserNote}${tail}${verification}${goalNote}${listing}`,
489
589
  },
490
590
  ],
491
591
  details: {
@@ -1,10 +1,6 @@
1
1
  /**
2
2
  * Terminal rendering for the plan review screen.
3
3
  *
4
- * `sanitizePlanText` is exported so the transcript renderer (`tools.ts`) can
5
- * strip control characters from plan text with the same rules the review screen
6
- * uses.
7
- *
8
4
  * `PlanViewComponent` is the scrollable review screen `exit_plan_mode` opens
9
5
  * for approval. The plan body is rendered by Pi's own `Markdown` component with
10
6
  * the active Markdown theme, so headings, lists, code, links, and quotes match
@@ -30,7 +26,7 @@ import {
30
26
  type TuiMouseEvent,
31
27
  type TuiMouseEventResult,
32
28
  } from "@earendil-works/pi-tui";
33
- import { sanitizeLine, stripControlChars } from "../../lib/format.ts";
29
+ import { sanitizeLine } from "../../lib/format.ts";
34
30
  import {
35
31
  editorTheme,
36
32
  screenHint,
@@ -45,8 +41,8 @@ import { titleRule } from "../../lib/tui/rules.ts";
45
41
  import { emptyState, checkbox } from "../../lib/tui/rows.ts";
46
42
  import { paint } from "../../lib/tui/tokens.ts";
47
43
  import { planTitle, type StoredPlan } from "./plans.ts";
48
- import { changedSections, extractPlanSections } from "./steps.ts";
49
- import { stripFeedback } from "./feedback.ts";
44
+ import { changedSections, sanitizePlanText, type SectionDigest } from "./plan-text.ts";
45
+ import { extractPlanSections } from "./steps.ts";
50
46
 
51
47
  /** What the user chose from the read/review screen. */
52
48
  export type PlanViewAction = "approve" | "refine" | "keep" | "browser";
@@ -71,10 +67,10 @@ interface BodyCache {
71
67
  export interface PlanViewOptions {
72
68
  plan: StoredPlan;
73
69
  /**
74
- * The content the previous review of this plan showed, when there was one.
75
- * Used only to mark the sections that changed since then.
70
+ * The section digests the previous review of this plan showed, when there was
71
+ * one. Used only to mark the sections that changed since then.
76
72
  */
77
- previous?: string;
73
+ baseline?: SectionDigest[];
78
74
  theme: Theme;
79
75
  /**
80
76
  * Called once when the screen closes. `refinement` carries the typed text
@@ -110,17 +106,6 @@ const PRIMARY_HINTS: ScreenHint[] = [
110
106
  const SCROLL_HINTS: ScreenHint[] = ["↑/↓ or k/j scroll", "space/b page", "g/G ends"];
111
107
  /** Section-jump hint, shown when the plan has more than one heading section. */
112
108
  const SECTION_HINTS: ScreenHint[] = ["n/p section"];
113
- /** Spaces a tab expands to; the terminal's own tab stops are not width-modelled. */
114
- const TAB_WIDTH = 4;
115
-
116
- /**
117
- * Replace control characters (including ESC and the C1 block) and expand tabs
118
- * so model-authored plan text cannot drive the terminal. Newlines are kept, so
119
- * callers can still render or split the text line by line.
120
- */
121
- export function sanitizePlanText(text: string): string {
122
- return stripControlChars(text.replace(/\t/g, " ".repeat(TAB_WIDTH)));
123
- }
124
109
 
125
110
  /** A task checkbox in a list marker: `- [x] `, `1. [ ] `. */
126
111
  const TASK_MARKER = /^([-*+]|\d+[.)])(\s+)(\[([ xX])\])(\s*)$/;
@@ -210,13 +195,7 @@ export class PlanViewComponent implements Component {
210
195
  const base = this.options.markdownTheme ?? getMarkdownTheme();
211
196
  const theme = planMarkdownTheme(this.theme, base);
212
197
  const current = sanitizePlanText(this.options.plan.content);
213
- // Both sides of the diff get the same normalization: the baseline is stored
214
- // raw, so sanitizing only `current` would report a tab as a change on every
215
- // review.
216
- const changed =
217
- this.options.previous === undefined
218
- ? []
219
- : changedSections(stripFeedback(sanitizePlanText(this.options.previous)), stripFeedback(current));
198
+ const changed = changedSections(this.options.baseline, current);
220
199
  const chunks = extractPlanSections(current);
221
200
  const lines: string[] = [];
222
201
  const sections: SectionOffset[] = [];
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { PlanAnnotation } from "./feedback.ts";
6
+ import type { SectionDigest } from "./plan-text.ts";
6
7
  import type { StepUpdate } from "./steps.ts";
7
8
 
8
9
  /** Persisted (custom entry) plan-mode state. */
@@ -15,6 +16,14 @@ export interface PlanModeEntry {
15
16
  removed?: string[];
16
17
  }
17
18
 
19
+ /** Persisted (custom entry) review baseline for one plan. */
20
+ export interface ReviewBaselineEntry {
21
+ /** Resolved path of the plan file the review showed. */
22
+ path: string;
23
+ /** One digest per heading section, in document order. */
24
+ sections: SectionDigest[];
25
+ }
26
+
18
27
  /** Structured result carried in `enter_plan_mode`'s `details`. */
19
28
  export interface EnterPlanModeDetails {
20
29
  /** Whether the user agreed to enter plan mode. */
@@ -16,6 +16,7 @@
16
16
  import {
17
17
  brandLockup,
18
18
  docHead,
19
+ SIDEBAR_COLLAPSE_MAX,
19
20
  themeToggle,
20
21
  WEB_BASE_CSS,
21
22
  WEB_COMPONENTS_CSS,
@@ -90,7 +91,6 @@ ${WEB_COMPONENTS_CSS}
90
91
  .review-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
91
92
  /* Review panel: the plan's review facts, the section outline, then the keys. */
92
93
  .bf-app__body--single{grid-template-columns:minmax(0,1fr)}
93
- .bf-app__sidebar .bf-panel{flex:1 1 auto;min-height:0;min-width:0}
94
94
  .review-panel{display:flex;flex-direction:column;gap:var(--space-3);padding:var(--space-4) var(--space-4) var(--space-2)}
95
95
  .review-facts{display:flex;flex-direction:column;gap:var(--space-2);padding-bottom:var(--space-3);border-bottom:1px solid var(--border)}
96
96
  .review-fact{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);font-size:var(--fs-sm);color:var(--muted)}
@@ -172,7 +172,7 @@ ${WEB_COMPONENTS_CSS}
172
172
  .review-nav .lvl-2 a,.review-nav .lvl-3 a,.review-nav .lvl-4 a,.review-nav .lvl-5 a,.review-nav .lvl-6 a{padding-left:8px}
173
173
  .review-hints{display:none}
174
174
  }
175
- @media (max-width:860px){
175
+ @media (max-width:${SIDEBAR_COLLAPSE_MAX}px){
176
176
  .bf-review{padding:var(--space-4) 18px 80px}
177
177
  .review-nav__title{display:none}
178
178
  }