@yemi33/minions 0.1.2036 → 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
 
@@ -122,7 +135,7 @@ function _formatCcDrawerLabel(autoMode) {
122
135
 
123
136
  function _processStatusUpdate(data) {
124
137
  // Detect fresh install — clear stale browser state AND reload so module-scoped
125
- // JS caches (_sectionCache, _prevCounts, _kbRefreshCount, _managedProcessesLastItems,
138
+ // JS caches (_sectionCache, _prevCounts, _managedProcessesLastItems,
126
139
  // _pipelinePollHash, _meetingPollHash, etc.) reinitialise against the new install's
127
140
  // data shape. localStorage.clear() alone leaves those caches stale after a
128
141
  // MINIONS_HOME swap that doesn't restart the dashboard (F11 / W-mpgcijjo000ce878).
@@ -252,16 +265,25 @@ function _processStatusUpdate(data) {
252
265
  if (swi) swi.textContent = (data.workItems || []).length || '';
253
266
  const spr = document.getElementById('sidebar-pr');
254
267
  if (spr) spr.textContent = (data.pullRequests || []).length || '';
255
- // Refresh KB and plans less frequently (every 3rd cycle = ~12s)
256
- if (!window._kbRefreshCount) window._kbRefreshCount = 0;
257
- if (window._kbRefreshCount++ % 3 === 0) { refreshKnowledgeBase(); refreshPlans(); }
268
+ // Refresh KB and plans every status cycle (~4s) so plan status flips
269
+ // (approve/archive/complete) and KB additions surface within the SPA's
270
+ // 4s poll target. Server-side caches keep this cheap:
271
+ // - /api/plans: 5s in-memory TTL + invalidation on every mutating endpoint
272
+ // (dashboard.js _plansCache / invalidatePlansCache) — back-to-back polls
273
+ // hit the cache; external file edits surface within 5s.
274
+ // - /api/knowledge: 30s in-memory TTL + invalidation on POST /api/knowledge
275
+ // and after every kb-sweep (engine/queries.js _kbCache / kb-sweep.js).
276
+ // Previously throttled to every 3rd cycle (~12s) — see W-mphfb6ss000a3b9e
277
+ // for the cadence audit + Playwright coverage.
278
+ refreshKnowledgeBase();
279
+ refreshPlans();
258
280
 
259
281
  // Cross-slice render triggers (F1/F3, W-mpgb0xbh000e3b86): renderPrs reads
260
282
  // window._lastWorkItems for the +N follow-up chip count and derivePlanStatus
261
283
  // reads window._lastStatus.pullRequests for verify-follow-up reconciliation.
262
284
  // When workItems OR pullRequests change, re-render the dependents against the
263
- // freshest cached data — without waiting up to 12s for the next refreshPlans
264
- // cycle (F3) or for a PR object to mutate (F1). Runs after the window._last*
285
+ // freshest cached data — without waiting for the next refreshPlans cycle (F3)
286
+ // or for a PR object to mutate (F1). Runs after the window._last*
265
287
  // assignments above so the cached globals these renderers consult are fresh.
266
288
  if (_workItemsChanged && !_prsChanged) {
267
289
  // F1: only the work-item slice moved this tick — renderPrs wasn't called
@@ -271,9 +293,9 @@ function _processStatusUpdate(data) {
271
293
  }
272
294
  if ((_workItemsChanged || _prsChanged) && Array.isArray(window._lastPlans) && typeof renderPlans === 'function') {
273
295
  // F3: derivePlanStatus + _renderVerifyBadge derive from pullRequests +
274
- // workItems. Re-render against the cached plan list so plan status flips
275
- // within one /api/status tick (~4s) instead of one refreshPlans poll
276
- // (~12s). No-op when _lastPlans hasn't been populated yet (first load).
296
+ // workItems. Re-render against cached plans so plan status flips within
297
+ // one /api/status tick (~4s) instead of one refreshPlans poll. No-op
298
+ // until _lastPlans is populated.
277
299
  renderPlans(window._lastPlans);
278
300
  }
279
301
 
@@ -311,9 +333,78 @@ let _lastStatusData = null;
311
333
  // when the first hasn't finished — the next interval fire picks up the
312
334
  // fresh state anyway.
313
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
+
314
385
  async function refresh() {
315
386
  if (_refreshInFlight) return;
316
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;
317
408
  try {
318
409
  const headers = {};
319
410
  if (_lastStatusEtag) headers['If-None-Match'] = _lastStatusEtag;
@@ -322,6 +413,10 @@ async function refresh() {
322
413
  if (res.status === 304 && _lastStatusData) {
323
414
  // Cache hit — reuse last payload, skip parsing entirely.
324
415
  data = _lastStatusData;
416
+ if (_diagEntry) {
417
+ _diagEntry.response_status = '304';
418
+ _diagEntry.bytes_received = 0;
419
+ }
325
420
  } else {
326
421
  data = await res.json();
327
422
  const etag = res.headers && (res.headers.get ? res.headers.get('etag') : null);
@@ -329,6 +424,12 @@ async function refresh() {
329
424
  _lastStatusEtag = etag;
330
425
  _lastStatusData = data;
331
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
+ }
332
433
  }
333
434
  // Auto-reload when dashboard restarts (stale connections cause "Failed to fetch" on CC/doc-chat)
334
435
  const dashId = (data.version && data.version.dashboardStartedAt) || null;
@@ -348,9 +449,37 @@ async function refresh() {
348
449
  return;
349
450
  }
350
451
  if (buildId) _knownDashboardBuildId = buildId;
351
- _processStatusUpdate(data);
352
- } catch(e) { console.error('refresh error', e); }
353
- 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
+ }
354
483
  }
355
484
 
356
485
  refresh();
@@ -366,3 +495,134 @@ document.querySelectorAll('.sidebar-link').forEach(link => {
366
495
  switchPage(currentPage);
367
496
 
368
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,
@@ -27,10 +27,13 @@ let currentPage = getPageFromUrl();
27
27
 
28
28
  // W-mpgb0xa7000d90d4 (dashboard-refresh-audit.md F5) — switchPage previously
29
29
  // only cleaned intervals and flipped CSS; tabs like /plans and /inbox waited
30
- // up to ~12s for refresh.js's slow-cycle (refreshKnowledgeBase + refreshPlans
31
- // every 3rd status tick) before showing fresh data. The QA tab worked around
32
- // this with its own switchPage monkey-patch in qa.js (__qaWrapped) — that
33
- // per-tab pattern is now generalized into one canonical lifecycle below.
30
+ // for refresh.js's next slow-cycle (refreshKnowledgeBase + refreshPlans) before
31
+ // showing fresh data. The QA tab worked around this with its own switchPage
32
+ // monkey-patch in qa.js (__qaWrapped) — that per-tab pattern is now generalized
33
+ // into one canonical lifecycle below. (As of W-mphfb6ss000a3b9e the slow-cycle
34
+ // modulo was dropped, so refreshPlans/refreshKnowledgeBase now run every 4s
35
+ // status tick — but the page-enter loader still wins on tab-switch because it
36
+ // fires synchronously without waiting for the next interval boundary.)
34
37
  //
35
38
  // PAGE_LAZY_LOADERS: functions to invoke when ENTERING a page (resolved by
36
39
  // name through window so the function body can live alongside its tab UI).
package/dashboard.js CHANGED
@@ -1628,6 +1628,20 @@ function _getSlowMtimes() {
1628
1628
  return result;
1629
1629
  }
1630
1630
 
1631
+ // Reset the per-source caches that outlive the slow-state TTL when a tracked
1632
+ // source file changes (W-mphfdgwv000bf549). The slow-state mtime tracker now
1633
+ // covers skill discovery dirs and MCP config files, but
1634
+ // `queries._skillsCache` (30 s) and the local `_mcpServersCache` (5 min)
1635
+ // would still serve stale data into `_buildStatusSlowState()` — defeating
1636
+ // the <4 s freshness goal. Only call this when an mtime delta is detected;
1637
+ // TTL-driven rebuilds keep using the inner caches so we don't pay disk-scan
1638
+ // cost on every 60 s slow-state rollover.
1639
+ function _invalidateSlowInnerCachesForMtimeChange() {
1640
+ try { queries.invalidateSkillsCache(); } catch { /* optional */ }
1641
+ _mcpServersCache = null;
1642
+ _mcpServersCacheTs = 0;
1643
+ }
1644
+
1631
1645
  function _mtimesChanged(prev, curr) {
1632
1646
  for (const fp of Object.keys(curr)) {
1633
1647
  if (prev[fp] !== curr[fp]) return true;
@@ -1694,7 +1708,7 @@ function _buildStatusFastState() {
1694
1708
  dispatch: getDispatchQueue(),
1695
1709
  engineLog: getEngineLog(),
1696
1710
  metrics: getMetrics(),
1697
- workItems: getWorkItems(),
1711
+ workItems: _slimWorkItemsForStatus(getWorkItems()),
1698
1712
  watches: watchesMod.getWatches(),
1699
1713
  meetings: _safeStatusSlice('meetings', () => meetingMod.getMeetings(), []),
1700
1714
  // QA runs — surfaced for the sidebar activity-dot counter and any future
@@ -1731,6 +1745,105 @@ function _safeStatusSlice(name, fn, fallback) {
1731
1745
  }
1732
1746
  }
1733
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
+
1734
1847
  // Build the slow-state slice (rarely-changing data: ~60s TTL).
1735
1848
  function _buildStatusSlowState() {
1736
1849
  const prdInfo = getPrdInfo();
@@ -1798,6 +1911,13 @@ function _buildStatusSlowState() {
1798
1911
  // on the client (R3, W-mpgb0xgc000hf1d3). Quotes stripped from the
1799
1912
  // weak/strong ETag form so consumers see a bare hex digest.
1800
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,
1801
1921
  disk: diskVersion,
1802
1922
  diskCommit,
1803
1923
  engineStale,
@@ -1838,9 +1958,13 @@ function getStatus() {
1838
1958
  // so changes surface within one SPA poll (~4 s) instead of waiting up to
1839
1959
  // 60 s for TTL. Same pre-build snapshot semantics as fast-state below.
1840
1960
  let slowStale = !_slowState || (now - _slowStateTs) >= SLOW_STATE_TTL;
1961
+ let slowMtimeChanged = false;
1841
1962
  if (!slowStale) {
1842
1963
  const currSlowMtimes = _getSlowMtimes();
1843
- if (_mtimesChanged(_lastSlowMtimes, currSlowMtimes)) slowStale = true;
1964
+ if (_mtimesChanged(_lastSlowMtimes, currSlowMtimes)) {
1965
+ slowStale = true;
1966
+ slowMtimeChanged = true;
1967
+ }
1844
1968
  }
1845
1969
 
1846
1970
  // If nothing stale, return cached merged result
@@ -1867,6 +1991,7 @@ function getStatus() {
1867
1991
  // Same pre-build snapshot pattern as fast-state — capture mtimes BEFORE
1868
1992
  // disk reads so any write landing mid-build busts the next poll.
1869
1993
  if (slowStale) {
1994
+ if (slowMtimeChanged) _invalidateSlowInnerCachesForMtimeChange();
1870
1995
  const preBuildSlowMtimes = _getSlowMtimes();
1871
1996
  _slowState = _buildStatusSlowState();
1872
1997
  _slowStateTs = now;
@@ -1912,9 +2037,13 @@ function refreshStatusAsync() {
1912
2037
  if (_mtimesChanged(_lastMtimes, currMtimes)) fastStale = true;
1913
2038
  }
1914
2039
  let slowStale = !_slowState || (now - _slowStateTs) >= SLOW_STATE_TTL;
2040
+ let slowMtimeChanged = false;
1915
2041
  if (!slowStale) {
1916
2042
  const currSlowMtimes = _getSlowMtimes();
1917
- if (_mtimesChanged(_lastSlowMtimes, currSlowMtimes)) slowStale = true;
2043
+ if (_mtimesChanged(_lastSlowMtimes, currSlowMtimes)) {
2044
+ slowStale = true;
2045
+ slowMtimeChanged = true;
2046
+ }
1918
2047
  }
1919
2048
 
1920
2049
  if (!fastStale && !slowStale && _statusCache) return _statusCache;
@@ -1944,6 +2073,7 @@ function refreshStatusAsync() {
1944
2073
  let slow = _slowState;
1945
2074
  let preBuildSlowMtimes = null;
1946
2075
  if (slowStale) {
2076
+ if (slowMtimeChanged) _invalidateSlowInnerCachesForMtimeChange();
1947
2077
  preBuildSlowMtimes = _getSlowMtimes();
1948
2078
  slow = _buildStatusSlowState();
1949
2079
  }
@@ -4797,6 +4927,31 @@ const server = http.createServer(async (req, res) => {
4797
4927
  } catch (e) { console.error('Archive fetch error:', e.message); return jsonReply(res, e.statusCode || 500, { error: e.message }); }
4798
4928
  }
4799
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
+
4800
4955
  async function handleWorkItemsReopen(req, res) {
4801
4956
  try {
4802
4957
  const body = await readBody(req);
@@ -8368,6 +8523,21 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8368
8523
  }
8369
8524
  // String fields
8370
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
+ }
8371
8541
  // W-mpejf0fq000e84d6: operator login override. Empty string clears
8372
8542
  // the override (engine falls back to gh/git/os resolution); any other
8373
8543
  // value pins the login used in `user/<login>/<wi-id>-<slug>` branches.
@@ -8699,6 +8869,52 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8699
8869
  } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
8700
8870
  }
8701
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
+
8702
8918
  // Slim UX surface for the experimental redesigned dashboard.
8703
8919
  // The HTML lives in dashboard/slim.html so the human can iterate on the
8704
8920
  // markup directly — we read the file from disk on each request (no in-
@@ -9569,6 +9785,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9569
9785
  invalidateStatusCache();
9570
9786
  return jsonReply(res, 200, { ok: true });
9571
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 },
9572
9795
 
9573
9796
  // Pinned notes
9574
9797
  { method: 'GET', path: '/api/pinned', desc: 'Get pinned notes', handler: async (req, res) => {
@@ -10181,6 +10404,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10181
10404
  // Feature flags (experimental / in-progress UX gates — see engine/features.js)
10182
10405
  { method: 'GET', path: '/api/features', desc: 'List registered feature flags with current enabled state', handler: handleFeaturesList },
10183
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 },
10184
10410
  ];
10185
10411
 
10186
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/queries.js CHANGED
@@ -2079,25 +2079,43 @@ function getStatusFastStateMtimePaths(config) {
2079
2079
  * - Per-project paths must use `shared.project*` so newly-added projects
2080
2080
  * are picked up without registry edits.
2081
2081
  *
2082
+ * Skill and MCP source paths (W-mphfdgwv000bf549):
2083
+ * - Skill discovery roots (`~/.claude/skills`, `~/.copilot/skills`,
2084
+ * `~/.agents/skills`, `<project>/.claude/skills`, `<project>/.github/skills`,
2085
+ * `<project>/.agents/skills`) plus plugin registries are tracked here
2086
+ * because manual `SKILL.md` drops bypass the agent-close
2087
+ * `invalidateStatusCache({includeSlow:true})` path that previously
2088
+ * covered the agent-extraction case. Directory mtime advances reliably
2089
+ * on Windows NTFS when a subdirectory is added/removed (verified via
2090
+ * INBOX_DIR). Tracking the user-home dirs means non-Minions skill
2091
+ * installs on this machine also bust the fleet's slow-state — but skill
2092
+ * dirs only mutate on rare events (install / manual edit), not on every
2093
+ * CLI command, so the steady-state noise is negligible.
2094
+ * - MCP config files (`~/.claude.json`, `~/.copilot/mcp-config.json`,
2095
+ * `<project>/.mcp.json`) feed `getMcpServers()`. `~/.claude.json`
2096
+ * stores more than `mcpServers`, but it only flips on intentional
2097
+ * Claude CLI mutations (mcp add/remove, settings edits), not on every
2098
+ * prompt, so whole-file tracking is acceptable.
2099
+ *
2082
2100
  * Files intentionally NOT tracked here:
2083
- * - `mcpServers`, version, autoMode, installId — change only on human/
2084
- * CLI edits, which already pop the slow-state via reloadConfig + the
2085
- * 60 s TTL.
2086
- * - `~/.claude/skills/`, `~/.copilot/skills/`, `<project>/.claude/skills/`,
2087
- * `<project>/.github/skills/` — `extractSkillsFromOutput` writes here
2088
- * from the agent-close path, which already calls
2089
- * `invalidateStatusCache({includeSlow: true})` directly. Tracking the
2090
- * user-home dir is additionally harmful because it's shared with every
2091
- * Claude Code session on the machine; non-Minions activity would
2092
- * otherwise bust this fleet's dashboard cache.
2101
+ * - version, autoMode, installId — change only on human/CLI edits, which
2102
+ * already pop the slow-state via reloadConfig + the 60 s TTL.
2093
2103
  * - project git state — already invalidated via the
2094
2104
  * `_setOnProjectGitStatusChanged` callback into `invalidateStatusCache`
2095
2105
  * (W-mpgrk5cy fix); also tracked in fast-state via `.git/logs/HEAD`.
2106
+ *
2107
+ * NOTE: Detecting a change here busts the dashboard's slow-state cache, but
2108
+ * the inner per-source caches (`queries._skillsCache` 30 s, dashboard's
2109
+ * `_mcpServersCache` 5 min) survive across `_buildStatusSlowState()` calls.
2110
+ * dashboard.js calls `queries.invalidateSkillsCache()` and resets
2111
+ * `_mcpServersCache` whenever this tracker fires, so the rebuild reads
2112
+ * fresh disk state. Keep that invalidation wired up if you add new sources.
2096
2113
  */
2097
- function getStatusSlowStateMtimePaths(_config) {
2098
- // _config accepted for symmetry with getStatusFastStateMtimePaths but
2099
- // unused every entry below is a fleet-global path.
2100
- return [
2114
+ function getStatusSlowStateMtimePaths(config) {
2115
+ config = config || getConfig();
2116
+ const projects = getProjects(config);
2117
+ const homeDir = os.homedir();
2118
+ const files = [
2101
2119
  // prd/*.json (surfaced by getPrdInfo) — engine writes via syncPrdFromPrs,
2102
2120
  // the materializer, and plan-to-prd outputs.
2103
2121
  PRD_DIR,
@@ -2117,6 +2135,42 @@ function getStatusSlowStateMtimePaths(_config) {
2117
2135
  // CLI/editor edit that bypasses the API.
2118
2136
  path.join(MINIONS_DIR, 'pinned.md'),
2119
2137
  ];
2138
+
2139
+ // Skill discovery roots (surfaced by _buildStatusSlowState → getSkills).
2140
+ // Mirrors collectSkillFiles' source enumeration so adding a new runtime
2141
+ // adapter automatically extends the tracker. ENOENT is tolerated by
2142
+ // dashboard._statMtimeMs (returns 0), so absent dirs cost nothing.
2143
+ try {
2144
+ const { listRuntimes, resolveRuntime } = require('./runtimes');
2145
+ for (const runtimeName of listRuntimes()) {
2146
+ const runtime = resolveRuntime(runtimeName);
2147
+ if (typeof runtime.getSkillRoots !== 'function') continue;
2148
+ for (const root of runtime.getSkillRoots({ homeDir })) {
2149
+ if (root && root.dir) files.push(root.dir);
2150
+ }
2151
+ for (const project of projects) {
2152
+ if (!project || !project.localPath) continue;
2153
+ for (const root of runtime.getSkillRoots({ homeDir, project })) {
2154
+ if (root && root.dir) files.push(root.dir);
2155
+ }
2156
+ }
2157
+ }
2158
+ } catch { /* runtime registry optional in partial installs */ }
2159
+
2160
+ // Plugin skill registries (also feed collectSkillFiles).
2161
+ files.push(path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json'));
2162
+ files.push(path.join(homeDir, '.copilot', 'installed-plugins'));
2163
+
2164
+ // MCP server config files (surfaced by _buildStatusSlowState → getMcpServers).
2165
+ files.push(path.join(homeDir, '.claude.json'));
2166
+ files.push(path.join(homeDir, '.copilot', 'mcp-config.json'));
2167
+ for (const project of projects) {
2168
+ if (project && project.localPath) {
2169
+ files.push(path.join(project.localPath, '.mcp.json'));
2170
+ }
2171
+ }
2172
+
2173
+ return files;
2120
2174
  }
2121
2175
 
2122
2176
  // ── Exports ─────────────────────────────────────────────────────────────────
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.2036",
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"