@gavin-hu/my-pi-agent 0.19.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 +17 -11
- package/README.zh-CN.md +13 -8
- package/docs/extensions/README.md +1 -1
- package/docs/extensions/board.md +286 -123
- package/docs/extensions/bytefly.md +381 -223
- package/docs/extensions/file-browse.md +14 -7
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +56 -11
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +45 -17
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +23 -5
- package/extensions/board/format.ts +107 -9
- package/extensions/board/join.ts +1 -1
- package/extensions/board/page.ts +170 -94
- package/extensions/board/result.ts +9 -0
- package/extensions/board/schema.ts +22 -7
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +1 -0
- package/extensions/board/tools.ts +186 -21
- package/extensions/board/transport/router.ts +27 -108
- package/extensions/board/transport/server.ts +18 -29
- package/extensions/board/types.ts +67 -0
- package/extensions/board/view/client.ts +23 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +5 -171
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +48 -88
- package/extensions/board/view/render.ts +213 -190
- package/extensions/board/view/styles.ts +65 -48
- package/extensions/board/view/team.ts +63 -41
- 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 +28 -1
- 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.ts +72 -23
- 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/check/commands.ts +2 -1
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +8 -547
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/transport/router.ts +0 -2
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +6 -7
- package/extensions/file-browse/view/markdown.ts +11 -13
- package/extensions/file-browse/view/raw-html.ts +18 -13
- package/extensions/job/runtime.ts +4 -4
- package/extensions/plan/feedback.ts +40 -16
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +17 -1
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +31 -2
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +10 -1
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/types.ts +15 -0
- 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 -1
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +194 -0
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/session-bus/rows.ts +7 -3
- package/extensions/session-bus/runtime.ts +100 -38
- package/extensions/session-bus/store.ts +14 -2
- package/extensions/session-bus/tui.ts +1 -1
- package/extensions/session-bus/types.ts +10 -1
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/team/commands.ts +41 -8
- package/extensions/team/format.ts +14 -0
- package/extensions/team/index.ts +2 -1
- package/extensions/team/tools.ts +30 -9
- package/extensions/team/types.ts +17 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/wechat/bridge.ts +43 -172
- package/extensions/wechat/intake.ts +245 -0
- 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 +15 -285
- 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 +6 -5
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +86 -32
- package/lib/board/round-text.ts +11 -2
- package/lib/board/round.ts +25 -20
- package/lib/board/rules.ts +144 -11
- package/lib/board/store.ts +239 -52
- package/lib/board/view.ts +94 -10
- package/lib/format.ts +22 -0
- package/lib/http/serve.ts +3 -0
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +14 -1
- package/lib/session-roster.ts +74 -3
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/glyphs.ts +2 -3
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -0
- package/package.json +1 -1
- package/extensions/board/view/actions.ts +0 -306
- package/extensions/board/writes.ts +0 -99
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/job/process.ts +0 -35
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { decodeCodePoint, escapeHtml } from "../../../lib/escape.ts";
|
|
29
|
+
import { emitImage, emitImageLink, EXTERNAL_ATTRS } from "./emit.ts";
|
|
29
30
|
import { resolveImage, resolveLink } from "./urls.ts";
|
|
30
31
|
|
|
31
32
|
/** Elements re-emitted with sanitized attributes. */
|
|
@@ -139,6 +140,12 @@ const BLOCK_OPEN = /^ {0,3}<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/;
|
|
|
139
140
|
const COMMENT_OPEN = /^ {0,3}<!--/;
|
|
140
141
|
const DECLARATION_OPEN = /^ {0,3}<[!?]/;
|
|
141
142
|
/** The five XML references, plus numeric ones, are the only refs decoded in a value. */
|
|
143
|
+
/**
|
|
144
|
+
* The five references an attribute value may carry, decoded.
|
|
145
|
+
*
|
|
146
|
+
* Not the Markdown renderer's table: a tag's attribute is not running text, and decoding
|
|
147
|
+
* ` ` or `—` inside one would invent characters the author did not write.
|
|
148
|
+
*/
|
|
142
149
|
const ATTRIBUTE_ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
143
150
|
const ATTRIBUTE_ENTITY = /&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z]+));/g;
|
|
144
151
|
|
|
@@ -407,7 +414,7 @@ function sanitizeOpenTag(tag: RawTag, baseDir: string): string {
|
|
|
407
414
|
const target = href === undefined ? undefined : resolveLink(href, baseDir);
|
|
408
415
|
if (target !== undefined) {
|
|
409
416
|
out.push(` href="${escapeHtml(target.href)}"`);
|
|
410
|
-
if (target.external) out.push(
|
|
417
|
+
if (target.external) out.push(EXTERNAL_ATTRS);
|
|
411
418
|
}
|
|
412
419
|
break;
|
|
413
420
|
}
|
|
@@ -456,16 +463,14 @@ function sanitizeImage(tag: RawTag, baseDir: string): string {
|
|
|
456
463
|
const src = attribute(tag, "src");
|
|
457
464
|
const target = src === undefined ? undefined : resolveImage(src, baseDir);
|
|
458
465
|
if (target === undefined) return alt === "" ? "" : escapeHtml(alt);
|
|
459
|
-
if (!target.image)
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
if (title !== undefined) out.push(` title="${escapeHtml(title)}"`);
|
|
470
|
-
return `<img${out.join("")} loading="lazy" decoding="async">`;
|
|
466
|
+
if (!target.image) return emitImageLink(target.href, alt === "" ? target.href : alt);
|
|
467
|
+
// The element is `emit.ts`'s, so an `<img>` written as HTML and the same one written as
|
|
468
|
+
// Markdown are the same element, down to the lazy decoding.
|
|
469
|
+
return emitImage({
|
|
470
|
+
href: target.href,
|
|
471
|
+
alt,
|
|
472
|
+
title: attribute(tag, "title"),
|
|
473
|
+
width: numeric(attribute(tag, "width")),
|
|
474
|
+
height: numeric(attribute(tag, "height")),
|
|
475
|
+
});
|
|
471
476
|
}
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type KillTreeFn,
|
|
30
30
|
type SpawnedProcess,
|
|
31
31
|
type SpawnFn,
|
|
32
|
+
startTokenOf,
|
|
32
33
|
type StdinSink,
|
|
33
34
|
} from "../../lib/spawn.ts";
|
|
34
35
|
import { loadConfig, type JobsConfig } from "./config.ts";
|
|
@@ -38,7 +39,6 @@ import { defaultInterval } from "../../lib/timer.ts";
|
|
|
38
39
|
import { clipLabel } from "../../lib/tui/columns.ts";
|
|
39
40
|
import { compareJobs, formatLogs } from "./format.ts";
|
|
40
41
|
import { readLogTail } from "./logs.ts";
|
|
41
|
-
import { defaultStartToken, type LivenessFn, type StartTokenFn } from "./process.ts";
|
|
42
42
|
import {
|
|
43
43
|
loadRegistries,
|
|
44
44
|
mergeRecords,
|
|
@@ -114,9 +114,9 @@ export interface SendResult {
|
|
|
114
114
|
|
|
115
115
|
export interface RuntimeOptions {
|
|
116
116
|
spawn?: SpawnFn;
|
|
117
|
-
liveness?:
|
|
117
|
+
liveness?: typeof isPidAlive;
|
|
118
118
|
killTree?: KillTreeFn;
|
|
119
|
-
startToken?:
|
|
119
|
+
startToken?: typeof startTokenOf;
|
|
120
120
|
now?: () => number;
|
|
121
121
|
/** Platform seam: whether this is Windows. Defaults to the running platform. */
|
|
122
122
|
isWindows?: () => boolean;
|
|
@@ -183,7 +183,7 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
|
183
183
|
const spawn = options.spawn ?? defaultSpawn;
|
|
184
184
|
const liveness = options.liveness ?? isPidAlive;
|
|
185
185
|
const killTree = options.killTree ?? defaultKillTree;
|
|
186
|
-
const startToken = options.startToken ??
|
|
186
|
+
const startToken = options.startToken ?? startTokenOf;
|
|
187
187
|
const now = options.now ?? (() => Date.now());
|
|
188
188
|
const isWindows = options.isWindows ?? (() => process.platform === "win32");
|
|
189
189
|
|
|
@@ -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`. */
|
|
@@ -220,6 +235,7 @@ export function readFeedback(plan: string): FeedbackRead {
|
|
|
220
235
|
comment: marker.comment,
|
|
221
236
|
};
|
|
222
237
|
if (marker.author) annotation.author = marker.author;
|
|
238
|
+
if (marker.moved) annotation.moved = true;
|
|
223
239
|
return annotation;
|
|
224
240
|
});
|
|
225
241
|
return { body: clean, blocks, annotations };
|
|
@@ -231,12 +247,17 @@ export function parseFeedback(plan: string): PlanAnnotation[] {
|
|
|
231
247
|
}
|
|
232
248
|
|
|
233
249
|
/** Sanitized, non-empty comments with the anchor they land on. */
|
|
234
|
-
function storableComments(
|
|
235
|
-
|
|
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 }> = [];
|
|
236
254
|
for (const annotation of annotations) {
|
|
237
255
|
const comment = sanitizeLine(annotation.comment);
|
|
238
256
|
if (comment.length === 0 || comment.length > MAX_COMMENT_LENGTH) continue;
|
|
239
|
-
|
|
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 });
|
|
240
261
|
}
|
|
241
262
|
return items;
|
|
242
263
|
}
|
|
@@ -251,13 +272,13 @@ export function formatFeedback(plan: string, annotations: PlanAnnotation[], opti
|
|
|
251
272
|
const { clean, markers } = scanMarkers(plan);
|
|
252
273
|
const kept = markers
|
|
253
274
|
.filter((marker) => marker.author !== author)
|
|
254
|
-
.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) }));
|
|
255
276
|
const blocks = planBlocks(clean);
|
|
256
277
|
const added = storableComments(annotations).map((item) => ({
|
|
257
278
|
// A marker beside a block, never after a blank line: an anchor that names
|
|
258
279
|
// blank space is written where the page can read it back as a thread.
|
|
259
280
|
at: contentBlockAt(blocks, item.anchor)?.end ?? -1,
|
|
260
|
-
line: markerLine(author, item.comment),
|
|
281
|
+
line: markerLine(author, item.comment, item.moved),
|
|
261
282
|
}));
|
|
262
283
|
return compose(clean, [...kept, ...added]);
|
|
263
284
|
}
|
|
@@ -428,13 +449,16 @@ export function carryFeedback(previous: string, next: string): string {
|
|
|
428
449
|
|
|
429
450
|
const placed = markers.map((marker) => {
|
|
430
451
|
const block = blockAt(oldBlocks, marker.at);
|
|
431
|
-
|
|
432
|
-
|
|
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) };
|
|
433
457
|
if (block.kind !== "blank") {
|
|
434
458
|
const match = queues.get(blockKey(block))?.shift();
|
|
435
|
-
if (match !== undefined) return { at: match.end, line };
|
|
459
|
+
if (match !== undefined) return { at: match.end, line: keep(false) };
|
|
436
460
|
}
|
|
437
|
-
return { at: bySection(block) ?? tail, line };
|
|
461
|
+
return { at: bySection(block) ?? tail, line: keep(true) };
|
|
438
462
|
});
|
|
439
463
|
return compose(cleanNext, placed);
|
|
440
464
|
}
|
|
@@ -453,7 +477,7 @@ export function displayFeedback(plan: string): string {
|
|
|
453
477
|
if (fenced.has(index)) return line;
|
|
454
478
|
const match = MARKER_LINE.exec(line);
|
|
455
479
|
if (!match) return line;
|
|
456
|
-
const body = match[
|
|
480
|
+
const body = (match[2] ?? "").trim();
|
|
457
481
|
if (body === "") return line;
|
|
458
482
|
const authored = AUTHOR_PREFIX.exec(body);
|
|
459
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";
|
|
@@ -168,6 +175,15 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
168
175
|
}
|
|
169
176
|
return undefined;
|
|
170
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
|
+
}
|
|
171
187
|
const blocked = policy.check(toolName);
|
|
172
188
|
if (blocked) return `Plan mode: ${blocked.reason}`;
|
|
173
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
|
+
}
|
package/extensions/plan/plans.ts
CHANGED
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
* This module owns paths and bytes only; tool wiring lives in `tools.ts`.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { randomBytes } from "node:crypto";
|
|
25
24
|
import {
|
|
26
25
|
existsSync,
|
|
27
26
|
lstatSync,
|
|
@@ -38,6 +37,7 @@ import { homedir } from "node:os";
|
|
|
38
37
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
39
38
|
import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
40
39
|
import { resolveEffectiveCwd } from "../../lib/env.ts";
|
|
40
|
+
import { keepRevision, writeAtomic } from "./plan-files.ts";
|
|
41
41
|
import { sanitizeLine, shortenPath } from "../../lib/format.ts";
|
|
42
42
|
import { isInside, isInsideReal } from "../../lib/path.ts";
|
|
43
43
|
import { repoKeyFor } from "../../lib/repo-key.ts";
|
|
@@ -69,6 +69,11 @@ export interface StoredPlan {
|
|
|
69
69
|
/** A plan written to disk, carrying the title that named it. */
|
|
70
70
|
export interface WrittenPlan extends StoredPlan {
|
|
71
71
|
title: string;
|
|
72
|
+
/**
|
|
73
|
+
* The revision this write replaced, when it refined an existing plan: the text as the
|
|
74
|
+
* reviewer last read it, kept beside the plan.
|
|
75
|
+
*/
|
|
76
|
+
replaced?: { path: string; relativePath: string };
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
/** One row of the plan index: enough to choose a plan without opening it. */
|
|
@@ -195,27 +200,6 @@ function resolvePlanArg(dir: string, planPath: string): string {
|
|
|
195
200
|
return isAbsolute(text) ? resolve(text) : resolve(dir, text);
|
|
196
201
|
}
|
|
197
202
|
|
|
198
|
-
/**
|
|
199
|
-
* Write a plan atomically: a hidden sibling temp file, then a rename. A crash
|
|
200
|
-
* or a full disk mid-write leaves the previous file intact instead of a
|
|
201
|
-
* truncated plan, and the temp name is a dotfile, so neither `latest()` nor a
|
|
202
|
-
* plan lookup ever mistakes it for a plan.
|
|
203
|
-
*/
|
|
204
|
-
function writeAtomic(path: string, content: string): void {
|
|
205
|
-
const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
206
|
-
try {
|
|
207
|
-
writeFileSync(temp, content, { encoding: "utf-8", flag: "wx" });
|
|
208
|
-
renameSync(temp, path);
|
|
209
|
-
} catch (error) {
|
|
210
|
-
try {
|
|
211
|
-
unlinkSync(temp);
|
|
212
|
-
} catch {
|
|
213
|
-
// The temp file was never created, or the rename already moved it.
|
|
214
|
-
}
|
|
215
|
-
throw error;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
203
|
function uniquePath(dir: string, base: string): string {
|
|
220
204
|
const taken = (candidate: string): boolean => {
|
|
221
205
|
try {
|
|
@@ -367,8 +351,20 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
|
|
|
367
351
|
throw new Error(`The plan is too large with its review comments (${nextBytes} bytes).`);
|
|
368
352
|
}
|
|
369
353
|
|
|
354
|
+
// Both are kept: the plan is what the review and the model read from now on, and the
|
|
355
|
+
// revision is what they read before this call — the only thing a diff, or a comment
|
|
356
|
+
// carried onto a block that moved, can be answered against.
|
|
357
|
+
// A refinement that changed nothing has nothing to keep: the copy would be the plan.
|
|
358
|
+
const replaced = previous === undefined || previous === content ? undefined : keepRevision(path, previous);
|
|
370
359
|
writeAtomic(path, content);
|
|
371
|
-
return {
|
|
360
|
+
return {
|
|
361
|
+
path,
|
|
362
|
+
relativePath: shortenPath(path),
|
|
363
|
+
title,
|
|
364
|
+
content,
|
|
365
|
+
bytes: nextBytes,
|
|
366
|
+
...(replaced === undefined ? {} : { replaced: { path: replaced, relativePath: shortenPath(replaced) } }),
|
|
367
|
+
};
|
|
372
368
|
};
|
|
373
369
|
|
|
374
370
|
const read = async (cwd: string, planPath: string): Promise<StoredPlan | undefined> => {
|
|
@@ -17,12 +17,14 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
17
17
|
import { createReadOnlyPolicy, WORKSPACE_READERS, type ReadOnlyPolicy } from "../../lib/policy.ts";
|
|
18
18
|
import {
|
|
19
19
|
ASK_USER_QUESTION_TOOL,
|
|
20
|
+
BOARD_TOOL,
|
|
20
21
|
GOAL_TOOL,
|
|
21
22
|
SUBAGENT_TOOL,
|
|
22
23
|
TODO_TOOL,
|
|
23
24
|
WEB_FETCH_TOOL,
|
|
24
25
|
WEB_SEARCH_TOOL,
|
|
25
26
|
} from "../../lib/tool-names.ts";
|
|
27
|
+
|
|
26
28
|
import { ENTER_TOOL } from "./runtime.ts";
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -41,12 +43,15 @@ export const PLAN_SAFE_TOOLS = [
|
|
|
41
43
|
];
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
|
-
* Tools that may stay active while planning.
|
|
45
|
-
* filtered out,
|
|
46
|
-
*
|
|
47
|
-
*
|
|
46
|
+
* Tools that may stay active while planning. Two of them are here so they are not
|
|
47
|
+
* filtered out, without being in {@link PLAN_SAFE_TOOLS}: the `tool_call` guard
|
|
48
|
+
* narrows each before the path backstop would see it. `subagent` is forced
|
|
49
|
+
* read-only per call, and `board` admits only its `list` action — a model planning
|
|
50
|
+
* work should be able to read the board it is about to add cards to, and the hint
|
|
51
|
+
* cannot say that because the same tool writes. `powershell` is deliberately
|
|
52
|
+
* absent — it is default-denied like any other shell.
|
|
48
53
|
*/
|
|
49
|
-
export const PLAN_ALLOWED_TOOLS = [...PLAN_SAFE_TOOLS, SUBAGENT_TOOL];
|
|
54
|
+
export const PLAN_ALLOWED_TOOLS = [...PLAN_SAFE_TOOLS, SUBAGENT_TOOL, BOARD_TOOL];
|
|
50
55
|
/**
|
|
51
56
|
* Tools always blocked while planning, even when annotated read-only.
|
|
52
57
|
* `enter_plan_mode` is denied so it stays hidden while planning; the guard exempts
|
|
@@ -28,7 +28,7 @@ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
|
|
|
28
28
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
29
29
|
import { createPlanStore, type PlanStore } from "./plans.ts";
|
|
30
30
|
import { sameSections, sectionDigests, type SectionDigest } from "./plan-text.ts";
|
|
31
|
-
import type { PlanModeEntry, ReviewBaselineEntry } from "./types.ts";
|
|
31
|
+
import type { PlanModeEntry, ReviewBaselineEntry, SeededStepsEntry } from "./types.ts";
|
|
32
32
|
|
|
33
33
|
/** Tool the model calls to leave plan mode after approval. */
|
|
34
34
|
export const EXIT_TOOL = "exit_plan_mode";
|
|
@@ -44,6 +44,8 @@ export const SET_PLAN_STEPS_TOOL = "set_plan_steps";
|
|
|
44
44
|
export const STATE_TYPE = PLAN_MODE_ENTRY_TYPE;
|
|
45
45
|
/** Custom-entry type used for a plan's review baseline. */
|
|
46
46
|
export const BASELINE_TYPE = "plan-baseline";
|
|
47
|
+
/** Custom-entry type used for the steps one approval seeded, so a re-approval can replace them. */
|
|
48
|
+
export const SEEDED_TYPE = "plan-seeded";
|
|
47
49
|
|
|
48
50
|
/** Longest plan-file basename shown in the footer chip. */
|
|
49
51
|
const STATUS_NAME_LENGTH = 28;
|
|
@@ -79,12 +81,18 @@ export interface PlanRuntime {
|
|
|
79
81
|
reviewed(path: string): SectionDigest[] | undefined;
|
|
80
82
|
/** Remember what a review just showed, as a session entry on this branch. */
|
|
81
83
|
markReviewed(path: string, content: string): void;
|
|
84
|
+
/** The step texts the last approval of this plan appended, or undefined. */
|
|
85
|
+
seeded(path: string): string[] | undefined;
|
|
86
|
+
/** Record what this approval appended, so a re-approval can replace it. */
|
|
87
|
+
markSeeded(path: string, steps: readonly string[]): void;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/** What the branch carries: the last plan-mode state, and a baseline per plan. */
|
|
85
91
|
interface BranchState {
|
|
86
92
|
mode: PlanModeEntry | undefined;
|
|
87
93
|
baselines: Map<string, SectionDigest[]>;
|
|
94
|
+
/** The step texts each approval appended, per plan path. */
|
|
95
|
+
seeded: Map<string, string[]>;
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
/** Well-formed `{ title, digest }` pairs from untrusted branch data, or undefined. */
|
|
@@ -106,6 +114,7 @@ function readSections(value: unknown): SectionDigest[] | undefined {
|
|
|
106
114
|
function readBranchState(ctx: ExtensionContext): BranchState {
|
|
107
115
|
let mode: PlanModeEntry | undefined;
|
|
108
116
|
const baselines = new Map<string, SectionDigest[]>();
|
|
117
|
+
const seeded = new Map<string, string[]>();
|
|
109
118
|
for (const raw of ctx.sessionManager.getBranch()) {
|
|
110
119
|
const entry = raw as { type?: string; customType?: string; data?: unknown };
|
|
111
120
|
if (entry.type !== "custom") continue;
|
|
@@ -119,6 +128,16 @@ function readBranchState(ctx: ExtensionContext): BranchState {
|
|
|
119
128
|
mode = removed ? { enabled: data.enabled, removed } : { enabled: data.enabled };
|
|
120
129
|
continue;
|
|
121
130
|
}
|
|
131
|
+
if (entry.customType === SEEDED_TYPE) {
|
|
132
|
+
const data = entry.data as { path?: unknown; steps?: unknown } | null | undefined;
|
|
133
|
+
if (typeof data?.path !== "string" || !Array.isArray(data.steps)) continue;
|
|
134
|
+
// Branch data is untrusted: keep only the strings a step can be.
|
|
135
|
+
seeded.set(
|
|
136
|
+
resolve(data.path),
|
|
137
|
+
data.steps.filter((text): text is string => typeof text === "string"),
|
|
138
|
+
);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
122
141
|
if (entry.customType !== BASELINE_TYPE) continue;
|
|
123
142
|
const data = entry.data as { path?: unknown; sections?: unknown } | null | undefined;
|
|
124
143
|
if (typeof data?.path !== "string") continue;
|
|
@@ -128,7 +147,7 @@ function readBranchState(ctx: ExtensionContext): BranchState {
|
|
|
128
147
|
// still answers a lookup for the plan it names.
|
|
129
148
|
if (sections) baselines.set(resolve(data.path), sections);
|
|
130
149
|
}
|
|
131
|
-
return { mode, baselines };
|
|
150
|
+
return { mode, baselines, seeded };
|
|
132
151
|
}
|
|
133
152
|
|
|
134
153
|
/** Compare two paths, resolving symlinks when possible so `/tmp` and `/private/tmp` match. */
|
|
@@ -161,6 +180,8 @@ export function createPlanRuntime(
|
|
|
161
180
|
// can mark what changed. Persisted with the branch, so `/resume` and `/tree`
|
|
162
181
|
// compare against the review this branch actually showed.
|
|
163
182
|
const reviewedPlans = new Map<string, SectionDigest[]>();
|
|
183
|
+
/** The steps each approval appended, so the next approval can replace its own leftovers. */
|
|
184
|
+
const seededPlans = new Map<string, string[]>();
|
|
164
185
|
// Tools plan mode deactivated, so disabling restores exactly those and does
|
|
165
186
|
// not re-activate a tool another extension intentionally hid.
|
|
166
187
|
let removedForPlan: string[] = [];
|
|
@@ -236,6 +257,8 @@ export function createPlanRuntime(
|
|
|
236
257
|
// never showed has no digest here, so nothing looks changed until it does.
|
|
237
258
|
reviewedPlans.clear();
|
|
238
259
|
for (const [path, sections] of branch.baselines) reviewedPlans.set(path, sections);
|
|
260
|
+
seededPlans.clear();
|
|
261
|
+
for (const [path, steps] of branch.seeded) seededPlans.set(path, steps);
|
|
239
262
|
const persisted = branch.mode;
|
|
240
263
|
// `--plan` starts a fresh session in plan mode; an explicit persisted
|
|
241
264
|
// choice (including a later disable) wins over the launch flag.
|
|
@@ -260,6 +283,12 @@ export function createPlanRuntime(
|
|
|
260
283
|
setStatus(ctx);
|
|
261
284
|
},
|
|
262
285
|
reviewed: (path) => reviewedPlans.get(resolve(path)),
|
|
286
|
+
seeded: (path) => seededPlans.get(resolve(path)),
|
|
287
|
+
markSeeded: (path, steps) => {
|
|
288
|
+
const key = resolve(path);
|
|
289
|
+
seededPlans.set(key, [...steps]);
|
|
290
|
+
pi.appendEntry(SEEDED_TYPE, { path: key, steps: [...steps] } satisfies SeededStepsEntry);
|
|
291
|
+
},
|
|
263
292
|
markReviewed: (path, content) => {
|
|
264
293
|
const key = resolve(path);
|
|
265
294
|
const sections = sectionDigests(content);
|