pi-task-tracker 0.1.2 → 0.1.3
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 -1
- package/index.ts +109 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ 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
17
|
- **Subagent-safe** — `.pi-subagents/` session artifacts are never recorded, listed, or committed.
|
|
18
18
|
- **~zero per-turn cost** — one incremental `git add` of touched files per task; no full-worktree scans, no background snapshot daemons.
|
|
19
19
|
|
package/index.ts
CHANGED
|
@@ -151,23 +151,35 @@ function gitCommitTask(
|
|
|
151
151
|
|
|
152
152
|
const subject = task ? task.slice(0, 72) : "chore: workflow auto-commit";
|
|
153
153
|
const visible = (f: string) => !isSubagentPath(f);
|
|
154
|
-
|
|
154
|
+
// Inner repos commit first, the session repo last, so the outer commit can
|
|
155
|
+
// carry anchors ("Nested: <rel>@<sha>") pointing at the exact nested-repo
|
|
156
|
+
// commits of this task - /rollback uses them for precise restoration.
|
|
157
|
+
const innerCommits: string[] = [];
|
|
158
|
+
const orderedRoots = [...groups.keys()].filter((r) => r !== cwd);
|
|
159
|
+
orderedRoots.push(cwd);
|
|
160
|
+
for (const root of orderedRoots) {
|
|
161
|
+
const files = groups.get(root)!;
|
|
155
162
|
ensureGitIdentity(root);
|
|
156
163
|
for (const f of files) {
|
|
157
164
|
gitOk(["add", "--", f], root);
|
|
158
165
|
}
|
|
159
|
-
|
|
160
|
-
if (!
|
|
166
|
+
const staged = git(["diff", "--cached", "--name-only"], root);
|
|
167
|
+
if (!staged && !(root === cwd && innerCommits.length > 0)) continue;
|
|
161
168
|
const parts: string[] = [];
|
|
162
169
|
if (root === cwd) {
|
|
163
170
|
if (created.some(visible)) parts.push(`Created: ${created.filter(visible).join(", ")}`);
|
|
164
171
|
if (edited.some(visible)) parts.push(`Edited: ${edited.filter(visible).join(", ")}`);
|
|
172
|
+
for (const ic of innerCommits) parts.push(`Nested: ${ic}`);
|
|
165
173
|
}
|
|
166
174
|
const args = ["commit", "-m", subject];
|
|
167
175
|
if (parts.length > 0) args.push("-m", parts.join("\n"));
|
|
168
|
-
|
|
176
|
+
if (!staged) args.push("--allow-empty");
|
|
177
|
+
if (gitOk(args, root) && root !== cwd) {
|
|
178
|
+
const rel = path.relative(cwd, root).replace(/\\/g, "/");
|
|
179
|
+
innerCommits.push(`${rel}@${git(["rev-parse", "--short", "HEAD"], root)}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
169
182
|
}
|
|
170
|
-
}
|
|
171
183
|
|
|
172
184
|
// Best-effort: bring an existing TODO up to the standard format (ensure header).
|
|
173
185
|
function normalizeTodo(todoPath: string): void {
|
|
@@ -212,6 +224,60 @@ function normalizeReadme(readmePath: string, name: string): void {
|
|
|
212
224
|
} catch {}
|
|
213
225
|
}
|
|
214
226
|
|
|
227
|
+
// ---- Rollback helpers ----
|
|
228
|
+
// Compute the worktree actions needed to bring `root`'s tracked files back to
|
|
229
|
+
// `sha`: restores (M/D paths) and removes (A paths). Subagent data excluded.
|
|
230
|
+
function repoDiffActions(root: string, sha: string): { restores: string[]; removes: string[] } {
|
|
231
|
+
const diff = git(["diff", "--name-status", "--no-renames", `${sha}..HEAD`], root);
|
|
232
|
+
const restores: string[] = [];
|
|
233
|
+
const removes: string[] = [];
|
|
234
|
+
for (const line of diff.split("\n")) {
|
|
235
|
+
const tab = line.indexOf("\t");
|
|
236
|
+
if (tab === -1) continue;
|
|
237
|
+
const status = line.slice(0, tab).trim()[0];
|
|
238
|
+
const file = line.slice(tab + 1).trim();
|
|
239
|
+
if (!file || file.includes("->")) continue;
|
|
240
|
+
if (isSubagentPath(file)) continue;
|
|
241
|
+
if (status === "A") removes.push(file);
|
|
242
|
+
else if (status === "M" || status === "D") restores.push(file);
|
|
243
|
+
}
|
|
244
|
+
return { restores, removes };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Apply actions computed by repoDiffActions (worktree only; chunked for
|
|
248
|
+
// Windows command-length limits).
|
|
249
|
+
function applyRepoRollback(root: string, sha: string, actions: { restores: string[]; removes: string[] }): void {
|
|
250
|
+
for (let i = 0; i < actions.restores.length; i += 50) {
|
|
251
|
+
gitOk(["restore", "--source=" + sha, "--worktree", "--", ...actions.restores.slice(i, i + 50)], root);
|
|
252
|
+
}
|
|
253
|
+
for (const f of actions.removes) {
|
|
254
|
+
try {
|
|
255
|
+
fs.rmSync(path.join(root, f), { force: true });
|
|
256
|
+
} catch {}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Find the commit in a nested repo matching the rolled-back task. One task
|
|
261
|
+
// produces commits with the SAME subject in every repo it touched, so match
|
|
262
|
+
// by subject AND occurrence ordinal (task names may repeat over time):
|
|
263
|
+
// outerIndexNewer = how many commits with this subject are newer than the
|
|
264
|
+
// outer target; pick the same ordinal (newest-first) in the nested history.
|
|
265
|
+
function findInnerTarget(root: string, subject: string, outerDateISO: string): string | null {
|
|
266
|
+
const log = git(["log", "--max-count=200", "--format=%H|%cI|%s"], root);
|
|
267
|
+
if (!log) return null;
|
|
268
|
+
const outerDate = Date.parse(outerDateISO);
|
|
269
|
+
if (Number.isNaN(outerDate)) return null;
|
|
270
|
+
let best: { sha: string; t: number } | null = null;
|
|
271
|
+
for (const line of log.split("\n")) {
|
|
272
|
+
if (!line) continue;
|
|
273
|
+
const [sha, date, ...rest] = line.split("|");
|
|
274
|
+
if (rest.join("|") !== subject) continue;
|
|
275
|
+
const t = Date.parse(date);
|
|
276
|
+
if (Number.isNaN(t) || t > outerDate) continue;
|
|
277
|
+
if (!best || t > best.t) best = { sha, t };
|
|
278
|
+
}
|
|
279
|
+
return best ? best.sha : null;
|
|
280
|
+
}
|
|
215
281
|
// ---- Session helpers ----
|
|
216
282
|
// Flatten an AgentMessage's content to plain text (string or content blocks).
|
|
217
283
|
function messageText(m: { content?: unknown }): string {
|
|
@@ -529,40 +595,53 @@ export default function (pi: ExtensionAPI) {
|
|
|
529
595
|
const target = entries.find((e) => e.label === chosen);
|
|
530
596
|
if (!target) return;
|
|
531
597
|
|
|
532
|
-
// 2. what changed between target and HEAD
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
598
|
+
// 2. what changed between target and HEAD in the session repo (rename
|
|
599
|
+
// pairs split into A+D), plus every nested repo touched this session:
|
|
600
|
+
// one task commits the same subject in each repo it touched, so nested
|
|
601
|
+
// repos roll back to their own matching commit (time-aligned).
|
|
602
|
+
const actions = repoDiffActions(cwd, target.sha);
|
|
603
|
+
const innerRollbacks: { root: string; sha: string; name: string; restores: number; removes: number }[] = [];
|
|
604
|
+
const innerApply: { root: string; sha: string; actions: { restores: string[]; removes: string[] } }[] = [];
|
|
605
|
+
if (nestedRoots.size > 0) {
|
|
606
|
+
// exact anchors recorded in the outer commit body: Nested: <rel>@<sha>
|
|
607
|
+
const body = git(["show", "-s", "--format=%B", target.sha], cwd);
|
|
608
|
+
const anchored = new Map<string, string>();
|
|
609
|
+
for (const line of body.split("\n")) {
|
|
610
|
+
const m = /^Nested: (.+)@([0-9a-f]{7,40})$/.exec(line.trim());
|
|
611
|
+
if (m) anchored.set(m[1], m[2]);
|
|
612
|
+
}
|
|
613
|
+
const subject = target.subject || git(["show", "-s", "--format=%s", target.sha], cwd);
|
|
614
|
+
const outerDate = git(["show", "-s", "--format=%cI", target.sha], cwd);
|
|
615
|
+
for (const root of nestedRoots) {
|
|
616
|
+
const rel = path.relative(cwd, root).replace(/\\/g, "/");
|
|
617
|
+
const innerSha = anchored.get(rel) || findInnerTarget(root, subject, outerDate);
|
|
618
|
+
if (!innerSha) continue;
|
|
619
|
+
const a = repoDiffActions(root, innerSha);
|
|
620
|
+
if (a.restores.length > 0 || a.removes.length > 0) {
|
|
621
|
+
innerRollbacks.push({ root, sha: innerSha, name: path.basename(root), restores: a.restores.length, removes: a.removes.length });
|
|
622
|
+
innerApply.push({ root, sha: innerSha, actions: a });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
546
625
|
}
|
|
547
|
-
|
|
626
|
+
const totalRestores = actions.restores.length + innerRollbacks.reduce((n, r) => n + r.restores, 0);
|
|
627
|
+
const totalRemoves = actions.removes.length + innerRollbacks.reduce((n, r) => n + r.removes, 0);
|
|
628
|
+
if (totalRestores === 0 && totalRemoves === 0) {
|
|
548
629
|
ctx.ui.notify("No tracked file changes since the target commit - nothing to roll back", "info");
|
|
549
630
|
return;
|
|
550
631
|
}
|
|
551
632
|
|
|
552
|
-
// 3. confirm
|
|
633
|
+
// 3. confirm - warn about uncommitted changes that would be overwritten
|
|
553
634
|
const dirty = git(["status", "--porcelain"], cwd).split("\n").filter(Boolean).length;
|
|
554
|
-
const
|
|
635
|
+
const scope = [`main repo: restore ${actions.restores.length}, remove ${actions.removes.length}`];
|
|
636
|
+
for (const r of innerRollbacks) scope.push(`${r.name}: restore ${r.restores}, remove ${r.removes}`);
|
|
637
|
+
const summary = `Back to ${target.sha} - ${scope.join('; ')}${dirty > 0 ? ` (WARNING: ${dirty} uncommitted change(s) - affected paths will be overwritten)` : ' (clean worktree)'}`;
|
|
555
638
|
const ok = await ctx.ui.select(`${summary}. Proceed? (worktree only, nothing is committed)`, ["Cancel", "Roll back"]);
|
|
556
639
|
if (ok !== "Roll back") return;
|
|
557
640
|
|
|
558
|
-
// 4. apply (worktree only
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
for (const f of removes) {
|
|
563
|
-
try {
|
|
564
|
-
fs.rmSync(path.join(cwd, f), { force: true });
|
|
565
|
-
} catch {}
|
|
641
|
+
// 4. apply everywhere (worktree only)
|
|
642
|
+
applyRepoRollback(cwd, target.sha, actions);
|
|
643
|
+
for (const r of innerApply) {
|
|
644
|
+
applyRepoRollback(r.root, r.sha, r.actions);
|
|
566
645
|
}
|
|
567
646
|
|
|
568
647
|
// 5. drop accumulated file records — they no longer reflect the worktree
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-task-tracker",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|