pi-do-always 0.12.0 → 0.15.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.
@@ -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.",
@@ -378,7 +418,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
378
418
  description: "Prepare a release (version, changelog, tag)",
379
419
  when: "git",
380
420
  prompt:
381
- "Prepare a release for this project (branch {{branch}}): check `git log` since the last tag, update the version in package.json (or the equivalent location), add a changelog entry summarizing the changes, and create a git tag if git present. Do not push.",
421
+ "Prepare a release for this project (branch {{branch}}): check `git log` since the last tag, then bump the version in package.json (or the equivalent location) to the next version, and add a changelog entry under that exact version summarizing the changes. The changelog entry must use the same version number now set in package.json — never add a changelog section for a version that package.json does not yet contain, and never leave an 'Unreleased' or placeholder version heading. Create a git tag if git present. Do not push.",
382
422
  },
383
423
  {
384
424
  name: "Commit",
@@ -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.15.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