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.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/index.ts +561 -0
- 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
|
+
}
|