pi-do-always 0.9.0 → 0.12.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 +92 -16
- package/extensions/pi-do-always/do-always.json +2 -2
- package/extensions/pi-do-always/index.ts +1364 -167
- package/extensions/pi-do-always/tasks.ts +506 -32
- package/package.json +1 -1
- package/pi.image.png +0 -0
|
@@ -46,24 +46,26 @@ export interface DoAlwaysTask {
|
|
|
46
46
|
guards?: Guard[];
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** The set of known guard types (used for validation at parse time). */
|
|
50
|
+
export const GUARD_TYPES = [
|
|
51
|
+
"requireDirty",
|
|
52
|
+
"requireBranch",
|
|
53
|
+
"requireRepo",
|
|
54
|
+
"requireFilePattern",
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
export type GuardType = (typeof GUARD_TYPES)[number];
|
|
58
|
+
|
|
49
59
|
/**
|
|
50
60
|
* A selection-time guard that blocks a task when its condition is not met.
|
|
51
61
|
* The task stays visible but selecting it notifies instead of injecting.
|
|
52
62
|
* `requireDirty` needs no `value`; the others require a string `value`.
|
|
53
63
|
*/
|
|
54
64
|
export interface Guard {
|
|
55
|
-
type:
|
|
65
|
+
type: GuardType;
|
|
56
66
|
value?: string;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
|
-
/** The set of known guard types (used for validation at parse time). */
|
|
60
|
-
export const GUARD_TYPES = [
|
|
61
|
-
"requireDirty",
|
|
62
|
-
"requireBranch",
|
|
63
|
-
"requireRepo",
|
|
64
|
-
"requireFilePattern",
|
|
65
|
-
] as const;
|
|
66
|
-
|
|
67
69
|
/**
|
|
68
70
|
* A config file can be a bare array of tasks, or {"tasks": [...], "shortcut": ...}.
|
|
69
71
|
* `shortcut` is a key id string (e.g. "f4", "ctrl+shift+p"), or null to disable
|
|
@@ -72,19 +74,24 @@ export const GUARD_TYPES = [
|
|
|
72
74
|
* `override` (default) replaces a global task with the same name;
|
|
73
75
|
* `append` keeps globals and only adds new project task names (a cascade).
|
|
74
76
|
*/
|
|
75
|
-
|
|
77
|
+
type DoAlwaysConfig =
|
|
76
78
|
| DoAlwaysTask[]
|
|
77
79
|
| {
|
|
78
80
|
tasks: DoAlwaysTask[];
|
|
79
81
|
shortcut?: string | null;
|
|
80
82
|
merge?: "append" | "override";
|
|
83
|
+
/**
|
|
84
|
+
* Whether chain runs write a Markdown report file (one per run, in
|
|
85
|
+
* the project root). Default true; set false to disable.
|
|
86
|
+
*/
|
|
87
|
+
report?: boolean;
|
|
81
88
|
};
|
|
82
89
|
|
|
83
90
|
/** Shortcut used when neither config file specifies one. */
|
|
84
91
|
export const DEFAULT_SHORTCUT = "f4";
|
|
85
92
|
|
|
86
93
|
/** Result of parsing a config file. */
|
|
87
|
-
|
|
94
|
+
interface ParsedDoAlwaysConfig {
|
|
88
95
|
tasks: DoAlwaysTask[];
|
|
89
96
|
/**
|
|
90
97
|
* The `shortcut` field, if present: a key id string, null when explicitly
|
|
@@ -96,10 +103,15 @@ export interface ParsedDoAlwaysConfig {
|
|
|
96
103
|
* file does not set one.
|
|
97
104
|
*/
|
|
98
105
|
merge?: "append" | "override" | undefined;
|
|
106
|
+
/**
|
|
107
|
+
* The `report` field, if present: whether chain runs write a Markdown
|
|
108
|
+
* report file. undefined when the file does not set one (default: on).
|
|
109
|
+
*/
|
|
110
|
+
report: boolean | undefined;
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
import { existsSync } from "node:fs";
|
|
102
|
-
import { join } from "node:path";
|
|
114
|
+
import { join, resolve, sep } from "node:path";
|
|
103
115
|
|
|
104
116
|
/**
|
|
105
117
|
* Structured facts about the working tree and git state, gathered once per
|
|
@@ -159,7 +171,7 @@ export const PROMPT_CONTEXT_KEYS = [
|
|
|
159
171
|
/** A fully populated prompt context: one entry per PROMPT_CONTEXT_KEYS. */
|
|
160
172
|
export type PromptContext = Record<(typeof PROMPT_CONTEXT_KEYS)[number], string>;
|
|
161
173
|
|
|
162
|
-
/** Max number of file paths listed in the `files_changed` string view (the count stays exact). */
|
|
174
|
+
/** Max number of file paths listed in the `files_changed` string view (the count stays exact). Exported for tests. */
|
|
163
175
|
export const MAX_FILES_LISTED = 20;
|
|
164
176
|
|
|
165
177
|
/**
|
|
@@ -184,6 +196,9 @@ export function toPromptContext(ctx: TaskContext): PromptContext {
|
|
|
184
196
|
};
|
|
185
197
|
}
|
|
186
198
|
|
|
199
|
+
/** Max number of file paths listed in the `staged_files` / `unstaged_files` string views. Exported for tests. */
|
|
200
|
+
export const MAX_FILE_LINES = 50;
|
|
201
|
+
|
|
187
202
|
/** Comma-joined list, capped at MAX_FILES_LISTED entries; "none" when empty. */
|
|
188
203
|
function formatFileList(files: string[]): string {
|
|
189
204
|
if (files.length === 0) return "none";
|
|
@@ -193,9 +208,31 @@ function formatFileList(files: string[]): string {
|
|
|
193
208
|
return files.join(", ");
|
|
194
209
|
}
|
|
195
210
|
|
|
196
|
-
/** Newline-joined list; "none" when empty. */
|
|
211
|
+
/** Newline-joined list, capped at MAX_FILE_LINES entries; "none" when empty. */
|
|
197
212
|
function formatFileLines(files: string[]): string {
|
|
198
|
-
|
|
213
|
+
if (files.length === 0) return "none";
|
|
214
|
+
if (files.length > MAX_FILE_LINES) {
|
|
215
|
+
const shown = files.slice(0, MAX_FILE_LINES);
|
|
216
|
+
const remaining = files.length - MAX_FILE_LINES;
|
|
217
|
+
return [...shown, `… (+${remaining} more)`].join("\n");
|
|
218
|
+
}
|
|
219
|
+
return files.join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Extract and normalize a file path from a git status porcelain line (v1).
|
|
224
|
+
* Handles rename targets (`old -> new`) and unquotes quoted paths (`"file with space"`).
|
|
225
|
+
*/
|
|
226
|
+
function extractPorcelainPath(line: string): string | null {
|
|
227
|
+
if (line.length < 4) return null;
|
|
228
|
+
let path = line.slice(3).trim();
|
|
229
|
+
if (path.includes(" -> ")) {
|
|
230
|
+
path = path.split(" -> ").pop()!.trim();
|
|
231
|
+
}
|
|
232
|
+
if (path.startsWith('"') && path.endsWith('"') && path.length >= 2) {
|
|
233
|
+
path = path.slice(1, -1).replace(/\\"/g, '"');
|
|
234
|
+
}
|
|
235
|
+
return path || null;
|
|
199
236
|
}
|
|
200
237
|
|
|
201
238
|
/**
|
|
@@ -205,19 +242,70 @@ function formatFileLines(files: string[]): string {
|
|
|
205
242
|
*/
|
|
206
243
|
export function parseStatusPorcelain(status: string): string[] {
|
|
207
244
|
const files: string[] = [];
|
|
245
|
+
const seen = new Set<string>();
|
|
208
246
|
for (const line of status.split("\n")) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
247
|
+
const path = extractPorcelainPath(line);
|
|
248
|
+
if (path && !seen.has(path)) {
|
|
249
|
+
seen.add(path);
|
|
250
|
+
files.push(path);
|
|
251
|
+
}
|
|
212
252
|
}
|
|
213
|
-
files.sort();
|
|
253
|
+
files.sort((a, b) => a.localeCompare(b));
|
|
214
254
|
return files;
|
|
215
255
|
}
|
|
216
256
|
|
|
217
|
-
/**
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Split `git status --porcelain` (v1) output into staged and unstaged file
|
|
259
|
+
* lists. Lines are "XY <path>" (X = index, Y = worktree; the path starts at
|
|
260
|
+
* index 3): a space in the X column means the change is unstaged (worktree
|
|
261
|
+
* only), anything else is staged or untracked. Short lines are skipped and
|
|
262
|
+
* paths are deduplicated across both lists.
|
|
263
|
+
*/
|
|
264
|
+
export function parseStatusStagedUnstaged(
|
|
265
|
+
status: string,
|
|
266
|
+
): { staged: string[]; unstaged: string[] } {
|
|
267
|
+
const staged: string[] = [];
|
|
268
|
+
const unstaged: string[] = [];
|
|
269
|
+
const seen = new Set<string>();
|
|
270
|
+
for (const line of status.split("\n")) {
|
|
271
|
+
const path = extractPorcelainPath(line);
|
|
272
|
+
if (!path || seen.has(path)) continue;
|
|
273
|
+
seen.add(path);
|
|
274
|
+
if (line[0] === " ") unstaged.push(path);
|
|
275
|
+
else staged.push(path);
|
|
276
|
+
}
|
|
277
|
+
return { staged, unstaged };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Extract the commit subject from a `git log --format=%H %s` line
|
|
282
|
+
* ("<hash> <subject>"). The subject may contain spaces, so everything after
|
|
283
|
+
* the first space is the subject. Returns "unknown" when the line is missing
|
|
284
|
+
* or carries no subject.
|
|
285
|
+
*/
|
|
286
|
+
export function parseCommitSubject(commitLine: string | undefined): string {
|
|
287
|
+
if (!commitLine) return "unknown";
|
|
288
|
+
const space = commitLine.indexOf(" ");
|
|
289
|
+
return space > 0 ? commitLine.slice(space + 1) || "unknown" : "unknown";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Extract one key's value from `git config --get-regexp` output (one
|
|
294
|
+
* "key value" pair per line). The value may contain spaces (e.g.
|
|
295
|
+
* user.name "John Doe"), so everything after the first space is the value.
|
|
296
|
+
* Returns undefined when the key is absent or its value is empty.
|
|
297
|
+
*/
|
|
298
|
+
export function parseConfigRegexpValueForKey(
|
|
299
|
+
raw: string | undefined,
|
|
300
|
+
key: string,
|
|
301
|
+
): string | undefined {
|
|
302
|
+
if (!raw) return undefined;
|
|
303
|
+
for (const line of raw.split("\n")) {
|
|
304
|
+
if (!line.startsWith(key + " ")) continue;
|
|
305
|
+
const value = line.slice(key.length + 1);
|
|
306
|
+
return value || undefined;
|
|
307
|
+
}
|
|
308
|
+
return undefined;
|
|
221
309
|
}
|
|
222
310
|
|
|
223
311
|
/** Used when neither config file defines any task. */
|
|
@@ -232,7 +320,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
|
232
320
|
"Change summary: {{diff_stat}}. Last commit: {{last_commit}}. " +
|
|
233
321
|
"Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, " +
|
|
234
322
|
"edge cases, security issues, and consistency with the rest of the codebase. " +
|
|
235
|
-
"Do a plan proposal for the fixes if needed. Do a summary of your findings",
|
|
323
|
+
"Do a plan proposal for the fixes if needed. Do a summary of your findings.",
|
|
236
324
|
},
|
|
237
325
|
{
|
|
238
326
|
name: "Review code",
|
|
@@ -306,7 +394,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
|
306
394
|
category: "Plan",
|
|
307
395
|
description: "Propose new features (Plan)",
|
|
308
396
|
prompt:
|
|
309
|
-
"Review this project and propose new features that would add value. For each idea, describe the problem it solves, the user benefit, and a rough implementation approach. Prioritize by impact and effort. Do not make any changes yet. Try to evaluate how many lines this will be in
|
|
397
|
+
"Review this project and propose new features that would add value. For each idea, describe the problem it solves, the user benefit, and a rough implementation approach. Prioritize by impact and effort. Do not make any changes yet. Try to evaluate how many lines this will be in terms of changes, whether this will break APIs, or introduce compatibility issues.",
|
|
310
398
|
},
|
|
311
399
|
];
|
|
312
400
|
|
|
@@ -326,14 +414,14 @@ export function parseConfig(
|
|
|
326
414
|
data = JSON.parse(raw);
|
|
327
415
|
} catch (err) {
|
|
328
416
|
onError(`do-always: invalid JSON in ${path}: ${err}`);
|
|
329
|
-
return { tasks: [], shortcut: undefined };
|
|
417
|
+
return { tasks: [], shortcut: undefined, report: undefined };
|
|
330
418
|
}
|
|
331
419
|
|
|
332
420
|
const list = Array.isArray(data) ? data : data?.tasks;
|
|
333
421
|
|
|
334
422
|
if (!Array.isArray(list)) {
|
|
335
423
|
onError(`do-always: ${path} must be a JSON array of tasks or {"tasks": [...]}`);
|
|
336
|
-
return { tasks: [], shortcut: undefined };
|
|
424
|
+
return { tasks: [], shortcut: undefined, report: undefined };
|
|
337
425
|
}
|
|
338
426
|
|
|
339
427
|
const tasks: DoAlwaysTask[] = [];
|
|
@@ -384,8 +472,13 @@ export function parseConfig(
|
|
|
384
472
|
if (!Array.isArray(data) && "merge" in data) {
|
|
385
473
|
merge = parseMerge(data.merge, path, onError);
|
|
386
474
|
}
|
|
475
|
+
let report: boolean | undefined;
|
|
476
|
+
if (!Array.isArray(data) && "report" in data) {
|
|
477
|
+
if (typeof data.report === "boolean") report = data.report;
|
|
478
|
+
else onError(`do-always: ignoring invalid "report" in ${path} (expected true or false)`);
|
|
479
|
+
}
|
|
387
480
|
|
|
388
|
-
return { tasks, shortcut, merge };
|
|
481
|
+
return { tasks, shortcut, merge, report };
|
|
389
482
|
}
|
|
390
483
|
|
|
391
484
|
const KEY_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
|
|
@@ -514,7 +607,12 @@ export function isValidWhen(when: unknown): boolean {
|
|
|
514
607
|
/** True when `relativePath` exists (as file or directory) under `cwd`. */
|
|
515
608
|
function pathExists(cwd: string, relativePath: string): boolean {
|
|
516
609
|
try {
|
|
517
|
-
|
|
610
|
+
const resolved = resolve(cwd, relativePath);
|
|
611
|
+
// Containment check: reject paths that escape the project root.
|
|
612
|
+
// `resolve` normalizes `..` sequences, so this catches
|
|
613
|
+
// "../../.ssh/id_rsa" → "/home/user/.ssh/id_rsa" when cwd is "/home/user/project".
|
|
614
|
+
if (!resolved.startsWith(cwd + sep) && resolved !== cwd) return false;
|
|
615
|
+
return existsSync(resolved);
|
|
518
616
|
} catch {
|
|
519
617
|
return false;
|
|
520
618
|
}
|
|
@@ -567,11 +665,11 @@ export function evaluateWhen(task: DoAlwaysTask, ctx: TaskContext): boolean {
|
|
|
567
665
|
return true;
|
|
568
666
|
}
|
|
569
667
|
|
|
570
|
-
/** Default order for category headers in the selector. */
|
|
668
|
+
/** Default order for category headers in the selector. Exported for tests. */
|
|
571
669
|
export const DEFAULT_CATEGORY_ORDER = ["Plan", "Do", "Docs", "Ops", "Other"];
|
|
572
670
|
|
|
573
671
|
/** A category group: a display name and the tasks that belong to it. */
|
|
574
|
-
|
|
672
|
+
interface TaskGroup {
|
|
575
673
|
name: string;
|
|
576
674
|
items: DoAlwaysTask[];
|
|
577
675
|
}
|
|
@@ -586,6 +684,11 @@ function titleCase(s: string): string {
|
|
|
586
684
|
* `order` (case-insensitive), then alphabetically; the original order within a
|
|
587
685
|
* group is preserved. The group `name` is the title-cased category. Tasks
|
|
588
686
|
* without a (non-empty) category fall under "Other".
|
|
687
|
+
*
|
|
688
|
+
* Not memoized: every caller passes a fresh array (selector open, config
|
|
689
|
+
* load, list), so an identity-keyed cache would never hit — and would go
|
|
690
|
+
* stale if a caller ever mutated its array in place. The input is small
|
|
691
|
+
* (a dozen tasks), so recomputing is cheap.
|
|
589
692
|
*/
|
|
590
693
|
export function groupTasksByCategory(
|
|
591
694
|
tasks: DoAlwaysTask[],
|
|
@@ -685,8 +788,13 @@ function filesMatchPattern(files: string[], pattern: string): boolean {
|
|
|
685
788
|
/** Regex metacharacters that must be escaped when matching a literal path char. */
|
|
686
789
|
const METACHARACTERS = ".+^${}()|[]";
|
|
687
790
|
|
|
791
|
+
/** Compiled regex cache: glob patterns are static config, so we memoize. */
|
|
792
|
+
const globRegexCache = new Map<string, RegExp>();
|
|
793
|
+
|
|
688
794
|
/** Convert a glob to an anchored RegExp (`**` -> `.*`, `*` -> `[^/]*`, `?` -> `[^/]`). */
|
|
689
795
|
function globToRegex(pattern: string): RegExp {
|
|
796
|
+
let cached = globRegexCache.get(pattern);
|
|
797
|
+
if (cached) return cached;
|
|
690
798
|
let out = "";
|
|
691
799
|
let i = 0;
|
|
692
800
|
while (i < pattern.length) {
|
|
@@ -706,7 +814,9 @@ function globToRegex(pattern: string): RegExp {
|
|
|
706
814
|
i++;
|
|
707
815
|
}
|
|
708
816
|
}
|
|
709
|
-
|
|
817
|
+
cached = new RegExp(`^${out}$`);
|
|
818
|
+
globRegexCache.set(pattern, cached);
|
|
819
|
+
return cached;
|
|
710
820
|
}
|
|
711
821
|
|
|
712
822
|
/**
|
|
@@ -789,3 +899,367 @@ export function formatList(tasks: DoAlwaysTask[]): string {
|
|
|
789
899
|
}
|
|
790
900
|
return lines.join("\n");
|
|
791
901
|
}
|
|
902
|
+
|
|
903
|
+
// ---------------------------------------------------------------------------
|
|
904
|
+
// Chains
|
|
905
|
+
//
|
|
906
|
+
// A chain is an ordered, duplicate-free list of tasks the user builds in the
|
|
907
|
+
// selector table (ORDER column) and runs from the pinned Run row. All
|
|
908
|
+
// operations are pure: they return new states, never mutate.
|
|
909
|
+
// ---------------------------------------------------------------------------
|
|
910
|
+
|
|
911
|
+
/** Maximum number of tasks in a chain. */
|
|
912
|
+
export const CHAIN_MAX = 8;
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* A task chain: ordered task names plus a LIFO history of adds (for undo).
|
|
916
|
+
* Pure state — every operation returns a new state.
|
|
917
|
+
*/
|
|
918
|
+
interface ChainState {
|
|
919
|
+
/** Task names in execution order (duplicate-free). */
|
|
920
|
+
items: string[];
|
|
921
|
+
/** LIFO history of added names, consumed by `chainUndo`. */
|
|
922
|
+
history: string[];
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** An empty chain. */
|
|
926
|
+
export function chainClear(): ChainState {
|
|
927
|
+
return { items: [], history: [] };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Add a task to the chain. A name already in the chain is moved to the end
|
|
932
|
+
* (`movedToEnd`); when the chain is at CHAIN_MAX the state is returned
|
|
933
|
+
* unchanged (`full`).
|
|
934
|
+
*/
|
|
935
|
+
export function chainAdd(
|
|
936
|
+
state: ChainState,
|
|
937
|
+
name: string,
|
|
938
|
+
): { state: ChainState; result: "added" | "movedToEnd" | "full" } {
|
|
939
|
+
if (state.items.includes(name)) {
|
|
940
|
+
return {
|
|
941
|
+
state: {
|
|
942
|
+
items: [...state.items.filter((n) => n !== name), name],
|
|
943
|
+
history: [...state.history, name],
|
|
944
|
+
},
|
|
945
|
+
result: "movedToEnd",
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
if (state.items.length >= CHAIN_MAX) {
|
|
949
|
+
return { state, result: "full" };
|
|
950
|
+
}
|
|
951
|
+
return {
|
|
952
|
+
state: { items: [...state.items, name], history: [...state.history, name] },
|
|
953
|
+
result: "added",
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** Remove a task from the chain (no-op when absent). History is untouched. */
|
|
958
|
+
export function chainRemove(state: ChainState, name: string): ChainState {
|
|
959
|
+
if (!state.items.includes(name)) return state;
|
|
960
|
+
return { ...state, items: state.items.filter((n) => n !== name) };
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Undo the most recent add that is still in the chain, skipping names that
|
|
965
|
+
* were removed in the meantime. Returns `removed: null` when there is
|
|
966
|
+
* nothing left to undo.
|
|
967
|
+
*/
|
|
968
|
+
export function chainUndo(state: ChainState): { state: ChainState; removed: string | null } {
|
|
969
|
+
for (let i = state.history.length - 1; i >= 0; i--) {
|
|
970
|
+
const name = state.history[i];
|
|
971
|
+
if (state.items.includes(name)) {
|
|
972
|
+
return {
|
|
973
|
+
state: {
|
|
974
|
+
items: state.items.filter((n) => n !== name),
|
|
975
|
+
history: state.history.slice(0, i),
|
|
976
|
+
},
|
|
977
|
+
removed: name,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return { state, removed: null };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Label for the pinned Run row: a dimmed placeholder for an empty chain,
|
|
986
|
+
* singular for one task, plural with the count otherwise.
|
|
987
|
+
*/
|
|
988
|
+
export function chainRunLabel(count: number): string {
|
|
989
|
+
if (count === 0) return "run the chain (0)";
|
|
990
|
+
if (count === 1) return "Run the task";
|
|
991
|
+
return `Run the chain (${count})`;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** One row of the task table (see `buildTableRows`). */
|
|
995
|
+
export interface TableRow {
|
|
996
|
+
kind: "header" | "task" | "run";
|
|
997
|
+
/** Header text (kind=header) or the run label (kind=run). */
|
|
998
|
+
name?: string;
|
|
999
|
+
/** The task (kind=task). */
|
|
1000
|
+
task?: DoAlwaysTask;
|
|
1001
|
+
/** 1-based chain position (kind=task, only when the task is chained). */
|
|
1002
|
+
order?: number;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Build the table rows: a header row per non-empty category, a task row per
|
|
1007
|
+
* task carrying its ORDER position, and the pinned Run row last (label from
|
|
1008
|
+
* `chainRunLabel`).
|
|
1009
|
+
*/
|
|
1010
|
+
export function buildTableRows(groups: TaskGroup[], chain: ChainState): TableRow[] {
|
|
1011
|
+
const rows: TableRow[] = [];
|
|
1012
|
+
for (const g of groups) {
|
|
1013
|
+
if (g.items.length === 0) continue;
|
|
1014
|
+
rows.push({ kind: "header", name: g.name });
|
|
1015
|
+
for (const t of g.items) {
|
|
1016
|
+
const pos = chain.items.indexOf(t.name);
|
|
1017
|
+
rows.push({
|
|
1018
|
+
kind: "task",
|
|
1019
|
+
task: t,
|
|
1020
|
+
...(pos >= 0 ? { order: pos + 1 } : {}),
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
rows.push({ kind: "run", name: chainRunLabel(chain.items.length) });
|
|
1025
|
+
return rows;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Footer preview of the chain: "1.⚡ Review changes → 2.Build". Tasks are
|
|
1030
|
+
* looked up in `tasks`; unknown names (a stale chain) are skipped.
|
|
1031
|
+
*/
|
|
1032
|
+
export function formatChainSequence(tasks: DoAlwaysTask[], chain: ChainState): string {
|
|
1033
|
+
// O(n) index map so find → O(1) lookup.
|
|
1034
|
+
const taskByName = new Map(tasks.map((t) => [t.name, t]));
|
|
1035
|
+
const parts = chain.items
|
|
1036
|
+
.map((name, i) => {
|
|
1037
|
+
const t = taskByName.get(name);
|
|
1038
|
+
if (!t) return null;
|
|
1039
|
+
const marker = shouldAutoRun(t) ? "⚡" : "";
|
|
1040
|
+
return `${i + 1}.${marker}${t.name}`;
|
|
1041
|
+
})
|
|
1042
|
+
.filter((p): p is string => p !== null);
|
|
1043
|
+
return parts.join(" → ");
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Validate a chain against the context: every task must pass its guards.
|
|
1048
|
+
* Returns the first failing step (1-based) with the guard message, or null
|
|
1049
|
+
* when the whole chain may run. Stale names (not found in `tasks`) are
|
|
1050
|
+
* skipped — the runner drops them.
|
|
1051
|
+
*/
|
|
1052
|
+
export function validateChain(
|
|
1053
|
+
tasks: DoAlwaysTask[],
|
|
1054
|
+
chain: ChainState,
|
|
1055
|
+
ctx: TaskContext,
|
|
1056
|
+
): { step: number; task: DoAlwaysTask; message: string } | null {
|
|
1057
|
+
for (let i = 0; i < chain.items.length; i++) {
|
|
1058
|
+
const task = tasks.find((t) => t.name === chain.items[i]);
|
|
1059
|
+
if (!task) continue;
|
|
1060
|
+
const message = evaluateGuards(task, ctx);
|
|
1061
|
+
if (message) return { step: i + 1, task, message };
|
|
1062
|
+
}
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// ── Chain report ─────────────────────────────────────────────────────────
|
|
1067
|
+
//
|
|
1068
|
+
// A chain run's results are appended to a Markdown report file (one file
|
|
1069
|
+
// per run, in the project root) as each step finishes, so earlier steps'
|
|
1070
|
+
// results survive later steps' output scrolling them off screen. The file
|
|
1071
|
+
// is written incrementally: even if the session dies mid-chain, the
|
|
1072
|
+
// finished steps' results are on disk.
|
|
1073
|
+
|
|
1074
|
+
/** HH:MM in the local timezone. */
|
|
1075
|
+
function reportTime(d: Date): string {
|
|
1076
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** File name for one chain run's report, e.g. do-always-report-tasks-2025-01-15-1432.md. Exported for tests. */
|
|
1080
|
+
export function reportFileName(now: Date): string {
|
|
1081
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
1082
|
+
return `do-always-report-tasks-${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.md`;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Resolve the report file path in `cwd`, appending -2, -3, … when a file
|
|
1087
|
+
* with the same name already exists (two runs within the same minute).
|
|
1088
|
+
*/
|
|
1089
|
+
export function resolveReportPath(
|
|
1090
|
+
cwd: string,
|
|
1091
|
+
now: Date,
|
|
1092
|
+
exists: (path: string) => boolean = existsSync,
|
|
1093
|
+
): string {
|
|
1094
|
+
const base = reportFileName(now);
|
|
1095
|
+
const first = join(cwd, base);
|
|
1096
|
+
if (!exists(first)) return first;
|
|
1097
|
+
const stem = base.slice(0, -3); // drop ".md"
|
|
1098
|
+
for (let i = 2; ; i++) {
|
|
1099
|
+
const candidate = join(cwd, `${stem}-${i}.md`);
|
|
1100
|
+
if (!exists(candidate)) return candidate;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** Markdown header for a new report file. */
|
|
1105
|
+
export function reportHeader(projectPath: string, stepNames: string[], now: Date): string {
|
|
1106
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
1107
|
+
const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${reportTime(now)}`;
|
|
1108
|
+
return [
|
|
1109
|
+
`# do-always chain report — ${stamp}`,
|
|
1110
|
+
"",
|
|
1111
|
+
`- Project: ${projectPath}`,
|
|
1112
|
+
`- Steps: ${stepNames.join(" → ")}`,
|
|
1113
|
+
"",
|
|
1114
|
+
"",
|
|
1115
|
+
].join("\n");
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Markdown section for one finished step: its number, name, outcome, run
|
|
1120
|
+
* time, and the final assistant message (the step's result). `startedAt`
|
|
1121
|
+
* is null when the run never started (failed-to-start).
|
|
1122
|
+
*/
|
|
1123
|
+
export function reportStepSection(
|
|
1124
|
+
index: number,
|
|
1125
|
+
name: string,
|
|
1126
|
+
status: string,
|
|
1127
|
+
startedAt: Date | null,
|
|
1128
|
+
endedAt: Date,
|
|
1129
|
+
text: string,
|
|
1130
|
+
): string {
|
|
1131
|
+
const times = startedAt ? `${reportTime(startedAt)} → ${reportTime(endedAt)}` : reportTime(endedAt);
|
|
1132
|
+
const lines = [`## ${index + 1}. ${name} — ${status} (${times})`, ""];
|
|
1133
|
+
const trimmed = text.trim();
|
|
1134
|
+
lines.push(trimmed === "" ? "_(no result text)_" : trimmed, "", "");
|
|
1135
|
+
return lines.join("\n");
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/** Markdown footer summarizing the whole run. */
|
|
1139
|
+
export function reportFooter(stepStatuses: string[], now: Date): string {
|
|
1140
|
+
const done = stepStatuses.filter((s) => s === "completed").length;
|
|
1141
|
+
const total = stepStatuses.length;
|
|
1142
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
1143
|
+
const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${reportTime(now)}`;
|
|
1144
|
+
const summary =
|
|
1145
|
+
done === total ? `${done}/${total} completed` : `${done}/${total} completed — chain stopped early`;
|
|
1146
|
+
return `---\n\n**Chain finished:** ${stamp} — ${summary}\n`;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** Markdown footer for a chain that never reached a terminal path (e.g., the session ended mid-chain). */
|
|
1150
|
+
export function reportAbandonedFooter(stepStatuses: string[], now: Date): string {
|
|
1151
|
+
const done = stepStatuses.filter((s) => s === "completed").length;
|
|
1152
|
+
const total = stepStatuses.length;
|
|
1153
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
1154
|
+
const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${reportTime(now)}`;
|
|
1155
|
+
return `---\n\n**Chain abandoned:** ${stamp} — ${done}/${total} completed\n`;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Whether a finished run's report file is worth keeping on disk: at least
|
|
1160
|
+
* one completed step, or some step section carried result text. A run that
|
|
1161
|
+
* produced neither (e.g. step 1 errored before any output, or the chain was
|
|
1162
|
+
* blocked before running) leaves no file behind — the failure is already
|
|
1163
|
+
* surfaced by the notification, and a quick same-minute retry would
|
|
1164
|
+
* otherwise get a `-N` sibling next to an empty report.
|
|
1165
|
+
*/
|
|
1166
|
+
export function reportWorthKeeping(stepStatuses: string[], hasContent: boolean): boolean {
|
|
1167
|
+
return hasContent || stepStatuses.some((s) => s === "completed");
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Extract an assistant message's text: string content as-is, or the text
|
|
1172
|
+
* parts of a content array joined with newlines (tool-call parts are not
|
|
1173
|
+
* text and are skipped). Same shape pi's own runtime uses. Null/undefined
|
|
1174
|
+
* content (a run that produced no assistant text) yields "".
|
|
1175
|
+
*/
|
|
1176
|
+
export function assistantText(
|
|
1177
|
+
content: string | Array<{ type?: string; text?: string }> | null | undefined,
|
|
1178
|
+
): string {
|
|
1179
|
+
if (typeof content === "string") return content;
|
|
1180
|
+
if (!Array.isArray(content)) return "";
|
|
1181
|
+
return content
|
|
1182
|
+
.flatMap((part) => (part && part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
|
1183
|
+
.join("\n");
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// ── Chain step summary ───────────────────────────────────────────────────
|
|
1187
|
+
//
|
|
1188
|
+
// Compact per-step and chain-end summary strings for notifications.
|
|
1189
|
+
// These are derived from the existing report data (outcome, timing, file count)
|
|
1190
|
+
// and provide immediate, scannable feedback after each chain step.
|
|
1191
|
+
|
|
1192
|
+
/** Outcome of one chain step's run (see `sendAndWait`). */
|
|
1193
|
+
export type ChainStepOutcome = "completed" | "aborted" | "error" | "failed-to-start";
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Generate a compact per-step summary string for notifications.
|
|
1197
|
+
* Examples:
|
|
1198
|
+
* "✓ Build — 3 files changed — 2m14s"
|
|
1199
|
+
* "✗ Tests — 45s"
|
|
1200
|
+
* "⊘ Review changes"
|
|
1201
|
+
*
|
|
1202
|
+
* @param outcome the step outcome
|
|
1203
|
+
* @param stepName the task name
|
|
1204
|
+
* @param durationMs how long the step took (0 if not timed)
|
|
1205
|
+
* @param fileCount number of changed files after the step (0 if unknown)
|
|
1206
|
+
* @returns the summary string
|
|
1207
|
+
*/
|
|
1208
|
+
export function stepSummary(
|
|
1209
|
+
outcome: ChainStepOutcome,
|
|
1210
|
+
stepName: string,
|
|
1211
|
+
durationMs: number,
|
|
1212
|
+
fileCount: number,
|
|
1213
|
+
): string {
|
|
1214
|
+
const marker = outcomeToMarker(outcome);
|
|
1215
|
+
const parts: string[] = [marker, stepName];
|
|
1216
|
+
if (fileCount > 0) {
|
|
1217
|
+
parts.push(`${fileCount} file${fileCount === 1 ? "" : "s"} changed`);
|
|
1218
|
+
}
|
|
1219
|
+
if (durationMs > 0) {
|
|
1220
|
+
parts.push(formatDuration(durationMs));
|
|
1221
|
+
}
|
|
1222
|
+
// Only add " — " separator when there are parts beyond marker+name.
|
|
1223
|
+
if (parts.length > 2) {
|
|
1224
|
+
return `${marker} ${stepName} — ${parts.slice(2).join(" — ")}`;
|
|
1225
|
+
}
|
|
1226
|
+
return `${marker} ${stepName}`;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Generate a compact chain-end summary string, e.g.
|
|
1231
|
+
* "✅ 4/4 steps completed in 6m42s". Only reachable when every step
|
|
1232
|
+
* completed — the stop paths return early with their own per-step
|
|
1233
|
+
* notification, so `completed` always equals `total` here.
|
|
1234
|
+
*/
|
|
1235
|
+
export function chainSummary(completed: number, total: number, totalMs: number): string {
|
|
1236
|
+
const time = totalMs > 0 ? formatDuration(totalMs) : "";
|
|
1237
|
+
return `✅ ${completed}/${total} steps completed${time ? ` in ${time}` : ""}`;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** Format milliseconds to a human-readable duration string. Exported for tests. */
|
|
1241
|
+
export function formatDuration(ms: number): string {
|
|
1242
|
+
if (ms < 1000) return `${ms}ms`;
|
|
1243
|
+
const s = Math.floor(ms / 1000);
|
|
1244
|
+
if (s < 60) return `${s}s`;
|
|
1245
|
+
const m = Math.floor(s / 60);
|
|
1246
|
+
const rem = s % 60;
|
|
1247
|
+
return rem > 0 ? `${m}m${rem}s` : `${m}m`;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** Map a step outcome to its display marker character. */
|
|
1251
|
+
function outcomeToMarker(outcome: ChainStepOutcome): string {
|
|
1252
|
+
switch (outcome) {
|
|
1253
|
+
case "completed":
|
|
1254
|
+
return "✓";
|
|
1255
|
+
case "aborted":
|
|
1256
|
+
return "⊘";
|
|
1257
|
+
case "error":
|
|
1258
|
+
return "✗";
|
|
1259
|
+
case "failed-to-start":
|
|
1260
|
+
return "✗";
|
|
1261
|
+
default:
|
|
1262
|
+
// Exhaustive check: the type is a literal union, so this is unreachable.
|
|
1263
|
+
throw new Error(`unexpected outcome: ${outcome as string}`);
|
|
1264
|
+
}
|
|
1265
|
+
}
|