@gavin-hu/my-pi-agent 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -21
- package/README.zh-CN.md +30 -22
- package/extensions/bytefly/README.md +139 -67
- package/extensions/bytefly/chrome.ts +7 -2
- package/extensions/bytefly/doctor/register.ts +6 -0
- package/extensions/bytefly/doctor/runtime.ts +36 -6
- package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
- package/extensions/bytefly/footer/config.ts +7 -1
- package/extensions/bytefly/footer/delegation.ts +100 -0
- package/extensions/bytefly/footer/register.ts +13 -2
- package/extensions/bytefly/footer/render/lines.ts +2 -0
- package/extensions/bytefly/footer/render/segments.ts +27 -0
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/index.ts +4 -3
- package/extensions/bytefly/notify/config.ts +2 -0
- package/extensions/bytefly/notify/format.ts +19 -1
- package/extensions/bytefly/notify/register.ts +37 -13
- package/extensions/bytefly/notify/types.ts +2 -0
- package/extensions/bytefly/panel/commands.ts +34 -13
- package/extensions/bytefly/panel/settings.ts +98 -16
- package/extensions/bytefly/update/cache.ts +73 -0
- package/extensions/bytefly/update/format.ts +65 -10
- package/extensions/bytefly/update/register.ts +99 -13
- package/extensions/bytefly/update/state.ts +13 -1
- package/extensions/document/README.md +22 -12
- package/extensions/document/config.ts +4 -0
- package/extensions/document/deadline.ts +61 -0
- package/extensions/document/tool.ts +13 -2
- package/extensions/file-browse/README.md +12 -8
- package/extensions/file-browse/client.ts +6 -7
- package/extensions/file-browse/files.ts +0 -4
- package/extensions/file-browse/git.ts +9 -10
- package/extensions/file-browse/html.ts +17 -7
- package/extensions/file-browse/router.ts +24 -15
- package/extensions/goal/README.md +7 -1
- package/extensions/goal/schema.ts +6 -2
- package/extensions/goal/types.ts +6 -2
- package/extensions/job/README.md +16 -12
- package/extensions/job/runtime.ts +23 -6
- package/extensions/job/status.ts +4 -2
- package/extensions/memory/README.md +55 -13
- package/extensions/memory/commands.ts +20 -2
- package/extensions/memory/doctor.ts +137 -0
- package/extensions/memory/format.ts +24 -7
- package/extensions/memory/runtime.ts +49 -0
- package/extensions/memory/store.ts +31 -0
- package/extensions/memory/tui.ts +136 -12
- package/extensions/plan/README.md +60 -29
- package/extensions/plan/commands.ts +67 -8
- package/extensions/plan/index.ts +35 -11
- package/extensions/plan/plan-index.ts +234 -0
- package/extensions/plan/plan-text.ts +95 -0
- package/extensions/plan/plans.ts +90 -1
- package/extensions/plan/review.ts +12 -9
- package/extensions/plan/runtime.ts +72 -27
- package/extensions/plan/steps.ts +35 -25
- package/extensions/plan/todos.ts +24 -2
- package/extensions/plan/tools.ts +124 -24
- package/extensions/plan/tui.ts +7 -28
- package/extensions/plan/types.ts +9 -0
- package/extensions/plan/web-page.ts +2 -2
- package/extensions/plan/web.ts +12 -11
- package/extensions/rewind/README.md +28 -8
- package/extensions/rewind/commands.ts +1 -1
- package/extensions/rewind/format.ts +4 -4
- package/extensions/rewind/git.ts +56 -18
- package/extensions/rewind/index.ts +3 -5
- package/extensions/rewind/path-picker.ts +198 -0
- package/extensions/rewind/paths.ts +128 -0
- package/extensions/rewind/restore.ts +69 -28
- package/extensions/rewind/rewind.ts +80 -3
- package/extensions/rewind/runtime.ts +13 -5
- package/extensions/rewind/snapshot-run.ts +5 -2
- package/extensions/rewind/types.ts +2 -0
- package/extensions/subagent/README.md +7 -1
- package/extensions/subagent/accounting.ts +34 -0
- package/extensions/subagent/index.ts +23 -1
- package/extensions/subagent/stream.ts +2 -0
- package/extensions/subagent/types.ts +2 -0
- package/extensions/watch/README.md +8 -3
- package/extensions/watch/index.ts +19 -0
- package/extensions/web-access/README.md +43 -13
- package/extensions/web-access/charset.ts +58 -0
- package/extensions/web-access/fetch/find.ts +11 -7
- package/extensions/web-access/fetch/format.ts +64 -18
- package/extensions/web-access/fetch/page.ts +5 -1
- package/extensions/web-access/fetch/tool.ts +19 -18
- package/extensions/web-access/http.ts +49 -8
- package/extensions/web-access/search/facts.ts +38 -0
- package/extensions/web-access/search/tool.ts +13 -0
- package/extensions/web-access/text.ts +50 -11
- package/extensions/worktree/README.md +74 -15
- package/extensions/worktree/config.ts +11 -0
- package/extensions/worktree/exit.ts +35 -3
- package/extensions/worktree/gaps.ts +74 -0
- package/extensions/worktree/guard.ts +50 -4
- package/extensions/worktree/handoff.ts +55 -0
- package/extensions/worktree/index.ts +12 -2
- package/extensions/worktree/runtime.ts +20 -0
- package/extensions/worktree/status.ts +15 -2
- package/extensions/worktree/tools.ts +3 -2
- package/lib/README.md +10 -8
- package/lib/events.ts +86 -1
- package/lib/goal-state.ts +95 -0
- package/lib/requirements.ts +282 -62
- package/lib/tui/README.md +91 -0
- package/lib/tui/glyphs.ts +1 -0
- package/lib/web/layout.ts +12 -2
- package/package.json +5 -3
- package/themes/bytefly-dark.json +1 -1
- package/assets/chrome.png +0 -0
- package/assets/file-browse.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question-cancelled.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory-long-subject.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc-running.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch-rejected.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan text as a review shows and compares it (pure).
|
|
3
|
+
*
|
|
4
|
+
* Two needs pull in the same direction. A plan is model-authored text, so it is
|
|
5
|
+
* neutralized before it reaches a terminal or a page; and a review has to compare
|
|
6
|
+
* what it shows against what the previous review showed. Both start from the same
|
|
7
|
+
* normalization — control characters stripped, tabs expanded, stored feedback
|
|
8
|
+
* removed — so the digest a review writes and the digest a later review compares
|
|
9
|
+
* cannot disagree about a plan that did not change.
|
|
10
|
+
*
|
|
11
|
+
* The baseline is one digest per heading section rather than the previous plan
|
|
12
|
+
* text, so it can live in a session entry without copying the plan file into the
|
|
13
|
+
* branch on every review.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { stripControlChars } from "../../lib/format.ts";
|
|
18
|
+
import { stripFeedback } from "./feedback.ts";
|
|
19
|
+
import { extractPlanSections } from "./steps.ts";
|
|
20
|
+
|
|
21
|
+
/** Spaces a tab expands to; the terminal's own tab stops are not width-modelled. */
|
|
22
|
+
const TAB_WIDTH = 4;
|
|
23
|
+
|
|
24
|
+
/** One heading section's identity and content digest. */
|
|
25
|
+
export interface SectionDigest {
|
|
26
|
+
/** Plain heading text, or `""` for the content before the first heading. */
|
|
27
|
+
title: string;
|
|
28
|
+
/** SHA-256 of the section's normalized text, heading included. */
|
|
29
|
+
digest: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Replace control characters (including ESC and the C1 block) and expand tabs
|
|
34
|
+
* so model-authored plan text cannot drive the terminal. Newlines are kept, so
|
|
35
|
+
* callers can still render or split the text line by line.
|
|
36
|
+
*/
|
|
37
|
+
export function sanitizePlanText(text: string): string {
|
|
38
|
+
return stripControlChars(text.replace(/\t/g, " ".repeat(TAB_WIDTH)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The text a review compares: terminal-safe, with tabs expanded and stored
|
|
43
|
+
* feedback removed. A review comment a browser wrote into the file is a note to
|
|
44
|
+
* the model, not an edit, so it never reads as a changed section.
|
|
45
|
+
*/
|
|
46
|
+
export function reviewText(content: string): string {
|
|
47
|
+
return stripFeedback(sanitizePlanText(content));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Digest one section's text; the heading line is part of it. */
|
|
51
|
+
function digestOf(text: string): string {
|
|
52
|
+
return createHash("sha256").update(text.trim()).digest("hex");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One digest per heading section, in document order, for a persisted baseline. */
|
|
56
|
+
export function sectionDigests(content: string): SectionDigest[] {
|
|
57
|
+
return extractPlanSections(reviewText(content)).map((section) => ({
|
|
58
|
+
title: section.title,
|
|
59
|
+
digest: digestOf(section.text),
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether two baselines describe the same content, section for section. */
|
|
64
|
+
export function sameSections(a: readonly SectionDigest[], b: readonly SectionDigest[]): boolean {
|
|
65
|
+
if (a.length !== b.length) return false;
|
|
66
|
+
return a.every((section, index) => section.title === b[index].title && section.digest === b[index].digest);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Per-section change flags for a second review: `boolean[]` aligned with
|
|
71
|
+
* `extractPlanSections(reviewText(current))`, so duplicate headings are flagged
|
|
72
|
+
* by occurrence rather than by title. An absent baseline flags nothing, which is
|
|
73
|
+
* what a first review of a plan looks like; `stripFeedback` only ever drops
|
|
74
|
+
* quote-marker lines, so the flags stay aligned with the sections a screen
|
|
75
|
+
* renders from the same content.
|
|
76
|
+
*/
|
|
77
|
+
export function changedSections(baseline: readonly SectionDigest[] | undefined, current: string): boolean[] {
|
|
78
|
+
const sections = extractPlanSections(reviewText(current));
|
|
79
|
+
if (baseline === undefined) return sections.map(() => false);
|
|
80
|
+
|
|
81
|
+
const before = new Map<string, string[]>();
|
|
82
|
+
for (const section of baseline) {
|
|
83
|
+
const digests = before.get(section.title);
|
|
84
|
+
if (digests) digests.push(section.digest);
|
|
85
|
+
else before.set(section.title, [section.digest]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const seen = new Map<string, number>();
|
|
89
|
+
return sections.map((section) => {
|
|
90
|
+
const index = seen.get(section.title) ?? 0;
|
|
91
|
+
seen.set(section.title, index + 1);
|
|
92
|
+
const prior = before.get(section.title)?.[index];
|
|
93
|
+
return prior === undefined || prior !== digestOf(section.text);
|
|
94
|
+
});
|
|
95
|
+
}
|
package/extensions/plan/plans.ts
CHANGED
|
@@ -23,12 +23,14 @@ import {
|
|
|
23
23
|
readFileSync,
|
|
24
24
|
readdirSync,
|
|
25
25
|
renameSync,
|
|
26
|
+
statSync,
|
|
26
27
|
unlinkSync,
|
|
27
28
|
writeFileSync,
|
|
28
29
|
} from "node:fs";
|
|
29
30
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
30
31
|
import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
31
32
|
import { repoRootFor } from "../../lib/git/index.ts";
|
|
33
|
+
import { sanitizeLine } from "../../lib/format.ts";
|
|
32
34
|
import { isInside, isInsideReal } from "../../lib/path.ts";
|
|
33
35
|
import { formatFeedback, type PlanAnnotation } from "./feedback.ts";
|
|
34
36
|
import { extractPlanSteps, matchesStepText, setStepStatuses, type StepUpdate } from "./steps.ts";
|
|
@@ -53,6 +55,23 @@ export interface WrittenPlan extends StoredPlan {
|
|
|
53
55
|
title: string;
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
/** One row of the plan index: enough to choose a plan without opening it. */
|
|
59
|
+
export interface PlanSummary {
|
|
60
|
+
/** Absolute path. */
|
|
61
|
+
path: string;
|
|
62
|
+
/** Path relative to the working directory, when it is inside it. */
|
|
63
|
+
relativePath: string;
|
|
64
|
+
/** The plan's `# title`, or a title derived from the file name. */
|
|
65
|
+
title: string;
|
|
66
|
+
/** File modification time, ms since the epoch. */
|
|
67
|
+
modified: number;
|
|
68
|
+
bytes: number;
|
|
69
|
+
/** Steps done and total, or undefined for a file the store would not have written. */
|
|
70
|
+
steps?: { done: number; total: number };
|
|
71
|
+
/** True when the file is over {@link MAX_PLAN_BYTES} and was listed without a read. */
|
|
72
|
+
oversize?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
56
75
|
export interface WritePlanInput {
|
|
57
76
|
/** Short title; drives the file-name slug. */
|
|
58
77
|
title: string;
|
|
@@ -70,6 +89,11 @@ export interface PlanStore {
|
|
|
70
89
|
read(cwd: string, planPath: string): Promise<StoredPlan | undefined>;
|
|
71
90
|
/** The newest plan file in the plans directory, or undefined when there is none. */
|
|
72
91
|
latest(cwd: string): Promise<StoredPlan | undefined>;
|
|
92
|
+
/**
|
|
93
|
+
* Every plan in the directory, newest first. The directory is the index, so
|
|
94
|
+
* this is one `readdir`, one `stat` per file, and one bounded read per plan.
|
|
95
|
+
*/
|
|
96
|
+
index(cwd: string): Promise<PlanSummary[]>;
|
|
73
97
|
/** Set the given top-level steps' checkboxes in an existing plan. Throws when the file or any step is invalid. */
|
|
74
98
|
setSteps(cwd: string, planPath: string, updates: StepUpdate[]): Promise<StoredPlan>;
|
|
75
99
|
/** Replace the plan's stored browser feedback. Throws when the file is missing or outside the plans directory. */
|
|
@@ -342,5 +366,70 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
|
|
|
342
366
|
return newest ? read(cwd, newest.path) : undefined;
|
|
343
367
|
};
|
|
344
368
|
|
|
345
|
-
|
|
369
|
+
/**
|
|
370
|
+
* Every plan in the directory, newest first: the directory is the index.
|
|
371
|
+
*
|
|
372
|
+
* One `readdir`, one `lstat`/`stat` per entry, and one `readFileSync` per plan
|
|
373
|
+
* that fits {@link MAX_PLAN_BYTES}. A larger file is listed as `oversize`
|
|
374
|
+
* without being read — the store would never have written it, and pulling it
|
|
375
|
+
* into memory to print one row is not worth the risk. A symlinked entry, a
|
|
376
|
+
* dotfile, or a file that vanishes mid-walk is simply not a plan.
|
|
377
|
+
*/
|
|
378
|
+
const index = async (cwd: string): Promise<PlanSummary[]> => {
|
|
379
|
+
const dir = await dirFor(cwd);
|
|
380
|
+
let names: string[];
|
|
381
|
+
try {
|
|
382
|
+
names = readdirSync(dir);
|
|
383
|
+
} catch {
|
|
384
|
+
return [];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const summaries: PlanSummary[] = [];
|
|
388
|
+
for (const name of names) {
|
|
389
|
+
const path = join(dir, name);
|
|
390
|
+
if (!isPlanFileName(path)) continue;
|
|
391
|
+
let info: ReturnType<typeof statSync>;
|
|
392
|
+
try {
|
|
393
|
+
// lstat, not stat: a symlink is never a plan.
|
|
394
|
+
info = lstatSync(path);
|
|
395
|
+
} catch {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (!info.isFile()) continue;
|
|
399
|
+
if (!isContainedPlan(dir, path)) continue;
|
|
400
|
+
|
|
401
|
+
const summary: PlanSummary = {
|
|
402
|
+
path,
|
|
403
|
+
relativePath: relativeTo(cwd, path),
|
|
404
|
+
title: planTitle(name),
|
|
405
|
+
modified: info.mtimeMs,
|
|
406
|
+
bytes: info.size,
|
|
407
|
+
};
|
|
408
|
+
if (info.size > MAX_PLAN_BYTES) {
|
|
409
|
+
summaries.push({ ...summary, oversize: true });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
const content = readFileSync(path, "utf-8");
|
|
414
|
+
const heading = /^ {0,3}#\s+(.+)$/m.exec(content)?.[1];
|
|
415
|
+
const title = heading === undefined ? "" : sanitizeLine(heading).trim();
|
|
416
|
+
const steps = extractPlanSteps(content);
|
|
417
|
+
summaries.push({
|
|
418
|
+
...summary,
|
|
419
|
+
...(title ? { title } : {}),
|
|
420
|
+
steps: { done: steps.filter((step) => step.status === "completed").length, total: steps.length },
|
|
421
|
+
});
|
|
422
|
+
} catch {
|
|
423
|
+
// An unreadable plan is still a file the user may want to see; list the
|
|
424
|
+
// row from its name and leave the content-derived fields out.
|
|
425
|
+
summaries.push(summary);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// Newest first, with the name (which carries the stamp) as the tie break —
|
|
429
|
+
// the same order `latest` picks from.
|
|
430
|
+
summaries.sort((a, b) => b.modified - a.modified || (a.path < b.path ? 1 : a.path > b.path ? -1 : 0));
|
|
431
|
+
return summaries;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
return { dirFor, write, read, latest, index, setSteps, saveFeedback };
|
|
346
435
|
}
|
|
@@ -18,6 +18,7 @@ import { askHuman } from "../../lib/interaction.ts";
|
|
|
18
18
|
import { FULL_SCREEN_OVERLAY } from "../../lib/tui/chrome.ts";
|
|
19
19
|
import { feedbackSummary, type PlanAnnotation } from "./feedback.ts";
|
|
20
20
|
import type { PlanStore, StoredPlan } from "./plans.ts";
|
|
21
|
+
import type { SectionDigest } from "./plan-text.ts";
|
|
21
22
|
import { extractPlanSteps } from "./steps.ts";
|
|
22
23
|
import { PlanViewComponent, type PlanViewAction } from "./tui.ts";
|
|
23
24
|
import type { PlanReviewResult, PlanReviewSession, PlanWebReview } from "./web.ts";
|
|
@@ -47,7 +48,9 @@ export type ReviewDecision =
|
|
|
47
48
|
|
|
48
49
|
/** What the previous review showed, so this one can mark what changed. */
|
|
49
50
|
export interface ReviewBaselines {
|
|
50
|
-
|
|
51
|
+
/** The sections the last review of `path` showed, when this branch has one. */
|
|
52
|
+
reviewed(path: string): SectionDigest[] | undefined;
|
|
53
|
+
/** Remember what a review just showed, as a session entry on this branch. */
|
|
51
54
|
markReviewed(path: string, content: string): void;
|
|
52
55
|
}
|
|
53
56
|
|
|
@@ -106,7 +109,7 @@ function planReviewDigest(content: string): string {
|
|
|
106
109
|
async function askPlanReview(
|
|
107
110
|
ctx: ExtensionToolContext,
|
|
108
111
|
plan: StoredPlan,
|
|
109
|
-
|
|
112
|
+
baseline: SectionDigest[] | undefined,
|
|
110
113
|
): Promise<PlanReviewChoice> {
|
|
111
114
|
let sentFullText = false;
|
|
112
115
|
for (;;) {
|
|
@@ -116,7 +119,7 @@ async function askPlanReview(
|
|
|
116
119
|
(tui, theme, _keybindings, done) =>
|
|
117
120
|
new PlanViewComponent({
|
|
118
121
|
plan,
|
|
119
|
-
|
|
122
|
+
baseline,
|
|
120
123
|
theme,
|
|
121
124
|
onClose: (action, refinement) => done({ action, refinement }),
|
|
122
125
|
requestRender: () => tui.requestRender(),
|
|
@@ -156,8 +159,8 @@ async function askPlanReview(
|
|
|
156
159
|
export interface BrowserReviewOptions {
|
|
157
160
|
/** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
|
|
158
161
|
canApprove: boolean;
|
|
159
|
-
/** The
|
|
160
|
-
|
|
162
|
+
/** The section digests the previous review showed, for the changed markers. */
|
|
163
|
+
baseline?: SectionDigest[];
|
|
161
164
|
/** Aborting the turn closes the session as cancelled. */
|
|
162
165
|
signal?: AbortSignal;
|
|
163
166
|
/** Called once the server is listening and the browser was opened. */
|
|
@@ -176,7 +179,7 @@ export async function runBrowserReview(
|
|
|
176
179
|
): Promise<PlanReviewResult | undefined> {
|
|
177
180
|
if (options.signal?.aborted) return { status: "cancelled" };
|
|
178
181
|
const session = await web
|
|
179
|
-
.start(cwd, plan, { canApprove: options.canApprove,
|
|
182
|
+
.start(cwd, plan, { canApprove: options.canApprove, baseline: options.baseline, signal: options.signal })
|
|
180
183
|
.catch(() => undefined);
|
|
181
184
|
if (!session) return undefined;
|
|
182
185
|
options.onOpened?.(session);
|
|
@@ -200,8 +203,8 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
|
|
|
200
203
|
if (signal?.aborted) return { action: "keep", plan };
|
|
201
204
|
// Read the baseline before the screen and mark it after, so what the next
|
|
202
205
|
// review compares against is the content this one actually showed.
|
|
203
|
-
const
|
|
204
|
-
const choice = await askPlanReview(ctx, plan,
|
|
206
|
+
const baseline = baselines.reviewed(plan.path);
|
|
207
|
+
const choice = await askPlanReview(ctx, plan, baseline);
|
|
205
208
|
baselines.markReviewed(plan.path, plan.content);
|
|
206
209
|
|
|
207
210
|
if (choice.action === "approve") return { action: "approve", plan, comments: [] };
|
|
@@ -218,7 +221,7 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
|
|
|
218
221
|
|
|
219
222
|
const result = await runBrowserReview(web, ctx.cwd, plan, {
|
|
220
223
|
canApprove: true,
|
|
221
|
-
|
|
224
|
+
baseline,
|
|
222
225
|
signal,
|
|
223
226
|
onOpened: (session) => ctx.ui.notify(`Plan review: opened ${session.url}`, "info"),
|
|
224
227
|
});
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* (present the plan for approval) and `write_plan` (save the plan artifact under
|
|
7
7
|
* `.pi/plans`). Disabling restores exactly what it hid. The enabled flag is
|
|
8
8
|
* persisted as a custom session entry so `/resume` and `/tree` follow the
|
|
9
|
-
* branch, and it is also mirrored into the footer status.
|
|
9
|
+
* branch, and it is also mirrored into the footer status. The review baseline
|
|
10
|
+
* travels the same way, as one digest per heading section, so the changed-section
|
|
11
|
+
* markers survive a resume too.
|
|
10
12
|
*
|
|
11
13
|
* `write_plan` is the one sanctioned write while planning. The read-only guard
|
|
12
14
|
* in `index.ts` permits a control tool only when the registered tool is this
|
|
@@ -23,7 +25,8 @@ import type { ReadOnlyPolicy } from "../../lib/policy.ts";
|
|
|
23
25
|
import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
|
|
24
26
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
25
27
|
import { createPlanStore, type PlanStore } from "./plans.ts";
|
|
26
|
-
import type
|
|
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
|
|
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
|
|
62
|
-
reviewed(path: string):
|
|
63
|
-
/** Remember what a review just
|
|
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
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
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"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
|
111
|
-
// mark what changed.
|
|
112
|
-
//
|
|
113
|
-
const reviewedPlans = new Map<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
|
-
|
|
181
|
-
//
|
|
182
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/extensions/plan/steps.ts
CHANGED
|
@@ -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
|
package/extensions/plan/todos.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|