@gavin-hu/my-pi-agent 0.16.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 (200) hide show
  1. package/README.md +29 -15
  2. package/README.zh-CN.md +27 -15
  3. package/extensions/bytefly/README.md +195 -115
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/notify/types.ts +1 -1
  17. package/extensions/bytefly/panel/commands.ts +46 -25
  18. package/extensions/bytefly/panel/format.ts +49 -14
  19. package/extensions/bytefly/panel/join.ts +35 -9
  20. package/extensions/bytefly/panel/settings.ts +29 -39
  21. package/extensions/bytefly/panel/skills.ts +51 -0
  22. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  23. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  24. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  25. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  26. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  27. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  28. package/extensions/bytefly/panel/tui.ts +223 -331
  29. package/extensions/bytefly/panel/types.ts +26 -8
  30. package/extensions/bytefly/picker/discovery.ts +32 -27
  31. package/extensions/bytefly/picker/resources.ts +16 -8
  32. package/extensions/bytefly/picker/runtime.ts +62 -34
  33. package/extensions/bytefly/picker/toggle.ts +16 -10
  34. package/extensions/bytefly/picker/types.ts +31 -13
  35. package/extensions/bytefly/update/format.ts +66 -8
  36. package/extensions/bytefly/update/patch.ts +66 -0
  37. package/extensions/bytefly/update/register.ts +4 -11
  38. package/extensions/bytefly/update/release.ts +65 -0
  39. package/extensions/bytefly/update/state.ts +14 -0
  40. package/extensions/bytefly/update/suppress.ts +47 -29
  41. package/extensions/bytefly/update/types.ts +15 -5
  42. package/extensions/check/tui.ts +2 -0
  43. package/extensions/context/README.md +21 -11
  44. package/extensions/context/format.ts +10 -8
  45. package/extensions/context/report.ts +21 -8
  46. package/extensions/context/tui.ts +26 -10
  47. package/extensions/context/types.ts +5 -3
  48. package/extensions/file-browse/README.md +89 -22
  49. package/extensions/file-browse/chrome.ts +83 -0
  50. package/extensions/file-browse/client.ts +74 -459
  51. package/extensions/file-browse/config.ts +12 -3
  52. package/extensions/file-browse/diff-html.ts +94 -0
  53. package/extensions/file-browse/html.ts +26 -152
  54. package/extensions/file-browse/live-client.ts +47 -1
  55. package/extensions/file-browse/live.ts +35 -14
  56. package/extensions/file-browse/palette.ts +224 -0
  57. package/extensions/file-browse/rev.ts +42 -0
  58. package/extensions/file-browse/router.ts +27 -6
  59. package/extensions/file-browse/server.ts +7 -0
  60. package/extensions/file-browse/styles.ts +10 -3
  61. package/extensions/file-browse/tree.ts +381 -0
  62. package/extensions/file-read/README.md +4 -1
  63. package/extensions/file-read/extract/odt.ts +7 -5
  64. package/extensions/goal/plan-route.ts +8 -2
  65. package/extensions/goal/tools.ts +6 -11
  66. package/extensions/job/README.md +7 -5
  67. package/extensions/job/index.ts +35 -52
  68. package/extensions/job/tools.ts +51 -77
  69. package/extensions/memory/README.md +3 -3
  70. package/extensions/memory/tools.ts +4 -6
  71. package/extensions/memory/tui.ts +5 -1
  72. package/extensions/plan/README.md +333 -84
  73. package/extensions/plan/commands.ts +73 -7
  74. package/extensions/plan/feedback.ts +300 -63
  75. package/extensions/plan/index.ts +1 -1
  76. package/extensions/plan/plan-index.ts +4 -2
  77. package/extensions/plan/plan-text.ts +18 -1
  78. package/extensions/plan/plans.ts +184 -58
  79. package/extensions/plan/review-prompt.ts +93 -0
  80. package/extensions/plan/review.ts +95 -95
  81. package/extensions/plan/runtime.ts +4 -2
  82. package/extensions/plan/split.ts +87 -0
  83. package/extensions/plan/steps.ts +123 -107
  84. package/extensions/plan/tools/cancel.ts +79 -0
  85. package/extensions/plan/tools/enter.ts +17 -12
  86. package/extensions/plan/tools/exit.ts +148 -59
  87. package/extensions/plan/tools/steps.ts +7 -19
  88. package/extensions/plan/tools/write.ts +29 -31
  89. package/extensions/plan/tools.ts +6 -3
  90. package/extensions/plan/tui.ts +60 -15
  91. package/extensions/plan/types.ts +45 -3
  92. package/extensions/plan/web-chrome.ts +102 -0
  93. package/extensions/plan/web-client.ts +177 -446
  94. package/extensions/plan/web-collab.ts +207 -0
  95. package/extensions/plan/web-comments.ts +269 -0
  96. package/extensions/plan/web-markdown.ts +222 -0
  97. package/extensions/plan/web-page.ts +131 -19
  98. package/extensions/plan/web-router.ts +148 -32
  99. package/extensions/plan/web-share-session.ts +197 -0
  100. package/extensions/plan/web-share.ts +169 -0
  101. package/extensions/plan/web-streams.ts +124 -0
  102. package/extensions/plan/web-tokens.ts +63 -0
  103. package/extensions/plan/web.ts +193 -45
  104. package/extensions/question/README.md +3 -0
  105. package/extensions/question/dialogs.ts +91 -1
  106. package/extensions/question/tools.ts +2 -1
  107. package/extensions/rewind/checklist.ts +4 -0
  108. package/extensions/rewind/review-tui.ts +3 -1
  109. package/extensions/rewind/rewind-tui.ts +4 -0
  110. package/extensions/session-bus/README.md +247 -0
  111. package/extensions/session-bus/commands.ts +165 -0
  112. package/extensions/session-bus/format.ts +124 -0
  113. package/extensions/session-bus/index.ts +69 -0
  114. package/extensions/session-bus/notice.ts +58 -0
  115. package/extensions/session-bus/protocol.ts +134 -0
  116. package/extensions/session-bus/result.ts +243 -0
  117. package/extensions/session-bus/rows.ts +169 -0
  118. package/extensions/session-bus/runtime.ts +575 -0
  119. package/extensions/session-bus/store.ts +329 -0
  120. package/extensions/session-bus/team.ts +102 -0
  121. package/extensions/session-bus/tools.ts +227 -0
  122. package/extensions/session-bus/tui.ts +394 -0
  123. package/extensions/session-bus/types.ts +98 -0
  124. package/extensions/subagent/README.md +26 -13
  125. package/extensions/subagent/format.ts +72 -25
  126. package/extensions/subagent/index.ts +21 -47
  127. package/extensions/subagent/orchestrate.ts +18 -41
  128. package/extensions/subagent/render.ts +29 -42
  129. package/extensions/subagent/stream.ts +24 -18
  130. package/extensions/subagent/types.ts +7 -0
  131. package/extensions/todo/tools.ts +5 -6
  132. package/extensions/todo/tui.ts +5 -1
  133. package/extensions/watch/README.md +9 -5
  134. package/extensions/watch/index.ts +25 -44
  135. package/extensions/watch/tools.ts +31 -59
  136. package/extensions/wechat/README.md +45 -36
  137. package/extensions/wechat/bridge.ts +46 -48
  138. package/extensions/wechat/client.ts +1 -4
  139. package/extensions/wechat/commands.ts +3 -3
  140. package/extensions/wechat/deliver.ts +57 -37
  141. package/extensions/wechat/interaction.ts +54 -18
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +9 -7
  144. package/extensions/wechat/peer-worker.ts +19 -34
  145. package/extensions/wechat/presence.ts +3 -4
  146. package/extensions/wechat/prompt.ts +53 -23
  147. package/extensions/wechat/reply-pipeline.ts +12 -4
  148. package/extensions/wechat/slash.ts +14 -0
  149. package/extensions/wechat/status-screen.ts +2 -2
  150. package/extensions/wechat/tools.ts +3 -11
  151. package/extensions/worktree/README.md +48 -12
  152. package/extensions/worktree/commands.ts +109 -65
  153. package/extensions/worktree/enter.ts +1 -1
  154. package/extensions/worktree/exit.ts +11 -1
  155. package/extensions/worktree/git.ts +5 -0
  156. package/extensions/worktree/lifecycle.ts +9 -1
  157. package/extensions/worktree/registry.ts +11 -5
  158. package/extensions/worktree/screen.ts +263 -0
  159. package/extensions/worktree/status.ts +84 -40
  160. package/lib/README.md +12 -7
  161. package/lib/claims/overlap.ts +43 -0
  162. package/lib/config.ts +12 -0
  163. package/lib/format.ts +6 -2
  164. package/lib/http/router.ts +40 -13
  165. package/lib/http/server.ts +27 -13
  166. package/lib/interaction.ts +28 -1
  167. package/lib/notice.ts +29 -2
  168. package/lib/plan-entry.ts +7 -2
  169. package/lib/plans/sections.ts +172 -0
  170. package/lib/plans/setting.ts +52 -0
  171. package/lib/plans/split.ts +215 -0
  172. package/lib/plans/store.ts +19 -0
  173. package/lib/plans/team.ts +151 -0
  174. package/lib/qr.ts +64 -0
  175. package/lib/repo-key.ts +74 -0
  176. package/lib/report.ts +117 -25
  177. package/lib/requirements.ts +1 -2
  178. package/lib/tool-result.ts +54 -4
  179. package/lib/tui/README.md +30 -8
  180. package/lib/tui/chrome.ts +27 -6
  181. package/lib/tui/dock-screen.ts +91 -19
  182. package/lib/tui/gauge.ts +9 -5
  183. package/lib/tui/line-pane.ts +15 -4
  184. package/lib/tui/tool.ts +23 -4
  185. package/lib/usage.ts +26 -0
  186. package/lib/web/README.md +131 -0
  187. package/lib/web/base.ts +19 -2
  188. package/lib/web/components.ts +10 -6
  189. package/lib/web/layout.ts +2 -0
  190. package/lib/web/markup.ts +2 -1
  191. package/lib/web/runtime.ts +103 -25
  192. package/lib/web/tokens.ts +23 -7
  193. package/package.json +5 -2
  194. package/skills/changelog/SKILL.md +57 -45
  195. package/skills/init/SKILL.md +80 -45
  196. package/skills/init/references/agents-md-quality.md +26 -2
  197. package/skills/init/references/interview-questions.md +36 -14
  198. package/skills/next/SKILL.md +209 -0
  199. package/skills/parallel-dev/SKILL.md +150 -0
  200. 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[] }
@@ -44,7 +42,9 @@ export type ReviewDecision =
44
42
  /** True when the request came from the browser, which is reported differently. */
45
43
  fromBrowser: boolean;
46
44
  }
47
- | { action: "keep"; plan: StoredPlan };
45
+ | { action: "keep"; plan: StoredPlan }
46
+ /** Leave plan mode without approving; the plan file stays for a later review. */
47
+ | { action: "cancel"; plan: StoredPlan };
48
48
 
49
49
  /** What the previous review showed, so this one can mark what changed. */
50
50
  export interface ReviewBaselines {
@@ -64,95 +64,68 @@ export interface ReviewPlanOptions {
64
64
  plan: StoredPlan;
65
65
  /** Aborting the turn closes a browser review and returns the plan unapproved. */
66
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";
67
76
  }
68
77
 
69
78
  /** Prompt shown by the dialog-based refine editor in non-TUI modes. */
70
79
  const REFINE_PROMPT = "Refine the plan — what should change? (Enter to save, Esc to cancel)";
71
80
 
72
- /** How much of one step the remote digest shows. */
73
- const DIGEST_STEP_CHARS = 90;
74
-
75
- /** The refinement request used when a browser save carried no comments. */
76
- const NO_BROWSER_COMMENTS = "See the review comments in the plan file.";
77
-
78
81
  /**
79
- * A one-message digest of the plan for a remote review.
80
- *
81
- * The file is the primary artifact, but a phone client may never render or open
82
- * it, and the peer still has to choose approve / refine. The digest carries the
83
- * title, the step count, and the first few steps; the full text follows as a
84
- * file or as the fallback message.
82
+ * How long a remote review waits for its answer. Much longer than a plain dialog's
83
+ * bound, because the human has to read the plan first and a phone is a slow place
84
+ * to do it; an expiry here also ends plan mode, so it must not be easy to hit.
85
85
  */
86
- function planReviewDigest(content: string): string {
87
- const title = /^#\s+(.+)$/m.exec(content)?.[1]?.trim();
88
- const steps = extractPlanSteps(content);
89
- const count = steps.length;
90
- const head = `Plan${title ? `: ${title}` : ""}${count > 0 ? ` (${count} step${count === 1 ? "" : "s"})` : ""}`;
91
- const preview = steps.slice(0, 3).map((step, index) => {
92
- const text = step.content.replace(/\s+/g, " ").trim();
93
- const shown = text.length > DIGEST_STEP_CHARS ? `${text.slice(0, DIGEST_STEP_CHARS - 1)}…` : text;
94
- return `${index + 1}. ${shown}`;
95
- });
96
- const more = count > preview.length ? `… (${count - preview.length} more)` : "";
97
- return [head, ...preview, more, "The full plan is attached above, or sent as text if the file failed."]
98
- .filter((line) => line !== "")
99
- .join("\n");
100
- }
86
+ export const PLAN_REVIEW_TIMEOUT_MS = 600_000;
101
87
 
102
88
  /**
103
- * Ask the user to review the plan file. The TUI opens the scrollable review
104
- * screen (with an inline refine editor); dialog-capable modes (RPC) fall back to
105
- * a select plus the refine editor; a remote turn gets the plan file and a digest
106
- * first. Asking for the full plan re-enters with the text only, then shows the
107
- * 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.
108
92
  */
109
93
  async function askPlanReview(
110
94
  ctx: ExtensionToolContext,
111
95
  plan: StoredPlan,
112
96
  baseline: SectionDigest[] | undefined,
97
+ split: ReviewSplit | undefined,
113
98
  ): Promise<PlanReviewChoice> {
114
- let sentFullText = false;
115
- for (;;) {
116
- const outcome = await askHuman<ReviewChoice>(ctx, {
117
- custom: async () => {
118
- const choice = await ctx.ui.custom<PlanReviewChoice | undefined>(
119
- (tui, theme, _keybindings, done) =>
120
- new PlanViewComponent({
121
- plan,
122
- baseline,
123
- theme,
124
- onClose: (action, refinement) => done({ action, refinement }),
125
- requestRender: () => tui.requestRender(),
126
- viewportRows: () => tui.terminal?.rows,
127
- tui,
128
- }),
129
- FULL_SCREEN_OVERLAY,
130
- );
131
- return choice ?? { action: "keep" };
132
- },
133
- dialogs: async () => {
134
- const choice = await ctx.ui.select("Plan mode — what next?", [
135
- "Approve and execute",
136
- "Send the full plan",
137
- "Refine the plan",
138
- "Keep planning",
139
- ]);
140
- if (choice === "Approve and execute") return { action: "approve" };
141
- if (choice === "Send the full plan") return { action: "send" };
142
- if (choice === "Refine the plan") return { action: "refine" };
143
- return { action: "keep" };
144
- },
145
- // A remote turn gets the plan file up front, falling back to the full text;
146
- // a local TUI turn ignores both and renders the component. The digest goes
147
- // out either way so the choices stay answerable when a phone client does not
148
- // render the file bubble.
149
- file: sentFullText ? undefined : { path: plan.path, name: basename(plan.relativePath) },
150
- body: plan.content,
151
- note: sentFullText ? undefined : planReviewDigest(plan.content),
152
- });
153
- if (outcome.action !== "send") return outcome;
154
- sentFullText = true;
155
- }
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
+ });
156
129
  }
157
130
 
158
131
  /** How a browser review may decide, and what to report when it opens. */
@@ -165,6 +138,13 @@ export interface BrowserReviewOptions {
165
138
  signal?: AbortSignal;
166
139
  /** Called once the server is listening and the browser was opened. */
167
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;
168
148
  }
169
149
 
170
150
  /**
@@ -179,7 +159,13 @@ export async function runBrowserReview(
179
159
  ): Promise<PlanReviewResult | undefined> {
180
160
  if (options.signal?.aborted) return { status: "cancelled" };
181
161
  const session = await web
182
- .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
+ })
183
169
  .catch(() => undefined);
184
170
  if (!session) return undefined;
185
171
  options.onOpened?.(session);
@@ -196,7 +182,7 @@ export async function runBrowserReview(
196
182
  * cancelled returns to the screen it was opened from.
197
183
  */
198
184
  export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDecision> {
199
- const { ctx, web, plans, baselines, signal } = options;
185
+ const { ctx, web, plans, baselines, signal, split } = options;
200
186
  let plan = options.plan;
201
187
  for (;;) {
202
188
  // An aborted turn has nobody left to answer the screen.
@@ -204,11 +190,12 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
204
190
  // Read the baseline before the screen and mark it after, so what the next
205
191
  // review compares against is the content this one actually showed.
206
192
  const baseline = baselines.reviewed(plan.path);
207
- const choice = await askPlanReview(ctx, plan, baseline);
193
+ const choice = await askPlanReview(ctx, plan, baseline, split);
208
194
  baselines.markReviewed(plan.path, plan.content);
209
195
 
210
196
  if (choice.action === "approve") return { action: "approve", plan, comments: [] };
211
197
  if (choice.action === "keep") return { action: "keep", plan };
198
+ if (choice.action === "cancel") return { action: "cancel", plan };
212
199
  if (choice.action === "refine") {
213
200
  const refinement = choice.refinement ?? (await ctx.ui.editor(REFINE_PROMPT, ""))?.trim();
214
201
  // An empty or cancelled editor returns to the review screen instead of
@@ -219,11 +206,22 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
219
206
  // An unrecognized action keeps planning rather than opening a server.
220
207
  if (choice.action !== "browser") return { action: "keep", plan };
221
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");
222
217
  const result = await runBrowserReview(web, ctx.cwd, plan, {
223
- canApprove: true,
218
+ canApprove: split === undefined,
219
+ ...(splitNote === undefined ? {} : { note: splitNote }),
224
220
  baseline,
225
221
  signal,
226
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"),
227
225
  });
228
226
  if (!result) {
229
227
  ctx.ui.notify("Plan review: could not start the browser review server.", "error");
@@ -231,13 +229,15 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
231
229
  }
232
230
  if (result.status === "approved") return { action: "approve", plan: result.plan, comments: result.annotations };
233
231
  if (result.status === "saved") {
234
- return {
235
- action: "refine",
236
- plan: result.plan,
237
- refinement: feedbackSummary(result.annotations) || NO_BROWSER_COMMENTS,
238
- comments: result.annotations,
239
- fromBrowser: true,
240
- };
232
+ // A save with no comments asks for nothing, so it returns to the screen the
233
+ // way an empty refine editor does, instead of reporting a refinement nobody
234
+ // wrote.
235
+ const refinement = feedbackSummary(result.annotations);
236
+ if (!refinement) {
237
+ plan = result.plan;
238
+ continue;
239
+ }
240
+ return { action: "refine", plan: result.plan, refinement, comments: result.annotations, fromBrowser: true };
241
241
  }
242
242
  // Cancelled or the tab closed: the file may have been rewritten underneath,
243
243
  // so re-read it before showing the screen again.
@@ -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
@@ -32,6 +32,8 @@ import type { PlanModeEntry, ReviewBaselineEntry } 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";
35
+ /** Tool the model calls to leave plan mode without approving. */
36
+ export const CANCEL_TOOL = "cancel_plan_mode";
35
37
  /** Tool the model calls to ask to enter plan mode. */
36
38
  export const ENTER_TOOL = PLAN_ENTER_TOOL;
37
39
  /** Tool the model calls to save the plan artifact. */
@@ -47,7 +49,7 @@ export const BASELINE_TYPE = "plan-baseline";
47
49
  const STATUS_NAME_LENGTH = 28;
48
50
 
49
51
  /** Tools plan mode activates itself; the guard permits only this extension's own. */
50
- const CONTROL_TOOLS = [EXIT_TOOL, WRITE_PLAN_TOOL];
52
+ const CONTROL_TOOLS = [EXIT_TOOL, CANCEL_TOOL, WRITE_PLAN_TOOL];
51
53
 
52
54
  export interface PlanRuntime {
53
55
  /** Whether plan mode is currently on. */
@@ -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
+ }