@gavin-hu/my-pi-agent 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -9
- package/README.zh-CN.md +16 -9
- package/extensions/bytefly/README.md +25 -18
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/settings.ts +25 -18
- package/extensions/file-browse/README.md +60 -21
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +62 -0
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +19 -147
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +25 -4
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +2 -0
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/README.md +3 -3
- package/extensions/plan/README.md +280 -86
- package/extensions/plan/commands.ts +56 -5
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +1 -1
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +165 -57
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +78 -98
- package/extensions/plan/runtime.ts +1 -1
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +80 -93
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +101 -23
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +51 -8
- package/extensions/plan/types.ts +37 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +121 -177
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +103 -17
- package/extensions/plan/web-router.ts +146 -30
- package/extensions/plan/web-share-session.ts +197 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +193 -45
- package/extensions/question/README.md +3 -0
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/session-bus/README.md +247 -0
- package/extensions/session-bus/commands.ts +165 -0
- package/extensions/session-bus/format.ts +124 -0
- package/extensions/session-bus/index.ts +69 -0
- package/extensions/session-bus/notice.ts +58 -0
- package/extensions/session-bus/protocol.ts +134 -0
- package/extensions/session-bus/result.ts +243 -0
- package/extensions/session-bus/rows.ts +169 -0
- package/extensions/session-bus/runtime.ts +575 -0
- package/extensions/session-bus/store.ts +329 -0
- package/extensions/session-bus/team.ts +102 -0
- package/extensions/session-bus/tools.ts +227 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/README.md +1 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +15 -9
- package/extensions/wechat/README.md +20 -33
- package/extensions/wechat/bridge.ts +8 -8
- package/extensions/wechat/commands.ts +2 -2
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +31 -16
- package/extensions/wechat/notices.ts +7 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +39 -24
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +6 -2
- package/lib/claims/overlap.ts +43 -0
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/plans/sections.ts +172 -0
- package/lib/plans/setting.ts +52 -0
- package/lib/plans/split.ts +215 -0
- package/lib/plans/store.ts +19 -0
- package/lib/plans/team.ts +151 -0
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +1 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/components.ts +1 -1
- package/package.json +3 -2
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +78 -53
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/qrcode.ts +0 -46
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The plan review gate: ask the human about a plan file and return one decision.
|
|
3
3
|
*
|
|
4
|
-
* A review has three presentations — the scrollable TUI screen, a
|
|
5
|
-
* editor
|
|
6
|
-
*
|
|
4
|
+
* A review has three presentations — the scrollable TUI screen, a dialog-only
|
|
5
|
+
* editor, and a remote turn's compact editor prompt — and one optional browser
|
|
6
|
+
* server. This module owns the loop that switches between
|
|
7
7
|
* them, so `exit_plan_mode` and `/plan review` share one lifecycle and only map
|
|
8
8
|
* the returned {@link ReviewDecision} to their own result.
|
|
9
9
|
*
|
|
@@ -12,14 +12,15 @@
|
|
|
12
12
|
* signal reaches the browser server so an aborted turn closes it.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type { ExtensionToolContext } from "@earendil-works/pi-coding-agent";
|
|
16
15
|
import { basename } from "node:path";
|
|
17
|
-
import {
|
|
16
|
+
import type { ExtensionToolContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { askHuman, isRemoteTurn, remoteLanguage } from "../../lib/interaction.ts";
|
|
18
18
|
import { FULL_SCREEN_OVERLAY } from "../../lib/tui/chrome.ts";
|
|
19
19
|
import { feedbackSummary, type PlanAnnotation } from "./feedback.ts";
|
|
20
|
+
import type { PlanUnit } from "../../lib/plans/team.ts";
|
|
20
21
|
import type { PlanStore, StoredPlan } from "./plans.ts";
|
|
21
22
|
import type { SectionDigest } from "./plan-text.ts";
|
|
22
|
-
import {
|
|
23
|
+
import { planReviewPrompt, parsePlanReviewReply } from "./review-prompt.ts";
|
|
23
24
|
import { PlanViewComponent, type PlanViewAction } from "./tui.ts";
|
|
24
25
|
import type { PlanReviewResult, PlanReviewSession, PlanWebReview } from "./web.ts";
|
|
25
26
|
|
|
@@ -29,9 +30,6 @@ interface PlanReviewChoice {
|
|
|
29
30
|
refinement?: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
/** A dialog-only choice: post the full plan text, then ask again. Never returned. */
|
|
33
|
-
type ReviewChoice = PlanReviewChoice | { action: "send" };
|
|
34
|
-
|
|
35
33
|
/** What a review decided, and the plan state it decided on. */
|
|
36
34
|
export type ReviewDecision =
|
|
37
35
|
| { action: "approve"; plan: StoredPlan; comments: PlanAnnotation[] }
|
|
@@ -66,6 +64,15 @@ export interface ReviewPlanOptions {
|
|
|
66
64
|
plan: StoredPlan;
|
|
67
65
|
/** Aborting the turn closes a browser review and returns the plan unapproved. */
|
|
68
66
|
signal?: AbortSignal;
|
|
67
|
+
/** The split approval would write, so the screen shows what is being approved. */
|
|
68
|
+
split?: ReviewSplit;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A validated split, as the review screen displays it. */
|
|
72
|
+
export interface ReviewSplit {
|
|
73
|
+
lead: string;
|
|
74
|
+
units: readonly PlanUnit[];
|
|
75
|
+
verdict: "pair" | "team";
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
/** Prompt shown by the dialog-based refine editor in non-TUI modes. */
|
|
@@ -78,98 +85,47 @@ const REFINE_PROMPT = "Refine the plan — what should change? (Enter to save, E
|
|
|
78
85
|
*/
|
|
79
86
|
export const PLAN_REVIEW_TIMEOUT_MS = 600_000;
|
|
80
87
|
|
|
81
|
-
/** How much of one step the remote digest shows. */
|
|
82
|
-
const DIGEST_STEP_CHARS = 90;
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* A one-message digest of the plan for a remote review.
|
|
86
|
-
*
|
|
87
|
-
* The file is the primary artifact, but a phone client may never render or open
|
|
88
|
-
* it, and the peer still has to choose approve / refine. The digest carries the
|
|
89
|
-
* title, the step count, and the first few steps; the full text follows as a
|
|
90
|
-
* file or as the fallback message.
|
|
91
|
-
*/
|
|
92
|
-
function planReviewDigest(content: string): string {
|
|
93
|
-
const title = /^#\s+(.+)$/m.exec(content)?.[1]?.trim();
|
|
94
|
-
const steps = extractPlanSteps(content);
|
|
95
|
-
const count = steps.length;
|
|
96
|
-
const head = `Plan${title ? `: ${title}` : ""}${count > 0 ? ` (${count} step${count === 1 ? "" : "s"})` : ""}`;
|
|
97
|
-
const preview = steps.slice(0, 3).map((step, index) => {
|
|
98
|
-
const text = step.content.replace(/\s+/g, " ").trim();
|
|
99
|
-
const shown = text.length > DIGEST_STEP_CHARS ? `${text.slice(0, DIGEST_STEP_CHARS - 1)}…` : text;
|
|
100
|
-
return `${index + 1}. ${shown}`;
|
|
101
|
-
});
|
|
102
|
-
const more = count > preview.length ? `… (${count - preview.length} more)` : "";
|
|
103
|
-
return [
|
|
104
|
-
head,
|
|
105
|
-
...preview,
|
|
106
|
-
more,
|
|
107
|
-
"Plan mode is on — write access is off. Choose “Exit plan mode” to leave without approving.",
|
|
108
|
-
"The full plan is attached above, or sent as text if the file failed.",
|
|
109
|
-
]
|
|
110
|
-
.filter((line) => line !== "")
|
|
111
|
-
.join("\n");
|
|
112
|
-
}
|
|
113
|
-
|
|
114
88
|
/**
|
|
115
|
-
* Ask the user to review the plan file. The TUI opens the scrollable
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* first. Asking for the full plan re-enters with the text only, then shows the
|
|
119
|
-
* choices again.
|
|
89
|
+
* Ask the user to review the plan file. The TUI opens the scrollable screen;
|
|
90
|
+
* dialog-capable modes (RPC/WeChat) get one editor prompt. A reply that is not an
|
|
91
|
+
* action is a refinement.
|
|
120
92
|
*/
|
|
121
93
|
async function askPlanReview(
|
|
122
94
|
ctx: ExtensionToolContext,
|
|
123
95
|
plan: StoredPlan,
|
|
124
96
|
baseline: SectionDigest[] | undefined,
|
|
97
|
+
split: ReviewSplit | undefined,
|
|
125
98
|
): Promise<PlanReviewChoice> {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (choice === "Keep planning (stay in plan mode)") return { action: "keep" };
|
|
157
|
-
// Dismissing the plan gate means leaving plan mode, not silently staying
|
|
158
|
-
// in it. The explicit Keep choice above is the only way to stay.
|
|
159
|
-
return { action: "cancel" };
|
|
160
|
-
},
|
|
161
|
-
// A remote turn gets the plan file up front, falling back to the full text;
|
|
162
|
-
// a local TUI turn ignores both and renders the component. The digest goes
|
|
163
|
-
// out either way so the choices stay answerable when a phone client does not
|
|
164
|
-
// render the file bubble.
|
|
165
|
-
file: sentFullText ? undefined : { path: plan.path, name: basename(plan.relativePath) },
|
|
166
|
-
body: plan.content,
|
|
167
|
-
note: sentFullText ? undefined : planReviewDigest(plan.content),
|
|
168
|
-
timeoutMs: PLAN_REVIEW_TIMEOUT_MS,
|
|
169
|
-
});
|
|
170
|
-
if (outcome.action !== "send") return outcome;
|
|
171
|
-
sentFullText = true;
|
|
172
|
-
}
|
|
99
|
+
const language = remoteLanguage(ctx) ?? "en";
|
|
100
|
+
const remote = isRemoteTurn(ctx);
|
|
101
|
+
return askHuman<PlanReviewChoice>(ctx, {
|
|
102
|
+
custom: async () => {
|
|
103
|
+
const choice = await ctx.ui.custom<PlanReviewChoice | undefined>(
|
|
104
|
+
(tui, theme, _keybindings, done) =>
|
|
105
|
+
new PlanViewComponent({
|
|
106
|
+
plan,
|
|
107
|
+
baseline,
|
|
108
|
+
...(split === undefined ? {} : { split }),
|
|
109
|
+
theme,
|
|
110
|
+
onClose: (action, refinement) => done({ action, refinement }),
|
|
111
|
+
requestRender: () => tui.requestRender(),
|
|
112
|
+
viewportRows: () => tui.terminal?.rows,
|
|
113
|
+
tui,
|
|
114
|
+
}),
|
|
115
|
+
FULL_SCREEN_OVERLAY,
|
|
116
|
+
);
|
|
117
|
+
return choice ?? { action: "keep" };
|
|
118
|
+
},
|
|
119
|
+
dialogs: async () => {
|
|
120
|
+
const answer = await ctx.ui.editor(planReviewPrompt(plan.content, language, remote), "");
|
|
121
|
+
return parsePlanReviewReply(answer ?? "");
|
|
122
|
+
},
|
|
123
|
+
// The full plan is the artifact under review, so it goes out before the
|
|
124
|
+
// choices; if the upload fails, the same text is the fallback.
|
|
125
|
+
file: { path: plan.path, name: basename(plan.relativePath) },
|
|
126
|
+
body: plan.content,
|
|
127
|
+
timeoutMs: PLAN_REVIEW_TIMEOUT_MS,
|
|
128
|
+
});
|
|
173
129
|
}
|
|
174
130
|
|
|
175
131
|
/** How a browser review may decide, and what to report when it opens. */
|
|
@@ -182,6 +138,13 @@ export interface BrowserReviewOptions {
|
|
|
182
138
|
signal?: AbortSignal;
|
|
183
139
|
/** Called once the server is listening and the browser was opened. */
|
|
184
140
|
onOpened?: (session: PlanReviewSession) => void;
|
|
141
|
+
/**
|
|
142
|
+
* Called when a shared reviewer's post lands, so the owner learns about it where
|
|
143
|
+
* they are waiting: the page updates itself, the terminal has to be told.
|
|
144
|
+
*/
|
|
145
|
+
onReviewerComment?: (author: string, count: number) => void;
|
|
146
|
+
/** A line the page shows the owner, such as why approval belongs in the terminal. */
|
|
147
|
+
note?: string;
|
|
185
148
|
}
|
|
186
149
|
|
|
187
150
|
/**
|
|
@@ -196,7 +159,13 @@ export async function runBrowserReview(
|
|
|
196
159
|
): Promise<PlanReviewResult | undefined> {
|
|
197
160
|
if (options.signal?.aborted) return { status: "cancelled" };
|
|
198
161
|
const session = await web
|
|
199
|
-
.start(cwd, plan, {
|
|
162
|
+
.start(cwd, plan, {
|
|
163
|
+
canApprove: options.canApprove,
|
|
164
|
+
baseline: options.baseline,
|
|
165
|
+
signal: options.signal,
|
|
166
|
+
...(options.note === undefined ? {} : { note: options.note }),
|
|
167
|
+
...(options.onReviewerComment === undefined ? {} : { onReviewerComment: options.onReviewerComment }),
|
|
168
|
+
})
|
|
200
169
|
.catch(() => undefined);
|
|
201
170
|
if (!session) return undefined;
|
|
202
171
|
options.onOpened?.(session);
|
|
@@ -213,7 +182,7 @@ export async function runBrowserReview(
|
|
|
213
182
|
* cancelled returns to the screen it was opened from.
|
|
214
183
|
*/
|
|
215
184
|
export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDecision> {
|
|
216
|
-
const { ctx, web, plans, baselines, signal } = options;
|
|
185
|
+
const { ctx, web, plans, baselines, signal, split } = options;
|
|
217
186
|
let plan = options.plan;
|
|
218
187
|
for (;;) {
|
|
219
188
|
// An aborted turn has nobody left to answer the screen.
|
|
@@ -221,7 +190,7 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
|
|
|
221
190
|
// Read the baseline before the screen and mark it after, so what the next
|
|
222
191
|
// review compares against is the content this one actually showed.
|
|
223
192
|
const baseline = baselines.reviewed(plan.path);
|
|
224
|
-
const choice = await askPlanReview(ctx, plan, baseline);
|
|
193
|
+
const choice = await askPlanReview(ctx, plan, baseline, split);
|
|
225
194
|
baselines.markReviewed(plan.path, plan.content);
|
|
226
195
|
|
|
227
196
|
if (choice.action === "approve") return { action: "approve", plan, comments: [] };
|
|
@@ -237,11 +206,22 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
|
|
|
237
206
|
// An unrecognized action keeps planning rather than opening a server.
|
|
238
207
|
if (choice.action !== "browser") return { action: "keep", plan };
|
|
239
208
|
|
|
209
|
+
// A split section exists only after approval, and the page anchors comments to
|
|
210
|
+
// source lines, so a page cannot show — or approve — a plan it cannot carry.
|
|
211
|
+
// The terminal review is where the split is approved.
|
|
212
|
+
const splitNote =
|
|
213
|
+
split === undefined
|
|
214
|
+
? undefined
|
|
215
|
+
: "This plan has a split, which the page cannot show; approve it in the terminal review.";
|
|
216
|
+
if (splitNote !== undefined) ctx.ui.notify(splitNote, "info");
|
|
240
217
|
const result = await runBrowserReview(web, ctx.cwd, plan, {
|
|
241
|
-
canApprove:
|
|
218
|
+
canApprove: split === undefined,
|
|
219
|
+
...(splitNote === undefined ? {} : { note: splitNote }),
|
|
242
220
|
baseline,
|
|
243
221
|
signal,
|
|
244
222
|
onOpened: (session) => ctx.ui.notify(`Plan review: opened ${session.url}`, "info"),
|
|
223
|
+
onReviewerComment: (author, count) =>
|
|
224
|
+
ctx.ui.notify(`Plan review: @${author} posted ${count === 1 ? "a comment" : `${count} comments`}.`, "info"),
|
|
245
225
|
});
|
|
246
226
|
if (!result) {
|
|
247
227
|
ctx.ui.notify("Plan review: could not start the browser review server.", "error");
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Enabling plan mode removes every tool the policy does not consider read-only
|
|
5
5
|
* from the active set and activates the plan-mode control tools: `exit_plan_mode`
|
|
6
6
|
* (present the plan for approval) and `write_plan` (save the plan artifact under
|
|
7
|
-
*
|
|
7
|
+
* the plans directory). Disabling restores exactly what it hid. The enabled flag is
|
|
8
8
|
* persisted as a custom session entry so `/resume` and `/tree` follow the
|
|
9
9
|
* branch, and it is also mirrored into the footer status. The review baseline
|
|
10
10
|
* travels the same way, as one digest per heading section, so the changed-section
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The split at approval: the switch that enables it, the roster card, and the
|
|
3
|
+
* `## Team` section it writes.
|
|
4
|
+
*
|
|
5
|
+
* Plan mode keeps one job — the plan document and its approval gate — and this
|
|
6
|
+
* module is the part of that gate that concerns a team: whether the feature is on,
|
|
7
|
+
* what the user is told to run in the sessions this package cannot start, and the
|
|
8
|
+
* write that makes the team real. Everything mechanical about the split itself
|
|
9
|
+
* (which tables are legal, which paths collide) is `lib/plans/split.ts`, because
|
|
10
|
+
* the bus reads the same section.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { sanitizeLine, shortenPath } from "../../lib/format.ts";
|
|
14
|
+
import { splitEnabled } from "../../lib/plans/setting.ts";
|
|
15
|
+
import { checkSplit, mergeOrder } from "../../lib/plans/split.ts";
|
|
16
|
+
import type { PlanTeam, PlanUnit } from "../../lib/plans/team.ts";
|
|
17
|
+
|
|
18
|
+
export { SPLIT_OFF_NOTE, SPLIT_SETTING_ID, SPLIT_SETTING_LABEL, splitEnabled } from "../../lib/plans/setting.ts";
|
|
19
|
+
|
|
20
|
+
/** What approval decides about the units a model proposed. */
|
|
21
|
+
export type SplitDecision =
|
|
22
|
+
| { kind: "none" }
|
|
23
|
+
| { kind: "off" }
|
|
24
|
+
| { kind: "refused"; reason: string }
|
|
25
|
+
| { kind: "serial" }
|
|
26
|
+
| { kind: "team"; team: PlanTeam; verdict: "pair" | "team" };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Decide what to do with the `units` an approval carried.
|
|
30
|
+
*
|
|
31
|
+
* The switch is checked first: with it off the units are ignored entirely rather
|
|
32
|
+
* than validated, so a user who never enabled the feature cannot be blocked by a
|
|
33
|
+
* rule about it.
|
|
34
|
+
*/
|
|
35
|
+
export function decideSplit(
|
|
36
|
+
cwd: string,
|
|
37
|
+
root: string | undefined,
|
|
38
|
+
units: readonly PlanUnit[] | undefined,
|
|
39
|
+
): SplitDecision {
|
|
40
|
+
if (units === undefined || units.length === 0) return { kind: "none" };
|
|
41
|
+
if (!splitEnabled(cwd, root)) return { kind: "off" };
|
|
42
|
+
const check = checkSplit(units);
|
|
43
|
+
if (!check.ok) return { kind: "refused", reason: check.reason };
|
|
44
|
+
if (check.verdict === "serial") return { kind: "serial" };
|
|
45
|
+
return { kind: "team", team: { lead: check.lead, units: check.units }, verdict: check.verdict };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The roster card: what the user runs, in which session, and in what order.
|
|
50
|
+
*
|
|
51
|
+
* A lead cannot start, name, or prompt a peer session, so the card is the whole of
|
|
52
|
+
* what step 3 can do — and its first line says so, because the rest of the flow
|
|
53
|
+
* depends on the user believing it.
|
|
54
|
+
*/
|
|
55
|
+
export function rosterCard(team: PlanTeam, planPath: string, sessionLabel?: string): string {
|
|
56
|
+
const lead = team.units.find((unit) => unit.lead)?.name ?? team.lead;
|
|
57
|
+
const owners = team.units.filter((unit) => !unit.lead);
|
|
58
|
+
const rows = owners.map((unit) => {
|
|
59
|
+
const join = `session({action:"join", team:"${teamName(planPath)}", unit:"${unit.name}"})`;
|
|
60
|
+
return ` ${unit.name}: enter_worktree ${unit.name} · /sessions name ${unit.name} · /sessions watch wake\n ${join}`;
|
|
61
|
+
});
|
|
62
|
+
const order = mergeOrderLine(team);
|
|
63
|
+
// The lead's own label is the one line of the card it cannot get itself until
|
|
64
|
+
// after this card is printed, so the card either names it or says how to set it.
|
|
65
|
+
const name = sessionLabel?.trim();
|
|
66
|
+
const label = name
|
|
67
|
+
? ` your label: ${sanitizeLine(name)} — owners send their report there`
|
|
68
|
+
: ` your label: none — name this session: session({action:"name", label:"${lead}"})`;
|
|
69
|
+
return [
|
|
70
|
+
`Split: ${team.units.length} units · ${shortenPath(planPath)}`,
|
|
71
|
+
" You start and switch every session; nothing here can.",
|
|
72
|
+
...rows,
|
|
73
|
+
` ${order} · your unit: ${lead}`,
|
|
74
|
+
label,
|
|
75
|
+
].join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The team's name is the plan file's stem, so two plans never share one. */
|
|
79
|
+
export function teamName(planPath: string): string {
|
|
80
|
+
const name = planPath.split(/[\\/]/).pop() ?? planPath;
|
|
81
|
+
return name.replace(/\.md$/i, "");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Merge order in one line, from the dependency graph the validation accepted. */
|
|
85
|
+
export function mergeOrderLine(team: PlanTeam): string {
|
|
86
|
+
return `merge order: ${mergeOrder(team.units).join(" → ")}`;
|
|
87
|
+
}
|
package/extensions/plan/steps.ts
CHANGED
|
@@ -10,13 +10,35 @@
|
|
|
10
10
|
* the digest is computed over.
|
|
11
11
|
*
|
|
12
12
|
* Only top-level list items become steps: nested sub-bullets are notes, and the
|
|
13
|
-
* shallowest list level is treated as the plan.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* shallowest list level is treated as the plan. An indented, marker-less line
|
|
14
|
+
* continues the item above it, so a wrapped step keeps its full text. When the
|
|
15
|
+
* plan has a `Steps` section (the canonical outline's heading), only that
|
|
16
|
+
* section's list items are steps, so prose bullets elsewhere never seed the todo
|
|
17
|
+
* list or consume the step cap. List items inside fenced code blocks are ignored. A `- [ ]` / `- [x]`
|
|
17
18
|
* checkbox or a `[DONE:n]` marker sets the status.
|
|
18
19
|
*/
|
|
19
20
|
|
|
21
|
+
import {
|
|
22
|
+
extractSections as extractPlanSections,
|
|
23
|
+
FENCE,
|
|
24
|
+
HEADING,
|
|
25
|
+
normalizeHeadingText,
|
|
26
|
+
} from "../../lib/plans/sections.ts";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The section machinery is `lib/plans/sections.ts`, because the split writes
|
|
30
|
+
* `## Team` with the same fence-aware scan this module navigates with. These
|
|
31
|
+
* re-exports keep the plan extension's public surface one import.
|
|
32
|
+
*/
|
|
33
|
+
export {
|
|
34
|
+
extractSections as extractPlanSections,
|
|
35
|
+
FENCE,
|
|
36
|
+
HEADING,
|
|
37
|
+
normalizeHeadingText,
|
|
38
|
+
replaceSection,
|
|
39
|
+
} from "../../lib/plans/sections.ts";
|
|
40
|
+
export type { PlanSection } from "../../lib/plans/sections.ts";
|
|
41
|
+
|
|
20
42
|
/** Most steps seeded from one plan. */
|
|
21
43
|
export const MAX_STEPS = 50;
|
|
22
44
|
/** Longest seeded step text; longer lines are elided. */
|
|
@@ -82,26 +104,17 @@ export interface PlanStep {
|
|
|
82
104
|
status: "pending" | "completed";
|
|
83
105
|
}
|
|
84
106
|
|
|
85
|
-
/** A heading-delimited chunk of the plan, for the review screen's navigation. */
|
|
86
|
-
export interface PlanSection {
|
|
87
|
-
/** Plain heading text, or `""` for the content before the first heading. */
|
|
88
|
-
title: string;
|
|
89
|
-
/** The section's markdown, including its heading line. */
|
|
90
|
-
text: string;
|
|
91
|
-
/** 0-based source line of the section's heading (0 for the preamble). */
|
|
92
|
-
line: number;
|
|
93
|
-
/** Heading level, or 0 for the preamble before the first heading. */
|
|
94
|
-
level: number;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
107
|
/** A top-level list item located in the source. */
|
|
98
108
|
interface PlanListItem {
|
|
99
|
-
/** Zero-based source line index. */
|
|
109
|
+
/** Zero-based source line index of the item's first line. */
|
|
100
110
|
line: number;
|
|
101
111
|
indent: number;
|
|
102
112
|
/** The list marker with its trailing whitespace, e.g. `1. ` or `- `. */
|
|
103
113
|
lead: string;
|
|
104
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* Item text after the marker on its first line; indented continuation lines
|
|
116
|
+
* are folded into {@link step}, so a checkbox edit rewrites only this line.
|
|
117
|
+
*/
|
|
105
118
|
raw: string;
|
|
106
119
|
step: PlanStep;
|
|
107
120
|
}
|
|
@@ -109,39 +122,10 @@ interface PlanListItem {
|
|
|
109
122
|
/** A top-level numbered or bulleted list item, capturing its parts. */
|
|
110
123
|
const LIST_ITEM = /^([ \t]*)((?:\d+[.)]|[-*+])\s+)(.*\S)\s*$/;
|
|
111
124
|
const CHECKBOX = /^\[([ xX])\]\s*(.*)$/;
|
|
112
|
-
/**
|
|
113
|
-
* A markdown heading outside a fence, capturing its level and text: up to three
|
|
114
|
-
* leading spaces, a space after the hashes, and a closing hash run only when a
|
|
115
|
-
* space separates it. Shared with `blocks.ts`, so the section split and the
|
|
116
|
-
* browser outline read a heading line the same way, and `## C#` keeps its title.
|
|
117
|
-
*/
|
|
118
|
-
export const HEADING = /^ {0,3}(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/;
|
|
119
|
-
/**
|
|
120
|
-
* A markdown code-fence opener or closer (backticks or tildes). Shared with
|
|
121
|
-
* `blocks.ts`: a tab-indented fence is prose for both parsers, never code for
|
|
122
|
-
* one and prose for the other.
|
|
123
|
-
*/
|
|
124
|
-
export const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
125
125
|
const DONE_MARKER = /\[DONE:\d+\]/gi;
|
|
126
126
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
129
|
-
* code removed, whitespace collapsed. Exported so `blocks.ts` labels an outline
|
|
130
|
-
* entry the same way `extractPlanSections` labels its section, which is what
|
|
131
|
-
* lets the browser mark a changed section.
|
|
132
|
-
*/
|
|
133
|
-
export function normalizeHeadingText(text: string): string {
|
|
134
|
-
return text
|
|
135
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
136
|
-
.replace(/\*([^*]+)\*/g, "$1")
|
|
137
|
-
.replace(/(?<![A-Za-z0-9_])_([^_]+)_(?![A-Za-z0-9_])/g, "$1")
|
|
138
|
-
.replace(/`([^`]+)`/g, "$1")
|
|
139
|
-
.replace(/\s+/g, " ")
|
|
140
|
-
.trim();
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Strip inline markdown emphasis and code, then collapse to one clean line. */
|
|
144
|
-
function plainText(text: string): string {
|
|
127
|
+
/** A label with its inline markers and any done marker removed. */
|
|
128
|
+
function cleanLabel(text: string): string {
|
|
145
129
|
return normalizeHeadingText(text.replace(DONE_MARKER, ""));
|
|
146
130
|
}
|
|
147
131
|
|
|
@@ -157,7 +141,12 @@ function parseStep(raw: string): PlanStep {
|
|
|
157
141
|
}
|
|
158
142
|
if (/\[DONE:\d+\]/i.test(text)) done = true;
|
|
159
143
|
|
|
160
|
-
return { content:
|
|
144
|
+
return { content: cleanLabel(text), status: done ? "completed" : "pending" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Leading whitespace width of a line, for continuation indent checks. */
|
|
148
|
+
function indentOf(line: string): number {
|
|
149
|
+
return line.length - line.trimStart().length;
|
|
161
150
|
}
|
|
162
151
|
|
|
163
152
|
/** Every top-level list item, in order, before de-duplication and capping. */
|
|
@@ -165,22 +154,57 @@ function scanListItems(plan: string): PlanListItem[] {
|
|
|
165
154
|
const items: PlanListItem[] = [];
|
|
166
155
|
// Fences hide example lists (`- name: foo` in a YAML block) from extraction.
|
|
167
156
|
let fence: string | undefined;
|
|
157
|
+
// The item still collecting indented continuation lines, and those lines.
|
|
158
|
+
let current: { item: PlanListItem; continuation: string[] } | undefined;
|
|
159
|
+
|
|
160
|
+
/** Fold the collected continuation into the item, then close it. */
|
|
161
|
+
const finish = (): void => {
|
|
162
|
+
if (current === undefined) return;
|
|
163
|
+
if (current.continuation.length > 0) {
|
|
164
|
+
current.item.step = parseStep(`${current.item.raw} ${current.continuation.join(" ")}`);
|
|
165
|
+
}
|
|
166
|
+
current = undefined;
|
|
167
|
+
};
|
|
168
168
|
|
|
169
169
|
plan.split("\n").forEach((line, index) => {
|
|
170
170
|
const fenceMatch = line.match(FENCE);
|
|
171
171
|
if (fenceMatch) {
|
|
172
|
+
finish();
|
|
172
173
|
if (fence === undefined) fence = fenceMatch[1][0];
|
|
173
174
|
else if (fence === fenceMatch[1][0]) fence = undefined;
|
|
174
175
|
return;
|
|
175
176
|
}
|
|
176
177
|
if (fence !== undefined) return;
|
|
177
178
|
|
|
179
|
+
if (line.trim() === "") {
|
|
180
|
+
finish();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
178
184
|
const match = line.match(LIST_ITEM);
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
185
|
+
if (match) {
|
|
186
|
+
finish();
|
|
187
|
+
const step = parseStep(match[3]);
|
|
188
|
+
if (!step.content) return;
|
|
189
|
+
const item: PlanListItem = { line: index, indent: match[1].length, lead: match[2], raw: match[3], step };
|
|
190
|
+
items.push(item);
|
|
191
|
+
current = { item, continuation: [] };
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A deeper, marker-less line continues the step's paragraph; a heading,
|
|
196
|
+
// a shallow line, or a blank line ends it. `raw` stays the first line so
|
|
197
|
+
// `setStepStatuses` edits the checkbox without touching these lines.
|
|
198
|
+
if (current !== undefined && indentOf(line) > current.item.indent && !HEADING.test(line)) {
|
|
199
|
+
const text = line.trim();
|
|
200
|
+
if (text) current.continuation.push(text);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
finish();
|
|
183
205
|
});
|
|
206
|
+
|
|
207
|
+
finish();
|
|
184
208
|
return items;
|
|
185
209
|
}
|
|
186
210
|
|
|
@@ -208,7 +232,7 @@ function stepsSection(plan: string): { start: number; end: number } | undefined
|
|
|
208
232
|
if (!heading) continue;
|
|
209
233
|
const depth = heading[1].length;
|
|
210
234
|
if (start < 0) {
|
|
211
|
-
if (
|
|
235
|
+
if (normalizeHeadingText(heading[2]).toLowerCase() !== "steps") continue;
|
|
212
236
|
start = index;
|
|
213
237
|
level = depth;
|
|
214
238
|
continue;
|
|
@@ -281,7 +305,7 @@ export function verificationItem(plan: string): PlanStep | undefined {
|
|
|
281
305
|
// A fenced block is how the outline asks for commands; its fence lines are
|
|
282
306
|
// punctuation, not a check, so only the commands inside it are carried.
|
|
283
307
|
.filter((line) => !FENCE.test(line))
|
|
284
|
-
.map((line) =>
|
|
308
|
+
.map((line) => cleanLabel(line.replace(LIST_PREFIX, "")))
|
|
285
309
|
.filter(Boolean);
|
|
286
310
|
if (items.length === 0) return undefined;
|
|
287
311
|
return { content: elide(`Verify: ${items.join("; ")}`), status: "pending" };
|
|
@@ -361,43 +385,6 @@ export function setStepStatuses(plan: string, updates: readonly StepEdit[]): str
|
|
|
361
385
|
return lines.join("\n");
|
|
362
386
|
}
|
|
363
387
|
|
|
364
|
-
/**
|
|
365
|
-
* Sections of a plan, split at heading lines outside code fences. The content
|
|
366
|
-
* before the first heading is a section titled `""`. Pi's `Markdown` renderer
|
|
367
|
-
* exposes no source-line map, so the review screen renders these chunks
|
|
368
|
-
* separately to know where each section begins.
|
|
369
|
-
*/
|
|
370
|
-
export function extractPlanSections(plan: string): PlanSection[] {
|
|
371
|
-
const sections: PlanSection[] = [];
|
|
372
|
-
let current: PlanSection | undefined;
|
|
373
|
-
let fence: string | undefined;
|
|
374
|
-
|
|
375
|
-
const flush = (): void => {
|
|
376
|
-
if (current && (current.title !== "" || current.text.trim() !== "")) sections.push(current);
|
|
377
|
-
current = undefined;
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
plan.split("\n").forEach((line, index) => {
|
|
381
|
-
const fenceMatch = line.match(FENCE);
|
|
382
|
-
if (fenceMatch) {
|
|
383
|
-
if (fence === undefined) fence = fenceMatch[1][0];
|
|
384
|
-
else if (fence === fenceMatch[1][0]) fence = undefined;
|
|
385
|
-
} else if (fence === undefined) {
|
|
386
|
-
const heading = line.match(HEADING);
|
|
387
|
-
if (heading) {
|
|
388
|
-
flush();
|
|
389
|
-
current = { title: plainText(heading[2]), text: line, line: index, level: heading[1].length };
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
if (current === undefined) current = { title: "", text: line, line: 0, level: 0 };
|
|
394
|
-
else current.text += `\n${line}`;
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
flush();
|
|
398
|
-
return sections;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
388
|
/**
|
|
402
389
|
* The plan's own objective: the first paragraph of its `## Goal` section, with
|
|
403
390
|
* inline markdown and a leading list marker removed.
|
|
@@ -12,8 +12,8 @@ import { GLYPHS } from "../../../lib/tui/glyphs.ts";
|
|
|
12
12
|
import { paint } from "../../../lib/tui/tokens.ts";
|
|
13
13
|
import { callLine, detailsOrLine } from "../../../lib/tui/tool.ts";
|
|
14
14
|
import { failedResult } from "../../../lib/tool-result.ts";
|
|
15
|
-
import { isRemoteTurn } from "../../../lib/interaction.ts";
|
|
16
|
-
import { PLAN_CONSENT, READ_ONLY_SUMMARY } from "../../../lib/plan-entry.ts";
|
|
15
|
+
import { isRemoteTurn, remoteLanguage } from "../../../lib/interaction.ts";
|
|
16
|
+
import { PLAN_CONSENT, PLAN_CONSENT_SHORT, PLAN_CONSENT_SHORT_ZH, READ_ONLY_SUMMARY } from "../../../lib/plan-entry.ts";
|
|
17
17
|
import { ENTER_TOOL, type PlanRuntime } from "../runtime.ts";
|
|
18
18
|
import type { EnterPlanModeDetails } from "../types.ts";
|
|
19
19
|
|
|
@@ -53,7 +53,12 @@ export function registerEnterTool(pi: ExtensionAPI, runtime: PlanRuntime): void
|
|
|
53
53
|
);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
const
|
|
56
|
+
const consent = isRemoteTurn(ctx)
|
|
57
|
+
? remoteLanguage(ctx) === "zh"
|
|
58
|
+
? PLAN_CONSENT_SHORT_ZH
|
|
59
|
+
: PLAN_CONSENT_SHORT
|
|
60
|
+
: PLAN_CONSENT;
|
|
61
|
+
const approved = await ctx.ui.confirm("Enter plan mode?", consent);
|
|
57
62
|
if (!approved) {
|
|
58
63
|
return {
|
|
59
64
|
content: [{ type: "text", text: "Stayed in normal mode. Proceed, or ask the user how to continue." }],
|