@yemi33/minions 0.1.2041 → 0.1.2043

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.
@@ -14,7 +14,15 @@ const _pageCounters = {
14
14
  // triggers or the count changes; triggerCount removed because it advances
15
15
  // on the same event as last_triggered (F9/S4).
16
16
  watches: function(d) { return (d.watches || []).length + '|' + (d.watches || []).reduce(function(m, w) { return Math.max(m, new Date(w.last_triggered || 0).getTime() || 0); }, 0); },
17
- meetings: function(d) { return (d.meetings || []).length + '|' + (d.meetings || []).reduce(function(s, m) { return s + (m.round || 0); }, 0); },
17
+ // meetings signature: full count + sum of all rounds. Uses meetingsTotal
18
+ // (top-level full count of meetings on disk) NOT meetings.length — the
19
+ // latter is the slim slice which drops terminal meetings >7d via
20
+ // statusMeetingsRetentionDays, so an archived meeting reaching round 3
21
+ // would silently fail to flip the dot. Round-sum stays on the slim slice
22
+ // (we don't track per-meeting round in meetingsTotal) — operators who
23
+ // care about archived-meeting round transitions can crank the retention
24
+ // window or set it to 0. (W-mphlrxx6000a8760)
25
+ meetings: function(d) { return (d.meetingsTotal ?? (d.meetings || []).length) + '|' + (d.meetings || []).reduce(function(s, m) { return s + (m.round || 0); }, 0); },
18
26
  pipelines: function(d) { return (d.pipelines || []).length + '|' + (d.pipelines || []).reduce(function(s, p) { return s + (p.runs || []).length; }, 0); },
19
27
  schedule: function(d) { return (d.schedules || []).length; },
20
28
  // tools signature: skills count + mcp servers count.
@@ -59,7 +67,7 @@ const RENDER_VERSIONS = {
59
67
  agents: 1,
60
68
  prdProgress: 1,
61
69
  prdPrs: 1,
62
- inbox: 1,
70
+ inbox: 2,
63
71
  projects: 1,
64
72
  notes: 1,
65
73
  prd: 1,
@@ -69,8 +77,8 @@ const RENDER_VERSIONS = {
69
77
  version: 1,
70
78
  adoThrottle: 1,
71
79
  ghThrottle: 1,
72
- dispatch: 1,
73
- engineLog: 1,
80
+ dispatch: 2,
81
+ engineLog: 2,
74
82
  metrics: 1,
75
83
  workItems: 1,
76
84
  skills: 1,
@@ -80,6 +88,7 @@ const RENDER_VERSIONS = {
80
88
  meetings: 1,
81
89
  pipelines: 1,
82
90
  pinned: 1,
91
+ kbPayload: 2,
83
92
  };
84
93
  const _sectionCache = {};
85
94
  const _lastValueByKey = {};
@@ -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 };
@@ -89,6 +89,7 @@ async function openSettings() {
89
89
  settingsField('Meeting Round Timeout', 'set-meetingRoundTimeout', e.meetingRoundTimeout || 900000, 'ms', 'Auto-advance meeting round after this') +
90
90
  settingsField('Operator login (used in branch names)', 'set-operatorLogin', e.operatorLogin || '', '', 'Override the human operator login used in user/<loginname>/<wi-id>-<slug> branches. Empty = auto-resolve via gh / git email / OS username (currently resolves to: ' + (e._resolvedOperatorLogin || 'unknown') + ')') +
91
91
  settingsField('Status WorkItems Retention', 'set-statusWorkItemsRetentionDays', e.statusWorkItemsRetentionDays ?? 7, 'days', 'Trim done/failed/cancelled work items older than N days from the /api/status workItems slice (active items are always shipped). Cuts SPA payload from ~3MB to <500KB. Set to 0 to disable trimming (full list shipped, restoring legacy behavior).') +
92
+ settingsField('Status Meetings Retention', 'set-statusMeetingsRetentionDays', e.statusMeetingsRetentionDays ?? 7, 'days', 'Trim completed/archived meetings older than N days from the /api/status meetings slice (active meetings are always shipped). Cuts SPA payload from ~4.3MB to <500KB. Detail modal still fetches full transcripts via /api/meetings/:id. Set to 0 to disable trimming (full list shipped, restoring legacy behavior).') +
92
93
  '</div>' +
93
94
  '<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">Automation</h3>' +
94
95
  '<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:16px">' +
@@ -599,6 +600,7 @@ async function saveSettings() {
599
600
  meetingRoundTimeout: document.getElementById('set-meetingRoundTimeout').value,
600
601
  operatorLogin: (document.getElementById('set-operatorLogin')?.value ?? '').trim(),
601
602
  statusWorkItemsRetentionDays: document.getElementById('set-statusWorkItemsRetentionDays').value,
603
+ statusMeetingsRetentionDays: document.getElementById('set-statusMeetingsRetentionDays').value,
602
604
  autoApprovePlans: document.getElementById('set-autoApprovePlans').checked,
603
605
  evalLoop: document.getElementById('set-evalLoop').checked,
604
606
  autoDecompose: document.getElementById('set-autoDecompose').checked,
package/dashboard.js CHANGED
@@ -23,6 +23,7 @@ const shared = require('./engine/shared');
23
23
  const queries = require('./engine/queries');
24
24
  const ado = require('./engine/ado');
25
25
  const gh = require('./engine/github');
26
+ const ghToken = require('./engine/gh-token');
26
27
  const issues = require('./engine/issues');
27
28
  const watchesMod = require('./engine/watches');
28
29
  const meetingMod = require('./engine/meeting');
@@ -1710,7 +1711,14 @@ function _buildStatusFastState() {
1710
1711
  metrics: getMetrics(),
1711
1712
  workItems: _slimWorkItemsForStatus(getWorkItems()),
1712
1713
  watches: watchesMod.getWatches(),
1713
- meetings: _safeStatusSlice('meetings', () => meetingMod.getMeetings(), []),
1714
+ meetings: _safeStatusSlice('meetings', () => _slimMeetingsForStatus(meetingMod.getMeetings()), []),
1715
+ // Top-level full meeting count (NOT slim slice length). Surfaced so the
1716
+ // sidebar activity-dot counter (dashboard/js/refresh.js _pageCounters.meetings)
1717
+ // still fires when ANY meeting — including old/archived ones dropped from
1718
+ // the slim slice by statusMeetingsRetentionDays — gains a new round.
1719
+ // Without this, completing the third round of an archived meeting would
1720
+ // silently fail to light the sidebar dot. (W-mphlrxx6000a8760)
1721
+ meetingsTotal: _safeStatusSlice('meetingsTotal', () => _countMeetingsForStatus(), 0),
1714
1722
  // QA runs — surfaced for the sidebar activity-dot counter and any future
1715
1723
  // CC/aggregate view. Tab-level rendering keeps its own /api/qa/runs poll
1716
1724
  // (5 s while the QA page is mounted). qa-runs.json is in the mtime tracker
@@ -1844,6 +1852,101 @@ function _slimWorkItemsForStatus(items) {
1844
1852
  return surviving;
1845
1853
  }
1846
1854
 
1855
+ // ── /api/status meetings slimming (W-mphlrxx6000a8760) ──────────────────────
1856
+ // Mirrors the workItems trim above (PR #2816). Meetings are the second
1857
+ // largest /api/status slice after workItems — live measurement: 22 meetings
1858
+ // / 4.3MB (60% of the 7.2MB payload). The list renderer in
1859
+ // dashboard/js/render-meetings.js:renderMeetings only needs:
1860
+ // - id, title, status, round, participants, agenda(short), createdAt,
1861
+ // completedAt
1862
+ // - per-participant booleans of findings/debate (used to pick the
1863
+ // ✓/⏳/○ icon — `m.findings?.[p]` truthy check, line 48-50)
1864
+ // The detail modal calls `/api/meetings/:id` which serves the full record
1865
+ // (findings.content + debate.content + conclusion + transcript bodies), so
1866
+ // dropping those from the slice is safe.
1867
+ //
1868
+ // Active meetings (investigating/debating/concluding) are ALWAYS kept
1869
+ // regardless of age. Terminal meetings (completed/archived) only survive
1870
+ // if their completedAt/roundStartedAt/createdAt is within the window.
1871
+ // Set engine.statusMeetingsRetentionDays = 0 to disable trimming entirely
1872
+ // (returns the full list — but still slim-shaped — restoring legacy size).
1873
+ const _ACTIVE_MEETING_STATUSES_FOR_STATUS = new Set(['investigating', 'debating', 'concluding']);
1874
+ const _TERMINAL_MEETING_STATUSES_FOR_STATUS = new Set(['completed', 'archived']);
1875
+ function _resolveStatusMeetingsRetentionDays() {
1876
+ const raw = CONFIG?.engine?.statusMeetingsRetentionDays;
1877
+ if (raw === 0 || raw === '0') return 0;
1878
+ const n = Number(raw);
1879
+ if (Number.isFinite(n) && n >= 0) return n;
1880
+ return shared.ENGINE_DEFAULTS.statusMeetingsRetentionDays;
1881
+ }
1882
+ function _slimMeetingForStatus(meeting) {
1883
+ // Reduce findings/debate objects to {agentId: true} sentinels — the list
1884
+ // renderer only checks `m.findings?.[p]` for truthiness when picking the
1885
+ // participant-badge icon. Keeping just the keys preserves that contract
1886
+ // while dropping the per-round agent transcript bodies (~95KB+ each).
1887
+ const findingsKeys = meeting.findings && typeof meeting.findings === 'object'
1888
+ ? Object.keys(meeting.findings) : [];
1889
+ const debateKeys = meeting.debate && typeof meeting.debate === 'object'
1890
+ ? Object.keys(meeting.debate) : [];
1891
+ const findings = {};
1892
+ for (const k of findingsKeys) findings[k] = true;
1893
+ const debate = {};
1894
+ for (const k of debateKeys) debate[k] = true;
1895
+ const slim = {
1896
+ id: meeting.id,
1897
+ title: meeting.title,
1898
+ status: meeting.status,
1899
+ round: meeting.round,
1900
+ participants: Array.isArray(meeting.participants) ? meeting.participants : [],
1901
+ agenda: meeting.agenda,
1902
+ createdAt: meeting.createdAt,
1903
+ findings,
1904
+ debate,
1905
+ };
1906
+ if (meeting.completedAt !== undefined) slim.completedAt = meeting.completedAt;
1907
+ if (meeting.roundStartedAt !== undefined) slim.roundStartedAt = meeting.roundStartedAt;
1908
+ if (meeting.createdBy !== undefined) slim.createdBy = meeting.createdBy;
1909
+ return slim;
1910
+ }
1911
+ function _slimMeetingsForStatus(meetings) {
1912
+ if (!Array.isArray(meetings)) return meetings;
1913
+ const retentionDays = _resolveStatusMeetingsRetentionDays();
1914
+ if (retentionDays <= 0) {
1915
+ // Trimming disabled — keep full list but still flatten via slim shape
1916
+ // so wire format is consistent and the heavy bodies never ship via
1917
+ // /api/status regardless of operator config.
1918
+ return meetings.map(_slimMeetingForStatus);
1919
+ }
1920
+ const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
1921
+ const surviving = [];
1922
+ for (const meeting of meetings) {
1923
+ if (!meeting) continue;
1924
+ const status = meeting.status || 'investigating';
1925
+ if (_TERMINAL_MEETING_STATUSES_FOR_STATUS.has(status)) {
1926
+ const ts = meeting.completedAt || meeting.roundStartedAt || meeting.createdAt || '';
1927
+ const tsMs = ts ? Date.parse(ts) : NaN;
1928
+ // Drop only when we have a parseable timestamp and it's beyond the
1929
+ // window. Meetings with missing/unparseable timestamps stay visible —
1930
+ // we'd rather over-include than silently hide them.
1931
+ if (Number.isFinite(tsMs) && tsMs < cutoffMs) {
1932
+ continue;
1933
+ }
1934
+ } else if (!_ACTIVE_MEETING_STATUSES_FOR_STATUS.has(status)) {
1935
+ // Unknown status — keep, so a future round name isn't silently
1936
+ // hidden until the constant set is updated.
1937
+ }
1938
+ surviving.push(_slimMeetingForStatus(meeting));
1939
+ }
1940
+ return surviving;
1941
+ }
1942
+ // Count meetings on disk without rehydrating bodies — backs the sidebar
1943
+ // activity dot signature so new rounds in trimmed/archived meetings still
1944
+ // flip the counter (refresh.js _pageCounters.meetings reads meetingsTotal).
1945
+ function _countMeetingsForStatus() {
1946
+ const meetings = meetingMod.getMeetings();
1947
+ return Array.isArray(meetings) ? meetings.length : 0;
1948
+ }
1949
+
1847
1950
  // Build the slow-state slice (rarely-changing data: ~60s TTL).
1848
1951
  function _buildStatusSlowState() {
1849
1952
  const prdInfo = getPrdInfo();
@@ -8538,6 +8641,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8538
8641
  _setEngineConfig('statusWorkItemsRetentionDays', val);
8539
8642
  }
8540
8643
  }
8644
+ // W-mphlrxx6000a8760: /api/status meetings retention window. Same
8645
+ // shape as statusWorkItemsRetentionDays — 0 must persist literally
8646
+ // (disables trim), so handled outside the numericFields loop.
8647
+ if (e.statusMeetingsRetentionDays !== undefined) {
8648
+ const raw = e.statusMeetingsRetentionDays;
8649
+ if (raw === '' || raw === null) {
8650
+ _deleteEngineConfig('statusMeetingsRetentionDays');
8651
+ } else {
8652
+ let val = Number(raw);
8653
+ if (!Number.isFinite(val) || val < 0) val = D.statusMeetingsRetentionDays;
8654
+ if (val > 365) { _clamped.push(`statusMeetingsRetentionDays: ${val} → 365 (range: 0–365)`); val = 365; }
8655
+ _setEngineConfig('statusMeetingsRetentionDays', val);
8656
+ }
8657
+ }
8541
8658
  // W-mpejf0fq000e84d6: operator login override. Empty string clears
8542
8659
  // the override (engine falls back to gh/git/os resolution); any other
8543
8660
  // value pins the login used in `user/<login>/<wi-id>-<slug>` branches.
@@ -9915,7 +10032,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9915
10032
  // a regex match against untrusted PR-link input (the body of POST
9916
10033
  // /api/pull-requests/link); validate before exec. `prNum` is already
9917
10034
  // a number; coerce to string for argv.
9918
- const result = await shared.shellSafeGh(['api', `repos/${shared.validateGhSlug(slug)}/pulls/${String(prNum)}`], { timeout: 15000 });
10035
+ //
10036
+ // W-mphm0kt0000cebc3 (Bug A): route the GH PAT per slug. Without this,
10037
+ // cross-account scopes (e.g. opg-microsoft/minions when active gh is
10038
+ // yemi33) 404 silently and enrichment never lands — title stays
10039
+ // "PR #N (polling...)" indefinitely. Mirrors engine/github.js:316.
10040
+ const token = ghToken.resolveTokenForSlug(slug);
10041
+ const ghOpts = { timeout: 15000 };
10042
+ if (token) ghOpts.env = { ...process.env, GH_TOKEN: token };
10043
+ const result = await shared.shellSafeGh(['api', `repos/${shared.validateGhSlug(slug)}/pulls/${String(prNum)}`], ghOpts);
9919
10044
  const d = JSON.parse(result);
9920
10045
  prData = { title: d.title, description: d.body, branch: d.head?.ref, author: d.user?.login };
9921
10046
  } else if (adoTarget && !initialPrData) {
@@ -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
 
@@ -111,14 +111,28 @@ function getBranchDispatchLockKey(entry) {
111
111
  }
112
112
 
113
113
  function findActivePrOrBranchLock(dispatch, item) {
114
- if (item?.type !== WORK_TYPE.FIX) return null;
115
114
  const active = dispatch.active || [];
116
- const prTargetKey = getPrDispatchTargetKey(item);
117
- if (prTargetKey) {
118
- const existing = active.find(d => getPrDispatchTargetKey(d) === prTargetKey);
119
- if (existing) return { existing, reason: `active PR dispatch ${prTargetKey}` };
115
+
116
+ // PR-target dedup is FIX-only: a FIX shouldn't stack on top of another FIX
117
+ // for the same PR, but a REVIEW + FIX pair targeting the same PR is the
118
+ // normal review-then-fix flow and must not be dedup'd here.
119
+ if (item?.type === WORK_TYPE.FIX) {
120
+ const prTargetKey = getPrDispatchTargetKey(item);
121
+ if (prTargetKey) {
122
+ const existing = active.find(d => getPrDispatchTargetKey(d) === prTargetKey);
123
+ if (existing) return { existing, reason: `active PR dispatch ${prTargetKey}` };
124
+ }
120
125
  }
121
126
 
127
+ // Branch-lock applies to EVERY type, not just FIX (W-mphll3py0006234e —
128
+ // issue #2817). Any dispatch that carries meta.branch is, by definition,
129
+ // claiming ownership of that branch for the duration of its run — if two
130
+ // such dispatches overlap on the same branch they race the eventual
131
+ // `git push` and the spawn-time stale-HEAD guard fails whichever push
132
+ // lost the race. Mirrors the dispatch-loop's `lockedBranches` mutex
133
+ // (engine.js ~6577-6731) at queue-time so maintenance-class dispatches
134
+ // (setup / test / docs / implement / verify / decompose / review …) get
135
+ // the same coordination FIX already had.
122
136
  const branchLockKey = getBranchDispatchLockKey(item);
123
137
  if (!branchLockKey) return null;
124
138
  const existing = active.find(d => getBranchDispatchLockKey(d) === branchLockKey);
@@ -135,10 +149,13 @@ function addToDispatch(item) {
135
149
  }
136
150
  let added = false;
137
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
+
138
155
  // Dedup: skip if same work item ID is already pending or active
139
156
  const wiId = item.meta?.source === 'central-work-item-fanout' ? null : item.meta?.item?.id;
140
157
  if (wiId) {
141
- const existing = [...dispatch.pending, ...(dispatch.active || [])].find(d => d.meta?.item?.id === wiId);
158
+ const existing = queued.find(d => d.meta?.item?.id === wiId);
142
159
  if (existing) {
143
160
  log('info', `Dedup: skipping ${item.id} — work item ${wiId} already in ${existing.id}`);
144
161
  return dispatch;
@@ -146,7 +163,7 @@ function addToDispatch(item) {
146
163
  }
147
164
  // Also dedup by dispatchKey
148
165
  if (item.meta?.dispatchKey) {
149
- 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);
150
167
  if (existing) {
151
168
  log('info', `Dedup: skipping ${item.id} — dispatchKey ${item.meta.dispatchKey} already in ${existing.id}`);
152
169
  return dispatch;
@@ -154,8 +171,7 @@ function addToDispatch(item) {
154
171
  }
155
172
  const prDedupeKey = getPrDispatchDedupeKey(item);
156
173
  if (prDedupeKey) {
157
- const existing = [...dispatch.pending, ...(dispatch.active || [])]
158
- .find(d => getPrDispatchDedupeKey(d) === prDedupeKey);
174
+ const existing = queued.find(d => getPrDispatchDedupeKey(d) === prDedupeKey);
159
175
  if (existing) {
160
176
  log('info', `Dedup: skipping ${item.id} — PR dispatch ${prDedupeKey} already in ${existing.id}`);
161
177
  return dispatch;
@@ -439,8 +455,8 @@ function readLiveWorkItem(meta) {
439
455
  if (!itemId) return null;
440
456
  const wiPath = lifecycle().resolveWorkItemPath(meta);
441
457
  if (!wiPath) return null;
442
- const items = safeJson(wiPath) || [];
443
- 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;
444
460
  }
445
461
 
446
462
  function writeFailedAgentReport(item, reason, resultSummary, failureClass) {
@@ -868,14 +884,13 @@ function cancelPendingDispatchesForPr(prId) {
868
884
  * @returns {number} count of removed entries
869
885
  */
870
886
  function cleanDispatchEntries(matchFn) {
871
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
872
887
  const tmpDir = path.join(MINIONS_DIR, 'engine', 'tmp');
873
888
  let removed = 0;
874
889
  const pidsToKill = [];
875
890
  const filesToDelete = [];
876
891
  const dispatchDirsToRemove = [];
877
892
  try {
878
- mutateJsonFileLocked(dispatchPath, (dispatch) => {
893
+ mutateJsonFileLocked(DISPATCH_PATH, (dispatch) => {
879
894
  for (const queue of ['pending', 'active', 'completed']) {
880
895
  dispatch[queue] = Array.isArray(dispatch[queue]) ? dispatch[queue] : [];
881
896
  const before = dispatch[queue].length;
package/engine/github.js CHANGED
@@ -669,6 +669,35 @@ async function pollPrStatus(config) {
669
669
  }
670
670
  }
671
671
 
672
+ // W-mphm0kt0000cebc3 (Bug B): backfill title/description/agent for
673
+ // project-local PRs that are still on the link-time placeholder. The
674
+ // central poller (above, ~lines 561-583) does this already; without
675
+ // parity here a manually-linked project-local PR whose initial
676
+ // enrichment IIFE failed (cross-account auth, transient blip) would
677
+ // stay stuck on "PR #N (polling...)" forever. `prData` is already in
678
+ // hand from line 622 — no extra API call needed.
679
+ const currentTitleForBackfill = pr.title || '';
680
+ if (!currentTitleForBackfill
681
+ || currentTitleForBackfill.includes('polling...')
682
+ || /[{}"\[\]]/.test(currentTitleForBackfill)
683
+ || /^[0-9a-f-]{8,}$/i.test(currentTitleForBackfill)) {
684
+ if (prData.title) {
685
+ const nextTitle = String(prData.title).slice(0, 120);
686
+ if (pr.title !== nextTitle) {
687
+ pr.title = nextTitle;
688
+ updated = true;
689
+ }
690
+ }
691
+ }
692
+ if (pr.description === undefined) {
693
+ pr.description = (prData.body || '').slice(0, 500);
694
+ updated = true;
695
+ }
696
+ if (pr.agent === 'human' && prData.user?.login) {
697
+ pr.agent = prData.user.login;
698
+ updated = true;
699
+ }
700
+
672
701
  // Map GitHub PR state to minions status
673
702
  let newStatus = pr.status;
674
703
  if (prData.merged) newStatus = PR_STATUS.MERGED;
@@ -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/shared.js CHANGED
@@ -2041,6 +2041,19 @@ const ENGINE_DEFAULTS = {
2041
2041
  // via GET /api/work-items/<id> when description/references/AC are needed.
2042
2042
  // 0 disables the trim (full list shipped, restoring legacy behavior).
2043
2043
  statusWorkItemsRetentionDays: 7,
2044
+
2045
+ // ── /api/status meetings retention (W-mphlrxx6000a8760) ─────────────────────
2046
+ // Same shape as statusWorkItemsRetentionDays — mirrors the trim+slim pass
2047
+ // for the meetings slice (live: 22 meetings / 4.3MB → ~5 meetings / <500KB
2048
+ // typical). Active meetings (investigating/debating/concluding) are ALWAYS
2049
+ // shipped regardless of age — only terminal meetings (completed/archived)
2050
+ // past the window are dropped. The detail modal fetches the full record
2051
+ // (findings, debate, conclusion, transcript bodies) on demand via
2052
+ // GET /api/meetings/<id> when opened. A top-level meetingsTotal field is
2053
+ // synthesized so the sidebar activity dot still fires when ANY meeting
2054
+ // (including those dropped from the slim slice) gains a new round.
2055
+ // 0 disables the trim (full list shipped, restoring legacy behavior).
2056
+ statusMeetingsRetentionDays: 7,
2044
2057
  };
2045
2058
 
2046
2059
  // ─── Runtime Fleet Resolution (P-3b8e5f1d) ──────────────────────────────────
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
 
@@ -4151,6 +4132,18 @@ function ensurePrBranchForDispatch(project, pr, automationType) {
4151
4132
  }
4152
4133
  return branch;
4153
4134
  }
4135
+ // W-mphm0kt0000cebc3 (Bug C): suppress the red "missing branch" badge during
4136
+ // the link grace window. User reports the badge "is so loud as a warning -
4137
+ // makes the user think something is wrong when it's just taking its time".
4138
+ // A just-linked PR has `created` set within the last few seconds and has
4139
+ // never been polled (no `headSha`); the enrichment IIFE + first poll cycle
4140
+ // need 10-30s to land the real `branch`. During that window, return ''
4141
+ // silently so dispatch is deferred without flipping any UI state. The next
4142
+ // tick retries; if branch still missing past the grace window, fall through
4143
+ // to the existing _branchResolutionError path (red badge as before).
4144
+ if (isWithinLinkGraceWindow(pr)) {
4145
+ return '';
4146
+ }
4154
4147
  const reason = `Cannot dispatch ${automationType} for ${shared.getPrDisplayId(pr)}: missing pr_branch/source branch metadata. Link or refresh the PR so the source branch is known.`;
4155
4148
  if (updatePrBranchResolutionState(project, pr, { reason })) {
4156
4149
  log('warn', `PR ${pr.id}: ${reason}`);
@@ -4158,6 +4151,21 @@ function ensurePrBranchForDispatch(project, pr, automationType) {
4158
4151
  return '';
4159
4152
  }
4160
4153
 
4154
+ // W-mphm0kt0000cebc3 (Bug C): a PR is "freshly linked" if it was created in
4155
+ // the last 120s AND has never been successfully polled (no `headSha`). The
4156
+ // enrichment IIFE in dashboard.js's POST /api/pull-requests/link runs async
4157
+ // after returning the response, and the first GH/ADO poll completes within
4158
+ // 10-30s. The 120s window comfortably covers both without masking PRs whose
4159
+ // branch is genuinely missing.
4160
+ const PR_LINK_GRACE_WINDOW_MS = 120 * 1000;
4161
+ function isWithinLinkGraceWindow(pr) {
4162
+ if (!pr || !pr.created) return false;
4163
+ if (pr.headSha) return false; // poll has run successfully — past the grace window
4164
+ const createdMs = Date.parse(pr.created);
4165
+ if (!Number.isFinite(createdMs)) return false;
4166
+ return (Date.now() - createdMs) < PR_LINK_GRACE_WINDOW_MS;
4167
+ }
4168
+
4161
4169
  function prCausePart(value, fallback = 'unknown') {
4162
4170
  const raw = String(value || '').trim();
4163
4171
  return shared.safeSlugComponent(raw || fallback, 80);
@@ -4923,7 +4931,7 @@ function resolveWorkItemPrRecord(item, project) {
4923
4931
  if (!project) return null;
4924
4932
  const prRef = getWorkItemPrRef(item);
4925
4933
  if (!prRef) return null;
4926
- const prs = safeJson(projectPrPath(project)) || [];
4934
+ const prs = safeJsonArr(projectPrPath(project));
4927
4935
  shared.normalizePrRecords(prs, project);
4928
4936
  return shared.findPrRecord(prs, prRef, project);
4929
4937
  }
@@ -5010,7 +5018,7 @@ function discoverFromWorkItems(config, project) {
5010
5018
  }
5011
5019
 
5012
5020
  const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
5013
- const items = safeJson(projectWorkItemsPath(project)) || [];
5021
+ const items = safeJsonArr(projectWorkItemsPath(project));
5014
5022
  const cooldownMs = (src.cooldownMinutes || 0) * 60 * 1000;
5015
5023
  const newWork = [];
5016
5024
  // PRD sync for dispatched status deferred to spawnAgent success (#480)
@@ -5560,7 +5568,7 @@ function extractSpecInfo(filePath, projectRoot_) {
5560
5568
  */
5561
5569
  function discoverCentralWorkItems(config) {
5562
5570
  const centralPath = path.join(MINIONS_DIR, 'work-items.json');
5563
- const items = safeJson(centralPath) || [];
5571
+ const items = safeJsonArr(centralPath);
5564
5572
  const projects = getProjects(config);
5565
5573
  const dispatchProjects = getCentralDispatchProjects(projects);
5566
5574
  const newWork = [];
@@ -6068,11 +6076,11 @@ async function discoverWork(config) {
6068
6076
  // readdir and read (e.g. concurrent archive), do not resurrect it
6069
6077
  // from a stale .backup sidecar (W-mouptdh1000h9f39).
6070
6078
  const plan = safeJsonNoRestore(path.join(prdDir, f));
6071
- if (!plan?.missing_features || plan.status === 'completed') {
6072
- 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);
6073
6081
  continue;
6074
6082
  }
6075
- if (plan.status !== 'approved' && plan.status !== 'active') continue;
6083
+ if (plan.status !== PLAN_STATUS.APPROVED && plan.status !== PLAN_STATUS.ACTIVE) continue;
6076
6084
  // Simulate the meta object checkPlanCompletion expects
6077
6085
  const completed = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
6078
6086
  if (completed) completedPlanCache.add(f);
@@ -6380,14 +6388,14 @@ async function tickInner() {
6380
6388
  const projects = getProjects(config);
6381
6389
  const pullRequests = projects.flatMap(p => {
6382
6390
  const prPath = path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json');
6383
- return safeJson(prPath) || [];
6391
+ return safeJsonArr(prPath);
6384
6392
  });
6385
6393
  const workItems = projects.flatMap(p => {
6386
6394
  const wiPath = path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json');
6387
- return safeJson(wiPath) || [];
6395
+ return safeJsonArr(wiPath);
6388
6396
  });
6389
6397
  // Also include central work items
6390
- const centralWi = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
6398
+ const centralWi = safeJsonArr(path.join(MINIONS_DIR, 'work-items.json'));
6391
6399
 
6392
6400
  // Gather state for the new generalized target types. Each block is
6393
6401
  // best-effort — if a module/file is missing the watch evaluator will
@@ -6411,7 +6419,7 @@ async function tickInner() {
6411
6419
  } catch { /* optional */ }
6412
6420
 
6413
6421
  let scheduleRuns = {};
6414
- 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 */ }
6415
6423
 
6416
6424
  let pipelineRuns = {};
6417
6425
  try { pipelineRuns = require('./engine/pipeline').getPipelineRuns(); } catch { /* optional */ }
@@ -6481,10 +6489,10 @@ async function tickInner() {
6481
6489
  continue;
6482
6490
  }
6483
6491
  const plan = safeJson(path.join(PRD_DIR, file));
6484
- if (plan && plan.missing_features && plan.status !== 'completed') {
6492
+ if (plan && plan.missing_features && plan.status !== PLAN_STATUS.COMPLETED) {
6485
6493
  const completed = checkPlanCompletion({ item: { sourcePlan: file } }, config);
6486
6494
  if (completed) completedPlanCache.add(file);
6487
- } else if (plan?.status === 'completed') {
6495
+ } else if (plan?.status === PLAN_STATUS.COMPLETED) {
6488
6496
  completedPlanCache.add(file);
6489
6497
  }
6490
6498
  }
@@ -6983,6 +6991,7 @@ module.exports = {
6983
6991
  _maxTurnsForType, buildProjectContext, normalizeAc, _buildAgentSpawnFlags, _classifyAgentFailure, // exported for testing
6984
6992
  promoteCheckpointSteeringForClose, // exported for testing
6985
6993
  normalizePrBranch, resolvePrBranch, prCausePart, getPrCauseHead, getPrCauseBase, getPrAutomationCauseKey, getPrAutomationDispatchKey, // exported for testing
6994
+ ensurePrBranchForDispatch, isWithinLinkGraceWindow, PR_LINK_GRACE_WINDOW_MS, // exported for testing (W-mphm0kt0000cebc3)
6986
6995
 
6987
6996
  // Playbooks
6988
6997
  renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS, buildWorkItemDispatchVars,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2041",
3
+ "version": "0.1.2043",
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"