pi-plans 0.2.0 → 0.3.1

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 (75) hide show
  1. package/README.md +90 -26
  2. package/agents/ref-analyst.md +18 -0
  3. package/index.ts +121 -9
  4. package/package.json +16 -1
  5. package/references/pi-planning-workflow.md +21 -6
  6. package/references/state-and-config.md +52 -5
  7. package/scripts/validate.ts +5 -0
  8. package/skills/plan-with-refs/SKILL.md +3 -3
  9. package/src/code-graph/commands.ts +483 -0
  10. package/src/code-graph/discovery.ts +118 -0
  11. package/src/code-graph/git.ts +108 -0
  12. package/src/code-graph/identity.ts +59 -0
  13. package/src/code-graph/indexer.ts +281 -0
  14. package/src/code-graph/materialize.ts +166 -0
  15. package/src/code-graph/mode.ts +28 -0
  16. package/src/code-graph/mutations.ts +160 -0
  17. package/src/code-graph/parser.ts +51 -0
  18. package/src/code-graph/parsers/javascript.ts +35 -0
  19. package/src/code-graph/parsers/python.ts +160 -0
  20. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  21. package/src/code-graph/paths.ts +85 -0
  22. package/src/code-graph/prompts.ts +18 -0
  23. package/src/code-graph/resolver.ts +69 -0
  24. package/src/code-graph/runtime.ts +158 -0
  25. package/src/code-graph/schema.ts +135 -0
  26. package/src/code-graph/screening.ts +82 -0
  27. package/src/code-graph/store.ts +278 -0
  28. package/src/code-graph/summary.ts +435 -0
  29. package/src/code-graph/types.ts +163 -0
  30. package/src/compaction.ts +1125 -371
  31. package/src/config-command.ts +361 -0
  32. package/src/exec.ts +508 -693
  33. package/src/guard.ts +14 -1
  34. package/src/refine-prompts.ts +109 -0
  35. package/src/refine-ui-helpers.ts +71 -18
  36. package/src/refine-ui-state.ts +88 -22
  37. package/src/refine-ui.ts +210 -102
  38. package/src/state.ts +36 -7
  39. package/src/subagent.ts +164 -61
  40. package/src/termination-prompt.ts +22 -0
  41. package/tests/analyze-refs.test.ts +265 -0
  42. package/tests/ask-choice.test.ts +264 -0
  43. package/tests/autocomplete.test.ts +6 -1
  44. package/tests/code-graph-apply-action.test.ts +173 -0
  45. package/tests/code-graph-apply.test.ts +185 -0
  46. package/tests/code-graph-commands.test.ts +211 -0
  47. package/tests/code-graph-db.test.ts +166 -0
  48. package/tests/code-graph-discovery.test.ts +38 -0
  49. package/tests/code-graph-git.test.ts +94 -0
  50. package/tests/code-graph-index.test.ts +175 -0
  51. package/tests/code-graph-loop.e2e.test.ts +159 -0
  52. package/tests/code-graph-mutations.test.ts +117 -0
  53. package/tests/code-graph-parser.test.ts +85 -0
  54. package/tests/code-graph-rollback.test.ts +100 -0
  55. package/tests/code-graph-summary-batching.test.ts +518 -0
  56. package/tests/code-graph-summary.test.ts +148 -0
  57. package/tests/compaction.test.ts +371 -57
  58. package/tests/config-command.test.ts +263 -0
  59. package/tests/exec.test.ts +808 -241
  60. package/tests/fixtures/code-graph/sample.js +36 -0
  61. package/tests/fixtures/code-graph/sample.py +20 -0
  62. package/tests/fixtures/code-graph/sample.ts +15 -0
  63. package/tests/graph-aware-file-tools.test.ts +411 -0
  64. package/tests/guard.test.ts +27 -1
  65. package/tests/plans.test.ts +10 -0
  66. package/tests/refine-prompts.test.ts +101 -2
  67. package/tests/refine-ui.test.ts +371 -72
  68. package/tests/state.test.ts +32 -0
  69. package/tests/subagent.test.ts +48 -20
  70. package/tools/analyze-refs.ts +263 -0
  71. package/tools/ask-choice.ts +159 -11
  72. package/tools/code-graph.ts +277 -0
  73. package/tools/graph-aware-file-tools.ts +392 -0
  74. package/tools/plans.ts +97 -2
  75. package/tools/refine.ts +61 -15
@@ -0,0 +1,263 @@
1
+ /**
2
+ * `analyze_refs` tool — plan-with-refs per-reference analysis via read-only Pi
3
+ * subagents with isolated context. One lane per reference (cwd = the ref's own
4
+ * directory), reusing the reviewer role gates from `.git/pi_plans/config.json`
5
+ * and the concurrent refinement overlay (title "Refs"). Batches are capped at
6
+ * three concurrent lanes; larger ref sets run as sequential batches.
7
+ *
8
+ * Recording is best-effort: spawns land in `subagents.jsonl` (role
9
+ * `ref-analyst`) only when an active planning run exists. Analysis output is
10
+ * returned to the main agent, which owns REF_ANALYSIS.md and refs.jsonl.
11
+ */
12
+
13
+ import { StringEnum } from "@earendil-works/pi-ai";
14
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { truncateHead } from "@earendil-works/pi-coding-agent";
16
+ import { Text } from "@earendil-works/pi-tui";
17
+ import { Type } from "typebox";
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { loadConfig, normalizeWorkdir, readActive, recordSubagent, resolveStateRootOrNull, StateError } from "../src/state.ts";
21
+ import { buildRefAnalystTask, type RefAnalystTaskInput } from "../src/refine-prompts.ts";
22
+ import { runPiSubagent, stripFrontmatter } from "../src/subagent.ts";
23
+ import { RefineOverlayController, refineOverlayContext } from "../src/refine-ui.ts";
24
+
25
+ const BATCH_SIZE = 3;
26
+ const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"];
27
+
28
+ const AnalyzeRefsParams = Type.Object({
29
+ refs: Type.Array(
30
+ Type.Object({
31
+ id: Type.String({ description: "Stable ref id, e.g. ref-1; used in lane names and result sections" }),
32
+ localPath: Type.String({ description: "Local directory of the downloaded reference (absolute or relative to workdir)" }),
33
+ title: Type.Optional(Type.String({ description: "Reference title" })),
34
+ url: Type.Optional(Type.String({ description: "Source URL" })),
35
+ kind: Type.Optional(Type.String({ description: "Reference kind, e.g. project | article | paper | docs" })),
36
+ }),
37
+ { description: "Downloaded references to analyze; each gets its own independent read-only subagent" },
38
+ ),
39
+ context: Type.Optional(
40
+ Type.String({ description: "Target repo context for adoptability judgments: user goals, repo evidence, constraints" }),
41
+ ),
42
+ workdir: Type.Optional(Type.String({ description: "Target workspace; default current working directory" })),
43
+ });
44
+
45
+ function gateError(problem: "state" | "mode" | "current-session" | "confirm"): StateError {
46
+ if (problem === "state") {
47
+ return new StateError("no pi-plans state found; run the plans tool (action: init) first");
48
+ }
49
+ if (problem === "mode") {
50
+ return new StateError(
51
+ "The reviewer role mode is missing or invalid in .git/pi_plans/config.json (analyze_refs reuses the reviewer gates). Ask the role-setting question with ask_choice first: 1. Delegated subagent (recommended; read-only pi subprocess with isolated context) 2. Current session 3. Other 4. Auto-complete — then persist with the plans tool (set-role, role=reviewer).",
52
+ );
53
+ }
54
+ if (problem === "current-session") {
55
+ return new StateError(
56
+ "The reviewer role mode is current-session, but analyze_refs only spawns delegated read-only subagents (one per reference). Ask the user to switch the reviewer mode to delegated-subagent via ask_choice, persist with the plans tool (set-role, role=reviewer, mode=delegated-subagent), then retry analyze_refs.",
57
+ );
58
+ }
59
+ return new StateError(
60
+ "The reviewer model was never confirmed (confirmed_at is null); analyze_refs reuses the reviewer confirmation. Ask the model-confirmation question with ask_choice: 1. Inherit the main agent's model (recommended) 2. Choose a model (list options from the /model picker; persist the exact provider/model selector) 3. Other 4. Auto-complete — then persist with the plans tool (set-role, role=reviewer, confirmed: true, modelSelector: the selector or 'inherit').",
61
+ );
62
+ }
63
+
64
+ interface AnalysisJob {
65
+ input: RefAnalystTaskInput;
66
+ name: string;
67
+ laneId: string;
68
+ dir: string;
69
+ missing: string | null;
70
+ }
71
+
72
+ export function registerAnalyzeRefsTool(pi: ExtensionAPI, baseDir: string): void {
73
+ const agentPrompt = stripFrontmatter(fs.readFileSync(path.join(baseDir, "agents", "ref-analyst.md"), "utf8"));
74
+
75
+ pi.registerTool({
76
+ name: "analyze_refs",
77
+ label: "Analyze Refs",
78
+ description:
79
+ "plan-with-refs: analyze downloaded references via independent read-only Pi subagents — one lane per reference (cwd = the ref directory), reusing the reviewer role gates and the concurrent overlay. Batches of at most 3 lanes run sequentially; results are structured per-reference sections for REF_ANALYSIS.md. Recording into subagents.jsonl is best-effort (active run only); refs.jsonl stays owned by the main agent via the plans record-ref action. Refuses until the reviewer mode/model gates pass in .git/pi_plans/config.json.",
80
+ promptSnippet: "Analyze plan-with-refs references with per-ref read-only subagents",
81
+ parameters: AnalyzeRefsParams,
82
+
83
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
84
+ const workdir = normalizeWorkdir(params.workdir ?? ctx.cwd);
85
+
86
+ // Read config read-only; state must already exist (same precondition as refine).
87
+ const root = resolveStateRootOrNull(workdir);
88
+ if (root === null || !fs.existsSync(path.join(root, "config.json"))) {
89
+ throw gateError("state");
90
+ }
91
+ const config = loadConfig(root);
92
+ const reviewer = config.reviewer;
93
+ if (!reviewer || (reviewer.mode !== "delegated-subagent" && reviewer.mode !== "current-session")) {
94
+ throw gateError("mode");
95
+ }
96
+ if (reviewer.mode === "current-session") {
97
+ throw gateError("current-session");
98
+ }
99
+ if (reviewer.confirmed_at === null) {
100
+ throw gateError("confirm");
101
+ }
102
+
103
+ // Resolve refs and validate directories up front; missing ones become
104
+ // FAILED sections instead of aborting the whole batch.
105
+ const active = readActive(workdir);
106
+ const jobs: AnalysisJob[] = params.refs.map((ref, index) => {
107
+ const dir = path.resolve(workdir, ref.localPath.replace(/^@/, ""));
108
+ const missing = fs.existsSync(dir) && fs.statSync(dir).isDirectory() ? null : `reference directory not found: ${dir}`;
109
+ return {
110
+ input: { refId: ref.id, localPath: dir, title: ref.title, url: ref.url, kind: ref.kind, context: params.context },
111
+ name: `pi-plans-refs-${active?.run_id ?? "adhoc"}-ref-${index + 1}`,
112
+ laneId: `ref-${index + 1}`,
113
+ dir,
114
+ missing,
115
+ };
116
+ });
117
+ if (jobs.length === 0) {
118
+ throw new StateError("analyze_refs requires at least one reference");
119
+ }
120
+
121
+ const model = reviewer.model_selector ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
122
+ const modelLabel = model ?? "inherit";
123
+ let languageTag: string | null = null;
124
+ if (active) {
125
+ try {
126
+ const run = JSON.parse(fs.readFileSync(path.join(root, "runs", active.run_id, "run.json"), "utf8")) as { language_tag?: string | null };
127
+ languageTag = run.language_tag ?? null;
128
+ } catch {
129
+ languageTag = null; // best-effort: corrupt/missing run.json must not fail the call
130
+ }
131
+ }
132
+
133
+ const record = (name: string, okModel?: string | null) => {
134
+ if (!active) return;
135
+ try {
136
+ recordSubagent(workdir, active.run_id, { role: "ref-analyst", name, model: okModel ?? model ?? null });
137
+ } catch {
138
+ /* best-effort: audit survives in the tool result */
139
+ }
140
+ };
141
+
142
+ const runJob = async (job: AnalysisJob, overlay: RefineOverlayController | undefined, relay: AbortController) => {
143
+ if (job.missing) {
144
+ return { ok: false as const, output: "", model: undefined, errorMessage: job.missing, stderr: "", turns: 0 };
145
+ }
146
+ try {
147
+ const result = await runPiSubagent({
148
+ systemPrompt: agentPrompt,
149
+ task: buildRefAnalystTask({ ...job.input, languageTag }),
150
+ cwd: job.dir,
151
+ model,
152
+ tools: READ_ONLY_TOOLS,
153
+ signal: relay.signal,
154
+ onProgress: (event) => overlay?.update(job.laneId, event),
155
+ });
156
+ overlay?.complete(job.laneId, result);
157
+ record(job.name, result.ok ? result.model ?? model : null);
158
+ return result;
159
+ } catch (error) {
160
+ record(job.name, null);
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ const result = { ok: false as const, output: "", model: model ?? undefined, errorMessage: message, stderr: "", turns: 0 };
163
+ overlay?.complete(job.laneId, result);
164
+ return result;
165
+ }
166
+ };
167
+
168
+ const sections: string[] = [];
169
+ const outputs: Array<{ name: string; lane: string; refId: string; ok: boolean; output: string; errorMessage?: string; turns: number }> = [];
170
+ let failures = 0;
171
+
172
+ // Sequential batches of at most BATCH_SIZE lanes; each batch gets its
173
+ // own overlay with the same lifecycle as a single refine round.
174
+ for (let start = 0; start < jobs.length; start += BATCH_SIZE) {
175
+ const batch = jobs.slice(start, start + BATCH_SIZE);
176
+ const controller = new AbortController();
177
+ const relayAbort = () => controller.abort();
178
+ if (signal?.aborted) controller.abort();
179
+ else signal?.addEventListener("abort", relayAbort, { once: true });
180
+
181
+ const overlay =
182
+ ctx.mode === "tui"
183
+ ? new RefineOverlayController("refs", batch.map((job) => ({ id: job.laneId, label: job.laneId })), relayAbort)
184
+ : undefined;
185
+ overlay?.open(refineOverlayContext(ctx), modelLabel);
186
+ try {
187
+ const results = await Promise.all(batch.map((job) => runJob(job, overlay, controller)));
188
+ for (let i = 0; i < batch.length; i += 1) {
189
+ const job = batch[i]!;
190
+ const result = results[i]!;
191
+ const title = `${job.name} — ${job.input.title ?? job.input.refId}`;
192
+ if (!result.ok) {
193
+ failures += 1;
194
+ sections.push(`### ${title} — FAILED\n${result.errorMessage ?? "unknown error"}`);
195
+ } else {
196
+ sections.push(`### ${title}\n${result.output}`);
197
+ }
198
+ outputs.push({
199
+ name: job.name,
200
+ lane: job.laneId,
201
+ refId: job.input.refId,
202
+ ok: result.ok,
203
+ output: result.output,
204
+ errorMessage: result.errorMessage,
205
+ turns: result.turns,
206
+ });
207
+ }
208
+ } finally {
209
+ await overlay?.close();
210
+ signal?.removeEventListener("abort", relayAbort);
211
+ }
212
+ }
213
+
214
+ if (failures === jobs.length) {
215
+ throw new Error(
216
+ `all reference analysis subagents failed (${failures}/${jobs.length})${model ? `\nIf the model selector "${model}" is unavailable, reset the reviewer confirmation (plans set-role, role=reviewer, resetConfirmation: true) and re-ask the model-confirmation question.` : ""}`,
217
+ );
218
+ }
219
+
220
+ const combined = sections.join("\n\n---\n\n");
221
+ const truncation = truncateHead(combined, { maxLines: 2000, maxBytes: 50 * 1024 });
222
+ let text = truncation.content;
223
+ if (truncation.truncated) text += `\n\n[Output truncated; full outputs remain in this tool result's details.]`;
224
+
225
+ return {
226
+ content: [
227
+ {
228
+ type: "text",
229
+ text: `${text}\n\n---\nPersist: paste each reference's analysis into REF_ANALYSIS.md, call the plans tool (record-ref) per reference with coverage and gaps filled from the analysis, then ask at least three ref-specific adoption questions per reference with ask_choice before using its ideas in PLAN_v1.md.`,
230
+ },
231
+ ],
232
+ details: {
233
+ mode: "delegated-subagent",
234
+ role: "ref-analyst",
235
+ reviewerGates: { mode: reviewer.mode, model },
236
+ batches: Math.ceil(jobs.length / BATCH_SIZE),
237
+ model,
238
+ outputs,
239
+ },
240
+ };
241
+ },
242
+
243
+ renderCall(args, theme) {
244
+ const count = args.refs?.length ?? 0;
245
+ let text = theme.fg("toolTitle", theme.bold("analyze_refs ")) + theme.fg("accent", `${count} ref${count === 1 ? "" : "s"}`);
246
+ const first = args.refs?.[0]?.localPath;
247
+ if (first) text += theme.fg("dim", ` ${first.split("/").pop() ?? first}${count > 1 ? ` +${count - 1}` : ""}`);
248
+ return new Text(text, 0, 0);
249
+ },
250
+
251
+ renderResult(result, { expanded }, theme) {
252
+ const text = result.content[0];
253
+ const raw = text?.type === "text" ? text.text : "";
254
+ if (!expanded) {
255
+ const firstLine = raw.split("\n").find((line) => line.trim()) ?? "(no output)";
256
+ return new Text(theme.fg("success", "✓ ") + theme.fg("muted", firstLine.slice(0, 120)), 0, 0);
257
+ }
258
+ return new Text(raw, 0, 0);
259
+ },
260
+ });
261
+ }
262
+
263
+ export type { ExtensionContext };
@@ -15,8 +15,119 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
15
  import { Text } from "@earendil-works/pi-tui";
16
16
  import { Type } from "typebox";
17
17
  import { disableAutoComplete, enableAutoComplete, isAutoCompleteEnabled, recordAskChoice } from "../src/autocomplete.ts";
18
+ import { TERMINATION_QUESTION, TERMINATION_OPTIONS, renderTerminationOptions } from "../src/termination-prompt.ts";
19
+ import { truncateToWidth, visibleWidth } from "../src/refine-ui-helpers.ts";
18
20
  import { normalizeWorkdir, readActive, recordDecision } from "../src/state.ts";
19
21
 
22
+ // ---------------------------------------------------------------------------
23
+ // Panel fitting: pi's ExtensionSelectorComponent renders each option as an
24
+ // auto-wrapping Text with NO height cap — an oversized panel exceeds the
25
+ // terminal rows and the TUI thrashes (flicker). These helpers sanitize and
26
+ // shrink the question/labels before they reach ctx.ui.select.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export const STATUS_BAR_HEIGHT = 1;
30
+ export const PANEL_SAFETY_MARGIN = 2;
31
+ export const PANEL_CHROME_LINES = 9; // 8 measured in extension-selector.js (DynamicBorder×2 + Spacer×4 + title + keyHint) + 1 slack
32
+ /** Each option renders in at most three wrapped lines (user-facing contract). */
33
+ export const OPTION_MAX_LINES = 3;
34
+ /**
35
+ * Per-row width overhead, pinned to extension-selector.js: DynamicBorder 1 +
36
+ * Text padding 1 + selected marker "→ " 2 = 4, plus 2 columns of slack for
37
+ * word-wrap inefficiency. Re-verify against that file if pi changes its layout.
38
+ */
39
+ export const SELECTOR_WIDTH_OVERHEAD = 6;
40
+ export const FALLBACK_COLUMNS = 100;
41
+ export const FALLBACK_ROWS = 30;
42
+ /** Minimal-form floor for tiny terminals (stage-3 width). */
43
+ const MINIMAL_LINE_WIDTH = 20;
44
+ /**
45
+ * Truncation floor for fixed tail labels (Other…/Auto-complete/Auto-refine
46
+ * loop): the longest magic prefix ("Auto-refine loop", 16 cols) plus slack.
47
+ * These labels drive startsWith() answer routing and must never lose it.
48
+ */
49
+ const FIXED_LABEL_FLOOR = 18;
50
+
51
+ export interface PanelItem {
52
+ /** Label without description (degradation stage 1+). */
53
+ core: string;
54
+ /** Full display label: core + description (degradation stage 0). */
55
+ display: string;
56
+ /** Fixed tail labels (Other…/Auto-complete/Auto-refine loop): truncation keeps at least the magic prefix. */
57
+ fixed?: boolean;
58
+ }
59
+
60
+ export interface FittedPanel {
61
+ question: string;
62
+ labels: string[];
63
+ /** True when even the minimal form exceeds the terminal budget. */
64
+ overflowWarned: boolean;
65
+ }
66
+
67
+ function sanitizeLine(text: string): string {
68
+ return text.replace(/\r\n|\n|\r/g, " ");
69
+ }
70
+
71
+ function truncateWithDotDot(text: string, budget: number): string {
72
+ if (visibleWidth(text) <= budget) return text;
73
+ return `${truncateToWidth(text, Math.max(0, budget - 2), "")}..`;
74
+ }
75
+
76
+ function truncateForItem(item: PanelItem, budget: number): string {
77
+ const effective = item.fixed ? Math.max(budget, FIXED_LABEL_FLOOR) : budget;
78
+ return truncateWithDotDot(item.display, effective);
79
+ }
80
+
81
+ function wrappedLineCount(text: string, lineWidth: number): number {
82
+ return Math.max(1, Math.ceil(visibleWidth(text) / lineWidth));
83
+ }
84
+
85
+ /**
86
+ * Sanitize and shrink the question/labels so the projected panel height stays
87
+ * under rows − statusBar − margin. Degradation order (D-001): per-label 3-line
88
+ * budget → strip descriptions → labels to one line → truncate the question →
89
+ * minimal 20-column form (overflowWarned; never fails closed).
90
+ */
91
+ export function fitAskChoicePanel(question: string, items: PanelItem[], columns: number, rows: number): FittedPanel {
92
+ const cols = columns > 0 ? columns : FALLBACK_COLUMNS;
93
+ const termRows = rows > 0 ? rows : FALLBACK_ROWS;
94
+ const lineBudget = Math.max(20, cols - SELECTOR_WIDTH_OVERHEAD);
95
+ const rowBudget = Math.max(10, termRows - STATUS_BAR_HEIGHT - PANEL_SAFETY_MARGIN);
96
+
97
+ const cleanQuestion = sanitizeLine(question);
98
+ const clean = items.map((item) => ({ core: sanitizeLine(item.core), display: sanitizeLine(item.display), fixed: item.fixed === true }));
99
+
100
+ const projected = (q: string, ls: string[]) =>
101
+ PANEL_CHROME_LINES + wrappedLineCount(q, lineBudget) + ls.reduce((sum, l) => sum + wrappedLineCount(l, lineBudget), 0);
102
+
103
+ // Stage 0: full display labels, each within the 3-line budget.
104
+ // (Signature note: items carry {core, display} because D-001 stage 1 strips
105
+ // descriptions, which a plain string list cannot express.)
106
+ let currentQuestion = cleanQuestion;
107
+ let currentLabels = clean.map((item) => truncateForItem(item, OPTION_MAX_LINES * lineBudget));
108
+
109
+ if (projected(currentQuestion, currentLabels) >= rowBudget) {
110
+ // Stage 1: strip descriptions (core labels only).
111
+ currentLabels = clean.map((item) => truncateForItem({ ...item, display: item.core }, OPTION_MAX_LINES * lineBudget));
112
+ }
113
+ if (projected(currentQuestion, currentLabels) >= rowBudget) {
114
+ // Stage 2: labels to a single line.
115
+ currentLabels = clean.map((item) => truncateForItem({ ...item, display: item.core }, lineBudget));
116
+ }
117
+ if (projected(currentQuestion, currentLabels) >= rowBudget) {
118
+ // Stage 3: truncate the question too.
119
+ currentQuestion = truncateWithDotDot(currentQuestion, lineBudget);
120
+ }
121
+ let overflowWarned = false;
122
+ if (projected(currentQuestion, currentLabels) >= rowBudget) {
123
+ // Minimal form for tiny terminals: 20-column floor; still over → warn, never fail closed.
124
+ currentQuestion = truncateWithDotDot(cleanQuestion, MINIMAL_LINE_WIDTH);
125
+ currentLabels = clean.map((item) => truncateForItem({ ...item, display: item.core }, MINIMAL_LINE_WIDTH));
126
+ overflowWarned = projected(currentQuestion, currentLabels) >= rowBudget;
127
+ }
128
+ return { question: currentQuestion, labels: currentLabels, overflowWarned };
129
+ }
130
+
20
131
  const Option = Type.Object({
21
132
  label: Type.String({ description: "Option label" }),
22
133
  description: Type.Optional(Type.String({ description: "Short tradeoff that matters, shown to the user" })),
@@ -33,6 +144,12 @@ const AskChoiceParams = Type.Object({
33
144
  "Offer the Auto-complete option (default true). MUST be false for the execution handoff, install waivers, publishing, deployment, merge, push, credential use, or any external-state change.",
34
145
  }),
35
146
  ),
147
+ trailing: Type.Optional(
148
+ StringEnum(["auto-refine-loop"] as const, {
149
+ description:
150
+ 'Replace the trailing Auto-complete option with "Auto-refine loop" (post-execution amelioration prompt). Selecting it returns instructions to ask the rounds/termination follow-up; Auto-complete is suppressed entirely for this question.',
151
+ }),
152
+ ),
36
153
  workdir: Type.Optional(Type.String({ description: "Target workspace; default current working directory" })),
37
154
  });
38
155
 
@@ -48,7 +165,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
48
165
  name: "ask_choice",
49
166
  label: "Ask Choice",
50
167
  description:
51
- "Ask the user one planning or refinement question as a numbered choice prompt: recommended option first, alternatives next, then Other and Auto-complete. One question per call. Use for every user-facing planning question, the final scope confirmation, refinement-mode questions, language/role/model settings, and the execution handoff (with autoComplete: false).",
168
+ "Ask the user one planning or refinement question as a numbered choice prompt: recommended option first, alternatives next, then Other and Auto-complete. One question per call. Use for every user-facing planning question, the final scope confirmation, refinement-mode questions, language/role/model settings, and the execution handoff (with autoComplete: false). The optional trailing parameter swaps the trailing option to Auto-refine loop for the post-execution amelioration prompt.",
52
169
  promptSnippet: "Ask structured planning questions with recommended/Other/Auto-complete ordering",
53
170
  promptGuidelines: [
54
171
  "Use ask_choice for every pi-plans question to the user instead of plain-text questions; it enforces option ordering and records decisions.",
@@ -59,7 +176,10 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
59
176
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
60
177
  const workdir = normalizeWorkdir(params.workdir ?? ctx.cwd);
61
178
  const allowOther = params.allowOther ?? true;
62
- const autoComplete = params.autoComplete ?? true;
179
+ // Param normalization: a trailing option replaces Auto-complete entirely,
180
+ // so an erroneously passed autoComplete flag is suppressed here.
181
+ const trailing = params.trailing;
182
+ const autoComplete = (params.autoComplete ?? true) && trailing === undefined;
63
183
  const options = params.options;
64
184
  if (options.length === 0) throw new Error("ask_choice requires at least one option");
65
185
  const recommended = options.find((option) => option.recommended) ?? options[0];
@@ -120,16 +240,30 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
120
240
  };
121
241
  }
122
242
 
123
- const displayLabels: string[] = options.map((option, index) => {
124
- let label = `${index + 1}. ${option.label}`;
125
- if (option === recommended) label += " (recommended)";
126
- if (option.description) label += ` — ${option.description}`;
127
- return label;
243
+ const AUTO_REFINE_LOOP_LABEL =
244
+ "Auto-refine loop (run refinement rounds until no high-severity finding or the 5-round cap)";
245
+ const panelItems: PanelItem[] = options.map((option, index) => {
246
+ const core = `${index + 1}. ${option.label}${option === recommended ? " (recommended)" : ""}`;
247
+ let display = `${index + 1}. ${option.label}`;
248
+ if (option === recommended) display += " (recommended)";
249
+ if (option.description) display += ` — ${option.description}`;
250
+ return { core, display };
128
251
  });
129
- if (allowOther) displayLabels.push("Other… (type your own answer)");
130
- if (autoComplete) displayLabels.push("Auto-complete (take the recommended option)");
252
+ if (allowOther) panelItems.push({ core: "Other… (type your own answer)", display: "Other… (type your own answer)", fixed: true });
253
+ if (autoComplete) panelItems.push({ core: "Auto-complete (take the recommended option)", display: "Auto-complete (take the recommended option)", fixed: true });
254
+ else if (trailing) panelItems.push({ core: AUTO_REFINE_LOOP_LABEL, display: AUTO_REFINE_LOOP_LABEL, fixed: true });
255
+
256
+ const panel = fitAskChoicePanel(
257
+ params.question,
258
+ panelItems,
259
+ process.stdout.columns ?? 0,
260
+ process.stdout.rows ?? 0,
261
+ );
262
+ if (panel.overflowWarned) {
263
+ ctx.ui.notify?.("Terminal too small: the ask_choice panel may overflow even in its minimal form.", "warning");
264
+ }
131
265
 
132
- const selected = await ctx.ui.select(params.question, displayLabels);
266
+ const selected = await ctx.ui.select(panel.question, panel.labels);
133
267
  if (selected === undefined) {
134
268
  disableAutoComplete(ctx, "question cancelled");
135
269
  return {
@@ -158,6 +292,20 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
158
292
  };
159
293
  }
160
294
 
295
+ if (trailing && selected.startsWith("Auto-refine loop")) {
296
+ recordAskChoice(ctx, false);
297
+ record("Auto-refine loop", "user");
298
+ return {
299
+ content: [
300
+ {
301
+ type: "text",
302
+ text: `User selected Auto-refine loop. Immediately ask the follow-up with ask_choice (autoComplete: false, in the session language): "${TERMINATION_QUESTION}" Options (recommended first): ${renderTerminationOptions()}. Then run the loop per the completion instructions: each round calls refine (role: "reviewer", target: "implementation"), accepts findings on evidence, applies fixes, re-runs relevant tests, and continues until the chosen termination condition — the goal-wait option keeps the loop running until no unpassed VCs remain.`,
303
+ },
304
+ ],
305
+ details: details("Auto-refine loop", "user"),
306
+ };
307
+ }
308
+
161
309
  if (allowOther && selected.startsWith("Other…")) {
162
310
  const typed = await ctx.ui.input(`${params.question} — your answer:`);
163
311
  if (typed === undefined || !typed.trim()) {
@@ -176,7 +324,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
176
324
  };
177
325
  }
178
326
 
179
- const index = displayLabels.indexOf(selected);
327
+ const index = panel.labels.indexOf(selected);
180
328
  const option = index >= 0 && index < options.length ? options[index] : undefined;
181
329
  if (!option) {
182
330
  recordAskChoice(ctx, false);