pi-do-always 0.8.0 → 0.9.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 CHANGED
@@ -147,6 +147,10 @@ injects “Review the changes on branch `fix/login-null` (3 changed files:
147
147
  |`{{files_changed}}`|Changed files from `git status` — comma-separated, capped at 20 entries (`none` when clean or not a git repo)|
148
148
  |`{{files_changed_count}}`|Number of changed files (`0` when clean or not a git repo)|
149
149
  |`{{user}}`|`git config user.name` (`unknown` when unset)|
150
+ |`{{diff_stat}}`|`git diff --shortstat` output, e.g. `3 files changed, 41 insertions(+), 7 deletions(-)` (`none` when unavailable)|
151
+ |`{{repo}}`|Basename of the git remote (or of the working directory when there is no remote) — disambiguates monorepo work|
152
+ |`{{staged_files}}`|Files staged for commit, one per line (`none` when empty)|
153
+ |`{{unstaged_files}}`|Modified-but-unstaged files, one per line (`none` when empty)|
150
154
 
151
155
  Unknown placeholders are left as-is, and a prompt without placeholders is
152
156
  injected unchanged, so existing configs keep working. The selector preview and
@@ -198,7 +202,7 @@ The `guards` array accepts these guard objects (all must pass):
198
202
 
199
203
  | `type` | `value` | Blocks when… |
200
204
  |---|---|---|
201
- | `requireDirty` | none | the working tree is clean (`files_changed_count === 0`) |
205
+ | `requireDirty` | none | the working tree is clean (no changed files) |
202
206
  | `requireBranch` | branch name | the current branch is not the given name |
203
207
  | `requireRepo` | repo name | the git-remote basename context value is not the given name |
204
208
  | `requireFilePattern` | glob | no changed file matches the glob |
@@ -4,9 +4,9 @@
4
4
  {
5
5
  "name": "Review changes",
6
6
  "category": "Plan",
7
- "requireDirty": true,
8
7
  "description": "Review the current code changes (Plan)",
9
- "prompt": "Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). Last commit: {{last_commit}}. Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. Do a plan proposal for the fixes if needed. Do a summary of your findings"
8
+ "requireDirty": true,
9
+ "prompt": "Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). Change summary: {{diff_stat}}. Last commit: {{last_commit}}. Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. Do a plan proposal for the fixes if needed. Do a summary of your findings"
10
10
  },
11
11
  {
12
12
  "name": "Review code",
@@ -17,7 +17,6 @@
17
17
  {
18
18
  "name": "Cleanup",
19
19
  "category": "Plan",
20
- "autoRun": false,
21
20
  "description": "Clean up dead code and duplicates (Plan)",
22
21
  "prompt": "Scan the project for dead code, unused imports, commented-out blocks, and duplicated logic. Do a plan proposal for the removals and consolidations, keeping behavior unchanged. Do not make any changes yet."
23
22
  },
@@ -61,13 +60,16 @@
61
60
  "name": "Release",
62
61
  "category": "Ops",
63
62
  "description": "Prepare a release (version, changelog, tag)",
63
+ "when": "git",
64
64
  "prompt": "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."
65
65
  },
66
66
  {
67
67
  "name": "Commit",
68
68
  "category": "Ops",
69
69
  "description": "Prepare a clean commit",
70
- "prompt": "Prepare the working tree on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}) for a clean commit: review `git status` and `git diff`, stage the relevant changes, and write a clear commit message describing what changed and why. Do not push."
70
+ "requireDirty": true,
71
+ "when": "git",
72
+ "prompt": "Prepare the working tree on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}) for a clean commit: stage the relevant changes, and write a clear commit message describing what changed and why. Do not push."
71
73
  }
72
74
  ]
73
75
  }
@@ -50,41 +50,51 @@ import {
50
50
  import {
51
51
  DEFAULT_SHORTCUT,
52
52
  DEFAULT_TASKS,
53
+ evaluateGuards,
54
+ evaluateWhen,
53
55
  formatList,
54
56
  groupTasksByCategory,
55
57
  isValidKeyId,
56
58
  mergeTasks,
57
59
  parseConfig,
60
+ parseStatusPorcelain,
58
61
  orderTasksByCategory,
59
62
  renderPrompt,
60
- evaluateGuards,
61
- evaluateWhen,
62
63
  resolveShortcut,
63
64
  resolveTask,
64
65
  shouldAutoRun,
66
+ splitFileLines,
67
+ toPromptContext,
65
68
  type DoAlwaysTask,
66
- type PromptContext,
69
+ type TaskContext,
67
70
  type TaskGroup,
68
71
  } from "./tasks";
69
72
 
70
73
  /**
71
74
  * Load tasks and the selector shortcut from config files.
72
- * Project-local tasks override global tasks with the same name; new ones are appended.
73
- * Falls back to DEFAULT_TASKS when nothing is defined.
75
+ * Project-local tasks override global tasks with the same name (or are
76
+ * appended, per the `merge` field); new ones are appended. Falls back to
77
+ * DEFAULT_TASKS when nothing is defined.
78
+ *
79
+ * Validation problems (malformed JSON, invalid tasks/shortcut/merge/when/
80
+ * guards) are reported through `onError` — callers must wire it up, since
81
+ * the default is a silent no-op.
74
82
  */
75
- function loadConfig(cwd: string): {
83
+ function loadConfig(
84
+ cwd: string,
85
+ onError: (message: string) => void = () => {},
86
+ ): {
76
87
  tasks: DoAlwaysTask[];
77
88
  shortcut: string | null;
78
- merge: "append" | "override";
79
89
  } {
80
90
  const globalPath = join(getAgentDir(), "do-always.json");
81
91
  const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
82
92
 
83
93
  const global = existsSync(globalPath)
84
- ? parseConfig(readFileSync(globalPath, "utf-8"), globalPath)
94
+ ? parseConfig(readFileSync(globalPath, "utf-8"), globalPath, onError)
85
95
  : { tasks: [], shortcut: undefined, merge: undefined };
86
96
  const project = existsSync(projectPath)
87
- ? parseConfig(readFileSync(projectPath, "utf-8"), projectPath)
97
+ ? parseConfig(readFileSync(projectPath, "utf-8"), projectPath, onError)
88
98
  : { tasks: [], shortcut: undefined, merge: undefined };
89
99
 
90
100
  // The project file's merge mode wins; otherwise the global value; otherwise
@@ -96,7 +106,6 @@ function loadConfig(cwd: string): {
96
106
  // `/do-always <n>`, and `list` all share one consistent order.
97
107
  tasks: orderTasksByCategory(mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS, mode)),
98
108
  shortcut: resolveShortcut(global.shortcut, project.shortcut),
99
- merge: mode,
100
109
  };
101
110
  }
102
111
 
@@ -120,19 +129,19 @@ function git(cwd: string, args: string[]): string | undefined {
120
129
  }
121
130
  }
122
131
 
123
- /** Max number of file paths listed in `files_changed` (the count stays exact). */
124
- const MAX_FILES_LISTED = 20;
125
-
126
132
  /**
127
- * Build the prompt context for `renderPrompt`. Always contains every
128
- * PROMPT_CONTEXT_KEYS entry: git facts fall back to neutral values when
129
- * unavailable (non-git dir, no git, empty repo) so default prompts read
130
- * cleanly in any directory.
133
+ * Build the structured context for the current directory. Git facts fall back
134
+ * to neutral values when unavailable (non-git dir, no git, empty repo) so
135
+ * default prompts read cleanly in any directory. The string view for
136
+ * `renderPrompt` is derived with `toPromptContext`.
131
137
  */
132
- function buildContext(cwd: string): PromptContext {
138
+ function buildContext(cwd: string): TaskContext {
133
139
  const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) ?? "unknown";
134
140
  const lastCommit = git(cwd, ["log", "-1", "--format=%s"]) ?? "unknown";
135
141
  const user = git(cwd, ["config", "user.name"]) ?? "unknown";
142
+ // Authoritative working-tree check — the branch sentinel is not (a branch
143
+ // could literally be named "unknown", and detached HEAD reports "HEAD").
144
+ const isGitRepo = git(cwd, ["rev-parse", "--is-inside-work-tree"]) === "true";
136
145
 
137
146
  // repo = bare name of the git remote (owner/repo.git -> repo), falling back
138
147
  // to the basename of cwd so monorepo work stays disambiguated everywhere.
@@ -141,38 +150,18 @@ function buildContext(cwd: string): PromptContext {
141
150
  ? (remoteUrl.replace(/\.git$/, "").split("/").pop() ?? "unknown")
142
151
  : cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown";
143
152
 
144
- const files: string[] = [];
145
- const status = git(cwd, ["status", "--porcelain"]);
146
- if (status) {
147
- for (const line of status.split("\n")) {
148
- // Porcelain v1 lines are "XY <path>" (X = index, Y = worktree).
149
- if (line.length < 4) continue;
150
- const path = line.slice(3);
151
- if (path && !files.includes(path)) files.push(path);
152
- }
153
- files.sort();
154
- }
155
- let listed: string;
156
- if (files.length === 0) {
157
- listed = "none";
158
- } else if (files.length > MAX_FILES_LISTED) {
159
- listed = [...files.slice(0, MAX_FILES_LISTED), `… (+${files.length - MAX_FILES_LISTED} more)`].join(", ");
160
- } else {
161
- listed = files.join(", ");
162
- }
163
-
164
153
  return {
165
154
  cwd,
166
155
  date: new Date().toLocaleDateString("en-CA"), // local YYYY-MM-DD
167
156
  branch,
168
- last_commit: lastCommit,
169
- files_changed: listed,
170
- files_changed_count: String(files.length),
157
+ lastCommit,
158
+ files: parseStatusPorcelain(git(cwd, ["status", "--porcelain"]) ?? ""),
171
159
  user,
172
- diff_stat: git(cwd, ["diff", "--shortstat"]) ?? "none",
160
+ diffStat: git(cwd, ["diff", "--shortstat"]) ?? "none",
173
161
  repo,
174
- staged_files: git(cwd, ["diff", "--cached", "--name-only"]) ?? "none",
175
- unstaged_files: git(cwd, ["diff", "--name-only"]) ?? "none",
162
+ stagedFiles: splitFileLines(git(cwd, ["diff", "--cached", "--name-only"])),
163
+ unstagedFiles: splitFileLines(git(cwd, ["diff", "--name-only"])),
164
+ isGitRepo,
176
165
  };
177
166
  }
178
167
 
@@ -194,23 +183,62 @@ const PREVIEW_MAX_LINES = 3;
194
183
  export default function doAlwaysExtension(pi: ExtensionAPI) {
195
184
  let tasks: DoAlwaysTask[] = [];
196
185
  let loadedCwd = ""; // cwd the cached `tasks` were loaded for
186
+ // The visible (when-filtered) list for the last context we built, so
187
+ // argument completions number tasks the same way the selector and
188
+ // `/do-always <n>` do. When stale (or absent), completions fall back to
189
+ // the full list rather than guessing.
190
+ let visibleCache: { cwd: string; visible: DoAlwaysTask[] } | null = null;
191
+
192
+ /** Filter tasks by their `when` condition and refresh the completion cache. */
193
+ function refreshVisible(cwd: string, context: TaskContext): DoAlwaysTask[] {
194
+ const visible = tasks.filter((t) => evaluateWhen(t, context));
195
+ visibleCache = { cwd, visible };
196
+ return visible;
197
+ }
197
198
 
198
- pi.on("session_start", async (_event, ctx) => {
199
+ /**
200
+ * Register the selector shortcut from a resolved config value (null
201
+ * disables it). Called from session_start so it reads the session's cwd,
202
+ * not the process cwd; re-registering the same key is idempotent.
203
+ */
204
+ function registerShortcut(shortcut: string | null, onError: (message: string) => void): void {
205
+ if (shortcut === null) return;
206
+ const shortcutKey = isValidKeyId(shortcut) ? shortcut : DEFAULT_SHORTCUT;
207
+ if (shortcutKey !== shortcut) {
208
+ onError(`do-always: invalid shortcut "${shortcut}" in do-always.json — using ${DEFAULT_SHORTCUT}`);
209
+ }
210
+ pi.registerShortcut(shortcutKey as KeyId, {
211
+ description: "do-always: pick a common task",
212
+ handler: async (ctx) => {
213
+ await runDoAlways("", ctx);
214
+ },
215
+ });
216
+ }
217
+
218
+ pi.on("session_start", (_event, ctx) => {
219
+ // Surface config validation problems (the README promises warnings);
220
+ // in non-TUI modes there is no UI, so fall back to the console.
221
+ const onError = (m: string) => {
222
+ if (ctx.mode === "tui") ctx.ui.notify(m, "warning");
223
+ else console.warn(m);
224
+ };
199
225
  loadedCwd = ctx.cwd;
200
- tasks = loadConfig(ctx.cwd).tasks;
226
+ const config = loadConfig(ctx.cwd, onError);
227
+ tasks = config.tasks;
228
+ refreshVisible(ctx.cwd, buildContext(ctx.cwd));
229
+ registerShortcut(config.shortcut, onError);
201
230
  });
202
231
 
203
232
  /** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
204
- async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext): Promise<void> {
205
- // Render the prompt with the current context (branch, changed files, …)
206
- // so the injected text matches this directory at this moment.
207
- const context = buildContext(ctx.cwd);
233
+ async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext, context: TaskContext): Promise<void> {
208
234
  const blocked = evaluateGuards(task, context);
209
235
  if (blocked) {
210
236
  ctx.ui.notify(`do-always: ${blocked}`, "info");
211
237
  return;
212
238
  }
213
- const prompt = renderPrompt(task.prompt, context);
239
+ // Render with the same context the selector/preview used, so what the
240
+ // user saw is exactly what gets injected.
241
+ const prompt = renderPrompt(task.prompt, toPromptContext(context));
214
242
  if (shouldAutoRun(task)) {
215
243
  await pi.sendUserMessage(prompt);
216
244
  ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
@@ -227,15 +255,15 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
227
255
  /**
228
256
  * Numbered selector with categorized sections. Press 1-9 to pick by global
229
257
  * number, type to filter, or navigate with arrows + Enter, Esc to cancel.
258
+ * The context is built once per command run (never inside the render loop
259
+ * — no process spawning per frame) and shared with `fillPrompt`.
230
260
  */
231
- async function showSelector(ctx: ExtensionContext): Promise<void> {
232
- // Build the context once per selector session (never inside the render
233
- // loop — no process spawning per frame). fillPrompt re-renders at
234
- // selection time, so a few seconds of drift is acceptable.
235
- const context = buildContext(ctx.cwd);
261
+ async function showSelector(ctx: ExtensionContext, context: TaskContext): Promise<void> {
236
262
  // Filter by the `when` condition once per session, so hidden tasks never
237
263
  // appear, are never numbered, and can't be picked.
238
264
  const visibleTasks = tasks.filter((t) => evaluateWhen(t, context));
265
+ // String view for prompt rendering (derived once, used by the preview).
266
+ const strings = toPromptContext(context);
239
267
  const selected = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
240
268
  let settled = false;
241
269
  let previewVisible = false;
@@ -381,7 +409,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
381
409
  if (sel) {
382
410
  const wrapWidth = Math.max(10, width - 4);
383
411
  // Show the rendered prompt — exactly what will be injected.
384
- const wrapped = wrapTextWithAnsi(renderPrompt(sel.task.prompt, context), wrapWidth);
412
+ const wrapped = wrapTextWithAnsi(renderPrompt(sel.task.prompt, strings), wrapWidth);
385
413
  const shown = wrapped.slice(0, PREVIEW_MAX_LINES);
386
414
  const truncated = wrapped.length > PREVIEW_MAX_LINES;
387
415
  lines.push("");
@@ -483,17 +511,22 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
483
511
  });
484
512
 
485
513
  if (selected === null || selected === undefined) return;
486
- await fillPrompt(tasks[selected], ctx);
514
+ await fillPrompt(tasks[selected], ctx, context);
487
515
  }
488
516
 
489
517
  pi.registerCommand("do-always", {
490
518
  description: "Pick a common task (review, readme, ...) by number — fills the prompt",
491
519
  getArgumentCompletions: (prefix) => {
492
520
  const p = prefix.trim().toLowerCase();
521
+ // Number tasks by position in the VISIBLE (when-filtered) list — the
522
+ // same list the selector and `/do-always <n>` use. The cache is
523
+ // refreshed on session start and every command run; when it is
524
+ // stale, fall back to the full list rather than guessing.
525
+ const visible = visibleCache?.cwd === loadedCwd ? visibleCache.visible : tasks;
493
526
  const matches = [
494
527
  { value: "list", label: "list" },
495
528
  { value: "list-details", label: "list-details" },
496
- ...tasks.map((t, i) => ({ value: t.name, label: `${i + 1}. ${t.name}` })),
529
+ ...visible.map((t, i) => ({ value: t.name, label: `${i + 1}. ${t.name}` })),
497
530
  ].filter((c) => c.value.toLowerCase().includes(p));
498
531
  return matches.length > 0 ? matches : null;
499
532
  },
@@ -502,48 +535,35 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
502
535
  },
503
536
  });
504
537
 
505
- // Keyboard shortcut: open the task selector without typing the command.
506
- // The key is configurable via the "shortcut" field in do-always.json
507
- // (null disables it). It is read once at extension load, so changing it
508
- // requires a reload or a new session.
509
- const configuredShortcut = loadConfig(process.cwd()).shortcut;
510
- if (configuredShortcut !== null) {
511
- const shortcutKey = isValidKeyId(configuredShortcut) ? configuredShortcut : DEFAULT_SHORTCUT;
512
- if (shortcutKey !== configuredShortcut) {
513
- console.warn(`do-always: invalid shortcut "${configuredShortcut}" in do-always.json — using ${DEFAULT_SHORTCUT}`);
514
- }
515
- pi.registerShortcut(shortcutKey as KeyId, {
516
- description: "do-always: pick a common task",
517
- handler: async (ctx) => {
518
- await runDoAlways("", ctx);
519
- },
520
- });
521
- }
522
-
523
538
  async function runDoAlways(args: string, ctx: ExtensionContext): Promise<void> {
524
539
  // Reload when the active directory changes, so switching projects
525
540
  // mid-session serves the right config instead of stale tasks.
526
541
  if (ctx.cwd !== loadedCwd) {
527
542
  loadedCwd = ctx.cwd;
528
- tasks = loadConfig(ctx.cwd).tasks;
543
+ const onError = (m: string) => {
544
+ if (ctx.mode === "tui") ctx.ui.notify(m, "warning");
545
+ else console.warn(m);
546
+ };
547
+ tasks = loadConfig(ctx.cwd, onError).tasks;
529
548
  }
530
549
 
550
+ // One context per command run: shared by visibility filtering, rendering,
551
+ // and the completion cache (never inside a render loop).
552
+ const context = buildContext(ctx.cwd);
553
+ const visible = refreshVisible(ctx.cwd, context);
554
+
531
555
  const arg = args.trim();
532
556
 
533
557
  if (!arg) {
534
558
  if (ctx.mode === "tui") {
535
- await showSelector(ctx);
559
+ await showSelector(ctx, context);
536
560
  } else {
537
- const context = buildContext(ctx.cwd);
538
- const visible = tasks.filter((t) => evaluateWhen(t, context));
539
561
  ctx.ui.notify(`do-always tasks (use /do-always <number|name>):\n${formatList(visible)}`, "info");
540
562
  }
541
563
  return;
542
564
  }
543
565
 
544
566
  if (arg.toLowerCase() === "list") {
545
- const context = buildContext(ctx.cwd);
546
- const visible = tasks.filter((t) => evaluateWhen(t, context));
547
567
  ctx.ui.notify(formatList(visible), "info");
548
568
  return;
549
569
  }
@@ -551,14 +571,13 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
551
571
  if (arg.toLowerCase() === "list-details") {
552
572
  // Display only — the description is metadata; selecting a task injects just its prompt.
553
573
  // Render with the current context so what is shown is what gets injected.
554
- const context = buildContext(ctx.cwd);
555
- const visible = tasks.filter((t) => evaluateWhen(t, context));
574
+ const strings = toPromptContext(context);
556
575
  const details = visible
557
576
  .map((t, i) => {
558
577
  const lines = [`${i + 1}. ${t.name}`];
559
578
  if (t.description) lines.push(` description: ${t.description}`);
560
579
  lines.push(" prompt (this is what gets injected on select):");
561
- for (const line of renderPrompt(t.prompt, context).split("\n")) lines.push(` ${line}`);
580
+ for (const line of renderPrompt(t.prompt, strings).split("\n")) lines.push(` ${line}`);
562
581
  return lines.join("\n");
563
582
  })
564
583
  .join("\n\n");
@@ -573,8 +592,6 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
573
592
  // Numbers index the VISIBLE list (what the user sees in the selector and
574
593
  // `list`); names resolve against the full set so picking a hidden task by
575
594
  // name gets an explanatory message below instead of "unknown task".
576
- const context = buildContext(ctx.cwd);
577
- const visible = tasks.filter((t) => evaluateWhen(t, context));
578
595
  const task = resolveTask(/^\d+$/.test(arg) ? visible : tasks, arg);
579
596
  if (!task) {
580
597
  const available = visible.map((t, i) => `${i + 1}=${t.name}`).join(", ");
@@ -587,6 +604,6 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
587
604
  ctx.ui.notify(`do-always: "${task.name}" is hidden by its "when" condition`, "info");
588
605
  return;
589
606
  }
590
- await fillPrompt(task, ctx);
607
+ await fillPrompt(task, ctx, context);
591
608
  }
592
609
  }
@@ -98,6 +98,44 @@ export interface ParsedDoAlwaysConfig {
98
98
  merge?: "append" | "override" | undefined;
99
99
  }
100
100
 
101
+ import { existsSync } from "node:fs";
102
+ import { join } from "node:path";
103
+
104
+ /**
105
+ * Structured facts about the working tree and git state, gathered once per
106
+ * use (see `buildContext` in index.ts). `when` conditions and guards are
107
+ * evaluated against this; `renderPrompt` consumes the derived string view
108
+ * from `toPromptContext`.
109
+ *
110
+ * Keeping the structured form here (instead of re-parsing the rendered
111
+ * strings) means guards see the complete file list — `files_changed` in the
112
+ * string view is capped for display, but `files` is never truncated.
113
+ */
114
+ export interface TaskContext {
115
+ /** Absolute path of the working directory. */
116
+ cwd: string;
117
+ /** Local date, YYYY-MM-DD. */
118
+ date: string;
119
+ /** Current git branch, or "unknown" when unavailable. */
120
+ branch: string;
121
+ /** Subject of the latest commit, or "unknown" when unavailable. */
122
+ lastCommit: string;
123
+ /** All changed files (staged, unstaged, untracked), deduplicated and sorted. */
124
+ files: string[];
125
+ /** `git config user.name`, or "unknown" when unset. */
126
+ user: string;
127
+ /** Output of `git diff --shortstat`, or "none" when unavailable. */
128
+ diffStat: string;
129
+ /** Basename of the git remote (or cwd), to disambiguate monorepo work. */
130
+ repo: string;
131
+ /** Files staged for commit. */
132
+ stagedFiles: string[];
133
+ /** Modified-but-unstaged files. */
134
+ unstagedFiles: string[];
135
+ /** True when cwd is inside a git working tree (authoritative, not inferred from the branch name). */
136
+ isGitRepo: boolean;
137
+ }
138
+
101
139
  /**
102
140
  * The set of context keys the extension can inject into prompts (see
103
141
  * `renderPrompt`). `index.ts` is responsible for supplying all of them (with
@@ -112,17 +150,75 @@ export const PROMPT_CONTEXT_KEYS = [
112
150
  "files_changed",
113
151
  "files_changed_count",
114
152
  "user",
115
- "diff_stat", // NEW: "3 files changed, 41 insertions(+), 7 deletions(-)"
116
- "repo", // NEW: basename of cwd or git remote (disambiguates monorepos)
117
- "staged_files", // NEW: files staged for commit
118
- "unstaged_files", // NEW: modified-but-unstaged files
153
+ "diff_stat",
154
+ "repo",
155
+ "staged_files",
156
+ "unstaged_files",
119
157
  ] as const;
120
158
 
121
159
  /** A fully populated prompt context: one entry per PROMPT_CONTEXT_KEYS. */
122
160
  export type PromptContext = Record<(typeof PROMPT_CONTEXT_KEYS)[number], string>;
123
161
 
124
- import { existsSync } from "node:fs";
125
- import { join } from "node:path";
162
+ /** Max number of file paths listed in the `files_changed` string view (the count stays exact). */
163
+ export const MAX_FILES_LISTED = 20;
164
+
165
+ /**
166
+ * Derive the string view consumed by `renderPrompt` from a structured context.
167
+ * `files_changed` lists at most MAX_FILES_LISTED paths (with a "… (+N more)"
168
+ * suffix) and `files_changed_count` stays exact; `staged_files` and
169
+ * `unstaged_files` are newline-separated. Empty lists render as "none".
170
+ */
171
+ export function toPromptContext(ctx: TaskContext): PromptContext {
172
+ return {
173
+ cwd: ctx.cwd,
174
+ date: ctx.date,
175
+ branch: ctx.branch,
176
+ last_commit: ctx.lastCommit,
177
+ files_changed: formatFileList(ctx.files),
178
+ files_changed_count: String(ctx.files.length),
179
+ user: ctx.user,
180
+ diff_stat: ctx.diffStat,
181
+ repo: ctx.repo,
182
+ staged_files: formatFileLines(ctx.stagedFiles),
183
+ unstaged_files: formatFileLines(ctx.unstagedFiles),
184
+ };
185
+ }
186
+
187
+ /** Comma-joined list, capped at MAX_FILES_LISTED entries; "none" when empty. */
188
+ function formatFileList(files: string[]): string {
189
+ if (files.length === 0) return "none";
190
+ if (files.length > MAX_FILES_LISTED) {
191
+ return [...files.slice(0, MAX_FILES_LISTED), `… (+${files.length - MAX_FILES_LISTED} more)`].join(", ");
192
+ }
193
+ return files.join(", ");
194
+ }
195
+
196
+ /** Newline-joined list; "none" when empty. */
197
+ function formatFileLines(files: string[]): string {
198
+ return files.length === 0 ? "none" : files.join("\n");
199
+ }
200
+
201
+ /**
202
+ * Parse `git status --porcelain` (v1) output into changed file paths. Lines
203
+ * are "XY <path>" (X = index, Y = worktree); short lines are skipped,
204
+ * duplicates removed, and the result sorted.
205
+ */
206
+ export function parseStatusPorcelain(status: string): string[] {
207
+ const files: string[] = [];
208
+ for (const line of status.split("\n")) {
209
+ if (line.length < 4) continue;
210
+ const path = line.slice(3);
211
+ if (path && !files.includes(path)) files.push(path);
212
+ }
213
+ files.sort();
214
+ return files;
215
+ }
216
+
217
+ /** Split raw `git diff --name-only` output into file paths (trimmed, non-empty lines). */
218
+ export function splitFileLines(raw: string | undefined): string[] {
219
+ if (!raw) return [];
220
+ return raw.split("\n").map((line) => line.trim()).filter(Boolean);
221
+ }
126
222
 
127
223
  /** Used when neither config file defines any task. */
128
224
  export const DEFAULT_TASKS: DoAlwaysTask[] = [
@@ -415,15 +511,6 @@ export function isValidWhen(when: unknown): boolean {
415
511
  return true;
416
512
  }
417
513
 
418
- /**
419
- * True when the current directory is inside a git working tree. The `branch`
420
- * context falls back to "unknown" outside a repo (and on an empty repo), so a
421
- * non-"unknown" branch is the git-repo signal.
422
- */
423
- function isGitRepo(ctx: PromptContext): boolean {
424
- return ctx.branch !== "unknown";
425
- }
426
-
427
514
  /** True when `relativePath` exists (as file or directory) under `cwd`. */
428
515
  function pathExists(cwd: string, relativePath: string): boolean {
429
516
  try {
@@ -434,14 +521,14 @@ function pathExists(cwd: string, relativePath: string): boolean {
434
521
  }
435
522
 
436
523
  /**
437
- * Evaluate a single `when` object entry against the current prompt context.
524
+ * Evaluate a single `when` object entry against the current context.
438
525
  * Unknown keys are treated as no-ops (permissive) so a typo never hides a task
439
526
  * at runtime (parse time rejects them with a warning instead).
440
527
  */
441
- function evaluateWhenEntry(key: string, value: unknown, ctx: PromptContext): boolean {
528
+ function evaluateWhenEntry(key: string, value: unknown, ctx: TaskContext): boolean {
442
529
  switch (key) {
443
530
  case "git":
444
- return typeof value === "boolean" ? isGitRepo(ctx) === value : false;
531
+ return typeof value === "boolean" ? ctx.isGitRepo === value : false;
445
532
  case "branch":
446
533
  return typeof value === "string" && ctx.branch === value;
447
534
  case "file":
@@ -462,13 +549,13 @@ function evaluateWhenEntry(key: string, value: unknown, ctx: PromptContext): boo
462
549
  * set of conditions that must all hold (logical AND): `git`, `branch`, `file`,
463
550
  * or `repo` (see the `DoAlwaysTask.when` field).
464
551
  */
465
- export function evaluateWhen(task: DoAlwaysTask, ctx: PromptContext): boolean {
552
+ export function evaluateWhen(task: DoAlwaysTask, ctx: TaskContext): boolean {
466
553
  const when = task.when;
467
554
  if (when === undefined || when === null) return true;
468
555
  if (typeof when === "string") {
469
556
  const negated = when.startsWith("!");
470
557
  const key = negated ? when.slice(1) : when;
471
- if (key === "git") return negated ? !isGitRepo(ctx) : isGitRepo(ctx);
558
+ if (key === "git") return negated ? !ctx.isGitRepo : ctx.isGitRepo;
472
559
  return true; // an invalid string condition is rejected at parse time
473
560
  }
474
561
  if (typeof when === "object") {
@@ -550,7 +637,7 @@ export function shouldAutoRun(task: DoAlwaysTask): boolean {
550
637
  * Guards keep low-value round-trips down: e.g. `requireDirty` blocks Review and
551
638
  * Commit on a clean tree so the agent is never asked to inspect nothing.
552
639
  */
553
- export function evaluateGuards(task: DoAlwaysTask, ctx: PromptContext): string | null {
640
+ export function evaluateGuards(task: DoAlwaysTask, ctx: TaskContext): string | null {
554
641
  // Legacy `requireDirty` is folded into the guard table so the set of guards
555
642
  // is extensible without touching this function's callers.
556
643
  const guards: Guard[] = [];
@@ -568,38 +655,31 @@ export function evaluateGuards(task: DoAlwaysTask, ctx: PromptContext): string |
568
655
  * when the guard passes. All guards are evaluated against the current prompt
569
656
  * context, so a task is only injected when every guard is met.
570
657
  */
571
- function guardFailureMessage(g: Guard, ctx: PromptContext): string | null {
658
+ function guardFailureMessage(g: Guard, ctx: TaskContext): string | null {
572
659
  switch (g.type) {
573
660
  case "requireDirty":
574
- return ctx.files_changed_count === "0" ? "working tree is clean — nothing to review" : null;
661
+ return ctx.files.length === 0 ? "working tree is clean — nothing to review" : null;
575
662
  case "requireBranch":
576
663
  return ctx.branch === g.value ? null : `not on branch "${g.value}" (currently ${ctx.branch})`;
577
664
  case "requireRepo":
578
665
  return ctx.repo === g.value ? null : `not in repo "${g.value}" (currently ${ctx.repo})`;
579
666
  case "requireFilePattern":
580
- return filesMatchPattern(ctx, g.value!) ? null : `no changed files match "${g.value}"`;
667
+ return filesMatchPattern(ctx.files, g.value!) ? null : `no changed files match "${g.value}"`;
581
668
  default:
582
669
  return null; // an unknown type is rejected at parse time
583
670
  }
584
671
  }
585
672
 
586
- /**
587
- * The changed files for `ctx`, split on commas (matching how `files_changed`
588
- * is rendered). Empty on a clean tree or outside a git repo.
589
- */
590
- function changedFiles(ctx: PromptContext): string[] {
591
- if (ctx.files_changed_count === "0" || ctx.files_changed === "none") return [];
592
- return ctx.files_changed.split(",");
593
- }
594
-
595
673
  /**
596
674
  * Whether any changed file matches `pattern`, treated as a glob: `*` matches
597
675
  * within a path segment, `**` crosses segments, `?` matches one non-separator
598
- * character, and other regex metacharacters are literal.
676
+ * character, and other regex metacharacters are literal. Matches against the
677
+ * complete file list (never the capped display string), so files beyond
678
+ * MAX_FILES_LISTED are still considered.
599
679
  */
600
- function filesMatchPattern(ctx: PromptContext, pattern: string): boolean {
680
+ function filesMatchPattern(files: string[], pattern: string): boolean {
601
681
  const re = globToRegex(pattern);
602
- return changedFiles(ctx).some((f) => re.test(f.trim()));
682
+ return files.some((f) => re.test(f));
603
683
  }
604
684
 
605
685
  /** Regex metacharacters that must be escaped when matching a literal path char. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-do-always",
3
- "version": "0.8.0",
3
+ "version": "0.9.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": {
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "homepage": "https://github.com/aginies/pi-do-always",
25
25
  "bugs": {
26
- "url": "https://github.com/aginies/pi-do-always/issues"
26
+ "url": "git+https://github.com/aginies/pi-do-always/issues"
27
27
  },
28
28
  "files": [
29
29
  "extensions/pi-do-always/index.ts",
package/pi.image.png CHANGED
Binary file