@yemi33/minions 0.1.2379 → 0.1.2380

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.
Files changed (96) hide show
  1. package/bin/minions.js +19 -9
  2. package/dashboard/js/refresh.js +2 -3
  3. package/dashboard/js/render-prd.js +1 -1
  4. package/dashboard/js/render-work-items.js +13 -6
  5. package/dashboard.js +393 -721
  6. package/docs/architecture-review-2026-07-09.md +2 -4
  7. package/docs/auto-discovery.md +36 -49
  8. package/docs/blog-first-successful-dispatch.md +1 -1
  9. package/docs/branch-derivation.md +2 -2
  10. package/docs/command-center.md +1 -1
  11. package/docs/completion-reports.md +14 -4
  12. package/docs/constellation-bridge.md +59 -10
  13. package/docs/constellation-style-telemetry.md +6 -6
  14. package/docs/cooldown-merge-semantics.md +4 -0
  15. package/docs/copilot-cli-schema.md +3 -3
  16. package/docs/cross-repo-plans.md +17 -17
  17. package/docs/deprecated.json +2 -2
  18. package/docs/design-state-storage.md +1 -1
  19. package/docs/documentation-audit-2026-07-09.md +2 -2
  20. package/docs/engine-restart.md +20 -8
  21. package/docs/harness-mode.md +1 -1
  22. package/docs/managed-spawn.md +4 -4
  23. package/docs/onboarding.md +1 -2
  24. package/docs/pr-comment-followup.md +3 -3
  25. package/docs/pr-review-fix-loop.md +1 -1
  26. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  27. package/docs/qa-runbook-lifecycle.md +4 -4
  28. package/docs/qa-runbooks.md +2 -2
  29. package/docs/rfc-completion-json.md +4 -1
  30. package/docs/runtime-adapters.md +1 -1
  31. package/docs/self-improvement.md +4 -5
  32. package/docs/shared-lifecycle-module-map.md +3 -1
  33. package/docs/slim-ux/architecture-suggestions.md +5 -6
  34. package/docs/slim-ux/concepts.md +23 -25
  35. package/docs/watches.md +7 -7
  36. package/docs/workspace-manifests.md +1 -1
  37. package/docs/worktree-lifecycle.md +1 -1
  38. package/engine/abandoned-pr-reconciliation.js +4 -5
  39. package/engine/ado-status.js +5 -5
  40. package/engine/ado.js +20 -25
  41. package/engine/agent-worker-pool.js +58 -1
  42. package/engine/bridge.js +260 -5
  43. package/engine/cleanup.js +48 -131
  44. package/engine/cli.js +125 -83
  45. package/engine/cooldown.js +9 -16
  46. package/engine/db/index.js +22 -9
  47. package/engine/db/migrations/009-qa.js +1 -1
  48. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  49. package/engine/db/migrations/021-archived-work-items.js +72 -0
  50. package/engine/db/migrations/022-global-cc-session.js +31 -0
  51. package/engine/db/migrations/023-engine-state.js +30 -0
  52. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  53. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  54. package/engine/dispatch-store.js +2 -7
  55. package/engine/dispatch.js +34 -41
  56. package/engine/github.js +20 -27
  57. package/engine/lifecycle.js +221 -283
  58. package/engine/llm.js +1 -1
  59. package/engine/logs-store.js +2 -2
  60. package/engine/managed-spawn.js +2 -23
  61. package/engine/meeting.js +6 -6
  62. package/engine/metrics-store.js +2 -2
  63. package/engine/note-link-backfill.js +6 -11
  64. package/engine/pipeline.js +18 -36
  65. package/engine/playbook.js +1 -1
  66. package/engine/prd-store.js +73 -54
  67. package/engine/preflight.js +2 -5
  68. package/engine/projects.js +15 -62
  69. package/engine/pull-requests-store.js +0 -17
  70. package/engine/qa-runbooks.js +2 -2
  71. package/engine/qa-runs.js +1 -8
  72. package/engine/qa-sessions.js +41 -64
  73. package/engine/queries.js +120 -219
  74. package/engine/routing.js +3 -5
  75. package/engine/scheduler.js +0 -4
  76. package/engine/shared-branch-pr-reconcile.js +2 -3
  77. package/engine/shared.js +132 -637
  78. package/engine/small-state-store.js +89 -10
  79. package/engine/state-operations.js +16 -4
  80. package/engine/stdio-timestamps.js +1 -1
  81. package/engine/timeout.js +5 -12
  82. package/engine/watch-actions.js +20 -22
  83. package/engine/watches-store.js +1 -1
  84. package/engine/watches.js +6 -10
  85. package/engine/work-item-validation.js +52 -0
  86. package/engine/work-items-store.js +127 -29
  87. package/engine/worktree-gc.js +2 -2
  88. package/engine/worktree-pool.js +8 -18
  89. package/engine.js +147 -343
  90. package/minions.js +2 -2
  91. package/package.json +1 -1
  92. package/playbooks/plan-to-prd.md +2 -2
  93. package/playbooks/shared-rules.md +3 -3
  94. package/playbooks/templates/followup-dispatch.md +1 -1
  95. package/playbooks/verify.md +1 -1
  96. package/prompts/cc-system.md +2 -2
package/engine/queries.js CHANGED
@@ -12,13 +12,13 @@ const shared = require('./shared');
12
12
  const steering = require('./steering');
13
13
 
14
14
  const { safeRead, safeReadDir, safeJson, safeJsonObj, safeWrite, getProjects, mutateJsonFileLocked,
15
- projectWorkItemsPath, projectPrPath, parseSkillFrontmatter, KB_CATEGORIES,
15
+ parseSkillFrontmatter, KB_CATEGORIES,
16
16
  WI_STATUS, DONE_STATUSES, WORK_TYPE, PRD_ITEM_STATUS, PR_STATUS, ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS } = shared;
17
17
 
18
18
  // ─── Defensive PR enrichment cache (W-mq5uzmc6001d708f) ──────────────────────
19
19
  //
20
20
  // buildPrdProgress runs on every /api/status poll. When a work item carries
21
- // `_pr` but no canonical row exists in pull-requests.json, the synthesis
21
+ // `_pr` but no canonical tracked row exists, the synthesis
22
22
  // fallback below used to default to PR_STATUS.ACTIVE forever (blue ○). Now
23
23
  // we cache the resolved status per canonical id and kick off a best-effort
24
24
  // fire-and-forget enrollment so the next poll cycle sees the real status.
@@ -74,14 +74,14 @@ function _resetPrEnrichmentCacheForTest() {
74
74
 
75
75
  // W-mrcs8yqk001o6893 — shared enrichment-fallback resolver. Both the prLinks
76
76
  // loop and the wi._pr fallback loop in getPrdInfo() need the SAME defensive
77
- // behavior when the canonical PR id has no live record in pull-requests.json
77
+ // behavior when the canonical PR id has no live tracked record
78
78
  // (e.g. it was merged and then swept by the merged-PR cleanup routine): check
79
79
  // the enrichment cache first, else synthesize PR_STATUS.ACTIVE as a
80
80
  // placeholder and kick off a fire-and-forget enrollment so the next poll
81
81
  // (within PR_ENRICHMENT_TTL_MS) resolves the real status. Previously only the
82
82
  // wi._pr loop did this (W-mq5uzmc6001d708f); the prLinks loop hardcoded
83
83
  // `pr?.status || PR_STATUS.ACTIVE` with no self-heal, so PRs reached only via
84
- // pr_links stayed 'active' forever once removed from pull-requests.json.
84
+ // pr_links stayed 'active' forever once removed from tracked PR state.
85
85
  function _resolvePrStatusWithEnrichment(pr, canonicalPrId, project, itemId) {
86
86
  if (pr?.status) return pr.status;
87
87
  if (!canonicalPrId) return PR_STATUS.ACTIVE;
@@ -162,8 +162,6 @@ const ARCHIVE_DIR = path.join(MINIONS_DIR, 'notes', 'archive');
162
162
 
163
163
  const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
164
164
  const CONTROL_PATH = path.join(ENGINE_DIR, 'control.json');
165
- const DISPATCH_PATH = path.join(ENGINE_DIR, 'dispatch.json');
166
- const LOG_PATH = path.join(ENGINE_DIR, 'log.json');
167
165
  const NOTES_PATH = path.join(MINIONS_DIR, 'notes.md');
168
166
 
169
167
  // ── Helpers ─────────────────────────────────────────────────────────────────
@@ -245,7 +243,7 @@ function getDispatch() {
245
243
  // status derivation, work-item PR enrichment, etc.) but only changes on
246
244
  // mutateDispatch. SQL reads are sub-millisecond, so the cache is now
247
245
  // mostly a per-request memo rather than the disk-read amortizer it used
248
- // to be when dispatch.json could be 1+ MB.
246
+ // to be with legacy megabyte-scale dispatch files.
249
247
  const now = Date.now();
250
248
  if (_dispatchCache && (now - _dispatchCacheAt) < 2000) return _dispatchCache;
251
249
  // SQL is the canonical (and only) dispatch store after Phase 9.
@@ -438,7 +436,7 @@ function getMetrics() {
438
436
  };
439
437
  }
440
438
 
441
- // Enrich agent PR counts from pull-requests.json (source of truth)
439
+ // Enrich agent PR counts from tracked PR state.
442
440
  const allPrs = getPullRequests();
443
441
  const prCountByAgent = {};
444
442
  const prApprovedByAgent = {};
@@ -449,7 +447,7 @@ function getMetrics() {
449
447
  prCountByAgent[agent] = (prCountByAgent[agent] || 0) + 1;
450
448
  if (pr.reviewStatus === shared.REVIEW_STATUS.APPROVED || pr.status === shared.PR_STATUS.MERGED) prApprovedByAgent[agent] = (prApprovedByAgent[agent] || 0) + 1;
451
449
  // BUG-H13 / P-h13-rejected: this used to compare to the literal 'rejected',
452
- // which never appears in pull-requests.json — the canonical enum value is
450
+ // which never appears in tracked PR state — the canonical enum value is
453
451
  // REVIEW_STATUS.CHANGES_REQUESTED ('changes-requested'). The miscompare
454
452
  // permanently zeroed prRejectedByAgent, and the snapshot then clobbered
455
453
  // shared.trackReviewMetric()'s cumulative counter at the apply step below.
@@ -536,6 +534,37 @@ function _wiIdFromNoteFrontmatter(fm) {
536
534
  return fm.work_item || fm.wi || fm.sourceItem || null;
537
535
  }
538
536
 
537
+ const WORK_ITEM_KEY_SEPARATOR = '\u0000';
538
+
539
+ function _workItemIdentityKey(item, fallbackScope = null) {
540
+ if (!item?.id) return null;
541
+ const scope = item._source || fallbackScope || item.project || 'central';
542
+ return `${scope}${WORK_ITEM_KEY_SEPARATOR}${item.id}`;
543
+ }
544
+
545
+ function _dispatchWorkItemIdentityKey(entry) {
546
+ const item = entry?.meta?.item;
547
+ if (!item?.id) return null;
548
+ const fallbackScope = entry.meta.source === 'central-work-item'
549
+ || entry.meta.source === 'central-work-item-fanout'
550
+ ? 'central'
551
+ : (entry.meta.project?.name || item.project || 'central');
552
+ return _workItemIdentityKey(item, fallbackScope);
553
+ }
554
+
555
+ function _noteIdentityKey(fm, dispatchIdentityById) {
556
+ const wiId = _wiIdFromNoteFrontmatter(fm);
557
+ if (!wiId) return null;
558
+ const explicitScope = fm.work_item_scope || fm.scope;
559
+ if (explicitScope) return `${explicitScope}${WORK_ITEM_KEY_SEPARATOR}${wiId}`;
560
+ if (fm.dispatchId && dispatchIdentityById.has(fm.dispatchId)) {
561
+ return dispatchIdentityById.get(fm.dispatchId);
562
+ }
563
+ const inferredScope = fm.project || fm.sourceProject;
564
+ if (inferredScope) return `${inferredScope}${WORK_ITEM_KEY_SEPARATOR}${wiId}`;
565
+ return wiId;
566
+ }
567
+
539
568
  // Archive notes-by-WI cache. The archive dir accumulates thousands of
540
569
  // immutable note files (2000+ and growing); reading + frontmatter-parsing all
541
570
  // of them was the single dominant SYNCHRONOUS cost inside getWorkItems()
@@ -558,7 +587,7 @@ let _archiveNotesByWiCacheMtime = null;
558
587
  // full multi-thousand-file re-read on a fixed cadence when nothing changed.
559
588
  const ARCHIVE_NOTES_BY_WI_TTL = 300000; // 5 min
560
589
 
561
- function _buildArchiveNotesByWiMap() {
590
+ function _buildArchiveNoteRefs() {
562
591
  let dirMtime = null;
563
592
  try { dirMtime = fs.statSync(ARCHIVE_DIR).mtimeMs; } catch { dirMtime = null; }
564
593
  const now = Date.now();
@@ -567,14 +596,13 @@ function _buildArchiveNotesByWiMap() {
567
596
  && _archiveNotesByWiCacheMtime === dirMtime) {
568
597
  return _archiveNotesByWiCache;
569
598
  }
570
- const out = Object.create(null);
599
+ const out = [];
571
600
  for (const f of safeReadDir(ARCHIVE_DIR)) {
572
601
  if (!f.endsWith('.md')) continue;
573
602
  const fm = _parseNoteFrontmatter(safeRead(path.join(ARCHIVE_DIR, f)));
574
603
  const wiId = _wiIdFromNoteFrontmatter(fm);
575
604
  if (!wiId) continue;
576
- if (!out[wiId]) out[wiId] = [];
577
- out[wiId].push('archive:' + f);
605
+ out.push({ fm, token: 'archive:' + f });
578
606
  }
579
607
  _archiveNotesByWiCache = out;
580
608
  _archiveNotesByWiCacheAt = now;
@@ -586,26 +614,30 @@ function _buildArchiveNotesByWiMap() {
586
614
  // Archive entries are prefixed with `archive:` so the dashboard renderer can
587
615
  // tell them apart (matches the convention already used by _artifacts.notes).
588
616
  // Pure-function helper exposed for testing.
589
- function _buildNotesByWiMap() {
617
+ function _buildNotesByWiMap(dispatch) {
590
618
  const out = Object.create(null);
591
- const addNote = (wiId, token) => {
592
- if (!wiId || !token) return;
593
- if (!out[wiId]) out[wiId] = [];
594
- if (!out[wiId].includes(token)) out[wiId].push(token);
619
+ const dispatchIdentityById = new Map();
620
+ for (const section of ['pending', 'active', 'completed', 'review']) {
621
+ for (const entry of (dispatch?.[section] || [])) {
622
+ const key = _dispatchWorkItemIdentityKey(entry);
623
+ if (entry?.id && key) dispatchIdentityById.set(entry.id, key);
624
+ }
625
+ }
626
+ const addNote = (fm, token) => {
627
+ const key = _noteIdentityKey(fm, dispatchIdentityById);
628
+ if (!key || !token) return;
629
+ if (!out[key]) out[key] = [];
630
+ if (!out[key].includes(token)) out[key].push(token);
595
631
  };
596
632
  // Inbox: tiny and churns constantly — scan fresh every call.
597
633
  for (const f of safeReadDir(INBOX_DIR)) {
598
634
  if (!f.endsWith('.md')) continue;
599
635
  const fm = _parseNoteFrontmatter(safeRead(path.join(INBOX_DIR, f)));
600
- const wiId = _wiIdFromNoteFrontmatter(fm);
601
- if (wiId) addNote(wiId, f);
636
+ addNote(fm, f);
602
637
  }
603
638
  // Archive: large + immutable — served from the mtime-gated cache so we don't
604
639
  // re-read thousands of files on every getWorkItems() rebuild.
605
- const archiveMap = _buildArchiveNotesByWiMap();
606
- for (const wiId in archiveMap) {
607
- for (const token of archiveMap[wiId]) addNote(wiId, token);
608
- }
640
+ for (const ref of _buildArchiveNoteRefs()) addNote(ref.fm, ref.token);
609
641
  return out;
610
642
  }
611
643
 
@@ -613,10 +645,16 @@ function _buildNotesByWiMap() {
613
645
  // `archive:<filename>`) whose frontmatter cites this WI id. Returns [] when
614
646
  // nothing matches. Scans inbox + archive each call; for the bulk WI list
615
647
  // payload getWorkItems() pre-builds the map once per cache cycle.
616
- function notesForWorkItem(wiId) {
648
+ function notesForWorkItem(wiId, scope) {
617
649
  if (!wiId) return [];
618
650
  const map = _buildNotesByWiMap();
619
- return map[wiId] || [];
651
+ if (scope) return map[`${scope}${WORK_ITEM_KEY_SEPARATOR}${wiId}`] || map[wiId] || [];
652
+ const matches = [...(map[wiId] || [])];
653
+ const suffix = `${WORK_ITEM_KEY_SEPARATOR}${wiId}`;
654
+ for (const key of Object.keys(map)) {
655
+ if (key.endsWith(suffix)) matches.push(...map[key]);
656
+ }
657
+ return [...new Set(matches)];
620
658
  }
621
659
 
622
660
  function getInbox() {
@@ -636,7 +674,7 @@ function getInbox() {
636
674
 
637
675
  // ── Agents ──────────────────────────────────────────────────────────────────
638
676
 
639
- // Agent status is DERIVED from dispatch.json — single source of truth.
677
+ // Agent status is derived from dispatch state.
640
678
  // dispatch.active entry for this agent → working
641
679
  // dispatch.completed (most recent) → done/error
642
680
  // neither → idle
@@ -684,7 +722,7 @@ function getAgentStatus(agentId) {
684
722
  }
685
723
 
686
724
  // Fallback: derive active state from work-item markers.
687
- // This protects UI status when dispatch.json briefly desyncs from work-item files.
725
+ // This protects UI status when dispatch and work-item state briefly desync.
688
726
  // Guard: only trust dispatched state within 2x stale-orphan timeout to prevent stale
689
727
  // dispatched items from permanently showing an agent as working after a dead process.
690
728
  try {
@@ -720,7 +758,7 @@ function getAgentStatus(agentId) {
720
758
  return { status: 'idle', task: null, started_at: null, completed_at: null };
721
759
  }
722
760
 
723
- // setAgentStatus removed — agent status is derived from dispatch.json.
761
+ // setAgentStatus removed — agent status is derived from dispatch state.
724
762
  // Status.json files no longer exist.
725
763
 
726
764
  function getAgents(config) {
@@ -755,7 +793,7 @@ function getAgents(config) {
755
793
  const inboxFiles = allInboxFiles.filter(f => f.includes(a.id));
756
794
  let steeringInboxFiles = [];
757
795
  try { steeringInboxFiles = steering.listUnreadSteeringMessages(a.id); } catch { steeringInboxFiles = []; }
758
- const s = getAgentStatus(a.id); // derives from dispatch.json
796
+ const s = getAgentStatus(a.id); // derives from dispatch state
759
797
 
760
798
  let lastAction = 'Waiting for assignment';
761
799
  let lastActionPrefix = null;
@@ -812,7 +850,7 @@ function getAgentDetail(id) {
812
850
  let outputLog = safeRead(path.join(agentDir, 'output.log')) || '';
813
851
  if (outputLog.length > 50000) outputLog = '…(truncated — showing last 50KB)\n\n' + outputLog.slice(-50000);
814
852
 
815
- const statusData = getAgentStatus(id); // derives from dispatch.json
853
+ const statusData = getAgentStatus(id); // derives from dispatch state
816
854
 
817
855
  const inboxContents = safeReadDir(INBOX_DIR)
818
856
  .filter(f => f.includes(id))
@@ -846,7 +884,7 @@ function getAgentDetail(id) {
846
884
  function getPrs(project) {
847
885
  if (project) {
848
886
  const store = require('./pull-requests-store');
849
- const prs = store.readPullRequestsForScope(store.scopeForFilePath(projectPrPath(project))) || [];
887
+ const prs = store.readPullRequestsForScope(project.name) || [];
850
888
  shared.normalizePrRecords(prs, project);
851
889
  return prs;
852
890
  }
@@ -1796,22 +1834,14 @@ function getKnowledgeBaseIndex() {
1796
1834
  // (W-mpodww9h000b460a). The dashboard's `_buildStatusFastState` calls it
1797
1835
  // directly for the `workItems` slice, AND every idle agent's
1798
1836
  // `getAgentStatus()` fallback re-reads it to derive a work-item-marker
1799
- // "working" state when dispatch.json briefly desyncs from per-project
1800
- // work-items.json. With 5 agents (3 idle) and a 890 KB central
1801
- // work-items.json, the pre-cache cost was ~300 ms per fast-state rebuild —
1837
+ // "working" state when dispatch state briefly desyncs from work-item state.
1838
+ // With 5 agents (3 idle), the pre-cache cost was ~300 ms per rebuild —
1802
1839
  // the single biggest hot-path offender. Mirrors `_prsCache` / `_dispatchCache`
1803
1840
  // (1 s TTL eliminates intra-request duplication without masking real
1804
1841
  // updates from the next 4 s SPA poll cycle).
1805
1842
  //
1806
- // mtime-gated freshness (W-mpodww9h000b460a): bare TTL is not enough because
1807
- // callers like tests and `safeWrite` write the central + per-project files
1808
- // without going through `mutateWorkItems` (which busts the cache via
1809
- // `invalidateWorkItemsCache`). One cheap `statSync` per file per cache hit
1810
- // catches those writes within ~10 ms regardless of the TTL. Files that
1811
- // don't exist record `null` so subsequent appearance still busts.
1812
1843
  let _workItemsCache = null;
1813
1844
  let _workItemsCacheAt = 0;
1814
- let _workItemsCacheMtimes = null;
1815
1845
  // Lean cache for getWorkItems(config, { enrich: false }) — core fields only,
1816
1846
  // skipping the PR cross-reference + per-item _artifacts/_notes detail-modal
1817
1847
  // enrichment (the synchronous KB-scan + agent-dir reads that the detail modal
@@ -1822,41 +1852,11 @@ let _workItemsCacheMtimes = null;
1822
1852
  // objects each call, so the two caches never share mutable references.
1823
1853
  let _workItemsLeanCache = null;
1824
1854
  let _workItemsLeanCacheAt = 0;
1825
- let _workItemsLeanCacheMtimes = null;
1826
1855
  function invalidateWorkItemsCache() {
1827
1856
  _workItemsCache = null;
1828
1857
  _workItemsCacheAt = 0;
1829
- _workItemsCacheMtimes = null;
1830
1858
  _workItemsLeanCache = null;
1831
1859
  _workItemsLeanCacheAt = 0;
1832
- _workItemsLeanCacheMtimes = null;
1833
- }
1834
-
1835
- function _workItemsFilePaths(config) {
1836
- const projects = getProjects(config);
1837
- const out = [path.join(MINIONS_DIR, 'work-items.json')];
1838
- for (const p of projects) {
1839
- try { out.push(projectWorkItemsPath(p)); } catch { /* missing path helper output */ }
1840
- }
1841
- return out;
1842
- }
1843
- function _snapshotWorkItemsMtimes(config) {
1844
- const out = Object.create(null);
1845
- for (const fp of _workItemsFilePaths(config)) {
1846
- try { out[fp] = fs.statSync(fp).mtimeMs; }
1847
- catch { out[fp] = null; /* ENOENT → null; flipping to present must bust */ }
1848
- }
1849
- return out;
1850
- }
1851
- function _workItemsMtimesDiffer(prev, curr) {
1852
- if (!prev || !curr) return true;
1853
- for (const fp of Object.keys(curr)) {
1854
- if (prev[fp] !== curr[fp]) return true;
1855
- }
1856
- for (const fp of Object.keys(prev)) {
1857
- if (!(fp in curr)) return true;
1858
- }
1859
- return false;
1860
1860
  }
1861
1861
 
1862
1862
  // getWorkItems(config, { enrich })
@@ -1874,13 +1874,9 @@ function getWorkItems(config, opts) {
1874
1874
  const now = Date.now();
1875
1875
  config = config || getConfig();
1876
1876
  if (enrich) {
1877
- if (_workItemsCache && (now - _workItemsCacheAt) < 1000) {
1878
- const currMtimes = _snapshotWorkItemsMtimes(config);
1879
- if (!_workItemsMtimesDiffer(_workItemsCacheMtimes, currMtimes)) return _workItemsCache;
1880
- }
1877
+ if (_workItemsCache && (now - _workItemsCacheAt) < 1000) return _workItemsCache;
1881
1878
  } else if (_workItemsLeanCache && (now - _workItemsLeanCacheAt) < 1000) {
1882
- const currMtimes = _snapshotWorkItemsMtimes(config);
1883
- if (!_workItemsMtimesDiffer(_workItemsLeanCacheMtimes, currMtimes)) return _workItemsLeanCache;
1879
+ return _workItemsLeanCache;
1884
1880
  }
1885
1881
  let allItems = [];
1886
1882
 
@@ -1890,10 +1886,10 @@ function getWorkItems(config, opts) {
1890
1886
 
1891
1887
  // Cross-reference with dispatch (fill in agent from active dispatch if missing on work item)
1892
1888
  const dispatch = getDispatch();
1893
- const activeByWiId = new Map((dispatch.active || []).map(d => [d.meta?.item?.id, d.agent]));
1889
+ const activeByWiId = new Map((dispatch.active || []).map(d => [_dispatchWorkItemIdentityKey(d), d.agent]));
1894
1890
  for (const item of allItems) {
1895
1891
  if (item.status === WI_STATUS.DISPATCHED && !item.dispatched_to && !item.agent) {
1896
- const activeAgent = activeByWiId.get(item.id);
1892
+ const activeAgent = activeByWiId.get(_workItemIdentityKey(item));
1897
1893
  if (activeAgent) item.dispatched_to = activeAgent;
1898
1894
  }
1899
1895
  }
@@ -1902,7 +1898,7 @@ function getWorkItems(config, opts) {
1902
1898
  const pendingByWiId = new Map();
1903
1899
  for (const d of (dispatch.pending || [])) {
1904
1900
  if (d.meta?.item?.id && d.skipReason) {
1905
- pendingByWiId.set(d.meta.item.id, d);
1901
+ pendingByWiId.set(_dispatchWorkItemIdentityKey(d), d);
1906
1902
  }
1907
1903
  }
1908
1904
  if (pendingByWiId.size > 0) {
@@ -1912,7 +1908,7 @@ function getWorkItems(config, opts) {
1912
1908
  if (d.meta?.branch) branchToAgent.set(d.meta.branch, d.agentName || d.agent || '');
1913
1909
  }
1914
1910
  for (const item of allItems) {
1915
- const pendingEntry = pendingByWiId.get(item.id);
1911
+ const pendingEntry = pendingByWiId.get(_workItemIdentityKey(item));
1916
1912
  if (!pendingEntry) continue;
1917
1913
  item._skipReason = pendingEntry.skipReason;
1918
1914
  if (pendingEntry.skipReason === 'branch_locked' && pendingEntry.meta?.branch) {
@@ -1953,7 +1949,7 @@ function getWorkItems(config, opts) {
1953
1949
  // project so cross-repo branch collisions don't false-link.
1954
1950
  // (W-mpn0b76200044eed)
1955
1951
  const itemProject = item.project || item._source;
1956
- const linkedPr = allPrs.find(p => (p.prdItems || []).includes(item.id))
1952
+ const linkedPr = allPrs.find(p => p._project === itemProject && (p.prdItems || []).includes(item.id))
1957
1953
  || (item.branch && allPrs.find(p => p.branch === item.branch && p._project === itemProject));
1958
1954
  if (linkedPr) {
1959
1955
  item._pr = linkedPr.id;
@@ -1966,12 +1962,12 @@ function getWorkItems(config, opts) {
1966
1962
  // Build dispatch ID → work item ID lookup from completed dispatches
1967
1963
  const dispatchByWiId = {};
1968
1964
  for (const d of (dispatch.completed || [])) {
1969
- const wiId = d.meta?.item?.id;
1970
- if (wiId) dispatchByWiId[wiId] = d.id; // last completed dispatch wins
1965
+ const key = _dispatchWorkItemIdentityKey(d);
1966
+ if (key) dispatchByWiId[key] = d.id; // last completed dispatch wins
1971
1967
  }
1972
1968
  for (const d of (dispatch.active || [])) {
1973
- const wiId = d.meta?.item?.id;
1974
- if (wiId) dispatchByWiId[wiId] = d.id;
1969
+ const key = _dispatchWorkItemIdentityKey(d);
1970
+ if (key) dispatchByWiId[key] = d.id;
1975
1971
  }
1976
1972
  const _agentDirCache = {};
1977
1973
  const _inboxFiles = safeReadDir(INBOX_DIR);
@@ -1993,13 +1989,17 @@ function getWorkItems(config, opts) {
1993
1989
  const _archiveByAgent = {}; // agentId → archive filenames that include agentId
1994
1990
  // P-34fa5d79 — pre-build the wiId → notes map so each item's _notes lookup
1995
1991
  // is O(1) instead of re-scanning inbox+archive per item.
1996
- const _notesByWi = _buildNotesByWiMap();
1992
+ const _notesByWi = _buildNotesByWiMap(dispatch);
1993
+ const _workItemIdCounts = new Map();
1994
+ for (const item of allItems) {
1995
+ _workItemIdCounts.set(item.id, (_workItemIdCounts.get(item.id) || 0) + 1);
1996
+ }
1997
1997
  for (const item of allItems) {
1998
1998
  const arts = shared.isPlainObject(item._artifacts) ? { ...item._artifacts } : {};
1999
1999
  const agentId = item.dispatched_to || item.agent;
2000
2000
  if (agentId) {
2001
2001
  // Output log — match by dispatch ID (output-{dispatchId}.log)
2002
- const dispatchId = dispatchByWiId[item.id];
2002
+ const dispatchId = dispatchByWiId[_workItemIdentityKey(item)];
2003
2003
  if (dispatchId) {
2004
2004
  if (!_agentDirCache[agentId]) {
2005
2005
  _agentDirCache[agentId] = safeReadDir(path.join(MINIONS_DIR, 'agents', agentId)).filter(f => f.startsWith('output-') && f.endsWith('.log'));
@@ -2009,14 +2009,19 @@ function getWorkItems(config, opts) {
2009
2009
  }
2010
2010
  // Notes: inbox → KB (via source field) → archive (fallback if KB was swept)
2011
2011
  const itemId = item.id || '___';
2012
- const matchInbox = _inboxFiles.filter(f => f.includes(agentId) && f.includes(itemId));
2012
+ const hasUniqueId = _workItemIdCounts.get(itemId) === 1;
2013
+ const matchInbox = hasUniqueId
2014
+ ? _inboxFiles.filter(f => f.includes(agentId) && f.includes(itemId))
2015
+ : [];
2013
2016
  // Lazily bucket KB by agentId (computed once per distinct agent), then
2014
2017
  // filter the small bucket by itemId — same result as filtering all of
2015
2018
  // _kbEntries by (agentId AND itemId), without the per-item full scan.
2016
2019
  if (_kbByAgent[agentId] === undefined) {
2017
2020
  _kbByAgent[agentId] = _kbEntries.filter(kb => kb.source && kb.source.includes(agentId));
2018
2021
  }
2019
- const matchKb = _kbByAgent[agentId].filter(kb => kb.source.includes(itemId));
2022
+ const matchKb = hasUniqueId
2023
+ ? _kbByAgent[agentId].filter(kb => kb.source.includes(itemId))
2024
+ : [];
2020
2025
  const allNotes = [
2021
2026
  ...matchInbox,
2022
2027
  ...matchKb.map(kb => 'kb:' + kb.cat + '/' + kb.file),
@@ -2028,7 +2033,9 @@ function getWorkItems(config, opts) {
2028
2033
  if (_archiveByAgent[agentId] === undefined) {
2029
2034
  _archiveByAgent[agentId] = _archiveFiles.filter(f => f.includes(agentId));
2030
2035
  }
2031
- const matchArchive = _archiveByAgent[agentId].filter(f => f.includes(itemId));
2036
+ const matchArchive = hasUniqueId
2037
+ ? _archiveByAgent[agentId].filter(f => f.includes(itemId))
2038
+ : [];
2032
2039
  for (const f of matchArchive) allNotes.push('archive:' + f);
2033
2040
  }
2034
2041
  if (allNotes.length > 0) arts.notes = _mergeArtifactNotes(arts.notes, allNotes);
@@ -2044,7 +2051,9 @@ function getWorkItems(config, opts) {
2044
2051
  // prefixed with `archive:` so the dashboard chip renderer can pick the
2045
2052
  // right open path. Field is left undefined (not []) when empty so the
2046
2053
  // /api/work-items JSON stays compact for the common no-notes case.
2047
- const mentions = _notesByWi[item.id];
2054
+ const identityKey = _workItemIdentityKey(item);
2055
+ const mentions = _notesByWi[identityKey]
2056
+ || (_workItemIdCounts.get(item.id) === 1 ? _notesByWi[item.id] : null);
2048
2057
  if (mentions && mentions.length > 0) item._notes = mentions;
2049
2058
  }
2050
2059
  } // end if (enrich)
@@ -2064,7 +2073,7 @@ function getWorkItems(config, opts) {
2064
2073
  return (b.created || '').localeCompare(a.created || '');
2065
2074
  });
2066
2075
 
2067
- const cacheMtimes = _snapshotWorkItemsMtimes(config);
2076
+
2068
2077
  // Use Date.now() here (not the `now` captured at function entry) so the
2069
2078
  // 1 s TTL is measured from when the cache was BUILT, not from when the
2070
2079
  // call started. On a slow or loaded machine the first cold call can take
@@ -2076,11 +2085,9 @@ function getWorkItems(config, opts) {
2076
2085
  if (enrich) {
2077
2086
  _workItemsCache = allItems;
2078
2087
  _workItemsCacheAt = cacheSetAt;
2079
- _workItemsCacheMtimes = cacheMtimes;
2080
2088
  } else {
2081
2089
  _workItemsLeanCache = allItems;
2082
2090
  _workItemsLeanCacheAt = cacheSetAt;
2083
- _workItemsLeanCacheMtimes = cacheMtimes;
2084
2091
  }
2085
2092
  return allItems;
2086
2093
  }
@@ -2234,31 +2241,17 @@ function getPrdInfo(config) {
2234
2241
  // so they don't masquerade as a 2nd PRD item in the aggregate guard (W-mpem52qn).
2235
2242
  const wiById = {};
2236
2243
  const allWiById = {};
2237
- for (const project of projects) {
2238
- try {
2239
- const workItems = readJsonNoRestore(projectWorkItemsPath(project)) || [];
2240
- for (const wi of workItems) {
2241
- if (!wi?.id) { console.warn(`[queries] Skipping work item without id in ${project.name}:`, JSON.stringify(wi).slice(0, 120)); continue; }
2242
- if (!allWiById[wi.id]) allWiById[wi.id] = wi;
2243
- if (wi.sourcePlan) wiById[wi.id] = wi;
2244
- }
2245
- } catch { /* optional */ }
2246
- }
2247
- // Also check central work-items.json
2248
- try {
2249
- const centralWi = readJsonNoRestore(path.join(MINIONS_DIR, 'work-items.json')) || [];
2250
- for (const wi of centralWi) {
2251
- if (!wi?.id) { console.warn('[queries] Skipping central work item without id:', JSON.stringify(wi).slice(0, 120)); continue; }
2252
- if (!allWiById[wi.id]) allWiById[wi.id] = wi;
2253
- if (wi.sourcePlan && !wiById[wi.id]) wiById[wi.id] = wi;
2244
+ for (const wi of require('./work-items-store').readAllWorkItems()) {
2245
+ if (!wi?.id) {
2246
+ console.warn(`[queries] Skipping work item without id in ${wi?._source || 'unknown'}:`, JSON.stringify(wi).slice(0, 120));
2247
+ continue;
2254
2248
  }
2255
- } catch { /* optional */ }
2249
+ if (!allWiById[wi.id]) allWiById[wi.id] = wi;
2250
+ if (wi.sourcePlan && !wiById[wi.id]) wiById[wi.id] = wi;
2251
+ }
2256
2252
 
2257
- // Phase 10 step 4.3 index the (JSON-sourced) work items by their stable SQL
2258
- // FK so the per-item join can resolve PRD item → WI by prd_items.id rather
2259
- // than the feature-id string. The FK itself lives only in SQL (it's not in the
2260
- // JSON mirror), so read work_items.prd_item_id once and join it to the WI
2261
- // objects we already loaded, keyed by wi.id (first-wins, matching allWiById).
2253
+ // Index work items by their stable SQL FK so the per-item join can resolve
2254
+ // PRD item → WI by prd_items.id rather than the feature-id string.
2262
2255
  // Best-effort: null map → the join falls back to the legacy string match.
2263
2256
  let wiByPrdItemId = null;
2264
2257
  if (useFkJoin && prdItemIdByKey) {
@@ -2325,7 +2318,7 @@ function getPrdInfo(config) {
2325
2318
  for (const itemId of (itemIds || [])) {
2326
2319
  if (!prdToPr[itemId]) prdToPr[itemId] = [];
2327
2320
  // W-mrcs8yqk001o6893 — when the pr_links mapping outlives the PR record
2328
- // (merged-PR sweep removes it from pull-requests.json but pr_links is
2321
+ // (merged-PR sweep removes it from tracked PR state but pr_links is
2329
2322
  // never pruned), resolve the item's project so we can self-heal the
2330
2323
  // status the same way the wi._pr fallback loop below does.
2331
2324
  let itemProject = null;
@@ -2419,7 +2412,7 @@ function getPrdInfo(config) {
2419
2412
  const missing = (byStatus['missing'] || []).length;
2420
2413
  const donePercent = total > 0 ? Math.round((complete / total) * 100) : 0;
2421
2414
 
2422
- // Plan timings — use wiById (already includes central work-items.json)
2415
+ // Plan timings — wiById already includes the central scope.
2423
2416
  const planTimings = {};
2424
2417
  for (const wi of Object.values(wiById)) {
2425
2418
  if (!wi.sourcePlan) continue;
@@ -3075,71 +3068,8 @@ function _getProjectGitStatusCacheForTest() {
3075
3068
  return _projectGitStatusCache;
3076
3069
  }
3077
3070
 
3078
- /**
3079
- * Files whose mtime must trigger a dashboard `_fastState` rebuild
3080
- * (W-mpftp7na000td0f4). Single source of truth for the dashboard's
3081
- * cache-invalidation tracker — adding a new fast-state-surfaced JSON file
3082
- * is a **one-line change here**, next to the related fast-state getters.
3083
- *
3084
- * Contract for additions:
3085
- * 1. The file must be read (directly or transitively) by something in
3086
- * `dashboard._buildStatusFastState` (e.g. `getPullRequests`,
3087
- * `getDispatchQueue`, `watchesMod.getWatches`). Files surfaced only
3088
- * in `_buildStatusSlowState` (60 s TTL) should NOT be added — fast-
3089
- * state mtime invalidation has no effect on slow state, and they
3090
- * will silently no-op.
3091
- * 2. The file must be mutated through a `mutate*` helper (so writes
3092
- * actually advance mtime via `safeWrite`'s rename). Append-only logs
3093
- * can also be added, but expect rebuilds on every line write.
3094
- * 3. Per-project files must use `shared.projectPrPath` / `shared.
3095
- * projectWorkItemsPath` so newly-added projects are picked up
3096
- * automatically.
3097
- *
3098
- * Files intentionally NOT tracked:
3099
- * - `engine/log.json` — append-only log written many times per tick
3100
- * (PR polls, dispatch transitions, heartbeat). Its mtime advances
3101
- * every few seconds in steady state, which by itself was enough to
3102
- * bust the ETag cache on every poll and reduce the /api/status 304
3103
- * short-circuit to 0% hit rate (W-mpg8aapw001d7e0c). Log tail is
3104
- * surfaced by `getEngineLog()` and accepts the 10 s `FAST_STATE_TTL`
3105
- * backstop — log entries are not UX-blocking.
3106
- * - `engine/control.json` — heartbeat writes advance mtime every tick
3107
- * (~60 s) plus on any control-side mutation, dominating legitimate
3108
- * state changes. Engine state (running/paused/stopped) is surfaced
3109
- * by `getEngineState()`; the dashboard-side mutating handlers
3110
- * (`handleEngineRestart` etc.) call `invalidateStatusCache()`
3111
- * directly, and the 10 s TTL covers CLI-driven control-file edits.
3112
- * - `engine/state.json` — surfaced via `getEngineState()` but changes
3113
- * only on engine startup / reconcile. Negligible benefit.
3114
- * - `engine/metrics.json` — surfaced via `getMetrics()` but written by
3115
- * `trackEngineUsage()` / `trackAgentMetric()` / the engine tick itself
3116
- * (~10 s batched flush plus every per-agent metric event). Like
3117
- * `log.json` and `control.json`, its mtime advances at noise-floor
3118
- * cadence — every engine tick (~60 s) plus several per-agent writes
3119
- * in between, so with multiple agents active it bumps every few
3120
- * seconds and busts the ETag cache on /api/status before steady-state
3121
- * 304s can engage (W-mphejzct00065d8c). The engine-page metrics tile
3122
- * is not the hot path and the 10 s `FAST_STATE_TTL` is plenty for
3123
- * metric freshness; mutating handlers that need immediate metrics
3124
- * visibility should call `invalidateStatusCache()` directly.
3125
- * - `engine/cooldowns.json`, `engine/pr-links.json`, `engine/pending-
3126
- * rebases.json`, `agents/<id>/managed-spawn.json` — not in the
3127
- * `/api/status` payload.
3128
- * - `pinned.md`, `schedules`, `pipeline-runs.json`, `schedule-runs.json`,
3129
- * PRD JSON — slow-state only (see `getStatusSlowStateMtimePaths`).
3130
- *
3131
- * Files tracked PER-FILE rather than via dir mtime:
3132
- * - `meetings/<id>.json` — round transitions edit each file in-place via
3133
- * `engine/meeting.js#mutateMeeting`. The parent dir's mtime does not
3134
- * advance on Windows NTFS for in-place edits inside an existing file,
3135
- * so tracking each meeting JSON individually catches round/status flips
3136
- * that dir mtime would miss. Bounded by active meeting count.
3137
- *
3138
- * Performance: `_getMtimes()` in dashboard.js does `fs.statSync` per path
3139
- * per `getStatus()` call. Roughly N=4 engine paths + 2 per project today,
3140
- * so a 5-project fleet runs 14 statSync calls per cache miss — bounded
3141
- * and unmeasurable in benchmarks.
3142
- */
3071
+ // Runtime state invalidates through SQL events; these caches only track
3072
+ // intentionally file-backed dashboard inputs.
3143
3073
  // Cache the slow-state path list: it only changes when projects are added/
3144
3074
  // removed or runtimes are registered/deregistered (essentially process-
3145
3075
  // lifetime stable in practice). Without it we walked runtime-registry +
@@ -3157,22 +3087,7 @@ function _mtimePathsCacheKey(config) {
3157
3087
  return projects.join(';');
3158
3088
  }
3159
3089
  function getStatusFastStateMtimePaths(config) {
3160
- // Issue #2949 /api/status was slimmed to just engine/throttle state +
3161
- // skills/mcp/projects/version. dispatch.json, work-items.json,
3162
- // pull-requests.json, watches.json, notes.md, INBOX_DIR, qa-runs.json,
3163
- // and the meetings dir all moved to dedicated /api/<x> endpoints which
3164
- // have their OWN input-mtime ETags. Continuing to track those files in
3165
- // the fast-state registry would bust the /api/status outer cache on
3166
- // every engine write to them — pure wasted rebuild for slices that no
3167
- // longer appear in the payload.
3168
- //
3169
- // Fast-state inputs are now exclusively in-memory (engine heartbeat,
3170
- // ADO/GH throttle counters) so no file-mtime tracking is required.
3171
- // Returning [] makes the fast tracker a no-op; the slow tracker still
3172
- // catches the few file-derived slices that remain (skills, mcp,
3173
- // projects/git, autoMode/config). The cache version is bumped by
3174
- // `emitStateEvent` (db-events) when the slow registry detects a
3175
- // change OR when explicit invalidateStatusCache() callers fire.
3090
+ // Fast state is in memory or SQL, so file-mtime tracking is unnecessary.
3176
3091
  config = config || getConfig();
3177
3092
  const cacheKey = _mtimePathsCacheKey(config);
3178
3093
  if (_fastMtimePathsCache && _fastMtimePathsCacheKey === cacheKey) return _fastMtimePathsCache;
@@ -3185,22 +3100,8 @@ function getStatusFastStateMtimePaths(config) {
3185
3100
  /**
3186
3101
  * Slow-state mtime tracker — symmetric with the fast-state registry above.
3187
3102
  *
3188
- * Slow-state slices (`prdProgress`, `prd`, `verifyGuides`, `archivedPrds`,
3189
- * `skills`, `mcpServers`, `schedules`, `pipelines`, `pinned`, `projects`,
3190
- * `version`, etc.) live behind a 60 s TTL in `dashboard.js`. Without mtime
3191
- * tracking, engine-driven writes to `prd/*.json`, `engine/schedule-runs.json`,
3192
- * `engine/pipeline-runs.json`, or `prd/guides/*.md` waited up to the full
3193
- * 60 s before surfacing in the dashboard — which produced the visible
3194
- * "plan status doesn't update, pipelines never advance" symptom.
3195
- *
3196
- * The same conventions as fast-state apply:
3197
- * - Entries must back something `_buildStatusSlowState()` reads; otherwise
3198
- * the rebuild silently no-ops on detected change.
3199
- * - Files mutated through a `mutate*` helper are reliable (`safeWrite`
3200
- * rename advances mtime). Append-only logs reset the cache often but
3201
- * are not relevant here.
3202
- * - Per-project paths must use `shared.project*` so newly-added projects
3203
- * are picked up without registry edits.
3103
+ * Slow-state mtime tracking covers only intentionally file-backed inputs such
3104
+ * as config, skills, MCP settings, and project git metadata.
3204
3105
  *
3205
3106
  * Skill and MCP source paths (W-mphfdgwv000bf549):
3206
3107
  * - Skill discovery roots (`~/.claude/skills`, `~/.copilot/skills`,
@@ -3332,7 +3233,7 @@ function getStatusSlowStateMtimePaths(config) {
3332
3233
  module.exports = {
3333
3234
  // Paths (for modules that need direct access)
3334
3235
  MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, PLANS_DIR, PRD_DIR, SKILLS_DIR, KNOWLEDGE_DIR, ARCHIVE_DIR,
3335
- CONFIG_PATH, CONTROL_PATH, DISPATCH_PATH, LOG_PATH, NOTES_PATH,
3236
+ CONFIG_PATH, CONTROL_PATH, NOTES_PATH,
3336
3237
 
3337
3238
  // Helpers
3338
3239
  timeSince,