@stevecastaneda/paseo-progress 0.1.8 → 0.2.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.
@@ -0,0 +1,205 @@
1
+ // Where a worktree's progress files live, and the run history a repo can opt
2
+ // in to saving. The working files never touch the repo: they live outside it,
3
+ // one folder per worktree, so git never sees them and nothing needs hiding.
4
+ // Only runs the user asks to save are written into the repo, one file per run.
5
+ import { execFile } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
+ import { realpathSync } from "node:fs";
8
+ import { appendFile, mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import { basename, join, normalize, resolve } from "node:path";
11
+ import { promisify } from "node:util";
12
+ import { LEGACY_FOLDER, PANEL_OPENED_NAME, PROGRESS_FILE_NAME } from "../shared/events.ts";
13
+ import { withLock } from "./lock.ts";
14
+ import { outside } from "./paths.ts";
15
+
16
+ const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
17
+ const SAVE_RUNS_KEY = "paseo-progress.saveRuns";
18
+ // Exactly what 0.1.x wrote into .scratch/.gitignore. Only this is ever removed.
19
+ const LEGACY_GITIGNORE = `# Added by the progress plugin for Paseo so its files stay out of git.
20
+ ${PROGRESS_FILE_NAME}
21
+ ${LOCK_NAME}/
22
+ ${PANEL_OPENED_NAME}
23
+ .gitignore
24
+ `;
25
+
26
+ // `directory` holds the working files. `shown` names it for people.
27
+ export type Store = { directory: string; shown: string };
28
+
29
+ export async function git(cwd: string, args: string[]): Promise<{ code: number; stdout: string }> {
30
+ try {
31
+ const { stdout } = await promisify(execFile)("git", args, { cwd });
32
+ return { code: 0, stdout };
33
+ } catch (error) {
34
+ const failed = error as { code?: number | string; stdout?: string };
35
+ return { code: typeof failed.code === "number" ? failed.code : -1, stdout: failed.stdout ?? "" };
36
+ }
37
+ }
38
+
39
+ async function exists(path: string): Promise<boolean> {
40
+ return Boolean(await stat(path).catch(() => null));
41
+ }
42
+
43
+ // One folder per worktree on this computer, named so a person can tell which
44
+ // worktree it belongs to.
45
+ export function outsideDirectory(root: string, home = homedir()): string {
46
+ let real = root;
47
+ try {
48
+ real = realpathSync(root);
49
+ } catch {}
50
+ const hash = createHash("sha1").update(real).digest("hex").slice(0, 8);
51
+ return join(home, ".local", "state", "paseo-progress", `${basename(real)}-${hash}`);
52
+ }
53
+
54
+ // Whether git tracks a 0.1.x progress file, per worktree, until the file changes.
55
+ const trackedLegacy = new Map<string, { mtimeMs: number; tracked: boolean }>();
56
+
57
+ async function legacyTracked(root: string, mtimeMs: number): Promise<boolean> {
58
+ const cached = trackedLegacy.get(root);
59
+ if (cached && cached.mtimeMs === mtimeMs) return cached.tracked;
60
+ const { code } = await git(root, ["ls-files", "--error-unmatch", "--", `${LEGACY_FOLDER}/${PROGRESS_FILE_NAME}`]);
61
+ trackedLegacy.set(root, { mtimeMs, tracked: code === 0 });
62
+ return code === 0;
63
+ }
64
+
65
+ // The worktree's working files. A run 0.1.x left in .scratch moves outside the
66
+ // first time anything asks, unless git tracks it: then it stays and is used in
67
+ // place, so nothing changes for a repo that commits it.
68
+ export async function findStore(root: string, home = homedir()): Promise<Store> {
69
+ const directory = outsideDirectory(root, home);
70
+ const store = { directory, shown: directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory };
71
+ const legacy = join(root, LEGACY_FOLDER);
72
+ const info = await stat(join(legacy, PROGRESS_FILE_NAME)).catch(() => null);
73
+ if (!info) return store;
74
+ if (await legacyTracked(root, info.mtimeMs)) return { directory: legacy, shown: LEGACY_FOLDER };
75
+ await moveLegacy(legacy, directory);
76
+ return store;
77
+ }
78
+
79
+ // Appends the old file's lines to the new one rather than replacing it, so a
80
+ // command that wrote to the old place mid-move loses nothing: the next move
81
+ // picks those lines up. Then tidies what 0.1.x left behind.
82
+ async function moveLegacy(legacy: string, directory: string): Promise<void> {
83
+ await mkdir(directory, { recursive: true });
84
+ await withLock(join(legacy, LOCK_NAME), async () => {
85
+ const text = await readFile(join(legacy, PROGRESS_FILE_NAME), "utf8").catch(() => null);
86
+ if (text === null) return;
87
+ await withLock(join(directory, LOCK_NAME), async () => {
88
+ const current = await readFile(join(directory, PROGRESS_FILE_NAME), "utf8").catch(() => "");
89
+ const lead = current && !current.endsWith("\n") ? "\n" : "";
90
+ await appendFile(join(directory, PROGRESS_FILE_NAME), `${lead}${text}`);
91
+ });
92
+ await rm(join(legacy, PROGRESS_FILE_NAME));
93
+ if (await exists(join(legacy, PANEL_OPENED_NAME))) {
94
+ await writeFile(join(directory, PANEL_OPENED_NAME), "");
95
+ await rm(join(legacy, PANEL_OPENED_NAME));
96
+ }
97
+ });
98
+ const ignore = await readFile(join(legacy, ".gitignore"), "utf8").catch(() => null);
99
+ if (ignore === LEGACY_GITIGNORE) await rm(join(legacy, ".gitignore"));
100
+ // Only if nothing else is in there; the user's own files keep the folder.
101
+ await rmdir(legacy).catch(() => {});
102
+ }
103
+
104
+ // The repo's saved-history folder, kept in its local git config, which every
105
+ // worktree of the repo shares and nothing commits. Polls cost one stat; git
106
+ // runs again only when the config file changes.
107
+ const configFiles = new Map<string, string | null>();
108
+ const savedFolders = new Map<string, { mtimeMs: number; folder: string | null }>();
109
+
110
+ async function configFile(root: string): Promise<string | null> {
111
+ if (!configFiles.has(root)) {
112
+ const { code, stdout } = await git(root, ["rev-parse", "--git-common-dir"]);
113
+ configFiles.set(root, code === 0 ? join(resolve(root, stdout.trim()), "config") : null);
114
+ }
115
+ return configFiles.get(root)!;
116
+ }
117
+
118
+ // Whether the worktree is in a git repo, which saving run history needs.
119
+ export async function inGitRepo(root: string): Promise<boolean> {
120
+ return (await configFile(root)) !== null;
121
+ }
122
+
123
+ // The folder runs are saved to, relative to the worktree root, or null.
124
+ export async function historyFolder(root: string): Promise<string | null> {
125
+ const file = await configFile(root);
126
+ if (file === null) return null;
127
+ const mtimeMs = (await stat(file).catch(() => null))?.mtimeMs ?? 0;
128
+ const cached = savedFolders.get(file);
129
+ if (cached && cached.mtimeMs === mtimeMs) return cached.folder;
130
+ const { stdout } = await git(root, ["config", "--get", SAVE_RUNS_KEY]);
131
+ const folder = stdout.trim() || null;
132
+ savedFolders.set(file, { mtimeMs, folder });
133
+ return folder;
134
+ }
135
+
136
+ // A folder the user picked, as a clean path inside the worktree, or why not.
137
+ export function checkFolder(raw: string): { folder: string } | { error: string } {
138
+ const trimmed = raw.trim().replace(/\/+$/, "");
139
+ if (!trimmed) return { error: "Choose a folder" };
140
+ const folder = normalize(trimmed);
141
+ if (outside(folder) || folder === ".") return { error: "Choose a folder inside the repo" };
142
+ if (folder === ".git" || folder.startsWith(".git/")) return { error: "Choose a folder outside .git" };
143
+ return { folder };
144
+ }
145
+
146
+ // Starts or stops saving runs for the whole repo. Files already saved stay.
147
+ export async function setHistoryFolder(root: string, raw: string | null): Promise<string | null> {
148
+ if (raw === null) {
149
+ const { code } = await git(root, ["config", "--local", "--unset-all", SAVE_RUNS_KEY]);
150
+ // 5: it wasn't set, which is what was asked for.
151
+ if (code !== 0 && code !== 5) throw new Error("Could not change this repo's git settings.");
152
+ return null;
153
+ }
154
+ const checked = checkFolder(raw);
155
+ if ("error" in checked) throw new Error(checked.error);
156
+ const { code } = await git(root, ["config", "--local", SAVE_RUNS_KEY, checked.folder]);
157
+ if (code !== 0) throw new Error("Could not save this in the repo's git settings. Is it a git repo?");
158
+ return checked.folder;
159
+ }
160
+
161
+ // "20261001-064512-warn-when-scratch.jsonl": when the run started, then its
162
+ // title as it was at the start, so the name never changes and two runs never
163
+ // share one.
164
+ export function runFileName(startedAt: string, title: string): string {
165
+ const stamp = startedAt.replace(/\.\d+Z$/, "").replace(/[-:]/g, "").replace("T", "-");
166
+ const slug = title.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
167
+ return `${stamp}${slug ? `-${slug}` : ""}.jsonl`;
168
+ }
169
+
170
+ // After each change: when the repo saves runs, rewrite the current run's file
171
+ // with every event from its start. Runs before the first start aren't saved.
172
+ export async function saveRunCopy(root: string, text: string): Promise<void> {
173
+ const folder = await historyFolder(root);
174
+ if (!folder) return;
175
+ const lines = text.split("\n").filter((line) => line.trim());
176
+ let start = -1;
177
+ let event: { ts?: string; title?: string } | null = null;
178
+ for (let index = lines.length - 1; index >= 0; index--) {
179
+ try {
180
+ const parsed = JSON.parse(lines[index]);
181
+ if (parsed?.type === "run.start") {
182
+ start = index;
183
+ event = parsed;
184
+ break;
185
+ }
186
+ } catch {}
187
+ }
188
+ if (start < 0 || !event?.ts || !event.title) return;
189
+ const directory = join(root, folder);
190
+ await mkdir(directory, { recursive: true });
191
+ await writeFile(join(directory, runFileName(event.ts, event.title)), `${lines.slice(start).join("\n")}\n`);
192
+ }
193
+
194
+ // The folders directly inside one folder of the worktree ("" is the top), for
195
+ // the folder picker. .git is left out; so are links, which could leave the repo.
196
+ export async function listFolders(root: string, folder: string): Promise<{ folder: string; folders: string[] }> {
197
+ const checked = folder === "" ? { folder: "" } : checkFolder(folder);
198
+ if ("error" in checked) throw new Error(checked.error);
199
+ const entries = await readdir(join(root, checked.folder), { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
200
+ if (error.code === "ENOENT") return [];
201
+ throw error;
202
+ });
203
+ const folders = entries.filter((entry) => entry.isDirectory() && !(checked.folder === "" && entry.name === ".git")).map((entry) => entry.name);
204
+ return { folder: checked.folder, folders: folders.sort((a, b) => a.localeCompare(b)) };
205
+ }
package/shared/events.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { z } from "zod";
2
2
 
3
- // One line of `.scratch/progress.jsonl`. The command-line tool writes these;
4
- // the dashboard reducer reads them. `v` lets later versions change a shape
5
- // without misreading old files.
6
- export const PROGRESS_FILE = ".scratch/progress.jsonl";
3
+ // One line of `progress.jsonl`. The command-line tool writes these; the
4
+ // dashboard reducer reads them. `v` lets later versions change a shape without
5
+ // misreading old files. Which folder holds it is set up per repo.
6
+ export const PROGRESS_FILE_NAME = "progress.jsonl";
7
7
  // Present once someone has opened the Progress panel for this worktree.
8
- export const PANEL_OPENED_FILE = ".scratch/progress-panel-opened";
8
+ export const PANEL_OPENED_NAME = "progress-panel-opened";
9
+ // Where 0.1.x kept each worktree's files. store.ts moves them out.
10
+ export const LEGACY_FOLDER = ".scratch";
9
11
  export const EVENT_VERSION = 1;
10
12
 
11
13
  export const TICKET_STATUSES = ["not_started", "working", "blocked", "done", "skipped"] as const;
package/shared/rpc.ts CHANGED
@@ -5,8 +5,13 @@ import type { Dashboard } from "./dashboard.ts";
5
5
  // `panelOpened`: the Progress panel has been opened for this worktree before.
6
6
  // `root`: the worktree root the file was read from, which stored paths are
7
7
  // relative to. It can sit above the workspace directory.
8
+ // `file`: where the progress file is, for people. `savedTo`: the repo folder
9
+ // each run is saved to, or null when history stays on this computer.
10
+ // `inRepo`: saving history needs a git repo to keep the choice in.
8
11
  // `version` changes whenever anything else in the result does.
9
- export type DashboardResult = { configured: boolean; dashboard: Dashboard; panelOpened: boolean; root: string; version: string };
12
+ export type DashboardResult = {
13
+ configured: boolean; dashboard: Dashboard; panelOpened: boolean; file: string; savedTo: string | null; inRepo: boolean; root: string; version: string;
14
+ };
10
15
 
11
16
  export const getDashboard = defineRpc({
12
17
  name: "progress.get",
@@ -83,6 +88,30 @@ export const installSkill = defineRpc({
83
88
  output: skillStatus,
84
89
  });
85
90
 
91
+ // The folders inside one folder of the worktree ("" is the top), for the
92
+ // folder picker.
93
+ export const listFolders = defineRpc({
94
+ name: "progress.folders.list",
95
+ input: z.object({
96
+ workspaceId: z.string().min(1),
97
+ workspaceDirectory: z.string().min(1),
98
+ folder: z.string(),
99
+ }),
100
+ output: z.object({ folder: z.string(), folders: z.array(z.string()) }),
101
+ });
102
+
103
+ // Starts saving each run to a folder in the repo (relative to the worktree
104
+ // root), for every worktree of the repo, or stops when `folder` is null.
105
+ export const setHistoryFolder = defineRpc({
106
+ name: "progress.history.set",
107
+ input: z.object({
108
+ workspaceId: z.string().min(1),
109
+ workspaceDirectory: z.string().min(1),
110
+ folder: z.string().nullable(),
111
+ }),
112
+ output: z.object({ savedTo: z.string().nullable() }),
113
+ });
114
+
86
115
  // Opens a recorded local deliverable on the daemon host with its default app.
87
116
  export const openDeliverable = defineRpc({
88
117
  name: "progress.deliverable.open",