pi-do-always 0.10.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.
@@ -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,26 +59,43 @@ 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
 
72
+ /** The set of known guard types (used for validation at parse time). */
73
+ export const GUARD_TYPES = [
74
+ "requireDirty",
75
+ "requireBranch",
76
+ "requireRepo",
77
+ "requireFilePattern",
78
+ ] as const;
79
+
80
+ export type GuardType = (typeof GUARD_TYPES)[number];
81
+
49
82
  /**
50
83
  * A selection-time guard that blocks a task when its condition is not met.
51
84
  * The task stays visible but selecting it notifies instead of injecting.
52
85
  * `requireDirty` needs no `value`; the others require a string `value`.
53
86
  */
54
- interface Guard {
55
- type: "requireDirty" | "requireBranch" | "requireRepo" | "requireFilePattern";
87
+ export interface Guard {
88
+ type: GuardType;
56
89
  value?: string;
57
90
  }
58
91
 
59
- /** The set of known guard types (used for validation at parse time). */
60
- const GUARD_TYPES = [
61
- "requireDirty",
62
- "requireBranch",
63
- "requireRepo",
64
- "requireFilePattern",
92
+ /** The set of known browser types (used for validation at parse time). */
93
+ export const BROWSER_TYPES = [
94
+ "commits",
65
95
  ] as const;
66
96
 
97
+ export type BrowserType = (typeof BROWSER_TYPES)[number];
98
+
67
99
  /**
68
100
  * A config file can be a bare array of tasks, or {"tasks": [...], "shortcut": ...}.
69
101
  * `shortcut` is a key id string (e.g. "f4", "ctrl+shift+p"), or null to disable
@@ -142,6 +174,12 @@ export interface TaskContext {
142
174
  stagedFiles: string[];
143
175
  /** Modified-but-unstaged files. */
144
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;
145
183
  /** True when cwd is inside a git working tree (authoritative, not inferred from the branch name). */
146
184
  isGitRepo: boolean;
147
185
  }
@@ -164,12 +202,13 @@ export const PROMPT_CONTEXT_KEYS = [
164
202
  "repo",
165
203
  "staged_files",
166
204
  "unstaged_files",
205
+ "selected_commits",
167
206
  ] as const;
168
207
 
169
208
  /** A fully populated prompt context: one entry per PROMPT_CONTEXT_KEYS. */
170
209
  export type PromptContext = Record<(typeof PROMPT_CONTEXT_KEYS)[number], string>;
171
210
 
172
- /** Max number of file paths listed in the `files_changed` string view (the count stays exact). */
211
+ /** Max number of file paths listed in the `files_changed` string view (the count stays exact). Exported for tests. */
173
212
  export const MAX_FILES_LISTED = 20;
174
213
 
175
214
  /**
@@ -191,9 +230,13 @@ export function toPromptContext(ctx: TaskContext): PromptContext {
191
230
  repo: ctx.repo,
192
231
  staged_files: formatFileLines(ctx.stagedFiles),
193
232
  unstaged_files: formatFileLines(ctx.unstagedFiles),
233
+ selected_commits: ctx.selectedCommits,
194
234
  };
195
235
  }
196
236
 
237
+ /** Max number of file paths listed in the `staged_files` / `unstaged_files` string views. Exported for tests. */
238
+ export const MAX_FILE_LINES = 50;
239
+
197
240
  /** Comma-joined list, capped at MAX_FILES_LISTED entries; "none" when empty. */
198
241
  function formatFileList(files: string[]): string {
199
242
  if (files.length === 0) return "none";
@@ -203,9 +246,31 @@ function formatFileList(files: string[]): string {
203
246
  return files.join(", ");
204
247
  }
205
248
 
206
- /** Newline-joined list; "none" when empty. */
249
+ /** Newline-joined list, capped at MAX_FILE_LINES entries; "none" when empty. */
207
250
  function formatFileLines(files: string[]): string {
208
- return files.length === 0 ? "none" : files.join("\n");
251
+ if (files.length === 0) return "none";
252
+ if (files.length > MAX_FILE_LINES) {
253
+ const shown = files.slice(0, MAX_FILE_LINES);
254
+ const remaining = files.length - MAX_FILE_LINES;
255
+ return [...shown, `… (+${remaining} more)`].join("\n");
256
+ }
257
+ return files.join("\n");
258
+ }
259
+
260
+ /**
261
+ * Extract and normalize a file path from a git status porcelain line (v1).
262
+ * Handles rename targets (`old -> new`) and unquotes quoted paths (`"file with space"`).
263
+ */
264
+ function extractPorcelainPath(line: string): string | null {
265
+ if (line.length < 4) return null;
266
+ let path = line.slice(3).trim();
267
+ if (path.includes(" -> ")) {
268
+ path = path.split(" -> ").pop()!.trim();
269
+ }
270
+ if (path.startsWith('"') && path.endsWith('"') && path.length >= 2) {
271
+ path = path.slice(1, -1).replace(/\\"/g, '"');
272
+ }
273
+ return path || null;
209
274
  }
210
275
 
211
276
  /**
@@ -215,19 +280,70 @@ function formatFileLines(files: string[]): string {
215
280
  */
216
281
  export function parseStatusPorcelain(status: string): string[] {
217
282
  const files: string[] = [];
283
+ const seen = new Set<string>();
218
284
  for (const line of status.split("\n")) {
219
- if (line.length < 4) continue;
220
- const path = line.slice(3);
221
- if (path && !files.includes(path)) files.push(path);
285
+ const path = extractPorcelainPath(line);
286
+ if (path && !seen.has(path)) {
287
+ seen.add(path);
288
+ files.push(path);
289
+ }
222
290
  }
223
- files.sort();
291
+ files.sort((a, b) => a.localeCompare(b));
224
292
  return files;
225
293
  }
226
294
 
227
- /** Split raw `git diff --name-only` output into file paths (trimmed, non-empty lines). */
228
- export function splitFileLines(raw: string | undefined): string[] {
229
- if (!raw) return [];
230
- return raw.split("\n").map((line) => line.trim()).filter(Boolean);
295
+ /**
296
+ * Split `git status --porcelain` (v1) output into staged and unstaged file
297
+ * lists. Lines are "XY <path>" (X = index, Y = worktree; the path starts at
298
+ * index 3): a space in the X column means the change is unstaged (worktree
299
+ * only), anything else is staged or untracked. Short lines are skipped and
300
+ * paths are deduplicated across both lists.
301
+ */
302
+ export function parseStatusStagedUnstaged(
303
+ status: string,
304
+ ): { staged: string[]; unstaged: string[] } {
305
+ const staged: string[] = [];
306
+ const unstaged: string[] = [];
307
+ const seen = new Set<string>();
308
+ for (const line of status.split("\n")) {
309
+ const path = extractPorcelainPath(line);
310
+ if (!path || seen.has(path)) continue;
311
+ seen.add(path);
312
+ if (line[0] === " ") unstaged.push(path);
313
+ else staged.push(path);
314
+ }
315
+ return { staged, unstaged };
316
+ }
317
+
318
+ /**
319
+ * Extract the commit subject from a `git log --format=%H %s` line
320
+ * ("<hash> <subject>"). The subject may contain spaces, so everything after
321
+ * the first space is the subject. Returns "unknown" when the line is missing
322
+ * or carries no subject.
323
+ */
324
+ export function parseCommitSubject(commitLine: string | undefined): string {
325
+ if (!commitLine) return "unknown";
326
+ const space = commitLine.indexOf(" ");
327
+ return space > 0 ? commitLine.slice(space + 1) || "unknown" : "unknown";
328
+ }
329
+
330
+ /**
331
+ * Extract one key's value from `git config --get-regexp` output (one
332
+ * "key value" pair per line). The value may contain spaces (e.g.
333
+ * user.name "John Doe"), so everything after the first space is the value.
334
+ * Returns undefined when the key is absent or its value is empty.
335
+ */
336
+ export function parseConfigRegexpValueForKey(
337
+ raw: string | undefined,
338
+ key: string,
339
+ ): string | undefined {
340
+ if (!raw) return undefined;
341
+ for (const line of raw.split("\n")) {
342
+ if (!line.startsWith(key + " ")) continue;
343
+ const value = line.slice(key.length + 1);
344
+ return value || undefined;
345
+ }
346
+ return undefined;
231
347
  }
232
348
 
233
349
  /** Used when neither config file defines any task. */
@@ -237,17 +353,19 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
237
353
  category: "Plan",
238
354
  description: "Review the current code changes (Plan)",
239
355
  requireDirty: true,
356
+ notForCommits: true,
240
357
  prompt:
241
358
  "Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). " +
242
359
  "Change summary: {{diff_stat}}. Last commit: {{last_commit}}. " +
243
360
  "Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, " +
244
361
  "edge cases, security issues, and consistency with the rest of the codebase. " +
245
- "Do a plan proposal for the fixes if needed. Do a summary of your findings",
362
+ "Do a plan proposal for the fixes if needed. Do a summary of your findings.",
246
363
  },
247
364
  {
248
365
  name: "Review code",
249
366
  category: "Plan",
250
367
  description: "Review the whole project's code quality (Plan)",
368
+ notForCommits: true,
251
369
  prompt:
252
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. " +
253
371
  "Prioritize by impact, propose a plan for the fixes, and summarize your findings. Do not make any changes yet.",
@@ -315,8 +433,28 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
315
433
  name: "Propose features",
316
434
  category: "Plan",
317
435
  description: "Propose new features (Plan)",
436
+ notForCommits: true,
318
437
  prompt:
319
- "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 term of changes, if this will breaks API, compatibility issue.",
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.",
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.",
320
458
  },
321
459
  ];
322
460
 
@@ -349,7 +487,7 @@ export function parseConfig(
349
487
  const tasks: DoAlwaysTask[] = [];
350
488
  for (const entry of list) {
351
489
  const t = entry as Partial<DoAlwaysTask> | null;
352
- 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)) {
353
491
  const task: DoAlwaysTask = {
354
492
  name: t.name,
355
493
  prompt: t.prompt,
@@ -358,6 +496,15 @@ export function parseConfig(
358
496
  if (typeof t.category === "string" && t.category.trim() !== "") task.category = t.category.trim();
359
497
  if (typeof t.autoRun === "boolean") task.autoRun = t.autoRun;
360
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
+ }
361
508
  if (t.guards !== undefined) {
362
509
  if (Array.isArray(t.guards)) {
363
510
  const guards: Guard[] = [];
@@ -587,8 +734,17 @@ export function evaluateWhen(task: DoAlwaysTask, ctx: TaskContext): boolean {
587
734
  return true;
588
735
  }
589
736
 
590
- /** Default order for category headers in the selector. */
591
- export const DEFAULT_CATEGORY_ORDER = ["Plan", "Do", "Docs", "Ops", "Other"];
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
+
746
+ /** Default order for category headers in the selector. Exported for tests. */
747
+ export const DEFAULT_CATEGORY_ORDER = ["Plan", "Browse", "Do", "Docs", "Ops", "Other"];
592
748
 
593
749
  /** A category group: a display name and the tasks that belong to it. */
594
750
  interface TaskGroup {
@@ -606,6 +762,11 @@ function titleCase(s: string): string {
606
762
  * `order` (case-insensitive), then alphabetically; the original order within a
607
763
  * group is preserved. The group `name` is the title-cased category. Tasks
608
764
  * without a (non-empty) category fall under "Other".
765
+ *
766
+ * Not memoized: every caller passes a fresh array (selector open, config
767
+ * load, list), so an identity-keyed cache would never hit — and would go
768
+ * stale if a caller ever mutated its array in place. The input is small
769
+ * (a dozen tasks), so recomputing is cheap.
609
770
  */
610
771
  export function groupTasksByCategory(
611
772
  tasks: DoAlwaysTask[],
@@ -898,20 +1059,6 @@ export function chainUndo(state: ChainState): { state: ChainState; removed: stri
898
1059
  return { state, removed: null };
899
1060
  }
900
1061
 
901
- /**
902
- * Move a task one position up (-1) or down (1) in the chain. No-op at the
903
- * ends or when the name is not in the chain.
904
- */
905
- export function chainMove(state: ChainState, name: string, dir: -1 | 1): ChainState {
906
- const idx = state.items.indexOf(name);
907
- const target = idx + dir;
908
- if (idx < 0 || target < 0 || target >= state.items.length) return state;
909
- const items = [...state.items];
910
- items[idx] = items[target];
911
- items[target] = name;
912
- return { ...state, items };
913
- }
914
-
915
1062
  /**
916
1063
  * Label for the pinned Run row: a dimmed placeholder for an empty chain,
917
1064
  * singular for one task, plural with the count otherwise.
@@ -1007,7 +1154,7 @@ function reportTime(d: Date): string {
1007
1154
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1008
1155
  }
1009
1156
 
1010
- /** File name for one chain run's report, e.g. do-always-report-tasks-2025-01-15-1432.md. */
1157
+ /** File name for one chain run's report, e.g. do-always-report-tasks-2025-01-15-1432.md. Exported for tests. */
1011
1158
  export function reportFileName(now: Date): string {
1012
1159
  const p = (n: number) => String(n).padStart(2, "0");
1013
1160
  return `do-always-report-tasks-${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.md`;
@@ -1113,3 +1260,234 @@ export function assistantText(
1113
1260
  .flatMap((part) => (part && part.type === "text" && typeof part.text === "string" ? [part.text] : []))
1114
1261
  .join("\n");
1115
1262
  }
1263
+
1264
+ // ── Chain step summary ───────────────────────────────────────────────────
1265
+ //
1266
+ // Compact per-step and chain-end summary strings for notifications.
1267
+ // These are derived from the existing report data (outcome, timing, file count)
1268
+ // and provide immediate, scannable feedback after each chain step.
1269
+
1270
+ /** Outcome of one chain step's run (see `sendAndWait`). */
1271
+ export type ChainStepOutcome = "completed" | "aborted" | "error" | "failed-to-start";
1272
+
1273
+ /**
1274
+ * Generate a compact per-step summary string for notifications.
1275
+ * Examples:
1276
+ * "✓ Build — 3 files changed — 2m14s"
1277
+ * "✗ Tests — 45s"
1278
+ * "⊘ Review changes"
1279
+ *
1280
+ * @param outcome the step outcome
1281
+ * @param stepName the task name
1282
+ * @param durationMs how long the step took (0 if not timed)
1283
+ * @param fileCount number of changed files after the step (0 if unknown)
1284
+ * @returns the summary string
1285
+ */
1286
+ export function stepSummary(
1287
+ outcome: ChainStepOutcome,
1288
+ stepName: string,
1289
+ durationMs: number,
1290
+ fileCount: number,
1291
+ ): string {
1292
+ const marker = outcomeToMarker(outcome);
1293
+ const parts: string[] = [marker, stepName];
1294
+ if (fileCount > 0) {
1295
+ parts.push(`${fileCount} file${fileCount === 1 ? "" : "s"} changed`);
1296
+ }
1297
+ if (durationMs > 0) {
1298
+ parts.push(formatDuration(durationMs));
1299
+ }
1300
+ // Only add " — " separator when there are parts beyond marker+name.
1301
+ if (parts.length > 2) {
1302
+ return `${marker} ${stepName} — ${parts.slice(2).join(" — ")}`;
1303
+ }
1304
+ return `${marker} ${stepName}`;
1305
+ }
1306
+
1307
+ /**
1308
+ * Generate a compact chain-end summary string, e.g.
1309
+ * "✅ 4/4 steps completed in 6m42s". Only reachable when every step
1310
+ * completed — the stop paths return early with their own per-step
1311
+ * notification, so `completed` always equals `total` here.
1312
+ */
1313
+ export function chainSummary(completed: number, total: number, totalMs: number): string {
1314
+ const time = totalMs > 0 ? formatDuration(totalMs) : "";
1315
+ return `✅ ${completed}/${total} steps completed${time ? ` in ${time}` : ""}`;
1316
+ }
1317
+
1318
+ /** Format milliseconds to a human-readable duration string. Exported for tests. */
1319
+ export function formatDuration(ms: number): string {
1320
+ if (ms < 1000) return `${ms}ms`;
1321
+ const s = Math.floor(ms / 1000);
1322
+ if (s < 60) return `${s}s`;
1323
+ const m = Math.floor(s / 60);
1324
+ const rem = s % 60;
1325
+ return rem > 0 ? `${m}m${rem}s` : `${m}m`;
1326
+ }
1327
+
1328
+ /** Map a step outcome to its display marker character. */
1329
+ function outcomeToMarker(outcome: ChainStepOutcome): string {
1330
+ switch (outcome) {
1331
+ case "completed":
1332
+ return "✓";
1333
+ case "aborted":
1334
+ return "⊘";
1335
+ case "error":
1336
+ return "✗";
1337
+ case "failed-to-start":
1338
+ return "✗";
1339
+ default:
1340
+ // Exhaustive check: the type is a literal union, so this is unreachable.
1341
+ throw new Error(`unexpected outcome: ${outcome as string}`);
1342
+ }
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.10.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