@gavin-hu/my-pi-agent 0.17.0 → 0.19.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 +61 -45
- package/README.zh-CN.md +54 -42
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +344 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
- package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
- package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
- package/docs/extensions/file-browse.md +554 -0
- package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
- package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
- package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
- package/{extensions/job/README.md → docs/extensions/job.md} +66 -53
- package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
- package/docs/extensions/plan.md +976 -0
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
- package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
- package/docs/extensions/session-bus.md +285 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
- package/docs/extensions/team.md +226 -0
- package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
- package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
- package/extensions/board/commands.ts +87 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +72 -0
- package/extensions/board/index.ts +40 -0
- package/extensions/board/input.ts +54 -0
- package/extensions/board/join.ts +42 -0
- package/extensions/board/page.ts +312 -0
- package/extensions/board/result.ts +99 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +75 -0
- package/extensions/board/seat.ts +29 -0
- package/extensions/board/snapshot.ts +41 -0
- package/extensions/board/tools.ts +279 -0
- package/extensions/board/transport/router.ts +172 -0
- package/extensions/board/transport/server.ts +149 -0
- package/extensions/board/types.ts +54 -0
- package/extensions/board/view/actions.ts +306 -0
- package/extensions/board/view/client.ts +41 -0
- package/extensions/board/view/keys.ts +121 -0
- package/extensions/board/view/live.ts +397 -0
- package/extensions/board/view/page.ts +158 -0
- package/extensions/board/view/render.ts +496 -0
- package/extensions/board/view/styles.ts +211 -0
- package/extensions/board/view/team.ts +305 -0
- package/extensions/board/writes.ts +99 -0
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +40 -7
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +11 -21
- package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
- package/extensions/bytefly/picker/discovery.ts +0 -2
- package/extensions/bytefly/picker/types.ts +0 -2
- package/extensions/bytefly/update/check.ts +39 -0
- package/extensions/bytefly/update/format.ts +15 -0
- package/extensions/bytefly/update/register.ts +73 -34
- package/extensions/bytefly/update/state.ts +6 -3
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
- package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
- package/extensions/file-browse/index.ts +4 -4
- package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
- package/extensions/file-browse/live/rev.ts +42 -0
- package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/view/chrome.ts +83 -0
- package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
- package/extensions/file-browse/view/diff-html.ts +94 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
- package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
- package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
- package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
- package/extensions/file-read/schema.ts +2 -1
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +77 -21
- package/extensions/plan/feedback.ts +409 -63
- package/extensions/plan/index.ts +7 -5
- package/extensions/plan/plan-index.ts +44 -10
- package/extensions/plan/plan-text.ts +51 -24
- package/extensions/plan/plans.ts +162 -57
- package/extensions/plan/policy.ts +20 -12
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +51 -95
- package/extensions/plan/runtime.ts +22 -10
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +63 -95
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +13 -19
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +29 -15
- package/extensions/plan/types.ts +29 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +122 -178
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +96 -17
- package/extensions/plan/web-router.ts +109 -47
- package/extensions/plan/web-share-session.ts +191 -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 +245 -151
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
- package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
- package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
- package/extensions/rewind/{config.ts → config/config.ts} +1 -1
- package/extensions/rewind/index.ts +3 -3
- package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
- package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
- package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
- package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
- package/extensions/rewind/{format.ts → text/format.ts} +3 -3
- package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
- package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
- package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
- package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
- package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
- package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
- package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
- package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
- package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
- package/extensions/rewind/{status.ts → view/status.ts} +4 -4
- package/extensions/session-bus/commands.ts +113 -0
- package/extensions/session-bus/format.ts +101 -0
- package/extensions/session-bus/index.ts +75 -0
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +86 -0
- package/extensions/session-bus/overlap.ts +36 -0
- package/extensions/session-bus/protocol.ts +104 -0
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +208 -0
- package/extensions/session-bus/rows.ts +171 -0
- package/extensions/session-bus/runtime.ts +466 -0
- package/extensions/session-bus/store.ts +365 -0
- package/extensions/session-bus/tools.ts +192 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +112 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +105 -0
- package/extensions/team/index.ts +33 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +246 -0
- package/extensions/team/types.ts +48 -0
- package/extensions/watch/index.ts +15 -9
- package/extensions/web-access/fetch/page.ts +4 -1
- package/extensions/web-access/fetch/structured.ts +447 -0
- package/extensions/web-access/fetch/tool.ts +2 -1
- package/extensions/web-access/search/tool.ts +2 -1
- package/extensions/wechat/bridge.ts +76 -92
- package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
- package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
- package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
- package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
- package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
- package/extensions/wechat/dialog/channels.ts +136 -0
- package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
- package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
- package/extensions/wechat/index.ts +10 -10
- package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
- package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
- package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
- package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
- package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
- package/extensions/wechat/{state.ts → state/state.ts} +14 -2
- package/extensions/wechat/{format.ts → text/format.ts} +3 -3
- package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
- package/extensions/wechat/tools.ts +29 -12
- package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
- package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
- package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
- package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
- package/extensions/wechat/transport/outbound.ts +142 -0
- package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
- package/extensions/wechat/types.ts +10 -1
- package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
- package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/guard.ts +3 -5
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +12 -6
- package/lib/board/card.ts +290 -0
- package/lib/board/links.ts +117 -0
- package/lib/board/page-pointer.ts +256 -0
- package/lib/board/round-text.ts +72 -0
- package/lib/board/round.ts +89 -0
- package/lib/board/rules.ts +304 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +310 -0
- package/lib/board/view.ts +150 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +13 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/router.ts +40 -13
- package/lib/http/serve.ts +234 -0
- package/lib/http/server.ts +29 -13
- package/lib/http/sink.ts +110 -0
- package/lib/http/token.ts +38 -0
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/policy.ts +12 -2
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +7 -9
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +130 -0
- package/lib/session-tool.ts +184 -0
- package/lib/tool-names.ts +25 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/glyphs.ts +2 -0
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/README.md +5 -2
- package/lib/web/components.ts +6 -1
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +27 -14
- 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 +77 -51
- package/skills/project-docs/SKILL.md +133 -0
- package/skills/project-docs/references/audit.md +131 -0
- package/skills/project-docs/references/changelog.md +105 -0
- package/skills/project-docs/references/discovery.md +127 -0
- package/skills/project-docs/references/readme.md +80 -0
- package/skills/project-docs/references/release.md +84 -0
- package/skills/project-docs/references/roadmap.md +93 -0
- package/skills/project-docs/scripts/state.ts +316 -0
- package/skills/refactor/SKILL.md +100 -125
- package/skills/refactor/references/handoff.md +35 -0
- package/skills/refactor/references/signals.md +54 -0
- package/skills/refactor/scripts/inventory.ts +587 -0
- package/extensions/file-browse/README.md +0 -467
- package/extensions/file-browse/server.ts +0 -134
- package/extensions/plan/README.md +0 -709
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/outbound.ts +0 -100
- package/extensions/wechat/qrcode.ts +0 -46
- package/skills/changelog/SKILL.md +0 -148
- /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
- /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
- /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
- /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
- /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
- /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
- /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
- /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
- /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
|
@@ -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, contentBlockAt, planBlocks, type PlanBlock } from "./blocks.ts";
|
|
25
|
+
import { extractSections, sectionKeys } from "./sections.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.
|
|
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.
|
|
46
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,386 @@ 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
|
+
* A plan read the way the review reads it: its body, its blocks, and its
|
|
195
|
+
* comments, out of one parse.
|
|
196
|
+
*/
|
|
197
|
+
export interface FeedbackRead {
|
|
198
|
+
/** The plan body with every stored marker removed. */
|
|
199
|
+
body: string;
|
|
200
|
+
/** The body split into source-line blocks: the shape an anchor names. */
|
|
201
|
+
blocks: PlanBlock[];
|
|
202
|
+
/** The comments the markers carried, each anchored to a content block's start. */
|
|
203
|
+
annotations: PlanAnnotation[];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Read a plan once. An anchor is the start of the block the marker follows, so a
|
|
208
|
+
* comment keeps its place across reloads — and it is always a block with
|
|
209
|
+
* content, because the page renders no line for a blank one and so can never
|
|
210
|
+
* attach a thread to it. A plan whose every line is blank has no such block: its
|
|
211
|
+
* comments keep anchor `0` and the page lists them as unattached, which is the
|
|
212
|
+
* truth there.
|
|
213
|
+
*/
|
|
214
|
+
export function readFeedback(plan: string): FeedbackRead {
|
|
215
|
+
const { clean, markers } = scanMarkers(plan);
|
|
216
|
+
const blocks = planBlocks(clean);
|
|
217
|
+
const annotations = markers.map((marker) => {
|
|
218
|
+
const annotation: PlanAnnotation = {
|
|
219
|
+
anchor: contentBlockAt(blocks, marker.at)?.start ?? 0,
|
|
220
|
+
comment: marker.comment,
|
|
221
|
+
};
|
|
222
|
+
if (marker.author) annotation.author = marker.author;
|
|
223
|
+
return annotation;
|
|
224
|
+
});
|
|
225
|
+
return { body: clean, blocks, annotations };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The comments a plan carries, in the order they were written. */
|
|
229
|
+
export function parseFeedback(plan: string): PlanAnnotation[] {
|
|
230
|
+
return readFeedback(plan).annotations;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Sanitized, non-empty comments with the anchor they land on. */
|
|
234
|
+
function storableComments(annotations: readonly PlanAnnotation[]): Array<{ anchor: number; comment: string }> {
|
|
235
|
+
const items: Array<{ anchor: number; comment: string }> = [];
|
|
236
|
+
for (const annotation of annotations) {
|
|
237
|
+
const comment = sanitizeLine(annotation.comment);
|
|
238
|
+
if (comment.length === 0 || comment.length > MAX_COMMENT_LENGTH) continue;
|
|
239
|
+
items.push({ anchor: annotation.anchor, comment });
|
|
240
|
+
}
|
|
241
|
+
return items;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Write `annotations` into `plan` as `options.author`'s comments, replacing the
|
|
246
|
+
* markers that author already had and keeping every other writer's in place.
|
|
247
|
+
* With no author, the writer is the plan owner — the untagged markers.
|
|
248
|
+
*/
|
|
249
|
+
export function formatFeedback(plan: string, annotations: PlanAnnotation[], options: { author?: string } = {}): string {
|
|
250
|
+
const author = normalizeAuthor(options.author);
|
|
251
|
+
const { clean, markers } = scanMarkers(plan);
|
|
252
|
+
const kept = markers
|
|
253
|
+
.filter((marker) => marker.author !== author)
|
|
254
|
+
.map((marker) => ({ at: marker.at, line: markerLine(marker.author, marker.comment) }));
|
|
255
|
+
const blocks = planBlocks(clean);
|
|
256
|
+
const added = storableComments(annotations).map((item) => ({
|
|
257
|
+
// A marker beside a block, never after a blank line: an anchor that names
|
|
258
|
+
// blank space is written where the page can read it back as a thread.
|
|
259
|
+
at: contentBlockAt(blocks, item.anchor)?.end ?? -1,
|
|
260
|
+
line: markerLine(author, item.comment),
|
|
261
|
+
}));
|
|
262
|
+
return compose(clean, [...kept, ...added]);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Text normalized the way an anchor match compares it. */
|
|
266
|
+
function matchingText(text: string): string {
|
|
267
|
+
return text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Block text normalized for matching a comment's anchor across a rewrite. */
|
|
271
|
+
function blockKey(block: PlanBlock): string {
|
|
272
|
+
return matchingText(block.lines.join(" "));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Words a block is compared by when it may merely have been reworded: lowercased,
|
|
277
|
+
* with the list marker and task checkbox dropped. Every step of a `## Steps`
|
|
278
|
+
* section would otherwise match every other one through its `- [ ]` alone.
|
|
279
|
+
*/
|
|
280
|
+
function blockWords(block: PlanBlock): Set<string> {
|
|
281
|
+
const text = block.lines
|
|
282
|
+
.join(" ")
|
|
283
|
+
.toLowerCase()
|
|
284
|
+
.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, " ")
|
|
285
|
+
.replace(/\[[ x]\]/g, " ");
|
|
286
|
+
return new Set(text.split(/[^\p{L}\p{N}]+/u).filter((word) => word !== ""));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Share of the longer block's words that both blocks have. */
|
|
290
|
+
function sharedShare(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
|
|
291
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
292
|
+
let shared = 0;
|
|
293
|
+
for (const word of a) if (b.has(word)) shared += 1;
|
|
294
|
+
return shared / Math.max(a.size, b.size);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Share of its words a block keeps, at or above which it is the same block reworded. */
|
|
298
|
+
const MATCH_SHARE = 0.5;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Which block of `next` each block of `old` became, keeping both lists in
|
|
302
|
+
* document order: the longest set of pairs whose wording overlaps enough to be one
|
|
303
|
+
* block reworded. A block left without a pair is `-1` — deleted, or replaced by
|
|
304
|
+
* text that shares nothing — which is the gap its comment belongs in.
|
|
305
|
+
*/
|
|
306
|
+
function alignBlocks(old: readonly PlanBlock[], next: readonly PlanBlock[]): number[] {
|
|
307
|
+
const before = old.map(blockWords);
|
|
308
|
+
const after = next.map(blockWords);
|
|
309
|
+
const matches = before.map((words) => after.map((other) => (sharedShare(words, other) >= MATCH_SHARE ? 1 : 0)));
|
|
310
|
+
// A longest common subsequence over the match grid, so a block that moved keeps
|
|
311
|
+
// its own pairing instead of pulling the blocks around it along.
|
|
312
|
+
const width = next.length + 1;
|
|
313
|
+
const counts = new Array<number>((old.length + 1) * width).fill(0);
|
|
314
|
+
for (let row = old.length - 1; row >= 0; row--) {
|
|
315
|
+
for (let column = next.length - 1; column >= 0; column--) {
|
|
316
|
+
const paired = matches[row][column] === 1 ? 1 + counts[(row + 1) * width + column + 1] : 0;
|
|
317
|
+
counts[row * width + column] = Math.max(
|
|
318
|
+
paired,
|
|
319
|
+
counts[(row + 1) * width + column],
|
|
320
|
+
counts[row * width + column + 1],
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const aligned = new Array<number>(old.length).fill(-1);
|
|
325
|
+
let row = 0;
|
|
326
|
+
let column = 0;
|
|
327
|
+
while (row < old.length && column < next.length) {
|
|
328
|
+
if (matches[row][column] === 1 && counts[row * width + column] === 1 + counts[(row + 1) * width + column + 1]) {
|
|
329
|
+
aligned[row] = column;
|
|
330
|
+
row += 1;
|
|
331
|
+
column += 1;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (counts[(row + 1) * width + column] >= counts[row * width + column + 1]) row += 1;
|
|
335
|
+
else column += 1;
|
|
336
|
+
}
|
|
337
|
+
return aligned;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A plan's heading sections, keyed by `sectionKeys`, each with the blocks a
|
|
342
|
+
* comment can hang on and the last line of that content. Blank lines are left out
|
|
343
|
+
* on purpose: a position among them would move when one is added or removed
|
|
344
|
+
* above, and a reworded step would lose its comment to the next block. A section
|
|
345
|
+
* with no content block ends at its own heading, which the page renders.
|
|
346
|
+
*/
|
|
347
|
+
function sectionIndex(plan: string, blocks: readonly PlanBlock[]): Map<string, { blocks: PlanBlock[]; end: number }> {
|
|
348
|
+
const sections = extractSections(plan);
|
|
349
|
+
const index = new Map<string, { blocks: PlanBlock[]; end: number }>();
|
|
350
|
+
const keys = sectionKeys(sections);
|
|
351
|
+
keys.forEach((key, at) => index.set(key, { blocks: [], end: sections[at]?.line ?? 0 }));
|
|
352
|
+
|
|
353
|
+
let position = 0;
|
|
354
|
+
for (const block of blocks) {
|
|
355
|
+
while (position + 1 < sections.length && (sections[position + 1]?.line ?? 0) <= block.start) position += 1;
|
|
356
|
+
const key = keys[position];
|
|
357
|
+
if (key === undefined) continue;
|
|
358
|
+
const entry = index.get(key);
|
|
359
|
+
if (!entry || block.kind === "blank") continue;
|
|
360
|
+
entry.blocks.push(block);
|
|
361
|
+
entry.end = block.end;
|
|
362
|
+
}
|
|
363
|
+
return index;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Carry the comments in `previous` onto `next`, the plan as it was rewritten.
|
|
368
|
+
* Comments are not plan content, so `next` is stripped of any marker it carries,
|
|
369
|
+
* and every old one is re-attached by the strongest evidence that survives: the
|
|
370
|
+
* block's text (per occurrence, so two identical steps keep their own comments),
|
|
371
|
+
* then the block the alignment says it became when the wording changed, then the
|
|
372
|
+
* gap it left between the blocks that survived, then the end of that section's
|
|
373
|
+
* content. A comment whose section is gone too moves to the plan's last content
|
|
374
|
+
* block, so the page keeps showing it under a block rather than listing it as
|
|
375
|
+
* unattached; only a plan with no block at all leaves it in that list.
|
|
376
|
+
*/
|
|
377
|
+
export function carryFeedback(previous: string, next: string): string {
|
|
378
|
+
const { clean: oldClean, markers } = scanMarkers(previous);
|
|
379
|
+
const cleanNext = stripFeedback(next);
|
|
380
|
+
if (markers.length === 0) return cleanNext;
|
|
381
|
+
const oldBlocks = planBlocks(oldClean);
|
|
382
|
+
const nextBlocks = planBlocks(cleanNext);
|
|
383
|
+
// Identical blocks queue up in document order, so the first unclaimed one takes
|
|
384
|
+
// the next comment rather than every comment landing on the last of them.
|
|
385
|
+
const queues = new Map<string, PlanBlock[]>();
|
|
386
|
+
for (const block of nextBlocks) {
|
|
387
|
+
if (block.kind === "blank") continue;
|
|
388
|
+
const key = blockKey(block);
|
|
389
|
+
const queue = queues.get(key);
|
|
390
|
+
if (queue) queue.push(block);
|
|
391
|
+
else queues.set(key, [block]);
|
|
392
|
+
}
|
|
393
|
+
const oldSections = sectionIndex(oldClean, oldBlocks);
|
|
394
|
+
const nextSections = sectionIndex(cleanNext, nextBlocks);
|
|
395
|
+
const lastLine = cleanNext === "" ? -1 : cleanNext.split("\n").length - 1;
|
|
396
|
+
// Where a comment goes when nothing about it survived: beside the plan's last
|
|
397
|
+
// block, never after the blank line a trailing newline leaves.
|
|
398
|
+
const tail = contentBlockAt(nextBlocks, lastLine)?.end ?? lastLine;
|
|
399
|
+
|
|
400
|
+
/** One alignment per section, reused by every comment that lands in it. */
|
|
401
|
+
const alignments = new Map<string, number[]>();
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Where a comment goes when its own block's text is gone: the block the alignment
|
|
405
|
+
* says it became, else the gap it left, else its section's end.
|
|
406
|
+
*/
|
|
407
|
+
const bySection = (block: PlanBlock): number | undefined => {
|
|
408
|
+
for (const [key, before] of oldSections) {
|
|
409
|
+
const index = before.blocks.indexOf(block);
|
|
410
|
+
if (index < 0) continue;
|
|
411
|
+
const after = nextSections.get(key);
|
|
412
|
+
if (after === undefined) return undefined;
|
|
413
|
+
let aligned = alignments.get(key);
|
|
414
|
+
if (aligned === undefined) {
|
|
415
|
+
aligned = alignBlocks(before.blocks, after.blocks);
|
|
416
|
+
alignments.set(key, aligned);
|
|
417
|
+
}
|
|
418
|
+
const became = aligned[index];
|
|
419
|
+
if (became >= 0) return after.blocks[became].end;
|
|
420
|
+
// The gap it left is right after the block before its nearest surviving
|
|
421
|
+
// successor: for the first block of a section, that is the heading.
|
|
422
|
+
const successor = aligned.findIndex((at, from) => from > index && at >= 0);
|
|
423
|
+
const gap = successor >= 0 ? after.blocks[aligned[successor] - 1] : undefined;
|
|
424
|
+
return gap?.end ?? after.end;
|
|
425
|
+
}
|
|
426
|
+
return undefined;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const placed = markers.map((marker) => {
|
|
430
|
+
const block = blockAt(oldBlocks, marker.at);
|
|
431
|
+
const line = markerLine(marker.author, marker.comment);
|
|
432
|
+
if (block === undefined) return { at: tail, line };
|
|
433
|
+
if (block.kind !== "blank") {
|
|
434
|
+
const match = queues.get(blockKey(block))?.shift();
|
|
435
|
+
if (match !== undefined) return { at: match.end, line };
|
|
436
|
+
}
|
|
437
|
+
return { at: bySection(block) ?? tail, line };
|
|
438
|
+
});
|
|
439
|
+
return compose(cleanNext, placed);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Show stored comments in place, as the quotes they are: `@alice: …` for a
|
|
444
|
+
* reviewer, `Review: …` for the owner. A terminal review, a dialog, and a phone
|
|
445
|
+
* prompt read the plan through this, so a comment is never invisible just
|
|
446
|
+
* because it is not the reader's own. Fenced code is opaque, as everywhere else.
|
|
447
|
+
*/
|
|
448
|
+
export function displayFeedback(plan: string): string {
|
|
449
|
+
const fenced = fencedLines(plan);
|
|
450
|
+
return plan
|
|
451
|
+
.split("\n")
|
|
452
|
+
.map((line, index) => {
|
|
453
|
+
if (fenced.has(index)) return line;
|
|
454
|
+
const match = MARKER_LINE.exec(line);
|
|
455
|
+
if (!match) return line;
|
|
456
|
+
const body = match[1].trim();
|
|
457
|
+
if (body === "") return line;
|
|
458
|
+
const authored = AUTHOR_PREFIX.exec(body);
|
|
459
|
+
const author = authored ? normalizeAuthor(authored[1]) : "";
|
|
460
|
+
const comment = (authored ? authored[2] : body).trim();
|
|
461
|
+
if (!comment) return line;
|
|
462
|
+
return author ? `> @${author}: ${comment}` : `> Review: ${comment}`;
|
|
463
|
+
})
|
|
464
|
+
.join("\n");
|
|
465
|
+
}
|
|
466
|
+
|
|
130
467
|
/** One-line quote for the model summary. */
|
|
131
468
|
function shortQuote(quote: string): string {
|
|
132
469
|
const clean = sanitizeLine(quote);
|
|
133
470
|
return clean.length > 80 ? `${clean.slice(0, 79).trimEnd()}…` : clean;
|
|
134
471
|
}
|
|
135
472
|
|
|
136
|
-
/** Numbered, model-facing text for the comments a
|
|
473
|
+
/** Numbered, model-facing text for the comments a review carried. */
|
|
137
474
|
export function feedbackSummary(annotations: PlanAnnotation[]): string {
|
|
138
475
|
const items = annotations
|
|
139
476
|
.map((annotation) => ({
|
|
140
477
|
quote: annotation.quote ? shortQuote(annotation.quote) : "",
|
|
478
|
+
// A name attached by a payload is data from another browser, not a label
|
|
479
|
+
// the plan owner chose, so it is normalized like every other author.
|
|
480
|
+
author: normalizeAuthor(annotation.author),
|
|
141
481
|
comment: sanitizeLine(annotation.comment),
|
|
142
482
|
}))
|
|
143
483
|
.filter((item) => item.comment.length > 0);
|
|
144
484
|
return items
|
|
145
|
-
.map((item, index) =>
|
|
485
|
+
.map((item, index) => {
|
|
486
|
+
const who = item.author ? `@${item.author}: ` : "";
|
|
487
|
+
const where = item.quote ? `On "${item.quote}": ` : "";
|
|
488
|
+
return `${index + 1}. ${who}${where}${item.comment}`;
|
|
489
|
+
})
|
|
146
490
|
.join("\n");
|
|
147
491
|
}
|
|
148
492
|
|
|
149
493
|
/**
|
|
150
|
-
* Validate an untrusted annotations payload from
|
|
494
|
+
* Validate an untrusted annotations payload from a browser. Returns a
|
|
151
495
|
* normalized list, or `undefined` when the shape is wrong. Empty comments are
|
|
152
496
|
* dropped rather than rejected, so saving after deleting every comment clears
|
|
153
|
-
*
|
|
497
|
+
* that writer's feedback.
|
|
154
498
|
*/
|
|
155
499
|
export function coerceAnnotations(value: unknown): PlanAnnotation[] | undefined {
|
|
156
500
|
if (!Array.isArray(value) || value.length > MAX_ANNOTATIONS) return undefined;
|
|
@@ -162,11 +506,13 @@ export function coerceAnnotations(value: unknown): PlanAnnotation[] | undefined
|
|
|
162
506
|
if (typeof anchor !== "number" || !Number.isInteger(anchor) || anchor < 0) return undefined;
|
|
163
507
|
if (typeof record.comment !== "string") return undefined;
|
|
164
508
|
if (record.quote !== undefined && typeof record.quote !== "string") return undefined;
|
|
509
|
+
if (record.author !== undefined && typeof record.author !== "string") return undefined;
|
|
165
510
|
const comment = sanitizeLine(record.comment);
|
|
166
511
|
if (comment.length > MAX_COMMENT_LENGTH) return undefined;
|
|
167
512
|
if (comment.length === 0) continue;
|
|
168
513
|
const quote = typeof record.quote === "string" ? sanitizeLine(record.quote).slice(0, MAX_QUOTE_LENGTH) : undefined;
|
|
169
|
-
|
|
514
|
+
const author = normalizeAuthor(record.author);
|
|
515
|
+
annotations.push({ anchor, comment, ...(quote ? { quote } : {}), ...(author ? { author } : {}) });
|
|
170
516
|
}
|
|
171
517
|
return annotations;
|
|
172
518
|
}
|
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
|
|
@@ -149,10 +149,12 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
149
149
|
* the path backstop (a hint on a path-carrying tool is only a claim).
|
|
150
150
|
*/
|
|
151
151
|
const blockedReason = (toolName: string, input: unknown): string | undefined => {
|
|
152
|
-
// Plan-owned
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
152
|
+
// Plan-owned tools are the sanctioned exception: `write_plan` saves the
|
|
153
|
+
// artifact, `exit_plan_mode` presents it, `cancel_plan_mode` restores access,
|
|
154
|
+
// and `enter_plan_mode` answers "already" to a call that was in flight when
|
|
155
|
+
// plan mode came on. The source-path check keeps a same-named tool from
|
|
156
|
+
// another extension from borrowing the exemption.
|
|
157
|
+
if (runtime.isOwnTool(toolName)) return undefined;
|
|
156
158
|
// `subagent` stays active, but only as read-only delegation. The flag is
|
|
157
159
|
// forced in place (the child enforces it through `--tools`), so the model
|
|
158
160
|
// cannot forget it; a `subagent` that does not declare the parameter is
|