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.
- package/README.md +2 -0
- package/index.ts +79 -46
- 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
|
-
|
|
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,
|
|
84
|
-
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
rootFiles.
|
|
150
|
-
|
|
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 !==
|
|
159
|
-
orderedRoots.push(
|
|
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 ===
|
|
191
|
+
if (!staged && !(root === gitRoot && innerCommits.length > 0)) continue;
|
|
168
192
|
const parts: string[] = [];
|
|
169
|
-
if (root ===
|
|
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 !==
|
|
178
|
-
const rel = path.relative(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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(
|
|
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"],
|
|
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
|
|
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],
|
|
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],
|
|
614
|
-
const outerDate = git(["show", "-s", "--format=%cI", target.sha],
|
|
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(
|
|
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"],
|
|
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(
|
|
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],
|
|
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
|
+
"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",
|