pi-do-always 0.8.0 → 0.10.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 +95 -17
- package/extensions/pi-do-always/do-always.json +6 -4
- package/extensions/pi-do-always/index.ts +1152 -213
- package/extensions/pi-do-always/tasks.ts +450 -46
- package/package.json +2 -2
- package/pi.image.png +0 -0
|
@@ -51,13 +51,13 @@ export interface DoAlwaysTask {
|
|
|
51
51
|
* The task stays visible but selecting it notifies instead of injecting.
|
|
52
52
|
* `requireDirty` needs no `value`; the others require a string `value`.
|
|
53
53
|
*/
|
|
54
|
-
|
|
54
|
+
interface Guard {
|
|
55
55
|
type: "requireDirty" | "requireBranch" | "requireRepo" | "requireFilePattern";
|
|
56
56
|
value?: string;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/** The set of known guard types (used for validation at parse time). */
|
|
60
|
-
|
|
60
|
+
const GUARD_TYPES = [
|
|
61
61
|
"requireDirty",
|
|
62
62
|
"requireBranch",
|
|
63
63
|
"requireRepo",
|
|
@@ -72,19 +72,24 @@ export const GUARD_TYPES = [
|
|
|
72
72
|
* `override` (default) replaces a global task with the same name;
|
|
73
73
|
* `append` keeps globals and only adds new project task names (a cascade).
|
|
74
74
|
*/
|
|
75
|
-
|
|
75
|
+
type DoAlwaysConfig =
|
|
76
76
|
| DoAlwaysTask[]
|
|
77
77
|
| {
|
|
78
78
|
tasks: DoAlwaysTask[];
|
|
79
79
|
shortcut?: string | null;
|
|
80
80
|
merge?: "append" | "override";
|
|
81
|
+
/**
|
|
82
|
+
* Whether chain runs write a Markdown report file (one per run, in
|
|
83
|
+
* the project root). Default true; set false to disable.
|
|
84
|
+
*/
|
|
85
|
+
report?: boolean;
|
|
81
86
|
};
|
|
82
87
|
|
|
83
88
|
/** Shortcut used when neither config file specifies one. */
|
|
84
89
|
export const DEFAULT_SHORTCUT = "f4";
|
|
85
90
|
|
|
86
91
|
/** Result of parsing a config file. */
|
|
87
|
-
|
|
92
|
+
interface ParsedDoAlwaysConfig {
|
|
88
93
|
tasks: DoAlwaysTask[];
|
|
89
94
|
/**
|
|
90
95
|
* The `shortcut` field, if present: a key id string, null when explicitly
|
|
@@ -96,6 +101,49 @@ export interface ParsedDoAlwaysConfig {
|
|
|
96
101
|
* file does not set one.
|
|
97
102
|
*/
|
|
98
103
|
merge?: "append" | "override" | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* The `report` field, if present: whether chain runs write a Markdown
|
|
106
|
+
* report file. undefined when the file does not set one (default: on).
|
|
107
|
+
*/
|
|
108
|
+
report: boolean | undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
import { existsSync } from "node:fs";
|
|
112
|
+
import { join, resolve, sep } from "node:path";
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Structured facts about the working tree and git state, gathered once per
|
|
116
|
+
* use (see `buildContext` in index.ts). `when` conditions and guards are
|
|
117
|
+
* evaluated against this; `renderPrompt` consumes the derived string view
|
|
118
|
+
* from `toPromptContext`.
|
|
119
|
+
*
|
|
120
|
+
* Keeping the structured form here (instead of re-parsing the rendered
|
|
121
|
+
* strings) means guards see the complete file list — `files_changed` in the
|
|
122
|
+
* string view is capped for display, but `files` is never truncated.
|
|
123
|
+
*/
|
|
124
|
+
export interface TaskContext {
|
|
125
|
+
/** Absolute path of the working directory. */
|
|
126
|
+
cwd: string;
|
|
127
|
+
/** Local date, YYYY-MM-DD. */
|
|
128
|
+
date: string;
|
|
129
|
+
/** Current git branch, or "unknown" when unavailable. */
|
|
130
|
+
branch: string;
|
|
131
|
+
/** Subject of the latest commit, or "unknown" when unavailable. */
|
|
132
|
+
lastCommit: string;
|
|
133
|
+
/** All changed files (staged, unstaged, untracked), deduplicated and sorted. */
|
|
134
|
+
files: string[];
|
|
135
|
+
/** `git config user.name`, or "unknown" when unset. */
|
|
136
|
+
user: string;
|
|
137
|
+
/** Output of `git diff --shortstat`, or "none" when unavailable. */
|
|
138
|
+
diffStat: string;
|
|
139
|
+
/** Basename of the git remote (or cwd), to disambiguate monorepo work. */
|
|
140
|
+
repo: string;
|
|
141
|
+
/** Files staged for commit. */
|
|
142
|
+
stagedFiles: string[];
|
|
143
|
+
/** Modified-but-unstaged files. */
|
|
144
|
+
unstagedFiles: string[];
|
|
145
|
+
/** True when cwd is inside a git working tree (authoritative, not inferred from the branch name). */
|
|
146
|
+
isGitRepo: boolean;
|
|
99
147
|
}
|
|
100
148
|
|
|
101
149
|
/**
|
|
@@ -112,17 +160,75 @@ export const PROMPT_CONTEXT_KEYS = [
|
|
|
112
160
|
"files_changed",
|
|
113
161
|
"files_changed_count",
|
|
114
162
|
"user",
|
|
115
|
-
"diff_stat",
|
|
116
|
-
"repo",
|
|
117
|
-
"staged_files",
|
|
118
|
-
"unstaged_files",
|
|
163
|
+
"diff_stat",
|
|
164
|
+
"repo",
|
|
165
|
+
"staged_files",
|
|
166
|
+
"unstaged_files",
|
|
119
167
|
] as const;
|
|
120
168
|
|
|
121
169
|
/** A fully populated prompt context: one entry per PROMPT_CONTEXT_KEYS. */
|
|
122
170
|
export type PromptContext = Record<(typeof PROMPT_CONTEXT_KEYS)[number], string>;
|
|
123
171
|
|
|
124
|
-
|
|
125
|
-
|
|
172
|
+
/** Max number of file paths listed in the `files_changed` string view (the count stays exact). */
|
|
173
|
+
export const MAX_FILES_LISTED = 20;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Derive the string view consumed by `renderPrompt` from a structured context.
|
|
177
|
+
* `files_changed` lists at most MAX_FILES_LISTED paths (with a "… (+N more)"
|
|
178
|
+
* suffix) and `files_changed_count` stays exact; `staged_files` and
|
|
179
|
+
* `unstaged_files` are newline-separated. Empty lists render as "none".
|
|
180
|
+
*/
|
|
181
|
+
export function toPromptContext(ctx: TaskContext): PromptContext {
|
|
182
|
+
return {
|
|
183
|
+
cwd: ctx.cwd,
|
|
184
|
+
date: ctx.date,
|
|
185
|
+
branch: ctx.branch,
|
|
186
|
+
last_commit: ctx.lastCommit,
|
|
187
|
+
files_changed: formatFileList(ctx.files),
|
|
188
|
+
files_changed_count: String(ctx.files.length),
|
|
189
|
+
user: ctx.user,
|
|
190
|
+
diff_stat: ctx.diffStat,
|
|
191
|
+
repo: ctx.repo,
|
|
192
|
+
staged_files: formatFileLines(ctx.stagedFiles),
|
|
193
|
+
unstaged_files: formatFileLines(ctx.unstagedFiles),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Comma-joined list, capped at MAX_FILES_LISTED entries; "none" when empty. */
|
|
198
|
+
function formatFileList(files: string[]): string {
|
|
199
|
+
if (files.length === 0) return "none";
|
|
200
|
+
if (files.length > MAX_FILES_LISTED) {
|
|
201
|
+
return [...files.slice(0, MAX_FILES_LISTED), `… (+${files.length - MAX_FILES_LISTED} more)`].join(", ");
|
|
202
|
+
}
|
|
203
|
+
return files.join(", ");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Newline-joined list; "none" when empty. */
|
|
207
|
+
function formatFileLines(files: string[]): string {
|
|
208
|
+
return files.length === 0 ? "none" : files.join("\n");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Parse `git status --porcelain` (v1) output into changed file paths. Lines
|
|
213
|
+
* are "XY <path>" (X = index, Y = worktree); short lines are skipped,
|
|
214
|
+
* duplicates removed, and the result sorted.
|
|
215
|
+
*/
|
|
216
|
+
export function parseStatusPorcelain(status: string): string[] {
|
|
217
|
+
const files: string[] = [];
|
|
218
|
+
for (const line of status.split("\n")) {
|
|
219
|
+
if (line.length < 4) continue;
|
|
220
|
+
const path = line.slice(3);
|
|
221
|
+
if (path && !files.includes(path)) files.push(path);
|
|
222
|
+
}
|
|
223
|
+
files.sort();
|
|
224
|
+
return files;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Split raw `git diff --name-only` output into file paths (trimmed, non-empty lines). */
|
|
228
|
+
export function splitFileLines(raw: string | undefined): string[] {
|
|
229
|
+
if (!raw) return [];
|
|
230
|
+
return raw.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
231
|
+
}
|
|
126
232
|
|
|
127
233
|
/** Used when neither config file defines any task. */
|
|
128
234
|
export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
@@ -230,14 +336,14 @@ export function parseConfig(
|
|
|
230
336
|
data = JSON.parse(raw);
|
|
231
337
|
} catch (err) {
|
|
232
338
|
onError(`do-always: invalid JSON in ${path}: ${err}`);
|
|
233
|
-
return { tasks: [], shortcut: undefined };
|
|
339
|
+
return { tasks: [], shortcut: undefined, report: undefined };
|
|
234
340
|
}
|
|
235
341
|
|
|
236
342
|
const list = Array.isArray(data) ? data : data?.tasks;
|
|
237
343
|
|
|
238
344
|
if (!Array.isArray(list)) {
|
|
239
345
|
onError(`do-always: ${path} must be a JSON array of tasks or {"tasks": [...]}`);
|
|
240
|
-
return { tasks: [], shortcut: undefined };
|
|
346
|
+
return { tasks: [], shortcut: undefined, report: undefined };
|
|
241
347
|
}
|
|
242
348
|
|
|
243
349
|
const tasks: DoAlwaysTask[] = [];
|
|
@@ -288,8 +394,13 @@ export function parseConfig(
|
|
|
288
394
|
if (!Array.isArray(data) && "merge" in data) {
|
|
289
395
|
merge = parseMerge(data.merge, path, onError);
|
|
290
396
|
}
|
|
397
|
+
let report: boolean | undefined;
|
|
398
|
+
if (!Array.isArray(data) && "report" in data) {
|
|
399
|
+
if (typeof data.report === "boolean") report = data.report;
|
|
400
|
+
else onError(`do-always: ignoring invalid "report" in ${path} (expected true or false)`);
|
|
401
|
+
}
|
|
291
402
|
|
|
292
|
-
return { tasks, shortcut, merge };
|
|
403
|
+
return { tasks, shortcut, merge, report };
|
|
293
404
|
}
|
|
294
405
|
|
|
295
406
|
const KEY_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
|
|
@@ -415,33 +526,29 @@ export function isValidWhen(when: unknown): boolean {
|
|
|
415
526
|
return true;
|
|
416
527
|
}
|
|
417
528
|
|
|
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
529
|
/** True when `relativePath` exists (as file or directory) under `cwd`. */
|
|
428
530
|
function pathExists(cwd: string, relativePath: string): boolean {
|
|
429
531
|
try {
|
|
430
|
-
|
|
532
|
+
const resolved = resolve(cwd, relativePath);
|
|
533
|
+
// Containment check: reject paths that escape the project root.
|
|
534
|
+
// `resolve` normalizes `..` sequences, so this catches
|
|
535
|
+
// "../../.ssh/id_rsa" → "/home/user/.ssh/id_rsa" when cwd is "/home/user/project".
|
|
536
|
+
if (!resolved.startsWith(cwd + sep) && resolved !== cwd) return false;
|
|
537
|
+
return existsSync(resolved);
|
|
431
538
|
} catch {
|
|
432
539
|
return false;
|
|
433
540
|
}
|
|
434
541
|
}
|
|
435
542
|
|
|
436
543
|
/**
|
|
437
|
-
* Evaluate a single `when` object entry against the current
|
|
544
|
+
* Evaluate a single `when` object entry against the current context.
|
|
438
545
|
* Unknown keys are treated as no-ops (permissive) so a typo never hides a task
|
|
439
546
|
* at runtime (parse time rejects them with a warning instead).
|
|
440
547
|
*/
|
|
441
|
-
function evaluateWhenEntry(key: string, value: unknown, ctx:
|
|
548
|
+
function evaluateWhenEntry(key: string, value: unknown, ctx: TaskContext): boolean {
|
|
442
549
|
switch (key) {
|
|
443
550
|
case "git":
|
|
444
|
-
return typeof value === "boolean" ? isGitRepo
|
|
551
|
+
return typeof value === "boolean" ? ctx.isGitRepo === value : false;
|
|
445
552
|
case "branch":
|
|
446
553
|
return typeof value === "string" && ctx.branch === value;
|
|
447
554
|
case "file":
|
|
@@ -462,13 +569,13 @@ function evaluateWhenEntry(key: string, value: unknown, ctx: PromptContext): boo
|
|
|
462
569
|
* set of conditions that must all hold (logical AND): `git`, `branch`, `file`,
|
|
463
570
|
* or `repo` (see the `DoAlwaysTask.when` field).
|
|
464
571
|
*/
|
|
465
|
-
export function evaluateWhen(task: DoAlwaysTask, ctx:
|
|
572
|
+
export function evaluateWhen(task: DoAlwaysTask, ctx: TaskContext): boolean {
|
|
466
573
|
const when = task.when;
|
|
467
574
|
if (when === undefined || when === null) return true;
|
|
468
575
|
if (typeof when === "string") {
|
|
469
576
|
const negated = when.startsWith("!");
|
|
470
577
|
const key = negated ? when.slice(1) : when;
|
|
471
|
-
if (key === "git") return negated ? !isGitRepo
|
|
578
|
+
if (key === "git") return negated ? !ctx.isGitRepo : ctx.isGitRepo;
|
|
472
579
|
return true; // an invalid string condition is rejected at parse time
|
|
473
580
|
}
|
|
474
581
|
if (typeof when === "object") {
|
|
@@ -484,7 +591,7 @@ export function evaluateWhen(task: DoAlwaysTask, ctx: PromptContext): boolean {
|
|
|
484
591
|
export const DEFAULT_CATEGORY_ORDER = ["Plan", "Do", "Docs", "Ops", "Other"];
|
|
485
592
|
|
|
486
593
|
/** A category group: a display name and the tasks that belong to it. */
|
|
487
|
-
|
|
594
|
+
interface TaskGroup {
|
|
488
595
|
name: string;
|
|
489
596
|
items: DoAlwaysTask[];
|
|
490
597
|
}
|
|
@@ -550,7 +657,7 @@ export function shouldAutoRun(task: DoAlwaysTask): boolean {
|
|
|
550
657
|
* Guards keep low-value round-trips down: e.g. `requireDirty` blocks Review and
|
|
551
658
|
* Commit on a clean tree so the agent is never asked to inspect nothing.
|
|
552
659
|
*/
|
|
553
|
-
export function evaluateGuards(task: DoAlwaysTask, ctx:
|
|
660
|
+
export function evaluateGuards(task: DoAlwaysTask, ctx: TaskContext): string | null {
|
|
554
661
|
// Legacy `requireDirty` is folded into the guard table so the set of guards
|
|
555
662
|
// is extensible without touching this function's callers.
|
|
556
663
|
const guards: Guard[] = [];
|
|
@@ -568,45 +675,43 @@ export function evaluateGuards(task: DoAlwaysTask, ctx: PromptContext): string |
|
|
|
568
675
|
* when the guard passes. All guards are evaluated against the current prompt
|
|
569
676
|
* context, so a task is only injected when every guard is met.
|
|
570
677
|
*/
|
|
571
|
-
function guardFailureMessage(g: Guard, ctx:
|
|
678
|
+
function guardFailureMessage(g: Guard, ctx: TaskContext): string | null {
|
|
572
679
|
switch (g.type) {
|
|
573
680
|
case "requireDirty":
|
|
574
|
-
return ctx.
|
|
681
|
+
return ctx.files.length === 0 ? "working tree is clean — nothing to review" : null;
|
|
575
682
|
case "requireBranch":
|
|
576
683
|
return ctx.branch === g.value ? null : `not on branch "${g.value}" (currently ${ctx.branch})`;
|
|
577
684
|
case "requireRepo":
|
|
578
685
|
return ctx.repo === g.value ? null : `not in repo "${g.value}" (currently ${ctx.repo})`;
|
|
579
686
|
case "requireFilePattern":
|
|
580
|
-
return filesMatchPattern(ctx, g.value!) ? null : `no changed files match "${g.value}"`;
|
|
687
|
+
return filesMatchPattern(ctx.files, g.value!) ? null : `no changed files match "${g.value}"`;
|
|
581
688
|
default:
|
|
582
689
|
return null; // an unknown type is rejected at parse time
|
|
583
690
|
}
|
|
584
691
|
}
|
|
585
692
|
|
|
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
693
|
/**
|
|
596
694
|
* Whether any changed file matches `pattern`, treated as a glob: `*` matches
|
|
597
695
|
* within a path segment, `**` crosses segments, `?` matches one non-separator
|
|
598
|
-
* character, and other regex metacharacters are literal.
|
|
696
|
+
* character, and other regex metacharacters are literal. Matches against the
|
|
697
|
+
* complete file list (never the capped display string), so files beyond
|
|
698
|
+
* MAX_FILES_LISTED are still considered.
|
|
599
699
|
*/
|
|
600
|
-
function filesMatchPattern(
|
|
700
|
+
function filesMatchPattern(files: string[], pattern: string): boolean {
|
|
601
701
|
const re = globToRegex(pattern);
|
|
602
|
-
return
|
|
702
|
+
return files.some((f) => re.test(f));
|
|
603
703
|
}
|
|
604
704
|
|
|
605
705
|
/** Regex metacharacters that must be escaped when matching a literal path char. */
|
|
606
706
|
const METACHARACTERS = ".+^${}()|[]";
|
|
607
707
|
|
|
708
|
+
/** Compiled regex cache: glob patterns are static config, so we memoize. */
|
|
709
|
+
const globRegexCache = new Map<string, RegExp>();
|
|
710
|
+
|
|
608
711
|
/** Convert a glob to an anchored RegExp (`**` -> `.*`, `*` -> `[^/]*`, `?` -> `[^/]`). */
|
|
609
712
|
function globToRegex(pattern: string): RegExp {
|
|
713
|
+
let cached = globRegexCache.get(pattern);
|
|
714
|
+
if (cached) return cached;
|
|
610
715
|
let out = "";
|
|
611
716
|
let i = 0;
|
|
612
717
|
while (i < pattern.length) {
|
|
@@ -626,7 +731,9 @@ function globToRegex(pattern: string): RegExp {
|
|
|
626
731
|
i++;
|
|
627
732
|
}
|
|
628
733
|
}
|
|
629
|
-
|
|
734
|
+
cached = new RegExp(`^${out}$`);
|
|
735
|
+
globRegexCache.set(pattern, cached);
|
|
736
|
+
return cached;
|
|
630
737
|
}
|
|
631
738
|
|
|
632
739
|
/**
|
|
@@ -709,3 +816,300 @@ export function formatList(tasks: DoAlwaysTask[]): string {
|
|
|
709
816
|
}
|
|
710
817
|
return lines.join("\n");
|
|
711
818
|
}
|
|
819
|
+
|
|
820
|
+
// ---------------------------------------------------------------------------
|
|
821
|
+
// Chains
|
|
822
|
+
//
|
|
823
|
+
// A chain is an ordered, duplicate-free list of tasks the user builds in the
|
|
824
|
+
// selector table (ORDER column) and runs from the pinned Run row. All
|
|
825
|
+
// operations are pure: they return new states, never mutate.
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
|
|
828
|
+
/** Maximum number of tasks in a chain. */
|
|
829
|
+
export const CHAIN_MAX = 8;
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* A task chain: ordered task names plus a LIFO history of adds (for undo).
|
|
833
|
+
* Pure state — every operation returns a new state.
|
|
834
|
+
*/
|
|
835
|
+
interface ChainState {
|
|
836
|
+
/** Task names in execution order (duplicate-free). */
|
|
837
|
+
items: string[];
|
|
838
|
+
/** LIFO history of added names, consumed by `chainUndo`. */
|
|
839
|
+
history: string[];
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** An empty chain. */
|
|
843
|
+
export function chainClear(): ChainState {
|
|
844
|
+
return { items: [], history: [] };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Add a task to the chain. A name already in the chain is moved to the end
|
|
849
|
+
* (`movedToEnd`); when the chain is at CHAIN_MAX the state is returned
|
|
850
|
+
* unchanged (`full`).
|
|
851
|
+
*/
|
|
852
|
+
export function chainAdd(
|
|
853
|
+
state: ChainState,
|
|
854
|
+
name: string,
|
|
855
|
+
): { state: ChainState; result: "added" | "movedToEnd" | "full" } {
|
|
856
|
+
if (state.items.includes(name)) {
|
|
857
|
+
return {
|
|
858
|
+
state: {
|
|
859
|
+
items: [...state.items.filter((n) => n !== name), name],
|
|
860
|
+
history: [...state.history, name],
|
|
861
|
+
},
|
|
862
|
+
result: "movedToEnd",
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
if (state.items.length >= CHAIN_MAX) {
|
|
866
|
+
return { state, result: "full" };
|
|
867
|
+
}
|
|
868
|
+
return {
|
|
869
|
+
state: { items: [...state.items, name], history: [...state.history, name] },
|
|
870
|
+
result: "added",
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** Remove a task from the chain (no-op when absent). History is untouched. */
|
|
875
|
+
export function chainRemove(state: ChainState, name: string): ChainState {
|
|
876
|
+
if (!state.items.includes(name)) return state;
|
|
877
|
+
return { ...state, items: state.items.filter((n) => n !== name) };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Undo the most recent add that is still in the chain, skipping names that
|
|
882
|
+
* were removed in the meantime. Returns `removed: null` when there is
|
|
883
|
+
* nothing left to undo.
|
|
884
|
+
*/
|
|
885
|
+
export function chainUndo(state: ChainState): { state: ChainState; removed: string | null } {
|
|
886
|
+
for (let i = state.history.length - 1; i >= 0; i--) {
|
|
887
|
+
const name = state.history[i];
|
|
888
|
+
if (state.items.includes(name)) {
|
|
889
|
+
return {
|
|
890
|
+
state: {
|
|
891
|
+
items: state.items.filter((n) => n !== name),
|
|
892
|
+
history: state.history.slice(0, i),
|
|
893
|
+
},
|
|
894
|
+
removed: name,
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return { state, removed: null };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Move a task one position up (-1) or down (1) in the chain. No-op at the
|
|
903
|
+
* ends or when the name is not in the chain.
|
|
904
|
+
*/
|
|
905
|
+
export function chainMove(state: ChainState, name: string, dir: -1 | 1): ChainState {
|
|
906
|
+
const idx = state.items.indexOf(name);
|
|
907
|
+
const target = idx + dir;
|
|
908
|
+
if (idx < 0 || target < 0 || target >= state.items.length) return state;
|
|
909
|
+
const items = [...state.items];
|
|
910
|
+
items[idx] = items[target];
|
|
911
|
+
items[target] = name;
|
|
912
|
+
return { ...state, items };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Label for the pinned Run row: a dimmed placeholder for an empty chain,
|
|
917
|
+
* singular for one task, plural with the count otherwise.
|
|
918
|
+
*/
|
|
919
|
+
export function chainRunLabel(count: number): string {
|
|
920
|
+
if (count === 0) return "run the chain (0)";
|
|
921
|
+
if (count === 1) return "Run the task";
|
|
922
|
+
return `Run the chain (${count})`;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** One row of the task table (see `buildTableRows`). */
|
|
926
|
+
export interface TableRow {
|
|
927
|
+
kind: "header" | "task" | "run";
|
|
928
|
+
/** Header text (kind=header) or the run label (kind=run). */
|
|
929
|
+
name?: string;
|
|
930
|
+
/** The task (kind=task). */
|
|
931
|
+
task?: DoAlwaysTask;
|
|
932
|
+
/** 1-based chain position (kind=task, only when the task is chained). */
|
|
933
|
+
order?: number;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Build the table rows: a header row per non-empty category, a task row per
|
|
938
|
+
* task carrying its ORDER position, and the pinned Run row last (label from
|
|
939
|
+
* `chainRunLabel`).
|
|
940
|
+
*/
|
|
941
|
+
export function buildTableRows(groups: TaskGroup[], chain: ChainState): TableRow[] {
|
|
942
|
+
const rows: TableRow[] = [];
|
|
943
|
+
for (const g of groups) {
|
|
944
|
+
if (g.items.length === 0) continue;
|
|
945
|
+
rows.push({ kind: "header", name: g.name });
|
|
946
|
+
for (const t of g.items) {
|
|
947
|
+
const pos = chain.items.indexOf(t.name);
|
|
948
|
+
rows.push({
|
|
949
|
+
kind: "task",
|
|
950
|
+
task: t,
|
|
951
|
+
...(pos >= 0 ? { order: pos + 1 } : {}),
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
rows.push({ kind: "run", name: chainRunLabel(chain.items.length) });
|
|
956
|
+
return rows;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Footer preview of the chain: "1.⚡ Review changes → 2.Build". Tasks are
|
|
961
|
+
* looked up in `tasks`; unknown names (a stale chain) are skipped.
|
|
962
|
+
*/
|
|
963
|
+
export function formatChainSequence(tasks: DoAlwaysTask[], chain: ChainState): string {
|
|
964
|
+
// O(n) index map so find → O(1) lookup.
|
|
965
|
+
const taskByName = new Map(tasks.map((t) => [t.name, t]));
|
|
966
|
+
const parts = chain.items
|
|
967
|
+
.map((name, i) => {
|
|
968
|
+
const t = taskByName.get(name);
|
|
969
|
+
if (!t) return null;
|
|
970
|
+
const marker = shouldAutoRun(t) ? "⚡" : "";
|
|
971
|
+
return `${i + 1}.${marker}${t.name}`;
|
|
972
|
+
})
|
|
973
|
+
.filter((p): p is string => p !== null);
|
|
974
|
+
return parts.join(" → ");
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Validate a chain against the context: every task must pass its guards.
|
|
979
|
+
* Returns the first failing step (1-based) with the guard message, or null
|
|
980
|
+
* when the whole chain may run. Stale names (not found in `tasks`) are
|
|
981
|
+
* skipped — the runner drops them.
|
|
982
|
+
*/
|
|
983
|
+
export function validateChain(
|
|
984
|
+
tasks: DoAlwaysTask[],
|
|
985
|
+
chain: ChainState,
|
|
986
|
+
ctx: TaskContext,
|
|
987
|
+
): { step: number; task: DoAlwaysTask; message: string } | null {
|
|
988
|
+
for (let i = 0; i < chain.items.length; i++) {
|
|
989
|
+
const task = tasks.find((t) => t.name === chain.items[i]);
|
|
990
|
+
if (!task) continue;
|
|
991
|
+
const message = evaluateGuards(task, ctx);
|
|
992
|
+
if (message) return { step: i + 1, task, message };
|
|
993
|
+
}
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// ── Chain report ─────────────────────────────────────────────────────────
|
|
998
|
+
//
|
|
999
|
+
// A chain run's results are appended to a Markdown report file (one file
|
|
1000
|
+
// per run, in the project root) as each step finishes, so earlier steps'
|
|
1001
|
+
// results survive later steps' output scrolling them off screen. The file
|
|
1002
|
+
// is written incrementally: even if the session dies mid-chain, the
|
|
1003
|
+
// finished steps' results are on disk.
|
|
1004
|
+
|
|
1005
|
+
/** HH:MM in the local timezone. */
|
|
1006
|
+
function reportTime(d: Date): string {
|
|
1007
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** File name for one chain run's report, e.g. do-always-report-tasks-2025-01-15-1432.md. */
|
|
1011
|
+
export function reportFileName(now: Date): string {
|
|
1012
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
1013
|
+
return `do-always-report-tasks-${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.md`;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Resolve the report file path in `cwd`, appending -2, -3, … when a file
|
|
1018
|
+
* with the same name already exists (two runs within the same minute).
|
|
1019
|
+
*/
|
|
1020
|
+
export function resolveReportPath(
|
|
1021
|
+
cwd: string,
|
|
1022
|
+
now: Date,
|
|
1023
|
+
exists: (path: string) => boolean = existsSync,
|
|
1024
|
+
): string {
|
|
1025
|
+
const base = reportFileName(now);
|
|
1026
|
+
const first = join(cwd, base);
|
|
1027
|
+
if (!exists(first)) return first;
|
|
1028
|
+
const stem = base.slice(0, -3); // drop ".md"
|
|
1029
|
+
for (let i = 2; ; i++) {
|
|
1030
|
+
const candidate = join(cwd, `${stem}-${i}.md`);
|
|
1031
|
+
if (!exists(candidate)) return candidate;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** Markdown header for a new report file. */
|
|
1036
|
+
export function reportHeader(projectPath: string, stepNames: string[], now: Date): string {
|
|
1037
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
1038
|
+
const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${reportTime(now)}`;
|
|
1039
|
+
return [
|
|
1040
|
+
`# do-always chain report — ${stamp}`,
|
|
1041
|
+
"",
|
|
1042
|
+
`- Project: ${projectPath}`,
|
|
1043
|
+
`- Steps: ${stepNames.join(" → ")}`,
|
|
1044
|
+
"",
|
|
1045
|
+
"",
|
|
1046
|
+
].join("\n");
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Markdown section for one finished step: its number, name, outcome, run
|
|
1051
|
+
* time, and the final assistant message (the step's result). `startedAt`
|
|
1052
|
+
* is null when the run never started (failed-to-start).
|
|
1053
|
+
*/
|
|
1054
|
+
export function reportStepSection(
|
|
1055
|
+
index: number,
|
|
1056
|
+
name: string,
|
|
1057
|
+
status: string,
|
|
1058
|
+
startedAt: Date | null,
|
|
1059
|
+
endedAt: Date,
|
|
1060
|
+
text: string,
|
|
1061
|
+
): string {
|
|
1062
|
+
const times = startedAt ? `${reportTime(startedAt)} → ${reportTime(endedAt)}` : reportTime(endedAt);
|
|
1063
|
+
const lines = [`## ${index + 1}. ${name} — ${status} (${times})`, ""];
|
|
1064
|
+
const trimmed = text.trim();
|
|
1065
|
+
lines.push(trimmed === "" ? "_(no result text)_" : trimmed, "", "");
|
|
1066
|
+
return lines.join("\n");
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** Markdown footer summarizing the whole run. */
|
|
1070
|
+
export function reportFooter(stepStatuses: string[], now: Date): string {
|
|
1071
|
+
const done = stepStatuses.filter((s) => s === "completed").length;
|
|
1072
|
+
const total = stepStatuses.length;
|
|
1073
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
1074
|
+
const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${reportTime(now)}`;
|
|
1075
|
+
const summary =
|
|
1076
|
+
done === total ? `${done}/${total} completed` : `${done}/${total} completed — chain stopped early`;
|
|
1077
|
+
return `---\n\n**Chain finished:** ${stamp} — ${summary}\n`;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** Markdown footer for a chain that never reached a terminal path (e.g., the session ended mid-chain). */
|
|
1081
|
+
export function reportAbandonedFooter(stepStatuses: string[], now: Date): string {
|
|
1082
|
+
const done = stepStatuses.filter((s) => s === "completed").length;
|
|
1083
|
+
const total = stepStatuses.length;
|
|
1084
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
1085
|
+
const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${reportTime(now)}`;
|
|
1086
|
+
return `---\n\n**Chain abandoned:** ${stamp} — ${done}/${total} completed\n`;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Whether a finished run's report file is worth keeping on disk: at least
|
|
1091
|
+
* one completed step, or some step section carried result text. A run that
|
|
1092
|
+
* produced neither (e.g. step 1 errored before any output, or the chain was
|
|
1093
|
+
* blocked before running) leaves no file behind — the failure is already
|
|
1094
|
+
* surfaced by the notification, and a quick same-minute retry would
|
|
1095
|
+
* otherwise get a `-N` sibling next to an empty report.
|
|
1096
|
+
*/
|
|
1097
|
+
export function reportWorthKeeping(stepStatuses: string[], hasContent: boolean): boolean {
|
|
1098
|
+
return hasContent || stepStatuses.some((s) => s === "completed");
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Extract an assistant message's text: string content as-is, or the text
|
|
1103
|
+
* parts of a content array joined with newlines (tool-call parts are not
|
|
1104
|
+
* text and are skipped). Same shape pi's own runtime uses. Null/undefined
|
|
1105
|
+
* content (a run that produced no assistant text) yields "".
|
|
1106
|
+
*/
|
|
1107
|
+
export function assistantText(
|
|
1108
|
+
content: string | Array<{ type?: string; text?: string }> | null | undefined,
|
|
1109
|
+
): string {
|
|
1110
|
+
if (typeof content === "string") return content;
|
|
1111
|
+
if (!Array.isArray(content)) return "";
|
|
1112
|
+
return content
|
|
1113
|
+
.flatMap((part) => (part && part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
|
1114
|
+
.join("\n");
|
|
1115
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-do-always",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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
|