pan-wizard 3.24.0 → 3.25.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.
@@ -192,7 +192,7 @@ This agent file ships to all runtimes (keeps the installer uniform), but only ge
192
192
  When invoked by `/pan:army` (ADR-0033), you are **Mission Control** for a whole-project campaign, not a single phase — same harness, wider scope. The differences:
193
193
 
194
194
  - **You delegate to squads, not bare agents.** Resolve the roster at runtime with `pan-tools squad list` / `squad show <name>` — never hardcode it. Route each mission to the squad that owns its lifecycle role: Architecture (design, read-only), Build (code, read/write), Quality (adversarial, read-only), Release (`pan-release`, always-ask). Workers (document_code, distiller) take narrow, high-volume jobs; they are the agents the `budget` profile drops to the `fast` tier, and run at the inherited reasoning tier otherwise.
195
- - **Build parallelizes by worktree.** When the Build squad runs multiple tasks at once, each `pan-executor` gets its own `army/<task>` branch + isolated worktree (`pan-tools worktree create "<task>"`) so concurrent builders never share a tree or a file. The spawn cap and budget ceiling still bound the fan-out.
195
+ - **Build parallelizes by worktree — and you tear the worktrees down.** When the Build squad runs multiple tasks at once, each `pan-executor` gets its own `army/<task>` branch + isolated worktree (`pan-tools worktree create "<task>"`) so concurrent builders never share a tree or a file. The spawn cap and budget ceiling still bound the fan-out. Those worktrees are scaffolding: after each task's squash-merge lands, `pan-tools worktree remove <path> --branch army/<task>`; at campaign end or on any abort, `pan-tools worktree cleanup` sweeps the strays (`--force` to also discard dirty or unintegrated ones). Leaving `pan-army-*` sibling directories behind is a campaign defect, not residue (P-1815).
196
196
  - **Integration is human-gated.** You never merge to a protected branch. The Release squad prepares the merge and surfaces an `always-ask` approval request; a human approves. Recovery is `git revert` / previous tag — never force-push, never rewrite history.
197
197
  - **The loop carries learnings.** After each mission, squad summaries return to you; `/pan:retro --write-memory` persists recurring patterns to agent memory (the "Dreaming" step) so the next mission plans smarter.
198
198
 
@@ -133,6 +133,8 @@ Spawn the Quality squad on the built tree (parallel, read-only). Merge findings
133
133
  ### Phase 5 — Integrate (Release, human-gated)
134
134
  Spawn `pan-release`. It prepares the squash-merge, runs the configured `verification`, and surfaces an **always-ask** approval request. A human approves the merge to the protected branch; release then tags and records the rollback target. `--push` pushes the approved result.
135
135
 
136
+ **Teardown after the merge lands (P-1815):** worktrees are scaffolding, not deliverables — the squash-merge is the permanent artifact. Once the approval lands, remove each integrated task's worktree **and** its branch in one call: `pan-tools worktree remove ../pan-army-<slug> --branch army/<slug>`. The `--branch` half is not optional: a squash-merged branch never looks merged to git, so nothing else will ever clean it. At campaign end — and on **any** abort path — sweep whatever remains: `pan-tools worktree cleanup` (`--force` also discards dirty worktrees and unintegrated branches; without it the sweep keeps anything that might hold the only copy of work, and says exactly why and how to delete it). A campaign that ends with `pan-tools worktree list` non-empty has skipped this step.
137
+
136
138
  **Phase report (opt-in build deliverable):** when `workflow.phase_reports.enabled` is `true`, generate the mission's self-contained per-phase HTML report **in the built tree, before staging the squash-merge** — `pan-tools report phase <N>` — so the report rides along in the merge as a phase deliverable. **Never run `report index` inside a squad worktree:** the timeline index is a single shared file that aggregates *all* phases, so a worktree would see only its own phase and concurrent squads would race on it. The index is a single-writer, post-merge concern (Phase 6). Never opens a browser.
137
139
 
138
140
  ### Phase 6 — Learn (Dreaming)
@@ -142,6 +144,18 @@ Squad summaries return to Mission Control. Run `/pan:retro --write-memory` (and
142
144
 
143
145
  ---
144
146
 
147
+ <output_paths>
148
+
149
+ - the project tree + `.planning/orchestration/` — mission state, reports, loop-state (permanent)
150
+ - `<parent-of-project>/pan-army-<task-slug>/` — one worktree per Build task, a **sibling of the project directory** (temporary — removed per task by the Phase 5 teardown; strays swept by `pan-tools worktree cleanup`)
151
+ - branches `army/<task-slug>` — the worktrees' branches (temporary — deleted with their worktree once the squash-merge lands; squash-merged branches never look merged to git and must be deleted explicitly)
152
+
153
+ Nothing under `pan-army-*` or on an `army/*` branch is a deliverable: the squash-merge into the protected branch is the permanent artifact, same doctrine as what-if's disposable worktrees.
154
+
155
+ </output_paths>
156
+
157
+ ---
158
+
145
159
  ## Scheduled, self-resuming campaigns (ADR-0034)
146
160
 
147
161
  PAN is not a daemon — it cannot wake itself while the session is closed. `--schedule` arms a campaign and lets an external trigger drive it; the human merge gate is never relaxed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.24.0",
3
+ "version": "3.25.0",
4
4
  "description": "Command a bot army for your codebase: an Opus Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
5
5
  "bin": {
6
6
  "pan-wizard": "bin/install.js"
@@ -89,6 +89,91 @@ function listArmyWorktrees(cwd) {
89
89
  return out;
90
90
  }
91
91
 
92
+ /**
93
+ * Sweep every army worktree and orphaned army/ branch (P-1815, PanLoop
94
+ * finding 13). The what-if subsystem always had this (`whatif cleanup`); the
95
+ * army path created worktrees that nothing removed — sibling `pan-army-*`
96
+ * directories and `army/*` branches accumulated after every campaign.
97
+ *
98
+ * Safety posture (the L3 lesson — never trade clutter for silent data loss):
99
+ * - A DIRTY worktree is kept unless `force` — git refuses, we surface why.
100
+ * - A branch is deleted only when its tip is reachable from HEAD (truly
101
+ * integrated). Squash-merged and aborted branches are NOT reachable and may
102
+ * hold the only copy of real work, so by default they are KEPT and listed
103
+ * with the exact command to delete them; `force` sweeps them too.
104
+ * (The per-task Phase 5 teardown — `worktree remove --branch` right after
105
+ * the merge lands — deletes unconditionally; at that moment the deletion is
106
+ * the documented intent. The sweeper is the abort/orphan tool, so it errs
107
+ * the other way.)
108
+ * @param {string} cwd - main project root
109
+ * @param {Object} [opts] - { force: boolean }
110
+ * @returns {{removed_worktrees, deleted_branches, kept, pruned, clean}|{error}}
111
+ */
112
+ function cleanupArmyWorktrees(cwd, opts) {
113
+ if (!isGitRepo(cwd)) return { error: 'Not a git repo' };
114
+ const force = opts?.force === true;
115
+ const removedWorktrees = [];
116
+ const deletedBranches = [];
117
+ const kept = [];
118
+
119
+ const branchIsIntegrated = (branch) =>
120
+ execGit(cwd, ['merge-base', '--is-ancestor', branch, 'HEAD']).exitCode === 0;
121
+
122
+ const deleteBranch = (branch, hadWorktree) => {
123
+ if (force || branchIsIntegrated(branch)) {
124
+ const del = execGit(cwd, ['branch', '-D', branch]);
125
+ if (del.exitCode === 0) deletedBranches.push(branch);
126
+ else kept.push({ branch, reason: `branch -D failed: ${del.stderr.trim()}` });
127
+ } else {
128
+ kept.push({
129
+ branch,
130
+ reason: `carries commits not reachable from HEAD (squash-merged or aborted work${hadWorktree ? '' : '; no worktree attached'}) — rerun with --force, or: git branch -D ${branch}`,
131
+ });
132
+ }
133
+ };
134
+
135
+ // 1. Registered army worktrees. Track every branch step 1 has already
136
+ // decided on — deleted OR deliberately kept — so the orphan scan below
137
+ // does not re-process (and double-report) it.
138
+ const handledBranches = new Set();
139
+ for (const t of listArmyWorktrees(cwd)) {
140
+ const rmArgs = ['worktree', 'remove'];
141
+ if (force) rmArgs.push('--force');
142
+ rmArgs.push(t.worktree);
143
+ const rm = execGit(cwd, rmArgs);
144
+ if (rm.exitCode !== 0) {
145
+ kept.push({ worktree: t.worktree, branch: t.branch, reason: `worktree remove refused: ${rm.stderr.trim()} — pass --force to discard uncommitted changes` });
146
+ if (t.branch) handledBranches.add(t.branch);
147
+ continue;
148
+ }
149
+ removedWorktrees.push(t.worktree);
150
+ if (t.branch) {
151
+ handledBranches.add(t.branch);
152
+ deleteBranch(t.branch, true);
153
+ }
154
+ }
155
+
156
+ // 2. Drop stale registrations (a manually deleted directory leaves one).
157
+ const pruned = execGit(cwd, ['worktree', 'prune']).exitCode === 0;
158
+
159
+ // 3. Orphaned army/ branches — a worktree removed without its branch.
160
+ const stillAttached = new Set(listArmyWorktrees(cwd).map(t => t.branch));
161
+ const ls = execGit(cwd, ['branch', '--list', `${ARMY_BRANCH_PREFIX}*`, '--format=%(refname:short)']);
162
+ if (ls.exitCode === 0) {
163
+ for (const branch of ls.stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
164
+ if (!stillAttached.has(branch) && !handledBranches.has(branch)) deleteBranch(branch, false);
165
+ }
166
+ }
167
+
168
+ return {
169
+ removed_worktrees: removedWorktrees,
170
+ deleted_branches: deletedBranches,
171
+ kept,
172
+ pruned,
173
+ clean: kept.length === 0,
174
+ };
175
+ }
176
+
92
177
  // ─── CLI ─────────────────────────────────────────────────────────────────────
93
178
 
94
179
  function cmdWorktreeList(cwd, raw) {
@@ -112,12 +197,25 @@ function cmdWorktreeRemove(cwd, worktreePath, branch, raw, opts) {
112
197
  output(r, raw, r.warnings.length ? r.warnings.join('\n') : 'removed');
113
198
  }
114
199
 
200
+ function cmdWorktreeCleanup(cwd, raw, opts) {
201
+ const r = cleanupArmyWorktrees(cwd, opts);
202
+ if (r.error) return error(r.error);
203
+ const lines = [
204
+ ...r.removed_worktrees.map(w => `removed worktree ${w}`),
205
+ ...r.deleted_branches.map(b => `deleted branch ${b}`),
206
+ ...r.kept.map(k => `KEPT ${k.worktree || k.branch}: ${k.reason}`),
207
+ ];
208
+ output(r, raw, lines.length ? lines.join('\n') : 'nothing to clean');
209
+ }
210
+
115
211
  module.exports = {
116
212
  ARMY_BRANCH_PREFIX,
117
213
  createTaskWorktree,
118
214
  removeTaskWorktree,
119
215
  listArmyWorktrees,
216
+ cleanupArmyWorktrees,
120
217
  cmdWorktreeList,
121
218
  cmdWorktreeCreate,
122
219
  cmdWorktreeRemove,
220
+ cmdWorktreeCleanup,
123
221
  };
@@ -1191,8 +1191,10 @@ async function main() {
1191
1191
  worktree.cmdWorktreeCreate(cwd, args[2], raw, { base: getArgValue(args, '--base') });
1192
1192
  } else if (subcommand === 'remove') {
1193
1193
  worktree.cmdWorktreeRemove(cwd, args[2], getArgValue(args, '--branch'), raw, { force: args.includes('--force') });
1194
+ } else if (subcommand === 'cleanup') {
1195
+ worktree.cmdWorktreeCleanup(cwd, raw, { force: args.includes('--force') });
1194
1196
  } else {
1195
- error('Unknown worktree subcommand. Available: list, create, remove');
1197
+ error('Unknown worktree subcommand. Available: list, create, remove, cleanup');
1196
1198
  }
1197
1199
  break;
1198
1200
  }