@gavin-hu/my-pi-agent 0.18.0 → 0.20.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 +66 -52
- package/README.zh-CN.md +56 -46
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +507 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
- 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 +561 -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} +62 -52
- package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
- package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
- package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
- package/docs/extensions/session-bus.md +298 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
- package/docs/extensions/team.md +254 -0
- package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
- package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +260 -163
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
- package/extensions/board/commands.ts +105 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +170 -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 +388 -0
- package/extensions/board/result.ts +108 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +90 -0
- package/extensions/board/seat.ts +28 -0
- package/extensions/board/snapshot.ts +42 -0
- package/extensions/board/tools.ts +444 -0
- package/extensions/board/transport/router.ts +91 -0
- package/extensions/board/transport/server.ts +138 -0
- package/extensions/board/types.ts +121 -0
- package/extensions/board/view/client.ts +51 -0
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +125 -0
- package/extensions/board/view/live.ts +231 -0
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +118 -0
- package/extensions/board/view/render.ts +519 -0
- package/extensions/board/view/styles.ts +228 -0
- package/extensions/board/view/team.ts +327 -0
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -8
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +117 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/panel/commands.ts +67 -7
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +75 -43
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- 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/check/commands.ts +2 -1
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +12 -0
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/files/tree.ts +101 -0
- 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} +20 -69
- package/extensions/file-browse/{router.ts → transport/router.ts} +21 -17
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
- package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
- package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
- package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -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/job/runtime.ts +4 -4
- package/extensions/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +21 -16
- package/extensions/plan/feedback.ts +185 -52
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +23 -5
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-index.ts +43 -9
- package/extensions/plan/plan-text.ts +33 -23
- package/extensions/plan/plans.ts +27 -34
- package/extensions/plan/policy.ts +30 -17
- package/extensions/plan/review.ts +3 -27
- package/extensions/plan/runtime.ts +52 -11
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +3 -22
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +20 -95
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/tui.ts +5 -34
- package/extensions/plan/types.ts +15 -8
- package/extensions/plan/web-blocks.ts +89 -0
- package/extensions/plan/web-chrome.ts +3 -5
- package/extensions/plan/web-client.ts +179 -330
- package/extensions/plan/web-collab.ts +3 -3
- package/extensions/plan/web-comments.ts +18 -8
- package/extensions/plan/web-composer.ts +80 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +134 -0
- package/extensions/plan/web-outline.ts +192 -0
- package/extensions/plan/web-page.ts +4 -8
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +3 -58
- package/extensions/plan/web-settle.ts +194 -0
- package/extensions/plan/web-share-session.ts +24 -30
- package/extensions/plan/web-streams.ts +14 -14
- package/extensions/plan/web-tokens.ts +2 -2
- package/extensions/plan/web.ts +66 -120
- package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
- package/extensions/rewind/{review.ts → commands/review.ts} +15 -14
- 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 +17 -69
- package/extensions/session-bus/format.ts +22 -45
- package/extensions/session-bus/index.ts +13 -7
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +32 -4
- package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
- package/extensions/session-bus/protocol.ts +19 -49
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +16 -51
- package/extensions/session-bus/rows.ts +11 -5
- package/extensions/session-bus/runtime.ts +220 -267
- package/extensions/session-bus/store.ts +55 -7
- package/extensions/session-bus/tools.ts +31 -66
- package/extensions/session-bus/tui.ts +5 -5
- package/extensions/session-bus/types.ts +23 -14
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +145 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +119 -0
- package/extensions/team/index.ts +34 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +267 -0
- package/extensions/team/types.ts +65 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- 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 +106 -251
- 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} +37 -37
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
- package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
- package/extensions/wechat/index.ts +10 -10
- package/extensions/wechat/intake.ts +245 -0
- 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} +100 -22
- package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
- 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} +8 -0
- 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} +72 -28
- 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/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +18 -290
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +11 -8
- package/lib/board/card.ts +479 -0
- package/lib/board/links.ts +141 -0
- package/lib/board/page-pointer.ts +310 -0
- package/lib/board/round-text.ts +81 -0
- package/lib/board/round.ts +94 -0
- package/lib/board/rules.ts +437 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +497 -0
- package/lib/board/view.ts +234 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +35 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/serve.ts +237 -0
- package/lib/http/server.ts +12 -10
- package/lib/http/sink.ts +135 -0
- package/lib/http/token.ts +38 -0
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +25 -2
- package/lib/requirements.ts +6 -7
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +201 -0
- package/lib/session-tool.ts +184 -0
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +38 -2
- package/lib/tui/glyphs.ts +3 -2
- package/lib/web/README.md +57 -4
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/components.ts +5 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -0
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +26 -14
- package/skills/next/SKILL.md +4 -3
- 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/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/file-browse/README.md +0 -506
- package/extensions/file-browse/files.ts +0 -564
- package/extensions/file-browse/server.ts +0 -141
- package/extensions/job/process.ts +0 -35
- package/extensions/plan/split.ts +0 -87
- package/extensions/session-bus/README.md +0 -247
- package/extensions/session-bus/team.ts +0 -102
- package/extensions/wechat/outbound.ts +0 -100
- package/lib/plans/sections.ts +0 -172
- package/lib/plans/setting.ts +0 -52
- package/lib/plans/split.ts +0 -215
- package/lib/plans/store.ts +0 -19
- package/lib/plans/team.ts +0 -151
- package/skills/changelog/SKILL.md +0 -148
- package/skills/parallel-dev/SKILL.md +0 -150
- /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
- /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/{live-client.ts → view/live-client.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
|
@@ -74,17 +74,24 @@ export function readMemoryFile(path: string): string {
|
|
|
74
74
|
return readFileSync(path, "utf-8");
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Identity of a store file, for cheap change detection.
|
|
79
|
+
*
|
|
80
|
+
* The stamp is nanoseconds, not milliseconds: an edit that keeps the file's size
|
|
81
|
+
* is otherwise invisible for a whole millisecond, which is long enough for an
|
|
82
|
+
* editor that writes in place. `size` stays a number so the doctor's byte counts
|
|
83
|
+
* read the same.
|
|
84
|
+
*/
|
|
78
85
|
export interface MemoryFileStamp {
|
|
79
|
-
|
|
86
|
+
mtimeNs: bigint;
|
|
80
87
|
size: number;
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
/** Freshness stamp for a store file; `undefined` when it is missing or unstattable. */
|
|
84
91
|
export function memoryFileStamp(path: string): MemoryFileStamp | undefined {
|
|
85
92
|
try {
|
|
86
|
-
const info = statSync(path);
|
|
87
|
-
return {
|
|
93
|
+
const info = statSync(path, { bigint: true });
|
|
94
|
+
return { mtimeNs: info.mtimeNs, size: Number(info.size) };
|
|
88
95
|
} catch {
|
|
89
96
|
return undefined;
|
|
90
97
|
}
|
|
@@ -93,7 +100,7 @@ export function memoryFileStamp(path: string): MemoryFileStamp | undefined {
|
|
|
93
100
|
/** Whether two stamps describe the same file state. */
|
|
94
101
|
export function sameStamp(a: MemoryFileStamp | undefined, b: MemoryFileStamp | undefined): boolean {
|
|
95
102
|
if (a === undefined || b === undefined) return a === b;
|
|
96
|
-
return a.
|
|
103
|
+
return a.mtimeNs === b.mtimeNs && a.size === b.size;
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
/** Note text of every bullet line, sanitized to one terminal-safe line. */
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* step.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { FENCE, HEADING } from "./
|
|
17
|
+
import { FENCE, HEADING } from "./sections.ts";
|
|
18
18
|
|
|
19
19
|
/** How a block is rendered and grouped. */
|
|
20
20
|
export type PlanBlockKind = "heading" | "paragraph" | "list" | "code" | "quote" | "table" | "hr" | "blank";
|
|
@@ -176,7 +176,7 @@ export function planBlocks(plan: string): PlanBlock[] {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
/** The block containing source line `line`, or the nearest block before it. */
|
|
179
|
-
export function blockAt(blocks: PlanBlock[], line: number): PlanBlock | undefined {
|
|
179
|
+
export function blockAt(blocks: readonly PlanBlock[], line: number): PlanBlock | undefined {
|
|
180
180
|
let candidate: PlanBlock | undefined;
|
|
181
181
|
for (const block of blocks) {
|
|
182
182
|
if (block.start <= line && line <= block.end) return block;
|
|
@@ -186,6 +186,27 @@ export function blockAt(blocks: PlanBlock[], line: number): PlanBlock | undefine
|
|
|
186
186
|
return candidate;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The nearest block with content at or before `line`, else the first one after
|
|
191
|
+
* it: what a comment in blank space belongs to.
|
|
192
|
+
*
|
|
193
|
+
* A blank line is a block, so {@link blockAt} may name one, but a comment cannot
|
|
194
|
+
* hang on nothing: the review page renders no line for a blank block, so an
|
|
195
|
+
* anchor there is one it can never attach a thread to.
|
|
196
|
+
*/
|
|
197
|
+
export function contentBlockAt(blocks: readonly PlanBlock[], line: number): PlanBlock | undefined {
|
|
198
|
+
let before: PlanBlock | undefined;
|
|
199
|
+
for (const block of blocks) {
|
|
200
|
+
const content = block.kind !== "blank";
|
|
201
|
+
if (block.start <= line) {
|
|
202
|
+
if (content) before = block;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (content) return before ?? block;
|
|
206
|
+
}
|
|
207
|
+
return before;
|
|
208
|
+
}
|
|
209
|
+
|
|
189
210
|
/** The source inside a fenced code block, without its opening and closing fence lines. */
|
|
190
211
|
export function codeBody(block: PlanBlock): string[] {
|
|
191
212
|
if (block.kind !== "code") return block.lines.slice();
|
|
@@ -13,6 +13,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
13
13
|
import { isRemoteTurn } from "../../lib/interaction.ts";
|
|
14
14
|
import { READ_ONLY_SUMMARY } from "../../lib/plan-entry.ts";
|
|
15
15
|
import { qrLines } from "../../lib/qr.ts";
|
|
16
|
+
import { withRailsSuppressed } from "../../lib/rails.ts";
|
|
16
17
|
import { feedbackSummary } from "./feedback.ts";
|
|
17
18
|
import { PlanIndexComponent, formatPlanIndex } from "./plan-index.ts";
|
|
18
19
|
import { runBrowserReview } from "./review.ts";
|
|
@@ -192,22 +193,26 @@ async function openPlanIndex(
|
|
|
192
193
|
|
|
193
194
|
// The review runs after the screen closes, so the input focus is free again.
|
|
194
195
|
let selected: (PlanSummary | undefined) | undefined;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
196
|
+
// A dock list shares the editor slot with the goal and todo rails, so it takes
|
|
197
|
+
// the dock to itself for as long as it is open, like the other list screens.
|
|
198
|
+
await withRailsSuppressed(pi, () =>
|
|
199
|
+
ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
200
|
+
const component = new PlanIndexComponent({
|
|
201
|
+
summaries,
|
|
202
|
+
dir,
|
|
203
|
+
theme,
|
|
204
|
+
onSelect: (summary) => {
|
|
205
|
+
selected = summary;
|
|
206
|
+
done();
|
|
207
|
+
},
|
|
208
|
+
onClose: () => done(),
|
|
209
|
+
reload: summarise,
|
|
210
|
+
requestRender: () => tui.requestRender(),
|
|
211
|
+
viewportRows: () => tui.terminal?.rows,
|
|
212
|
+
});
|
|
213
|
+
return component;
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
211
216
|
if (selected) await reviewInBrowser(pi, runtime, web, selected.path, ctx);
|
|
212
217
|
}
|
|
213
218
|
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
24
|
-
import { blockAt, planBlocks, type PlanBlock } from "./blocks.ts";
|
|
25
|
-
import {
|
|
24
|
+
import { blockAt, contentBlockAt, planBlocks, type PlanBlock } from "./blocks.ts";
|
|
25
|
+
import { extractSections, sectionKeys } from "./sections.ts";
|
|
26
26
|
|
|
27
27
|
/** Marker that opens every stored feedback line. */
|
|
28
28
|
export const REVIEW_MARKER = "[!REVIEW]";
|
|
@@ -37,6 +37,12 @@ export interface PlanAnnotation {
|
|
|
37
37
|
comment: string;
|
|
38
38
|
/** Who wrote it; `""`/absent is the plan owner, the only writer before sharing. */
|
|
39
39
|
author?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Set when a refinement carried this comment onto a block other than the text it was
|
|
42
|
+
* written on: the note is still the reviewer's, but where it sits is this build's best
|
|
43
|
+
* guess, and the page says so instead of presenting a guess as her own anchor.
|
|
44
|
+
*/
|
|
45
|
+
moved?: true;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
/** Most annotations one save may carry. */
|
|
@@ -51,8 +57,12 @@ export const MAX_AUTHOR_LENGTH = 40;
|
|
|
51
57
|
/** The name a reviewer write is stored under, or why it cannot be decided. */
|
|
52
58
|
export type AuthorClaim = { name: string } | { status: number; message: string };
|
|
53
59
|
|
|
54
|
-
/**
|
|
55
|
-
|
|
60
|
+
/**
|
|
61
|
+
* A stored feedback line, capturing whether a refinement moved it, its author, and its
|
|
62
|
+
* comment. The flag is inside the marker tag rather than the comment, so the text a
|
|
63
|
+
* reviewer wrote stays exactly what she typed.
|
|
64
|
+
*/
|
|
65
|
+
const MARKER_LINE = /^\s*>\s*\[!REVIEW(\s+moved)?\]\s?(.*)$/;
|
|
56
66
|
/** The author prefix inside a marker body (`@alice: note`). */
|
|
57
67
|
const AUTHOR_PREFIX = /^@([^:]{1,40}):\s?(.*)$/;
|
|
58
68
|
|
|
@@ -121,6 +131,8 @@ interface StoredMarker {
|
|
|
121
131
|
at: number;
|
|
122
132
|
comment: string;
|
|
123
133
|
author: string;
|
|
134
|
+
/** Whether a refinement carried this comment off the text it was written on. */
|
|
135
|
+
moved: boolean;
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
/** The plan body without markers, plus the markers that were removed from it. */
|
|
@@ -134,20 +146,23 @@ function scanMarkers(plan: string): { clean: string; markers: StoredMarker[] } {
|
|
|
134
146
|
clean.push(line);
|
|
135
147
|
return;
|
|
136
148
|
}
|
|
137
|
-
const body = match[
|
|
149
|
+
const body = (match[2] ?? "").trim();
|
|
138
150
|
if (body === "") return;
|
|
139
151
|
const authored = AUTHOR_PREFIX.exec(body);
|
|
140
152
|
const author = authored ? normalizeAuthor(authored[1]) : "";
|
|
141
153
|
const comment = (authored ? authored[2] : body).trim();
|
|
142
|
-
if (comment) markers.push({ at: clean.length - 1, comment, author });
|
|
154
|
+
if (comment) markers.push({ at: clean.length - 1, comment, author, moved: match[1] !== undefined });
|
|
143
155
|
});
|
|
144
156
|
return { clean: clean.join("\n"), markers };
|
|
145
157
|
}
|
|
146
158
|
|
|
147
159
|
/** One marker line in its stored form. */
|
|
148
|
-
function markerLine(author: string, comment: string): string {
|
|
160
|
+
function markerLine(author: string, comment: string, moved = false): string {
|
|
149
161
|
const body = sanitizeLine(comment);
|
|
150
|
-
|
|
162
|
+
// The flag rides inside the tag, where it cannot be confused with the comment: after
|
|
163
|
+
// the closing bracket, a comment that begins "moved …" would read as the flag.
|
|
164
|
+
const tag = moved ? "[!REVIEW moved]" : REVIEW_MARKER;
|
|
165
|
+
return author ? `> ${tag} @${author}: ${body}` : `> ${tag} ${body}`;
|
|
151
166
|
}
|
|
152
167
|
|
|
153
168
|
/** A marker waiting to be placed after clean-plan line `at`. */
|
|
@@ -191,28 +206,58 @@ export function stripFeedback(plan: string): string {
|
|
|
191
206
|
}
|
|
192
207
|
|
|
193
208
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
209
|
+
* A plan read the way the review reads it: its body, its blocks, and its
|
|
210
|
+
* comments, out of one parse.
|
|
196
211
|
*/
|
|
197
|
-
export
|
|
212
|
+
export interface FeedbackRead {
|
|
213
|
+
/** The plan body with every stored marker removed. */
|
|
214
|
+
body: string;
|
|
215
|
+
/** The body split into source-line blocks: the shape an anchor names. */
|
|
216
|
+
blocks: PlanBlock[];
|
|
217
|
+
/** The comments the markers carried, each anchored to a content block's start. */
|
|
218
|
+
annotations: PlanAnnotation[];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Read a plan once. An anchor is the start of the block the marker follows, so a
|
|
223
|
+
* comment keeps its place across reloads — and it is always a block with
|
|
224
|
+
* content, because the page renders no line for a blank one and so can never
|
|
225
|
+
* attach a thread to it. A plan whose every line is blank has no such block: its
|
|
226
|
+
* comments keep anchor `0` and the page lists them as unattached, which is the
|
|
227
|
+
* truth there.
|
|
228
|
+
*/
|
|
229
|
+
export function readFeedback(plan: string): FeedbackRead {
|
|
198
230
|
const { clean, markers } = scanMarkers(plan);
|
|
199
|
-
if (markers.length === 0) return [];
|
|
200
231
|
const blocks = planBlocks(clean);
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
232
|
+
const annotations = markers.map((marker) => {
|
|
233
|
+
const annotation: PlanAnnotation = {
|
|
234
|
+
anchor: contentBlockAt(blocks, marker.at)?.start ?? 0,
|
|
235
|
+
comment: marker.comment,
|
|
236
|
+
};
|
|
204
237
|
if (marker.author) annotation.author = marker.author;
|
|
238
|
+
if (marker.moved) annotation.moved = true;
|
|
205
239
|
return annotation;
|
|
206
240
|
});
|
|
241
|
+
return { body: clean, blocks, annotations };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The comments a plan carries, in the order they were written. */
|
|
245
|
+
export function parseFeedback(plan: string): PlanAnnotation[] {
|
|
246
|
+
return readFeedback(plan).annotations;
|
|
207
247
|
}
|
|
208
248
|
|
|
209
249
|
/** Sanitized, non-empty comments with the anchor they land on. */
|
|
210
|
-
function storableComments(
|
|
211
|
-
|
|
250
|
+
function storableComments(
|
|
251
|
+
annotations: readonly PlanAnnotation[],
|
|
252
|
+
): Array<{ anchor: number; comment: string; moved: boolean }> {
|
|
253
|
+
const items: Array<{ anchor: number; comment: string; moved: boolean }> = [];
|
|
212
254
|
for (const annotation of annotations) {
|
|
213
255
|
const comment = sanitizeLine(annotation.comment);
|
|
214
256
|
if (comment.length === 0 || comment.length > MAX_COMMENT_LENGTH) continue;
|
|
215
|
-
|
|
257
|
+
// Editing a moved note's text does not put it back on its old block, so the fact
|
|
258
|
+
// that a refinement placed it where it sits survives the save: the page posts back
|
|
259
|
+
// the annotation it was handed, flag included, and this keeps it.
|
|
260
|
+
items.push({ anchor: annotation.anchor, comment, moved: annotation.moved === true });
|
|
216
261
|
}
|
|
217
262
|
return items;
|
|
218
263
|
}
|
|
@@ -227,11 +272,13 @@ export function formatFeedback(plan: string, annotations: PlanAnnotation[], opti
|
|
|
227
272
|
const { clean, markers } = scanMarkers(plan);
|
|
228
273
|
const kept = markers
|
|
229
274
|
.filter((marker) => marker.author !== author)
|
|
230
|
-
.map((marker) => ({ at: marker.at, line: markerLine(marker.author, marker.comment) }));
|
|
275
|
+
.map((marker) => ({ at: marker.at, line: markerLine(marker.author, marker.comment, marker.moved) }));
|
|
231
276
|
const blocks = planBlocks(clean);
|
|
232
277
|
const added = storableComments(annotations).map((item) => ({
|
|
233
|
-
|
|
234
|
-
|
|
278
|
+
// A marker beside a block, never after a blank line: an anchor that names
|
|
279
|
+
// blank space is written where the page can read it back as a thread.
|
|
280
|
+
at: contentBlockAt(blocks, item.anchor)?.end ?? -1,
|
|
281
|
+
line: markerLine(author, item.comment, item.moved),
|
|
235
282
|
}));
|
|
236
283
|
return compose(clean, [...kept, ...added]);
|
|
237
284
|
}
|
|
@@ -247,30 +294,92 @@ function blockKey(block: PlanBlock): string {
|
|
|
247
294
|
}
|
|
248
295
|
|
|
249
296
|
/**
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
|
|
297
|
+
* Words a block is compared by when it may merely have been reworded: lowercased,
|
|
298
|
+
* with the list marker and task checkbox dropped. Every step of a `## Steps`
|
|
299
|
+
* section would otherwise match every other one through its `- [ ]` alone.
|
|
300
|
+
*/
|
|
301
|
+
function blockWords(block: PlanBlock): Set<string> {
|
|
302
|
+
const text = block.lines
|
|
303
|
+
.join(" ")
|
|
304
|
+
.toLowerCase()
|
|
305
|
+
.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, " ")
|
|
306
|
+
.replace(/\[[ x]\]/g, " ");
|
|
307
|
+
return new Set(text.split(/[^\p{L}\p{N}]+/u).filter((word) => word !== ""));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Share of the longer block's words that both blocks have. */
|
|
311
|
+
function sharedShare(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
|
|
312
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
313
|
+
let shared = 0;
|
|
314
|
+
for (const word of a) if (b.has(word)) shared += 1;
|
|
315
|
+
return shared / Math.max(a.size, b.size);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Share of its words a block keeps, at or above which it is the same block reworded. */
|
|
319
|
+
const MATCH_SHARE = 0.5;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Which block of `next` each block of `old` became, keeping both lists in
|
|
323
|
+
* document order: the longest set of pairs whose wording overlaps enough to be one
|
|
324
|
+
* block reworded. A block left without a pair is `-1` — deleted, or replaced by
|
|
325
|
+
* text that shares nothing — which is the gap its comment belongs in.
|
|
326
|
+
*/
|
|
327
|
+
function alignBlocks(old: readonly PlanBlock[], next: readonly PlanBlock[]): number[] {
|
|
328
|
+
const before = old.map(blockWords);
|
|
329
|
+
const after = next.map(blockWords);
|
|
330
|
+
const matches = before.map((words) => after.map((other) => (sharedShare(words, other) >= MATCH_SHARE ? 1 : 0)));
|
|
331
|
+
// A longest common subsequence over the match grid, so a block that moved keeps
|
|
332
|
+
// its own pairing instead of pulling the blocks around it along.
|
|
333
|
+
const width = next.length + 1;
|
|
334
|
+
const counts = new Array<number>((old.length + 1) * width).fill(0);
|
|
335
|
+
for (let row = old.length - 1; row >= 0; row--) {
|
|
336
|
+
for (let column = next.length - 1; column >= 0; column--) {
|
|
337
|
+
const paired = matches[row][column] === 1 ? 1 + counts[(row + 1) * width + column + 1] : 0;
|
|
338
|
+
counts[row * width + column] = Math.max(
|
|
339
|
+
paired,
|
|
340
|
+
counts[(row + 1) * width + column],
|
|
341
|
+
counts[row * width + column + 1],
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const aligned = new Array<number>(old.length).fill(-1);
|
|
346
|
+
let row = 0;
|
|
347
|
+
let column = 0;
|
|
348
|
+
while (row < old.length && column < next.length) {
|
|
349
|
+
if (matches[row][column] === 1 && counts[row * width + column] === 1 + counts[(row + 1) * width + column + 1]) {
|
|
350
|
+
aligned[row] = column;
|
|
351
|
+
row += 1;
|
|
352
|
+
column += 1;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (counts[(row + 1) * width + column] >= counts[row * width + column + 1]) row += 1;
|
|
356
|
+
else column += 1;
|
|
357
|
+
}
|
|
358
|
+
return aligned;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* A plan's heading sections, keyed by `sectionKeys`, each with the blocks a
|
|
363
|
+
* comment can hang on and the last line of that content. Blank lines are left out
|
|
364
|
+
* on purpose: a position among them would move when one is added or removed
|
|
365
|
+
* above, and a reworded step would lose its comment to the next block. A section
|
|
366
|
+
* with no content block ends at its own heading, which the page renders.
|
|
254
367
|
*/
|
|
255
368
|
function sectionIndex(plan: string, blocks: readonly PlanBlock[]): Map<string, { blocks: PlanBlock[]; end: number }> {
|
|
256
|
-
const sections =
|
|
257
|
-
const lineCount = plan === "" ? 0 : plan.split("\n").length;
|
|
258
|
-
const seen = new Map<string, number>();
|
|
369
|
+
const sections = extractSections(plan);
|
|
259
370
|
const index = new Map<string, { blocks: PlanBlock[]; end: number }>();
|
|
260
|
-
const keys = sections
|
|
261
|
-
|
|
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
|
-
});
|
|
371
|
+
const keys = sectionKeys(sections);
|
|
372
|
+
keys.forEach((key, at) => index.set(key, { blocks: [], end: sections[at]?.line ?? 0 }));
|
|
267
373
|
|
|
268
374
|
let position = 0;
|
|
269
375
|
for (const block of blocks) {
|
|
270
376
|
while (position + 1 < sections.length && (sections[position + 1]?.line ?? 0) <= block.start) position += 1;
|
|
271
377
|
const key = keys[position];
|
|
272
378
|
if (key === undefined) continue;
|
|
273
|
-
index.get(key)
|
|
379
|
+
const entry = index.get(key);
|
|
380
|
+
if (!entry || block.kind === "blank") continue;
|
|
381
|
+
entry.blocks.push(block);
|
|
382
|
+
entry.end = block.end;
|
|
274
383
|
}
|
|
275
384
|
return index;
|
|
276
385
|
}
|
|
@@ -280,9 +389,11 @@ function sectionIndex(plan: string, blocks: readonly PlanBlock[]): Map<string, {
|
|
|
280
389
|
* Comments are not plan content, so `next` is stripped of any marker it carries,
|
|
281
390
|
* and every old one is re-attached by the strongest evidence that survives: the
|
|
282
391
|
* block's text (per occurrence, so two identical steps keep their own comments),
|
|
283
|
-
* then the
|
|
284
|
-
*
|
|
285
|
-
*
|
|
392
|
+
* then the block the alignment says it became when the wording changed, then the
|
|
393
|
+
* gap it left between the blocks that survived, then the end of that section's
|
|
394
|
+
* content. A comment whose section is gone too moves to the plan's last content
|
|
395
|
+
* block, so the page keeps showing it under a block rather than listing it as
|
|
396
|
+
* unattached; only a plan with no block at all leaves it in that list.
|
|
286
397
|
*/
|
|
287
398
|
export function carryFeedback(previous: string, next: string): string {
|
|
288
399
|
const { clean: oldClean, markers } = scanMarkers(previous);
|
|
@@ -303,29 +414,51 @@ export function carryFeedback(previous: string, next: string): string {
|
|
|
303
414
|
const oldSections = sectionIndex(oldClean, oldBlocks);
|
|
304
415
|
const nextSections = sectionIndex(cleanNext, nextBlocks);
|
|
305
416
|
const lastLine = cleanNext === "" ? -1 : cleanNext.split("\n").length - 1;
|
|
417
|
+
// Where a comment goes when nothing about it survived: beside the plan's last
|
|
418
|
+
// block, never after the blank line a trailing newline leaves.
|
|
419
|
+
const tail = contentBlockAt(nextBlocks, lastLine)?.end ?? lastLine;
|
|
420
|
+
|
|
421
|
+
/** One alignment per section, reused by every comment that lands in it. */
|
|
422
|
+
const alignments = new Map<string, number[]>();
|
|
306
423
|
|
|
307
|
-
/**
|
|
424
|
+
/**
|
|
425
|
+
* Where a comment goes when its own block's text is gone: the block the alignment
|
|
426
|
+
* says it became, else the gap it left, else its section's end.
|
|
427
|
+
*/
|
|
308
428
|
const bySection = (block: PlanBlock): number | undefined => {
|
|
309
|
-
for (const [key,
|
|
310
|
-
const
|
|
311
|
-
if (
|
|
312
|
-
const
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
429
|
+
for (const [key, before] of oldSections) {
|
|
430
|
+
const index = before.blocks.indexOf(block);
|
|
431
|
+
if (index < 0) continue;
|
|
432
|
+
const after = nextSections.get(key);
|
|
433
|
+
if (after === undefined) return undefined;
|
|
434
|
+
let aligned = alignments.get(key);
|
|
435
|
+
if (aligned === undefined) {
|
|
436
|
+
aligned = alignBlocks(before.blocks, after.blocks);
|
|
437
|
+
alignments.set(key, aligned);
|
|
438
|
+
}
|
|
439
|
+
const became = aligned[index];
|
|
440
|
+
if (became >= 0) return after.blocks[became].end;
|
|
441
|
+
// The gap it left is right after the block before its nearest surviving
|
|
442
|
+
// successor: for the first block of a section, that is the heading.
|
|
443
|
+
const successor = aligned.findIndex((at, from) => from > index && at >= 0);
|
|
444
|
+
const gap = successor >= 0 ? after.blocks[aligned[successor] - 1] : undefined;
|
|
445
|
+
return gap?.end ?? after.end;
|
|
316
446
|
}
|
|
317
447
|
return undefined;
|
|
318
448
|
};
|
|
319
449
|
|
|
320
450
|
const placed = markers.map((marker) => {
|
|
321
451
|
const block = blockAt(oldBlocks, marker.at);
|
|
322
|
-
|
|
323
|
-
|
|
452
|
+
// The flag means "this note is no longer on the text it was written about", so it
|
|
453
|
+
// is decided by the carry in hand: a block whose text survived proves the anchor,
|
|
454
|
+
// and anything after that is a guess the page has to admit to.
|
|
455
|
+
const keep = (moved: boolean): string => markerLine(marker.author, marker.comment, moved);
|
|
456
|
+
if (block === undefined) return { at: tail, line: keep(true) };
|
|
324
457
|
if (block.kind !== "blank") {
|
|
325
458
|
const match = queues.get(blockKey(block))?.shift();
|
|
326
|
-
if (match !== undefined) return { at: match.end, line };
|
|
459
|
+
if (match !== undefined) return { at: match.end, line: keep(false) };
|
|
327
460
|
}
|
|
328
|
-
return { at: bySection(block) ??
|
|
461
|
+
return { at: bySection(block) ?? tail, line: keep(true) };
|
|
329
462
|
});
|
|
330
463
|
return compose(cleanNext, placed);
|
|
331
464
|
}
|
|
@@ -344,7 +477,7 @@ export function displayFeedback(plan: string): string {
|
|
|
344
477
|
if (fenced.has(index)) return line;
|
|
345
478
|
const match = MARKER_LINE.exec(line);
|
|
346
479
|
if (!match) return line;
|
|
347
|
-
const body = match[
|
|
480
|
+
const body = (match[2] ?? "").trim();
|
|
348
481
|
if (body === "") return line;
|
|
349
482
|
const authored = AUTHOR_PREFIX.exec(body);
|
|
350
483
|
const author = authored ? normalizeAuthor(authored[1]) : "";
|
|
@@ -72,16 +72,23 @@ export function mergeSummary(merged: TodoMerge, steps: PlanStep[]): string {
|
|
|
72
72
|
const total = steps.length;
|
|
73
73
|
const counted = `${merged.added + merged.skipped} of ${total} plan step${total === 1 ? "" : "s"}`;
|
|
74
74
|
const notes = [
|
|
75
|
+
merged.replaced > 0
|
|
76
|
+
? `${merged.replaced} stale item${merged.replaced === 1 ? "" : "s"} from the earlier approval replaced`
|
|
77
|
+
: "",
|
|
75
78
|
merged.skipped > 0 ? `${merged.skipped} already present` : "",
|
|
76
79
|
merged.kept > 0 ? `${merged.kept} existing kept` : "",
|
|
77
80
|
merged.deferred > 0 ? `${merged.deferred} deferred, list full` : "",
|
|
78
81
|
].filter(Boolean);
|
|
79
82
|
const suffix = notes.length > 0 ? ` (${notes.join(", ")})` : "";
|
|
80
83
|
const tick = `Tick each one off in the file with ${SET_PLAN_STEPS_TOOL} as you finish it, passing the step's text as \`expected\`.`;
|
|
84
|
+
const touched =
|
|
85
|
+
merged.stale > 0
|
|
86
|
+
? ` ${merged.stale} older item${merged.stale === 1 ? "" : "s"} no longer match${merged.stale === 1 ? "es" : ""} this plan but ${merged.stale === 1 ? "was" : "were"} left alone: ${merged.stale === 1 ? "it was" : "they were"} started or finished, so check ${merged.stale === 1 ? "it" : "them"} yourself.`
|
|
87
|
+
: "";
|
|
81
88
|
if (merged.added === 0 && merged.skipped === 0) {
|
|
82
|
-
return ` The todo list is full, so no plan steps were added${suffix}
|
|
89
|
+
return ` The todo list is full, so no plan steps were added${suffix}.${touched} Keep these steps in mind. ${tick}`;
|
|
83
90
|
}
|
|
84
91
|
if (merged.added === 0)
|
|
85
|
-
return ` ${total === 1 ? "The plan's step is" : "Every plan step is"} already in the todo list${suffix}
|
|
86
|
-
return ` Merged ${counted} into the todo list${suffix}
|
|
92
|
+
return ` ${total === 1 ? "The plan's step is" : "Every plan step is"} already in the todo list${suffix}.${touched} ${tick}`;
|
|
93
|
+
return ` Merged ${counted} into the todo list${suffix}.${touched} ${tick}`;
|
|
87
94
|
}
|
package/extensions/plan/index.ts
CHANGED
|
@@ -24,7 +24,14 @@ import { isExtensionEnabled } from "../../lib/env.ts";
|
|
|
24
24
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
25
25
|
import { lastGoal, isPursuedGoal, type PublishedGoal } from "../../lib/goal-state.ts";
|
|
26
26
|
import { READ_ONLY_SUMMARY, PLAN_ENTER_REQUEST, type PlanEnterRequest } from "../../lib/plan-entry.ts";
|
|
27
|
-
import {
|
|
27
|
+
import { isBoardRead } from "../../lib/policy.ts";
|
|
28
|
+
import {
|
|
29
|
+
ASK_USER_QUESTION_TOOL,
|
|
30
|
+
BOARD_READ_ACTION,
|
|
31
|
+
BOARD_TOOL,
|
|
32
|
+
SUBAGENT_READ_ONLY_PARAM,
|
|
33
|
+
SUBAGENT_TOOL,
|
|
34
|
+
} from "../../lib/tool-names.ts";
|
|
28
35
|
import { clip } from "../../lib/tui/columns.ts";
|
|
29
36
|
import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
|
|
30
37
|
import { planModeNotice, registerCommands } from "./commands.ts";
|
|
@@ -149,10 +156,12 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
149
156
|
* the path backstop (a hint on a path-carrying tool is only a claim).
|
|
150
157
|
*/
|
|
151
158
|
const blockedReason = (toolName: string, input: unknown): string | undefined => {
|
|
152
|
-
// Plan-owned
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
159
|
+
// Plan-owned tools are the sanctioned exception: `write_plan` saves the
|
|
160
|
+
// artifact, `exit_plan_mode` presents it, `cancel_plan_mode` restores access,
|
|
161
|
+
// and `enter_plan_mode` answers "already" to a call that was in flight when
|
|
162
|
+
// plan mode came on. The source-path check keeps a same-named tool from
|
|
163
|
+
// another extension from borrowing the exemption.
|
|
164
|
+
if (runtime.isOwnTool(toolName)) return undefined;
|
|
156
165
|
// `subagent` stays active, but only as read-only delegation. The flag is
|
|
157
166
|
// forced in place (the child enforces it through `--tools`), so the model
|
|
158
167
|
// cannot forget it; a `subagent` that does not declare the parameter is
|
|
@@ -166,6 +175,15 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
166
175
|
}
|
|
167
176
|
return undefined;
|
|
168
177
|
}
|
|
178
|
+
// `board` stays active so planning can read the board it is about to add cards
|
|
179
|
+
// to. The action is the truth here, not the annotation: the same tool writes, so
|
|
180
|
+
// `readOnlyHint` cannot say it, and every verb but the read is refused by name.
|
|
181
|
+
if (toolName === BOARD_TOOL) {
|
|
182
|
+
if (isBoardRead(input)) return undefined;
|
|
183
|
+
const action = (input as { action?: unknown } | undefined)?.action;
|
|
184
|
+
const verb = typeof action === "string" && action.trim() !== "" ? action.trim() : "board";
|
|
185
|
+
return `Plan mode: \`${verb}\` changes the board — only \`${BOARD_READ_ACTION}\` reads it. ${BLOCKED_GUIDANCE}`;
|
|
186
|
+
}
|
|
169
187
|
const blocked = policy.check(toolName);
|
|
170
188
|
if (blocked) return `Plan mode: ${blocked.reason}`;
|
|
171
189
|
// `bash` is allowed by the policy so read-only git stays possible; this is
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a plan file reaches the disk, and what it leaves behind (one job).
|
|
3
|
+
*
|
|
4
|
+
* Two writes, one subject: the plan itself, atomically so a crash cannot truncate
|
|
5
|
+
* the artifact a review is reading, and the revision a refinement replaces, kept
|
|
6
|
+
* beside it so the two can be compared. Keeping them together is deliberate — the
|
|
7
|
+
* revision has to be written with the same care as the plan, and before the rename
|
|
8
|
+
* that overwrites it, or the text is gone.
|
|
9
|
+
*
|
|
10
|
+
* A revision is a dotfile named for its plan (`.‹plan›-r001.md`): `isPlanFileName`
|
|
11
|
+
* and every scan that reads the directory as an index skip it, so a revision is
|
|
12
|
+
* never listed, reviewed, approved, or opened by `plan_path`.
|
|
13
|
+
*
|
|
14
|
+
* Value-only, as a store helper must be: the paths and the text are the caller's.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomBytes } from "node:crypto";
|
|
18
|
+
import { readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { basename, dirname, join } from "node:path";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How many replaced revisions a plan keeps, as dotfiles beside it.
|
|
23
|
+
*
|
|
24
|
+
* A refinement overwrites the file it replaces, because the reviewed artifact has to
|
|
25
|
+
* be the executed one — so the text the reviewer read was gone the moment it was
|
|
26
|
+
* refined: a comment `carryFeedback` mis-anchored could not be checked against what
|
|
27
|
+
* she actually saw, and two revisions could not be diffed. Five is a chain deep
|
|
28
|
+
* enough to answer "what changed since I last looked" and short enough that a plan
|
|
29
|
+
* refined all afternoon does not leave fifty files in the store.
|
|
30
|
+
*/
|
|
31
|
+
const MAX_REVISIONS = 5;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Write a plan atomically: a hidden sibling temp file, then a rename. A crash
|
|
35
|
+
* or a full disk mid-write leaves the previous file intact instead of a
|
|
36
|
+
* truncated plan, and the temp name is a dotfile, so neither `latest()` nor a
|
|
37
|
+
* plan lookup ever mistakes it for a plan.
|
|
38
|
+
*/
|
|
39
|
+
export function writeAtomic(path: string, content: string): void {
|
|
40
|
+
const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
41
|
+
try {
|
|
42
|
+
writeFileSync(temp, content, { encoding: "utf-8", flag: "wx" });
|
|
43
|
+
renameSync(temp, path);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
try {
|
|
46
|
+
unlinkSync(temp);
|
|
47
|
+
} catch {
|
|
48
|
+
// The temp file was never created, or the rename already moved it.
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The prefix a plan's revisions share, so one chain is one `startsWith`. */
|
|
55
|
+
function revisionPrefix(path: string): string {
|
|
56
|
+
return `.${basename(path).replace(/\.md$/i, "")}-r`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The number a revision's name carries, so the newest is the highest. */
|
|
60
|
+
function revisionNumber(name: string): number {
|
|
61
|
+
const digits = /-r(\d+)\.md$/i.exec(name)?.[1];
|
|
62
|
+
return digits === undefined ? 0 : Number.parseInt(digits, 10);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Keep `previous` as `path`'s next revision, and drop all but the newest few. */
|
|
66
|
+
export function keepRevision(path: string, previous: string): string {
|
|
67
|
+
const dir = dirname(path);
|
|
68
|
+
const prefix = revisionPrefix(path);
|
|
69
|
+
let names: string[];
|
|
70
|
+
try {
|
|
71
|
+
names = readdirSync(dir).filter((name) => name.startsWith(prefix) && revisionNumber(name) > 0);
|
|
72
|
+
} catch {
|
|
73
|
+
names = [];
|
|
74
|
+
}
|
|
75
|
+
const next = Math.max(0, ...names.map(revisionNumber)) + 1;
|
|
76
|
+
const target = join(dir, `${prefix}${String(next).padStart(3, "0")}.md`);
|
|
77
|
+
writeAtomic(target, previous);
|
|
78
|
+
// Oldest first, so what stays is the newest chain.
|
|
79
|
+
const chain = [...names, basename(target)].sort((a, b) => revisionNumber(a) - revisionNumber(b));
|
|
80
|
+
for (const name of chain.slice(0, Math.max(0, chain.length - MAX_REVISIONS))) {
|
|
81
|
+
try {
|
|
82
|
+
unlinkSync(join(dir, name));
|
|
83
|
+
} catch {
|
|
84
|
+
// A revision that vanished is one fewer to prune.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return target;
|
|
88
|
+
}
|