@yemi33/minions 0.1.2379 → 0.1.2381

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 +3 -4
  3. package/dashboard/js/render-other.js +43 -23
  4. package/dashboard/js/render-prd.js +1 -1
  5. package/dashboard/js/render-work-items.js +13 -6
  6. package/dashboard/js/settings.js +40 -17
  7. package/dashboard.js +435 -768
  8. package/docs/architecture-review-2026-07-09.md +2 -4
  9. package/docs/auto-discovery.md +36 -49
  10. package/docs/blog-first-successful-dispatch.md +1 -1
  11. package/docs/branch-derivation.md +2 -2
  12. package/docs/command-center.md +1 -1
  13. package/docs/completion-reports.md +14 -4
  14. package/docs/constellation-bridge.md +59 -10
  15. package/docs/constellation-style-telemetry.md +6 -6
  16. package/docs/cooldown-merge-semantics.md +4 -0
  17. package/docs/copilot-cli-schema.md +3 -3
  18. package/docs/cross-repo-plans.md +17 -17
  19. package/docs/deprecated.json +2 -2
  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/live-checkout-mode.md +45 -26
  25. package/docs/managed-spawn.md +4 -4
  26. package/docs/onboarding.md +1 -2
  27. package/docs/pr-comment-followup.md +3 -3
  28. package/docs/pr-review-fix-loop.md +1 -1
  29. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  30. package/docs/qa-runbook-lifecycle.md +4 -4
  31. package/docs/qa-runbooks.md +2 -2
  32. package/docs/rfc-completion-json.md +4 -1
  33. package/docs/runtime-adapters.md +1 -1
  34. package/docs/self-improvement.md +4 -5
  35. package/docs/shared-lifecycle-module-map.md +3 -1
  36. package/docs/slim-ux/architecture-suggestions.md +5 -6
  37. package/docs/slim-ux/concepts.md +23 -25
  38. package/docs/watches.md +7 -7
  39. package/docs/workspace-manifests.md +1 -1
  40. package/docs/worktree-lifecycle.md +1 -1
  41. package/engine/abandoned-pr-reconciliation.js +4 -5
  42. package/engine/ado-status.js +5 -5
  43. package/engine/ado.js +20 -25
  44. package/engine/agent-worker-pool.js +58 -1
  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 +39 -44
  59. package/engine/github.js +20 -27
  60. package/engine/lifecycle.js +279 -354
  61. package/engine/live-checkout.js +193 -149
  62. package/engine/llm.js +1 -1
  63. package/engine/logs-store.js +2 -2
  64. package/engine/managed-spawn.js +2 -23
  65. package/engine/meeting.js +6 -6
  66. package/engine/metrics-store.js +2 -2
  67. package/engine/note-link-backfill.js +6 -11
  68. package/engine/pipeline.js +18 -36
  69. package/engine/playbook.js +13 -16
  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 +4 -6
  79. package/engine/scheduler.js +0 -4
  80. package/engine/shared-branch-pr-reconcile.js +2 -3
  81. package/engine/shared.js +268 -699
  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 +197 -358
  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 +9 -9
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
  }
@@ -420,8 +421,14 @@ function mergeSettingsConfigUpdate(current, candidate, body, patch = {}) {
420
421
  // `candidate` in handleSettingsUpdate) is silently dropped on the way
421
422
  // through mergeSettingsConfigUpdate → mutateDashboardConfig and never
422
423
  // reaches disk — the endpoint would return 200 but persist nothing.
424
+ const modeWasRequested = Object.prototype.hasOwnProperty.call(update, 'checkoutMode')
425
+ || Object.prototype.hasOwnProperty.call(update, 'worktreeMode');
423
426
  if (Object.prototype.hasOwnProperty.call(candidateProject, 'checkoutMode')) {
424
427
  currentProject.checkoutMode = candidateProject.checkoutMode;
428
+ } else if (Object.prototype.hasOwnProperty.call(candidateProject, 'worktreeMode')) {
429
+ currentProject.checkoutMode = shared.resolveCheckoutMode(candidateProject);
430
+ } else if (!modeWasRequested && Object.prototype.hasOwnProperty.call(currentProject, 'worktreeMode')) {
431
+ currentProject.checkoutMode = shared.resolveCheckoutMode(currentProject);
425
432
  } else {
426
433
  delete currentProject.checkoutMode;
427
434
  }
@@ -609,7 +616,7 @@ function inferActionPrRecord(action, prs, project = null) {
609
616
  // real pull request before `copyWorkItemPrFields` promotes it to structured
610
617
  // targetPr/pr_id/prNumber fields. GitHub & ADO issues share the PR number
611
618
  // 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
619
+ // issue mints a phantom tracked PR record (agent 'human', goes
613
620
  // abandoned) AND routes the dispatch through the PR-fix path instead of
614
621
  // implement-and-open-a-fresh-PR.
615
622
  //
@@ -767,7 +774,7 @@ async function _fetchLivePrRecord(canonicalId, opts) {
767
774
  function _findTrackedPrRecord(prRef, project) {
768
775
  if (!project) return null;
769
776
  try {
770
- const prs = shared.safeJsonArr(shared.projectPrPath(project));
777
+ const prs = shared.readPullRequests(project);
771
778
  return shared.findPrRecord(prs, prRef, project);
772
779
  } catch { return null; }
773
780
  }
@@ -831,7 +838,7 @@ async function copyWorkItemPrFields(item, input, pr = null, opts = {}) {
831
838
  if (structuredRef) {
832
839
  prRef = structuredRef; // explicit operator intent — trusted, no verification
833
840
  } else {
834
- if (String(item?.type || '').toLowerCase() !== WORK_TYPE.FIX) return;
841
+ if (!shared.isFixLikeWorkType(String(item?.type || '').toLowerCase())) return;
835
842
  const looseRef = getWorkItemPrRef(input);
836
843
  if (!looseRef) return;
837
844
  // issue #246 — confirm the loose ref is a PR (not an issue) before stamping.
@@ -919,14 +926,13 @@ function normalizeWorkItemDedupTitle(value) {
919
926
  .toLowerCase();
920
927
  }
921
928
 
922
- function resolveWorkItemDedupProject(item, wiPath = '') {
929
+ function resolveWorkItemDedupProject(item, project = null) {
923
930
  const projectName = normalizeWorkItemDedupText(item?.project || item?._project || item?._source);
924
931
  if (projectName) {
925
932
  const namedProject = shared.resolveProjectSource(projectName, PROJECTS, { allowCentral: false });
926
933
  if (namedProject.project) return namedProject.project;
927
934
  }
928
- if (!wiPath) return null;
929
- return shared.resolveProjectSource(wiPath, PROJECTS, { allowCentral: false }).project || null;
935
+ return project;
930
936
  }
931
937
 
932
938
  function getWorkItemPrRefCandidates(item) {
@@ -962,7 +968,7 @@ function normalizeWorkItemDedupPrIdentity(item, project = null) {
962
968
  }
963
969
 
964
970
  function workItemCreateFingerprint(item, options = {}) {
965
- const project = resolveWorkItemDedupProject(item, options.wiPath);
971
+ const project = resolveWorkItemDedupProject(item, options.project);
966
972
  return {
967
973
  title: normalizeWorkItemDedupTitle(item?.title),
968
974
  type: routing.normalizeWorkType(item?.type || item?.workType, WORK_TYPE.IMPLEMENT),
@@ -1015,10 +1021,11 @@ function findDuplicateWorkItemCreate(items, candidate, options = {}) {
1015
1021
  }) || null;
1016
1022
  }
1017
1023
 
1018
- function createWorkItemWithDedup(wiPath, item, options = {}) {
1024
+ function createWorkItemWithDedup(source, item, options = {}) {
1025
+ const project = options.project || (source && typeof source === 'object' ? source : null);
1019
1026
  let result = null;
1020
- mutateWorkItems(wiPath, items => {
1021
- const existing = findDuplicateWorkItemCreate(items, item, { ...options, wiPath });
1027
+ mutateWorkItems(source, items => {
1028
+ const existing = findDuplicateWorkItemCreate(items, item, { ...options, project });
1022
1029
  if (existing) {
1023
1030
  result = { created: false, item: existing, duplicateOf: existing.id };
1024
1031
  return items;
@@ -1153,21 +1160,26 @@ function dispatchPrefixForResolvedSource(target) {
1153
1160
  return target?.project ? `work-${target.project.name}-` : 'central-work-';
1154
1161
  }
1155
1162
 
1163
+ function matchesResolvedWorkItemDispatch(entry, target, id) {
1164
+ const dispatchKey = dispatchPrefixForResolvedSource(target) + id;
1165
+ return entry?.meta?.dispatchKey === dispatchKey || entry?.meta?.parentKey === dispatchKey;
1166
+ }
1167
+
1156
1168
  function findWorkItemsTargetById(id, source, projects = PROJECTS) {
1157
1169
  const explicitSource = source !== undefined && source !== null && String(source).trim() !== '';
1158
1170
  if (explicitSource) {
1159
1171
  const target = resolveProjectSourceTarget(source, projects);
1160
1172
  if (target.error) return { error: target.error };
1161
- const items = shared.safeJsonArr(target.wiPath);
1173
+ const items = shared.readWorkItems(target.scope);
1162
1174
  return { ...target, found: items.some(i => i.id === id) };
1163
1175
  }
1164
1176
 
1165
1177
  const central = resolveProjectSourceTarget('central', projects);
1166
- const centralItems = shared.safeJsonArr(central.wiPath);
1178
+ const centralItems = shared.readWorkItems(central.scope);
1167
1179
  if (centralItems.some(i => i.id === id)) return { ...central, found: true };
1168
1180
  for (const project of projects) {
1169
1181
  const target = resolveProjectSourceTarget(project.name, projects);
1170
- const items = shared.safeJsonArr(target.wiPath);
1182
+ const items = shared.readWorkItems(target.scope);
1171
1183
  if (items.some(i => i.id === id)) return { ...target, found: true };
1172
1184
  }
1173
1185
  return { found: false };
@@ -1314,23 +1326,18 @@ function validatePipelineProjects(pipeline, projects = PROJECTS) {
1314
1326
  }
1315
1327
 
1316
1328
  /**
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.
1329
+ * Aggregate archived work items from central and project SQL scopes.
1322
1330
  *
1323
1331
  * Exported for testing (P-h3arch-8c19).
1324
1332
  */
1325
- function collectArchivedWorkItems(minionsDir = MINIONS_DIR, projects = PROJECTS) {
1333
+ function collectArchivedWorkItems(projects = PROJECTS) {
1334
+ const store = require('./engine/work-items-store');
1326
1335
  const archived = [];
1327
- const centralPath = path.join(minionsDir, 'work-items-archive.json');
1328
- for (const item of safeJsonArr(centralPath)) {
1336
+ for (const item of store.readArchivedWorkItemsForScope('central')) {
1329
1337
  archived.push({ ...item, _source: 'central' });
1330
1338
  }
1331
1339
  for (const project of projects) {
1332
- const archPath = shared.projectWorkItemsPath(project).replace('.json', '-archive.json');
1333
- for (const item of safeJsonArr(archPath)) {
1340
+ for (const item of store.readArchivedWorkItemsForScope(project.name)) {
1334
1341
  archived.push({ ...item, _source: project.name });
1335
1342
  }
1336
1343
  }
@@ -1344,7 +1351,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
1344
1351
  }
1345
1352
  const projects = shared.getProjects(config);
1346
1353
  const { project: targetProject, resolution: projectResolution } = resolveManualPrLinkProject(url, projectName, projects);
1347
- const prPath = targetProject ? shared.projectPrPath(targetProject) : shared.centralPullRequestsPath(MINIONS_DIR);
1354
+ const source = targetProject || 'central';
1348
1355
 
1349
1356
  const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
1350
1357
  const prNum = prNumMatch ? (prNumMatch[1] || prNumMatch[2]) : Date.now().toString().slice(-6);
@@ -1353,7 +1360,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
1353
1360
  const contextText = typeof context === 'string' ? context : (context == null ? '' : JSON.stringify(context));
1354
1361
  const metadata = normalizePrMetadata(options.metadata);
1355
1362
  const resolvedContextOnly = typeof contextOnly === 'boolean' ? contextOnly : false;
1356
- const result = shared.upsertPullRequestRecord(prPath, {
1363
+ const result = shared.upsertPullRequestRecord(source, {
1357
1364
  id: prId,
1358
1365
  prNumber: parseInt(prNum, 10) || null,
1359
1366
  title: (metadata?.title || title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
@@ -1382,7 +1389,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
1382
1389
  // tombstone left by a prior explicit delete rather than silently no-op.
1383
1390
  allowResurrect: true,
1384
1391
  });
1385
- return { ...result, prPath, targetProject, projectResolution, prNum };
1392
+ return { ...result, scope: shared.stateScope(source), targetProject, projectResolution, prNum };
1386
1393
  }
1387
1394
 
1388
1395
  // W-mpmwxkzm0009ba0b — Per-row auto-observe toggle backing helper for
@@ -1394,9 +1401,9 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
1394
1401
  // fallback (`contextOnly = !observe`) for any caller that still sends it —
1395
1402
  // the dashboard client itself now sends `contextOnly` exclusively
1396
1403
  // (P-2b6e4d81; see docs/deprecated.json#pr-observe-observe-body-param).
1397
- // Returns the updated record + the PR path that was touched. Throws an
1404
+ // Returns the updated record + SQL scope. Throws an
1398
1405
  // 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) {
1406
+ function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly } = {}, config = CONFIG) {
1400
1407
  const hostStr = String(host || '').trim().toLowerCase();
1401
1408
  const slugStr = String(slug || '').trim();
1402
1409
  const numberInt = Number.parseInt(number, 10);
@@ -1428,21 +1435,18 @@ function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly
1428
1435
 
1429
1436
  const canonicalId = `${hostStr}:${slugStr}#${numberInt}`;
1430
1437
  const projects = shared.getProjects(config);
1431
- const prPaths = [
1432
- ...projects.map(p => shared.projectPrPath(p)),
1433
- shared.centralPullRequestsPath(minionsDir),
1434
- ];
1438
+ const sources = [...projects, 'central'];
1435
1439
 
1436
1440
  let updated = null;
1437
- let updatedPath = null;
1438
- for (const prPath of prPaths) {
1441
+ let updatedScope = null;
1442
+ for (const source of sources) {
1439
1443
  if (updated) break;
1440
- shared.mutatePullRequests(prPath, (prs) => {
1444
+ shared.mutatePullRequests(source, (prs) => {
1441
1445
  const pr = prs.find(p => p && p.id === canonicalId);
1442
1446
  if (!pr) return prs;
1443
1447
  pr.contextOnly = contextOnlyValue;
1444
1448
  updated = { id: pr.id, contextOnly: pr.contextOnly };
1445
- updatedPath = prPath;
1449
+ updatedScope = shared.stateScope(source);
1446
1450
  return prs;
1447
1451
  });
1448
1452
  }
@@ -1452,7 +1456,7 @@ function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly
1452
1456
  err.statusCode = 404;
1453
1457
  throw err;
1454
1458
  }
1455
- return { ...updated, prPath: updatedPath };
1459
+ return { ...updated, scope: updatedScope };
1456
1460
  }
1457
1461
 
1458
1462
  function _normalizeSkillDirForCompare(dir) {
@@ -1659,24 +1663,12 @@ const PLANS_CACHE_TTL_MS = 5000;
1659
1663
  function invalidatePlansCache() { _plansCache = null; _plansCacheTs = 0; }
1660
1664
 
1661
1665
  function statePathExists(filePath) {
1662
- if (prdStore.parsePrdPath(filePath)) return prdStore.readPrd(filePath) != null;
1663
1666
  return fs.existsSync(filePath);
1664
1667
  }
1665
1668
 
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.
1669
+ // Resolve a human-authored Markdown plan path.
1668
1670
  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/
1671
+ if (!file.endsWith('.md')) throw new Error('expected a Markdown plan filename');
1680
1672
  shared.sanitizePath(file, PLANS_DIR);
1681
1673
  const active = path.join(PLANS_DIR, file);
1682
1674
  if (fs.existsSync(active)) return active;
@@ -1685,72 +1677,36 @@ function resolvePlanPath(file) {
1685
1677
  return active;
1686
1678
  }
1687
1679
 
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;
1680
+ // Archive a PRD in SQL, then move its source Markdown plan.
1681
+ function _archivePrdPostProcess({ planFile, plansDir, _mutate, _read } = {}) {
1682
+ const mutate = typeof _mutate === 'function' ? _mutate : prdStore.mutatePrd;
1683
+ const read = typeof _read === 'function' ? _read : prdStore.readPrd;
1701
1684
  const archiveWarnings = [];
1702
1685
  let plan = {};
1703
1686
  let archivedSource = null;
1704
1687
 
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
1688
  try {
1711
- plan = mutate(archivePath, (data) => {
1689
+ plan = mutate(planFile, (data) => {
1712
1690
  if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
1713
1691
  data.status = 'archived';
1714
1692
  data.archived = true;
1715
1693
  data.archivedAt = new Date().toISOString();
1716
1694
  return data;
1717
- }, { defaultValue: {} }) || {};
1695
+ }) || {};
1718
1696
  } catch (e) {
1719
1697
  const warning = `Archive status flip failed for ${planFile}: ${e.message}`;
1720
1698
  archiveWarnings.push(warning);
1721
1699
  console.warn(warning);
1722
1700
  }
1723
1701
 
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
1702
  if (!plan.source_plan) {
1728
1703
  try {
1729
- const disk = readObj(archivePath);
1730
- if (disk && typeof disk === 'object' && disk.source_plan) plan = disk;
1731
- } catch { /* readObj fallback is best-effort */ }
1704
+ const stored = read(planFile);
1705
+ if (stored && typeof stored === 'object' && stored.source_plan) plan = stored;
1706
+ } catch { /* best-effort */ }
1732
1707
  }
1733
1708
 
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/.
1709
+ // Move the source plan Markdown into plans/archive/.
1754
1710
  if (plan.source_plan) {
1755
1711
  try {
1756
1712
  // Canonical key: a `plans/`-prefixed source_plan would otherwise join to
@@ -2398,18 +2354,15 @@ function _ifNoneMatchHasEtag(headerValue, currentEtag) {
2398
2354
  return false;
2399
2355
  }
2400
2356
 
2401
- // mtime-based cache invalidation.
2357
+ // Cross-process cache invalidation.
2402
2358
  //
2403
2359
  // Engine and dashboard are independent processes; `invalidateStatusCache()`
2404
2360
  // 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.
2361
+ // SQL mutations advance the events table; intentionally file-backed inputs
2362
+ // still use mtime tracking.
2409
2363
  //
2410
2364
  // 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, …).
2365
+ // - getStatusFastStateMtimePaths — file-backed hot inputs.
2413
2366
  // - getStatusSlowStateMtimePaths — slower-cadence writes (PRD JSON,
2414
2367
  // schedule/pipeline runs, pinned, skill discovery roots, MCP configs,
2415
2368
  // git refs, …).
@@ -2591,26 +2544,7 @@ function _buildStatusSlowState() {
2591
2544
  ...status,
2592
2545
  mainBranch,
2593
2546
  branchMismatch,
2594
- // P-a3f9b209 / W-mqgzcrln002613b3 — surface the per-project dispatch
2595
- // mode so the Projects view can render a "Worktrees" vs "Live checkout"
2596
- // pill. resolveCheckoutMode honors the legacy worktreeMode field and
2597
- // defaults to 'worktree' when unset (matches engine spawn behavior).
2598
- checkoutMode: shared.resolveCheckoutMode(p),
2599
- // Surface the hybrid live-validation type (when configured) so the
2600
- // Projects view can render a distinct "Hybrid" pill: coding WIs author
2601
- // in isolated worktrees while only the named validation type runs
2602
- // in-place on the live checkout. Only meaningful when checkoutMode is
2603
- // 'live'; null otherwise (full-live or worktree projects).
2604
- liveValidationType: (shared.resolveCheckoutMode(p) === 'live' && p.liveValidation && p.liveValidation.type)
2605
- ? p.liveValidation.type
2606
- : null,
2607
- // W-mr2q361a00097e5c — surface liveValidation.autoDispatch alongside
2608
- // .type so the hybrid picker can preselect the "auto-validate"
2609
- // checkbox and the pill can visibly distinguish manual vs
2610
- // auto-validating hybrid projects. Only meaningful when checkoutMode
2611
- // is 'live'; false otherwise (matches the implicit today-default of
2612
- // autoDispatch being absent/false).
2613
- liveValidationAutoDispatch: !!(shared.resolveCheckoutMode(p) === 'live' && p.liveValidation && p.liveValidation.autoDispatch === true),
2547
+ ..._projectCheckoutConfigView(p),
2614
2548
  };
2615
2549
  }),
2616
2550
  autoMode: {
@@ -2937,18 +2871,10 @@ const STATE_READ_ALLOWED_FILES = new Set([
2937
2871
  const STATE_READ_DENIED_BASENAMES = new Set([
2938
2872
  'session.json',
2939
2873
  'sessions.json',
2940
- 'cc-session.json',
2941
- 'cc-sessions.json',
2942
- 'qa-sessions.json',
2943
2874
  'qa-sessions-history.json',
2944
2875
  'session-state.json',
2945
2876
  'session-token.json',
2946
2877
  '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
2878
  ]);
2953
2879
  const STATE_READ_DENIED_PATTERNS = [
2954
2880
  /(^|[\\/])state\.db($|-wal$|-shm$)/i,
@@ -2989,7 +2915,7 @@ function _stateReadContentType(ext) {
2989
2915
  // serveFreshJson is "run server-side enrichment, ETag off input mtimes."
2990
2916
  //
2991
2917
  // Used for slices whose payload joins multiple files (agents derived from
2992
- // config + dispatch + inbox + steering, metrics derived from metrics.json
2918
+ // config + dispatch + inbox + steering, with SQL-backed metrics
2993
2919
  // + dispatch + PRs, PRD derived from PRD dir + work-items + PRs, etc.).
2994
2920
  // Replicating those joins in browser JS would be brittle, and shipping the
2995
2921
  // fully-enriched result through /api/status pays the staleness tax that
@@ -3079,6 +3005,44 @@ async function serveFreshJson(req, res, opts) {
3079
3005
  res.end(JSON.stringify(payload));
3080
3006
  }
3081
3007
 
3008
+ function _buildConstellationBridgeSnapshot() {
3009
+ const config = queries.getConfig() || {};
3010
+ const scheduleRuns = smallStateStore.readScheduleRuns();
3011
+ const schedules = (Array.isArray(config.schedules) ? config.schedules : []).map(schedule => {
3012
+ const run = scheduleRuns[schedule.id];
3013
+ const lastRun = typeof run === 'string'
3014
+ ? run
3015
+ : (run?.lastRun || run?.lastCompletedAt || null);
3016
+ return { ...schedule, _lastRun: lastRun };
3017
+ });
3018
+ const plans = prdStore.listPrdRows().map(row => {
3019
+ const plan = row.plan && typeof row.plan === 'object' ? row.plan : {};
3020
+ return {
3021
+ id: row.filename,
3022
+ title: plan.plan_summary || plan.title || row.filename,
3023
+ status: row.archived
3024
+ ? 'archived'
3025
+ : (plan.status || (plan.requires_approval && !plan.approvedAt ? 'awaiting-approval' : 'active')),
3026
+ agent: plan.generated_by || null,
3027
+ project: plan.project || null,
3028
+ createdAt: plan.generated_at || null,
3029
+ updatedAt: row.updatedAt || null,
3030
+ };
3031
+ });
3032
+
3033
+ return require('./engine/bridge').buildBridgeSnapshot({
3034
+ minionsVersion: _dashboardVersion.codeVersion,
3035
+ control: queries.getControl(),
3036
+ projects: shared.getProjects(config),
3037
+ workItems: queries.getWorkItems(config, { enrich: false }),
3038
+ pullRequests: queries.getPullRequests(config),
3039
+ plans,
3040
+ schedules,
3041
+ agents: queries.getAgents(config),
3042
+ dispatch: queries.getDispatch(),
3043
+ });
3044
+ }
3045
+
3082
3046
  function handleStateRead(req, res) {
3083
3047
  const urlPath = req.url.split('?')[0];
3084
3048
  const rel = decodeURIComponent(urlPath.slice('/state/'.length));
@@ -3285,7 +3249,7 @@ async function _handleStatusRequest(req, res) {
3285
3249
  }
3286
3250
  }
3287
3251
 
3288
- // Periodic push for engine-driven changes (dispatch.json, work-items.json, pull-requests.json) that bypass invalidateStatusCache
3252
+ // Periodic push for engine-driven SQL state changes that bypass invalidateStatusCache.
3289
3253
  setInterval(() => {
3290
3254
  if (_statusStreamClients.size === 0) return;
3291
3255
  const data = getStatusJson();
@@ -3836,14 +3800,12 @@ function _filterCcTabSessions(sessions) {
3836
3800
  }
3837
3801
 
3838
3802
  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.
3803
+ if (!prune) return _filterCcTabSessions(smallStateStore.readCcSessions());
3842
3804
  let sessions;
3843
- mutateJsonFileLocked(CC_SESSIONS_PATH, (raw) => {
3805
+ smallStateStore.applyCcSessionsMutation((raw) => {
3844
3806
  sessions = _filterCcTabSessions(raw);
3845
3807
  return sessions;
3846
- }, { defaultValue: [] });
3808
+ });
3847
3809
  return sessions;
3848
3810
  }
3849
3811
 
@@ -3951,7 +3913,7 @@ function _joinCcPromptParts(...parts) {
3951
3913
  // only restore-time validity checks here are sessionId presence (anything
3952
3914
  // else would auto-expire the user's chat without their consent).
3953
3915
  try {
3954
- const saved = safeJson(path.join(ENGINE_DIR, 'cc-session.json'));
3916
+ const saved = smallStateStore.readCcGlobalSession();
3955
3917
  if (saved && saved.sessionId) ccSession = saved;
3956
3918
  } catch { /* optional */ }
3957
3919
 
@@ -4138,20 +4100,40 @@ function _resetPreambleCache() {
4138
4100
  _preambleCacheTs = 0;
4139
4101
  }
4140
4102
 
4141
- // Compact one-word-ish label for a project's effective dispatch checkout mode,
4142
- // for CC state surfaces (preamble + refresh). 'worktree' | 'live' |
4143
- // 'hybrid (live-validation: <type>)' so CC can see at a glance whether a project
4144
- // authors in isolated worktrees, runs in-place on the operator tree, or splits
4145
- // the two (coding → worktree, validation type → live). See resolveCheckoutMode.
4103
+ function _projectLiveValidationTypeValue(p) {
4104
+ const types = shared.resolveLiveValidationTypes(p);
4105
+ if (types.length === 0) return null;
4106
+ return Array.isArray(p?.liveValidation?.type) ? types : types[0];
4107
+ }
4108
+
4109
+ function _projectCheckoutConfigView(p) {
4110
+ const checkoutMode = shared.resolveCheckoutMode(p);
4111
+ return {
4112
+ checkoutMode,
4113
+ liveValidationType: checkoutMode === 'live' ? _projectLiveValidationTypeValue(p) : null,
4114
+ liveValidationInvalid: checkoutMode === 'live' && shared.isLiveValidationConfigInvalid(p),
4115
+ liveValidationAutoDispatch: !!shared.resolveLiveValidationAutoDispatchType(p),
4116
+ };
4117
+ }
4118
+
4119
+ // Compact checkout-mode label for Command Center state surfaces.
4146
4120
  function _projectCheckoutModeLabel(p) {
4147
- const mode = shared.resolveCheckoutMode(p);
4148
- if (mode === 'live' && p && p.liveValidation && p.liveValidation.type) {
4149
- // liveValidation.type may be a single string (legacy) or an array of
4150
- // strings (W-mr2m1ute000a9c01 — multi-select hybrid types); render all.
4151
- const types = Array.isArray(p.liveValidation.type) ? p.liveValidation.type : [p.liveValidation.type];
4121
+ const {
4122
+ checkoutMode,
4123
+ liveValidationType,
4124
+ liveValidationInvalid,
4125
+ } = _projectCheckoutConfigView(p);
4126
+ if (liveValidationInvalid) {
4127
+ const effectiveTypes = liveValidationType
4128
+ ? (Array.isArray(liveValidationType) ? liveValidationType : [liveValidationType])
4129
+ : [];
4130
+ return `hybrid (invalid liveValidation; effective live types: ${effectiveTypes.join(', ') || 'none'})`;
4131
+ }
4132
+ if (checkoutMode === 'live' && liveValidationType) {
4133
+ const types = Array.isArray(liveValidationType) ? liveValidationType : [liveValidationType];
4152
4134
  return `hybrid (live-validation: ${types.join(', ')})`;
4153
4135
  }
4154
- return mode;
4136
+ return checkoutMode;
4155
4137
  }
4156
4138
 
4157
4139
  function buildCCStatePreamble() {
@@ -4168,7 +4150,10 @@ function buildCCStatePreamble() {
4168
4150
  const prCount = getPullRequests().length;
4169
4151
  const wiCount = getWorkItems(null, { enrich: false }).length;
4170
4152
 
4171
- const planFiles = [...safeReadDir(PLANS_DIR), ...safeReadDir(PRD_DIR)].filter(f => f.endsWith('.md') || f.endsWith('.json'));
4153
+ const planFiles = [
4154
+ ...safeReadDir(PLANS_DIR).filter(file => file.endsWith('.md')),
4155
+ ...prdStore.listPrdRows().map(row => row.filename),
4156
+ ];
4172
4157
 
4173
4158
  const schedules = CONFIG.schedules || [];
4174
4159
  const enabledSchedules = schedules.filter(s => s.enabled !== false).length;
@@ -4321,13 +4306,13 @@ function getWorkItemPrRef(input) {
4321
4306
  // afford to be loose because gating would still require explicit
4322
4307
  // operator intent.
4323
4308
  //
4324
- // FIX-TYPE GATE on the stamp path (W-mqbaby2a000pa8ee): in practice
4309
+ // FIX-LIKE GATE on the stamp path (W-mqbaby2a000pa8ee): in practice
4325
4310
  // the asymmetry leaked — the loose stamp writes to `item.pr_id` (a
4326
4311
  // canonical structured field), and the strict gate later reads
4327
4312
  // `item.pr_id` and sees the loose stamp AS IF it were structured
4328
4313
  // intent. To stop that leak without changing the loose detector
4329
4314
  // (which has other legitimate callers), `copyWorkItemPrFields` now
4330
- // gates the loose result on `item.type === "fix"`. Non-fix WIs only
4315
+ // gates the loose result through `isFixLikeWorkType`. Other WIs only
4331
4316
  // get stamped from structured fields (incl. references/follow-up).
4332
4317
  // See the comment block on `copyWorkItemPrFields` above for details.
4333
4318
  return shared.extractWorkItemPrRef(input);
@@ -4340,12 +4325,13 @@ function getWorkItemPrRef(input) {
4340
4325
  // invalidation paths are prompt-hash mismatch (correctness — a stale system
4341
4326
  // prompt would carry the old persona/rules into resume turns) and the
4342
4327
  // 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
4328
  const DOC_SESSION_MAX_ENTRIES = shared.ENGINE_DEFAULTS.docSessionMaxEntries;
4347
4329
  const docSessions = new Map(); // key → { sessionId, lastActiveAt, turnCount }
4348
4330
 
4331
+ function persistCcSession() {
4332
+ smallStateStore.applyCcGlobalSessionMutation(() => ccSession);
4333
+ }
4334
+
4349
4335
  function _docSessionLastActiveMs(session) {
4350
4336
  const ms = Date.parse(session?.lastActiveAt || session?.createdAt || '');
4351
4337
  return Number.isFinite(ms) ? ms : 0;
@@ -4374,7 +4360,7 @@ function pruneDocSessions() {
4374
4360
  // Load persisted doc sessions on startup — no TTL/turn filtering, sessions
4375
4361
  // are non-expiring. Stale prompt-hash entries are dropped by pruneDocSessions.
4376
4362
  try {
4377
- const saved = safeJson(DOC_SESSIONS_PATH);
4363
+ const saved = smallStateStore.readDocSessions();
4378
4364
  if (saved && typeof saved === 'object') {
4379
4365
  for (const [key, s] of Object.entries(saved)) {
4380
4366
  docSessions.set(key, s);
@@ -4387,8 +4373,7 @@ function persistDocSessions() {
4387
4373
  pruneDocSessions();
4388
4374
  const obj = {};
4389
4375
  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: {} });
4376
+ smallStateStore.applyDocSessionsMutation(() => obj);
4392
4377
  }
4393
4378
 
4394
4379
  // Hourly hygiene sweep — drops prompt-hash mismatches and trims the LRU cap.
@@ -4456,7 +4441,7 @@ function updateSession(store, key, sessionId, existing) {
4456
4441
  turnCount: (existing ? ccSession.turnCount : 0) + 1,
4457
4442
  _promptHash: _ccPromptHash,
4458
4443
  };
4459
- mutateJsonFileLocked(CC_SESSION_PATH, () => ccSession, { defaultValue: {} });
4444
+ persistCcSession();
4460
4445
  } else if (key) {
4461
4446
  const prev = docSessions.get(key);
4462
4447
  docSessions.set(key, {
@@ -4831,7 +4816,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
4831
4816
  // Invalidate the dead session so future calls don't try to resume it
4832
4817
  if (store === 'cc') {
4833
4818
  ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
4834
- mutateJsonFileLocked(CC_SESSION_PATH, () => ccSession, { defaultValue: {} });
4819
+ persistCcSession();
4835
4820
  } else if (sessionKey) {
4836
4821
  docSessions.delete(sessionKey);
4837
4822
  schedulePersistDocSessions();
@@ -4980,7 +4965,7 @@ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext
4980
4965
  sessionId = null;
4981
4966
  if (store === 'cc') {
4982
4967
  ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
4983
- mutateJsonFileLocked(CC_SESSION_PATH, () => ccSession, { defaultValue: {} });
4968
+ persistCcSession();
4984
4969
  } else if (sessionKey) {
4985
4970
  docSessions.delete(sessionKey);
4986
4971
  schedulePersistDocSessions();
@@ -6061,36 +6046,20 @@ const server = http.createServer(async (req, res) => {
6061
6046
  try {
6062
6047
  const body = await readBody(req);
6063
6048
  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 });
6049
+ let projectPlan = prdStore.readPrd(body.file);
6050
+ if (!projectPlan && prdStore.readPrd(body.file, { archived: true })) {
6051
+ projectPlan = prdStore.restorePrdFromArchive(body.file);
6052
+ prdStore.mutatePrd(body.file, (data) => {
6053
+ data.status = 'approved';
6054
+ delete data.completedAt;
6055
+ delete data.planStale;
6056
+ return data;
6057
+ });
6058
+ projectPlan = prdStore.readPrd(body.file);
6087
6059
  }
6060
+ if (!projectPlan) return jsonReply(res, 404, { error: 'PRD not found' });
6088
6061
 
6089
6062
  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
6063
  const projectTarget = projectPlan?.project
6095
6064
  ? resolveProjectSourceTarget(projectPlan.project, PROJECTS, { allowCentral: false })
6096
6065
  : null;
@@ -6098,9 +6067,8 @@ const server = http.createServer(async (req, res) => {
6098
6067
 
6099
6068
  // Check for existing verify WI — reset to pending if already done (re-verify)
6100
6069
  if (project) {
6101
- const wiPath = shared.projectWorkItemsPath(project);
6102
6070
  let existingVerify = null;
6103
- mutateWorkItems(wiPath, items => {
6071
+ mutateWorkItems(project, items => {
6104
6072
  const v = items.find(w => w.sourcePlan === body.file && w.itemType === WORK_TYPE.VERIFY);
6105
6073
  if (v && (v.status === WI_STATUS.DONE || v.status === WI_STATUS.FAILED)) {
6106
6074
  // BUG-M10: use shared.reopenWorkItem so re-verify resets mirror
@@ -6119,17 +6087,16 @@ const server = http.createServer(async (req, res) => {
6119
6087
  }
6120
6088
 
6121
6089
  // No existing verify — clear completion flag and trigger fresh creation
6122
- mutateJsonFileLocked(activePath, (planData) => {
6090
+ prdStore.mutatePrd(body.file, (planData) => {
6123
6091
  if (planData?._completionNotified) planData._completionNotified = false;
6124
6092
  return planData;
6125
- }, { defaultValue: {}, skipWriteIfUnchanged: true });
6093
+ });
6126
6094
 
6127
6095
  const lifecycle = require('./engine/lifecycle');
6128
6096
  lifecycle.checkPlanCompletion({ item: { sourcePlan: body.file, id: 'manual' } }, config);
6129
6097
 
6130
6098
  if (project) {
6131
- const wiPath = shared.projectWorkItemsPath(project);
6132
- const items = safeJsonArr(wiPath);
6099
+ const items = shared.readWorkItems(project);
6133
6100
  const verify = items.find(w => w.sourcePlan === body.file && w.itemType === WORK_TYPE.VERIFY);
6134
6101
  if (verify) {
6135
6102
  invalidateStatusCache();
@@ -6147,36 +6114,24 @@ const server = http.createServer(async (req, res) => {
6147
6114
  const { id, source } = body;
6148
6115
  if (!id) return jsonReply(res, 400, { error: 'id required' });
6149
6116
 
6150
- // Find the right file — check source first, then search all project files
6117
+ // Resolve the owning SQL scope.
6151
6118
  let resolvedTarget = findWorkItemsTargetById(id, source, PROJECTS);
6152
6119
  if (resolvedTarget.error) return jsonReply(res, 404, { error: resolvedTarget.error });
6153
- let wiPath = resolvedTarget.found ? resolvedTarget.wiPath : null;
6120
+ let wiScope = resolvedTarget.found ? resolvedTarget.scope : null;
6154
6121
  // If no work item found, attempt to re-materialize from PRD item definition
6155
- if (!wiPath) {
6122
+ if (!wiScope) {
6156
6123
  const prdFile = body.prdFile;
6157
6124
  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);
6125
+ const plan = prdStore.readPrd(prdFile);
6171
6126
  if (!plan?.missing_features) return jsonReply(res, 404, { error: 'PRD file not found or invalid' });
6172
6127
  const prdItem = plan.missing_features.find(f => f.id === id);
6173
6128
  if (!prdItem) return jsonReply(res, 404, { error: 'PRD item not found in ' + prdFile });
6174
6129
 
6175
- // Determine target work-items file (project from PRD item or plan, fallback to central)
6130
+ // Determine target scope (project from PRD item or plan, fallback to central).
6176
6131
  const projName = prdItem.project || plan.project || prdFile.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
6177
6132
  const prdTarget = resolveProjectSourceTarget(projName, PROJECTS, { allowCentral: false });
6178
6133
  const proj = prdTarget.project;
6179
- const targetWiPath = proj ? shared.projectWorkItemsPath(proj) : shared.centralWorkItemsPath(MINIONS_DIR);
6134
+ const targetScope = proj || 'central';
6180
6135
 
6181
6136
  // Create new work item from PRD item definition (same logic as materializePlansAsWorkItems)
6182
6137
  const complexity = prdItem.estimated_complexity || 'medium';
@@ -6201,7 +6156,7 @@ const server = http.createServer(async (req, res) => {
6201
6156
  _source: proj?.name || 'central',
6202
6157
  _retryCount: 0,
6203
6158
  };
6204
- mutateWorkItems(targetWiPath, items => { items.push(newItem); });
6159
+ mutateWorkItems(targetScope, items => { items.push(newItem); });
6205
6160
 
6206
6161
  // Reset PRD item status to pending
6207
6162
  try {
@@ -6210,15 +6165,14 @@ const server = http.createServer(async (req, res) => {
6210
6165
  } catch (e) { console.error('PRD status sync:', e.message); }
6211
6166
 
6212
6167
  // Clear dispatch history and cooldowns for this item
6213
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
6214
6168
  const dispatchKey = (proj ? `work-${proj.name}-` : 'central-work-') + id;
6215
6169
  try {
6216
- mutateJsonFileLocked(dispatchPath, (dispatch) => {
6170
+ dispatchMod.mutateDispatch((dispatch) => {
6217
6171
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
6218
6172
  dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
6219
6173
  dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
6220
6174
  return dispatch;
6221
- }, { defaultValue: { pending: [], active: [], completed: [] } });
6175
+ });
6222
6176
  } catch (e) { console.error('dispatch cleanup:', e.message); }
6223
6177
  try {
6224
6178
  mutateCooldowns(cooldowns => {
@@ -6232,7 +6186,7 @@ const server = http.createServer(async (req, res) => {
6232
6186
 
6233
6187
  let found = false;
6234
6188
  let blocked = null;
6235
- mutateJsonFileLocked(wiPath, (items) => {
6189
+ mutateWorkItems(wiScope, (items) => {
6236
6190
  if (!Array.isArray(items)) items = [];
6237
6191
  const item = items.find(i => i.id === id);
6238
6192
  if (!item) return items;
@@ -6279,15 +6233,14 @@ const server = http.createServer(async (req, res) => {
6279
6233
  if (!found) return jsonReply(res, 404, { error: 'item not found' });
6280
6234
 
6281
6235
  // Clear completed dispatch entries so the engine doesn't dedup this item
6282
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
6283
6236
  const dispatchKey = dispatchPrefixForResolvedSource(resolvedTarget) + id;
6284
6237
  try {
6285
- mutateJsonFileLocked(dispatchPath, (dispatch) => {
6238
+ dispatchMod.mutateDispatch((dispatch) => {
6286
6239
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
6287
6240
  dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
6288
6241
  dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
6289
6242
  return dispatch;
6290
- }, { defaultValue: { pending: [], active: [], completed: [] } });
6243
+ });
6291
6244
  } catch (e) { console.error('dispatch cleanup:', e.message); }
6292
6245
 
6293
6246
  // Clear cooldown so item isn't blocked by exponential backoff
@@ -6310,11 +6263,10 @@ const server = http.createServer(async (req, res) => {
6310
6263
 
6311
6264
  const target = resolveProjectSourceTarget(source, PROJECTS);
6312
6265
  if (target.error) return jsonReply(res, 404, { error: target.error });
6313
- const wiPath = target.wiPath;
6314
6266
 
6315
6267
  let item = null;
6316
6268
  let found = false;
6317
- mutateJsonFileLocked(wiPath, (items) => {
6269
+ mutateWorkItems(target.scope, (items) => {
6318
6270
  if (!Array.isArray(items)) return items;
6319
6271
  const idx = items.findIndex(i => i.id === id);
6320
6272
  if (idx === -1) return items;
@@ -6322,21 +6274,16 @@ const server = http.createServer(async (req, res) => {
6322
6274
  items.splice(idx, 1);
6323
6275
  found = true;
6324
6276
  return items;
6325
- }, { defaultValue: [] });
6277
+ });
6326
6278
  if (!found) return jsonReply(res, 404, { error: 'item not found' });
6327
6279
 
6328
6280
  // 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
- );
6281
+ const dispatchRemoved = cleanDispatchEntries(d => matchesResolvedWorkItemDispatch(d, target, id));
6333
6282
 
6334
6283
  // Clean cooldown entries so item can be re-created immediately
6335
6284
  try {
6336
6285
  mutateCooldowns(cooldowns => {
6337
- for (const key of Object.keys(cooldowns)) {
6338
- if (key.includes(id)) delete cooldowns[key];
6339
- }
6286
+ delete cooldowns[dispatchPrefixForResolvedSource(target) + id];
6340
6287
  return cooldowns;
6341
6288
  });
6342
6289
  } catch (e) { console.error('cooldown cleanup:', e.message); }
@@ -6362,10 +6309,9 @@ const server = http.createServer(async (req, res) => {
6362
6309
 
6363
6310
  const target = resolveProjectSourceTarget(source, PROJECTS);
6364
6311
  if (target.error) return jsonReply(res, 404, { error: target.error });
6365
- const wiPath = target.wiPath;
6366
6312
 
6367
6313
  let result = null;
6368
- mutateJsonFileLocked(wiPath, (items) => {
6314
+ mutateWorkItems(target.scope, (items) => {
6369
6315
  if (!Array.isArray(items)) items = [];
6370
6316
  const item = items.find(i => i.id === id);
6371
6317
  if (!item) { result = { code: 404, body: { error: 'item not found' } }; return items; }
@@ -6384,17 +6330,12 @@ const server = http.createServer(async (req, res) => {
6384
6330
  if (result.code !== 200) return jsonReply(res, result.code, result.body);
6385
6331
 
6386
6332
  // 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
- );
6333
+ const dispatchRemoved = cleanDispatchEntries(d => matchesResolvedWorkItemDispatch(d, target, id));
6391
6334
 
6392
6335
  // Clean cooldown entries
6393
6336
  try {
6394
6337
  mutateCooldowns(cooldowns => {
6395
- for (const key of Object.keys(cooldowns)) {
6396
- if (key.includes(id)) delete cooldowns[key];
6397
- }
6338
+ delete cooldowns[dispatchPrefixForResolvedSource(target) + id];
6398
6339
  return cooldowns;
6399
6340
  });
6400
6341
  } catch (e) { console.error('cooldown cleanup on cancel:', e.message); }
@@ -6412,37 +6353,13 @@ const server = http.createServer(async (req, res) => {
6412
6353
 
6413
6354
  const target = resolveProjectSourceTarget(source, PROJECTS);
6414
6355
  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
- });
6356
+ const archivedItem = require('./engine/work-items-store').archiveWorkItem(target.scope, id);
6426
6357
  if (!archivedItem) return jsonReply(res, 404, { error: 'item not found' });
6427
6358
 
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
6359
  // 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
- );
6360
+ cleanDispatchEntries(d => matchesResolvedWorkItemDispatch(d, target, id));
6442
6361
 
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.
6362
+ // Archiving removes the item from the active status snapshot.
6446
6363
  invalidateStatusCache();
6447
6364
  return jsonReply(res, 200, { ok: true, id });
6448
6365
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
@@ -6453,7 +6370,7 @@ const server = http.createServer(async (req, res) => {
6453
6370
  // collectArchivedWorkItems uses safeJsonArr (typed default + logged parse
6454
6371
  // failure), so a corrupt archive file is surfaced via console.error and
6455
6372
  // contributes zero items instead of taking down the whole listing.
6456
- return jsonReply(res, 200, collectArchivedWorkItems(MINIONS_DIR, PROJECTS));
6373
+ return jsonReply(res, 200, collectArchivedWorkItems(PROJECTS));
6457
6374
  } catch (e) { console.error('Archive fetch error:', e.message); return jsonReply(res, e.statusCode || 500, { error: e.message }); }
6458
6375
  }
6459
6376
 
@@ -6474,10 +6391,15 @@ const server = http.createServer(async (req, res) => {
6474
6391
  id === 'cancel' || id === 'reopen' || id === 'feedback') {
6475
6392
  return jsonReply(res, 404, { error: 'work item not found' });
6476
6393
  }
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);
6394
+ const source = (new URL(req.url, 'http://localhost').searchParams.get('source') || '').trim();
6395
+ const matches = getWorkItems().filter(w => w && w.id === id && (!source || w._source === source));
6396
+ if (!source && matches.length > 1) {
6397
+ return jsonReply(res, 409, {
6398
+ error: 'work item id is ambiguous; provide source',
6399
+ sources: matches.map(w => w._source),
6400
+ });
6401
+ }
6402
+ const found = matches[0];
6481
6403
  if (!found) return jsonReply(res, 404, { error: 'work item not found' });
6482
6404
  return jsonReply(res, 200, { item: found });
6483
6405
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
@@ -6526,10 +6448,9 @@ const server = http.createServer(async (req, res) => {
6526
6448
 
6527
6449
  const target = resolveProjectSourceTarget(project, PROJECTS);
6528
6450
  if (target.error) return jsonReply(res, 404, { error: target.error });
6529
- const wiPath = target.wiPath;
6530
6451
 
6531
6452
  let result = null;
6532
- mutateJsonFileLocked(wiPath, (items) => {
6453
+ mutateWorkItems(target.scope, (items) => {
6533
6454
  if (!Array.isArray(items)) items = [];
6534
6455
  const item = items.find(i => i.id === id);
6535
6456
  if (!item) { result = { code: 404, body: { error: 'item not found' } }; return items; }
@@ -6548,13 +6469,12 @@ const server = http.createServer(async (req, res) => {
6548
6469
  // Clear dispatch history and cooldowns outside lock
6549
6470
  const dispatchKey = dispatchPrefixForResolvedSource(target) + id;
6550
6471
  try {
6551
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
6552
- mutateJsonFileLocked(dispatchPath, (dispatch) => {
6472
+ dispatchMod.mutateDispatch((dispatch) => {
6553
6473
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
6554
6474
  dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
6555
6475
  dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
6556
6476
  return dispatch;
6557
- }, { defaultValue: { pending: [], active: [], completed: [] } });
6477
+ });
6558
6478
  } catch (e) { console.error('dispatch cleanup on reopen:', e.message); }
6559
6479
  try {
6560
6480
  mutateCooldowns(cooldowns => {
@@ -6624,7 +6544,7 @@ const server = http.createServer(async (req, res) => {
6624
6544
  }
6625
6545
  const target = resolveWorkItemsCreateTarget(body.project);
6626
6546
  if (target.error) return jsonReply(res, 400, { error: target.error });
6627
- const wiPath = target.wiPath;
6547
+ const wiScope = target.scope;
6628
6548
  const targetProject = target.project;
6629
6549
  const id = 'W-' + shared.uid();
6630
6550
  const item = {
@@ -6701,7 +6621,7 @@ const server = http.createServer(async (req, res) => {
6701
6621
  if (originAgent) item._originAgent = originAgent;
6702
6622
  if (originWi) item._originWi = originWi;
6703
6623
  await copyWorkItemPrFields(item, body, null, { project: targetProject });
6704
- // W-mq5wfh1v000e0da9 — Auto-enroll the PR into pull-requests.json when
6624
+ // W-mq5wfh1v000e0da9 — Auto-enroll the PR into tracked state when
6705
6625
  // this is a `type: fix/review/test` WI carrying a structured PR pointer and
6706
6626
  // the PR isn't tracked yet. Without this, the engine's `pr_not_found` gate
6707
6627
  // skips the WI forever (engine.js dispatch loop). Idempotent — safe to
@@ -6732,7 +6652,7 @@ const server = http.createServer(async (req, res) => {
6732
6652
  // statuses) and takes precedence when matched.
6733
6653
  if (validatedFollowup) {
6734
6654
  let followupConflict = null;
6735
- mutateWorkItems(wiPath, items => {
6655
+ mutateWorkItems(wiScope, items => {
6736
6656
  followupConflict = findExistingFollowupForComment(items, validatedFollowup.parent_comment_id);
6737
6657
  if (followupConflict) return items;
6738
6658
  items.push(item);
@@ -6751,7 +6671,7 @@ const server = http.createServer(async (req, res) => {
6751
6671
  invalidateStatusCache();
6752
6672
  return jsonReply(res, 200, { ok: true, id });
6753
6673
  }
6754
- const createResult = createWorkItemWithDedup(wiPath, item);
6674
+ const createResult = createWorkItemWithDedup(wiScope, item, { project: targetProject });
6755
6675
  if (!createResult.created) {
6756
6676
  const duplicateId = createResult.duplicateOf || createResult.item?.id;
6757
6677
  // W-mr466ocx000l1191 — record the CC turn creation on the dedup path
@@ -6803,10 +6723,9 @@ const server = http.createServer(async (req, res) => {
6803
6723
 
6804
6724
  const target = resolveProjectSourceTarget(source, PROJECTS);
6805
6725
  if (target.error) return jsonReply(res, 404, { error: target.error });
6806
- const wiPath = target.wiPath;
6726
+ const wiScope = target.scope;
6807
6727
 
6808
- // W-mrazefzz000358e3 — body.project reassigns which projects/<name>/
6809
- // work-items.json (or central) file owns this item. Resolve + validate
6728
+ // W-mrazefzz000358e3 — body.project reassigns the owning SQL scope.
6810
6729
  // it up front (mirrors resolveWorkItemsCreateTarget's create-path
6811
6730
  // contract) so an unknown project name 400s cleanly instead of the
6812
6731
  // historical silent no-op. `projectMove` stays null when body.project
@@ -6820,19 +6739,14 @@ const server = http.createServer(async (req, res) => {
6820
6739
  knownProjects: PROJECTS.map(p => p.name),
6821
6740
  });
6822
6741
  }
6823
- projectMove = { wiPath: projTarget.wiPath, projectName: projTarget.project ? projTarget.project.name : null };
6742
+ projectMove = { scope: projTarget.scope, projectName: projTarget.project ? projTarget.project.name : null };
6824
6743
  }
6825
6744
 
6826
6745
  let result = null;
6827
6746
  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
6747
  let movedItem = null;
6834
6748
  let needsMove = false;
6835
- mutateJsonFileLocked(wiPath, (items) => {
6749
+ mutateWorkItems(wiScope, (items) => {
6836
6750
  if (!Array.isArray(items)) items = [];
6837
6751
  const idx = items.findIndex(i => i.id === id);
6838
6752
  if (idx === -1) { result = { code: 404, body: { error: 'item not found' } }; return items; }
@@ -6869,23 +6783,7 @@ const server = http.createServer(async (req, res) => {
6869
6783
  }
6870
6784
  item.updatedAt = new Date().toISOString();
6871
6785
 
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)) {
6786
+ if (projectMove && projectMove.scope !== wiScope) {
6889
6787
  needsMove = true;
6890
6788
  movedItem = { ...item };
6891
6789
  if (projectMove.projectName) movedItem.project = projectMove.projectName;
@@ -6899,79 +6797,14 @@ const server = http.createServer(async (req, res) => {
6899
6797
 
6900
6798
  if (needsMove && result.code === 200) {
6901
6799
  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
- });
6800
+ const moved = require('./engine/work-items-store')
6801
+ .moveWorkItem(wiScope, projectMove.scope, id, movedItem);
6802
+ if (!moved) {
6803
+ return jsonReply(res, 404, { error: 'item disappeared before project move' });
6971
6804
  }
6972
-
6805
+ } catch (moveErr) {
6973
6806
  return jsonReply(res, 500, {
6974
- error: `project move failed while removing from source store; move was rolled back: ${spliceErr.message}`,
6807
+ error: `project move failed; item remains in its original scope: ${moveErr.message}`,
6975
6808
  });
6976
6809
  }
6977
6810
  }
@@ -7015,8 +6848,7 @@ const server = http.createServer(async (req, res) => {
7015
6848
  const planWorkItem = buildPlanWorkItem(body);
7016
6849
  if (planWorkItem.error) return jsonReply(res, 400, { error: planWorkItem.error });
7017
6850
  // 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); });
6851
+ mutateWorkItems('central', items => { items.push(planWorkItem.item); });
7020
6852
  recordCcTurnIfPresent(req, {
7021
6853
  kind: 'plan', id: planWorkItem.id, title: planWorkItem.item?.title || body.title.trim(), project: planWorkItem.item?.project || null,
7022
6854
  });
@@ -7032,8 +6864,7 @@ const server = http.createServer(async (req, res) => {
7032
6864
  if (manualPrd.error) return jsonReply(res, 400, { error: manualPrd.error });
7033
6865
 
7034
6866
  const planFile = 'manual-' + shared.uid() + '.json';
7035
- const manualPrdPath = path.join(PRD_DIR, planFile);
7036
- safeWrite(manualPrdPath, manualPrd.plan);
6867
+ prdStore.writePrd(planFile, manualPrd.plan);
7037
6868
  invalidatePlansCache();
7038
6869
  return jsonReply(res, 200, { ok: true, id: manualPrd.id, file: planFile });
7039
6870
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
@@ -7043,19 +6874,14 @@ const server = http.createServer(async (req, res) => {
7043
6874
  try {
7044
6875
  const body = await readBody(req);
7045
6876
  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
6877
  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);
6878
+ const preCheck = prdStore.readPrd(body.source);
6879
+ if (!preCheck) return jsonReply(res, 404, { error: 'plan not found' });
7053
6880
  const preItem = (preCheck.missing_features || []).find(f => f.id === body.itemId);
7054
6881
  if (!preItem) return jsonReply(res, 404, { error: 'item not found in plan' });
7055
6882
 
7056
- // Atomically read-modify-write under file lock
7057
6883
  let item;
7058
- mutateJsonFileLocked(planPath, (plan) => {
6884
+ prdStore.mutatePrd(body.source, (plan) => {
7059
6885
  const target = (plan.missing_features || []).find(f => f.id === body.itemId);
7060
6886
  if (!target) return plan; // TOCTOU: item deleted between pre-check and lock acquisition
7061
6887
  if (body.name !== undefined) target.name = body.name;
@@ -7072,13 +6898,10 @@ const server = http.createServer(async (req, res) => {
7072
6898
 
7073
6899
  // Feature 3: Sync edits to materialized work item if still pending
7074
6900
  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) {
6901
+ const workItemScopes = ['central', ...PROJECTS];
6902
+ for (const scope of workItemScopes) {
7080
6903
  try {
7081
- mutateWorkItems(wiPath, items => {
6904
+ mutateWorkItems(scope, items => {
7082
6905
  const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
7083
6906
  if (wi && wi.status === WI_STATUS.PENDING) {
7084
6907
  if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
@@ -7103,10 +6926,9 @@ const server = http.createServer(async (req, res) => {
7103
6926
  const body = await readBody(req);
7104
6927
  if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
7105
6928
  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' });
6929
+ if (!prdStore.readPrd(body.source)) return jsonReply(res, 404, { error: 'plan not found' });
7108
6930
  let removed = false;
7109
- mutateJsonFileLocked(planPath, (plan) => {
6931
+ prdStore.mutatePrd(body.source, (plan) => {
7110
6932
  if (!plan || Array.isArray(plan) || typeof plan !== 'object') plan = { missing_features: [] };
7111
6933
  const features = Array.isArray(plan.missing_features) ? plan.missing_features : [];
7112
6934
  const idx = features.findIndex(f => f.id === body.itemId);
@@ -7120,13 +6942,10 @@ const server = http.createServer(async (req, res) => {
7120
6942
 
7121
6943
  // Also remove any materialized work item for this plan item
7122
6944
  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) {
6945
+ const workItemScopes = ['central', ...PROJECTS];
6946
+ for (const scope of workItemScopes) {
7128
6947
  try {
7129
- mutateWorkItems(wiPath, items => {
6948
+ mutateWorkItems(scope, items => {
7130
6949
  const filtered = items.filter(w => !(w.sourcePlan === body.source && w.id === body.itemId));
7131
6950
  if (filtered.length < items.length) {
7132
6951
  cancelled = true;
@@ -7170,22 +6989,20 @@ const server = http.createServer(async (req, res) => {
7170
6989
  try { fs.unlinkSync(path.join(agentDir, 'steer.md')); } catch { /* optional */ }
7171
6990
 
7172
6991
  // 3. Remove all active dispatch entries for this agent
7173
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
7174
6992
  const removedIds = [];
7175
- mutateJsonFileLocked(dispatchPath, (dp) => {
6993
+ dispatchMod.mutateDispatch((dp) => {
7176
6994
  const removed = (dp.active || []).filter(d => d.agent === agentId);
7177
6995
  removed.forEach(d => removedIds.push(d.id));
7178
6996
  dp.active = (dp.active || []).filter(d => d.agent !== agentId);
7179
6997
  return dp;
7180
- }, { defaultValue: { pending: [], active: [], completed: [] } });
6998
+ });
7181
6999
 
7182
7000
  // 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));
7001
+ const workItemScopes = ['central', ...PROJECTS];
7185
7002
  let resetCount = 0;
7186
- for (const wiPath of allWiPaths) {
7003
+ for (const scope of workItemScopes) {
7187
7004
  try {
7188
- mutateWorkItems(wiPath, items => {
7005
+ mutateWorkItems(scope, items => {
7189
7006
  for (const item of items) {
7190
7007
  if (item.dispatched_to === agentId && item.status === WI_STATUS.DISPATCHED) {
7191
7008
  item.status = WI_STATUS.PENDING;
@@ -7214,8 +7031,7 @@ const server = http.createServer(async (req, res) => {
7214
7031
  if (!requestedAgent && !requestedTask) return jsonReply(res, 400, { error: 'agent or task required' });
7215
7032
 
7216
7033
  // 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);
7034
+ const dispatch = queries.getDispatch();
7219
7035
  const active = Array.isArray(dispatch.active) ? dispatch.active : [];
7220
7036
  const matchFn = (d) => {
7221
7037
  const matchAgent = requestedAgent && d.agent === requestedAgent;
@@ -7227,7 +7043,7 @@ const server = http.createServer(async (req, res) => {
7227
7043
  // Route PID resolution + kill + dispatch removal through the canonical primitive.
7228
7044
  // cleanDispatchEntries resolves the PID from engine/tmp/dispatch-<id>-*/pid-<id>.pid
7229
7045
  // (see shared.findDispatchPidFile), kills outside the dispatch lock, and removes
7230
- // the entry via mutateDispatch (SQL + JSON mirror in one atomic write).
7046
+ // the entry through the transactional dispatch store.
7231
7047
  // The defunct per-agent status sidecar reads/writes that used to live here are gone
7232
7048
  // (engine/queries.js documents that file no longer exists).
7233
7049
  if (cancelled.length > 0) {
@@ -7751,7 +7567,7 @@ const server = http.createServer(async (req, res) => {
7751
7567
  // path.join. Without this, cat='..' collapses kbCatDir to MINIONS_DIR
7752
7568
  // (path.join normalizes '..') and the next sanitizePath('config.json',
7753
7569
  // MINIONS_DIR) check still passes, allowing safeRead to disclose
7754
- // config.json / work-items.json / engine state to any browser the
7570
+ // config.json / runtime state to any browser the
7755
7571
  // operator has open. The whitelist also rejects encoded variants
7756
7572
  // (`%2e%2e`, `..%2f`, etc.) since none of those are valid category
7757
7573
  // names. Single source of truth lives in shared.KB_READABLE_CATEGORIES.
@@ -7991,12 +7807,17 @@ const server = http.createServer(async (req, res) => {
7991
7807
 
7992
7808
  async function handlePlansArchiveRead(req, res, match) {
7993
7809
  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));
7810
+ if (file.includes('..') || file.includes('\0') || file.includes('/') || file.includes('\\')) {
7811
+ return jsonReply(res, 400, { error: 'invalid' });
7812
+ }
7813
+ let content = '';
7814
+ if (file.endsWith('.json')) {
7815
+ const row = prdStore.listPrdRows()
7816
+ .find(candidate => candidate.filename === file && (candidate.archived || shared.isPrdArchived(candidate.plan)));
7817
+ if (row) content = JSON.stringify(row.plan, null, 2);
7818
+ } else {
7819
+ content = safeRead(path.join(PLANS_DIR, 'archive', file));
7820
+ }
8000
7821
  if (!content) return jsonReply(res, 404, { error: 'not found' });
8001
7822
  const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
8002
7823
  res.setHeader('Content-Type', contentType + '; charset=utf-8');
@@ -8007,17 +7828,30 @@ const server = http.createServer(async (req, res) => {
8007
7828
  async function handlePlansRead(req, res, match) {
8008
7829
  const file = decodeURIComponent(match[1]);
8009
7830
  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));
7831
+ let content = '';
7832
+ let resolvedPath = null;
7833
+ if (file.endsWith('.json')) {
7834
+ const row = prdStore.listPrdRows().find(candidate => candidate.filename === file);
7835
+ if (row) {
7836
+ content = JSON.stringify(row.plan, null, 2);
7837
+ res.setHeader('Last-Modified', new Date(row.updatedAt).toISOString());
7838
+ res.setHeader('X-Resolved-Path', `sql:prd/${row.archived ? 'archive/' : ''}${file}`);
7839
+ }
7840
+ } else {
7841
+ const candidates = [
7842
+ resolvePlanPath(file),
7843
+ path.join(PRD_DIR, 'guides', file),
7844
+ path.join(PLANS_DIR, 'archive', file),
7845
+ ];
7846
+ resolvedPath = candidates.find(candidate => fs.existsSync(candidate)) || null;
7847
+ if (resolvedPath) content = safeRead(resolvedPath);
7848
+ }
8017
7849
  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 */ } }
7850
+ if (resolvedPath) {
7851
+ const stat = fs.statSync(resolvedPath);
7852
+ res.setHeader('Last-Modified', stat.mtime.toISOString());
7853
+ res.setHeader('X-Resolved-Path', path.relative(MINIONS_DIR, resolvedPath).replace(/\\/g, '/'));
7854
+ }
8021
7855
  const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
8022
7856
  res.setHeader('Content-Type', contentType + '; charset=utf-8');
8023
7857
  res.setHeader('Cache-Control', 'no-cache');
@@ -8030,9 +7864,8 @@ const server = http.createServer(async (req, res) => {
8030
7864
  const body = await readBody(req);
8031
7865
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
8032
7866
  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
7867
  let wasStale = false;
8035
- const plan = mutateJsonFileLocked(planPath, (data) => {
7868
+ const plan = prdStore.mutatePrd(body.file, (data) => {
8036
7869
  if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
8037
7870
  wasStale = !!data.planStale;
8038
7871
  data.status = 'approved';
@@ -8042,7 +7875,7 @@ const server = http.createServer(async (req, res) => {
8042
7875
  delete data.planStale;
8043
7876
  delete data._completionNotified;
8044
7877
  return data;
8045
- }, { defaultValue: {} });
7878
+ });
8046
7879
 
8047
7880
  // W-mqacrzis0003df4a + W-mqfevwr60018bd09 — Fresh source-plan
8048
7881
  // staleness check (content-hash gated). Approve is the last gate
@@ -8064,13 +7897,10 @@ const server = http.createServer(async (req, res) => {
8064
7897
  // Resume paused work items across all projects
8065
7898
  let resumed = 0;
8066
7899
  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) {
7900
+ const workItemScopes = ['central', ...PROJECTS];
7901
+ for (const scope of workItemScopes) {
8072
7902
  try {
8073
- mutateJsonFileLocked(wiPath, (items) => {
7903
+ mutateWorkItems(scope, (items) => {
8074
7904
  if (!Array.isArray(items)) return items;
8075
7905
  for (const w of items) {
8076
7906
  if (w.sourcePlan === body.file && w.status === WI_STATUS.PAUSED && w._pausedBy === 'prd-pause') {
@@ -8082,19 +7912,18 @@ const server = http.createServer(async (req, res) => {
8082
7912
  }
8083
7913
  }
8084
7914
  return items;
8085
- }, { defaultValue: [] });
7915
+ });
8086
7916
  } catch (e) { console.error('resume work items:', e.message); }
8087
7917
  }
8088
7918
 
8089
7919
  // Clear dispatch completed entries for resumed items so they aren't dedup-blocked
8090
7920
  if (resumedItemIds.length > 0) {
8091
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
8092
7921
  const resumedSet = new Set(resumedItemIds);
8093
- mutateJsonFileLocked(dispatchPath, (dispatch) => {
7922
+ dispatchMod.mutateDispatch((dispatch) => {
8094
7923
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
8095
7924
  dispatch.completed = dispatch.completed.filter(d => !resumedSet.has(d.meta?.item?.id));
8096
7925
  return dispatch;
8097
- }, { defaultValue: { pending: [], active: [], completed: [] } });
7926
+ });
8098
7927
  }
8099
7928
 
8100
7929
  // Diff-aware PRD update: if plan was stale (source .md revised), dispatch plan-to-prd
@@ -8104,7 +7933,7 @@ const server = http.createServer(async (req, res) => {
8104
7933
  const config = queries.getConfig();
8105
7934
  const allWorkItems = queries.getWorkItems(config);
8106
7935
  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)));
7936
+ const allPrs = PROJECTS.flatMap(p => shared.readPullRequests(p));
8108
7937
  const prLinks = shared.getPrLinks();
8109
7938
  const implContext = (plan.missing_features || []).map(f => {
8110
7939
  const wi = planWis.find(w => w.id === f.id);
@@ -8146,19 +7975,18 @@ const server = http.createServer(async (req, res) => {
8146
7975
  // cleanup; pause never stopped regeneration.)
8147
7976
  function stopPlanMaterializedWork(prdFile, prdSourcePlan, opts) {
8148
7977
  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));
7978
+ const workItemScopes = ['central', ...PROJECTS];
8151
7979
 
8152
7980
  // Step 1: find dispatched item ids (read-only, no lock).
8153
7981
  const dispatchedItemIds = new Set();
8154
- for (const wiPath of wiPaths) {
7982
+ for (const scope of workItemScopes) {
8155
7983
  try {
8156
- for (const w of safeJsonArr(wiPath)) {
7984
+ for (const w of shared.readWorkItems(scope)) {
8157
7985
  if (w.sourcePlan !== prdFile) continue;
8158
7986
  if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
8159
7987
  if (w.status === WI_STATUS.DISPATCHED && w.id) dispatchedItemIds.add(w.id);
8160
7988
  }
8161
- } catch { /* file may not exist */ }
7989
+ } catch { /* scope may be unavailable */ }
8162
7990
  }
8163
7991
 
8164
7992
  // Step 2: kill active dispatches via the canonical primitive (resolves PIDs from
@@ -8172,11 +8000,11 @@ const server = http.createServer(async (req, res) => {
8172
8000
  });
8173
8001
  }
8174
8002
 
8175
- // Step 3: transition WIs per path (each lock held briefly, no nesting).
8003
+ // Step 3: transition WIs per scope.
8176
8004
  let affected = 0;
8177
- for (const wiPath of wiPaths) {
8005
+ for (const scope of workItemScopes) {
8178
8006
  try {
8179
- mutateWorkItems(wiPath, items => {
8007
+ mutateWorkItems(scope, items => {
8180
8008
  for (const w of items) {
8181
8009
  if (w.sourcePlan !== prdFile) continue;
8182
8010
  if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
@@ -8198,8 +8026,7 @@ const server = http.createServer(async (req, res) => {
8198
8026
  // stopped. (delete handles the DONE plan-to-prd WI separately to revert to draft.)
8199
8027
  if (prdSourcePlan) {
8200
8028
  try {
8201
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
8202
- mutateWorkItems(centralPath, items => {
8029
+ mutateWorkItems('central', items => {
8203
8030
  for (const w of items) {
8204
8031
  if (w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === prdSourcePlan &&
8205
8032
  !DONE_STATUSES.has(w.status) && w.status !== WI_STATUS.CANCELLED) {
@@ -8218,14 +8045,13 @@ const server = http.createServer(async (req, res) => {
8218
8045
  const body = await readBody(req);
8219
8046
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
8220
8047
  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
8048
  let prdSourcePlan = null;
8223
- const updated = mutateJsonFileLocked(planPath, (plan) => {
8049
+ const updated = prdStore.mutatePrd(body.file, (plan) => {
8224
8050
  if (!plan || Array.isArray(plan) || typeof plan !== 'object') plan = {};
8225
8051
  plan.status = 'paused';
8226
8052
  plan.pausedAt = new Date().toISOString();
8227
8053
  return plan;
8228
- }, { defaultValue: {} });
8054
+ });
8229
8055
  prdSourcePlan = updated?.source_plan || null;
8230
8056
 
8231
8057
  // Propagate pause to materialized work items across all projects + cancel the
@@ -8279,15 +8105,14 @@ const server = http.createServer(async (req, res) => {
8279
8105
  const body = await readBody(req);
8280
8106
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
8281
8107
  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) => {
8108
+ const plan = prdStore.mutatePrd(body.file, (data) => {
8284
8109
  if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
8285
8110
  data.status = 'rejected';
8286
8111
  data.rejectedAt = new Date().toISOString();
8287
8112
  data.rejectedBy = body.rejectedBy || os.userInfo().username;
8288
8113
  if (body.reason) data.rejectionReason = body.reason;
8289
8114
  return data;
8290
- }, { defaultValue: {} });
8115
+ });
8291
8116
 
8292
8117
  // RC3: reject used to flip only the PRD status — its materialized work items
8293
8118
  // kept dispatching and any active agent kept running, and a pending plan-to-prd
@@ -8307,26 +8132,26 @@ const server = http.createServer(async (req, res) => {
8307
8132
  try {
8308
8133
  const body = await readBody(req);
8309
8134
  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);
8135
+ if (!body.source.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename' });
8136
+ const plan = prdStore.readPrd(body.source);
8137
+ if (!plan) return jsonReply(res, 404, { error: 'plan not found' });
8313
8138
  const planItems = plan.missing_features || [];
8314
8139
 
8315
8140
  let reset = 0, kept = 0, newCount = 0;
8316
8141
  const deletedItemIds = [];
8317
8142
 
8318
8143
  // Scan all work item sources for materialized items from this plan
8319
- const wiPaths = [{ path: path.join(MINIONS_DIR, 'work-items.json'), label: 'central' }];
8144
+ const workItemScopes = [{ scope: 'central', label: 'central' }];
8320
8145
  for (const proj of PROJECTS) {
8321
- wiPaths.push({ path: shared.projectWorkItemsPath(proj), label: proj.name });
8146
+ workItemScopes.push({ scope: proj, label: proj.name });
8322
8147
  }
8323
8148
 
8324
8149
  // Track which plan items have materialized work items
8325
8150
  const materializedPlanItemIds = new Set();
8326
8151
 
8327
- for (const wiInfo of wiPaths) {
8152
+ for (const wiInfo of workItemScopes) {
8328
8153
  try {
8329
- mutateWorkItems(wiInfo.path, items => {
8154
+ mutateWorkItems(wiInfo.scope, items => {
8330
8155
  const filtered = [];
8331
8156
  for (const w of items) {
8332
8157
  if (w.sourcePlan === body.source) {
@@ -8372,7 +8197,7 @@ const server = http.createServer(async (req, res) => {
8372
8197
  }
8373
8198
  if (orphanPendingIds.size > 0) {
8374
8199
  try {
8375
- mutateJsonFileLocked(planPath, (current) => {
8200
+ prdStore.mutatePrd(body.source, (current) => {
8376
8201
  if (!current || !Array.isArray(current.missing_features)) return current;
8377
8202
  const stamp = new Date().toISOString();
8378
8203
  for (const f of current.missing_features) {
@@ -8402,50 +8227,37 @@ const server = http.createServer(async (req, res) => {
8402
8227
  try {
8403
8228
  const body = await readBody(req);
8404
8229
  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
8230
+ const isPrd = body.file.endsWith('.json');
8231
+ let planPath = null;
8409
8232
  let planObj = null;
8410
8233
  let prdSourcePlan = null;
8411
- if (body.file.endsWith('.json')) {
8412
- try { planObj = safeJsonObj(planPath); prdSourcePlan = planObj?.source_plan || null; } catch {}
8234
+ let archivedPrd = false;
8235
+ if (isPrd) {
8236
+ planObj = prdStore.readPrd(body.file);
8237
+ if (!planObj) {
8238
+ archivedPrd = true;
8239
+ planObj = prdStore.readPrd(body.file, { archived: true });
8240
+ }
8241
+ if (!planObj) return jsonReply(res, 404, { error: 'plan not found' });
8242
+ prdSourcePlan = planObj.source_plan || null;
8243
+ } else {
8244
+ planPath = resolvePlanPath(body.file);
8245
+ if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
8413
8246
  }
8414
8247
  // Clean up worktrees before deleting work items (needs branch info from work items)
8415
8248
  try {
8416
8249
  const { cleanupPlanWorktrees } = require('./engine/lifecycle');
8417
8250
  cleanupPlanWorktrees(body.file, planObj || {}, PROJECTS, queries.getConfig());
8418
8251
  } 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
- }
8252
+ if (isPrd) prdStore.deletePrd(body.file, { archived: archivedPrd });
8253
+ else safeUnlink(planPath);
8439
8254
 
8440
8255
  // Clean up materialized work items from all projects + central
8441
8256
  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) {
8257
+ const workItemScopes = ['central', ...PROJECTS];
8258
+ for (const scope of workItemScopes) {
8447
8259
  try {
8448
- mutateWorkItems(wiPath, items => {
8260
+ mutateWorkItems(scope, items => {
8449
8261
  const filtered = items.filter(w => w.sourcePlan !== body.file);
8450
8262
  if (filtered.length < items.length) {
8451
8263
  cleaned += items.length - filtered.length;
@@ -8465,8 +8277,7 @@ const server = http.createServer(async (req, res) => {
8465
8277
  // If deleting a PRD .json, reset the plan-to-prd work item so the source .md reverts to draft
8466
8278
  if (prdSourcePlan) {
8467
8279
  try {
8468
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
8469
- mutateWorkItems(centralPath, items => {
8280
+ mutateWorkItems('central', items => {
8470
8281
  for (const w of items) {
8471
8282
  if (w.type === WORK_TYPE.PLAN_TO_PRD && DONE_STATUSES.has(w.status) && w.planFile === prdSourcePlan) {
8472
8283
  w.status = WI_STATUS.CANCELLED;
@@ -8488,23 +8299,18 @@ const server = http.createServer(async (req, res) => {
8488
8299
  const body = await readBody(req);
8489
8300
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
8490
8301
  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;
8302
+ if (!isPrd) shared.sanitizePath(body.file, PLANS_DIR);
8303
+ let planPath = null;
8500
8304
  if (isPrd) {
8501
- archivePath = planPath; // in-place do not move the PRD json
8305
+ if (!prdStore.readPrd(body.file)) return jsonReply(res, 404, { error: 'plan not found' });
8502
8306
  } else {
8307
+ planPath = resolvePlanPath(body.file);
8308
+ if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
8503
8309
  const archiveDir = path.join(PLANS_DIR, 'archive');
8504
8310
  // DATA-LOSS GUARD: moveFileNoClobber dedupes the destination so a same-
8505
8311
  // basename collision can't silently destroy a previously-archived plan +
8506
8312
  // its completed work-item history. Bumps to -2/-3 instead of overwriting.
8507
- archivePath = shared.moveFileNoClobber(planPath, archiveDir, body.file);
8313
+ shared.moveFileNoClobber(planPath, archiveDir, body.file);
8508
8314
  }
8509
8315
 
8510
8316
  let archivedSource = null;
@@ -8515,8 +8321,6 @@ const server = http.createServer(async (req, res) => {
8515
8321
  if (isPrd) {
8516
8322
  const result = _archivePrdPostProcess({
8517
8323
  planFile: body.file,
8518
- archivePath,
8519
- planPath,
8520
8324
  plansDir: PLANS_DIR,
8521
8325
  });
8522
8326
  archivedSource = result.archivedSource;
@@ -8527,38 +8331,17 @@ const server = http.createServer(async (req, res) => {
8527
8331
  // archive any PRD whose `source_plan` points back at it. Without this,
8528
8332
  // the dashboard still renders the PRD as a status-completed plan card
8529
8333
  // 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
- }
8334
+ // minions-opg-2026-06-10-2.json).
8335
+ const prdRows = prdStore.listPrdRows().filter(row => !row.archived);
8336
+ for (const { filename: prdFile, plan: prd } of prdRows) {
8548
8337
  // Canonical match (prefix/dir-tolerant): a raw `prd.source_plan !==
8549
8338
  // body.file` skipped PRDs whose source_plan carried the legacy
8550
8339
  // `plans/` prefix, archiving the plan but NOT its PRD — the plan↔PRD
8551
8340
  // status desync behind the "old PRDs came back" resurrection.
8552
8341
  if (!prd || !shared.prdMatchesSourcePlan(prd.source_plan, body.file)) continue;
8553
8342
 
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
8343
  const cascadeResult = _archivePrdPostProcess({
8559
8344
  planFile: prdFile,
8560
- archivePath: prdLivePath,
8561
- planPath: prdLivePath,
8562
8345
  plansDir: PLANS_DIR,
8563
8346
  });
8564
8347
  archiveWarnings.push(...cascadeResult.archiveWarnings);
@@ -8570,10 +8353,10 @@ const server = http.createServer(async (req, res) => {
8570
8353
  // Cancel pending work items linked to this plan so the engine stops
8571
8354
  // dispatching for an archived plan. Done items are preserved as history.
8572
8355
  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) {
8356
+ const workItemScopes = ['central', ...PROJECTS];
8357
+ for (const scope of workItemScopes) {
8575
8358
  try {
8576
- mutateWorkItems(wiPath, items => {
8359
+ mutateWorkItems(scope, items => {
8577
8360
  for (const w of items) {
8578
8361
  if (w.sourcePlan !== body.file) continue;
8579
8362
  if (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.QUEUED) {
@@ -8605,66 +8388,43 @@ const server = http.createServer(async (req, res) => {
8605
8388
  try {
8606
8389
  const body = await readBody(req);
8607
8390
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
8608
- try { shared.sanitizePath(body.file, body.file.endsWith('.json') ? PRD_DIR : PLANS_DIR); }
8391
+ try {
8392
+ if (!body.file.endsWith('.json')) shared.sanitizePath(body.file, PLANS_DIR);
8393
+ }
8609
8394
  catch { return jsonReply(res, 400, { error: 'invalid filename' }); }
8610
8395
  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
8396
+ let restoredAs = body.file;
8656
8397
  let unarchivedSource = null;
8657
8398
  if (isJson) {
8399
+ const archivedRow = prdStore.readPrd(body.file, { archived: true });
8400
+ let prd = archivedRow
8401
+ ? prdStore.restorePrdFromArchive(body.file)
8402
+ : prdStore.readPrd(body.file);
8403
+ if (!prd || (!archivedRow && !shared.isPrdArchived(prd))) {
8404
+ return jsonReply(res, 404, { error: 'File not found in archive' });
8405
+ }
8406
+ prdStore.mutatePrd(body.file, (data) => {
8407
+ if (data.status === 'archived') data.status = 'active';
8408
+ delete data.archived;
8409
+ delete data.archivedAt;
8410
+ return data;
8411
+ });
8412
+ prd = prdStore.readPrd(body.file);
8658
8413
  try {
8659
- const prd = safeJson(liveDest);
8660
8414
  if (prd?.source_plan) {
8661
- const mdArchivePath = path.join(PLANS_DIR, 'archive', prd.source_plan);
8415
+ const sourcePlan = shared.sourcePlanKey(prd.source_plan);
8416
+ const mdArchivePath = path.join(PLANS_DIR, 'archive', sourcePlan);
8662
8417
  if (fs.existsSync(mdArchivePath)) {
8663
- const liveMdDest = shared.moveFileNoClobber(mdArchivePath, PLANS_DIR, prd.source_plan);
8418
+ const liveMdDest = shared.moveFileNoClobber(mdArchivePath, PLANS_DIR, sourcePlan);
8664
8419
  unarchivedSource = path.basename(liveMdDest);
8665
8420
  }
8666
8421
  }
8667
8422
  } catch { /* optional */ }
8423
+ } else {
8424
+ const archivePath = path.join(PLANS_DIR, 'archive', body.file);
8425
+ if (!fs.existsSync(archivePath)) return jsonReply(res, 404, { error: 'File not found in archive' });
8426
+ const liveDest = shared.moveFileNoClobber(archivePath, PLANS_DIR, body.file);
8427
+ restoredAs = path.basename(liveDest);
8668
8428
  }
8669
8429
 
8670
8430
  invalidateStatusCache();
@@ -8672,7 +8432,6 @@ const server = http.createServer(async (req, res) => {
8672
8432
  // `restoredAs` reflects the actual restored basename: moveFileNoClobber
8673
8433
  // bumps to <name>-2 when a live file of the same name already exists, so
8674
8434
  // the client isn't misled into expecting body.file when it differs.
8675
- const restoredAs = path.basename(liveDest);
8676
8435
  const payload = { ok: true, unarchivedSource };
8677
8436
  if (restoredAs !== body.file) payload.restoredAs = restoredAs;
8678
8437
  return jsonReply(res, 200, payload);
@@ -8684,20 +8443,18 @@ const server = http.createServer(async (req, res) => {
8684
8443
  const body = await readBody(req);
8685
8444
  if (!body.file || !body.feedback) return jsonReply(res, 400, { error: 'file and feedback required' });
8686
8445
  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) => {
8446
+ const plan = prdStore.mutatePrd(body.file, (data) => {
8689
8447
  if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
8690
8448
  data.status = 'revision-requested';
8691
8449
  data.revision_feedback = body.feedback;
8692
8450
  data.revisionRequestedAt = new Date().toISOString();
8693
8451
  data.revisionRequestedBy = body.requestedBy || os.userInfo().username;
8694
8452
  return data;
8695
- }, { defaultValue: {} });
8453
+ });
8696
8454
 
8697
8455
  // Create a work item to revise the plan
8698
- const wiPath = path.join(MINIONS_DIR, 'work-items.json');
8699
8456
  const id = 'W-' + shared.uid();
8700
- mutateWorkItems(wiPath, items => {
8457
+ mutateWorkItems('central', items => {
8701
8458
  items.push({
8702
8459
  id, title: 'Revise plan: ' + (plan.plan_summary || body.file),
8703
8460
  type: WORK_TYPE.PLAN_TO_PRD, priority: 'high',
@@ -8718,11 +8475,10 @@ const server = http.createServer(async (req, res) => {
8718
8475
  try {
8719
8476
  const body = await readBody(req);
8720
8477
  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);
8478
+ if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename' });
8479
+ const plan = prdStore.readPrd(body.file);
8480
+ if (!plan) return jsonReply(res, 404, { error: 'plan not found' });
8481
+ const planReference = `SQL PRD ${body.file}`;
8726
8482
  const projectName = plan.project || 'Unknown';
8727
8483
 
8728
8484
  // Build the session launch script
@@ -8730,10 +8486,10 @@ const server = http.createServer(async (req, res) => {
8730
8486
  let sysPrompt;
8731
8487
  try {
8732
8488
  sysPrompt = fs.readFileSync(path.join(MINIONS_DIR, 'prompts', 'plan-advisor-system.md'), 'utf8')
8733
- .replace(/\{\{plan_path\}\}/g, planPath)
8489
+ .replace(/\{\{plan_path\}\}/g, planReference)
8734
8490
  .replace(/\{\{project_name\}\}/g, projectName);
8735
8491
  } catch {
8736
- sysPrompt = `You are a Plan Advisor. The plan is at ${planPath} for project ${projectName}. Help the user review and approve it.`;
8492
+ sysPrompt = `You are a Plan Advisor. The plan is ${planReference} for project ${projectName}. Help the user review and approve it.`;
8737
8493
  }
8738
8494
 
8739
8495
  const initialPrompt = `Here's the plan awaiting your review:
@@ -9585,7 +9341,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9585
9341
  _clearCcLiveStream(tabId);
9586
9342
  }
9587
9343
  // P-c2sess-1d8e: lock single-session reset against concurrent updateSession writes.
9588
- mutateJsonFileLocked(CC_SESSION_PATH, () => ccSession, { defaultValue: {} });
9344
+ persistCcSession();
9589
9345
  return jsonReply(res, 200, { ok: true });
9590
9346
  }
9591
9347
 
@@ -9619,12 +9375,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9619
9375
  async function handleCCSessionDelete(req, res, match) {
9620
9376
  const id = match?.[1];
9621
9377
  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) => {
9378
+ smallStateStore.applyCcSessionsMutation((raw) => {
9625
9379
  const sessions = _filterCcTabSessions(raw);
9626
9380
  return sessions.filter(s => s.id !== id);
9627
- }, { defaultValue: [] });
9381
+ });
9628
9382
  // Sub-task C of W-mp2w003600196c51: tear down the persistent ACP worker
9629
9383
  // for this tab so we don't leak a Copilot process after the user closes
9630
9384
  // the tab. closeTab is a no-op when the pool has no entry for the tabId,
@@ -9814,7 +9568,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9814
9568
  // W-mrb1a3950003d9ea — flat, stable label (no per-watch-id interpolation).
9815
9569
  // Every other _engine category (kb-sweep, consolidation, command-center, …)
9816
9570
  // 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).
9571
+ // permanent metrics category per watch firing (20+ confirmed live).
9818
9572
  const label = 'watch-cc-triage';
9819
9573
 
9820
9574
  const model = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
@@ -10692,10 +10446,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10692
10446
  const _persistTabId = body.tabId;
10693
10447
  if (_persistTabId && responseSessionId) {
10694
10448
  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
10449
  const preview = (body.message || '').slice(0, 80);
10698
- mutateJsonFileLocked(CC_SESSIONS_PATH, (raw) => {
10450
+ smallStateStore.applyCcSessionsMutation((raw) => {
10699
10451
  const sessions = _filterCcTabSessions(raw);
10700
10452
  const existing = sessions.find(s => s.id === _persistTabId);
10701
10453
  if (existing) {
@@ -10709,7 +10461,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10709
10461
  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
10462
  }
10711
10463
  return sessions;
10712
- }, { defaultValue: [] });
10464
+ });
10713
10465
  } catch { /* non-critical */ }
10714
10466
  }
10715
10467
 
@@ -10899,9 +10651,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10899
10651
  participants: Array.isArray(schedForRun.participants) ? schedForRun.participants : [],
10900
10652
  });
10901
10653
  } else {
10902
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
10903
10654
  let duplicate = null;
10904
- mutateJsonFileLocked(centralPath, (items) => {
10655
+ mutateWorkItems('central', (items) => {
10905
10656
  if (!Array.isArray(items)) items = [];
10906
10657
  duplicate = items.find(i =>
10907
10658
  i._scheduleId === item._scheduleId &&
@@ -10911,7 +10662,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10911
10662
  );
10912
10663
  if (!duplicate) items.push(item);
10913
10664
  return items;
10914
- }, { defaultValue: [] });
10665
+ });
10915
10666
  if (duplicate) {
10916
10667
  return jsonReply(res, 409, {
10917
10668
  error: 'Schedule already has an active work item',
@@ -11091,19 +10842,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11091
10842
  name: p.name,
11092
10843
  localPath: p.localPath || null,
11093
10844
  mainBranch: p.mainBranch || null,
11094
- // P-a3f9b207 — surface checkoutMode so the Settings UI can pre-fill
11095
- // the per-project dropdown. resolveCheckoutMode honors the legacy
11096
- // worktreeMode field; 'worktree' (default) or 'live'.
11097
- checkoutMode: shared.resolveCheckoutMode(p),
11098
- // W-mr9mx93d000x1dda — surface the hybrid liveValidation config
11099
- // (type(s) + autoDispatch) so the Settings → Projects checkout-mode
11100
- // control can pre-fill its Hybrid sub-panel authoritatively (mirrors
11101
- // the /api/status project mapping the Projects-bar picker reads).
11102
- // Only meaningful under checkoutMode:'live'; null/false otherwise.
11103
- liveValidationType: (shared.resolveCheckoutMode(p) === 'live' && p.liveValidation && p.liveValidation.type)
11104
- ? p.liveValidation.type
11105
- : null,
11106
- liveValidationAutoDispatch: !!(shared.resolveCheckoutMode(p) === 'live' && p.liveValidation && p.liveValidation.autoDispatch === true),
10845
+ ..._projectCheckoutConfigView(p),
11107
10846
  // W-mqtvnnj1000357fa — surface the RAW per-project auto-stash override
11108
10847
  // (true / false / undefined) so the Settings UI can pre-select the
11109
10848
  // tri-state dropdown. Undefined means "use fleet default"
@@ -12117,7 +11856,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12117
11856
 
12118
11857
  // ── Managed processes API (P-4b8d2e57, plan W-mp7k1r760003b5dd item 4) ──
12119
11858
  // List + by-name + kill + restart for the engine-owned managed-spawn
12120
- // services (engine/managed-processes.json). Mirrors the keep-processes
11859
+ // services (SQL managed-process state). Mirrors the keep-processes
12121
11860
  // pattern above but lives a layer deeper because the state is engine-side
12122
11861
  // (not per-agent sidecars).
12123
11862
 
@@ -12416,7 +12155,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12416
12155
  const projectName = runbook.project || target.owner_project || '';
12417
12156
  const resolveTarget = resolveWorkItemsCreateTarget(projectName);
12418
12157
  if (resolveTarget.error) return jsonReply(res, 400, { error: resolveTarget.error }, req);
12419
- const wiPath = resolveTarget.wiPath;
12158
+ const wiScope = resolveTarget.scope;
12420
12159
  const targetProject = resolveTarget.project;
12421
12160
 
12422
12161
  // Create run record BEFORE the WI so we can stamp the WI with qaRunId.
@@ -12452,12 +12191,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12452
12191
  if (targetProject) wi.project = targetProject.name;
12453
12192
  if (body.agent && typeof body.agent === 'string' && body.agent.trim()) wi.agent = body.agent.trim();
12454
12193
 
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
12194
+ // createRun and the work-item append are separate transactions; order is
12195
+ // intentional so a
12457
12196
  // crash between the two leaves the run as `pending` (recoverable) rather
12458
12197
  // than orphaning a WI with no run record.
12459
12198
  let added = false;
12460
- mutateWorkItems(wiPath, (items) => {
12199
+ mutateWorkItems(wiScope, (items) => {
12461
12200
  if (!Array.isArray(items)) items = [];
12462
12201
  if (!items.some(i => i && i.id === wiId)) {
12463
12202
  items.push(wi);
@@ -12633,7 +12372,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12633
12372
  }
12634
12373
 
12635
12374
  // 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
12375
+ // run from SQL QA state and best-effort wipes the on-disk artifact
12637
12376
  // directory under engine/qa-artifacts/<runId>/. Terminal-only — refuses
12638
12377
  // pending/running runs with 409. Caller must cancel/complete the run via
12639
12378
  // the lifecycle helpers before deletion. CSRF/Origin gate is applied by
@@ -12721,7 +12460,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12721
12460
  // Thin HTTP wrappers over engine/qa-sessions.js + engine/qa-runners.js. The
12722
12461
  // lifecycle (state machine, work-item builders, dispatch chain) lives in
12723
12462
  // those modules; these handlers translate HTTP into the right call,
12724
- // resolve project/wiPath the same way handleQaRunbookRun does, and shape
12463
+ // resolve project/scope the same way handleQaRunbookRun does, and shape
12725
12464
  // qa-sessions thrown errors into 400/404/409 instead of 500.
12726
12465
  //
12727
12466
  // Endpoint summary:
@@ -12752,22 +12491,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12752
12491
  return 500;
12753
12492
  }
12754
12493
 
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.
12494
+ // Resolve the project and SQL scope for QA work items.
12760
12495
  function _qaSessionsResolveTarget(projectName) {
12761
12496
  const target = resolveWorkItemsCreateTarget(projectName || '');
12762
12497
  if (target.error) return { error: target.error };
12763
- return { wiPath: target.wiPath, project: target.project ? target.project.name : null };
12498
+ return { scope: target.scope, project: target.project ? target.project.name : null };
12764
12499
  }
12765
12500
 
12766
12501
  // W-mpq6xqzj000606d0 — Multi-project resolver. Walks each requested project
12767
12502
  // 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.
12503
+ // {scope, project} entries (with the first treated as primary) or
12504
+ // { error } on the first failure.
12771
12505
  function _qaSessionsResolveTargets(body) {
12772
12506
  const list = Array.isArray(body && body.projects) && body.projects.length > 0
12773
12507
  ? body.projects.slice()
@@ -12815,7 +12549,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12815
12549
  let setupWiId = null;
12816
12550
  try {
12817
12551
  setupWiId = qaSessions.queueSetup(session.id, {
12818
- resolvedTargets: resolution.targets.map(t => ({ project: t.project, wiPath: t.wiPath })),
12552
+ resolvedTargets: resolution.targets.map(t => ({ project: t.project, scope: t.scope })),
12819
12553
  });
12820
12554
  } catch (e) {
12821
12555
  // queueSetup throws when pending→spawning is rejected (createSession
@@ -12923,7 +12657,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12923
12657
  let setupWiId = null;
12924
12658
  try {
12925
12659
  setupWiId = qaSessions.queueSetup(session.id, {
12926
- resolvedTargets: resolution.targets.map(t => ({ project: t.project, wiPath: t.wiPath })),
12660
+ resolvedTargets: resolution.targets.map(t => ({ project: t.project, scope: t.scope })),
12927
12661
  });
12928
12662
  } catch (e) {
12929
12663
  return jsonReply(res, _qaSessionsErrorToStatus(e), {
@@ -13067,7 +12801,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13067
12801
  let executeWiId;
13068
12802
  try {
13069
12803
  executeWiId = qaSessions.approveDraft(session.id, {
13070
- wiPath: resolved.wiPath,
12804
+ scope: resolved.scope,
13071
12805
  qaRunId: run.id,
13072
12806
  project: resolved.project,
13073
12807
  });
@@ -13106,7 +12840,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13106
12840
  let draftWiId;
13107
12841
  try {
13108
12842
  draftWiId = qaSessions.editDraft(session.id, {
13109
- wiPath: resolved.wiPath,
12843
+ scope: resolved.scope,
13110
12844
  feedback,
13111
12845
  project: resolved.project,
13112
12846
  });
@@ -13260,6 +12994,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13260
12994
 
13261
12995
  // Status & health
13262
12996
  { method: 'GET', path: '/api/status', desc: 'Full dashboard status snapshot (agents, PRDs, work items, dispatch, etc.)', handler: handleStatus },
12997
+ { method: 'GET', path: '/api/constellation/v1/snapshot', desc: 'Versioned metadata-only full snapshot for the local Constellation mirror', handler: (req, res) => {
12998
+ return serveFreshJson(req, res, {
12999
+ tag: `constellation-v1-${_dashboardVersion.codeVersion || 'unknown'}-${_dashboardVersion.codeCommit || 'unknown'}`,
13000
+ inputs: [CONFIG_PATH, queries.CONTROL_PATH, INBOX_DIR, AGENTS_DIR],
13001
+ builder: _buildConstellationBridgeSnapshot,
13002
+ });
13003
+ }},
13263
13004
  // Raw state-file passthrough (no /api/ prefix — this serves files, not API
13264
13005
  // responses). Allowlisted top-level dirs only; mtime+size ETag → 304.
13265
13006
  { method: 'GET', path: /^\/state\/.+/, desc: 'Serve a raw state file (or directory listing) from MINIONS_DIR with mtime/size ETag', handler: handleStateRead },
@@ -13272,34 +13013,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13272
13013
  tag: 'agents',
13273
13014
  inputs: [
13274
13015
  CONFIG_PATH,
13275
- path.join(ENGINE_DIR, 'dispatch.json'),
13276
13016
  INBOX_DIR,
13277
13017
  AGENTS_DIR,
13278
13018
  ],
13279
13019
  builder: () => getAgents(),
13280
13020
  });
13281
13021
  }},
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
- }
13022
+ { 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
13023
  return serveFreshJson(req, res, {
13301
13024
  tag: 'work-items',
13302
- inputs,
13025
+ inputs: [CONFIG_PATH],
13303
13026
  // W-mq5xg5e9000nec0e — Slim every item before stringify so the polled
13304
13027
  // refresh-loop payload stays < ~300 KB even when description fields
13305
13028
  // include 100+ KB transcripts. slimWorkItemForList shallow-copies so
@@ -13307,22 +13030,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13307
13030
  builder: () => getWorkItems().map(slimWorkItemForList),
13308
13031
  });
13309
13032
  }},
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
- }
13033
+ { method: 'GET', path: '/api/pull-requests', desc: 'Fully-enriched SQL-backed pull requests with URL backfill and project stamps', handler: (req, res) => {
13323
13034
  return serveFreshJson(req, res, {
13324
13035
  tag: 'pull-requests',
13325
- inputs,
13036
+ inputs: [CONFIG_PATH],
13326
13037
  builder: () => queries.getPullRequests(),
13327
13038
  });
13328
13039
  }},
@@ -13334,54 +13045,22 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13334
13045
  { method: 'GET', path: '/api/dispatch', desc: 'Live dispatch queue with completion-report summaries (pending/active/completed slices)', handler: (req, res) => {
13335
13046
  return serveFreshJson(req, res, {
13336
13047
  tag: 'dispatch',
13337
- inputs: [
13338
- path.join(ENGINE_DIR, 'dispatch.json'),
13339
- path.join(ENGINE_DIR, 'metrics.json'),
13340
- ],
13048
+ inputs: [],
13341
13049
  builder: () => getDispatchQueue(),
13342
13050
  });
13343
13051
  }},
13344
13052
  { 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
13053
  return serveFreshJson(req, res, {
13359
13054
  tag: 'metrics',
13360
- inputs,
13055
+ inputs: [CONFIG_PATH],
13361
13056
  builder: () => getMetrics(),
13362
13057
  });
13363
13058
  }},
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 || [];
13059
+ { method: 'GET', path: '/api/prd', desc: 'PRD status + progress derived from SQL-backed PRDs, work items, and pull requests', handler: (req, res) => {
13367
13060
  const inputs = [
13368
13061
  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
13062
  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
13063
  ];
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
13064
  return serveFreshJson(req, res, {
13386
13065
  tag: 'prd',
13387
13066
  inputs,
@@ -13391,7 +13070,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13391
13070
  { method: 'GET', path: '/api/archived-prds', desc: 'List of archived PRD files with status + completedAt', handler: (req, res) => {
13392
13071
  return serveFreshJson(req, res, {
13393
13072
  tag: 'archived-prds',
13394
- inputs: [path.join(PRD_DIR, 'archive')],
13073
+ inputs: [],
13395
13074
  builder: () => [],
13396
13075
  });
13397
13076
  }},
@@ -13418,12 +13097,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13418
13097
  builder: () => {
13419
13098
  // Read config.json directly via queries.getConfig() rather than
13420
13099
  // 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.)
13100
+ // ensureConfiguredProjectStateFiles(); the builder only needs
13101
+ // config.schedules, so getConfig avoids unrelated setup work.
13427
13102
  const cfg = queries.getConfig() || {};
13428
13103
  const scheds = cfg.schedules || [];
13429
13104
  const runs = require('./engine/small-state-store').readScheduleRuns();
@@ -13570,11 +13245,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13570
13245
  const { id, source, rating, comment } = body;
13571
13246
  if (!id || !rating) return jsonReply(res, 400, { error: 'id and rating required' });
13572
13247
  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));
13248
+ const workItemScopes = ['central', ...projects];
13575
13249
  let found = null;
13576
- for (const wiPath of paths) {
13577
- mutateWorkItems(wiPath, items => {
13250
+ for (const scope of workItemScopes) {
13251
+ mutateWorkItems(scope, items => {
13578
13252
  const item = items.find(i => i.id === id);
13579
13253
  if (item && !found) {
13580
13254
  item._humanFeedback = { rating, comment: comment || '', at: new Date().toISOString() };
@@ -13714,7 +13388,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13714
13388
  } catch (e) {
13715
13389
  return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
13716
13390
  }
13717
- const { id: prId, prPath, prNum, created, linked, targetProject, projectResolution, skipped, resurrected } = linkResult;
13391
+ const { id: prId, scope, prNum, created, linked, targetProject, projectResolution, skipped, resurrected } = linkResult;
13718
13392
  invalidateStatusCache();
13719
13393
  // W-mrdutzdr000t22f4 — upsertPullRequestRecord can leave the record
13720
13394
  // untouched (`skipped: true`) for reasons other than a resurrect, e.g.
@@ -13763,7 +13437,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13763
13437
  } catch { /* ADO token may not be available */ }
13764
13438
  }
13765
13439
  if (!prData) return;
13766
- mutateJsonFileLocked(prPath, (prs) => {
13440
+ shared.mutatePullRequests(scope, (prs) => {
13767
13441
  const pr = prs.find(p => p.id === prId);
13768
13442
  if (!pr) return prs;
13769
13443
  // Remote title always wins — any user-supplied title is a placeholder (closes #1283)
@@ -13978,14 +13652,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13978
13652
  const { id, project: requestedProjectName } = body;
13979
13653
  if (!id) return jsonReply(res, 400, { error: 'id required' });
13980
13654
  reloadConfig();
13981
- // Search all project PR files and central file
13655
+ // Search all project and central PR scopes.
13982
13656
  const projects = shared.getProjects(CONFIG);
13983
13657
  const projectByName = new Map(projects.map(project => [project.name, project]));
13984
13658
  const store = require('./engine/pull-requests-store');
13985
13659
  const storedPrs = store.readAllPullRequests() || [];
13986
13660
  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 }],
13661
+ ...projects.map(project => [project.name, { source: project, project }]),
13662
+ ['central', { source: 'central', project: null }],
13989
13663
  ]);
13990
13664
  const canonicalRequest = shared.parseCanonicalPrId(id);
13991
13665
  const requestedIdentities = new Set();
@@ -14016,7 +13690,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14016
13690
  const ownerProject = scope !== 'central' ? projectByName.get(scope) || null : null;
14017
13691
  if (!requestedIdentities.has(shared.getPrIdentityKey(pr, ownerProject))) continue;
14018
13692
  if (!prScopes.has(scope)) {
14019
- prScopes.set(scope, { prPath: store._filePathForScope(scope), project: ownerProject });
13693
+ prScopes.set(scope, { source: ownerProject || scope, project: ownerProject });
14020
13694
  }
14021
13695
  }
14022
13696
  let found = false;
@@ -14027,11 +13701,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14027
13701
  // the other scope's copy fully untouched — queries.js#getPullRequests
14028
13702
  // then de-dupes by picking that untouched duplicate as the "winner"
14029
13703
  // and silently resurrects the "deleted" PR on the next read.
14030
- for (const { prPath, project } of prScopes.values()) {
13704
+ for (const { source, project } of prScopes.values()) {
14031
13705
  // Issue #384: use a tombstone (userDeleted:true) instead of splice so
14032
13706
  // 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) => {
13707
+ // state but is filtered from API responses and skipped by pollers.
13708
+ shared.mutatePullRequests(source, (prs) => {
14035
13709
  if (!Array.isArray(prs)) return prs;
14036
13710
  const matches = prs.filter(pr =>
14037
13711
  requestedIdentities.has(shared.getPrIdentityKey(pr, project))
@@ -14056,22 +13730,19 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14056
13730
  if (!prId) return jsonReply(res, 400, { error: 'prId required' });
14057
13731
  if (!cause) return jsonReply(res, 400, { error: 'cause required' });
14058
13732
  // Validate cause is one of the known PR_FIX_CAUSE values to reject
14059
- // typos before scanning state files.
13733
+ // typos before scanning state.
14060
13734
  const knownCauses = new Set(Object.values(shared.PR_FIX_CAUSE));
14061
13735
  if (!knownCauses.has(cause)) {
14062
13736
  return jsonReply(res, 400, { error: `unknown cause: ${cause}` });
14063
13737
  }
14064
13738
  reloadConfig();
14065
13739
  const lifecycle = require('./engine/lifecycle');
14066
- const prPaths = [
14067
- ...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
14068
- shared.centralPullRequestsPath(MINIONS_DIR),
14069
- ];
13740
+ const prSources = [...shared.getProjects(CONFIG), 'central'];
14070
13741
  let prFound = false;
14071
13742
  let causeFound = false;
14072
- for (const prPath of prPaths) {
13743
+ for (const source of prSources) {
14073
13744
  if (causeFound) break;
14074
- shared.mutatePullRequests(prPath, (prs) => {
13745
+ shared.mutatePullRequests(source, (prs) => {
14075
13746
  if (!Array.isArray(prs)) return prs;
14076
13747
  const target = prs.find(p => p && p.id === prId);
14077
13748
  if (!target) return prs;
@@ -14094,7 +13765,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14094
13765
  // separately from `_noOpFixes` and so isn't reachable via the endpoint
14095
13766
  // above. Once a fresh CI build is queued against a rebased/retried head,
14096
13767
  // this lets an operator clear the stale pause without hand-editing
14097
- // pull-requests.json.
13768
+ // tracked PR state.
14098
13769
  { 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
13770
  const body = await readBody(req);
14100
13771
  const prId = typeof body?.prId === 'string' ? body.prId.trim() : '';
@@ -14102,16 +13773,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14102
13773
  if (!prId) return jsonReply(res, 400, { error: 'prId required' });
14103
13774
  reloadConfig();
14104
13775
  const lifecycle = require('./engine/lifecycle');
14105
- const prPaths = [
14106
- ...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
14107
- shared.centralPullRequestsPath(MINIONS_DIR),
14108
- ];
13776
+ const prSources = [...shared.getProjects(CONFIG), 'central'];
14109
13777
  let prFound = false;
14110
13778
  let recordCleared = false;
14111
13779
  let staleHead = false;
14112
- for (const prPath of prPaths) {
13780
+ for (const source of prSources) {
14113
13781
  if (recordCleared || staleHead) break;
14114
- shared.mutatePullRequests(prPath, (prs) => {
13782
+ shared.mutatePullRequests(source, (prs) => {
14115
13783
  if (!Array.isArray(prs)) return prs;
14116
13784
  const target = prs.find(p => p && p.id === prId);
14117
13785
  if (!target) return prs;
@@ -14150,15 +13818,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14150
13818
  if (!prId) return jsonReply(res, 400, { error: 'prId required' });
14151
13819
  reloadConfig();
14152
13820
  const lifecycle = require('./engine/lifecycle');
14153
- const prPaths = [
14154
- ...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
14155
- shared.centralPullRequestsPath(MINIONS_DIR),
14156
- ];
13821
+ const prSources = [...shared.getProjects(CONFIG), 'central'];
14157
13822
  let prFound = false;
14158
13823
  let recordCleared = false;
14159
- for (const prPath of prPaths) {
13824
+ for (const source of prSources) {
14160
13825
  if (recordCleared) break;
14161
- shared.mutatePullRequests(prPath, (prs) => {
13826
+ shared.mutatePullRequests(source, (prs) => {
14162
13827
  if (!Array.isArray(prs)) return prs;
14163
13828
  const target = prs.find(p => p && p.id === prId);
14164
13829
  if (!target) return prs;
@@ -14593,10 +14258,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14593
14258
  completeRun(body.id, run.runId, 'failed');
14594
14259
  // Cancel pending/active work items and dispatches spawned by this run
14595
14260
  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) {
14261
+ const workItemScopes = ['central', ...PROJECTS];
14262
+ for (const scope of workItemScopes) {
14598
14263
  try {
14599
- mutateWorkItems(wiPath, items => {
14264
+ mutateWorkItems(scope, items => {
14600
14265
  for (const w of items) {
14601
14266
  if (w._pipelineRun === run.runId && w.status !== shared.WI_STATUS.DONE && w.status !== shared.WI_STATUS.CANCELLED) {
14602
14267
  w.status = shared.WI_STATUS.CANCELLED;
@@ -14971,6 +14636,7 @@ module.exports = {
14971
14636
  _findDuplicateWorkItemCreate: findDuplicateWorkItemCreate,
14972
14637
  _createWorkItemWithDedup: createWorkItemWithDedup,
14973
14638
  _resolveWorkItemsCreateTarget: resolveWorkItemsCreateTarget,
14639
+ _matchesResolvedWorkItemDispatch: matchesResolvedWorkItemDispatch, // exported for testing
14974
14640
  _validatePrFollowupShape: validatePrFollowupShape,
14975
14641
  _findExistingFollowupForComment: findExistingFollowupForComment,
14976
14642
  _extractMinionsAgentHeader: extractMinionsAgentHeader,
@@ -14984,6 +14650,7 @@ module.exports = {
14984
14650
  buildCCStateRefresh,
14985
14651
  _resetRefreshCache,
14986
14652
  _routesAsMeta,
14653
+ _buildConstellationBridgeSnapshot,
14987
14654
  _server: server,
14988
14655
  _buildTranscriptCarryover,
14989
14656
  _transcriptHasCarryoverContext,