@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
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
* Slash command for the plan-mode extension.
|
|
3
3
|
*
|
|
4
4
|
* `/plan` toggles plan mode, or enters it and sends an optional task;
|
|
5
|
-
* `Ctrl+Alt+P` toggles too.
|
|
5
|
+
* `Ctrl+Alt+P` toggles too. Four subcommands are reserved: `/plan review [path]`
|
|
6
6
|
* opens a plan in the browser (and sends saved comments to the model as a
|
|
7
|
-
* refinement),
|
|
8
|
-
* Enter opening a
|
|
7
|
+
* refinement), `/plan share [stop]` prints or revokes the link a teammate opens,
|
|
8
|
+
* `/plan list` shows the plans directory, newest first with Enter opening a
|
|
9
|
+
* review, and `/plan cancel` leaves the mode without approving.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
13
|
import { isRemoteTurn } from "../../lib/interaction.ts";
|
|
13
14
|
import { READ_ONLY_SUMMARY } from "../../lib/plan-entry.ts";
|
|
15
|
+
import { qrLines } from "../../lib/qr.ts";
|
|
14
16
|
import { feedbackSummary } from "./feedback.ts";
|
|
15
17
|
import { PlanIndexComponent, formatPlanIndex } from "./plan-index.ts";
|
|
16
18
|
import { runBrowserReview } from "./review.ts";
|
|
@@ -21,6 +23,7 @@ import { createPlanWebReview, type PlanWebReview } from "./web.ts";
|
|
|
21
23
|
const ENABLED_NOTICE = `Plan mode enabled — ${READ_ONLY_SUMMARY}.`;
|
|
22
24
|
const DISABLED_NOTICE = "Plan mode disabled — full access restored.";
|
|
23
25
|
const REVIEW_PREFIX = "review";
|
|
26
|
+
const SHARE_PREFIX = "share";
|
|
24
27
|
const LIST_PREFIX = "list";
|
|
25
28
|
const CANCEL_PREFIX = "cancel";
|
|
26
29
|
|
|
@@ -67,6 +70,44 @@ function isReviewSubcommand(prompt: string): boolean {
|
|
|
67
70
|
return target.endsWith(".md") || target.includes("/") || target.includes("\\");
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Whether a command line is the reserved `share` subcommand. Exact `share` and
|
|
75
|
+
* `share stop` are; `share the tokens` stays a task.
|
|
76
|
+
*/
|
|
77
|
+
function isShareSubcommand(prompt: string): boolean {
|
|
78
|
+
const text = prompt.toLowerCase();
|
|
79
|
+
return text === SHARE_PREFIX || text === `${SHARE_PREFIX} stop`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `/plan share`: print the link (and a QR) a teammate opens, or revoke it. The
|
|
84
|
+
* browser page has the same button; this is the path a phone-owned or remote
|
|
85
|
+
* session needs, where there is no page to click.
|
|
86
|
+
*/
|
|
87
|
+
async function shareReview(web: PlanWebReview, prompt: string, ctx: ExtensionContext): Promise<void> {
|
|
88
|
+
const stopping = prompt.toLowerCase().endsWith("stop");
|
|
89
|
+
const result = stopping ? await web.stopSharing() : await web.share();
|
|
90
|
+
if (!result) {
|
|
91
|
+
ctx.ui.notify("Plan share: no browser review is open; run /plan review first.", "warning");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (stopping) {
|
|
95
|
+
ctx.ui.notify("Plan share: reviewers can no longer open this review.", "info");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const link = result.state.links.at(-1);
|
|
99
|
+
if (!result.state.sharing || !link) {
|
|
100
|
+
ctx.ui.notify(`Plan share: ${result.state.reason ?? "no link is available"}.`, "warning");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const lines = ["Review link: " + link.url, ...(await qrLines(link.url))];
|
|
104
|
+
// One link is one person, so more than one is the team the page can list.
|
|
105
|
+
if (result.state.links.length > 1) {
|
|
106
|
+
lines.push(`${result.state.links.length} links shared — the page lists every link and who each one posts as.`);
|
|
107
|
+
}
|
|
108
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
109
|
+
}
|
|
110
|
+
|
|
70
111
|
/**
|
|
71
112
|
* Open `planPath` in the browser and, if the user saves comments, hand them to
|
|
72
113
|
* the model. A cancel is silent beyond a notice; nothing is sent.
|
|
@@ -137,6 +178,10 @@ async function openPlanIndex(
|
|
|
137
178
|
ctx: ExtensionContext,
|
|
138
179
|
): Promise<void> {
|
|
139
180
|
const dir = await runtime.plans.dirFor(ctx.cwd);
|
|
181
|
+
// A store an older version kept in the checkout was just moved in; the list is
|
|
182
|
+
// where a user notices their plans are somewhere else.
|
|
183
|
+
const moved = runtime.plans.takeNotice();
|
|
184
|
+
if (moved) ctx.ui.notify(moved, "info");
|
|
140
185
|
const summarise = async (): Promise<Awaited<ReturnType<typeof runtime.plans.index>>> => runtime.plans.index(ctx.cwd);
|
|
141
186
|
const summaries = await summarise();
|
|
142
187
|
|
|
@@ -173,11 +218,13 @@ export function registerCommands(
|
|
|
173
218
|
): void {
|
|
174
219
|
pi.registerCommand("plan", {
|
|
175
220
|
description:
|
|
176
|
-
"Toggle plan mode (read-only exploration), enter it with an optional task, list saved plans, review one in the browser, or cancel planning without approving",
|
|
221
|
+
"Toggle plan mode (read-only exploration), enter it with an optional task, list saved plans, review one in the browser, share that review with a link, or cancel planning without approving",
|
|
177
222
|
getArgumentCompletions: (prefix) => {
|
|
178
223
|
const text = prefix.trimStart();
|
|
179
224
|
if (text.includes(" ")) return null;
|
|
180
|
-
const subcommands = [LIST_PREFIX, REVIEW_PREFIX, CANCEL_PREFIX].filter((name) =>
|
|
225
|
+
const subcommands = [LIST_PREFIX, REVIEW_PREFIX, SHARE_PREFIX, CANCEL_PREFIX].filter((name) =>
|
|
226
|
+
name.startsWith(text),
|
|
227
|
+
);
|
|
181
228
|
return subcommands.length > 0 ? subcommands.map((value) => ({ value, label: value })) : null;
|
|
182
229
|
},
|
|
183
230
|
handler: async (args, ctx) => {
|
|
@@ -198,6 +245,10 @@ export function registerCommands(
|
|
|
198
245
|
await openPlanIndex(pi, runtime, web, ctx);
|
|
199
246
|
return;
|
|
200
247
|
}
|
|
248
|
+
if (isShareSubcommand(prompt)) {
|
|
249
|
+
await shareReview(web, prompt, ctx);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
201
252
|
if (isReviewSubcommand(prompt)) {
|
|
202
253
|
await reviewInBrowser(pi, runtime, web, reviewTarget(prompt), ctx);
|
|
203
254
|
return;
|
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Inline review feedback for plan documents (pure).
|
|
3
3
|
*
|
|
4
|
-
* A
|
|
5
|
-
*
|
|
4
|
+
* A comment is stored *in the plan file itself* as a blockquote on the line
|
|
5
|
+
* directly after the block it refers to:
|
|
6
6
|
*
|
|
7
7
|
* - [ ] Add a token bucket
|
|
8
|
-
* > [!REVIEW] what about the burst case?
|
|
8
|
+
* > [!REVIEW] @alice: what about the burst case?
|
|
9
|
+
* > [!REVIEW] name the ceiling too
|
|
9
10
|
*
|
|
10
11
|
* Placing it after a whole block (never inside a fence) keeps the markdown
|
|
11
|
-
* valid and puts the comment next to the text the model must revise.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* valid and puts the comment next to the text the model must revise. The author
|
|
13
|
+
* tag names whoever wrote it: a tagged marker belongs to that reviewer, and an
|
|
14
|
+
* untagged one is the plan owner's. Every write is authored, so saving one
|
|
15
|
+
* person's comments replaces only their own and leaves the rest in place.
|
|
16
|
+
*
|
|
17
|
+
* The anchor is a source line in the *stripped* plan, which is exactly what the
|
|
18
|
+
* browser rendered. Saving is idempotent: {@link formatFeedback} strips the
|
|
19
|
+
* writer's prior markers first, so repeated saves replace rather than
|
|
20
|
+
* accumulate.
|
|
15
21
|
*/
|
|
16
22
|
|
|
17
23
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
18
|
-
import { blockAt, planBlocks } from "./blocks.ts";
|
|
24
|
+
import { blockAt, planBlocks, type PlanBlock } from "./blocks.ts";
|
|
25
|
+
import { extractPlanSections } from "./steps.ts";
|
|
19
26
|
|
|
20
27
|
/** Marker that opens every stored feedback line. */
|
|
21
28
|
export const REVIEW_MARKER = "[!REVIEW]";
|
|
22
29
|
|
|
23
|
-
/** A single
|
|
30
|
+
/** A single review comment, anchored to a source block. */
|
|
24
31
|
export interface PlanAnnotation {
|
|
25
32
|
/** 0-based first source line of the anchored block, in the stripped plan. */
|
|
26
33
|
anchor: number;
|
|
@@ -28,6 +35,8 @@ export interface PlanAnnotation {
|
|
|
28
35
|
quote?: string;
|
|
29
36
|
/** The comment text. */
|
|
30
37
|
comment: string;
|
|
38
|
+
/** Who wrote it; `""`/absent is the plan owner, the only writer before sharing. */
|
|
39
|
+
author?: string;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
/** Most annotations one save may carry. */
|
|
@@ -36,14 +45,67 @@ export const MAX_ANNOTATIONS = 200;
|
|
|
36
45
|
export const MAX_COMMENT_LENGTH = 2000;
|
|
37
46
|
/** Longest retained selection quote. */
|
|
38
47
|
export const MAX_QUOTE_LENGTH = 500;
|
|
48
|
+
/** Longest stored author name. */
|
|
49
|
+
export const MAX_AUTHOR_LENGTH = 40;
|
|
50
|
+
|
|
51
|
+
/** The name a reviewer write is stored under, or why it cannot be decided. */
|
|
52
|
+
export type AuthorClaim = { name: string } | { status: number; message: string };
|
|
39
53
|
|
|
40
|
-
/** A stored feedback line, capturing its comment. */
|
|
54
|
+
/** A stored feedback line, capturing its author and comment. */
|
|
41
55
|
const MARKER_LINE = /^\s*>\s*\[!REVIEW\]\s?(.*)$/;
|
|
56
|
+
/** The author prefix inside a marker body (`@alice: note`). */
|
|
57
|
+
const AUTHOR_PREFIX = /^@([^:]{1,40}):\s?(.*)$/;
|
|
42
58
|
|
|
43
59
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
60
|
+
* A reviewer name as it is stored and displayed: one sanitized line, no leading
|
|
61
|
+
* `@`, no `:` (which would end the author early in the marker grammar), capped.
|
|
62
|
+
* Anything unusable becomes `""`, the owner.
|
|
46
63
|
*/
|
|
64
|
+
export function normalizeAuthor(raw: unknown): string {
|
|
65
|
+
if (typeof raw !== "string") return "";
|
|
66
|
+
const text = sanitizeLine(raw)
|
|
67
|
+
.replace(/^[\s@]+/, "")
|
|
68
|
+
.replace(/:/g, " ")
|
|
69
|
+
.replace(/\s+/g, " ")
|
|
70
|
+
.trim();
|
|
71
|
+
return text.slice(0, MAX_AUTHOR_LENGTH).trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Decide the name a reviewer's write is stored under.
|
|
76
|
+
*
|
|
77
|
+
* A bound link keeps its name: a payload that signs a different one is a stale or
|
|
78
|
+
* foreign page — a second tab of the same link, or a script — and re-signing it
|
|
79
|
+
* would move markers that belong to whoever picked that name. Before the first
|
|
80
|
+
* write the payload's own signature claims the name, unless another live link
|
|
81
|
+
* already holds it. An unsigned post claims nothing, so it is refused rather than
|
|
82
|
+
* binding a name to an accidental save.
|
|
83
|
+
*/
|
|
84
|
+
export function claimAuthor(
|
|
85
|
+
input: { bound?: string; taken?: readonly string[] },
|
|
86
|
+
annotations: readonly PlanAnnotation[],
|
|
87
|
+
): AuthorClaim {
|
|
88
|
+
const signed = [...new Set(annotations.map((annotation) => normalizeAuthor(annotation.author)))].filter(Boolean);
|
|
89
|
+
if (input.bound !== undefined) {
|
|
90
|
+
if (signed.some((name) => name !== input.bound)) {
|
|
91
|
+
return {
|
|
92
|
+
status: 409,
|
|
93
|
+
message: `This link posts as @${input.bound}; reload the page to comment as @${input.bound}.`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return { name: input.bound };
|
|
97
|
+
}
|
|
98
|
+
if (signed.length === 0) return { status: 400, message: "Choose a display name before commenting." };
|
|
99
|
+
if (signed.length > 1) return { status: 409, message: "A post signs one name; reload the page and post again." };
|
|
100
|
+
const name = signed[0] ?? "";
|
|
101
|
+
if ((input.taken ?? []).includes(name)) {
|
|
102
|
+
return { status: 400, message: "That name is already in use in this review." };
|
|
103
|
+
}
|
|
104
|
+
return { name };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Source lines inside a fenced code block. A marker-looking line there is
|
|
108
|
+
* documentation, not stored feedback, so it is never stripped or parsed. */
|
|
47
109
|
function fencedLines(plan: string): Set<number> {
|
|
48
110
|
const fenced = new Set<number>();
|
|
49
111
|
for (const block of planBlocks(plan)) {
|
|
@@ -53,104 +115,277 @@ function fencedLines(plan: string): Set<number> {
|
|
|
53
115
|
return fenced;
|
|
54
116
|
}
|
|
55
117
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
.join("\n");
|
|
118
|
+
/** A stored marker, with the clean-plan line it followed and who wrote it. */
|
|
119
|
+
interface StoredMarker {
|
|
120
|
+
/** Index in the clean plan of the line the marker followed; `-1` is the top. */
|
|
121
|
+
at: number;
|
|
122
|
+
comment: string;
|
|
123
|
+
author: string;
|
|
63
124
|
}
|
|
64
125
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
* marker in the stripped plan, so a comment keeps its place across reloads.
|
|
68
|
-
*/
|
|
69
|
-
export function parseFeedback(plan: string): PlanAnnotation[] {
|
|
126
|
+
/** The plan body without markers, plus the markers that were removed from it. */
|
|
127
|
+
function scanMarkers(plan: string): { clean: string; markers: StoredMarker[] } {
|
|
70
128
|
const fenced = fencedLines(plan);
|
|
71
129
|
const clean: string[] = [];
|
|
72
|
-
const markers:
|
|
130
|
+
const markers: StoredMarker[] = [];
|
|
73
131
|
plan.split("\n").forEach((line, index) => {
|
|
74
132
|
const match = fenced.has(index) ? null : MARKER_LINE.exec(line);
|
|
75
|
-
if (match) {
|
|
76
|
-
|
|
77
|
-
if (comment) markers.push({ after: clean.length, comment });
|
|
133
|
+
if (!match) {
|
|
134
|
+
clean.push(line);
|
|
78
135
|
return;
|
|
79
136
|
}
|
|
80
|
-
|
|
137
|
+
const body = match[1].trim();
|
|
138
|
+
if (body === "") return;
|
|
139
|
+
const authored = AUTHOR_PREFIX.exec(body);
|
|
140
|
+
const author = authored ? normalizeAuthor(authored[1]) : "";
|
|
141
|
+
const comment = (authored ? authored[2] : body).trim();
|
|
142
|
+
if (comment) markers.push({ at: clean.length - 1, comment, author });
|
|
81
143
|
});
|
|
82
|
-
|
|
144
|
+
return { clean: clean.join("\n"), markers };
|
|
145
|
+
}
|
|
83
146
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
147
|
+
/** One marker line in its stored form. */
|
|
148
|
+
function markerLine(author: string, comment: string): string {
|
|
149
|
+
const body = sanitizeLine(comment);
|
|
150
|
+
return author ? `> ${REVIEW_MARKER} @${author}: ${body}` : `> ${REVIEW_MARKER} ${body}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A marker waiting to be placed after clean-plan line `at`. */
|
|
154
|
+
interface Placed {
|
|
155
|
+
at: number;
|
|
156
|
+
line: string;
|
|
90
157
|
}
|
|
91
158
|
|
|
92
159
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* never split and a list item gains its comment as a following quote.
|
|
160
|
+
* Rebuild `clean` with `entries` inserted after the lines they name, grouping
|
|
161
|
+
* several entries on one line in the order given. `-1` places at the top.
|
|
96
162
|
*/
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
.map((annotation) => ({ ...annotation, comment: sanitizeLine(annotation.comment) }))
|
|
101
|
-
.filter((annotation) => annotation.comment.length > 0 && annotation.comment.length <= MAX_COMMENT_LENGTH);
|
|
102
|
-
if (items.length === 0) return clean;
|
|
103
|
-
if (clean === "") return items.map((annotation) => `> ${REVIEW_MARKER} ${annotation.comment}`).join("\n");
|
|
104
|
-
|
|
105
|
-
const blocks = planBlocks(clean);
|
|
106
|
-
// Insertion line -> marker lines, so several comments on one block stay grouped
|
|
107
|
-
// in the order they were written.
|
|
163
|
+
function compose(clean: string, entries: Placed[]): string {
|
|
164
|
+
if (entries.length === 0) return clean;
|
|
165
|
+
if (clean === "") return entries.map((entry) => entry.line).join("\n");
|
|
108
166
|
const buckets = new Map<number, string[]>();
|
|
109
|
-
for (const
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
bucket.push(`> ${REVIEW_MARKER} ${annotation.comment}`);
|
|
114
|
-
buckets.set(at, bucket);
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
const bucket = buckets.get(entry.at) ?? [];
|
|
169
|
+
bucket.push(entry.line);
|
|
170
|
+
buckets.set(entry.at, bucket);
|
|
115
171
|
}
|
|
116
|
-
|
|
172
|
+
const lines = clean.split("\n");
|
|
117
173
|
const out: string[] = [];
|
|
118
174
|
const flush = (at: number): void => {
|
|
119
175
|
const bucket = buckets.get(at);
|
|
120
176
|
if (bucket) out.push(...bucket);
|
|
121
177
|
};
|
|
122
178
|
flush(-1);
|
|
123
|
-
|
|
179
|
+
lines.forEach((line, index) => {
|
|
124
180
|
out.push(line);
|
|
125
181
|
flush(index);
|
|
126
182
|
});
|
|
183
|
+
// A marker placed past the last line still lands after it rather than nowhere.
|
|
184
|
+
flush(lines.length);
|
|
127
185
|
return out.join("\n");
|
|
128
186
|
}
|
|
129
187
|
|
|
188
|
+
/** Remove every stored feedback line, leaving the plan body as authored. */
|
|
189
|
+
export function stripFeedback(plan: string): string {
|
|
190
|
+
return scanMarkers(plan).clean;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Read stored feedback back. The anchor is the block ending just before each
|
|
195
|
+
* marker in the stripped plan, so a comment keeps its place across reloads.
|
|
196
|
+
*/
|
|
197
|
+
export function parseFeedback(plan: string): PlanAnnotation[] {
|
|
198
|
+
const { clean, markers } = scanMarkers(plan);
|
|
199
|
+
if (markers.length === 0) return [];
|
|
200
|
+
const blocks = planBlocks(clean);
|
|
201
|
+
return markers.map((marker) => {
|
|
202
|
+
const block = blockAt(blocks, marker.at);
|
|
203
|
+
const annotation: PlanAnnotation = { anchor: block ? block.start : 0, comment: marker.comment };
|
|
204
|
+
if (marker.author) annotation.author = marker.author;
|
|
205
|
+
return annotation;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Sanitized, non-empty comments with the anchor they land on. */
|
|
210
|
+
function storableComments(annotations: readonly PlanAnnotation[]): Array<{ anchor: number; comment: string }> {
|
|
211
|
+
const items: Array<{ anchor: number; comment: string }> = [];
|
|
212
|
+
for (const annotation of annotations) {
|
|
213
|
+
const comment = sanitizeLine(annotation.comment);
|
|
214
|
+
if (comment.length === 0 || comment.length > MAX_COMMENT_LENGTH) continue;
|
|
215
|
+
items.push({ anchor: annotation.anchor, comment });
|
|
216
|
+
}
|
|
217
|
+
return items;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Write `annotations` into `plan` as `options.author`'s comments, replacing the
|
|
222
|
+
* markers that author already had and keeping every other writer's in place.
|
|
223
|
+
* With no author, the writer is the plan owner — the untagged markers.
|
|
224
|
+
*/
|
|
225
|
+
export function formatFeedback(plan: string, annotations: PlanAnnotation[], options: { author?: string } = {}): string {
|
|
226
|
+
const author = normalizeAuthor(options.author);
|
|
227
|
+
const { clean, markers } = scanMarkers(plan);
|
|
228
|
+
const kept = markers
|
|
229
|
+
.filter((marker) => marker.author !== author)
|
|
230
|
+
.map((marker) => ({ at: marker.at, line: markerLine(marker.author, marker.comment) }));
|
|
231
|
+
const blocks = planBlocks(clean);
|
|
232
|
+
const added = storableComments(annotations).map((item) => ({
|
|
233
|
+
at: blockAt(blocks, item.anchor)?.end ?? -1,
|
|
234
|
+
line: markerLine(author, item.comment),
|
|
235
|
+
}));
|
|
236
|
+
return compose(clean, [...kept, ...added]);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Text normalized the way an anchor match compares it. */
|
|
240
|
+
function matchingText(text: string): string {
|
|
241
|
+
return text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Block text normalized for matching a comment's anchor across a rewrite. */
|
|
245
|
+
function blockKey(block: PlanBlock): string {
|
|
246
|
+
return matchingText(block.lines.join(" "));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A plan's heading sections, keyed by title and occurrence, each with the blocks
|
|
251
|
+
* inside it and its last source line. Two `## Notes` sections are told apart by
|
|
252
|
+
* which one they are, the way `changedSections` does it, so a duplicate heading
|
|
253
|
+
* never collects both sections' blocks.
|
|
254
|
+
*/
|
|
255
|
+
function sectionIndex(plan: string, blocks: readonly PlanBlock[]): Map<string, { blocks: PlanBlock[]; end: number }> {
|
|
256
|
+
const sections = extractPlanSections(plan);
|
|
257
|
+
const lineCount = plan === "" ? 0 : plan.split("\n").length;
|
|
258
|
+
const seen = new Map<string, number>();
|
|
259
|
+
const index = new Map<string, { blocks: PlanBlock[]; end: number }>();
|
|
260
|
+
const keys = sections.map((section, position) => {
|
|
261
|
+
const occurrence = seen.get(section.title) ?? 0;
|
|
262
|
+
seen.set(section.title, occurrence + 1);
|
|
263
|
+
const key = `${occurrence}\u0000${section.title}`;
|
|
264
|
+
index.set(key, { blocks: [], end: (sections[position + 1]?.line ?? lineCount) - 1 });
|
|
265
|
+
return key;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
let position = 0;
|
|
269
|
+
for (const block of blocks) {
|
|
270
|
+
while (position + 1 < sections.length && (sections[position + 1]?.line ?? 0) <= block.start) position += 1;
|
|
271
|
+
const key = keys[position];
|
|
272
|
+
if (key === undefined) continue;
|
|
273
|
+
index.get(key)?.blocks.push(block);
|
|
274
|
+
}
|
|
275
|
+
return index;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Carry the comments in `previous` onto `next`, the plan as it was rewritten.
|
|
280
|
+
* Comments are not plan content, so `next` is stripped of any marker it carries,
|
|
281
|
+
* and every old one is re-attached by the strongest evidence that survives: the
|
|
282
|
+
* block's text (per occurrence, so two identical steps keep their own comments),
|
|
283
|
+
* then the same section and ordinal, then the end of that section. A comment
|
|
284
|
+
* whose section is gone too is placed at the end rather than dropped, where the
|
|
285
|
+
* review page shows it as unattached.
|
|
286
|
+
*/
|
|
287
|
+
export function carryFeedback(previous: string, next: string): string {
|
|
288
|
+
const { clean: oldClean, markers } = scanMarkers(previous);
|
|
289
|
+
const cleanNext = stripFeedback(next);
|
|
290
|
+
if (markers.length === 0) return cleanNext;
|
|
291
|
+
const oldBlocks = planBlocks(oldClean);
|
|
292
|
+
const nextBlocks = planBlocks(cleanNext);
|
|
293
|
+
// Identical blocks queue up in document order, so the first unclaimed one takes
|
|
294
|
+
// the next comment rather than every comment landing on the last of them.
|
|
295
|
+
const queues = new Map<string, PlanBlock[]>();
|
|
296
|
+
for (const block of nextBlocks) {
|
|
297
|
+
if (block.kind === "blank") continue;
|
|
298
|
+
const key = blockKey(block);
|
|
299
|
+
const queue = queues.get(key);
|
|
300
|
+
if (queue) queue.push(block);
|
|
301
|
+
else queues.set(key, [block]);
|
|
302
|
+
}
|
|
303
|
+
const oldSections = sectionIndex(oldClean, oldBlocks);
|
|
304
|
+
const nextSections = sectionIndex(cleanNext, nextBlocks);
|
|
305
|
+
const lastLine = cleanNext === "" ? -1 : cleanNext.split("\n").length - 1;
|
|
306
|
+
|
|
307
|
+
/** The block at the same ordinal in the same section, else that section's end. */
|
|
308
|
+
const bySection = (block: PlanBlock): number | undefined => {
|
|
309
|
+
for (const [key, old] of oldSections) {
|
|
310
|
+
const ordinal = old.blocks.indexOf(block);
|
|
311
|
+
if (ordinal < 0) continue;
|
|
312
|
+
const section = nextSections.get(key);
|
|
313
|
+
if (section === undefined) return undefined;
|
|
314
|
+
const same = section.blocks[ordinal];
|
|
315
|
+
return same ? same.end : section.end;
|
|
316
|
+
}
|
|
317
|
+
return undefined;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const placed = markers.map((marker) => {
|
|
321
|
+
const block = blockAt(oldBlocks, marker.at);
|
|
322
|
+
const line = markerLine(marker.author, marker.comment);
|
|
323
|
+
if (block === undefined) return { at: lastLine, line };
|
|
324
|
+
if (block.kind !== "blank") {
|
|
325
|
+
const match = queues.get(blockKey(block))?.shift();
|
|
326
|
+
if (match !== undefined) return { at: match.end, line };
|
|
327
|
+
}
|
|
328
|
+
return { at: bySection(block) ?? lastLine, line };
|
|
329
|
+
});
|
|
330
|
+
return compose(cleanNext, placed);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Show stored comments in place, as the quotes they are: `@alice: …` for a
|
|
335
|
+
* reviewer, `Review: …` for the owner. A terminal review, a dialog, and a phone
|
|
336
|
+
* prompt read the plan through this, so a comment is never invisible just
|
|
337
|
+
* because it is not the reader's own. Fenced code is opaque, as everywhere else.
|
|
338
|
+
*/
|
|
339
|
+
export function displayFeedback(plan: string): string {
|
|
340
|
+
const fenced = fencedLines(plan);
|
|
341
|
+
return plan
|
|
342
|
+
.split("\n")
|
|
343
|
+
.map((line, index) => {
|
|
344
|
+
if (fenced.has(index)) return line;
|
|
345
|
+
const match = MARKER_LINE.exec(line);
|
|
346
|
+
if (!match) return line;
|
|
347
|
+
const body = match[1].trim();
|
|
348
|
+
if (body === "") return line;
|
|
349
|
+
const authored = AUTHOR_PREFIX.exec(body);
|
|
350
|
+
const author = authored ? normalizeAuthor(authored[1]) : "";
|
|
351
|
+
const comment = (authored ? authored[2] : body).trim();
|
|
352
|
+
if (!comment) return line;
|
|
353
|
+
return author ? `> @${author}: ${comment}` : `> Review: ${comment}`;
|
|
354
|
+
})
|
|
355
|
+
.join("\n");
|
|
356
|
+
}
|
|
357
|
+
|
|
130
358
|
/** One-line quote for the model summary. */
|
|
131
359
|
function shortQuote(quote: string): string {
|
|
132
360
|
const clean = sanitizeLine(quote);
|
|
133
361
|
return clean.length > 80 ? `${clean.slice(0, 79).trimEnd()}…` : clean;
|
|
134
362
|
}
|
|
135
363
|
|
|
136
|
-
/** Numbered, model-facing text for the comments a
|
|
364
|
+
/** Numbered, model-facing text for the comments a review carried. */
|
|
137
365
|
export function feedbackSummary(annotations: PlanAnnotation[]): string {
|
|
138
366
|
const items = annotations
|
|
139
367
|
.map((annotation) => ({
|
|
140
368
|
quote: annotation.quote ? shortQuote(annotation.quote) : "",
|
|
369
|
+
// A name attached by a payload is data from another browser, not a label
|
|
370
|
+
// the plan owner chose, so it is normalized like every other author.
|
|
371
|
+
author: normalizeAuthor(annotation.author),
|
|
141
372
|
comment: sanitizeLine(annotation.comment),
|
|
142
373
|
}))
|
|
143
374
|
.filter((item) => item.comment.length > 0);
|
|
144
375
|
return items
|
|
145
|
-
.map((item, index) =>
|
|
376
|
+
.map((item, index) => {
|
|
377
|
+
const who = item.author ? `@${item.author}: ` : "";
|
|
378
|
+
const where = item.quote ? `On "${item.quote}": ` : "";
|
|
379
|
+
return `${index + 1}. ${who}${where}${item.comment}`;
|
|
380
|
+
})
|
|
146
381
|
.join("\n");
|
|
147
382
|
}
|
|
148
383
|
|
|
149
384
|
/**
|
|
150
|
-
* Validate an untrusted annotations payload from
|
|
385
|
+
* Validate an untrusted annotations payload from a browser. Returns a
|
|
151
386
|
* normalized list, or `undefined` when the shape is wrong. Empty comments are
|
|
152
387
|
* dropped rather than rejected, so saving after deleting every comment clears
|
|
153
|
-
*
|
|
388
|
+
* that writer's feedback.
|
|
154
389
|
*/
|
|
155
390
|
export function coerceAnnotations(value: unknown): PlanAnnotation[] | undefined {
|
|
156
391
|
if (!Array.isArray(value) || value.length > MAX_ANNOTATIONS) return undefined;
|
|
@@ -162,11 +397,13 @@ export function coerceAnnotations(value: unknown): PlanAnnotation[] | undefined
|
|
|
162
397
|
if (typeof anchor !== "number" || !Number.isInteger(anchor) || anchor < 0) return undefined;
|
|
163
398
|
if (typeof record.comment !== "string") return undefined;
|
|
164
399
|
if (record.quote !== undefined && typeof record.quote !== "string") return undefined;
|
|
400
|
+
if (record.author !== undefined && typeof record.author !== "string") return undefined;
|
|
165
401
|
const comment = sanitizeLine(record.comment);
|
|
166
402
|
if (comment.length > MAX_COMMENT_LENGTH) return undefined;
|
|
167
403
|
if (comment.length === 0) continue;
|
|
168
404
|
const quote = typeof record.quote === "string" ? sanitizeLine(record.quote).slice(0, MAX_QUOTE_LENGTH) : undefined;
|
|
169
|
-
|
|
405
|
+
const author = normalizeAuthor(record.author);
|
|
406
|
+
annotations.push({ anchor, comment, ...(quote ? { quote } : {}), ...(author ? { author } : {}) });
|
|
170
407
|
}
|
|
171
408
|
return annotations;
|
|
172
409
|
}
|
package/extensions/plan/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* read-only git command, powershell is blocked, and everything else that mutates
|
|
7
7
|
* (worktree, MCP tools) is blocked. `subagent` stays active but is forced
|
|
8
8
|
* read-only. The one permitted write is `write_plan`,
|
|
9
|
-
* which saves the plan
|
|
9
|
+
* which saves the plan in the agent directory. The model investigates, saves the
|
|
10
10
|
* plan, and calls `exit_plan_mode` to read the file back and ask for approval.
|
|
11
11
|
*
|
|
12
12
|
* Toggle with `/plan`, `Ctrl+Alt+P`, or start with `--plan`. The state is
|
|
@@ -207,7 +207,7 @@ export class PlanIndexComponent implements Component {
|
|
|
207
207
|
|
|
208
208
|
const body: string[] = [];
|
|
209
209
|
if (this.summaries.length === 0) {
|
|
210
|
-
body.push(...emptyState(theme, w, { label: "No plans yet. write_plan saves one
|
|
210
|
+
body.push(...emptyState(theme, w, { label: "No plans yet. write_plan saves one in the agent directory." }));
|
|
211
211
|
} else {
|
|
212
212
|
const { start, end } = this.cursor.window(this.summaries.length);
|
|
213
213
|
for (let index = start; index < end; index++) {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
17
|
import { stripControlChars } from "../../lib/format.ts";
|
|
18
|
-
import { stripFeedback } from "./feedback.ts";
|
|
18
|
+
import { displayFeedback, stripFeedback } from "./feedback.ts";
|
|
19
19
|
import { extractPlanSections } from "./steps.ts";
|
|
20
20
|
|
|
21
21
|
/** Spaces a tab expands to; the terminal's own tab stops are not width-modelled. */
|
|
@@ -38,6 +38,14 @@ export function sanitizePlanText(text: string): string {
|
|
|
38
38
|
return stripControlChars(text.replace(/\t/g, " ".repeat(TAB_WIDTH)));
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The text a review *shows*: terminal-safe, with stored comments kept in place
|
|
43
|
+
* as readable quotes, so a reviewer sees what a teammate wrote.
|
|
44
|
+
*/
|
|
45
|
+
export function planDisplayText(content: string): string {
|
|
46
|
+
return displayFeedback(sanitizePlanText(content));
|
|
47
|
+
}
|
|
48
|
+
|
|
41
49
|
/**
|
|
42
50
|
* The text a review compares: terminal-safe, with tabs expanded and stored
|
|
43
51
|
* feedback removed. A review comment a browser wrote into the file is a note to
|
|
@@ -52,6 +60,15 @@ function digestOf(text: string): string {
|
|
|
52
60
|
return createHash("sha256").update(text.trim()).digest("hex");
|
|
53
61
|
}
|
|
54
62
|
|
|
63
|
+
/**
|
|
64
|
+
* A short, stable id for one plan text. The review page keys this tab's unsaved
|
|
65
|
+
* draft on it, so a draft is restored for the plan it was written against and
|
|
66
|
+
* dropped for a plan that changed underneath it.
|
|
67
|
+
*/
|
|
68
|
+
export function planDigest(text: string): string {
|
|
69
|
+
return digestOf(text).slice(0, 12);
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
/** One digest per heading section, in document order, for a persisted baseline. */
|
|
56
73
|
export function sectionDigests(content: string): SectionDigest[] {
|
|
57
74
|
return extractPlanSections(reviewText(content)).map((section) => ({
|