pi-task-tracker 0.1.3 → 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 +1 -0
  2. package/index.ts +75 -46
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -14,6 +14,7 @@ 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
+ - **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,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,8 @@ 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;
240
266
  if (isSubagentPath(file)) continue;
241
267
  if (status === "A") removes.push(file);
242
268
  else if (status === "M" || status === "D") restores.push(file);
@@ -325,6 +351,7 @@ export default function (pi: ExtensionAPI) {
325
351
  let todoPath = "";
326
352
  let readmePath = "";
327
353
  let cwd = "";
354
+ let gitRoot = '';
328
355
  let config: TrackerConfig = readConfig();
329
356
  let currentTask: string | null = null;
330
357
  const createdFiles: string[] = [];
@@ -338,18 +365,22 @@ export default function (pi: ExtensionAPI) {
338
365
  const nestedRoots = new Set<string>();
339
366
  // learn the owning repo of a touched path and remember nested roots
340
367
  const watchRootFor = (absPath: string) => {
341
- const root = ownerRepoRoot(absPath, cwd);
342
- if (root !== cwd) nestedRoots.add(root);
368
+ const root = ownerRepoRoot(absPath, gitRoot);
369
+ if (root !== cwd && root !== gitRoot) nestedRoots.add(root);
343
370
  };
344
371
  const snapshotAll = (): Map<string, Map<string, string>> => {
345
372
  const m = new Map<string, Map<string, string>>();
346
- 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));
347
376
  for (const root of nestedRoots) m.set(root, gitStatusSet(root));
348
377
  return m;
349
378
  };
350
379
 
351
380
  pi.on("session_start", async (_event, ctx) => {
352
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;
353
384
  config = readConfig();
354
385
  todoPath = config.todo ? path.join(cwd, "TODO.md") : "";
355
386
  readmePath = config.readme ? path.join(cwd, "README.md") : "";
@@ -379,7 +410,7 @@ export default function (pi: ExtensionAPI) {
379
410
  }
380
411
 
381
412
  // bootstrap git (safe on existing repos: only sets missing config/.gitignore)
382
- if (config.autoCommit) initGit(cwd);
413
+ if (config.autoCommit && gitRoot === cwd) initGit(cwd);
383
414
  });
384
415
 
385
416
  pi.on("before_agent_start", async (event) => {
@@ -452,14 +483,11 @@ export default function (pi: ExtensionAPI) {
452
483
  if (p === "TODO.md" || p === "README.md") continue; // handled at commit time
453
484
  // Normalize to a cwd-relative path. Inner-repo entries come relative
454
485
  // 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;
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;
463
491
  if (isSubagentPath(rel)) continue; // subagent session data is never tracked
464
492
  if (prev === undefined) {
465
493
  // newly appeared in the working tree
@@ -551,7 +579,7 @@ export default function (pi: ExtensionAPI) {
551
579
 
552
580
  // commit a snapshot of this task's changes (fresh and existing repos alike)
553
581
  if (cwd && config.autoCommit) {
554
- gitCommitTask(cwd, taskSnapshot, createdFiles, editedFiles);
582
+ gitCommitTask(cwd, gitRoot, taskSnapshot, createdFiles, editedFiles);
555
583
  }
556
584
 
557
585
  createdFiles.length = 0;
@@ -571,13 +599,13 @@ export default function (pi: ExtensionAPI) {
571
599
  description: "Roll the working tree back to a previous commit (worktree-only, nothing is committed; task commits are the checkpoints)",
572
600
  handler: async (_args, ctx) => {
573
601
  if (!cwd) return;
574
- if (!fs.existsSync(path.join(cwd, ".git"))) {
602
+ if (!gitRoot || !fs.existsSync(path.join(gitRoot, ".git"))) {
575
603
  ctx?.ui.notify("Not a git repository - nothing to roll back", "warning");
576
604
  return;
577
605
  }
578
606
 
579
607
  // 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);
608
+ const log = git(["log", "--max-count=30", "--date=format:%m-%d %H:%M", "--format=%h|%ad|%s"], gitRoot);
581
609
  if (!log) {
582
610
  ctx?.ui.notify("No commits yet - nothing to roll back", "warning");
583
611
  return;
@@ -599,21 +627,22 @@ export default function (pi: ExtensionAPI) {
599
627
  // pairs split into A+D), plus every nested repo touched this session:
600
628
  // one task commits the same subject in each repo it touched, so nested
601
629
  // repos roll back to their own matching commit (time-aligned).
602
- const actions = repoDiffActions(cwd, target.sha);
630
+ const relCwd = path.relative(gitRoot, cwd).replace(/\\/g, "/");
631
+ const actions = repoDiffActions(gitRoot, target.sha, relCwd === "" ? undefined : relCwd);
603
632
  const innerRollbacks: { root: string; sha: string; name: string; restores: number; removes: number }[] = [];
604
633
  const innerApply: { root: string; sha: string; actions: { restores: string[]; removes: string[] } }[] = [];
605
634
  if (nestedRoots.size > 0) {
606
635
  // exact anchors recorded in the outer commit body: Nested: <rel>@<sha>
607
- const body = git(["show", "-s", "--format=%B", target.sha], cwd);
636
+ const body = git(["show", "-s", "--format=%B", target.sha], gitRoot);
608
637
  const anchored = new Map<string, string>();
609
638
  for (const line of body.split("\n")) {
610
639
  const m = /^Nested: (.+)@([0-9a-f]{7,40})$/.exec(line.trim());
611
640
  if (m) anchored.set(m[1], m[2]);
612
641
  }
613
- const subject = target.subject || git(["show", "-s", "--format=%s", target.sha], cwd);
614
- const outerDate = git(["show", "-s", "--format=%cI", target.sha], cwd);
642
+ const subject = target.subject || git(["show", "-s", "--format=%s", target.sha], gitRoot);
643
+ const outerDate = git(["show", "-s", "--format=%cI", target.sha], gitRoot);
615
644
  for (const root of nestedRoots) {
616
- const rel = path.relative(cwd, root).replace(/\\/g, "/");
645
+ const rel = path.relative(gitRoot, root).replace(/\\/g, "/");
617
646
  const innerSha = anchored.get(rel) || findInnerTarget(root, subject, outerDate);
618
647
  if (!innerSha) continue;
619
648
  const a = repoDiffActions(root, innerSha);
@@ -631,15 +660,15 @@ export default function (pi: ExtensionAPI) {
631
660
  }
632
661
 
633
662
  // 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}`];
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}`];
636
665
  for (const r of innerRollbacks) scope.push(`${r.name}: restore ${r.restores}, remove ${r.removes}`);
637
666
  const summary = `Back to ${target.sha} - ${scope.join('; ')}${dirty > 0 ? ` (WARNING: ${dirty} uncommitted change(s) - affected paths will be overwritten)` : ' (clean worktree)'}`;
638
667
  const ok = await ctx.ui.select(`${summary}. Proceed? (worktree only, nothing is committed)`, ["Cancel", "Roll back"]);
639
668
  if (ok !== "Roll back") return;
640
669
 
641
670
  // 4. apply everywhere (worktree only)
642
- applyRepoRollback(cwd, target.sha, actions);
671
+ applyRepoRollback(gitRoot, target.sha, actions);
643
672
  for (const r of innerApply) {
644
673
  applyRepoRollback(r.root, r.sha, r.actions);
645
674
  }
@@ -656,7 +685,7 @@ export default function (pi: ExtensionAPI) {
656
685
  // in the session tree — /tree can navigate back at any time.
657
686
  let conversationNote = "";
658
687
  try {
659
- const subject = git(["show", "-s", "--format=%s", target.sha], cwd);
688
+ const subject = git(["show", "-s", "--format=%s", target.sha], gitRoot);
660
689
  if (subject && ctx.sessionManager) {
661
690
  const norm = (t: string) => t.replace(/\s+/g, " ").trim();
662
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.3",
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",