pi-do-always 0.4.5 → 0.6.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
@@ -109,10 +109,24 @@ Fields:
109
109
  - `description` (optional) — one-line label shown in the selector
110
110
  - `prompt` (required) — the text filled into the editor (supports `{{placeholders}}` — see [Prompt placeholders](#prompt-placeholders))
111
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.
112
+ - `when` (optional) — a condition that hides the task from the selector and lists when it is not met (see [Conditionals](#conditionals)).
113
+ - `guards` (optional) — an array of selection-time guards that block the task (with a message, not a hide) when a condition is unmet (see [Guards](#guards)). The legacy `requireDirty` (boolean) still works and is combined with any `guards`.
112
114
 
113
115
  In the object form you can also configure the selector shortcut:
114
116
 
115
117
  - `shortcut` (optional) — key that opens the selector, e.g. `"f4"`. Set to `null` to disable the shortcut. Defaults to `F4`. The project file's value wins over the global one.
118
+ - `merge` (optional) — how project tasks combine with the global tasks: `"override"` (default) replaces a global task with the same `name`; `"append"` keeps the global tasks and only adds new project task names (a cascade, like CSS). The project file's value wins over the global one; when neither sets it, the default is `override` (the historical behavior).
119
+
120
+ Example project file that only *adds* tasks without overriding the global set:
121
+
122
+ ```json
123
+ {
124
+ "merge": "append",
125
+ "tasks": [
126
+ { "name": "deploy", "category": "Ops", "prompt": "Deploy this project to staging." }
127
+ ]
128
+ }
129
+ ```
116
130
 
117
131
  Reload Pi (or start a new session) after editing a config file.
118
132
 
@@ -138,6 +152,71 @@ injected unchanged, so existing configs keep working. The selector preview and
138
152
  `/do-always list-details` show the rendered prompt — what you see is what gets
139
153
  injected.
140
154
 
155
+ ## Conditionals
156
+
157
+ A task's `when` field controls whether it is shown in the selector and in
158
+ `/do-always list` / `list-details`. When the condition is not met the task is
159
+ hidden everywhere (including when picked by number or name), so it can never
160
+ be selected into a no-op. Omitting `when` always shows the task.
161
+
162
+ The string form is a single condition:
163
+
164
+ - `"git"` — shown only inside a git working tree.
165
+ - `"!git"` — shown only outside a git working tree.
166
+
167
+ The object form is a set of conditions that must **all** hold (logical AND):
168
+
169
+ | Key | Meaning |
170
+ |---|---|
171
+ | `"git": boolean` | `true` inside a git repo, `false` outside |
172
+ | `"branch": string` | current branch equals the given name (exact match) |
173
+ | `"file": string` | path exists (file or directory) relative to the working tree |
174
+ | `"repo": string` | equals the git-remote basename context value |
175
+
176
+ ```json
177
+ [
178
+ { "name": "review", "category": "Plan", "prompt": "Review the changes…", "when": "git" },
179
+ { "name": "deploy-staging", "category": "Ops", "prompt": "Deploy to staging.", "when": { "branch": "main" } },
180
+ { "name": "lint-js", "category": "Do", "prompt": "Lint the JavaScript.", "when": { "file": "package.json" } }
181
+ ]
182
+ ```
183
+
184
+ An invalid `when` (wrong type, unknown key) is ignored with a warning and the
185
+ task is shown, so a typo never silently hides a task. Reload Pi (or start a new
186
+ session) after editing a config file.
187
+
188
+ ## Guards
189
+
190
+ Guards keep low-value round-trips down: the task stays visible, but selecting it
191
+ notifies with the reason instead of injecting a no-op prompt. Guards are
192
+ evaluated against the current prompt context, so a task is only injected when
193
+ **every** guard is met. `requireDirty` (boolean, the historical guard) is
194
+ combined with any `guards` array.
195
+
196
+ The `guards` array accepts these guard objects (all must pass):
197
+
198
+ | `type` | `value` | Blocks when… |
199
+ |---|---|---|
200
+ | `requireDirty` | none | the working tree is clean (`files_changed_count === 0`) |
201
+ | `requireBranch` | branch name | the current branch is not the given name |
202
+ | `requireRepo` | repo name | the git-remote basename context value is not the given name |
203
+ | `requireFilePattern` | glob | no changed file matches the glob |
204
+
205
+ For `requireFilePattern`, `*` matches within a path segment, `**` crosses
206
+ segments, `?` matches one non-separator character, and other regex
207
+ metacharacters are literal.
208
+
209
+ ```json
210
+ [
211
+ { "name": "deploy-staging", "category": "Ops", "prompt": "Deploy to staging.", "guards": [ { "type": "requireBranch", "value": "main" } ] },
212
+ { "name": "lint-tests", "category": "Do", "prompt": "Run the test suite.", "guards": [ { "type": "requireFilePattern", "value": "**/*.test.ts" } ] }
213
+ ]
214
+ ```
215
+
216
+ An invalid guard (unknown `type`, missing `value`, or a non-array `guards`)
217
+ is ignored with a warning, so a typo never silently disables a guard. Reload Pi
218
+ (or start a new session) after editing a config file.
219
+
141
220
  ## Development
142
221
 
143
222
  ```bash
@@ -58,6 +58,7 @@ import {
58
58
  orderTasksByCategory,
59
59
  renderPrompt,
60
60
  evaluateGuards,
61
+ evaluateWhen,
61
62
  resolveShortcut,
62
63
  resolveTask,
63
64
  shouldAutoRun,
@@ -71,22 +72,31 @@ import {
71
72
  * Project-local tasks override global tasks with the same name; new ones are appended.
72
73
  * Falls back to DEFAULT_TASKS when nothing is defined.
73
74
  */
74
- function loadConfig(cwd: string): { tasks: DoAlwaysTask[]; shortcut: string | null } {
75
+ function loadConfig(cwd: string): {
76
+ tasks: DoAlwaysTask[];
77
+ shortcut: string | null;
78
+ merge: "append" | "override";
79
+ } {
75
80
  const globalPath = join(getAgentDir(), "do-always.json");
76
81
  const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
77
82
 
78
83
  const global = existsSync(globalPath)
79
84
  ? parseConfig(readFileSync(globalPath, "utf-8"), globalPath)
80
- : { tasks: [], shortcut: undefined };
85
+ : { tasks: [], shortcut: undefined, merge: undefined };
81
86
  const project = existsSync(projectPath)
82
87
  ? parseConfig(readFileSync(projectPath, "utf-8"), projectPath)
83
- : { tasks: [], shortcut: undefined };
88
+ : { tasks: [], shortcut: undefined, merge: undefined };
89
+
90
+ // The project file's merge mode wins; otherwise the global value; otherwise
91
+ // override (the historical behavior), so existing configs are unaffected.
92
+ const mode = project.merge ?? global.merge ?? "override";
84
93
 
85
94
  return {
86
95
  // Order the merged list by category so the selector numbers, digit-pick,
87
96
  // `/do-always <n>`, and `list` all share one consistent order.
88
- tasks: orderTasksByCategory(mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS)),
97
+ tasks: orderTasksByCategory(mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS, mode)),
89
98
  shortcut: resolveShortcut(global.shortcut, project.shortcut),
99
+ merge: mode,
90
100
  };
91
101
  }
92
102
 
@@ -223,6 +233,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
223
233
  // loop — no process spawning per frame). fillPrompt re-renders at
224
234
  // selection time, so a few seconds of drift is acceptable.
225
235
  const context = buildContext(ctx.cwd);
236
+ // Filter by the `when` condition once per session, so hidden tasks never
237
+ // appear, are never numbered, and can't be picked.
238
+ const visibleTasks = tasks.filter((t) => evaluateWhen(t, context));
226
239
  const selected = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
227
240
  let settled = false;
228
241
  let previewVisible = false;
@@ -235,11 +248,15 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
235
248
  }
236
249
  }
237
250
 
238
- const finish = (value: number | null) => {
251
+ // `finish` receives the chosen task (or null) and translates it to the
252
+ // full index `tasks[selected]` expects. Reference-based, so it stays
253
+ // correct while a text filter is active (itemRows is then a subset of
254
+ // visibleTasks and positional indices would point at the wrong task).
255
+ const finish = (task: DoAlwaysTask | null) => {
239
256
  if (settled) return;
240
257
  settled = true;
241
258
  clearPreviewTimer();
242
- done(value);
259
+ done(task ? tasks.indexOf(task) : null);
243
260
  };
244
261
 
245
262
  // The prompt preview appears only after the selection has been stable
@@ -257,7 +274,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
257
274
  }
258
275
 
259
276
  // Group tasks under category headers, in a stable order.
260
- const groups = groupTasksByCategory(tasks);
277
+ const groups = groupTasksByCategory(visibleTasks);
261
278
 
262
279
  const kb = getKeybindings();
263
280
  const maxVisible = 12;
@@ -347,7 +364,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
347
364
  continue;
348
365
  }
349
366
  if (!visibleItemKeys.has(row.task)) continue;
350
- const globalIndex = tasks.indexOf(row.task);
367
+ const globalIndex = visibleTasks.indexOf(row.task);
351
368
  const isSelected = row.task === itemRows[selectedIndex].task;
352
369
  lines.push(renderLabel(row.task, globalIndex, isSelected, width));
353
370
  itemLine.set(lines.length - 1, row.task);
@@ -391,10 +408,10 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
391
408
  },
392
409
  invalidate() {},
393
410
  handleInput(data: string) {
394
- // Direct pick by number (1-9) — only when not filtering, so
395
- // digits can be typed into the filter otherwise.
396
- if (!filter && /^[1-9]$/.test(data) && Number(data) <= tasks.length) {
397
- finish(Number(data) - 1);
411
+ // Direct pick by number (1-9) — only when not filtering, and within
412
+ // the visible set, so digits pick a visible task by its number.
413
+ if (!filter && /^[1-9]$/.test(data) && Number(data) <= visibleTasks.length) {
414
+ finish(visibleTasks[Number(data) - 1]);
398
415
  return;
399
416
  }
400
417
  // Filter typing.
@@ -426,7 +443,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
426
443
  }
427
444
  else if (kb.matches(data, "tui.select.confirm")) {
428
445
  const chosen = itemRows[selectedIndex];
429
- if (chosen) finish(tasks.indexOf(chosen.task));
446
+ if (chosen) finish(chosen.task);
430
447
  }
431
448
  else if (kb.matches(data, "tui.select.cancel")) {
432
449
  finish(null);
@@ -459,7 +476,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
459
476
  const clicked = mousePressedIndex ?? idx;
460
477
  mousePressedIndex = null;
461
478
  const chosen = itemRows[clicked];
462
- if (chosen) finish(tasks.indexOf(chosen.task));
479
+ if (chosen) finish(chosen.task);
463
480
  return { handled: true };
464
481
  },
465
482
  };
@@ -517,13 +534,17 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
517
534
  if (ctx.mode === "tui") {
518
535
  await showSelector(ctx);
519
536
  } else {
520
- ctx.ui.notify(`do-always tasks (use /do-always <number|name>):\n${formatList(tasks)}`, "info");
537
+ const context = buildContext(ctx.cwd);
538
+ const visible = tasks.filter((t) => evaluateWhen(t, context));
539
+ ctx.ui.notify(`do-always tasks (use /do-always <number|name>):\n${formatList(visible)}`, "info");
521
540
  }
522
541
  return;
523
542
  }
524
543
 
525
544
  if (arg.toLowerCase() === "list") {
526
- ctx.ui.notify(formatList(tasks), "info");
545
+ const context = buildContext(ctx.cwd);
546
+ const visible = tasks.filter((t) => evaluateWhen(t, context));
547
+ ctx.ui.notify(formatList(visible), "info");
527
548
  return;
528
549
  }
529
550
 
@@ -531,7 +552,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
531
552
  // Display only — the description is metadata; selecting a task injects just its prompt.
532
553
  // Render with the current context so what is shown is what gets injected.
533
554
  const context = buildContext(ctx.cwd);
534
- const details = tasks
555
+ const visible = tasks.filter((t) => evaluateWhen(t, context));
556
+ const details = visible
535
557
  .map((t, i) => {
536
558
  const lines = [`${i + 1}. ${t.name}`];
537
559
  if (t.description) lines.push(` description: ${t.description}`);
@@ -548,12 +570,23 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
548
570
  return;
549
571
  }
550
572
 
551
- const task = resolveTask(tasks, arg);
573
+ // Numbers index the VISIBLE list (what the user sees in the selector and
574
+ // `list`); names resolve against the full set so picking a hidden task by
575
+ // 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
+ const task = resolveTask(/^\d+$/.test(arg) ? visible : tasks, arg);
552
579
  if (!task) {
553
- const available = tasks.map((t, i) => `${i + 1}=${t.name}`).join(", ");
580
+ const available = visible.map((t, i) => `${i + 1}=${t.name}`).join(", ");
554
581
  ctx.ui.notify(`do-always: unknown task "${arg}". Available: ${available}`, "error");
555
582
  return;
556
583
  }
584
+ // Respect the task's `when` condition: never inject a task hidden for the
585
+ // current environment (the selector and lists already hide it).
586
+ if (!evaluateWhen(task, context)) {
587
+ ctx.ui.notify(`do-always: "${task.name}" is hidden by its "when" condition`, "info");
588
+ return;
589
+ }
557
590
  await fillPrompt(task, ctx);
558
591
  }
559
592
  }
@@ -26,14 +26,59 @@ export interface DoAlwaysTask {
26
26
  * against the current prompt context (see `evaluateGuards`).
27
27
  */
28
28
  requireDirty?: boolean;
29
+ /**
30
+ * Environment condition controlling whether the task is shown in the
31
+ * selector and lists. A string is a single condition ("git" | "!git"); an
32
+ * object is a set of conditions that must all hold (logical AND):
33
+ * "git": boolean — inside a git repo (true) or not (false)
34
+ * "branch": string — current branch equals the given name (exact match)
35
+ * "file": string — a path that must exist in the working tree
36
+ * "repo": string — equals the git-remote basename context value
37
+ * Omitted/undefined always shows the task. Evaluated by `evaluateWhen`.
38
+ */
39
+ when?: string | Record<string, unknown>;
40
+ /**
41
+ * Extra selection-time guards, evaluated alongside the legacy `requireDirty`
42
+ * (see `evaluateGuards`). Each guard blocks the task (with a message, not a
43
+ * hide) when its condition is not met. `requireDirty` is kept for backward
44
+ * compatibility; new guards use this array so the set is extensible.
45
+ */
46
+ guards?: Guard[];
29
47
  }
30
48
 
49
+ /**
50
+ * A selection-time guard that blocks a task when its condition is not met.
51
+ * The task stays visible but selecting it notifies instead of injecting.
52
+ * `requireDirty` needs no `value`; the others require a string `value`.
53
+ */
54
+ export interface Guard {
55
+ type: "requireDirty" | "requireBranch" | "requireRepo" | "requireFilePattern";
56
+ value?: string;
57
+ }
58
+
59
+ /** The set of known guard types (used for validation at parse time). */
60
+ export const GUARD_TYPES = [
61
+ "requireDirty",
62
+ "requireBranch",
63
+ "requireRepo",
64
+ "requireFilePattern",
65
+ ] as const;
66
+
31
67
  /**
32
68
  * A config file can be a bare array of tasks, or {"tasks": [...], "shortcut": ...}.
33
69
  * `shortcut` is a key id string (e.g. "f4", "ctrl+shift+p"), or null to disable
34
70
  * the keyboard shortcut.
71
+ * `merge` controls how project tasks combine with global tasks:
72
+ * `override` (default) replaces a global task with the same name;
73
+ * `append` keeps globals and only adds new project task names (a cascade).
35
74
  */
36
- export type DoAlwaysConfig = DoAlwaysTask[] | { tasks: DoAlwaysTask[]; shortcut?: string | null };
75
+ export type DoAlwaysConfig =
76
+ | DoAlwaysTask[]
77
+ | {
78
+ tasks: DoAlwaysTask[];
79
+ shortcut?: string | null;
80
+ merge?: "append" | "override";
81
+ };
37
82
 
38
83
  /** Shortcut used when neither config file specifies one. */
39
84
  export const DEFAULT_SHORTCUT = "f4";
@@ -46,6 +91,11 @@ export interface ParsedDoAlwaysConfig {
46
91
  * disabled, undefined when the file does not set one.
47
92
  */
48
93
  shortcut: string | null | undefined;
94
+ /**
95
+ * The `merge` field, if present: "append" or "override", undefined when the
96
+ * file does not set one.
97
+ */
98
+ merge?: "append" | "override" | undefined;
49
99
  }
50
100
 
51
101
  /**
@@ -71,6 +121,9 @@ export const PROMPT_CONTEXT_KEYS = [
71
121
  /** A fully populated prompt context: one entry per PROMPT_CONTEXT_KEYS. */
72
122
  export type PromptContext = Record<(typeof PROMPT_CONTEXT_KEYS)[number], string>;
73
123
 
124
+ import { existsSync } from "node:fs";
125
+ import { join } from "node:path";
126
+
74
127
  /** Used when neither config file defines any task. */
75
128
  export const DEFAULT_TASKS: DoAlwaysTask[] = [
76
129
  {
@@ -131,6 +184,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
131
184
  name: "Release",
132
185
  category: "Ops",
133
186
  description: "Prepare a release (version, changelog, tag)",
187
+ when: "git",
134
188
  prompt:
135
189
  "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.",
136
190
  },
@@ -139,6 +193,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
139
193
  category: "Ops",
140
194
  description: "Prepare a clean commit",
141
195
  requireDirty: true,
196
+ when: "git",
142
197
  prompt:
143
198
  "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.",
144
199
  },
@@ -185,25 +240,48 @@ export function parseConfig(
185
240
  name: t.name,
186
241
  prompt: t.prompt,
187
242
  };
188
- if (typeof t.description === "string") task.description = t.description;
189
- if (typeof t.category === "string" && t.category.trim() !== "") task.category = t.category.trim();
243
+ if (typeof t.description === "string") task.description = t.description;
244
+ if (typeof t.category === "string" && t.category.trim() !== "") task.category = t.category.trim();
190
245
  if (typeof t.autoRun === "boolean") task.autoRun = t.autoRun;
191
246
  if (typeof t.requireDirty === "boolean") task.requireDirty = t.requireDirty;
192
- tasks.push(task);
247
+ if (t.guards !== undefined) {
248
+ if (Array.isArray(t.guards)) {
249
+ const guards: Guard[] = [];
250
+ for (const g of t.guards) {
251
+ const parsed = parseGuard(g, path, onError);
252
+ if (parsed) guards.push(parsed);
253
+ }
254
+ if (guards.length > 0) task.guards = guards;
255
+ } else {
256
+ onError(`do-always: ignoring invalid "guards" in ${path} (expected an array of guards)`);
257
+ }
258
+ }
259
+ if (t.when !== undefined) {
260
+ if (isValidWhen(t.when)) {
261
+ task.when = t.when;
262
+ } else {
263
+ onError(`do-always: ignoring invalid "when" in ${path} (expected "git"/"!git" or an object of git|branch|file|repo conditions)`);
264
+ }
265
+ }
266
+ tasks.push(task);
193
267
  } else {
194
268
  onError(`do-always: skipping invalid task in ${path} (each task needs "name" and "prompt")`);
195
269
  }
196
270
  }
197
271
 
198
272
  let shortcut: string | null | undefined;
273
+ let merge: "append" | "override" | undefined;
199
274
  if (!Array.isArray(data) && "shortcut" in data) {
200
275
  const s = data.shortcut;
201
276
  if (s === null) shortcut = null;
202
277
  else if (typeof s === "string") shortcut = s.trim() === "" ? null : s.trim();
203
278
  else onError(`do-always: ignoring invalid "shortcut" in ${path} (expected a key string or null)`);
204
279
  }
280
+ if (!Array.isArray(data) && "merge" in data) {
281
+ merge = parseMerge(data.merge, path, onError);
282
+ }
205
283
 
206
- return { tasks, shortcut };
284
+ return { tasks, shortcut, merge };
207
285
  }
208
286
 
209
287
  const KEY_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
@@ -242,12 +320,53 @@ export function resolveShortcut(
242
320
  return DEFAULT_SHORTCUT;
243
321
  }
244
322
 
323
+ /**
324
+ * Parse the optional `merge` field: "append" or "override" (case-insensitive),
325
+ * or undefined when absent. A non-string or unrecognized value is ignored with
326
+ * a warning, so it never silently changes behavior.
327
+ */
328
+ function parseMerge(
329
+ raw: unknown,
330
+ path: string,
331
+ onError: (message: string) => void,
332
+ ): "append" | "override" | undefined {
333
+ if (raw === undefined) return undefined;
334
+ if (typeof raw !== "string") {
335
+ onError(`do-always: ignoring invalid "merge" in ${path} (expected "append" or "override")`);
336
+ return undefined;
337
+ }
338
+ const v = raw.trim().toLowerCase();
339
+ if (v === "append" || v === "override") return v;
340
+ onError(`do-always: ignoring invalid "merge" in ${path} (expected "append" or "override")`);
341
+ return undefined;
342
+ }
343
+
245
344
  /**
246
345
  * Merge project-local tasks over global tasks.
247
- * A project task with a name matching a global task replaces it; new names are appended.
346
+ *
347
+ * When `mode` is "override" (default), a project task with a name matching a
348
+ * global task replaces it; new names are appended. When "append", globals are
349
+ * kept as-is and only new (non-duplicate) project task names are appended.
248
350
  * Returns fallback when the merged result is empty.
249
351
  */
250
- export function mergeTasks(globalTasks: DoAlwaysTask[], projectTasks: DoAlwaysTask[], fallback: DoAlwaysTask[]): DoAlwaysTask[] {
352
+ export function mergeTasks(
353
+ globalTasks: DoAlwaysTask[],
354
+ projectTasks: DoAlwaysTask[],
355
+ fallback: DoAlwaysTask[],
356
+ mode: "append" | "override" = "override",
357
+ ): DoAlwaysTask[] {
358
+ if (mode === "append") {
359
+ const merged = [...globalTasks];
360
+ const names = new Set(merged.map((t) => t.name));
361
+ for (const task of projectTasks) {
362
+ if (!names.has(task.name)) {
363
+ merged.push(task);
364
+ names.add(task.name);
365
+ }
366
+ }
367
+ return merged.length > 0 ? merged : fallback;
368
+ }
369
+
251
370
  const merged = [...globalTasks];
252
371
  for (const task of projectTasks) {
253
372
  const idx = merged.findIndex((t) => t.name === task.name);
@@ -257,6 +376,102 @@ export function mergeTasks(globalTasks: DoAlwaysTask[], projectTasks: DoAlwaysTa
257
376
  return merged.length > 0 ? merged : fallback;
258
377
  }
259
378
 
379
+ /**
380
+ * Validate the shape of a `when` condition: the string "git" or "!git", or an
381
+ * object whose entries are all known condition keys with matching value types
382
+ * (`git` -> boolean; `branch`/`file`/`repo` -> string). Used by `parseConfig`
383
+ * to reject malformed conditions with a warning instead of silently changing
384
+ * behavior.
385
+ */
386
+ export function isValidWhen(when: unknown): boolean {
387
+ if (typeof when === "string") {
388
+ return when === "git" || when === "!git";
389
+ }
390
+ if (typeof when !== "object" || when === null || Array.isArray(when)) {
391
+ return false;
392
+ }
393
+ for (const [key, value] of Object.entries(when as Record<string, unknown>)) {
394
+ switch (key) {
395
+ case "git":
396
+ if (typeof value !== "boolean") return false;
397
+ break;
398
+ case "branch":
399
+ case "file":
400
+ case "repo":
401
+ if (typeof value !== "string") return false;
402
+ break;
403
+ default:
404
+ return false; // unknown condition key
405
+ }
406
+ }
407
+ return true;
408
+ }
409
+
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
+ /** True when `relativePath` exists (as file or directory) under `cwd`. */
420
+ function pathExists(cwd: string, relativePath: string): boolean {
421
+ try {
422
+ return existsSync(join(cwd, relativePath));
423
+ } catch {
424
+ return false;
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Evaluate a single `when` object entry against the current prompt context.
430
+ * Unknown keys are treated as no-ops (permissive) so a typo never hides a task
431
+ * at runtime (parse time rejects them with a warning instead).
432
+ */
433
+ function evaluateWhenEntry(key: string, value: unknown, ctx: PromptContext): boolean {
434
+ switch (key) {
435
+ case "git":
436
+ return typeof value === "boolean" ? isGitRepo(ctx) === value : false;
437
+ case "branch":
438
+ return typeof value === "string" && ctx.branch === value;
439
+ case "file":
440
+ return typeof value === "string" && pathExists(ctx.cwd, value);
441
+ case "repo":
442
+ return typeof value === "string" && ctx.repo === value;
443
+ default:
444
+ return true;
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Evaluate a task's `when` condition against the current prompt context.
450
+ * Returns true when the task should be shown, false when its condition is not
451
+ * met. An omitted/undefined condition always shows the task.
452
+ *
453
+ * The string form is a single condition ("git" | "!git"). The object form is a
454
+ * set of conditions that must all hold (logical AND): `git`, `branch`, `file`,
455
+ * or `repo` (see the `DoAlwaysTask.when` field).
456
+ */
457
+ export function evaluateWhen(task: DoAlwaysTask, ctx: PromptContext): boolean {
458
+ const when = task.when;
459
+ if (when === undefined || when === null) return true;
460
+ if (typeof when === "string") {
461
+ const negated = when.startsWith("!");
462
+ const key = negated ? when.slice(1) : when;
463
+ if (key === "git") return negated ? !isGitRepo(ctx) : isGitRepo(ctx);
464
+ return true; // an invalid string condition is rejected at parse time
465
+ }
466
+ if (typeof when === "object") {
467
+ for (const [key, value] of Object.entries(when as Record<string, unknown>)) {
468
+ if (!evaluateWhenEntry(key, value, ctx)) return false;
469
+ }
470
+ return true;
471
+ }
472
+ return true;
473
+ }
474
+
260
475
  /** Default order for category headers in the selector. */
261
476
  export const DEFAULT_CATEGORY_ORDER = ["Plan", "Do", "Docs", "Ops", "Other"];
262
477
 
@@ -328,12 +543,117 @@ export function shouldAutoRun(task: DoAlwaysTask): boolean {
328
543
  * Commit on a clean tree so the agent is never asked to inspect nothing.
329
544
  */
330
545
  export function evaluateGuards(task: DoAlwaysTask, ctx: PromptContext): string | null {
331
- if (task.requireDirty && ctx.files_changed_count === "0") {
332
- return "working tree is clean — nothing to review";
546
+ // Legacy `requireDirty` is folded into the guard table so the set of guards
547
+ // is extensible without touching this function's callers.
548
+ const guards: Guard[] = [];
549
+ if (task.requireDirty) guards.push({ type: "requireDirty" });
550
+ guards.push(...(task.guards ?? []));
551
+ for (const g of guards) {
552
+ const message = guardFailureMessage(g, ctx);
553
+ if (message) return message;
333
554
  }
334
555
  return null;
335
556
  }
336
557
 
558
+ /**
559
+ * The blocking message a guard produces when its condition is unmet, or null
560
+ * when the guard passes. All guards are evaluated against the current prompt
561
+ * context, so a task is only injected when every guard is met.
562
+ */
563
+ function guardFailureMessage(g: Guard, ctx: PromptContext): string | null {
564
+ switch (g.type) {
565
+ case "requireDirty":
566
+ return ctx.files_changed_count === "0" ? "working tree is clean — nothing to review" : null;
567
+ case "requireBranch":
568
+ return ctx.branch === g.value ? null : `not on branch "${g.value}" (currently ${ctx.branch})`;
569
+ case "requireRepo":
570
+ return ctx.repo === g.value ? null : `not in repo "${g.value}" (currently ${ctx.repo})`;
571
+ case "requireFilePattern":
572
+ return filesMatchPattern(ctx, g.value!) ? null : `no changed files match "${g.value}"`;
573
+ default:
574
+ return null; // an unknown type is rejected at parse time
575
+ }
576
+ }
577
+
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
+ /**
588
+ * Whether any changed file matches `pattern`, treated as a glob: `*` matches
589
+ * within a path segment, `**` crosses segments, `?` matches one non-separator
590
+ * character, and other regex metacharacters are literal.
591
+ */
592
+ function filesMatchPattern(ctx: PromptContext, pattern: string): boolean {
593
+ const re = globToRegex(pattern);
594
+ return changedFiles(ctx).some((f) => re.test(f.trim()));
595
+ }
596
+
597
+ /** Regex metacharacters that must be escaped when matching a literal path char. */
598
+ const METACHARACTERS = ".+^${}()|[]";
599
+
600
+ /** Convert a glob to an anchored RegExp (`**` -> `.*`, `*` -> `[^/]*`, `?` -> `[^/]`). */
601
+ function globToRegex(pattern: string): RegExp {
602
+ let out = "";
603
+ let i = 0;
604
+ while (i < pattern.length) {
605
+ const c = pattern[i];
606
+ if (c === "*") {
607
+ let stars = 0;
608
+ while (i < pattern.length && pattern[i] === "*") {
609
+ stars++;
610
+ i++;
611
+ }
612
+ out += stars >= 2 ? ".*" : "[^/]*"; // `**` crosses path separators
613
+ } else if (c === "?") {
614
+ out += "[^/]";
615
+ i++;
616
+ } else {
617
+ out += METACHARACTERS.includes(c) ? "\\" + c : c;
618
+ i++;
619
+ }
620
+ }
621
+ return new RegExp(`^${out}$`);
622
+ }
623
+
624
+ /**
625
+ * Validate and normalize a single `guards` entry. Returns undefined (after
626
+ * warning) for an invalid entry so it is skipped rather than changing behavior.
627
+ */
628
+ export function parseGuard(
629
+ raw: unknown,
630
+ path: string,
631
+ onError: (message: string) => void = () => {},
632
+ ): Guard | undefined {
633
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
634
+ onError(`do-always: ignoring invalid guard in ${path} (expected an object)`);
635
+ return undefined;
636
+ }
637
+ const { type } = raw as Record<string, unknown>;
638
+ if (typeof type !== "string" || !GUARD_TYPES.includes(type as Guard["type"])) {
639
+ const known = GUARD_TYPES.join(", ");
640
+ onError(
641
+ `do-always: ignoring invalid "type" in guard ${path} (expected one of: ${known})`,
642
+ );
643
+ return undefined;
644
+ }
645
+ const guard: Guard = { type: type as Guard["type"] };
646
+ if (type !== "requireDirty") {
647
+ const { value } = raw as Record<string, unknown>;
648
+ if (typeof value !== "string") {
649
+ onError(`do-always: guard "${type}" in ${path} requires a string "value"`);
650
+ return undefined;
651
+ }
652
+ guard.value = value;
653
+ }
654
+ return guard;
655
+ }
656
+
337
657
  /**
338
658
  * Resolve a task from a command argument: by number (1-based) or by name (case-insensitive).
339
659
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-do-always",
3
- "version": "0.4.5",
3
+ "version": "0.6.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": {