@yemi33/minions 0.1.2057 → 0.1.2058

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.
@@ -138,7 +138,6 @@ async function openAgentDetail(id) {
138
138
  renderDetailTabs(detail);
139
139
  renderDetailContent(detail, currentTab);
140
140
  } catch(e) {
141
- // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: error message, agent id)
142
141
  document.getElementById('detail-content').innerHTML =
143
142
  '<div style="padding:24px;text-align:center">' +
144
143
  '<div style="color:var(--red);margin-bottom:12px">Error loading agent detail: ' + escapeHtml(e.message) + '</div>' +
@@ -296,7 +296,7 @@ function renderTokenUsage(metrics) {
296
296
  const engAgg = _aggregateEngineUsageForTokenTile(engine);
297
297
  const engineCost = engAgg.cost, engineInput = engAgg.input,
298
298
  engineOutput = engAgg.output, engineCache = engAgg.cache,
299
- engineCalls = engAgg.calls; // eslint-disable-line no-unused-vars
299
+ engineCalls = engAgg.calls;
300
300
 
301
301
  const totalCost = agentCost + engineCost;
302
302
  const totalInput = agentInput + engineInput;
@@ -459,7 +459,6 @@ async function planExecute(file, project, btn) {
459
459
  function planReexecuteModal(file, project) {
460
460
  const inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
461
461
  document.getElementById('modal-title').textContent = 'Re-execute Plan';
462
- // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: plan file, project)
463
462
  document.getElementById('modal-body').innerHTML =
464
463
  '<div style="display:flex;flex-direction:column;gap:10px">' +
465
464
  '<div style="font-size:12px;color:var(--muted)">' + escapeHtml(file) + '</div>' +
@@ -549,7 +548,6 @@ function _renderPlanModal(normalizedFile, raw, lastMod) {
549
548
  if (normalizedFile.endsWith('.json')) {
550
549
  let plan;
551
550
  try { plan = JSON.parse(raw); } catch (e) {
552
- // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: parse error message, raw plan excerpt)
553
551
  document.getElementById('modal-body').innerHTML = '<p style="color:var(--red)">Failed to parse plan JSON: ' + escapeHtml(e.message) + '</p><pre style="font-size:10px;max-height:200px;overflow:auto">' + escapeHtml((raw || '').slice(0, 500)) + '</pre>';
554
552
  return;
555
553
  }
@@ -374,7 +374,6 @@ let _feedbackRating = null;
374
374
  function feedbackWorkItem(id, source) {
375
375
  _feedbackRating = null;
376
376
  document.getElementById('modal-title').textContent = 'Feedback on ' + id;
377
- // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: work item id, source)
378
377
  document.getElementById('modal-body').innerHTML =
379
378
  '<div style="display:flex;flex-direction:column;gap:16px">' +
380
379
  '<div style="display:flex;gap:16px;justify-content:center">' +
@@ -460,6 +460,7 @@ function _renderMdChunked(fullText) {
460
460
  el.remove();
461
461
  return;
462
462
  }
463
+ // eslint-disable-next-line no-unsanitized/method -- reason: _renderMdCore() escapes input via escHtml() at the top (utils.js:242) before applying safe markdown transforms; chunks are slices of the same fullText whose first chunk is already injected via firstHtml above (utils.js:445)
463
464
  el.insertAdjacentHTML('beforebegin', _renderMdCore(chunks[idx]));
464
465
  idx++;
465
466
  if (idx >= chunks.length) {
package/dashboard.js CHANGED
@@ -1717,6 +1717,18 @@ function _buildStatusFastState() {
1717
1717
  // outside so the async path can yield between reload and rebuild.
1718
1718
  const engineState = getEngineState();
1719
1719
  const hbAge = engineState.heartbeat ? Date.now() - engineState.heartbeat : 0;
1720
+ // W-mpof1xxe000ac689 — Two-signal liveness check. heartbeat (15s cadence,
1721
+ // engine/cli.js writeHeartbeatNow) and lastTickAt (tickInterval cadence,
1722
+ // engine.js#tickInner) are written by independent setIntervals. If the
1723
+ // heartbeat writer silently dies but tickInner keeps running, the heartbeat
1724
+ // ages past 120s while lastTickAt stays fresh — the dashboard used to lie
1725
+ // ("STALE") on a provably-ticking engine. Engine is alive iff ANY signal is
1726
+ // fresh; only when BOTH age past threshold do we trip the badge. The tick
1727
+ // threshold is floored by ENGINE_HEARTBEAT_STALE_MS so a misconfigured 5s
1728
+ // tickInterval cannot trip the boolean in 10s.
1729
+ const tickInterval = Number(CONFIG?.engine?.tickInterval) || shared.ENGINE_DEFAULTS.tickInterval;
1730
+ const tickStaleThresholdMs = Math.max(ENGINE_HEARTBEAT_STALE_MS, 2 * tickInterval);
1731
+ const tickAge = engineState.lastTickAt ? Date.now() - engineState.lastTickAt : Infinity;
1720
1732
  return {
1721
1733
  agents: getAgents(),
1722
1734
  inbox: getInbox(),
@@ -1729,12 +1741,14 @@ function _buildStatusFastState() {
1729
1741
  // off these instead of recomputing Date.now() - heartbeat against a
1730
1742
  // possibly-cached payload (false-positive banner regression #2754).
1731
1743
  heartbeatAgeMs: engineState.heartbeat ? hbAge : null,
1732
- heartbeatStale: !!(engineState.heartbeat && hbAge > ENGINE_HEARTBEAT_STALE_MS),
1744
+ heartbeatStale: !!(engineState.heartbeat
1745
+ && hbAge > ENGINE_HEARTBEAT_STALE_MS
1746
+ && tickAge > tickStaleThresholdMs),
1733
1747
  // W-mpnc4u8c001d9d6c — Surface the tick cadence so the client's
1734
1748
  // #engine-quick-stats "Next tick in Xs" countdown reads the SAME value
1735
1749
  // the engine actually uses (Settings parity rule). Paired with
1736
1750
  // control.lastTickAt (above) stamped at the start of every tickInner.
1737
- tickInterval: Number(CONFIG?.engine?.tickInterval) || shared.ENGINE_DEFAULTS.tickInterval,
1751
+ tickInterval: tickInterval,
1738
1752
  },
1739
1753
  adoThrottle: ado.getAdoThrottleState(),
1740
1754
  ghThrottle: gh.getGhThrottleState(),
@@ -2165,10 +2179,47 @@ function getStatus() {
2165
2179
  // next caller — including the synchronous fallback inside
2166
2180
  // `_handleStatusRequest` — rebuilds against the post-invalidate signal
2167
2181
  // and bumps the version then.
2182
+ // ── /api/status profiling (W-mpodww9h000b460a) ─────────────────────────────
2183
+ // Feature-flagged per-phase timing emitter. Off by default (no perf cost).
2184
+ // Enable with:
2185
+ // DEBUG_STATUS_TIMING=1 — log every rebuild forever
2186
+ // DEBUG_STATUS_TIMING_SAMPLES=N — log the next N rebuilds then
2187
+ // auto-disable (one-shot mode
2188
+ // for live triage; preferred)
2189
+ // Output goes to stderr as a tagged single line so it's easy to grep:
2190
+ // [status-timing] fastBuild=350ms slowBuild=120ms stringify=45ms gzip=180ms
2191
+ // total=695ms fastStale=true slowStale=false cacheV=42
2192
+ // All four phases are timed regardless of which actually ran — phases that
2193
+ // were skipped (cache hit) report 0ms, so the columns line up across calls.
2194
+ const _STATUS_TIMING_ENABLED = !!process.env.DEBUG_STATUS_TIMING;
2195
+ let _statusTimingSamplesRemaining = (() => {
2196
+ const raw = process.env.DEBUG_STATUS_TIMING_SAMPLES;
2197
+ if (!raw) return null;
2198
+ const n = Number.parseInt(raw, 10);
2199
+ return Number.isFinite(n) && n > 0 ? n : null;
2200
+ })();
2201
+ function _statusTimingActive() {
2202
+ if (_STATUS_TIMING_ENABLED) return true;
2203
+ return _statusTimingSamplesRemaining != null && _statusTimingSamplesRemaining > 0;
2204
+ }
2205
+ function _emitStatusTiming(parts) {
2206
+ if (_statusTimingSamplesRemaining != null && _statusTimingSamplesRemaining > 0) {
2207
+ _statusTimingSamplesRemaining -= 1;
2208
+ }
2209
+ const cols = Object.entries(parts)
2210
+ .map(([k, v]) => `${k}=${typeof v === 'number' ? v.toFixed(1) + 'ms' : v}`)
2211
+ .join(' ');
2212
+ try { process.stderr.write('[status-timing] ' + cols + '\n'); } catch { /* stderr unavailable */ }
2213
+ }
2214
+
2168
2215
  function refreshStatusAsync() {
2169
2216
  if (_statusRebuildPromise) return _statusRebuildPromise;
2170
2217
 
2171
2218
  _statusRebuildPromise = (async () => {
2219
+ const profile = _statusTimingActive();
2220
+ const tOverall = profile ? process.hrtime.bigint() : null;
2221
+ let fastBuildMs = 0;
2222
+ let slowBuildMs = 0;
2172
2223
  try {
2173
2224
  const now = Date.now();
2174
2225
  const startGeneration = _statusInvalidationGeneration;
@@ -2188,7 +2239,16 @@ function refreshStatusAsync() {
2188
2239
  }
2189
2240
  }
2190
2241
 
2191
- if (!fastStale && !slowStale && _statusCache) return _statusCache;
2242
+ if (!fastStale && !slowStale && _statusCache) {
2243
+ if (profile) {
2244
+ _emitStatusTiming({
2245
+ phase: 'cache-hit',
2246
+ total: Number(process.hrtime.bigint() - tOverall) / 1e6,
2247
+ fastStale: false, slowStale: false, cacheV: _statusCacheVersion,
2248
+ });
2249
+ }
2250
+ return _statusCache;
2251
+ }
2192
2252
 
2193
2253
  let fast = _fastState;
2194
2254
  // Pre-build mtime snapshot (see getStatus() for the rationale). The async
@@ -2199,7 +2259,9 @@ function refreshStatusAsync() {
2199
2259
  if (fastStale) {
2200
2260
  reloadConfig();
2201
2261
  preBuildMtimes = _getMtimes();
2262
+ const tFast = profile ? process.hrtime.bigint() : null;
2202
2263
  fast = _buildStatusFastState();
2264
+ if (profile) fastBuildMs = Number(process.hrtime.bigint() - tFast) / 1e6;
2203
2265
  }
2204
2266
 
2205
2267
  // Unconditional cooperative yield between phases — guarantees the event
@@ -2217,7 +2279,9 @@ function refreshStatusAsync() {
2217
2279
  if (slowStale) {
2218
2280
  if (slowMtimeChanged) _invalidateSlowInnerCachesForMtimeChange();
2219
2281
  preBuildSlowMtimes = _getSlowMtimes();
2282
+ const tSlow = profile ? process.hrtime.bigint() : null;
2220
2283
  slow = _buildStatusSlowState();
2284
+ if (profile) slowBuildMs = Number(process.hrtime.bigint() - tSlow) / 1e6;
2221
2285
  }
2222
2286
 
2223
2287
  // Invalidation-race guard: if an invalidation fired during the await
@@ -2226,6 +2290,14 @@ function refreshStatusAsync() {
2226
2290
  // next sync getStatus() OR async refreshStatusAsync() rebuilds fresh
2227
2291
  // against the post-invalidate generation.
2228
2292
  if (_statusInvalidationGeneration !== startGeneration) {
2293
+ if (profile) {
2294
+ _emitStatusTiming({
2295
+ phase: 'discarded-invalidation-race',
2296
+ fastBuild: fastBuildMs, slowBuild: slowBuildMs,
2297
+ total: Number(process.hrtime.bigint() - tOverall) / 1e6,
2298
+ cacheV: _statusCacheVersion,
2299
+ });
2300
+ }
2229
2301
  return _statusCache;
2230
2302
  }
2231
2303
 
@@ -2241,6 +2313,14 @@ function refreshStatusAsync() {
2241
2313
  }
2242
2314
  _statusCache = { ..._fastState, ..._slowState, timestamp: new Date().toISOString() };
2243
2315
  _markStatusCacheBuilt();
2316
+ if (profile) {
2317
+ _emitStatusTiming({
2318
+ phase: 'rebuilt',
2319
+ fastBuild: fastBuildMs, slowBuild: slowBuildMs,
2320
+ total: Number(process.hrtime.bigint() - tOverall) / 1e6,
2321
+ fastStale, slowStale, cacheV: _statusCacheVersion,
2322
+ });
2323
+ }
2244
2324
  return _statusCache;
2245
2325
  } finally {
2246
2326
  _statusRebuildPromise = null;
@@ -2279,8 +2359,22 @@ function _resetStatusCacheForTesting() {
2279
2359
  function getStatusJson() {
2280
2360
  getStatus(); // ensure _statusCache is fresh
2281
2361
  if (!_statusCacheJson) {
2362
+ const profile = _statusTimingActive();
2363
+ const tStringify = profile ? process.hrtime.bigint() : null;
2282
2364
  _statusCacheJson = JSON.stringify(_statusCache);
2365
+ const stringifyMs = profile ? Number(process.hrtime.bigint() - tStringify) / 1e6 : 0;
2366
+ const tGzip = profile ? process.hrtime.bigint() : null;
2283
2367
  _statusCacheGzip = zlib.gzipSync(_statusCacheJson); // pre-compute gzip once per cache rebuild
2368
+ if (profile) {
2369
+ const gzipMs = Number(process.hrtime.bigint() - tGzip) / 1e6;
2370
+ _emitStatusTiming({
2371
+ phase: 'serialize',
2372
+ stringify: stringifyMs, gzip: gzipMs,
2373
+ jsonKB: (_statusCacheJson.length / 1024).toFixed(1),
2374
+ gzipKB: (_statusCacheGzip.length / 1024).toFixed(1),
2375
+ cacheV: _statusCacheVersion,
2376
+ });
2377
+ }
2284
2378
  }
2285
2379
  return _statusCacheJson;
2286
2380
  }
@@ -11082,6 +11176,10 @@ module.exports = {
11082
11176
  _setStatusRefreshHook,
11083
11177
  _resetStatusCacheForTesting,
11084
11178
  _ifNoneMatchHasEtag,
11179
+ // Exported for the engine-stale-two-signal test (W-mpof1xxe000ac689) — the
11180
+ // staleness verdict it stamps on engine.heartbeatStale is the contract under
11181
+ // test. No production caller imports this; it is a test seam.
11182
+ _buildStatusFastState,
11085
11183
  };
11086
11184
 
11087
11185
  // Start the HTTP server only when run directly (node dashboard.js).
package/engine/meeting.js CHANGED
@@ -334,12 +334,48 @@ function writeMeetingTranscriptToInbox(meeting, meetingId, agents) {
334
334
  } catch (e) { log('warn', `Meeting ${meetingId} inbox write: ${e.message}`); }
335
335
  }
336
336
 
337
+ // Cache for getMeetings (W-mpodww9h000b460a). Dashboard fast-state calls
338
+ // getMeetings() twice per rebuild — once for the slim slice and once for
339
+ // the meetingsTotal sidebar counter — and each call reads + parses every
340
+ // meetings/<id>.json on disk (~9 MB total at task time → ~30–45 ms).
341
+ // 1 s TTL mirrors `_dispatchCache` / `_prsCache` / `_workItemsCache`:
342
+ // short enough that the next SPA poll (~4 s) reflects new rounds, and the
343
+ // dashboard mtime tracker on meetings/*.json busts the upstream
344
+ // `_statusCache` on real writes anyway.
345
+ //
346
+ // mtime gate: a single statSync on MEETINGS_DIR catches new/deleted
347
+ // meetings (NTFS + ext4 both advance dir mtime on entry add/remove),
348
+ // covering tests and any code path that bypasses mutateMeeting.
349
+ let _meetingsCache = null;
350
+ let _meetingsCacheAt = 0;
351
+ let _meetingsCacheDirMtime = 0;
352
+ function invalidateMeetingsCache() {
353
+ _meetingsCache = null;
354
+ _meetingsCacheAt = 0;
355
+ _meetingsCacheDirMtime = 0;
356
+ }
357
+
337
358
  function getMeetings() {
338
- if (!fs.existsSync(MEETINGS_DIR)) return [];
339
- return fs.readdirSync(MEETINGS_DIR)
359
+ const now = Date.now();
360
+ if (_meetingsCache && (now - _meetingsCacheAt) < 1000) {
361
+ let currDirMtime = 0;
362
+ try { currDirMtime = fs.statSync(MEETINGS_DIR).mtimeMs; } catch { /* missing */ }
363
+ if (currDirMtime === _meetingsCacheDirMtime) return _meetingsCache;
364
+ }
365
+ if (!fs.existsSync(MEETINGS_DIR)) {
366
+ _meetingsCache = [];
367
+ _meetingsCacheAt = now;
368
+ _meetingsCacheDirMtime = 0;
369
+ return _meetingsCache;
370
+ }
371
+ const result = fs.readdirSync(MEETINGS_DIR)
340
372
  .filter(f => f.endsWith('.json'))
341
373
  .map(f => safeJson(path.join(MEETINGS_DIR, f)))
342
374
  .filter(Boolean);
375
+ _meetingsCache = result;
376
+ _meetingsCacheAt = now;
377
+ try { _meetingsCacheDirMtime = fs.statSync(MEETINGS_DIR).mtimeMs; } catch { _meetingsCacheDirMtime = 0; }
378
+ return result;
343
379
  }
344
380
 
345
381
  function getMeeting(id) {
@@ -397,6 +433,10 @@ function mutateMeeting(id, fn) {
397
433
  }
398
434
  return userResult;
399
435
  }, { defaultValue: {}, skipWriteIfUnchanged: true });
436
+ // Invalidate the cached getMeetings() list so subsequent dashboard reads
437
+ // observe the write (W-mpodww9h000b460a). Mirrors the
438
+ // mutateDispatch → invalidateDispatchCache pattern.
439
+ invalidateMeetingsCache();
400
440
  return userResult === undefined ? null : userResult;
401
441
  }
402
442
 
@@ -818,6 +858,7 @@ function deleteMeeting(id) {
818
858
  // mutateMeeting writes a .backup sidecar; safeJson auto-restores from it
819
859
  // when the primary is missing, so deletion must also drop the backup.
820
860
  try { fs.unlinkSync(filePath + '.backup'); } catch { /* sidecar may not exist */ }
861
+ invalidateMeetingsCache();
821
862
  return true;
822
863
  }
823
864
 
@@ -914,6 +955,7 @@ function checkMeetingTimeouts(config) {
914
955
  }
915
956
  module.exports = {
916
957
  MEETINGS_DIR, getMeetings, getMeeting, saveMeeting, mutateMeeting, createMeeting,
958
+ invalidateMeetingsCache,
917
959
  discoverMeetingWork, collectMeetingFindings, checkMeetingTimeouts,
918
960
  addMeetingNote, advanceMeetingRound, endMeeting, archiveMeeting, unarchiveMeeting, deleteMeeting,
919
961
  EMPTY_OUTPUT_PATTERNS,
package/engine/queries.js CHANGED
@@ -1159,8 +1159,66 @@ function getKnowledgeBaseIndex() {
1159
1159
 
1160
1160
  // ── Work Items ──────────────────────────────────────────────────────────────
1161
1161
 
1162
+ // Cache: getWorkItems is called 5–7x per /api/status fast-state rebuild
1163
+ // (W-mpodww9h000b460a). The dashboard's `_buildStatusFastState` calls it
1164
+ // directly for the `workItems` slice, AND every idle agent's
1165
+ // `getAgentStatus()` fallback re-reads it to derive a work-item-marker
1166
+ // "working" state when dispatch.json briefly desyncs from per-project
1167
+ // work-items.json. With 5 agents (3 idle) and a 890 KB central
1168
+ // work-items.json, the pre-cache cost was ~300 ms per fast-state rebuild —
1169
+ // the single biggest hot-path offender. Mirrors `_prsCache` / `_dispatchCache`
1170
+ // (1 s TTL eliminates intra-request duplication without masking real
1171
+ // updates from the next 4 s SPA poll cycle).
1172
+ //
1173
+ // mtime-gated freshness (W-mpodww9h000b460a): bare TTL is not enough because
1174
+ // callers like tests and `safeWrite` write the central + per-project files
1175
+ // without going through `mutateWorkItems` (which busts the cache via
1176
+ // `invalidateWorkItemsCache`). One cheap `statSync` per file per cache hit
1177
+ // catches those writes within ~10 ms regardless of the TTL. Files that
1178
+ // don't exist record `null` so subsequent appearance still busts.
1179
+ let _workItemsCache = null;
1180
+ let _workItemsCacheAt = 0;
1181
+ let _workItemsCacheMtimes = null;
1182
+ function invalidateWorkItemsCache() {
1183
+ _workItemsCache = null;
1184
+ _workItemsCacheAt = 0;
1185
+ _workItemsCacheMtimes = null;
1186
+ }
1187
+
1188
+ function _workItemsFilePaths(config) {
1189
+ const projects = getProjects(config);
1190
+ const out = [path.join(MINIONS_DIR, 'work-items.json')];
1191
+ for (const p of projects) {
1192
+ try { out.push(projectWorkItemsPath(p)); } catch { /* missing path helper output */ }
1193
+ }
1194
+ return out;
1195
+ }
1196
+ function _snapshotWorkItemsMtimes(config) {
1197
+ const out = Object.create(null);
1198
+ for (const fp of _workItemsFilePaths(config)) {
1199
+ try { out[fp] = fs.statSync(fp).mtimeMs; }
1200
+ catch { out[fp] = null; /* ENOENT → null; flipping to present must bust */ }
1201
+ }
1202
+ return out;
1203
+ }
1204
+ function _workItemsMtimesDiffer(prev, curr) {
1205
+ if (!prev || !curr) return true;
1206
+ for (const fp of Object.keys(curr)) {
1207
+ if (prev[fp] !== curr[fp]) return true;
1208
+ }
1209
+ for (const fp of Object.keys(prev)) {
1210
+ if (!(fp in curr)) return true;
1211
+ }
1212
+ return false;
1213
+ }
1214
+
1162
1215
  function getWorkItems(config) {
1216
+ const now = Date.now();
1163
1217
  config = config || getConfig();
1218
+ if (_workItemsCache && (now - _workItemsCacheAt) < 1000) {
1219
+ const currMtimes = _snapshotWorkItemsMtimes(config);
1220
+ if (!_workItemsMtimesDiffer(_workItemsCacheMtimes, currMtimes)) return _workItemsCache;
1221
+ }
1164
1222
  const projects = getProjects(config);
1165
1223
  const allItems = [];
1166
1224
 
@@ -1326,6 +1384,9 @@ function getWorkItems(config) {
1326
1384
  return (b.created || '').localeCompare(a.created || '');
1327
1385
  });
1328
1386
 
1387
+ _workItemsCache = allItems;
1388
+ _workItemsCacheAt = now;
1389
+ _workItemsCacheMtimes = _snapshotWorkItemsMtimes(config);
1329
1390
  return allItems;
1330
1391
  }
1331
1392
 
@@ -2367,5 +2428,5 @@ module.exports = {
2367
2428
  getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getKnowledgeBaseIndex,
2368
2429
 
2369
2430
  // Work items & PRD
2370
- getWorkItems, getPrdInfo,
2431
+ getWorkItems, invalidateWorkItemsCache, getPrdInfo,
2371
2432
  };
package/engine/shared.js CHANGED
@@ -4564,10 +4564,16 @@ function listProcessReachable(rootPids, allProcesses = null) {
4564
4564
  * @param {Function} mutator - Receives the array, mutates in place or returns new value
4565
4565
  */
4566
4566
  function mutateWorkItems(filePath, mutator) {
4567
- return mutateJsonFileLocked(filePath, (data) => {
4567
+ const result = mutateJsonFileLocked(filePath, (data) => {
4568
4568
  if (!Array.isArray(data)) data = [];
4569
4569
  return mutator(data) || data;
4570
4570
  }, { defaultValue: [], skipWriteIfUnchanged: true });
4571
+ // Invalidate the read cache so the next getWorkItems() sees fresh data
4572
+ // (W-mpodww9h000b460a). Mirrors dispatch.js's invalidateDispatchCache
4573
+ // call after mutateDispatch. Lazy-required to avoid the queries→shared
4574
+ // require cycle.
4575
+ try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
4576
+ return result;
4571
4577
  }
4572
4578
 
4573
4579
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2057",
3
+ "version": "0.1.2058",
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"