pi-do-always 0.7.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 +20 -15
- package/extensions/pi-do-always/do-always.json +14 -5
- package/extensions/pi-do-always/index.ts +106 -89
- package/extensions/pi-do-always/tasks.ts +126 -38
- package/package.json +2 -2
- package/pi.image.png +0 -0
package/README.md
CHANGED
|
@@ -12,21 +12,22 @@ type to filter, scroll or click, or navigate with arrows + Enter → the task's
|
|
|
12
12
|
do-always — pick a task
|
|
13
13
|
|
|
14
14
|
PLAN
|
|
15
|
-
▸ 1. ⚡ Review
|
|
16
|
-
2. ⚡
|
|
17
|
-
3. ⚡
|
|
18
|
-
4. ⚡
|
|
19
|
-
5. ⚡
|
|
15
|
+
▸ 1. ⚡ Review changes Review the current code changes (Plan)
|
|
16
|
+
2. ⚡ Review code Review the whole project's code quality (Plan)
|
|
17
|
+
3. ⚡ Cleanup Clean up dead code and duplicates (Plan)
|
|
18
|
+
4. ⚡ Security Security audit (Plan)
|
|
19
|
+
5. ⚡ Performance Performance review (Plan)
|
|
20
|
+
6. ⚡ Propose features Propose new features (Plan)
|
|
20
21
|
DO
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
7. Build Test build is ok and fix issues
|
|
23
|
+
8. Tests Run tests and fix failures
|
|
23
24
|
DOCS
|
|
24
|
-
|
|
25
|
+
9. Readme Update the README.md
|
|
25
26
|
OPS
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
10. Release Prepare a release (version, changelog, tag)
|
|
28
|
+
11. Commit Prepare a clean commit
|
|
28
29
|
|
|
29
|
-
Review — prompt:
|
|
30
|
+
Review changes — prompt:
|
|
30
31
|
Review the changes on branch fix/login-null (3 changed files: auth.ts, login.ts,
|
|
31
32
|
test/auth.test.ts). Last commit: Fix null check in login. Check `git status` and
|
|
32
33
|
`git diff` to see what changed, then double-check the changes for bugs, edge …
|
|
@@ -42,7 +43,7 @@ type to filter, scroll or click, or navigate with arrows + Enter → the task's
|
|
|
42
43
|
|---|---|
|
|
43
44
|
|`/do-always` or the shortcut key (default `F4`)|Show the numbered task selector|
|
|
44
45
|
|`/do-always 2`|Fill the prompt for task #2 directly|
|
|
45
|
-
|`/do-always review`|Fill the prompt for the task named `review` (task names autocomplete after `/do-always`)|
|
|
46
|
+
|`/do-always review changes`|Fill the prompt for the task named `review changes` (task names autocomplete after `/do-always`)|
|
|
46
47
|
|`/do-always list`|Print the task list|
|
|
47
48
|
|`/do-always list-details`|Show the full rendered prompt text each task will inject|
|
|
48
49
|
|
|
@@ -87,7 +88,7 @@ Tasks are read from JSON files (an array of tasks, or the object form `{"tasks":
|
|
|
87
88
|
|`~/.pi/agent/do-always.json`|Global (all projects)|
|
|
88
89
|
|`<project>/.pi/do-always.json`|Project-local; overrides global tasks with the same `name`|
|
|
89
90
|
|
|
90
|
-
If neither file exists, the built-in defaults (Review, Cleanup, Security, Performance, Propose features, Build, Tests, Readme, Release, Commit) are used.
|
|
91
|
+
If neither file exists, the built-in defaults (Review changes, Review code, Cleanup, Security, Performance, Propose features, Build, Tests, Readme, Release, Commit) are used.
|
|
91
92
|
This repo ships a sample in [`do-always.json`](./extensions/pi-do-always/do-always.json) — copy it to one of the
|
|
92
93
|
locations above to make it your own:
|
|
93
94
|
|
|
@@ -133,7 +134,7 @@ Reload Pi (or start a new session) after editing a config file.
|
|
|
133
134
|
## Prompt placeholders
|
|
134
135
|
|
|
135
136
|
Task prompts support `{{placeholders}}` that are filled in from the current
|
|
136
|
-
directory when a task is selected — so `/do-always review` on a hotfix branch
|
|
137
|
+
directory when a task is selected — so `/do-always review changes` on a hotfix branch
|
|
137
138
|
injects “Review the changes on branch `fix/login-null` (3 changed files:
|
|
138
139
|
`auth.ts`, `login.ts`, `test/auth.test.ts`) …” instead of a generic nudge.
|
|
139
140
|
|
|
@@ -146,6 +147,10 @@ injects “Review the changes on branch `fix/login-null` (3 changed files:
|
|
|
146
147
|
|`{{files_changed}}`|Changed files from `git status` — comma-separated, capped at 20 entries (`none` when clean or not a git repo)|
|
|
147
148
|
|`{{files_changed_count}}`|Number of changed files (`0` when clean or not a git repo)|
|
|
148
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)|
|
|
149
154
|
|
|
150
155
|
Unknown placeholders are left as-is, and a prompt without placeholders is
|
|
151
156
|
injected unchanged, so existing configs keep working. The selector preview and
|
|
@@ -197,7 +202,7 @@ The `guards` array accepts these guard objects (all must pass):
|
|
|
197
202
|
|
|
198
203
|
| `type` | `value` | Blocks when… |
|
|
199
204
|
|---|---|---|
|
|
200
|
-
| `requireDirty` | none | the working tree is clean (
|
|
205
|
+
| `requireDirty` | none | the working tree is clean (no changed files) |
|
|
201
206
|
| `requireBranch` | branch name | the current branch is not the given name |
|
|
202
207
|
| `requireRepo` | repo name | the git-remote basename context value is not the given name |
|
|
203
208
|
| `requireFilePattern` | glob | no changed file matches the glob |
|
|
@@ -2,15 +2,21 @@
|
|
|
2
2
|
"shortcut": "f4",
|
|
3
3
|
"tasks": [
|
|
4
4
|
{
|
|
5
|
-
"name": "Review",
|
|
5
|
+
"name": "Review changes",
|
|
6
6
|
"category": "Plan",
|
|
7
|
-
"description": "Review
|
|
8
|
-
"
|
|
7
|
+
"description": "Review the current code changes (Plan)",
|
|
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
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "Review code",
|
|
13
|
+
"category": "Plan",
|
|
14
|
+
"description": "Review the whole project's code quality (Plan)",
|
|
15
|
+
"prompt": "Review this project's code holistically: identify code smells, dead code, duplication, awkward architecture or patterns, maintainability issues, inconsistencies, and missing or unclear documentation. Prioritize by impact, propose a plan for the fixes, and summarize your findings. Do not make any changes yet."
|
|
9
16
|
},
|
|
10
17
|
{
|
|
11
18
|
"name": "Cleanup",
|
|
12
19
|
"category": "Plan",
|
|
13
|
-
"autoRun": false,
|
|
14
20
|
"description": "Clean up dead code and duplicates (Plan)",
|
|
15
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."
|
|
16
22
|
},
|
|
@@ -54,13 +60,16 @@
|
|
|
54
60
|
"name": "Release",
|
|
55
61
|
"category": "Ops",
|
|
56
62
|
"description": "Prepare a release (version, changelog, tag)",
|
|
63
|
+
"when": "git",
|
|
57
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."
|
|
58
65
|
},
|
|
59
66
|
{
|
|
60
67
|
"name": "Commit",
|
|
61
68
|
"category": "Ops",
|
|
62
69
|
"description": "Prepare a clean commit",
|
|
63
|
-
"
|
|
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."
|
|
64
73
|
}
|
|
65
74
|
]
|
|
66
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,24 +150,82 @@ 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[] = [
|
|
129
225
|
{
|
|
130
|
-
name: "Review",
|
|
226
|
+
name: "Review changes",
|
|
131
227
|
category: "Plan",
|
|
132
|
-
description: "Review
|
|
228
|
+
description: "Review the current code changes (Plan)",
|
|
133
229
|
requireDirty: true,
|
|
134
230
|
prompt:
|
|
135
231
|
"Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). " +
|
|
@@ -138,6 +234,14 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
|
138
234
|
"edge cases, security issues, and consistency with the rest of the codebase. " +
|
|
139
235
|
"Do a plan proposal for the fixes if needed. Do a summary of your findings",
|
|
140
236
|
},
|
|
237
|
+
{
|
|
238
|
+
name: "Review code",
|
|
239
|
+
category: "Plan",
|
|
240
|
+
description: "Review the whole project's code quality (Plan)",
|
|
241
|
+
prompt:
|
|
242
|
+
"Review this project's code holistically: identify code smells, dead code, duplication, awkward architecture or patterns, maintainability issues, inconsistencies, and missing or unclear documentation. " +
|
|
243
|
+
"Prioritize by impact, propose a plan for the fixes, and summarize your findings. Do not make any changes yet.",
|
|
244
|
+
},
|
|
141
245
|
{
|
|
142
246
|
name: "Cleanup",
|
|
143
247
|
category: "Plan",
|
|
@@ -407,15 +511,6 @@ export function isValidWhen(when: unknown): boolean {
|
|
|
407
511
|
return true;
|
|
408
512
|
}
|
|
409
513
|
|
|
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
514
|
/** True when `relativePath` exists (as file or directory) under `cwd`. */
|
|
420
515
|
function pathExists(cwd: string, relativePath: string): boolean {
|
|
421
516
|
try {
|
|
@@ -426,14 +521,14 @@ function pathExists(cwd: string, relativePath: string): boolean {
|
|
|
426
521
|
}
|
|
427
522
|
|
|
428
523
|
/**
|
|
429
|
-
* Evaluate a single `when` object entry against the current
|
|
524
|
+
* Evaluate a single `when` object entry against the current context.
|
|
430
525
|
* Unknown keys are treated as no-ops (permissive) so a typo never hides a task
|
|
431
526
|
* at runtime (parse time rejects them with a warning instead).
|
|
432
527
|
*/
|
|
433
|
-
function evaluateWhenEntry(key: string, value: unknown, ctx:
|
|
528
|
+
function evaluateWhenEntry(key: string, value: unknown, ctx: TaskContext): boolean {
|
|
434
529
|
switch (key) {
|
|
435
530
|
case "git":
|
|
436
|
-
return typeof value === "boolean" ? isGitRepo
|
|
531
|
+
return typeof value === "boolean" ? ctx.isGitRepo === value : false;
|
|
437
532
|
case "branch":
|
|
438
533
|
return typeof value === "string" && ctx.branch === value;
|
|
439
534
|
case "file":
|
|
@@ -454,13 +549,13 @@ function evaluateWhenEntry(key: string, value: unknown, ctx: PromptContext): boo
|
|
|
454
549
|
* set of conditions that must all hold (logical AND): `git`, `branch`, `file`,
|
|
455
550
|
* or `repo` (see the `DoAlwaysTask.when` field).
|
|
456
551
|
*/
|
|
457
|
-
export function evaluateWhen(task: DoAlwaysTask, ctx:
|
|
552
|
+
export function evaluateWhen(task: DoAlwaysTask, ctx: TaskContext): boolean {
|
|
458
553
|
const when = task.when;
|
|
459
554
|
if (when === undefined || when === null) return true;
|
|
460
555
|
if (typeof when === "string") {
|
|
461
556
|
const negated = when.startsWith("!");
|
|
462
557
|
const key = negated ? when.slice(1) : when;
|
|
463
|
-
if (key === "git") return negated ? !isGitRepo
|
|
558
|
+
if (key === "git") return negated ? !ctx.isGitRepo : ctx.isGitRepo;
|
|
464
559
|
return true; // an invalid string condition is rejected at parse time
|
|
465
560
|
}
|
|
466
561
|
if (typeof when === "object") {
|
|
@@ -542,7 +637,7 @@ export function shouldAutoRun(task: DoAlwaysTask): boolean {
|
|
|
542
637
|
* Guards keep low-value round-trips down: e.g. `requireDirty` blocks Review and
|
|
543
638
|
* Commit on a clean tree so the agent is never asked to inspect nothing.
|
|
544
639
|
*/
|
|
545
|
-
export function evaluateGuards(task: DoAlwaysTask, ctx:
|
|
640
|
+
export function evaluateGuards(task: DoAlwaysTask, ctx: TaskContext): string | null {
|
|
546
641
|
// Legacy `requireDirty` is folded into the guard table so the set of guards
|
|
547
642
|
// is extensible without touching this function's callers.
|
|
548
643
|
const guards: Guard[] = [];
|
|
@@ -560,38 +655,31 @@ export function evaluateGuards(task: DoAlwaysTask, ctx: PromptContext): string |
|
|
|
560
655
|
* when the guard passes. All guards are evaluated against the current prompt
|
|
561
656
|
* context, so a task is only injected when every guard is met.
|
|
562
657
|
*/
|
|
563
|
-
function guardFailureMessage(g: Guard, ctx:
|
|
658
|
+
function guardFailureMessage(g: Guard, ctx: TaskContext): string | null {
|
|
564
659
|
switch (g.type) {
|
|
565
660
|
case "requireDirty":
|
|
566
|
-
return ctx.
|
|
661
|
+
return ctx.files.length === 0 ? "working tree is clean — nothing to review" : null;
|
|
567
662
|
case "requireBranch":
|
|
568
663
|
return ctx.branch === g.value ? null : `not on branch "${g.value}" (currently ${ctx.branch})`;
|
|
569
664
|
case "requireRepo":
|
|
570
665
|
return ctx.repo === g.value ? null : `not in repo "${g.value}" (currently ${ctx.repo})`;
|
|
571
666
|
case "requireFilePattern":
|
|
572
|
-
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}"`;
|
|
573
668
|
default:
|
|
574
669
|
return null; // an unknown type is rejected at parse time
|
|
575
670
|
}
|
|
576
671
|
}
|
|
577
672
|
|
|
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
673
|
/**
|
|
588
674
|
* Whether any changed file matches `pattern`, treated as a glob: `*` matches
|
|
589
675
|
* within a path segment, `**` crosses segments, `?` matches one non-separator
|
|
590
|
-
* 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.
|
|
591
679
|
*/
|
|
592
|
-
function filesMatchPattern(
|
|
680
|
+
function filesMatchPattern(files: string[], pattern: string): boolean {
|
|
593
681
|
const re = globToRegex(pattern);
|
|
594
|
-
return
|
|
682
|
+
return files.some((f) => re.test(f));
|
|
595
683
|
}
|
|
596
684
|
|
|
597
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
|