pi-do-always 0.4.4 → 0.5.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
|
@@ -112,7 +112,19 @@ Fields:
|
|
|
112
112
|
|
|
113
113
|
In the object form you can also configure the selector shortcut:
|
|
114
114
|
|
|
115
|
-
- `shortcut` (optional) — key that opens the selector, e.g. `"f4"
|
|
115
|
+
- `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.
|
|
116
|
+
- `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).
|
|
117
|
+
|
|
118
|
+
Example project file that only *adds* tasks without overriding the global set:
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"merge": "append",
|
|
123
|
+
"tasks": [
|
|
124
|
+
{ "name": "deploy", "category": "Ops", "prompt": "Deploy this project to staging." }
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
```
|
|
116
128
|
|
|
117
129
|
Reload Pi (or start a new session) after editing a config file.
|
|
118
130
|
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"name": "Propose features",
|
|
31
31
|
"category": "Plan",
|
|
32
32
|
"description": "Propose new features (Plan)",
|
|
33
|
-
"prompt": "Review this project and propose new features that would add value. For each idea, describe the problem it solves, the user benefit, and a rough implementation approach. Prioritize by impact and effort. Do not make any changes yet."
|
|
33
|
+
"prompt": "Review this project and propose new features that would add value. For each idea, describe the problem it solves, the user benefit, and a rough implementation approach. Prioritize by impact and effort. Do not make any changes yet. Try to evaluate how many lines this will be in term of changes, if this will breaks API, compatibility issue."
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"name": "Build",
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
parseConfig,
|
|
58
58
|
orderTasksByCategory,
|
|
59
59
|
renderPrompt,
|
|
60
|
+
evaluateGuards,
|
|
60
61
|
resolveShortcut,
|
|
61
62
|
resolveTask,
|
|
62
63
|
shouldAutoRun,
|
|
@@ -70,22 +71,31 @@ import {
|
|
|
70
71
|
* Project-local tasks override global tasks with the same name; new ones are appended.
|
|
71
72
|
* Falls back to DEFAULT_TASKS when nothing is defined.
|
|
72
73
|
*/
|
|
73
|
-
function loadConfig(cwd: string): {
|
|
74
|
+
function loadConfig(cwd: string): {
|
|
75
|
+
tasks: DoAlwaysTask[];
|
|
76
|
+
shortcut: string | null;
|
|
77
|
+
merge: "append" | "override";
|
|
78
|
+
} {
|
|
74
79
|
const globalPath = join(getAgentDir(), "do-always.json");
|
|
75
80
|
const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
|
|
76
81
|
|
|
77
82
|
const global = existsSync(globalPath)
|
|
78
83
|
? parseConfig(readFileSync(globalPath, "utf-8"), globalPath)
|
|
79
|
-
: { tasks: [], shortcut: undefined };
|
|
84
|
+
: { tasks: [], shortcut: undefined, merge: undefined };
|
|
80
85
|
const project = existsSync(projectPath)
|
|
81
86
|
? parseConfig(readFileSync(projectPath, "utf-8"), projectPath)
|
|
82
|
-
: { tasks: [], shortcut: undefined };
|
|
87
|
+
: { tasks: [], shortcut: undefined, merge: undefined };
|
|
88
|
+
|
|
89
|
+
// The project file's merge mode wins; otherwise the global value; otherwise
|
|
90
|
+
// override (the historical behavior), so existing configs are unaffected.
|
|
91
|
+
const mode = project.merge ?? global.merge ?? "override";
|
|
83
92
|
|
|
84
93
|
return {
|
|
85
94
|
// Order the merged list by category so the selector numbers, digit-pick,
|
|
86
95
|
// `/do-always <n>`, and `list` all share one consistent order.
|
|
87
|
-
tasks: orderTasksByCategory(mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS)),
|
|
96
|
+
tasks: orderTasksByCategory(mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS, mode)),
|
|
88
97
|
shortcut: resolveShortcut(global.shortcut, project.shortcut),
|
|
98
|
+
merge: mode,
|
|
89
99
|
};
|
|
90
100
|
}
|
|
91
101
|
|
|
@@ -123,6 +133,13 @@ function buildContext(cwd: string): PromptContext {
|
|
|
123
133
|
const lastCommit = git(cwd, ["log", "-1", "--format=%s"]) ?? "unknown";
|
|
124
134
|
const user = git(cwd, ["config", "user.name"]) ?? "unknown";
|
|
125
135
|
|
|
136
|
+
// repo = bare name of the git remote (owner/repo.git -> repo), falling back
|
|
137
|
+
// to the basename of cwd so monorepo work stays disambiguated everywhere.
|
|
138
|
+
const remoteUrl = git(cwd, ["config", "--get", "remote.origin.url"]);
|
|
139
|
+
const repo = remoteUrl
|
|
140
|
+
? (remoteUrl.replace(/\.git$/, "").split("/").pop() ?? "unknown")
|
|
141
|
+
: cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown";
|
|
142
|
+
|
|
126
143
|
const files: string[] = [];
|
|
127
144
|
const status = git(cwd, ["status", "--porcelain"]);
|
|
128
145
|
if (status) {
|
|
@@ -151,6 +168,10 @@ function buildContext(cwd: string): PromptContext {
|
|
|
151
168
|
files_changed: listed,
|
|
152
169
|
files_changed_count: String(files.length),
|
|
153
170
|
user,
|
|
171
|
+
diff_stat: git(cwd, ["diff", "--shortstat"]) ?? "none",
|
|
172
|
+
repo,
|
|
173
|
+
staged_files: git(cwd, ["diff", "--cached", "--name-only"]) ?? "none",
|
|
174
|
+
unstaged_files: git(cwd, ["diff", "--name-only"]) ?? "none",
|
|
154
175
|
};
|
|
155
176
|
}
|
|
156
177
|
|
|
@@ -182,7 +203,13 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
182
203
|
async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext): Promise<void> {
|
|
183
204
|
// Render the prompt with the current context (branch, changed files, …)
|
|
184
205
|
// so the injected text matches this directory at this moment.
|
|
185
|
-
const
|
|
206
|
+
const context = buildContext(ctx.cwd);
|
|
207
|
+
const blocked = evaluateGuards(task, context);
|
|
208
|
+
if (blocked) {
|
|
209
|
+
ctx.ui.notify(`do-always: ${blocked}`, "info");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const prompt = renderPrompt(task.prompt, context);
|
|
186
213
|
if (shouldAutoRun(task)) {
|
|
187
214
|
await pi.sendUserMessage(prompt);
|
|
188
215
|
ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
|
|
@@ -19,14 +19,30 @@ export interface DoAlwaysTask {
|
|
|
19
19
|
* category: "Plan" tasks auto-run, everything else fills the editor.
|
|
20
20
|
*/
|
|
21
21
|
autoRun?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Whether the task should be blocked when the working tree is clean
|
|
24
|
+
* (`files_changed_count === 0`). When set and unmet, the extension notifies
|
|
25
|
+
* instead of injecting, avoiding a no-op round-trip. Guards are evaluated
|
|
26
|
+
* against the current prompt context (see `evaluateGuards`).
|
|
27
|
+
*/
|
|
28
|
+
requireDirty?: boolean;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
/**
|
|
25
32
|
* A config file can be a bare array of tasks, or {"tasks": [...], "shortcut": ...}.
|
|
26
33
|
* `shortcut` is a key id string (e.g. "f4", "ctrl+shift+p"), or null to disable
|
|
27
34
|
* the keyboard shortcut.
|
|
35
|
+
* `merge` controls how project tasks combine with global tasks:
|
|
36
|
+
* `override` (default) replaces a global task with the same name;
|
|
37
|
+
* `append` keeps globals and only adds new project task names (a cascade).
|
|
28
38
|
*/
|
|
29
|
-
export type DoAlwaysConfig =
|
|
39
|
+
export type DoAlwaysConfig =
|
|
40
|
+
| DoAlwaysTask[]
|
|
41
|
+
| {
|
|
42
|
+
tasks: DoAlwaysTask[];
|
|
43
|
+
shortcut?: string | null;
|
|
44
|
+
merge?: "append" | "override";
|
|
45
|
+
};
|
|
30
46
|
|
|
31
47
|
/** Shortcut used when neither config file specifies one. */
|
|
32
48
|
export const DEFAULT_SHORTCUT = "f4";
|
|
@@ -39,6 +55,11 @@ export interface ParsedDoAlwaysConfig {
|
|
|
39
55
|
* disabled, undefined when the file does not set one.
|
|
40
56
|
*/
|
|
41
57
|
shortcut: string | null | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* The `merge` field, if present: "append" or "override", undefined when the
|
|
60
|
+
* file does not set one.
|
|
61
|
+
*/
|
|
62
|
+
merge?: "append" | "override" | undefined;
|
|
42
63
|
}
|
|
43
64
|
|
|
44
65
|
/**
|
|
@@ -55,6 +76,10 @@ export const PROMPT_CONTEXT_KEYS = [
|
|
|
55
76
|
"files_changed",
|
|
56
77
|
"files_changed_count",
|
|
57
78
|
"user",
|
|
79
|
+
"diff_stat", // NEW: "3 files changed, 41 insertions(+), 7 deletions(-)"
|
|
80
|
+
"repo", // NEW: basename of cwd or git remote (disambiguates monorepos)
|
|
81
|
+
"staged_files", // NEW: files staged for commit
|
|
82
|
+
"unstaged_files", // NEW: modified-but-unstaged files
|
|
58
83
|
] as const;
|
|
59
84
|
|
|
60
85
|
/** A fully populated prompt context: one entry per PROMPT_CONTEXT_KEYS. */
|
|
@@ -66,8 +91,13 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
|
66
91
|
name: "Review",
|
|
67
92
|
category: "Plan",
|
|
68
93
|
description: "Review code and double-check changes (Plan)",
|
|
94
|
+
requireDirty: true,
|
|
69
95
|
prompt:
|
|
70
|
-
"Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}).
|
|
96
|
+
"Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). " +
|
|
97
|
+
"Change summary: {{diff_stat}}. Last commit: {{last_commit}}. " +
|
|
98
|
+
"Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, " +
|
|
99
|
+
"edge cases, security issues, and consistency with the rest of the codebase. " +
|
|
100
|
+
"Do a plan proposal for the fixes if needed. Do a summary of your findings",
|
|
71
101
|
},
|
|
72
102
|
{
|
|
73
103
|
name: "Cleanup",
|
|
@@ -122,6 +152,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
|
122
152
|
name: "Commit",
|
|
123
153
|
category: "Ops",
|
|
124
154
|
description: "Prepare a clean commit",
|
|
155
|
+
requireDirty: true,
|
|
125
156
|
prompt:
|
|
126
157
|
"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.",
|
|
127
158
|
},
|
|
@@ -130,7 +161,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
|
130
161
|
category: "Plan",
|
|
131
162
|
description: "Propose new features (Plan)",
|
|
132
163
|
prompt:
|
|
133
|
-
"Review this project and propose new features that would add value. For each idea, describe the problem it solves, the user benefit, and a rough implementation approach. Prioritize by impact and effort. Do not make any changes yet.",
|
|
164
|
+
"Review this project and propose new features that would add value. For each idea, describe the problem it solves, the user benefit, and a rough implementation approach. Prioritize by impact and effort. Do not make any changes yet. Try to evaluate how many lines this will be in term of changes, if this will breaks API, compatibility issue.",
|
|
134
165
|
},
|
|
135
166
|
];
|
|
136
167
|
|
|
@@ -170,22 +201,27 @@ export function parseConfig(
|
|
|
170
201
|
};
|
|
171
202
|
if (typeof t.description === "string") task.description = t.description;
|
|
172
203
|
if (typeof t.category === "string" && t.category.trim() !== "") task.category = t.category.trim();
|
|
173
|
-
|
|
174
|
-
|
|
204
|
+
if (typeof t.autoRun === "boolean") task.autoRun = t.autoRun;
|
|
205
|
+
if (typeof t.requireDirty === "boolean") task.requireDirty = t.requireDirty;
|
|
206
|
+
tasks.push(task);
|
|
175
207
|
} else {
|
|
176
208
|
onError(`do-always: skipping invalid task in ${path} (each task needs "name" and "prompt")`);
|
|
177
209
|
}
|
|
178
210
|
}
|
|
179
211
|
|
|
180
212
|
let shortcut: string | null | undefined;
|
|
213
|
+
let merge: "append" | "override" | undefined;
|
|
181
214
|
if (!Array.isArray(data) && "shortcut" in data) {
|
|
182
215
|
const s = data.shortcut;
|
|
183
216
|
if (s === null) shortcut = null;
|
|
184
217
|
else if (typeof s === "string") shortcut = s.trim() === "" ? null : s.trim();
|
|
185
218
|
else onError(`do-always: ignoring invalid "shortcut" in ${path} (expected a key string or null)`);
|
|
186
219
|
}
|
|
220
|
+
if (!Array.isArray(data) && "merge" in data) {
|
|
221
|
+
merge = parseMerge(data.merge, path, onError);
|
|
222
|
+
}
|
|
187
223
|
|
|
188
|
-
return { tasks, shortcut };
|
|
224
|
+
return { tasks, shortcut, merge };
|
|
189
225
|
}
|
|
190
226
|
|
|
191
227
|
const KEY_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
|
|
@@ -224,12 +260,53 @@ export function resolveShortcut(
|
|
|
224
260
|
return DEFAULT_SHORTCUT;
|
|
225
261
|
}
|
|
226
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Parse the optional `merge` field: "append" or "override" (case-insensitive),
|
|
265
|
+
* or undefined when absent. A non-string or unrecognized value is ignored with
|
|
266
|
+
* a warning, so it never silently changes behavior.
|
|
267
|
+
*/
|
|
268
|
+
function parseMerge(
|
|
269
|
+
raw: unknown,
|
|
270
|
+
path: string,
|
|
271
|
+
onError: (message: string) => void,
|
|
272
|
+
): "append" | "override" | undefined {
|
|
273
|
+
if (raw === undefined) return undefined;
|
|
274
|
+
if (typeof raw !== "string") {
|
|
275
|
+
onError(`do-always: ignoring invalid "merge" in ${path} (expected "append" or "override")`);
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
const v = raw.trim().toLowerCase();
|
|
279
|
+
if (v === "append" || v === "override") return v;
|
|
280
|
+
onError(`do-always: ignoring invalid "merge" in ${path} (expected "append" or "override")`);
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
227
284
|
/**
|
|
228
285
|
* Merge project-local tasks over global tasks.
|
|
229
|
-
*
|
|
286
|
+
*
|
|
287
|
+
* When `mode` is "override" (default), a project task with a name matching a
|
|
288
|
+
* global task replaces it; new names are appended. When "append", globals are
|
|
289
|
+
* kept as-is and only new (non-duplicate) project task names are appended.
|
|
230
290
|
* Returns fallback when the merged result is empty.
|
|
231
291
|
*/
|
|
232
|
-
export function mergeTasks(
|
|
292
|
+
export function mergeTasks(
|
|
293
|
+
globalTasks: DoAlwaysTask[],
|
|
294
|
+
projectTasks: DoAlwaysTask[],
|
|
295
|
+
fallback: DoAlwaysTask[],
|
|
296
|
+
mode: "append" | "override" = "override",
|
|
297
|
+
): DoAlwaysTask[] {
|
|
298
|
+
if (mode === "append") {
|
|
299
|
+
const merged = [...globalTasks];
|
|
300
|
+
const names = new Set(merged.map((t) => t.name));
|
|
301
|
+
for (const task of projectTasks) {
|
|
302
|
+
if (!names.has(task.name)) {
|
|
303
|
+
merged.push(task);
|
|
304
|
+
names.add(task.name);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return merged.length > 0 ? merged : fallback;
|
|
308
|
+
}
|
|
309
|
+
|
|
233
310
|
const merged = [...globalTasks];
|
|
234
311
|
for (const task of projectTasks) {
|
|
235
312
|
const idx = merged.findIndex((t) => t.name === task.name);
|
|
@@ -301,6 +378,21 @@ export function shouldAutoRun(task: DoAlwaysTask): boolean {
|
|
|
301
378
|
return (task.category ?? "").trim().toLowerCase() === "plan";
|
|
302
379
|
}
|
|
303
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Evaluate a task's guards against the current prompt context. Returns a
|
|
383
|
+
* notification message (caller should notify and abort) when a guard fails, or
|
|
384
|
+
* null when every guard is met and the task may proceed.
|
|
385
|
+
*
|
|
386
|
+
* Guards keep low-value round-trips down: e.g. `requireDirty` blocks Review and
|
|
387
|
+
* Commit on a clean tree so the agent is never asked to inspect nothing.
|
|
388
|
+
*/
|
|
389
|
+
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";
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
|
|
304
396
|
/**
|
|
305
397
|
* Resolve a task from a command argument: by number (1-based) or by name (case-insensitive).
|
|
306
398
|
*/
|