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.
- package/README.md +44 -36
- package/extensions/pi-do-always/do-always.json +21 -2
- package/extensions/pi-do-always/index.ts +1199 -214
- package/extensions/pi-do-always/tasks.ts +417 -39
- package/package.json +1 -1
- package/pi.image.png +0 -0
|
@@ -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:
|
|
87
|
+
export interface Guard {
|
|
88
|
+
type: GuardType;
|
|
56
89
|
value?: string;
|
|
57
90
|
}
|
|
58
91
|
|
|
59
|
-
/** The set of known
|
|
60
|
-
const
|
|
61
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
/**
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
-
/**
|
|
591
|
-
|
|
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
package/pi.image.png
CHANGED
|
Binary file
|