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.
- package/README.md +1 -0
- package/index.ts +75 -46
- 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
|
-
|
|
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,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,
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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(
|
|
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"],
|
|
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
|
|
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],
|
|
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],
|
|
614
|
-
const outerDate = git(["show", "-s", "--format=%cI", target.sha],
|
|
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(
|
|
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"],
|
|
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(
|
|
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],
|
|
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
|
+
"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",
|