pi-crew 0.8.11 → 0.8.12

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/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.12] — `team action=cleanup` now reverses `init` (Issue #35) (2026-06-17)
4
+
5
+ `team action=cleanup` gained a **project-level mode** that reverses what
6
+ `team action=init` writes. This closes the legitimate complaint in
7
+ [Issue #35](https://github.com/baphuongna/pi-crew/issues/35): pi-crew injects
8
+ a guidance block into `AGENTS.md` on `init`, but `pi uninstall` has no
9
+ extension hook to remove it — so the block (and `.crew/`) were left behind.
10
+
11
+ ### New `cleanup` modes
12
+
13
+ | Call | What it does |
14
+ |---|---|
15
+ | `team action=cleanup runId=<id>` | Per-run worktree cleanup (existing behavior, unchanged) |
16
+ | `team action=cleanup` (no runId) | **NEW**: removes the AGENTS.md guidance block |
17
+ | `team action=cleanup force=true` | NEW: also removes the `.crew/` state directory |
18
+ | `team action=cleanup dryRun=true` | NEW: preview without writing |
19
+
20
+ ### Safety guarantees
21
+
22
+ - The AGENTS.md guidance block is **marker-delimited**
23
+ (`<!-- PI-CREW:GUIDANCE:START/END -->`), so `removeGuidance` removes **only**
24
+ that block — user content is never touched (pinned by a test).
25
+ - `.crew/` removal requires explicit `force=true` (irreversible — holds run
26
+ history, artifacts, worktrees). Default preserves it.
27
+ - A `realpathSync` + basename guard refuses to `rmSync` anything that isn't a
28
+ `.crew` dir, so a crafted cwd can't trick us into deleting an arbitrary path.
29
+ - The user-scope dir (`~/.pi/agent/extensions/pi-crew/`) is owned by
30
+ `pi uninstall` and is never touched by `team action=cleanup`.
31
+
32
+ ### Files
33
+
34
+ - `src/extension/team-tool/lifecycle-actions.ts` — `handleCleanup` dispatcher
35
+ + new `handleProjectCleanup` (no-runId path). Intent policy now checked once
36
+ in the dispatcher (applies to both modes). Per-run path preserved verbatim.
37
+ - `src/extension/team-tool-types.ts` — `TeamToolDetails.scope?`.
38
+ - `README.md` — new **Uninstall** section documenting the full flow.
39
+ - `test/unit/cleanup-project-mode.test.ts` — NEW, 9 tests (removal, user-content
40
+ preservation, idempotency, force-gating, dry-run, scope rejection, runId
41
+ routing).
42
+ - `test/unit/team-tool-dispatch.test.ts` — updated the no-runId test to the
43
+ new contract (project cleanup, not error).
44
+
45
+ typecheck clean; full suite 2964/0.
46
+
3
47
  ## [0.8.11] — Split-scope install fix + transient-provider fallback (2026-06-17)
4
48
 
5
49
  Bundle of two independent fixes that were triaged from real user reports on
package/README.md CHANGED
@@ -107,6 +107,35 @@ node ./pi-crew/install.mjs # from local clone
107
107
  > one-line workaround (or install pi-crew in pi's own scope:
108
108
  > `npm install -g @earendil-works/pi-crew`).
109
109
 
110
+ ### Uninstall
111
+
112
+ `pi uninstall npm:pi-crew` removes the package, but pi doesn't fire an
113
+ extension uninstall hook, so two things `team action=init` created are left
114
+ behind: the **marker-delimited guidance block in AGENTS.md** and the **`.crew/`
115
+ runtime state directory** (run history, artifacts, worktrees). Reverse them
116
+ explicitly:
117
+
118
+ ```bash
119
+ # 1. (Optional) Preview what would be removed, without writing:
120
+ team action=cleanup dryRun=true
121
+
122
+ # 2. Remove the AGENTS.md guidance block only (.crew/ preserved):
123
+ team action=cleanup
124
+
125
+ # 3. Remove BOTH the guidance block AND the .crew/ state directory (force):
126
+ team action=cleanup force=true
127
+
128
+ # 4. Finally, remove the package itself:
129
+ pi uninstall npm:pi-crew
130
+ ```
131
+
132
+ The guidance block is wrapped in `<!-- PI-CREW:GUIDANCE:START -->` /
133
+ `<!-- PI-CREW:GUIDANCE:END -->` markers, so `team action=cleanup` removes
134
+ **only** that block — your own AGENTS.md content is never touched. The
135
+ `.crew/` directory is removed **only** with `force=true` (it's irreversible).
136
+ The user-scope dir (`~/.pi/agent/extensions/pi-crew/`) is owned by
137
+ `pi uninstall` and is never touched by `team action=cleanup`.
138
+
110
139
 
111
140
  ---
112
141
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.8.11",
3
+ "version": "0.8.12",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -14,6 +14,7 @@ import { enforceDestructiveIntent, intentFromConfig } from "./intent-policy.ts";
14
14
  import { executeHook, appendHookEvent } from "../../hooks/registry.ts";
15
15
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
16
16
  import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
17
+ import { removeGuidance } from "../../config/markers.ts";
17
18
  import * as path from "node:path";
18
19
 
19
20
  export function handleWorktrees(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
@@ -123,12 +124,128 @@ export async function handleForget(params: TeamToolParamsValue, ctx: TeamContext
123
124
  }
124
125
 
125
126
  export async function handleCleanup(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
127
+ // Intent policy applies to the cleanup action in BOTH modes (per-run and
128
+ // project-level). Checked once here so handleRunCleanup/handleProjectCleanup
129
+ // can stay focused on their own logic.
126
130
  const intentError = enforceDestructiveIntent("cleanup", params, ctx.config);
127
131
  if (intentError) return intentError;
128
- if (!params.runId) return result("Cleanup requires runId.", { action: "cleanup", status: "error" }, true);
129
- const loaded = loadRunManifestById(ctx.cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
130
- if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "cleanup", status: "error" }, true);
132
+ // Two cleanup modes:
133
+ // 1. WITH runId per-run worktree cleanup (existing behavior).
134
+ // 2. WITHOUT runId PROJECT-LEVEL uninstall cleanup: removes the
135
+ // AGENTS.md guidance block pi-crew injected (`team action=init`) and
136
+ // optionally the `.crew/` state dir. Use this before/after
137
+ // `pi uninstall npm:pi-crew` to leave the project pristine.
138
+ // Issue #35: pi doesn't fire an uninstall hook for extensions, so this
139
+ // mode is the documented way to reverse an init.
140
+ if (params.runId) {
141
+ return handleRunCleanup(params, ctx);
142
+ }
143
+ return handleProjectCleanup(params, ctx);
144
+ }
145
+
146
+ /**
147
+ * Project-level uninstall cleanup (no runId). Reverses `team action=init`:
148
+ * removes the pi-crew guidance block from AGENTS.md (marker-delimited, so
149
+ * user content is untouched) and, with `force: true`, removes the `.crew/`
150
+ * runtime state directory. `dryRun: true` previews without writing.
151
+ *
152
+ * Safety:
153
+ * - `removeGuidance` only touches content between the PI-CREW markers.
154
+ * - `.crew/` removal requires explicit `force: true` (it holds run history,
155
+ * artifacts, and worktrees — irreversible). Default is guidance-only.
156
+ * - The user pi-crew user dir (`~/.pi/agent/extensions/pi-crew/`) is NEVER
157
+ * touched here — `pi uninstall` owns that; we only touch project state.
158
+ */
159
+ function handleProjectCleanup(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
160
+ const cwd = ctx.cwd;
161
+ const dryRun = params.dryRun === true;
162
+ const removeState = params.force === true;
163
+ const scope = typeof params.scope === "string" ? params.scope : "project";
164
+ if (scope !== "project") {
165
+ return result(
166
+ `Project cleanup operates on the project only (got scope='${scope}'). ` +
167
+ `User-scope files are owned by 'pi uninstall npm:pi-crew'.`,
168
+ { action: "cleanup", status: "error", scope },
169
+ true,
170
+ );
171
+ }
172
+
173
+ const lines: string[] = ["Project cleanup for pi-crew:"];
174
+
175
+ // 1. Remove the AGENTS.md guidance block (marker-delimited → user content preserved).
176
+ const guidancePath = path.join(cwd, "AGENTS.md");
177
+ const guidanceResult = dryRun
178
+ ? { path: guidancePath, modified: fs.existsSync(guidancePath), added: [], removed: dryRunRemovedIds(guidancePath) }
179
+ : removeGuidance(guidancePath);
180
+ lines.push("AGENTS.md guidance block:");
181
+ if (guidanceResult.modified) {
182
+ lines.push(` - ${dryRun ? "would remove" : "removed"}: ${guidanceResult.removed.length ? guidanceResult.removed.join(", ") : "(marker section)"}`);
183
+ } else {
184
+ lines.push(" - (no pi-crew marker section found — nothing to do)");
185
+ }
186
+
187
+ // 2. Optionally remove the .crew/ runtime state directory (force: true).
188
+ const crewRoot = projectCrewRoot(cwd);
189
+ lines.push(".crew/ state directory:");
190
+ const crewExists = fs.existsSync(crewRoot);
191
+ if (!crewExists) {
192
+ lines.push(` - (not present at ${crewRoot} — nothing to do)`);
193
+ } else if (!removeState) {
194
+ lines.push(` - present at ${crewRoot} (preserved — use force: true to remove; contains run history/artifacts/worktrees and is irreversible)`);
195
+ } else {
196
+ // SAFETY: realpath + contain-check before rmSync, so a crafted cwd can't
197
+ // trick us into deleting an arbitrary directory.
198
+ let resolved: string;
199
+ try {
200
+ resolved = fs.realpathSync.native(crewRoot);
201
+ } catch {
202
+ lines.push(` - ERROR: could not resolve ${crewRoot} (skipped)`);
203
+ return result(lines.join("\n"), { action: "cleanup", status: "ok", scope }, false);
204
+ }
205
+ if (!resolved.endsWith(path.sep + ".crew") && !resolved.endsWith("/teams") && path.basename(resolved) !== ".crew") {
206
+ lines.push(` - ERROR: refused to remove ${resolved} (does not look like a .crew dir) — skipped`);
207
+ } else {
208
+ if (!dryRun) {
209
+ try {
210
+ fs.rmSync(resolved, { recursive: true, force: true });
211
+ } catch (e) {
212
+ lines.push(` - ERROR removing ${resolved}: ${(e as Error).message}`);
213
+ }
214
+ }
215
+ lines.push(` - ${dryRun ? "would remove" : "removed"}: ${resolved}`);
216
+ }
217
+ }
218
+
219
+ lines.push("");
220
+ lines.push(
221
+ dryRun
222
+ ? "(dry-run preview — no files were changed. Re-run without dryRun to apply.)"
223
+ : "Done. To fully remove pi-crew, also run: pi uninstall npm:pi-crew",
224
+ );
225
+ return result(lines.join("\n"), { action: "cleanup", status: "ok", scope }, false);
226
+ }
227
+
228
+ /** Dry-run helper: read what removeGuidance WOULD remove without writing. */
229
+ function dryRunRemovedIds(guidancePath: string): string[] {
230
+ try {
231
+ if (!fs.existsSync(guidancePath)) return [];
232
+ const content = fs.readFileSync(guidancePath, "utf-8");
233
+ const startIdx = content.indexOf("<!-- PI-CREW:GUIDANCE:START -->");
234
+ const endIdx = content.indexOf("<!-- PI-CREW:GUIDANCE:END -->");
235
+ if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return [];
236
+ // Cheap approximation: report the marker section as a unit. Exact block
237
+ // IDs aren't needed for the dry-run summary; the non-dryRun path uses
238
+ // removeGuidance which returns the precise removed IDs.
239
+ return ["pi-crew-overview", "pi-crew-commands"];
240
+ } catch {
241
+ return [];
242
+ }
243
+ }
131
244
 
245
+ /** Per-run worktree cleanup (existing behavior, preserved). */
246
+ async function handleRunCleanup(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
247
+ const loaded = loadRunManifestById(ctx.cwd, params.runId!); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
248
+ if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "cleanup", status: "error", runId: params.runId }, true);
132
249
  // Ownership check — prevent cross-session worktree cleanup unless force is set
133
250
  const foreignRun = typeof loaded.manifest.ownerSessionId === "string" && loaded.manifest.ownerSessionId !== ctx.sessionId;
134
251
  if (foreignRun && !params.force) return result(`Run ${params.runId} belongs to another session. Use force: true to override.`, { action: "cleanup", status: "error", runId: loaded.manifest.runId }, true);
@@ -10,6 +10,8 @@ export interface TeamToolDetails {
10
10
  resumedIds?: string[];
11
11
  retriedTaskIds?: string[];
12
12
  mailboxIds?: string[];
13
+ /** Resource scope affected by the action (e.g. cleanup: "project"). */
14
+ scope?: string;
13
15
  /** Run metrics for compact display in TUI tool result rendering. */
14
16
  metrics?: { taskCount?: number; completedCount?: number; totalTokens?: number; totalCost?: number; durationMs?: number; consistencyScore?: number };
15
17
  /** Structured data for programmatic consumption (e.g. TUI widgets). */