pi-task-tracker 0.1.2 → 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 +1 -1
  2. package/index.ts +109 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -13,7 +13,7 @@ Task workflow tracking for the [pi coding agent](https://www.npmjs.com/package/@
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
 
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,60 @@ 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
+ }
215
281
  // ---- Session helpers ----
216
282
  // Flatten an AgentMessage's content to plain text (string or content blocks).
217
283
  function messageText(m: { content?: unknown }): string {
@@ -529,40 +595,53 @@ export default function (pi: ExtensionAPI) {
529
595
  const target = entries.find((e) => e.label === chosen);
530
596
  if (!target) return;
531
597
 
532
- // 2. what changed between target and HEAD (rename pairs split into A+D)
533
- const diff = git(["diff", "--name-status", "--no-renames", `${target.sha}..HEAD`], cwd);
534
- const restores: string[] = [];
535
- const removes: string[] = [];
536
- for (const line of diff.split("\n")) {
537
- const tab = line.indexOf("\t");
538
- if (tab === -1) continue;
539
- const status = line.slice(0, tab).trim()[0];
540
- const file = line.slice(tab + 1).trim();
541
- if (!file || file.includes("->")) continue;
542
- // never touch subagent session data
543
- if (isSubagentPath(file)) continue;
544
- if (status === "A") removes.push(file);
545
- 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
+ }
546
625
  }
547
- 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) {
548
629
  ctx.ui.notify("No tracked file changes since the target commit - nothing to roll back", "info");
549
630
  return;
550
631
  }
551
632
 
552
- // 3. confirm warn about uncommitted changes that would be overwritten
633
+ // 3. confirm - warn about uncommitted changes that would be overwritten
553
634
  const dirty = git(["status", "--porcelain"], cwd).split("\n").filter(Boolean).length;
554
- 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)'}`;
555
638
  const ok = await ctx.ui.select(`${summary}. Proceed? (worktree only, nothing is committed)`, ["Cancel", "Roll back"]);
556
639
  if (ok !== "Roll back") return;
557
640
 
558
- // 4. apply (worktree only; chunked for Windows command-length limits)
559
- for (let i = 0; i < restores.length; i += 50) {
560
- gitOk(["restore", "--source=" + target.sha, "--worktree", "--", ...restores.slice(i, i + 50)], cwd);
561
- }
562
- for (const f of removes) {
563
- try {
564
- fs.rmSync(path.join(cwd, f), { force: true });
565
- } 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);
566
645
  }
567
646
 
568
647
  // 5. drop accumulated file records — they no longer reflect the worktree
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-task-tracker",
3
- "version": "0.1.2",
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",