pi-task-tracker 0.1.1

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/index.ts +561 -0
  4. package/package.json +31 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Page
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # pi-task-tracker
2
+
3
+ Task workflow tracking for the [pi coding agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent): every completed task becomes a real git commit on your branch, and those commits double as interactive rollback checkpoints.
4
+
5
+ ## What it does
6
+
7
+ - **TODO.md tracking** — each prompt is recorded as a task entry in `TODO.md` and checked off when the agent settles.
8
+ - **README.md maintenance** — the project structure section stays up to date with agent-created files.
9
+ - **Per-task git auto-commit** — when a task completes, only the files the agent actually touched are staged and committed with the task as the message. Nothing else is swept in.
10
+ - **`/rollback`** — pick any prior commit interactively and the working tree is restored to that point. Worktree-only, nothing is committed, fully reversible (`git diff` to review, commit when satisfied).
11
+
12
+ ### What makes it different
13
+
14
+ - **Hashline-aware** — records edits made through `pi-hashline-edit-pro`'s `replace`/`insert` tools, not just the native `edit` tool (most setups that replace native edit would otherwise go completely untracked).
15
+ - **Shell-aware** — captures files created/modified by `bash` **and** `powershell` tool calls via before/after `git status` diffing.
16
+ - **Nested-repo aware** — changes inside a sub-repository (a `.git` below your session root) are committed *there* — the innermost repo wins — and never pollute the outer repo.
17
+ - **Subagent-safe** — `.pi-subagents/` session artifacts are never recorded, listed, or committed.
18
+ - **~zero per-turn cost** — one incremental `git add` of touched files per task; no full-worktree scans, no background snapshot daemons.
19
+
20
+ ## /rollback
21
+
22
+ ```
23
+ /rollback
24
+ → pick a commit (sha, date, task subject)
25
+ → review the impact summary (files restored / removed, dirty-worktree warning)
26
+ → confirm → working tree restored to that commit
27
+ ```
28
+
29
+ Conservative by design: the index is untouched, the rollback itself is never auto-committed, and untracked files are out of scope (they were never committed, so their provenance is unknown).
30
+
31
+ ## Configuration
32
+
33
+ Optional `taskTracker` key in `~/.pi/agent/settings.json` (all default to `true`):
34
+
35
+ ```json
36
+ {
37
+ "taskTracker": {
38
+ "todo": true,
39
+ "readme": true,
40
+ "autoCommit": true
41
+ }
42
+ }
43
+ ```
44
+
45
+ - `todo` — maintain `TODO.md`
46
+ - `readme` — maintain the `README.md` project-structure section
47
+ - `autoCommit` — per-task commits; these are the `/rollback` checkpoints, so disabling this also disables rollback targets
48
+
49
+ ## Requirements
50
+
51
+ - [pi coding agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) with the extension API
52
+ - `git` on PATH
53
+ - Works with or without `pi-hashline-edit-pro`
54
+
55
+ ## Limitations
56
+
57
+ - Rollback granularity is per **task** (agent turn-set), not per message. If you need message-level time travel including your own manual edits, a snapshot-based extension is a better fit — at the cost of full-worktree scans every turn.
58
+ - Files created by shell commands inside a nested repo that was never touched via write/edit during the session are not captured.
59
+ - An embedded (unregistered) sub-repo shows as `?? dir/` in the outer `git status` — that's standard git behavior; this extension never stages it.
60
+
61
+ ## 本简介(中文)
62
+
63
+ `pi-task-tracker` 为 pi coding agent 提供任务级工作流跟踪:每次任务完成时,agent 实际改动的文件被自动提交到你的分支(提交信息即任务名),这些提交同时构成检查点;`/rollback` 可交互式地把工作区回滚到任意历史提交(仅工作区、不自动提交、可逆)。支持 hashline 编辑工具、bash/powershell 产物捕获、嵌套仓库归属最内层、自动排除 `.pi-subagents/` 会话数据。配置项见上方 `taskTracker`。
64
+
65
+ ## License
66
+
67
+ MIT
package/index.ts ADDED
@@ -0,0 +1,561 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ isBashToolResult,
4
+ isPowerShellToolResult,
5
+ isToolCallEventType,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import * as fs from "node:fs";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+
12
+ function ts(): string {
13
+ return new Date().toISOString().slice(0, 19).replace("T", " ");
14
+ }
15
+
16
+ // ---- Git version control helpers ----
17
+ // spawnSync bypasses the shell, so commit messages with any characters
18
+ // (quotes, $, backticks) are safe on both Windows and Unix.
19
+ function git(args: string[], cwd: string): string {
20
+ try {
21
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
22
+ return r.status === 0 ? (r.stdout || "").trim() : "";
23
+ } catch {
24
+ return "";
25
+ }
26
+ }
27
+
28
+ function gitOk(args: string[], cwd: string): boolean {
29
+ try {
30
+ return spawnSync("git", args, { cwd, stdio: "ignore" }).status === 0;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ // Snapshot of working-tree changes (untracked + modified, gitignore-respecting).
37
+ // Returns Map<relativePath, porcelainStatus>. Used to diff what a bash command
38
+ // changed: tool_call fires before execution, tool_result after.
39
+ function gitStatusSet(cwd: string): Map<string, string> {
40
+ // NOTE: do not use the trimming git() helper here -- .trim() strips the leading
41
+ // space of the first porcelain line (e.g. " M path"), corrupting the 2-char
42
+ // status field and shifting the path by one. Read stdout raw instead.
43
+ let out = "";
44
+ try {
45
+ const r = spawnSync("git", ["-c", "core.quotepath=false", "status", "--porcelain", "-uall"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
46
+ out = r.status === 0 ? r.stdout || "" : "";
47
+ } catch {
48
+ return new Map();
49
+ }
50
+ const m = new Map<string, string>();
51
+ for (const line of out.split("\n")) {
52
+ if (line.length < 3) continue;
53
+ const status = line.slice(0, 2);
54
+ let p = line.slice(3);
55
+ const arrow = p.indexOf(" -> ");
56
+ if (arrow !== -1) p = p.slice(arrow + 4);
57
+ if (p.startsWith('"') && p.endsWith('"')) {
58
+ try { p = JSON.parse(p); } catch {}
59
+ }
60
+ if (p) m.set(p.replace(/\\/g, "/"), status);
61
+ }
62
+ return m;
63
+ }
64
+
65
+ // Subagent working data (inputs/outputs/transcripts/mission metadata) is
66
+ // session machinery, not project content: never record, list or commit it.
67
+ const SUBAGENT_DIR = ".pi-subagents";
68
+
69
+ function isSubagentPath(rel: string): boolean {
70
+ const p = rel.replace(/\\/g, "/");
71
+ return p === SUBAGENT_DIR || p.startsWith(SUBAGENT_DIR + "/");
72
+ }
73
+
74
+ // Nearest enclosing git worktree root for an absolute path, searching upward
75
+ // from the file's own directory and stopping at the session cwd (guaranteed a
76
+ // repo by initGit). A .git found below cwd (nested/embedded repository) wins,
77
+ // so changes made inside sub-repositories are attributed to the innermost
78
+ // repo's history instead of polluting the outer one.
79
+ function ownerRepoRoot(absPath: string, cwd: string): string {
80
+ let dir = path.dirname(absPath);
81
+ for (;;) {
82
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
83
+ const rel = path.relative(dir, cwd);
84
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return cwd;
85
+ const parent = path.dirname(dir);
86
+ if (parent === dir) return cwd;
87
+ dir = parent;
88
+ }
89
+ }
90
+
91
+ function ensureGitIdentity(cwd: string): void {
92
+ // ensure local identity so commits never fail on a fresh machine
93
+ if (!git(["config", "user.name"], cwd)) {
94
+ gitOk(["config", "user.name", "Pi Agent"], cwd);
95
+ }
96
+ if (!git(["config", "user.email"], cwd)) {
97
+ gitOk(["config", "user.email", "pi-agent@local"], cwd);
98
+ }
99
+ }
100
+
101
+ const GITIGNORE_SEED =
102
+ "node_modules/\n.env\n.env.*\n*.log\n.DS_Store\n__pycache__/\n*.pyc\n.pi/\n.pi-subagents/\ndist/\nbuild/\n";
103
+
104
+ function initGit(cwd: string): void {
105
+ // 1. init repo if absent
106
+ if (!fs.existsSync(path.join(cwd, ".git"))) {
107
+ gitOk(["init"], cwd);
108
+ }
109
+ ensureGitIdentity(cwd);
110
+ // 2. seed a sensible .gitignore if none exists
111
+ const gi = path.join(cwd, ".gitignore");
112
+ if (!fs.existsSync(gi)) {
113
+ fs.writeFileSync(gi, GITIGNORE_SEED, "utf-8");
114
+ } else if (!fs.readFileSync(gi, "utf-8").split(/\r?\n/).some((l) => l.trim() === ".pi-subagents/")) {
115
+ // existing repos: make sure subagent working data stays untracked
116
+ const prev = fs.readFileSync(gi, "utf-8");
117
+ fs.writeFileSync(gi, prev.replace(/\n*$/, "\n\n") + ".pi-subagents/\n", "utf-8");
118
+ }
119
+ }
120
+
121
+ // Stage only agent-touched files + workflow trackers (TODO.md / README.md),
122
+ // then commit one snapshot per completed task.
123
+ // Nested repositories: files are grouped by their owning git repo (nearest
124
+ // enclosing .git). Changes inside a sub-repo are committed THERE (innermost
125
+ // repo wins) and never staged in the outer repo; subagent working data
126
+ // (.pi-subagents/) is never committed at all.
127
+ function gitCommitTask(
128
+ cwd: string,
129
+ task: string | null,
130
+ created: string[],
131
+ edited: string[]
132
+ ): void {
133
+ const groups = new Map<string, Set<string>>();
134
+ const consider = (relPath: string) => {
135
+ if (isSubagentPath(relPath)) return;
136
+ const abs = path.resolve(cwd, relPath);
137
+ if (path.relative(cwd, abs).startsWith("..") || path.isAbsolute(path.relative(cwd, abs))) return;
138
+ const root = ownerRepoRoot(abs, cwd);
139
+ const rel = path.relative(root, abs).replace(/\\/g, "/");
140
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return;
141
+ if (!groups.has(root)) groups.set(root, new Set());
142
+ groups.get(root)!.add(rel);
143
+ };
144
+ for (const f of created) consider(f);
145
+ for (const f of edited) consider(f);
146
+ if (!groups.has(cwd)) groups.set(cwd, new Set());
147
+ const rootFiles = groups.get(cwd)!;
148
+ rootFiles.add("TODO.md");
149
+ rootFiles.add("README.md");
150
+ rootFiles.add(".gitignore");
151
+
152
+ const subject = task ? task.slice(0, 72) : "chore: workflow auto-commit";
153
+ const visible = (f: string) => !isSubagentPath(f);
154
+ for (const [root, files] of groups) {
155
+ ensureGitIdentity(root);
156
+ for (const f of files) {
157
+ gitOk(["add", "--", f], root);
158
+ }
159
+ // skip when nothing ended up staged in this repo
160
+ if (!git(["diff", "--cached", "--name-only"], root)) continue;
161
+ const parts: string[] = [];
162
+ if (root === cwd) {
163
+ if (created.some(visible)) parts.push(`Created: ${created.filter(visible).join(", ")}`);
164
+ if (edited.some(visible)) parts.push(`Edited: ${edited.filter(visible).join(", ")}`);
165
+ }
166
+ const args = ["commit", "-m", subject];
167
+ if (parts.length > 0) args.push("-m", parts.join("\n"));
168
+ gitOk(args, root);
169
+ }
170
+ }
171
+
172
+ // Best-effort: bring an existing TODO up to the standard format (ensure header).
173
+ function normalizeTodo(todoPath: string): void {
174
+ try {
175
+ const content = fs.readFileSync(todoPath, "utf-8");
176
+ if (!content.trimStart().startsWith("# TODO")) {
177
+ fs.writeFileSync(todoPath, "# TODO\n\n" + content, "utf-8");
178
+ }
179
+ } catch {}
180
+ }
181
+
182
+ // Best-effort: bring an existing README up to the standard format (ensure sections).
183
+ function normalizeReadme(readmePath: string, name: string): void {
184
+ try {
185
+ let content = fs.readFileSync(readmePath, "utf-8");
186
+ let changed = false;
187
+
188
+ // remove legacy "## Recent Changes" log section (rule: no logs in README)
189
+ const rcHeader = "## Recent Changes";
190
+ const rcIdx = content.indexOf(rcHeader);
191
+ if (rcIdx !== -1) {
192
+ const nextSection = content.indexOf("\n## ", rcIdx + rcHeader.length);
193
+ if (nextSection !== -1) {
194
+ content = content.slice(0, rcIdx) + content.slice(nextSection + 1);
195
+ } else {
196
+ content = content.slice(0, rcIdx).trimEnd() + "\n";
197
+ }
198
+ changed = true;
199
+ }
200
+
201
+ if (!content.includes("## Overview")) {
202
+ content = content.trimEnd() + "\n\n## Overview\n\nTODO: Add project description.\n";
203
+ changed = true;
204
+ }
205
+ if (!content.includes("## Project Structure")) {
206
+ content = content.trimEnd() + `\n\n## Project Structure\n\n\`\`\`\n${name}/\n\`\`\`\n`;
207
+ changed = true;
208
+ }
209
+ if (changed) {
210
+ fs.writeFileSync(readmePath, content, "utf-8");
211
+ }
212
+ } catch {}
213
+ }
214
+
215
+ // ---- Configuration ----
216
+ // Optional `taskTracker` key in ~/.pi/agent/settings.json (env
217
+ // PI_CODING_AGENT_DIR respected). All flags default to true.
218
+ // { "taskTracker": { "todo": true, "readme": true, "autoCommit": true } }
219
+ // todo - maintain TODO.md (task entries, completion bookkeeping)
220
+ // readme - maintain README.md (structure section updates)
221
+ // autoCommit - per-task git commits; these commits are the /rollback
222
+ // checkpoints, so disabling it also disables rollback targets
223
+ interface TrackerConfig {
224
+ todo: boolean;
225
+ readme: boolean;
226
+ autoCommit: boolean;
227
+ }
228
+
229
+ function readConfig(): TrackerConfig {
230
+ try {
231
+ const dir = process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
232
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, "settings.json"), "utf-8"));
233
+ const t = raw.taskTracker && typeof raw.taskTracker === "object" ? raw.taskTracker : {};
234
+ return {
235
+ todo: t.todo !== false,
236
+ readme: t.readme !== false,
237
+ autoCommit: t.autoCommit !== false,
238
+ };
239
+ } catch {
240
+ return { todo: true, readme: true, autoCommit: true };
241
+ }
242
+ }
243
+
244
+ export default function (pi: ExtensionAPI) {
245
+ let todoPath = "";
246
+ let readmePath = "";
247
+ let cwd = "";
248
+ let config: TrackerConfig = readConfig();
249
+ let currentTask: string | null = null;
250
+ const createdFiles: string[] = [];
251
+ const editedFiles: string[] = [];
252
+ // toolCallId -> pre-run git status snapshot, so tool_result can diff to
253
+ // toolCallId -> pre-run git status snapshot per repo root (cwd + every nested
254
+ // repo discovered this session), so tool_result can diff to capture files
255
+ // created/edited by bash scripts (write/edit listeners miss these).
256
+ const beforeStatus = new Map<string, Map<string, Map<string, string>>>();
257
+ // nested repository roots (inner repos below cwd) seen so far this session
258
+ const nestedRoots = new Set<string>();
259
+ // learn the owning repo of a touched path and remember nested roots
260
+ const watchRootFor = (absPath: string) => {
261
+ const root = ownerRepoRoot(absPath, cwd);
262
+ if (root !== cwd) nestedRoots.add(root);
263
+ };
264
+ const snapshotAll = (): Map<string, Map<string, string>> => {
265
+ const m = new Map<string, Map<string, string>>();
266
+ m.set(cwd, gitStatusSet(cwd));
267
+ for (const root of nestedRoots) m.set(root, gitStatusSet(root));
268
+ return m;
269
+ };
270
+
271
+ pi.on("session_start", async (_event, ctx) => {
272
+ cwd = ctx.cwd;
273
+ config = readConfig();
274
+ todoPath = config.todo ? path.join(cwd, "TODO.md") : "";
275
+ readmePath = config.readme ? path.join(cwd, "README.md") : "";
276
+ readmePath = path.join(cwd, "README.md");
277
+ currentTask = null;
278
+ nestedRoots.clear();
279
+ createdFiles.length = 0;
280
+ editedFiles.length = 0;
281
+
282
+ if (!fs.existsSync(todoPath)) {
283
+ fs.writeFileSync(todoPath, "# TODO\n\n", "utf-8");
284
+ } else {
285
+ // try to bring an existing TODO up to standard format
286
+ normalizeTodo(todoPath);
287
+ }
288
+
289
+ if (!fs.existsSync(readmePath)) {
290
+ const name = path.basename(cwd);
291
+ fs.writeFileSync(
292
+ readmePath,
293
+ `# ${name}\n\n## Overview\n\nTODO: Add project description.\n\n## Project Structure\n\n\`\`\`\n${name}/\n\`\`\`\n`,
294
+ "utf-8"
295
+ );
296
+ } else if (readmePath) {
297
+ // try to bring an existing README up to standard format
298
+ normalizeReadme(readmePath, path.basename(cwd));
299
+ }
300
+
301
+ // bootstrap git (safe on existing repos: only sets missing config/.gitignore)
302
+ if (config.autoCommit) initGit(cwd);
303
+ });
304
+
305
+ pi.on("before_agent_start", async (event) => {
306
+ const prompt = event.prompt;
307
+ if (!prompt || !prompt.trim() || !todoPath) return;
308
+
309
+ const task = prompt.trim().replace(/\n/g, " ").slice(0, 200);
310
+ currentTask = task;
311
+
312
+ const line = `- [ ] ${task} _(${ts()})_\n`;
313
+ fs.appendFileSync(todoPath, line, "utf-8");
314
+ });
315
+
316
+ pi.on("tool_call", async (event) => {
317
+ if (!cwd) return;
318
+
319
+ if (isToolCallEventType("write", event)) {
320
+ const p = event.input.path as string;
321
+ if (p) {
322
+ const rel = path.relative(cwd, p);
323
+ if (rel && !rel.startsWith("..") && !isSubagentPath(rel)) {
324
+ watchRootFor(path.resolve(cwd, p));
325
+ if (!fs.existsSync(p)) {
326
+ createdFiles.push(rel.replace(/\\/g, "/"));
327
+ }
328
+ }
329
+ }
330
+ }
331
+
332
+ // Edit-like tools: the native "edit" tool plus pi-hashline-edit-pro's
333
+ // "replace"/"insert" (hashline replaces the native edit tool, so without
334
+ // this, hashline edits would go unrecorded and never be committed).
335
+ // Both hashline tools carry the target file in input.path; native edit
336
+ // uses filePath || path.
337
+ if (event.toolName === "edit" || event.toolName === "replace" || event.toolName === "insert") {
338
+ const p = (event.input.filePath || event.input.path) as string;
339
+ if (p) {
340
+ const rel = path.relative(cwd, p);
341
+ if (rel && !rel.startsWith("..") && !isSubagentPath(rel) && !editedFiles.includes(rel)) {
342
+ watchRootFor(path.resolve(cwd, p));
343
+ editedFiles.push(rel.replace(/\\/g, "/"));
344
+ }
345
+ }
346
+ }
347
+
348
+ // shell tools: snapshot working-tree state BEFORE execution so tool_result
349
+ // can diff. Covers bash and (on Windows) powershell.
350
+ if (isToolCallEventType("bash", event) || isToolCallEventType("powershell", event)) {
351
+ try {
352
+ beforeStatus.set(event.toolCallId, snapshotAll());
353
+ } catch {}
354
+ }
355
+ });
356
+
357
+ // After a bash command finishes, diff the working tree against the pre-run
358
+ // snapshot and record new/modified files. This captures files produced by
359
+ // scripts (node/python/shell) that the write/edit listeners cannot see.
360
+ pi.on("tool_result", async (event) => {
361
+ if (!cwd) return;
362
+ if (!isBashToolResult(event) && !isPowerShellToolResult(event)) return;
363
+ const before = beforeStatus.get(event.toolCallId);
364
+ beforeStatus.delete(event.toolCallId);
365
+ if (!before) return;
366
+ const after = snapshotAll();
367
+ for (const [root, afterSet] of after) {
368
+ const beforeSet = before.get(root) ?? new Map<string, string>();
369
+ for (const [p, status] of afterSet) {
370
+ const prev = beforeSet.get(p);
371
+ if (prev === status) continue; // unchanged by this command
372
+ if (p === "TODO.md" || p === "README.md") continue; // handled at commit time
373
+ // Normalize to a cwd-relative path. Inner-repo entries come relative
374
+ // to the inner root and need rebasing onto cwd.
375
+ let rel = p;
376
+ if (root !== cwd) {
377
+ rel = path.relative(cwd, path.join(root, p)).replace(/\\/g, "/");
378
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) continue;
379
+ }
380
+ // Guard: never track paths outside cwd (git status emits "../" paths
381
+ // when cwd is a subdirectory of a parent repo), nor subagent data.
382
+ if (p === ".." || p.startsWith("../") || path.isAbsolute(p)) continue;
383
+ if (isSubagentPath(rel)) continue; // subagent session data is never tracked
384
+ if (prev === undefined) {
385
+ // newly appeared in the working tree
386
+ if (status[0] === "?" || status[1] === "?") {
387
+ if (!createdFiles.includes(rel)) createdFiles.push(rel);
388
+ } else {
389
+ if (!editedFiles.includes(rel)) editedFiles.push(rel);
390
+ }
391
+ } else {
392
+ // status changed (e.g. clean -> modified, or modified -> deleted) => edited
393
+ if (!editedFiles.includes(rel)) editedFiles.push(rel);
394
+ }
395
+ }
396
+ }
397
+ });
398
+
399
+ // Baseline reset on /tree navigation (same-session rewind).
400
+ // pi-rewind-hook may restore files to an earlier point when the user
401
+ // navigates the session tree. File records accumulated before the
402
+ // navigation no longer reflect the worktree, so drop them instead of
403
+ // staging stale/reverted paths into the next task's commit.
404
+ // NOTE: /fork, /resume and /new switch to a different session file and
405
+ // re-fire session_start, which already resets everything — /tree is the
406
+ // only navigation that stays inside the same session.
407
+ // currentTask is kept on purpose: an in-flight task entry in TODO.md
408
+ // must still be closed out at agent_settled.
409
+ // Conservative by design: the rollback diff is NOT auto-committed here
410
+ // (it could swallow the user's own manual edits); it simply remains as
411
+ // uncommitted worktree state for the user/agent to commit deliberately.
412
+ pi.on("session_tree", async (_event, _ctx) => {
413
+ createdFiles.length = 0;
414
+ editedFiles.length = 0;
415
+ beforeStatus.clear();
416
+ });
417
+
418
+ pi.on("agent_settled", async (_event, _ctx) => {
419
+ const taskSnapshot = currentTask;
420
+ currentTask = null;
421
+ if (taskSnapshot && config.todo && todoPath && fs.existsSync(todoPath)) {
422
+ try {
423
+ const content = fs.readFileSync(todoPath, "utf-8");
424
+ const lines = content.split("\n");
425
+ for (let i = lines.length - 1; i >= 0; i--) {
426
+ if (lines[i].includes("- [ ]")) {
427
+ lines.splice(i, 1); // delete the completed TODO entry
428
+ break;
429
+ }
430
+ }
431
+ fs.writeFileSync(todoPath, lines.join("\n"), "utf-8");
432
+ } catch {}
433
+ }
434
+
435
+ if (config.readme && readmePath && fs.existsSync(readmePath)) {
436
+ try {
437
+ let content = fs.readFileSync(readmePath, "utf-8");
438
+ let changed = false;
439
+
440
+ if (createdFiles.length > 0) {
441
+ const marker = "```\n";
442
+ const structIdx = content.indexOf("## Project Structure");
443
+ if (structIdx !== -1) {
444
+ const closeIdx = content.indexOf(marker, structIdx + "## Project Structure".length);
445
+ if (closeIdx !== -1) {
446
+ const insertPos = closeIdx + marker.length;
447
+ const newLines = createdFiles
448
+ .filter((f) => !content.includes(f))
449
+ .map((f) => `├── ${f}\n`)
450
+ .join("");
451
+ if (newLines) {
452
+ content = content.slice(0, insertPos) + newLines + content.slice(insertPos);
453
+ changed = true;
454
+ }
455
+ }
456
+ } else {
457
+ // no structure section yet: add one in standard format
458
+ const section = `## Project Structure\n\n\`\`\`\n${createdFiles
459
+ .map((f) => `├── ${f}\n`)
460
+ .join("")}\`\`\`\n`;
461
+ content = content.trimEnd() + "\n\n" + section;
462
+ changed = true;
463
+ }
464
+ }
465
+
466
+ if (changed) {
467
+ fs.writeFileSync(readmePath, content, "utf-8");
468
+ }
469
+ } catch {}
470
+ }
471
+
472
+ // commit a snapshot of this task's changes (fresh and existing repos alike)
473
+ if (cwd && config.autoCommit) {
474
+ gitCommitTask(cwd, taskSnapshot, createdFiles, editedFiles);
475
+ }
476
+
477
+ createdFiles.length = 0;
478
+ editedFiles.length = 0;
479
+ beforeStatus.clear();
480
+ });
481
+
482
+ // ── /rollback: restore the working tree to a previous commit ──────────
483
+ // Task commits (one per completed task, subject = task name) double as a
484
+ // checkpoint chain. This command restores WORKING TREE files to any prior
485
+ // commit — deliberately conservative:
486
+ // - worktree only (index untouched), nothing is auto-committed;
487
+ // - the rollback itself is reversible (git diff / git checkout .);
488
+ // - untracked files are out of scope (never committed, unknown provenance).
489
+ // Replaces pi-rewind-hook for the common case at ~zero per-turn cost.
490
+ pi.registerCommand("rollback", {
491
+ description: "Roll the working tree back to a previous commit (worktree-only, nothing is committed; task commits are the checkpoints)",
492
+ handler: async (_args, ctx) => {
493
+ if (!cwd) return;
494
+ if (!fs.existsSync(path.join(cwd, ".git"))) {
495
+ ctx?.ui.notify("Not a git repository - nothing to roll back", "warning");
496
+ return;
497
+ }
498
+
499
+ // 1. list recent commits as rollback targets
500
+ const log = git(["log", "--max-count=30", "--date=format:%m-%d %H:%M", "--format=%h|%ad|%s"], cwd);
501
+ if (!log) {
502
+ ctx?.ui.notify("No commits yet - nothing to roll back", "warning");
503
+ return;
504
+ }
505
+ const entries = log.split("\n").map((l) => {
506
+ const [sha, date, ...rest] = l.split("|");
507
+ return { sha, label: `${sha} ${date} ${rest.join("|").slice(0, 58)}` };
508
+ });
509
+ if (!ctx?.hasUI) {
510
+ ctx?.ui.notify("/rollback requires interactive mode. Recent commits:\n" + entries.map((e) => e.label).join("\n"), "info");
511
+ return;
512
+ }
513
+ const chosen = await ctx.ui.select("Roll back to which commit?", entries.map((e) => e.label));
514
+ if (!chosen) return; // cancelled
515
+ const target = entries.find((e) => e.label === chosen);
516
+ if (!target) return;
517
+
518
+ // 2. what changed between target and HEAD (rename pairs split into A+D)
519
+ const diff = git(["diff", "--name-status", "--no-renames", `${target.sha}..HEAD`], cwd);
520
+ const restores: string[] = [];
521
+ const removes: string[] = [];
522
+ for (const line of diff.split("\n")) {
523
+ const tab = line.indexOf("\t");
524
+ if (tab === -1) continue;
525
+ const status = line.slice(0, tab).trim()[0];
526
+ const file = line.slice(tab + 1).trim();
527
+ if (!file || file.includes("->")) continue;
528
+ // never touch subagent session data
529
+ if (isSubagentPath(file)) continue;
530
+ if (status === "A") removes.push(file);
531
+ else if (status === "M" || status === "D") restores.push(file);
532
+ }
533
+ if (restores.length === 0 && removes.length === 0) {
534
+ ctx.ui.notify("No tracked file changes since the target commit - nothing to roll back", "info");
535
+ return;
536
+ }
537
+
538
+ // 3. confirm — warn about uncommitted changes that would be overwritten
539
+ const dirty = git(["status", "--porcelain"], cwd).split("\n").filter(Boolean).length;
540
+ const summary = `Restore ${restores.length} file(s) and remove ${removes.length} file(s), back to ${target.sha}${dirty > 0 ? ` (WARNING: ${dirty} uncommitted change(s) in the worktree - affected paths will be overwritten)` : " (clean worktree)"}`;
541
+ const ok = await ctx.ui.select(`${summary}. Proceed? (worktree only, nothing is committed)`, ["Cancel", "Roll back"]);
542
+ if (ok !== "Roll back") return;
543
+
544
+ // 4. apply (worktree only; chunked for Windows command-length limits)
545
+ for (let i = 0; i < restores.length; i += 50) {
546
+ gitOk(["restore", "--source=" + target.sha, "--worktree", "--", ...restores.slice(i, i + 50)], cwd);
547
+ }
548
+ for (const f of removes) {
549
+ try {
550
+ fs.rmSync(path.join(cwd, f), { force: true });
551
+ } catch {}
552
+ }
553
+
554
+ // 5. drop accumulated file records — they no longer reflect the worktree
555
+ createdFiles.length = 0;
556
+ editedFiles.length = 0;
557
+ beforeStatus.clear();
558
+ ctx.ui.notify(`Rolled back to ${target.sha} - worktree updated, nothing committed. Review with git diff, then commit when satisfied.`, "info");
559
+ },
560
+ });
561
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "pi-task-tracker",
3
+ "version": "0.1.1",
4
+ "description": "Task workflow tracking for the pi coding agent: TODO/README maintenance, per-task git auto-commits as checkpoints, and an interactive /rollback. Hashline-aware, nested-repo aware, subagent-artifact-safe.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "coding-agent",
9
+ "git",
10
+ "todo",
11
+ "rollback",
12
+ "checkpoint",
13
+ "auto-commit",
14
+ "workflow"
15
+ ],
16
+ "author": "Page",
17
+ "license": "MIT",
18
+ "files": [
19
+ "index.ts",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "pi": {
24
+ "extensions": [
25
+ "./index.ts"
26
+ ]
27
+ },
28
+ "engines": {
29
+ "node": ">=18"
30
+ }
31
+ }