pi-do-always 0.9.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.
@@ -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
- export interface Guard {
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
- export const GUARD_TYPES = [
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
- export type DoAlwaysConfig =
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
- export interface ParsedDoAlwaysConfig {
92
+ interface ParsedDoAlwaysConfig {
88
93
  tasks: DoAlwaysTask[];
89
94
  /**
90
95
  * The `shortcut` field, if present: a key id string, null when explicitly
@@ -96,10 +101,15 @@ 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;
99
109
  }
100
110
 
101
111
  import { existsSync } from "node:fs";
102
- import { join } from "node:path";
112
+ import { join, resolve, sep } from "node:path";
103
113
 
104
114
  /**
105
115
  * Structured facts about the working tree and git state, gathered once per
@@ -326,14 +336,14 @@ export function parseConfig(
326
336
  data = JSON.parse(raw);
327
337
  } catch (err) {
328
338
  onError(`do-always: invalid JSON in ${path}: ${err}`);
329
- return { tasks: [], shortcut: undefined };
339
+ return { tasks: [], shortcut: undefined, report: undefined };
330
340
  }
331
341
 
332
342
  const list = Array.isArray(data) ? data : data?.tasks;
333
343
 
334
344
  if (!Array.isArray(list)) {
335
345
  onError(`do-always: ${path} must be a JSON array of tasks or {"tasks": [...]}`);
336
- return { tasks: [], shortcut: undefined };
346
+ return { tasks: [], shortcut: undefined, report: undefined };
337
347
  }
338
348
 
339
349
  const tasks: DoAlwaysTask[] = [];
@@ -384,8 +394,13 @@ export function parseConfig(
384
394
  if (!Array.isArray(data) && "merge" in data) {
385
395
  merge = parseMerge(data.merge, path, onError);
386
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
+ }
387
402
 
388
- return { tasks, shortcut, merge };
403
+ return { tasks, shortcut, merge, report };
389
404
  }
390
405
 
391
406
  const KEY_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
@@ -514,7 +529,12 @@ export function isValidWhen(when: unknown): boolean {
514
529
  /** True when `relativePath` exists (as file or directory) under `cwd`. */
515
530
  function pathExists(cwd: string, relativePath: string): boolean {
516
531
  try {
517
- return existsSync(join(cwd, relativePath));
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);
518
538
  } catch {
519
539
  return false;
520
540
  }
@@ -571,7 +591,7 @@ export function evaluateWhen(task: DoAlwaysTask, ctx: TaskContext): boolean {
571
591
  export const DEFAULT_CATEGORY_ORDER = ["Plan", "Do", "Docs", "Ops", "Other"];
572
592
 
573
593
  /** A category group: a display name and the tasks that belong to it. */
574
- export interface TaskGroup {
594
+ interface TaskGroup {
575
595
  name: string;
576
596
  items: DoAlwaysTask[];
577
597
  }
@@ -685,8 +705,13 @@ function filesMatchPattern(files: string[], pattern: string): boolean {
685
705
  /** Regex metacharacters that must be escaped when matching a literal path char. */
686
706
  const METACHARACTERS = ".+^${}()|[]";
687
707
 
708
+ /** Compiled regex cache: glob patterns are static config, so we memoize. */
709
+ const globRegexCache = new Map<string, RegExp>();
710
+
688
711
  /** Convert a glob to an anchored RegExp (`**` -> `.*`, `*` -> `[^/]*`, `?` -> `[^/]`). */
689
712
  function globToRegex(pattern: string): RegExp {
713
+ let cached = globRegexCache.get(pattern);
714
+ if (cached) return cached;
690
715
  let out = "";
691
716
  let i = 0;
692
717
  while (i < pattern.length) {
@@ -706,7 +731,9 @@ function globToRegex(pattern: string): RegExp {
706
731
  i++;
707
732
  }
708
733
  }
709
- return new RegExp(`^${out}$`);
734
+ cached = new RegExp(`^${out}$`);
735
+ globRegexCache.set(pattern, cached);
736
+ return cached;
710
737
  }
711
738
 
712
739
  /**
@@ -789,3 +816,300 @@ export function formatList(tasks: DoAlwaysTask[]): string {
789
816
  }
790
817
  return lines.join("\n");
791
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.9.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": {
package/pi.image.png CHANGED
Binary file