@yemi33/minions 0.1.60 → 0.1.62

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,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.62 (2026-03-30)
4
+
5
+ ### Dashboard
6
+ - dashboard/js/command-center.js
7
+ - dashboard/js/command-history.js
8
+ - dashboard/js/command-input.js
9
+ - dashboard/js/command-parser.js
10
+ - dashboard/js/detail-panel.js
11
+ - dashboard/js/live-stream.js
12
+ - dashboard/js/modal-qa.js
13
+ - dashboard/js/modal.js
14
+ - dashboard/js/refresh.js
15
+ - dashboard/js/render-agents.js
16
+ - dashboard/js/render-dispatch.js
17
+ - dashboard/js/render-inbox.js
18
+ - dashboard/js/render-kb.js
19
+ - dashboard/js/render-other.js
20
+ - dashboard/js/render-pinned.js
21
+ - dashboard/js/render-plans.js
22
+ - dashboard/js/render-prd.js
23
+ - dashboard/js/render-prs.js
24
+ - dashboard/js/render-schedules.js
25
+ - dashboard/js/render-skills.js
26
+ - dashboard/js/render-work-items.js
27
+ - dashboard/js/settings.js
28
+ - dashboard/js/state.js
29
+ - dashboard/js/utils.js
30
+
31
+ ## 0.1.61 (2026-03-30)
32
+
33
+ ### Engine
34
+ - engine.js
35
+ - engine/cleanup.js
36
+ - engine/dispatch.js
37
+ - engine/timeout.js
38
+
39
+ ### Other
40
+ - test/unit.test.js
41
+
3
42
  ## 0.1.60 (2026-03-30)
4
43
 
5
44
  ### Engine
@@ -378,3 +378,5 @@ async function ccExecuteAction(action) {
378
378
  msgs.scrollTop = msgs.scrollHeight;
379
379
  refresh();
380
380
  }
381
+
382
+ window.MinionsCC = { toggleCommandCenter, ccNewSession, ccRestoreMessages, ccSaveState, ccUpdateSessionIndicator, ccAddMessage, ccSend, ccExecuteAction };
@@ -68,3 +68,5 @@ function timeSinceStr(date) {
68
68
  if (s < 86400) return Math.floor(s / 3600) + 'h ago';
69
69
  return Math.floor(s / 86400) + 'd ago';
70
70
  }
71
+
72
+ window.MinionsCmdHistory = { cmdGetHistory, cmdSaveHistory, cmdShowHistory, cmdResubmit, timeSinceStr };
@@ -266,3 +266,5 @@ function cmdInsertPopupItem(id) {
266
266
  cmdHidePopup();
267
267
  cmdRenderMeta();
268
268
  }
269
+
270
+ window.MinionsCmdInput = { cmdAutoResize, cmdUpdateHighlight, syncHighlightScroll, cmdInputChanged, cmdKeyDown, cmdSubmit, cmdRenderMeta, cmdShowMentions, cmdShowProjects, cmdHidePopup, cmdInsertPopupItem };
@@ -127,3 +127,5 @@ function cmdParseInput(raw) {
127
127
 
128
128
  return result;
129
129
  }
130
+
131
+ window.MinionsCmdParser = { cmdUpdateAgentList, cmdUpdateProjectList, showToast, detectWorkItemType, cmdParseInput };
@@ -105,3 +105,5 @@ function renderDetailContent(detail, tab) {
105
105
  el.innerHTML = '<div class="section">' + escHtml(detail.outputLog || 'No output log. The coordinator will save agent output here when tasks complete.') + '</div>';
106
106
  }
107
107
  }
108
+
109
+ window.MinionsDetail = { closeDetail, renderDetailTabs, switchTab, renderDetailContent };
@@ -157,3 +157,5 @@ async function sendSteering() {
157
157
  }
158
158
  } catch (e) { alert('Error: ' + e.message); }
159
159
  }
160
+
161
+ window.MinionsLive = { renderLiveChatMessage, startLiveStream, stopLiveStream, startLivePolling, stopLivePolling, refreshLiveOutput, sendSteering };
@@ -307,3 +307,5 @@ async function qaReplacePrd(planFile) {
307
307
  const btn = document.getElementById('qa-generate-prd-btn');
308
308
  if (btn) btn.innerHTML = '<span style="color:var(--orange);font-size:12px">Replacing PRD — old items paused, agent regenerating from revised plan.</span>';
309
309
  }
310
+
311
+ window.MinionsQA = { showModalQa, modalAskAboutSelection, clearQaSelection, clearQaConversation, modalSend, qaNewPrd, qaReplacePrd };
@@ -129,3 +129,5 @@ function renderArchiveButtons(archives) {
129
129
  '<button class="archive-btn" onclick="openArchive(' + i + ')">Archived: ' + escHtml(a.version) + ' (' + a.total + ' items)</button>'
130
130
  ).join(' ');
131
131
  }
132
+
133
+ window.MinionsModal = { closeModal, copyModalContent, showNotifBadge, clearNotifBadge, restoreNotifBadges, findCardForFile, renderArchiveButtons };
@@ -83,3 +83,5 @@ try {
83
83
  const _hotReload = new EventSource('/api/hot-reload');
84
84
  _hotReload.onmessage = (e) => { if (e.data === 'reload') location.reload(); };
85
85
  } catch { /* expected */ }
86
+
87
+ window.MinionsRefresh = { refresh };
@@ -42,3 +42,5 @@ async function openAgentDetail(id) {
42
42
  document.getElementById('detail-overlay').classList.add('open');
43
43
  document.getElementById('detail-panel').classList.add('open');
44
44
  }
45
+
46
+ window.MinionsAgents = { renderAgents, openAgentDetail };
@@ -169,3 +169,5 @@ async function showErrorDetails(agentId, reason, task) {
169
169
  document.getElementById('modal-body').textContent = 'Reason: ' + reason + '\n\n(Could not load agent output)';
170
170
  }
171
171
  }
172
+
173
+ window.MinionsDispatch = { renderEngineStatus, renderEngineAlert, renderDispatch, renderEngineLog, shortTime, showErrorDetails };
@@ -198,3 +198,5 @@ async function doPromoteToKB(name, category) {
198
198
  }
199
199
  } catch (e) { alert('Error: ' + e.message); }
200
200
  }
201
+
202
+ window.MinionsInbox = { renderInbox, promoteToKB, renderNotes, openNotesModal, modalToggleEdit, modalSaveEdit, modalCancelEdit, deleteInboxItem, openInboxInExplorer, openQuickNoteModal, submitQuickNote, doPromoteToKB };
@@ -162,3 +162,5 @@ async function kbOpenItem(category, file) {
162
162
  console.error('Failed to load KB item:', e);
163
163
  }
164
164
  }
165
+
166
+ window.MinionsKb = { refreshKnowledgeBase, renderKnowledgeBase, kbSetTab, kbSweep, openCreateKbModal, submitKbEntry, kbOpenItem };
@@ -180,3 +180,5 @@ function renderTokenUsage(metrics) {
180
180
 
181
181
  el.innerHTML = html;
182
182
  }
183
+
184
+ window.MinionsOther = { renderProjects, renderMcpServers, renderMetrics, renderTokenUsage };
@@ -51,3 +51,5 @@ async function removePinnedNote(title) {
51
51
  refresh();
52
52
  } catch (e) { alert('Error: ' + e.message); }
53
53
  }
54
+
55
+ window.MinionsPinned = { renderPinned, openPinNoteModal, submitPinnedNote, removePinnedNote };
@@ -629,3 +629,5 @@ async function triggerVerify(file) {
629
629
  }
630
630
  } catch (e) { alert('Error: ' + e.message); }
631
631
  }
632
+
633
+ window.MinionsPlans = { openCreatePlanModal, refreshPlans, derivePlanStatus, renderPlans, openArchivedPlansModal, qaDisablePrdButtons, showPlanVersionActions, qaJustSave, planExecute, planSubmitRevise, planShowRevise, planHideRevise, planView, planApprove, planDelete, planPause, planReject, planDiscuss, planOpenInDocChat, planRegeneratePRD, openVerifyGuide, triggerVerify };
@@ -716,3 +716,5 @@ function openArchive(i) {
716
716
  document.getElementById('modal-body').style.whiteSpace = 'normal';
717
717
  document.getElementById('modal').classList.add('open');
718
718
  }
719
+
720
+ window.MinionsPrd = { renderPrd, renderPrdProgress, openArchivedPrdModal, showArchivedPrdDetail, prdItemEdit, prdItemSave, prdItemRemove, prdItemRequeue, prdRegenerate, openArchive };
@@ -143,3 +143,5 @@ async function _submitLinkPr() {
143
143
  }
144
144
  } catch (e) { alert('Error: ' + e.message); }
145
145
  }
146
+
147
+ window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal };
@@ -156,3 +156,5 @@ async function deleteSchedule(id) {
156
156
  }
157
157
  } catch (e) { alert('Delete error: ' + e.message); }
158
158
  }
159
+
160
+ window.MinionsSchedules = { renderSchedules, openCreateScheduleModal, openEditScheduleModal, submitSchedule, toggleScheduleEnabled, deleteSchedule };
@@ -87,3 +87,5 @@ function openSkill(file, source, dir) {
87
87
  })
88
88
  .catch(() => {});
89
89
  }
90
+
91
+ window.MinionsSkills = { renderSkills, openSkill };
@@ -396,3 +396,5 @@ function openAllWorkItems() {
396
396
  document.getElementById('modal-body').style.whiteSpace = 'normal';
397
397
  document.getElementById('modal').classList.add('open');
398
398
  }
399
+
400
+ window.MinionsWork = { wiRow, renderWorkItems, editWorkItem, submitWorkItemEdit, deleteWorkItem, archiveWorkItem, toggleWorkItemArchive, retryWorkItem, wiPrev, wiNext, feedbackWorkItem, submitFeedback, openCreateWorkItemModal, openWorkItemDetail, openAllWorkItems };
@@ -151,3 +151,5 @@ async function addProject() {
151
151
  refresh();
152
152
  } catch (e) { alert('Error: ' + e.message); }
153
153
  }
154
+
155
+ window.MinionsSettings = { openSettings, saveSettings, addProject };
@@ -82,3 +82,5 @@ function rerenderPrdFromCache() {
82
82
  if (!window._lastStatus || !window._lastStatus.prdProgress) return;
83
83
  renderPrd(window._lastStatus.prd, window._lastStatus.prdProgress);
84
84
  }
85
+
86
+ window.MinionsState = { getPageFromUrl, switchPage, getPrdRequeueState, setPrdRequeueState, clearPrdRequeueState, prunePrdRequeueState, rerenderPrdFromCache };
@@ -40,3 +40,5 @@ function copyLlmText(btn) {
40
40
  btn.innerHTML = '&#10003;';
41
41
  setTimeout(() => { btn.innerHTML = '&#x2398;'; }, 1500);
42
42
  }
43
+
44
+ window.MinionsUtils = { wakeEngine, escHtml, normalizePlanFile, timeAgo, statusColor, llmCopyBtn, copyLlmText };
@@ -0,0 +1,393 @@
1
+ /**
2
+ * engine/cleanup.js — Periodic cleanup: temp files, worktrees, zombies, migrations.
3
+ * Extracted from engine.js for modularity. No logic changes.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const shared = require('./shared');
9
+ const queries = require('./queries');
10
+
11
+ const { exec, execSilent } = shared;
12
+ const { safeJson, safeWrite, safeReadDir, getProjects, projectWorkItemsPath, projectPrPath,
13
+ sanitizeBranch, KB_CATEGORIES } = shared;
14
+ const { getDispatch, getAgentStatus } = queries;
15
+
16
+ const MINIONS_DIR = shared.MINIONS_DIR;
17
+ const AGENTS_DIR = queries.AGENTS_DIR;
18
+ const ENGINE_DIR = queries.ENGINE_DIR;
19
+ const PRD_DIR = queries.PRD_DIR;
20
+ const PLANS_DIR = queries.PLANS_DIR;
21
+
22
+ // Lazy require to break circular dependency with engine.js
23
+ let _engine = null;
24
+ function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
25
+ function log(level, msg, meta) { return engine().log(level, msg, meta); }
26
+ function ts() { return engine().ts(); }
27
+
28
+ // Lazy require for dispatch module
29
+ let _dispatch = null;
30
+ function dispatchModule() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }
31
+
32
+ // ─── Cleanup Orchestrator ────────────────────────────────────────────────────
33
+
34
+ function runCleanup(config, verbose = false) {
35
+ const activeProcesses = engine().activeProcesses;
36
+ const projects = getProjects(config);
37
+ let cleaned = { tempFiles: 0, liveOutputs: 0, worktrees: 0, zombies: 0 };
38
+
39
+ // 1. Clean stale temp prompt/sysprompt files (older than 1 hour)
40
+ const oneHourAgo = Date.now() - 3600000;
41
+ try {
42
+ const tmpDir = path.join(ENGINE_DIR, 'tmp');
43
+ const scanDirs = [ENGINE_DIR, ...(fs.existsSync(tmpDir) ? [tmpDir] : [])];
44
+ for (const dir of scanDirs) {
45
+ for (const f of fs.readdirSync(dir)) {
46
+ if (f.startsWith('prompt-') || f.startsWith('sysprompt-') || f.startsWith('tmp-sysprompt-')) {
47
+ const fp = path.join(dir, f);
48
+ try {
49
+ const stat = fs.statSync(fp);
50
+ if (stat.mtimeMs < oneHourAgo) {
51
+ fs.unlinkSync(fp);
52
+ cleaned.tempFiles++;
53
+ }
54
+ } catch { /* cleanup */ }
55
+ }
56
+ }
57
+ }
58
+ } catch (e) { log('warn', 'cleanup temp files: ' + e.message); }
59
+
60
+ // 2. Clean live-output.log for idle agents (not currently working)
61
+ for (const [agentId] of Object.entries(config.agents || {})) {
62
+ const status = getAgentStatus(agentId);
63
+ if (status.status !== 'working') {
64
+ const livePath = path.join(AGENTS_DIR, agentId, 'live-output.log');
65
+ if (fs.existsSync(livePath)) {
66
+ try {
67
+ const stat = fs.statSync(livePath);
68
+ if (stat.mtimeMs < oneHourAgo) {
69
+ fs.unlinkSync(livePath);
70
+ cleaned.liveOutputs++;
71
+ }
72
+ } catch { /* cleanup */ }
73
+ }
74
+ }
75
+ }
76
+
77
+ // 3. Clean git worktrees for merged/abandoned PRs
78
+ for (const project of projects) {
79
+ const root = project.localPath ? path.resolve(project.localPath) : null;
80
+ if (!root || !fs.existsSync(root)) continue;
81
+
82
+ const worktreeRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
83
+ if (!fs.existsSync(worktreeRoot)) continue;
84
+
85
+ // Get PRs for this project
86
+ const prs = safeJson(projectPrPath(project)) || [];
87
+ const mergedBranches = new Set();
88
+ for (const pr of prs) {
89
+ if (pr.status === 'merged' || pr.status === 'abandoned' || pr.status === 'completed') {
90
+ if (pr.branch) mergedBranches.add(pr.branch);
91
+ }
92
+ }
93
+
94
+ // List worktrees — collect info for age-based + cap-based cleanup
95
+ const MAX_WORKTREES = 10;
96
+ try {
97
+ const dirs = fs.readdirSync(worktreeRoot);
98
+ const wtEntries = []; // { dir, wtPath, mtime, shouldClean, isProtected }
99
+ const dispatch = getDispatch();
100
+
101
+ for (const dir of dirs) {
102
+ const wtPath = path.join(worktreeRoot, dir);
103
+ try { if (!fs.statSync(wtPath).isDirectory()) continue; } catch { continue; }
104
+
105
+ let shouldClean = false;
106
+ let isProtected = false;
107
+
108
+ // Check if this worktree's branch is merged/abandoned
109
+ // Use sanitized exact match on the branch portion of the dir name (format: {slug}-{branch}-{suffix})
110
+ const dirLower = dir.toLowerCase();
111
+ for (const branch of mergedBranches) {
112
+ const branchSlug = sanitizeBranch(branch).toLowerCase();
113
+ if (dirLower === branchSlug || dirLower.includes(branchSlug + '-') || dirLower.endsWith('-' + branchSlug)) {
114
+ shouldClean = true;
115
+ break;
116
+ }
117
+ }
118
+
119
+ // Check if referenced by active/pending dispatch (use sanitized branch comparison)
120
+ const isReferenced = [...dispatch.pending, ...(dispatch.active || [])].some(d => {
121
+ if (!d.meta?.branch) return false;
122
+ const dispBranch = sanitizeBranch(d.meta.branch).toLowerCase();
123
+ return dirLower.includes(dispBranch);
124
+ });
125
+ if (isReferenced) isProtected = true;
126
+
127
+ // Also clean worktrees older than 2 hours with no active dispatch referencing them
128
+ let mtime = Date.now();
129
+ if (!shouldClean) {
130
+ try {
131
+ const stat = fs.statSync(wtPath);
132
+ mtime = stat.mtimeMs;
133
+ const ageMs = Date.now() - mtime;
134
+ if (ageMs > 7200000 && !isReferenced) { // 2 hours
135
+ shouldClean = true;
136
+ }
137
+ } catch { /* optional */ }
138
+ }
139
+
140
+ // Skip worktrees for active shared-branch plans (check both prd/ and plans/ for .json PRDs)
141
+ if (shouldClean || !isProtected) {
142
+ try {
143
+ for (const checkDir of [PRD_DIR, path.join(MINIONS_DIR, 'plans')]) {
144
+ if (!fs.existsSync(checkDir)) continue;
145
+ for (const pf of fs.readdirSync(checkDir).filter(f => f.endsWith('.json'))) {
146
+ const plan = safeJson(path.join(checkDir, pf));
147
+ if (plan?.branch_strategy === 'shared-branch' && plan?.feature_branch && plan?.status !== 'completed') {
148
+ const planBranch = sanitizeBranch(plan.feature_branch).toLowerCase();
149
+ if (dirLower.includes(planBranch)) {
150
+ isProtected = true;
151
+ if (shouldClean) {
152
+ shouldClean = false;
153
+ if (verbose) console.log(` Skipping worktree ${dir}: active shared-branch plan`);
154
+ }
155
+ break;
156
+ }
157
+ }
158
+ }
159
+ if (isProtected) break;
160
+ }
161
+ } catch (e) { log('warn', 'check shared-branch protection: ' + e.message); }
162
+ }
163
+
164
+ wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected });
165
+ }
166
+
167
+ // Enforce max worktree cap — if over limit, mark oldest unprotected for cleanup
168
+ const surviving = wtEntries.filter(e => !e.shouldClean && !e.isProtected);
169
+ if (surviving.length + wtEntries.filter(e => e.isProtected).length > MAX_WORKTREES) {
170
+ // Sort oldest first
171
+ surviving.sort((a, b) => a.mtime - b.mtime);
172
+ const excess = surviving.length + wtEntries.filter(e => e.isProtected).length - MAX_WORKTREES;
173
+ for (let i = 0; i < Math.min(excess, surviving.length); i++) {
174
+ surviving[i].shouldClean = true;
175
+ if (verbose) console.log(` Marking worktree ${surviving[i].dir} for cap cleanup (${MAX_WORKTREES} max)`);
176
+ }
177
+ }
178
+
179
+ // Remove all marked worktrees
180
+ for (const entry of wtEntries) {
181
+ if (entry.shouldClean) {
182
+ try {
183
+ exec(`git worktree remove "${entry.wtPath}" --force`, { cwd: root, stdio: 'pipe' });
184
+ cleaned.worktrees++;
185
+ if (verbose) console.log(` Removed worktree: ${entry.wtPath}`);
186
+ } catch (e) {
187
+ if (verbose) console.log(` Failed to remove worktree ${entry.wtPath}: ${e.message}`);
188
+ }
189
+ }
190
+ }
191
+ } catch (e) { log('warn', 'cleanup worktrees: ' + e.message); }
192
+ }
193
+
194
+ // 4. Kill zombie claude processes not tracked by the engine
195
+ // List all node processes, check if any are running spawn-agent.js for our minions
196
+ try {
197
+ const dispatch = getDispatch();
198
+ const activePids = new Set();
199
+ for (const [, info] of activeProcesses.entries()) {
200
+ if (info.proc?.pid) activePids.add(info.proc.pid);
201
+ }
202
+
203
+ // Clean individual orphaned processes — no matching active dispatch
204
+ const activeIds = new Set((dispatch.active || []).map(d => d.id));
205
+ for (const [id, info] of activeProcesses.entries()) {
206
+ if (!activeIds.has(id)) {
207
+ try { if (info.proc) info.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
208
+ activeProcesses.delete(id);
209
+ cleaned.zombies++;
210
+ }
211
+ }
212
+ } catch (e) { log('warn', 'cleanup zombie processes: ' + e.message); }
213
+
214
+ // 5. Clean spawn-debug.log
215
+ try { fs.unlinkSync(path.join(ENGINE_DIR, 'spawn-debug.log')); } catch { /* cleanup */ }
216
+
217
+ // 6. Prune old output archive files (keep last 30 per agent)
218
+ for (const agentId of Object.keys(config.agents || {})) {
219
+ const agentDir = path.join(MINIONS_DIR, 'agents', agentId);
220
+ if (!fs.existsSync(agentDir)) continue;
221
+ try {
222
+ const outputFiles = fs.readdirSync(agentDir)
223
+ .filter(f => f.startsWith('output-') && f.endsWith('.log') && f !== 'output.log')
224
+ .map(f => ({ name: f, mtime: fs.statSync(path.join(agentDir, f)).mtimeMs }))
225
+ .sort((a, b) => b.mtime - a.mtime);
226
+ for (const old of outputFiles.slice(30)) {
227
+ try { fs.unlinkSync(path.join(agentDir, old.name)); cleaned.files++; } catch { /* cleanup */ }
228
+ }
229
+ } catch (e) { log('warn', 'prune output archives: ' + e.message); }
230
+ }
231
+
232
+ // 7. Prune orphaned dispatch entries — items whose source work item no longer exists
233
+ cleaned.orphanedDispatches = 0;
234
+ try {
235
+ const dispatch = getDispatch();
236
+ // Collect all work item IDs across all sources
237
+ const allWiIds = new Set();
238
+ try {
239
+ const central = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
240
+ central.forEach(w => allWiIds.add(w.id));
241
+ } catch (e) { log('warn', 'read central work items for orphan check: ' + e.message); }
242
+ for (const project of projects) {
243
+ try {
244
+ const projItems = safeJson(projectWorkItemsPath(project)) || [];
245
+ projItems.forEach(w => allWiIds.add(w.id));
246
+ } catch (e) { log('warn', 'read project work items for orphan check: ' + e.message); }
247
+ }
248
+
249
+ let changed = false;
250
+ for (const queue of ['pending', 'active']) {
251
+ if (!dispatch[queue]) continue;
252
+ const before = dispatch[queue].length;
253
+ dispatch[queue] = dispatch[queue].filter(d => {
254
+ const itemId = d.meta?.item?.id;
255
+ if (!itemId) return true; // keep entries without item tracking
256
+ return allWiIds.has(itemId);
257
+ });
258
+ const removed = before - dispatch[queue].length;
259
+ if (removed > 0) {
260
+ cleaned.orphanedDispatches += removed;
261
+ changed = true;
262
+ }
263
+ }
264
+ if (changed) {
265
+ const { mutateDispatch } = dispatchModule();
266
+ mutateDispatch((dp) => {
267
+ for (const queue of ['pending', 'active']) {
268
+ if (!dp[queue]) continue;
269
+ dp[queue] = dp[queue].filter(d => {
270
+ const itemId = d.meta?.item?.id;
271
+ if (!itemId) return true;
272
+ return allWiIds.has(itemId);
273
+ });
274
+ }
275
+ });
276
+ }
277
+ } catch (e) { log('warn', 'prune orphaned dispatches: ' + e.message); }
278
+
279
+ if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies + (cleaned.files || 0) + cleaned.orphanedDispatches > 0) {
280
+ log('info', `Cleanup: ${cleaned.tempFiles} temp, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies, ${cleaned.files || 0} archives, ${cleaned.orphanedDispatches} orphaned dispatches`);
281
+ }
282
+
283
+ // 8. Clean swept KB files older than 7 days
284
+ try {
285
+ const sweptDir = path.join(MINIONS_DIR, 'knowledge', '_swept');
286
+ if (fs.existsSync(sweptDir)) {
287
+ const sevenDaysAgo = Date.now() - 7 * 86400000;
288
+ for (const f of fs.readdirSync(sweptDir)) {
289
+ try {
290
+ const fp = path.join(sweptDir, f);
291
+ if (fs.statSync(fp).mtimeMs < sevenDaysAgo) {
292
+ fs.unlinkSync(fp);
293
+ if (!cleaned.sweptKb) cleaned.sweptKb = 0;
294
+ cleaned.sweptKb++;
295
+ }
296
+ } catch { /* cleanup */ }
297
+ }
298
+ }
299
+ } catch (e) { log('warn', 'cleanup swept KB files: ' + e.message); }
300
+
301
+ // 9. KB watchdog — restore deleted KB files from git if count dropped vs checkpoint
302
+ try {
303
+ const checkpoint = safeJson(path.join(ENGINE_DIR, 'kb-checkpoint.json'));
304
+ if (checkpoint && checkpoint.count > 0) {
305
+ const cats = KB_CATEGORIES;
306
+ const knowledgeDir = path.join(MINIONS_DIR, 'knowledge');
307
+ let current = 0;
308
+ for (const cat of cats) {
309
+ const d = path.join(knowledgeDir, cat);
310
+ if (fs.existsSync(d)) current += fs.readdirSync(d).length;
311
+ }
312
+ if (current < checkpoint.count) {
313
+ log('warn', `KB watchdog: file count dropped ${checkpoint.count} → ${current}, restoring from git`);
314
+ try {
315
+ const trackedCheck = execSilent('git ls-tree --name-only HEAD -- knowledge', { cwd: MINIONS_DIR }).toString().trim();
316
+ if (!trackedCheck) {
317
+ log('warn', 'KB watchdog: knowledge/ is not tracked in git HEAD — skipping restore');
318
+ } else {
319
+ execSilent('git checkout HEAD -- knowledge', { cwd: MINIONS_DIR });
320
+ log('info', 'KB watchdog: restored knowledge/ from git HEAD');
321
+ }
322
+ } catch (err) {
323
+ log('error', `KB watchdog: git restore failed — ${err.message}`);
324
+ }
325
+ }
326
+ }
327
+ } catch (e) { log('warn', 'KB watchdog check: ' + e.message); }
328
+
329
+ // 6. Migrate legacy work-item statuses to canonical values
330
+ // in-pr, implemented, complete → done (one-time correction per item)
331
+ const LEGACY_DONE_STATUSES = new Set(['in-pr', 'implemented', 'complete']);
332
+ for (const project of projects) {
333
+ try {
334
+ const wiPath = projectWorkItemsPath(project);
335
+ const items = safeJson(wiPath) || [];
336
+ let migrated = 0;
337
+ for (const item of items) {
338
+ if (LEGACY_DONE_STATUSES.has(item.status)) {
339
+ item.status = 'done';
340
+ migrated++;
341
+ }
342
+ }
343
+ if (migrated > 0) {
344
+ safeWrite(wiPath, items);
345
+ log('info', `Migrated ${migrated} legacy status(es) → done in ${project.name} work items`);
346
+ }
347
+ } catch (e) { log('warn', 'migrate legacy statuses: ' + e.message); }
348
+ }
349
+ // Central work items
350
+ try {
351
+ const centralPath = path.join(MINIONS_DIR, 'work-items.json');
352
+ const centralItems = safeJson(centralPath) || [];
353
+ let migrated = 0;
354
+ for (const item of centralItems) {
355
+ if (LEGACY_DONE_STATUSES.has(item.status)) {
356
+ item.status = 'done';
357
+ migrated++;
358
+ }
359
+ }
360
+ if (migrated > 0) {
361
+ safeWrite(centralPath, centralItems);
362
+ log('info', `Migrated ${migrated} legacy status(es) → done in central work items`);
363
+ }
364
+ } catch (e) { log('warn', 'migrate central legacy statuses: ' + e.message); }
365
+ // PRD items (missing_features[].status)
366
+ try {
367
+ const prdFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json'));
368
+ for (const pf of prdFiles) {
369
+ const prdPath = path.join(PRD_DIR, pf);
370
+ const prd = safeJson(prdPath);
371
+ if (!prd?.missing_features) continue;
372
+ let migrated = 0;
373
+ for (const feat of prd.missing_features) {
374
+ if (LEGACY_DONE_STATUSES.has(feat.status)) {
375
+ feat.status = 'done';
376
+ migrated++;
377
+ }
378
+ }
379
+ if (migrated > 0) {
380
+ safeWrite(prdPath, prd);
381
+ log('info', `Migrated ${migrated} legacy PRD item status(es) → done in ${pf}`);
382
+ }
383
+ }
384
+ } catch (e) { log('warn', 'migrate PRD legacy statuses: ' + e.message); }
385
+
386
+ return cleaned;
387
+ }
388
+
389
+ // ─── Exports ─────────────────────────────────────────────────────────────────
390
+
391
+ module.exports = {
392
+ runCleanup,
393
+ };