@yemi33/minions 0.1.1553 → 0.1.1555

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.1555 (2026-04-27)
4
+
5
+ ### Features
6
+ - comprehensive minions remove (CLI + dashboard)
7
+
8
+ ## 0.1.1554 (2026-04-27)
9
+
10
+ ### Fixes
11
+ - archive bugs — undefined resetBtn, .backup re-trigger, dispatch leak
12
+
3
13
  ## 0.1.1553 (2026-04-27)
4
14
 
5
15
  ### Other
@@ -601,13 +601,12 @@ async function planArchive(file, btn) {
601
601
  if (!confirm(confirmMsg)) return;
602
602
  _stopPlanPoll();
603
603
  markDeleted('plan:' + file);
604
- // Also optimistically hide the linked source plan (server archives both)
605
604
  if (isPrd) {
606
605
  var linkedPlan = (window._lastPlans || []).find(function(p) { return p.file === file && p.sourcePlan; });
607
606
  if (linkedPlan) markDeleted('plan:' + linkedPlan.sourcePlan);
608
607
  }
609
608
  try { closeModal(); } catch { /* may not be open */ }
610
- showToast('cmd-toast', 'Archiving...', true);
609
+ showToast('cmd-toast', 'Archived', true);
611
610
  try {
612
611
  const res = await fetch('/api/plans/archive', {
613
612
  method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -617,16 +616,18 @@ async function planArchive(file, btn) {
617
616
  if (!ct.includes('json')) { refresh(); return; }
618
617
  const d = await res.json().catch(() => ({}));
619
618
  if (res.ok && d.ok) {
620
- var msg = 'Archived';
621
- if (d.archivedSource) msg += ' PRD + source plan (' + d.archivedSource + ')';
622
- if (d.cancelledItems) msg += ', cancelled ' + d.cancelledItems + ' pending item(s)';
623
- showToast('cmd-toast', msg, true);
619
+ if (d.archivedSource || d.cancelledItems) {
620
+ var msg = 'Archived';
621
+ if (d.archivedSource) msg += ' PRD + source plan';
622
+ if (d.cancelledItems) msg += ', cancelled ' + d.cancelledItems + ' pending item(s)';
623
+ showToast('cmd-toast', msg, true);
624
+ }
624
625
  refresh();
625
626
  } else {
626
- resetBtn();
627
- alert('Archive failed: ' + (d.error || 'unknown'));
627
+ showToast('cmd-toast', 'Archive failed: ' + (d.error || 'unknown'), false);
628
+ refresh();
628
629
  }
629
- } catch (e) { resetBtn(); alert('Error: ' + e.message); }
630
+ } catch (e) { showToast('cmd-toast', 'Error: ' + e.message, false); refresh(); }
630
631
  }
631
632
 
632
633
  async function planPause(file, btn) {
@@ -793,8 +794,12 @@ async function planUnarchive(file, btn) {
793
794
  body: JSON.stringify({ file })
794
795
  });
795
796
  if (res.ok) { refreshPlans(); refresh(); }
796
- else { const d = await res.json().catch(() => ({})); alert('Unarchive failed: ' + (d.error || 'unknown')); refresh(); }
797
- } catch (e) { alert('Error: ' + e.message); refresh(); }
797
+ else {
798
+ const d = await res.json().catch(() => ({}));
799
+ showToast('cmd-toast', 'Unarchive failed: ' + (d.error || 'unknown'), false);
800
+ refresh();
801
+ }
802
+ } catch (e) { showToast('cmd-toast', 'Error: ' + e.message, false); refresh(); }
798
803
  }
799
804
 
800
805
  window.MinionsPlans = { openCreatePlanModal, refreshPlans, derivePlanStatus, renderPlans, openArchivedPlansModal, planExecute, planSubmitRevise, planShowRevise, planHideRevise, planView, planApprove, planArchive, planUnarchive, planDelete, planPause, planReject, planDiscuss, planOpenInDocChat, planRegeneratePRD, openVerifyGuide, triggerVerify };
@@ -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
 
@@ -3341,46 +3289,69 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
3341
3289
  try {
3342
3290
  const body = await readBody(req);
3343
3291
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
3344
- shared.sanitizePath(body.file, body.file.endsWith('.json') ? PRD_DIR : PLANS_DIR);
3292
+ const isPrd = body.file.endsWith('.json');
3293
+ shared.sanitizePath(body.file, isPrd ? PRD_DIR : PLANS_DIR);
3345
3294
  const planPath = resolvePlanPath(body.file);
3346
3295
  if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
3347
3296
 
3348
- // Move to archive directory
3349
- const archiveDir = body.file.endsWith('.json') ? path.join(PRD_DIR, 'archive') : path.join(PLANS_DIR, 'archive');
3297
+ const archiveDir = isPrd ? path.join(PRD_DIR, 'archive') : path.join(PLANS_DIR, 'archive');
3350
3298
  if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
3351
3299
  const archivePath = path.join(archiveDir, body.file);
3352
3300
  fs.renameSync(planPath, archivePath);
3353
3301
 
3354
- // Mark archived in JSON if PRD
3355
3302
  let archivedSource = null;
3356
- if (body.file.endsWith('.json')) {
3303
+ let plan = {};
3304
+ if (isPrd) {
3357
3305
  try {
3358
- const prd = safeJsonObj(archivePath);
3359
- prd.status = 'archived';
3360
- prd.archivedAt = new Date().toISOString();
3361
- safeWrite(archivePath, prd);
3362
- // Also archive linked source plan
3363
- if (prd.source_plan) {
3364
- const mdPath = path.join(PLANS_DIR, prd.source_plan);
3306
+ plan = safeJsonObj(archivePath) || {};
3307
+ plan.status = 'archived';
3308
+ plan.archivedAt = new Date().toISOString();
3309
+ safeWrite(archivePath, plan);
3310
+ // Without removing the .backup sidecar, safeJson would auto-restore the
3311
+ // pre-completion snapshot on engine restart, re-triggering plan completion
3312
+ // and spawning duplicate verify tasks (regression of #f28162b0).
3313
+ const backupPath = planPath + '.backup';
3314
+ try { fs.unlinkSync(backupPath); } catch {
3315
+ try { fs.writeFileSync(backupPath, JSON.stringify({ status: 'archived' })); } catch { /* best-effort */ }
3316
+ }
3317
+ if (plan.source_plan) {
3318
+ const mdPath = path.join(PLANS_DIR, plan.source_plan);
3365
3319
  if (fs.existsSync(mdPath)) {
3366
3320
  const planArchive = path.join(PLANS_DIR, 'archive');
3367
3321
  if (!fs.existsSync(planArchive)) fs.mkdirSync(planArchive, { recursive: true });
3368
- fs.renameSync(mdPath, path.join(planArchive, prd.source_plan));
3369
- archivedSource = prd.source_plan;
3322
+ fs.renameSync(mdPath, path.join(planArchive, plan.source_plan));
3323
+ archivedSource = plan.source_plan;
3370
3324
  }
3371
3325
  }
3372
3326
  } catch { /* optional */ }
3373
3327
  }
3374
3328
 
3375
- // Clean up worktrees associated with this plan
3329
+ // Cancel pending work items linked to this plan so the engine stops
3330
+ // dispatching for an archived plan. Done items are preserved as history.
3331
+ let cancelledItems = 0;
3332
+ const wiPaths = [path.join(MINIONS_DIR, 'work-items.json'), ...PROJECTS.map(p => shared.projectWorkItemsPath(p))];
3333
+ for (const wiPath of wiPaths) {
3334
+ try {
3335
+ mutateWorkItems(wiPath, items => {
3336
+ for (const w of items) {
3337
+ if (w.sourcePlan !== body.file) continue;
3338
+ if (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.QUEUED) {
3339
+ w.status = WI_STATUS.CANCELLED;
3340
+ w._cancelledBy = 'plan-archived';
3341
+ cancelledItems++;
3342
+ }
3343
+ }
3344
+ });
3345
+ } catch (e) { console.error('plan archive cancel:', e.message); }
3346
+ }
3347
+
3376
3348
  try {
3377
- const plan = body.file.endsWith('.json') ? (safeJsonObj(archivePath) || {}) : {};
3378
3349
  const { cleanupPlanWorktrees } = require('./engine/lifecycle');
3379
3350
  cleanupPlanWorktrees(body.file, plan, PROJECTS, getConfig());
3380
3351
  } catch (e) { console.error('plan worktree cleanup:', e.message); }
3381
3352
 
3382
3353
  invalidateStatusCache();
3383
- return jsonReply(res, 200, { ok: true, archived: body.file, archivedSource });
3354
+ return jsonReply(res, 200, { ok: true, archived: body.file, archivedSource, cancelledItems });
3384
3355
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
3385
3356
  }
3386
3357
 
@@ -3915,6 +3886,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3915
3886
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
3916
3887
  }
3917
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
+
3918
3903
  async function handleProjectsScan(req, res) {
3919
3904
  try {
3920
3905
  const body = await readBody(req);
@@ -5204,6 +5189,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5204
5189
  { method: 'POST', path: '/api/projects/scan', desc: 'Scan a directory for git repos', params: 'path?, depth?', handler: handleProjectsScan },
5205
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 },
5206
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 },
5207
5193
 
5208
5194
  // Bug Filing
5209
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.1553",
3
+ "version": "0.1.1555",
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"