pi-do-always 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,7 @@ type to filter, scroll or click, or navigate with arrows + Enter → the task's
20
20
  |`/do-always review changes`|Fill the prompt for the task named `review changes` (task names autocomplete after `/do-always`)|
21
21
  |`/do-always list`|Print the task list|
22
22
  |`/do-always list-details`|Show the full rendered prompt text each task will inject|
23
+ |`/do-always replan`|Re-open the plan questionnaire for the last offered proposal (e.g. after an accidental Esc)|
23
24
 
24
25
  The selector supports direct number-pick (1-9), live type-to-filter, arrow/Enter navigation,
25
26
  mouse-wheel scrolling, and click-to-select. While a filter is active, typed digits refine the
@@ -136,6 +137,68 @@ substituted in; any other prompt gets the block appended under
136
137
  the latest commit is selected and the first eligible `Plan` task (not
137
138
  `notForCommits`) whose guards pass runs on it.
138
139
 
140
+ ## Plan questionnaire
141
+
142
+ Auto-run `Plan` tasks (⚡) are asked to end their reply with a
143
+ machine-readable `plan` block: a one-line summary plus the proposed action
144
+ items grouped into priority tiers (`P0`, `P1`, …). When the run settles, the
145
+ reply is parsed and — if a plan block is present — a **questionnaire** opens
146
+ in the TUI. The block is the data channel, but by default it is hidden: as
147
+ soon as the reply is finalized, the extension strips the block from the
148
+ transcript in the TUI (the prose around it is the human-facing summary), so
149
+ the conversation stays clean — the questionnaire still works, because the
150
+ raw text is captured before the strip. In non-TUI modes the block stays in
151
+ the transcript, so the model can resolve the item-number replies the
152
+ notification offers. Set `hidePlan` to `false` (per task or
153
+ globally) to keep the block visible. With the questionnaire disabled the
154
+ block is not requested at all:
155
+
156
+ ```
157
+ Plan proposal — Review changes
158
+ 2 critical bugs, 3 cleanups
159
+
160
+ [·] P0 — Critical (0/2)
161
+ · Fix null deref in parse()
162
+ · Validate input length
163
+ [◐] P1 — Important (1/3)
164
+ ✓ Remove unused imports
165
+ · Drop dead config flag
166
+ · Tighten error message
167
+
168
+ ─────────────────────────────
169
+ Confirm (1/5)
170
+ space/⏎ toggle • a all • ctrl+u clear • e note • ⏎ confirm • esc withdraw
171
+ ```
172
+
173
+ - **↑/↓** (wrapping), **Home/End** move the cursor; **Space** or **Enter**
174
+ toggles the row under the cursor — a tier row toggles the whole tier
175
+ (`·` none → `◐` partial → `✓` all), an item row toggles just that item.
176
+ - **a** (or **Ctrl+A**) selects everything; **Ctrl+U** clears the selection.
177
+ - **e** on an item row opens a note editor for that item (Enter saves, Esc
178
+ cancels). The note is shown on the row (`✎ …`) and appended to that item
179
+ in the execution prompt — a way to steer an item without retyping it.
180
+ - Long plans scroll: the list shows 12 rows at a time and the window follows
181
+ the cursor (**↑/↓**, **Home/End**, or the mouse wheel); a `(n/N)` marker
182
+ shows the position. The `Confirm` row stays pinned.
183
+ - **Enter** (or a mouse click) on the pinned `Confirm (n/N)` row sends the
184
+ selection as a single follow-up turn: the agent executes exactly the
185
+ selected items, in tier order, and nothing else. **Esc** withdraws —
186
+ nothing is sent and the proposal is kept in memory; re-open it with
187
+ `/do-always replan` (an accidental Esc is cheap to undo).
188
+ - Mouse: clicking a tier/item row toggles it; clicking the Confirm row
189
+ confirms.
190
+
191
+ Nothing is preselected. A Plan run that finds nothing to do replies with an
192
+ empty tier list, and you just get the usual one-line summary. If the reply
193
+ has no parseable `plan` block, the questionnaire is disabled, or the mode is
194
+ not the TUI, the extension falls back to the plain summary notification —
195
+ and when a questionnaire was expected, the notification says why (no plan
196
+ block in the reply, or the block is not valid JSON), so a fallback is never
197
+ a silent mystery. In non-TUI modes a parseable proposal is listed as a
198
+ notification instead, so you can reply with the item numbers to execute. The
199
+ questionnaire is offered only on the single auto-run path (selector pick,
200
+ `/do-always <n>`, commit picker) — chain steps never get it.
201
+
139
202
  ## Install
140
203
 
141
204
  Install it from npm as a Pi package, which loads the bundled `index.ts` (and its `tasks.ts`) without
@@ -190,6 +253,8 @@ Fields:
190
253
  - `browser` (optional) — a browser to open on selection instead of injecting the prompt. Only `"commits"` is supported: it opens the date-grouped commit browser, and after the selection the task runs on the selected commits — directly when its prompt references `{{selected_commits}}`, otherwise via a picker of `Plan` tasks (see [Commit browser](#commit-browser)). An invalid value is ignored with a warning.
191
254
  - `hidden` (optional) — when `true`, the task is not shown in the selector or in `/do-always list` / `list-details`. Unlike a `when` condition, a hidden task can still be run by name (`/do-always <name>`), and it is offered as a candidate by the commit picker. The built-in `Review commits` uses this: it is a pick-after-browse option, not a standalone entry.
192
255
  - `notForCommits` (optional) — when `true`, the task is excluded from the commit picker (the “run on the selected commits” list) because it does not operate on a set of commits. The task is otherwise unaffected (selector, lists, CLI). The built-in `Review changes`, `Review code`, and `Propose features` use this.
256
+ - `questionnaire` (optional) — whether the plan questionnaire is offered after this task's completed run (see [Plan questionnaire](#plan-questionnaire)). Default `true`; set `false` to keep the plain summary notification.
257
+ - `hidePlan` (optional) — whether the raw `plan` block is hidden from the transcript after this task's completed run: the fenced block is stripped from the finalized reply (TUI only — in non-TUI modes the block is always kept so the model can resolve item-number replies). Default `true`; set `false` to keep the block visible in the conversation.
193
258
  - `when` (optional) — a condition that hides the task from the selector and lists when it is not met (see [Conditionals](#conditionals)).
194
259
  - `guards` (optional) — an array of selection-time guards that block the task (with a message, not a hide) when a condition is unmet (see [Guards](#guards)). The legacy `requireDirty` (boolean) still works and is combined with any `guards`.
195
260
 
@@ -198,6 +263,8 @@ In the object form you can also configure the selector shortcut:
198
263
  - `shortcut` (optional) — key that opens the selector, e.g. `"f4"`. Set to `null` to disable the shortcut. Defaults to `F4`. The project file's value wins over the global one.
199
264
  - `merge` (optional) — how project tasks combine with the global tasks: `"override"` (default) replaces a global task with the same `name`; `"append"` keeps the global tasks and only adds new project task names (a cascade, like CSS). The project file's value wins over the global one; when neither sets it, the default is `override` (the historical behavior).
200
265
  - `report` (optional) — whether chain runs write a Markdown report file in the project root (one per run, appended as each step finishes). Default `true`; set `false` to disable. The project file's value wins over the global one. See [Chains](#chains).
266
+ - `questionnaire` (optional) — whether completed auto-run tasks whose reply carries a plan block offer the selection questionnaire. Default `true`; set `false` to keep the plain summary notification. The project file's value wins over the global one. See [Plan questionnaire](#plan-questionnaire).
267
+ - `hidePlan` (optional) — whether the raw `plan` block is stripped from the transcript after a completed auto-run task (TUI only — in non-TUI modes the block is always kept). Default `true`; set `false` to keep the block visible in the conversation. The project file's value wins over the global one.
201
268
 
202
269
  Example project file that only *adds* tasks without overriding the global set:
203
270
 
@@ -36,7 +36,7 @@
36
36
  import { execFile } from "node:child_process";
37
37
  import { appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
38
38
  import { join, relative } from "node:path";
39
- import type { AgentEndEvent, ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
39
+ import type { AgentEndEvent, ExtensionAPI, ExtensionContext, MessageEndEvent, Theme } from "@earendil-works/pi-coding-agent";
40
40
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
41
41
  import {
42
42
  type KeyId,
@@ -55,6 +55,7 @@ import {
55
55
  COMMIT_SELECT_MAX,
56
56
  DEFAULT_SHORTCUT,
57
57
  DEFAULT_TASKS,
58
+ PLAN_OUTPUT_INSTRUCTION,
58
59
  assistantText,
59
60
  buildTableRows,
60
61
  chainAdd,
@@ -67,9 +68,11 @@ import {
67
68
  evaluateWhen,
68
69
  formatChainSequence,
69
70
  formatList,
71
+ formatPlanExecutionPrompt,
70
72
  formatSelectedCommits,
71
73
  groupCommitsByDate,
72
74
  groupTasksByCategory,
75
+ isPlanTask,
73
76
  isTaskVisible,
74
77
  isValidKeyId,
75
78
  mergeTasks,
@@ -77,9 +80,18 @@ import {
77
80
  parseConfigRegexpValueForKey,
78
81
  parseCommitSubject,
79
82
  parseGitLogOutput,
83
+ parsePlanProposal,
80
84
  parseStatusPorcelain,
81
85
  parseStatusStagedUnstaged,
82
86
  orderTasksByCategory,
87
+ planBlockDiagnostics,
88
+ planItemKey,
89
+ planSelectAll,
90
+ planSelectionClear,
91
+ planSelectedItems,
92
+ planTierState,
93
+ planToggleItem,
94
+ planToggleTier,
83
95
  reportAbandonedFooter,
84
96
  reportFooter,
85
97
  reportHeader,
@@ -91,12 +103,16 @@ import {
91
103
  resolveTask,
92
104
  shouldAutoRun,
93
105
  stepSummary,
106
+ stripPlanBlocks,
94
107
  toPromptContext,
95
108
  validateChain,
96
109
  type ChainStepOutcome,
97
110
  type CommitInfo,
98
111
  type DateGroup,
99
112
  type DoAlwaysTask,
113
+ type PlanProposal,
114
+ type PlanSelection,
115
+ type PlanSelectionEntry,
100
116
  type SelectedCommit,
101
117
  type TaskContext,
102
118
  type TableRow,
@@ -149,6 +165,10 @@ function loadConfig(
149
165
  shortcut: string | null;
150
166
  /** Whether chain runs write a Markdown report file (default true). */
151
167
  report: boolean;
168
+ /** Whether plan questionnaires are offered (default true). */
169
+ questionnaire: boolean;
170
+ /** Whether the raw plan block is hidden from the transcript (default true). */
171
+ hidePlan: boolean;
152
172
  } {
153
173
  const globalPath = join(getAgentDir(), "do-always.json");
154
174
  const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
@@ -158,10 +178,10 @@ function loadConfig(
158
178
 
159
179
  const global = globalRaw !== null
160
180
  ? parseConfig(globalRaw, globalPath, onError)
161
- : { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
181
+ : { tasks: [], shortcut: undefined, merge: undefined, report: undefined, questionnaire: undefined, hidePlan: undefined };
162
182
  const project = projectRaw !== null
163
183
  ? parseConfig(projectRaw, projectPath, onError)
164
- : { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
184
+ : { tasks: [], shortcut: undefined, merge: undefined, report: undefined, questionnaire: undefined, hidePlan: undefined };
165
185
 
166
186
  // The project file's merge mode wins; otherwise the global value; otherwise
167
187
  // override (the historical behavior), so existing configs are unaffected.
@@ -175,6 +195,10 @@ function loadConfig(
175
195
  // The project file's value wins; otherwise the global value; otherwise
176
196
  // reports are on.
177
197
  report: project.report ?? global.report ?? true,
198
+ // Same precedence: project, then global, then on.
199
+ questionnaire: project.questionnaire ?? global.questionnaire ?? true,
200
+ // Same precedence: project, then global, then on (the block is hidden).
201
+ hidePlan: project.hidePlan ?? global.hidePlan ?? true,
178
202
  };
179
203
  }
180
204
 
@@ -950,10 +974,30 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
950
974
  // flag would post a spurious summary for the next unrelated turn.
951
975
  let pendingSummaryTask: string | null = null;
952
976
  let pendingSummaryTimer: NodeJS.Timeout | null = null;
977
+ // The completed auto-run task's reply, captured at agent_end and offered
978
+ // at agent_settled (the session is fully idle then, so the confirm
979
+ // follow-up cannot race queued continuations). Discarded on agent_start
980
+ // — a new run started, so the proposal is stale — and on session start.
981
+ let pendingProposal: { taskName: string; text: string } | null = null;
982
+ // The last offered plan proposal (parseable, questionnaire enabled), kept
983
+ // so `/do-always replan` can re-open the questionnaire after a
984
+ // withdrawal. Cleared on confirm (executed) and on session start.
985
+ let lastProposal: { taskName: string; text: string } | null = null;
986
+ // The raw (unstripped) reply text of the pending auto-run task, captured
987
+ // at message_end before its plan block is stripped from the transcript
988
+ // (by the time agent_end fires, the message there is already stripped).
989
+ // Cleared on agent_start (a new run makes it stale), agent_end (consumed
990
+ // or discarded), and session start.
991
+ let pendingPlanRaw: string | null = null;
953
992
 
954
993
  /** Clear the auto-run summary flag and its grace timer (session start). */
955
994
  function resetPendingSummary(): void {
956
995
  pendingSummaryTask = null;
996
+ // A completed auto-run's captured reply is only offered at the
997
+ // settle that follows its own agent_end — a new run invalidates it.
998
+ pendingProposal = null;
999
+ lastProposal = null;
1000
+ pendingPlanRaw = null;
957
1001
  if (pendingSummaryTimer) {
958
1002
  clearTimeout(pendingSummaryTimer);
959
1003
  pendingSummaryTimer = null;
@@ -962,6 +1006,410 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
962
1006
  // Whether chain runs write a Markdown report file (config `report`,
963
1007
  // default true). Refreshed whenever the config is (re)loaded.
964
1008
  let reportEnabled = true;
1009
+ // Whether completed auto-run tasks whose reply carries a "plan" block
1010
+ // offer the selection questionnaire (config `questionnaire`, default
1011
+ // true). Refreshed whenever the config is (re)loaded.
1012
+ let questionnaireEnabled = true;
1013
+ // Whether the raw "plan" block is stripped from the transcript after a
1014
+ // completed auto-run task (config `hidePlan`, default true). Refreshed
1015
+ // whenever the config is (re)loaded.
1016
+ let hidePlanEnabled = true;
1017
+
1018
+ /** The result of the plan questionnaire: the confirmed selection, or a withdrawal. */
1019
+ type PlanQuestionnaireResult =
1020
+ | { kind: "confirm"; items: PlanSelectionEntry[] }
1021
+ | { kind: "withdraw" };
1022
+
1023
+ /**
1024
+ * Render a task's prompt for injection or preview. Plan-category tasks get
1025
+ * PLAN_OUTPUT_INSTRUCTION appended (once) so the reply carries the
1026
+ * machine-readable "plan" block the questionnaire parses — but only when
1027
+ * the questionnaire is actually offered for this task, otherwise the agent
1028
+ * would emit a block nobody reads. A prompt that already mentions the plan
1029
+ * fence keeps its own contract; chain-step prompts are rendered with the
1030
+ * plain renderPrompt and never get it.
1031
+ */
1032
+ function renderTaskPrompt(task: DoAlwaysTask, ctx: Record<string, string>): string {
1033
+ const base = renderPrompt(task.prompt, ctx);
1034
+ const enabled = task.questionnaire ?? questionnaireEnabled;
1035
+ if (isPlanTask(task) && enabled && !base.includes("```plan")) {
1036
+ return `${base}\n\n${PLAN_OUTPUT_INSTRUCTION}`;
1037
+ }
1038
+ return base;
1039
+ }
1040
+
1041
+ /**
1042
+ * Offer a completed auto-run task's reply. When it carries a parseable
1043
+ * "plan" block and the questionnaire is enabled, the TUI shows the tier/
1044
+ * item questionnaire: confirming sends the selection as an execution
1045
+ * follow-up, Esc withdraws (nothing happens). Everything else — disabled,
1046
+ * non-TUI, or no plan block — falls back to the plain summary
1047
+ * notification.
1048
+ */
1049
+ async function offerPlanProposal(
1050
+ captured: { taskName: string; text: string },
1051
+ ctx: ExtensionContext | null,
1052
+ ): Promise<void> {
1053
+ const proposal = parsePlanProposal(captured.text);
1054
+ if (!ctx) return;
1055
+ const task = tasks.find((t) => t.name === captured.taskName);
1056
+ const enabled = task?.questionnaire ?? questionnaireEnabled;
1057
+ // The prompt asked for a plan block only for Plan tasks with the
1058
+ // questionnaire enabled (renderTaskPrompt's gate) — only then is a
1059
+ // missing or invalid block a contract violation worth explaining; for
1060
+ // a non-Plan auto-run task its absence is the expected outcome.
1061
+ const blockExpected = task !== undefined && isPlanTask(task) && enabled;
1062
+ if (!proposal || !enabled) {
1063
+ const base = `do-always: ${stepSummary("completed", captured.taskName, 0, 0)}`;
1064
+ if (!blockExpected) {
1065
+ // Questionnaire disabled, or a non-Plan auto-run task: the
1066
+ // prompt never asked for a plan block, so its absence is not a
1067
+ // contract violation — plain summary.
1068
+ ctx.ui.notify(base, "info");
1069
+ return;
1070
+ }
1071
+ // The prompt asked for a plan block but none was usable — say why,
1072
+ // so the fallback to a plain summary is not a mystery.
1073
+ const diag = planBlockDiagnostics(captured.text);
1074
+ if (diag.kind === "none") {
1075
+ ctx.ui.notify(`${base} — reply had no plan block, so no questionnaire was offered`, "warning");
1076
+ } else if (diag.kind === "malformed") {
1077
+ ctx.ui.notify(`${base} — plan block was not valid JSON (${diag.detail}); no questionnaire offered`, "warning");
1078
+ } else {
1079
+ // "empty": the agent proposed no action items — a legitimate
1080
+ // outcome, not a contract violation.
1081
+ ctx.ui.notify(`${base} — no action items proposed`, "info");
1082
+ }
1083
+ return;
1084
+ }
1085
+ // Remember the last offered proposal so /do-always replan can re-open
1086
+ // it after a withdrawal.
1087
+ lastProposal = captured;
1088
+ if (ctx.mode !== "tui") {
1089
+ // Non-TUI: list the proposed items so the user can reply with a
1090
+ // selection; nothing is sent automatically.
1091
+ const all = planSelectedItems(proposal, planSelectAll(proposal, planSelectionClear()));
1092
+ const list = all.map(({ tier, item }, i) => ` ${i + 1}. [${tier.id}] ${item.title}`).join("\n");
1093
+ ctx.ui.notify(
1094
+ `do-always: "${captured.taskName}" proposed ${all.length} action item(s):\n${list}\nReply with the item numbers to execute (or do nothing to withdraw).`,
1095
+ "info",
1096
+ );
1097
+ return;
1098
+ }
1099
+ const result = await showPlanQuestionnaire(ctx, proposal, captured.taskName);
1100
+ if (result.kind === "confirm") {
1101
+ lastProposal = null; // executed — nothing left to re-offer
1102
+ const prompt = formatPlanExecutionPrompt(result.items, captured.taskName);
1103
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
1104
+ ctx.ui.notify(
1105
+ `do-always: executing ${result.items.length} selected item(s) from the "${captured.taskName}" plan`,
1106
+ "info",
1107
+ );
1108
+ } else {
1109
+ ctx.ui.notify(`do-always: plan withdrawn — no action taken (re-open with /do-always replan)`, "info");
1110
+ }
1111
+ }
1112
+
1113
+ /**
1114
+ * Plan questionnaire: shown after a completed auto-run task whose reply
1115
+ * carries a parseable "plan" block. The summary line up top, then the tiers
1116
+ * with their action items. Selecting a tier row toggles the whole tier (all
1117
+ * its items); selecting an item row toggles just that item. The pinned
1118
+ * Confirm row sends the selection as an execution follow-up; Esc withdraws
1119
+ * (nothing happens).
1120
+ */
1121
+ function showPlanQuestionnaire(
1122
+ ctx: ExtensionContext,
1123
+ proposal: PlanProposal,
1124
+ taskName: string,
1125
+ ): Promise<PlanQuestionnaireResult> {
1126
+ return new Promise<PlanQuestionnaireResult>((resolve) => {
1127
+ ctx.ui.custom<PlanQuestionnaireResult>((tui, theme, _kb, done) => {
1128
+ const kb = getKeybindings();
1129
+ let settled = false;
1130
+ let selection: PlanSelection = planSelectionClear();
1131
+
1132
+ // Flat cursor rows: a tier header row, its item rows, and the
1133
+ // pinned Confirm row last.
1134
+ type Row =
1135
+ | { kind: "tier"; index: number }
1136
+ | { kind: "item"; tier: number; index: number }
1137
+ | { kind: "confirm" };
1138
+ const rows: Row[] = [];
1139
+ proposal.tiers.forEach((tier, ti) => {
1140
+ rows.push({ kind: "tier", index: ti });
1141
+ tier.items.forEach((_, ii) => rows.push({ kind: "item", tier: ti, index: ii }));
1142
+ });
1143
+ rows.push({ kind: "confirm" });
1144
+ // The scrollable part: everything but the pinned Confirm row.
1145
+ // (Cast — slice() does not narrow the row union; the last row
1146
+ // is always the confirm row pushed above.)
1147
+ type ListRow = Exclude<Row, { kind: "confirm" }>;
1148
+ const listRows = rows.slice(0, -1) as ListRow[];
1149
+ let cursor = 0;
1150
+ const MAX_VISIBLE = 12; // tier/item rows visible in the scroll window
1151
+ // Per-item notes (item key → note), added with `e` on an item
1152
+ // row and carried into the execution prompt on confirm.
1153
+ const notes = new Map<string, string>();
1154
+ let noteKey: string | null = null; // the item being annotated
1155
+ let noteDraft = "";
1156
+
1157
+ function finish(result: PlanQuestionnaireResult) {
1158
+ if (settled) return;
1159
+ settled = true;
1160
+ done(result);
1161
+ resolve(result);
1162
+ }
1163
+
1164
+ function tierCount(ti: number): string {
1165
+ const tier = proposal.tiers[ti];
1166
+ let n = 0;
1167
+ tier.items.forEach((_, ii) => {
1168
+ if (selection.has(planItemKey(ti, ii))) n++;
1169
+ });
1170
+ return `${n}/${tier.items.length}`;
1171
+ }
1172
+
1173
+ // Line map for mouse hit-testing (rebuilt on every render).
1174
+ let rowLine = new Map<number, Row>();
1175
+
1176
+ function render(width: number): string[] {
1177
+ rowLine = new Map();
1178
+ const lines: string[] = [];
1179
+ lines.push(
1180
+ theme.fg("accent", theme.bold(truncateToWidth(` Plan proposal — ${taskName}`, width - 2, ""))),
1181
+ );
1182
+ if (proposal.summary) {
1183
+ lines.push(theme.fg("muted", truncateToWidth(` ${proposal.summary}`, width - 2, "…")));
1184
+ }
1185
+ lines.push("");
1186
+ // Scroll window over the tier/item rows (the Confirm row is
1187
+ // pinned below it). The window follows the cursor, clamped at
1188
+ // both edges — the same pattern as the task selector.
1189
+ const anchor = cursor < listRows.length ? cursor : listRows.length - 1;
1190
+ const winStart =
1191
+ listRows.length > MAX_VISIBLE
1192
+ ? Math.max(0, Math.min(anchor + 1 - MAX_VISIBLE, listRows.length - MAX_VISIBLE))
1193
+ : 0;
1194
+ const winEnd = Math.min(winStart + MAX_VISIBLE, listRows.length);
1195
+ for (let i = winStart; i < winEnd; i++) {
1196
+ const row = listRows[i];
1197
+ if (row.kind === "tier") {
1198
+ const ti = row.index;
1199
+ const tier = proposal.tiers[ti];
1200
+ const state = planTierState(proposal, ti, selection);
1201
+ const glyph = state === "all" ? "✓" : state === "partial" ? "◐" : "·";
1202
+ const color = state === "all" ? "success" : state === "partial" ? "warning" : "dim";
1203
+ const headerText = ` [${glyph}] ${tier.id} — ${tier.label} (${tierCount(ti)})`;
1204
+ if (cursor === i) {
1205
+ lines.push(theme.bg("selectedBg", theme.bold(headerText)));
1206
+ } else {
1207
+ lines.push(` ${theme.fg(color, `[${glyph}]`)} ${tier.id} — ${tier.label} (${tierCount(ti)})`);
1208
+ }
1209
+ rowLine.set(lines.length - 1, row);
1210
+ } else {
1211
+ const ti = row.tier;
1212
+ const ii = row.index;
1213
+ const key = planItemKey(ti, ii);
1214
+ const item = proposal.tiers[ti].items[ii];
1215
+ const mark = selection.has(key) ? theme.fg("success", "✓") : theme.fg("dim", "·");
1216
+ const note = notes.get(key);
1217
+ const title = truncateToWidth(
1218
+ note ? `${item.title} ✎ ${note}` : item.title,
1219
+ Math.max(10, width - 8),
1220
+ "…",
1221
+ );
1222
+ const rowText = ` ${mark} ${title}`;
1223
+ if (cursor === i) {
1224
+ lines.push(theme.bg("selectedBg", theme.bold(rowText)));
1225
+ } else {
1226
+ lines.push(rowText);
1227
+ }
1228
+ rowLine.set(lines.length - 1, row);
1229
+ }
1230
+ }
1231
+ // Scroll position marker (only when the list overflows the window).
1232
+ if (listRows.length > MAX_VISIBLE) {
1233
+ lines.push(theme.fg("dim", ` (${anchor + 1}/${listRows.length})`));
1234
+ }
1235
+ // Pinned Confirm row (always visible, outside the scroll window).
1236
+ lines.push("");
1237
+ lines.push(theme.fg("dim", ` ${"─".repeat(Math.max(1, width - 4))}`));
1238
+ const totalItems = proposal.tiers.reduce((n, t) => n + t.items.length, 0);
1239
+ const confirmLabel = `Confirm (${selection.size}/${totalItems})`;
1240
+ if (cursor === rows.length - 1) {
1241
+ lines.push(theme.bg("selectedBg", theme.bold(`${theme.fg("accent", "►")} ${confirmLabel}`)));
1242
+ } else if (selection.size > 0) {
1243
+ lines.push(theme.fg("accent", ` ${confirmLabel}`));
1244
+ } else {
1245
+ lines.push(theme.fg("dim", ` ${confirmLabel}`));
1246
+ }
1247
+ rowLine.set(lines.length - 1, { kind: "confirm" });
1248
+ if (noteKey !== null) {
1249
+ // Note editor: replaces the key hint while active.
1250
+ lines.push(theme.fg("accent", truncateToWidth(` note> ${noteDraft}`, width - 2, "")));
1251
+ lines.push(theme.fg("dim", " enter save note • esc cancel"));
1252
+ } else {
1253
+ lines.push(
1254
+ theme.fg(
1255
+ "dim",
1256
+ truncateToWidth(
1257
+ ` space/⏎ toggle • a all • ctrl+u clear • e note • ⏎ confirm • esc withdraw`,
1258
+ width - 2,
1259
+ "",
1260
+ ),
1261
+ ),
1262
+ );
1263
+ }
1264
+ return lines;
1265
+ }
1266
+
1267
+ function toggleAt(row: Row) {
1268
+ if (row.kind === "tier") {
1269
+ selection = planToggleTier(proposal, row.index, selection).selection;
1270
+ } else if (row.kind === "item") {
1271
+ selection = planToggleItem(selection, planItemKey(row.tier, row.index));
1272
+ }
1273
+ }
1274
+
1275
+ function confirmIfPossible(): boolean {
1276
+ const items = planSelectedItems(proposal, selection, notes);
1277
+ if (items.length === 0) {
1278
+ ctx.ui.notify("do-always: nothing selected — pick a tier or item first (or esc to withdraw)", "info");
1279
+ return false;
1280
+ }
1281
+ finish({ kind: "confirm", items });
1282
+ return true;
1283
+ }
1284
+
1285
+ function handleInput(data: string) {
1286
+ if (settled) return;
1287
+ // Note mode: capture the note for the item under the cursor.
1288
+ // Enter saves (an empty note clears it), Esc cancels (the
1289
+ // previous note, if any, is kept).
1290
+ if (noteKey !== null) {
1291
+ if (matchesKey(data, "enter") || kb.matches(data, "tui.select.confirm")) {
1292
+ const trimmed = noteDraft.trim();
1293
+ if (trimmed) notes.set(noteKey, trimmed);
1294
+ else notes.delete(noteKey);
1295
+ noteKey = null;
1296
+ noteDraft = "";
1297
+ tui.requestRender();
1298
+ return;
1299
+ }
1300
+ if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
1301
+ noteKey = null;
1302
+ noteDraft = "";
1303
+ tui.requestRender();
1304
+ return;
1305
+ }
1306
+ if (kb.matches(data, "tui.editor.deleteCharBackward")) {
1307
+ noteDraft = noteDraft.slice(0, -1);
1308
+ tui.requestRender();
1309
+ return;
1310
+ }
1311
+ if (isPrintable(data)) {
1312
+ if (noteDraft.length < 200) noteDraft += data;
1313
+ tui.requestRender();
1314
+ return;
1315
+ }
1316
+ return; // swallow other keys while editing
1317
+ }
1318
+ const row = rows[cursor];
1319
+ // Withdraw (Esc / Ctrl+C).
1320
+ if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
1321
+ finish({ kind: "withdraw" });
1322
+ return;
1323
+ }
1324
+ // Space or Enter: toggle at the cursor (tier or item), or
1325
+ // confirm on the Confirm row.
1326
+ if (matchesKey(data, "space") || kb.matches(data, "tui.select.confirm") || matchesKey(data, "enter")) {
1327
+ if (row.kind === "confirm") {
1328
+ confirmIfPossible();
1329
+ } else {
1330
+ toggleAt(row);
1331
+ tui.requestRender();
1332
+ }
1333
+ return;
1334
+ }
1335
+ // a / Ctrl+A: select all.
1336
+ if (data === "a" || matchesKey(data, "ctrl+a")) {
1337
+ selection = planSelectAll(proposal, selection);
1338
+ tui.requestRender();
1339
+ return;
1340
+ }
1341
+ // e: edit the note for the item under the cursor.
1342
+ if (data === "e") {
1343
+ if (row.kind === "item") {
1344
+ const key = planItemKey(row.tier, row.index);
1345
+ noteKey = key;
1346
+ noteDraft = notes.get(key) ?? "";
1347
+ tui.requestRender();
1348
+ } else {
1349
+ ctx.ui.notify("do-always: notes attach to item rows — put the cursor on an item, then press e", "info");
1350
+ }
1351
+ return;
1352
+ }
1353
+ // Ctrl+U: clear the selection.
1354
+ if (matchesKey(data, "ctrl+u")) {
1355
+ selection = planSelectionClear();
1356
+ tui.requestRender();
1357
+ return;
1358
+ }
1359
+ // Navigation (wraps at the edges).
1360
+ if (kb.matches(data, "tui.select.up") || matchesKey(data, "up")) {
1361
+ cursor = cursor === 0 ? rows.length - 1 : cursor - 1;
1362
+ tui.requestRender();
1363
+ return;
1364
+ }
1365
+ if (kb.matches(data, "tui.select.down") || matchesKey(data, "down")) {
1366
+ cursor = cursor === rows.length - 1 ? 0 : cursor + 1;
1367
+ tui.requestRender();
1368
+ return;
1369
+ }
1370
+ if (matchesKey(data, "home")) {
1371
+ cursor = 0;
1372
+ tui.requestRender();
1373
+ return;
1374
+ }
1375
+ if (matchesKey(data, "end")) {
1376
+ cursor = rows.length - 1;
1377
+ tui.requestRender();
1378
+ return;
1379
+ }
1380
+ }
1381
+
1382
+ function handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
1383
+ // Wheel: move the cursor one row (the scroll window follows) —
1384
+ // the same behavior as the task selector.
1385
+ if (event.type === "wheel" && event.wheelDelta) {
1386
+ if (noteKey !== null) return { handled: true };
1387
+ const delta = event.wheelDelta < 0 ? -1 : 1;
1388
+ const next = Math.max(0, Math.min(rows.length - 1, cursor + delta));
1389
+ if (next === cursor) return { handled: true };
1390
+ cursor = next;
1391
+ return { handled: true, render: true };
1392
+ }
1393
+ if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
1394
+ if (noteKey !== null) return { handled: true }; // clicks are swallowed while editing
1395
+ const row = rowLine.get(event.y);
1396
+ if (!row) return undefined;
1397
+ if (row.kind === "confirm") {
1398
+ if (event.type === "press") confirmIfPossible();
1399
+ return { handled: true };
1400
+ }
1401
+ if (event.type === "press") {
1402
+ toggleAt(row);
1403
+ return { handled: true, render: true };
1404
+ }
1405
+ return { handled: true };
1406
+ }
1407
+
1408
+ return { render, handleInput, handleMouse, invalidate: () => {} };
1409
+ });
1410
+ });
1411
+ }
1412
+
965
1413
  // The in-flight chain's report file: its path (absolute + relative for
966
1414
  // display), the precomputed header (deferred — written together with the
967
1415
  // first step section, so a chain that dies before that leaves no
@@ -1232,6 +1680,10 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1232
1680
  clearTimeout(pendingSummaryTimer);
1233
1681
  pendingSummaryTimer = null;
1234
1682
  }
1683
+ // A new run began before the captured plan proposal was offered (the
1684
+ // user typed a prompt right after the Plan run ended) — it is stale.
1685
+ pendingProposal = null;
1686
+ pendingPlanRaw = null;
1235
1687
  // The run actually began — time the step for the report.
1236
1688
  if (chainReport) chainReport.stepStartedAt = new Date();
1237
1689
  // Fill-first: step 1 left the editor and is running — update the
@@ -1261,6 +1713,69 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1261
1713
  return "completed";
1262
1714
  }
1263
1715
 
1716
+ /**
1717
+ * The message with every fenced plan block removed from its text parts,
1718
+ * or null when nothing changed. Strips per part: a fence spanning two
1719
+ * parts is left in place (the questionnaire parser works on the joined
1720
+ * text, so its behavior is unaffected by that edge case).
1721
+ */
1722
+ function stripPlanFromMessage(message: MessageEndEvent["message"]): MessageEndEvent["message"] | null {
1723
+ if (message.role !== "assistant") return null;
1724
+ // Assistant content is a parts array (text / thinking / toolCall);
1725
+ // only text parts can carry the plan fence.
1726
+ const content = message.content;
1727
+ if (!Array.isArray(content)) return null;
1728
+ let removed = false;
1729
+ const parts = content.map((part) => {
1730
+ if (part.type === "text" && typeof part.text === "string") {
1731
+ const { text, removed: partRemoved } = stripPlanBlocks(part.text);
1732
+ if (partRemoved) {
1733
+ removed = true;
1734
+ return { ...part, text };
1735
+ }
1736
+ }
1737
+ return part;
1738
+ });
1739
+ return removed ? { ...message, content: parts } : null;
1740
+ }
1741
+
1742
+ /**
1743
+ * Hide the plan block: while a single auto-run Plan task is in flight
1744
+ * (its prompt carried PLAN_OUTPUT_INSTRUCTION), capture the reply's raw
1745
+ * text for the questionnaire, then — in the TUI — replace the finalized
1746
+ * message with the plan block(s) stripped out. The runtime applies the
1747
+ * replacement in place, so the stripped text is what the model sees in
1748
+ * later turns and what the session file persists. In non-TUI modes the
1749
+ * block stays in the transcript: it is the model's only record of the
1750
+ * proposal, and the user may reply with item numbers to execute. Scoped
1751
+ * to the pending auto-run Plan task (renderTaskPrompt's gate), so a
1752
+ * plan-tagged JSON block in a normal conversation or in a non-Plan
1753
+ * auto-run's reply is never touched; `hidePlan` (per task, then global)
1754
+ * opts out of the strip.
1755
+ */
1756
+ pi.on("message_end", (event) => {
1757
+ if (event.message.role !== "assistant" || !pendingSummaryTask) return;
1758
+ const task = tasks.find((t) => t.name === pendingSummaryTask);
1759
+ // Only the runs whose prompt carried PLAN_OUTPUT_INSTRUCTION (Plan
1760
+ // tasks with the questionnaire enabled — renderTaskPrompt's gate)
1761
+ // may have the block captured and stripped; a plan fence in any other
1762
+ // reply is the user's content and stays in the transcript.
1763
+ if (!task || !isPlanTask(task) || !(task.questionnaire ?? questionnaireEnabled)) return;
1764
+ const text = assistantText(event.message.content);
1765
+ if (!text.includes("```plan")) return;
1766
+ // Capture the raw (unstripped) text for the questionnaire — after the
1767
+ // strip below the message text no longer carries the block.
1768
+ pendingPlanRaw = text;
1769
+ // TUI-only strip: in non-TUI modes the block stays in the transcript
1770
+ // so the model can resolve the item-number replies the notification
1771
+ // offers (and the session file keeps the proposal on record).
1772
+ if (lastCtx?.mode !== "tui") return;
1773
+ // `hidePlan` (per task, then global) opts out of the strip.
1774
+ if (!(task.hidePlan ?? hidePlanEnabled)) return;
1775
+ const stripped = stripPlanFromMessage(event.message);
1776
+ if (stripped) return { message: stripped };
1777
+ });
1778
+
1264
1779
  pi.on("agent_end", (event) => {
1265
1780
  // The agent may have changed the repo — drop the TTL context cache so
1266
1781
  // the next action sees the new tree (a running chain keeps its own
@@ -1276,10 +1791,26 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1276
1791
  const lastAssistant = lastAssistantMessage(event.messages);
1277
1792
  if (lastAssistant) {
1278
1793
  const outcome = outcomeFromStopReason(lastAssistant.stopReason);
1279
- const summary = stepSummary(outcome, pendingSummaryTask, 0, 0);
1280
- lastCtx?.ui.notify(`do-always: ${summary}`, "info");
1794
+ if (outcome === "completed") {
1795
+ // Completed auto-run: capture the reply; the plan
1796
+ // questionnaire (or the plain summary when there is no
1797
+ // parseable plan block / the mode is not TUI) is offered at
1798
+ // agent_settled, when the session is fully idle. The
1799
+ // message_end handler stripped the plan block from the
1800
+ // transcript, so the message here no longer carries it — the
1801
+ // raw capture is the parse source (the message text is the
1802
+ // fallback when no capture exists).
1803
+ pendingProposal = {
1804
+ taskName: pendingSummaryTask,
1805
+ text: pendingPlanRaw ?? assistantText(lastAssistant.content),
1806
+ };
1807
+ } else {
1808
+ const summary = stepSummary(outcome, pendingSummaryTask, 0, 0);
1809
+ lastCtx?.ui.notify(`do-always: ${summary}`, "info");
1810
+ }
1281
1811
  }
1282
1812
  pendingSummaryTask = null;
1813
+ pendingPlanRaw = null;
1283
1814
  return;
1284
1815
  }
1285
1816
  if (!chainWaiter) return;
@@ -1325,9 +1856,25 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1325
1856
  }
1326
1857
  }
1327
1858
  });
1859
+ // The settle that follows a chain run's agent_end (or the
1860
+ // failed-to-start grace timer): settle the waiter there, not in
1861
+ // agent_end, because agent_end can fire while a queued follow-up is
1862
+ // still pending — the settle is the point where the session is
1863
+ // truly idle. A settle with no waiter is a plain user turn (or the
1864
+ // settle of a completed auto-run, which offers its captured reply).
1328
1865
  pi.on("agent_settled", () => {
1329
- if (!chainWaiter) return;
1330
- settleChainWaiter(chainWaiter.started ? (chainWaiter.outcome ?? "completed") : "failed-to-start");
1866
+ if (chainWaiter) {
1867
+ settleChainWaiter(chainWaiter.started ? (chainWaiter.outcome ?? "completed") : "failed-to-start");
1868
+ return;
1869
+ }
1870
+ // A completed auto-run task may have a captured reply to offer (the
1871
+ // plan questionnaire in TUI, the summary elsewhere). Skip when a chain
1872
+ // is active — the questionnaire is only for single auto-runs.
1873
+ if (pendingProposal && !chainActive) {
1874
+ const captured = pendingProposal;
1875
+ pendingProposal = null;
1876
+ void offerPlanProposal(captured, lastCtx);
1877
+ }
1331
1878
  });
1332
1879
 
1333
1880
  /**
@@ -1403,6 +1950,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1403
1950
  const config = loadConfig(ctx.cwd, onError);
1404
1951
  tasks = config.tasks;
1405
1952
  reportEnabled = config.report;
1953
+ questionnaireEnabled = config.questionnaire;
1954
+ hidePlanEnabled = config.hidePlan;
1406
1955
  refreshVisible(ctx.cwd, await getContext(ctx.cwd));
1407
1956
  registerShortcut(config.shortcut, onError);
1408
1957
  });
@@ -1528,8 +2077,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1528
2077
  const details = formatSelectedCommits(selected);
1529
2078
  const strings = toPromptContext(context);
1530
2079
  const prompt = /\{\{\s*selected_commits\s*\}\}/.test(chosen.prompt)
1531
- ? renderPrompt(chosen.prompt, { ...strings, selected_commits: details })
1532
- : `${renderPrompt(chosen.prompt, strings)}\n\nSelected commits:\n${details}`;
2080
+ ? renderTaskPrompt(chosen, { ...strings, selected_commits: details })
2081
+ : `${renderTaskPrompt(chosen, strings)}\n\nSelected commits:\n${details}`;
1533
2082
 
1534
2083
  pendingSummaryTask = chosen.name;
1535
2084
  if (pendingSummaryTimer) clearTimeout(pendingSummaryTimer);
@@ -1554,7 +2103,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1554
2103
  }
1555
2104
  // Render with the same context the selector/preview used, so what the
1556
2105
  // user saw is exactly what gets injected.
1557
- const prompt = renderPrompt(task.prompt, toPromptContext(context));
2106
+ const prompt = renderTaskPrompt(task, toPromptContext(context));
1558
2107
  if (shouldAutoRun(task)) {
1559
2108
  // Fire-and-forget: sendUserMessage returns void; the run proceeds
1560
2109
  // independently (see the chain control notes for why).
@@ -2081,7 +2630,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
2081
2630
  const sel = itemRows[cursor.row];
2082
2631
  if (sel) {
2083
2632
  const wrapWidth = Math.max(10, width - 4);
2084
- const wrapped = wrapTextWithAnsi(renderPrompt(sel.task.prompt, strings), wrapWidth);
2633
+ const wrapped = wrapTextWithAnsi(renderTaskPrompt(sel.task, strings), wrapWidth);
2085
2634
  const shown = wrapped.slice(0, PREVIEW_MAX_LINES);
2086
2635
  const truncated = wrapped.length > PREVIEW_MAX_LINES;
2087
2636
  lines.push("");
@@ -2429,6 +2978,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
2429
2978
  const matches = [
2430
2979
  { value: "list", label: "list" },
2431
2980
  { value: "list-details", label: "list-details" },
2981
+ { value: "replan", label: "replan" },
2982
+ { value: "questionnaire", label: "questionnaire (toggle)" },
2432
2983
  ...visible.map((t, i) => ({ value: t.name, label: `${i + 1}. ${t.name}` })),
2433
2984
  ].filter((c) => c.value.toLowerCase().includes(p));
2434
2985
  return matches.length > 0 ? matches : null;
@@ -2451,6 +3002,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
2451
3002
  const config = loadConfig(ctx.cwd, onError);
2452
3003
  tasks = config.tasks;
2453
3004
  reportEnabled = config.report;
3005
+ questionnaireEnabled = config.questionnaire;
3006
+ hidePlanEnabled = config.hidePlan;
2454
3007
  }
2455
3008
 
2456
3009
  // One context per command run: shared by visibility filtering, rendering,
@@ -2475,6 +3028,30 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
2475
3028
  return;
2476
3029
  }
2477
3030
 
3031
+ // Re-open the questionnaire for the last offered plan proposal (e.g.
3032
+ // after an accidental esc). Re-offers the same captured reply, so the
3033
+ // confirm/withdraw behavior is exactly as before.
3034
+ if (arg.toLowerCase() === "replan") {
3035
+ if (!lastProposal) {
3036
+ ctx.ui.notify("do-always: no plan proposal to re-open — run a Plan task (⚡) first", "info");
3037
+ return;
3038
+ }
3039
+ void offerPlanProposal(lastProposal, ctx);
3040
+ return;
3041
+ }
3042
+
3043
+ // Toggle the questionnaire on/off for the rest of this session.
3044
+ if (arg.toLowerCase() === "questionnaire") {
3045
+ questionnaireEnabled = !questionnaireEnabled;
3046
+ ctx.ui.notify(
3047
+ questionnaireEnabled
3048
+ ? "do-always: questionnaire enabled (plan proposals will be offered)"
3049
+ : "do-always: questionnaire disabled (plan proposals will not be offered)",
3050
+ "info",
3051
+ );
3052
+ return;
3053
+ }
3054
+
2478
3055
  if (arg.toLowerCase() === "list-details") {
2479
3056
  // Display only — the description is metadata; selecting a task injects just its prompt.
2480
3057
  // Render with the current context so what is shown is what gets injected.
@@ -2484,7 +3061,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
2484
3061
  const lines = [`${i + 1}. ${t.name}`];
2485
3062
  if (t.description) lines.push(` description: ${t.description}`);
2486
3063
  lines.push(" prompt (this is what gets injected on select):");
2487
- for (const line of renderPrompt(t.prompt, strings).split("\n")) lines.push(` ${line}`);
3064
+ for (const line of renderTaskPrompt(t, strings).split("\n")) lines.push(` ${line}`);
2488
3065
  return lines.join("\n");
2489
3066
  })
2490
3067
  .join("\n\n");
@@ -67,6 +67,25 @@ export interface DoAlwaysTask {
67
67
  * configs that predate this field keep working.
68
68
  */
69
69
  browser?: BrowserType;
70
+ /**
71
+ * Whether the plan questionnaire is offered after this task's run: the
72
+ * reply's "plan" block (tiers + action items) becomes a selectable list
73
+ * the user confirms or withdraws. Default true (the global config
74
+ * "questionnaire" sets the fallback); set false to keep the plain summary
75
+ * notification. Only meaningful for auto-run tasks (Plan category by
76
+ * default).
77
+ */
78
+ questionnaire?: boolean;
79
+ /**
80
+ * Whether the raw "plan" block is hidden from the transcript after this
81
+ * task's run: the fenced block is stripped from the finalized reply in
82
+ * the TUI (the questionnaire still parses the captured raw text; in
83
+ * non-TUI modes the block is always kept). Default true (the global
84
+ * config "hidePlan" sets the fallback); set false to keep the block
85
+ * visible in the transcript. Only meaningful for auto-run tasks (Plan
86
+ * category by default).
87
+ */
88
+ hidePlan?: boolean;
70
89
  }
71
90
 
72
91
  /** The set of known guard types (used for validation at parse time). */
@@ -115,6 +134,19 @@ type DoAlwaysConfig =
115
134
  * the project root). Default true; set false to disable.
116
135
  */
117
136
  report?: boolean;
137
+ /**
138
+ * Whether completed auto-run tasks whose reply carries a "plan"
139
+ * block offer the selection questionnaire. Default true; set false
140
+ * to keep the plain summary notification.
141
+ */
142
+ questionnaire?: boolean;
143
+ /**
144
+ * Whether the raw "plan" block is stripped from the transcript
145
+ * after a completed auto-run task (TUI only — in non-TUI modes
146
+ * the block is always kept). Default true; set false to keep the
147
+ * block visible in the transcript.
148
+ */
149
+ hidePlan?: boolean;
118
150
  };
119
151
 
120
152
  /** Shortcut used when neither config file specifies one. */
@@ -138,6 +170,18 @@ interface ParsedDoAlwaysConfig {
138
170
  * report file. undefined when the file does not set one (default: on).
139
171
  */
140
172
  report: boolean | undefined;
173
+ /**
174
+ * The `questionnaire` field, if present: whether completed auto-run
175
+ * tasks whose reply carries a plan block offer the selection
176
+ * questionnaire. undefined when the file does not set one (default: on).
177
+ */
178
+ questionnaire: boolean | undefined;
179
+ /**
180
+ * The `hidePlan` field, if present: whether the raw plan block is
181
+ * stripped from the transcript after a completed auto-run task.
182
+ * undefined when the file does not set one (default: on).
183
+ */
184
+ hidePlan: boolean | undefined;
141
185
  }
142
186
 
143
187
  import { existsSync } from "node:fs";
@@ -474,14 +518,14 @@ export function parseConfig(
474
518
  data = JSON.parse(raw);
475
519
  } catch (err) {
476
520
  onError(`do-always: invalid JSON in ${path}: ${err}`);
477
- return { tasks: [], shortcut: undefined, report: undefined };
521
+ return { tasks: [], shortcut: undefined, report: undefined, questionnaire: undefined, hidePlan: undefined };
478
522
  }
479
523
 
480
524
  const list = Array.isArray(data) ? data : data?.tasks;
481
525
 
482
526
  if (!Array.isArray(list)) {
483
527
  onError(`do-always: ${path} must be a JSON array of tasks or {"tasks": [...]}`);
484
- return { tasks: [], shortcut: undefined, report: undefined };
528
+ return { tasks: [], shortcut: undefined, report: undefined, questionnaire: undefined, hidePlan: undefined };
485
529
  }
486
530
 
487
531
  const tasks: DoAlwaysTask[] = [];
@@ -498,6 +542,8 @@ export function parseConfig(
498
542
  if (typeof t.requireDirty === "boolean") task.requireDirty = t.requireDirty;
499
543
  if (typeof t.hidden === "boolean") task.hidden = t.hidden;
500
544
  if (typeof t.notForCommits === "boolean") task.notForCommits = t.notForCommits;
545
+ if (typeof t.questionnaire === "boolean") task.questionnaire = t.questionnaire;
546
+ if (typeof t.hidePlan === "boolean") task.hidePlan = t.hidePlan;
501
547
  if (t.browser !== undefined) {
502
548
  if (typeof t.browser === "string" && BROWSER_TYPES.includes(t.browser as BrowserType)) {
503
549
  task.browser = t.browser as BrowserType;
@@ -546,8 +592,18 @@ export function parseConfig(
546
592
  if (typeof data.report === "boolean") report = data.report;
547
593
  else onError(`do-always: ignoring invalid "report" in ${path} (expected true or false)`);
548
594
  }
595
+ let questionnaire: boolean | undefined;
596
+ if (!Array.isArray(data) && "questionnaire" in data) {
597
+ if (typeof data.questionnaire === "boolean") questionnaire = data.questionnaire;
598
+ else onError(`do-always: ignoring invalid "questionnaire" in ${path} (expected true or false)`);
599
+ }
600
+ let hidePlan: boolean | undefined;
601
+ if (!Array.isArray(data) && "hidePlan" in data) {
602
+ if (typeof data.hidePlan === "boolean") hidePlan = data.hidePlan;
603
+ else onError(`do-always: ignoring invalid "hidePlan" in ${path} (expected true or false)`);
604
+ }
549
605
 
550
- return { tasks, shortcut, merge, report };
606
+ return { tasks, shortcut, merge, report, questionnaire, hidePlan };
551
607
  }
552
608
 
553
609
  const KEY_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
@@ -807,7 +863,7 @@ export function orderTasksByCategory(
807
863
  */
808
864
  export function shouldAutoRun(task: DoAlwaysTask): boolean {
809
865
  if (typeof task.autoRun === "boolean") return task.autoRun;
810
- return (task.category ?? "").trim().toLowerCase() === "plan";
866
+ return isPlanTask(task);
811
867
  }
812
868
 
813
869
  /**
@@ -1491,3 +1547,352 @@ export function formatCommitReviewPrompt(commits: SelectedCommit[]): string {
1491
1547
  `Summarize your findings for each commit and propose a plan for any fixes if needed. Do not make changes yet.`
1492
1548
  );
1493
1549
  }
1550
+
1551
+ // ── Plan proposal ────────────────────────────────────────────────────────────
1552
+ //
1553
+ // Auto-run Plan tasks end their reply with a machine-readable plan block:
1554
+ // a fenced code block tagged "plan" carrying the proposed action items as
1555
+ // JSON (summary + tiers of items). PLAN_OUTPUT_INSTRUCTION is appended to
1556
+ // Plan task prompts at render time so the agent emits the block;
1557
+ // parsePlanProposal extracts and normalizes it; the selection state machine
1558
+ // and the execution prompt builder are pure so they can be unit-tested
1559
+ // without the Pi runtime.
1560
+
1561
+ /**
1562
+ * Instruction appended to Plan-category task prompts at render time: it
1563
+ * requires the reply to end with a fenced "plan" code block containing the
1564
+ * proposed action items as JSON (summary + tiers of items). Kept in one
1565
+ * place so every Plan prompt shares the same contract.
1566
+ */
1567
+ export const PLAN_OUTPUT_INSTRUCTION =
1568
+ "End your reply with a machine-readable plan block: a fenced code block tagged plan (```plan) containing JSON of exactly this shape: " +
1569
+ '{"summary":"one-line summary","tiers":[{"id":"P0","label":"Critical","items":[{"title":"short action","detail":"where and why (file:line if known)"}]}]}. ' +
1570
+ "One tier per priority level, most urgent first (P0, P1, P2, ...). " +
1571
+ "Each item must be one concrete, independently doable action. " +
1572
+ 'Use an empty "tiers" array when no action is needed.';
1573
+
1574
+ /** One concrete action item proposed by a Plan run. */
1575
+ export interface PlanItem {
1576
+ /** Short action description. */
1577
+ title: string;
1578
+ /** Where and why — file:line, rationale (optional). */
1579
+ detail?: string;
1580
+ }
1581
+
1582
+ /** A priority tier grouping action items (most urgent tier first). */
1583
+ export interface PlanTier {
1584
+ /** Tier id as emitted by the agent (e.g. "P0"); defaulted by position when absent. */
1585
+ id: string;
1586
+ /** Human label (e.g. "Critical"); defaults to the id when absent. */
1587
+ label: string;
1588
+ /** The tier's action items, in the order the agent emitted them. */
1589
+ items: PlanItem[];
1590
+ }
1591
+
1592
+ /** A parsed plan proposal: the agent's reply, normalized for selection. */
1593
+ export interface PlanProposal {
1594
+ /** One-line summary from the block (undefined when absent or blank). */
1595
+ summary?: string;
1596
+ /** Tiers in the order the agent emitted them (most urgent first). */
1597
+ tiers: PlanTier[];
1598
+ }
1599
+
1600
+ /** True when the task belongs to the Plan category (case-insensitive). */
1601
+ export function isPlanTask(task: DoAlwaysTask): boolean {
1602
+ return (task.category ?? "").trim().toLowerCase() === "plan";
1603
+ }
1604
+
1605
+ /**
1606
+ * Matches a fenced code block whose info string is "plan" (trailing
1607
+ * whitespace allowed). The lazy body stops at the first closing fence.
1608
+ */
1609
+ const PLAN_FENCE_RE = /```plan[^\S\n]*\r?\n([\s\S]*?)```/gi;
1610
+
1611
+ /**
1612
+ * Remove every fenced plan block from `text`, collapsing the blank lines
1613
+ * they leave behind and trimming the ends. The stripped text is what the
1614
+ * transcript shows (the message_end handler in index.ts replaces the
1615
+ * finalized message with it); the raw text is captured separately for the
1616
+ * questionnaire parser. `removed` is false when no plan fence was present
1617
+ * (the text is returned unchanged).
1618
+ */
1619
+ export function stripPlanBlocks(text: string): { text: string; removed: boolean } {
1620
+ if (typeof text !== "string" || text === "") return { text, removed: false };
1621
+ if (!text.includes("```plan")) return { text, removed: false };
1622
+ // An unclosed fence (no closing ```) matches nothing: leave the text
1623
+ // alone instead of claiming a removal that didn't happen.
1624
+ if ([...text.matchAll(PLAN_FENCE_RE)].length === 0) return { text, removed: false };
1625
+ const stripped = text
1626
+ .replace(PLAN_FENCE_RE, "")
1627
+ .replace(/\n{3,}/g, "\n\n")
1628
+ .replace(/^\s+/, "")
1629
+ .replace(/\s+$/, "");
1630
+ return { text: stripped, removed: true };
1631
+ }
1632
+
1633
+ /**
1634
+ * Parse a plan proposal from a Plan run's reply text. Finds the fenced
1635
+ * "plan" code blocks, tries them from last to first (the agent may emit an
1636
+ * early malformed one and correct it), and returns the first that yields at
1637
+ * least one valid item. Returns null when there is no plan block, the JSON
1638
+ * is malformed, or nothing valid survives normalization — callers then fall
1639
+ * back to the plain summary notification.
1640
+ */
1641
+ export function parsePlanProposal(text: string): PlanProposal | null {
1642
+ if (typeof text !== "string" || text === "") return null;
1643
+ const fences = [...text.matchAll(PLAN_FENCE_RE)];
1644
+ for (let i = fences.length - 1; i >= 0; i--) {
1645
+ const proposal = normalizePlanJson(fences[i][1]);
1646
+ if (proposal) return proposal;
1647
+ }
1648
+ return null;
1649
+ }
1650
+
1651
+ /**
1652
+ * Why (or why not) a reply's plan block is usable for the questionnaire.
1653
+ * Mirrors parsePlanProposal's block precedence (last to first), so the
1654
+ * reported reason matches what the parser decided: "ok" is exactly the case
1655
+ * where parsePlanProposal returns a proposal.
1656
+ */
1657
+ export type PlanBlockDiagnostic =
1658
+ | { kind: "none" } // no fenced plan block at all
1659
+ | { kind: "malformed"; detail: string } // the last block's JSON does not parse (detail: the parse error)
1660
+ | { kind: "empty" } // JSON parsed, but no valid items survived normalization
1661
+ | { kind: "ok"; itemCount: number };
1662
+
1663
+ export function planBlockDiagnostics(text: string): PlanBlockDiagnostic {
1664
+ if (typeof text !== "string" || text === "") return { kind: "none" };
1665
+ const fences = [...text.matchAll(PLAN_FENCE_RE)];
1666
+ if (fences.length === 0) return { kind: "none" };
1667
+ let lastError: string | null = null;
1668
+ let parsedButEmpty = false;
1669
+ for (let i = fences.length - 1; i >= 0; i--) {
1670
+ try {
1671
+ JSON.parse(fences[i][1].trim());
1672
+ } catch (err) {
1673
+ // The loop runs last-to-first, so the first error seen is the
1674
+ // last block's — the agent's final answer, which is the one to
1675
+ // report. Strip the position suffix from JSON.parse errors
1676
+ // ("at position N (line L column C)") so the message is readable
1677
+ // in a large plan block where the position number is meaningless.
1678
+ if (lastError === null) {
1679
+ const raw = err instanceof Error ? err.message : String(err);
1680
+ lastError = raw.replace(/\s+at\s+position\s+\d+(?:\s*\(line\s+\d+\s+column\s+\d+\))?/, "");
1681
+ }
1682
+ continue;
1683
+ }
1684
+ const proposal = normalizePlanJson(fences[i][1]);
1685
+ if (proposal) {
1686
+ const count = proposal.tiers.reduce((n, t) => n + t.items.length, 0);
1687
+ if (count > 0) return { kind: "ok", itemCount: count };
1688
+ }
1689
+ parsedButEmpty = true;
1690
+ }
1691
+ if (lastError !== null) return { kind: "malformed", detail: lastError };
1692
+ if (parsedButEmpty) return { kind: "empty" };
1693
+ return { kind: "malformed", detail: "invalid JSON" };
1694
+ }
1695
+
1696
+ /**
1697
+ * Normalize one plan block's JSON body. Accepts the tiered shape
1698
+ * ({tiers: [{id, label, items: [...]}]}) and a lenient flat shape
1699
+ * ({items: [{tier, title, ...}]}, grouped preserving first-seen tier
1700
+ * order). Invalid entries are dropped; a result with no valid items is
1701
+ * null.
1702
+ */
1703
+ function normalizePlanJson(raw: string): PlanProposal | null {
1704
+ let data: unknown;
1705
+ try {
1706
+ data = JSON.parse(raw.trim());
1707
+ } catch {
1708
+ return null;
1709
+ }
1710
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
1711
+ const obj = data as Record<string, unknown>;
1712
+ const summary =
1713
+ typeof obj.summary === "string" && obj.summary.trim() !== "" ? obj.summary.trim() : undefined;
1714
+ const tiers: PlanTier[] = [];
1715
+ if (Array.isArray(obj.tiers)) {
1716
+ obj.tiers.forEach((t, i) => {
1717
+ const tier = normalizePlanTier(t, i);
1718
+ if (tier) tiers.push(tier);
1719
+ });
1720
+ } else if (Array.isArray(obj.items)) {
1721
+ const byTier = new Map<string, PlanTier>();
1722
+ for (const entry of obj.items) {
1723
+ const item = normalizePlanItem(entry);
1724
+ if (!item) continue;
1725
+ const tierField =
1726
+ typeof entry === "object" && entry !== null
1727
+ ? (entry as Record<string, unknown>).tier
1728
+ : undefined;
1729
+ const id = typeof tierField === "string" && tierField.trim() !== "" ? tierField.trim() : "P0";
1730
+ if (!byTier.has(id)) byTier.set(id, { id, label: id, items: [] });
1731
+ byTier.get(id)!.items.push(item);
1732
+ }
1733
+ for (const tier of byTier.values()) tiers.push(tier);
1734
+ }
1735
+ if (tiers.length === 0) return null;
1736
+ return { summary, tiers };
1737
+ }
1738
+
1739
+ /** Normalize one tier entry; null when it carries no valid items. */
1740
+ function normalizePlanTier(raw: unknown, index: number): PlanTier | null {
1741
+ if (typeof raw !== "object" || raw === null) return null;
1742
+ const obj = raw as Record<string, unknown>;
1743
+ const items: PlanItem[] = [];
1744
+ if (Array.isArray(obj.items)) {
1745
+ for (const entry of obj.items) {
1746
+ const item = normalizePlanItem(entry);
1747
+ if (item) items.push(item);
1748
+ }
1749
+ }
1750
+ if (items.length === 0) return null;
1751
+ const id = typeof obj.id === "string" && obj.id.trim() !== "" ? obj.id.trim() : `P${index}`;
1752
+ const label = typeof obj.label === "string" && obj.label.trim() !== "" ? obj.label.trim() : id;
1753
+ return { id, label, items };
1754
+ }
1755
+
1756
+ /**
1757
+ * Normalize one item entry: a plain string is a title-only item; an object
1758
+ * needs a non-blank string "title" ("detail" is optional). Null otherwise.
1759
+ */
1760
+ function normalizePlanItem(raw: unknown): PlanItem | null {
1761
+ if (typeof raw === "string") {
1762
+ const title = raw.trim();
1763
+ return title === "" ? null : { title };
1764
+ }
1765
+ if (typeof raw !== "object" || raw === null) return null;
1766
+ const obj = raw as Record<string, unknown>;
1767
+ const title = typeof obj.title === "string" ? obj.title.trim() : "";
1768
+ if (title === "") return null;
1769
+ const item: PlanItem = { title };
1770
+ const detail = typeof obj.detail === "string" ? obj.detail.trim() : "";
1771
+ if (detail !== "") item.detail = detail;
1772
+ return item;
1773
+ }
1774
+
1775
+ // ── Plan questionnaire selection ─────────────────────────────────────────────
1776
+ //
1777
+ // The user's selection in the plan questionnaire: which item keys are
1778
+ // checked. A key is "tierIndex:itemIndex" (0-based positions in the
1779
+ // normalized proposal). Pure state — every operation returns a new Set,
1780
+ // never mutating the input (same style as the chain state).
1781
+
1782
+ /** The checked item keys of one questionnaire. */
1783
+ export type PlanSelection = Set<string>;
1784
+
1785
+ /** An empty selection. */
1786
+ export function planSelectionClear(): PlanSelection {
1787
+ return new Set();
1788
+ }
1789
+
1790
+ /** The selection key of one item (0-based tier and item positions). */
1791
+ export function planItemKey(tierIndex: number, itemIndex: number): string {
1792
+ return `${tierIndex}:${itemIndex}`;
1793
+ }
1794
+
1795
+ /** Toggle one item's membership. */
1796
+ export function planToggleItem(selection: PlanSelection, key: string): PlanSelection {
1797
+ const next = new Set(selection);
1798
+ if (next.has(key)) next.delete(key);
1799
+ else next.add(key);
1800
+ return next;
1801
+ }
1802
+
1803
+ /**
1804
+ * Toggle a whole tier: fully selected → clear all its items; none or
1805
+ * partial → select all of them. Returns the new selection and whether the
1806
+ * tier ended up selected. Unknown tier index: the selection is unchanged.
1807
+ */
1808
+ export function planToggleTier(
1809
+ proposal: PlanProposal,
1810
+ tierIndex: number,
1811
+ selection: PlanSelection,
1812
+ ): { selection: PlanSelection; selected: boolean } {
1813
+ const tier = proposal.tiers[tierIndex];
1814
+ if (!tier) return { selection, selected: false };
1815
+ const keys = tier.items.map((_, i) => planItemKey(tierIndex, i));
1816
+ const allSelected = keys.every((k) => selection.has(k));
1817
+ const next = new Set(selection);
1818
+ if (allSelected) for (const k of keys) next.delete(k);
1819
+ else for (const k of keys) next.add(k);
1820
+ return { selection: next, selected: !allSelected };
1821
+ }
1822
+
1823
+ /** Select every item in the proposal. */
1824
+ export function planSelectAll(proposal: PlanProposal, selection: PlanSelection): PlanSelection {
1825
+ const next = new Set(selection);
1826
+ proposal.tiers.forEach((tier, ti) => {
1827
+ tier.items.forEach((_, ii) => next.add(planItemKey(ti, ii)));
1828
+ });
1829
+ return next;
1830
+ }
1831
+
1832
+ /** One tier's aggregate state: no items, some, or all items selected. */
1833
+ export function planTierState(
1834
+ proposal: PlanProposal,
1835
+ tierIndex: number,
1836
+ selection: PlanSelection,
1837
+ ): "none" | "partial" | "all" {
1838
+ const tier = proposal.tiers[tierIndex];
1839
+ if (!tier) return "none";
1840
+ let count = 0;
1841
+ tier.items.forEach((_, ii) => {
1842
+ if (selection.has(planItemKey(tierIndex, ii))) count++;
1843
+ });
1844
+ if (count === 0) return "none";
1845
+ if (count === tier.items.length) return "all";
1846
+ return "partial";
1847
+ }
1848
+
1849
+ /** A selected item with its tier, for display and the execution prompt. */
1850
+ export interface PlanSelectionEntry {
1851
+ tier: PlanTier;
1852
+ item: PlanItem;
1853
+ /** A user note added in the questionnaire for this item, if any. */
1854
+ note?: string;
1855
+ }
1856
+
1857
+ /**
1858
+ * The selected items in execution order: tier order, item order within a
1859
+ * tier. Empty when nothing is selected. When `notes` is given, a non-empty
1860
+ * note for a selected item (keyed by planItemKey) is carried on the entry.
1861
+ */
1862
+ export function planSelectedItems(
1863
+ proposal: PlanProposal,
1864
+ selection: PlanSelection,
1865
+ notes?: ReadonlyMap<string, string>,
1866
+ ): PlanSelectionEntry[] {
1867
+ const out: PlanSelectionEntry[] = [];
1868
+ proposal.tiers.forEach((tier, ti) => {
1869
+ tier.items.forEach((item, ii) => {
1870
+ const key = planItemKey(ti, ii);
1871
+ if (!selection.has(key)) return;
1872
+ const note = notes?.get(key)?.trim();
1873
+ out.push(note ? { tier, item, note } : { tier, item });
1874
+ });
1875
+ });
1876
+ return out;
1877
+ }
1878
+
1879
+ /**
1880
+ * Build the follow-up prompt sent when the user confirms a questionnaire
1881
+ * selection. The proposal itself is already in the conversation (the Plan
1882
+ * run's reply), so the prompt references it and lists only the selected
1883
+ * items, in execution order.
1884
+ */
1885
+ export function formatPlanExecutionPrompt(selected: PlanSelectionEntry[], taskName: string): string {
1886
+ const lines: string[] = [
1887
+ `Execute the following action items from the "${taskName}" plan proposal, in exactly this order. ` +
1888
+ "Do ONLY these items — skip every other item from the proposal, and do not start anything else. " +
1889
+ "When done, summarize what you changed.",
1890
+ "",
1891
+ ];
1892
+ selected.forEach(({ tier, item, note }, i) => {
1893
+ lines.push(
1894
+ `${i + 1}. [${tier.id}] ${item.title}${item.detail ? ` — ${item.detail}` : ""}${note ? ` [note: ${note}]` : ""}`,
1895
+ );
1896
+ });
1897
+ return lines.join("\n");
1898
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-do-always",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension: /do-always — pick a common task by number, it fills your prompt",
6
6
  "author": {