pi-task-tracker 0.1.3 → 0.1.5

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 -0
  2. package/index.ts +79 -46
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -14,6 +14,8 @@ Task workflow tracking for the [pi coding agent](https://www.npmjs.com/package/@
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
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
+ - **Pre-existing double tracking respected** — if a file is already tracked by both an outer repo and a nested repo, changes go to the nested repo (innermost wins), pi never untracks the outer copy, and `/rollback` never lets the outer repo restore its stale copy over the inner worktree.
18
+ - **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
19
  - **Subagent-safe** — `.pi-subagents/` session artifacts are never recorded, listed, or committed.
18
20
  - **~zero per-turn cost** — one incremental `git add` of touched files per task; no full-worktree scans, no background snapshot daemons.
19
21
 
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,11 +164,14 @@ 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);
@@ -155,8 +179,8 @@ function gitCommitTask(
155
179
  // carry anchors ("Nested: <rel>@<sha>") pointing at the exact nested-repo
156
180
  // commits of this task - /rollback uses them for precise restoration.
157
181
  const innerCommits: string[] = [];
158
- const orderedRoots = [...groups.keys()].filter((r) => r !== cwd);
159
- orderedRoots.push(cwd);
182
+ const orderedRoots = [...groups.keys()].filter((r) => r !== gitRoot);
183
+ orderedRoots.push(gitRoot);
160
184
  for (const root of orderedRoots) {
161
185
  const files = groups.get(root)!;
162
186
  ensureGitIdentity(root);
@@ -164,9 +188,9 @@ function gitCommitTask(
164
188
  gitOk(["add", "--", f], root);
165
189
  }
166
190
  const staged = git(["diff", "--cached", "--name-only"], root);
167
- if (!staged && !(root === cwd && innerCommits.length > 0)) continue;
191
+ if (!staged && !(root === gitRoot && innerCommits.length > 0)) continue;
168
192
  const parts: string[] = [];
169
- if (root === cwd) {
193
+ if (root === gitRoot) {
170
194
  if (created.some(visible)) parts.push(`Created: ${created.filter(visible).join(", ")}`);
171
195
  if (edited.some(visible)) parts.push(`Edited: ${edited.filter(visible).join(", ")}`);
172
196
  for (const ic of innerCommits) parts.push(`Nested: ${ic}`);
@@ -174,12 +198,12 @@ function gitCommitTask(
174
198
  const args = ["commit", "-m", subject];
175
199
  if (parts.length > 0) args.push("-m", parts.join("\n"));
176
200
  if (!staged) args.push("--allow-empty");
177
- if (gitOk(args, root) && root !== cwd) {
178
- const rel = path.relative(cwd, root).replace(/\\/g, "/");
201
+ if (gitOk(args, root) && root !== gitRoot) {
202
+ const rel = path.relative(gitRoot, root).replace(/\\/g, "/");
179
203
  innerCommits.push(`${rel}@${git(["rev-parse", "--short", "HEAD"], root)}`);
180
204
  }
181
205
  }
182
- }
206
+ }
183
207
 
184
208
  // Best-effort: bring an existing TODO up to the standard format (ensure header).
185
209
  function normalizeTodo(todoPath: string): void {
@@ -227,7 +251,7 @@ function normalizeReadme(readmePath: string, name: string): void {
227
251
  // ---- Rollback helpers ----
228
252
  // Compute the worktree actions needed to bring `root`'s tracked files back to
229
253
  // `sha`: restores (M/D paths) and removes (A paths). Subagent data excluded.
230
- function repoDiffActions(root: string, sha: string): { restores: string[]; removes: string[] } {
254
+ function repoDiffActions(root: string, sha: string, subtreeRel?: string): { restores: string[]; removes: string[] } {
231
255
  const diff = git(["diff", "--name-status", "--no-renames", `${sha}..HEAD`], root);
232
256
  const restores: string[] = [];
233
257
  const removes: string[] = [];
@@ -237,6 +261,12 @@ function repoDiffActions(root: string, sha: string): { restores: string[]; remov
237
261
  const status = line.slice(0, tab).trim()[0];
238
262
  const file = line.slice(tab + 1).trim();
239
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
+ // Pre-existing double-tracked files (tracked by both this repo and a
267
+ // nested repo) stay under the nested repo's authority: this repo's
268
+ // rollback must never restore/remove them over the inner worktree.
269
+ if (ownerRepoRoot(path.join(root, file), root) !== root) continue;
240
270
  if (isSubagentPath(file)) continue;
241
271
  if (status === "A") removes.push(file);
242
272
  else if (status === "M" || status === "D") restores.push(file);
@@ -325,6 +355,7 @@ export default function (pi: ExtensionAPI) {
325
355
  let todoPath = "";
326
356
  let readmePath = "";
327
357
  let cwd = "";
358
+ let gitRoot = '';
328
359
  let config: TrackerConfig = readConfig();
329
360
  let currentTask: string | null = null;
330
361
  const createdFiles: string[] = [];
@@ -338,18 +369,22 @@ export default function (pi: ExtensionAPI) {
338
369
  const nestedRoots = new Set<string>();
339
370
  // learn the owning repo of a touched path and remember nested roots
340
371
  const watchRootFor = (absPath: string) => {
341
- const root = ownerRepoRoot(absPath, cwd);
342
- if (root !== cwd) nestedRoots.add(root);
372
+ const root = ownerRepoRoot(absPath, gitRoot);
373
+ if (root !== cwd && root !== gitRoot) nestedRoots.add(root);
343
374
  };
344
375
  const snapshotAll = (): Map<string, Map<string, string>> => {
345
376
  const m = new Map<string, Map<string, string>>();
346
- m.set(cwd, gitStatusSet(cwd));
377
+ // git 2.5x 'status --porcelain' emits repo-root-relative paths; run it at the
378
+ // repo root so the keys are unambiguous, then rebase below.
379
+ m.set(cwd, gitStatusSet(gitRoot));
347
380
  for (const root of nestedRoots) m.set(root, gitStatusSet(root));
348
381
  return m;
349
382
  };
350
383
 
351
384
  pi.on("session_start", async (_event, ctx) => {
352
385
  cwd = ctx.cwd;
386
+ // reuse an enclosing repository instead of creating one in a subdirectory
387
+ gitRoot = fs.existsSync(path.join(cwd, ".git")) ? cwd : findEnclosingRepo(cwd) || cwd;
353
388
  config = readConfig();
354
389
  todoPath = config.todo ? path.join(cwd, "TODO.md") : "";
355
390
  readmePath = config.readme ? path.join(cwd, "README.md") : "";
@@ -379,7 +414,7 @@ export default function (pi: ExtensionAPI) {
379
414
  }
380
415
 
381
416
  // bootstrap git (safe on existing repos: only sets missing config/.gitignore)
382
- if (config.autoCommit) initGit(cwd);
417
+ if (config.autoCommit && gitRoot === cwd) initGit(cwd);
383
418
  });
384
419
 
385
420
  pi.on("before_agent_start", async (event) => {
@@ -452,14 +487,11 @@ export default function (pi: ExtensionAPI) {
452
487
  if (p === "TODO.md" || p === "README.md") continue; // handled at commit time
453
488
  // Normalize to a cwd-relative path. Inner-repo entries come relative
454
489
  // to the inner root and need rebasing onto cwd.
455
- let rel = p;
456
- if (root !== cwd) {
457
- rel = path.relative(cwd, path.join(root, p)).replace(/\\/g, "/");
458
- if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) continue;
459
- }
460
- // Guard: never track paths outside cwd (git status emits "../" paths
461
- // when cwd is a subdirectory of a parent repo), nor subagent data.
462
- if (p === ".." || p.startsWith("../") || path.isAbsolute(p)) continue;
490
+ // Rebase the repo-root-relative status key onto sessionCwd; skip
491
+ // anything outside the session subtree.
492
+ const base = root === cwd ? gitRoot : root;
493
+ let rel = path.relative(cwd, path.join(base, p)).replace(/\\/g, "/");
494
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) continue;
463
495
  if (isSubagentPath(rel)) continue; // subagent session data is never tracked
464
496
  if (prev === undefined) {
465
497
  // newly appeared in the working tree
@@ -551,7 +583,7 @@ export default function (pi: ExtensionAPI) {
551
583
 
552
584
  // commit a snapshot of this task's changes (fresh and existing repos alike)
553
585
  if (cwd && config.autoCommit) {
554
- gitCommitTask(cwd, taskSnapshot, createdFiles, editedFiles);
586
+ gitCommitTask(cwd, gitRoot, taskSnapshot, createdFiles, editedFiles);
555
587
  }
556
588
 
557
589
  createdFiles.length = 0;
@@ -571,13 +603,13 @@ export default function (pi: ExtensionAPI) {
571
603
  description: "Roll the working tree back to a previous commit (worktree-only, nothing is committed; task commits are the checkpoints)",
572
604
  handler: async (_args, ctx) => {
573
605
  if (!cwd) return;
574
- if (!fs.existsSync(path.join(cwd, ".git"))) {
606
+ if (!gitRoot || !fs.existsSync(path.join(gitRoot, ".git"))) {
575
607
  ctx?.ui.notify("Not a git repository - nothing to roll back", "warning");
576
608
  return;
577
609
  }
578
610
 
579
611
  // 1. list recent commits as rollback targets
580
- const log = git(["log", "--max-count=30", "--date=format:%m-%d %H:%M", "--format=%h|%ad|%s"], cwd);
612
+ const log = git(["log", "--max-count=30", "--date=format:%m-%d %H:%M", "--format=%h|%ad|%s"], gitRoot);
581
613
  if (!log) {
582
614
  ctx?.ui.notify("No commits yet - nothing to roll back", "warning");
583
615
  return;
@@ -599,21 +631,22 @@ export default function (pi: ExtensionAPI) {
599
631
  // pairs split into A+D), plus every nested repo touched this session:
600
632
  // one task commits the same subject in each repo it touched, so nested
601
633
  // repos roll back to their own matching commit (time-aligned).
602
- const actions = repoDiffActions(cwd, target.sha);
634
+ const relCwd = path.relative(gitRoot, cwd).replace(/\\/g, "/");
635
+ const actions = repoDiffActions(gitRoot, target.sha, relCwd === "" ? undefined : relCwd);
603
636
  const innerRollbacks: { root: string; sha: string; name: string; restores: number; removes: number }[] = [];
604
637
  const innerApply: { root: string; sha: string; actions: { restores: string[]; removes: string[] } }[] = [];
605
638
  if (nestedRoots.size > 0) {
606
639
  // exact anchors recorded in the outer commit body: Nested: <rel>@<sha>
607
- const body = git(["show", "-s", "--format=%B", target.sha], cwd);
640
+ const body = git(["show", "-s", "--format=%B", target.sha], gitRoot);
608
641
  const anchored = new Map<string, string>();
609
642
  for (const line of body.split("\n")) {
610
643
  const m = /^Nested: (.+)@([0-9a-f]{7,40})$/.exec(line.trim());
611
644
  if (m) anchored.set(m[1], m[2]);
612
645
  }
613
- const subject = target.subject || git(["show", "-s", "--format=%s", target.sha], cwd);
614
- const outerDate = git(["show", "-s", "--format=%cI", target.sha], cwd);
646
+ const subject = target.subject || git(["show", "-s", "--format=%s", target.sha], gitRoot);
647
+ const outerDate = git(["show", "-s", "--format=%cI", target.sha], gitRoot);
615
648
  for (const root of nestedRoots) {
616
- const rel = path.relative(cwd, root).replace(/\\/g, "/");
649
+ const rel = path.relative(gitRoot, root).replace(/\\/g, "/");
617
650
  const innerSha = anchored.get(rel) || findInnerTarget(root, subject, outerDate);
618
651
  if (!innerSha) continue;
619
652
  const a = repoDiffActions(root, innerSha);
@@ -631,15 +664,15 @@ export default function (pi: ExtensionAPI) {
631
664
  }
632
665
 
633
666
  // 3. confirm - warn about uncommitted changes that would be overwritten
634
- const dirty = git(["status", "--porcelain"], cwd).split("\n").filter(Boolean).length;
635
- const scope = [`main repo: restore ${actions.restores.length}, remove ${actions.removes.length}`];
667
+ const dirty = git(["status", "--porcelain"], gitRoot).split("\n").filter(Boolean).length;
668
+ const scope = [`main repo${relCwd ? ` [./${relCwd}]` : ""}: restore ${actions.restores.length}, remove ${actions.removes.length}`];
636
669
  for (const r of innerRollbacks) scope.push(`${r.name}: restore ${r.restores}, remove ${r.removes}`);
637
670
  const summary = `Back to ${target.sha} - ${scope.join('; ')}${dirty > 0 ? ` (WARNING: ${dirty} uncommitted change(s) - affected paths will be overwritten)` : ' (clean worktree)'}`;
638
671
  const ok = await ctx.ui.select(`${summary}. Proceed? (worktree only, nothing is committed)`, ["Cancel", "Roll back"]);
639
672
  if (ok !== "Roll back") return;
640
673
 
641
674
  // 4. apply everywhere (worktree only)
642
- applyRepoRollback(cwd, target.sha, actions);
675
+ applyRepoRollback(gitRoot, target.sha, actions);
643
676
  for (const r of innerApply) {
644
677
  applyRepoRollback(r.root, r.sha, r.actions);
645
678
  }
@@ -656,7 +689,7 @@ export default function (pi: ExtensionAPI) {
656
689
  // in the session tree — /tree can navigate back at any time.
657
690
  let conversationNote = "";
658
691
  try {
659
- const subject = git(["show", "-s", "--format=%s", target.sha], cwd);
692
+ const subject = git(["show", "-s", "--format=%s", target.sha], gitRoot);
660
693
  if (subject && ctx.sessionManager) {
661
694
  const norm = (t: string) => t.replace(/\s+/g, " ").trim();
662
695
  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.3",
3
+ "version": "0.1.5",
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",