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"` or `"ctrl+shift+p"`. Set to `null` to disable the shortcut. Defaults to `F4`. The project file's value wins over the global one.
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): { tasks: DoAlwaysTask[]; shortcut: string | null } {
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 prompt = renderPrompt(task.prompt, buildContext(ctx.cwd));
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 = DoAlwaysTask[] | { tasks: DoAlwaysTask[]; shortcut?: string | null };
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}}). Last commit: {{last_commit}}. Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. Do a plan proposal for the fixes if needed. Do a summary of your findings",
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
- if (typeof t.autoRun === "boolean") task.autoRun = t.autoRun;
174
- tasks.push(task);
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
- * A project task with a name matching a global task replaces it; new names are appended.
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(globalTasks: DoAlwaysTask[], projectTasks: DoAlwaysTask[], fallback: DoAlwaysTask[]): DoAlwaysTask[] {
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
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-do-always",
3
- "version": "0.4.4",
3
+ "version": "0.5.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": {