pi-task-tracker 0.1.2 → 0.1.4

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 +2 -1
  2. package/index.ts +169 -61
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -13,7 +13,8 @@ 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
+ - **Subdirectory start** — start pi in a subdirectory of an existing repo and no fresh `.git` is created there: all session changes (including TODO.md / README.md) are recorded in the enclosing repository, and `/rollback` restores the enclosing repo scoped to the session subtree.
17
18
  - **Subagent-safe** — `.pi-subagents/` session artifacts are never recorded, listed, or committed.
18
19
  - **~zero per-turn cost** — one incremental `git add` of touched files per task; no full-worktree scans, no background snapshot daemons.
19
20
 
package/index.ts CHANGED
@@ -76,18 +76,36 @@ function isSubagentPath(rel: string): boolean {
76
76
  // repo by initGit). A .git found below cwd (nested/embedded repository) wins,
77
77
  // so changes made inside sub-repositories are attributed to the innermost
78
78
  // repo's history instead of polluting the outer one.
79
- function ownerRepoRoot(absPath: string, cwd: string): string {
79
+ // Nearest enclosing git worktree root for an absolute path, searching upward
80
+ // from the file's own directory and stopping at `stopAt` (the session's git
81
+ // root). A .git found below stopAt (nested/embedded repository) wins, so
82
+ // changes made inside sub-repositories are attributed to the innermost
83
+ // repo's history instead of polluting the outer one.
84
+ function ownerRepoRoot(absPath: string, stopAt: string): string {
80
85
  let dir = path.dirname(absPath);
81
86
  for (;;) {
82
87
  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;
88
+ const rel = path.relative(dir, stopAt);
89
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return stopAt;
85
90
  const parent = path.dirname(dir);
86
- if (parent === dir) return cwd;
91
+ if (parent === dir) return stopAt;
87
92
  dir = parent;
88
93
  }
89
94
  }
90
95
 
96
+ // If `dir` itself has no .git, find the repository that contains it (walking
97
+ // upward). Used when pi is started in a subdirectory of an existing repo:
98
+ // that repo is reused instead of creating a fresh one in the subdirectory.
99
+ function findEnclosingRepo(dir: string): string | null {
100
+ let d = dir;
101
+ for (;;) {
102
+ const parent = path.dirname(d);
103
+ if (parent === d) return null;
104
+ d = parent;
105
+ if (fs.existsSync(path.join(d, ".git"))) return d;
106
+ }
107
+ }
108
+
91
109
  function ensureGitIdentity(cwd: string): void {
92
110
  // ensure local identity so commits never fail on a fresh machine
93
111
  if (!git(["config", "user.name"], cwd)) {
@@ -103,7 +121,7 @@ const GITIGNORE_SEED =
103
121
 
104
122
  function initGit(cwd: string): void {
105
123
  // 1. init repo if absent
106
- if (!fs.existsSync(path.join(cwd, ".git"))) {
124
+ if (!fs.existsSync(path.join(cwd, ".git"))) {
107
125
  gitOk(["init"], cwd);
108
126
  }
109
127
  ensureGitIdentity(cwd);
@@ -125,7 +143,8 @@ function initGit(cwd: string): void {
125
143
  // repo wins) and never staged in the outer repo; subagent working data
126
144
  // (.pi-subagents/) is never committed at all.
127
145
  function gitCommitTask(
128
- cwd: string,
146
+ sessionCwd: string,
147
+ gitRoot: string,
129
148
  task: string | null,
130
149
  created: string[],
131
150
  edited: string[]
@@ -133,9 +152,11 @@ function gitCommitTask(
133
152
  const groups = new Map<string, Set<string>>();
134
153
  const consider = (relPath: string) => {
135
154
  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);
155
+ const abs = path.resolve(sessionCwd, relPath);
156
+ const fromSession = path.relative(sessionCwd, abs);
157
+ if (fromSession.startsWith("..") || path.isAbsolute(fromSession)) return;
158
+ // innermost repo wins; the session repo is the enclosing one (gitRoot)
159
+ const root = ownerRepoRoot(abs, gitRoot);
139
160
  const rel = path.relative(root, abs).replace(/\\/g, "/");
140
161
  if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return;
141
162
  if (!groups.has(root)) groups.set(root, new Set());
@@ -143,29 +164,44 @@ function gitCommitTask(
143
164
  };
144
165
  for (const f of created) consider(f);
145
166
  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");
167
+ // workflow trackers ride along on the session repo's commit; they live in
168
+ // sessionCwd, which may be a subdirectory of gitRoot
169
+ if (!groups.has(gitRoot)) groups.set(gitRoot, new Set());
170
+ const rootFiles = groups.get(gitRoot)!;
171
+ const relOf = (f: string) => path.relative(gitRoot, path.join(sessionCwd, f)).replace(/\\/g, "/");
172
+ rootFiles.add(relOf("TODO.md"));
173
+ rootFiles.add(relOf("README.md"));
174
+ if (gitRoot === sessionCwd) rootFiles.add(".gitignore");
151
175
 
152
176
  const subject = task ? task.slice(0, 72) : "chore: workflow auto-commit";
153
177
  const visible = (f: string) => !isSubagentPath(f);
154
- for (const [root, files] of groups) {
178
+ // Inner repos commit first, the session repo last, so the outer commit can
179
+ // carry anchors ("Nested: <rel>@<sha>") pointing at the exact nested-repo
180
+ // commits of this task - /rollback uses them for precise restoration.
181
+ const innerCommits: string[] = [];
182
+ const orderedRoots = [...groups.keys()].filter((r) => r !== gitRoot);
183
+ orderedRoots.push(gitRoot);
184
+ for (const root of orderedRoots) {
185
+ const files = groups.get(root)!;
155
186
  ensureGitIdentity(root);
156
187
  for (const f of files) {
157
188
  gitOk(["add", "--", f], root);
158
189
  }
159
- // skip when nothing ended up staged in this repo
160
- if (!git(["diff", "--cached", "--name-only"], root)) continue;
190
+ const staged = git(["diff", "--cached", "--name-only"], root);
191
+ if (!staged && !(root === gitRoot && innerCommits.length > 0)) continue;
161
192
  const parts: string[] = [];
162
- if (root === cwd) {
193
+ if (root === gitRoot) {
163
194
  if (created.some(visible)) parts.push(`Created: ${created.filter(visible).join(", ")}`);
164
195
  if (edited.some(visible)) parts.push(`Edited: ${edited.filter(visible).join(", ")}`);
196
+ for (const ic of innerCommits) parts.push(`Nested: ${ic}`);
165
197
  }
166
198
  const args = ["commit", "-m", subject];
167
199
  if (parts.length > 0) args.push("-m", parts.join("\n"));
168
- gitOk(args, root);
200
+ if (!staged) args.push("--allow-empty");
201
+ if (gitOk(args, root) && root !== gitRoot) {
202
+ const rel = path.relative(gitRoot, root).replace(/\\/g, "/");
203
+ innerCommits.push(`${rel}@${git(["rev-parse", "--short", "HEAD"], root)}`);
204
+ }
169
205
  }
170
206
  }
171
207
 
@@ -212,6 +248,62 @@ function normalizeReadme(readmePath: string, name: string): void {
212
248
  } catch {}
213
249
  }
214
250
 
251
+ // ---- Rollback helpers ----
252
+ // Compute the worktree actions needed to bring `root`'s tracked files back to
253
+ // `sha`: restores (M/D paths) and removes (A paths). Subagent data excluded.
254
+ function repoDiffActions(root: string, sha: string, subtreeRel?: string): { restores: string[]; removes: string[] } {
255
+ const diff = git(["diff", "--name-status", "--no-renames", `${sha}..HEAD`], root);
256
+ const restores: string[] = [];
257
+ const removes: string[] = [];
258
+ for (const line of diff.split("\n")) {
259
+ const tab = line.indexOf("\t");
260
+ if (tab === -1) continue;
261
+ const status = line.slice(0, tab).trim()[0];
262
+ const file = line.slice(tab + 1).trim();
263
+ if (!file || file.includes("->")) continue;
264
+ // when operating on a repo subdirectory, only touch paths inside it
265
+ if (subtreeRel !== undefined && !(file === subtreeRel || file.startsWith(subtreeRel + "/"))) continue;
266
+ if (isSubagentPath(file)) continue;
267
+ if (status === "A") removes.push(file);
268
+ else if (status === "M" || status === "D") restores.push(file);
269
+ }
270
+ return { restores, removes };
271
+ }
272
+
273
+ // Apply actions computed by repoDiffActions (worktree only; chunked for
274
+ // Windows command-length limits).
275
+ function applyRepoRollback(root: string, sha: string, actions: { restores: string[]; removes: string[] }): void {
276
+ for (let i = 0; i < actions.restores.length; i += 50) {
277
+ gitOk(["restore", "--source=" + sha, "--worktree", "--", ...actions.restores.slice(i, i + 50)], root);
278
+ }
279
+ for (const f of actions.removes) {
280
+ try {
281
+ fs.rmSync(path.join(root, f), { force: true });
282
+ } catch {}
283
+ }
284
+ }
285
+
286
+ // Find the commit in a nested repo matching the rolled-back task. One task
287
+ // produces commits with the SAME subject in every repo it touched, so match
288
+ // by subject AND occurrence ordinal (task names may repeat over time):
289
+ // outerIndexNewer = how many commits with this subject are newer than the
290
+ // outer target; pick the same ordinal (newest-first) in the nested history.
291
+ function findInnerTarget(root: string, subject: string, outerDateISO: string): string | null {
292
+ const log = git(["log", "--max-count=200", "--format=%H|%cI|%s"], root);
293
+ if (!log) return null;
294
+ const outerDate = Date.parse(outerDateISO);
295
+ if (Number.isNaN(outerDate)) return null;
296
+ let best: { sha: string; t: number } | null = null;
297
+ for (const line of log.split("\n")) {
298
+ if (!line) continue;
299
+ const [sha, date, ...rest] = line.split("|");
300
+ if (rest.join("|") !== subject) continue;
301
+ const t = Date.parse(date);
302
+ if (Number.isNaN(t) || t > outerDate) continue;
303
+ if (!best || t > best.t) best = { sha, t };
304
+ }
305
+ return best ? best.sha : null;
306
+ }
215
307
  // ---- Session helpers ----
216
308
  // Flatten an AgentMessage's content to plain text (string or content blocks).
217
309
  function messageText(m: { content?: unknown }): string {
@@ -259,6 +351,7 @@ export default function (pi: ExtensionAPI) {
259
351
  let todoPath = "";
260
352
  let readmePath = "";
261
353
  let cwd = "";
354
+ let gitRoot = '';
262
355
  let config: TrackerConfig = readConfig();
263
356
  let currentTask: string | null = null;
264
357
  const createdFiles: string[] = [];
@@ -272,18 +365,22 @@ export default function (pi: ExtensionAPI) {
272
365
  const nestedRoots = new Set<string>();
273
366
  // learn the owning repo of a touched path and remember nested roots
274
367
  const watchRootFor = (absPath: string) => {
275
- const root = ownerRepoRoot(absPath, cwd);
276
- if (root !== cwd) nestedRoots.add(root);
368
+ const root = ownerRepoRoot(absPath, gitRoot);
369
+ if (root !== cwd && root !== gitRoot) nestedRoots.add(root);
277
370
  };
278
371
  const snapshotAll = (): Map<string, Map<string, string>> => {
279
372
  const m = new Map<string, Map<string, string>>();
280
- m.set(cwd, gitStatusSet(cwd));
373
+ // git 2.5x 'status --porcelain' emits repo-root-relative paths; run it at the
374
+ // repo root so the keys are unambiguous, then rebase below.
375
+ m.set(cwd, gitStatusSet(gitRoot));
281
376
  for (const root of nestedRoots) m.set(root, gitStatusSet(root));
282
377
  return m;
283
378
  };
284
379
 
285
380
  pi.on("session_start", async (_event, ctx) => {
286
381
  cwd = ctx.cwd;
382
+ // reuse an enclosing repository instead of creating one in a subdirectory
383
+ gitRoot = fs.existsSync(path.join(cwd, ".git")) ? cwd : findEnclosingRepo(cwd) || cwd;
287
384
  config = readConfig();
288
385
  todoPath = config.todo ? path.join(cwd, "TODO.md") : "";
289
386
  readmePath = config.readme ? path.join(cwd, "README.md") : "";
@@ -313,7 +410,7 @@ export default function (pi: ExtensionAPI) {
313
410
  }
314
411
 
315
412
  // bootstrap git (safe on existing repos: only sets missing config/.gitignore)
316
- if (config.autoCommit) initGit(cwd);
413
+ if (config.autoCommit && gitRoot === cwd) initGit(cwd);
317
414
  });
318
415
 
319
416
  pi.on("before_agent_start", async (event) => {
@@ -386,14 +483,11 @@ export default function (pi: ExtensionAPI) {
386
483
  if (p === "TODO.md" || p === "README.md") continue; // handled at commit time
387
484
  // Normalize to a cwd-relative path. Inner-repo entries come relative
388
485
  // to the inner root and need rebasing onto cwd.
389
- let rel = p;
390
- if (root !== cwd) {
391
- rel = path.relative(cwd, path.join(root, p)).replace(/\\/g, "/");
392
- if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) continue;
393
- }
394
- // Guard: never track paths outside cwd (git status emits "../" paths
395
- // when cwd is a subdirectory of a parent repo), nor subagent data.
396
- if (p === ".." || p.startsWith("../") || path.isAbsolute(p)) continue;
486
+ // Rebase the repo-root-relative status key onto sessionCwd; skip
487
+ // anything outside the session subtree.
488
+ const base = root === cwd ? gitRoot : root;
489
+ let rel = path.relative(cwd, path.join(base, p)).replace(/\\/g, "/");
490
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) continue;
397
491
  if (isSubagentPath(rel)) continue; // subagent session data is never tracked
398
492
  if (prev === undefined) {
399
493
  // newly appeared in the working tree
@@ -485,7 +579,7 @@ export default function (pi: ExtensionAPI) {
485
579
 
486
580
  // commit a snapshot of this task's changes (fresh and existing repos alike)
487
581
  if (cwd && config.autoCommit) {
488
- gitCommitTask(cwd, taskSnapshot, createdFiles, editedFiles);
582
+ gitCommitTask(cwd, gitRoot, taskSnapshot, createdFiles, editedFiles);
489
583
  }
490
584
 
491
585
  createdFiles.length = 0;
@@ -505,13 +599,13 @@ export default function (pi: ExtensionAPI) {
505
599
  description: "Roll the working tree back to a previous commit (worktree-only, nothing is committed; task commits are the checkpoints)",
506
600
  handler: async (_args, ctx) => {
507
601
  if (!cwd) return;
508
- if (!fs.existsSync(path.join(cwd, ".git"))) {
602
+ if (!gitRoot || !fs.existsSync(path.join(gitRoot, ".git"))) {
509
603
  ctx?.ui.notify("Not a git repository - nothing to roll back", "warning");
510
604
  return;
511
605
  }
512
606
 
513
607
  // 1. list recent commits as rollback targets
514
- const log = git(["log", "--max-count=30", "--date=format:%m-%d %H:%M", "--format=%h|%ad|%s"], cwd);
608
+ const log = git(["log", "--max-count=30", "--date=format:%m-%d %H:%M", "--format=%h|%ad|%s"], gitRoot);
515
609
  if (!log) {
516
610
  ctx?.ui.notify("No commits yet - nothing to roll back", "warning");
517
611
  return;
@@ -529,40 +623,54 @@ export default function (pi: ExtensionAPI) {
529
623
  const target = entries.find((e) => e.label === chosen);
530
624
  if (!target) return;
531
625
 
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);
626
+ // 2. what changed between target and HEAD in the session repo (rename
627
+ // pairs split into A+D), plus every nested repo touched this session:
628
+ // one task commits the same subject in each repo it touched, so nested
629
+ // repos roll back to their own matching commit (time-aligned).
630
+ const relCwd = path.relative(gitRoot, cwd).replace(/\\/g, "/");
631
+ const actions = repoDiffActions(gitRoot, target.sha, relCwd === "" ? undefined : relCwd);
632
+ const innerRollbacks: { root: string; sha: string; name: string; restores: number; removes: number }[] = [];
633
+ const innerApply: { root: string; sha: string; actions: { restores: string[]; removes: string[] } }[] = [];
634
+ if (nestedRoots.size > 0) {
635
+ // exact anchors recorded in the outer commit body: Nested: <rel>@<sha>
636
+ const body = git(["show", "-s", "--format=%B", target.sha], gitRoot);
637
+ const anchored = new Map<string, string>();
638
+ for (const line of body.split("\n")) {
639
+ const m = /^Nested: (.+)@([0-9a-f]{7,40})$/.exec(line.trim());
640
+ if (m) anchored.set(m[1], m[2]);
641
+ }
642
+ const subject = target.subject || git(["show", "-s", "--format=%s", target.sha], gitRoot);
643
+ const outerDate = git(["show", "-s", "--format=%cI", target.sha], gitRoot);
644
+ for (const root of nestedRoots) {
645
+ const rel = path.relative(gitRoot, root).replace(/\\/g, "/");
646
+ const innerSha = anchored.get(rel) || findInnerTarget(root, subject, outerDate);
647
+ if (!innerSha) continue;
648
+ const a = repoDiffActions(root, innerSha);
649
+ if (a.restores.length > 0 || a.removes.length > 0) {
650
+ innerRollbacks.push({ root, sha: innerSha, name: path.basename(root), restores: a.restores.length, removes: a.removes.length });
651
+ innerApply.push({ root, sha: innerSha, actions: a });
652
+ }
653
+ }
546
654
  }
547
- if (restores.length === 0 && removes.length === 0) {
655
+ const totalRestores = actions.restores.length + innerRollbacks.reduce((n, r) => n + r.restores, 0);
656
+ const totalRemoves = actions.removes.length + innerRollbacks.reduce((n, r) => n + r.removes, 0);
657
+ if (totalRestores === 0 && totalRemoves === 0) {
548
658
  ctx.ui.notify("No tracked file changes since the target commit - nothing to roll back", "info");
549
659
  return;
550
660
  }
551
661
 
552
- // 3. confirm warn about uncommitted changes that would be overwritten
553
- 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)"}`;
662
+ // 3. confirm - warn about uncommitted changes that would be overwritten
663
+ const dirty = git(["status", "--porcelain"], gitRoot).split("\n").filter(Boolean).length;
664
+ const scope = [`main repo${relCwd ? ` [./${relCwd}]` : ""}: restore ${actions.restores.length}, remove ${actions.removes.length}`];
665
+ for (const r of innerRollbacks) scope.push(`${r.name}: restore ${r.restores}, remove ${r.removes}`);
666
+ const summary = `Back to ${target.sha} - ${scope.join('; ')}${dirty > 0 ? ` (WARNING: ${dirty} uncommitted change(s) - affected paths will be overwritten)` : ' (clean worktree)'}`;
555
667
  const ok = await ctx.ui.select(`${summary}. Proceed? (worktree only, nothing is committed)`, ["Cancel", "Roll back"]);
556
668
  if (ok !== "Roll back") return;
557
669
 
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 {}
670
+ // 4. apply everywhere (worktree only)
671
+ applyRepoRollback(gitRoot, target.sha, actions);
672
+ for (const r of innerApply) {
673
+ applyRepoRollback(r.root, r.sha, r.actions);
566
674
  }
567
675
 
568
676
  // 5. drop accumulated file records — they no longer reflect the worktree
@@ -577,7 +685,7 @@ export default function (pi: ExtensionAPI) {
577
685
  // in the session tree — /tree can navigate back at any time.
578
686
  let conversationNote = "";
579
687
  try {
580
- const subject = git(["show", "-s", "--format=%s", target.sha], cwd);
688
+ const subject = git(["show", "-s", "--format=%s", target.sha], gitRoot);
581
689
  if (subject && ctx.sessionManager) {
582
690
  const norm = (t: string) => t.replace(/\s+/g, " ").trim();
583
691
  const branch = ctx.sessionManager.getBranch();
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.4",
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",