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.
@@ -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: "requireDirty" | "requireBranch" | "requireRepo" | "requireFilePattern";
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
- return files.length === 0 ? "none" : files.join("\n");
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
- if (line.length < 4) continue;
220
- const path = line.slice(3);
221
- if (path && !files.includes(path)) files.push(path);
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
- /** 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);
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 term of changes, if this will breaks API, compatibility issue.",
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-do-always",
3
- "version": "0.10.0",
3
+ "version": "0.12.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": {