pi-do-always 0.7.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
@@ -12,21 +12,22 @@ type to filter, scroll or click, or navigate with arrows + Enter → the task's
12
12
  do-always — pick a task
13
13
 
14
14
  PLAN
15
- ▸ 1. ⚡ Review Review code and double-check changes (Plan)
16
- 2. ⚡ Cleanup Clean up dead code and duplicates (Plan)
17
- 3. ⚡ Security Security audit (Plan)
18
- 4. ⚡ Performance Performance review (Plan)
19
- 5. ⚡ Propose features Propose new features (Plan)
15
+ ▸ 1. ⚡ Review changes Review the current code changes (Plan)
16
+ 2. ⚡ Review code Review the whole project's code quality (Plan)
17
+ 3. ⚡ Cleanup Clean up dead code and duplicates (Plan)
18
+ 4. ⚡ Security Security audit (Plan)
19
+ 5. ⚡ Performance Performance review (Plan)
20
+ 6. ⚡ Propose features Propose new features (Plan)
20
21
  DO
21
- 6. Build Test build is ok and fix issues
22
- 7. Tests Run tests and fix failures
22
+ 7. Build Test build is ok and fix issues
23
+ 8. Tests Run tests and fix failures
23
24
  DOCS
24
- 8. Readme Update the README.md
25
+ 9. Readme Update the README.md
25
26
  OPS
26
- 9. Release Prepare a release (version, changelog, tag)
27
- 10. Commit Prepare a clean commit
27
+ 10. Release Prepare a release (version, changelog, tag)
28
+ 11. Commit Prepare a clean commit
28
29
 
29
- Review — prompt:
30
+ Review changes — prompt:
30
31
  Review the changes on branch fix/login-null (3 changed files: auth.ts, login.ts,
31
32
  test/auth.test.ts). Last commit: Fix null check in login. Check `git status` and
32
33
  `git diff` to see what changed, then double-check the changes for bugs, edge …
@@ -42,7 +43,7 @@ type to filter, scroll or click, or navigate with arrows + Enter → the task's
42
43
  |---|---|
43
44
  |`/do-always` or the shortcut key (default `F4`)|Show the numbered task selector|
44
45
  |`/do-always 2`|Fill the prompt for task #2 directly|
45
- |`/do-always review`|Fill the prompt for the task named `review` (task names autocomplete after `/do-always`)|
46
+ |`/do-always review changes`|Fill the prompt for the task named `review changes` (task names autocomplete after `/do-always`)|
46
47
  |`/do-always list`|Print the task list|
47
48
  |`/do-always list-details`|Show the full rendered prompt text each task will inject|
48
49
 
@@ -87,7 +88,7 @@ Tasks are read from JSON files (an array of tasks, or the object form `{"tasks":
87
88
  |`~/.pi/agent/do-always.json`|Global (all projects)|
88
89
  |`<project>/.pi/do-always.json`|Project-local; overrides global tasks with the same `name`|
89
90
 
90
- If neither file exists, the built-in defaults (Review, Cleanup, Security, Performance, Propose features, Build, Tests, Readme, Release, Commit) are used.
91
+ If neither file exists, the built-in defaults (Review changes, Review code, Cleanup, Security, Performance, Propose features, Build, Tests, Readme, Release, Commit) are used.
91
92
  This repo ships a sample in [`do-always.json`](./extensions/pi-do-always/do-always.json) — copy it to one of the
92
93
  locations above to make it your own:
93
94
 
@@ -133,7 +134,7 @@ Reload Pi (or start a new session) after editing a config file.
133
134
  ## Prompt placeholders
134
135
 
135
136
  Task prompts support `{{placeholders}}` that are filled in from the current
136
- directory when a task is selected — so `/do-always review` on a hotfix branch
137
+ directory when a task is selected — so `/do-always review changes` on a hotfix branch
137
138
  injects “Review the changes on branch `fix/login-null` (3 changed files:
138
139
  `auth.ts`, `login.ts`, `test/auth.test.ts`) …” instead of a generic nudge.
139
140
 
@@ -146,6 +147,10 @@ injects “Review the changes on branch `fix/login-null` (3 changed files:
146
147
  |`{{files_changed}}`|Changed files from `git status` — comma-separated, capped at 20 entries (`none` when clean or not a git repo)|
147
148
  |`{{files_changed_count}}`|Number of changed files (`0` when clean or not a git repo)|
148
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)|
149
154
 
150
155
  Unknown placeholders are left as-is, and a prompt without placeholders is
151
156
  injected unchanged, so existing configs keep working. The selector preview and
@@ -197,7 +202,7 @@ The `guards` array accepts these guard objects (all must pass):
197
202
 
198
203
  | `type` | `value` | Blocks when… |
199
204
  |---|---|---|
200
- | `requireDirty` | none | the working tree is clean (`files_changed_count === 0`) |
205
+ | `requireDirty` | none | the working tree is clean (no changed files) |
201
206
  | `requireBranch` | branch name | the current branch is not the given name |
202
207
  | `requireRepo` | repo name | the git-remote basename context value is not the given name |
203
208
  | `requireFilePattern` | glob | no changed file matches the glob |
@@ -2,15 +2,21 @@
2
2
  "shortcut": "f4",
3
3
  "tasks": [
4
4
  {
5
- "name": "Review",
5
+ "name": "Review changes",
6
6
  "category": "Plan",
7
- "description": "Review code and double-check changes (Plan)",
8
- "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"
7
+ "description": "Review the current code changes (Plan)",
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
+ },
11
+ {
12
+ "name": "Review code",
13
+ "category": "Plan",
14
+ "description": "Review the whole project's code quality (Plan)",
15
+ "prompt": "Review this project's code holistically: identify code smells, dead code, duplication, awkward architecture or patterns, maintainability issues, inconsistencies, and missing or unclear documentation. Prioritize by impact, propose a plan for the fixes, and summarize your findings. Do not make any changes yet."
9
16
  },
10
17
  {
11
18
  "name": "Cleanup",
12
19
  "category": "Plan",
13
- "autoRun": false,
14
20
  "description": "Clean up dead code and duplicates (Plan)",
15
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."
16
22
  },
@@ -54,13 +60,16 @@
54
60
  "name": "Release",
55
61
  "category": "Ops",
56
62
  "description": "Prepare a release (version, changelog, tag)",
63
+ "when": "git",
57
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."
58
65
  },
59
66
  {
60
67
  "name": "Commit",
61
68
  "category": "Ops",
62
69
  "description": "Prepare a clean commit",
63
- "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."
64
73
  }
65
74
  ]
66
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,24 +150,82 @@ 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[] = [
129
225
  {
130
- name: "Review",
226
+ name: "Review changes",
131
227
  category: "Plan",
132
- description: "Review code and double-check changes (Plan)",
228
+ description: "Review the current code changes (Plan)",
133
229
  requireDirty: true,
134
230
  prompt:
135
231
  "Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). " +
@@ -138,6 +234,14 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
138
234
  "edge cases, security issues, and consistency with the rest of the codebase. " +
139
235
  "Do a plan proposal for the fixes if needed. Do a summary of your findings",
140
236
  },
237
+ {
238
+ name: "Review code",
239
+ category: "Plan",
240
+ description: "Review the whole project's code quality (Plan)",
241
+ prompt:
242
+ "Review this project's code holistically: identify code smells, dead code, duplication, awkward architecture or patterns, maintainability issues, inconsistencies, and missing or unclear documentation. " +
243
+ "Prioritize by impact, propose a plan for the fixes, and summarize your findings. Do not make any changes yet.",
244
+ },
141
245
  {
142
246
  name: "Cleanup",
143
247
  category: "Plan",
@@ -407,15 +511,6 @@ export function isValidWhen(when: unknown): boolean {
407
511
  return true;
408
512
  }
409
513
 
410
- /**
411
- * True when the current directory is inside a git working tree. The `branch`
412
- * context falls back to "unknown" outside a repo (and on an empty repo), so a
413
- * non-"unknown" branch is the git-repo signal.
414
- */
415
- function isGitRepo(ctx: PromptContext): boolean {
416
- return ctx.branch !== "unknown";
417
- }
418
-
419
514
  /** True when `relativePath` exists (as file or directory) under `cwd`. */
420
515
  function pathExists(cwd: string, relativePath: string): boolean {
421
516
  try {
@@ -426,14 +521,14 @@ function pathExists(cwd: string, relativePath: string): boolean {
426
521
  }
427
522
 
428
523
  /**
429
- * Evaluate a single `when` object entry against the current prompt context.
524
+ * Evaluate a single `when` object entry against the current context.
430
525
  * Unknown keys are treated as no-ops (permissive) so a typo never hides a task
431
526
  * at runtime (parse time rejects them with a warning instead).
432
527
  */
433
- function evaluateWhenEntry(key: string, value: unknown, ctx: PromptContext): boolean {
528
+ function evaluateWhenEntry(key: string, value: unknown, ctx: TaskContext): boolean {
434
529
  switch (key) {
435
530
  case "git":
436
- return typeof value === "boolean" ? isGitRepo(ctx) === value : false;
531
+ return typeof value === "boolean" ? ctx.isGitRepo === value : false;
437
532
  case "branch":
438
533
  return typeof value === "string" && ctx.branch === value;
439
534
  case "file":
@@ -454,13 +549,13 @@ function evaluateWhenEntry(key: string, value: unknown, ctx: PromptContext): boo
454
549
  * set of conditions that must all hold (logical AND): `git`, `branch`, `file`,
455
550
  * or `repo` (see the `DoAlwaysTask.when` field).
456
551
  */
457
- export function evaluateWhen(task: DoAlwaysTask, ctx: PromptContext): boolean {
552
+ export function evaluateWhen(task: DoAlwaysTask, ctx: TaskContext): boolean {
458
553
  const when = task.when;
459
554
  if (when === undefined || when === null) return true;
460
555
  if (typeof when === "string") {
461
556
  const negated = when.startsWith("!");
462
557
  const key = negated ? when.slice(1) : when;
463
- if (key === "git") return negated ? !isGitRepo(ctx) : isGitRepo(ctx);
558
+ if (key === "git") return negated ? !ctx.isGitRepo : ctx.isGitRepo;
464
559
  return true; // an invalid string condition is rejected at parse time
465
560
  }
466
561
  if (typeof when === "object") {
@@ -542,7 +637,7 @@ export function shouldAutoRun(task: DoAlwaysTask): boolean {
542
637
  * Guards keep low-value round-trips down: e.g. `requireDirty` blocks Review and
543
638
  * Commit on a clean tree so the agent is never asked to inspect nothing.
544
639
  */
545
- export function evaluateGuards(task: DoAlwaysTask, ctx: PromptContext): string | null {
640
+ export function evaluateGuards(task: DoAlwaysTask, ctx: TaskContext): string | null {
546
641
  // Legacy `requireDirty` is folded into the guard table so the set of guards
547
642
  // is extensible without touching this function's callers.
548
643
  const guards: Guard[] = [];
@@ -560,38 +655,31 @@ export function evaluateGuards(task: DoAlwaysTask, ctx: PromptContext): string |
560
655
  * when the guard passes. All guards are evaluated against the current prompt
561
656
  * context, so a task is only injected when every guard is met.
562
657
  */
563
- function guardFailureMessage(g: Guard, ctx: PromptContext): string | null {
658
+ function guardFailureMessage(g: Guard, ctx: TaskContext): string | null {
564
659
  switch (g.type) {
565
660
  case "requireDirty":
566
- 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;
567
662
  case "requireBranch":
568
663
  return ctx.branch === g.value ? null : `not on branch "${g.value}" (currently ${ctx.branch})`;
569
664
  case "requireRepo":
570
665
  return ctx.repo === g.value ? null : `not in repo "${g.value}" (currently ${ctx.repo})`;
571
666
  case "requireFilePattern":
572
- 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}"`;
573
668
  default:
574
669
  return null; // an unknown type is rejected at parse time
575
670
  }
576
671
  }
577
672
 
578
- /**
579
- * The changed files for `ctx`, split on commas (matching how `files_changed`
580
- * is rendered). Empty on a clean tree or outside a git repo.
581
- */
582
- function changedFiles(ctx: PromptContext): string[] {
583
- if (ctx.files_changed_count === "0" || ctx.files_changed === "none") return [];
584
- return ctx.files_changed.split(",");
585
- }
586
-
587
673
  /**
588
674
  * Whether any changed file matches `pattern`, treated as a glob: `*` matches
589
675
  * within a path segment, `**` crosses segments, `?` matches one non-separator
590
- * 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.
591
679
  */
592
- function filesMatchPattern(ctx: PromptContext, pattern: string): boolean {
680
+ function filesMatchPattern(files: string[], pattern: string): boolean {
593
681
  const re = globToRegex(pattern);
594
- return changedFiles(ctx).some((f) => re.test(f.trim()));
682
+ return files.some((f) => re.test(f));
595
683
  }
596
684
 
597
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.7.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