@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.
Files changed (112) hide show
  1. package/README.md +17 -9
  2. package/README.zh-CN.md +16 -9
  3. package/extensions/bytefly/README.md +25 -18
  4. package/extensions/bytefly/notify/types.ts +1 -1
  5. package/extensions/bytefly/panel/settings.ts +25 -18
  6. package/extensions/file-browse/README.md +60 -21
  7. package/extensions/file-browse/chrome.ts +83 -0
  8. package/extensions/file-browse/client.ts +62 -0
  9. package/extensions/file-browse/config.ts +12 -3
  10. package/extensions/file-browse/diff-html.ts +94 -0
  11. package/extensions/file-browse/html.ts +19 -147
  12. package/extensions/file-browse/live-client.ts +47 -1
  13. package/extensions/file-browse/live.ts +35 -14
  14. package/extensions/file-browse/rev.ts +42 -0
  15. package/extensions/file-browse/router.ts +25 -4
  16. package/extensions/file-browse/server.ts +7 -0
  17. package/extensions/file-browse/styles.ts +2 -0
  18. package/extensions/goal/plan-route.ts +8 -2
  19. package/extensions/job/README.md +7 -5
  20. package/extensions/job/index.ts +15 -7
  21. package/extensions/memory/README.md +3 -3
  22. package/extensions/plan/README.md +280 -86
  23. package/extensions/plan/commands.ts +56 -5
  24. package/extensions/plan/feedback.ts +300 -63
  25. package/extensions/plan/index.ts +1 -1
  26. package/extensions/plan/plan-index.ts +1 -1
  27. package/extensions/plan/plan-text.ts +18 -1
  28. package/extensions/plan/plans.ts +165 -57
  29. package/extensions/plan/review-prompt.ts +93 -0
  30. package/extensions/plan/review.ts +78 -98
  31. package/extensions/plan/runtime.ts +1 -1
  32. package/extensions/plan/split.ts +87 -0
  33. package/extensions/plan/steps.ts +80 -93
  34. package/extensions/plan/tools/enter.ts +8 -3
  35. package/extensions/plan/tools/exit.ts +101 -23
  36. package/extensions/plan/tools/write.ts +8 -3
  37. package/extensions/plan/tools.ts +1 -1
  38. package/extensions/plan/tui.ts +51 -8
  39. package/extensions/plan/types.ts +37 -3
  40. package/extensions/plan/web-chrome.ts +2 -1
  41. package/extensions/plan/web-client.ts +121 -177
  42. package/extensions/plan/web-collab.ts +207 -0
  43. package/extensions/plan/web-comments.ts +269 -0
  44. package/extensions/plan/web-page.ts +103 -17
  45. package/extensions/plan/web-router.ts +146 -30
  46. package/extensions/plan/web-share-session.ts +197 -0
  47. package/extensions/plan/web-share.ts +169 -0
  48. package/extensions/plan/web-streams.ts +124 -0
  49. package/extensions/plan/web-tokens.ts +63 -0
  50. package/extensions/plan/web.ts +193 -45
  51. package/extensions/question/README.md +3 -0
  52. package/extensions/question/dialogs.ts +91 -1
  53. package/extensions/question/tools.ts +2 -1
  54. package/extensions/session-bus/README.md +247 -0
  55. package/extensions/session-bus/commands.ts +165 -0
  56. package/extensions/session-bus/format.ts +124 -0
  57. package/extensions/session-bus/index.ts +69 -0
  58. package/extensions/session-bus/notice.ts +58 -0
  59. package/extensions/session-bus/protocol.ts +134 -0
  60. package/extensions/session-bus/result.ts +243 -0
  61. package/extensions/session-bus/rows.ts +169 -0
  62. package/extensions/session-bus/runtime.ts +575 -0
  63. package/extensions/session-bus/store.ts +329 -0
  64. package/extensions/session-bus/team.ts +102 -0
  65. package/extensions/session-bus/tools.ts +227 -0
  66. package/extensions/session-bus/tui.ts +394 -0
  67. package/extensions/session-bus/types.ts +98 -0
  68. package/extensions/subagent/README.md +1 -1
  69. package/extensions/watch/README.md +9 -5
  70. package/extensions/watch/index.ts +15 -9
  71. package/extensions/wechat/README.md +20 -33
  72. package/extensions/wechat/bridge.ts +8 -8
  73. package/extensions/wechat/commands.ts +2 -2
  74. package/extensions/wechat/deliver.ts +57 -37
  75. package/extensions/wechat/interaction.ts +31 -16
  76. package/extensions/wechat/notices.ts +7 -7
  77. package/extensions/wechat/peer-worker.ts +19 -34
  78. package/extensions/wechat/presence.ts +3 -4
  79. package/extensions/wechat/prompt.ts +39 -24
  80. package/extensions/wechat/reply-pipeline.ts +12 -4
  81. package/extensions/worktree/git.ts +5 -0
  82. package/extensions/worktree/registry.ts +11 -5
  83. package/extensions/worktree/status.ts +1 -3
  84. package/lib/README.md +6 -2
  85. package/lib/claims/overlap.ts +43 -0
  86. package/lib/http/router.ts +40 -13
  87. package/lib/http/server.ts +27 -13
  88. package/lib/interaction.ts +10 -0
  89. package/lib/notice.ts +29 -2
  90. package/lib/plan-entry.ts +7 -2
  91. package/lib/plans/sections.ts +172 -0
  92. package/lib/plans/setting.ts +52 -0
  93. package/lib/plans/split.ts +215 -0
  94. package/lib/plans/store.ts +19 -0
  95. package/lib/plans/team.ts +151 -0
  96. package/lib/qr.ts +64 -0
  97. package/lib/repo-key.ts +74 -0
  98. package/lib/report.ts +52 -33
  99. package/lib/requirements.ts +1 -2
  100. package/lib/tui/README.md +16 -2
  101. package/lib/tui/dock-screen.ts +85 -17
  102. package/lib/tui/line-pane.ts +15 -4
  103. package/lib/tui/tool.ts +23 -4
  104. package/lib/web/components.ts +1 -1
  105. package/package.json +3 -2
  106. package/skills/init/SKILL.md +80 -45
  107. package/skills/init/references/agents-md-quality.md +26 -2
  108. package/skills/init/references/interview-questions.md +36 -14
  109. package/skills/next/SKILL.md +78 -53
  110. package/skills/parallel-dev/SKILL.md +150 -0
  111. package/extensions/plan/config.ts +0 -27
  112. 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 select plus
5
- * editor for dialog-only modes, and a remote turn's plan file plus digest — and
6
- * one optional browser server. This module owns the loop that switches between
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 { askHuman } from "../../lib/interaction.ts";
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 { extractPlanSteps } from "./steps.ts";
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 review
116
- * screen (with an inline refine editor); dialog-capable modes (RPC) fall back to
117
- * a select plus the refine editor; a remote turn gets the plan file and a digest
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
- let sentFullText = false;
127
- for (;;) {
128
- const outcome = await askHuman<ReviewChoice>(ctx, {
129
- custom: async () => {
130
- const choice = await ctx.ui.custom<PlanReviewChoice | undefined>(
131
- (tui, theme, _keybindings, done) =>
132
- new PlanViewComponent({
133
- plan,
134
- baseline,
135
- theme,
136
- onClose: (action, refinement) => done({ action, refinement }),
137
- requestRender: () => tui.requestRender(),
138
- viewportRows: () => tui.terminal?.rows,
139
- tui,
140
- }),
141
- FULL_SCREEN_OVERLAY,
142
- );
143
- return choice ?? { action: "keep" };
144
- },
145
- dialogs: async () => {
146
- const choice = await ctx.ui.select("Plan mode — what next?", [
147
- "Approve and execute",
148
- "Send the full plan",
149
- "Refine the plan",
150
- "Keep planning (stay in plan mode)",
151
- "Exit plan mode (discard)",
152
- ]);
153
- if (choice === "Approve and execute") return { action: "approve" };
154
- if (choice === "Send the full plan") return { action: "send" };
155
- if (choice === "Refine the plan") return { action: "refine" };
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, { canApprove: options.canApprove, baseline: options.baseline, signal: options.signal })
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: true,
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
- * `.pi/plans`). Disabling restores exactly what it hid. The enabled flag is
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
+ }
@@ -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. When the plan has a `Steps`
14
- * section (the canonical outline's heading), only that section's list items are
15
- * steps, so prose bullets elsewhere never seed the todo list or consume the
16
- * step cap. List items inside fenced code blocks are ignored. A `- [ ]` / `- [x]`
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
- /** Item text after the marker, right-trimmed (an optional checkbox included). */
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
- * A heading or step label as the review surfaces compare it: inline emphasis and
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: plainText(text), status: done ? "completed" : "pending" };
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 (!match) return;
180
- const step = parseStep(match[3]);
181
- if (!step.content) return;
182
- items.push({ line: index, indent: match[1].length, lead: match[2], raw: match[3], step });
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 (plainText(heading[2]).toLowerCase() !== "steps") continue;
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) => plainText(line.replace(LIST_PREFIX, "")))
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 approved = await ctx.ui.confirm("Enter plan mode?", PLAN_CONSENT);
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." }],