@yemi33/minions 0.1.2378 → 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 (100) 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/README.md +1 -0
  7. package/docs/architecture-review-2026-07-09.md +2 -4
  8. package/docs/auto-discovery.md +36 -49
  9. package/docs/blog-first-successful-dispatch.md +1 -1
  10. package/docs/branch-derivation.md +2 -2
  11. package/docs/command-center.md +1 -1
  12. package/docs/completion-reports.md +14 -4
  13. package/docs/constellation-bridge.md +59 -10
  14. package/docs/constellation-style-telemetry.md +6 -6
  15. package/docs/cooldown-merge-semantics.md +4 -0
  16. package/docs/copilot-cli-schema.md +3 -3
  17. package/docs/cross-repo-plans.md +17 -17
  18. package/docs/deprecated.json +2 -2
  19. package/docs/design-inbox-entries-schema.md +31 -39
  20. package/docs/design-state-storage.md +1 -1
  21. package/docs/documentation-audit-2026-07-09.md +2 -2
  22. package/docs/engine-restart.md +20 -8
  23. package/docs/harness-mode.md +1 -1
  24. package/docs/managed-spawn.md +4 -4
  25. package/docs/onboarding.md +1 -2
  26. package/docs/pr-comment-followup.md +3 -3
  27. package/docs/pr-review-fix-loop.md +1 -1
  28. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  29. package/docs/qa-runbook-lifecycle.md +4 -4
  30. package/docs/qa-runbooks.md +2 -2
  31. package/docs/rfc-completion-json.md +4 -1
  32. package/docs/runtime-adapters.md +1 -1
  33. package/docs/self-improvement.md +4 -5
  34. package/docs/shared-lifecycle-module-map.md +3 -1
  35. package/docs/slim-ux/architecture-suggestions.md +5 -6
  36. package/docs/slim-ux/concepts.md +23 -25
  37. package/docs/watches.md +7 -7
  38. package/docs/workspace-manifests.md +1 -1
  39. package/docs/worktree-lifecycle.md +1 -1
  40. package/engine/abandoned-pr-reconciliation.js +4 -5
  41. package/engine/acp-transport.js +49 -8
  42. package/engine/ado-status.js +5 -5
  43. package/engine/ado.js +20 -25
  44. package/engine/agent-worker-pool.js +124 -15
  45. package/engine/bridge.js +260 -5
  46. package/engine/cleanup.js +48 -131
  47. package/engine/cli.js +125 -83
  48. package/engine/cooldown.js +9 -16
  49. package/engine/db/index.js +22 -9
  50. package/engine/db/migrations/009-qa.js +1 -1
  51. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  52. package/engine/db/migrations/021-archived-work-items.js +72 -0
  53. package/engine/db/migrations/022-global-cc-session.js +31 -0
  54. package/engine/db/migrations/023-engine-state.js +30 -0
  55. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  56. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  57. package/engine/dispatch-store.js +2 -7
  58. package/engine/dispatch.js +34 -41
  59. package/engine/github.js +20 -27
  60. package/engine/lifecycle.js +221 -283
  61. package/engine/llm.js +1 -1
  62. package/engine/logs-store.js +2 -2
  63. package/engine/managed-spawn.js +2 -23
  64. package/engine/meeting.js +6 -6
  65. package/engine/metrics-store.js +2 -2
  66. package/engine/note-link-backfill.js +6 -11
  67. package/engine/pipeline.js +18 -36
  68. package/engine/playbook.js +1 -1
  69. package/engine/pooled-agent-process.js +46 -26
  70. package/engine/prd-store.js +73 -54
  71. package/engine/preflight.js +2 -5
  72. package/engine/projects.js +15 -62
  73. package/engine/pull-requests-store.js +0 -17
  74. package/engine/qa-runbooks.js +2 -2
  75. package/engine/qa-runs.js +1 -8
  76. package/engine/qa-sessions.js +41 -64
  77. package/engine/queries.js +120 -219
  78. package/engine/routing.js +3 -5
  79. package/engine/scheduler.js +0 -4
  80. package/engine/shared-branch-pr-reconcile.js +2 -3
  81. package/engine/shared.js +132 -637
  82. package/engine/small-state-store.js +89 -10
  83. package/engine/state-operations.js +16 -4
  84. package/engine/stdio-timestamps.js +1 -1
  85. package/engine/timeout.js +5 -12
  86. package/engine/watch-actions.js +20 -22
  87. package/engine/watches-store.js +1 -1
  88. package/engine/watches.js +6 -10
  89. package/engine/work-item-validation.js +52 -0
  90. package/engine/work-items-store.js +127 -29
  91. package/engine/worktree-gc.js +2 -2
  92. package/engine/worktree-pool.js +8 -18
  93. package/engine.js +167 -349
  94. package/minions.js +2 -2
  95. package/package.json +1 -1
  96. package/playbooks/plan-to-prd.md +2 -2
  97. package/playbooks/shared-rules.md +3 -3
  98. package/playbooks/templates/followup-dispatch.md +1 -1
  99. package/playbooks/verify.md +1 -1
  100. package/prompts/cc-system.md +2 -2
package/dashboard.js CHANGED
@@ -66,13 +66,14 @@ const features = require('./engine/features');
66
66
  const ccWorkerPool = require('./engine/cc-worker-pool');
67
67
  const diagnosticsMemory = require('./engine/diagnostics-memory');
68
68
  const memoryStore = require('./engine/memory-store');
69
+ const smallStateStore = require('./engine/small-state-store');
69
70
  const os = require('os');
70
71
 
71
72
  const { safeRead, safeReadOrNull, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateTextFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, WORKTREE_REQUIRING_TYPES, reopenWorkItem } = shared;
72
73
  const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
73
74
  getSkills, getCommands, getInbox, getNotesWithMeta, getPullRequests,
74
75
  getEngineLog, getMetrics, getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getProjectGitStatus, timeSince,
75
- MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, DISPATCH_PATH, PRD_DIR } = queries;
76
+ MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, PRD_DIR } = queries;
76
77
 
77
78
  // Dev vs binary differentiation. When two dashboards run side-by-side (npm
78
79
  // install on 7331, local checkout on 7332), the favicon and title need to
@@ -230,7 +231,7 @@ function ensureConfiguredProjectStateFiles() {
230
231
  const root = p.localPath ? path.resolve(p.localPath) : null;
231
232
  if (!root || !fs.existsSync(root)) continue;
232
233
  try {
233
- shared.ensureProjectStateFiles(p);
234
+ shared.initializeProjectState(p);
234
235
  } catch (e) {
235
236
  console.warn(`[dashboard] project state setup failed for "${p.name}": ${e.message}`);
236
237
  }
@@ -609,7 +610,7 @@ function inferActionPrRecord(action, prs, project = null) {
609
610
  // real pull request before `copyWorkItemPrFields` promotes it to structured
610
611
  // targetPr/pr_id/prNumber fields. GitHub & ADO issues share the PR number
611
612
  // namespace, so a bare `owner/repo#244` / `#244` can be an ISSUE. Stamping an
612
- // issue mints a phantom pull-requests.json record (agent 'human', goes
613
+ // issue mints a phantom tracked PR record (agent 'human', goes
613
614
  // abandoned) AND routes the dispatch through the PR-fix path instead of
614
615
  // implement-and-open-a-fresh-PR.
615
616
  //
@@ -767,7 +768,7 @@ async function _fetchLivePrRecord(canonicalId, opts) {
767
768
  function _findTrackedPrRecord(prRef, project) {
768
769
  if (!project) return null;
769
770
  try {
770
- const prs = shared.safeJsonArr(shared.projectPrPath(project));
771
+ const prs = shared.readPullRequests(project);
771
772
  return shared.findPrRecord(prs, prRef, project);
772
773
  } catch { return null; }
773
774
  }
@@ -919,14 +920,13 @@ function normalizeWorkItemDedupTitle(value) {
919
920
  .toLowerCase();
920
921
  }
921
922
 
922
- function resolveWorkItemDedupProject(item, wiPath = '') {
923
+ function resolveWorkItemDedupProject(item, project = null) {
923
924
  const projectName = normalizeWorkItemDedupText(item?.project || item?._project || item?._source);
924
925
  if (projectName) {
925
926
  const namedProject = shared.resolveProjectSource(projectName, PROJECTS, { allowCentral: false });
926
927
  if (namedProject.project) return namedProject.project;
927
928
  }
928
- if (!wiPath) return null;
929
- return shared.resolveProjectSource(wiPath, PROJECTS, { allowCentral: false }).project || null;
929
+ return project;
930
930
  }
931
931
 
932
932
  function getWorkItemPrRefCandidates(item) {
@@ -962,7 +962,7 @@ function normalizeWorkItemDedupPrIdentity(item, project = null) {
962
962
  }
963
963
 
964
964
  function workItemCreateFingerprint(item, options = {}) {
965
- const project = resolveWorkItemDedupProject(item, options.wiPath);
965
+ const project = resolveWorkItemDedupProject(item, options.project);
966
966
  return {
967
967
  title: normalizeWorkItemDedupTitle(item?.title),
968
968
  type: routing.normalizeWorkType(item?.type || item?.workType, WORK_TYPE.IMPLEMENT),
@@ -1015,10 +1015,11 @@ function findDuplicateWorkItemCreate(items, candidate, options = {}) {
1015
1015
  }) || null;
1016
1016
  }
1017
1017
 
1018
- function createWorkItemWithDedup(wiPath, item, options = {}) {
1018
+ function createWorkItemWithDedup(source, item, options = {}) {
1019
+ const project = options.project || (source && typeof source === 'object' ? source : null);
1019
1020
  let result = null;
1020
- mutateWorkItems(wiPath, items => {
1021
- const existing = findDuplicateWorkItemCreate(items, item, { ...options, wiPath });
1021
+ mutateWorkItems(source, items => {
1022
+ const existing = findDuplicateWorkItemCreate(items, item, { ...options, project });
1022
1023
  if (existing) {
1023
1024
  result = { created: false, item: existing, duplicateOf: existing.id };
1024
1025
  return items;
@@ -1153,21 +1154,26 @@ function dispatchPrefixForResolvedSource(target) {
1153
1154
  return target?.project ? `work-${target.project.name}-` : 'central-work-';
1154
1155
  }
1155
1156
 
1157
+ function matchesResolvedWorkItemDispatch(entry, target, id) {
1158
+ const dispatchKey = dispatchPrefixForResolvedSource(target) + id;
1159
+ return entry?.meta?.dispatchKey === dispatchKey || entry?.meta?.parentKey === dispatchKey;
1160
+ }
1161
+
1156
1162
  function findWorkItemsTargetById(id, source, projects = PROJECTS) {
1157
1163
  const explicitSource = source !== undefined && source !== null && String(source).trim() !== '';
1158
1164
  if (explicitSource) {
1159
1165
  const target = resolveProjectSourceTarget(source, projects);
1160
1166
  if (target.error) return { error: target.error };
1161
- const items = shared.safeJsonArr(target.wiPath);
1167
+ const items = shared.readWorkItems(target.scope);
1162
1168
  return { ...target, found: items.some(i => i.id === id) };
1163
1169
  }
1164
1170
 
1165
1171
  const central = resolveProjectSourceTarget('central', projects);
1166
- const centralItems = shared.safeJsonArr(central.wiPath);
1172
+ const centralItems = shared.readWorkItems(central.scope);
1167
1173
  if (centralItems.some(i => i.id === id)) return { ...central, found: true };
1168
1174
  for (const project of projects) {
1169
1175
  const target = resolveProjectSourceTarget(project.name, projects);
1170
- const items = shared.safeJsonArr(target.wiPath);
1176
+ const items = shared.readWorkItems(target.scope);
1171
1177
  if (items.some(i => i.id === id)) return { ...target, found: true };
1172
1178
  }
1173
1179
  return { found: false };
@@ -1314,23 +1320,18 @@ function validatePipelineProjects(pipeline, projects = PROJECTS) {
1314
1320
  }
1315
1321
 
1316
1322
  /**
1317
- * Aggregate archived work items from the central archive plus every project
1318
- * archive. Each item is tagged with `_source` (`'central'` or the project name)
1319
- * so the UI can group/filter. Reads via `safeJsonArr` — a corrupt archive
1320
- * surfaces a logged parse failure and contributes zero items, instead of
1321
- * throwing 500 or silently dropping the file.
1323
+ * Aggregate archived work items from central and project SQL scopes.
1322
1324
  *
1323
1325
  * Exported for testing (P-h3arch-8c19).
1324
1326
  */
1325
- function collectArchivedWorkItems(minionsDir = MINIONS_DIR, projects = PROJECTS) {
1327
+ function collectArchivedWorkItems(projects = PROJECTS) {
1328
+ const store = require('./engine/work-items-store');
1326
1329
  const archived = [];
1327
- const centralPath = path.join(minionsDir, 'work-items-archive.json');
1328
- for (const item of safeJsonArr(centralPath)) {
1330
+ for (const item of store.readArchivedWorkItemsForScope('central')) {
1329
1331
  archived.push({ ...item, _source: 'central' });
1330
1332
  }
1331
1333
  for (const project of projects) {
1332
- const archPath = shared.projectWorkItemsPath(project).replace('.json', '-archive.json');
1333
- for (const item of safeJsonArr(archPath)) {
1334
+ for (const item of store.readArchivedWorkItemsForScope(project.name)) {
1334
1335
  archived.push({ ...item, _source: project.name });
1335
1336
  }
1336
1337
  }
@@ -1344,7 +1345,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
1344
1345
  }
1345
1346
  const projects = shared.getProjects(config);
1346
1347
  const { project: targetProject, resolution: projectResolution } = resolveManualPrLinkProject(url, projectName, projects);
1347
- const prPath = targetProject ? shared.projectPrPath(targetProject) : shared.centralPullRequestsPath(MINIONS_DIR);
1348
+ const source = targetProject || 'central';
1348
1349
 
1349
1350
  const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
1350
1351
  const prNum = prNumMatch ? (prNumMatch[1] || prNumMatch[2]) : Date.now().toString().slice(-6);
@@ -1353,7 +1354,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
1353
1354
  const contextText = typeof context === 'string' ? context : (context == null ? '' : JSON.stringify(context));
1354
1355
  const metadata = normalizePrMetadata(options.metadata);
1355
1356
  const resolvedContextOnly = typeof contextOnly === 'boolean' ? contextOnly : false;
1356
- const result = shared.upsertPullRequestRecord(prPath, {
1357
+ const result = shared.upsertPullRequestRecord(source, {
1357
1358
  id: prId,
1358
1359
  prNumber: parseInt(prNum, 10) || null,
1359
1360
  title: (metadata?.title || title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
@@ -1382,7 +1383,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
1382
1383
  // tombstone left by a prior explicit delete rather than silently no-op.
1383
1384
  allowResurrect: true,
1384
1385
  });
1385
- return { ...result, prPath, targetProject, projectResolution, prNum };
1386
+ return { ...result, scope: shared.stateScope(source), targetProject, projectResolution, prNum };
1386
1387
  }
1387
1388
 
1388
1389
  // W-mpmwxkzm0009ba0b — Per-row auto-observe toggle backing helper for
@@ -1394,9 +1395,9 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
1394
1395
  // fallback (`contextOnly = !observe`) for any caller that still sends it —
1395
1396
  // the dashboard client itself now sends `contextOnly` exclusively
1396
1397
  // (P-2b6e4d81; see docs/deprecated.json#pr-observe-observe-body-param).
1397
- // Returns the updated record + the PR path that was touched. Throws an
1398
+ // Returns the updated record + SQL scope. Throws an
1398
1399
  // Error with `statusCode` for the route handler to map to an HTTP status.
1399
- function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly } = {}, config = CONFIG, minionsDir = MINIONS_DIR) {
1400
+ function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly } = {}, config = CONFIG) {
1400
1401
  const hostStr = String(host || '').trim().toLowerCase();
1401
1402
  const slugStr = String(slug || '').trim();
1402
1403
  const numberInt = Number.parseInt(number, 10);
@@ -1428,21 +1429,18 @@ function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly
1428
1429
 
1429
1430
  const canonicalId = `${hostStr}:${slugStr}#${numberInt}`;
1430
1431
  const projects = shared.getProjects(config);
1431
- const prPaths = [
1432
- ...projects.map(p => shared.projectPrPath(p)),
1433
- shared.centralPullRequestsPath(minionsDir),
1434
- ];
1432
+ const sources = [...projects, 'central'];
1435
1433
 
1436
1434
  let updated = null;
1437
- let updatedPath = null;
1438
- for (const prPath of prPaths) {
1435
+ let updatedScope = null;
1436
+ for (const source of sources) {
1439
1437
  if (updated) break;
1440
- shared.mutatePullRequests(prPath, (prs) => {
1438
+ shared.mutatePullRequests(source, (prs) => {
1441
1439
  const pr = prs.find(p => p && p.id === canonicalId);
1442
1440
  if (!pr) return prs;
1443
1441
  pr.contextOnly = contextOnlyValue;
1444
1442
  updated = { id: pr.id, contextOnly: pr.contextOnly };
1445
- updatedPath = prPath;
1443
+ updatedScope = shared.stateScope(source);
1446
1444
  return prs;
1447
1445
  });
1448
1446
  }
@@ -1452,7 +1450,7 @@ function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly
1452
1450
  err.statusCode = 404;
1453
1451
  throw err;
1454
1452
  }
1455
- return { ...updated, prPath: updatedPath };
1453
+ return { ...updated, scope: updatedScope };
1456
1454
  }
1457
1455
 
1458
1456
  function _normalizeSkillDirForCompare(dir) {
@@ -1659,24 +1657,12 @@ const PLANS_CACHE_TTL_MS = 5000;
1659
1657
  function invalidatePlansCache() { _plansCache = null; _plansCacheTs = 0; }
1660
1658
 
1661
1659
  function statePathExists(filePath) {
1662
- if (prdStore.parsePrdPath(filePath)) return prdStore.readPrd(filePath) != null;
1663
1660
  return fs.existsSync(filePath);
1664
1661
  }
1665
1662
 
1666
- // Resolve a plan/PRD file path: .json files live in prd/, .md files in plans/
1667
- // Validates that the file stays within the expected directory to prevent path traversal.
1663
+ // Resolve a human-authored Markdown plan path.
1668
1664
  function resolvePlanPath(file) {
1669
- if (file.endsWith('.json')) {
1670
- // Validate against both prd/ and prd/archive/
1671
- shared.sanitizePath(file, PRD_DIR);
1672
- const active = path.join(PRD_DIR, file);
1673
- if (prdStore.readPrd(active)) return active;
1674
- const archived = path.join(PRD_DIR, 'archive', file);
1675
- if (prdStore.readPrd(archived)) return archived;
1676
- return active;
1677
- }
1678
-
1679
- // Validate against both plans/ and plans/archive/
1665
+ if (!file.endsWith('.md')) throw new Error('expected a Markdown plan filename');
1680
1666
  shared.sanitizePath(file, PLANS_DIR);
1681
1667
  const active = path.join(PLANS_DIR, file);
1682
1668
  if (fs.existsSync(active)) return active;
@@ -1685,72 +1671,36 @@ function resolvePlanPath(file) {
1685
1671
  return active;
1686
1672
  }
1687
1673
 
1688
- // W-mqa13ulk0002def5 PRD post-archive: flip status+archivedAt, neutralize
1689
- // the .backup sidecar, and move the source plan markdown to plans/archive/.
1690
- // Each concern runs in its own try/catch so a failure in one (e.g. lock
1691
- // contention from the engine's plan-completion scan throwing in
1692
- // mutateJsonFileLocked) does not silently swallow the others — the bug that
1693
- // orphaned plans/killswitches-and-granular-controls.md on 2026-06-11 after its
1694
- // PRD archived OK. Failures are pushed into archiveWarnings (returned to the
1695
- // dashboard as `warnings: [...]`) so the user sees them instead of a silent
1696
- // `archivedSource: null`. _mutate / _safeJsonObj injection points are test
1697
- // seams; production callers omit them.
1698
- function _archivePrdPostProcess({ planFile, archivePath, planPath, plansDir, _mutate, _safeJsonObj } = {}) {
1699
- const mutate = typeof _mutate === 'function' ? _mutate : mutateJsonFileLocked;
1700
- const readObj = typeof _safeJsonObj === 'function' ? _safeJsonObj : safeJsonObj;
1674
+ // Archive a PRD in SQL, then move its source Markdown plan.
1675
+ function _archivePrdPostProcess({ planFile, plansDir, _mutate, _read } = {}) {
1676
+ const mutate = typeof _mutate === 'function' ? _mutate : prdStore.mutatePrd;
1677
+ const read = typeof _read === 'function' ? _read : prdStore.readPrd;
1701
1678
  const archiveWarnings = [];
1702
1679
  let plan = {};
1703
1680
  let archivedSource = null;
1704
1681
 
1705
- // (a) Flip the archived FLAG + status + archivedAt. Phase 10 step 4.2b:
1706
- // archived-ness is the flag now (not the directory), so set `archived:true`
1707
- // explicitly. The path-shaped mutator routes PRDs into SQL.
1708
- // When archivePath === planPath (in-place archive) this flags the PRD where it
1709
- // sits; when they differ (.md-cascade / legacy move) it flags the moved copy.
1710
1682
  try {
1711
- plan = mutate(archivePath, (data) => {
1683
+ plan = mutate(planFile, (data) => {
1712
1684
  if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
1713
1685
  data.status = 'archived';
1714
1686
  data.archived = true;
1715
1687
  data.archivedAt = new Date().toISOString();
1716
1688
  return data;
1717
- }, { defaultValue: {} }) || {};
1689
+ }) || {};
1718
1690
  } catch (e) {
1719
1691
  const warning = `Archive status flip failed for ${planFile}: ${e.message}`;
1720
1692
  archiveWarnings.push(warning);
1721
1693
  console.warn(warning);
1722
1694
  }
1723
1695
 
1724
- // Re-read the PRD from disk if mutate threw or returned an empty object —
1725
- // the file was already renamed into the archive dir, so the source_plan
1726
- // field is still on disk and the markdown move below needs it.
1727
1696
  if (!plan.source_plan) {
1728
1697
  try {
1729
- const disk = readObj(archivePath);
1730
- if (disk && typeof disk === 'object' && disk.source_plan) plan = disk;
1731
- } catch { /* readObj fallback is best-effort */ }
1698
+ const stored = read(planFile);
1699
+ if (stored && typeof stored === 'object' && stored.source_plan) plan = stored;
1700
+ } catch { /* best-effort */ }
1732
1701
  }
1733
1702
 
1734
- // (b) Neutralize the .backup sidecar at the OLD live path ONLY when the file
1735
- // actually moved (archivePath !== planPath) — a stale prd/<f>.json.backup left
1736
- // behind by a move is resurrection fuel. For an in-place archive the file (and
1737
- // its current .backup) stay put and are valid, so there is nothing to neutralize.
1738
- if (planPath !== archivePath) {
1739
- try {
1740
- const backupCleanup = shared.neutralizeJsonBackupSidecar(planPath);
1741
- if (!backupCleanup.ok) {
1742
- const warning = `Archive backup cleanup failed for ${planFile}: unlink failed (${backupCleanup.unlinkError}); fallback neutralize failed (${backupCleanup.writeError})`;
1743
- archiveWarnings.push(warning);
1744
- console.warn(warning);
1745
- }
1746
- } catch (e) {
1747
- const warning = `Archive backup cleanup failed for ${planFile}: ${e.message}`;
1748
- archiveWarnings.push(warning);
1749
- console.warn(warning);
1750
- }
1751
- }
1752
-
1753
- // (c) Move the source plan markdown into plans/archive/.
1703
+ // Move the source plan Markdown into plans/archive/.
1754
1704
  if (plan.source_plan) {
1755
1705
  try {
1756
1706
  // Canonical key: a `plans/`-prefixed source_plan would otherwise join to
@@ -2398,18 +2348,15 @@ function _ifNoneMatchHasEtag(headerValue, currentEtag) {
2398
2348
  return false;
2399
2349
  }
2400
2350
 
2401
- // mtime-based cache invalidation.
2351
+ // Cross-process cache invalidation.
2402
2352
  //
2403
2353
  // Engine and dashboard are independent processes; `invalidateStatusCache()`
2404
2354
  // lives in dashboard.js memory and is unreachable from engine code. We
2405
- // detect engine-side state flips (WI pending→done, PR status changes,
2406
- // dispatch.json mutations, PRD progress derived from work-items, …) by
2407
- // statSync'ing a registry of input files on every status request. Any
2408
- // mtime advance → cache busted → fresh rebuild on the same poll.
2355
+ // SQL mutations advance the events table; intentionally file-backed inputs
2356
+ // still use mtime tracking.
2409
2357
  //
2410
2358
  // The two source-of-truth lists live in `engine/queries.js`:
2411
- // - getStatusFastStateMtimePaths — engine-driven hot writes (dispatch,
2412
- // work-items, PR files, watches, qa runs, inbox, notes, meetings, …).
2359
+ // - getStatusFastStateMtimePaths — file-backed hot inputs.
2413
2360
  // - getStatusSlowStateMtimePaths — slower-cadence writes (PRD JSON,
2414
2361
  // schedule/pipeline runs, pinned, skill discovery roots, MCP configs,
2415
2362
  // git refs, …).
@@ -2937,18 +2884,10 @@ const STATE_READ_ALLOWED_FILES = new Set([
2937
2884
  const STATE_READ_DENIED_BASENAMES = new Set([
2938
2885
  'session.json',
2939
2886
  'sessions.json',
2940
- 'cc-session.json',
2941
- 'cc-sessions.json',
2942
- 'qa-sessions.json',
2943
2887
  'qa-sessions-history.json',
2944
2888
  'session-state.json',
2945
2889
  'session-token.json',
2946
2890
  'keep-pids.json',
2947
- // doc-sessions.json holds user-authored doc-chat conversation state
2948
- // (questions + document context + LLM responses). Was previously denied
2949
- // via a broad *sessions?.json regex that round-2 #9 narrowed; restoring
2950
- // explicit coverage here. (Round-3 review finding #2.)
2951
- 'doc-sessions.json',
2952
2891
  ]);
2953
2892
  const STATE_READ_DENIED_PATTERNS = [
2954
2893
  /(^|[\\/])state\.db($|-wal$|-shm$)/i,
@@ -2989,7 +2928,7 @@ function _stateReadContentType(ext) {
2989
2928
  // serveFreshJson is "run server-side enrichment, ETag off input mtimes."
2990
2929
  //
2991
2930
  // Used for slices whose payload joins multiple files (agents derived from
2992
- // config + dispatch + inbox + steering, metrics derived from metrics.json
2931
+ // config + dispatch + inbox + steering, with SQL-backed metrics
2993
2932
  // + dispatch + PRs, PRD derived from PRD dir + work-items + PRs, etc.).
2994
2933
  // Replicating those joins in browser JS would be brittle, and shipping the
2995
2934
  // fully-enriched result through /api/status pays the staleness tax that
@@ -3079,6 +3018,44 @@ async function serveFreshJson(req, res, opts) {
3079
3018
  res.end(JSON.stringify(payload));
3080
3019
  }
3081
3020
 
3021
+ function _buildConstellationBridgeSnapshot() {
3022
+ const config = queries.getConfig() || {};
3023
+ const scheduleRuns = smallStateStore.readScheduleRuns();
3024
+ const schedules = (Array.isArray(config.schedules) ? config.schedules : []).map(schedule => {
3025
+ const run = scheduleRuns[schedule.id];
3026
+ const lastRun = typeof run === 'string'
3027
+ ? run
3028
+ : (run?.lastRun || run?.lastCompletedAt || null);
3029
+ return { ...schedule, _lastRun: lastRun };
3030
+ });
3031
+ const plans = prdStore.listPrdRows().map(row => {
3032
+ const plan = row.plan && typeof row.plan === 'object' ? row.plan : {};
3033
+ return {
3034
+ id: row.filename,
3035
+ title: plan.plan_summary || plan.title || row.filename,
3036
+ status: row.archived
3037
+ ? 'archived'
3038
+ : (plan.status || (plan.requires_approval && !plan.approvedAt ? 'awaiting-approval' : 'active')),
3039
+ agent: plan.generated_by || null,
3040
+ project: plan.project || null,
3041
+ createdAt: plan.generated_at || null,
3042
+ updatedAt: row.updatedAt || null,
3043
+ };
3044
+ });
3045
+
3046
+ return require('./engine/bridge').buildBridgeSnapshot({
3047
+ minionsVersion: _dashboardVersion.codeVersion,
3048
+ control: queries.getControl(),
3049
+ projects: shared.getProjects(config),
3050
+ workItems: queries.getWorkItems(config, { enrich: false }),
3051
+ pullRequests: queries.getPullRequests(config),
3052
+ plans,
3053
+ schedules,
3054
+ agents: queries.getAgents(config),
3055
+ dispatch: queries.getDispatch(),
3056
+ });
3057
+ }
3058
+
3082
3059
  function handleStateRead(req, res) {
3083
3060
  const urlPath = req.url.split('?')[0];
3084
3061
  const rel = decodeURIComponent(urlPath.slice('/state/'.length));
@@ -3285,7 +3262,7 @@ async function _handleStatusRequest(req, res) {
3285
3262
  }
3286
3263
  }
3287
3264
 
3288
- // Periodic push for engine-driven changes (dispatch.json, work-items.json, pull-requests.json) that bypass invalidateStatusCache
3265
+ // Periodic push for engine-driven SQL state changes that bypass invalidateStatusCache.
3289
3266
  setInterval(() => {
3290
3267
  if (_statusStreamClients.size === 0) return;
3291
3268
  const data = getStatusJson();
@@ -3836,14 +3813,12 @@ function _filterCcTabSessions(sessions) {
3836
3813
  }
3837
3814
 
3838
3815
  function _readCcTabSessions({ prune = true } = {}) {
3839
- if (!prune) return _filterCcTabSessions(shared.safeJsonArr(CC_SESSIONS_PATH));
3840
- // P-c2sess-1d8e: read+filter+write atomically under the file lock so a
3841
- // concurrent tab upsert/delete cannot lose entries to last-write-wins.
3816
+ if (!prune) return _filterCcTabSessions(smallStateStore.readCcSessions());
3842
3817
  let sessions;
3843
- mutateJsonFileLocked(CC_SESSIONS_PATH, (raw) => {
3818
+ smallStateStore.applyCcSessionsMutation((raw) => {
3844
3819
  sessions = _filterCcTabSessions(raw);
3845
3820
  return sessions;
3846
- }, { defaultValue: [] });
3821
+ });
3847
3822
  return sessions;
3848
3823
  }
3849
3824
 
@@ -3951,7 +3926,7 @@ function _joinCcPromptParts(...parts) {
3951
3926
  // only restore-time validity checks here are sessionId presence (anything
3952
3927
  // else would auto-expire the user's chat without their consent).
3953
3928
  try {
3954
- const saved = safeJson(path.join(ENGINE_DIR, 'cc-session.json'));
3929
+ const saved = smallStateStore.readCcGlobalSession();
3955
3930
  if (saved && saved.sessionId) ccSession = saved;
3956
3931
  } catch { /* optional */ }
3957
3932
 
@@ -4168,7 +4143,10 @@ function buildCCStatePreamble() {
4168
4143
  const prCount = getPullRequests().length;
4169
4144
  const wiCount = getWorkItems(null, { enrich: false }).length;
4170
4145
 
4171
- const planFiles = [...safeReadDir(PLANS_DIR), ...safeReadDir(PRD_DIR)].filter(f => f.endsWith('.md') || f.endsWith('.json'));
4146
+ const planFiles = [
4147
+ ...safeReadDir(PLANS_DIR).filter(file => file.endsWith('.md')),
4148
+ ...prdStore.listPrdRows().map(row => row.filename),
4149
+ ];
4172
4150
 
4173
4151
  const schedules = CONFIG.schedules || [];
4174
4152
  const enabledSchedules = schedules.filter(s => s.enabled !== false).length;
@@ -4340,12 +4318,13 @@ function getWorkItemPrRef(input) {
4340
4318
  // invalidation paths are prompt-hash mismatch (correctness — a stale system
4341
4319
  // prompt would carry the old persona/rules into resume turns) and the
4342
4320
  // LRU cap below (storage hygiene). Users can resume a doc-chat any time.
4343
- const CC_SESSIONS_PATH = path.join(ENGINE_DIR, 'cc-sessions.json');
4344
- const DOC_SESSIONS_PATH = path.join(ENGINE_DIR, 'doc-sessions.json');
4345
- const CC_SESSION_PATH = path.join(ENGINE_DIR, 'cc-session.json');
4346
4321
  const DOC_SESSION_MAX_ENTRIES = shared.ENGINE_DEFAULTS.docSessionMaxEntries;
4347
4322
  const docSessions = new Map(); // key → { sessionId, lastActiveAt, turnCount }
4348
4323
 
4324
+ function persistCcSession() {
4325
+ smallStateStore.applyCcGlobalSessionMutation(() => ccSession);
4326
+ }
4327
+
4349
4328
  function _docSessionLastActiveMs(session) {
4350
4329
  const ms = Date.parse(session?.lastActiveAt || session?.createdAt || '');
4351
4330
  return Number.isFinite(ms) ? ms : 0;
@@ -4374,7 +4353,7 @@ function pruneDocSessions() {
4374
4353
  // Load persisted doc sessions on startup — no TTL/turn filtering, sessions
4375
4354
  // are non-expiring. Stale prompt-hash entries are dropped by pruneDocSessions.
4376
4355
  try {
4377
- const saved = safeJson(DOC_SESSIONS_PATH);
4356
+ const saved = smallStateStore.readDocSessions();
4378
4357
  if (saved && typeof saved === 'object') {
4379
4358
  for (const [key, s] of Object.entries(saved)) {
4380
4359
  docSessions.set(key, s);
@@ -4387,8 +4366,7 @@ function persistDocSessions() {
4387
4366
  pruneDocSessions();
4388
4367
  const obj = {};
4389
4368
  for (const [key, s] of docSessions) obj[key] = s;
4390
- // P-c2sess-1d8e: lock against engine/cleanup.js's cap-trim RMW.
4391
- mutateJsonFileLocked(DOC_SESSIONS_PATH, () => obj, { defaultValue: {} });
4369
+ smallStateStore.applyDocSessionsMutation(() => obj);
4392
4370
  }
4393
4371
 
4394
4372
  // Hourly hygiene sweep — drops prompt-hash mismatches and trims the LRU cap.
@@ -4456,7 +4434,7 @@ function updateSession(store, key, sessionId, existing) {
4456
4434
  turnCount: (existing ? ccSession.turnCount : 0) + 1,
4457
4435
  _promptHash: _ccPromptHash,
4458
4436
  };
4459
- mutateJsonFileLocked(CC_SESSION_PATH, () => ccSession, { defaultValue: {} });
4437
+ persistCcSession();
4460
4438
  } else if (key) {
4461
4439
  const prev = docSessions.get(key);
4462
4440
  docSessions.set(key, {
@@ -4831,7 +4809,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
4831
4809
  // Invalidate the dead session so future calls don't try to resume it
4832
4810
  if (store === 'cc') {
4833
4811
  ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
4834
- mutateJsonFileLocked(CC_SESSION_PATH, () => ccSession, { defaultValue: {} });
4812
+ persistCcSession();
4835
4813
  } else if (sessionKey) {
4836
4814
  docSessions.delete(sessionKey);
4837
4815
  schedulePersistDocSessions();
@@ -4980,7 +4958,7 @@ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext
4980
4958
  sessionId = null;
4981
4959
  if (store === 'cc') {
4982
4960
  ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
4983
- mutateJsonFileLocked(CC_SESSION_PATH, () => ccSession, { defaultValue: {} });
4961
+ persistCcSession();
4984
4962
  } else if (sessionKey) {
4985
4963
  docSessions.delete(sessionKey);
4986
4964
  schedulePersistDocSessions();
@@ -6061,36 +6039,20 @@ const server = http.createServer(async (req, res) => {
6061
6039
  try {
6062
6040
  const body = await readBody(req);
6063
6041
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
6064
- shared.sanitizePath(body.file, PRD_DIR);
6065
-
6066
- // Find the PRD — check active and archive
6067
- const prdDir = path.join(MINIONS_DIR, 'prd');
6068
- let prdPath = path.join(prdDir, body.file);
6069
- let fromArchive = false;
6070
- if (!statePathExists(prdPath)) {
6071
- prdPath = path.join(prdDir, 'archive', body.file);
6072
- fromArchive = true;
6073
- }
6074
- if (!statePathExists(prdPath)) return jsonReply(res, 404, { error: 'PRD not found' });
6075
-
6076
- // If archived, temporarily restore to active so checkPlanCompletion can find it
6077
- const activePath = path.join(prdDir, body.file);
6078
- if (fromArchive) {
6079
- const archivedPlan = safeJson(prdPath);
6080
- if (!archivedPlan) return jsonReply(res, 500, { error: 'Could not parse PRD file' });
6081
- mutateJsonFileLocked(activePath, () => {
6082
- archivedPlan.status = 'approved';
6083
- delete archivedPlan.completedAt;
6084
- delete archivedPlan.planStale;
6085
- return archivedPlan;
6086
- }, { defaultValue: archivedPlan });
6042
+ let projectPlan = prdStore.readPrd(body.file);
6043
+ if (!projectPlan && prdStore.readPrd(body.file, { archived: true })) {
6044
+ projectPlan = prdStore.restorePrdFromArchive(body.file);
6045
+ prdStore.mutatePrd(body.file, (data) => {
6046
+ data.status = 'approved';
6047
+ delete data.completedAt;
6048
+ delete data.planStale;
6049
+ return data;
6050
+ });
6051
+ projectPlan = prdStore.readPrd(body.file);
6087
6052
  }
6053
+ if (!projectPlan) return jsonReply(res, 404, { error: 'PRD not found' });
6088
6054
 
6089
6055
  const config = queries.getConfig();
6090
- // safeJsonNoRestore — never resurrect an archived PRD's .backup sidecar
6091
- // during project resolution (W-mouptdh1000h9f39). The active-from-archive
6092
- // write above (mutateJsonFileLocked) already created activePath when needed.
6093
- const projectPlan = safeJsonNoRestore(activePath) || safeJsonNoRestore(prdPath);
6094
6056
  const projectTarget = projectPlan?.project
6095
6057
  ? resolveProjectSourceTarget(projectPlan.project, PROJECTS, { allowCentral: false })
6096
6058
  : null;
@@ -6098,9 +6060,8 @@ const server = http.createServer(async (req, res) => {
6098
6060
 
6099
6061
  // Check for existing verify WI — reset to pending if already done (re-verify)
6100
6062
  if (project) {
6101
- const wiPath = shared.projectWorkItemsPath(project);
6102
6063
  let existingVerify = null;
6103
- mutateWorkItems(wiPath, items => {
6064
+ mutateWorkItems(project, items => {
6104
6065
  const v = items.find(w => w.sourcePlan === body.file && w.itemType === WORK_TYPE.VERIFY);
6105
6066
  if (v && (v.status === WI_STATUS.DONE || v.status === WI_STATUS.FAILED)) {
6106
6067
  // BUG-M10: use shared.reopenWorkItem so re-verify resets mirror
@@ -6119,17 +6080,16 @@ const server = http.createServer(async (req, res) => {
6119
6080
  }
6120
6081
 
6121
6082
  // No existing verify — clear completion flag and trigger fresh creation
6122
- mutateJsonFileLocked(activePath, (planData) => {
6083
+ prdStore.mutatePrd(body.file, (planData) => {
6123
6084
  if (planData?._completionNotified) planData._completionNotified = false;
6124
6085
  return planData;
6125
- }, { defaultValue: {}, skipWriteIfUnchanged: true });
6086
+ });
6126
6087
 
6127
6088
  const lifecycle = require('./engine/lifecycle');
6128
6089
  lifecycle.checkPlanCompletion({ item: { sourcePlan: body.file, id: 'manual' } }, config);
6129
6090
 
6130
6091
  if (project) {
6131
- const wiPath = shared.projectWorkItemsPath(project);
6132
- const items = safeJsonArr(wiPath);
6092
+ const items = shared.readWorkItems(project);
6133
6093
  const verify = items.find(w => w.sourcePlan === body.file && w.itemType === WORK_TYPE.VERIFY);
6134
6094
  if (verify) {
6135
6095
  invalidateStatusCache();
@@ -6147,36 +6107,24 @@ const server = http.createServer(async (req, res) => {
6147
6107
  const { id, source } = body;
6148
6108
  if (!id) return jsonReply(res, 400, { error: 'id required' });
6149
6109
 
6150
- // Find the right file — check source first, then search all project files
6110
+ // Resolve the owning SQL scope.
6151
6111
  let resolvedTarget = findWorkItemsTargetById(id, source, PROJECTS);
6152
6112
  if (resolvedTarget.error) return jsonReply(res, 404, { error: resolvedTarget.error });
6153
- let wiPath = resolvedTarget.found ? resolvedTarget.wiPath : null;
6113
+ let wiScope = resolvedTarget.found ? resolvedTarget.scope : null;
6154
6114
  // If no work item found, attempt to re-materialize from PRD item definition
6155
- if (!wiPath) {
6115
+ if (!wiScope) {
6156
6116
  const prdFile = body.prdFile;
6157
6117
  if (!prdFile) return jsonReply(res, 404, { error: 'work item not found in any source' });
6158
- // W-mrdykt9m0006e459 prdFile is unauthenticated client input; reject
6159
- // traversal/absolute paths and anything that resolves outside PRD_DIR
6160
- // before it ever reaches a filesystem read.
6161
- try { shared.sanitizePath(prdFile, PRD_DIR); }
6162
- catch { return jsonReply(res, 400, { error: 'invalid prdFile path' }); }
6163
-
6164
- // Look up PRD item to create a new work item on-demand.
6165
- // safeJsonNoRestore — PRDs are terminal artifacts; a stale .backup
6166
- // sidecar from an archived PRD must not resurrect the active PRD
6167
- // just because a user clicked retry on a stray work item
6168
- // (W-mouptdh1000h9f39).
6169
- const prdPath = path.join(PRD_DIR, prdFile);
6170
- const plan = shared.safeJsonNoRestore(prdPath);
6118
+ const plan = prdStore.readPrd(prdFile);
6171
6119
  if (!plan?.missing_features) return jsonReply(res, 404, { error: 'PRD file not found or invalid' });
6172
6120
  const prdItem = plan.missing_features.find(f => f.id === id);
6173
6121
  if (!prdItem) return jsonReply(res, 404, { error: 'PRD item not found in ' + prdFile });
6174
6122
 
6175
- // Determine target work-items file (project from PRD item or plan, fallback to central)
6123
+ // Determine target scope (project from PRD item or plan, fallback to central).
6176
6124
  const projName = prdItem.project || plan.project || prdFile.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
6177
6125
  const prdTarget = resolveProjectSourceTarget(projName, PROJECTS, { allowCentral: false });
6178
6126
  const proj = prdTarget.project;
6179
- const targetWiPath = proj ? shared.projectWorkItemsPath(proj) : shared.centralWorkItemsPath(MINIONS_DIR);
6127
+ const targetScope = proj || 'central';
6180
6128
 
6181
6129
  // Create new work item from PRD item definition (same logic as materializePlansAsWorkItems)
6182
6130
  const complexity = prdItem.estimated_complexity || 'medium';
@@ -6201,7 +6149,7 @@ const server = http.createServer(async (req, res) => {
6201
6149
  _source: proj?.name || 'central',
6202
6150
  _retryCount: 0,
6203
6151
  };
6204
- mutateWorkItems(targetWiPath, items => { items.push(newItem); });
6152
+ mutateWorkItems(targetScope, items => { items.push(newItem); });
6205
6153
 
6206
6154
  // Reset PRD item status to pending
6207
6155
  try {
@@ -6210,15 +6158,14 @@ const server = http.createServer(async (req, res) => {
6210
6158
  } catch (e) { console.error('PRD status sync:', e.message); }
6211
6159
 
6212
6160
  // Clear dispatch history and cooldowns for this item
6213
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
6214
6161
  const dispatchKey = (proj ? `work-${proj.name}-` : 'central-work-') + id;
6215
6162
  try {
6216
- mutateJsonFileLocked(dispatchPath, (dispatch) => {
6163
+ dispatchMod.mutateDispatch((dispatch) => {
6217
6164
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
6218
6165
  dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
6219
6166
  dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
6220
6167
  return dispatch;
6221
- }, { defaultValue: { pending: [], active: [], completed: [] } });
6168
+ });
6222
6169
  } catch (e) { console.error('dispatch cleanup:', e.message); }
6223
6170
  try {
6224
6171
  mutateCooldowns(cooldowns => {
@@ -6232,7 +6179,7 @@ const server = http.createServer(async (req, res) => {
6232
6179
 
6233
6180
  let found = false;
6234
6181
  let blocked = null;
6235
- mutateJsonFileLocked(wiPath, (items) => {
6182
+ mutateWorkItems(wiScope, (items) => {
6236
6183
  if (!Array.isArray(items)) items = [];
6237
6184
  const item = items.find(i => i.id === id);
6238
6185
  if (!item) return items;
@@ -6279,15 +6226,14 @@ const server = http.createServer(async (req, res) => {
6279
6226
  if (!found) return jsonReply(res, 404, { error: 'item not found' });
6280
6227
 
6281
6228
  // Clear completed dispatch entries so the engine doesn't dedup this item
6282
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
6283
6229
  const dispatchKey = dispatchPrefixForResolvedSource(resolvedTarget) + id;
6284
6230
  try {
6285
- mutateJsonFileLocked(dispatchPath, (dispatch) => {
6231
+ dispatchMod.mutateDispatch((dispatch) => {
6286
6232
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
6287
6233
  dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
6288
6234
  dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
6289
6235
  return dispatch;
6290
- }, { defaultValue: { pending: [], active: [], completed: [] } });
6236
+ });
6291
6237
  } catch (e) { console.error('dispatch cleanup:', e.message); }
6292
6238
 
6293
6239
  // Clear cooldown so item isn't blocked by exponential backoff
@@ -6310,11 +6256,10 @@ const server = http.createServer(async (req, res) => {
6310
6256
 
6311
6257
  const target = resolveProjectSourceTarget(source, PROJECTS);
6312
6258
  if (target.error) return jsonReply(res, 404, { error: target.error });
6313
- const wiPath = target.wiPath;
6314
6259
 
6315
6260
  let item = null;
6316
6261
  let found = false;
6317
- mutateJsonFileLocked(wiPath, (items) => {
6262
+ mutateWorkItems(target.scope, (items) => {
6318
6263
  if (!Array.isArray(items)) return items;
6319
6264
  const idx = items.findIndex(i => i.id === id);
6320
6265
  if (idx === -1) return items;
@@ -6322,21 +6267,16 @@ const server = http.createServer(async (req, res) => {
6322
6267
  items.splice(idx, 1);
6323
6268
  found = true;
6324
6269
  return items;
6325
- }, { defaultValue: [] });
6270
+ });
6326
6271
  if (!found) return jsonReply(res, 404, { error: 'item not found' });
6327
6272
 
6328
6273
  // Clean dispatch entries + kill running agent (outside lock)
6329
- const dispatchRemoved = cleanDispatchEntries(d =>
6330
- d.meta?.item?.id === id ||
6331
- d.meta?.dispatchKey?.endsWith(id)
6332
- );
6274
+ const dispatchRemoved = cleanDispatchEntries(d => matchesResolvedWorkItemDispatch(d, target, id));
6333
6275
 
6334
6276
  // Clean cooldown entries so item can be re-created immediately
6335
6277
  try {
6336
6278
  mutateCooldowns(cooldowns => {
6337
- for (const key of Object.keys(cooldowns)) {
6338
- if (key.includes(id)) delete cooldowns[key];
6339
- }
6279
+ delete cooldowns[dispatchPrefixForResolvedSource(target) + id];
6340
6280
  return cooldowns;
6341
6281
  });
6342
6282
  } catch (e) { console.error('cooldown cleanup:', e.message); }
@@ -6362,10 +6302,9 @@ const server = http.createServer(async (req, res) => {
6362
6302
 
6363
6303
  const target = resolveProjectSourceTarget(source, PROJECTS);
6364
6304
  if (target.error) return jsonReply(res, 404, { error: target.error });
6365
- const wiPath = target.wiPath;
6366
6305
 
6367
6306
  let result = null;
6368
- mutateJsonFileLocked(wiPath, (items) => {
6307
+ mutateWorkItems(target.scope, (items) => {
6369
6308
  if (!Array.isArray(items)) items = [];
6370
6309
  const item = items.find(i => i.id === id);
6371
6310
  if (!item) { result = { code: 404, body: { error: 'item not found' } }; return items; }
@@ -6384,17 +6323,12 @@ const server = http.createServer(async (req, res) => {
6384
6323
  if (result.code !== 200) return jsonReply(res, result.code, result.body);
6385
6324
 
6386
6325
  // Clean dispatch entries + kill running agent (outside lock)
6387
- const dispatchRemoved = cleanDispatchEntries(d =>
6388
- d.meta?.item?.id === id ||
6389
- d.meta?.dispatchKey?.endsWith(id)
6390
- );
6326
+ const dispatchRemoved = cleanDispatchEntries(d => matchesResolvedWorkItemDispatch(d, target, id));
6391
6327
 
6392
6328
  // Clean cooldown entries
6393
6329
  try {
6394
6330
  mutateCooldowns(cooldowns => {
6395
- for (const key of Object.keys(cooldowns)) {
6396
- if (key.includes(id)) delete cooldowns[key];
6397
- }
6331
+ delete cooldowns[dispatchPrefixForResolvedSource(target) + id];
6398
6332
  return cooldowns;
6399
6333
  });
6400
6334
  } catch (e) { console.error('cooldown cleanup on cancel:', e.message); }
@@ -6412,37 +6346,13 @@ const server = http.createServer(async (req, res) => {
6412
6346
 
6413
6347
  const target = resolveProjectSourceTarget(source, PROJECTS);
6414
6348
  if (target.error) return jsonReply(res, 404, { error: target.error });
6415
- const wiPath = target.wiPath;
6416
-
6417
- let archivedItem = null;
6418
- mutateJsonFileLocked(wiPath, (items) => {
6419
- if (!Array.isArray(items)) items = [];
6420
- const idx = items.findIndex(i => i.id === id);
6421
- if (idx === -1) return items;
6422
- archivedItem = items.splice(idx, 1)[0];
6423
- archivedItem.archivedAt = new Date().toISOString();
6424
- return items;
6425
- });
6349
+ const archivedItem = require('./engine/work-items-store').archiveWorkItem(target.scope, id);
6426
6350
  if (!archivedItem) return jsonReply(res, 404, { error: 'item not found' });
6427
6351
 
6428
- // Append to archive file (outside lock)
6429
- const archivePath = wiPath.replace('.json', '-archive.json');
6430
- mutateJsonFileLocked(archivePath, (archive) => {
6431
- if (!Array.isArray(archive)) archive = [];
6432
- archive.push(archivedItem);
6433
- return archive;
6434
- }, { defaultValue: [] });
6435
-
6436
6352
  // Clean dispatch entries for archived item
6437
- const sourcePrefix = dispatchPrefixForResolvedSource(target);
6438
- cleanDispatchEntries(d =>
6439
- d.meta?.dispatchKey === sourcePrefix + id ||
6440
- d.meta?.item?.id === id
6441
- );
6353
+ cleanDispatchEntries(d => matchesResolvedWorkItemDispatch(d, target, id));
6442
6354
 
6443
- // (W-mpfsl2rw000m9469) Archiving removes a row from work-items.json, which
6444
- // is part of /api/status fast-state. Match every other mutating handler
6445
- // and invalidate so the dashboard reflects the archive immediately.
6355
+ // Archiving removes the item from the active status snapshot.
6446
6356
  invalidateStatusCache();
6447
6357
  return jsonReply(res, 200, { ok: true, id });
6448
6358
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
@@ -6453,7 +6363,7 @@ const server = http.createServer(async (req, res) => {
6453
6363
  // collectArchivedWorkItems uses safeJsonArr (typed default + logged parse
6454
6364
  // failure), so a corrupt archive file is surfaced via console.error and
6455
6365
  // contributes zero items instead of taking down the whole listing.
6456
- return jsonReply(res, 200, collectArchivedWorkItems(MINIONS_DIR, PROJECTS));
6366
+ return jsonReply(res, 200, collectArchivedWorkItems(PROJECTS));
6457
6367
  } catch (e) { console.error('Archive fetch error:', e.message); return jsonReply(res, e.statusCode || 500, { error: e.message }); }
6458
6368
  }
6459
6369
 
@@ -6474,10 +6384,15 @@ const server = http.createServer(async (req, res) => {
6474
6384
  id === 'cancel' || id === 'reopen' || id === 'feedback') {
6475
6385
  return jsonReply(res, 404, { error: 'work item not found' });
6476
6386
  }
6477
- // Search central + every project's work-items.json for this id. The
6478
- // engine never enforces global id uniqueness in the file system, but
6479
- // collisions in practice are nil — first hit wins.
6480
- const found = getWorkItems().find(w => w && w.id === id);
6387
+ const source = (new URL(req.url, 'http://localhost').searchParams.get('source') || '').trim();
6388
+ const matches = getWorkItems().filter(w => w && w.id === id && (!source || w._source === source));
6389
+ if (!source && matches.length > 1) {
6390
+ return jsonReply(res, 409, {
6391
+ error: 'work item id is ambiguous; provide source',
6392
+ sources: matches.map(w => w._source),
6393
+ });
6394
+ }
6395
+ const found = matches[0];
6481
6396
  if (!found) return jsonReply(res, 404, { error: 'work item not found' });
6482
6397
  return jsonReply(res, 200, { item: found });
6483
6398
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
@@ -6526,10 +6441,9 @@ const server = http.createServer(async (req, res) => {
6526
6441
 
6527
6442
  const target = resolveProjectSourceTarget(project, PROJECTS);
6528
6443
  if (target.error) return jsonReply(res, 404, { error: target.error });
6529
- const wiPath = target.wiPath;
6530
6444
 
6531
6445
  let result = null;
6532
- mutateJsonFileLocked(wiPath, (items) => {
6446
+ mutateWorkItems(target.scope, (items) => {
6533
6447
  if (!Array.isArray(items)) items = [];
6534
6448
  const item = items.find(i => i.id === id);
6535
6449
  if (!item) { result = { code: 404, body: { error: 'item not found' } }; return items; }
@@ -6548,13 +6462,12 @@ const server = http.createServer(async (req, res) => {
6548
6462
  // Clear dispatch history and cooldowns outside lock
6549
6463
  const dispatchKey = dispatchPrefixForResolvedSource(target) + id;
6550
6464
  try {
6551
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
6552
- mutateJsonFileLocked(dispatchPath, (dispatch) => {
6465
+ dispatchMod.mutateDispatch((dispatch) => {
6553
6466
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
6554
6467
  dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
6555
6468
  dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
6556
6469
  return dispatch;
6557
- }, { defaultValue: { pending: [], active: [], completed: [] } });
6470
+ });
6558
6471
  } catch (e) { console.error('dispatch cleanup on reopen:', e.message); }
6559
6472
  try {
6560
6473
  mutateCooldowns(cooldowns => {
@@ -6624,7 +6537,7 @@ const server = http.createServer(async (req, res) => {
6624
6537
  }
6625
6538
  const target = resolveWorkItemsCreateTarget(body.project);
6626
6539
  if (target.error) return jsonReply(res, 400, { error: target.error });
6627
- const wiPath = target.wiPath;
6540
+ const wiScope = target.scope;
6628
6541
  const targetProject = target.project;
6629
6542
  const id = 'W-' + shared.uid();
6630
6543
  const item = {
@@ -6701,7 +6614,7 @@ const server = http.createServer(async (req, res) => {
6701
6614
  if (originAgent) item._originAgent = originAgent;
6702
6615
  if (originWi) item._originWi = originWi;
6703
6616
  await copyWorkItemPrFields(item, body, null, { project: targetProject });
6704
- // W-mq5wfh1v000e0da9 — Auto-enroll the PR into pull-requests.json when
6617
+ // W-mq5wfh1v000e0da9 — Auto-enroll the PR into tracked state when
6705
6618
  // this is a `type: fix/review/test` WI carrying a structured PR pointer and
6706
6619
  // the PR isn't tracked yet. Without this, the engine's `pr_not_found` gate
6707
6620
  // skips the WI forever (engine.js dispatch loop). Idempotent — safe to
@@ -6732,7 +6645,7 @@ const server = http.createServer(async (req, res) => {
6732
6645
  // statuses) and takes precedence when matched.
6733
6646
  if (validatedFollowup) {
6734
6647
  let followupConflict = null;
6735
- mutateWorkItems(wiPath, items => {
6648
+ mutateWorkItems(wiScope, items => {
6736
6649
  followupConflict = findExistingFollowupForComment(items, validatedFollowup.parent_comment_id);
6737
6650
  if (followupConflict) return items;
6738
6651
  items.push(item);
@@ -6751,7 +6664,7 @@ const server = http.createServer(async (req, res) => {
6751
6664
  invalidateStatusCache();
6752
6665
  return jsonReply(res, 200, { ok: true, id });
6753
6666
  }
6754
- const createResult = createWorkItemWithDedup(wiPath, item);
6667
+ const createResult = createWorkItemWithDedup(wiScope, item, { project: targetProject });
6755
6668
  if (!createResult.created) {
6756
6669
  const duplicateId = createResult.duplicateOf || createResult.item?.id;
6757
6670
  // W-mr466ocx000l1191 — record the CC turn creation on the dedup path
@@ -6803,10 +6716,9 @@ const server = http.createServer(async (req, res) => {
6803
6716
 
6804
6717
  const target = resolveProjectSourceTarget(source, PROJECTS);
6805
6718
  if (target.error) return jsonReply(res, 404, { error: target.error });
6806
- const wiPath = target.wiPath;
6719
+ const wiScope = target.scope;
6807
6720
 
6808
- // W-mrazefzz000358e3 — body.project reassigns which projects/<name>/
6809
- // work-items.json (or central) file owns this item. Resolve + validate
6721
+ // W-mrazefzz000358e3 — body.project reassigns the owning SQL scope.
6810
6722
  // it up front (mirrors resolveWorkItemsCreateTarget's create-path
6811
6723
  // contract) so an unknown project name 400s cleanly instead of the
6812
6724
  // historical silent no-op. `projectMove` stays null when body.project
@@ -6820,19 +6732,14 @@ const server = http.createServer(async (req, res) => {
6820
6732
  knownProjects: PROJECTS.map(p => p.name),
6821
6733
  });
6822
6734
  }
6823
- projectMove = { wiPath: projTarget.wiPath, projectName: projTarget.project ? projTarget.project.name : null };
6735
+ projectMove = { scope: projTarget.scope, projectName: projTarget.project ? projTarget.project.name : null };
6824
6736
  }
6825
6737
 
6826
6738
  let result = null;
6827
6739
  let agentChanged = false;
6828
- // Set inside the source-file lock below when a project move is
6829
- // needed. W-mrbbmltq000fce9f: the move itself (target push + source
6830
- // removal) is executed AFTER this lock releases, target-write-first,
6831
- // so a failure never straddles "spliced from source, not yet in
6832
- // target" — see the two-phase block below.
6833
6740
  let movedItem = null;
6834
6741
  let needsMove = false;
6835
- mutateJsonFileLocked(wiPath, (items) => {
6742
+ mutateWorkItems(wiScope, (items) => {
6836
6743
  if (!Array.isArray(items)) items = [];
6837
6744
  const idx = items.findIndex(i => i.id === id);
6838
6745
  if (idx === -1) { result = { code: 404, body: { error: 'item not found' } }; return items; }
@@ -6869,23 +6776,7 @@ const server = http.createServer(async (req, res) => {
6869
6776
  }
6870
6777
  item.updatedAt = new Date().toISOString();
6871
6778
 
6872
- // W-mrazefzz000358e3 a cross-project move actually MOVEs the item
6873
- // between work-items.json files when the resolved target differs
6874
- // from where it lives today, rather than just stamping item.project
6875
- // in place. Only trip this when the resolved wiPath actually
6876
- // changes — re-assigning the same project the item already lives in
6877
- // is a no-op move.
6878
- //
6879
- // W-mrbbmltq000fce9f — do NOT splice the item out of the source
6880
- // array here. Doing the splice-then-push as two independent locked
6881
- // calls (the historical bug) means a target-write failure after
6882
- // this callback commits leaves the item spliced from source and
6883
- // never written to target: silently lost from both stores. Instead
6884
- // only STAGE the move — clone the (already field-edited) item with
6885
- // its new `project` value — and leave the source array untouched.
6886
- // The actual splice happens in a later, separate lock, only once
6887
- // the target push below has durably committed.
6888
- if (projectMove && path.resolve(projectMove.wiPath) !== path.resolve(wiPath)) {
6779
+ if (projectMove && projectMove.scope !== wiScope) {
6889
6780
  needsMove = true;
6890
6781
  movedItem = { ...item };
6891
6782
  if (projectMove.projectName) movedItem.project = projectMove.projectName;
@@ -6899,79 +6790,14 @@ const server = http.createServer(async (req, res) => {
6899
6790
 
6900
6791
  if (needsMove && result.code === 200) {
6901
6792
  try {
6902
- // Phase 1 of the move: durably write the item into the TARGET
6903
- // store first. If this throws (lock timeout, storage error,
6904
- // process crash), the source store still holds the item
6905
- // untouched (aside from the field edits already committed above)
6906
- // — nothing to roll back, nothing lost.
6907
- mutateJsonFileLocked(projectMove.wiPath, (targetItems) => {
6908
- if (!Array.isArray(targetItems)) targetItems = [];
6909
- targetItems.push(movedItem);
6910
- return targetItems;
6911
- });
6912
- } catch (targetErr) {
6913
- return jsonReply(res, 500, {
6914
- error: `project move failed while writing to target store; item left unchanged in its original project: ${targetErr.message}`,
6915
- });
6916
- }
6917
-
6918
- try {
6919
- // Phase 2: the target write is confirmed durable — now it's safe
6920
- // to remove the item from the source store.
6921
- mutateJsonFileLocked(wiPath, (items) => {
6922
- if (!Array.isArray(items)) items = [];
6923
- const spliceIdx = items.findIndex(i => i.id === id);
6924
- if (spliceIdx !== -1) items.splice(spliceIdx, 1);
6925
- return items;
6926
- });
6927
- } catch (spliceErr) {
6928
- // Removing from source failed AFTER the target write already
6929
- // committed. Compensate by removing the item from the target
6930
- // store again, so the item ends up in exactly one store (its
6931
- // original) instead of duplicated across two.
6932
- //
6933
- // W-mrciooy1000e2ac3 — the compensating rollback write can ITSELF
6934
- // fail (double-failure: destination insert succeeded, source
6935
- // splice failed, and the compensating removal from destination
6936
- // also failed). Swallowing that second error and unconditionally
6937
- // reporting "move was rolled back" is a lie: the item is still
6938
- // duplicated in both stores. Track the rollback outcome and
6939
- // surface the double-failure distinctly so the caller/UI never
6940
- // believes a rollback happened when it didn't.
6941
- let rollbackErr = null;
6942
- try {
6943
- mutateJsonFileLocked(projectMove.wiPath, (targetItems) => {
6944
- if (!Array.isArray(targetItems)) targetItems = [];
6945
- const ti = targetItems.findIndex(i => i.id === id);
6946
- if (ti !== -1) targetItems.splice(ti, 1);
6947
- return targetItems;
6948
- });
6949
- } catch (rbErr) { rollbackErr = rbErr; }
6950
-
6951
- if (rollbackErr) {
6952
- console.error(
6953
- `[work-items/update] DOUBLE FAILURE on cross-project move of ${id}: ` +
6954
- `source-splice failed (${spliceErr.message}) AND compensating rollback ` +
6955
- `from target store also failed (${rollbackErr.message}). Item may now be ` +
6956
- `duplicated across source (${wiPath}) and target (${projectMove.wiPath}) ` +
6957
- `— manual reconciliation required.`
6958
- );
6959
- return jsonReply(res, 500, {
6960
- error: `project move failed while removing from source store, and the compensating rollback also failed: ${rollbackErr.message}. ` +
6961
- `Item may be duplicated across both stores — manual reconciliation required.`,
6962
- reconciliation: {
6963
- required: true,
6964
- id,
6965
- sourceStore: wiPath,
6966
- targetStore: projectMove.wiPath,
6967
- spliceError: spliceErr.message,
6968
- rollbackError: rollbackErr.message,
6969
- },
6970
- });
6793
+ const moved = require('./engine/work-items-store')
6794
+ .moveWorkItem(wiScope, projectMove.scope, id, movedItem);
6795
+ if (!moved) {
6796
+ return jsonReply(res, 404, { error: 'item disappeared before project move' });
6971
6797
  }
6972
-
6798
+ } catch (moveErr) {
6973
6799
  return jsonReply(res, 500, {
6974
- error: `project move failed while removing from source store; move was rolled back: ${spliceErr.message}`,
6800
+ error: `project move failed; item remains in its original scope: ${moveErr.message}`,
6975
6801
  });
6976
6802
  }
6977
6803
  }
@@ -7015,8 +6841,7 @@ const server = http.createServer(async (req, res) => {
7015
6841
  const planWorkItem = buildPlanWorkItem(body);
7016
6842
  if (planWorkItem.error) return jsonReply(res, 400, { error: planWorkItem.error });
7017
6843
  // Write as a work item with type 'plan' — user must explicitly execute plan-to-prd after reviewing
7018
- const wiPath = path.join(MINIONS_DIR, 'work-items.json');
7019
- mutateWorkItems(wiPath, items => { items.push(planWorkItem.item); });
6844
+ mutateWorkItems('central', items => { items.push(planWorkItem.item); });
7020
6845
  recordCcTurnIfPresent(req, {
7021
6846
  kind: 'plan', id: planWorkItem.id, title: planWorkItem.item?.title || body.title.trim(), project: planWorkItem.item?.project || null,
7022
6847
  });
@@ -7032,8 +6857,7 @@ const server = http.createServer(async (req, res) => {
7032
6857
  if (manualPrd.error) return jsonReply(res, 400, { error: manualPrd.error });
7033
6858
 
7034
6859
  const planFile = 'manual-' + shared.uid() + '.json';
7035
- const manualPrdPath = path.join(PRD_DIR, planFile);
7036
- safeWrite(manualPrdPath, manualPrd.plan);
6860
+ prdStore.writePrd(planFile, manualPrd.plan);
7037
6861
  invalidatePlansCache();
7038
6862
  return jsonReply(res, 200, { ok: true, id: manualPrd.id, file: planFile });
7039
6863
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
@@ -7043,19 +6867,14 @@ const server = http.createServer(async (req, res) => {
7043
6867
  try {
7044
6868
  const body = await readBody(req);
7045
6869
  if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
7046
- // Reject markdown filenames — mutateJsonFileLocked below silently overwrites
7047
- // the file with JSON, destroying source plans (see /api/plans/approve trap).
7048
6870
  if (!body.source.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename in `source` (got `' + body.source + '`). Pass prd/<plan>.json, not plans/<plan>.md.' });
7049
- const planPath = resolvePlanPath(body.source);
7050
- if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
7051
- // Pre-check: verify item exists before taking the lock
7052
- const preCheck = safeJsonObj(planPath);
6871
+ const preCheck = prdStore.readPrd(body.source);
6872
+ if (!preCheck) return jsonReply(res, 404, { error: 'plan not found' });
7053
6873
  const preItem = (preCheck.missing_features || []).find(f => f.id === body.itemId);
7054
6874
  if (!preItem) return jsonReply(res, 404, { error: 'item not found in plan' });
7055
6875
 
7056
- // Atomically read-modify-write under file lock
7057
6876
  let item;
7058
- mutateJsonFileLocked(planPath, (plan) => {
6877
+ prdStore.mutatePrd(body.source, (plan) => {
7059
6878
  const target = (plan.missing_features || []).find(f => f.id === body.itemId);
7060
6879
  if (!target) return plan; // TOCTOU: item deleted between pre-check and lock acquisition
7061
6880
  if (body.name !== undefined) target.name = body.name;
@@ -7072,13 +6891,10 @@ const server = http.createServer(async (req, res) => {
7072
6891
 
7073
6892
  // Feature 3: Sync edits to materialized work item if still pending
7074
6893
  let workItemSynced = false;
7075
- const wiSyncPaths = [path.join(MINIONS_DIR, 'work-items.json')];
7076
- for (const proj of PROJECTS) {
7077
- wiSyncPaths.push(shared.projectWorkItemsPath(proj));
7078
- }
7079
- for (const wiPath of wiSyncPaths) {
6894
+ const workItemScopes = ['central', ...PROJECTS];
6895
+ for (const scope of workItemScopes) {
7080
6896
  try {
7081
- mutateWorkItems(wiPath, items => {
6897
+ mutateWorkItems(scope, items => {
7082
6898
  const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
7083
6899
  if (wi && wi.status === WI_STATUS.PENDING) {
7084
6900
  if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
@@ -7103,10 +6919,9 @@ const server = http.createServer(async (req, res) => {
7103
6919
  const body = await readBody(req);
7104
6920
  if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
7105
6921
  if (!body.source.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename in `source` (got `' + body.source + '`). Pass prd/<plan>.json, not plans/<plan>.md.' });
7106
- const planPath = resolvePlanPath(body.source);
7107
- if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
6922
+ if (!prdStore.readPrd(body.source)) return jsonReply(res, 404, { error: 'plan not found' });
7108
6923
  let removed = false;
7109
- mutateJsonFileLocked(planPath, (plan) => {
6924
+ prdStore.mutatePrd(body.source, (plan) => {
7110
6925
  if (!plan || Array.isArray(plan) || typeof plan !== 'object') plan = { missing_features: [] };
7111
6926
  const features = Array.isArray(plan.missing_features) ? plan.missing_features : [];
7112
6927
  const idx = features.findIndex(f => f.id === body.itemId);
@@ -7120,13 +6935,10 @@ const server = http.createServer(async (req, res) => {
7120
6935
 
7121
6936
  // Also remove any materialized work item for this plan item
7122
6937
  let cancelled = false;
7123
- const allWiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
7124
- for (const proj of PROJECTS) {
7125
- allWiPaths.push(shared.projectWorkItemsPath(proj));
7126
- }
7127
- for (const wiPath of allWiPaths) {
6938
+ const workItemScopes = ['central', ...PROJECTS];
6939
+ for (const scope of workItemScopes) {
7128
6940
  try {
7129
- mutateWorkItems(wiPath, items => {
6941
+ mutateWorkItems(scope, items => {
7130
6942
  const filtered = items.filter(w => !(w.sourcePlan === body.source && w.id === body.itemId));
7131
6943
  if (filtered.length < items.length) {
7132
6944
  cancelled = true;
@@ -7170,22 +6982,20 @@ const server = http.createServer(async (req, res) => {
7170
6982
  try { fs.unlinkSync(path.join(agentDir, 'steer.md')); } catch { /* optional */ }
7171
6983
 
7172
6984
  // 3. Remove all active dispatch entries for this agent
7173
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
7174
6985
  const removedIds = [];
7175
- mutateJsonFileLocked(dispatchPath, (dp) => {
6986
+ dispatchMod.mutateDispatch((dp) => {
7176
6987
  const removed = (dp.active || []).filter(d => d.agent === agentId);
7177
6988
  removed.forEach(d => removedIds.push(d.id));
7178
6989
  dp.active = (dp.active || []).filter(d => d.agent !== agentId);
7179
6990
  return dp;
7180
- }, { defaultValue: { pending: [], active: [], completed: [] } });
6991
+ });
7181
6992
 
7182
6993
  // 4. Reset work items from dispatched → pending so they can be retried
7183
- const allWiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
7184
- for (const proj of PROJECTS) allWiPaths.push(shared.projectWorkItemsPath(proj));
6994
+ const workItemScopes = ['central', ...PROJECTS];
7185
6995
  let resetCount = 0;
7186
- for (const wiPath of allWiPaths) {
6996
+ for (const scope of workItemScopes) {
7187
6997
  try {
7188
- mutateWorkItems(wiPath, items => {
6998
+ mutateWorkItems(scope, items => {
7189
6999
  for (const item of items) {
7190
7000
  if (item.dispatched_to === agentId && item.status === WI_STATUS.DISPATCHED) {
7191
7001
  item.status = WI_STATUS.PENDING;
@@ -7214,8 +7024,7 @@ const server = http.createServer(async (req, res) => {
7214
7024
  if (!requestedAgent && !requestedTask) return jsonReply(res, 400, { error: 'agent or task required' });
7215
7025
 
7216
7026
  // Pre-read for response snapshot. Lockless — cleanDispatchEntries owns the actual mutation.
7217
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
7218
- const dispatch = safeJsonObj(dispatchPath);
7027
+ const dispatch = queries.getDispatch();
7219
7028
  const active = Array.isArray(dispatch.active) ? dispatch.active : [];
7220
7029
  const matchFn = (d) => {
7221
7030
  const matchAgent = requestedAgent && d.agent === requestedAgent;
@@ -7227,7 +7036,7 @@ const server = http.createServer(async (req, res) => {
7227
7036
  // Route PID resolution + kill + dispatch removal through the canonical primitive.
7228
7037
  // cleanDispatchEntries resolves the PID from engine/tmp/dispatch-<id>-*/pid-<id>.pid
7229
7038
  // (see shared.findDispatchPidFile), kills outside the dispatch lock, and removes
7230
- // the entry via mutateDispatch (SQL + JSON mirror in one atomic write).
7039
+ // the entry through the transactional dispatch store.
7231
7040
  // The defunct per-agent status sidecar reads/writes that used to live here are gone
7232
7041
  // (engine/queries.js documents that file no longer exists).
7233
7042
  if (cancelled.length > 0) {
@@ -7751,7 +7560,7 @@ const server = http.createServer(async (req, res) => {
7751
7560
  // path.join. Without this, cat='..' collapses kbCatDir to MINIONS_DIR
7752
7561
  // (path.join normalizes '..') and the next sanitizePath('config.json',
7753
7562
  // MINIONS_DIR) check still passes, allowing safeRead to disclose
7754
- // config.json / work-items.json / engine state to any browser the
7563
+ // config.json / runtime state to any browser the
7755
7564
  // operator has open. The whitelist also rejects encoded variants
7756
7565
  // (`%2e%2e`, `..%2f`, etc.) since none of those are valid category
7757
7566
  // names. Single source of truth lives in shared.KB_READABLE_CATEGORIES.
@@ -7991,12 +7800,17 @@ const server = http.createServer(async (req, res) => {
7991
7800
 
7992
7801
  async function handlePlansArchiveRead(req, res, match) {
7993
7802
  const file = decodeURIComponent(match[1]);
7994
- if (file.includes('..') || file.includes('\0')) return jsonReply(res, 400, { error: 'invalid' });
7995
- // Check prd/archive/ first for .json, then plans/archive/ for .md
7996
- const archiveDir = file.endsWith('.json') ? path.join(PRD_DIR, 'archive') : path.join(PLANS_DIR, 'archive');
7997
- let content = safeRead(path.join(archiveDir, file));
7998
- // Fallback: check the other archive dir
7999
- if (!content) content = safeRead(path.join(file.endsWith('.json') ? path.join(PLANS_DIR, 'archive') : path.join(PRD_DIR, 'archive'), file));
7803
+ if (file.includes('..') || file.includes('\0') || file.includes('/') || file.includes('\\')) {
7804
+ return jsonReply(res, 400, { error: 'invalid' });
7805
+ }
7806
+ let content = '';
7807
+ if (file.endsWith('.json')) {
7808
+ const row = prdStore.listPrdRows()
7809
+ .find(candidate => candidate.filename === file && (candidate.archived || shared.isPrdArchived(candidate.plan)));
7810
+ if (row) content = JSON.stringify(row.plan, null, 2);
7811
+ } else {
7812
+ content = safeRead(path.join(PLANS_DIR, 'archive', file));
7813
+ }
8000
7814
  if (!content) return jsonReply(res, 404, { error: 'not found' });
8001
7815
  const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
8002
7816
  res.setHeader('Content-Type', contentType + '; charset=utf-8');
@@ -8007,17 +7821,30 @@ const server = http.createServer(async (req, res) => {
8007
7821
  async function handlePlansRead(req, res, match) {
8008
7822
  const file = decodeURIComponent(match[1]);
8009
7823
  if (file.includes('..') || file.includes('\0') || file.includes('/') || file.includes('\\')) return jsonReply(res, 400, { error: 'invalid' });
8010
- let content = safeRead(resolvePlanPath(file));
8011
- // Fallback: check all directories (prd/, plans/, guides/, archives)
8012
- if (!content) content = safeRead(path.join(PRD_DIR, file));
8013
- if (!content) content = safeRead(path.join(PRD_DIR, 'guides', file));
8014
- if (!content) content = safeRead(path.join(PLANS_DIR, file));
8015
- if (!content) content = safeRead(path.join(PRD_DIR, 'archive', file));
8016
- if (!content) content = safeRead(path.join(PLANS_DIR, 'archive', file));
7824
+ let content = '';
7825
+ let resolvedPath = null;
7826
+ if (file.endsWith('.json')) {
7827
+ const row = prdStore.listPrdRows().find(candidate => candidate.filename === file);
7828
+ if (row) {
7829
+ content = JSON.stringify(row.plan, null, 2);
7830
+ res.setHeader('Last-Modified', new Date(row.updatedAt).toISOString());
7831
+ res.setHeader('X-Resolved-Path', `sql:prd/${row.archived ? 'archive/' : ''}${file}`);
7832
+ }
7833
+ } else {
7834
+ const candidates = [
7835
+ resolvePlanPath(file),
7836
+ path.join(PRD_DIR, 'guides', file),
7837
+ path.join(PLANS_DIR, 'archive', file),
7838
+ ];
7839
+ resolvedPath = candidates.find(candidate => fs.existsSync(candidate)) || null;
7840
+ if (resolvedPath) content = safeRead(resolvedPath);
7841
+ }
8017
7842
  if (!content) return jsonReply(res, 404, { error: 'not found' });
8018
- // Find the actual file path for Last-Modified header + expose resolved relative path
8019
- const planCandidates = [resolvePlanPath(file), path.join(PRD_DIR, file), path.join(PRD_DIR, 'guides', file), path.join(PLANS_DIR, file), path.join(PRD_DIR, 'archive', file), path.join(PLANS_DIR, 'archive', file)];
8020
- for (const p of planCandidates) { try { const st = fs.statSync(p); if (st) { res.setHeader('Last-Modified', st.mtime.toISOString()); res.setHeader('X-Resolved-Path', path.relative(MINIONS_DIR, p).replace(/\\/g, '/')); break; } } catch { /* optional */ } }
7843
+ if (resolvedPath) {
7844
+ const stat = fs.statSync(resolvedPath);
7845
+ res.setHeader('Last-Modified', stat.mtime.toISOString());
7846
+ res.setHeader('X-Resolved-Path', path.relative(MINIONS_DIR, resolvedPath).replace(/\\/g, '/'));
7847
+ }
8021
7848
  const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
8022
7849
  res.setHeader('Content-Type', contentType + '; charset=utf-8');
8023
7850
  res.setHeader('Cache-Control', 'no-cache');
@@ -8030,9 +7857,8 @@ const server = http.createServer(async (req, res) => {
8030
7857
  const body = await readBody(req);
8031
7858
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
8032
7859
  if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename (got `' + body.file + '`). To approve a plan, pass prd/<plan>.json, not the source plans/<plan>.md.' });
8033
- const planPath = resolvePlanPath(body.file);
8034
7860
  let wasStale = false;
8035
- const plan = mutateJsonFileLocked(planPath, (data) => {
7861
+ const plan = prdStore.mutatePrd(body.file, (data) => {
8036
7862
  if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
8037
7863
  wasStale = !!data.planStale;
8038
7864
  data.status = 'approved';
@@ -8042,7 +7868,7 @@ const server = http.createServer(async (req, res) => {
8042
7868
  delete data.planStale;
8043
7869
  delete data._completionNotified;
8044
7870
  return data;
8045
- }, { defaultValue: {} });
7871
+ });
8046
7872
 
8047
7873
  // W-mqacrzis0003df4a + W-mqfevwr60018bd09 — Fresh source-plan
8048
7874
  // staleness check (content-hash gated). Approve is the last gate
@@ -8064,13 +7890,10 @@ const server = http.createServer(async (req, res) => {
8064
7890
  // Resume paused work items across all projects
8065
7891
  let resumed = 0;
8066
7892
  const resumedItemIds = [];
8067
- const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
8068
- for (const proj of PROJECTS) {
8069
- wiPaths.push(shared.projectWorkItemsPath(proj));
8070
- }
8071
- for (const wiPath of wiPaths) {
7893
+ const workItemScopes = ['central', ...PROJECTS];
7894
+ for (const scope of workItemScopes) {
8072
7895
  try {
8073
- mutateJsonFileLocked(wiPath, (items) => {
7896
+ mutateWorkItems(scope, (items) => {
8074
7897
  if (!Array.isArray(items)) return items;
8075
7898
  for (const w of items) {
8076
7899
  if (w.sourcePlan === body.file && w.status === WI_STATUS.PAUSED && w._pausedBy === 'prd-pause') {
@@ -8082,19 +7905,18 @@ const server = http.createServer(async (req, res) => {
8082
7905
  }
8083
7906
  }
8084
7907
  return items;
8085
- }, { defaultValue: [] });
7908
+ });
8086
7909
  } catch (e) { console.error('resume work items:', e.message); }
8087
7910
  }
8088
7911
 
8089
7912
  // Clear dispatch completed entries for resumed items so they aren't dedup-blocked
8090
7913
  if (resumedItemIds.length > 0) {
8091
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
8092
7914
  const resumedSet = new Set(resumedItemIds);
8093
- mutateJsonFileLocked(dispatchPath, (dispatch) => {
7915
+ dispatchMod.mutateDispatch((dispatch) => {
8094
7916
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
8095
7917
  dispatch.completed = dispatch.completed.filter(d => !resumedSet.has(d.meta?.item?.id));
8096
7918
  return dispatch;
8097
- }, { defaultValue: { pending: [], active: [], completed: [] } });
7919
+ });
8098
7920
  }
8099
7921
 
8100
7922
  // Diff-aware PRD update: if plan was stale (source .md revised), dispatch plan-to-prd
@@ -8104,7 +7926,7 @@ const server = http.createServer(async (req, res) => {
8104
7926
  const config = queries.getConfig();
8105
7927
  const allWorkItems = queries.getWorkItems(config);
8106
7928
  const planWis = allWorkItems.filter(w => w.sourcePlan === body.file && w.itemType !== 'pr' && w.itemType !== 'verify');
8107
- const allPrs = PROJECTS.flatMap(p => shared.safeJsonArr(shared.projectPrPath(p)));
7929
+ const allPrs = PROJECTS.flatMap(p => shared.readPullRequests(p));
8108
7930
  const prLinks = shared.getPrLinks();
8109
7931
  const implContext = (plan.missing_features || []).map(f => {
8110
7932
  const wi = planWis.find(w => w.id === f.id);
@@ -8146,19 +7968,18 @@ const server = http.createServer(async (req, res) => {
8146
7968
  // cleanup; pause never stopped regeneration.)
8147
7969
  function stopPlanMaterializedWork(prdFile, prdSourcePlan, opts) {
8148
7970
  const targetStatus = opts.targetStatus;
8149
- const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
8150
- for (const proj of PROJECTS) wiPaths.push(shared.projectWorkItemsPath(proj));
7971
+ const workItemScopes = ['central', ...PROJECTS];
8151
7972
 
8152
7973
  // Step 1: find dispatched item ids (read-only, no lock).
8153
7974
  const dispatchedItemIds = new Set();
8154
- for (const wiPath of wiPaths) {
7975
+ for (const scope of workItemScopes) {
8155
7976
  try {
8156
- for (const w of safeJsonArr(wiPath)) {
7977
+ for (const w of shared.readWorkItems(scope)) {
8157
7978
  if (w.sourcePlan !== prdFile) continue;
8158
7979
  if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
8159
7980
  if (w.status === WI_STATUS.DISPATCHED && w.id) dispatchedItemIds.add(w.id);
8160
7981
  }
8161
- } catch { /* file may not exist */ }
7982
+ } catch { /* scope may be unavailable */ }
8162
7983
  }
8163
7984
 
8164
7985
  // Step 2: kill active dispatches via the canonical primitive (resolves PIDs from
@@ -8172,11 +7993,11 @@ const server = http.createServer(async (req, res) => {
8172
7993
  });
8173
7994
  }
8174
7995
 
8175
- // Step 3: transition WIs per path (each lock held briefly, no nesting).
7996
+ // Step 3: transition WIs per scope.
8176
7997
  let affected = 0;
8177
- for (const wiPath of wiPaths) {
7998
+ for (const scope of workItemScopes) {
8178
7999
  try {
8179
- mutateWorkItems(wiPath, items => {
8000
+ mutateWorkItems(scope, items => {
8180
8001
  for (const w of items) {
8181
8002
  if (w.sourcePlan !== prdFile) continue;
8182
8003
  if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
@@ -8198,8 +8019,7 @@ const server = http.createServer(async (req, res) => {
8198
8019
  // stopped. (delete handles the DONE plan-to-prd WI separately to revert to draft.)
8199
8020
  if (prdSourcePlan) {
8200
8021
  try {
8201
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
8202
- mutateWorkItems(centralPath, items => {
8022
+ mutateWorkItems('central', items => {
8203
8023
  for (const w of items) {
8204
8024
  if (w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === prdSourcePlan &&
8205
8025
  !DONE_STATUSES.has(w.status) && w.status !== WI_STATUS.CANCELLED) {
@@ -8218,14 +8038,13 @@ const server = http.createServer(async (req, res) => {
8218
8038
  const body = await readBody(req);
8219
8039
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
8220
8040
  if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename (got `' + body.file + '`). Pass prd/<plan>.json, not the source plans/<plan>.md.' });
8221
- const planPath = resolvePlanPath(body.file);
8222
8041
  let prdSourcePlan = null;
8223
- const updated = mutateJsonFileLocked(planPath, (plan) => {
8042
+ const updated = prdStore.mutatePrd(body.file, (plan) => {
8224
8043
  if (!plan || Array.isArray(plan) || typeof plan !== 'object') plan = {};
8225
8044
  plan.status = 'paused';
8226
8045
  plan.pausedAt = new Date().toISOString();
8227
8046
  return plan;
8228
- }, { defaultValue: {} });
8047
+ });
8229
8048
  prdSourcePlan = updated?.source_plan || null;
8230
8049
 
8231
8050
  // Propagate pause to materialized work items across all projects + cancel the
@@ -8279,15 +8098,14 @@ const server = http.createServer(async (req, res) => {
8279
8098
  const body = await readBody(req);
8280
8099
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
8281
8100
  if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename (got `' + body.file + '`). Pass prd/<plan>.json, not the source plans/<plan>.md.' });
8282
- const planPath = resolvePlanPath(body.file);
8283
- const plan = mutateJsonFileLocked(planPath, (data) => {
8101
+ const plan = prdStore.mutatePrd(body.file, (data) => {
8284
8102
  if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
8285
8103
  data.status = 'rejected';
8286
8104
  data.rejectedAt = new Date().toISOString();
8287
8105
  data.rejectedBy = body.rejectedBy || os.userInfo().username;
8288
8106
  if (body.reason) data.rejectionReason = body.reason;
8289
8107
  return data;
8290
- }, { defaultValue: {} });
8108
+ });
8291
8109
 
8292
8110
  // RC3: reject used to flip only the PRD status — its materialized work items
8293
8111
  // kept dispatching and any active agent kept running, and a pending plan-to-prd
@@ -8307,26 +8125,26 @@ const server = http.createServer(async (req, res) => {
8307
8125
  try {
8308
8126
  const body = await readBody(req);
8309
8127
  if (!body.source) return jsonReply(res, 400, { error: 'source required' });
8310
- const planPath = resolvePlanPath(body.source);
8311
- if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
8312
- const plan = safeJsonObj(planPath);
8128
+ if (!body.source.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename' });
8129
+ const plan = prdStore.readPrd(body.source);
8130
+ if (!plan) return jsonReply(res, 404, { error: 'plan not found' });
8313
8131
  const planItems = plan.missing_features || [];
8314
8132
 
8315
8133
  let reset = 0, kept = 0, newCount = 0;
8316
8134
  const deletedItemIds = [];
8317
8135
 
8318
8136
  // Scan all work item sources for materialized items from this plan
8319
- const wiPaths = [{ path: path.join(MINIONS_DIR, 'work-items.json'), label: 'central' }];
8137
+ const workItemScopes = [{ scope: 'central', label: 'central' }];
8320
8138
  for (const proj of PROJECTS) {
8321
- wiPaths.push({ path: shared.projectWorkItemsPath(proj), label: proj.name });
8139
+ workItemScopes.push({ scope: proj, label: proj.name });
8322
8140
  }
8323
8141
 
8324
8142
  // Track which plan items have materialized work items
8325
8143
  const materializedPlanItemIds = new Set();
8326
8144
 
8327
- for (const wiInfo of wiPaths) {
8145
+ for (const wiInfo of workItemScopes) {
8328
8146
  try {
8329
- mutateWorkItems(wiInfo.path, items => {
8147
+ mutateWorkItems(wiInfo.scope, items => {
8330
8148
  const filtered = [];
8331
8149
  for (const w of items) {
8332
8150
  if (w.sourcePlan === body.source) {
@@ -8372,7 +8190,7 @@ const server = http.createServer(async (req, res) => {
8372
8190
  }
8373
8191
  if (orphanPendingIds.size > 0) {
8374
8192
  try {
8375
- mutateJsonFileLocked(planPath, (current) => {
8193
+ prdStore.mutatePrd(body.source, (current) => {
8376
8194
  if (!current || !Array.isArray(current.missing_features)) return current;
8377
8195
  const stamp = new Date().toISOString();
8378
8196
  for (const f of current.missing_features) {
@@ -8402,50 +8220,37 @@ const server = http.createServer(async (req, res) => {
8402
8220
  try {
8403
8221
  const body = await readBody(req);
8404
8222
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
8405
- shared.sanitizePath(body.file, body.file.endsWith('.json') ? PRD_DIR : PLANS_DIR);
8406
- const planPath = resolvePlanPath(body.file);
8407
- if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
8408
- // Read plan content before deleting — needed for worktree cleanup and source_plan
8223
+ const isPrd = body.file.endsWith('.json');
8224
+ let planPath = null;
8409
8225
  let planObj = null;
8410
8226
  let prdSourcePlan = null;
8411
- if (body.file.endsWith('.json')) {
8412
- try { planObj = safeJsonObj(planPath); prdSourcePlan = planObj?.source_plan || null; } catch {}
8227
+ let archivedPrd = false;
8228
+ if (isPrd) {
8229
+ planObj = prdStore.readPrd(body.file);
8230
+ if (!planObj) {
8231
+ archivedPrd = true;
8232
+ planObj = prdStore.readPrd(body.file, { archived: true });
8233
+ }
8234
+ if (!planObj) return jsonReply(res, 404, { error: 'plan not found' });
8235
+ prdSourcePlan = planObj.source_plan || null;
8236
+ } else {
8237
+ planPath = resolvePlanPath(body.file);
8238
+ if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
8413
8239
  }
8414
8240
  // Clean up worktrees before deleting work items (needs branch info from work items)
8415
8241
  try {
8416
8242
  const { cleanupPlanWorktrees } = require('./engine/lifecycle');
8417
8243
  cleanupPlanWorktrees(body.file, planObj || {}, PROJECTS, queries.getConfig());
8418
8244
  } catch (e) { console.error('plan worktree cleanup:', e.message); }
8419
- safeUnlink(planPath);
8420
- // Phase 10 step 2 — a deleted PRD must also leave SQL, or the read-flip
8421
- // (step 3) would resurrect it from a stale mirror row. Best-effort; no-op
8422
- // for non-PRD paths (parsePrdPath returns null for plan .md deletes).
8423
- if (body.file.endsWith('.json')) prdStore.deletePrd(planPath);
8424
- // Neutralize the `.backup` sidecar so `safeJson` auto-restore can't
8425
- // RESURRECT the PRD we just deleted (the live file is gone, but a stray
8426
- // `prd/<plan>.json.backup` would be auto-restored on the next safeJson read
8427
- // — the PRD comes back, with its prior `status: approved/active`, and the
8428
- // materializer re-dispatches its work items). Mirrors the Archive handler's
8429
- // backup cleanup (handlePlansArchive). A PRD delete must remove the restore
8430
- // fuel, not just the live file.
8431
- if (body.file.endsWith('.json')) {
8432
- try {
8433
- const backupCleanup = shared.neutralizeJsonBackupSidecar(planPath);
8434
- if (!backupCleanup.ok) {
8435
- console.warn(`Delete backup cleanup failed for ${body.file}: unlink (${backupCleanup.unlinkError}) / neutralize (${backupCleanup.writeError})`);
8436
- }
8437
- } catch (e) { console.warn(`Delete backup cleanup threw for ${body.file}: ${e.message}`); }
8438
- }
8245
+ if (isPrd) prdStore.deletePrd(body.file, { archived: archivedPrd });
8246
+ else safeUnlink(planPath);
8439
8247
 
8440
8248
  // Clean up materialized work items from all projects + central
8441
8249
  let cleaned = 0;
8442
- const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
8443
- for (const proj of PROJECTS) {
8444
- wiPaths.push(shared.projectWorkItemsPath(proj));
8445
- }
8446
- for (const wiPath of wiPaths) {
8250
+ const workItemScopes = ['central', ...PROJECTS];
8251
+ for (const scope of workItemScopes) {
8447
8252
  try {
8448
- mutateWorkItems(wiPath, items => {
8253
+ mutateWorkItems(scope, items => {
8449
8254
  const filtered = items.filter(w => w.sourcePlan !== body.file);
8450
8255
  if (filtered.length < items.length) {
8451
8256
  cleaned += items.length - filtered.length;
@@ -8465,8 +8270,7 @@ const server = http.createServer(async (req, res) => {
8465
8270
  // If deleting a PRD .json, reset the plan-to-prd work item so the source .md reverts to draft
8466
8271
  if (prdSourcePlan) {
8467
8272
  try {
8468
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
8469
- mutateWorkItems(centralPath, items => {
8273
+ mutateWorkItems('central', items => {
8470
8274
  for (const w of items) {
8471
8275
  if (w.type === WORK_TYPE.PLAN_TO_PRD && DONE_STATUSES.has(w.status) && w.planFile === prdSourcePlan) {
8472
8276
  w.status = WI_STATUS.CANCELLED;
@@ -8488,23 +8292,18 @@ const server = http.createServer(async (req, res) => {
8488
8292
  const body = await readBody(req);
8489
8293
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
8490
8294
  const isPrd = body.file.endsWith('.json');
8491
- shared.sanitizePath(body.file, isPrd ? PRD_DIR : PLANS_DIR);
8492
- const planPath = resolvePlanPath(body.file);
8493
- if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
8494
-
8495
- // Phase 10 step 4.2b: a PRD is archived IN PLACE (flag flip via
8496
- // _archivePrdPostProcess — the json stays in prd/, no move, so the
8497
- // live↔archive basename-collision class / footgun #7 is gone). Only the
8498
- // .md plan still physically moves to plans/archive/.
8499
- let archivePath;
8295
+ if (!isPrd) shared.sanitizePath(body.file, PLANS_DIR);
8296
+ let planPath = null;
8500
8297
  if (isPrd) {
8501
- archivePath = planPath; // in-place do not move the PRD json
8298
+ if (!prdStore.readPrd(body.file)) return jsonReply(res, 404, { error: 'plan not found' });
8502
8299
  } else {
8300
+ planPath = resolvePlanPath(body.file);
8301
+ if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
8503
8302
  const archiveDir = path.join(PLANS_DIR, 'archive');
8504
8303
  // DATA-LOSS GUARD: moveFileNoClobber dedupes the destination so a same-
8505
8304
  // basename collision can't silently destroy a previously-archived plan +
8506
8305
  // its completed work-item history. Bumps to -2/-3 instead of overwriting.
8507
- archivePath = shared.moveFileNoClobber(planPath, archiveDir, body.file);
8306
+ shared.moveFileNoClobber(planPath, archiveDir, body.file);
8508
8307
  }
8509
8308
 
8510
8309
  let archivedSource = null;
@@ -8515,8 +8314,6 @@ const server = http.createServer(async (req, res) => {
8515
8314
  if (isPrd) {
8516
8315
  const result = _archivePrdPostProcess({
8517
8316
  planFile: body.file,
8518
- archivePath,
8519
- planPath,
8520
8317
  plansDir: PLANS_DIR,
8521
8318
  });
8522
8319
  archivedSource = result.archivedSource;
@@ -8527,38 +8324,17 @@ const server = http.createServer(async (req, res) => {
8527
8324
  // archive any PRD whose `source_plan` points back at it. Without this,
8528
8325
  // the dashboard still renders the PRD as a status-completed plan card
8529
8326
  // forever (real incident: killswitches-and-granular-controls.md →
8530
- // minions-opg-2026-06-10-2.json). The per-PRD status-flip / sidecar /
8531
- // source-plan-move steps are delegated to _archivePrdPostProcess so
8532
- // both branches share the per-concern try/catch granularity Dallas
8533
- // shipped for the PRD branch in W-mqa13ulk0002def5 (PR #3222) — the
8534
- // helper's source-plan move is a safe no-op here because the outer
8535
- // handler already renamed body.file into plans/archive/.
8536
- const prdFiles = prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
8537
- for (const prdFile of prdFiles) {
8538
- const prdLivePath = path.join(PRD_DIR, prdFile);
8539
- let prd = null;
8540
- try {
8541
- prd = safeJsonObj(prdLivePath);
8542
- } catch (e) {
8543
- const warning = `Archive could not read PRD ${prdFile}: ${e.message}`;
8544
- archiveWarnings.push(warning);
8545
- console.warn(warning);
8546
- continue;
8547
- }
8327
+ // minions-opg-2026-06-10-2.json).
8328
+ const prdRows = prdStore.listPrdRows().filter(row => !row.archived);
8329
+ for (const { filename: prdFile, plan: prd } of prdRows) {
8548
8330
  // Canonical match (prefix/dir-tolerant): a raw `prd.source_plan !==
8549
8331
  // body.file` skipped PRDs whose source_plan carried the legacy
8550
8332
  // `plans/` prefix, archiving the plan but NOT its PRD — the plan↔PRD
8551
8333
  // status desync behind the "old PRDs came back" resurrection.
8552
8334
  if (!prd || !shared.prdMatchesSourcePlan(prd.source_plan, body.file)) continue;
8553
8335
 
8554
- // Phase 10 step 4.2b: flag the PRD archived IN PLACE (no move). The
8555
- // outer handler already moved the .md to plans/archive/, so the
8556
- // helper's (c) source-plan move is a no-op; (a) flips the flag (dual-
8557
- // writing SQL) and (b) skips backup-neutralize since the file stayed.
8558
8336
  const cascadeResult = _archivePrdPostProcess({
8559
8337
  planFile: prdFile,
8560
- archivePath: prdLivePath,
8561
- planPath: prdLivePath,
8562
8338
  plansDir: PLANS_DIR,
8563
8339
  });
8564
8340
  archiveWarnings.push(...cascadeResult.archiveWarnings);
@@ -8570,10 +8346,10 @@ const server = http.createServer(async (req, res) => {
8570
8346
  // Cancel pending work items linked to this plan so the engine stops
8571
8347
  // dispatching for an archived plan. Done items are preserved as history.
8572
8348
  let cancelledItems = 0;
8573
- const wiPaths = [path.join(MINIONS_DIR, 'work-items.json'), ...PROJECTS.map(p => shared.projectWorkItemsPath(p))];
8574
- for (const wiPath of wiPaths) {
8349
+ const workItemScopes = ['central', ...PROJECTS];
8350
+ for (const scope of workItemScopes) {
8575
8351
  try {
8576
- mutateWorkItems(wiPath, items => {
8352
+ mutateWorkItems(scope, items => {
8577
8353
  for (const w of items) {
8578
8354
  if (w.sourcePlan !== body.file) continue;
8579
8355
  if (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.QUEUED) {
@@ -8605,66 +8381,43 @@ const server = http.createServer(async (req, res) => {
8605
8381
  try {
8606
8382
  const body = await readBody(req);
8607
8383
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
8608
- try { shared.sanitizePath(body.file, body.file.endsWith('.json') ? PRD_DIR : PLANS_DIR); }
8384
+ try {
8385
+ if (!body.file.endsWith('.json')) shared.sanitizePath(body.file, PLANS_DIR);
8386
+ }
8609
8387
  catch { return jsonReply(res, 400, { error: 'invalid filename' }); }
8610
8388
  const isJson = body.file.endsWith('.json');
8611
- const targetDir = isJson ? PRD_DIR : PLANS_DIR;
8612
- const archivePath = path.join(targetDir, 'archive', body.file);
8613
- const livePath = path.join(targetDir, body.file);
8614
- // Phase 10 step 4.2b: a PRD can be archived two ways now — legacy
8615
- // (physically in <dir>/archive/) or in-place (in <dir>/ with the archived
8616
- // flag set). Restore from whichever it is.
8617
- let liveDest;
8618
- // Existence probes only — NEVER used as the write payload. The actual
8619
- // restore below (prdStore.restorePrdFromArchive) re-reads the archived
8620
- // row fresh inside its own transaction immediately before writing, so a
8621
- // concurrent engine write to that row between this probe and the write
8622
- // can't be clobbered by a stale in-memory snapshot (W-mrenu075000a94c9).
8623
- const archivedPrdExists = isJson ? prdStore.readPrd(archivePath) != null : false;
8624
- const activePrdData = isJson ? prdStore.readPrd(livePath) : null;
8625
- if (isJson && archivedPrdExists) {
8626
- const restored = prdStore.restorePrdFromArchive(body.file);
8627
- if (!restored) return jsonReply(res, 404, { error: 'File not found in archive' });
8628
- liveDest = livePath;
8629
- } else if (fs.existsSync(archivePath)) {
8630
- // DATA-LOSS GUARD: restoring must not clobber a LIVE plan/PRD of the same
8631
- // basename (renameSync overwrites). moveFileNoClobber bumps the restored
8632
- // name instead so the existing live file survives.
8633
- liveDest = shared.moveFileNoClobber(archivePath, targetDir, body.file);
8634
- } else if (isJson && activePrdData && shared.isPrdArchived(activePrdData)) {
8635
- liveDest = livePath; // in-place flag-archived — already in prd/, just clear the flag below
8636
- } else {
8637
- return jsonReply(res, 404, { error: 'File not found in archive' });
8638
- }
8639
- // Clear the archived flag/status so the unarchived PRD renders LIVE again.
8640
- // Archived-ness is the FLAG now (4.2a), so moving the file back is no longer
8641
- // enough — a legacy PRD carries status='archived' and would still render
8642
- // archived without this. Dual-writes the cleared state into SQL.
8643
- if (isJson) {
8644
- try {
8645
- mutateJsonFileLocked(liveDest, (d) => {
8646
- if (!d || typeof d !== 'object' || Array.isArray(d)) return d;
8647
- if (d.status === 'archived') d.status = 'active';
8648
- delete d.archived;
8649
- delete d.archivedAt;
8650
- return d;
8651
- }, { skipWriteIfUnchanged: true });
8652
- } catch (e) { console.warn(`Unarchive flag-clear failed for ${body.file}: ${e.message}`); }
8653
- }
8654
-
8655
- // Also unarchive linked source plan
8389
+ let restoredAs = body.file;
8656
8390
  let unarchivedSource = null;
8657
8391
  if (isJson) {
8392
+ const archivedRow = prdStore.readPrd(body.file, { archived: true });
8393
+ let prd = archivedRow
8394
+ ? prdStore.restorePrdFromArchive(body.file)
8395
+ : prdStore.readPrd(body.file);
8396
+ if (!prd || (!archivedRow && !shared.isPrdArchived(prd))) {
8397
+ return jsonReply(res, 404, { error: 'File not found in archive' });
8398
+ }
8399
+ prdStore.mutatePrd(body.file, (data) => {
8400
+ if (data.status === 'archived') data.status = 'active';
8401
+ delete data.archived;
8402
+ delete data.archivedAt;
8403
+ return data;
8404
+ });
8405
+ prd = prdStore.readPrd(body.file);
8658
8406
  try {
8659
- const prd = safeJson(liveDest);
8660
8407
  if (prd?.source_plan) {
8661
- const mdArchivePath = path.join(PLANS_DIR, 'archive', prd.source_plan);
8408
+ const sourcePlan = shared.sourcePlanKey(prd.source_plan);
8409
+ const mdArchivePath = path.join(PLANS_DIR, 'archive', sourcePlan);
8662
8410
  if (fs.existsSync(mdArchivePath)) {
8663
- const liveMdDest = shared.moveFileNoClobber(mdArchivePath, PLANS_DIR, prd.source_plan);
8411
+ const liveMdDest = shared.moveFileNoClobber(mdArchivePath, PLANS_DIR, sourcePlan);
8664
8412
  unarchivedSource = path.basename(liveMdDest);
8665
8413
  }
8666
8414
  }
8667
8415
  } catch { /* optional */ }
8416
+ } else {
8417
+ const archivePath = path.join(PLANS_DIR, 'archive', body.file);
8418
+ if (!fs.existsSync(archivePath)) return jsonReply(res, 404, { error: 'File not found in archive' });
8419
+ const liveDest = shared.moveFileNoClobber(archivePath, PLANS_DIR, body.file);
8420
+ restoredAs = path.basename(liveDest);
8668
8421
  }
8669
8422
 
8670
8423
  invalidateStatusCache();
@@ -8672,7 +8425,6 @@ const server = http.createServer(async (req, res) => {
8672
8425
  // `restoredAs` reflects the actual restored basename: moveFileNoClobber
8673
8426
  // bumps to <name>-2 when a live file of the same name already exists, so
8674
8427
  // the client isn't misled into expecting body.file when it differs.
8675
- const restoredAs = path.basename(liveDest);
8676
8428
  const payload = { ok: true, unarchivedSource };
8677
8429
  if (restoredAs !== body.file) payload.restoredAs = restoredAs;
8678
8430
  return jsonReply(res, 200, payload);
@@ -8684,20 +8436,18 @@ const server = http.createServer(async (req, res) => {
8684
8436
  const body = await readBody(req);
8685
8437
  if (!body.file || !body.feedback) return jsonReply(res, 400, { error: 'file and feedback required' });
8686
8438
  if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename (got `' + body.file + '`). Pass prd/<plan>.json, not the source plans/<plan>.md.' });
8687
- const planPath = resolvePlanPath(body.file);
8688
- const plan = mutateJsonFileLocked(planPath, (data) => {
8439
+ const plan = prdStore.mutatePrd(body.file, (data) => {
8689
8440
  if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
8690
8441
  data.status = 'revision-requested';
8691
8442
  data.revision_feedback = body.feedback;
8692
8443
  data.revisionRequestedAt = new Date().toISOString();
8693
8444
  data.revisionRequestedBy = body.requestedBy || os.userInfo().username;
8694
8445
  return data;
8695
- }, { defaultValue: {} });
8446
+ });
8696
8447
 
8697
8448
  // Create a work item to revise the plan
8698
- const wiPath = path.join(MINIONS_DIR, 'work-items.json');
8699
8449
  const id = 'W-' + shared.uid();
8700
- mutateWorkItems(wiPath, items => {
8450
+ mutateWorkItems('central', items => {
8701
8451
  items.push({
8702
8452
  id, title: 'Revise plan: ' + (plan.plan_summary || body.file),
8703
8453
  type: WORK_TYPE.PLAN_TO_PRD, priority: 'high',
@@ -8718,11 +8468,10 @@ const server = http.createServer(async (req, res) => {
8718
8468
  try {
8719
8469
  const body = await readBody(req);
8720
8470
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
8721
- const planPath = resolvePlanPath(body.file);
8722
- const planContent = safeRead(planPath);
8723
- if (!planContent) return jsonReply(res, 404, { error: 'plan not found' });
8724
-
8725
- const plan = JSON.parse(planContent);
8471
+ if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename' });
8472
+ const plan = prdStore.readPrd(body.file);
8473
+ if (!plan) return jsonReply(res, 404, { error: 'plan not found' });
8474
+ const planReference = `SQL PRD ${body.file}`;
8726
8475
  const projectName = plan.project || 'Unknown';
8727
8476
 
8728
8477
  // Build the session launch script
@@ -8730,10 +8479,10 @@ const server = http.createServer(async (req, res) => {
8730
8479
  let sysPrompt;
8731
8480
  try {
8732
8481
  sysPrompt = fs.readFileSync(path.join(MINIONS_DIR, 'prompts', 'plan-advisor-system.md'), 'utf8')
8733
- .replace(/\{\{plan_path\}\}/g, planPath)
8482
+ .replace(/\{\{plan_path\}\}/g, planReference)
8734
8483
  .replace(/\{\{project_name\}\}/g, projectName);
8735
8484
  } catch {
8736
- sysPrompt = `You are a Plan Advisor. The plan is at ${planPath} for project ${projectName}. Help the user review and approve it.`;
8485
+ sysPrompt = `You are a Plan Advisor. The plan is ${planReference} for project ${projectName}. Help the user review and approve it.`;
8737
8486
  }
8738
8487
 
8739
8488
  const initialPrompt = `Here's the plan awaiting your review:
@@ -9585,7 +9334,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9585
9334
  _clearCcLiveStream(tabId);
9586
9335
  }
9587
9336
  // P-c2sess-1d8e: lock single-session reset against concurrent updateSession writes.
9588
- mutateJsonFileLocked(CC_SESSION_PATH, () => ccSession, { defaultValue: {} });
9337
+ persistCcSession();
9589
9338
  return jsonReply(res, 200, { ok: true });
9590
9339
  }
9591
9340
 
@@ -9619,12 +9368,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9619
9368
  async function handleCCSessionDelete(req, res, match) {
9620
9369
  const id = match?.[1];
9621
9370
  if (!id) return jsonReply(res, 400, { error: 'id required' });
9622
- // P-c2sess-1d8e: one locked RMW so a concurrent upsert from the streaming
9623
- // handler cannot resurrect the deleted tab between read and write.
9624
- mutateJsonFileLocked(CC_SESSIONS_PATH, (raw) => {
9371
+ smallStateStore.applyCcSessionsMutation((raw) => {
9625
9372
  const sessions = _filterCcTabSessions(raw);
9626
9373
  return sessions.filter(s => s.id !== id);
9627
- }, { defaultValue: [] });
9374
+ });
9628
9375
  // Sub-task C of W-mp2w003600196c51: tear down the persistent ACP worker
9629
9376
  // for this tab so we don't leak a Copilot process after the user closes
9630
9377
  // the tab. closeTab is a no-op when the pool has no entry for the tabId,
@@ -9814,7 +9561,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9814
9561
  // W-mrb1a3950003d9ea — flat, stable label (no per-watch-id interpolation).
9815
9562
  // Every other _engine category (kb-sweep, consolidation, command-center, …)
9816
9563
  // uses one flat label; a dynamic watchId suffix here used to mint a brand-new
9817
- // permanent metrics.json._engine key per watch firing (20+ confirmed live).
9564
+ // permanent metrics category per watch firing (20+ confirmed live).
9818
9565
  const label = 'watch-cc-triage';
9819
9566
 
9820
9567
  const model = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
@@ -10692,10 +10439,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10692
10439
  const _persistTabId = body.tabId;
10693
10440
  if (_persistTabId && responseSessionId) {
10694
10441
  try {
10695
- // P-c2sess-1d8e: one locked RMW so concurrent multi-tab streams can't
10696
- // race on read+modify+write — both upsert paths share the lock.
10697
10442
  const preview = (body.message || '').slice(0, 80);
10698
- mutateJsonFileLocked(CC_SESSIONS_PATH, (raw) => {
10443
+ smallStateStore.applyCcSessionsMutation((raw) => {
10699
10444
  const sessions = _filterCcTabSessions(raw);
10700
10445
  const existing = sessions.find(s => s.id === _persistTabId);
10701
10446
  if (existing) {
@@ -10709,7 +10454,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10709
10454
  sessions.push({ id: _persistTabId, title: (body.message || 'New chat').slice(0, 40), sessionId: responseSessionId, createdAt: new Date(now).toISOString(), lastActiveAt: new Date(now).toISOString(), turnCount: 1, preview, _promptHash: _ccPromptHash, runtime: currentRuntime });
10710
10455
  }
10711
10456
  return sessions;
10712
- }, { defaultValue: [] });
10457
+ });
10713
10458
  } catch { /* non-critical */ }
10714
10459
  }
10715
10460
 
@@ -10899,9 +10644,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10899
10644
  participants: Array.isArray(schedForRun.participants) ? schedForRun.participants : [],
10900
10645
  });
10901
10646
  } else {
10902
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
10903
10647
  let duplicate = null;
10904
- mutateJsonFileLocked(centralPath, (items) => {
10648
+ mutateWorkItems('central', (items) => {
10905
10649
  if (!Array.isArray(items)) items = [];
10906
10650
  duplicate = items.find(i =>
10907
10651
  i._scheduleId === item._scheduleId &&
@@ -10911,7 +10655,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10911
10655
  );
10912
10656
  if (!duplicate) items.push(item);
10913
10657
  return items;
10914
- }, { defaultValue: [] });
10658
+ });
10915
10659
  if (duplicate) {
10916
10660
  return jsonReply(res, 409, {
10917
10661
  error: 'Schedule already has an active work item',
@@ -12117,7 +11861,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12117
11861
 
12118
11862
  // ── Managed processes API (P-4b8d2e57, plan W-mp7k1r760003b5dd item 4) ──
12119
11863
  // List + by-name + kill + restart for the engine-owned managed-spawn
12120
- // services (engine/managed-processes.json). Mirrors the keep-processes
11864
+ // services (SQL managed-process state). Mirrors the keep-processes
12121
11865
  // pattern above but lives a layer deeper because the state is engine-side
12122
11866
  // (not per-agent sidecars).
12123
11867
 
@@ -12416,7 +12160,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12416
12160
  const projectName = runbook.project || target.owner_project || '';
12417
12161
  const resolveTarget = resolveWorkItemsCreateTarget(projectName);
12418
12162
  if (resolveTarget.error) return jsonReply(res, 400, { error: resolveTarget.error }, req);
12419
- const wiPath = resolveTarget.wiPath;
12163
+ const wiScope = resolveTarget.scope;
12420
12164
  const targetProject = resolveTarget.project;
12421
12165
 
12422
12166
  // Create run record BEFORE the WI so we can stamp the WI with qaRunId.
@@ -12452,12 +12196,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12452
12196
  if (targetProject) wi.project = targetProject.name;
12453
12197
  if (body.agent && typeof body.agent === 'string' && body.agent.trim()) wi.agent = body.agent.trim();
12454
12198
 
12455
- // Atomic WI creation: mutateWorkItems holds the file lock for the entire
12456
- // append. createRun above is its own lock — order is intentional so a
12199
+ // createRun and the work-item append are separate transactions; order is
12200
+ // intentional so a
12457
12201
  // crash between the two leaves the run as `pending` (recoverable) rather
12458
12202
  // than orphaning a WI with no run record.
12459
12203
  let added = false;
12460
- mutateWorkItems(wiPath, (items) => {
12204
+ mutateWorkItems(wiScope, (items) => {
12461
12205
  if (!Array.isArray(items)) items = [];
12462
12206
  if (!items.some(i => i && i.id === wiId)) {
12463
12207
  items.push(wi);
@@ -12633,7 +12377,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12633
12377
  }
12634
12378
 
12635
12379
  // W-mpxp90xs000i5732 — DELETE /api/qa/runs/<id>. Hard-delete: removes the
12636
- // run from engine/qa-runs.json AND best-effort wipes the on-disk artifact
12380
+ // run from SQL QA state and best-effort wipes the on-disk artifact
12637
12381
  // directory under engine/qa-artifacts/<runId>/. Terminal-only — refuses
12638
12382
  // pending/running runs with 409. Caller must cancel/complete the run via
12639
12383
  // the lifecycle helpers before deletion. CSRF/Origin gate is applied by
@@ -12721,7 +12465,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12721
12465
  // Thin HTTP wrappers over engine/qa-sessions.js + engine/qa-runners.js. The
12722
12466
  // lifecycle (state machine, work-item builders, dispatch chain) lives in
12723
12467
  // those modules; these handlers translate HTTP into the right call,
12724
- // resolve project/wiPath the same way handleQaRunbookRun does, and shape
12468
+ // resolve project/scope the same way handleQaRunbookRun does, and shape
12725
12469
  // qa-sessions thrown errors into 400/404/409 instead of 500.
12726
12470
  //
12727
12471
  // Endpoint summary:
@@ -12752,22 +12496,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12752
12496
  return 500;
12753
12497
  }
12754
12498
 
12755
- // Resolve the (project, wiPath) tuple for a session create/approve/edit
12756
- // request. session.spec.project (or the create-time project field) decides
12757
- // which work-items.json receives the new WI. central → root-level
12758
- // work-items.json. Returns { error } on resolution failure so the handler
12759
- // can reply 400.
12499
+ // Resolve the project and SQL scope for QA work items.
12760
12500
  function _qaSessionsResolveTarget(projectName) {
12761
12501
  const target = resolveWorkItemsCreateTarget(projectName || '');
12762
12502
  if (target.error) return { error: target.error };
12763
- return { wiPath: target.wiPath, project: target.project ? target.project.name : null };
12503
+ return { scope: target.scope, project: target.project ? target.project.name : null };
12764
12504
  }
12765
12505
 
12766
12506
  // W-mpq6xqzj000606d0 — Multi-project resolver. Walks each requested project
12767
12507
  // through resolveWorkItemsCreateTarget, returning either the full list of
12768
- // {wiPath, project} entries (with the first treated as primary) or
12769
- // { error } on the first failure. Accepts the canonical `projects: string[]`
12770
- // and falls back to the legacy single `project` string.
12508
+ // {scope, project} entries (with the first treated as primary) or
12509
+ // { error } on the first failure.
12771
12510
  function _qaSessionsResolveTargets(body) {
12772
12511
  const list = Array.isArray(body && body.projects) && body.projects.length > 0
12773
12512
  ? body.projects.slice()
@@ -12815,7 +12554,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12815
12554
  let setupWiId = null;
12816
12555
  try {
12817
12556
  setupWiId = qaSessions.queueSetup(session.id, {
12818
- resolvedTargets: resolution.targets.map(t => ({ project: t.project, wiPath: t.wiPath })),
12557
+ resolvedTargets: resolution.targets.map(t => ({ project: t.project, scope: t.scope })),
12819
12558
  });
12820
12559
  } catch (e) {
12821
12560
  // queueSetup throws when pending→spawning is rejected (createSession
@@ -12923,7 +12662,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12923
12662
  let setupWiId = null;
12924
12663
  try {
12925
12664
  setupWiId = qaSessions.queueSetup(session.id, {
12926
- resolvedTargets: resolution.targets.map(t => ({ project: t.project, wiPath: t.wiPath })),
12665
+ resolvedTargets: resolution.targets.map(t => ({ project: t.project, scope: t.scope })),
12927
12666
  });
12928
12667
  } catch (e) {
12929
12668
  return jsonReply(res, _qaSessionsErrorToStatus(e), {
@@ -13067,7 +12806,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13067
12806
  let executeWiId;
13068
12807
  try {
13069
12808
  executeWiId = qaSessions.approveDraft(session.id, {
13070
- wiPath: resolved.wiPath,
12809
+ scope: resolved.scope,
13071
12810
  qaRunId: run.id,
13072
12811
  project: resolved.project,
13073
12812
  });
@@ -13106,7 +12845,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13106
12845
  let draftWiId;
13107
12846
  try {
13108
12847
  draftWiId = qaSessions.editDraft(session.id, {
13109
- wiPath: resolved.wiPath,
12848
+ scope: resolved.scope,
13110
12849
  feedback,
13111
12850
  project: resolved.project,
13112
12851
  });
@@ -13260,6 +12999,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13260
12999
 
13261
13000
  // Status & health
13262
13001
  { method: 'GET', path: '/api/status', desc: 'Full dashboard status snapshot (agents, PRDs, work items, dispatch, etc.)', handler: handleStatus },
13002
+ { method: 'GET', path: '/api/constellation/v1/snapshot', desc: 'Versioned metadata-only full snapshot for the local Constellation mirror', handler: (req, res) => {
13003
+ return serveFreshJson(req, res, {
13004
+ tag: `constellation-v1-${_dashboardVersion.codeVersion || 'unknown'}-${_dashboardVersion.codeCommit || 'unknown'}`,
13005
+ inputs: [CONFIG_PATH, queries.CONTROL_PATH, INBOX_DIR, AGENTS_DIR],
13006
+ builder: _buildConstellationBridgeSnapshot,
13007
+ });
13008
+ }},
13263
13009
  // Raw state-file passthrough (no /api/ prefix — this serves files, not API
13264
13010
  // responses). Allowlisted top-level dirs only; mtime+size ETag → 304.
13265
13011
  { method: 'GET', path: /^\/state\/.+/, desc: 'Serve a raw state file (or directory listing) from MINIONS_DIR with mtime/size ETag', handler: handleStateRead },
@@ -13272,34 +13018,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13272
13018
  tag: 'agents',
13273
13019
  inputs: [
13274
13020
  CONFIG_PATH,
13275
- path.join(ENGINE_DIR, 'dispatch.json'),
13276
13021
  INBOX_DIR,
13277
13022
  AGENTS_DIR,
13278
13023
  ],
13279
13024
  builder: () => getAgents(),
13280
13025
  });
13281
13026
  }},
13282
- { method: 'GET', path: '/api/work-items', desc: 'Fully-enriched work items (per-project files joined + dispatch/PR cross-reference) — fresh on every request; description hard-capped + acceptanceCriteria/references replaced with *Count integers (W-mq5xg5e9000nec0e); detail modal lazy-loads the full record via GET /api/work-items/<id>', handler: (req, res) => {
13283
- const config = queries.getConfig();
13284
- const projects = config.projects || [];
13285
- const inputs = [
13286
- CONFIG_PATH,
13287
- path.join(ENGINE_DIR, 'dispatch.json'),
13288
- // Central work-items.json at MINIONS_DIR root — backs the 'central'
13289
- // scope (schedule/pipeline/CC-created rootless WIs, per
13290
- // work-items-store.js#_filePathForScope). Without this, a new
13291
- // central-scope WI never busts the ETag until dispatch.json changes.
13292
- shared.centralWorkItemsPath(MINIONS_DIR),
13293
- ];
13294
- for (const p of projects) {
13295
- if (p && p.name) {
13296
- inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json'));
13297
- inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
13298
- }
13299
- }
13027
+ { method: 'GET', path: '/api/work-items', desc: 'Fully-enriched SQL-backed work items joined with dispatch/PR state — fresh on every request; description hard-capped + acceptanceCriteria/references replaced with *Count integers (W-mq5xg5e9000nec0e); detail modal lazy-loads the full record via GET /api/work-items/<id>', handler: (req, res) => {
13300
13028
  return serveFreshJson(req, res, {
13301
13029
  tag: 'work-items',
13302
- inputs,
13030
+ inputs: [CONFIG_PATH],
13303
13031
  // W-mq5xg5e9000nec0e — Slim every item before stringify so the polled
13304
13032
  // refresh-loop payload stays < ~300 KB even when description fields
13305
13033
  // include 100+ KB transcripts. slimWorkItemForList shallow-copies so
@@ -13307,22 +13035,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13307
13035
  builder: () => getWorkItems().map(slimWorkItemForList),
13308
13036
  });
13309
13037
  }},
13310
- { method: 'GET', path: '/api/pull-requests', desc: 'Fully-enriched pull requests (per-project files joined + url backfill + _project stamp)', handler: (req, res) => {
13311
- const config = queries.getConfig();
13312
- const projects = config.projects || [];
13313
- const inputs = [
13314
- CONFIG_PATH,
13315
- // Central pull-requests.json at MINIONS_DIR root — backs the
13316
- // 'central' scope per pull-requests-store.js#_filePathForScope.
13317
- // Mirrors the same class of staleness fix as /api/work-items.
13318
- shared.centralPullRequestsPath(MINIONS_DIR),
13319
- ];
13320
- for (const p of projects) {
13321
- if (p && p.name) inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
13322
- }
13038
+ { method: 'GET', path: '/api/pull-requests', desc: 'Fully-enriched SQL-backed pull requests with URL backfill and project stamps', handler: (req, res) => {
13323
13039
  return serveFreshJson(req, res, {
13324
13040
  tag: 'pull-requests',
13325
- inputs,
13041
+ inputs: [CONFIG_PATH],
13326
13042
  builder: () => queries.getPullRequests(),
13327
13043
  });
13328
13044
  }},
@@ -13334,54 +13050,22 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13334
13050
  { method: 'GET', path: '/api/dispatch', desc: 'Live dispatch queue with completion-report summaries (pending/active/completed slices)', handler: (req, res) => {
13335
13051
  return serveFreshJson(req, res, {
13336
13052
  tag: 'dispatch',
13337
- inputs: [
13338
- path.join(ENGINE_DIR, 'dispatch.json'),
13339
- path.join(ENGINE_DIR, 'metrics.json'),
13340
- ],
13053
+ inputs: [],
13341
13054
  builder: () => getDispatchQueue(),
13342
13055
  });
13343
13056
  }},
13344
13057
  { method: 'GET', path: '/api/metrics', desc: 'Per-agent metrics with PR/runtime enrichment (joined against pull-requests + dispatch.completed)', handler: (req, res) => {
13345
- const config = queries.getConfig();
13346
- const projects = config.projects || [];
13347
- const inputs = [
13348
- path.join(ENGINE_DIR, 'metrics.json'),
13349
- path.join(ENGINE_DIR, 'dispatch.json'),
13350
- // getMetrics() enriches from getPullRequests(), which includes the
13351
- // central pull-requests.json scope — include it here too so a new
13352
- // central-scope PR busts the ETag (same class of bug as /api/work-items).
13353
- shared.centralPullRequestsPath(MINIONS_DIR),
13354
- ];
13355
- for (const p of projects) {
13356
- if (p && p.name) inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
13357
- }
13358
13058
  return serveFreshJson(req, res, {
13359
13059
  tag: 'metrics',
13360
- inputs,
13060
+ inputs: [CONFIG_PATH],
13361
13061
  builder: () => getMetrics(),
13362
13062
  });
13363
13063
  }},
13364
- { method: 'GET', path: '/api/prd', desc: 'PRD status + progress derived from prd/*.json × per-project work-items × pull-requests', handler: (req, res) => {
13365
- const config = queries.getConfig();
13366
- const projects = config.projects || [];
13064
+ { method: 'GET', path: '/api/prd', desc: 'PRD status + progress derived from SQL-backed PRDs, work items, and pull requests', handler: (req, res) => {
13367
13065
  const inputs = [
13368
13066
  CONFIG_PATH,
13369
- PRD_DIR,
13370
- path.join(PRD_DIR, 'archive'),
13371
- // Plans dir — getPrdInfo statSyncs plans/<plan.source_plan> to
13372
- // compute the `planStale` flag; edits to plans/*.md need to bust
13373
- // the ETag so the Regenerate prompt surfaces. (Review finding #7.)
13374
13067
  path.join(MINIONS_DIR, 'plans'),
13375
- // Central work-items.json at MINIONS_DIR root — getPrdInfo
13376
- // cross-references plan items lacking a project scope. (Review #7.)
13377
- path.join(MINIONS_DIR, 'work-items.json'),
13378
13068
  ];
13379
- for (const p of projects) {
13380
- if (p && p.name) {
13381
- inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json'));
13382
- inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
13383
- }
13384
- }
13385
13069
  return serveFreshJson(req, res, {
13386
13070
  tag: 'prd',
13387
13071
  inputs,
@@ -13391,7 +13075,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13391
13075
  { method: 'GET', path: '/api/archived-prds', desc: 'List of archived PRD files with status + completedAt', handler: (req, res) => {
13392
13076
  return serveFreshJson(req, res, {
13393
13077
  tag: 'archived-prds',
13394
- inputs: [path.join(PRD_DIR, 'archive')],
13078
+ inputs: [],
13395
13079
  builder: () => [],
13396
13080
  });
13397
13081
  }},
@@ -13418,12 +13102,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13418
13102
  builder: () => {
13419
13103
  // Read config.json directly via queries.getConfig() rather than
13420
13104
  // calling reloadConfig() — the latter cascades into
13421
- // ensureConfiguredProjectStateFiles() which acquires
13422
- // mutateJsonFileLocked on every project's work-items.json AND
13423
- // pull-requests.json, putting the schedules sidebar poll on the
13424
- // critical path of every engine PR/WI writer. The builder only
13425
- // needs config.schedules; getConfig is a pure disk read.
13426
- // (Round-2 review finding #3.)
13105
+ // ensureConfiguredProjectStateFiles(); the builder only needs
13106
+ // config.schedules, so getConfig avoids unrelated setup work.
13427
13107
  const cfg = queries.getConfig() || {};
13428
13108
  const scheds = cfg.schedules || [];
13429
13109
  const runs = require('./engine/small-state-store').readScheduleRuns();
@@ -13570,11 +13250,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13570
13250
  const { id, source, rating, comment } = body;
13571
13251
  if (!id || !rating) return jsonReply(res, 400, { error: 'id and rating required' });
13572
13252
  const projects = shared.getProjects(CONFIG);
13573
- const paths = [path.join(MINIONS_DIR, 'work-items.json')];
13574
- for (const p of projects) paths.push(shared.projectWorkItemsPath(p));
13253
+ const workItemScopes = ['central', ...projects];
13575
13254
  let found = null;
13576
- for (const wiPath of paths) {
13577
- mutateWorkItems(wiPath, items => {
13255
+ for (const scope of workItemScopes) {
13256
+ mutateWorkItems(scope, items => {
13578
13257
  const item = items.find(i => i.id === id);
13579
13258
  if (item && !found) {
13580
13259
  item._humanFeedback = { rating, comment: comment || '', at: new Date().toISOString() };
@@ -13714,7 +13393,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13714
13393
  } catch (e) {
13715
13394
  return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
13716
13395
  }
13717
- const { id: prId, prPath, prNum, created, linked, targetProject, projectResolution, skipped, resurrected } = linkResult;
13396
+ const { id: prId, scope, prNum, created, linked, targetProject, projectResolution, skipped, resurrected } = linkResult;
13718
13397
  invalidateStatusCache();
13719
13398
  // W-mrdutzdr000t22f4 — upsertPullRequestRecord can leave the record
13720
13399
  // untouched (`skipped: true`) for reasons other than a resurrect, e.g.
@@ -13763,7 +13442,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13763
13442
  } catch { /* ADO token may not be available */ }
13764
13443
  }
13765
13444
  if (!prData) return;
13766
- mutateJsonFileLocked(prPath, (prs) => {
13445
+ shared.mutatePullRequests(scope, (prs) => {
13767
13446
  const pr = prs.find(p => p.id === prId);
13768
13447
  if (!pr) return prs;
13769
13448
  // Remote title always wins — any user-supplied title is a placeholder (closes #1283)
@@ -13978,14 +13657,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13978
13657
  const { id, project: requestedProjectName } = body;
13979
13658
  if (!id) return jsonReply(res, 400, { error: 'id required' });
13980
13659
  reloadConfig();
13981
- // Search all project PR files and central file
13660
+ // Search all project and central PR scopes.
13982
13661
  const projects = shared.getProjects(CONFIG);
13983
13662
  const projectByName = new Map(projects.map(project => [project.name, project]));
13984
13663
  const store = require('./engine/pull-requests-store');
13985
13664
  const storedPrs = store.readAllPullRequests() || [];
13986
13665
  const prScopes = new Map([
13987
- ...projects.map(project => [project.name, { prPath: shared.projectPrPath(project), project }]),
13988
- ['central', { prPath: path.join(MINIONS_DIR, 'pull-requests.json'), project: null }],
13666
+ ...projects.map(project => [project.name, { source: project, project }]),
13667
+ ['central', { source: 'central', project: null }],
13989
13668
  ]);
13990
13669
  const canonicalRequest = shared.parseCanonicalPrId(id);
13991
13670
  const requestedIdentities = new Set();
@@ -14016,7 +13695,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14016
13695
  const ownerProject = scope !== 'central' ? projectByName.get(scope) || null : null;
14017
13696
  if (!requestedIdentities.has(shared.getPrIdentityKey(pr, ownerProject))) continue;
14018
13697
  if (!prScopes.has(scope)) {
14019
- prScopes.set(scope, { prPath: store._filePathForScope(scope), project: ownerProject });
13698
+ prScopes.set(scope, { source: ownerProject || scope, project: ownerProject });
14020
13699
  }
14021
13700
  }
14022
13701
  let found = false;
@@ -14027,11 +13706,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14027
13706
  // the other scope's copy fully untouched — queries.js#getPullRequests
14028
13707
  // then de-dupes by picking that untouched duplicate as the "winner"
14029
13708
  // and silently resurrects the "deleted" PR on the next read.
14030
- for (const { prPath, project } of prScopes.values()) {
13709
+ for (const { source, project } of prScopes.values()) {
14031
13710
  // Issue #384: use a tombstone (userDeleted:true) instead of splice so
14032
13711
  // the next poller cycle cannot re-add the PR. The entry stays in the
14033
- // file but is filtered from API responses and skipped by pollers.
14034
- shared.mutatePullRequests(prPath, (prs) => {
13712
+ // state but is filtered from API responses and skipped by pollers.
13713
+ shared.mutatePullRequests(source, (prs) => {
14035
13714
  if (!Array.isArray(prs)) return prs;
14036
13715
  const matches = prs.filter(pr =>
14037
13716
  requestedIdentities.has(shared.getPrIdentityKey(pr, project))
@@ -14056,22 +13735,19 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14056
13735
  if (!prId) return jsonReply(res, 400, { error: 'prId required' });
14057
13736
  if (!cause) return jsonReply(res, 400, { error: 'cause required' });
14058
13737
  // Validate cause is one of the known PR_FIX_CAUSE values to reject
14059
- // typos before scanning state files.
13738
+ // typos before scanning state.
14060
13739
  const knownCauses = new Set(Object.values(shared.PR_FIX_CAUSE));
14061
13740
  if (!knownCauses.has(cause)) {
14062
13741
  return jsonReply(res, 400, { error: `unknown cause: ${cause}` });
14063
13742
  }
14064
13743
  reloadConfig();
14065
13744
  const lifecycle = require('./engine/lifecycle');
14066
- const prPaths = [
14067
- ...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
14068
- shared.centralPullRequestsPath(MINIONS_DIR),
14069
- ];
13745
+ const prSources = [...shared.getProjects(CONFIG), 'central'];
14070
13746
  let prFound = false;
14071
13747
  let causeFound = false;
14072
- for (const prPath of prPaths) {
13748
+ for (const source of prSources) {
14073
13749
  if (causeFound) break;
14074
- shared.mutatePullRequests(prPath, (prs) => {
13750
+ shared.mutatePullRequests(source, (prs) => {
14075
13751
  if (!Array.isArray(prs)) return prs;
14076
13752
  const target = prs.find(p => p && p.id === prId);
14077
13753
  if (!target) return prs;
@@ -14094,7 +13770,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14094
13770
  // separately from `_noOpFixes` and so isn't reachable via the endpoint
14095
13771
  // above. Once a fresh CI build is queued against a rebased/retried head,
14096
13772
  // this lets an operator clear the stale pause without hand-editing
14097
- // pull-requests.json.
13773
+ // tracked PR state.
14098
13774
  { method: 'POST', path: '/api/pull-requests/clear-build-fix-ineffective', desc: 'Resume a paused build-fix-ineffective ("infra issue suspected") pause (issue #671) — clears _buildFixIneffective so BUILD_FAILURE auto-fix dispatch can resume', params: 'prId (canonical host:slug#number), headSha? (optional — if provided, must match the paused record\'s headSha or the clear is rejected as stale)', handler: async (req, res) => {
14099
13775
  const body = await readBody(req);
14100
13776
  const prId = typeof body?.prId === 'string' ? body.prId.trim() : '';
@@ -14102,16 +13778,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14102
13778
  if (!prId) return jsonReply(res, 400, { error: 'prId required' });
14103
13779
  reloadConfig();
14104
13780
  const lifecycle = require('./engine/lifecycle');
14105
- const prPaths = [
14106
- ...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
14107
- shared.centralPullRequestsPath(MINIONS_DIR),
14108
- ];
13781
+ const prSources = [...shared.getProjects(CONFIG), 'central'];
14109
13782
  let prFound = false;
14110
13783
  let recordCleared = false;
14111
13784
  let staleHead = false;
14112
- for (const prPath of prPaths) {
13785
+ for (const source of prSources) {
14113
13786
  if (recordCleared || staleHead) break;
14114
- shared.mutatePullRequests(prPath, (prs) => {
13787
+ shared.mutatePullRequests(source, (prs) => {
14115
13788
  if (!Array.isArray(prs)) return prs;
14116
13789
  const target = prs.find(p => p && p.id === prId);
14117
13790
  if (!target) return prs;
@@ -14150,15 +13823,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14150
13823
  if (!prId) return jsonReply(res, 400, { error: 'prId required' });
14151
13824
  reloadConfig();
14152
13825
  const lifecycle = require('./engine/lifecycle');
14153
- const prPaths = [
14154
- ...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
14155
- shared.centralPullRequestsPath(MINIONS_DIR),
14156
- ];
13826
+ const prSources = [...shared.getProjects(CONFIG), 'central'];
14157
13827
  let prFound = false;
14158
13828
  let recordCleared = false;
14159
- for (const prPath of prPaths) {
13829
+ for (const source of prSources) {
14160
13830
  if (recordCleared) break;
14161
- shared.mutatePullRequests(prPath, (prs) => {
13831
+ shared.mutatePullRequests(source, (prs) => {
14162
13832
  if (!Array.isArray(prs)) return prs;
14163
13833
  const target = prs.find(p => p && p.id === prId);
14164
13834
  if (!target) return prs;
@@ -14593,10 +14263,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14593
14263
  completeRun(body.id, run.runId, 'failed');
14594
14264
  // Cancel pending/active work items and dispatches spawned by this run
14595
14265
  let cancelled = 0;
14596
- const wiPaths = [path.join(MINIONS_DIR, 'work-items.json'), ...PROJECTS.map(p => shared.projectWorkItemsPath(p))];
14597
- for (const wiPath of wiPaths) {
14266
+ const workItemScopes = ['central', ...PROJECTS];
14267
+ for (const scope of workItemScopes) {
14598
14268
  try {
14599
- mutateWorkItems(wiPath, items => {
14269
+ mutateWorkItems(scope, items => {
14600
14270
  for (const w of items) {
14601
14271
  if (w._pipelineRun === run.runId && w.status !== shared.WI_STATUS.DONE && w.status !== shared.WI_STATUS.CANCELLED) {
14602
14272
  w.status = shared.WI_STATUS.CANCELLED;
@@ -14971,6 +14641,7 @@ module.exports = {
14971
14641
  _findDuplicateWorkItemCreate: findDuplicateWorkItemCreate,
14972
14642
  _createWorkItemWithDedup: createWorkItemWithDedup,
14973
14643
  _resolveWorkItemsCreateTarget: resolveWorkItemsCreateTarget,
14644
+ _matchesResolvedWorkItemDispatch: matchesResolvedWorkItemDispatch, // exported for testing
14974
14645
  _validatePrFollowupShape: validatePrFollowupShape,
14975
14646
  _findExistingFollowupForComment: findExistingFollowupForComment,
14976
14647
  _extractMinionsAgentHeader: extractMinionsAgentHeader,
@@ -14984,6 +14655,7 @@ module.exports = {
14984
14655
  buildCCStateRefresh,
14985
14656
  _resetRefreshCache,
14986
14657
  _routesAsMeta,
14658
+ _buildConstellationBridgeSnapshot,
14987
14659
  _server: server,
14988
14660
  _buildTranscriptCarryover,
14989
14661
  _transcriptHasCarryoverContext,