@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.
- package/README.md +17 -9
- package/README.zh-CN.md +16 -9
- package/extensions/bytefly/README.md +25 -18
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/settings.ts +25 -18
- package/extensions/file-browse/README.md +60 -21
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +62 -0
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +19 -147
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +25 -4
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +2 -0
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/README.md +3 -3
- package/extensions/plan/README.md +280 -86
- package/extensions/plan/commands.ts +56 -5
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +1 -1
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +165 -57
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +78 -98
- package/extensions/plan/runtime.ts +1 -1
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +80 -93
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +101 -23
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +51 -8
- package/extensions/plan/types.ts +37 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +121 -177
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +103 -17
- package/extensions/plan/web-router.ts +146 -30
- package/extensions/plan/web-share-session.ts +197 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +193 -45
- package/extensions/question/README.md +3 -0
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/session-bus/README.md +247 -0
- package/extensions/session-bus/commands.ts +165 -0
- package/extensions/session-bus/format.ts +124 -0
- package/extensions/session-bus/index.ts +69 -0
- package/extensions/session-bus/notice.ts +58 -0
- package/extensions/session-bus/protocol.ts +134 -0
- package/extensions/session-bus/result.ts +243 -0
- package/extensions/session-bus/rows.ts +169 -0
- package/extensions/session-bus/runtime.ts +575 -0
- package/extensions/session-bus/store.ts +329 -0
- package/extensions/session-bus/team.ts +102 -0
- package/extensions/session-bus/tools.ts +227 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/README.md +1 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +15 -9
- package/extensions/wechat/README.md +20 -33
- package/extensions/wechat/bridge.ts +8 -8
- package/extensions/wechat/commands.ts +2 -2
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +31 -16
- package/extensions/wechat/notices.ts +7 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +39 -24
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +6 -2
- package/lib/claims/overlap.ts +43 -0
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/plans/sections.ts +172 -0
- package/lib/plans/setting.ts +52 -0
- package/lib/plans/split.ts +215 -0
- package/lib/plans/store.ts +19 -0
- package/lib/plans/team.ts +151 -0
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +1 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/components.ts +1 -1
- package/package.json +3 -2
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +78 -53
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/qrcode.ts +0 -46
package/extensions/plan/plans.ts
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
103
|
-
saveFeedback(
|
|
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
|
-
/**
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
/**
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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
|
|
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
|
|
244
|
-
const dir =
|
|
245
|
-
dirCache.set(base, dir);
|
|
246
|
-
|
|
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 =
|
|
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
|
-
|
|
279
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
337
|
-
*
|
|
338
|
-
*
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
+
}
|