pi-do-always 0.8.0 → 0.9.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 +5 -1
- package/extensions/pi-do-always/do-always.json +6 -4
- package/extensions/pi-do-always/index.ts +106 -89
- package/extensions/pi-do-always/tasks.ts +116 -36
- package/package.json +2 -2
- package/pi.image.png +0 -0
package/README.md
CHANGED
|
@@ -147,6 +147,10 @@ injects “Review the changes on branch `fix/login-null` (3 changed files:
|
|
|
147
147
|
|`{{files_changed}}`|Changed files from `git status` — comma-separated, capped at 20 entries (`none` when clean or not a git repo)|
|
|
148
148
|
|`{{files_changed_count}}`|Number of changed files (`0` when clean or not a git repo)|
|
|
149
149
|
|`{{user}}`|`git config user.name` (`unknown` when unset)|
|
|
150
|
+
|`{{diff_stat}}`|`git diff --shortstat` output, e.g. `3 files changed, 41 insertions(+), 7 deletions(-)` (`none` when unavailable)|
|
|
151
|
+
|`{{repo}}`|Basename of the git remote (or of the working directory when there is no remote) — disambiguates monorepo work|
|
|
152
|
+
|`{{staged_files}}`|Files staged for commit, one per line (`none` when empty)|
|
|
153
|
+
|`{{unstaged_files}}`|Modified-but-unstaged files, one per line (`none` when empty)|
|
|
150
154
|
|
|
151
155
|
Unknown placeholders are left as-is, and a prompt without placeholders is
|
|
152
156
|
injected unchanged, so existing configs keep working. The selector preview and
|
|
@@ -198,7 +202,7 @@ The `guards` array accepts these guard objects (all must pass):
|
|
|
198
202
|
|
|
199
203
|
| `type` | `value` | Blocks when… |
|
|
200
204
|
|---|---|---|
|
|
201
|
-
| `requireDirty` | none | the working tree is clean (
|
|
205
|
+
| `requireDirty` | none | the working tree is clean (no changed files) |
|
|
202
206
|
| `requireBranch` | branch name | the current branch is not the given name |
|
|
203
207
|
| `requireRepo` | repo name | the git-remote basename context value is not the given name |
|
|
204
208
|
| `requireFilePattern` | glob | no changed file matches the glob |
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
{
|
|
5
5
|
"name": "Review changes",
|
|
6
6
|
"category": "Plan",
|
|
7
|
-
"requireDirty": true,
|
|
8
7
|
"description": "Review the current code changes (Plan)",
|
|
9
|
-
"
|
|
8
|
+
"requireDirty": true,
|
|
9
|
+
"prompt": "Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). Change summary: {{diff_stat}}. 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"
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
12
|
"name": "Review code",
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
{
|
|
18
18
|
"name": "Cleanup",
|
|
19
19
|
"category": "Plan",
|
|
20
|
-
"autoRun": false,
|
|
21
20
|
"description": "Clean up dead code and duplicates (Plan)",
|
|
22
21
|
"prompt": "Scan the project for dead code, unused imports, commented-out blocks, and duplicated logic. Do a plan proposal for the removals and consolidations, keeping behavior unchanged. Do not make any changes yet."
|
|
23
22
|
},
|
|
@@ -61,13 +60,16 @@
|
|
|
61
60
|
"name": "Release",
|
|
62
61
|
"category": "Ops",
|
|
63
62
|
"description": "Prepare a release (version, changelog, tag)",
|
|
63
|
+
"when": "git",
|
|
64
64
|
"prompt": "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."
|
|
65
65
|
},
|
|
66
66
|
{
|
|
67
67
|
"name": "Commit",
|
|
68
68
|
"category": "Ops",
|
|
69
69
|
"description": "Prepare a clean commit",
|
|
70
|
-
"
|
|
70
|
+
"requireDirty": true,
|
|
71
|
+
"when": "git",
|
|
72
|
+
"prompt": "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."
|
|
71
73
|
}
|
|
72
74
|
]
|
|
73
75
|
}
|
|
@@ -50,41 +50,51 @@ import {
|
|
|
50
50
|
import {
|
|
51
51
|
DEFAULT_SHORTCUT,
|
|
52
52
|
DEFAULT_TASKS,
|
|
53
|
+
evaluateGuards,
|
|
54
|
+
evaluateWhen,
|
|
53
55
|
formatList,
|
|
54
56
|
groupTasksByCategory,
|
|
55
57
|
isValidKeyId,
|
|
56
58
|
mergeTasks,
|
|
57
59
|
parseConfig,
|
|
60
|
+
parseStatusPorcelain,
|
|
58
61
|
orderTasksByCategory,
|
|
59
62
|
renderPrompt,
|
|
60
|
-
evaluateGuards,
|
|
61
|
-
evaluateWhen,
|
|
62
63
|
resolveShortcut,
|
|
63
64
|
resolveTask,
|
|
64
65
|
shouldAutoRun,
|
|
66
|
+
splitFileLines,
|
|
67
|
+
toPromptContext,
|
|
65
68
|
type DoAlwaysTask,
|
|
66
|
-
type
|
|
69
|
+
type TaskContext,
|
|
67
70
|
type TaskGroup,
|
|
68
71
|
} from "./tasks";
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
74
|
* Load tasks and the selector shortcut from config files.
|
|
72
|
-
* Project-local tasks override global tasks with the same name
|
|
73
|
-
*
|
|
75
|
+
* Project-local tasks override global tasks with the same name (or are
|
|
76
|
+
* appended, per the `merge` field); new ones are appended. Falls back to
|
|
77
|
+
* DEFAULT_TASKS when nothing is defined.
|
|
78
|
+
*
|
|
79
|
+
* Validation problems (malformed JSON, invalid tasks/shortcut/merge/when/
|
|
80
|
+
* guards) are reported through `onError` — callers must wire it up, since
|
|
81
|
+
* the default is a silent no-op.
|
|
74
82
|
*/
|
|
75
|
-
function loadConfig(
|
|
83
|
+
function loadConfig(
|
|
84
|
+
cwd: string,
|
|
85
|
+
onError: (message: string) => void = () => {},
|
|
86
|
+
): {
|
|
76
87
|
tasks: DoAlwaysTask[];
|
|
77
88
|
shortcut: string | null;
|
|
78
|
-
merge: "append" | "override";
|
|
79
89
|
} {
|
|
80
90
|
const globalPath = join(getAgentDir(), "do-always.json");
|
|
81
91
|
const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
|
|
82
92
|
|
|
83
93
|
const global = existsSync(globalPath)
|
|
84
|
-
? parseConfig(readFileSync(globalPath, "utf-8"), globalPath)
|
|
94
|
+
? parseConfig(readFileSync(globalPath, "utf-8"), globalPath, onError)
|
|
85
95
|
: { tasks: [], shortcut: undefined, merge: undefined };
|
|
86
96
|
const project = existsSync(projectPath)
|
|
87
|
-
? parseConfig(readFileSync(projectPath, "utf-8"), projectPath)
|
|
97
|
+
? parseConfig(readFileSync(projectPath, "utf-8"), projectPath, onError)
|
|
88
98
|
: { tasks: [], shortcut: undefined, merge: undefined };
|
|
89
99
|
|
|
90
100
|
// The project file's merge mode wins; otherwise the global value; otherwise
|
|
@@ -96,7 +106,6 @@ function loadConfig(cwd: string): {
|
|
|
96
106
|
// `/do-always <n>`, and `list` all share one consistent order.
|
|
97
107
|
tasks: orderTasksByCategory(mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS, mode)),
|
|
98
108
|
shortcut: resolveShortcut(global.shortcut, project.shortcut),
|
|
99
|
-
merge: mode,
|
|
100
109
|
};
|
|
101
110
|
}
|
|
102
111
|
|
|
@@ -120,19 +129,19 @@ function git(cwd: string, args: string[]): string | undefined {
|
|
|
120
129
|
}
|
|
121
130
|
}
|
|
122
131
|
|
|
123
|
-
/** Max number of file paths listed in `files_changed` (the count stays exact). */
|
|
124
|
-
const MAX_FILES_LISTED = 20;
|
|
125
|
-
|
|
126
132
|
/**
|
|
127
|
-
* Build the
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
133
|
+
* Build the structured context for the current directory. Git facts fall back
|
|
134
|
+
* to neutral values when unavailable (non-git dir, no git, empty repo) so
|
|
135
|
+
* default prompts read cleanly in any directory. The string view for
|
|
136
|
+
* `renderPrompt` is derived with `toPromptContext`.
|
|
131
137
|
*/
|
|
132
|
-
function buildContext(cwd: string):
|
|
138
|
+
function buildContext(cwd: string): TaskContext {
|
|
133
139
|
const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) ?? "unknown";
|
|
134
140
|
const lastCommit = git(cwd, ["log", "-1", "--format=%s"]) ?? "unknown";
|
|
135
141
|
const user = git(cwd, ["config", "user.name"]) ?? "unknown";
|
|
142
|
+
// Authoritative working-tree check — the branch sentinel is not (a branch
|
|
143
|
+
// could literally be named "unknown", and detached HEAD reports "HEAD").
|
|
144
|
+
const isGitRepo = git(cwd, ["rev-parse", "--is-inside-work-tree"]) === "true";
|
|
136
145
|
|
|
137
146
|
// repo = bare name of the git remote (owner/repo.git -> repo), falling back
|
|
138
147
|
// to the basename of cwd so monorepo work stays disambiguated everywhere.
|
|
@@ -141,38 +150,18 @@ function buildContext(cwd: string): PromptContext {
|
|
|
141
150
|
? (remoteUrl.replace(/\.git$/, "").split("/").pop() ?? "unknown")
|
|
142
151
|
: cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown";
|
|
143
152
|
|
|
144
|
-
const files: string[] = [];
|
|
145
|
-
const status = git(cwd, ["status", "--porcelain"]);
|
|
146
|
-
if (status) {
|
|
147
|
-
for (const line of status.split("\n")) {
|
|
148
|
-
// Porcelain v1 lines are "XY <path>" (X = index, Y = worktree).
|
|
149
|
-
if (line.length < 4) continue;
|
|
150
|
-
const path = line.slice(3);
|
|
151
|
-
if (path && !files.includes(path)) files.push(path);
|
|
152
|
-
}
|
|
153
|
-
files.sort();
|
|
154
|
-
}
|
|
155
|
-
let listed: string;
|
|
156
|
-
if (files.length === 0) {
|
|
157
|
-
listed = "none";
|
|
158
|
-
} else if (files.length > MAX_FILES_LISTED) {
|
|
159
|
-
listed = [...files.slice(0, MAX_FILES_LISTED), `… (+${files.length - MAX_FILES_LISTED} more)`].join(", ");
|
|
160
|
-
} else {
|
|
161
|
-
listed = files.join(", ");
|
|
162
|
-
}
|
|
163
|
-
|
|
164
153
|
return {
|
|
165
154
|
cwd,
|
|
166
155
|
date: new Date().toLocaleDateString("en-CA"), // local YYYY-MM-DD
|
|
167
156
|
branch,
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
files_changed_count: String(files.length),
|
|
157
|
+
lastCommit,
|
|
158
|
+
files: parseStatusPorcelain(git(cwd, ["status", "--porcelain"]) ?? ""),
|
|
171
159
|
user,
|
|
172
|
-
|
|
160
|
+
diffStat: git(cwd, ["diff", "--shortstat"]) ?? "none",
|
|
173
161
|
repo,
|
|
174
|
-
|
|
175
|
-
|
|
162
|
+
stagedFiles: splitFileLines(git(cwd, ["diff", "--cached", "--name-only"])),
|
|
163
|
+
unstagedFiles: splitFileLines(git(cwd, ["diff", "--name-only"])),
|
|
164
|
+
isGitRepo,
|
|
176
165
|
};
|
|
177
166
|
}
|
|
178
167
|
|
|
@@ -194,23 +183,62 @@ const PREVIEW_MAX_LINES = 3;
|
|
|
194
183
|
export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
195
184
|
let tasks: DoAlwaysTask[] = [];
|
|
196
185
|
let loadedCwd = ""; // cwd the cached `tasks` were loaded for
|
|
186
|
+
// The visible (when-filtered) list for the last context we built, so
|
|
187
|
+
// argument completions number tasks the same way the selector and
|
|
188
|
+
// `/do-always <n>` do. When stale (or absent), completions fall back to
|
|
189
|
+
// the full list rather than guessing.
|
|
190
|
+
let visibleCache: { cwd: string; visible: DoAlwaysTask[] } | null = null;
|
|
191
|
+
|
|
192
|
+
/** Filter tasks by their `when` condition and refresh the completion cache. */
|
|
193
|
+
function refreshVisible(cwd: string, context: TaskContext): DoAlwaysTask[] {
|
|
194
|
+
const visible = tasks.filter((t) => evaluateWhen(t, context));
|
|
195
|
+
visibleCache = { cwd, visible };
|
|
196
|
+
return visible;
|
|
197
|
+
}
|
|
197
198
|
|
|
198
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Register the selector shortcut from a resolved config value (null
|
|
201
|
+
* disables it). Called from session_start so it reads the session's cwd,
|
|
202
|
+
* not the process cwd; re-registering the same key is idempotent.
|
|
203
|
+
*/
|
|
204
|
+
function registerShortcut(shortcut: string | null, onError: (message: string) => void): void {
|
|
205
|
+
if (shortcut === null) return;
|
|
206
|
+
const shortcutKey = isValidKeyId(shortcut) ? shortcut : DEFAULT_SHORTCUT;
|
|
207
|
+
if (shortcutKey !== shortcut) {
|
|
208
|
+
onError(`do-always: invalid shortcut "${shortcut}" in do-always.json — using ${DEFAULT_SHORTCUT}`);
|
|
209
|
+
}
|
|
210
|
+
pi.registerShortcut(shortcutKey as KeyId, {
|
|
211
|
+
description: "do-always: pick a common task",
|
|
212
|
+
handler: async (ctx) => {
|
|
213
|
+
await runDoAlways("", ctx);
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
pi.on("session_start", (_event, ctx) => {
|
|
219
|
+
// Surface config validation problems (the README promises warnings);
|
|
220
|
+
// in non-TUI modes there is no UI, so fall back to the console.
|
|
221
|
+
const onError = (m: string) => {
|
|
222
|
+
if (ctx.mode === "tui") ctx.ui.notify(m, "warning");
|
|
223
|
+
else console.warn(m);
|
|
224
|
+
};
|
|
199
225
|
loadedCwd = ctx.cwd;
|
|
200
|
-
|
|
226
|
+
const config = loadConfig(ctx.cwd, onError);
|
|
227
|
+
tasks = config.tasks;
|
|
228
|
+
refreshVisible(ctx.cwd, buildContext(ctx.cwd));
|
|
229
|
+
registerShortcut(config.shortcut, onError);
|
|
201
230
|
});
|
|
202
231
|
|
|
203
232
|
/** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
|
|
204
|
-
async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext): Promise<void> {
|
|
205
|
-
// Render the prompt with the current context (branch, changed files, …)
|
|
206
|
-
// so the injected text matches this directory at this moment.
|
|
207
|
-
const context = buildContext(ctx.cwd);
|
|
233
|
+
async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext, context: TaskContext): Promise<void> {
|
|
208
234
|
const blocked = evaluateGuards(task, context);
|
|
209
235
|
if (blocked) {
|
|
210
236
|
ctx.ui.notify(`do-always: ${blocked}`, "info");
|
|
211
237
|
return;
|
|
212
238
|
}
|
|
213
|
-
|
|
239
|
+
// Render with the same context the selector/preview used, so what the
|
|
240
|
+
// user saw is exactly what gets injected.
|
|
241
|
+
const prompt = renderPrompt(task.prompt, toPromptContext(context));
|
|
214
242
|
if (shouldAutoRun(task)) {
|
|
215
243
|
await pi.sendUserMessage(prompt);
|
|
216
244
|
ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
|
|
@@ -227,15 +255,15 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
227
255
|
/**
|
|
228
256
|
* Numbered selector with categorized sections. Press 1-9 to pick by global
|
|
229
257
|
* number, type to filter, or navigate with arrows + Enter, Esc to cancel.
|
|
258
|
+
* The context is built once per command run (never inside the render loop
|
|
259
|
+
* — no process spawning per frame) and shared with `fillPrompt`.
|
|
230
260
|
*/
|
|
231
|
-
async function showSelector(ctx: ExtensionContext): Promise<void> {
|
|
232
|
-
// Build the context once per selector session (never inside the render
|
|
233
|
-
// loop — no process spawning per frame). fillPrompt re-renders at
|
|
234
|
-
// selection time, so a few seconds of drift is acceptable.
|
|
235
|
-
const context = buildContext(ctx.cwd);
|
|
261
|
+
async function showSelector(ctx: ExtensionContext, context: TaskContext): Promise<void> {
|
|
236
262
|
// Filter by the `when` condition once per session, so hidden tasks never
|
|
237
263
|
// appear, are never numbered, and can't be picked.
|
|
238
264
|
const visibleTasks = tasks.filter((t) => evaluateWhen(t, context));
|
|
265
|
+
// String view for prompt rendering (derived once, used by the preview).
|
|
266
|
+
const strings = toPromptContext(context);
|
|
239
267
|
const selected = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
|
|
240
268
|
let settled = false;
|
|
241
269
|
let previewVisible = false;
|
|
@@ -381,7 +409,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
381
409
|
if (sel) {
|
|
382
410
|
const wrapWidth = Math.max(10, width - 4);
|
|
383
411
|
// Show the rendered prompt — exactly what will be injected.
|
|
384
|
-
const wrapped = wrapTextWithAnsi(renderPrompt(sel.task.prompt,
|
|
412
|
+
const wrapped = wrapTextWithAnsi(renderPrompt(sel.task.prompt, strings), wrapWidth);
|
|
385
413
|
const shown = wrapped.slice(0, PREVIEW_MAX_LINES);
|
|
386
414
|
const truncated = wrapped.length > PREVIEW_MAX_LINES;
|
|
387
415
|
lines.push("");
|
|
@@ -483,17 +511,22 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
483
511
|
});
|
|
484
512
|
|
|
485
513
|
if (selected === null || selected === undefined) return;
|
|
486
|
-
await fillPrompt(tasks[selected], ctx);
|
|
514
|
+
await fillPrompt(tasks[selected], ctx, context);
|
|
487
515
|
}
|
|
488
516
|
|
|
489
517
|
pi.registerCommand("do-always", {
|
|
490
518
|
description: "Pick a common task (review, readme, ...) by number — fills the prompt",
|
|
491
519
|
getArgumentCompletions: (prefix) => {
|
|
492
520
|
const p = prefix.trim().toLowerCase();
|
|
521
|
+
// Number tasks by position in the VISIBLE (when-filtered) list — the
|
|
522
|
+
// same list the selector and `/do-always <n>` use. The cache is
|
|
523
|
+
// refreshed on session start and every command run; when it is
|
|
524
|
+
// stale, fall back to the full list rather than guessing.
|
|
525
|
+
const visible = visibleCache?.cwd === loadedCwd ? visibleCache.visible : tasks;
|
|
493
526
|
const matches = [
|
|
494
527
|
{ value: "list", label: "list" },
|
|
495
528
|
{ value: "list-details", label: "list-details" },
|
|
496
|
-
...
|
|
529
|
+
...visible.map((t, i) => ({ value: t.name, label: `${i + 1}. ${t.name}` })),
|
|
497
530
|
].filter((c) => c.value.toLowerCase().includes(p));
|
|
498
531
|
return matches.length > 0 ? matches : null;
|
|
499
532
|
},
|
|
@@ -502,48 +535,35 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
502
535
|
},
|
|
503
536
|
});
|
|
504
537
|
|
|
505
|
-
// Keyboard shortcut: open the task selector without typing the command.
|
|
506
|
-
// The key is configurable via the "shortcut" field in do-always.json
|
|
507
|
-
// (null disables it). It is read once at extension load, so changing it
|
|
508
|
-
// requires a reload or a new session.
|
|
509
|
-
const configuredShortcut = loadConfig(process.cwd()).shortcut;
|
|
510
|
-
if (configuredShortcut !== null) {
|
|
511
|
-
const shortcutKey = isValidKeyId(configuredShortcut) ? configuredShortcut : DEFAULT_SHORTCUT;
|
|
512
|
-
if (shortcutKey !== configuredShortcut) {
|
|
513
|
-
console.warn(`do-always: invalid shortcut "${configuredShortcut}" in do-always.json — using ${DEFAULT_SHORTCUT}`);
|
|
514
|
-
}
|
|
515
|
-
pi.registerShortcut(shortcutKey as KeyId, {
|
|
516
|
-
description: "do-always: pick a common task",
|
|
517
|
-
handler: async (ctx) => {
|
|
518
|
-
await runDoAlways("", ctx);
|
|
519
|
-
},
|
|
520
|
-
});
|
|
521
|
-
}
|
|
522
|
-
|
|
523
538
|
async function runDoAlways(args: string, ctx: ExtensionContext): Promise<void> {
|
|
524
539
|
// Reload when the active directory changes, so switching projects
|
|
525
540
|
// mid-session serves the right config instead of stale tasks.
|
|
526
541
|
if (ctx.cwd !== loadedCwd) {
|
|
527
542
|
loadedCwd = ctx.cwd;
|
|
528
|
-
|
|
543
|
+
const onError = (m: string) => {
|
|
544
|
+
if (ctx.mode === "tui") ctx.ui.notify(m, "warning");
|
|
545
|
+
else console.warn(m);
|
|
546
|
+
};
|
|
547
|
+
tasks = loadConfig(ctx.cwd, onError).tasks;
|
|
529
548
|
}
|
|
530
549
|
|
|
550
|
+
// One context per command run: shared by visibility filtering, rendering,
|
|
551
|
+
// and the completion cache (never inside a render loop).
|
|
552
|
+
const context = buildContext(ctx.cwd);
|
|
553
|
+
const visible = refreshVisible(ctx.cwd, context);
|
|
554
|
+
|
|
531
555
|
const arg = args.trim();
|
|
532
556
|
|
|
533
557
|
if (!arg) {
|
|
534
558
|
if (ctx.mode === "tui") {
|
|
535
|
-
await showSelector(ctx);
|
|
559
|
+
await showSelector(ctx, context);
|
|
536
560
|
} else {
|
|
537
|
-
const context = buildContext(ctx.cwd);
|
|
538
|
-
const visible = tasks.filter((t) => evaluateWhen(t, context));
|
|
539
561
|
ctx.ui.notify(`do-always tasks (use /do-always <number|name>):\n${formatList(visible)}`, "info");
|
|
540
562
|
}
|
|
541
563
|
return;
|
|
542
564
|
}
|
|
543
565
|
|
|
544
566
|
if (arg.toLowerCase() === "list") {
|
|
545
|
-
const context = buildContext(ctx.cwd);
|
|
546
|
-
const visible = tasks.filter((t) => evaluateWhen(t, context));
|
|
547
567
|
ctx.ui.notify(formatList(visible), "info");
|
|
548
568
|
return;
|
|
549
569
|
}
|
|
@@ -551,14 +571,13 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
551
571
|
if (arg.toLowerCase() === "list-details") {
|
|
552
572
|
// Display only — the description is metadata; selecting a task injects just its prompt.
|
|
553
573
|
// Render with the current context so what is shown is what gets injected.
|
|
554
|
-
const
|
|
555
|
-
const visible = tasks.filter((t) => evaluateWhen(t, context));
|
|
574
|
+
const strings = toPromptContext(context);
|
|
556
575
|
const details = visible
|
|
557
576
|
.map((t, i) => {
|
|
558
577
|
const lines = [`${i + 1}. ${t.name}`];
|
|
559
578
|
if (t.description) lines.push(` description: ${t.description}`);
|
|
560
579
|
lines.push(" prompt (this is what gets injected on select):");
|
|
561
|
-
for (const line of renderPrompt(t.prompt,
|
|
580
|
+
for (const line of renderPrompt(t.prompt, strings).split("\n")) lines.push(` ${line}`);
|
|
562
581
|
return lines.join("\n");
|
|
563
582
|
})
|
|
564
583
|
.join("\n\n");
|
|
@@ -573,8 +592,6 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
573
592
|
// Numbers index the VISIBLE list (what the user sees in the selector and
|
|
574
593
|
// `list`); names resolve against the full set so picking a hidden task by
|
|
575
594
|
// 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
595
|
const task = resolveTask(/^\d+$/.test(arg) ? visible : tasks, arg);
|
|
579
596
|
if (!task) {
|
|
580
597
|
const available = visible.map((t, i) => `${i + 1}=${t.name}`).join(", ");
|
|
@@ -587,6 +604,6 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
587
604
|
ctx.ui.notify(`do-always: "${task.name}" is hidden by its "when" condition`, "info");
|
|
588
605
|
return;
|
|
589
606
|
}
|
|
590
|
-
await fillPrompt(task, ctx);
|
|
607
|
+
await fillPrompt(task, ctx, context);
|
|
591
608
|
}
|
|
592
609
|
}
|
|
@@ -98,6 +98,44 @@ export interface ParsedDoAlwaysConfig {
|
|
|
98
98
|
merge?: "append" | "override" | undefined;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
import { existsSync } from "node:fs";
|
|
102
|
+
import { join } from "node:path";
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Structured facts about the working tree and git state, gathered once per
|
|
106
|
+
* use (see `buildContext` in index.ts). `when` conditions and guards are
|
|
107
|
+
* evaluated against this; `renderPrompt` consumes the derived string view
|
|
108
|
+
* from `toPromptContext`.
|
|
109
|
+
*
|
|
110
|
+
* Keeping the structured form here (instead of re-parsing the rendered
|
|
111
|
+
* strings) means guards see the complete file list — `files_changed` in the
|
|
112
|
+
* string view is capped for display, but `files` is never truncated.
|
|
113
|
+
*/
|
|
114
|
+
export interface TaskContext {
|
|
115
|
+
/** Absolute path of the working directory. */
|
|
116
|
+
cwd: string;
|
|
117
|
+
/** Local date, YYYY-MM-DD. */
|
|
118
|
+
date: string;
|
|
119
|
+
/** Current git branch, or "unknown" when unavailable. */
|
|
120
|
+
branch: string;
|
|
121
|
+
/** Subject of the latest commit, or "unknown" when unavailable. */
|
|
122
|
+
lastCommit: string;
|
|
123
|
+
/** All changed files (staged, unstaged, untracked), deduplicated and sorted. */
|
|
124
|
+
files: string[];
|
|
125
|
+
/** `git config user.name`, or "unknown" when unset. */
|
|
126
|
+
user: string;
|
|
127
|
+
/** Output of `git diff --shortstat`, or "none" when unavailable. */
|
|
128
|
+
diffStat: string;
|
|
129
|
+
/** Basename of the git remote (or cwd), to disambiguate monorepo work. */
|
|
130
|
+
repo: string;
|
|
131
|
+
/** Files staged for commit. */
|
|
132
|
+
stagedFiles: string[];
|
|
133
|
+
/** Modified-but-unstaged files. */
|
|
134
|
+
unstagedFiles: string[];
|
|
135
|
+
/** True when cwd is inside a git working tree (authoritative, not inferred from the branch name). */
|
|
136
|
+
isGitRepo: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
101
139
|
/**
|
|
102
140
|
* The set of context keys the extension can inject into prompts (see
|
|
103
141
|
* `renderPrompt`). `index.ts` is responsible for supplying all of them (with
|
|
@@ -112,17 +150,75 @@ export const PROMPT_CONTEXT_KEYS = [
|
|
|
112
150
|
"files_changed",
|
|
113
151
|
"files_changed_count",
|
|
114
152
|
"user",
|
|
115
|
-
"diff_stat",
|
|
116
|
-
"repo",
|
|
117
|
-
"staged_files",
|
|
118
|
-
"unstaged_files",
|
|
153
|
+
"diff_stat",
|
|
154
|
+
"repo",
|
|
155
|
+
"staged_files",
|
|
156
|
+
"unstaged_files",
|
|
119
157
|
] as const;
|
|
120
158
|
|
|
121
159
|
/** A fully populated prompt context: one entry per PROMPT_CONTEXT_KEYS. */
|
|
122
160
|
export type PromptContext = Record<(typeof PROMPT_CONTEXT_KEYS)[number], string>;
|
|
123
161
|
|
|
124
|
-
|
|
125
|
-
|
|
162
|
+
/** Max number of file paths listed in the `files_changed` string view (the count stays exact). */
|
|
163
|
+
export const MAX_FILES_LISTED = 20;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Derive the string view consumed by `renderPrompt` from a structured context.
|
|
167
|
+
* `files_changed` lists at most MAX_FILES_LISTED paths (with a "… (+N more)"
|
|
168
|
+
* suffix) and `files_changed_count` stays exact; `staged_files` and
|
|
169
|
+
* `unstaged_files` are newline-separated. Empty lists render as "none".
|
|
170
|
+
*/
|
|
171
|
+
export function toPromptContext(ctx: TaskContext): PromptContext {
|
|
172
|
+
return {
|
|
173
|
+
cwd: ctx.cwd,
|
|
174
|
+
date: ctx.date,
|
|
175
|
+
branch: ctx.branch,
|
|
176
|
+
last_commit: ctx.lastCommit,
|
|
177
|
+
files_changed: formatFileList(ctx.files),
|
|
178
|
+
files_changed_count: String(ctx.files.length),
|
|
179
|
+
user: ctx.user,
|
|
180
|
+
diff_stat: ctx.diffStat,
|
|
181
|
+
repo: ctx.repo,
|
|
182
|
+
staged_files: formatFileLines(ctx.stagedFiles),
|
|
183
|
+
unstaged_files: formatFileLines(ctx.unstagedFiles),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Comma-joined list, capped at MAX_FILES_LISTED entries; "none" when empty. */
|
|
188
|
+
function formatFileList(files: string[]): string {
|
|
189
|
+
if (files.length === 0) return "none";
|
|
190
|
+
if (files.length > MAX_FILES_LISTED) {
|
|
191
|
+
return [...files.slice(0, MAX_FILES_LISTED), `… (+${files.length - MAX_FILES_LISTED} more)`].join(", ");
|
|
192
|
+
}
|
|
193
|
+
return files.join(", ");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Newline-joined list; "none" when empty. */
|
|
197
|
+
function formatFileLines(files: string[]): string {
|
|
198
|
+
return files.length === 0 ? "none" : files.join("\n");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Parse `git status --porcelain` (v1) output into changed file paths. Lines
|
|
203
|
+
* are "XY <path>" (X = index, Y = worktree); short lines are skipped,
|
|
204
|
+
* duplicates removed, and the result sorted.
|
|
205
|
+
*/
|
|
206
|
+
export function parseStatusPorcelain(status: string): string[] {
|
|
207
|
+
const files: string[] = [];
|
|
208
|
+
for (const line of status.split("\n")) {
|
|
209
|
+
if (line.length < 4) continue;
|
|
210
|
+
const path = line.slice(3);
|
|
211
|
+
if (path && !files.includes(path)) files.push(path);
|
|
212
|
+
}
|
|
213
|
+
files.sort();
|
|
214
|
+
return files;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Split raw `git diff --name-only` output into file paths (trimmed, non-empty lines). */
|
|
218
|
+
export function splitFileLines(raw: string | undefined): string[] {
|
|
219
|
+
if (!raw) return [];
|
|
220
|
+
return raw.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
221
|
+
}
|
|
126
222
|
|
|
127
223
|
/** Used when neither config file defines any task. */
|
|
128
224
|
export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
@@ -415,15 +511,6 @@ export function isValidWhen(when: unknown): boolean {
|
|
|
415
511
|
return true;
|
|
416
512
|
}
|
|
417
513
|
|
|
418
|
-
/**
|
|
419
|
-
* True when the current directory is inside a git working tree. The `branch`
|
|
420
|
-
* context falls back to "unknown" outside a repo (and on an empty repo), so a
|
|
421
|
-
* non-"unknown" branch is the git-repo signal.
|
|
422
|
-
*/
|
|
423
|
-
function isGitRepo(ctx: PromptContext): boolean {
|
|
424
|
-
return ctx.branch !== "unknown";
|
|
425
|
-
}
|
|
426
|
-
|
|
427
514
|
/** True when `relativePath` exists (as file or directory) under `cwd`. */
|
|
428
515
|
function pathExists(cwd: string, relativePath: string): boolean {
|
|
429
516
|
try {
|
|
@@ -434,14 +521,14 @@ function pathExists(cwd: string, relativePath: string): boolean {
|
|
|
434
521
|
}
|
|
435
522
|
|
|
436
523
|
/**
|
|
437
|
-
* Evaluate a single `when` object entry against the current
|
|
524
|
+
* Evaluate a single `when` object entry against the current context.
|
|
438
525
|
* Unknown keys are treated as no-ops (permissive) so a typo never hides a task
|
|
439
526
|
* at runtime (parse time rejects them with a warning instead).
|
|
440
527
|
*/
|
|
441
|
-
function evaluateWhenEntry(key: string, value: unknown, ctx:
|
|
528
|
+
function evaluateWhenEntry(key: string, value: unknown, ctx: TaskContext): boolean {
|
|
442
529
|
switch (key) {
|
|
443
530
|
case "git":
|
|
444
|
-
return typeof value === "boolean" ? isGitRepo
|
|
531
|
+
return typeof value === "boolean" ? ctx.isGitRepo === value : false;
|
|
445
532
|
case "branch":
|
|
446
533
|
return typeof value === "string" && ctx.branch === value;
|
|
447
534
|
case "file":
|
|
@@ -462,13 +549,13 @@ function evaluateWhenEntry(key: string, value: unknown, ctx: PromptContext): boo
|
|
|
462
549
|
* set of conditions that must all hold (logical AND): `git`, `branch`, `file`,
|
|
463
550
|
* or `repo` (see the `DoAlwaysTask.when` field).
|
|
464
551
|
*/
|
|
465
|
-
export function evaluateWhen(task: DoAlwaysTask, ctx:
|
|
552
|
+
export function evaluateWhen(task: DoAlwaysTask, ctx: TaskContext): boolean {
|
|
466
553
|
const when = task.when;
|
|
467
554
|
if (when === undefined || when === null) return true;
|
|
468
555
|
if (typeof when === "string") {
|
|
469
556
|
const negated = when.startsWith("!");
|
|
470
557
|
const key = negated ? when.slice(1) : when;
|
|
471
|
-
if (key === "git") return negated ? !isGitRepo
|
|
558
|
+
if (key === "git") return negated ? !ctx.isGitRepo : ctx.isGitRepo;
|
|
472
559
|
return true; // an invalid string condition is rejected at parse time
|
|
473
560
|
}
|
|
474
561
|
if (typeof when === "object") {
|
|
@@ -550,7 +637,7 @@ export function shouldAutoRun(task: DoAlwaysTask): boolean {
|
|
|
550
637
|
* Guards keep low-value round-trips down: e.g. `requireDirty` blocks Review and
|
|
551
638
|
* Commit on a clean tree so the agent is never asked to inspect nothing.
|
|
552
639
|
*/
|
|
553
|
-
export function evaluateGuards(task: DoAlwaysTask, ctx:
|
|
640
|
+
export function evaluateGuards(task: DoAlwaysTask, ctx: TaskContext): string | null {
|
|
554
641
|
// Legacy `requireDirty` is folded into the guard table so the set of guards
|
|
555
642
|
// is extensible without touching this function's callers.
|
|
556
643
|
const guards: Guard[] = [];
|
|
@@ -568,38 +655,31 @@ export function evaluateGuards(task: DoAlwaysTask, ctx: PromptContext): string |
|
|
|
568
655
|
* when the guard passes. All guards are evaluated against the current prompt
|
|
569
656
|
* context, so a task is only injected when every guard is met.
|
|
570
657
|
*/
|
|
571
|
-
function guardFailureMessage(g: Guard, ctx:
|
|
658
|
+
function guardFailureMessage(g: Guard, ctx: TaskContext): string | null {
|
|
572
659
|
switch (g.type) {
|
|
573
660
|
case "requireDirty":
|
|
574
|
-
return ctx.
|
|
661
|
+
return ctx.files.length === 0 ? "working tree is clean — nothing to review" : null;
|
|
575
662
|
case "requireBranch":
|
|
576
663
|
return ctx.branch === g.value ? null : `not on branch "${g.value}" (currently ${ctx.branch})`;
|
|
577
664
|
case "requireRepo":
|
|
578
665
|
return ctx.repo === g.value ? null : `not in repo "${g.value}" (currently ${ctx.repo})`;
|
|
579
666
|
case "requireFilePattern":
|
|
580
|
-
return filesMatchPattern(ctx, g.value!) ? null : `no changed files match "${g.value}"`;
|
|
667
|
+
return filesMatchPattern(ctx.files, g.value!) ? null : `no changed files match "${g.value}"`;
|
|
581
668
|
default:
|
|
582
669
|
return null; // an unknown type is rejected at parse time
|
|
583
670
|
}
|
|
584
671
|
}
|
|
585
672
|
|
|
586
|
-
/**
|
|
587
|
-
* The changed files for `ctx`, split on commas (matching how `files_changed`
|
|
588
|
-
* is rendered). Empty on a clean tree or outside a git repo.
|
|
589
|
-
*/
|
|
590
|
-
function changedFiles(ctx: PromptContext): string[] {
|
|
591
|
-
if (ctx.files_changed_count === "0" || ctx.files_changed === "none") return [];
|
|
592
|
-
return ctx.files_changed.split(",");
|
|
593
|
-
}
|
|
594
|
-
|
|
595
673
|
/**
|
|
596
674
|
* Whether any changed file matches `pattern`, treated as a glob: `*` matches
|
|
597
675
|
* within a path segment, `**` crosses segments, `?` matches one non-separator
|
|
598
|
-
* character, and other regex metacharacters are literal.
|
|
676
|
+
* character, and other regex metacharacters are literal. Matches against the
|
|
677
|
+
* complete file list (never the capped display string), so files beyond
|
|
678
|
+
* MAX_FILES_LISTED are still considered.
|
|
599
679
|
*/
|
|
600
|
-
function filesMatchPattern(
|
|
680
|
+
function filesMatchPattern(files: string[], pattern: string): boolean {
|
|
601
681
|
const re = globToRegex(pattern);
|
|
602
|
-
return
|
|
682
|
+
return files.some((f) => re.test(f));
|
|
603
683
|
}
|
|
604
684
|
|
|
605
685
|
/** Regex metacharacters that must be escaped when matching a literal path char. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-do-always",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"homepage": "https://github.com/aginies/pi-do-always",
|
|
25
25
|
"bugs": {
|
|
26
|
-
"url": "https://github.com/aginies/pi-do-always/issues"
|
|
26
|
+
"url": "git+https://github.com/aginies/pi-do-always/issues"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"extensions/pi-do-always/index.ts",
|
package/pi.image.png
CHANGED
|
Binary file
|