@gavin-hu/my-pi-agent 0.17.0 → 0.18.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 (112) hide show
  1. package/README.md +17 -9
  2. package/README.zh-CN.md +16 -9
  3. package/extensions/bytefly/README.md +25 -18
  4. package/extensions/bytefly/notify/types.ts +1 -1
  5. package/extensions/bytefly/panel/settings.ts +25 -18
  6. package/extensions/file-browse/README.md +60 -21
  7. package/extensions/file-browse/chrome.ts +83 -0
  8. package/extensions/file-browse/client.ts +62 -0
  9. package/extensions/file-browse/config.ts +12 -3
  10. package/extensions/file-browse/diff-html.ts +94 -0
  11. package/extensions/file-browse/html.ts +19 -147
  12. package/extensions/file-browse/live-client.ts +47 -1
  13. package/extensions/file-browse/live.ts +35 -14
  14. package/extensions/file-browse/rev.ts +42 -0
  15. package/extensions/file-browse/router.ts +25 -4
  16. package/extensions/file-browse/server.ts +7 -0
  17. package/extensions/file-browse/styles.ts +2 -0
  18. package/extensions/goal/plan-route.ts +8 -2
  19. package/extensions/job/README.md +7 -5
  20. package/extensions/job/index.ts +15 -7
  21. package/extensions/memory/README.md +3 -3
  22. package/extensions/plan/README.md +280 -86
  23. package/extensions/plan/commands.ts +56 -5
  24. package/extensions/plan/feedback.ts +300 -63
  25. package/extensions/plan/index.ts +1 -1
  26. package/extensions/plan/plan-index.ts +1 -1
  27. package/extensions/plan/plan-text.ts +18 -1
  28. package/extensions/plan/plans.ts +165 -57
  29. package/extensions/plan/review-prompt.ts +93 -0
  30. package/extensions/plan/review.ts +78 -98
  31. package/extensions/plan/runtime.ts +1 -1
  32. package/extensions/plan/split.ts +87 -0
  33. package/extensions/plan/steps.ts +80 -93
  34. package/extensions/plan/tools/enter.ts +8 -3
  35. package/extensions/plan/tools/exit.ts +101 -23
  36. package/extensions/plan/tools/write.ts +8 -3
  37. package/extensions/plan/tools.ts +1 -1
  38. package/extensions/plan/tui.ts +51 -8
  39. package/extensions/plan/types.ts +37 -3
  40. package/extensions/plan/web-chrome.ts +2 -1
  41. package/extensions/plan/web-client.ts +121 -177
  42. package/extensions/plan/web-collab.ts +207 -0
  43. package/extensions/plan/web-comments.ts +269 -0
  44. package/extensions/plan/web-page.ts +103 -17
  45. package/extensions/plan/web-router.ts +146 -30
  46. package/extensions/plan/web-share-session.ts +197 -0
  47. package/extensions/plan/web-share.ts +169 -0
  48. package/extensions/plan/web-streams.ts +124 -0
  49. package/extensions/plan/web-tokens.ts +63 -0
  50. package/extensions/plan/web.ts +193 -45
  51. package/extensions/question/README.md +3 -0
  52. package/extensions/question/dialogs.ts +91 -1
  53. package/extensions/question/tools.ts +2 -1
  54. package/extensions/session-bus/README.md +247 -0
  55. package/extensions/session-bus/commands.ts +165 -0
  56. package/extensions/session-bus/format.ts +124 -0
  57. package/extensions/session-bus/index.ts +69 -0
  58. package/extensions/session-bus/notice.ts +58 -0
  59. package/extensions/session-bus/protocol.ts +134 -0
  60. package/extensions/session-bus/result.ts +243 -0
  61. package/extensions/session-bus/rows.ts +169 -0
  62. package/extensions/session-bus/runtime.ts +575 -0
  63. package/extensions/session-bus/store.ts +329 -0
  64. package/extensions/session-bus/team.ts +102 -0
  65. package/extensions/session-bus/tools.ts +227 -0
  66. package/extensions/session-bus/tui.ts +394 -0
  67. package/extensions/session-bus/types.ts +98 -0
  68. package/extensions/subagent/README.md +1 -1
  69. package/extensions/watch/README.md +9 -5
  70. package/extensions/watch/index.ts +15 -9
  71. package/extensions/wechat/README.md +20 -33
  72. package/extensions/wechat/bridge.ts +8 -8
  73. package/extensions/wechat/commands.ts +2 -2
  74. package/extensions/wechat/deliver.ts +57 -37
  75. package/extensions/wechat/interaction.ts +31 -16
  76. package/extensions/wechat/notices.ts +7 -7
  77. package/extensions/wechat/peer-worker.ts +19 -34
  78. package/extensions/wechat/presence.ts +3 -4
  79. package/extensions/wechat/prompt.ts +39 -24
  80. package/extensions/wechat/reply-pipeline.ts +12 -4
  81. package/extensions/worktree/git.ts +5 -0
  82. package/extensions/worktree/registry.ts +11 -5
  83. package/extensions/worktree/status.ts +1 -3
  84. package/lib/README.md +6 -2
  85. package/lib/claims/overlap.ts +43 -0
  86. package/lib/http/router.ts +40 -13
  87. package/lib/http/server.ts +27 -13
  88. package/lib/interaction.ts +10 -0
  89. package/lib/notice.ts +29 -2
  90. package/lib/plan-entry.ts +7 -2
  91. package/lib/plans/sections.ts +172 -0
  92. package/lib/plans/setting.ts +52 -0
  93. package/lib/plans/split.ts +215 -0
  94. package/lib/plans/store.ts +19 -0
  95. package/lib/plans/team.ts +151 -0
  96. package/lib/qr.ts +64 -0
  97. package/lib/repo-key.ts +74 -0
  98. package/lib/report.ts +52 -33
  99. package/lib/requirements.ts +1 -2
  100. package/lib/tui/README.md +16 -2
  101. package/lib/tui/dock-screen.ts +85 -17
  102. package/lib/tui/line-pane.ts +15 -4
  103. package/lib/tui/tool.ts +23 -4
  104. package/lib/web/components.ts +1 -1
  105. package/package.json +3 -2
  106. package/skills/init/SKILL.md +80 -45
  107. package/skills/init/references/agents-md-quality.md +26 -2
  108. package/skills/init/references/interview-questions.md +36 -14
  109. package/skills/next/SKILL.md +78 -53
  110. package/skills/parallel-dev/SKILL.md +150 -0
  111. package/extensions/plan/config.ts +0 -27
  112. package/extensions/wechat/qrcode.ts +0 -46
@@ -4,15 +4,19 @@
4
4
  * A plan is written to disk as markdown: one file per plan, named from the
5
5
  * title and stamp. A refinement rewrites that file atomically, so the user can
6
6
  * read the same artifact the model is working from and the plan survives
7
- * outside the conversation without a partial write ever being visible. Files
8
- * live under `<repo-root>/.pi/plans` (the worktree root inside a worktree), or
9
- * under `<agent-dir>/plans` outside a repository. An extension tool sees the
10
- * session cwd rather than the worktree the user entered, so the active worktree
11
- * is applied before any path is resolved.
7
+ * outside the conversation without a partial write ever being visible.
12
8
  *
13
- * The directory is self-ignoring: the first write drops a `.gitignore` with `*`
14
- * so plans never show up as untracked files or inside rewind snapshots,
15
- * without editing the project's own `.gitignore`.
9
+ * Plans live under `<agent-dir>/plans/<repo-key>`, keyed by the repository's
10
+ * common git directory, so every worktree and every session of one repository
11
+ * reads one store — a plan approved in a worktree is the same plan from the main
12
+ * checkout. Outside a repository they land under `<agent-dir>/plans`. An
13
+ * extension tool sees the session cwd rather than the worktree the user entered,
14
+ * so the active worktree is applied before any path is resolved.
15
+ *
16
+ * Plans are outside the project, so they are never untracked files and never
17
+ * appear in a rewind snapshot. A store written by an older version, under
18
+ * `<repo>/.pi/plans`, is moved in once, and nothing is deleted before its plans
19
+ * have arrived.
16
20
  *
17
21
  * This module owns paths and bytes only; tool wiring lives in `tools.ts`.
18
22
  */
@@ -25,17 +29,20 @@ import {
25
29
  readFileSync,
26
30
  readdirSync,
27
31
  renameSync,
32
+ rmdirSync,
28
33
  statSync,
29
34
  unlinkSync,
30
35
  writeFileSync,
31
36
  } from "node:fs";
32
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
37
+ import { homedir } from "node:os";
38
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
33
39
  import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
34
40
  import { resolveEffectiveCwd } from "../../lib/env.ts";
35
- import { repoRootFor } from "../../lib/git/index.ts";
36
- import { sanitizeLine } from "../../lib/format.ts";
41
+ import { sanitizeLine, shortenPath } from "../../lib/format.ts";
37
42
  import { isInside, isInsideReal } from "../../lib/path.ts";
38
- import { formatFeedback, type PlanAnnotation } from "./feedback.ts";
43
+ import { plansDir } from "../../lib/plans/store.ts";
44
+ import { repoKeyFor } from "../../lib/repo-key.ts";
45
+ import { carryFeedback, formatFeedback, normalizeAuthor, type PlanAnnotation } from "./feedback.ts";
39
46
  import { extractPlanSteps, matchesStepText, setStepStatuses, type StepUpdate } from "./steps.ts";
40
47
 
41
48
  /** Largest plan accepted, in UTF-8 bytes. */
@@ -47,7 +54,7 @@ const MAX_SLUG_LENGTH = 60;
47
54
  export interface StoredPlan {
48
55
  /** Absolute path. */
49
56
  path: string;
50
- /** Path relative to the working directory, when it is inside it. */
57
+ /** How the path is shown to the user: `~`-shortened, since plans live outside the project. */
51
58
  relativePath: string;
52
59
  content: string;
53
60
  bytes: number;
@@ -62,7 +69,7 @@ export interface WrittenPlan extends StoredPlan {
62
69
  export interface PlanSummary {
63
70
  /** Absolute path. */
64
71
  path: string;
65
- /** Path relative to the working directory, when it is inside it. */
72
+ /** How the path is shown to the user: `~`-shortened, since plans live outside the project. */
66
73
  relativePath: string;
67
74
  /** The plan's `# title`, or a title derived from the file name. */
68
75
  title: string;
@@ -87,6 +94,13 @@ export interface WritePlanInput {
87
94
  export interface PlanStore {
88
95
  /** Directory plans are written to for `cwd`. */
89
96
  dirFor(cwd: string): Promise<string>;
97
+ /**
98
+ * The repository root `cwd` belongs to, or undefined outside a repository.
99
+ *
100
+ * A repository-scoped setting — the split switch — is read at the root, so a
101
+ * session started in a subdirectory still sees what the project turned on.
102
+ */
103
+ rootFor(cwd: string): Promise<string | undefined>;
90
104
  write(cwd: string, input: WritePlanInput): Promise<WrittenPlan>;
91
105
  /** Read a plan inside the plans directory, or undefined when missing/outside. */
92
106
  read(cwd: string, planPath: string): Promise<StoredPlan | undefined>;
@@ -99,8 +113,20 @@ export interface PlanStore {
99
113
  index(cwd: string): Promise<PlanSummary[]>;
100
114
  /** Set the given top-level steps' checkboxes in an existing plan. Throws when the file or any step is invalid. */
101
115
  setSteps(cwd: string, planPath: string, updates: StepUpdate[]): Promise<StoredPlan>;
102
- /** Replace the plan's stored browser feedback. Throws when the file is missing or outside the plans directory. */
103
- saveFeedback(cwd: string, planPath: string, annotations: PlanAnnotation[]): Promise<StoredPlan>;
116
+ /** Replace the comments of `options.author` (the owner when absent) and keep every other writer's. */
117
+ saveFeedback(
118
+ cwd: string,
119
+ planPath: string,
120
+ annotations: PlanAnnotation[],
121
+ options?: { author?: string },
122
+ ): Promise<StoredPlan>;
123
+ /**
124
+ * The line a one-time migration wants reported, or undefined.
125
+ *
126
+ * A moved store is the user's business — plans are not where they used to be —
127
+ * so the first caller that can show a notice takes it and clears it.
128
+ */
129
+ takeNotice(): string | undefined;
104
130
  }
105
131
 
106
132
  /** Timestamp prefix the plan store adds to file names. */
@@ -155,16 +181,19 @@ function isContainedPlan(dir: string, path: string): boolean {
155
181
  return isInsideReal(dir, dirname(path));
156
182
  }
157
183
 
158
- /** Keep plan files out of git (and so out of rewind snapshots) without touching the project. */
159
- function ensureIgnored(dir: string): void {
160
- const ignore = join(dir, ".gitignore");
161
- try {
162
- // `wx` is race-free: an existing ignore file (ours or a user's) is left alone.
163
- writeFileSync(ignore, "*\n", { encoding: "utf-8", flag: "wx" });
164
- } catch {
165
- // A read-only plans directory will fail on the plan write anyway; the
166
- // ignore file is best-effort.
167
- }
184
+ /**
185
+ * Resolve a `plan_path` argument against the store.
186
+ *
187
+ * A plan lives outside the project, so a path relative to the working directory
188
+ * can no longer name one. Absolute paths and the `~/…` form the tools display are
189
+ * accepted, and a bare name resolves inside the store — which is what a reader of
190
+ * a tool result has in hand.
191
+ */
192
+ function resolvePlanArg(dir: string, planPath: string): string {
193
+ const text = planPath.trim();
194
+ if (text === "~") return homedir();
195
+ if (text.startsWith("~/") || text.startsWith(`~${sep}`)) return resolve(homedir(), text.slice(2));
196
+ return isAbsolute(text) ? resolve(text) : resolve(dir, text);
168
197
  }
169
198
 
170
199
  /**
@@ -188,12 +217,6 @@ function writeAtomic(path: string, content: string): void {
188
217
  }
189
218
  }
190
219
 
191
- /** `path` relative to `cwd` when inside it, else `path`. */
192
- function relativeTo(cwd: string, path: string): string {
193
- const rel = relative(cwd, path);
194
- return rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) ? rel : path;
195
- }
196
-
197
220
  function uniquePath(dir: string, base: string): string {
198
221
  const taken = (candidate: string): boolean => {
199
222
  try {
@@ -214,16 +237,69 @@ function uniquePath(dir: string, base: string): string {
214
237
  return candidate;
215
238
  }
216
239
 
217
- /** Repository (or worktree) root of `cwd`, or undefined outside a repository. */
218
- async function repoRoot(pi: ExtensionAPI, cwd: string): Promise<string | undefined> {
219
- // Local `rev-parse` is fast; cap it so a stuck call cannot hang the tool.
220
- return repoRootFor(pi, cwd, 5_000);
240
+ /** A repository (or worktree) root is applied before any path here is resolved. */
241
+
242
+ /** How many legacy plans one migration moves, so a pathological directory cannot stall a tool call. */
243
+ const MAX_MIGRATED_PLANS = 200;
244
+
245
+ /**
246
+ * Move a store written by an older version, under `<repo>/.pi/plans`, into `dir`.
247
+ *
248
+ * Copy-then-unlink when a rename crosses a filesystem boundary, so a store on a
249
+ * different volume still arrives. Nothing here deletes before its plan has
250
+ * landed, a file that cannot move is left where it is, and the old directory
251
+ * goes only when what remains is the ignore file this package used to write.
252
+ */
253
+ function migrateLegacyStore(dir: string, legacy: string): number {
254
+ if (!existsSync(legacy)) return 0;
255
+ let names: string[];
256
+ try {
257
+ names = readdirSync(legacy);
258
+ } catch {
259
+ return 0;
260
+ }
261
+ const plans = names.filter((name) => isPlanFileName(name)).slice(0, MAX_MIGRATED_PLANS);
262
+ let moved = 0;
263
+ for (const name of plans) {
264
+ const from = join(legacy, name);
265
+ try {
266
+ if (!statSync(from).isFile()) continue;
267
+ mkdirSync(dir, { recursive: true });
268
+ const to = uniquePath(dir, name.replace(/\.md$/i, ""));
269
+ try {
270
+ renameSync(from, to);
271
+ } catch {
272
+ writeFileSync(to, readFileSync(from), { flag: "wx" });
273
+ unlinkSync(from);
274
+ }
275
+ moved += 1;
276
+ } catch {
277
+ // A plan that cannot move stays where it is, and the directory stays with it.
278
+ }
279
+ }
280
+ try {
281
+ if (readdirSync(legacy).every((name) => name === ".gitignore")) {
282
+ const ignore = join(legacy, ".gitignore");
283
+ if (existsSync(ignore) && readFileSync(ignore, "utf-8") === "*\n") unlinkSync(ignore);
284
+ rmdirSync(legacy);
285
+ }
286
+ } catch {
287
+ // Leave a directory that still holds something.
288
+ }
289
+ return moved;
221
290
  }
222
291
 
223
292
  export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date } = {}): PlanStore {
224
293
  const now = options.now ?? (() => new Date());
225
294
  // One `git rev-parse` per effective root; a worktree switch changes that root.
226
- const dirCache = new Map<string, string>();
295
+ const dirCache = new Map<string, { dir: string; root: string | undefined }>();
296
+ let notice: string | undefined;
297
+
298
+ const takeNotice = (): string | undefined => {
299
+ const line = notice;
300
+ notice = undefined;
301
+ return line;
302
+ };
227
303
 
228
304
  /**
229
305
  * The root every path here is resolved against.
@@ -231,21 +307,31 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
231
307
  * A tool registered by an extension receives the session's cwd, not the
232
308
  * worktree the user entered: only the built-ins are re-rooted. The worktree
233
309
  * extension publishes its root in the environment, so this is where the two
234
- * meet, and it is applied before the git lookup, the relative path, and the
235
- * plan path resolution.
310
+ * meet, and it is applied before the repository lookup, the migration of an
311
+ * older store, and the plan path resolution.
236
312
  */
237
313
  const baseOf = (cwd: string): string => resolveEffectiveCwd(cwd);
238
314
 
239
- const dirFor = async (cwd: string): Promise<string> => {
315
+ const resolve = async (cwd: string): Promise<{ dir: string; root: string | undefined }> => {
240
316
  const base = baseOf(cwd);
241
317
  const cached = dirCache.get(base);
242
318
  if (cached) return cached;
243
- const root = await repoRoot(pi, base);
244
- const dir = root ? join(root, CONFIG_DIR_NAME, "plans") : join(getAgentDir(), "plans");
245
- dirCache.set(base, dir);
246
- return dir;
319
+ const repo = await repoKeyFor(pi, base);
320
+ const dir = plansDir(getAgentDir(), repo.fromGit ? repo.key : undefined);
321
+ dirCache.set(base, { dir, root: repo.fromGit ? repo.root : undefined });
322
+ if (repo.fromGit) {
323
+ // The keyed directory is the store; a store an older version left in the
324
+ // checkout moves in once, so an upgrade does not strand its plans.
325
+ const moved = migrateLegacyStore(dir, join(base, CONFIG_DIR_NAME, "plans"));
326
+ if (moved > 0) notice = `Moved ${moved} plan${moved === 1 ? "" : "s"} to ${shortenPath(dir)}.`;
327
+ }
328
+ return { dir, root: repo.fromGit ? repo.root : undefined };
247
329
  };
248
330
 
331
+ const dirFor = async (cwd: string): Promise<string> => (await resolve(cwd)).dir;
332
+
333
+ const rootFor = async (cwd: string): Promise<string | undefined> => (await resolve(cwd)).root;
334
+
249
335
  const write = async (cwd: string, input: WritePlanInput): Promise<WrittenPlan> => {
250
336
  const base = baseOf(cwd);
251
337
  const title = input.title.trim();
@@ -257,11 +343,10 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
257
343
 
258
344
  const dir = await dirFor(base);
259
345
  mkdirSync(dir, { recursive: true });
260
- ensureIgnored(dir);
261
346
 
262
347
  let path: string;
263
348
  if (input.planPath) {
264
- const resolved = resolve(base, input.planPath);
349
+ const resolved = resolvePlanArg(dir, input.planPath);
265
350
  if (!isContainedPlan(dir, resolved)) {
266
351
  throw new Error("plan_path must be a markdown plan file inside the plans directory.");
267
352
  }
@@ -275,20 +360,30 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
275
360
  throw new Error("The plan path is not writable inside the plans directory.");
276
361
  }
277
362
 
278
- writeAtomic(path, input.content);
279
- return { path, relativePath: relativeTo(base, path), title, content: input.content, bytes };
363
+ // A refinement rewrites the body but not the review: every stored comment is
364
+ // carried onto the block whose text survived, so revising a plan under review
365
+ // does not silently discard what a teammate wrote.
366
+ const previous = existsSync(path) ? readFileSync(path, "utf-8") : undefined;
367
+ const content = previous === undefined ? carryFeedback("", input.content) : carryFeedback(previous, input.content);
368
+ const nextBytes = Buffer.byteLength(content, "utf-8");
369
+ if (nextBytes > MAX_PLAN_BYTES) {
370
+ throw new Error(`The plan is too large with its review comments (${nextBytes} bytes).`);
371
+ }
372
+
373
+ writeAtomic(path, content);
374
+ return { path, relativePath: shortenPath(path), title, content, bytes: nextBytes };
280
375
  };
281
376
 
282
377
  const read = async (cwd: string, planPath: string): Promise<StoredPlan | undefined> => {
283
378
  const base = baseOf(cwd);
284
379
  const dir = await dirFor(base);
285
- const resolved = resolve(base, planPath);
380
+ const resolved = resolvePlanArg(dir, planPath);
286
381
  if (!isContainedPlan(dir, resolved) || !existsSync(resolved)) return undefined;
287
382
  try {
288
383
  const content = readFileSync(resolved, "utf-8");
289
384
  return {
290
385
  path: resolved,
291
- relativePath: relativeTo(base, resolved),
386
+ relativePath: shortenPath(resolved),
292
387
  content,
293
388
  bytes: Buffer.byteLength(content, "utf-8"),
294
389
  };
@@ -333,14 +428,27 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
333
428
  };
334
429
 
335
430
  /**
336
- * Write the browser review comments into an existing plan, replacing any
337
- * feedback already stored there. The file must already be a contained plan,
338
- * so this is the same path-safe write as `setSteps`.
431
+ * Write the browser review comments into an existing plan, replacing the
432
+ * comments of `options.author` (the owner when absent) and leaving every other
433
+ * writer's in place. The file must already be a contained plan, so this is the
434
+ * same path-safe write as `setSteps`.
339
435
  */
340
- const saveFeedback = async (cwd: string, planPath: string, annotations: PlanAnnotation[]): Promise<StoredPlan> => {
436
+ const saveFeedback = async (
437
+ cwd: string,
438
+ planPath: string,
439
+ annotations: PlanAnnotation[],
440
+ options: { author?: string } = {},
441
+ ): Promise<StoredPlan> => {
341
442
  const plan = await read(cwd, planPath);
342
443
  if (!plan) throw new Error("No plan file there; pass a plan_path written by write_plan.");
343
- const content = formatFeedback(plan.content, annotations);
444
+ const author = normalizeAuthor(options.author);
445
+ // An untagged entry is the writer's own; one tagged with a different name is
446
+ // another writer's comment, so it is dropped rather than re-signed.
447
+ const own = annotations.filter((annotation) => {
448
+ const who = normalizeAuthor(annotation.author);
449
+ return who === "" || who === author;
450
+ });
451
+ const content = formatFeedback(plan.content, own, { author });
344
452
  writeAtomic(plan.path, content);
345
453
  return {
346
454
  path: plan.path,
@@ -417,7 +525,7 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
417
525
 
418
526
  const summary: PlanSummary = {
419
527
  path,
420
- relativePath: relativeTo(base, path),
528
+ relativePath: shortenPath(path),
421
529
  title: planTitle(name),
422
530
  modified: info.mtimeMs,
423
531
  bytes: info.size,
@@ -448,5 +556,5 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
448
556
  return summaries;
449
557
  };
450
558
 
451
- return { dirFor, write, read, latest, index, setSteps, saveFeedback };
559
+ return { dirFor, rootFor, write, read, latest, index, setSteps, saveFeedback, takeNotice };
452
560
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Pure rendering and parsing for the compact remote plan review.
3
+ *
4
+ * The prompt and its reply share one action table, so changing a number or a
5
+ * localized label cannot leave the parser behind. File delivery and the review
6
+ * lifecycle stay in `review.ts`.
7
+ */
8
+
9
+ import type { InteractionLanguage } from "../../lib/interaction.ts";
10
+ import { extractPlanSteps } from "./steps.ts";
11
+
12
+ /** The actions the compact prompt can resolve without a refinement. */
13
+ export type PlanReviewAction = "approve" | "keep" | "cancel";
14
+
15
+ /** One editor reply: an action, or free text the model should apply. */
16
+ export type PlanReviewReply =
17
+ | { action: "approve" }
18
+ | { action: "keep" }
19
+ | { action: "cancel" }
20
+ | { action: "refine"; refinement: string };
21
+
22
+ interface ReviewAction {
23
+ key: string;
24
+ action: PlanReviewAction;
25
+ zh: string;
26
+ en: string;
27
+ words: readonly string[];
28
+ }
29
+
30
+ /** The action line and the parser read this one table, in display order. */
31
+ const REVIEW_ACTIONS: readonly ReviewAction[] = [
32
+ { key: "1", action: "approve", zh: "批准", en: "approve", words: ["approve", "批准", "同意"] },
33
+ {
34
+ key: "2",
35
+ action: "keep",
36
+ zh: "继续规划",
37
+ en: "keep planning",
38
+ words: ["keep", "keep planning", "继续", "继续规划"],
39
+ },
40
+ { key: "3", action: "cancel", zh: "退出", en: "exit", words: ["exit", "cancel", "quit", "退出", "取消"] },
41
+ ];
42
+
43
+ /** How much of one step the remote digest shows. */
44
+ const DIGEST_STEP_CHARS = 90;
45
+
46
+ /**
47
+ * One compact remote prompt: title, first steps, mode status, and the action
48
+ * line. The plan file is sent before it; a non-action reply refines the plan.
49
+ */
50
+ export function planReviewPrompt(content: string, language: InteractionLanguage, attached: boolean): string {
51
+ const title = /^#\s+(.+)$/m.exec(content)?.[1]?.trim();
52
+ const steps = extractPlanSteps(content);
53
+ const count = steps.length;
54
+ const head =
55
+ language === "zh"
56
+ ? `计划${title ? `:${title}` : ""}${count > 0 ? `(${count} 步)` : ""}`
57
+ : `Plan${title ? `: ${title}` : ""}${count > 0 ? ` (${count} step${count === 1 ? "" : "s"})` : ""}`;
58
+ const preview = steps.slice(0, 3).map((step, index) => {
59
+ const text = step.content.replace(/\s+/g, " ").trim();
60
+ const shown = text.length > DIGEST_STEP_CHARS ? `${text.slice(0, DIGEST_STEP_CHARS - 1)}…` : text;
61
+ return `${index + 1}. ${shown}`;
62
+ });
63
+ const more =
64
+ count > preview.length
65
+ ? language === "zh"
66
+ ? `… 还有 ${count - preview.length} 步`
67
+ : `… (${count - preview.length} more)`
68
+ : "";
69
+ const status = language === "zh" ? "Plan 模式开启,写入已关闭。" : "Plan mode is on — write access is off.";
70
+ const attachment = attached ? (language === "zh" ? "计划已在上方。" : "The plan is above.") : "";
71
+ const choices = REVIEW_ACTIONS.map((action) => `${action.key} ${action[language]}`).join(
72
+ language === "zh" ? "," : ", ",
73
+ );
74
+ const actions =
75
+ language === "zh"
76
+ ? `回复 ${choices};或直接输入修改意见(想做别的先退出或批准)。`
77
+ : `Reply ${choices} — or type feedback to revise. To ask something else, exit or approve first.`;
78
+ return [head, ...preview, more, status, attachment, actions].filter((line) => line !== "").join("\n");
79
+ }
80
+
81
+ /**
82
+ * Parse the one editor reply. Only an exact number or action word is an action;
83
+ * any other non-empty text is a refinement request.
84
+ */
85
+ export function parsePlanReviewReply(raw: string): PlanReviewReply {
86
+ const text = raw.trim();
87
+ if (text === "") return { action: "cancel" };
88
+ const key = text.toLowerCase();
89
+ for (const action of REVIEW_ACTIONS) {
90
+ if (key === action.key || action.words.includes(key)) return { action: action.action };
91
+ }
92
+ return { action: "refine", refinement: text };
93
+ }