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 +79 -0
- package/extensions/pi-do-always/index.ts +52 -19
- package/extensions/pi-do-always/tasks.ts +329 -9
- package/package.json +1 -1
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): {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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,
|
|
395
|
-
// digits
|
|
396
|
-
if (!filter && /^[1-9]$/.test(data) && Number(data) <=
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
|
|
332
|
-
|
|
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
|
*/
|