pi-do-always 0.3.0 → 0.4.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
@@ -5,17 +5,18 @@ repeated "do the usual" prompts.
5
5
 
6
6
  Type `/do-always` → a numbered list of tasks appears, grouped by category → press a number,
7
7
  type to filter, scroll or click, or navigate with arrows + Enter → the task's prompt is
8
- **filled into the input editor**. Review it, tweak it, press Enter to run.
8
+ **filled into the input editor**. Review it, tweak it, press Enter to run. Tasks marked `⚡`
9
+ (the `Plan` category by default) auto-run on selection instead — see `autoRun` below.
9
10
 
10
11
  ```text
11
12
  do-always — pick a task
12
13
 
13
14
  PLAN
14
- ▸ 1. Review Review code and double-check changes (Plan)
15
- 2. Cleanup Clean up dead code and duplicates (Plan)
16
- 3. Security Security audit (Plan)
17
- 4. Performance Performance review (Plan)
18
- 5. Propose features Propose new features (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)
19
20
  DO
20
21
  6. Build Test build is ok and fix issues
21
22
  7. Tests Run tests and fix failures
@@ -25,7 +26,12 @@ type to filter, scroll or click, or navigate with arrows + Enter → the task's
25
26
  9. Release Prepare a release (version, changelog, tag)
26
27
  10. Commit Prepare a clean commit
27
28
 
28
- 1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel
29
+ Review — prompt:
30
+ Review the changes on branch fix/login-null (3 changed files: auth.ts, login.ts,
31
+ test/auth.test.ts). Last commit: Fix null check in login. Check `git status` and
32
+ `git diff` to see what changed, then double-check the changes for bugs, edge …
33
+
34
+ 1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel • ⚡ auto-runs
29
35
  ```
30
36
 
31
37
  ![do-always — the numbered task selector](pi.image.png)
@@ -38,11 +44,16 @@ type to filter, scroll or click, or navigate with arrows + Enter → the task's
38
44
  |`/do-always 2`|Fill the prompt for task #2 directly|
39
45
  |`/do-always review`|Fill the prompt for the task named `review` (task names autocomplete after `/do-always`)|
40
46
  |`/do-always list`|Print the task list|
41
- |`/do-always list-details`|Show the full prompt text each task will inject|
47
+ |`/do-always list-details`|Show the full rendered prompt text each task will inject|
42
48
 
43
49
  The selector supports direct number-pick (1-9), live type-to-filter, arrow/Enter navigation,
44
50
  mouse-wheel scrolling, and click-to-select. While a filter is active, typed digits refine the
45
51
  filter instead of picking by number; clear the filter (backspace) to use number-pick again.
52
+ Pause on a task for two seconds and its full prompt is previewed below the list, so you can
53
+ see exactly what will be injected before running it; moving the selection or typing hides it
54
+ and restarts the delay. A task marked `⚡` runs immediately on selection (its prompt is sent,
55
+ not filled): the `Plan` category does this by default, and any task can opt in or out via the
56
+ `autoRun` field.
46
57
 
47
58
  In non-interactive modes (no TUI) there is no editor to fill, so the selected prompt is sent
48
59
  as a user message instead.
@@ -86,7 +97,7 @@ locations above to make it your own:
86
97
  "name": "review",
87
98
  "category": "Plan",
88
99
  "description": "Review code and double-check changes",
89
- "prompt": "Review the recent code changes in this project. Check `git status` and `git diff` ..."
100
+ "prompt": "Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). Last commit: {{last_commit}}. Check `git status` and `git diff` ..."
90
101
  }
91
102
  ]
92
103
  ```
@@ -96,7 +107,8 @@ Fields:
96
107
  - `name` (required) — short unique id, used for `/do-always <name>`
97
108
  - `category` (optional) — group header the task is shown under in the selector (e.g. `"Plan"`, `"Do"`). Matching is case-insensitive and the header is title-cased, so `"plan"` and `"Plan"` land in the same `Plan` group. Tasks without a category fall under `Other`. The built-in defaults are grouped into `Plan`, `Do`, `Docs`, and `Ops`.
98
109
  - `description` (optional) — one-line label shown in the selector
99
- - `prompt` (required) — the text filled into the editor
110
+ - `prompt` (required) — the text filled into the editor (supports `{{placeholders}}` — see [Prompt placeholders](#prompt-placeholders))
111
+ - `autoRun` (optional) — when `true`, selecting the task sends its prompt immediately instead of filling the editor; when `false`, it always fills the editor. When omitted, the default is derived from the category: `Plan` tasks auto-run, everything else fills the editor. Auto-run tasks are marked `⚡` in the selector.
100
112
 
101
113
  In the object form you can also configure the selector shortcut:
102
114
 
@@ -104,6 +116,28 @@ In the object form you can also configure the selector shortcut:
104
116
 
105
117
  Reload Pi (or start a new session) after editing a config file.
106
118
 
119
+ ## Prompt placeholders
120
+
121
+ Task prompts support `{{placeholders}}` that are filled in from the current
122
+ directory when a task is selected — so `/do-always review` on a hotfix branch
123
+ injects “Review the changes on branch `fix/login-null` (3 changed files:
124
+ `auth.ts`, `login.ts`, `test/auth.test.ts`) …” instead of a generic nudge.
125
+
126
+ |Placeholder|Value|
127
+ |---|---|
128
+ |`{{cwd}}`|Absolute path of the working directory|
129
+ |`{{date}}`|Local date (`YYYY-MM-DD`)|
130
+ |`{{branch}}`|Current git branch (`unknown` outside a git repo)|
131
+ |`{{last_commit}}`|Subject of the latest commit (`unknown` if unavailable, e.g. empty repo)|
132
+ |`{{files_changed}}`|Changed files from `git status` — comma-separated, capped at 20 entries (`none` when clean or not a git repo)|
133
+ |`{{files_changed_count}}`|Number of changed files (`0` when clean or not a git repo)|
134
+ |`{{user}}`|`git config user.name` (`unknown` when unset)|
135
+
136
+ Unknown placeholders are left as-is, and a prompt without placeholders is
137
+ injected unchanged, so existing configs keep working. The selector preview and
138
+ `/do-always list-details` show the rendered prompt — what you see is what gets
139
+ injected.
140
+
107
141
  ## Development
108
142
 
109
143
  ```bash
@@ -112,6 +146,6 @@ npm run typecheck # tsc --noEmit
112
146
  npm test # run the unit tests (node:test + tsx, in test/)
113
147
  ```
114
148
 
115
- The pure task logic (`parseConfig`, `mergeTasks`, `resolveTask`, `formatList`) lives in
149
+ The pure task logic (`parseConfig`, `mergeTasks`, `renderPrompt`, `resolveTask`, `formatList`) lives in
116
150
  [`tasks.ts`](./tasks.ts) with no Pi dependencies, so it is unit-tested independently of the
117
151
  Pi runtime. The extension (`index.ts`) imports that logic and adds only the Pi UI.
package/do-always.json CHANGED
@@ -5,11 +5,12 @@
5
5
  "name": "Review",
6
6
  "category": "Plan",
7
7
  "description": "Review code and double-check changes (Plan)",
8
- "prompt": "Review the recent code changes in this project. 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
+ "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"
9
9
  },
10
10
  {
11
11
  "name": "Cleanup",
12
12
  "category": "Plan",
13
+ "autoRun": false,
13
14
  "description": "Clean up dead code and duplicates (Plan)",
14
15
  "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."
15
16
  },
@@ -53,13 +54,13 @@
53
54
  "name": "Release",
54
55
  "category": "Ops",
55
56
  "description": "Prepare a release (version, changelog, tag)",
56
- "prompt": "Prepare a release for this project: 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."
57
+ "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."
57
58
  },
58
59
  {
59
60
  "name": "Commit",
60
61
  "category": "Ops",
61
62
  "description": "Prepare a clean commit",
62
- "prompt": "Prepare the working tree 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."
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."
63
64
  }
64
65
  ]
65
66
  }
package/index.ts CHANGED
@@ -33,6 +33,7 @@
33
33
  * If no config file exists, built-in default tasks are used.
34
34
  */
35
35
 
36
+ import { execFileSync } from "node:child_process";
36
37
  import { existsSync, readFileSync } from "node:fs";
37
38
  import { join } from "node:path";
38
39
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -44,6 +45,7 @@ import {
44
45
  getKeybindings,
45
46
  truncateToWidth,
46
47
  visibleWidth,
48
+ wrapTextWithAnsi,
47
49
  } from "@earendil-works/pi-tui";
48
50
  import {
49
51
  DEFAULT_SHORTCUT,
@@ -54,9 +56,12 @@ import {
54
56
  mergeTasks,
55
57
  parseConfig,
56
58
  orderTasksByCategory,
59
+ renderPrompt,
57
60
  resolveShortcut,
58
61
  resolveTask,
62
+ shouldAutoRun,
59
63
  type DoAlwaysTask,
64
+ type PromptContext,
60
65
  type TaskGroup,
61
66
  } from "./tasks";
62
67
 
@@ -84,6 +89,71 @@ function loadConfig(cwd: string): { tasks: DoAlwaysTask[]; shortcut: string | nu
84
89
  };
85
90
  }
86
91
 
92
+ /**
93
+ * Run a git command in `cwd` and return its trimmed stdout.
94
+ * Returns undefined on any failure (not a git repo, git not installed,
95
+ * empty repo, …) so callers can fall back to a neutral value.
96
+ * No shell is involved (argument array), so file names cannot inject commands.
97
+ */
98
+ function git(cwd: string, args: string[]): string | undefined {
99
+ try {
100
+ const out = execFileSync("git", args, {
101
+ cwd,
102
+ encoding: "utf-8",
103
+ stdio: ["ignore", "pipe", "ignore"],
104
+ });
105
+ const trimmed = out.trim();
106
+ return trimmed === "" ? undefined : trimmed;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ /** Max number of file paths listed in `files_changed` (the count stays exact). */
113
+ const MAX_FILES_LISTED = 20;
114
+
115
+ /**
116
+ * Build the prompt context for `renderPrompt`. Always contains every
117
+ * PROMPT_CONTEXT_KEYS entry: git facts fall back to neutral values when
118
+ * unavailable (non-git dir, no git, empty repo) so default prompts read
119
+ * cleanly in any directory.
120
+ */
121
+ function buildContext(cwd: string): PromptContext {
122
+ const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) ?? "unknown";
123
+ const lastCommit = git(cwd, ["log", "-1", "--format=%s"]) ?? "unknown";
124
+ const user = git(cwd, ["config", "user.name"]) ?? "unknown";
125
+
126
+ const files: string[] = [];
127
+ const status = git(cwd, ["status", "--porcelain"]);
128
+ if (status) {
129
+ for (const line of status.split("\n")) {
130
+ // Porcelain v1 lines are "XY <path>" (X = index, Y = worktree).
131
+ if (line.length < 4) continue;
132
+ const path = line.slice(3);
133
+ if (path && !files.includes(path)) files.push(path);
134
+ }
135
+ files.sort();
136
+ }
137
+ let listed: string;
138
+ if (files.length === 0) {
139
+ listed = "none";
140
+ } else if (files.length > MAX_FILES_LISTED) {
141
+ listed = [...files.slice(0, MAX_FILES_LISTED), `… (+${files.length - MAX_FILES_LISTED} more)`].join(", ");
142
+ } else {
143
+ listed = files.join(", ");
144
+ }
145
+
146
+ return {
147
+ cwd,
148
+ date: new Date().toLocaleDateString("en-CA"), // local YYYY-MM-DD
149
+ branch,
150
+ last_commit: lastCommit,
151
+ files_changed: listed,
152
+ files_changed_count: String(files.length),
153
+ user,
154
+ };
155
+ }
156
+
87
157
  /** Find the group (among `groups`) that contains a task. */
88
158
  function findGroupOf(task: DoAlwaysTask, groups: TaskGroup[]): TaskGroup | undefined {
89
159
  return groups.find((g) => g.items.includes(task));
@@ -94,6 +164,11 @@ function isPrintable(data: string): boolean {
94
164
  return data.length === 1 && data >= " " && data <= "~";
95
165
  }
96
166
 
167
+ /** Delay before the selector reveals the selected task's prompt preview. */
168
+ const PREVIEW_DELAY_MS = 2000;
169
+ /** Max lines of the prompt shown in the selector preview. */
170
+ const PREVIEW_MAX_LINES = 3;
171
+
97
172
  export default function doAlwaysExtension(pi: ExtensionAPI) {
98
173
  let tasks: DoAlwaysTask[] = [];
99
174
  let loadedCwd = ""; // cwd the cached `tasks` were loaded for
@@ -105,11 +180,19 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
105
180
 
106
181
  /** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
107
182
  async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext): Promise<void> {
183
+ // Render the prompt with the current context (branch, changed files, …)
184
+ // so the injected text matches this directory at this moment.
185
+ const prompt = renderPrompt(task.prompt, buildContext(ctx.cwd));
186
+ if (shouldAutoRun(task)) {
187
+ await pi.sendUserMessage(prompt);
188
+ ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
189
+ return;
190
+ }
108
191
  if (ctx.mode === "tui") {
109
- ctx.ui.setEditorText(task.prompt);
192
+ ctx.ui.setEditorText(prompt);
110
193
  ctx.ui.notify(`do-always: prompt for "${task.name}" filled — press Enter to run`, "info");
111
194
  } else {
112
- await pi.sendUserMessage(task.prompt);
195
+ await pi.sendUserMessage(prompt);
113
196
  }
114
197
  }
115
198
 
@@ -118,14 +201,43 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
118
201
  * number, type to filter, or navigate with arrows + Enter, Esc to cancel.
119
202
  */
120
203
  async function showSelector(ctx: ExtensionContext): Promise<void> {
204
+ // Build the context once per selector session (never inside the render
205
+ // loop — no process spawning per frame). fillPrompt re-renders at
206
+ // selection time, so a few seconds of drift is acceptable.
207
+ const context = buildContext(ctx.cwd);
121
208
  const selected = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
122
209
  let settled = false;
210
+ let previewVisible = false;
211
+ let previewTimer: ReturnType<typeof setTimeout> | null = null;
212
+
213
+ function clearPreviewTimer() {
214
+ if (previewTimer) {
215
+ clearTimeout(previewTimer);
216
+ previewTimer = null;
217
+ }
218
+ }
219
+
123
220
  const finish = (value: number | null) => {
124
221
  if (settled) return;
125
222
  settled = true;
223
+ clearPreviewTimer();
126
224
  done(value);
127
225
  };
128
226
 
227
+ // The prompt preview appears only after the selection has been stable
228
+ // for PREVIEW_DELAY_MS; any change hides it and restarts the delay.
229
+ function resetPreview() {
230
+ previewVisible = false;
231
+ clearPreviewTimer();
232
+ previewTimer = setTimeout(() => {
233
+ previewTimer = null;
234
+ if (!settled) {
235
+ previewVisible = true;
236
+ tui.requestRender();
237
+ }
238
+ }, PREVIEW_DELAY_MS);
239
+ }
240
+
129
241
  // Group tasks under category headers, in a stable order.
130
242
  const groups = groupTasksByCategory(tasks);
131
243
 
@@ -135,6 +247,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
135
247
  let selectedIndex = 0;
136
248
  let mousePressedIndex: number | null = null;
137
249
 
250
+ // Arm the preview timer for the initial selection.
251
+ resetPreview();
252
+
138
253
  const matchesFilter = (t: DoAlwaysTask) => {
139
254
  if (!filter) return true;
140
255
  const f = filter.toLowerCase();
@@ -173,7 +288,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
173
288
 
174
289
  function renderLabel(task: DoAlwaysTask, globalIndex: number, isSelected: boolean, width: number): string {
175
290
  const prefix = isSelected ? "▸ " : " ";
176
- const label = `${prefix}${globalIndex + 1}. ${task.name}`;
291
+ const marker = shouldAutoRun(task) ? "⚡ " : "";
292
+ const label = `${prefix}${globalIndex + 1}. ${marker}${task.name}`;
177
293
  if (!task.description) {
178
294
  const line = truncateToWidth(label, Math.max(1, width - 2), "");
179
295
  return isSelected ? theme.fg("accent", theme.bold(line)) : line;
@@ -223,13 +339,31 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
223
339
  lines.push(theme.fg("dim", truncateToWidth(hint, width - 2, "")));
224
340
  }
225
341
  }
342
+ // Prompt preview: revealed after the selection has been stable for
343
+ // PREVIEW_DELAY_MS, showing exactly what will be injected.
344
+ if (previewVisible) {
345
+ const sel = itemRows[selectedIndex];
346
+ if (sel) {
347
+ const wrapWidth = Math.max(10, width - 4);
348
+ // Show the rendered prompt — exactly what will be injected.
349
+ const wrapped = wrapTextWithAnsi(renderPrompt(sel.task.prompt, context), wrapWidth);
350
+ const shown = wrapped.slice(0, PREVIEW_MAX_LINES);
351
+ const truncated = wrapped.length > PREVIEW_MAX_LINES;
352
+ lines.push("");
353
+ lines.push(theme.fg("dim", theme.bold(` ${sel.task.name} — prompt:`)));
354
+ shown.forEach((ln, i) => {
355
+ const isLast = i === shown.length - 1;
356
+ const text = isLast && truncated ? truncateToWidth(`${ln} …`, wrapWidth, "") : ln;
357
+ lines.push(theme.fg("muted", ` ${text}`));
358
+ });
359
+ }
360
+ }
226
361
  lines.push("");
227
- lines.push(
228
- theme.fg(
229
- "dim",
230
- truncateToWidth(" 1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel", width - 2, ""),
231
- ),
232
- );
362
+ const anyAutoRun = itemRows.some((r) => shouldAutoRun(r.task));
363
+ const footer = anyAutoRun
364
+ ? " 1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel • ⚡ auto-runs"
365
+ : " 1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel";
366
+ lines.push(theme.fg("dim", truncateToWidth(footer, width - 2, "")));
233
367
  return { lines, itemLine, itemRows };
234
368
  }
235
369
 
@@ -249,12 +383,14 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
249
383
  if (kb.matches(data, "tui.editor.deleteCharBackward")) {
250
384
  filter = filter.slice(0, -1);
251
385
  selectedIndex = 0;
386
+ resetPreview();
252
387
  tui.requestRender();
253
388
  return;
254
389
  }
255
390
  if (isPrintable(data)) {
256
391
  filter += data;
257
392
  selectedIndex = 0;
393
+ resetPreview();
258
394
  tui.requestRender();
259
395
  return;
260
396
  }
@@ -262,10 +398,12 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
262
398
  const { itemRows } = getVisible();
263
399
  if (kb.matches(data, "tui.select.up")) {
264
400
  selectedIndex = selectedIndex === 0 ? itemRows.length - 1 : selectedIndex - 1;
401
+ resetPreview();
265
402
  tui.requestRender();
266
403
  }
267
404
  else if (kb.matches(data, "tui.select.down")) {
268
405
  selectedIndex = selectedIndex === itemRows.length - 1 ? 0 : selectedIndex + 1;
406
+ resetPreview();
269
407
  tui.requestRender();
270
408
  }
271
409
  else if (kb.matches(data, "tui.select.confirm")) {
@@ -283,6 +421,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
283
421
  const delta = event.wheelDelta < 0 ? -1 : 1;
284
422
  const prev = selectedIndex;
285
423
  selectedIndex = Math.max(0, Math.min(itemRows.length - 1, selectedIndex + delta));
424
+ if (selectedIndex !== prev) resetPreview();
286
425
  return { handled: true, render: selectedIndex !== prev };
287
426
  }
288
427
  if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
@@ -293,7 +432,10 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
293
432
  if (idx < 0) return undefined;
294
433
  if (event.type === "press") {
295
434
  mousePressedIndex = idx;
296
- selectedIndex = idx;
435
+ if (selectedIndex !== idx) {
436
+ selectedIndex = idx;
437
+ resetPreview();
438
+ }
297
439
  return { handled: true, focus: true, render: true };
298
440
  }
299
441
  const clicked = mousePressedIndex ?? idx;
@@ -369,12 +511,14 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
369
511
 
370
512
  if (arg.toLowerCase() === "list-details") {
371
513
  // Display only — the description is metadata; selecting a task injects just its prompt.
514
+ // Render with the current context so what is shown is what gets injected.
515
+ const context = buildContext(ctx.cwd);
372
516
  const details = tasks
373
517
  .map((t, i) => {
374
518
  const lines = [`${i + 1}. ${t.name}`];
375
519
  if (t.description) lines.push(` description: ${t.description}`);
376
520
  lines.push(" prompt (this is what gets injected on select):");
377
- for (const line of t.prompt.split("\n")) lines.push(` ${line}`);
521
+ for (const line of renderPrompt(t.prompt, context).split("\n")) lines.push(` ${line}`);
378
522
  return lines.join("\n");
379
523
  })
380
524
  .join("\n\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-do-always",
3
- "version": "0.3.0",
3
+ "version": "0.4.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": {
package/pi.image.png CHANGED
Binary file
package/tasks.ts CHANGED
@@ -13,6 +13,12 @@ export interface DoAlwaysTask {
13
13
  description?: string;
14
14
  /** Prompt filled into the editor when the task is selected */
15
15
  prompt: string;
16
+ /**
17
+ * Whether selecting the task sends its prompt immediately (auto-run) instead
18
+ * of filling the editor. When omitted, the default is derived from the
19
+ * category: "Plan" tasks auto-run, everything else fills the editor.
20
+ */
21
+ autoRun?: boolean;
16
22
  }
17
23
 
18
24
  /**
@@ -35,6 +41,25 @@ export interface ParsedDoAlwaysConfig {
35
41
  shortcut: string | null | undefined;
36
42
  }
37
43
 
44
+ /**
45
+ * The set of context keys the extension can inject into prompts (see
46
+ * `renderPrompt`). `index.ts` is responsible for supplying all of them (with
47
+ * neutral fallbacks when a fact is unavailable); tests use this to check that
48
+ * default prompts only reference known keys.
49
+ */
50
+ export const PROMPT_CONTEXT_KEYS = [
51
+ "cwd",
52
+ "date",
53
+ "branch",
54
+ "last_commit",
55
+ "files_changed",
56
+ "files_changed_count",
57
+ "user",
58
+ ] as const;
59
+
60
+ /** A fully populated prompt context: one entry per PROMPT_CONTEXT_KEYS. */
61
+ export type PromptContext = Record<(typeof PROMPT_CONTEXT_KEYS)[number], string>;
62
+
38
63
  /** Used when neither config file defines any task. */
39
64
  export const DEFAULT_TASKS: DoAlwaysTask[] = [
40
65
  {
@@ -42,7 +67,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
42
67
  category: "Plan",
43
68
  description: "Review code and double-check changes (Plan)",
44
69
  prompt:
45
- "Review the recent code changes in this project. 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",
70
+ "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",
46
71
  },
47
72
  {
48
73
  name: "Cleanup",
@@ -91,14 +116,14 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
91
116
  category: "Ops",
92
117
  description: "Prepare a release (version, changelog, tag)",
93
118
  prompt:
94
- "Prepare a release for this project: 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.",
119
+ "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.",
95
120
  },
96
121
  {
97
122
  name: "Commit",
98
123
  category: "Ops",
99
124
  description: "Prepare a clean commit",
100
125
  prompt:
101
- "Prepare the working tree 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.",
126
+ "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.",
102
127
  },
103
128
  {
104
129
  name: "Propose features",
@@ -145,6 +170,7 @@ export function parseConfig(
145
170
  };
146
171
  if (typeof t.description === "string") task.description = t.description;
147
172
  if (typeof t.category === "string" && t.category.trim() !== "") task.category = t.category.trim();
173
+ if (typeof t.autoRun === "boolean") task.autoRun = t.autoRun;
148
174
  tasks.push(task);
149
175
  } else {
150
176
  onError(`do-always: skipping invalid task in ${path} (each task needs "name" and "prompt")`);
@@ -264,6 +290,17 @@ export function orderTasksByCategory(
264
290
  return groupTasksByCategory(tasks, order).flatMap((g) => g.items);
265
291
  }
266
292
 
293
+ /**
294
+ * Whether selecting a task should auto-run it (send its prompt immediately)
295
+ * instead of filling the editor. An explicit `autoRun` flag wins; otherwise the
296
+ * default is derived from the category — "Plan" tasks auto-run, everything else
297
+ * fills the editor.
298
+ */
299
+ export function shouldAutoRun(task: DoAlwaysTask): boolean {
300
+ if (typeof task.autoRun === "boolean") return task.autoRun;
301
+ return (task.category ?? "").trim().toLowerCase() === "plan";
302
+ }
303
+
267
304
  /**
268
305
  * Resolve a task from a command argument: by number (1-based) or by name (case-insensitive).
269
306
  */
@@ -277,6 +314,22 @@ export function resolveTask(tasks: DoAlwaysTask[], arg: string): DoAlwaysTask |
277
314
  return tasks.find((t) => t.name.toLowerCase() === a.toLowerCase());
278
315
  }
279
316
 
317
+ /** Matches a `{{key}}` placeholder: key is [A-Za-z0-9_]+, optional inner whitespace. */
318
+ const PLACEHOLDER_RE = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g;
319
+
320
+ /**
321
+ * Substitute `{{key}}` placeholders in `template` with values from `ctx`.
322
+ * A key that is not present in `ctx` is left as-is, so missing context stays
323
+ * visible instead of silently blanking the sentence; a key present with an
324
+ * empty-string value renders as empty. Templates without placeholders are
325
+ * returned unchanged, so existing plain prompts keep working.
326
+ */
327
+ export function renderPrompt(template: string, ctx: Record<string, string>): string {
328
+ return template.replace(PLACEHOLDER_RE, (match, key: string) =>
329
+ Object.prototype.hasOwnProperty.call(ctx, key) ? ctx[key] : match,
330
+ );
331
+ }
332
+
280
333
  /**
281
334
  * Render a numbered, one-line-per-task list. When tasks span more than one
282
335
  * category, a header line is emitted before each group (matching the selector).