pi-do-always 0.5.0 → 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 +67 -0
- package/extensions/pi-do-always/index.ts +39 -15
- package/extensions/pi-do-always/tasks.ts +266 -5
- package/package.json +1 -1
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
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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,
|
|
404
|
-
// digits
|
|
405
|
-
if (!filter && /^[1-9]$/.test(data) && Number(data) <=
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
*/
|