pi-do-always 0.10.0 → 0.12.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 +13 -11
- package/extensions/pi-do-always/do-always.json +2 -2
- package/extensions/pi-do-always/index.ts +480 -205
- package/extensions/pi-do-always/tasks.ts +189 -39
- package/package.json +1 -1
|
@@ -46,24 +46,26 @@ export interface DoAlwaysTask {
|
|
|
46
46
|
guards?: Guard[];
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** The set of known guard types (used for validation at parse time). */
|
|
50
|
+
export const GUARD_TYPES = [
|
|
51
|
+
"requireDirty",
|
|
52
|
+
"requireBranch",
|
|
53
|
+
"requireRepo",
|
|
54
|
+
"requireFilePattern",
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
export type GuardType = (typeof GUARD_TYPES)[number];
|
|
58
|
+
|
|
49
59
|
/**
|
|
50
60
|
* A selection-time guard that blocks a task when its condition is not met.
|
|
51
61
|
* The task stays visible but selecting it notifies instead of injecting.
|
|
52
62
|
* `requireDirty` needs no `value`; the others require a string `value`.
|
|
53
63
|
*/
|
|
54
|
-
interface Guard {
|
|
55
|
-
type:
|
|
64
|
+
export interface Guard {
|
|
65
|
+
type: GuardType;
|
|
56
66
|
value?: string;
|
|
57
67
|
}
|
|
58
68
|
|
|
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",
|
|
65
|
-
] as const;
|
|
66
|
-
|
|
67
69
|
/**
|
|
68
70
|
* A config file can be a bare array of tasks, or {"tasks": [...], "shortcut": ...}.
|
|
69
71
|
* `shortcut` is a key id string (e.g. "f4", "ctrl+shift+p"), or null to disable
|
|
@@ -169,7 +171,7 @@ export const PROMPT_CONTEXT_KEYS = [
|
|
|
169
171
|
/** A fully populated prompt context: one entry per PROMPT_CONTEXT_KEYS. */
|
|
170
172
|
export type PromptContext = Record<(typeof PROMPT_CONTEXT_KEYS)[number], string>;
|
|
171
173
|
|
|
172
|
-
/** Max number of file paths listed in the `files_changed` string view (the count stays exact). */
|
|
174
|
+
/** Max number of file paths listed in the `files_changed` string view (the count stays exact). Exported for tests. */
|
|
173
175
|
export const MAX_FILES_LISTED = 20;
|
|
174
176
|
|
|
175
177
|
/**
|
|
@@ -194,6 +196,9 @@ export function toPromptContext(ctx: TaskContext): PromptContext {
|
|
|
194
196
|
};
|
|
195
197
|
}
|
|
196
198
|
|
|
199
|
+
/** Max number of file paths listed in the `staged_files` / `unstaged_files` string views. Exported for tests. */
|
|
200
|
+
export const MAX_FILE_LINES = 50;
|
|
201
|
+
|
|
197
202
|
/** Comma-joined list, capped at MAX_FILES_LISTED entries; "none" when empty. */
|
|
198
203
|
function formatFileList(files: string[]): string {
|
|
199
204
|
if (files.length === 0) return "none";
|
|
@@ -203,9 +208,31 @@ function formatFileList(files: string[]): string {
|
|
|
203
208
|
return files.join(", ");
|
|
204
209
|
}
|
|
205
210
|
|
|
206
|
-
/** Newline-joined list; "none" when empty. */
|
|
211
|
+
/** Newline-joined list, capped at MAX_FILE_LINES entries; "none" when empty. */
|
|
207
212
|
function formatFileLines(files: string[]): string {
|
|
208
|
-
|
|
213
|
+
if (files.length === 0) return "none";
|
|
214
|
+
if (files.length > MAX_FILE_LINES) {
|
|
215
|
+
const shown = files.slice(0, MAX_FILE_LINES);
|
|
216
|
+
const remaining = files.length - MAX_FILE_LINES;
|
|
217
|
+
return [...shown, `… (+${remaining} more)`].join("\n");
|
|
218
|
+
}
|
|
219
|
+
return files.join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Extract and normalize a file path from a git status porcelain line (v1).
|
|
224
|
+
* Handles rename targets (`old -> new`) and unquotes quoted paths (`"file with space"`).
|
|
225
|
+
*/
|
|
226
|
+
function extractPorcelainPath(line: string): string | null {
|
|
227
|
+
if (line.length < 4) return null;
|
|
228
|
+
let path = line.slice(3).trim();
|
|
229
|
+
if (path.includes(" -> ")) {
|
|
230
|
+
path = path.split(" -> ").pop()!.trim();
|
|
231
|
+
}
|
|
232
|
+
if (path.startsWith('"') && path.endsWith('"') && path.length >= 2) {
|
|
233
|
+
path = path.slice(1, -1).replace(/\\"/g, '"');
|
|
234
|
+
}
|
|
235
|
+
return path || null;
|
|
209
236
|
}
|
|
210
237
|
|
|
211
238
|
/**
|
|
@@ -215,19 +242,70 @@ function formatFileLines(files: string[]): string {
|
|
|
215
242
|
*/
|
|
216
243
|
export function parseStatusPorcelain(status: string): string[] {
|
|
217
244
|
const files: string[] = [];
|
|
245
|
+
const seen = new Set<string>();
|
|
218
246
|
for (const line of status.split("\n")) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
247
|
+
const path = extractPorcelainPath(line);
|
|
248
|
+
if (path && !seen.has(path)) {
|
|
249
|
+
seen.add(path);
|
|
250
|
+
files.push(path);
|
|
251
|
+
}
|
|
222
252
|
}
|
|
223
|
-
files.sort();
|
|
253
|
+
files.sort((a, b) => a.localeCompare(b));
|
|
224
254
|
return files;
|
|
225
255
|
}
|
|
226
256
|
|
|
227
|
-
/**
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Split `git status --porcelain` (v1) output into staged and unstaged file
|
|
259
|
+
* lists. Lines are "XY <path>" (X = index, Y = worktree; the path starts at
|
|
260
|
+
* index 3): a space in the X column means the change is unstaged (worktree
|
|
261
|
+
* only), anything else is staged or untracked. Short lines are skipped and
|
|
262
|
+
* paths are deduplicated across both lists.
|
|
263
|
+
*/
|
|
264
|
+
export function parseStatusStagedUnstaged(
|
|
265
|
+
status: string,
|
|
266
|
+
): { staged: string[]; unstaged: string[] } {
|
|
267
|
+
const staged: string[] = [];
|
|
268
|
+
const unstaged: string[] = [];
|
|
269
|
+
const seen = new Set<string>();
|
|
270
|
+
for (const line of status.split("\n")) {
|
|
271
|
+
const path = extractPorcelainPath(line);
|
|
272
|
+
if (!path || seen.has(path)) continue;
|
|
273
|
+
seen.add(path);
|
|
274
|
+
if (line[0] === " ") unstaged.push(path);
|
|
275
|
+
else staged.push(path);
|
|
276
|
+
}
|
|
277
|
+
return { staged, unstaged };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Extract the commit subject from a `git log --format=%H %s` line
|
|
282
|
+
* ("<hash> <subject>"). The subject may contain spaces, so everything after
|
|
283
|
+
* the first space is the subject. Returns "unknown" when the line is missing
|
|
284
|
+
* or carries no subject.
|
|
285
|
+
*/
|
|
286
|
+
export function parseCommitSubject(commitLine: string | undefined): string {
|
|
287
|
+
if (!commitLine) return "unknown";
|
|
288
|
+
const space = commitLine.indexOf(" ");
|
|
289
|
+
return space > 0 ? commitLine.slice(space + 1) || "unknown" : "unknown";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Extract one key's value from `git config --get-regexp` output (one
|
|
294
|
+
* "key value" pair per line). The value may contain spaces (e.g.
|
|
295
|
+
* user.name "John Doe"), so everything after the first space is the value.
|
|
296
|
+
* Returns undefined when the key is absent or its value is empty.
|
|
297
|
+
*/
|
|
298
|
+
export function parseConfigRegexpValueForKey(
|
|
299
|
+
raw: string | undefined,
|
|
300
|
+
key: string,
|
|
301
|
+
): string | undefined {
|
|
302
|
+
if (!raw) return undefined;
|
|
303
|
+
for (const line of raw.split("\n")) {
|
|
304
|
+
if (!line.startsWith(key + " ")) continue;
|
|
305
|
+
const value = line.slice(key.length + 1);
|
|
306
|
+
return value || undefined;
|
|
307
|
+
}
|
|
308
|
+
return undefined;
|
|
231
309
|
}
|
|
232
310
|
|
|
233
311
|
/** Used when neither config file defines any task. */
|
|
@@ -242,7 +320,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
|
242
320
|
"Change summary: {{diff_stat}}. Last commit: {{last_commit}}. " +
|
|
243
321
|
"Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, " +
|
|
244
322
|
"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",
|
|
323
|
+
"Do a plan proposal for the fixes if needed. Do a summary of your findings.",
|
|
246
324
|
},
|
|
247
325
|
{
|
|
248
326
|
name: "Review code",
|
|
@@ -316,7 +394,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
|
316
394
|
category: "Plan",
|
|
317
395
|
description: "Propose new features (Plan)",
|
|
318
396
|
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
|
|
397
|
+
"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.",
|
|
320
398
|
},
|
|
321
399
|
];
|
|
322
400
|
|
|
@@ -587,7 +665,7 @@ export function evaluateWhen(task: DoAlwaysTask, ctx: TaskContext): boolean {
|
|
|
587
665
|
return true;
|
|
588
666
|
}
|
|
589
667
|
|
|
590
|
-
/** Default order for category headers in the selector. */
|
|
668
|
+
/** Default order for category headers in the selector. Exported for tests. */
|
|
591
669
|
export const DEFAULT_CATEGORY_ORDER = ["Plan", "Do", "Docs", "Ops", "Other"];
|
|
592
670
|
|
|
593
671
|
/** A category group: a display name and the tasks that belong to it. */
|
|
@@ -606,6 +684,11 @@ function titleCase(s: string): string {
|
|
|
606
684
|
* `order` (case-insensitive), then alphabetically; the original order within a
|
|
607
685
|
* group is preserved. The group `name` is the title-cased category. Tasks
|
|
608
686
|
* without a (non-empty) category fall under "Other".
|
|
687
|
+
*
|
|
688
|
+
* Not memoized: every caller passes a fresh array (selector open, config
|
|
689
|
+
* load, list), so an identity-keyed cache would never hit — and would go
|
|
690
|
+
* stale if a caller ever mutated its array in place. The input is small
|
|
691
|
+
* (a dozen tasks), so recomputing is cheap.
|
|
609
692
|
*/
|
|
610
693
|
export function groupTasksByCategory(
|
|
611
694
|
tasks: DoAlwaysTask[],
|
|
@@ -898,20 +981,6 @@ export function chainUndo(state: ChainState): { state: ChainState; removed: stri
|
|
|
898
981
|
return { state, removed: null };
|
|
899
982
|
}
|
|
900
983
|
|
|
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
984
|
/**
|
|
916
985
|
* Label for the pinned Run row: a dimmed placeholder for an empty chain,
|
|
917
986
|
* singular for one task, plural with the count otherwise.
|
|
@@ -1007,7 +1076,7 @@ function reportTime(d: Date): string {
|
|
|
1007
1076
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
1008
1077
|
}
|
|
1009
1078
|
|
|
1010
|
-
/** File name for one chain run's report, e.g. do-always-report-tasks-2025-01-15-1432.md. */
|
|
1079
|
+
/** File name for one chain run's report, e.g. do-always-report-tasks-2025-01-15-1432.md. Exported for tests. */
|
|
1011
1080
|
export function reportFileName(now: Date): string {
|
|
1012
1081
|
const p = (n: number) => String(n).padStart(2, "0");
|
|
1013
1082
|
return `do-always-report-tasks-${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.md`;
|
|
@@ -1113,3 +1182,84 @@ export function assistantText(
|
|
|
1113
1182
|
.flatMap((part) => (part && part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
|
1114
1183
|
.join("\n");
|
|
1115
1184
|
}
|
|
1185
|
+
|
|
1186
|
+
// ── Chain step summary ───────────────────────────────────────────────────
|
|
1187
|
+
//
|
|
1188
|
+
// Compact per-step and chain-end summary strings for notifications.
|
|
1189
|
+
// These are derived from the existing report data (outcome, timing, file count)
|
|
1190
|
+
// and provide immediate, scannable feedback after each chain step.
|
|
1191
|
+
|
|
1192
|
+
/** Outcome of one chain step's run (see `sendAndWait`). */
|
|
1193
|
+
export type ChainStepOutcome = "completed" | "aborted" | "error" | "failed-to-start";
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Generate a compact per-step summary string for notifications.
|
|
1197
|
+
* Examples:
|
|
1198
|
+
* "✓ Build — 3 files changed — 2m14s"
|
|
1199
|
+
* "✗ Tests — 45s"
|
|
1200
|
+
* "⊘ Review changes"
|
|
1201
|
+
*
|
|
1202
|
+
* @param outcome the step outcome
|
|
1203
|
+
* @param stepName the task name
|
|
1204
|
+
* @param durationMs how long the step took (0 if not timed)
|
|
1205
|
+
* @param fileCount number of changed files after the step (0 if unknown)
|
|
1206
|
+
* @returns the summary string
|
|
1207
|
+
*/
|
|
1208
|
+
export function stepSummary(
|
|
1209
|
+
outcome: ChainStepOutcome,
|
|
1210
|
+
stepName: string,
|
|
1211
|
+
durationMs: number,
|
|
1212
|
+
fileCount: number,
|
|
1213
|
+
): string {
|
|
1214
|
+
const marker = outcomeToMarker(outcome);
|
|
1215
|
+
const parts: string[] = [marker, stepName];
|
|
1216
|
+
if (fileCount > 0) {
|
|
1217
|
+
parts.push(`${fileCount} file${fileCount === 1 ? "" : "s"} changed`);
|
|
1218
|
+
}
|
|
1219
|
+
if (durationMs > 0) {
|
|
1220
|
+
parts.push(formatDuration(durationMs));
|
|
1221
|
+
}
|
|
1222
|
+
// Only add " — " separator when there are parts beyond marker+name.
|
|
1223
|
+
if (parts.length > 2) {
|
|
1224
|
+
return `${marker} ${stepName} — ${parts.slice(2).join(" — ")}`;
|
|
1225
|
+
}
|
|
1226
|
+
return `${marker} ${stepName}`;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Generate a compact chain-end summary string, e.g.
|
|
1231
|
+
* "✅ 4/4 steps completed in 6m42s". Only reachable when every step
|
|
1232
|
+
* completed — the stop paths return early with their own per-step
|
|
1233
|
+
* notification, so `completed` always equals `total` here.
|
|
1234
|
+
*/
|
|
1235
|
+
export function chainSummary(completed: number, total: number, totalMs: number): string {
|
|
1236
|
+
const time = totalMs > 0 ? formatDuration(totalMs) : "";
|
|
1237
|
+
return `✅ ${completed}/${total} steps completed${time ? ` in ${time}` : ""}`;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** Format milliseconds to a human-readable duration string. Exported for tests. */
|
|
1241
|
+
export function formatDuration(ms: number): string {
|
|
1242
|
+
if (ms < 1000) return `${ms}ms`;
|
|
1243
|
+
const s = Math.floor(ms / 1000);
|
|
1244
|
+
if (s < 60) return `${s}s`;
|
|
1245
|
+
const m = Math.floor(s / 60);
|
|
1246
|
+
const rem = s % 60;
|
|
1247
|
+
return rem > 0 ? `${m}m${rem}s` : `${m}m`;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** Map a step outcome to its display marker character. */
|
|
1251
|
+
function outcomeToMarker(outcome: ChainStepOutcome): string {
|
|
1252
|
+
switch (outcome) {
|
|
1253
|
+
case "completed":
|
|
1254
|
+
return "✓";
|
|
1255
|
+
case "aborted":
|
|
1256
|
+
return "⊘";
|
|
1257
|
+
case "error":
|
|
1258
|
+
return "✗";
|
|
1259
|
+
case "failed-to-start":
|
|
1260
|
+
return "✗";
|
|
1261
|
+
default:
|
|
1262
|
+
// Exhaustive check: the type is a literal union, so this is unreachable.
|
|
1263
|
+
throw new Error(`unexpected outcome: ${outcome as string}`);
|
|
1264
|
+
}
|
|
1265
|
+
}
|