@yemi33/minions 0.1.2042 → 0.1.2044

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.
@@ -67,7 +67,7 @@ const RENDER_VERSIONS = {
67
67
  agents: 1,
68
68
  prdProgress: 1,
69
69
  prdPrs: 1,
70
- inbox: 1,
70
+ inbox: 2,
71
71
  projects: 1,
72
72
  notes: 1,
73
73
  prd: 1,
@@ -77,8 +77,8 @@ const RENDER_VERSIONS = {
77
77
  version: 1,
78
78
  adoThrottle: 1,
79
79
  ghThrottle: 1,
80
- dispatch: 1,
81
- engineLog: 1,
80
+ dispatch: 2,
81
+ engineLog: 2,
82
82
  metrics: 1,
83
83
  workItems: 1,
84
84
  skills: 1,
@@ -88,6 +88,7 @@ const RENDER_VERSIONS = {
88
88
  meetings: 1,
89
89
  pipelines: 1,
90
90
  pinned: 1,
91
+ kbPayload: 2,
91
92
  };
92
93
  const _sectionCache = {};
93
94
  const _lastValueByKey = {};
@@ -520,7 +521,9 @@ window.MinionsRefresh = { refresh };
520
521
  chip.type = 'button';
521
522
  chip.textContent = 'diag';
522
523
  chip.title = 'Open dashboard refresh diagnostics';
523
- chip.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9999;background:var(--surface,#222);color:var(--muted,#aaa);border:1px solid var(--border,#444);border-radius:10px;padding:3px 8px;font-size:10px;font-family:inherit;cursor:pointer;opacity:0.75';
524
+ // Bottom-LEFT (not right) to avoid overlapping the Command Center Send
525
+ // button when the CC drawer is open (the drawer covers the right edge).
526
+ chip.style.cssText = 'position:fixed;left:12px;bottom:12px;z-index:9999;background:var(--surface,#222);color:var(--muted,#aaa);border:1px solid var(--border,#444);border-radius:10px;padding:3px 8px;font-size:10px;font-family:inherit;cursor:pointer;opacity:0.75';
524
527
  chip.addEventListener('click', _openRefreshDiagModal);
525
528
  document.body.appendChild(chip);
526
529
  } catch { /* DOM may not be ready in unusual test embeds */ }
@@ -191,19 +191,24 @@ function renderDispatch(dispatch) {
191
191
  '<div class="dispatch-stat"><div class="dispatch-stat-num blue">' + (dispatch.pending || []).length + '</div><div class="dispatch-stat-label">Pending</div></div>' +
192
192
  '<div class="dispatch-stat"><div class="dispatch-stat-num green">' + (dispatch.completedTotal || (dispatch.completed || []).length) + '</div><div class="dispatch-stat-label">Completed</div></div>';
193
193
 
194
+ // Shared row markup for the Active and Pending lists. The two differ only in
195
+ // the trailing chip: Active shows started_at, Pending shows skipReason.
196
+ const dispatchItemHtml = (d, trailing) =>
197
+ '<div class="dispatch-item">' +
198
+ '<span class="dispatch-type ' + (d.type || '') + '">' + escHtml(d.type || '') + '</span>' +
199
+ '<span class="dispatch-agent">' + escHtml(d.agentName || d.agent || '') + '</span>' +
200
+ '<span class="dispatch-task" title="' + escHtml(d.task || '') + '">' + escHtml(d.task || '') + '</span>' +
201
+ renderStuckChip(d) +
202
+ trailing +
203
+ '</div>';
204
+
194
205
  // Active
195
206
  const activeEl = document.getElementById('dispatch-active');
196
207
  if ((dispatch.active || []).length > 0) {
197
208
  activeEl.innerHTML = '<div style="font-size:11px;color:var(--green);margin-bottom:6px;font-weight:600">ACTIVE</div><div class="dispatch-list">' +
198
- dispatch.active.map(d =>
199
- '<div class="dispatch-item">' +
200
- '<span class="dispatch-type ' + (d.type || '') + '">' + escHtml(d.type || '') + '</span>' +
201
- '<span class="dispatch-agent">' + escHtml(d.agentName || d.agent || '') + '</span>' +
202
- '<span class="dispatch-task" title="' + escHtml(d.task || '') + '">' + escHtml(d.task || '') + '</span>' +
203
- renderStuckChip(d) +
204
- '<span class="dispatch-time">' + shortTime(d.started_at) + '</span>' +
205
- '</div>'
206
- ).join('') + '</div>';
209
+ dispatch.active.map(d => dispatchItemHtml(d,
210
+ '<span class="dispatch-time">' + shortTime(d.started_at) + '</span>'
211
+ )).join('') + '</div>';
207
212
  } else {
208
213
  activeEl.innerHTML = '<div style="color:var(--muted);font-size:11px;margin-bottom:8px">No active dispatches</div>';
209
214
  }
@@ -212,15 +217,9 @@ function renderDispatch(dispatch) {
212
217
  const pendingEl = document.getElementById('dispatch-pending');
213
218
  if ((dispatch.pending || []).length > 0) {
214
219
  pendingEl.innerHTML = '<div style="font-size:11px;color:var(--yellow);margin:8px 0 6px;font-weight:600">PENDING</div><div class="dispatch-list">' +
215
- dispatch.pending.map(d =>
216
- '<div class="dispatch-item">' +
217
- '<span class="dispatch-type ' + (d.type || '') + '">' + escHtml(d.type || '') + '</span>' +
218
- '<span class="dispatch-agent">' + escHtml(d.agentName || d.agent || '') + '</span>' +
219
- '<span class="dispatch-task" title="' + escHtml(d.task || '') + '">' + escHtml(d.task || '') + '</span>' +
220
- renderStuckChip(d) +
221
- (d.skipReason ? '<span style="font-size:9px;color:var(--muted);margin-left:6px" title="' + escHtml(d.skipReason) + '">' + escHtml(d.skipReason.replace(/_/g, ' ')) + '</span>' : '') +
222
- '</div>'
223
- ).join('') + '</div>';
220
+ dispatch.pending.map(d => dispatchItemHtml(d,
221
+ d.skipReason ? '<span style="font-size:9px;color:var(--muted);margin-left:6px" title="' + escHtml(d.skipReason) + '">' + escHtml(d.skipReason.replace(/_/g, ' ')) + '</span>' : ''
222
+ )).join('') + '</div>';
224
223
  } else {
225
224
  pendingEl.innerHTML = '';
226
225
  }
@@ -254,14 +253,12 @@ function renderDispatch(dispatch) {
254
253
  '<td class="pr-date">' + shortTime(d.completed_at) + '</td>' +
255
254
  '</tr>';
256
255
  }).join('') + '</tbody></table>';
257
- if (completed.length > COMPLETED_PER_PAGE) {
258
- completedEl.insertAdjacentHTML('beforeend', '<div class="pr-pager">' +
259
- '<span class="pr-page-info">Showing ' + (compStart + 1) + ' to ' + Math.min(compStart + COMPLETED_PER_PAGE, completed.length) + ' of ' + completed.length + '</span>' +
260
- '<div class="pr-pager-btns">' +
261
- '<button class="pr-pager-btn ' + (_completedPage === 0 ? 'disabled' : '') + '" onclick="_completedPrev()">Prev</button>' +
262
- '<button class="pr-pager-btn ' + (_completedPage >= totalCompPages - 1 ? 'disabled' : '') + '" onclick="_completedNext()">Next</button>' +
263
- '</div></div>');
264
- }
256
+ const compPager = renderPager({
257
+ start: compStart, perPage: COMPLETED_PER_PAGE, total: completed.length,
258
+ page: _completedPage, totalPages: totalCompPages,
259
+ onPrev: '_completedPrev()', onNext: '_completedNext()',
260
+ });
261
+ if (compPager) completedEl.insertAdjacentHTML('beforeend', compPager);
265
262
  } else {
266
263
  completedEl.innerHTML = '<p class="empty">No completed dispatches yet.</p>';
267
264
  }
@@ -288,14 +285,12 @@ function renderEngineLog(log) {
288
285
  escHtml(e.message || '') +
289
286
  '</div>'
290
287
  ).join('');
291
- if (reversed.length > LOG_PER_PAGE) {
292
- el.insertAdjacentHTML('beforeend', '<div class="pr-pager">' +
293
- '<span class="pr-page-info">Showing ' + (logStart + 1) + ' to ' + Math.min(logStart + LOG_PER_PAGE, reversed.length) + ' of ' + reversed.length + '</span>' +
294
- '<div class="pr-pager-btns">' +
295
- '<button class="pr-pager-btn ' + (_logPage === 0 ? 'disabled' : '') + '" onclick="_logPrev()">Prev</button>' +
296
- '<button class="pr-pager-btn ' + (_logPage >= totalLogPages - 1 ? 'disabled' : '') + '" onclick="_logNext()">Next</button>' +
297
- '</div></div>');
298
- }
288
+ const logPager = renderPager({
289
+ start: logStart, perPage: LOG_PER_PAGE, total: reversed.length,
290
+ page: _logPage, totalPages: totalLogPages,
291
+ onPrev: '_logPrev()', onNext: '_logNext()',
292
+ });
293
+ if (logPager) el.insertAdjacentHTML('beforeend', logPager);
299
294
  }
300
295
 
301
296
  function shortTime(t) {
@@ -35,21 +35,19 @@ function renderInbox(inbox) {
35
35
  </div>
36
36
  <div class="inbox-preview" onclick="openModal(${idx})" style="cursor:pointer">${escapeHtml(item.content.slice(0,200))}</div>
37
37
  <div style="display:flex;gap:6px;margin-top:6px;align-items:center">
38
- <button class="pr-pager-btn pin-btn${pinned ? ' pinned' : ''}" style="font-size:9px;padding:2px 8px" data-pin-key="${escapeHtml(pk)}" onclick="event.stopPropagation();_togglePinAndRefresh(this.dataset.pinKey,'inbox')">${pinned ? 'Unpin' : 'Pin'}</button>
38
+ ${pinButton(pk, pinned, 'inbox')}
39
39
  <button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" data-inbox-name="${escapeHtml(item.name)}" onclick="event.stopPropagation();promoteToKB(this.dataset.inboxName)">Add to Knowledge Base</button>
40
40
  <button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" data-inbox-name="${escapeHtml(item.name)}" onclick="event.stopPropagation();openInboxInExplorer(this.dataset.inboxName)">Open in Explorer</button>
41
41
  <button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red)" data-inbox-name="${escapeHtml(item.name)}" onclick="event.stopPropagation();deleteInboxItem(this.dataset.inboxName)">Delete</button>
42
42
  </div>
43
43
  </div>`;
44
44
  }).join('');
45
- if (inbox.length > INBOX_PER_PAGE) {
46
- list.insertAdjacentHTML('beforeend', '<div class="pr-pager">' +
47
- '<span class="pr-page-info">Showing ' + (inboxStart + 1) + ' to ' + Math.min(inboxStart + INBOX_PER_PAGE, inbox.length) + ' of ' + inbox.length + '</span>' +
48
- '<div class="pr-pager-btns">' +
49
- '<button class="pr-pager-btn ' + (_inboxPage === 0 ? 'disabled' : '') + '" onclick="_inboxPrev()">Prev</button>' +
50
- '<button class="pr-pager-btn ' + (_inboxPage >= totalInboxPages - 1 ? 'disabled' : '') + '" onclick="_inboxNext()">Next</button>' +
51
- '</div></div>');
52
- }
45
+ const inboxPager = renderPager({
46
+ start: inboxStart, perPage: INBOX_PER_PAGE, total: inbox.length,
47
+ page: _inboxPage, totalPages: totalInboxPages,
48
+ onPrev: '_inboxPrev()', onNext: '_inboxNext()',
49
+ });
50
+ if (inboxPager) list.insertAdjacentHTML('beforeend', inboxPager);
53
51
  restoreNotifBadges();
54
52
  }
55
53
 
@@ -142,7 +142,7 @@ function renderKnowledgeBase() {
142
142
  return '<div class="kb-item' + (pinned ? ' item-pinned' : '') + '" data-file="knowledge/' + escapeHtml(item.category) + '/' + escapeHtml(item.file) + '" onclick="kbOpenItem(\'' + escapeHtml(item.category) + '\', \'' + escapeHtml(item.file) + '\')">' +
143
143
  '<div class="kb-item-body">' +
144
144
  '<div class="kb-item-title">' + icon + ' ' + escapeHtml(item.title) +
145
- ' <button class="pr-pager-btn pin-btn' + (pinned ? ' pinned' : '') + '" style="font-size:9px;padding:1px 6px;margin-left:6px;vertical-align:middle" data-pin-key="' + escapeHtml(pinKey) + '" onclick="event.stopPropagation();_togglePinAndRefresh(this.dataset.pinKey,\'kb\')">' + (pinned ? 'Unpin' : 'Pin') + '</button>' +
145
+ ' ' + pinButton(pinKey, pinned, 'kb', { extraStyle: 'padding:1px 6px;margin-left:6px;vertical-align:middle' }) +
146
146
  '</div>' +
147
147
  '<div class="kb-item-meta">' +
148
148
  '<span>' + label + '</span>' +
@@ -154,14 +154,12 @@ function renderKnowledgeBase() {
154
154
  '</div>' +
155
155
  '</div>';
156
156
  }).join('');
157
- if (items.length > KB_PER_PAGE) {
158
- listEl.insertAdjacentHTML('beforeend', '<div class="pr-pager">' +
159
- '<span class="pr-page-info">Showing ' + (kbStart + 1) + ' to ' + Math.min(kbStart + KB_PER_PAGE, items.length) + ' of ' + items.length + '</span>' +
160
- '<div class="pr-pager-btns">' +
161
- '<button class="pr-pager-btn ' + (_kbPage === 0 ? 'disabled' : '') + '" onclick="_kbPrev()">Prev</button>' +
162
- '<button class="pr-pager-btn ' + (_kbPage >= totalKbPages - 1 ? 'disabled' : '') + '" onclick="_kbNext()">Next</button>' +
163
- '</div></div>');
164
- }
157
+ const kbPager = renderPager({
158
+ start: kbStart, perPage: KB_PER_PAGE, total: items.length,
159
+ page: _kbPage, totalPages: totalKbPages,
160
+ onPrev: '_kbPrev()', onNext: '_kbNext()',
161
+ });
162
+ if (kbPager) listEl.insertAdjacentHTML('beforeend', kbPager);
165
163
  restoreNotifBadges();
166
164
  }
167
165
 
@@ -352,4 +352,56 @@ function renderAgentOutput(text) {
352
352
  return fragments.join('');
353
353
  }
354
354
 
355
- window.MinionsRenderUtils = { formatToolSummary, renderAgentOutput };
355
+ /**
356
+ * Standard "Prev / Next" pager HTML for a paginated list. Used by inbox, KB,
357
+ * completed dispatches, and engine log. The caller is responsible for the
358
+ * `onPrev`/`onNext` JS expression strings — they're inlined as `onclick`
359
+ * attributes, so callers must pass safe identifier-only expressions
360
+ * (e.g. `'_inboxPrev()'`).
361
+ *
362
+ * @param {object} opts
363
+ * @param {number} opts.start - Zero-based start index of the current page.
364
+ * @param {number} opts.perPage - Items per page.
365
+ * @param {number} opts.total - Total items across all pages.
366
+ * @param {number} opts.page - Zero-based current page index.
367
+ * @param {number} opts.totalPages - Total page count.
368
+ * @param {string} opts.onPrev - JS expression for the Prev button's onclick.
369
+ * @param {string} opts.onNext - JS expression for the Next button's onclick.
370
+ * @returns {string} HTML string for the pager (empty when total <= perPage).
371
+ */
372
+ function renderPager(opts) {
373
+ var start = opts.start, perPage = opts.perPage, total = opts.total;
374
+ var page = opts.page, totalPages = opts.totalPages;
375
+ if (total <= perPage) return '';
376
+ return '<div class="pr-pager">' +
377
+ '<span class="pr-page-info">Showing ' + (start + 1) + ' to ' + Math.min(start + perPage, total) + ' of ' + total + '</span>' +
378
+ '<div class="pr-pager-btns">' +
379
+ '<button class="pr-pager-btn ' + (page === 0 ? 'disabled' : '') + '" onclick="' + opts.onPrev + '">Prev</button>' +
380
+ '<button class="pr-pager-btn ' + (page >= totalPages - 1 ? 'disabled' : '') + '" onclick="' + opts.onNext + '">Next</button>' +
381
+ '</div></div>';
382
+ }
383
+
384
+ /**
385
+ * Renders the standard Pin/Unpin chip used in the inbox and KB lists. Both
386
+ * call _togglePinAndRefresh(pinKey, source) on click; the only thing that
387
+ * varies is the source name. The data-pin-key attribute is escaped via
388
+ * escHtml; callers must pass the already-computed pin key.
389
+ *
390
+ * @param {string} pinKey - The pin storage key (e.g. inboxPinKey(name)).
391
+ * @param {boolean} pinned - Current pinned state.
392
+ * @param {string} source - The source name passed to _togglePinAndRefresh
393
+ * (e.g. 'inbox', 'kb').
394
+ * @param {object} [opts]
395
+ * @param {string} [opts.extraStyle] - Inline CSS appended to the button.
396
+ * @returns {string} HTML for the pin button.
397
+ */
398
+ function pinButton(pinKey, pinned, source, opts) {
399
+ var extraStyle = (opts && opts.extraStyle) || '';
400
+ return '<button class="pr-pager-btn pin-btn' + (pinned ? ' pinned' : '') +
401
+ '" style="font-size:9px;padding:2px 8px;' + extraStyle +
402
+ '" data-pin-key="' + escHtml(pinKey) +
403
+ '" onclick="event.stopPropagation();_togglePinAndRefresh(this.dataset.pinKey,\'' + source + '\')">' +
404
+ (pinned ? 'Unpin' : 'Pin') + '</button>';
405
+ }
406
+
407
+ window.MinionsRenderUtils = { formatToolSummary, renderAgentOutput, renderPager, pinButton };
@@ -9,11 +9,11 @@ const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
  const { setCooldown, setCooldownFailure } = require('./cooldown');
11
11
 
12
- const { safeJson, mutateJsonFileLocked, mutateWorkItems,
13
- mutatePullRequests, getProjects, projectWorkItemsPath, projectPrPath, log, ts, dateStamp,
12
+ const { safeJsonArr, mutateJsonFileLocked, mutateWorkItems,
13
+ mutatePullRequests, getProjects, projectPrPath, log, ts, dateStamp,
14
14
  sidecarDispatchPrompt, deleteDispatchPromptSidecar,
15
15
  WI_STATUS, WORK_TYPE, DISPATCH_RESULT, ENGINE_DEFAULTS, AGENT_STATUS, FAILURE_CLASS, PR_STATUS } = shared;
16
- const { getConfig, getDispatch, DISPATCH_PATH, INBOX_DIR } = queries;
16
+ const { getConfig, DISPATCH_PATH, INBOX_DIR } = queries;
17
17
 
18
18
  const MINIONS_DIR = shared.MINIONS_DIR;
19
19
 
@@ -149,10 +149,13 @@ function addToDispatch(item) {
149
149
  }
150
150
  let added = false;
151
151
  mutateDispatch((dispatch) => {
152
+ // Walked once per addToDispatch — all three dedup checks below find against the same set.
153
+ const queued = [...dispatch.pending, ...(dispatch.active || [])];
154
+
152
155
  // Dedup: skip if same work item ID is already pending or active
153
156
  const wiId = item.meta?.source === 'central-work-item-fanout' ? null : item.meta?.item?.id;
154
157
  if (wiId) {
155
- const existing = [...dispatch.pending, ...(dispatch.active || [])].find(d => d.meta?.item?.id === wiId);
158
+ const existing = queued.find(d => d.meta?.item?.id === wiId);
156
159
  if (existing) {
157
160
  log('info', `Dedup: skipping ${item.id} — work item ${wiId} already in ${existing.id}`);
158
161
  return dispatch;
@@ -160,7 +163,7 @@ function addToDispatch(item) {
160
163
  }
161
164
  // Also dedup by dispatchKey
162
165
  if (item.meta?.dispatchKey) {
163
- const existing = [...dispatch.pending, ...(dispatch.active || [])].find(d => d.meta?.dispatchKey === item.meta.dispatchKey);
166
+ const existing = queued.find(d => d.meta?.dispatchKey === item.meta.dispatchKey);
164
167
  if (existing) {
165
168
  log('info', `Dedup: skipping ${item.id} — dispatchKey ${item.meta.dispatchKey} already in ${existing.id}`);
166
169
  return dispatch;
@@ -168,8 +171,7 @@ function addToDispatch(item) {
168
171
  }
169
172
  const prDedupeKey = getPrDispatchDedupeKey(item);
170
173
  if (prDedupeKey) {
171
- const existing = [...dispatch.pending, ...(dispatch.active || [])]
172
- .find(d => getPrDispatchDedupeKey(d) === prDedupeKey);
174
+ const existing = queued.find(d => getPrDispatchDedupeKey(d) === prDedupeKey);
173
175
  if (existing) {
174
176
  log('info', `Dedup: skipping ${item.id} — PR dispatch ${prDedupeKey} already in ${existing.id}`);
175
177
  return dispatch;
@@ -453,8 +455,8 @@ function readLiveWorkItem(meta) {
453
455
  if (!itemId) return null;
454
456
  const wiPath = lifecycle().resolveWorkItemPath(meta);
455
457
  if (!wiPath) return null;
456
- const items = safeJson(wiPath) || [];
457
- return Array.isArray(items) ? items.find(i => i.id === itemId) || null : null;
458
+ const items = safeJsonArr(wiPath);
459
+ return items.find(i => i.id === itemId) || null;
458
460
  }
459
461
 
460
462
  function writeFailedAgentReport(item, reason, resultSummary, failureClass) {
@@ -882,14 +884,13 @@ function cancelPendingDispatchesForPr(prId) {
882
884
  * @returns {number} count of removed entries
883
885
  */
884
886
  function cleanDispatchEntries(matchFn) {
885
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
886
887
  const tmpDir = path.join(MINIONS_DIR, 'engine', 'tmp');
887
888
  let removed = 0;
888
889
  const pidsToKill = [];
889
890
  const filesToDelete = [];
890
891
  const dispatchDirsToRemove = [];
891
892
  try {
892
- mutateJsonFileLocked(dispatchPath, (dispatch) => {
893
+ mutateJsonFileLocked(DISPATCH_PATH, (dispatch) => {
893
894
  for (const queue of ['pending', 'active', 'completed']) {
894
895
  dispatch[queue] = Array.isArray(dispatch[queue]) ? dispatch[queue] : [];
895
896
  const before = dispatch[queue].length;
@@ -7,7 +7,7 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
9
  const shared = require('./shared');
10
- const { safeRead, safeJson, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execAsync, projectPrPath, getPrLinks,
10
+ const { safeRead, safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execAsync, projectPrPath, getPrLinks,
11
11
  log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PR_STATUS, DISPATCH_RESULT,
12
12
  ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
13
13
  const { trackEngineUsage } = require('./llm');
@@ -113,7 +113,7 @@ function checkPlanCompletion(meta, config) {
113
113
  for (const p of projects) {
114
114
  try {
115
115
  const prPath = shared.projectPrPath(p);
116
- const prs = safeJson(prPath) || [];
116
+ const prs = safeJsonArr(prPath);
117
117
  const prLinks = getPrLinks();
118
118
  for (const pr of prs) {
119
119
  const linkedItemIds = prLinks[pr.id] || [];
@@ -221,7 +221,7 @@ function checkPlanCompletion(meta, config) {
221
221
  const projectPrs = {};
222
222
  const prLinks = getPrLinks();
223
223
  for (const p of projects) {
224
- const prs = (safeJson(shared.projectPrPath(p)) || [])
224
+ const prs = safeJsonArr(shared.projectPrPath(p))
225
225
  .filter(pr => {
226
226
  const linkedIds = prLinks[pr.id] || [];
227
227
  return pr.status === PR_STATUS.ACTIVE && linkedIds.some(itemId => doneItems.find(w => w.id === itemId));
@@ -498,7 +498,7 @@ function cleanupPlanWorktrees(planFile, plan, projects, config) {
498
498
 
499
499
  for (const p of projects) {
500
500
  try {
501
- const prs = safeJson(shared.projectPrPath(p)) || [];
501
+ const prs = safeJsonArr(shared.projectPrPath(p));
502
502
  const prLinks = getPrLinks();
503
503
  for (const pr of prs) {
504
504
  const linkedIds = prLinks[pr.id] || [];
@@ -1612,7 +1612,7 @@ function resolveReviewPrContext(pr, project, config, structuredCompletion = null
1612
1612
 
1613
1613
  for (const candidateProject of projectCandidates) {
1614
1614
  const prPath = shared.projectPrPath(candidateProject);
1615
- const prs = safeJson(prPath) || [];
1615
+ const prs = safeJsonArr(prPath);
1616
1616
  for (const ref of refs) {
1617
1617
  const refUrl = typeof ref === 'object' ? ref.url || '' : String(ref || '');
1618
1618
  if (!shared.isPrCompatibleWithProject(candidateProject, ref, refUrl)) continue;
@@ -1622,7 +1622,7 @@ function resolveReviewPrContext(pr, project, config, structuredCompletion = null
1622
1622
  }
1623
1623
 
1624
1624
  const centralPath = centralPrPath();
1625
- const centralPrs = safeJson(centralPath) || [];
1625
+ const centralPrs = safeJsonArr(centralPath);
1626
1626
  const centralRefs = reportedPr ? [reportedPr] : refs;
1627
1627
  for (const ref of centralRefs) {
1628
1628
  const target = shared.findPrRecord(centralPrs, ref, null);
@@ -2215,7 +2215,7 @@ function findDependentActivePrs(mergedItemId, config) {
2215
2215
 
2216
2216
  const projects = shared.getProjects(config);
2217
2217
  for (const p of projects) {
2218
- const prs = safeJson(projectPrPath(p)) || [];
2218
+ const prs = safeJsonArr(projectPrPath(p));
2219
2219
  for (const pr of prs) {
2220
2220
  if (!pr.branch || pr.status !== PR_STATUS.ACTIVE) continue;
2221
2221
  const linked = (pr.prdItems || []).some(id => dependentWis.some(wi => wi.id === id));
@@ -4505,12 +4505,12 @@ function syncPrdFromPrs(config) {
4505
4505
  const allProjects = shared.getProjects(config);
4506
4506
 
4507
4507
  // Exact prdItems match only — no fuzzy matching
4508
- const allPrs = allProjects.flatMap(p => safeJson(shared.projectPrPath(p)) || []);
4508
+ const allPrs = allProjects.flatMap(p => safeJsonArr(shared.projectPrPath(p)));
4509
4509
 
4510
4510
  let totalReconciled = 0;
4511
4511
  for (const project of allProjects) {
4512
4512
  const wiPath = shared.projectWorkItemsPath(project);
4513
- const items = safeJson(wiPath) || [];
4513
+ const items = safeJsonArr(wiPath);
4514
4514
  const hasReconcilable = items.some(wi =>
4515
4515
  (wi.status === WI_STATUS.PENDING && !wi._pr) || wi.status === WI_STATUS.FAILED);
4516
4516
  if (!hasReconcilable) continue;
package/engine.js CHANGED
@@ -94,6 +94,8 @@ const { getProjects, projectRoot, projectStateDir, projectWorkItemsPath, project
94
94
  // ─── Utilities ──────────────────────────────────────────────────────────────
95
95
 
96
96
  const safeJson = shared.safeJson;
97
+ const safeJsonArr = shared.safeJsonArr;
98
+ const safeJsonObj = shared.safeJsonObj;
97
99
  const safeJsonNoRestore = shared.safeJsonNoRestore;
98
100
  const safeRead = shared.safeRead;
99
101
  const safeWrite = shared.safeWrite;
@@ -154,8 +156,6 @@ const { renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS,
154
156
  // through to an interactive `gh auth login` device-code flow.
155
157
  const ghToken = require('./engine/gh-token');
156
158
 
157
- // sanitizeBranch imported from shared.js
158
-
159
159
  // ─── Lifecycle (extracted to engine/lifecycle.js) ────────────────────────────
160
160
 
161
161
  const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, reconcilePrdStatuses, handlePostMerge, checkPlanCompletion,
@@ -559,9 +559,9 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
559
559
  // Find PR branches for each dependency work item
560
560
  for (const p of projects) {
561
561
  const prPath = shared.projectPrPath(p);
562
- const prs = safeJson(prPath) || [];
562
+ const prs = safeJsonArr(prPath);
563
563
  for (const pr of prs) {
564
- if (!pr.branch || pr.status !== 'active') continue;
564
+ if (!pr.branch || pr.status !== PR_STATUS.ACTIVE) continue;
565
565
  const linked = (pr.prdItems || []).some(id =>
566
566
  depWorkItems.find(w => w.id === id)
567
567
  );
@@ -1463,10 +1463,10 @@ async function spawnAgent(dispatchItem, config) {
1463
1463
  // Fetch all dependency branches in parallel (git fetches are independent)
1464
1464
  const fetchable = depBranches.filter(d => !_failedRefCache.has(d.branch));
1465
1465
  const unfetchable = depBranches.filter(d => _failedRefCache.has(d.branch));
1466
- const allPrsForDeps = unfetchable.length > 0 ? shared.getProjects(config).reduce((acc, p) => acc.concat(safeJson(shared.projectPrPath(p)) || []), []) : [];
1466
+ const allPrsForDeps = unfetchable.length > 0 ? shared.getProjects(config).reduce((acc, p) => acc.concat(safeJsonArr(shared.projectPrPath(p))), []) : [];
1467
1467
  for (const { branch: depBranch, prId } of unfetchable) {
1468
1468
  const pr = allPrsForDeps.find(p => p.id === prId);
1469
- if (pr && (pr.status === 'merged' || pr.status === 'closed')) {
1469
+ if (pr && (pr.status === PR_STATUS.MERGED || pr.status === PR_STATUS.CLOSED)) {
1470
1470
  log('info', `Dependency ${depBranch} (${prId}) already merged — skipping, changes already in main`);
1471
1471
  continue;
1472
1472
  }
@@ -1482,7 +1482,7 @@ async function spawnAgent(dispatchItem, config) {
1482
1482
  )
1483
1483
  );
1484
1484
  const hasFetchFailures = fetchResults.some(r => r.status === 'rejected');
1485
- const allPrsForFetch = hasFetchFailures ? shared.getProjects(config).reduce((acc, p) => acc.concat(safeJson(shared.projectPrPath(p)) || []), []) : [];
1485
+ const allPrsForFetch = hasFetchFailures ? shared.getProjects(config).reduce((acc, p) => acc.concat(safeJsonArr(shared.projectPrPath(p))), []) : [];
1486
1486
  // Track branches recovered by local-only push so they can be merged
1487
1487
  const recoveredBranches = new Set();
1488
1488
  for (let i = 0; i < fetchResults.length; i++) {
@@ -1491,7 +1491,7 @@ async function spawnAgent(dispatchItem, config) {
1491
1491
  const failedPrId = fetchable[i].prId;
1492
1492
  const errMsg = fetchResults[i].reason?.message || '';
1493
1493
  const pr = allPrsForFetch.find(p => p.id === failedPrId);
1494
- if (pr && (pr.status === 'merged' || pr.status === 'closed')) {
1494
+ if (pr && (pr.status === PR_STATUS.MERGED || pr.status === PR_STATUS.CLOSED)) {
1495
1495
  log('info', `Dependency ${failedBranch} (${failedPrId}) already merged — skipping, changes already in main`);
1496
1496
  continue;
1497
1497
  }
@@ -2079,10 +2079,12 @@ async function spawnAgent(dispatchItem, config) {
2079
2079
  // orphan detector's "logSize > stub-only" check can tell this apart from a
2080
2080
  // hung process. Preserves the diagnostic the prior inline catch wrote.
2081
2081
  try { fs.appendFileSync(liveOutputPath, `[${new Date().toISOString()}] spawn-failed: ${spawnErr.message}\n[process-exit] spawn-failed\n`); } catch { /* cleanup-only best effort */ }
2082
- } else if (proc && typeof proc.kill === 'function') {
2082
+ } else if (proc && proc.pid) {
2083
2083
  // spawn() returned a handle but a later registration step threw —
2084
- // kill the orphan child so it doesn't run unmonitored.
2085
- try { proc.kill('SIGKILL'); } catch { /* already exited */ }
2084
+ // kill the orphan child so it doesn't run unmonitored. shared.killImmediate
2085
+ // recurses into the process tree (footgun #4) — plain proc.kill('SIGKILL')
2086
+ // doesn't on Windows.
2087
+ try { shared.killImmediate(proc); } catch { /* already exited */ }
2086
2088
  }
2087
2089
  if (registeredInActiveProcesses) {
2088
2090
  try { activeProcesses.delete(id); } catch { /* map.delete never throws but be defensive */ }
@@ -3271,10 +3273,6 @@ async function spawnAgent(dispatchItem, config) {
3271
3273
  return proc;
3272
3274
  }
3273
3275
 
3274
- // addToDispatch, isRetryableFailureReason — now in engine/dispatch.js
3275
-
3276
- // completeDispatch — now in engine/dispatch.js
3277
-
3278
3276
  // ─── Dependency Gate ─────────────────────────────────────────────────────────
3279
3277
  // Returns: true (deps met), false (deps pending), 'failed' (dep failed — propagate)
3280
3278
  function areDependenciesMet(item, config) {
@@ -3368,9 +3366,6 @@ function detectDependencyCycles(items) {
3368
3366
  return [...cycleIds];
3369
3367
  }
3370
3368
 
3371
-
3372
- // writeInboxAlert — now in engine/dispatch.js
3373
-
3374
3369
  // Reconciles work items against known PRs.
3375
3370
  // Primary linkage comes from prdItems in pull-requests.json; fallback linkage
3376
3371
  // uses engine/pr-links.json so matching does not depend on branch/title parsing.
@@ -3462,10 +3457,6 @@ function updateSnapshot(config) {
3462
3457
  safeWrite(path.join(IDENTITY_DIR, 'now.md'), snapshot);
3463
3458
  }
3464
3459
 
3465
- // checkIdleThreshold, checkSteering, checkTimeouts — now in engine/timeout.js
3466
-
3467
- // runCleanup — now in engine/cleanup.js
3468
-
3469
3460
  // ─── Cooldowns (extracted to engine/cooldown.js) ─────────────────────────────
3470
3461
 
3471
3462
  const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
@@ -3473,16 +3464,6 @@ const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
3473
3464
  setCooldownFailure, clearCooldown, getPrReviewCooldownKey, clearLegacyPrReviewCooldown,
3474
3465
  isAlreadyDispatched, isBranchActive } = require('./engine/cooldown');
3475
3466
 
3476
-
3477
-
3478
- /**
3479
- * Scan ~/.minions/plans/ for plan-generated PRD files → queue implement tasks.
3480
- * Plans are project-scoped JSON files written by the plan-to-prd playbook.
3481
- */
3482
- /**
3483
- * Convert plan files into project work items (side-effect, like specs).
3484
- * Plans write to the target project's work-items.json — picked up by discoverFromWorkItems next tick.
3485
- */
3486
3467
  // Auto-clean pending/failed work items for a PRD so they re-materialize with updated plan data
3487
3468
  function autoCleanPrdWorkItems(prdFile, config) {
3488
3469
  const allProjects = getProjects(config);
@@ -3928,7 +3909,7 @@ function materializePlansAsWorkItems(config) {
3928
3909
  if (!alreadyExists) {
3929
3910
  for (const p of allProjects) {
3930
3911
  if (String(p.name || '').toLowerCase() === String(projName || '').toLowerCase()) continue;
3931
- const otherItems = safeJson(projectWorkItemsPath(p)) || [];
3912
+ const otherItems = safeJsonArr(projectWorkItemsPath(p));
3932
3913
  const otherWi = otherItems.find(w => w.id === item.id);
3933
3914
  if (otherWi) {
3934
3915
  if (DONE_STATUSES.has(otherWi.status) && shouldReopen) {
@@ -3969,7 +3950,7 @@ function materializePlansAsWorkItems(config) {
3969
3950
 
3970
3951
  if (created > 0) {
3971
3952
  // Reconciliation: exact prdItems match only, scoped to newly created items
3972
- const allPrsForReconcile = allProjects.flatMap(p => safeJson(projectPrPath(p)) || []);
3953
+ const allPrsForReconcile = allProjects.flatMap(p => safeJsonArr(projectPrPath(p)));
3973
3954
  const reconciled = reconcileItemsWithPrs(existingItems, allPrsForReconcile, { onlyIds: newlyCreatedIds });
3974
3955
  if (reconciled > 0) log('info', `Plan reconciliation: marked ${reconciled} item(s) as done → ${projName}`);
3975
3956
 
@@ -4950,7 +4931,7 @@ function resolveWorkItemPrRecord(item, project) {
4950
4931
  if (!project) return null;
4951
4932
  const prRef = getWorkItemPrRef(item);
4952
4933
  if (!prRef) return null;
4953
- const prs = safeJson(projectPrPath(project)) || [];
4934
+ const prs = safeJsonArr(projectPrPath(project));
4954
4935
  shared.normalizePrRecords(prs, project);
4955
4936
  return shared.findPrRecord(prs, prRef, project);
4956
4937
  }
@@ -5037,7 +5018,7 @@ function discoverFromWorkItems(config, project) {
5037
5018
  }
5038
5019
 
5039
5020
  const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
5040
- const items = safeJson(projectWorkItemsPath(project)) || [];
5021
+ const items = safeJsonArr(projectWorkItemsPath(project));
5041
5022
  const cooldownMs = (src.cooldownMinutes || 0) * 60 * 1000;
5042
5023
  const newWork = [];
5043
5024
  // PRD sync for dispatched status deferred to spawnAgent success (#480)
@@ -5587,7 +5568,7 @@ function extractSpecInfo(filePath, projectRoot_) {
5587
5568
  */
5588
5569
  function discoverCentralWorkItems(config) {
5589
5570
  const centralPath = path.join(MINIONS_DIR, 'work-items.json');
5590
- const items = safeJson(centralPath) || [];
5571
+ const items = safeJsonArr(centralPath);
5591
5572
  const projects = getProjects(config);
5592
5573
  const dispatchProjects = getCentralDispatchProjects(projects);
5593
5574
  const newWork = [];
@@ -6095,11 +6076,11 @@ async function discoverWork(config) {
6095
6076
  // readdir and read (e.g. concurrent archive), do not resurrect it
6096
6077
  // from a stale .backup sidecar (W-mouptdh1000h9f39).
6097
6078
  const plan = safeJsonNoRestore(path.join(prdDir, f));
6098
- if (!plan?.missing_features || plan.status === 'completed') {
6099
- if (plan?.status === 'completed') completedPlanCache.add(f);
6079
+ if (!plan?.missing_features || plan.status === PLAN_STATUS.COMPLETED) {
6080
+ if (plan?.status === PLAN_STATUS.COMPLETED) completedPlanCache.add(f);
6100
6081
  continue;
6101
6082
  }
6102
- if (plan.status !== 'approved' && plan.status !== 'active') continue;
6083
+ if (plan.status !== PLAN_STATUS.APPROVED && plan.status !== PLAN_STATUS.ACTIVE) continue;
6103
6084
  // Simulate the meta object checkPlanCompletion expects
6104
6085
  const completed = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
6105
6086
  if (completed) completedPlanCache.add(f);
@@ -6407,14 +6388,14 @@ async function tickInner() {
6407
6388
  const projects = getProjects(config);
6408
6389
  const pullRequests = projects.flatMap(p => {
6409
6390
  const prPath = path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json');
6410
- return safeJson(prPath) || [];
6391
+ return safeJsonArr(prPath);
6411
6392
  });
6412
6393
  const workItems = projects.flatMap(p => {
6413
6394
  const wiPath = path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json');
6414
- return safeJson(wiPath) || [];
6395
+ return safeJsonArr(wiPath);
6415
6396
  });
6416
6397
  // Also include central work items
6417
- const centralWi = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
6398
+ const centralWi = safeJsonArr(path.join(MINIONS_DIR, 'work-items.json'));
6418
6399
 
6419
6400
  // Gather state for the new generalized target types. Each block is
6420
6401
  // best-effort — if a module/file is missing the watch evaluator will
@@ -6438,7 +6419,7 @@ async function tickInner() {
6438
6419
  } catch { /* optional */ }
6439
6420
 
6440
6421
  let scheduleRuns = {};
6441
- try { scheduleRuns = safeJson(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')) || {}; } catch { /* optional */ }
6422
+ try { scheduleRuns = safeJsonObj(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')); } catch { /* optional */ }
6442
6423
 
6443
6424
  let pipelineRuns = {};
6444
6425
  try { pipelineRuns = require('./engine/pipeline').getPipelineRuns(); } catch { /* optional */ }
@@ -6508,10 +6489,10 @@ async function tickInner() {
6508
6489
  continue;
6509
6490
  }
6510
6491
  const plan = safeJson(path.join(PRD_DIR, file));
6511
- if (plan && plan.missing_features && plan.status !== 'completed') {
6492
+ if (plan && plan.missing_features && plan.status !== PLAN_STATUS.COMPLETED) {
6512
6493
  const completed = checkPlanCompletion({ item: { sourcePlan: file } }, config);
6513
6494
  if (completed) completedPlanCache.add(file);
6514
- } else if (plan?.status === 'completed') {
6495
+ } else if (plan?.status === PLAN_STATUS.COMPLETED) {
6515
6496
  completedPlanCache.add(file);
6516
6497
  }
6517
6498
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2042",
3
+ "version": "0.1.2044",
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"