pi-do-always 0.13.0 → 0.16.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.
@@ -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
  /**
@@ -1353,11 +1900,11 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1353
1900
  * Send a prompt and resolve when the run it starts has fully settled,
1354
1901
  * reporting the run's outcome (see armWaiter).
1355
1902
  */
1356
- function sendAndWait(prompt: string, graceMs = 10_000, stepIndex = 0): Promise<ChainStepOutcome> {
1357
- const done = armWaiter(graceMs, stepIndex);
1358
- pi.sendUserMessage(prompt);
1359
- return done;
1360
- }
1903
+ function sendAndWait(prompt: string, graceMs = 10_000, stepIndex = 0): Promise<ChainStepOutcome> {
1904
+ const done = armWaiter(graceMs, stepIndex);
1905
+ pi.sendUserMessage(prompt, { deliverAs: 'followUp' });
1906
+ return done;
1907
+ }
1361
1908
 
1362
1909
  /** Filter tasks by visibility (`hidden` flag + `when` condition) and refresh the completion cache. */
1363
1910
  function refreshVisible(cwd: string, context: TaskContext): DoAlwaysTask[] {
@@ -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);
@@ -1540,7 +2089,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1540
2089
  pendingSummaryTask = null;
1541
2090
  lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
1542
2091
  }, 10_000);
1543
- pi.sendUserMessage(prompt);
2092
+ pi.sendUserMessage(prompt, { deliverAs: 'followUp' });
1544
2093
  ctx.ui.notify(
1545
2094
  `do-always: ${selected.length} commit${selected.length !== 1 ? "s" : ""} → ${chosen.name}`,
1546
2095
  "info",
@@ -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).
@@ -1571,7 +2120,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1571
2120
  pendingSummaryTask = null;
1572
2121
  lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
1573
2122
  }, 10_000);
1574
- pi.sendUserMessage(prompt);
2123
+ pi.sendUserMessage(prompt, { deliverAs: 'followUp' });
1575
2124
  ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
1576
2125
  return;
1577
2126
  }
@@ -1579,7 +2128,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1579
2128
  ctx.ui.setEditorText(prompt);
1580
2129
  ctx.ui.notify(`do-always: prompt for "${task.name}" filled — press Enter to run`, "info");
1581
2130
  } else {
1582
- pi.sendUserMessage(prompt);
2131
+ pi.sendUserMessage(prompt, { deliverAs: 'followUp' });
1583
2132
  }
1584
2133
  }
1585
2134
 
@@ -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,7 @@ 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" },
2432
2982
  ...visible.map((t, i) => ({ value: t.name, label: `${i + 1}. ${t.name}` })),
2433
2983
  ].filter((c) => c.value.toLowerCase().includes(p));
2434
2984
  return matches.length > 0 ? matches : null;
@@ -2451,6 +3001,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
2451
3001
  const config = loadConfig(ctx.cwd, onError);
2452
3002
  tasks = config.tasks;
2453
3003
  reportEnabled = config.report;
3004
+ questionnaireEnabled = config.questionnaire;
3005
+ hidePlanEnabled = config.hidePlan;
2454
3006
  }
2455
3007
 
2456
3008
  // One context per command run: shared by visibility filtering, rendering,
@@ -2475,6 +3027,18 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
2475
3027
  return;
2476
3028
  }
2477
3029
 
3030
+ // Re-open the questionnaire for the last offered plan proposal (e.g.
3031
+ // after an accidental esc). Re-offers the same captured reply, so the
3032
+ // confirm/withdraw behavior is exactly as before.
3033
+ if (arg.toLowerCase() === "replan") {
3034
+ if (!lastProposal) {
3035
+ ctx.ui.notify("do-always: no plan proposal to re-open — run a Plan task (⚡) first", "info");
3036
+ return;
3037
+ }
3038
+ void offerPlanProposal(lastProposal, ctx);
3039
+ return;
3040
+ }
3041
+
2478
3042
  if (arg.toLowerCase() === "list-details") {
2479
3043
  // Display only — the description is metadata; selecting a task injects just its prompt.
2480
3044
  // Render with the current context so what is shown is what gets injected.
@@ -2484,7 +3048,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
2484
3048
  const lines = [`${i + 1}. ${t.name}`];
2485
3049
  if (t.description) lines.push(` description: ${t.description}`);
2486
3050
  lines.push(" prompt (this is what gets injected on select):");
2487
- for (const line of renderPrompt(t.prompt, strings).split("\n")) lines.push(` ${line}`);
3051
+ for (const line of renderTaskPrompt(t, strings).split("\n")) lines.push(` ${line}`);
2488
3052
  return lines.join("\n");
2489
3053
  })
2490
3054
  .join("\n\n");