pi-task-tracker 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/index.ts +167 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,13 +7,13 @@ Task workflow tracking for the [pi coding agent](https://www.npmjs.com/package/@
|
|
|
7
7
|
- **TODO.md tracking** — each prompt is recorded as a task entry in `TODO.md` and checked off when the agent settles.
|
|
8
8
|
- **README.md maintenance** — the project structure section stays up to date with agent-created files.
|
|
9
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).
|
|
10
|
+
- **`/rollback`** — pick any prior commit interactively and the working tree is restored to that point **while the conversation rewinds to the turn that produced it** (one checkpoint for files *and* context). Worktree-only, nothing is committed, fully reversible (`git diff` to review, commit when satisfied). Old session branches are preserved — `/tree` can navigate back.
|
|
11
11
|
|
|
12
12
|
### What makes it different
|
|
13
13
|
|
|
14
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
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.
|
|
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. The outer task commit records anchors (`Nested: <repo>@<sha>`), so `/rollback` restores nested repos to their exact matching commit.
|
|
17
17
|
- **Subagent-safe** — `.pi-subagents/` session artifacts are never recorded, listed, or committed.
|
|
18
18
|
- **~zero per-turn cost** — one incremental `git add` of touched files per task; no full-worktree scans, no background snapshot daemons.
|
|
19
19
|
|
|
@@ -26,7 +26,7 @@ Task workflow tracking for the [pi coding agent](https://www.npmjs.com/package/@
|
|
|
26
26
|
→ confirm → working tree restored to that commit
|
|
27
27
|
```
|
|
28
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).
|
|
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). Conversation rewind reuses pi's session-tree navigation, so nothing is ever deleted from the session file.
|
|
30
30
|
|
|
31
31
|
## Configuration
|
|
32
32
|
|
|
@@ -60,7 +60,7 @@ Optional `taskTracker` key in `~/.pi/agent/settings.json` (all default to `true`
|
|
|
60
60
|
|
|
61
61
|
## 本简介(中文)
|
|
62
62
|
|
|
63
|
-
`pi-task-tracker` 为 pi coding agent 提供任务级工作流跟踪:每次任务完成时,agent 实际改动的文件被自动提交到你的分支(提交信息即任务名),这些提交同时构成检查点;`/rollback`
|
|
63
|
+
`pi-task-tracker` 为 pi coding agent 提供任务级工作流跟踪:每次任务完成时,agent 实际改动的文件被自动提交到你的分支(提交信息即任务名),这些提交同时构成检查点;`/rollback` 可交互式地把工作区回滚到任意历史提交,**同时把对话上下文回退到产生该提交的任务轮次**(文件与上下文同一检查点;仅工作区、不自动提交、可逆,旧会话分支保留可 /tree 回访)。支持 hashline 编辑工具、bash/powershell 产物捕获、嵌套仓库归属最内层、自动排除 `.pi-subagents/` 会话数据。配置项见上方 `taskTracker`。
|
|
64
64
|
|
|
65
65
|
## License
|
|
66
66
|
|
package/index.ts
CHANGED
|
@@ -151,23 +151,35 @@ function gitCommitTask(
|
|
|
151
151
|
|
|
152
152
|
const subject = task ? task.slice(0, 72) : "chore: workflow auto-commit";
|
|
153
153
|
const visible = (f: string) => !isSubagentPath(f);
|
|
154
|
-
|
|
154
|
+
// Inner repos commit first, the session repo last, so the outer commit can
|
|
155
|
+
// carry anchors ("Nested: <rel>@<sha>") pointing at the exact nested-repo
|
|
156
|
+
// commits of this task - /rollback uses them for precise restoration.
|
|
157
|
+
const innerCommits: string[] = [];
|
|
158
|
+
const orderedRoots = [...groups.keys()].filter((r) => r !== cwd);
|
|
159
|
+
orderedRoots.push(cwd);
|
|
160
|
+
for (const root of orderedRoots) {
|
|
161
|
+
const files = groups.get(root)!;
|
|
155
162
|
ensureGitIdentity(root);
|
|
156
163
|
for (const f of files) {
|
|
157
164
|
gitOk(["add", "--", f], root);
|
|
158
165
|
}
|
|
159
|
-
|
|
160
|
-
if (!
|
|
166
|
+
const staged = git(["diff", "--cached", "--name-only"], root);
|
|
167
|
+
if (!staged && !(root === cwd && innerCommits.length > 0)) continue;
|
|
161
168
|
const parts: string[] = [];
|
|
162
169
|
if (root === cwd) {
|
|
163
170
|
if (created.some(visible)) parts.push(`Created: ${created.filter(visible).join(", ")}`);
|
|
164
171
|
if (edited.some(visible)) parts.push(`Edited: ${edited.filter(visible).join(", ")}`);
|
|
172
|
+
for (const ic of innerCommits) parts.push(`Nested: ${ic}`);
|
|
165
173
|
}
|
|
166
174
|
const args = ["commit", "-m", subject];
|
|
167
175
|
if (parts.length > 0) args.push("-m", parts.join("\n"));
|
|
168
|
-
|
|
176
|
+
if (!staged) args.push("--allow-empty");
|
|
177
|
+
if (gitOk(args, root) && root !== cwd) {
|
|
178
|
+
const rel = path.relative(cwd, root).replace(/\\/g, "/");
|
|
179
|
+
innerCommits.push(`${rel}@${git(["rev-parse", "--short", "HEAD"], root)}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
169
182
|
}
|
|
170
|
-
}
|
|
171
183
|
|
|
172
184
|
// Best-effort: bring an existing TODO up to the standard format (ensure header).
|
|
173
185
|
function normalizeTodo(todoPath: string): void {
|
|
@@ -212,6 +224,74 @@ function normalizeReadme(readmePath: string, name: string): void {
|
|
|
212
224
|
} catch {}
|
|
213
225
|
}
|
|
214
226
|
|
|
227
|
+
// ---- Rollback helpers ----
|
|
228
|
+
// Compute the worktree actions needed to bring `root`'s tracked files back to
|
|
229
|
+
// `sha`: restores (M/D paths) and removes (A paths). Subagent data excluded.
|
|
230
|
+
function repoDiffActions(root: string, sha: string): { restores: string[]; removes: string[] } {
|
|
231
|
+
const diff = git(["diff", "--name-status", "--no-renames", `${sha}..HEAD`], root);
|
|
232
|
+
const restores: string[] = [];
|
|
233
|
+
const removes: string[] = [];
|
|
234
|
+
for (const line of diff.split("\n")) {
|
|
235
|
+
const tab = line.indexOf("\t");
|
|
236
|
+
if (tab === -1) continue;
|
|
237
|
+
const status = line.slice(0, tab).trim()[0];
|
|
238
|
+
const file = line.slice(tab + 1).trim();
|
|
239
|
+
if (!file || file.includes("->")) continue;
|
|
240
|
+
if (isSubagentPath(file)) continue;
|
|
241
|
+
if (status === "A") removes.push(file);
|
|
242
|
+
else if (status === "M" || status === "D") restores.push(file);
|
|
243
|
+
}
|
|
244
|
+
return { restores, removes };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Apply actions computed by repoDiffActions (worktree only; chunked for
|
|
248
|
+
// Windows command-length limits).
|
|
249
|
+
function applyRepoRollback(root: string, sha: string, actions: { restores: string[]; removes: string[] }): void {
|
|
250
|
+
for (let i = 0; i < actions.restores.length; i += 50) {
|
|
251
|
+
gitOk(["restore", "--source=" + sha, "--worktree", "--", ...actions.restores.slice(i, i + 50)], root);
|
|
252
|
+
}
|
|
253
|
+
for (const f of actions.removes) {
|
|
254
|
+
try {
|
|
255
|
+
fs.rmSync(path.join(root, f), { force: true });
|
|
256
|
+
} catch {}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Find the commit in a nested repo matching the rolled-back task. One task
|
|
261
|
+
// produces commits with the SAME subject in every repo it touched, so match
|
|
262
|
+
// by subject AND occurrence ordinal (task names may repeat over time):
|
|
263
|
+
// outerIndexNewer = how many commits with this subject are newer than the
|
|
264
|
+
// outer target; pick the same ordinal (newest-first) in the nested history.
|
|
265
|
+
function findInnerTarget(root: string, subject: string, outerDateISO: string): string | null {
|
|
266
|
+
const log = git(["log", "--max-count=200", "--format=%H|%cI|%s"], root);
|
|
267
|
+
if (!log) return null;
|
|
268
|
+
const outerDate = Date.parse(outerDateISO);
|
|
269
|
+
if (Number.isNaN(outerDate)) return null;
|
|
270
|
+
let best: { sha: string; t: number } | null = null;
|
|
271
|
+
for (const line of log.split("\n")) {
|
|
272
|
+
if (!line) continue;
|
|
273
|
+
const [sha, date, ...rest] = line.split("|");
|
|
274
|
+
if (rest.join("|") !== subject) continue;
|
|
275
|
+
const t = Date.parse(date);
|
|
276
|
+
if (Number.isNaN(t) || t > outerDate) continue;
|
|
277
|
+
if (!best || t > best.t) best = { sha, t };
|
|
278
|
+
}
|
|
279
|
+
return best ? best.sha : null;
|
|
280
|
+
}
|
|
281
|
+
// ---- Session helpers ----
|
|
282
|
+
// Flatten an AgentMessage's content to plain text (string or content blocks).
|
|
283
|
+
function messageText(m: { content?: unknown }): string {
|
|
284
|
+
const c = (m as { content?: unknown }).content;
|
|
285
|
+
if (typeof c === "string") return c;
|
|
286
|
+
if (Array.isArray(c)) {
|
|
287
|
+
return c
|
|
288
|
+
.filter((b) => b && typeof b === "object" && (b as { type?: string }).type === "text")
|
|
289
|
+
.map((b) => String((b as { text?: string }).text ?? ""))
|
|
290
|
+
.join(" ");
|
|
291
|
+
}
|
|
292
|
+
return "";
|
|
293
|
+
}
|
|
294
|
+
|
|
215
295
|
// ---- Configuration ----
|
|
216
296
|
// Optional `taskTracker` key in ~/.pi/agent/settings.json (env
|
|
217
297
|
// PI_CODING_AGENT_DIR respected). All flags default to true.
|
|
@@ -515,47 +595,103 @@ export default function (pi: ExtensionAPI) {
|
|
|
515
595
|
const target = entries.find((e) => e.label === chosen);
|
|
516
596
|
if (!target) return;
|
|
517
597
|
|
|
518
|
-
// 2. what changed between target and HEAD
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
598
|
+
// 2. what changed between target and HEAD in the session repo (rename
|
|
599
|
+
// pairs split into A+D), plus every nested repo touched this session:
|
|
600
|
+
// one task commits the same subject in each repo it touched, so nested
|
|
601
|
+
// repos roll back to their own matching commit (time-aligned).
|
|
602
|
+
const actions = repoDiffActions(cwd, target.sha);
|
|
603
|
+
const innerRollbacks: { root: string; sha: string; name: string; restores: number; removes: number }[] = [];
|
|
604
|
+
const innerApply: { root: string; sha: string; actions: { restores: string[]; removes: string[] } }[] = [];
|
|
605
|
+
if (nestedRoots.size > 0) {
|
|
606
|
+
// exact anchors recorded in the outer commit body: Nested: <rel>@<sha>
|
|
607
|
+
const body = git(["show", "-s", "--format=%B", target.sha], cwd);
|
|
608
|
+
const anchored = new Map<string, string>();
|
|
609
|
+
for (const line of body.split("\n")) {
|
|
610
|
+
const m = /^Nested: (.+)@([0-9a-f]{7,40})$/.exec(line.trim());
|
|
611
|
+
if (m) anchored.set(m[1], m[2]);
|
|
612
|
+
}
|
|
613
|
+
const subject = target.subject || git(["show", "-s", "--format=%s", target.sha], cwd);
|
|
614
|
+
const outerDate = git(["show", "-s", "--format=%cI", target.sha], cwd);
|
|
615
|
+
for (const root of nestedRoots) {
|
|
616
|
+
const rel = path.relative(cwd, root).replace(/\\/g, "/");
|
|
617
|
+
const innerSha = anchored.get(rel) || findInnerTarget(root, subject, outerDate);
|
|
618
|
+
if (!innerSha) continue;
|
|
619
|
+
const a = repoDiffActions(root, innerSha);
|
|
620
|
+
if (a.restores.length > 0 || a.removes.length > 0) {
|
|
621
|
+
innerRollbacks.push({ root, sha: innerSha, name: path.basename(root), restores: a.restores.length, removes: a.removes.length });
|
|
622
|
+
innerApply.push({ root, sha: innerSha, actions: a });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
532
625
|
}
|
|
533
|
-
|
|
626
|
+
const totalRestores = actions.restores.length + innerRollbacks.reduce((n, r) => n + r.restores, 0);
|
|
627
|
+
const totalRemoves = actions.removes.length + innerRollbacks.reduce((n, r) => n + r.removes, 0);
|
|
628
|
+
if (totalRestores === 0 && totalRemoves === 0) {
|
|
534
629
|
ctx.ui.notify("No tracked file changes since the target commit - nothing to roll back", "info");
|
|
535
630
|
return;
|
|
536
631
|
}
|
|
537
632
|
|
|
538
|
-
// 3. confirm
|
|
633
|
+
// 3. confirm - warn about uncommitted changes that would be overwritten
|
|
539
634
|
const dirty = git(["status", "--porcelain"], cwd).split("\n").filter(Boolean).length;
|
|
540
|
-
const
|
|
635
|
+
const scope = [`main repo: restore ${actions.restores.length}, remove ${actions.removes.length}`];
|
|
636
|
+
for (const r of innerRollbacks) scope.push(`${r.name}: restore ${r.restores}, remove ${r.removes}`);
|
|
637
|
+
const summary = `Back to ${target.sha} - ${scope.join('; ')}${dirty > 0 ? ` (WARNING: ${dirty} uncommitted change(s) - affected paths will be overwritten)` : ' (clean worktree)'}`;
|
|
541
638
|
const ok = await ctx.ui.select(`${summary}. Proceed? (worktree only, nothing is committed)`, ["Cancel", "Roll back"]);
|
|
542
639
|
if (ok !== "Roll back") return;
|
|
543
640
|
|
|
544
|
-
// 4. apply (worktree only
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
for (const f of removes) {
|
|
549
|
-
try {
|
|
550
|
-
fs.rmSync(path.join(cwd, f), { force: true });
|
|
551
|
-
} catch {}
|
|
641
|
+
// 4. apply everywhere (worktree only)
|
|
642
|
+
applyRepoRollback(cwd, target.sha, actions);
|
|
643
|
+
for (const r of innerApply) {
|
|
644
|
+
applyRepoRollback(r.root, r.sha, r.actions);
|
|
552
645
|
}
|
|
553
646
|
|
|
554
647
|
// 5. drop accumulated file records — they no longer reflect the worktree
|
|
555
648
|
createdFiles.length = 0;
|
|
556
649
|
editedFiles.length = 0;
|
|
557
650
|
beforeStatus.clear();
|
|
558
|
-
|
|
651
|
+
|
|
652
|
+
// 6. rewind the conversation to the turn that produced this commit, so
|
|
653
|
+
// context and files land on the same checkpoint. The task commit's
|
|
654
|
+
// subject is the (normalized) user prompt, so match the last user
|
|
655
|
+
// message starting with it, then the end of that turn. Old entries stay
|
|
656
|
+
// in the session tree — /tree can navigate back at any time.
|
|
657
|
+
let conversationNote = "";
|
|
658
|
+
try {
|
|
659
|
+
const subject = git(["show", "-s", "--format=%s", target.sha], cwd);
|
|
660
|
+
if (subject && ctx.sessionManager) {
|
|
661
|
+
const norm = (t: string) => t.replace(/\s+/g, " ").trim();
|
|
662
|
+
const branch = ctx.sessionManager.getBranch();
|
|
663
|
+
let userIdx = -1;
|
|
664
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
665
|
+
const e = branch[i] as { type?: string; message?: { role?: string; content?: unknown } };
|
|
666
|
+
if (e.type !== "message" || e.message?.role !== "user") continue;
|
|
667
|
+
if (norm(messageText(e.message as { content?: unknown })).startsWith(norm(subject))) {
|
|
668
|
+
userIdx = i;
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (userIdx !== -1) {
|
|
673
|
+
// end of that turn = last entry before the next user message
|
|
674
|
+
let end = userIdx;
|
|
675
|
+
for (let j = userIdx + 1; j < branch.length; j++) {
|
|
676
|
+
const e = branch[j] as { type?: string; message?: { role?: string } };
|
|
677
|
+
if (e.type === "message" && e.message?.role === "user") break;
|
|
678
|
+
end = j;
|
|
679
|
+
}
|
|
680
|
+
const targetId = branch[end].id;
|
|
681
|
+
const nav = ctx.navigateTree
|
|
682
|
+
? await ctx.navigateTree(targetId, { label: `rollback → ${target.sha}` })
|
|
683
|
+
: { cancelled: false };
|
|
684
|
+
if (!nav.cancelled) {
|
|
685
|
+
conversationNote = " Conversation rewound to that point (/tree can navigate back).";
|
|
686
|
+
}
|
|
687
|
+
} else {
|
|
688
|
+
conversationNote = " (No matching conversation entry found - context unchanged.)";
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
} catch {
|
|
692
|
+
conversationNote = " (Conversation rewind failed - files were rolled back.)";
|
|
693
|
+
}
|
|
694
|
+
ctx.ui.notify(`Rolled back to ${target.sha} - worktree updated, nothing committed. Review with git diff, then commit when satisfied.${conversationNote}`, "info");
|
|
559
695
|
},
|
|
560
696
|
});
|
|
561
697
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-task-tracker",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|