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.
Files changed (3) hide show
  1. package/README.md +4 -4
  2. package/index.ts +167 -31
  3. 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` 可交互式地把工作区回滚到任意历史提交(仅工作区、不自动提交、可逆)。支持 hashline 编辑工具、bash/powershell 产物捕获、嵌套仓库归属最内层、自动排除 `.pi-subagents/` 会话数据。配置项见上方 `taskTracker`。
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
- for (const [root, files] of groups) {
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
- // skip when nothing ended up staged in this repo
160
- if (!git(["diff", "--cached", "--name-only"], root)) continue;
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
- gitOk(args, root);
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 (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);
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
- if (restores.length === 0 && removes.length === 0) {
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 warn about uncommitted changes that would be overwritten
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 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)"}`;
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; 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 {}
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
- ctx.ui.notify(`Rolled back to ${target.sha} - worktree updated, nothing committed. Review with git diff, then commit when satisfied.`, "info");
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.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",