@yemi33/minions 0.1.2037 → 0.1.2038

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.
@@ -84,6 +84,12 @@ const RENDER_VERSIONS = {
84
84
  const _sectionCache = {};
85
85
  const _lastValueByKey = {};
86
86
  const _sectionCacheVersions = {};
87
+ // Refresh diagnostics: when refresh() opens a tick, it sets _lastChangedFlags
88
+ // to a fresh object so every _changed() call publishes its boolean result
89
+ // here. After _processStatusUpdate() returns, refresh() captures the map
90
+ // into the ring-buffer entry then resets _lastChangedFlags to null so steady-
91
+ // state has no side effect. See "Refresh diagnostics" block below.
92
+ let _lastChangedFlags = null;
87
93
  function _changed(key, value, version) {
88
94
  var v = version == null ? (RENDER_VERSIONS[key] || 0) : version;
89
95
  // Drop the stale-version entry so the cache doesn't grow unbounded across bumps.
@@ -98,11 +104,18 @@ function _changed(key, value, version) {
98
104
  var cacheKey = key + ':v' + v;
99
105
  // Reference-equality short-circuit: skip the stringify entirely when the
100
106
  // server returned 304 and the same object reference is being re-checked.
101
- if (_lastValueByKey[key] === value) return false;
107
+ if (_lastValueByKey[key] === value) {
108
+ if (_lastChangedFlags) _lastChangedFlags[key] = false;
109
+ return false;
110
+ }
102
111
  _lastValueByKey[key] = value;
103
112
  var json = JSON.stringify(value);
104
- if (_sectionCache[cacheKey] === json) return false;
113
+ if (_sectionCache[cacheKey] === json) {
114
+ if (_lastChangedFlags) _lastChangedFlags[key] = false;
115
+ return false;
116
+ }
105
117
  _sectionCache[cacheKey] = json;
118
+ if (_lastChangedFlags) _lastChangedFlags[key] = true;
106
119
  return true;
107
120
  }
108
121
 
@@ -320,9 +333,78 @@ let _lastStatusData = null;
320
333
  // when the first hasn't finished — the next interval fire picks up the
321
334
  // fresh state anyway.
322
335
  let _refreshInFlight = false;
336
+
337
+ // ── Refresh diagnostics (W-mphejzx100081972) ─────────────────────────────
338
+ // Ring buffer capturing the last 50 /api/status poll cycles so a user
339
+ // reporting "the dashboard didn't auto-update when X changed" can paste
340
+ // `window._refreshDiagnostics()` from devtools or click the "diag" footer
341
+ // chip to surface a table. Gated behind feature flag
342
+ // `dashboard-refresh-diagnostics` (engine/features.js) — disabled in
343
+ // production, enabled in dev. When disabled, the helpers below are no-ops
344
+ // (the boolean check is the first line of every collection path) so the
345
+ // steady-state poll loop is byte-identical to its pre-flag behaviour.
346
+ const _DIAG_RING_SIZE = 50;
347
+ const _refreshDiagBuf = [];
348
+ let _prevRefreshTs = 0;
349
+ let _diagHiddenSinceMs = 0; // ts when tab last went hidden; 0 when visible
350
+ let _lastVisibilityChangeAt = 0;
351
+ function _isRefreshDiagOn() {
352
+ try {
353
+ return !!(window.MinionsFeatures && window.MinionsFeatures.isOn('dashboard-refresh-diagnostics'));
354
+ } catch { return false; }
355
+ }
356
+ function _pushDiagEntry(entry) {
357
+ _refreshDiagBuf.push(entry);
358
+ if (_refreshDiagBuf.length > _DIAG_RING_SIZE) _refreshDiagBuf.shift();
359
+ }
360
+ // Expose a copy of the ring buffer so devtools paste-back captures a
361
+ // stable snapshot — not a live reference the user could accidentally mutate.
362
+ window._refreshDiagnostics = function() { return _refreshDiagBuf.slice(); };
363
+ window._refreshDiagnosticsClear = function() { _refreshDiagBuf.length = 0; };
364
+ document.addEventListener('visibilitychange', function() {
365
+ if (!_isRefreshDiagOn()) return;
366
+ const now = Date.now();
367
+ const vs = document.visibilityState;
368
+ let hiddenForMs = null;
369
+ if (vs === 'visible' && _diagHiddenSinceMs > 0) {
370
+ hiddenForMs = now - _diagHiddenSinceMs;
371
+ _diagHiddenSinceMs = 0;
372
+ } else if (vs === 'hidden') {
373
+ _diagHiddenSinceMs = now;
374
+ }
375
+ _pushDiagEntry({
376
+ ts: now,
377
+ kind: 'visibility',
378
+ visibility: vs,
379
+ hidden_for_ms: hiddenForMs,
380
+ gap_since_prev_ms: _lastVisibilityChangeAt ? now - _lastVisibilityChangeAt : null,
381
+ });
382
+ _lastVisibilityChangeAt = now;
383
+ });
384
+
323
385
  async function refresh() {
324
386
  if (_refreshInFlight) return;
325
387
  _refreshInFlight = true;
388
+ const _diagOn = _isRefreshDiagOn();
389
+ const _t0 = _diagOn ? Date.now() : 0;
390
+ const _gap = _diagOn && _prevRefreshTs ? _t0 - _prevRefreshTs : null;
391
+ if (_diagOn) _prevRefreshTs = _t0;
392
+ const _diagEntry = _diagOn ? {
393
+ ts: _t0,
394
+ kind: 'refresh',
395
+ gap_since_prev_ms: _gap,
396
+ visibility: (typeof document !== 'undefined' && document.visibilityState) || null,
397
+ response_status: null,
398
+ etag_sent: _lastStatusEtag,
399
+ etag_received: null,
400
+ bytes_received: null,
401
+ statusCacheVersion: null,
402
+ workItems_changed: null,
403
+ workItems_count: null,
404
+ changed: null,
405
+ render_duration_ms: null,
406
+ error_message: null,
407
+ } : null;
326
408
  try {
327
409
  const headers = {};
328
410
  if (_lastStatusEtag) headers['If-None-Match'] = _lastStatusEtag;
@@ -331,6 +413,10 @@ async function refresh() {
331
413
  if (res.status === 304 && _lastStatusData) {
332
414
  // Cache hit — reuse last payload, skip parsing entirely.
333
415
  data = _lastStatusData;
416
+ if (_diagEntry) {
417
+ _diagEntry.response_status = '304';
418
+ _diagEntry.bytes_received = 0;
419
+ }
334
420
  } else {
335
421
  data = await res.json();
336
422
  const etag = res.headers && (res.headers.get ? res.headers.get('etag') : null);
@@ -338,6 +424,12 @@ async function refresh() {
338
424
  _lastStatusEtag = etag;
339
425
  _lastStatusData = data;
340
426
  }
427
+ if (_diagEntry) {
428
+ _diagEntry.response_status = String(res.status);
429
+ _diagEntry.etag_received = etag || null;
430
+ const cl = res.headers && (res.headers.get ? res.headers.get('content-length') : null);
431
+ _diagEntry.bytes_received = cl != null && cl !== '' ? Number(cl) : null;
432
+ }
341
433
  }
342
434
  // Auto-reload when dashboard restarts (stale connections cause "Failed to fetch" on CC/doc-chat)
343
435
  const dashId = (data.version && data.version.dashboardStartedAt) || null;
@@ -357,9 +449,37 @@ async function refresh() {
357
449
  return;
358
450
  }
359
451
  if (buildId) _knownDashboardBuildId = buildId;
360
- _processStatusUpdate(data);
361
- } catch(e) { console.error('refresh error', e); }
362
- finally { _refreshInFlight = false; }
452
+ const _renderStart = _diagOn ? Date.now() : 0;
453
+ let _diagChanges = null;
454
+ if (_diagOn) {
455
+ _diagChanges = {};
456
+ _lastChangedFlags = _diagChanges;
457
+ }
458
+ try {
459
+ _processStatusUpdate(data);
460
+ } finally {
461
+ if (_diagOn) {
462
+ _lastChangedFlags = null;
463
+ _diagEntry.render_duration_ms = Date.now() - _renderStart;
464
+ _diagEntry.changed = _diagChanges;
465
+ _diagEntry.workItems_changed = !!(_diagChanges && _diagChanges.workItems);
466
+ _diagEntry.workItems_count = (data.workItems || []).length;
467
+ _diagEntry.statusCacheVersion = (data.version && data.version.statusCacheVersion) != null
468
+ ? data.version.statusCacheVersion
469
+ : null;
470
+ }
471
+ }
472
+ } catch(e) {
473
+ console.error('refresh error', e);
474
+ if (_diagEntry && !_diagEntry.response_status) {
475
+ _diagEntry.response_status = (e && e.name === 'AbortError') ? 'abort' : 'error';
476
+ _diagEntry.error_message = String((e && e.message) || e);
477
+ }
478
+ }
479
+ finally {
480
+ _refreshInFlight = false;
481
+ if (_diagEntry) _pushDiagEntry(_diagEntry);
482
+ }
363
483
  }
364
484
 
365
485
  refresh();
@@ -375,3 +495,134 @@ document.querySelectorAll('.sidebar-link').forEach(link => {
375
495
  switchPage(currentPage);
376
496
 
377
497
  window.MinionsRefresh = { refresh };
498
+
499
+ // ── Refresh-diagnostic footer chip + modal (W-mphejzx100081972) ───────────
500
+ // Injected only when the dashboard-refresh-diagnostics feature flag is on
501
+ // (engine/features.js → MINIONS_FEATURES.flags). The chip floats bottom-right
502
+ // and opens a simple table view of the ring buffer plus a "send to engine"
503
+ // button that POSTs the snapshot to /api/diagnostics/refresh. No DOM is
504
+ // touched (and no listeners installed beyond the visibilitychange handler
505
+ // above, which itself short-circuits when the flag is off) when the flag is
506
+ // disabled — production behaviour stays untouched.
507
+ (function installRefreshDiagChip() {
508
+ if (!_isRefreshDiagOn()) return;
509
+ try {
510
+ const chip = document.createElement('button');
511
+ chip.id = 'refresh-diag-chip';
512
+ chip.type = 'button';
513
+ chip.textContent = 'diag';
514
+ chip.title = 'Open dashboard refresh diagnostics';
515
+ 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';
516
+ chip.addEventListener('click', _openRefreshDiagModal);
517
+ document.body.appendChild(chip);
518
+ } catch { /* DOM may not be ready in unusual test embeds */ }
519
+ })();
520
+
521
+ function _openRefreshDiagModal() {
522
+ // Remove any prior instance so re-clicks re-render against the latest buffer.
523
+ const prior = document.getElementById('refresh-diag-modal');
524
+ if (prior) prior.remove();
525
+ const entries = window._refreshDiagnostics();
526
+ const overlay = document.createElement('div');
527
+ overlay.id = 'refresh-diag-modal';
528
+ overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center;padding:24px';
529
+ overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.remove(); });
530
+ const panel = document.createElement('div');
531
+ panel.style.cssText = 'background:var(--surface,#1c1c1c);color:var(--text,#ddd);border:1px solid var(--border,#444);border-radius:8px;max-width:1200px;max-height:80vh;width:100%;display:flex;flex-direction:column;overflow:hidden;font-family:inherit;font-size:11px';
532
+ const head = document.createElement('div');
533
+ head.style.cssText = 'padding:10px 14px;border-bottom:1px solid var(--border,#444);display:flex;align-items:center;justify-content:space-between;gap:8px';
534
+ const title = document.createElement('div');
535
+ title.style.cssText = 'font-weight:700;color:var(--blue,#58a6ff);font-size:13px';
536
+ title.textContent = 'Refresh diagnostics — ' + entries.length + ' / ' + _DIAG_RING_SIZE + ' entries';
537
+ const actions = document.createElement('div');
538
+ actions.style.cssText = 'display:flex;gap:6px';
539
+ const sendBtn = document.createElement('button');
540
+ sendBtn.type = 'button';
541
+ sendBtn.textContent = 'Send to engine';
542
+ sendBtn.style.cssText = 'background:var(--blue,#1f6feb);color:#fff;border:none;border-radius:4px;padding:4px 10px;font-size:11px;cursor:pointer';
543
+ sendBtn.addEventListener('click', function() {
544
+ sendBtn.disabled = true;
545
+ sendBtn.textContent = 'Sending…';
546
+ safeFetch('/api/diagnostics/refresh', {
547
+ method: 'POST',
548
+ headers: { 'Content-Type': 'application/json' },
549
+ body: JSON.stringify({ entries: window._refreshDiagnostics() }),
550
+ })
551
+ .then(function(r) { return r.json().catch(function() { return {}; }).then(function(j) { return { ok: r.ok, body: j }; }); })
552
+ .then(function(r) {
553
+ sendBtn.textContent = r.ok ? ('Sent (' + (r.body && r.body.written) + ')') : ('Failed: ' + (r.body && r.body.error || r.ok));
554
+ })
555
+ .catch(function(e) { sendBtn.textContent = 'Error: ' + (e && e.message || e); })
556
+ .finally(function() { setTimeout(function() { sendBtn.disabled = false; sendBtn.textContent = 'Send to engine'; }, 2500); });
557
+ });
558
+ const clearBtn = document.createElement('button');
559
+ clearBtn.type = 'button';
560
+ clearBtn.textContent = 'Clear';
561
+ clearBtn.style.cssText = 'background:none;color:var(--muted,#aaa);border:1px solid var(--border,#444);border-radius:4px;padding:4px 10px;font-size:11px;cursor:pointer';
562
+ clearBtn.addEventListener('click', function() { window._refreshDiagnosticsClear(); overlay.remove(); });
563
+ const closeBtn = document.createElement('button');
564
+ closeBtn.type = 'button';
565
+ closeBtn.textContent = 'Close';
566
+ closeBtn.style.cssText = 'background:none;color:var(--muted,#aaa);border:1px solid var(--border,#444);border-radius:4px;padding:4px 10px;font-size:11px;cursor:pointer';
567
+ closeBtn.addEventListener('click', function() { overlay.remove(); });
568
+ actions.appendChild(sendBtn);
569
+ actions.appendChild(clearBtn);
570
+ actions.appendChild(closeBtn);
571
+ head.appendChild(title);
572
+ head.appendChild(actions);
573
+ const body = document.createElement('div');
574
+ body.style.cssText = 'overflow:auto;padding:8px 14px';
575
+ body.appendChild(_renderRefreshDiagTable(entries));
576
+ panel.appendChild(head);
577
+ panel.appendChild(body);
578
+ overlay.appendChild(panel);
579
+ document.body.appendChild(overlay);
580
+ }
581
+
582
+ function _renderRefreshDiagTable(entries) {
583
+ const table = document.createElement('table');
584
+ table.style.cssText = 'width:100%;border-collapse:collapse;font-family:monospace;font-size:11px';
585
+ const cols = ['ts', 'kind', 'gap', 'vis', 'status', 'etag↓', 'bytes', 'cacheV', 'wi', 'wiΔ', 'changed', 'render(ms)', 'note'];
586
+ const thead = document.createElement('thead');
587
+ const trh = document.createElement('tr');
588
+ for (const c of cols) {
589
+ const th = document.createElement('th');
590
+ th.textContent = c;
591
+ th.style.cssText = 'text-align:left;padding:3px 6px;border-bottom:1px solid var(--border,#444);color:var(--muted,#aaa);font-weight:600';
592
+ trh.appendChild(th);
593
+ }
594
+ thead.appendChild(trh);
595
+ table.appendChild(thead);
596
+ const tbody = document.createElement('tbody');
597
+ for (let i = entries.length - 1; i >= 0; i--) {
598
+ const e = entries[i];
599
+ const tr = document.createElement('tr');
600
+ const changedKeys = e.changed ? Object.keys(e.changed).filter(function(k) { return e.changed[k]; }) : [];
601
+ const isVis = e.kind === 'visibility';
602
+ const cells = [
603
+ new Date(e.ts).toLocaleTimeString(),
604
+ e.kind || 'refresh',
605
+ e.gap_since_prev_ms != null ? e.gap_since_prev_ms + 'ms' : '',
606
+ e.visibility || '',
607
+ isVis ? (e.hidden_for_ms != null ? 'hidden ' + e.hidden_for_ms + 'ms' : '—') : (e.response_status || ''),
608
+ e.etag_received ? String(e.etag_received).slice(0, 12) + '…' : '',
609
+ e.bytes_received != null ? e.bytes_received : '',
610
+ e.statusCacheVersion != null ? e.statusCacheVersion : '',
611
+ e.workItems_count != null ? e.workItems_count : '',
612
+ e.workItems_changed === true ? '✓' : (e.workItems_changed === false ? '' : ''),
613
+ changedKeys.join(',') || '',
614
+ e.render_duration_ms != null ? e.render_duration_ms : '',
615
+ e.error_message || '',
616
+ ];
617
+ for (const v of cells) {
618
+ const td = document.createElement('td');
619
+ td.textContent = String(v);
620
+ td.style.cssText = 'padding:3px 6px;border-bottom:1px solid rgba(255,255,255,0.05);vertical-align:top';
621
+ tr.appendChild(td);
622
+ }
623
+ if (isVis) tr.style.background = 'rgba(63,185,80,0.05)';
624
+ tbody.appendChild(tr);
625
+ }
626
+ table.appendChild(tbody);
627
+ return table;
628
+ }
@@ -82,8 +82,20 @@ function wiRow(item) {
82
82
  '<td>' + prLink + '</td>' +
83
83
  '<td><span class="pr-date">' + escapeHtml((item.created || '').slice(0, 16).replace('T', ' ')) + '</span></td>' +
84
84
  '<td style="white-space:nowrap;font-size:9px;color:var(--muted)">' +
85
- (item.references && item.references.length ? '<span title="' + item.references.length + ' reference(s)" style="margin-right:4px">&#x1F517;' + item.references.length + '</span>' : '') +
86
- (item.acceptanceCriteria && item.acceptanceCriteria.length ? '<span title="' + item.acceptanceCriteria.length + ' acceptance criteria">&#x2611;' + item.acceptanceCriteria.length + '</span>' : '') +
85
+ (function() {
86
+ // /api/status slims the full arrays into integer counts
87
+ // (dashboard.js:1796-1797: referencesCount / acceptanceCriteriaCount).
88
+ // Read those first; fall back to the array length for callers that
89
+ // still pass the full record (e.g. legacy fixtures, hydrated detail).
90
+ var refCount = (typeof item.referencesCount === 'number')
91
+ ? item.referencesCount
92
+ : (Array.isArray(item.references) ? item.references.length : 0);
93
+ var acCount = (typeof item.acceptanceCriteriaCount === 'number')
94
+ ? item.acceptanceCriteriaCount
95
+ : (Array.isArray(item.acceptanceCriteria) ? item.acceptanceCriteria.length : 0);
96
+ return (refCount ? '<span title="' + refCount + ' reference(s)" style="margin-right:4px">&#x1F517;' + refCount + '</span>' : '') +
97
+ (acCount ? '<span title="' + acCount + ' acceptance criteria">&#x2611;' + acCount + '</span>' : '');
98
+ })() +
87
99
  '</td>' +
88
100
  '<td style="white-space:nowrap">' +
89
101
  ((item.status === 'pending' || item.status === 'failed') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--blue);border-color:var(--blue);margin-right:4px" onclick="event.stopPropagation();editWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Edit work item">&#x270E;</button>' : '') +
@@ -144,9 +156,42 @@ function renderWorkItems(items) {
144
156
  }
145
157
  }
146
158
 
147
- function editWorkItem(id, source) {
148
- const item = allWorkItems.find(i => i.id === id);
149
- if (!item) return;
159
+ async function editWorkItem(id, source) {
160
+ const cached = allWorkItems.find(i => i.id === id);
161
+ if (!cached) return;
162
+ // Hydrate the full record before rendering the form. The /api/status slice
163
+ // strips `description`, `references`, and `acceptanceCriteria` (replacing
164
+ // the arrays with referencesCount / acceptanceCriteriaCount integers — see
165
+ // dashboard.js:1796-1797). Without hydration the textareas would pre-fill
166
+ // with empty values, and Save would POST description='', references=[],
167
+ // acceptanceCriteria=[] back to handleWorkItemsUpdate, which treats those
168
+ // as defined (dashboard.js:5157, :5164-5165) and silently wipes the stored
169
+ // values: data-loss on every pencil-edit of a pending/failed WI.
170
+ // See PR #2816 review feedback.
171
+ let item = cached;
172
+ const needsHydration = !cached.description ||
173
+ (cached.acceptanceCriteriaCount > 0 && !Array.isArray(cached.acceptanceCriteria)) ||
174
+ (cached.referencesCount > 0 && !Array.isArray(cached.references));
175
+ if (needsHydration) {
176
+ try {
177
+ const r = await fetch('/api/work-items/' + encodeURIComponent(id));
178
+ if (!r.ok) throw new Error('HTTP ' + r.status);
179
+ const data = await r.json();
180
+ const full = data && data.item;
181
+ if (!full) throw new Error('no item in response');
182
+ // Cached cross-slice fields (_pr, _source, _artifacts, _pendingReason,
183
+ // etc.) WIN over the on-disk record — they only exist on the engine's
184
+ // in-memory enrichment pass. The full record contributes description,
185
+ // acceptanceCriteria, and references back.
186
+ item = Object.assign({}, full, cached);
187
+ if (full.description != null) item.description = full.description;
188
+ if (Array.isArray(full.acceptanceCriteria)) item.acceptanceCriteria = full.acceptanceCriteria;
189
+ if (Array.isArray(full.references)) item.references = full.references;
190
+ } catch (e) {
191
+ alert('Failed to load work item for editing: ' + (e && e.message ? e.message : e) + '. Refusing to open the edit form with empty fields — saving would wipe the stored values.');
192
+ return;
193
+ }
194
+ }
150
195
  const types = ['implement', 'fix', 'review', 'plan', 'verify', 'decompose', 'meeting', 'investigate', 'refactor', 'test', 'explore', 'ask', 'docs', 'setup'];
151
196
  const priorities = ['critical', 'high', 'medium', 'low'];
152
197
  const agentOpts = (cmdAgents || []).map(a => '<option value="' + escapeHtml(a.id) + '"' + (item.agent === a.id ? ' selected' : '') + '>' + escapeHtml(a.name) + '</option>').join('');
@@ -467,11 +512,7 @@ async function _submitCreateWorkItem(e) {
467
512
  } catch (e) { alert('Error: ' + e.message); openCreateWorkItemModal(); }
468
513
  }
469
514
 
470
- function openWorkItemDetail(id) {
471
- if (window.getSelection && window.getSelection().toString().length > 0) return;
472
- const item = allWorkItems.find(i => i.id === id);
473
- if (!item) return;
474
-
515
+ function _wiRenderDetail(item) {
475
516
  const field = (label, value) => value ? '<div style="margin-bottom:8px"><span style="color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:0.5px">' + label + '</span><div style="margin-top:2px">' + value + '</div></div>' : '';
476
517
  const badge = (cls, text) => '<span class="pr-badge ' + cls + '">' + escapeHtml(text) + '</span>';
477
518
  const statusCls = item.status === 'failed' ? 'rejected' : item.status === 'dispatched' ? 'building' : item.status === 'done' ? 'approved' : 'active';
@@ -483,7 +524,20 @@ function openWorkItemDetail(id) {
483
524
  '<span class="dispatch-type ' + (item.type || 'implement') + '">' + escapeHtml(item.type || 'implement') + '</span>' +
484
525
  '<span class="prd-item-priority ' + (item.priority || '') + '">' + escapeHtml(item.priority || 'medium') + '</span>' +
485
526
  '</div>';
486
- html += field('Description', '<div style="font-size:12px;max-height:320px;overflow-y:auto;padding:8px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm)">' + renderMd(item.description || item.title || '—') + '</div>');
527
+ // Description: rendered from the FULL record once it's hydrated. The /api/status
528
+ // slice drops `description` to keep the SPA payload <500KB; on first paint we
529
+ // either render the title as a placeholder or, when description is already
530
+ // present (legacy/full record), render it directly. The hydration pass below
531
+ // re-renders this section in place once the fetch lands.
532
+ var _descHtml;
533
+ if (item.description) {
534
+ _descHtml = renderMd(item.description);
535
+ } else if (item._descriptionLoading) {
536
+ _descHtml = '<span style="color:var(--muted)">Loading description…</span>';
537
+ } else {
538
+ _descHtml = escapeHtml(item.title || '—');
539
+ }
540
+ html += field('Description', '<div id="wi-detail-desc" style="font-size:12px;max-height:320px;overflow-y:auto;padding:8px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm)">' + _descHtml + '</div>');
487
541
  html += field('Agent', escapeHtml(item.dispatched_to || item.agent || 'Auto'));
488
542
  html += field('Source', escapeHtml(item._source || 'central'));
489
543
  if (item.created) html += field('Created', escapeHtml(formatLocalDateTime(item.created)));
@@ -522,9 +576,19 @@ function openWorkItemDetail(id) {
522
576
  : (typeof item.acceptanceCriteria === 'string' && item.acceptanceCriteria.trim()
523
577
  ? item.acceptanceCriteria.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
524
578
  : []);
525
- if (ac.length) html += field('Acceptance Criteria', '<ul style="margin:0;padding-left:20px">' + ac.map(c => '<li>' + escapeHtml(c) + '</li>').join('') + '</ul>');
579
+ if (ac.length) {
580
+ html += field('Acceptance Criteria', '<ul style="margin:0;padding-left:20px">' + ac.map(c => '<li>' + escapeHtml(c) + '</li>').join('') + '</ul>');
581
+ } else if (item.acceptanceCriteriaCount > 0) {
582
+ // Count-only marker from the slim /api/status slice; full list arrives
583
+ // on the next hydration pass (or is already loading).
584
+ html += field('Acceptance Criteria', '<span style="color:var(--muted)">' + item.acceptanceCriteriaCount + ' criteria — loading…</span>');
585
+ }
526
586
  var refs = Array.isArray(item.references) ? item.references.filter(r => r && typeof r === 'object' && r.url) : [];
527
- if (refs.length) html += field('References', refs.map(r => '<a href="' + escapeHtml(r.url) + '" target="_blank" style="color:var(--blue)">' + escapeHtml(r.title || r.url) + '</a>' + (r.type ? ' <span style="color:var(--muted);font-size:10px">(' + escapeHtml(r.type) + ')</span>' : '')).join('<br>'));
587
+ if (refs.length) {
588
+ html += field('References', refs.map(r => '<a href="' + escapeHtml(r.url) + '" target="_blank" style="color:var(--blue)">' + escapeHtml(r.title || r.url) + '</a>' + (r.type ? ' <span style="color:var(--muted);font-size:10px">(' + escapeHtml(r.type) + ')</span>' : '')).join('<br>'));
589
+ } else if (item.referencesCount > 0) {
590
+ html += field('References', '<span style="color:var(--muted)">' + item.referencesCount + ' reference(s) — loading…</span>');
591
+ }
528
592
  if (item._humanFeedback) html += field('Human Feedback', (item._humanFeedback.rating === 'up' ? '👍' : '👎') + (item._humanFeedback.comment ? ' — ' + escapeHtml(item._humanFeedback.comment) : ''));
529
593
  if (item._pr) html += field('Pull Request', '<a href="' + escapeHtml(item._prUrl || '#') + '" target="_blank" style="color:var(--blue)">' + escapeHtml(item._pr) + '</a>');
530
594
 
@@ -562,12 +626,55 @@ function openWorkItemDetail(id) {
562
626
  if (item._totalInputTokens) html += field('Total Input Tokens', Number(item._totalInputTokens).toLocaleString());
563
627
  if (item._totalOutputTokens) html += field('Total Output Tokens', Number(item._totalOutputTokens).toLocaleString());
564
628
  html += '</div>';
629
+ return html;
630
+ }
565
631
 
566
- document.getElementById('modal-title').textContent = item.title || item.id;
567
- document.getElementById('modal-body').innerHTML = html;
632
+ function openWorkItemDetail(id) {
633
+ if (window.getSelection && window.getSelection().toString().length > 0) return;
634
+ const cached = allWorkItems.find(i => i.id === id);
635
+ if (!cached) return;
636
+
637
+ // Render the modal immediately from the cached (slim) record so the click
638
+ // feels instant. The /api/status slice is the source of badges, status,
639
+ // PR link, artifacts, etc. — everything except the heavy free-text fields
640
+ // (description, full acceptanceCriteria, full references) — see
641
+ // W-mphejzmj000718bf. We then hydrate the missing fields from
642
+ // GET /api/work-items/<id> and re-render in place.
643
+ const needsHydration = !cached.description ||
644
+ (cached.acceptanceCriteriaCount > 0 && !Array.isArray(cached.acceptanceCriteria)) ||
645
+ (cached.referencesCount > 0 && !Array.isArray(cached.references));
646
+
647
+ const initial = needsHydration ? Object.assign({}, cached, { _descriptionLoading: true }) : cached;
648
+ document.getElementById('modal-title').textContent = initial.title || initial.id;
649
+ document.getElementById('modal-body').innerHTML = _wiRenderDetail(initial);
568
650
  document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
569
651
  document.getElementById('modal-body').style.whiteSpace = 'normal';
570
652
  document.getElementById('modal').classList.add('open');
653
+
654
+ if (!needsHydration) return;
655
+
656
+ fetch('/api/work-items/' + encodeURIComponent(id))
657
+ .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
658
+ .then(function(data) {
659
+ // Guard against modal navigation away from this WI during the fetch.
660
+ var title = document.getElementById('modal-title');
661
+ if (!title || title.textContent !== (initial.title || initial.id)) return;
662
+ var full = data && data.item;
663
+ if (!full) return;
664
+ // Merge: cached cross-slice fields (_pr, _artifacts, etc.) WIN over
665
+ // the on-disk record so we don't lose engine enrichment that lives
666
+ // only on the in-memory pass. The full record contributes description,
667
+ // acceptanceCriteria, and references back to the rendered shape.
668
+ var merged = Object.assign({}, full, cached);
669
+ merged.description = full.description || cached.description || '';
670
+ if (Array.isArray(full.acceptanceCriteria)) merged.acceptanceCriteria = full.acceptanceCriteria;
671
+ if (Array.isArray(full.references)) merged.references = full.references;
672
+ document.getElementById('modal-body').innerHTML = _wiRenderDetail(merged);
673
+ })
674
+ .catch(function() {
675
+ var desc = document.getElementById('wi-detail-desc');
676
+ if (desc) desc.innerHTML = '<span style="color:var(--red)">Failed to load full record.</span>';
677
+ });
571
678
  }
572
679
 
573
680
  function openAllWorkItems() {
@@ -88,6 +88,7 @@ async function openSettings() {
88
88
  settingsField('Restart Grace Period', 'set-restartGracePeriod', e.restartGracePeriod || 1200000, 'ms', 'Grace period before orphan detection on restart') +
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
+ 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).') +
91
92
  '</div>' +
92
93
  '<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">Automation</h3>' +
93
94
  '<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:16px">' +
@@ -597,6 +598,7 @@ async function saveSettings() {
597
598
  restartGracePeriod: document.getElementById('set-restartGracePeriod').value,
598
599
  meetingRoundTimeout: document.getElementById('set-meetingRoundTimeout').value,
599
600
  operatorLogin: (document.getElementById('set-operatorLogin')?.value ?? '').trim(),
601
+ statusWorkItemsRetentionDays: document.getElementById('set-statusWorkItemsRetentionDays').value,
600
602
  autoApprovePlans: document.getElementById('set-autoApprovePlans').checked,
601
603
  evalLoop: document.getElementById('set-evalLoop').checked,
602
604
  autoDecompose: document.getElementById('set-autoDecompose').checked,
package/dashboard.js CHANGED
@@ -1708,7 +1708,7 @@ function _buildStatusFastState() {
1708
1708
  dispatch: getDispatchQueue(),
1709
1709
  engineLog: getEngineLog(),
1710
1710
  metrics: getMetrics(),
1711
- workItems: getWorkItems(),
1711
+ workItems: _slimWorkItemsForStatus(getWorkItems()),
1712
1712
  watches: watchesMod.getWatches(),
1713
1713
  meetings: _safeStatusSlice('meetings', () => meetingMod.getMeetings(), []),
1714
1714
  // QA runs — surfaced for the sidebar activity-dot counter and any future
@@ -1745,6 +1745,105 @@ function _safeStatusSlice(name, fn, fallback) {
1745
1745
  }
1746
1746
  }
1747
1747
 
1748
+ // ── /api/status workItems slimming (W-mphejzmj000718bf) ─────────────────────
1749
+ // Strip down the workItems slice shipped on /api/status to (a) drop terminal
1750
+ // (done/failed/cancelled) items older than engine.statusWorkItemsRetentionDays
1751
+ // and (b) project each surviving item onto a narrow shape that omits the
1752
+ // large free-text fields (description, full acceptanceCriteria, full
1753
+ // references). The dashboard never renders description/AC/references-detail
1754
+ // off the cached slice — `wiRow` only needs counts + status/badge fields,
1755
+ // and `openWorkItemDetail` fetches the full record on demand via
1756
+ // GET /api/work-items/<id>. Live measurement at task time: 796 items / ~3MB
1757
+ // → ~50–100 items / <500KB typical. Active items (pending/dispatched/queued)
1758
+ // are ALWAYS kept regardless of age.
1759
+ //
1760
+ // Set engine.statusWorkItemsRetentionDays = 0 to disable trimming entirely
1761
+ // (returns the full list unchanged, restoring legacy behavior).
1762
+ const _ACTIVE_WI_STATUSES_FOR_STATUS = new Set(['pending', 'dispatched', 'queued', 'paused', 'decomposed']);
1763
+ const _TERMINAL_WI_STATUSES_FOR_STATUS = new Set(['done', 'failed', 'cancelled']);
1764
+ function _resolveStatusWorkItemsRetentionDays() {
1765
+ const raw = CONFIG?.engine?.statusWorkItemsRetentionDays;
1766
+ if (raw === 0 || raw === '0') return 0;
1767
+ const n = Number(raw);
1768
+ if (Number.isFinite(n) && n >= 0) return n;
1769
+ return shared.ENGINE_DEFAULTS.statusWorkItemsRetentionDays;
1770
+ }
1771
+ function _slimWorkItemForStatus(item) {
1772
+ const slim = {
1773
+ id: item.id,
1774
+ title: item.title,
1775
+ status: item.status,
1776
+ type: item.type,
1777
+ priority: item.priority,
1778
+ _source: item._source,
1779
+ created: item.created,
1780
+ };
1781
+ if (item.dispatched_to !== undefined) slim.dispatched_to = item.dispatched_to;
1782
+ if (item.agent !== undefined) slim.agent = item.agent;
1783
+ if (item._pr !== undefined) slim._pr = item._pr;
1784
+ if (item._prUrl !== undefined) slim._prUrl = item._prUrl;
1785
+ if (item._skipReason !== undefined) slim._skipReason = item._skipReason;
1786
+ if (item._blockedBy !== undefined) slim._blockedBy = item._blockedBy;
1787
+ if (item._humanFeedback !== undefined) slim._humanFeedback = item._humanFeedback;
1788
+ if (Array.isArray(item.completedAgents)) slim.completedAgents = item.completedAgents;
1789
+ if (item.failReason !== undefined) slim.failReason = item.failReason;
1790
+ if (item.branchStrategy !== undefined) slim.branchStrategy = item.branchStrategy;
1791
+ if (item.scope !== undefined) slim.scope = item.scope;
1792
+ if (item.completedAt !== undefined) slim.completedAt = item.completedAt;
1793
+ if (item.dispatched_at !== undefined) slim.dispatched_at = item.dispatched_at;
1794
+ if (item._reopened !== undefined) slim._reopened = item._reopened;
1795
+ if (item._pendingReason !== undefined) slim._pendingReason = item._pendingReason;
1796
+ if (item._managedSpawnPartial !== undefined) slim._managedSpawnPartial = item._managedSpawnPartial;
1797
+ if (item._securityFlag !== undefined) slim._securityFlag = item._securityFlag;
1798
+ if (item._artifacts !== undefined) slim._artifacts = item._artifacts;
1799
+ // Cross-slice fields read by derivePlanStatus / render-prd / refresh.js
1800
+ // (sourcePlan, planFile, itemType, project, parent_id). Without these the
1801
+ // plan-status derivation and decomposed-children rollup regress.
1802
+ if (item.sourcePlan !== undefined) slim.sourcePlan = item.sourcePlan;
1803
+ if (item.planFile !== undefined) slim.planFile = item.planFile;
1804
+ if (item.itemType !== undefined) slim.itemType = item.itemType;
1805
+ if (item.project !== undefined) slim.project = item.project;
1806
+ if (item.parent_id !== undefined) slim.parent_id = item.parent_id;
1807
+ // PR follow-up subobject is small and drives the +N chip + row badge.
1808
+ if (item.meta && item.meta.pr_followup) slim.meta = { pr_followup: item.meta.pr_followup };
1809
+ // Counts only — the modal fetches full arrays on demand via /api/work-items/<id>.
1810
+ slim.referencesCount = Array.isArray(item.references) ? item.references.length : 0;
1811
+ slim.acceptanceCriteriaCount = Array.isArray(item.acceptanceCriteria) ? item.acceptanceCriteria.length : 0;
1812
+ return slim;
1813
+ }
1814
+ function _slimWorkItemsForStatus(items) {
1815
+ if (!Array.isArray(items)) return items;
1816
+ const retentionDays = _resolveStatusWorkItemsRetentionDays();
1817
+ if (retentionDays <= 0) {
1818
+ // Trimming disabled — keep full list but still flatten via slim shape
1819
+ // so wire format is consistent (renderers never have to handle the old
1820
+ // heavy fields). Set retentionDays to a huge value if you want EVERY
1821
+ // terminal item, not just recent ones — 0 means "skip the date filter".
1822
+ return items.map(_slimWorkItemForStatus);
1823
+ }
1824
+ const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
1825
+ const surviving = [];
1826
+ for (const item of items) {
1827
+ if (!item) continue;
1828
+ const status = item.status || 'pending';
1829
+ if (_TERMINAL_WI_STATUSES_FOR_STATUS.has(status)) {
1830
+ const ts = item.completedAt || item.dispatched_at || item.created || '';
1831
+ const tsMs = ts ? Date.parse(ts) : NaN;
1832
+ // Drop only when we have a parseable timestamp and it's beyond the
1833
+ // window. Items with missing/unparseable timestamps stay visible —
1834
+ // we'd rather over-include than silently hide them.
1835
+ if (Number.isFinite(tsMs) && tsMs < cutoffMs) {
1836
+ continue;
1837
+ }
1838
+ } else if (!_ACTIVE_WI_STATUSES_FOR_STATUS.has(status)) {
1839
+ // Unknown status — keep, so a future status value isn't silently
1840
+ // hidden until the constant set is updated.
1841
+ }
1842
+ surviving.push(_slimWorkItemForStatus(item));
1843
+ }
1844
+ return surviving;
1845
+ }
1846
+
1748
1847
  // Build the slow-state slice (rarely-changing data: ~60s TTL).
1749
1848
  function _buildStatusSlowState() {
1750
1849
  const prdInfo = getPrdInfo();
@@ -1812,6 +1911,13 @@ function _buildStatusSlowState() {
1812
1911
  // on the client (R3, W-mpgb0xgc000hf1d3). Quotes stripped from the
1813
1912
  // weak/strong ETag form so consumers see a bare hex digest.
1814
1913
  dashboardBuildId: HTML_ETAG ? HTML_ETAG.replace(/^"|"$/g, '') : null,
1914
+ // Monotonic counter bumped on every successful /api/status cache rebuild.
1915
+ // Surfaced so the dashboard refresh-diagnostics ring buffer
1916
+ // (dashboard/js/refresh.js, W-mphejzx100081972) can correlate "did the
1917
+ // server cache actually advance between two ticks" — distinguishes
1918
+ // (b) server kept returning 304 with stale data from (c) renderer
1919
+ // change-detection caches skipped the render on fresh data.
1920
+ statusCacheVersion: _statusCacheVersion,
1815
1921
  disk: diskVersion,
1816
1922
  diskCommit,
1817
1923
  engineStale,
@@ -4821,6 +4927,31 @@ const server = http.createServer(async (req, res) => {
4821
4927
  } catch (e) { console.error('Archive fetch error:', e.message); return jsonReply(res, e.statusCode || 500, { error: e.message }); }
4822
4928
  }
4823
4929
 
4930
+ // GET /api/work-items/<id> — return a single FULL work-item record by id
4931
+ // (W-mphejzmj000718bf). The /api/status workItems slice ships a slimmed
4932
+ // shape that omits description, full acceptanceCriteria, and full references
4933
+ // to keep the SPA payload <500KB. The work-item detail modal calls this
4934
+ // endpoint on click to fetch the full record on demand.
4935
+ async function handleWorkItemsById(req, res, match) {
4936
+ try {
4937
+ const id = decodeURIComponent(match[1] || '').trim();
4938
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
4939
+ // Reserved sub-paths handled by their own static routes — never fall
4940
+ // through to this regex; defense-in-depth in case ordering ever
4941
+ // changes.
4942
+ if (id === 'archive' || id === 'retry' || id === 'update' || id === 'delete' ||
4943
+ id === 'cancel' || id === 'reopen' || id === 'feedback') {
4944
+ return jsonReply(res, 404, { error: 'work item not found' });
4945
+ }
4946
+ // Search central + every project's work-items.json for this id. The
4947
+ // engine never enforces global id uniqueness in the file system, but
4948
+ // collisions in practice are nil — first hit wins.
4949
+ const found = getWorkItems().find(w => w && w.id === id);
4950
+ if (!found) return jsonReply(res, 404, { error: 'work item not found' });
4951
+ return jsonReply(res, 200, { item: found });
4952
+ } catch (e) { return jsonReply(res, 500, { error: e.message }); }
4953
+ }
4954
+
4824
4955
  async function handleWorkItemsReopen(req, res) {
4825
4956
  try {
4826
4957
  const body = await readBody(req);
@@ -8392,6 +8523,21 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8392
8523
  }
8393
8524
  // String fields
8394
8525
  if (e.worktreeRoot !== undefined) _setEngineConfig('worktreeRoot', String(e.worktreeRoot || D.worktreeRoot));
8526
+ // W-mphejzmj000718bf: /api/status workItems retention window. Handled
8527
+ // outside the numericFields loop because `Number(0) || D[key]`
8528
+ // collapses the "disable trim" value (0) back to the default — we
8529
+ // want 0 to actually persist as "skip the date filter".
8530
+ if (e.statusWorkItemsRetentionDays !== undefined) {
8531
+ const raw = e.statusWorkItemsRetentionDays;
8532
+ if (raw === '' || raw === null) {
8533
+ _deleteEngineConfig('statusWorkItemsRetentionDays');
8534
+ } else {
8535
+ let val = Number(raw);
8536
+ if (!Number.isFinite(val) || val < 0) val = D.statusWorkItemsRetentionDays;
8537
+ if (val > 365) { _clamped.push(`statusWorkItemsRetentionDays: ${val} → 365 (range: 0–365)`); val = 365; }
8538
+ _setEngineConfig('statusWorkItemsRetentionDays', val);
8539
+ }
8540
+ }
8395
8541
  // W-mpejf0fq000e84d6: operator login override. Empty string clears
8396
8542
  // the override (engine falls back to gh/git/os resolution); any other
8397
8543
  // value pins the login used in `user/<login>/<wi-id>-<slug>` branches.
@@ -8723,6 +8869,52 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8723
8869
  } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
8724
8870
  }
8725
8871
 
8872
+ // Refresh diagnostics — append the browser-side ring buffer to a rotating
8873
+ // log so a user clicking "send to engine" from the diag chip persists the
8874
+ // last ~50 /api/status polls for offline triage (W-mphejzx100081972).
8875
+ // Rotated at 1 MB to bound disk usage; CSRF/origin-gated by the dispatcher
8876
+ // (MUTATING_METHODS guard at dashboard.js:4337+).
8877
+ const DASHBOARD_DIAGNOSTICS_LOG = path.join(ENGINE_DIR, 'dashboard-diagnostics.log');
8878
+ const DASHBOARD_DIAGNOSTICS_ROTATE_BYTES = 1024 * 1024;
8879
+ const DASHBOARD_DIAGNOSTICS_MAX_ENTRIES = 500;
8880
+
8881
+ async function handleDiagnosticsRefresh(req, res) {
8882
+ try {
8883
+ const body = await readBody(req);
8884
+ const entries = body && Array.isArray(body.entries) ? body.entries : null;
8885
+ if (!entries) return jsonReply(res, 400, { error: 'entries array required' });
8886
+ if (entries.length > DASHBOARD_DIAGNOSTICS_MAX_ENTRIES) {
8887
+ return jsonReply(res, 400, { error: `entries length ${entries.length} exceeds cap ${DASHBOARD_DIAGNOSTICS_MAX_ENTRIES}` });
8888
+ }
8889
+ // Rotate before write if the existing log is already at/over the cap.
8890
+ // Single rolled file (.1) — older rotations are discarded; this is a
8891
+ // diagnostic capture surface, not a long-term audit log.
8892
+ try {
8893
+ const st = fs.statSync(DASHBOARD_DIAGNOSTICS_LOG);
8894
+ if (st.size >= DASHBOARD_DIAGNOSTICS_ROTATE_BYTES) {
8895
+ const rolled = DASHBOARD_DIAGNOSTICS_LOG + '.1';
8896
+ try { fs.unlinkSync(rolled); } catch { /* may not exist */ }
8897
+ try { fs.renameSync(DASHBOARD_DIAGNOSTICS_LOG, rolled); } catch { /* best effort */ }
8898
+ }
8899
+ } catch { /* file may not exist yet */ }
8900
+ const header = {
8901
+ ts: new Date().toISOString(),
8902
+ ua: String(req.headers['user-agent'] || '').slice(0, 200),
8903
+ tab: String(req.headers['x-minions-dashboard-tab'] || '').slice(0, 80),
8904
+ url: String(req.headers['x-minions-dashboard-url'] || '').slice(0, 200),
8905
+ count: entries.length,
8906
+ };
8907
+ // One line per record: header line followed by one line per entry. JSON-
8908
+ // Lines makes the file trivially `jq`-able for later analysis.
8909
+ const lines = [JSON.stringify({ kind: 'batch', ...header })];
8910
+ for (const e of entries) {
8911
+ try { lines.push(JSON.stringify(e)); } catch { /* skip un-serialisable entry */ }
8912
+ }
8913
+ fs.appendFileSync(DASHBOARD_DIAGNOSTICS_LOG, lines.join('\n') + '\n');
8914
+ return jsonReply(res, 200, { ok: true, written: entries.length, path: path.relative(MINIONS_DIR, DASHBOARD_DIAGNOSTICS_LOG) });
8915
+ } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
8916
+ }
8917
+
8726
8918
  // Slim UX surface for the experimental redesigned dashboard.
8727
8919
  // The HTML lives in dashboard/slim.html so the human can iterate on the
8728
8920
  // markup directly — we read the file from disk on each request (no in-
@@ -9593,6 +9785,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9593
9785
  invalidateStatusCache();
9594
9786
  return jsonReply(res, 200, { ok: true });
9595
9787
  }},
9788
+ // GET /api/work-items/<id> — fetch a single FULL work-item record by id.
9789
+ // Registered AFTER all static /api/work-items/* routes so the regex never
9790
+ // shadows them (route matching is sequential, first match wins).
9791
+ // W-mphejzmj000718bf: the /api/status workItems slice drops description /
9792
+ // acceptanceCriteria-detail / references-detail to cut payload size; the
9793
+ // detail modal calls this endpoint on click to fetch the full record.
9794
+ { method: 'GET', path: /^\/api\/work-items\/([^/?]+)$/, template: '/api/work-items/<id>', desc: 'Fetch a single full work-item record by id (description, acceptanceCriteria, references). The /api/status workItems slice ships a slimmed shape; this endpoint backs the detail modal.', handler: handleWorkItemsById },
9596
9795
 
9597
9796
  // Pinned notes
9598
9797
  { method: 'GET', path: '/api/pinned', desc: 'Get pinned notes', handler: async (req, res) => {
@@ -10205,6 +10404,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10205
10404
  // Feature flags (experimental / in-progress UX gates — see engine/features.js)
10206
10405
  { method: 'GET', path: '/api/features', desc: 'List registered feature flags with current enabled state', handler: handleFeaturesList },
10207
10406
  { method: 'POST', path: '/api/features/toggle', desc: 'Enable/disable a registered feature flag', params: 'id, enabled', handler: handleFeaturesToggle },
10407
+
10408
+ // Diagnostics — refresh ring buffer persistence (W-mphejzx100081972).
10409
+ { method: 'POST', path: '/api/diagnostics/refresh', desc: 'Append a dashboard refresh-diagnostic ring buffer batch to engine/dashboard-diagnostics.log (rotated at 1 MB)', params: 'entries[]', handler: handleDiagnosticsRefresh },
10208
10410
  ];
10209
10411
 
10210
10412
  // ── Route Dispatcher ────────────────────────────────────────────────────────
@@ -53,6 +53,20 @@ const FEATURES = {
53
53
  addedIn: '0.1.1916',
54
54
  requiredCcRuntime: 'copilot',
55
55
  },
56
+ // dashboard-refresh-diagnostics — W-mphejzx100081972. Enables the in-browser
57
+ // /api/status poll telemetry ring buffer in dashboard/js/refresh.js: per-cycle
58
+ // ETag/status/bytes/render-duration capture, visibility-transition tracking,
59
+ // a small "diag" footer chip that opens a table modal, and a "send to engine"
60
+ // button that POSTs the buffer to /api/diagnostics/refresh for offline triage.
61
+ // Default OFF in production; flip ON in dev (or via env-var override) before
62
+ // reproducing a "didn't auto-update" complaint so the telemetry is captured.
63
+ // Disabled => the poll loop is byte-identical to its pre-flag steady state
64
+ // (the diagnostic helpers no-op via _isRefreshDiagOn() at the top of refresh()).
65
+ 'dashboard-refresh-diagnostics': {
66
+ description: 'Capture the last 50 dashboard /api/status poll cycles (timing, ETag, status code, render duration, per-renderer change flags) in a browser-side ring buffer accessible via window._refreshDiagnostics, a footer "diag" chip, and POST /api/diagnostics/refresh.',
67
+ default: false,
68
+ addedIn: '0.1.2034',
69
+ },
56
70
  };
57
71
 
58
72
  const ENV_TRUTHY = new Set(['1', 'true', 'on', 'yes']);
package/engine/shared.js CHANGED
@@ -2032,6 +2032,15 @@ const ENGINE_DEFAULTS = {
2032
2032
  // Settings UI exposes this as a free-text input; clearing the field deletes
2033
2033
  // the override and falls back to auto-resolution.
2034
2034
  operatorLogin: null,
2035
+ // ── /api/status workItems retention (W-mphejzmj000718bf) ────────────────────
2036
+ // Trim done/failed/cancelled work items older than this many days from the
2037
+ // /api/status workItems slice to cut the SPA payload (live: 796 items / 3MB
2038
+ // → ~50–100 items / <500KB typical). Active items (pending/dispatched/queued)
2039
+ // are ALWAYS shipped regardless of age — only terminal items past the
2040
+ // window are dropped. The detail modal fetches the full record on demand
2041
+ // via GET /api/work-items/<id> when description/references/AC are needed.
2042
+ // 0 disables the trim (full list shipped, restoring legacy behavior).
2043
+ statusWorkItemsRetentionDays: 7,
2035
2044
  };
2036
2045
 
2037
2046
  // ─── Runtime Fleet Resolution (P-3b8e5f1d) ──────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2037",
3
+ "version": "0.1.2038",
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"