pi-task-tracker 0.1.8 → 0.2.0
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 +12 -0
- package/index.ts +245 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ Task workflow tracking for the [pi coding agent](https://www.npmjs.com/package/@
|
|
|
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
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
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.
|
|
19
|
+
- **Scope guard** — the writable scope equals the trackable scope: out-of-session writes are blocked by default (`taskTracker.externalWrite`), so nothing can be changed that `/rollback` could not restore. Git rules are inherited from `pi-permission-system`.
|
|
19
20
|
- **Subagent-safe** — `.pi-subagents/` session artifacts are never recorded, listed, or committed.
|
|
20
21
|
|
|
21
22
|
## Lightweight by design (measured, not claimed)
|
|
@@ -74,6 +75,7 @@ Optional `taskTracker` key in `~/.pi/agent/settings.json` (all default to `true`
|
|
|
74
75
|
- `todo` — maintain `TODO.md`
|
|
75
76
|
- `readme` — maintain the `README.md` project-structure section
|
|
76
77
|
- `autoCommit` — per-task commits; these are the `/rollback` checkpoints, so disabling this also disables rollback targets
|
|
78
|
+
- `externalWrite` — policy for tool calls that would create/modify files OUTSIDE the session directory: `"block"` (default, hard deny — external changes cannot be tracked or rolled back), `"ask"` (per-attempt confirmation), `"off"` (no enforcement; `pi-permission-system` rules still apply). Reads stay free. Copying files FROM outside INTO the session is allowed (`cp` checks only its destination); moving/deleting external paths is blocked. Git history protection is **inherited**: history-rewriting git (rebase/reset/amend/force-push/clean/...) prompts for confirmation right here, so `pi-permission-system` can be uninstalled without losing that layer; routine git passes untouched, an external `git init`/`clone` is allowed with a heads-up that the new repo is not tracked. Shell commands are analyzed statically (redirects, `rm/mv/cp/touch/...`, `cd` tracking, PowerShell equivalents); git protection is inherited from `pi-permission-system` — routine git passes untouched and history-rewriting git still prompts there; only redirect targets and `-C` destinations on git segments are checked.
|
|
77
79
|
|
|
78
80
|
## Requirements
|
|
79
81
|
|
|
@@ -95,3 +97,13 @@ Optional `taskTracker` key in `~/.pi/agent/settings.json` (all default to `true`
|
|
|
95
97
|
## License
|
|
96
98
|
|
|
97
99
|
MIT
|
|
100
|
+
|
|
101
|
+
## Overview
|
|
102
|
+
|
|
103
|
+
TODO: Add project description.
|
|
104
|
+
|
|
105
|
+
## Project Structure
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
pi-task-tracker/
|
|
109
|
+
```
|
package/index.ts
CHANGED
|
@@ -106,6 +106,233 @@ function findEnclosingRepo(dir: string): string | null {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
|
|
110
|
+
// ---- External-write guard ----
|
|
111
|
+
// Enforces the tracking boundary as a write boundary: pi-task-tracker can
|
|
112
|
+
// only track (and therefore only roll back) the session directory, so by
|
|
113
|
+
// default (config taskTracker.externalWrite="block") tool calls that would
|
|
114
|
+
// create/modify files OUTSIDE it are denied. Reads stay free. Git command
|
|
115
|
+
// protection is inherited from pi-permission-system's bash rules (rebase/
|
|
116
|
+
// reset/amend/force-push/... ask there): this guard never inspects git
|
|
117
|
+
// arguments, only redirect targets and -C destinations on git segments.
|
|
118
|
+
|
|
119
|
+
const GUARD_WRITE_TOOLS = new Set(["write", "edit", "replace", "insert", "undo_last_change"]);
|
|
120
|
+
const GUARD_READ_TOOLS = new Set(["read", "grep", "find", "ls", "anchor_grep", "glob"]);
|
|
121
|
+
const GUARD_SHELL_WRITE_CMDS = new Set([
|
|
122
|
+
"rm", "mv", "touch", "mkdir", "rmdir", "tee", "truncate", "dd",
|
|
123
|
+
"patch", "chmod", "chmod", "chown", "sed",
|
|
124
|
+
]);
|
|
125
|
+
// copy-like commands only WRITE their destination (last path argument);
|
|
126
|
+
// the source is a read, so `cp /external/file ./` stays allowed
|
|
127
|
+
const GUARD_COPY_CMDS = new Set(["cp", "install", "ln", "rsync"]);
|
|
128
|
+
// interpreters: their -c/script payload can write anywhere; if the command
|
|
129
|
+
// text mentions an external absolute path we block, otherwise we cannot
|
|
130
|
+
// judge script contents (documented limitation)
|
|
131
|
+
const GUARD_INTERPRETERS = new Set(["python", "python3", "py", "node", "deno", "bun", "ruby", "perl", "php"]);
|
|
132
|
+
// inherited verbatim from pi-permissions.jsonc (pi-permission-system):
|
|
133
|
+
// git operations that DELETE or REWRITE history require user confirmation.
|
|
134
|
+
const GUARD_GIT_ASK = [
|
|
135
|
+
"git rebase", "git reset", "git commit --amend", "git push --force",
|
|
136
|
+
"git push -f", "git revert", "git branch -D", "git branch -d", "git tag -d",
|
|
137
|
+
"git clean", "git stash drop", "git stash clear", "git filter-branch",
|
|
138
|
+
];
|
|
139
|
+
const GUARD_GIT_ASK_CACHE_TTL = 0;
|
|
140
|
+
const GUARD_PS_WRITE_CMDS = new Set([
|
|
141
|
+
"set-content", "out-file", "add-content", "new-item", "remove-item",
|
|
142
|
+
"copy-item", "move-item", "clear-content", "ni", "ri", "mi", "ac", "sc", "sp",
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
// Resolve a shell token to an absolute path; unresolvable tokens (env vars,
|
|
146
|
+
// command substitution) are treated as external - conservative by design.
|
|
147
|
+
function guardResolve(effCwd: string, sessionCwd: string, token: string): { abs: string; external: boolean } {
|
|
148
|
+
let t = token.replace(/^["']|["']$/g, "").trim();
|
|
149
|
+
if (!t) return { abs: effCwd, external: false };
|
|
150
|
+
if (t.startsWith("~")) return { abs: t, external: true };
|
|
151
|
+
if (/[\$`]/.test(t)) return { abs: t, external: true };
|
|
152
|
+
if (/^[A-Za-z]:[\\/]/.test(t) || t.startsWith("/")) {
|
|
153
|
+
return { abs: t, external: isExternalAbs(t, sessionCwd) };
|
|
154
|
+
}
|
|
155
|
+
const abs = path.resolve(effCwd, t);
|
|
156
|
+
return { abs, external: isExternalAbs(abs, sessionCwd) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isExternalAbs(abs: string, base: string): boolean {
|
|
160
|
+
const rel = path.relative(base, abs);
|
|
161
|
+
return rel.startsWith("..") || path.isAbsolute(rel);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function guardIsDevNull(t: string): boolean {
|
|
165
|
+
return /^\/dev\/null/i.test(t) || /^nul$/i.test(t) || /^\$null$/i.test(t);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Analyze one shell command for writes that would land outside `sessionCwd`.
|
|
169
|
+
// Returns the offending path (for the deny reason) or null.
|
|
170
|
+
// Analyze one shell command. Returns { block, ask, notify } hits (any may be
|
|
171
|
+
// absent). External-write analysis is best effort: redirect targets,
|
|
172
|
+
// write-command arguments (copy-like: destination only), interpreter payloads
|
|
173
|
+
// mentioning external absolute paths, cd tracking. Git arguments are NOT
|
|
174
|
+
// analyzed (inherited protection: only history-rewriting git asks).
|
|
175
|
+
function guardShellExternalWrite(cmd: string, sessionCwd: string): { block?: string; ask?: string; notify?: string } | null {
|
|
176
|
+
const segments = cmd.split(/\n|&&|\|\||;|\|/).map((s) => s.trim()).filter(Boolean);
|
|
177
|
+
let effCwd = sessionCwd;
|
|
178
|
+
const out: { block?: string; ask?: string; notify?: string } = {};
|
|
179
|
+
for (const seg of segments) {
|
|
180
|
+
const cd = seg.match(/^cd\s+(.+)$/i) || seg.match(/^sl\s+(.+)$/i) || seg.match(/^set-location\s+(.+)$/i);
|
|
181
|
+
if (cd) {
|
|
182
|
+
const r = guardResolve(effCwd, sessionCwd, cd[1]);
|
|
183
|
+
// switch to the real effective cwd even when external: only WRITE ops
|
|
184
|
+
// are analyzed below, so `cd elsewhere && cat f` still passes while
|
|
185
|
+
// `cd elsewhere && touch a.txt` is correctly judged external.
|
|
186
|
+
effCwd = r.abs;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
// 2. redirects: > >> 2> 2>> &> &>> >| (not heredocs, not fd dups like 2>&1)
|
|
190
|
+
for (const m of seg.matchAll(/(?:^|[^<>&=(])(?:\d?>{1,2}|&>>?|>\|)\s*([^\s;&|]+)/g)) {
|
|
191
|
+
const tgt = m[1];
|
|
192
|
+
if (/^&?\d+$/.test(tgt) || guardIsDevNull(tgt)) continue;
|
|
193
|
+
const r = guardResolve(effCwd, sessionCwd, tgt);
|
|
194
|
+
if (r.external) out.block = r.abs;
|
|
195
|
+
}
|
|
196
|
+
const isGit = /^git(\s|$)/.test(seg);
|
|
197
|
+
// 1. inherited git protection: history-rewriting git asks (matches
|
|
198
|
+
// pi-permissions.jsonc bash patterns; prefix match like "git rebase*")
|
|
199
|
+
if (isGit) {
|
|
200
|
+
const low = seg.toLowerCase();
|
|
201
|
+
if (GUARD_GIT_ASK.some((p) => low.startsWith(p))) {
|
|
202
|
+
out.ask = seg.slice(0, 80);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (/^git\s+(init|clone)/.test(low)) {
|
|
206
|
+
const r = guardResolve(effCwd, sessionCwd, seg.split(/\s+/)[1] === "clone" ? (seg.match(/\s+(\S+)\s+\S+$/) || [])[1] || "." : ".");
|
|
207
|
+
if (r.external) {
|
|
208
|
+
out.notify = `git ${seg.startsWith("git clone") ? "clone" : "init"} outside the session directory (${r.abs}). pi-task-tracker does NOT track that directory; start a separate pi session there if you want it tracked.`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// external -C destination on any git command
|
|
212
|
+
const c = seg.match(/\s-C\s+(\S+)/);
|
|
213
|
+
if (c) {
|
|
214
|
+
const r = guardResolve(effCwd, sessionCwd, c[1]);
|
|
215
|
+
if (r.external) out.block = r.abs;
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// 3. write-ish commands: copy-like checks ONLY the destination (last path
|
|
220
|
+
// argument) so `cp /external/file ./` stays allowed; others check all
|
|
221
|
+
const tokens = seg.split(/\s+/);
|
|
222
|
+
const head = (tokens[0] || "").toLowerCase().replace(/\.exe$/, "");
|
|
223
|
+
const headBase = head.split(/[\/]/).pop() || head;
|
|
224
|
+
const isPsWrite = GUARD_PS_WRITE_CMDS.has(headBase);
|
|
225
|
+
const isShWrite = GUARD_SHELL_WRITE_CMDS.has(headBase);
|
|
226
|
+
const isCopy = GUARD_COPY_CMDS.has(headBase);
|
|
227
|
+
const isInterp = GUARD_INTERPRETERS.has(headBase);
|
|
228
|
+
if (!isPsWrite && !isShWrite && !isCopy && !isInterp) continue;
|
|
229
|
+
if (isInterp) {
|
|
230
|
+
// interpreter: block only if the command TEXT names an external path
|
|
231
|
+
for (const m of seg.matchAll(/[A-Za-z]:[\/]\S*|\/(?:[\w.-]+\/)+[\w.-]+/g)) {
|
|
232
|
+
if (guardIsDevNull(m[0])) continue;
|
|
233
|
+
if (isExternalAbs(m[0], sessionCwd)) { out.block = m[0]; break; }
|
|
234
|
+
}
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const pathArgs = [];
|
|
238
|
+
for (let k = 1; k < tokens.length; k++) {
|
|
239
|
+
if (tokens[k].startsWith("-")) continue;
|
|
240
|
+
pathArgs.push(tokens[k]);
|
|
241
|
+
}
|
|
242
|
+
const checkList = isCopy ? pathArgs.slice(-1) : pathArgs;
|
|
243
|
+
for (const t of checkList) {
|
|
244
|
+
if (/^[A-Za-z]:[\{1,2}]/.test(t) || t.startsWith("/")) { // absolute
|
|
245
|
+
if (guardIsDevNull(t)) continue;
|
|
246
|
+
if (isExternalAbs(t, sessionCwd)) out.block = t;
|
|
247
|
+
} else if (/[\$`~]/.test(t)) {
|
|
248
|
+
out.block = t; // unresolvable -> conservative
|
|
249
|
+
} else {
|
|
250
|
+
const abs = path.resolve(effCwd, t);
|
|
251
|
+
if (isExternalAbs(abs, sessionCwd)) out.block = t;
|
|
252
|
+
}
|
|
253
|
+
if (out.block) break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const GUARD_ASK_PROMPT = "pi-task-tracker external-write guard";
|
|
260
|
+
|
|
261
|
+
// Returns null (allow), "__ask__" (ask mode, user approved), or a deny reason.
|
|
262
|
+
async function guardExternalWriteHit(
|
|
263
|
+
event: { toolName: string; input?: unknown },
|
|
264
|
+
sessionCwd: string,
|
|
265
|
+
mode: "block" | "ask",
|
|
266
|
+
ctx?: { hasUI?: boolean; ui?: { confirm: (t: string, m: string) => Promise<boolean>; notify: (m: string, kind?: string) => void } }
|
|
267
|
+
): Promise<string | null> {
|
|
268
|
+
if (!sessionCwd) return null;
|
|
269
|
+
const toolName = event.toolName || "";
|
|
270
|
+
|
|
271
|
+
// shell tools: static best-effort analysis (bash/powershell)
|
|
272
|
+
if (toolName === "bash" || toolName === "powershell") {
|
|
273
|
+
const cmd = (event.input as { command?: string } | undefined)?.command || "";
|
|
274
|
+
const hit = guardShellExternalWrite(cmd, sessionCwd);
|
|
275
|
+
if (!hit) return null;
|
|
276
|
+
if (hit.notify && ctx?.hasUI && ctx.ui) ctx.ui.notify(hit.notify, "info");
|
|
277
|
+
if (hit.ask) {
|
|
278
|
+
if (!ctx?.hasUI || !ctx.ui) return `pi-task-tracker guard: history-rewriting git ('${hit.ask}') requires approval but no UI is available.`;
|
|
279
|
+
const ok = await ctx.ui.confirm(
|
|
280
|
+
"pi-task-tracker git guard (inherited)",
|
|
281
|
+
`History-rewriting git command: '${hit.ask}'. This protection was inherited from pi-permission-system. Allow?`,
|
|
282
|
+
);
|
|
283
|
+
if (ok) return null;
|
|
284
|
+
return `pi-task-tracker guard: user denied '${hit.ask}'. Do not retry or bypass.`;
|
|
285
|
+
}
|
|
286
|
+
if (hit.block) {
|
|
287
|
+
const why = "External changes cannot be tracked or rolled back by pi-task-tracker.";
|
|
288
|
+
if (mode === "ask") {
|
|
289
|
+
if (!ctx?.hasUI || !ctx.ui) return `pi-task-tracker scope guard: '${hit.block}' is outside the session directory and no UI is available. ${why}`;
|
|
290
|
+
const ok = await ctx.ui.confirm(
|
|
291
|
+
"pi-task-tracker external-write guard",
|
|
292
|
+
`This command writes to '${hit.block}' outside the session directory. ${why} Allow anyway?`,
|
|
293
|
+
);
|
|
294
|
+
if (ok) return null;
|
|
295
|
+
return `pi-task-tracker scope guard: user denied external write to '${hit.block}'. Do not retry or bypass.`;
|
|
296
|
+
}
|
|
297
|
+
return `pi-task-tracker scope guard (externalWrite=block): this command writes to '${hit.block}' outside the session directory. ${why} Stay inside the session directory, or have the user adjust taskTracker.externalWrite / run it manually.`;
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// path-bearing tools
|
|
303
|
+
const input = (event.input || {}) as Record<string, unknown>;
|
|
304
|
+
const p = typeof input.path === "string" ? input.path : typeof input.file_path === "string" ? input.file_path : "";
|
|
305
|
+
if (!p) return null;
|
|
306
|
+
const isWriteish = GUARD_WRITE_TOOLS.has(toolName);
|
|
307
|
+
if (!isWriteish && (GUARD_READ_TOOLS.has(toolName) || coveredByGuardHeuristics(toolName))) return null;
|
|
308
|
+
if (!isExternalAbs2(sessionCwd, p)) return null;
|
|
309
|
+
const why = "External changes cannot be tracked or rolled back by pi-task-tracker.";
|
|
310
|
+
if (mode === "ask") {
|
|
311
|
+
if (!ctx?.hasUI || !ctx.ui) return `pi-task-tracker scope guard: '${p}' is outside the session directory and no UI is available to approve external writes. ${why}`;
|
|
312
|
+
const ok = await ctx.ui.confirm(
|
|
313
|
+
"pi-task-tracker external-write guard",
|
|
314
|
+
`Tool '${toolName}' targets '${p}' outside the session directory. ${why} Allow anyway?`,
|
|
315
|
+
);
|
|
316
|
+
if (ok) return "__ask__";
|
|
317
|
+
return `pi-task-tracker scope guard: user denied external write to '${p}'. Do not retry or bypass.`;
|
|
318
|
+
}
|
|
319
|
+
return `pi-task-tracker scope guard (externalWrite=block): tool '${toolName}' targets '${p}' outside the session directory. ${why} Ask the user to adjust taskTracker.externalWrite or perform the change manually.`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isExternalAbs2(base: string, p: string): boolean {
|
|
323
|
+
const abs = path.isAbsolute(p) ? p : path.resolve(base, p);
|
|
324
|
+
const rel = path.relative(base, abs);
|
|
325
|
+
return rel.startsWith("..") || path.isAbsolute(rel);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Mirror of pi-permission-system's path-bearing heuristics: tools it already
|
|
329
|
+
// checks (or read-only ones) are left to it.
|
|
330
|
+
function coveredByGuardHeuristics(toolName: string): boolean {
|
|
331
|
+
const t = toolName.trim().toLowerCase();
|
|
332
|
+
const parts = t.split(/[^a-z0-9]+/).filter(Boolean);
|
|
333
|
+
return ["read", "write", "edit", "find", "grep", "search", "list", "ls"].some((s) => t.endsWith(s) || parts.includes(s));
|
|
334
|
+
}
|
|
335
|
+
|
|
109
336
|
// Repos whose identity was already ensured this process (git config persists,
|
|
110
337
|
// so one check per repo per pi session is enough - saves 2 spawns per repo
|
|
111
338
|
// per task).
|
|
@@ -340,20 +567,25 @@ interface TrackerConfig {
|
|
|
340
567
|
todo: boolean;
|
|
341
568
|
readme: boolean;
|
|
342
569
|
autoCommit: boolean;
|
|
570
|
+
/** Writes outside the session directory: "block" (default) | "ask" | "off" */
|
|
571
|
+
externalWrite: "block" | "ask" | "off";
|
|
343
572
|
}
|
|
344
573
|
|
|
345
574
|
function readConfig(): TrackerConfig {
|
|
575
|
+
const fallback: TrackerConfig = { todo: true, readme: true, autoCommit: true, externalWrite: "block" };
|
|
346
576
|
try {
|
|
347
577
|
const dir = process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
|
|
348
|
-
const
|
|
578
|
+
const file = process.env.PI_TRACKER_SETTINGS || path.join(dir, "settings.json");
|
|
579
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
349
580
|
const t = raw.taskTracker && typeof raw.taskTracker === "object" ? raw.taskTracker : {};
|
|
350
581
|
return {
|
|
351
582
|
todo: t.todo !== false,
|
|
352
583
|
readme: t.readme !== false,
|
|
353
584
|
autoCommit: t.autoCommit !== false,
|
|
585
|
+
externalWrite: t.externalWrite === "ask" || t.externalWrite === "off" ? t.externalWrite : "block",
|
|
354
586
|
};
|
|
355
587
|
} catch {
|
|
356
|
-
return
|
|
588
|
+
return fallback;
|
|
357
589
|
}
|
|
358
590
|
}
|
|
359
591
|
|
|
@@ -434,8 +666,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
434
666
|
fs.appendFileSync(todoPath, line, "utf-8");
|
|
435
667
|
});
|
|
436
668
|
|
|
437
|
-
pi.on("tool_call", async (event) => {
|
|
438
|
-
|
|
669
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
670
|
+
// External-write guard: keep the writable scope equal to the trackable
|
|
671
|
+
// scope. See the "External-write guard" block above for the policy.
|
|
672
|
+
if (config.externalWrite !== "off") {
|
|
673
|
+
const hit = await guardExternalWriteHit(event, cwd, config.externalWrite === "off" ? "block" : config.externalWrite, ctx);
|
|
674
|
+
if (hit) {
|
|
675
|
+
if (hit === "__ask__") return; // ask mode: user approved in guardExternalWriteHit
|
|
676
|
+
return { block: true, reason: hit };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
439
680
|
|
|
440
681
|
if (isToolCallEventType("write", event)) {
|
|
441
682
|
const p = event.input.path as string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-task-tracker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|