pi-do-always 0.12.0 → 0.13.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
@@ -8,29 +8,6 @@ type to filter, scroll or click, or navigate with arrows + Enter → the task's
8
8
  **filled into the input editor**. Review it, tweak it, press Enter to run. Tasks marked `⚡`
9
9
  (the `Plan` category by default) auto-run on selection instead — see `autoRun` below.
10
10
 
11
- ```text
12
- do-always — pick a task
13
-
14
- # TASK DESCRIPTION ORDER
15
- PLAN
16
- 1 ⚡ Review changes Review the current code changes (Plan) ·
17
- 2 ⚡ Review code Review the whole project's code quality (Plan) [1]
18
- 3 ⚡ Cleanup Clean up dead code and duplicates (Plan) [2]
19
- 4 ⚡ Security Security audit (Plan) ·
20
- 5 ⚡ Performance Performance review (Plan) ►[3]
21
- 6 ⚡ Propose features Propose new features (Plan) ·
22
- DO
23
- 7 Build Test build is ok and fix issues ·
24
- 8 Tests Run tests and fix failures ·
25
- DOCS
26
- 9 Readme Update the README.md ·
27
-
28
- ──────────────────────────────────────────────────────────────────────────────────────────────────
29
- Run the chain (3)
30
- ← tasks • ⏎ remove • esc • ctrl+u clear
31
-
32
- 1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel • ⚡ auto-runs
33
- ```
34
11
 
35
12
  ![do-always — the numbered task selector](pi.image.png)
36
13
 
@@ -134,6 +111,31 @@ that view and in the report file on disk. A run that stops early (aborted, error
134
111
  skipped steps) does not show the inline report — only the status trace widget below
135
112
  the prompt.
136
113
 
114
+ ## Commit browser
115
+
116
+ Two built-in tasks open the date-grouped commit browser (pages of 20, type to
117
+ filter, Space/Enter to select, ←/→ to page):
118
+
119
+ - **Browse commits** (category `Browse`, only shown inside a git repo) — the
120
+ generic entry: select commits, then confirm on the `do on the commits
121
+ (n commits)` row to pick which task runs on them — a picker lists your
122
+ visible `Plan` tasks, except those marked `notForCommits: true` (the
123
+ built-in Review changes, Review code, and Propose features — they operate
124
+ on the working tree or the whole project, not on a set of commits). The
125
+ chosen task's prompt is sent as a single turn (not a chain) with the
126
+ selection injected.
127
+ - **Review commits** (category `Plan`, hidden from the selector) — offered in
128
+ the picker after a selection; the review runs directly on the selected
129
+ commits (its prompt consumes the selection). Still resolvable by name:
130
+ `/do-always review commits` opens the browser as before.
131
+
132
+ The selection is injected into the chosen task's prompt: a prompt that
133
+ references `{{selected_commits}}` gets the numbered commit detail block
134
+ substituted in; any other prompt gets the block appended under
135
+ `Selected commits:`. In non-interactive modes (no TUI) the browser is skipped:
136
+ the latest commit is selected and the first eligible `Plan` task (not
137
+ `notForCommits`) whose guards pass runs on it.
138
+
137
139
  ## Install
138
140
 
139
141
  Install it from npm as a Pi package, which loads the bundled `index.ts` (and its `tasks.ts`) without
@@ -163,7 +165,7 @@ Tasks are read from JSON files (an array of tasks, or the object form `{"tasks":
163
165
  |`~/.pi/agent/do-always.json`|Global (all projects)|
164
166
  |`<project>/.pi/do-always.json`|Project-local; overrides global tasks with the same `name`|
165
167
 
166
- If neither file exists, the built-in defaults (Review changes, Review code, Cleanup, Security, Performance, Propose features, Build, Tests, Readme, Release, Commit) are used.
168
+ If neither file exists, the built-in defaults (Review changes, Review code, Cleanup, Security, Performance, Propose features, Review commits, Browse commits, Build, Tests, Readme, Release, Commit) are used.
167
169
  This repo ships a sample in [`do-always.json`](./extensions/pi-do-always/do-always.json) — copy it to one of the
168
170
  locations above to make it your own:
169
171
 
@@ -181,10 +183,13 @@ locations above to make it your own:
181
183
  Fields:
182
184
 
183
185
  - `name` (required) — short unique id, used for `/do-always <name>`
184
- - `category` (optional) — group header the task is shown under in the selector (e.g. `"Plan"`, `"Do"`). Matching is case-insensitive and the header is title-cased, so `"plan"` and `"Plan"` land in the same `Plan` group. Tasks without a category fall under `Other`. The built-in defaults are grouped into `Plan`, `Do`, `Docs`, and `Ops`.
186
+ - `category` (optional) — group header the task is shown under in the selector (e.g. `"Plan"`, `"Do"`). Matching is case-insensitive and the header is title-cased, so `"plan"` and `"Plan"` land in the same `Plan` group. Tasks without a category fall under `Other`. The built-in defaults are grouped into `Plan`, `Browse`, `Do`, `Docs`, and `Ops`.
185
187
  - `description` (optional) — one-line label shown in the selector
186
188
  - `prompt` (required) — the text filled into the editor (supports `{{placeholders}}` — see [Prompt placeholders](#prompt-placeholders))
187
189
  - `autoRun` (optional) — when `true`, selecting the task sends its prompt immediately instead of filling the editor; when `false`, it always fills the editor. When omitted, the default is derived from the category: `Plan` tasks auto-run, everything else fills the editor. Auto-run tasks are marked `⚡` in the selector.
190
+ - `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
+ - `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
+ - `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.
188
193
  - `when` (optional) — a condition that hides the task from the selector and lists when it is not met (see [Conditionals](#conditionals)).
189
194
  - `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`.
190
195
 
@@ -227,6 +232,7 @@ injects “Review the changes on branch `fix/login-null` (3 changed files:
227
232
  |`{{repo}}`|Basename of the git remote (or of the working directory when there is no remote) — disambiguates monorepo work|
228
233
  |`{{staged_files}}`|Files staged for commit, one per line (`none` when empty)|
229
234
  |`{{unstaged_files}}`|Modified-but-unstaged files, one per line (`none` when empty)|
235
+ |`{{selected_commits}}`|Commits selected in the commit browser, as a numbered detail block (`none` outside a browser run)|
230
236
 
231
237
  Unknown placeholders are left as-is, and a prompt without placeholders is
232
238
  injected unchanged, so existing configs keep working. The selector preview and
@@ -6,12 +6,14 @@
6
6
  "category": "Plan",
7
7
  "description": "Review the current code changes (Plan)",
8
8
  "requireDirty": true,
9
+ "notForCommits": true,
9
10
  "prompt": "Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). Change summary: {{diff_stat}}. Last commit: {{last_commit}}. Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. Do a plan proposal for the fixes if needed. Do a summary of your findings."
10
11
  },
11
12
  {
12
13
  "name": "Review code",
13
14
  "category": "Plan",
14
15
  "description": "Review the whole project's code quality (Plan)",
16
+ "notForCommits": true,
15
17
  "prompt": "Review this project's code holistically: identify code smells, dead code, duplication, awkward architecture or patterns, maintainability issues, inconsistencies, and missing or unclear documentation. Prioritize by impact, propose a plan for the fixes, and summarize your findings. Do not make any changes yet."
16
18
  },
17
19
  {
@@ -36,8 +38,25 @@
36
38
  "name": "Propose features",
37
39
  "category": "Plan",
38
40
  "description": "Propose new features (Plan)",
41
+ "notForCommits": true,
39
42
  "prompt": "Review this project and propose new features that would add value. For each idea, describe the problem it solves, the user benefit, and a rough implementation approach. Prioritize by impact and effort. Do not make any changes yet. Try to evaluate how many lines this will be in terms of changes, whether this will break APIs, or introduce compatibility issues."
40
43
  },
44
+ {
45
+ "name": "Review commits",
46
+ "category": "Plan",
47
+ "description": "Review the selected commits (Plan)",
48
+ "browser": "commits",
49
+ "hidden": true,
50
+ "prompt": "Review the selected commits: {{selected_commits}}. Inspect each with `git show <hash>`, double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. Summarize your findings per commit and propose a plan for any fixes if needed. Do not make any changes yet."
51
+ },
52
+ {
53
+ "name": "Browse commits",
54
+ "category": "Browse",
55
+ "description": "Browse and select commits, then pick a task to run on them",
56
+ "browser": "commits",
57
+ "when": "git",
58
+ "prompt": "Browse recent commits, select some, then pick a task to run on them."
59
+ },
41
60
  {
42
61
  "name": "Build",
43
62
  "category": "Do",
@@ -51,6 +51,8 @@ import {
51
51
  } from "@earendil-works/pi-tui";
52
52
  import {
53
53
  CHAIN_MAX,
54
+ COMMIT_BROWSER_MAX,
55
+ COMMIT_SELECT_MAX,
54
56
  DEFAULT_SHORTCUT,
55
57
  DEFAULT_TASKS,
56
58
  assistantText,
@@ -65,12 +67,16 @@ import {
65
67
  evaluateWhen,
66
68
  formatChainSequence,
67
69
  formatList,
70
+ formatSelectedCommits,
71
+ groupCommitsByDate,
68
72
  groupTasksByCategory,
73
+ isTaskVisible,
69
74
  isValidKeyId,
70
75
  mergeTasks,
71
76
  parseConfig,
72
77
  parseConfigRegexpValueForKey,
73
78
  parseCommitSubject,
79
+ parseGitLogOutput,
74
80
  parseStatusPorcelain,
75
81
  parseStatusStagedUnstaged,
76
82
  orderTasksByCategory,
@@ -88,7 +94,10 @@ import {
88
94
  toPromptContext,
89
95
  validateChain,
90
96
  type ChainStepOutcome,
97
+ type CommitInfo,
98
+ type DateGroup,
91
99
  type DoAlwaysTask,
100
+ type SelectedCommit,
92
101
  type TaskContext,
93
102
  type TableRow,
94
103
  } from "./tasks";
@@ -196,6 +205,17 @@ function git(cwd: string, args: string[]): Promise<string | undefined> {
196
205
  });
197
206
  }
198
207
 
208
+ /**
209
+ * Count changed files (staged, unstaged, untracked) with a single git spawn.
210
+ * Lighter than a full `buildContext` when only the count is needed (chain
211
+ * step summaries). Returns 0 when git is unavailable — the same neutral
212
+ * result `buildContext` yields via its empty file list.
213
+ */
214
+ async function changedFileCount(cwd: string): Promise<number> {
215
+ const porcelain = await git(cwd, ["status", "--porcelain"]);
216
+ return porcelain ? parseStatusPorcelain(porcelain).length : 0;
217
+ }
218
+
199
219
  /**
200
220
  * Build the structured context for the current directory. Git facts fall back
201
221
  * to neutral values when unavailable (non-git dir, no git, empty repo) so
@@ -233,6 +253,7 @@ async function buildContext(cwd: string): Promise<TaskContext> {
233
253
  repo: cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown",
234
254
  stagedFiles: [],
235
255
  unstagedFiles: [],
256
+ selectedCommits: "none",
236
257
  isGitRepo: false,
237
258
  };
238
259
  }
@@ -274,15 +295,566 @@ async function buildContext(cwd: string): Promise<TaskContext> {
274
295
  repo,
275
296
  stagedFiles,
276
297
  unstagedFiles,
298
+ selectedCommits: "none",
277
299
  isGitRepo,
278
300
  };
279
301
  }
280
302
 
303
+ /**
304
+ * Fetch a page of commits from git with lightweight stats via a single log call.
305
+ * Fetches pageSize + 1 commits to determine if more commits are available.
306
+ */
307
+ async function fetchCommitsPage(
308
+ cwd: string,
309
+ page = 0,
310
+ pageSize = COMMIT_BROWSER_MAX,
311
+ ): Promise<{ commits: CommitInfo[]; groups: DateGroup[]; hasMore: boolean }> {
312
+ const countToFetch = pageSize + 1;
313
+ const skip = page * pageSize;
314
+ const format = "COMMIT%x09%H%x09%h%x09%s%x09%ad%x09%an";
315
+ const logOutput = await git(cwd, [
316
+ "log",
317
+ "-n",
318
+ `${countToFetch}`,
319
+ `--skip=${skip}`,
320
+ `--format=${format}`,
321
+ "--shortstat",
322
+ "--date=short",
323
+ ]);
324
+ if (!logOutput) {
325
+ return { commits: [], groups: [], hasMore: false };
326
+ }
327
+
328
+ const allParsed = parseGitLogOutput(logOutput);
329
+ const hasMore = allParsed.length > pageSize;
330
+ const commits = hasMore ? allParsed.slice(0, pageSize) : allParsed;
331
+ const groups = groupCommitsByDate(commits);
332
+ return { commits, groups, hasMore };
333
+ }
334
+
335
+ /**
336
+ * Date-grouped commit browser: browse recent commits in pages of 20, select some,
337
+ * then review them. Reuses the task-selector interaction model (↑↓, Space toggle,
338
+ * type-to-filter, Ctrl+U clear, Esc cancel, ←/→ or PgUp/PgDn for paging).
339
+ *
340
+ * Returns the selected commits (ordered by user selection), or null on cancel.
341
+ */
342
+ function browseCommits(
343
+ ctx: ExtensionContext,
344
+ initialData?: { commits: CommitInfo[]; groups: DateGroup[]; hasMore: boolean },
345
+ ): Promise<SelectedCommit[] | null> {
346
+ return new Promise<SelectedCommit[] | null>((resolve) => {
347
+ const pageCache = new Map<number, { commits: CommitInfo[]; groups: DateGroup[]; hasMore: boolean }>();
348
+ let page = 0;
349
+ if (initialData) {
350
+ pageCache.set(0, initialData);
351
+ }
352
+
353
+ type CommitCursor = { kind: "commit"; index: number } | { kind: "run" };
354
+ const maxVisible = 14; // commits visible in the scroll window
355
+ const selectedMap = new Map<string, SelectedCommit>(); // hash -> SelectedCommit
356
+ let cursor: CommitCursor = { kind: "commit", index: 0 };
357
+ let lastCommitIndex = 0;
358
+ let filter = "";
359
+ let settled = false;
360
+ let isLoading = false;
361
+
362
+ ctx.ui.custom<SelectedCommit[] | null>((tui, theme, _kb, done) => {
363
+ const kb = getKeybindings();
364
+
365
+ function finish(selectedCommits: SelectedCommit[]) {
366
+ if (settled) return;
367
+ settled = true;
368
+ done(selectedCommits);
369
+ resolve(selectedCommits);
370
+ }
371
+
372
+ function finishCancel() {
373
+ if (settled) return;
374
+ settled = true;
375
+ done(null);
376
+ resolve(null);
377
+ }
378
+
379
+ async function loadPage(newPage: number) {
380
+ if (isLoading || newPage < 0) return;
381
+ page = newPage;
382
+ if (!pageCache.has(page)) {
383
+ isLoading = true;
384
+ tui.requestRender();
385
+ try {
386
+ const fetched = await fetchCommitsPage(ctx.cwd, page);
387
+ pageCache.set(page, fetched);
388
+ } finally {
389
+ isLoading = false;
390
+ }
391
+ }
392
+ cursor = { kind: "commit", index: 0 };
393
+ lastCommitIndex = 0;
394
+ tui.requestRender();
395
+ }
396
+
397
+ function getCurrentPageData() {
398
+ return pageCache.get(page) ?? { commits: [], groups: [], hasMore: false };
399
+ }
400
+
401
+ function getVisibleCommits(): CommitInfo[] {
402
+ const { commits } = getCurrentPageData();
403
+ if (!filter) return commits;
404
+ const f = filter.toLowerCase();
405
+ return commits.filter(
406
+ (c) => c.subject.toLowerCase().includes(f) || c.shortHash.toLowerCase().includes(f),
407
+ );
408
+ }
409
+
410
+ function render(width: number): string[] {
411
+ const currentData = getCurrentPageData();
412
+ const visibleCommits = getVisibleCommits();
413
+ const lines: string[] = [];
414
+
415
+ // Clamp cursor index if visible list changed
416
+ if (cursor.kind === "commit") {
417
+ if (visibleCommits.length === 0) {
418
+ cursor = { kind: "run" };
419
+ } else if (cursor.index >= visibleCommits.length) {
420
+ cursor = { kind: "commit", index: visibleCommits.length - 1 };
421
+ lastCommitIndex = cursor.index;
422
+ }
423
+ }
424
+
425
+ // Header
426
+ const startNum = page * COMMIT_BROWSER_MAX + 1;
427
+ const endNum = page * COMMIT_BROWSER_MAX + (visibleCommits.length || 0);
428
+ const pageLabel = currentData.hasMore
429
+ ? `Page ${page + 1} (${startNum}–${endNum}+)`
430
+ : `Page ${page + 1} (${startNum}–${endNum})`;
431
+
432
+ lines.push(
433
+ theme.fg(
434
+ "muted",
435
+ truncateToWidth(
436
+ ` # DATE HASH FILES ORDER SUBJECT [${pageLabel}]`,
437
+ width - 2,
438
+ "",
439
+ ),
440
+ ),
441
+ );
442
+
443
+ if (isLoading) {
444
+ lines.push(theme.fg("accent", " Loading commits from git…"));
445
+ } else if (visibleCommits.length === 0) {
446
+ lines.push(
447
+ filter
448
+ ? theme.fg("warning", ` No commits matching "${filter}" on this page`)
449
+ : theme.fg("muted", " No commits found"),
450
+ );
451
+ } else {
452
+ const anchor = cursor.kind === "commit" ? cursor.index : lastCommitIndex;
453
+ const winStart = visibleCommits.length > maxVisible
454
+ ? Math.max(0, Math.min(anchor + 1 - maxVisible, visibleCommits.length - maxVisible))
455
+ : 0;
456
+ const winEnd = Math.min(winStart + maxVisible, visibleCommits.length);
457
+
458
+ for (let i = winStart; i < winEnd; i++) {
459
+ const c = visibleCommits[i];
460
+ // Show date divider if it's the first commit in window or date changed
461
+ if (i === winStart || c.date !== visibleCommits[i - 1].date) {
462
+ lines.push(theme.fg("dim", ` ── ${c.date} ──`));
463
+ }
464
+
465
+ const isCursor = cursor.kind === "commit" && cursor.index === i;
466
+ const isSelected = selectedMap.has(c.hash);
467
+ const order = isSelected ? [...selectedMap.keys()].indexOf(c.hash) + 1 : 0;
468
+ const cursorMark = isCursor ? theme.fg("accent", "►") : " ";
469
+ const globalIdx = page * COMMIT_BROWSER_MAX + i + 1;
470
+ const num = `${cursorMark} ${String(globalIdx).padStart(2)}`;
471
+ const marker = isSelected ? `${theme.fg("accent", "◉")} [${order}]` : " · ";
472
+ const hash = theme.fg("dim", c.shortHash.slice(0, 7));
473
+ const statStr = c.filesChanged > 0 ? `${c.filesChanged}f` : "";
474
+ const statCell = statStr.padStart(3);
475
+ const dateShort = c.date.slice(5); // MM-DD
476
+ const subject = truncateToWidth(c.subject, Math.max(10, width - 36), "…");
477
+
478
+ const rowText = `${num} ${dateShort} ${hash} ${statCell} ${marker} ${subject}`;
479
+ if (isCursor) {
480
+ lines.push(theme.bg("selectedBg", theme.bold(rowText)));
481
+ } else {
482
+ lines.push(rowText);
483
+ }
484
+ }
485
+
486
+ if (visibleCommits.length > maxVisible) {
487
+ lines.push(
488
+ theme.fg(
489
+ "dim",
490
+ truncateToWidth(
491
+ ` (${cursor.kind === "commit" ? cursor.index + 1 : lastCommitIndex + 1}/${visibleCommits.length} on page ${page + 1})`,
492
+ width - 2,
493
+ "",
494
+ ),
495
+ ),
496
+ );
497
+ }
498
+ }
499
+
500
+ // Run row
501
+ lines.push("");
502
+ lines.push(theme.fg("dim", " " + "─".repeat(Math.max(1, width - 4))));
503
+ const runLabel = selectedMap.size === 0
504
+ ? "do on the commits (no commits selected)"
505
+ : `do on the commits (${selectedMap.size} commit${selectedMap.size !== 1 ? "s" : ""})`;
506
+ if (cursor.kind === "run") {
507
+ lines.push(theme.bg("selectedBg", theme.bold(`${theme.fg("accent", "►")} ${runLabel}`)));
508
+ } else if (selectedMap.size === 0) {
509
+ lines.push(theme.fg("dim", ` ${runLabel}`));
510
+ } else {
511
+ lines.push(theme.fg("accent", ` ${runLabel}`));
512
+ }
513
+
514
+ // Footer
515
+ const pageNavHints: string[] = [];
516
+ if (page > 0) pageNavHints.push("← prev page");
517
+ if (currentData.hasMore) pageNavHints.push("→ next page");
518
+ const pageHintStr = pageNavHints.length > 0 ? ` • ${pageNavHints.join(" • ")}` : "";
519
+
520
+ let footer = ` space/⏎ select • ↑/↓ move${pageHintStr} • esc`;
521
+ if (selectedMap.size > 0) {
522
+ footer += " • ctrl+u clear";
523
+ }
524
+ if (filter) {
525
+ footer += ` • filter: "${filter}"`;
526
+ }
527
+ lines.push(theme.fg("dim", truncateToWidth(footer, width - 2, "")));
528
+
529
+ return lines;
530
+ }
531
+
532
+ async function handleInput(data: string) {
533
+ if (settled) return;
534
+ const currentData = getCurrentPageData();
535
+ const visibleCommits = getVisibleCommits();
536
+
537
+ // Space: toggle selection on current commit
538
+ if (matchesKey(data, "space")) {
539
+ if (cursor.kind === "commit" && visibleCommits[cursor.index]) {
540
+ const c = visibleCommits[cursor.index];
541
+ if (selectedMap.has(c.hash)) {
542
+ selectedMap.delete(c.hash);
543
+ let i = 1;
544
+ for (const sc of selectedMap.values()) {
545
+ sc.selectionOrder = i++;
546
+ }
547
+ } else if (selectedMap.size < COMMIT_SELECT_MAX) {
548
+ selectedMap.set(c.hash, { ...c, selectionOrder: selectedMap.size + 1 });
549
+ } else {
550
+ ctx.ui.notify(`do-always: chain is full (${COMMIT_SELECT_MAX}) — remove a commit first`, "error");
551
+ }
552
+ tui.requestRender();
553
+ }
554
+ return;
555
+ }
556
+
557
+ // Confirm (Enter): run the chain if on run row, or toggle selection if on a commit
558
+ if (kb.matches(data, "tui.select.confirm") || matchesKey(data, "enter")) {
559
+ if (cursor.kind === "run") {
560
+ if (selectedMap.size === 0) {
561
+ ctx.ui.notify("do-always: no commits selected", "info");
562
+ } else {
563
+ finish([...selectedMap.values()]);
564
+ }
565
+ return;
566
+ }
567
+ if (cursor.kind === "commit" && visibleCommits[cursor.index]) {
568
+ const c = visibleCommits[cursor.index];
569
+ if (selectedMap.has(c.hash)) {
570
+ selectedMap.delete(c.hash);
571
+ let i = 1;
572
+ for (const sc of selectedMap.values()) {
573
+ sc.selectionOrder = i++;
574
+ }
575
+ } else if (selectedMap.size < COMMIT_SELECT_MAX) {
576
+ selectedMap.set(c.hash, { ...c, selectionOrder: selectedMap.size + 1 });
577
+ } else {
578
+ ctx.ui.notify(`do-always: chain is full (${COMMIT_SELECT_MAX}) — remove a commit first`, "error");
579
+ }
580
+ tui.requestRender();
581
+ }
582
+ return;
583
+ }
584
+
585
+ // Cancel (Esc / Ctrl+C)
586
+ if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
587
+ finishCancel();
588
+ return;
589
+ }
590
+
591
+ // Ctrl+U: clear selections
592
+ if (matchesKey(data, "ctrl+u")) {
593
+ if (selectedMap.size > 0) {
594
+ selectedMap.clear();
595
+ tui.requestRender();
596
+ }
597
+ return;
598
+ }
599
+
600
+ // Backspace: delete character from filter, or undo last selection
601
+ if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "backspace")) {
602
+ if (filter.length > 0) {
603
+ filter = filter.slice(0, -1);
604
+ cursor = { kind: "commit", index: 0 };
605
+ lastCommitIndex = 0;
606
+ } else if (selectedMap.size > 0) {
607
+ const lastHash = [...selectedMap.keys()].pop()!;
608
+ selectedMap.delete(lastHash);
609
+ }
610
+ tui.requestRender();
611
+ return;
612
+ }
613
+
614
+ // Page navigation: Next page (Right arrow, or PageDown when at the end)
615
+ if (matchesKey(data, "right")) {
616
+ if (currentData.hasMore) {
617
+ await loadPage(page + 1);
618
+ }
619
+ return;
620
+ }
621
+ if (kb.matches(data, "tui.select.pageDown") || matchesKey(data, "pageDown")) {
622
+ if (cursor.kind === "commit") {
623
+ const next = cursor.index + maxVisible;
624
+ if (next < visibleCommits.length) {
625
+ cursor = { kind: "commit", index: next };
626
+ lastCommitIndex = next;
627
+ tui.requestRender();
628
+ } else if (currentData.hasMore) {
629
+ await loadPage(page + 1);
630
+ } else {
631
+ cursor = { kind: "run" };
632
+ tui.requestRender();
633
+ }
634
+ } else if (currentData.hasMore) {
635
+ await loadPage(page + 1);
636
+ }
637
+ return;
638
+ }
639
+
640
+ // Page navigation: Previous page (Left arrow, or PageUp when at the top)
641
+ if (matchesKey(data, "left")) {
642
+ if (page > 0) {
643
+ await loadPage(page - 1);
644
+ }
645
+ return;
646
+ }
647
+ if (kb.matches(data, "tui.select.pageUp") || matchesKey(data, "pageUp")) {
648
+ if (cursor.kind === "commit") {
649
+ const prev = cursor.index - maxVisible;
650
+ if (prev >= 0) {
651
+ cursor = { kind: "commit", index: prev };
652
+ lastCommitIndex = prev;
653
+ tui.requestRender();
654
+ } else if (page > 0) {
655
+ await loadPage(page - 1);
656
+ } else {
657
+ cursor = { kind: "commit", index: 0 };
658
+ lastCommitIndex = 0;
659
+ tui.requestRender();
660
+ }
661
+ } else if (visibleCommits.length > 0) {
662
+ cursor = { kind: "commit", index: Math.max(0, visibleCommits.length - maxVisible) };
663
+ lastCommitIndex = cursor.index;
664
+ tui.requestRender();
665
+ }
666
+ return;
667
+ }
668
+
669
+ // Up arrow
670
+ if (kb.matches(data, "tui.select.up") || matchesKey(data, "up")) {
671
+ if (cursor.kind === "run") {
672
+ if (visibleCommits.length > 0) {
673
+ cursor = { kind: "commit", index: visibleCommits.length - 1 };
674
+ lastCommitIndex = visibleCommits.length - 1;
675
+ }
676
+ } else if (cursor.index > 0) {
677
+ cursor = { kind: "commit", index: cursor.index - 1 };
678
+ lastCommitIndex = cursor.index;
679
+ } else {
680
+ cursor = { kind: "run" };
681
+ }
682
+ tui.requestRender();
683
+ return;
684
+ }
685
+
686
+ // Down arrow
687
+ if (kb.matches(data, "tui.select.down") || matchesKey(data, "down")) {
688
+ if (cursor.kind === "run") {
689
+ if (visibleCommits.length > 0) {
690
+ cursor = { kind: "commit", index: 0 };
691
+ lastCommitIndex = 0;
692
+ }
693
+ } else if (cursor.index < visibleCommits.length - 1) {
694
+ cursor = { kind: "commit", index: cursor.index + 1 };
695
+ lastCommitIndex = cursor.index;
696
+ } else {
697
+ cursor = { kind: "run" };
698
+ }
699
+ tui.requestRender();
700
+ return;
701
+ }
702
+
703
+ // Home / End
704
+ if (matchesKey(data, "home")) {
705
+ if (visibleCommits.length > 0) {
706
+ cursor = { kind: "commit", index: 0 };
707
+ lastCommitIndex = 0;
708
+ tui.requestRender();
709
+ }
710
+ return;
711
+ }
712
+ if (matchesKey(data, "end")) {
713
+ cursor = { kind: "run" };
714
+ tui.requestRender();
715
+ return;
716
+ }
717
+
718
+ // Filter typing (printable chars, excluding Space which toggles)
719
+ if (isPrintable(data) && data !== " ") {
720
+ filter += data;
721
+ cursor = { kind: "commit", index: 0 };
722
+ lastCommitIndex = 0;
723
+ tui.requestRender();
724
+ return;
725
+ }
726
+ }
727
+
728
+ return {
729
+ render,
730
+ handleInput,
731
+ invalidate: () => {},
732
+ };
733
+ });
734
+ });
735
+ }
736
+
281
737
  /** True for a single printable ASCII character (used for filter typing). */
282
738
  function isPrintable(data: string): boolean {
283
739
  return data.length === 1 && data >= " " && data <= "~";
284
740
  }
285
741
 
742
+ /**
743
+ * Task picker shown after a commit selection: proposes the Plan tasks (hidden
744
+ * ones included — e.g. "Review commits" exists to be picked here) to run on
745
+ * the selected commits. Type to filter, ↑/↓ move, Enter runs, Esc cancels.
746
+ * Returns the chosen task, or null on cancel.
747
+ */
748
+ function pickPlanTaskForCommits(
749
+ ctx: ExtensionContext,
750
+ candidates: DoAlwaysTask[],
751
+ commitCount: number,
752
+ ): Promise<DoAlwaysTask | null> {
753
+ return new Promise<DoAlwaysTask | null>((resolve) => {
754
+ ctx.ui.custom<DoAlwaysTask | null>((tui, theme, _kb, done) => {
755
+ const kb = getKeybindings();
756
+ let settled = false;
757
+ let filter = "";
758
+ let cursor = 0;
759
+
760
+ const visible = () => {
761
+ if (!filter) return candidates;
762
+ const f = filter.toLowerCase();
763
+ return candidates.filter(
764
+ (t) =>
765
+ t.name.toLowerCase().includes(f) ||
766
+ (t.description ?? "").toLowerCase().includes(f),
767
+ );
768
+ };
769
+
770
+ function finish(task: DoAlwaysTask | null) {
771
+ if (settled) return;
772
+ settled = true;
773
+ done(task);
774
+ resolve(task);
775
+ }
776
+
777
+ function render(width: number): string[] {
778
+ const items = visible();
779
+ if (items.length === 0) cursor = 0;
780
+ else if (cursor >= items.length) cursor = items.length - 1;
781
+ const lines: string[] = [];
782
+ lines.push(
783
+ theme.fg(
784
+ "accent",
785
+ theme.bold(
786
+ truncateToWidth(
787
+ ` do-always — run on ${commitCount} selected commit${commitCount !== 1 ? "s" : ""}`,
788
+ width - 2,
789
+ "",
790
+ ),
791
+ ),
792
+ ),
793
+ );
794
+ lines.push(theme.fg("muted", " # TASK"));
795
+ if (items.length === 0) {
796
+ lines.push(theme.fg("warning", ` No Plan tasks matching "${filter}"`));
797
+ } else {
798
+ for (let i = 0; i < items.length; i++) {
799
+ const t = items[i];
800
+ const auto = shouldAutoRun(t);
801
+ const name = truncateToWidth(t.name, Math.max(10, width - 8 - (auto ? 3 : 0)), "…", true);
802
+ const rowText = ` ${String(i + 1).padStart(2)} ${auto ? "⚡ " : ""}${name}`;
803
+ lines.push(i === cursor ? theme.bg("selectedBg", theme.bold(rowText)) : rowText);
804
+ }
805
+ }
806
+ lines.push(
807
+ theme.fg(
808
+ "dim",
809
+ truncateToWidth(
810
+ ` ↑/↓ move • ⏎ run • esc${filter ? ` • filter: "${filter}"` : ""}`,
811
+ width - 2,
812
+ "",
813
+ ),
814
+ ),
815
+ );
816
+ return lines;
817
+ }
818
+
819
+ function handleInput(data: string) {
820
+ if (settled) return;
821
+ const items = visible();
822
+ if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
823
+ finish(null);
824
+ return;
825
+ }
826
+ if (kb.matches(data, "tui.select.confirm") || matchesKey(data, "enter")) {
827
+ finish(items[cursor] ?? null);
828
+ return;
829
+ }
830
+ if (kb.matches(data, "tui.select.up") || matchesKey(data, "up")) {
831
+ if (items.length > 0) cursor = cursor === 0 ? items.length - 1 : cursor - 1;
832
+ tui.requestRender();
833
+ return;
834
+ }
835
+ if (kb.matches(data, "tui.select.down") || matchesKey(data, "down")) {
836
+ if (items.length > 0) cursor = cursor === items.length - 1 ? 0 : cursor + 1;
837
+ tui.requestRender();
838
+ return;
839
+ }
840
+ if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "backspace")) {
841
+ filter = filter.slice(0, -1);
842
+ cursor = 0;
843
+ tui.requestRender();
844
+ return;
845
+ }
846
+ if (isPrintable(data)) {
847
+ filter += data;
848
+ cursor = 0;
849
+ tui.requestRender();
850
+ }
851
+ }
852
+
853
+ return { render, handleInput, invalidate: () => {} };
854
+ });
855
+ });
856
+ }
857
+
286
858
  /** Delay before the selector reveals the selected task's prompt preview. */
287
859
  const PREVIEW_DELAY_MS = 2000;
288
860
  /** Max lines of the prompt shown in the selector preview. */
@@ -787,9 +1359,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
787
1359
  return done;
788
1360
  }
789
1361
 
790
- /** Filter tasks by their `when` condition and refresh the completion cache. */
1362
+ /** Filter tasks by visibility (`hidden` flag + `when` condition) and refresh the completion cache. */
791
1363
  function refreshVisible(cwd: string, context: TaskContext): DoAlwaysTask[] {
792
- const visible = tasks.filter((t) => evaluateWhen(t, context));
1364
+ const visible = tasks.filter((t) => isTaskVisible(t, context));
793
1365
  visibleCache = { cwd, visible };
794
1366
  return visible;
795
1367
  }
@@ -875,6 +1447,106 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
875
1447
 
876
1448
  /** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
877
1449
  async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext, context: TaskContext): Promise<void> {
1450
+ // Commit browser: any task with `browser: "commits"` opens the
1451
+ // date-grouped browser first. The name check keeps configs that
1452
+ // predate the field working. After the selection: a task whose prompt
1453
+ // references {{selected_commits}} runs itself on the selection; a
1454
+ // generic entry (e.g. "Browse commits") proposes the Plan tasks to
1455
+ // run on it — hidden tasks included, since they exist to be picked
1456
+ // here (non-TUI: the first one whose guards pass).
1457
+ if (task.browser === "commits" || task.name === "Review commits") {
1458
+ let selected: SelectedCommit[] = [];
1459
+ if (ctx.mode !== "tui") {
1460
+ const initialData = await fetchCommitsPage(ctx.cwd, 0, 1);
1461
+ if (initialData.commits.length === 0) {
1462
+ ctx.ui.notify("do-always: no commits found", "info");
1463
+ return;
1464
+ }
1465
+ selected = [{ ...initialData.commits[0], selectionOrder: 1 }];
1466
+ } else {
1467
+ const initialData = await fetchCommitsPage(ctx.cwd, 0);
1468
+ if (initialData.commits.length === 0) {
1469
+ ctx.ui.notify("do-always: no commits found", "info");
1470
+ return;
1471
+ }
1472
+ const result = await browseCommits(ctx, initialData);
1473
+ if (!result) {
1474
+ ctx.ui.notify("do-always: commit browser cancelled", "info");
1475
+ return;
1476
+ }
1477
+ if (result.length === 0) {
1478
+ ctx.ui.notify("do-always: no commits selected", "info");
1479
+ return;
1480
+ }
1481
+ selected = result;
1482
+ }
1483
+
1484
+ // Which task runs on the selection: the originating task when its
1485
+ // prompt consumes {{selected_commits}}; otherwise a Plan task
1486
+ // picked by the user (TUI) or the first whose guards pass (non-TUI).
1487
+ const planTasks = tasks.filter(
1488
+ (t) =>
1489
+ !t.notForCommits &&
1490
+ evaluateWhen(t, context) &&
1491
+ (t.category ?? "").trim().toLowerCase() === "plan",
1492
+ );
1493
+ if (planTasks.length === 0) {
1494
+ ctx.ui.notify("do-always: no Plan task available to run on the selected commits", "info");
1495
+ return;
1496
+ }
1497
+ let chosen: DoAlwaysTask | undefined;
1498
+ if (/\{\{\s*selected_commits\s*\}\}/.test(task.prompt)) {
1499
+ chosen = task;
1500
+ } else if (ctx.mode === "tui") {
1501
+ const picked = await pickPlanTaskForCommits(ctx, planTasks, selected.length);
1502
+ if (!picked) {
1503
+ ctx.ui.notify("do-always: no task chosen — commits not used", "info");
1504
+ return;
1505
+ }
1506
+ chosen = picked;
1507
+ } else {
1508
+ // Non-TUI: no picker — the first Plan task whose guards pass.
1509
+ chosen = planTasks.find((t) => evaluateGuards(t, context) === null);
1510
+ }
1511
+ if (!chosen) {
1512
+ ctx.ui.notify("do-always: no Plan task available to run on the selected commits (guards unmet)", "info");
1513
+ return;
1514
+ }
1515
+
1516
+ // Guards apply to the commit run too: a picked (or originating)
1517
+ // task blocked for the current tree is not sent — same as the
1518
+ // non-browser path below.
1519
+ const blocked = evaluateGuards(chosen, context);
1520
+ if (blocked) {
1521
+ ctx.ui.notify(`do-always: ${blocked}`, "info");
1522
+ return;
1523
+ }
1524
+
1525
+ // Inject the selection: substitute {{selected_commits}} when the
1526
+ // prompt references it, otherwise append the detail block. The run
1527
+ // is a single turn (not a chain): sendUserMessage + summary.
1528
+ const details = formatSelectedCommits(selected);
1529
+ const strings = toPromptContext(context);
1530
+ 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}`;
1533
+
1534
+ pendingSummaryTask = chosen.name;
1535
+ if (pendingSummaryTimer) clearTimeout(pendingSummaryTimer);
1536
+ pendingSummaryTimer = setTimeout(() => {
1537
+ pendingSummaryTimer = null;
1538
+ if (!pendingSummaryTask) return;
1539
+ const name = pendingSummaryTask;
1540
+ pendingSummaryTask = null;
1541
+ lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
1542
+ }, 10_000);
1543
+ pi.sendUserMessage(prompt);
1544
+ ctx.ui.notify(
1545
+ `do-always: ${selected.length} commit${selected.length !== 1 ? "s" : ""} → ${chosen.name}`,
1546
+ "info",
1547
+ );
1548
+ return;
1549
+ }
878
1550
  const blocked = evaluateGuards(task, context);
879
1551
  if (blocked) {
880
1552
  ctx.ui.notify(`do-always: ${blocked}`, "info");
@@ -1007,12 +1679,21 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1007
1679
  return;
1008
1680
  }
1009
1681
  setChainStep(ctx, 0, "completed");
1010
- // Post-step summary for step 1 (fill-first)
1011
- const postContext = await getContext(ctx.cwd);
1012
- const fileCount = postContext.files.length;
1682
+ // Post-step summary for step 1 (fill-first). When more steps
1683
+ // follow, the fresh full build doubles as step 2's pre-context
1684
+ // (the tree can only change via agent runs, which have settled);
1685
+ // a single-task chain only needs the file count.
1686
+ let nextContext: TaskContext | undefined;
1687
+ let fileCount: number;
1688
+ if (steps.length === 1) {
1689
+ fileCount = await changedFileCount(ctx.cwd);
1690
+ } else {
1691
+ nextContext = await getContext(ctx.cwd);
1692
+ fileCount = nextContext.files.length;
1693
+ }
1013
1694
  const summary = stepSummary(outcome, first.name, chainDurations[0], fileCount);
1014
1695
  ctx.ui.notify(`do-always: step 1/${steps.length} — ${summary}`, "info");
1015
- await runChainSteps(steps, ctx, 1);
1696
+ await runChainSteps(steps, ctx, 1, nextContext);
1016
1697
  } finally {
1017
1698
  chainActive = false;
1018
1699
  }
@@ -1024,15 +1705,23 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1024
1705
  * and renders prompts against fresh repository context, and each step is
1025
1706
  * awaited until its run has fully settled; an aborted/errored step (or a
1026
1707
  * send that failed to start) stops the chain.
1708
+ *
1709
+ * Context builds are shared across steps: after each completed step the
1710
+ * fresh full build (needed for the file-count summary) is reused as the
1711
+ * next step's pre-context — the tree can only change via agent runs, and
1712
+ * the step has fully settled before the build runs. `initialContext` lets
1713
+ * the fill-first path hand over its post-step-1 build. The last step's
1714
+ * summary uses a single-spawn file count instead of a full build.
1027
1715
  */
1028
1716
  async function runChainSteps(
1029
1717
  steps: DoAlwaysTask[],
1030
1718
  ctx: ExtensionContext,
1031
1719
  startAt: number,
1720
+ initialContext?: TaskContext,
1032
1721
  ): Promise<void> {
1722
+ let context = initialContext ?? (await getContext(ctx.cwd));
1033
1723
  for (let i = startAt; i < steps.length; i++) {
1034
1724
  const step = steps[i];
1035
- const context = await getContext(ctx.cwd);
1036
1725
  const blocked = evaluateGuards(step, context);
1037
1726
  if (blocked) {
1038
1727
  markChainStopped(ctx, i, "skipped", blocked);
@@ -1050,9 +1739,16 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1050
1739
  chainDurations[i] = stepDuration;
1051
1740
  if (outcome === "completed") {
1052
1741
  setChainStep(ctx, i, "completed");
1053
- // Post-step summary: files changed + duration.
1054
- const postContext = await getContext(ctx.cwd);
1055
- const fileCount = postContext.files.length;
1742
+ // Post-step summary: files changed + duration. All but the last
1743
+ // step get a fresh full build that doubles as the next step's
1744
+ // pre-context; the last step only needs the count (one spawn).
1745
+ let fileCount: number;
1746
+ if (i === steps.length - 1) {
1747
+ fileCount = await changedFileCount(ctx.cwd);
1748
+ } else {
1749
+ context = await getContext(ctx.cwd);
1750
+ fileCount = context.files.length;
1751
+ }
1056
1752
  const summary = stepSummary(outcome, step.name, stepDuration, fileCount);
1057
1753
  ctx.ui.notify(`do-always: step ${i + 1}/${steps.length} — ${summary}`, "info");
1058
1754
  continue;
@@ -1109,9 +1805,10 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1109
1805
  context: TaskContext,
1110
1806
  cache: ReturnType<typeof createContextCache>,
1111
1807
  ): Promise<void> {
1112
- // Filter by the `when` condition once per session, so hidden tasks never
1113
- // appear, are never numbered, and can't be picked.
1114
- const visibleTasks = tasks.filter((t) => evaluateWhen(t, context));
1808
+ // Filter by the `hidden` flag and the `when` condition once per session,
1809
+ // so hidden tasks never appear, are never numbered, and can't be picked
1810
+ // (same predicate as refreshVisible, which numbers `/do-always <n>`).
1811
+ const visibleTasks = tasks.filter((t) => isTaskVisible(t, context));
1115
1812
  // String view for prompt rendering (derived once, used by the preview).
1116
1813
  const strings = toPromptContext(context);
1117
1814
  const result = await ctx.ui.custom<SelectorResult>((tui, theme, _kb, done) => {
@@ -1608,6 +2305,13 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1608
2305
  return;
1609
2306
  }
1610
2307
  // ORDER column: toggle this task's chain membership.
2308
+ // Browser tasks can't be chained: the chain runner sends
2309
+ // prompts directly, so the browser (and
2310
+ // {{selected_commits}}) never run — run them on their own.
2311
+ if (row.task.browser) {
2312
+ ctx.ui.notify(`do-always: "${row.task.name}" opens a browser — it can't be chained, run it on its own`, "info");
2313
+ return;
2314
+ }
1611
2315
  if (chain.items.includes(row.task.name)) {
1612
2316
  chain = chainRemove(chain, row.task.name);
1613
2317
  } else {
@@ -1670,16 +2374,22 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1670
2374
  const { itemRows } = visible;
1671
2375
  const idx = itemRows.findIndex((r) => r.task === task);
1672
2376
  if (idx < 0) return undefined;
1673
- // ORDER cell: press toggles chain membership.
1674
- if (orderColX !== null && event.x >= orderColX) {
1675
- if (event.type === "press") {
1676
- chain = chain.items.includes(task.name) ? chainRemove(chain, task.name) : chainAdd(chain, task.name).state;
1677
- clampCursor(visible);
1678
- resetPreview();
1679
- return { handled: true, render: true };
2377
+ // ORDER cell: press toggles chain membership.
2378
+ if (orderColX !== null && event.x >= orderColX) {
2379
+ if (event.type === "press") {
2380
+ if (task.browser) {
2381
+ // Same rule as the keyboard toggle: browser tasks
2382
+ // can't be chained (see ORDER column).
2383
+ ctx.ui.notify(`do-always: "${task.name}" opens a browser — it can't be chained, run it on its own`, "info");
2384
+ return { handled: true };
2385
+ }
2386
+ chain = chain.items.includes(task.name) ? chainRemove(chain, task.name) : chainAdd(chain, task.name).state;
2387
+ clampCursor(visible);
2388
+ resetPreview();
2389
+ return { handled: true, render: true };
2390
+ }
2391
+ return { handled: true }; // swallow the click after the press action
1680
2392
  }
1681
- return { handled: true }; // swallow the click after the press action
1682
- }
1683
2393
  // Task area: press selects, click runs (the classic fast path).
1684
2394
  if (event.type === "press") {
1685
2395
  mousePressedIndex = idx;
@@ -37,6 +37,21 @@ export interface DoAlwaysTask {
37
37
  * Omitted/undefined always shows the task. Evaluated by `evaluateWhen`.
38
38
  */
39
39
  when?: string | Record<string, unknown>;
40
+ /**
41
+ * Hide the task from the selector and the `list` commands. Hidden tasks
42
+ * are not standalone user-visible entries; they can still be resolved by
43
+ * name on the command line and are offered as candidates by the commit
44
+ * picker. The built-in "Review commits" uses this: it is a pick-after-
45
+ * browse option, not an entry point ("Browse commits" is the entry).
46
+ */
47
+ hidden?: boolean;
48
+ /**
49
+ * Exclude the task from the commit picker (the "run on the selected
50
+ * commits" list shown after a commit selection). For Plan tasks that
51
+ * operate on the working tree or the whole project rather than on a set
52
+ * of commits. The task is otherwise unaffected (selector, lists, CLI).
53
+ */
54
+ notForCommits?: boolean;
40
55
  /**
41
56
  * Extra selection-time guards, evaluated alongside the legacy `requireDirty`
42
57
  * (see `evaluateGuards`). Each guard blocks the task (with a message, not a
@@ -44,6 +59,14 @@ export interface DoAlwaysTask {
44
59
  * compatibility; new guards use this array so the set is extensible.
45
60
  */
46
61
  guards?: Guard[];
62
+ /**
63
+ * A browser to open on selection instead of injecting the prompt directly.
64
+ * Currently only "commits": opens the date-grouped commit browser, and the
65
+ * selected commits drive the review prompt (see `fillPrompt` in index.ts).
66
+ * The built-in "Review commits" task also triggers the browser by name, so
67
+ * configs that predate this field keep working.
68
+ */
69
+ browser?: BrowserType;
47
70
  }
48
71
 
49
72
  /** The set of known guard types (used for validation at parse time). */
@@ -66,6 +89,13 @@ export interface Guard {
66
89
  value?: string;
67
90
  }
68
91
 
92
+ /** The set of known browser types (used for validation at parse time). */
93
+ export const BROWSER_TYPES = [
94
+ "commits",
95
+ ] as const;
96
+
97
+ export type BrowserType = (typeof BROWSER_TYPES)[number];
98
+
69
99
  /**
70
100
  * A config file can be a bare array of tasks, or {"tasks": [...], "shortcut": ...}.
71
101
  * `shortcut` is a key id string (e.g. "f4", "ctrl+shift+p"), or null to disable
@@ -144,6 +174,12 @@ export interface TaskContext {
144
174
  stagedFiles: string[];
145
175
  /** Modified-but-unstaged files. */
146
176
  unstagedFiles: string[];
177
+ /**
178
+ * Commits selected in the commit browser, formatted for prompt injection
179
+ * (see `formatSelectedCommits`), or "none" when no selection is active.
180
+ * Context builds always set "none"; the browser flow overrides it at render.
181
+ */
182
+ selectedCommits: string;
147
183
  /** True when cwd is inside a git working tree (authoritative, not inferred from the branch name). */
148
184
  isGitRepo: boolean;
149
185
  }
@@ -166,6 +202,7 @@ export const PROMPT_CONTEXT_KEYS = [
166
202
  "repo",
167
203
  "staged_files",
168
204
  "unstaged_files",
205
+ "selected_commits",
169
206
  ] as const;
170
207
 
171
208
  /** A fully populated prompt context: one entry per PROMPT_CONTEXT_KEYS. */
@@ -193,6 +230,7 @@ export function toPromptContext(ctx: TaskContext): PromptContext {
193
230
  repo: ctx.repo,
194
231
  staged_files: formatFileLines(ctx.stagedFiles),
195
232
  unstaged_files: formatFileLines(ctx.unstagedFiles),
233
+ selected_commits: ctx.selectedCommits,
196
234
  };
197
235
  }
198
236
 
@@ -315,6 +353,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
315
353
  category: "Plan",
316
354
  description: "Review the current code changes (Plan)",
317
355
  requireDirty: true,
356
+ notForCommits: true,
318
357
  prompt:
319
358
  "Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). " +
320
359
  "Change summary: {{diff_stat}}. Last commit: {{last_commit}}. " +
@@ -326,6 +365,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
326
365
  name: "Review code",
327
366
  category: "Plan",
328
367
  description: "Review the whole project's code quality (Plan)",
368
+ notForCommits: true,
329
369
  prompt:
330
370
  "Review this project's code holistically: identify code smells, dead code, duplication, awkward architecture or patterns, maintainability issues, inconsistencies, and missing or unclear documentation. " +
331
371
  "Prioritize by impact, propose a plan for the fixes, and summarize your findings. Do not make any changes yet.",
@@ -393,9 +433,29 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
393
433
  name: "Propose features",
394
434
  category: "Plan",
395
435
  description: "Propose new features (Plan)",
436
+ notForCommits: true,
396
437
  prompt:
397
438
  "Review this project and propose new features that would add value. For each idea, describe the problem it solves, the user benefit, and a rough implementation approach. Prioritize by impact and effort. Do not make any changes yet. Try to evaluate how many lines this will be in terms of changes, whether this will break APIs, or introduce compatibility issues.",
398
439
  },
440
+ {
441
+ name: "Review commits",
442
+ category: "Plan",
443
+ description: "Review the selected commits (Plan)",
444
+ browser: "commits",
445
+ hidden: true,
446
+ prompt:
447
+ "Review the selected commits: {{selected_commits}}. Inspect each with `git show <hash>`, " +
448
+ "double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. " +
449
+ "Summarize your findings per commit and propose a plan for any fixes if needed. Do not make any changes yet.",
450
+ },
451
+ {
452
+ name: "Browse commits",
453
+ category: "Browse",
454
+ description: "Browse and select commits, then pick a task to run on them",
455
+ browser: "commits",
456
+ when: "git",
457
+ prompt: "Browse recent commits, select some, then pick a task to run on them.",
458
+ },
399
459
  ];
400
460
 
401
461
  /**
@@ -427,7 +487,7 @@ export function parseConfig(
427
487
  const tasks: DoAlwaysTask[] = [];
428
488
  for (const entry of list) {
429
489
  const t = entry as Partial<DoAlwaysTask> | null;
430
- if (t && typeof t.name === "string" && t.name.length > 0 && typeof t.prompt === "string" && t.prompt.length > 0) {
490
+ if (t && typeof t.name === "string" && t.name.length > 0 && typeof t.prompt === "string" && (t.prompt.length > 0 || t.autoRun === true)) {
431
491
  const task: DoAlwaysTask = {
432
492
  name: t.name,
433
493
  prompt: t.prompt,
@@ -436,6 +496,15 @@ export function parseConfig(
436
496
  if (typeof t.category === "string" && t.category.trim() !== "") task.category = t.category.trim();
437
497
  if (typeof t.autoRun === "boolean") task.autoRun = t.autoRun;
438
498
  if (typeof t.requireDirty === "boolean") task.requireDirty = t.requireDirty;
499
+ if (typeof t.hidden === "boolean") task.hidden = t.hidden;
500
+ if (typeof t.notForCommits === "boolean") task.notForCommits = t.notForCommits;
501
+ if (t.browser !== undefined) {
502
+ if (typeof t.browser === "string" && BROWSER_TYPES.includes(t.browser as BrowserType)) {
503
+ task.browser = t.browser as BrowserType;
504
+ } else {
505
+ onError(`do-always: ignoring invalid "browser" in ${path} (expected one of: ${BROWSER_TYPES.join(", ")})`);
506
+ }
507
+ }
439
508
  if (t.guards !== undefined) {
440
509
  if (Array.isArray(t.guards)) {
441
510
  const guards: Guard[] = [];
@@ -665,8 +734,17 @@ export function evaluateWhen(task: DoAlwaysTask, ctx: TaskContext): boolean {
665
734
  return true;
666
735
  }
667
736
 
737
+ /**
738
+ * Whether a task is visible in the selector and the lists: not `hidden` and
739
+ * its `when` condition passes. Single source of truth so the TUI selector and
740
+ * the `/do-always <n>` numbering can never diverge.
741
+ */
742
+ export function isTaskVisible(task: DoAlwaysTask, ctx: TaskContext): boolean {
743
+ return !task.hidden && evaluateWhen(task, ctx);
744
+ }
745
+
668
746
  /** Default order for category headers in the selector. Exported for tests. */
669
- export const DEFAULT_CATEGORY_ORDER = ["Plan", "Do", "Docs", "Ops", "Other"];
747
+ export const DEFAULT_CATEGORY_ORDER = ["Plan", "Browse", "Do", "Docs", "Ops", "Other"];
670
748
 
671
749
  /** A category group: a display name and the tasks that belong to it. */
672
750
  interface TaskGroup {
@@ -1263,3 +1341,153 @@ function outcomeToMarker(outcome: ChainStepOutcome): string {
1263
1341
  throw new Error(`unexpected outcome: ${outcome as string}`);
1264
1342
  }
1265
1343
  }
1344
+
1345
+ // ── Commit browser types ─────────────────────────────────────────────────
1346
+ //
1347
+ // Types and constants for the date-grouped commit browser (used by
1348
+ // the "Review commits" task in index.ts). Pure data — no Pi dependencies.
1349
+
1350
+ /** Maximum commits shown per page in the date-grouped browser. */
1351
+ export const COMMIT_BROWSER_MAX = 20;
1352
+
1353
+ /** Maximum commits that can be selected for a review chain. */
1354
+ export const COMMIT_SELECT_MAX = 20;
1355
+
1356
+ /** A single commit as returned by `git log --format`. */
1357
+ export interface CommitInfo {
1358
+ /** Full 40-char SHA. */
1359
+ hash: string;
1360
+ /** Short 7-char SHA. */
1361
+ shortHash: string;
1362
+ /** Commit subject line. */
1363
+ subject: string;
1364
+ /** Commit date (YYYY-MM-DD). */
1365
+ date: string;
1366
+ /** Commit author name. */
1367
+ author: string;
1368
+ /** Number of files changed in this commit. */
1369
+ filesChanged: number;
1370
+ /** Insertions in this commit. */
1371
+ insertions: number;
1372
+ /** Deletions in this commit. */
1373
+ deletions: number;
1374
+ }
1375
+
1376
+ /** A commit selected by the user for review. */
1377
+ export interface SelectedCommit extends CommitInfo {
1378
+ /** 1-based position in the user's selection order. */
1379
+ selectionOrder: number;
1380
+ }
1381
+
1382
+ /** Group of commits sharing the same date. */
1383
+ export interface DateGroup {
1384
+ /** Date string (YYYY-MM-DD). */
1385
+ date: string;
1386
+ /** Number of commits in this group. */
1387
+ count: number;
1388
+ /** Commits in this group (newest first). */
1389
+ commits: CommitInfo[];
1390
+ }
1391
+
1392
+ /**
1393
+ * Parse git log output formatted with:
1394
+ * `COMMIT%x09%H%x09%h%x09%s%x09%ad%x09%an` and optional `--shortstat`.
1395
+ */
1396
+ export function parseGitLogOutput(output: string): CommitInfo[] {
1397
+ if (!output) return [];
1398
+ const commits: CommitInfo[] = [];
1399
+ let current: CommitInfo | null = null;
1400
+ for (const rawLine of output.split("\n")) {
1401
+ const line = rawLine.trim();
1402
+ if (line.startsWith("COMMIT\t")) {
1403
+ if (current) commits.push(current);
1404
+ const parts = line.split("\t");
1405
+ if (parts.length >= 6) {
1406
+ const hash = parts[1];
1407
+ const shortHash = parts[2];
1408
+ const date = parts[parts.length - 2];
1409
+ const author = parts[parts.length - 1];
1410
+ const subject = parts.slice(3, parts.length - 2).join("\t") || "(no subject)";
1411
+ current = {
1412
+ hash,
1413
+ shortHash,
1414
+ subject,
1415
+ date,
1416
+ author,
1417
+ filesChanged: 0,
1418
+ insertions: 0,
1419
+ deletions: 0,
1420
+ };
1421
+ } else {
1422
+ current = null;
1423
+ }
1424
+ continue;
1425
+ }
1426
+ if (current && line.includes("changed")) {
1427
+ const mFiles = line.match(/(\d+) file/);
1428
+ const mIns = line.match(/(\d+) insertion/);
1429
+ const mDel = line.match(/(\d+) deletion/);
1430
+ if (mFiles) current.filesChanged = Number(mFiles[1]);
1431
+ if (mIns) current.insertions = Number(mIns[1]);
1432
+ if (mDel) current.deletions = Number(mDel[1]);
1433
+ }
1434
+ }
1435
+ if (current) commits.push(current);
1436
+ return commits;
1437
+ }
1438
+
1439
+ /** Group commits by date (preserving existing order within and between groups). */
1440
+ export function groupCommitsByDate(commits: CommitInfo[]): DateGroup[] {
1441
+ const groups = new Map<string, CommitInfo[]>();
1442
+ for (const c of commits) {
1443
+ if (!groups.has(c.date)) groups.set(c.date, []);
1444
+ groups.get(c.date)!.push(c);
1445
+ }
1446
+ return [...groups.entries()].map(([date, groupCommits]) => ({
1447
+ date,
1448
+ count: groupCommits.length,
1449
+ commits: groupCommits,
1450
+ }));
1451
+ }
1452
+
1453
+ /**
1454
+ * Format the selected commits as a numbered detail block (hash, subject,
1455
+ * author, date, stats per commit). Injected into task prompts via the
1456
+ * `{{selected_commits}}` placeholder; composed into the full review prompt by
1457
+ * `formatCommitReviewPrompt`.
1458
+ */
1459
+ export function formatSelectedCommits(commits: SelectedCommit[]): string {
1460
+ const count = commits.length;
1461
+ return commits
1462
+ .map((c, i) => {
1463
+ const num = count > 1 ? `${i + 1}. ` : "";
1464
+ return (
1465
+ `${num}Commit ${c.shortHash} (${c.hash})\n` +
1466
+ ` Subject: ${c.subject}\n` +
1467
+ ` Author: ${c.author} on ${c.date}\n` +
1468
+ ` Stats: ${c.filesChanged} file${c.filesChanged !== 1 ? "s" : ""} changed, +${c.insertions}/-${c.deletions} lines`
1469
+ );
1470
+ })
1471
+ .join("\n\n");
1472
+ }
1473
+
1474
+ /**
1475
+ * Build the review prompt for the selected commits to send to the agent.
1476
+ */
1477
+ export function formatCommitReviewPrompt(commits: SelectedCommit[]): string {
1478
+ const count = commits.length;
1479
+ const commitDetails = formatSelectedCommits(commits);
1480
+
1481
+ const showCmd = count === 1 ? `git show ${commits[0].hash}` : "git show <hash>";
1482
+
1483
+ return (
1484
+ `Review the following git commit${count !== 1 ? "s" : ""}:\n\n` +
1485
+ `${commitDetails}\n\n` +
1486
+ `Please inspect the changes using \`${showCmd}\` (and any related files in the repository). ` +
1487
+ `Double-check the changes for:\n` +
1488
+ `1. Bugs, edge cases, potential regressions, and logic errors.\n` +
1489
+ `2. Code quality, security, and unintended side effects.\n` +
1490
+ `3. Consistency with surrounding project patterns and tests.\n\n` +
1491
+ `Summarize your findings for each commit and propose a plan for any fixes if needed. Do not make changes yet.`
1492
+ );
1493
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-do-always",
3
- "version": "0.12.0",
3
+ "version": "0.13.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": {
package/pi.image.png CHANGED
Binary file