@yemi33/minions 0.1.1554 → 0.1.1556

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1556 (2026-04-27)
4
+
5
+ ### Other
6
+ - docs: document comprehensive minions remove
7
+
8
+ ## 0.1.1555 (2026-04-27)
9
+
10
+ ### Features
11
+ - comprehensive minions remove (CLI + dashboard)
12
+
3
13
  ## 0.1.1554 (2026-04-27)
4
14
 
5
15
  ### Fixes
package/README.md CHANGED
@@ -129,7 +129,7 @@ minions work "Explore the codebase and document the architecture"
129
129
  | `minions version` | Show installed vs package version |
130
130
  | `minions scan [dir] [depth]` | Scan for git repos and multi-select to add (default: ~, depth 3) |
131
131
  | `minions add <dir>` | Link a single project (auto-detects settings from git, prompts to confirm) |
132
- | `minions remove <dir>` | Unlink a project |
132
+ | `minions remove <dir-or-name> [--keep-data \| --purge --force]` | Unlink a project: cancels pending work items, drains dispatch + kills active agents, cleans worktrees, disables linked schedules, archives `projects/<name>/` to `projects/.archived/<name>-YYYYMMDD/`. Use `--keep-data` to leave the data dir in place, or `--purge --force` to delete it. |
133
133
  | `minions list` | List all linked projects with descriptions |
134
134
  | `minions start` | Start engine daemon (ticks every 60s, auto-syncs MCP servers) |
135
135
  | `minions stop` | Stop the engine |
@@ -87,7 +87,10 @@ async function openSettings() {
87
87
  '<div style="display:flex;flex-direction:column;gap:12px;margin-bottom:16px">' +
88
88
  (data.projects || []).map(function(p) {
89
89
  return '<div style="border:1px solid var(--border);border-radius:6px;padding:10px 12px">' +
90
- '<div style="font-size:12px;font-weight:600;margin-bottom:8px">' + escHtml(p.name) + '</div>' +
90
+ '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
91
+ '<div style="font-size:12px;font-weight:600">' + escHtml(p.name) + '</div>' +
92
+ '<button onclick="MinionsSettings.removeProject(\'' + escHtml(p.name) + '\')" style="font-size:9px;padding:2px 8px;background:transparent;color:var(--red);border:1px solid var(--red);border-radius:3px;cursor:pointer">Remove</button>' +
93
+ '</div>' +
91
94
  '<div style="display:flex;flex-direction:column;gap:6px">' +
92
95
  settingsToggle('Discover from PRs', 'set-ws-prs-' + p.name, p.workSources.pullRequests.enabled, 'Discovery gate: scan repo for open PRs and surface them as review tasks. Independent of ADO/GitHub polling — does not affect already-tracked PRs.') +
93
96
  settingsToggle('Discover from Work Items', 'set-ws-wi-' + p.name, p.workSources.workItems.enabled, 'Auto-discover work from ADO/GitHub work items') +
@@ -404,4 +407,30 @@ async function resetSettingsToDefaults() {
404
407
  }
405
408
  }
406
409
 
407
- window.MinionsSettings = { openSettings, saveSettings, addProject, resetSettingsToDefaults };
410
+ async function removeProject(name) {
411
+ if (!confirm('Remove project "' + name + '"? Pending work cancels, active agents are killed, data dir is archived to projects/.archived/.')) return;
412
+ showToast('cmd-toast', 'Removing project "' + name + '"...', true);
413
+ try {
414
+ const res = await fetch('/api/projects/remove', {
415
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
416
+ body: JSON.stringify({ name })
417
+ });
418
+ const d = await res.json().catch(() => ({}));
419
+ if (!res.ok || !d.ok) {
420
+ showToast('cmd-toast', 'Remove failed: ' + (d.error || 'unknown'), false);
421
+ return;
422
+ }
423
+ var parts = ['Removed "' + name + '"'];
424
+ if (d.cancelledItems) parts.push(d.cancelledItems + ' WI cancelled');
425
+ if (d.drainedDispatches) parts.push(d.drainedDispatches + ' dispatch drained');
426
+ if (d.cleanedWorktrees) parts.push(d.cleanedWorktrees + ' worktree(s) cleaned');
427
+ if (d.archivedTo) parts.push('archived to ' + d.archivedTo);
428
+ if (d.pipelineRefs?.length) parts.push('! pipelines still reference: ' + d.pipelineRefs.join(', '));
429
+ showToast('cmd-toast', parts.join(' — '), true);
430
+ setTimeout(() => openSettings(), 600);
431
+ } catch (e) {
432
+ showToast('cmd-toast', 'Error: ' + e.message, false);
433
+ }
434
+ }
435
+
436
+ window.MinionsSettings = { openSettings, saveSettings, addProject, removeProject, resetSettingsToDefaults };
package/dashboard.js CHANGED
@@ -1346,59 +1346,7 @@ function jsonReply(res, code, data, req) {
1346
1346
  * @param {(entry) => boolean} matchFn - return true for entries to remove
1347
1347
  * @returns {number} count of removed entries
1348
1348
  */
1349
- function cleanDispatchEntries(matchFn) {
1350
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
1351
- const engineDir = path.join(MINIONS_DIR, 'engine');
1352
- try {
1353
- let removed = 0;
1354
- // Collect PIDs and file paths inside the lock, execute kills outside
1355
- const pidsToKill = [];
1356
- const filesToDelete = [];
1357
- mutateJsonFileLocked(dispatchPath, (dispatch) => {
1358
- dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
1359
- dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
1360
- dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
1361
- for (const queue of ['pending', 'active', 'completed']) {
1362
- const before = dispatch[queue].length;
1363
- if (queue === 'active') {
1364
- for (const d of dispatch[queue]) {
1365
- if (!matchFn(d)) continue;
1366
- // Collect PID and cleanup paths — actual I/O happens after lock release
1367
- const pidFile = path.join(engineDir, `pid-${d.id}.pid`);
1368
- try {
1369
- const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim());
1370
- if (pid) pidsToKill.push(pid);
1371
- } catch { /* PID file may not exist */ }
1372
- filesToDelete.push(pidFile);
1373
- filesToDelete.push(path.join(engineDir, 'tmp', `prompt-${d.id}.md`));
1374
- filesToDelete.push(path.join(engineDir, 'tmp', `sysprompt-${d.id}.md`));
1375
- filesToDelete.push(path.join(engineDir, 'tmp', `sysprompt-${d.id}.md.tmp`));
1376
- }
1377
- }
1378
- dispatch[queue] = dispatch[queue].filter(d => !matchFn(d));
1379
- removed += before - dispatch[queue].length;
1380
- }
1381
- return dispatch;
1382
- }, { defaultValue: { pending: [], active: [], completed: [] } });
1383
- // Kill processes outside the lock — these can take hundreds of ms on Windows
1384
- for (const pid of pidsToKill) {
1385
- try {
1386
- const safePid = shared.validatePid(pid);
1387
- if (process.platform === 'win32') {
1388
- const { execFileSync } = require('child_process');
1389
- execFileSync('taskkill', ['/PID', String(safePid), '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
1390
- } else {
1391
- process.kill(safePid, 'SIGTERM');
1392
- }
1393
- } catch { /* process may already be dead */ }
1394
- }
1395
- // Clean up files outside the lock
1396
- for (const fp of filesToDelete) {
1397
- try { fs.unlinkSync(fp); } catch { /* file may not exist */ }
1398
- }
1399
- return removed;
1400
- } catch { return 0; }
1401
- }
1349
+ const { cleanDispatchEntries } = require('./engine/dispatch');
1402
1350
 
1403
1351
  // ── Engine Restart Helpers (used by watchdog + API) ─────────────────────────
1404
1352
 
@@ -3938,6 +3886,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3938
3886
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
3939
3887
  }
3940
3888
 
3889
+ async function handleProjectsRemove(req, res) {
3890
+ try {
3891
+ const body = await readBody(req);
3892
+ const target = body.name || body.path;
3893
+ if (!target) return jsonReply(res, 400, { error: 'name or path required' });
3894
+ const { removeProject } = require('./engine/projects');
3895
+ const result = removeProject(target, { keepData: body.keepData === true, purge: body.purge === true });
3896
+ if (!result.ok) return jsonReply(res, result.error?.includes('No project') ? 404 : 400, result);
3897
+ reloadConfig();
3898
+ invalidateStatusCache();
3899
+ return jsonReply(res, 200, result);
3900
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
3901
+ }
3902
+
3941
3903
  async function handleProjectsScan(req, res) {
3942
3904
  try {
3943
3905
  const body = await readBody(req);
@@ -5227,6 +5189,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5227
5189
  { method: 'POST', path: '/api/projects/scan', desc: 'Scan a directory for git repos', params: 'path?, depth?', handler: handleProjectsScan },
5228
5190
  { method: 'POST', path: '/api/projects/confirm-token', desc: 'Mint a single-use UUID token required to add a non-repo path (SEC-05)', handler: handleProjectsConfirmToken },
5229
5191
  { method: 'POST', path: '/api/projects/add', desc: 'Auto-discover and add a project to config (name validated SEC-04; path validated SEC-05)', params: 'path, name?, allowNonRepo?, confirmToken?', handler: handleProjectsAdd },
5192
+ { method: 'POST', path: '/api/projects/remove', desc: 'Unlink a project: cancels WIs, drains dispatch, kills agents, cleans worktrees, archives data dir', params: 'name or path, keepData?, purge?', handler: handleProjectsRemove },
5230
5193
 
5231
5194
  // Bug Filing
5232
5195
  { method: 'POST', path: '/api/issues/create', desc: 'File a bug on the Minions repo (yemi33/minions)', params: 'title, description?, labels?', handler: handleFileBug },
@@ -330,6 +330,85 @@ function cancelPendingDispatchesForPr(prId) {
330
330
  return cancelled;
331
331
  }
332
332
 
333
+ /**
334
+ * Remove dispatch entries matching a predicate from pending/active/completed.
335
+ * For matched active entries, kills the agent process and deletes its
336
+ * pid file + prompt sidecars in engine/tmp/. Lock callback only mutates state;
337
+ * kills and unlinks happen after release.
338
+ *
339
+ * @param {(entry) => boolean} matchFn
340
+ * @returns {number} count of removed entries
341
+ */
342
+ function cleanDispatchEntries(matchFn) {
343
+ const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
344
+ const engineDir = path.join(MINIONS_DIR, 'engine');
345
+ let removed = 0;
346
+ const pidsToKill = [];
347
+ const filesToDelete = [];
348
+ try {
349
+ mutateJsonFileLocked(dispatchPath, (dispatch) => {
350
+ for (const queue of ['pending', 'active', 'completed']) {
351
+ dispatch[queue] = Array.isArray(dispatch[queue]) ? dispatch[queue] : [];
352
+ const before = dispatch[queue].length;
353
+ if (queue === 'active') {
354
+ for (const d of dispatch[queue]) {
355
+ if (!matchFn(d)) continue;
356
+ const pidFile = path.join(engineDir, `pid-${d.id}.pid`);
357
+ try {
358
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim());
359
+ if (pid) pidsToKill.push(pid);
360
+ } catch { /* PID file may not exist */ }
361
+ filesToDelete.push(pidFile);
362
+ filesToDelete.push(path.join(engineDir, 'tmp', `prompt-${d.id}.md`));
363
+ filesToDelete.push(path.join(engineDir, 'tmp', `sysprompt-${d.id}.md`));
364
+ filesToDelete.push(path.join(engineDir, 'tmp', `sysprompt-${d.id}.md.tmp`));
365
+ }
366
+ }
367
+ dispatch[queue] = dispatch[queue].filter(d => !matchFn(d));
368
+ removed += before - dispatch[queue].length;
369
+ }
370
+ return dispatch;
371
+ }, { defaultValue: { pending: [], active: [], completed: [] } });
372
+ } catch { return 0; }
373
+ // Kill processes outside the lock — taskkill on Windows can take hundreds of ms
374
+ for (const pid of pidsToKill) {
375
+ try {
376
+ const safePid = shared.validatePid(pid);
377
+ if (process.platform === 'win32') {
378
+ const { execFileSync } = require('child_process');
379
+ execFileSync('taskkill', ['/PID', String(safePid), '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
380
+ } else {
381
+ process.kill(safePid, 'SIGTERM');
382
+ }
383
+ } catch { /* may already be dead */ }
384
+ }
385
+ for (const fp of filesToDelete) {
386
+ try { fs.unlinkSync(fp); } catch { /* may not exist */ }
387
+ }
388
+ return removed;
389
+ }
390
+
391
+ /**
392
+ * Cancel pending/queued work items matching a predicate. Done items pass through.
393
+ * Sets status=CANCELLED + _cancelledBy=reason. Returns count cancelled.
394
+ */
395
+ function cancelPendingWorkItems(wiPath, matchFn, reason) {
396
+ if (!fs.existsSync(wiPath)) return 0;
397
+ let cancelled = 0;
398
+ try {
399
+ mutateWorkItems(wiPath, items => {
400
+ for (const w of items) {
401
+ if (!matchFn(w)) continue;
402
+ if (w.status !== WI_STATUS.PENDING && w.status !== WI_STATUS.QUEUED) continue;
403
+ w.status = WI_STATUS.CANCELLED;
404
+ if (reason) w._cancelledBy = reason;
405
+ cancelled++;
406
+ }
407
+ });
408
+ } catch { /* file unwritable */ }
409
+ return cancelled;
410
+ }
411
+
333
412
  // ─── Exports ─────────────────────────────────────────────────────────────────
334
413
 
335
414
  module.exports = {
@@ -340,4 +419,6 @@ module.exports = {
340
419
  writeInboxAlert,
341
420
  updateAgentStatus,
342
421
  cancelPendingDispatchesForPr,
422
+ cleanDispatchEntries,
423
+ cancelPendingWorkItems,
343
424
  };
@@ -0,0 +1,158 @@
1
+ /**
2
+ * engine/projects.js — Project lifecycle (currently: comprehensive remove).
3
+ *
4
+ * Used by both the CLI (minions.js) and the dashboard (handleProjectsRemove)
5
+ * so removal semantics stay identical regardless of entry point.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const shared = require('./shared');
11
+ const dispatch = require('./dispatch');
12
+ const { MINIONS_DIR } = shared;
13
+
14
+ /**
15
+ * @param {object} d - dispatch entry
16
+ * @returns {string} project name from any of the three meta shapes the engine
17
+ * uses (item.project, project.name, project string)
18
+ */
19
+ function _dispatchProjectName(d) {
20
+ return d?.meta?.item?.project || d?.meta?.project?.name || d?.meta?.project || '';
21
+ }
22
+
23
+ /**
24
+ * Remove a project: cancel pending work items, drain dispatch + kill agents,
25
+ * clean worktrees, disable project-targeted schedules, archive (or purge) the
26
+ * data directory, then unlink from config.json.
27
+ *
28
+ * @param {string} target - Project name or localPath
29
+ * @param {object} [options]
30
+ * @param {'archive'|'keep'|'purge'} [options.dataMode='archive']
31
+ * archive: move projects/<name>/ to projects/.archived/<name>-YYYYMMDD/
32
+ * keep: leave projects/<name>/ in place
33
+ * purge: rm -rf projects/<name>/
34
+ * @returns {object} summary { ok, project, cancelledItems, killedAgents,
35
+ * drainedDispatches, cleanedWorktrees, disabledSchedules, archivedTo,
36
+ * purgedDataDir, pipelineRefs[], warnings[] } or { ok:false, error }
37
+ */
38
+ function removeProject(target, options = {}) {
39
+ const dataMode = options.dataMode || (options.purge ? 'purge' : (options.keepData ? 'keep' : 'archive'));
40
+ const summary = {
41
+ ok: false,
42
+ project: null,
43
+ cancelledItems: 0,
44
+ drainedDispatches: 0, // includes active dispatches whose agent processes were killed
45
+ cleanedWorktrees: 0,
46
+ disabledSchedules: 0,
47
+ pipelineRefs: [],
48
+ archivedTo: null,
49
+ purgedDataDir: false,
50
+ warnings: [],
51
+ };
52
+
53
+ const configPath = path.join(MINIONS_DIR, 'config.json');
54
+ let config;
55
+ try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
56
+ catch (e) { return { ...summary, error: 'Failed to read config: ' + e.message }; }
57
+
58
+ const project = (config.projects || []).find(p =>
59
+ p.name === target || path.resolve(p.localPath || '') === path.resolve(target));
60
+ if (!project) {
61
+ const available = (config.projects || []).map(p => p.name).join(', ') || '(none)';
62
+ return { ...summary, error: `No project linked matching: ${target}. Available: ${available}` };
63
+ }
64
+ summary.project = { name: project.name, localPath: project.localPath };
65
+
66
+ // 1. Cancel pending/queued work items linked to this project (project-local
67
+ // file + central). Done items are preserved as history.
68
+ for (const wiPath of [
69
+ path.join(MINIONS_DIR, 'projects', project.name, 'work-items.json'),
70
+ path.join(MINIONS_DIR, 'work-items.json'),
71
+ ]) {
72
+ summary.cancelledItems += dispatch.cancelPendingWorkItems(
73
+ wiPath,
74
+ w => !w.project || w.project === project.name,
75
+ 'project-removed',
76
+ );
77
+ }
78
+
79
+ // 2. Drain dispatch — also kills active agent processes and unlinks pid +
80
+ // prompt sidecars in engine/tmp/, matching what plan delete does.
81
+ summary.drainedDispatches = dispatch.cleanDispatchEntries(
82
+ d => _dispatchProjectName(d) === project.name,
83
+ );
84
+
85
+ // 3. Clean up worktrees under this project's worktree root, honoring
86
+ // config.engine.worktreeRoot (mirrors lifecycle.js cleanupPlanWorktrees).
87
+ if (project.localPath) {
88
+ try {
89
+ const wtRoot = path.resolve(project.localPath, config.engine?.worktreeRoot || '../worktrees');
90
+ if (fs.existsSync(wtRoot)) {
91
+ for (const dir of fs.readdirSync(wtRoot)) {
92
+ try {
93
+ if (shared.removeWorktree(path.join(wtRoot, dir), project.localPath, wtRoot)) {
94
+ summary.cleanedWorktrees++;
95
+ }
96
+ } catch { /* best effort */ }
97
+ }
98
+ }
99
+ } catch (e) { summary.warnings.push('worktree cleanup: ' + e.message); }
100
+ }
101
+
102
+ // 4. Disable schedules whose `project` field targets this project
103
+ // specifically. Don't touch schedules with project='any' or unset.
104
+ if (Array.isArray(config.schedules)) {
105
+ for (const s of config.schedules) {
106
+ if (s.project === project.name && s.enabled !== false) {
107
+ s.enabled = false;
108
+ summary.disabledSchedules++;
109
+ }
110
+ }
111
+ }
112
+
113
+ // 5. Surface pipelines that reference this project so the user can review
114
+ // them. Don't auto-modify — user intent there is unclear.
115
+ try {
116
+ const { getPipelines } = require('./pipeline');
117
+ for (const p of getPipelines() || []) {
118
+ const refs = [
119
+ ...(p.monitoredResources || []),
120
+ ...((p.stages || []).flatMap(s => s.monitoredResources || [])),
121
+ ];
122
+ if (refs.some(r => r && (r.project === project.name || r._project === project.name))) {
123
+ summary.pipelineRefs.push(p.id);
124
+ }
125
+ }
126
+ } catch { /* pipelines optional */ }
127
+
128
+ // 6. Remove from config.json (and persist any schedule disables)
129
+ config.projects = (config.projects || []).filter(p => p.name !== project.name);
130
+ try { fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); }
131
+ catch (e) { return { ...summary, error: 'Failed to write config: ' + e.message }; }
132
+
133
+ // 7. Move (or purge) projects/<name>/ — preserves PR/work-item history by
134
+ // default so a re-add can pick up where it left off.
135
+ const dataDir = path.join(MINIONS_DIR, 'projects', project.name);
136
+ if (fs.existsSync(dataDir)) {
137
+ if (dataMode === 'purge') {
138
+ try { fs.rmSync(dataDir, { recursive: true, force: true }); summary.purgedDataDir = true; }
139
+ catch (e) { summary.warnings.push('purge data dir: ' + e.message); }
140
+ } else if (dataMode === 'archive') {
141
+ try {
142
+ const archiveRoot = path.join(MINIONS_DIR, 'projects', '.archived');
143
+ fs.mkdirSync(archiveRoot, { recursive: true });
144
+ const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
145
+ let archived = path.join(archiveRoot, project.name + '-' + stamp);
146
+ let n = 1;
147
+ while (fs.existsSync(archived)) archived = path.join(archiveRoot, project.name + '-' + stamp + '-' + (++n));
148
+ fs.renameSync(dataDir, archived);
149
+ summary.archivedTo = path.relative(MINIONS_DIR, archived).replace(/\\/g, '/');
150
+ } catch (e) { summary.warnings.push('archive data dir: ' + e.message); }
151
+ }
152
+ }
153
+
154
+ summary.ok = true;
155
+ return summary;
156
+ }
157
+
158
+ module.exports = { removeProject };
package/minions.js CHANGED
@@ -206,20 +206,27 @@ async function addProject(targetDir) {
206
206
  console.log(` node ${MINIONS_HOME}/engine.js status # Status\n`);
207
207
  }
208
208
 
209
- function removeProject(targetDir) {
210
- const target = path.resolve(targetDir);
211
- const config = loadConfig();
212
- const before = (config.projects || []).length;
213
- config.projects = (config.projects || []).filter(p => path.resolve(p.localPath) !== target);
214
- const after = config.projects.length;
215
-
216
- if (before === after) {
217
- console.log(` No project linked at ${target}`);
218
- } else {
219
- saveConfig(config);
220
- console.log(` Removed project at ${target}`);
221
- console.log(` Remaining projects: ${config.projects.length}`);
209
+ function removeProject(target, options = {}) {
210
+ const { removeProject: doRemove } = require('./engine/projects');
211
+ // CLI accepts a path; project lookup also matches by name as a convenience
212
+ const result = doRemove(path.resolve(target), options);
213
+ if (!result.ok) {
214
+ console.log(' ' + (result.error || 'Removal failed'));
215
+ rl.close();
216
+ process.exitCode = 1;
217
+ return;
222
218
  }
219
+
220
+ const lines = [` Removed project: ${result.project.name} (${result.project.localPath})`];
221
+ if (result.cancelledItems) lines.push(` cancelled ${result.cancelledItems} pending work item(s)`);
222
+ if (result.drainedDispatches) lines.push(` drained ${result.drainedDispatches} dispatch entr${result.drainedDispatches === 1 ? 'y' : 'ies'} (active agents killed)`);
223
+ if (result.cleanedWorktrees) lines.push(` cleaned ${result.cleanedWorktrees} worktree(s)`);
224
+ if (result.disabledSchedules) lines.push(` disabled ${result.disabledSchedules} schedule(s)`);
225
+ if (result.archivedTo) lines.push(` archived data → ${result.archivedTo}`);
226
+ if (result.purgedDataDir) lines.push(` purged data directory`);
227
+ if (result.pipelineRefs.length) lines.push(` ! pipeline(s) still reference this project: ${result.pipelineRefs.join(', ')}`);
228
+ for (const w of result.warnings) lines.push(` warn: ${w}`);
229
+ console.log(lines.join('\n'));
223
230
  rl.close();
224
231
  }
225
232
 
@@ -498,9 +505,18 @@ const commands = {
498
505
  addProject(dir).catch(e => { console.error(e); process.exit(1); });
499
506
  },
500
507
  remove: () => {
501
- const dir = rest[0];
502
- if (!dir) { console.log('Usage: node minions remove <project-dir>'); process.exit(1); }
503
- removeProject(dir);
508
+ const positional = rest.filter(a => !a.startsWith('--'));
509
+ const dir = positional[0];
510
+ if (!dir) {
511
+ console.log('Usage: node minions remove <project-dir-or-name> [--keep-data | --purge]');
512
+ process.exit(1);
513
+ }
514
+ const options = { keepData: rest.includes('--keep-data'), purge: rest.includes('--purge') };
515
+ if (options.purge && !rest.includes('--force')) {
516
+ console.log(` --purge will permanently delete projects/<name>/. Re-run with --force to confirm.`);
517
+ process.exit(1);
518
+ }
519
+ removeProject(dir, options);
504
520
  },
505
521
  list: () => listProjects(),
506
522
  scan: () => scanAndAdd({ root: rest[0], depth: rest[1] })
@@ -516,7 +532,9 @@ if (cmd && commands[cmd]) {
516
532
  console.log(' init Initialize minions and scan for repos');
517
533
  console.log(' scan [dir] [depth] Scan for git repos and multi-select to add');
518
534
  console.log(' add <project-dir> Link a single project');
519
- console.log(' remove <project-dir> Unlink a project');
535
+ console.log(' remove <project-dir-or-name> [--keep-data | --purge --force]');
536
+ console.log(' Unlink: cancels WIs, drains dispatch, kills agents,');
537
+ console.log(' cleans worktrees, archives data dir to projects/.archived/');
520
538
  console.log(' list List linked projects\n');
521
539
  }
522
540
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1554",
3
+ "version": "0.1.1556",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"