pi-do-always 0.5.0 → 0.7.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,6 +109,8 @@ 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
 
@@ -150,6 +152,71 @@ injected unchanged, so existing configs keep working. The selector preview and
150
152
  `/do-always list-details` show the rendered prompt — what you see is what gets
151
153
  injected.
152
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
+
153
220
  ## Development
154
221
 
155
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,
@@ -232,6 +233,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
232
233
  // loop — no process spawning per frame). fillPrompt re-renders at
233
234
  // selection time, so a few seconds of drift is acceptable.
234
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));
235
239
  const selected = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
236
240
  let settled = false;
237
241
  let previewVisible = false;
@@ -244,11 +248,15 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
244
248
  }
245
249
  }
246
250
 
247
- 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) => {
248
256
  if (settled) return;
249
257
  settled = true;
250
258
  clearPreviewTimer();
251
- done(value);
259
+ done(task ? tasks.indexOf(task) : null);
252
260
  };
253
261
 
254
262
  // The prompt preview appears only after the selection has been stable
@@ -266,7 +274,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
266
274
  }
267
275
 
268
276
  // Group tasks under category headers, in a stable order.
269
- const groups = groupTasksByCategory(tasks);
277
+ const groups = groupTasksByCategory(visibleTasks);
270
278
 
271
279
  const kb = getKeybindings();
272
280
  const maxVisible = 12;
@@ -356,7 +364,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
356
364
  continue;
357
365
  }
358
366
  if (!visibleItemKeys.has(row.task)) continue;
359
- const globalIndex = tasks.indexOf(row.task);
367
+ const globalIndex = visibleTasks.indexOf(row.task);
360
368
  const isSelected = row.task === itemRows[selectedIndex].task;
361
369
  lines.push(renderLabel(row.task, globalIndex, isSelected, width));
362
370
  itemLine.set(lines.length - 1, row.task);
@@ -400,10 +408,10 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
400
408
  },
401
409
  invalidate() {},
402
410
  handleInput(data: string) {
403
- // Direct pick by number (1-9) — only when not filtering, so
404
- // digits can be typed into the filter otherwise.
405
- if (!filter && /^[1-9]$/.test(data) && Number(data) <= tasks.length) {
406
- 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]);
407
415
  return;
408
416
  }
409
417
  // Filter typing.
@@ -435,7 +443,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
435
443
  }
436
444
  else if (kb.matches(data, "tui.select.confirm")) {
437
445
  const chosen = itemRows[selectedIndex];
438
- if (chosen) finish(tasks.indexOf(chosen.task));
446
+ if (chosen) finish(chosen.task);
439
447
  }
440
448
  else if (kb.matches(data, "tui.select.cancel")) {
441
449
  finish(null);
@@ -468,7 +476,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
468
476
  const clicked = mousePressedIndex ?? idx;
469
477
  mousePressedIndex = null;
470
478
  const chosen = itemRows[clicked];
471
- if (chosen) finish(tasks.indexOf(chosen.task));
479
+ if (chosen) finish(chosen.task);
472
480
  return { handled: true };
473
481
  },
474
482
  };
@@ -526,13 +534,17 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
526
534
  if (ctx.mode === "tui") {
527
535
  await showSelector(ctx);
528
536
  } else {
529
- 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");
530
540
  }
531
541
  return;
532
542
  }
533
543
 
534
544
  if (arg.toLowerCase() === "list") {
535
- 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");
536
548
  return;
537
549
  }
538
550
 
@@ -540,7 +552,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
540
552
  // Display only — the description is metadata; selecting a task injects just its prompt.
541
553
  // Render with the current context so what is shown is what gets injected.
542
554
  const context = buildContext(ctx.cwd);
543
- const details = tasks
555
+ const visible = tasks.filter((t) => evaluateWhen(t, context));
556
+ const details = visible
544
557
  .map((t, i) => {
545
558
  const lines = [`${i + 1}. ${t.name}`];
546
559
  if (t.description) lines.push(` description: ${t.description}`);
@@ -557,12 +570,23 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
557
570
  return;
558
571
  }
559
572
 
560
- 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);
561
579
  if (!task) {
562
- const available = tasks.map((t, i) => `${i + 1}=${t.name}`).join(", ");
580
+ const available = visible.map((t, i) => `${i + 1}=${t.name}`).join(", ");
563
581
  ctx.ui.notify(`do-always: unknown task "${arg}". Available: ${available}`, "error");
564
582
  return;
565
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
+ }
566
590
  await fillPrompt(task, ctx);
567
591
  }
568
592
  }
@@ -26,8 +26,44 @@ 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[];
47
+ }
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;
29
57
  }
30
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
@@ -85,6 +121,9 @@ export const PROMPT_CONTEXT_KEYS = [
85
121
  /** A fully populated prompt context: one entry per PROMPT_CONTEXT_KEYS. */
86
122
  export type PromptContext = Record<(typeof PROMPT_CONTEXT_KEYS)[number], string>;
87
123
 
124
+ import { existsSync } from "node:fs";
125
+ import { join } from "node:path";
126
+
88
127
  /** Used when neither config file defines any task. */
89
128
  export const DEFAULT_TASKS: DoAlwaysTask[] = [
90
129
  {
@@ -145,6 +184,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
145
184
  name: "Release",
146
185
  category: "Ops",
147
186
  description: "Prepare a release (version, changelog, tag)",
187
+ when: "git",
148
188
  prompt:
149
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.",
150
190
  },
@@ -153,6 +193,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
153
193
  category: "Ops",
154
194
  description: "Prepare a clean commit",
155
195
  requireDirty: true,
196
+ when: "git",
156
197
  prompt:
157
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.",
158
199
  },
@@ -199,11 +240,30 @@ export function parseConfig(
199
240
  name: t.name,
200
241
  prompt: t.prompt,
201
242
  };
202
- if (typeof t.description === "string") task.description = t.description;
203
- 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();
204
245
  if (typeof t.autoRun === "boolean") task.autoRun = t.autoRun;
205
246
  if (typeof t.requireDirty === "boolean") task.requireDirty = t.requireDirty;
206
- 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);
207
267
  } else {
208
268
  onError(`do-always: skipping invalid task in ${path} (each task needs "name" and "prompt")`);
209
269
  }
@@ -316,6 +376,102 @@ export function mergeTasks(
316
376
  return merged.length > 0 ? merged : fallback;
317
377
  }
318
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
+
319
475
  /** Default order for category headers in the selector. */
320
476
  export const DEFAULT_CATEGORY_ORDER = ["Plan", "Do", "Docs", "Ops", "Other"];
321
477
 
@@ -387,12 +543,117 @@ export function shouldAutoRun(task: DoAlwaysTask): boolean {
387
543
  * Commit on a clean tree so the agent is never asked to inspect nothing.
388
544
  */
389
545
  export function evaluateGuards(task: DoAlwaysTask, ctx: PromptContext): string | null {
390
- if (task.requireDirty && ctx.files_changed_count === "0") {
391
- 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;
392
554
  }
393
555
  return null;
394
556
  }
395
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
+
396
657
  /**
397
658
  * Resolve a task from a command argument: by number (1-based) or by name (case-insensitive).
398
659
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-do-always",
3
- "version": "0.5.0",
3
+ "version": "0.7.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": {