@yemi33/minions 0.1.2378 → 0.1.2380

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/bin/minions.js +19 -9
  2. package/dashboard/js/refresh.js +2 -3
  3. package/dashboard/js/render-prd.js +1 -1
  4. package/dashboard/js/render-work-items.js +13 -6
  5. package/dashboard.js +393 -721
  6. package/docs/README.md +1 -0
  7. package/docs/architecture-review-2026-07-09.md +2 -4
  8. package/docs/auto-discovery.md +36 -49
  9. package/docs/blog-first-successful-dispatch.md +1 -1
  10. package/docs/branch-derivation.md +2 -2
  11. package/docs/command-center.md +1 -1
  12. package/docs/completion-reports.md +14 -4
  13. package/docs/constellation-bridge.md +59 -10
  14. package/docs/constellation-style-telemetry.md +6 -6
  15. package/docs/cooldown-merge-semantics.md +4 -0
  16. package/docs/copilot-cli-schema.md +3 -3
  17. package/docs/cross-repo-plans.md +17 -17
  18. package/docs/deprecated.json +2 -2
  19. package/docs/design-inbox-entries-schema.md +31 -39
  20. package/docs/design-state-storage.md +1 -1
  21. package/docs/documentation-audit-2026-07-09.md +2 -2
  22. package/docs/engine-restart.md +20 -8
  23. package/docs/harness-mode.md +1 -1
  24. package/docs/managed-spawn.md +4 -4
  25. package/docs/onboarding.md +1 -2
  26. package/docs/pr-comment-followup.md +3 -3
  27. package/docs/pr-review-fix-loop.md +1 -1
  28. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  29. package/docs/qa-runbook-lifecycle.md +4 -4
  30. package/docs/qa-runbooks.md +2 -2
  31. package/docs/rfc-completion-json.md +4 -1
  32. package/docs/runtime-adapters.md +1 -1
  33. package/docs/self-improvement.md +4 -5
  34. package/docs/shared-lifecycle-module-map.md +3 -1
  35. package/docs/slim-ux/architecture-suggestions.md +5 -6
  36. package/docs/slim-ux/concepts.md +23 -25
  37. package/docs/watches.md +7 -7
  38. package/docs/workspace-manifests.md +1 -1
  39. package/docs/worktree-lifecycle.md +1 -1
  40. package/engine/abandoned-pr-reconciliation.js +4 -5
  41. package/engine/acp-transport.js +49 -8
  42. package/engine/ado-status.js +5 -5
  43. package/engine/ado.js +20 -25
  44. package/engine/agent-worker-pool.js +124 -15
  45. package/engine/bridge.js +260 -5
  46. package/engine/cleanup.js +48 -131
  47. package/engine/cli.js +125 -83
  48. package/engine/cooldown.js +9 -16
  49. package/engine/db/index.js +22 -9
  50. package/engine/db/migrations/009-qa.js +1 -1
  51. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  52. package/engine/db/migrations/021-archived-work-items.js +72 -0
  53. package/engine/db/migrations/022-global-cc-session.js +31 -0
  54. package/engine/db/migrations/023-engine-state.js +30 -0
  55. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  56. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  57. package/engine/dispatch-store.js +2 -7
  58. package/engine/dispatch.js +34 -41
  59. package/engine/github.js +20 -27
  60. package/engine/lifecycle.js +221 -283
  61. package/engine/llm.js +1 -1
  62. package/engine/logs-store.js +2 -2
  63. package/engine/managed-spawn.js +2 -23
  64. package/engine/meeting.js +6 -6
  65. package/engine/metrics-store.js +2 -2
  66. package/engine/note-link-backfill.js +6 -11
  67. package/engine/pipeline.js +18 -36
  68. package/engine/playbook.js +1 -1
  69. package/engine/pooled-agent-process.js +46 -26
  70. package/engine/prd-store.js +73 -54
  71. package/engine/preflight.js +2 -5
  72. package/engine/projects.js +15 -62
  73. package/engine/pull-requests-store.js +0 -17
  74. package/engine/qa-runbooks.js +2 -2
  75. package/engine/qa-runs.js +1 -8
  76. package/engine/qa-sessions.js +41 -64
  77. package/engine/queries.js +120 -219
  78. package/engine/routing.js +3 -5
  79. package/engine/scheduler.js +0 -4
  80. package/engine/shared-branch-pr-reconcile.js +2 -3
  81. package/engine/shared.js +132 -637
  82. package/engine/small-state-store.js +89 -10
  83. package/engine/state-operations.js +16 -4
  84. package/engine/stdio-timestamps.js +1 -1
  85. package/engine/timeout.js +5 -12
  86. package/engine/watch-actions.js +20 -22
  87. package/engine/watches-store.js +1 -1
  88. package/engine/watches.js +6 -10
  89. package/engine/work-item-validation.js +52 -0
  90. package/engine/work-items-store.js +127 -29
  91. package/engine/worktree-gc.js +2 -2
  92. package/engine/worktree-pool.js +8 -18
  93. package/engine.js +167 -349
  94. package/minions.js +2 -2
  95. package/package.json +1 -1
  96. package/playbooks/plan-to-prd.md +2 -2
  97. package/playbooks/shared-rules.md +3 -3
  98. package/playbooks/templates/followup-dispatch.md +1 -1
  99. package/playbooks/verify.md +1 -1
  100. package/prompts/cc-system.md +2 -2
@@ -7,29 +7,26 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
9
  const shared = require('./shared');
10
- const { safeRead, safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execAsync, projectPrPath, getPrLinks,
10
+ const { safeRead, safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execAsync, getPrLinks,
11
11
  log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, REVIEW_STATUS, RETRY_DELAY_MS, DISPATCH_RESULT,
12
12
  ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
13
13
  const { resolveRuntime } = require('./runtimes');
14
14
  const adoGitAuth = require('./ado-git-auth');
15
15
  const queries = require('./queries');
16
16
  const harness = require('./harness');
17
+ const smallStateStore = require('./small-state-store');
18
+ const prdStore = require('./prd-store');
17
19
  const { isBranchActive } = require('./cooldown');
18
20
  const { worktreeMatchesBranch, getWorktreeBranch, cleanupMergedPrLocalBranch } = require('./cleanup');
19
21
  const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
20
- MINIONS_DIR, ENGINE_DIR, PLANS_DIR, PRD_DIR, INBOX_DIR, AGENTS_DIR } = queries;
22
+ MINIONS_DIR, ENGINE_DIR, PLANS_DIR, INBOX_DIR, AGENTS_DIR } = queries;
21
23
 
22
24
  // ─── Plan Completion Detection ───────────────────────────────────────────────
23
25
  function checkPlanCompletion(meta, config) {
24
26
 
25
27
  const planFile = meta.item?.sourcePlan;
26
28
  if (!planFile) return;
27
- const planPath = path.join(PRD_DIR, planFile);
28
- // Use safeJsonNoRestore — if the active PRD has been archived, the .backup
29
- // sidecar may still be sitting in prd/. safeJson would auto-restore it and
30
- // resurrect the archived PRD as active (W-mouptdh1000h9f39). PRDs are
31
- // terminal artifacts; missing primary means "gone", not "needs recovery".
32
- const plan = safeJsonNoRestore(planPath);
29
+ const plan = prdStore.readPrd(planFile);
33
30
  if (!plan?.missing_features) return;
34
31
 
35
32
  const projects = shared.getProjects(config);
@@ -54,7 +51,7 @@ function checkPlanCompletion(meta, config) {
54
51
  const reopenStaleCompletedPlan = (reason) => {
55
52
  if (plan.status !== PLAN_STATUS.COMPLETED && !plan._completionNotified) return false;
56
53
  log('warn', `Plan ${planFile}: re-opening stale completed PRD — ${reason}`);
57
- mutateJsonFileLocked(planPath, (data) => {
54
+ prdStore.mutatePrd(planFile, (data) => {
58
55
  if (data.status === PLAN_STATUS.COMPLETED) data.status = PLAN_STATUS.ACTIVE;
59
56
  delete data.completedAt;
60
57
  data._completionNotified = false;
@@ -180,8 +177,7 @@ function checkPlanCompletion(meta, config) {
180
177
  const prsCreated = [];
181
178
  for (const p of projects) {
182
179
  try {
183
- const prPath = shared.projectPrPath(p);
184
- const prs = safeJsonArr(prPath);
180
+ const prs = shared.readPullRequests(p);
185
181
  const prLinks = getPrLinks();
186
182
  for (const pr of prs) {
187
183
  const linkedItemIds = prLinks[pr.id] || [];
@@ -223,8 +219,7 @@ function checkPlanCompletion(meta, config) {
223
219
  shared.writeToInbox('engine', summarySlug, summary);
224
220
  log('info', `PRD completion summary written to notes/inbox/${summarySlug}`);
225
221
 
226
- // Persist completed status + _completionNotified via file lock
227
- mutateJsonFileLocked(planPath, (data) => {
222
+ prdStore.mutatePrd(planFile, (data) => {
228
223
  data.status = PLAN_STATUS.COMPLETED;
229
224
  data.completedAt = plan.completedAt;
230
225
  data._completionNotified = true;
@@ -258,17 +253,15 @@ function checkPlanCompletion(meta, config) {
258
253
  log('warn', `Plan ${planFile}: no primary project found — skipping PR/verify creation`);
259
254
  return;
260
255
  }
261
- const wiPath = shared.projectWorkItemsPath(primaryProject);
262
-
263
256
  // 3. For shared-branch plans, create PR work item
264
- if (plan.branch_strategy === 'shared-branch' && plan.feature_branch && wiPath) {
257
+ if (plan.branch_strategy === 'shared-branch' && plan.feature_branch) {
265
258
  const existingPrItem = allWorkItems.find(w => w.sourcePlan === planFile && w.itemType === 'pr');
266
259
  if (!existingPrItem) {
267
260
  const id = 'PL-' + shared.uid();
268
261
  const featureBranch = plan.feature_branch;
269
262
  const mainBranch = shared.resolveMainBranch(primaryProject.localPath, primaryProject.mainBranch);
270
263
  const itemSummary = doneItems.map(w => '- ' + w.id + ': ' + (w.title || 'Untitled').replace('Implement: ', '')).join('\n');
271
- mutateWorkItems(wiPath, workItems => {
264
+ mutateWorkItems(primaryProject, workItems => {
272
265
  if (workItems.some(w => w.sourcePlan === planFile && w.itemType === 'pr')) return workItems;
273
266
  workItems.push({
274
267
  id, title: `Create PR for plan: ${plan.plan_summary || planFile}`,
@@ -295,7 +288,7 @@ function checkPlanCompletion(meta, config) {
295
288
  const projectPrs = {};
296
289
  const prLinks = getPrLinks();
297
290
  for (const p of projects) {
298
- const prs = safeJsonArr(shared.projectPrPath(p))
291
+ const prs = shared.readPullRequests(p)
299
292
  .filter(pr => {
300
293
  const linkedIds = prLinks[pr.id] || [];
301
294
  return pr.status === PR_STATUS.ACTIVE && linkedIds.some(itemId => doneItems.find(w => w.id === itemId));
@@ -338,9 +331,8 @@ function checkPlanCompletion(meta, config) {
338
331
  }
339
332
  const verifyProject = existingVerify.project || projName;
340
333
  const vProject = shared.resolveProjectSource(verifyProject, projects, { allowCentral: false }).project || p;
341
- const vWiPath = shared.projectWorkItemsPath(vProject);
342
334
  let reopened = false;
343
- mutateWorkItems(vWiPath, items => {
335
+ mutateWorkItems(vProject, items => {
344
336
  const v = items.find(w => w.id === existingVerify.id);
345
337
  if (isReopenableVerify(v)) {
346
338
  shared.reopenWorkItem(v);
@@ -476,9 +468,8 @@ function checkPlanCompletion(meta, config) {
476
468
  ].join('\n');
477
469
 
478
470
  const verifyId = 'PL-' + shared.uid();
479
- const vWiPath = shared.projectWorkItemsPath(p);
480
471
  let created = false;
481
- mutateWorkItems(vWiPath, workItems => {
472
+ mutateWorkItems(p, workItems => {
482
473
  // Re-check under lock to prevent races
483
474
  if (workItems.some(w => w.sourcePlan === planFile && w.itemType === WORK_TYPE.VERIFY && (w.project === projName || (!w.project && projName === primaryProject.name)))) {
484
475
  return workItems;
@@ -517,18 +508,16 @@ function checkPlanCompletion(meta, config) {
517
508
 
518
509
  // ─── Archive Plan ───────────────────────────────────────────────────────────
519
510
  function archivePlan(planFile, plan, projects, config) {
520
- const planPath = path.join(PRD_DIR, planFile);
521
-
522
511
  // Archive in place by flagging the authoritative SQL PRD.
523
512
  try {
524
- if (safeJsonNoRestore(planPath)) {
525
- mutateJsonFileLocked(planPath, (data) => {
513
+ if (prdStore.readPrd(planFile)) {
514
+ prdStore.mutatePrd(planFile, (data) => {
526
515
  if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
527
516
  data.status = 'archived';
528
517
  data.archived = true;
529
518
  data.archivedAt = new Date().toISOString();
530
519
  return data;
531
- }, { defaultValue: {} });
520
+ });
532
521
  log('info', `Archived PRD in place: ${planFile} (archived flag set)`);
533
522
  }
534
523
  } catch (err) {
@@ -585,7 +574,7 @@ function cleanupPlanWorktrees(planFile, plan, projects, config) {
585
574
 
586
575
  for (const p of projects) {
587
576
  try {
588
- const prs = safeJsonArr(shared.projectPrPath(p));
577
+ const prs = shared.readPullRequests(p);
589
578
  const prLinks = getPrLinks();
590
579
  for (const pr of prs) {
591
580
  const linkedIds = prLinks[pr.id] || [];
@@ -619,34 +608,29 @@ function cleanupPlanWorktrees(planFile, plan, projects, config) {
619
608
  } catch (err) { log('warn', `Plan worktree cleanup: ${err.message}`); }
620
609
  }
621
610
 
622
- // ─── Work Item Path Resolution ───────────────────────────────────────────────
611
+ // ─── Work Item Scope Resolution ──────────────────────────────────────────────
623
612
 
624
- /** Resolve the work-items.json path from dispatch meta. Reused by retry paths. */
625
- function resolveWorkItemPath(meta) {
613
+ function resolveWorkItemScope(meta) {
626
614
  if (meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout') {
627
- return path.join(MINIONS_DIR, 'work-items.json');
615
+ return 'central';
628
616
  }
629
617
  if (meta.source === 'work-item' && meta.project?.name) {
630
- return path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json');
618
+ return meta.project.name;
631
619
  }
632
620
  return null;
633
621
  }
634
622
 
635
- function getWorkItemPaths(config) {
636
- const paths = [path.join(MINIONS_DIR, 'work-items.json')];
637
- for (const p of shared.getProjects(config || {})) {
638
- if (p && p.name) paths.push(shared.projectWorkItemsPath(p));
639
- }
640
- return paths;
623
+ function getWorkItemScopes(config) {
624
+ return ['central', ...shared.getProjects(config || {})];
641
625
  }
642
626
 
643
627
  function findWorkItemRecord(itemId, config) {
644
628
  if (!itemId) return null;
645
- for (const wiPath of getWorkItemPaths(config)) {
629
+ for (const scope of getWorkItemScopes(config)) {
646
630
  try {
647
- const items = readOptionalJsonStrict(wiPath, 'work-items', Array.isArray) || [];
631
+ const items = shared.readWorkItems(scope);
648
632
  const item = items.find(i => i && i.id === itemId);
649
- if (item) return { item, path: wiPath };
633
+ if (item) return { item, scope };
650
634
  } catch { /* best-effort lineage lookup */ }
651
635
  }
652
636
  return null;
@@ -707,11 +691,11 @@ function updateWorkItemStatus(meta, status, reason) {
707
691
  return;
708
692
  }
709
693
 
710
- const wiPath = resolveWorkItemPath(meta);
711
- if (!wiPath) return;
694
+ const wiScope = resolveWorkItemScope(meta);
695
+ if (!wiScope) return;
712
696
 
713
697
  let completionGuarded = false;
714
- mutateJsonFileLocked(wiPath, (items) => {
698
+ mutateWorkItems(wiScope, (items) => {
715
699
  if (!items || !Array.isArray(items)) return items;
716
700
  const target = items.find(i => i.id === itemId);
717
701
  if (!target) return items;
@@ -783,7 +767,7 @@ function updateWorkItemStatus(meta, status, reason) {
783
767
  }
784
768
  }
785
769
  return items;
786
- }, { defaultValue: [], skipWriteIfUnchanged: true });
770
+ });
787
771
 
788
772
  if (completionGuarded) {
789
773
  log('info', `Work item ${itemId} already completed — ignoring ${status} status update`);
@@ -803,15 +787,15 @@ function storeAffectedFilesFromCompletion(meta, structuredCompletion) {
803
787
  if (files.length === 0) return;
804
788
  const itemId = meta?.item?.id;
805
789
  if (!itemId) return;
806
- const wiPath = resolveWorkItemPath(meta);
807
- if (!wiPath) return;
808
- mutateJsonFileLocked(wiPath, (items) => {
790
+ const wiScope = resolveWorkItemScope(meta);
791
+ if (!wiScope) return;
792
+ mutateWorkItems(wiScope, (items) => {
809
793
  if (!Array.isArray(items)) return items;
810
794
  const target = items.find(i => i.id === itemId);
811
795
  if (!target) return items;
812
796
  target.affected_files = files;
813
797
  return items;
814
- }, { skipWriteIfUnchanged: true });
798
+ });
815
799
  }
816
800
 
817
801
  const _VALID_PRD_STATUSES = new Set([...Object.values(WI_STATUS), 'missing']);
@@ -832,16 +816,11 @@ function syncPrdItemStatus(itemId, status, sourcePlan) {
832
816
  if (!itemId) return;
833
817
  if (!_VALID_PRD_STATUSES.has(status)) return;
834
818
  try {
835
- const prdDir = path.join(MINIONS_DIR, 'prd');
836
819
  const files = sourcePlan
837
820
  ? [sourcePlan]
838
- : require('./prd-store').listPrdRows().filter(row => !row.archived).map(row => row.filename);
821
+ : prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
839
822
  for (const pf of files) {
840
- const fpath = path.join(prdDir, pf);
841
- // Lock-free peek: most PRDs won't contain the ID, so skip the lock cost.
842
- // safeJsonNoRestore so an archived PRD's .backup sidecar can't resurrect
843
- // the active PRD just because a done WI still references it.
844
- const plan = safeJsonNoRestore(fpath);
823
+ const plan = prdStore.readPrd(pf);
845
824
  // Frozen PRDs (paused/rejected/awaiting-approval/completed) must not have
846
825
  // their item statuses mutated by a late-arriving WI status update — that
847
826
  // would silently flip a paused/rejected PRD's items and feed the
@@ -850,7 +829,7 @@ function syncPrdItemStatus(itemId, status, sourcePlan) {
850
829
  const feature = plan?.missing_features?.find(f => f.id === itemId);
851
830
  if (!feature || feature.status === status) continue;
852
831
  let updated = false;
853
- mutateJsonFileLocked(fpath, (fresh) => {
832
+ prdStore.mutatePrd(pf, (fresh) => {
854
833
  // Re-check under the lock — status may have frozen between peek and lock.
855
834
  if (_PRD_FROZEN_STATUSES.has(fresh?.status)) return fresh;
856
835
  const f = fresh?.missing_features?.find(x => x.id === itemId);
@@ -859,7 +838,7 @@ function syncPrdItemStatus(itemId, status, sourcePlan) {
859
838
  updated = true;
860
839
  }
861
840
  return fresh;
862
- }, { skipWriteIfUnchanged: true });
841
+ });
863
842
  if (updated) return;
864
843
  }
865
844
  } catch (err) { log('warn', `PRD status sync: ${err.message}`); }
@@ -872,30 +851,28 @@ function syncPrdItemStatus(itemId, status, sourcePlan) {
872
851
  function stampPrdItemWorkItemId(itemId, sourcePlan) {
873
852
  if (!itemId || !sourcePlan) return;
874
853
  try {
875
- const fpath = path.join(PRD_DIR, sourcePlan);
876
- const plan = safeJsonNoRestore(fpath);
854
+ const plan = prdStore.readPrd(sourcePlan);
877
855
  const feature = plan?.missing_features?.find(f => f.id === itemId);
878
856
  if (!feature || feature.workItemId === itemId) return;
879
- mutateJsonFileLocked(fpath, (fresh) => {
857
+ prdStore.mutatePrd(sourcePlan, (fresh) => {
880
858
  const f = fresh?.missing_features?.find(x => x.id === itemId);
881
859
  if (f && f.workItemId !== itemId) f.workItemId = itemId;
882
860
  return fresh;
883
- }, { skipWriteIfUnchanged: true });
861
+ });
884
862
  } catch (err) { log('warn', `stampPrdItemWorkItemId: ${err.message}`); }
885
863
  }
886
864
 
887
865
  function stampPrdItemPrUrl(itemId, sourcePlan, prUrl) {
888
866
  if (!itemId || !sourcePlan || !prUrl) return;
889
867
  try {
890
- const fpath = path.join(PRD_DIR, sourcePlan);
891
- const plan = safeJsonNoRestore(fpath);
868
+ const plan = prdStore.readPrd(sourcePlan);
892
869
  const feature = plan?.missing_features?.find(f => f.id === itemId);
893
870
  if (!feature || feature.pr_url === prUrl) return;
894
- mutateJsonFileLocked(fpath, (fresh) => {
871
+ prdStore.mutatePrd(sourcePlan, (fresh) => {
895
872
  const f = fresh?.missing_features?.find(x => x.id === itemId);
896
873
  if (f && f.pr_url !== prUrl) f.pr_url = prUrl;
897
874
  return fresh;
898
- }, { skipWriteIfUnchanged: true });
875
+ });
899
876
  } catch (err) { log('warn', `stampPrdItemPrUrl: ${err?.message || err}`); }
900
877
  }
901
878
 
@@ -907,7 +884,7 @@ function stampPrdItemPrUrl(itemId, sourcePlan, prUrl) {
907
884
  // different ID than the original PRD feature, leaving the PRD status stale.
908
885
 
909
886
  function reconcilePrdStatuses(config) {
910
- const prdFiles = require('./prd-store').listPrdRows().filter(row => !row.archived).map(row => row.filename);
887
+ const prdFiles = prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
911
888
  if (prdFiles.length === 0) return;
912
889
 
913
890
  const allWorkItems = queries.getWorkItems(config);
@@ -922,9 +899,8 @@ function reconcilePrdStatuses(config) {
922
899
 
923
900
  for (const file of prdFiles) {
924
901
  try {
925
- const fpath = path.join(PRD_DIR, file);
926
902
  const logMessages = [];
927
- mutateJsonFileLocked(fpath, (plan) => {
903
+ prdStore.mutatePrd(file, (plan) => {
928
904
  if (!plan?.missing_features) return plan;
929
905
  // Skip frozen PRDs — paused/rejected/awaiting-approval/completed require no reconciliation
930
906
  if (_PRD_FROZEN_STATUSES.has(plan.status)) return plan;
@@ -943,7 +919,7 @@ function reconcilePrdStatuses(config) {
943
919
  }
944
920
  }
945
921
  return plan;
946
- }, { skipWriteIfUnchanged: true });
922
+ });
947
923
  for (const message of logMessages) log('info', message);
948
924
  } catch (err) { log('warn', `PRD backward-scan for ${file}: ${err.message}`); }
949
925
  }
@@ -1141,15 +1117,14 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
1141
1117
 
1142
1118
  const agentName = config.agents?.[agentId]?.name || agentId;
1143
1119
  let added = 0;
1144
- const centralPrPath = path.join(MINIONS_DIR, 'pull-requests.json');
1145
1120
 
1146
- // Group new PRs by target file path
1147
- const newPrsByPath = new Map(); // prPath -> [{ prId, newEntry }]
1121
+ const newPrsByScope = new Map();
1148
1122
 
1149
1123
  for (const prId of prEvidence.keys()) {
1150
1124
  const targetProject = useCentral ? null : resolveProjectForPr(prId);
1151
1125
  const targetName = targetProject ? targetProject.name : '_central';
1152
- const prPath = targetProject ? shared.projectPrPath(targetProject) : centralPrPath;
1126
+ const source = targetProject || 'central';
1127
+ const scope = shared.stateScope(source);
1153
1128
  const prUrl = extractPrUrl(prId);
1154
1129
  const fullId = shared.getCanonicalPrId(targetProject, prId, prUrl);
1155
1130
 
@@ -1160,7 +1135,7 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
1160
1135
  title = meta?.item?.title || '';
1161
1136
  }
1162
1137
 
1163
- if (!newPrsByPath.has(prPath)) newPrsByPath.set(prPath, { name: targetName, project: targetProject, entries: [] });
1138
+ if (!newPrsByScope.has(scope)) newPrsByScope.set(scope, { source, name: targetName, project: targetProject, entries: [] });
1164
1139
  const entry = {
1165
1140
  id: fullId,
1166
1141
  prNumber: parseInt(prId, 10) || prId,
@@ -1186,15 +1161,15 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
1186
1161
  // "review this PR" via CC) must stay reference-only. QA-session setup and
1187
1162
  // other oneShot+skipPr PR-targeted work are operational context, not an
1188
1163
  // observe toggle, so they must not demote a real tracked PR.
1189
- newPrsByPath.get(prPath).entries.push({ prId, fullId, entry });
1164
+ newPrsByScope.get(scope).entries.push({ prId, fullId, entry });
1190
1165
  }
1191
1166
 
1192
1167
  const entryBranch = meta?.branch || '';
1193
1168
 
1194
- for (const [prPath, { name, project: targetProject, entries }] of newPrsByPath) {
1169
+ for (const { source, name, project: targetProject, entries } of newPrsByScope.values()) {
1195
1170
  for (const { prId, fullId, entry } of entries) {
1196
1171
  let duplicateOnBranch = null;
1197
- const result = shared.upsertPullRequestRecord(prPath, entry, {
1172
+ const result = shared.upsertPullRequestRecord(source, entry, {
1198
1173
  project: targetProject,
1199
1174
  itemId: meta?.item?.id || null,
1200
1175
  beforeInsert: (prs, normalizedEntry) => {
@@ -1244,7 +1219,7 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
1244
1219
  }
1245
1220
  if (result.created || result.linked) added++;
1246
1221
  }
1247
- log('info', `Synced PR(s) from ${agentName}'s output to ${name === '_central' ? 'central' : name}/pull-requests.json`);
1222
+ log('info', `Synced PR(s) from ${agentName}'s output to ${name === '_central' ? 'central' : name} state`);
1248
1223
  }
1249
1224
  return added;
1250
1225
  }
@@ -1261,7 +1236,7 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
1261
1236
  //
1262
1237
  // `enrollPrFromCanonicalId` is the write-side fix: given a canonical PR id and
1263
1238
  // the owning project, fetch live state from GitHub (or ADO, best-effort) and
1264
- // insert a `contextOnly: true` row into the project's `pull-requests.json`.
1239
+ // insert a `contextOnly: true` row into the project's PR state.
1265
1240
  // Idempotent — no-op if a record already exists. Always marks the record as
1266
1241
  // context-only so the engine doesn't try to re-dispatch fix/review loops on
1267
1242
  // an already-merged PR (matches the #1772 enrollment-flag semantics).
@@ -1367,21 +1342,18 @@ async function enrollPrFromCanonicalId(canonicalPrId, project, opts = {}) {
1367
1342
  // pr-links stays consistent (#779 pattern).
1368
1343
  if (opts.itemId && Array.isArray(existing.prdItems) && !existing.prdItems.includes(opts.itemId)) {
1369
1344
  try {
1370
- // W-mrduef3q000m2172 — derive targetPath from the scope the store
1345
+ // W-mrduef3q000m2172 — use the scope the store
1371
1346
  // actually returned `existing` under (existing._scope), NOT from the
1372
1347
  // current call's `project` argument. When the two differ (e.g. the
1373
1348
  // record lives under 'central' but this enrollment call was made
1374
1349
  // with a specific project), re-deriving from `project` sends the
1375
- // upsert to the WRONG scope's file/array. upsertPullRequestRecord's
1350
+ // upsert to the wrong scope. upsertPullRequestRecord's
1376
1351
  // tombstone guard then finds no matching record in that (wrong)
1377
1352
  // scope and falls through to insert-as-new, silently clearing any
1378
1353
  // userDeleted tombstone on the real record. See issue writeup for
1379
1354
  // the confirmed repro.
1380
- const store = require('./pull-requests-store');
1381
- const targetPath = existing._scope
1382
- ? store._filePathForScope(existing._scope)
1383
- : (project ? shared.projectPrPath(project) : path.join(MINIONS_DIR, 'pull-requests.json'));
1384
- shared.upsertPullRequestRecord(targetPath, {
1355
+ const targetScope = existing._scope || project || 'central';
1356
+ shared.upsertPullRequestRecord(targetScope, {
1385
1357
  id: normalizedCanonicalId,
1386
1358
  prNumber,
1387
1359
  url: existing.url || '',
@@ -1439,8 +1411,7 @@ async function enrollPrFromCanonicalId(canonicalPrId, project, opts = {}) {
1439
1411
  return { enrolled: false, reason: 'unsupported_scope' };
1440
1412
  }
1441
1413
 
1442
- const prPath = project ? shared.projectPrPath(project) : path.join(MINIONS_DIR, 'pull-requests.json');
1443
- if (!prPath) return { enrolled: false, reason: 'no_pr_path' };
1414
+ const source = project || 'central';
1444
1415
 
1445
1416
  const entry = {
1446
1417
  id: normalizedCanonicalId,
@@ -1462,12 +1433,12 @@ async function enrollPrFromCanonicalId(canonicalPrId, project, opts = {}) {
1462
1433
  if (liveState && liveState.closedAt && !liveState.mergedAt) entry.closedAt = liveState.closedAt;
1463
1434
 
1464
1435
  try {
1465
- const result = shared.upsertPullRequestRecord(prPath, entry, {
1436
+ const result = shared.upsertPullRequestRecord(source, entry, {
1466
1437
  project,
1467
1438
  itemId: opts.itemId || null,
1468
1439
  });
1469
1440
  if (result.created) {
1470
- log('info', `enrollPrFromCanonicalId: enrolled ${normalizedCanonicalId} (status=${status}) into ${project?.name || 'central'}/pull-requests.json`);
1441
+ log('info', `enrollPrFromCanonicalId: enrolled ${normalizedCanonicalId} (status=${status}) into ${project?.name || 'central'} state`);
1471
1442
  }
1472
1443
  return {
1473
1444
  enrolled: !!(result.created || result.linked),
@@ -1548,11 +1519,10 @@ function stampWiPrRef(meta, config) {
1548
1519
  const prUrl = found.url;
1549
1520
  const prNumber = found.prNumber ?? found.number ?? null;
1550
1521
 
1551
- // Stamp the on-disk WI record.
1552
- const wiPath = resolveWorkItemPath(meta);
1553
- if (wiPath) {
1522
+ const wiScope = resolveWorkItemScope(meta);
1523
+ if (wiScope) {
1554
1524
  try {
1555
- mutateWorkItems(wiPath, items => {
1525
+ mutateWorkItems(wiScope, items => {
1556
1526
  const wi = items.find(i => i.id === itemId);
1557
1527
  if (wi && !wi._pr) {
1558
1528
  if (prId) wi._pr = prId;
@@ -1622,10 +1592,10 @@ function hasCanonicalPrAttachment(itemId, config) {
1622
1592
  if (Object.values(getPrLinks()).some(linkedIds => (linkedIds || []).includes(itemId))) return true;
1623
1593
  const projects = shared.getProjects(config);
1624
1594
  for (const p of projects) {
1625
- const prs = readOptionalJsonStrict(shared.projectPrPath(p), 'project pull-requests', Array.isArray) || [];
1595
+ const prs = shared.readPullRequests(p);
1626
1596
  if (prs.some(pr => (pr.prdItems || []).includes(itemId))) return true;
1627
1597
  }
1628
- const centralPrs = readOptionalJsonStrict(path.join(MINIONS_DIR, 'pull-requests.json'), 'central pull-requests', Array.isArray) || [];
1598
+ const centralPrs = shared.readPullRequests('central');
1629
1599
  return centralPrs.some(pr => (pr.prdItems || []).includes(itemId));
1630
1600
  }
1631
1601
 
@@ -1826,7 +1796,7 @@ function _attachFoundPrToWi(found, meta, agentId, resultSummary, config) {
1826
1796
  itemType: meta.item?.itemType || '',
1827
1797
  };
1828
1798
  applyScheduleContextToPrEntry(entry, meta?.item, config);
1829
- shared.upsertPullRequestRecord(shared.projectPrPath(found.project), entry, {
1799
+ shared.upsertPullRequestRecord(found.project, entry, {
1830
1800
  project: found.project,
1831
1801
  itemId: meta.item.id,
1832
1802
  });
@@ -1894,7 +1864,7 @@ function _outputHasRuntimeResultEvent(output) {
1894
1864
  }
1895
1865
 
1896
1866
  function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity, opts) {
1897
- const noPrWiPath = resolveWorkItemPath(meta);
1867
+ const noPrWiScope = resolveWorkItemScope(meta);
1898
1868
  const isHard = severity !== 'soft';
1899
1869
  const isPhantom = !!(opts && opts.phantom);
1900
1870
  let syncFailedToPrd = false;
@@ -1907,8 +1877,8 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity,
1907
1877
  let phantomRetryExhausted = false;
1908
1878
  let phantomRetryCount = 0;
1909
1879
  let systematicPhantomFired = false;
1910
- if (isHard && isPhantom && noPrWiPath) {
1911
- mutateJsonFileLocked(noPrWiPath, data => {
1880
+ if (isHard && isPhantom && noPrWiScope) {
1881
+ mutateWorkItems(noPrWiScope, data => {
1912
1882
  if (!Array.isArray(data)) return data;
1913
1883
  const w = data.find(i => i.id === meta.item.id);
1914
1884
  if (!w) return data;
@@ -1974,7 +1944,7 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity,
1974
1944
  phantomRetryCount = phantomRetries;
1975
1945
  }
1976
1946
  return data;
1977
- }, { skipWriteIfUnchanged: true });
1947
+ });
1978
1948
  if (phantomRetryDeferred) {
1979
1949
  // Soft inbox note: the runtime crashed but we're retrying; surface the
1980
1950
  // event without flagging the WI as silent failure.
@@ -2009,8 +1979,8 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity,
2009
1979
  reason = `${reason} — phantom retries exhausted (${phantomRetryCount}/${ENGINE_DEFAULTS.maxPhantomRetries})`;
2010
1980
  }
2011
1981
  }
2012
- if (noPrWiPath) {
2013
- mutateJsonFileLocked(noPrWiPath, data => {
1982
+ if (noPrWiScope) {
1983
+ mutateWorkItems(noPrWiScope, data => {
2014
1984
  if (!Array.isArray(data)) return data;
2015
1985
  const w = data.find(i => i.id === meta.item.id);
2016
1986
  if (!w) return data;
@@ -2037,7 +2007,7 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity,
2037
2007
  w._lastReviewReason = reason;
2038
2008
  }
2039
2009
  return data;
2040
- }, { skipWriteIfUnchanged: true });
2010
+ });
2041
2011
  }
2042
2012
  if (isHard && syncFailedToPrd) {
2043
2013
  syncPrdItemStatus(meta.item.id, WI_STATUS.FAILED, meta.item.sourcePlan);
@@ -2071,10 +2041,10 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity,
2071
2041
  }
2072
2042
 
2073
2043
  function markPrAttachmentVerificationError(meta, agentId, reason, resultSummary) {
2074
- const wiPath = resolveWorkItemPath(meta);
2044
+ const wiScope = resolveWorkItemScope(meta);
2075
2045
  let syncFailedToPrd = false;
2076
- if (wiPath) {
2077
- mutateWorkItems(wiPath, data => {
2046
+ if (wiScope) {
2047
+ mutateWorkItems(wiScope, data => {
2078
2048
  if (!Array.isArray(data)) return data;
2079
2049
  const w = data.find(i => i.id === meta.item.id);
2080
2050
  if (!w) return data;
@@ -2088,7 +2058,7 @@ function markPrAttachmentVerificationError(meta, agentId, reason, resultSummary)
2088
2058
  delete w._missingPrAttachment;
2089
2059
  delete w._unverifiedPrAttachment;
2090
2060
  return data;
2091
- }, { skipWriteIfUnchanged: true });
2061
+ });
2092
2062
  }
2093
2063
  if (syncFailedToPrd) {
2094
2064
  syncPrdItemStatus(meta.item.id, WI_STATUS.FAILED, meta.item.sourcePlan);
@@ -2169,7 +2139,7 @@ async function enforcePrAttachmentContract(type, meta, agentId, config, resultSu
2169
2139
 
2170
2140
  // W-mqsk1ip00006cbae — After a verify WI transitions to done, assert that a PR
2171
2141
  // exists: either a direct reference on the item (_pr/_prUrl) or a matching
2172
- // entry in pull-requests.json for the plan (by sourcePlan). If no PR is found,
2142
+ // tracked PR entry for the plan (by sourcePlan). If no PR is found,
2173
2143
  // flip the WI to failed with failure_class: verify-missing-pr and write an
2174
2144
  // inbox alert so operators can investigate.
2175
2145
  //
@@ -2199,10 +2169,10 @@ function enforceVerifyPrContract(type, meta, agentId, config, resultSummary) {
2199
2169
  try {
2200
2170
  const projects = shared.getProjects(config);
2201
2171
  for (const p of projects) {
2202
- const prs = readOptionalJsonStrict(shared.projectPrPath(p), 'project pull-requests', Array.isArray) || [];
2172
+ const prs = shared.readPullRequests(p);
2203
2173
  if (prs.some(pr => pr.sourcePlan === item.sourcePlan)) return null;
2204
2174
  }
2205
- const centralPrs = readOptionalJsonStrict(path.join(MINIONS_DIR, 'pull-requests.json'), 'central pull-requests', Array.isArray) || [];
2175
+ const centralPrs = shared.readPullRequests('central');
2206
2176
  if (centralPrs.some(pr => pr.sourcePlan === item.sourcePlan)) return null;
2207
2177
  } catch (err) {
2208
2178
  log('warn', `enforceVerifyPrContract: sourcePlan PR check failed for ${item.id}: ${err.message}`);
@@ -2212,13 +2182,13 @@ function enforceVerifyPrContract(type, meta, agentId, config, resultSummary) {
2212
2182
  // No PR found — flip WI to failed and surface an alert.
2213
2183
  const failureClass = FAILURE_CLASS.VERIFY_MISSING_PR;
2214
2184
  const reason = `Verify WI ${item.id} completed done but no PR was found ` +
2215
- `(no _prUrl/_pr on item, no canonical attachment, and no pull-requests.json entry for plan ` +
2185
+ `(no _prUrl/_pr on item, no canonical attachment, and no tracked PR entry for plan ` +
2216
2186
  `${item.sourcePlan || '(none)'}). Flipped to failed so the plan does not silently advance. ` +
2217
2187
  `(Branch: ${meta.branch || '(none)'}, agent: ${agentId})`;
2218
2188
 
2219
- const wiPath = resolveWorkItemPath(meta);
2220
- if (wiPath) {
2221
- mutateWorkItems(wiPath, data => {
2189
+ const wiScope = resolveWorkItemScope(meta);
2190
+ if (wiScope) {
2191
+ mutateWorkItems(wiScope, data => {
2222
2192
  if (!Array.isArray(data)) return data;
2223
2193
  const w = data.find(i => i.id === item.id);
2224
2194
  if (!w) return data;
@@ -2228,7 +2198,7 @@ function enforceVerifyPrContract(type, meta, agentId, config, resultSummary) {
2228
2198
  w.failedAt = ts();
2229
2199
  delete w.completedAt;
2230
2200
  return data;
2231
- }, { skipWriteIfUnchanged: true });
2201
+ });
2232
2202
  }
2233
2203
 
2234
2204
  if (item.sourcePlan) {
@@ -2312,10 +2282,6 @@ function reviewPrRefMatchesDispatchTarget(reportedPr, dispatchPr, project) {
2312
2282
  return !(reportedScoped && dispatchScoped);
2313
2283
  }
2314
2284
 
2315
- function centralPrPath() {
2316
- return path.join(MINIONS_DIR, 'pull-requests.json');
2317
- }
2318
-
2319
2285
  function resolveReviewPrContext(pr, project, config, structuredCompletion = null) {
2320
2286
  const reportedPr = reviewPrRefFromCompletion(structuredCompletion);
2321
2287
  if (reportedPr && pr && !reviewPrRefMatchesDispatchTarget(reportedPr, pr, project)) return null;
@@ -2330,28 +2296,26 @@ function resolveReviewPrContext(pr, project, config, structuredCompletion = null
2330
2296
  }
2331
2297
 
2332
2298
  for (const candidateProject of projectCandidates) {
2333
- const prPath = shared.projectPrPath(candidateProject);
2334
- const prs = safeJsonArr(prPath);
2299
+ const prs = shared.readPullRequests(candidateProject);
2335
2300
  for (const ref of refs) {
2336
2301
  const refUrl = typeof ref === 'object' ? ref.url || '' : String(ref || '');
2337
2302
  if (!shared.isPrCompatibleWithProject(candidateProject, ref, refUrl)) continue;
2338
2303
  const target = shared.findPrRecord(prs, ref, candidateProject);
2339
- if (target) return { pr: { ...target }, project: candidateProject, prPath };
2304
+ if (target) return { pr: { ...target }, project: candidateProject, scope: candidateProject.name };
2340
2305
  }
2341
2306
  }
2342
2307
 
2343
- const centralPath = centralPrPath();
2344
- const centralPrs = safeJsonArr(centralPath);
2308
+ const centralPrs = shared.readPullRequests('central');
2345
2309
  const centralRefs = reportedPr ? [reportedPr] : refs;
2346
2310
  for (const ref of centralRefs) {
2347
2311
  const target = shared.findPrRecord(centralPrs, ref, null);
2348
- if (target) return { pr: { ...target }, project: null, prPath: centralPath };
2312
+ if (target) return { pr: { ...target }, project: null, scope: 'central' };
2349
2313
  }
2350
2314
 
2351
2315
  if (reportedPr) return null;
2352
2316
 
2353
2317
  return pr?.id
2354
- ? { pr, project: project || null, prPath: project ? shared.projectPrPath(project) : centralPath }
2318
+ ? { pr, project: project || null, scope: project?.name || 'central' }
2355
2319
  : null;
2356
2320
  }
2357
2321
 
@@ -2372,7 +2336,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2372
2336
  }
2373
2337
  const reviewPr = reviewContext.pr;
2374
2338
  const reviewProject = reviewContext.project;
2375
- const prPath = reviewContext.prPath;
2339
+ const prScope = reviewContext.scope;
2376
2340
  const reviewerName = config.agents?.[agentId]?.name || agentId;
2377
2341
 
2378
2342
  // Check actual review status from the platform (agent may have approved or requested changes)
@@ -2383,7 +2347,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2383
2347
  // BUG-H14 (P-h14-repohost): resolve repoHost from the PR's canonical id
2384
2348
  // FIRST. The previous code derived hostForChecks from the fallback project
2385
2349
  // (`reviewProject || getProjects(config)[0]`) and silently defaulted to
2386
- // 'ado' on miss — so a github:* PR found in the central pull-requests.json
2350
+ // 'ado' on miss — so a github:* PR found in central state
2387
2351
  // (reviewProject=null) with an ADO project as projects[0] was mis-routed to
2388
2352
  // ado.checkLiveReviewStatus. The fallback project is still consulted when
2389
2353
  // the id is unparseable, but there is no silent 'ado' default — an
@@ -2504,7 +2468,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2504
2468
  const maxMissingRetries = config?.engine?.maxReviewVerdictMissingRetries ?? ENGINE_DEFAULTS.maxReviewVerdictMissingRetries;
2505
2469
  let missingCount = 0;
2506
2470
  let retriesExhausted = false;
2507
- shared.mutateJsonFileLocked(prPath, (prs) => {
2471
+ shared.mutatePullRequests(prScope, (prs) => {
2508
2472
  if (!Array.isArray(prs)) return prs;
2509
2473
  const target = shared.findPrRecord(prs, reviewPr, reviewProject);
2510
2474
  if (!target) return prs;
@@ -2524,7 +2488,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2524
2488
  // normal needsReview gate re-dispatches a fresh review next tick.
2525
2489
  if (retriesExhausted) target.lastReviewedAt = ts();
2526
2490
  return prs;
2527
- }, { defaultValue: [] });
2491
+ });
2528
2492
 
2529
2493
  const reason = `Review dispatch for ${reviewPr.id} completed without posting a VERDICT: comment ` +
2530
2494
  `(reviewer: ${reviewerName}, dispatch: ${dispatchItem?.id || structuredCompletion?.dispatchId || '(unknown)'}). ` +
@@ -2545,7 +2509,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2545
2509
  return;
2546
2510
  }
2547
2511
 
2548
- shared.mutateJsonFileLocked(prPath, (prs) => {
2512
+ shared.mutatePullRequests(prScope, (prs) => {
2549
2513
  if (!Array.isArray(prs)) return prs;
2550
2514
  const target = shared.findPrRecord(prs, reviewPr, reviewProject);
2551
2515
  if (!target) return prs;
@@ -3171,7 +3135,7 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
3171
3135
  options = { automationCauseKey: options, dispatchId: legacyDispatchId };
3172
3136
  }
3173
3137
  const explicitlyChangedBranch = options.branchChanged !== false;
3174
- const prPath = project ? shared.projectPrPath(project) : centralPrPath();
3138
+ const prScope = project || 'central';
3175
3139
  const automationCauseKey = options.automationCauseKey || options.dispatchItem?.meta?.automationCauseKey || '';
3176
3140
  const fixDispatchId = options.dispatchItem?.id || options.dispatchId || legacyDispatchId || '';
3177
3141
  const cause = shared.getPrFixAutomationCause({
@@ -3180,7 +3144,7 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
3180
3144
  task: options.dispatchItem?.task,
3181
3145
  });
3182
3146
  let result = null;
3183
- shared.mutateJsonFileLocked(prPath, (prs) => {
3147
+ shared.mutatePullRequests(prScope, (prs) => {
3184
3148
  if (!Array.isArray(prs)) return prs;
3185
3149
  const target = shared.findPrRecord(prs, pr, project);
3186
3150
  if (!target) return prs;
@@ -3384,7 +3348,7 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
3384
3348
  function updatePrAfterFixError(pr, project, source, options = {}) {
3385
3349
  if (!pr?.id) return null;
3386
3350
  if (!options || typeof options !== 'object' || Array.isArray(options)) return null;
3387
- const prPath = project ? shared.projectPrPath(project) : centralPrPath();
3351
+ const prScope = project || 'central';
3388
3352
  const dispatchItem = options.dispatchItem || null;
3389
3353
  const errorMessage = String(options.errorMessage || '').slice(0, 200);
3390
3354
  const errorClass = String(options.errorClass || 'agent-error');
@@ -3394,7 +3358,7 @@ function updatePrAfterFixError(pr, project, source, options = {}) {
3394
3358
  task: dispatchItem?.task,
3395
3359
  });
3396
3360
  let result = null;
3397
- shared.mutateJsonFileLocked(prPath, (prs) => {
3361
+ shared.mutatePullRequests(prScope, (prs) => {
3398
3362
  if (!Array.isArray(prs)) return prs;
3399
3363
  const target = shared.findPrRecord(prs, pr, project);
3400
3364
  if (!target) return prs;
@@ -3478,7 +3442,7 @@ function findDependentActivePrs(mergedItemId, config) {
3478
3442
 
3479
3443
  const projects = shared.getProjects(config);
3480
3444
  for (const p of projects) {
3481
- const prs = safeJsonArr(projectPrPath(p));
3445
+ const prs = shared.readPullRequests(p);
3482
3446
  for (const pr of prs) {
3483
3447
  if (!pr.branch || pr.status !== PR_STATUS.ACTIVE) continue;
3484
3448
  const linked = (pr.prdItems || []).some(id => dependentWis.some(wi => wi.id === id));
@@ -3560,10 +3524,8 @@ async function rebaseBranchOntoMain(pr, project, config) {
3560
3524
  }
3561
3525
  }
3562
3526
 
3563
- const PENDING_REBASES_PATH = path.join(ENGINE_DIR, 'pending-rebases.json');
3564
-
3565
3527
  function queuePendingRebase(pr, project, mergedItemId) {
3566
- mutateJsonFileLocked(PENDING_REBASES_PATH, (pending) => {
3528
+ smallStateStore.applyPendingRebasesMutation((pending) => {
3567
3529
  const prDisplayId = shared.getPrDisplayId(pr);
3568
3530
  if (pending.some(e => e.projectName === project.name && shared.getPrDisplayId(e.prId) === prDisplayId)) return pending; // already queued
3569
3531
  pending.push({ prId: pr.id, branch: pr.branch, projectName: project.name, mergedItemId, queuedAt: ts(), attempts: 0 });
@@ -3575,10 +3537,10 @@ async function processPendingRebases(config) {
3575
3537
  // Atomically drain the queue under lock so concurrent queuePendingRebase calls
3576
3538
  // during processing don't lose entries (they append to the now-empty file).
3577
3539
  let snapshot = [];
3578
- mutateJsonFileLocked(PENDING_REBASES_PATH, (data) => {
3540
+ smallStateStore.applyPendingRebasesMutation((data) => {
3579
3541
  snapshot = [...data];
3580
3542
  return []; // drain file
3581
- }, { defaultValue: [] });
3543
+ });
3582
3544
  if (snapshot.length === 0) return;
3583
3545
 
3584
3546
  const remaining = [];
@@ -3606,9 +3568,9 @@ async function processPendingRebases(config) {
3606
3568
  }
3607
3569
  // Merge remaining items back under lock — entries queued during processing are preserved
3608
3570
  if (remaining.length > 0) {
3609
- mutateJsonFileLocked(PENDING_REBASES_PATH, (data) => {
3571
+ smallStateStore.applyPendingRebasesMutation((data) => {
3610
3572
  data.push(...remaining);
3611
- }, { defaultValue: [] });
3573
+ });
3612
3574
  }
3613
3575
  }
3614
3576
 
@@ -3619,7 +3581,7 @@ async function handlePostMerge(pr, project, config, newStatus) {
3619
3581
  const prNum = shared.getPrNumber(pr);
3620
3582
 
3621
3583
  // `project.localPath` guard: central/observed PRs (e.g. ADO PRs tracked in
3622
- // ~/.minions/pull-requests.json with no configured project) are polled via a
3584
+ // central PRs with no configured project are polled via a
3623
3585
  // *synthesized* project that has no localPath (engine/ado.js + engine/github.js
3624
3586
  // central-poll loops). There is no checkout to clean for those, and an
3625
3587
  // unguarded path.resolve(undefined) here threw "paths[0] argument must be of
@@ -3664,13 +3626,11 @@ async function handlePostMerge(pr, project, config, newStatus) {
3664
3626
  if (mergedItemIds.length > 0) {
3665
3627
  const mergedItemSet = new Set(mergedItemIds);
3666
3628
  // Mark PRD feature as implemented
3667
- const prdDir = path.join(MINIONS_DIR, 'prd');
3668
3629
  try {
3669
- const planFiles = require('./prd-store').listPrdRows().filter(row => !row.archived).map(row => row.filename);
3630
+ const planFiles = prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
3670
3631
  let updated = 0;
3671
3632
  for (const pf of planFiles) {
3672
- const planPath = path.join(prdDir, pf);
3673
- mutateJsonFileLocked(planPath, (plan) => {
3633
+ prdStore.mutatePrd(pf, (plan) => {
3674
3634
  if (!plan?.missing_features) return plan;
3675
3635
  if (shared.isPrdArchived(plan)) return plan; // step 4: never mutate an archived (in-place) PRD's items
3676
3636
  for (const feature of plan.missing_features) {
@@ -3680,18 +3640,17 @@ async function handlePostMerge(pr, project, config, newStatus) {
3680
3640
  }
3681
3641
  }
3682
3642
  return plan;
3683
- }, { skipWriteIfUnchanged: true });
3643
+ });
3684
3644
  }
3685
3645
  if (updated > 0) log('info', `Post-merge: marked ${mergedItemIds.join(', ')} as done for ${pr.id}`);
3686
3646
  } catch (err) { log('warn', `Post-merge PRD update: ${err.message}`); }
3687
3647
 
3688
3648
  // Mark work item as done
3689
- const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
3690
- for (const p of shared.getProjects(config)) wiPaths.push(shared.projectWorkItemsPath(p));
3649
+ const workItemScopes = ['central', ...shared.getProjects(config)];
3691
3650
  const remainingMergedIds = new Set(mergedItemIds);
3692
- for (const wiPath of wiPaths) {
3651
+ for (const scope of workItemScopes) {
3693
3652
  try {
3694
- mutateWorkItems(wiPath, items => {
3653
+ mutateWorkItems(scope, items => {
3695
3654
  for (const item of items) {
3696
3655
  if (!remainingMergedIds.has(item.id)) continue;
3697
3656
  if (item.status !== WI_STATUS.DONE) {
@@ -3753,7 +3712,7 @@ function checkForLearnings(agentId, agentInfo, taskDesc) {
3753
3712
  }
3754
3713
  // Info, not warn: not writing learnings is a soft signal (the agent finished
3755
3714
  // and the engine continues fine). Operators don't need to act on it; keeping
3756
- // it at warn floods log.json (~50 entries/day) with non-actionable noise.
3715
+ // it at warn floods the engine log (~50 entries/day) with non-actionable noise.
3757
3716
  log('info', `${agentInfo?.name || agentId} didn't write learnings — no follow-up queued`);
3758
3717
  }
3759
3718
 
@@ -3846,9 +3805,8 @@ function extractSkillsFromOutput(output, agentId, dispatchItem, config, runtimeN
3846
3805
  const projectSkillRoot = skillWriteTargets(effectiveRuntime, proj).project
3847
3806
  || path.resolve(proj.localPath, '.claude', 'skills');
3848
3807
  const projectSkillPath = path.join(projectSkillRoot, skillDirName, 'SKILL.md');
3849
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
3850
3808
  let skillId = null;
3851
- mutateJsonFileLocked(centralPath, data => {
3809
+ mutateWorkItems('central', data => {
3852
3810
  data = data || [];
3853
3811
  if (data.some(i => i.title === `Add skill: ${name}` && i.status !== WI_STATUS.FAILED)) return data;
3854
3812
  skillId = `SK${String(data.filter(i => i.id?.startsWith('SK')).length + 1).padStart(3, '0')}`;
@@ -3856,7 +3814,7 @@ function extractSkillsFromOutput(output, agentId, dispatchItem, config, runtimeN
3856
3814
  description: `Create project-level skill \`${skillDirName}/SKILL.md\` in ${project}.\n\nWrite this file to \`${projectSkillPath}\` via a PR.\n\n## Skill Content\n\n\`\`\`\n${enrichedBlock}\n\`\`\``,
3857
3815
  priority: 'low', status: WI_STATUS.QUEUED, created: ts(), createdBy: `engine:skill-extraction:${agentName}` });
3858
3816
  return data;
3859
- }, { skipWriteIfUnchanged: true });
3817
+ });
3860
3818
  if (skillId) {
3861
3819
  log('info', `Queued work item ${skillId} to PR project skill "${name}" into ${project}`);
3862
3820
  }
@@ -4189,9 +4147,9 @@ function handleInjectionFlag(dispatchItem, agentId, structuredCompletion, config
4189
4147
  }
4190
4148
 
4191
4149
  try {
4192
- const wiPath = dispatchItem?.meta ? resolveWorkItemPath(dispatchItem.meta) : null;
4193
- if (wiPath && dispatchItem?.meta?.item?.id) {
4194
- mutateWorkItems(wiPath, (items) => {
4150
+ const wiScope = dispatchItem?.meta ? resolveWorkItemScope(dispatchItem.meta) : null;
4151
+ if (wiScope && dispatchItem?.meta?.item?.id) {
4152
+ mutateWorkItems(wiScope, (items) => {
4195
4153
  const wi = items.find((w) => w.id === dispatchItem.meta.item.id);
4196
4154
  if (wi) {
4197
4155
  wi._securityFlag = {
@@ -4367,8 +4325,8 @@ function mergeArtifactNotes(existing, additions) {
4367
4325
  function promoteCompletionArtifacts(meta, agentId, dispatchId, structuredCompletion, opts = {}) {
4368
4326
  const itemId = meta?.item?.id;
4369
4327
  if (!itemId) return { artifacts: [], notes: [] };
4370
- const wiPath = resolveWorkItemPath(meta);
4371
- if (!wiPath) return { artifacts: [], notes: [] };
4328
+ const wiScope = resolveWorkItemScope(meta);
4329
+ if (!wiScope) return { artifacts: [], notes: [] };
4372
4330
 
4373
4331
  const artifacts = normalizeCompletionArtifacts(structuredCompletion?.artifacts);
4374
4332
  const structuredNotes = artifacts.map(completionArtifactToNoteEntry).filter(Boolean);
@@ -4381,7 +4339,7 @@ function promoteCompletionArtifacts(meta, agentId, dispatchId, structuredComplet
4381
4339
  return { artifacts, notes };
4382
4340
  }
4383
4341
 
4384
- mutateJsonFileLocked(wiPath, data => {
4342
+ mutateWorkItems(wiScope, data => {
4385
4343
  if (!Array.isArray(data)) return data;
4386
4344
  const wi = data.find(i => i.id === itemId);
4387
4345
  if (!wi) return data;
@@ -4398,7 +4356,7 @@ function promoteCompletionArtifacts(meta, agentId, dispatchId, structuredComplet
4398
4356
  if (resultSummary) wi.resultSummary = resultSummary;
4399
4357
  wi._artifacts = arts;
4400
4358
  return data;
4401
- }, { defaultValue: [], skipWriteIfUnchanged: true });
4359
+ });
4402
4360
 
4403
4361
  return { artifacts, notes, agentId, dispatchId };
4404
4362
  }
@@ -4487,13 +4445,13 @@ function _deferRetryWithCounter(meta, detection, counterField, maxCount, pending
4487
4445
  const itemId = meta?.item?.id;
4488
4446
  const reason = detection?.reason || 'Nonterminal completion summary';
4489
4447
  if (!itemId) return reason;
4490
- const wiPath = resolveWorkItemPath(meta);
4491
- if (!wiPath) return reason;
4448
+ const wiScope = resolveWorkItemScope(meta);
4449
+ if (!wiScope) return reason;
4492
4450
 
4493
4451
  let finalStatus = WI_STATUS.PENDING;
4494
4452
  let systematicPhantom = null;
4495
4453
  try {
4496
- mutateJsonFileLocked(wiPath, data => {
4454
+ mutateWorkItems(wiScope, data => {
4497
4455
  if (!Array.isArray(data)) return data;
4498
4456
  const w = data.find(i => i.id === itemId);
4499
4457
  if (!w) return data;
@@ -4578,7 +4536,7 @@ function _deferRetryWithCounter(meta, detection, counterField, maxCount, pending
4578
4536
  log('warn', `Work item ${itemId} failed — repeated ${pendingReason} after ${maxCount} attempts`);
4579
4537
  }
4580
4538
  return data;
4581
- }, { defaultValue: [], skipWriteIfUnchanged: true });
4539
+ });
4582
4540
  syncPrdItemStatus(itemId, finalStatus, meta.item?.sourcePlan);
4583
4541
  } catch (err) {
4584
4542
  log('warn', `${pendingReason} gate: ${err.message}`);
@@ -4840,7 +4798,7 @@ function extractDecompositionJson(stdout, runtimeName) {
4840
4798
  * Parses the optional `followups` array on the agent's completion report,
4841
4799
  * logs each entry at `info` so operators can trace fan-out, and warns when
4842
4800
  * a claimed `wi_id` doesn't match any record in central or per-project
4843
- * work-items.json. The mismatch warning catches dispatches that were reverted
4801
+ * work-item state. The mismatch warning catches dispatches that were reverted
4844
4802
  * (HTTP 409 from /api/work-items dedup), fingerprinted away by the standard
4845
4803
  * dedup window, or otherwise didn't actually land — situations where the
4846
4804
  * agent claimed a follow-up exists but the dashboard/engine has no record.
@@ -4893,12 +4851,12 @@ function processCompletionFollowups(completion, agentId, dispatchItem, config) {
4893
4851
  log('info', `Followup audit (${agentId || 'unknown'} wi=${wiId}): dispatched ${followupWiId}${title ? ` — ${title}` : ''}${parentCommentId ? ` (comment=${parentCommentId})` : ''}${reason ? ` — ${reason}` : ''}`);
4894
4852
  const allIds = loadExistingIds();
4895
4853
  if (allIds.size > 0 && !allIds.has(followupWiId)) {
4896
- log('warn', `Followup audit (${agentId || 'unknown'} wi=${wiId}): claimed followup ${followupWiId} not found in any work-items.json — dispatch may have been deduped, reverted, or never landed`);
4854
+ log('warn', `Followup audit (${agentId || 'unknown'} wi=${wiId}): claimed followup ${followupWiId} not found in work-item state — dispatch may have been deduped, reverted, or never landed`);
4897
4855
  }
4898
4856
  if (parentItem && (parentSchedule.scheduleId || parentProject)) {
4899
4857
  let stamped = false;
4900
- for (const wiPath of getWorkItemPaths(config)) {
4901
- mutateWorkItems(wiPath, items => {
4858
+ for (const scope of getWorkItemScopes(config)) {
4859
+ mutateWorkItems(scope, items => {
4902
4860
  const target = Array.isArray(items) ? items.find(i => i && i.id === followupWiId) : null;
4903
4861
  if (!target) return items;
4904
4862
  if (!target.parent_id && target.id !== wiId) target.parent_id = wiId;
@@ -4949,12 +4907,11 @@ function handleDecompositionResult(stdout, meta, config, runtimeName) {
4949
4907
 
4950
4908
  // Find and update the parent work item
4951
4909
  const projects = shared.getProjects(config);
4952
- const allPaths = [path.join(MINIONS_DIR, 'work-items.json')];
4953
- for (const p of projects) allPaths.push(shared.projectWorkItemsPath(p));
4910
+ const allScopes = ['central', ...projects];
4954
4911
 
4955
- for (const wiPath of allPaths) {
4912
+ for (const scope of allScopes) {
4956
4913
  let found = false;
4957
- mutateJsonFileLocked(wiPath, data => {
4914
+ mutateWorkItems(scope, data => {
4958
4915
  if (!Array.isArray(data)) return data;
4959
4916
  const p = data.find(i => i.id === parentId);
4960
4917
  if (!p) return data;
@@ -5000,7 +4957,7 @@ function handleDecompositionResult(stdout, meta, config, runtimeName) {
5000
4957
  data.push(childItem);
5001
4958
  }
5002
4959
  return data;
5003
- }, { skipWriteIfUnchanged: true });
4960
+ });
5004
4961
  if (!found) continue;
5005
4962
  log('info', `Decomposition: ${parentId} → ${subItems.length} sub-items: ${subItems.map(s => s.id).join(', ')}`);
5006
4963
  return subItems.length;
@@ -5049,18 +5006,14 @@ function handleHarnessIterationResult(stdout, structuredCompletion, meta, config
5049
5006
  }
5050
5007
  if (!Array.isArray(nextItems) || nextItems.length === 0) return 0;
5051
5008
 
5052
- // Mirror handleDecompositionResult: scan central + per-project work-items.json
5053
- // and append into the file that owns the evaluator (the trio always lands in
5054
- // the central file in practice — scheduler.discoverScheduledWork writes
5055
- // directly to engine/work-items.json via engine.js — but iterate defensively).
5009
+ // Append into the scope that owns the evaluator.
5056
5010
  const projects = shared.getProjects(config);
5057
- const allPaths = [path.join(MINIONS_DIR, 'work-items.json')];
5058
- for (const p of projects) allPaths.push(shared.projectWorkItemsPath(p));
5011
+ const allScopes = ['central', ...projects];
5059
5012
 
5060
5013
  let appendedTo = null;
5061
- for (const wiPath of allPaths) {
5014
+ for (const scope of allScopes) {
5062
5015
  let found = false;
5063
- mutateJsonFileLocked(wiPath, data => {
5016
+ mutateWorkItems(scope, data => {
5064
5017
  if (!Array.isArray(data)) return data;
5065
5018
  const evaluator = data.find(i => i.id === evaluatorItem.id);
5066
5019
  if (!evaluator) return data;
@@ -5072,12 +5025,12 @@ function handleHarnessIterationResult(stdout, structuredCompletion, meta, config
5072
5025
  data.push(it);
5073
5026
  }
5074
5027
  return data;
5075
- }, { defaultValue: [] });
5076
- if (found) { appendedTo = wiPath; break; }
5028
+ });
5029
+ if (found) { appendedTo = shared.stateScope(scope); break; }
5077
5030
  }
5078
5031
 
5079
5032
  if (!appendedTo) {
5080
- log('warn', `Harness ${evaluatorItem._harness.missionId}: evaluator ${evaluatorItem.id} not found in any work-items.json — iteration skipped`);
5033
+ log('warn', `Harness ${evaluatorItem._harness.missionId}: evaluator ${evaluatorItem.id} not found in work-item state — iteration skipped`);
5081
5034
  return 0;
5082
5035
  }
5083
5036
 
@@ -5112,7 +5065,7 @@ function handleHarnessIterationResult(stdout, structuredCompletion, meta, config
5112
5065
  * ban. If even the deferred ANY_AGENT placeholder cannot be built, log and
5113
5066
  * skip — the existing discovery-driven re-review path (engine.js:~4249)
5114
5067
  * will re-evaluate on the next tick.
5115
- * - All RMW on dispatch.json goes through addToDispatch (mutateDispatch), per
5068
+ * - All dispatch RMW goes through addToDispatch (mutateDispatch), per
5116
5069
  * the concurrency rules in CLAUDE.md.
5117
5070
  */
5118
5071
  function dispatchReReviewForFix(fixDispatchItem, meta, config, opts = {}) {
@@ -5134,12 +5087,9 @@ function dispatchReReviewForFix(fixDispatchItem, meta, config, opts = {}) {
5134
5087
  const project = meta.project || null;
5135
5088
  let livePr = pr;
5136
5089
  try {
5137
- const prPath = project ? shared.projectPrPath(project) : null;
5138
- if (prPath) {
5139
- const prs = shared.safeJsonArr(prPath);
5140
- const target = shared.findPrRecord(prs, pr, project);
5141
- if (target) livePr = target;
5142
- }
5090
+ const prs = shared.readPullRequests(project || 'central');
5091
+ const target = shared.findPrRecord(prs, pr, project);
5092
+ if (target) livePr = target;
5143
5093
  } catch { /* fall through to dispatch meta copy */ }
5144
5094
  if (livePr.status && livePr.status !== PR_STATUS.ACTIVE) {
5145
5095
  log('info', `Re-review skipped for ${pr.id}: PR status is ${livePr.status}`);
@@ -5258,29 +5208,28 @@ function pickReReviewAgentHints(config, opts = {}) {
5258
5208
  function applyGoalInvalidation(sourceWiId, invalidates, config) {
5259
5209
  if (!Array.isArray(invalidates) || invalidates.length === 0) return;
5260
5210
 
5261
- const wiPaths = getWorkItemPaths(config);
5211
+ const workItemScopes = getWorkItemScopes(config);
5262
5212
  const cancellableStatuses = new Set([WI_STATUS.PENDING, WI_STATUS.QUEUED]);
5263
5213
  const cancellationReason = `invalidated-by:${sourceWiId}`;
5264
5214
 
5265
5215
  for (const targetId of invalidates) {
5266
5216
  if (!targetId || typeof targetId !== 'string') continue;
5267
5217
 
5268
- // Find which file contains the WI.
5269
- let foundPath = null;
5218
+ let foundScope = null;
5270
5219
  let foundStatus = null;
5271
- for (const wiPath of wiPaths) {
5220
+ for (const scope of workItemScopes) {
5272
5221
  try {
5273
- const items = readOptionalJsonStrict(wiPath, 'work-items', Array.isArray) || [];
5222
+ const items = shared.readWorkItems(scope);
5274
5223
  const item = items.find(i => i && i.id === targetId);
5275
5224
  if (item) {
5276
- foundPath = wiPath;
5225
+ foundScope = scope;
5277
5226
  foundStatus = item.status;
5278
5227
  break;
5279
5228
  }
5280
5229
  } catch { /* best-effort scan */ }
5281
5230
  }
5282
5231
 
5283
- if (!foundPath) {
5232
+ if (!foundScope) {
5284
5233
  log('warn', `Goal invalidation: WI ${targetId} listed in invalidates[] by ${sourceWiId} not found — skipping`);
5285
5234
  continue;
5286
5235
  }
@@ -5292,7 +5241,7 @@ function applyGoalInvalidation(sourceWiId, invalidates, config) {
5292
5241
 
5293
5242
  // Cancel the WI via mutateWorkItems so the SQL store + state event are updated.
5294
5243
  try {
5295
- mutateWorkItems(foundPath, (items) => {
5244
+ mutateWorkItems(foundScope, (items) => {
5296
5245
  if (!Array.isArray(items)) return items;
5297
5246
  const wi = items.find(i => i && i.id === targetId);
5298
5247
  if (!wi) return items;
@@ -5463,7 +5412,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5463
5412
  // PR follow-up audit (W-mpej3cox00099466) — when the agent declares
5464
5413
  // `followups: [{wi_id, title, reason, parent_comment_id}]` in its completion
5465
5414
  // report, log each entry and verify the claimed WI ids actually landed in
5466
- // some project's work-items.json. A mismatch (claimed wi_id with no matching
5415
+ // some project scope. A mismatch (claimed wi_id with no matching
5467
5416
  // record) is logged at warn so operators can spot dispatches that were
5468
5417
  // reverted, fingerprinted-deduped, or otherwise didn't take.
5469
5418
  if (structuredCompletion) {
@@ -5512,7 +5461,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5512
5461
  // syncPrsFromOutput only enrolls PRs whose URL appeared in this dispatch's
5513
5462
  // output. When meta.item._pr was stamped from an earlier dispatch (e.g. via
5514
5463
  // reconcileItemsWithPrs against pr-links) but the canonical row never made
5515
- // it into pull-requests.json, the dashboard's PRD-progress synthesis
5464
+ // it into tracked PR state, the dashboard's PRD-progress synthesis
5516
5465
  // defaults to ACTIVE forever. This catches those cases at the end of every
5517
5466
  // post-completion run.
5518
5467
  try {
@@ -5579,10 +5528,10 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5579
5528
  // the DONE update so the parent stays pending. The dispatch retry layer
5580
5529
  // will requeue it through the standard cooldown/backoff path.
5581
5530
  skipDoneStatus = true;
5582
- const wiPath = resolveWorkItemPath(meta);
5583
- if (wiPath) {
5531
+ const wiScope = resolveWorkItemScope(meta);
5532
+ if (wiScope) {
5584
5533
  try {
5585
- mutateJsonFileLocked(wiPath, data => {
5534
+ mutateWorkItems(wiScope, data => {
5586
5535
  if (!Array.isArray(data)) return data;
5587
5536
  const wi = data.find(i => i.id === meta.item.id);
5588
5537
  if (wi) {
@@ -5591,7 +5540,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5591
5540
  wi._pendingReason = 'decompose_no_children';
5592
5541
  }
5593
5542
  return data;
5594
- }, { skipWriteIfUnchanged: true });
5543
+ });
5595
5544
  } catch (err) { log('warn', `Decompose retry-prep cleanup: ${err.message}`); }
5596
5545
  }
5597
5546
  log('warn', `Decomposition for ${meta.item.id}: success report but no children created — parent left pending for retry`);
@@ -5600,7 +5549,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5600
5549
 
5601
5550
  // Tri-agent harness iteration (W-mq07a9gf000jbc2b): if the evaluator just
5602
5551
  // completed successfully and verdict says retry, append the next Gen+Eval
5603
- // pair into the same work-items.json. Engine will dispatch them on the
5552
+ // pair into the same work-item scope. Engine will dispatch them on the
5604
5553
  // next tick. No interaction with skipDoneStatus — the evaluator itself
5605
5554
  // still marks DONE; iteration is a sibling write, not a parent decomp.
5606
5555
  if (effectiveSuccess && meta?.item?._harness?.role === harness.HARNESS_ROLE.EVALUATOR) {
@@ -5628,10 +5577,10 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5628
5577
  log('info', `Review ${meta.item.id} bailed out (review already posted) — treating as DONE without retry`);
5629
5578
  } else if (!verdict) {
5630
5579
  skipDoneStatus = true;
5631
- const wiPath = resolveWorkItemPath(meta);
5632
- if (wiPath) {
5580
+ const wiScope = resolveWorkItemScope(meta);
5581
+ if (wiScope) {
5633
5582
  try {
5634
- mutateJsonFileLocked(wiPath, data => {
5583
+ mutateWorkItems(wiScope, data => {
5635
5584
  if (!Array.isArray(data)) return data;
5636
5585
  const w = data.find(i => i.id === meta.item.id);
5637
5586
  if (!w) return data;
@@ -5656,7 +5605,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5656
5605
  log('warn', `Review ${meta.item.id} failed — no verdict after ${ENGINE_DEFAULTS.maxRetries} retries`);
5657
5606
  }
5658
5607
  return data;
5659
- }, { skipWriteIfUnchanged: true });
5608
+ });
5660
5609
  } catch (err) { log('warn', `review verdict check: ${err.message}`); }
5661
5610
  }
5662
5611
  }
@@ -5683,7 +5632,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5683
5632
  if (!prd || !shared.prdMatchesSourcePlan(prd.source_plan, meta.item.planFile)) {
5684
5633
  throw new Error(`sidecar source_plan "${prd?.source_plan ?? '(unknown)'}" does not match "${meta.item.planFile}"`);
5685
5634
  }
5686
- prdStore.writePrd(path.join(PRD_DIR, expectedFile), prd);
5635
+ prdStore.writePrd(expectedFile, prd);
5687
5636
  fs.unlinkSync(resultPath);
5688
5637
  } catch (e) {
5689
5638
  log('warn', `plan-to-prd ${meta.item.id}: could not import result sidecar: ${e.message}`);
@@ -5707,14 +5656,14 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5707
5656
  }
5708
5657
  }
5709
5658
  // Migrate _prdFilename when the source_plan scan found our PRD under a
5710
- // different on-disk filename (e.g. agent bumped past a clobbered name, or
5659
+ // different filename (e.g. agent bumped past a clobbered name, or
5711
5660
  // a previous tick pinned a stale name). Future readers (artifacts, dashboard
5712
5661
  // links) need to point at the actual file.
5713
5662
  if (prdFound && resolvedPrdFilename && resolvedPrdFilename !== expectedFile) {
5714
- const wiPath = resolveWorkItemPath(meta);
5715
- if (wiPath) {
5663
+ const wiScope = resolveWorkItemScope(meta);
5664
+ if (wiScope) {
5716
5665
  try {
5717
- mutateJsonFileLocked(wiPath, data => {
5666
+ mutateWorkItems(wiScope, data => {
5718
5667
  if (!Array.isArray(data)) return data;
5719
5668
  const w = data.find(i => i.id === meta.item.id);
5720
5669
  if (!w) return data;
@@ -5723,7 +5672,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5723
5672
  w._artifacts.prd = resolvedPrdFilename;
5724
5673
  }
5725
5674
  return data;
5726
- }, { skipWriteIfUnchanged: true });
5675
+ });
5727
5676
  meta.item._prdFilename = resolvedPrdFilename;
5728
5677
  log('info', `plan-to-prd ${meta.item.id}: migrated _prdFilename "${expectedFile}" → "${resolvedPrdFilename}" (matched by source_plan, likely collision)`);
5729
5678
  } catch (err) { log('warn', `plan-to-prd _prdFilename migration: ${err.message}`); }
@@ -5731,10 +5680,10 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5731
5680
  }
5732
5681
  if (!prdFound) {
5733
5682
  skipDoneStatus = true;
5734
- const wiPath = resolveWorkItemPath(meta);
5735
- if (wiPath) {
5683
+ const wiScope = resolveWorkItemScope(meta);
5684
+ if (wiScope) {
5736
5685
  try {
5737
- mutateJsonFileLocked(wiPath, data => {
5686
+ mutateWorkItems(wiScope, data => {
5738
5687
  if (!Array.isArray(data)) return data;
5739
5688
  const w = data.find(i => i.id === meta.item.id);
5740
5689
  if (!w) return data;
@@ -5764,7 +5713,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5764
5713
  log('warn', `plan-to-prd ${meta.item.id} failed — no PRD result after ${ENGINE_DEFAULTS.maxRetries} retries`);
5765
5714
  }
5766
5715
  return data;
5767
- }, { skipWriteIfUnchanged: true });
5716
+ });
5768
5717
  } catch (err) { log('warn', `plan-to-prd PRD check: ${err.message}`); }
5769
5718
  }
5770
5719
  }
@@ -5854,15 +5803,15 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5854
5803
  // Failure retry is handled by completeDispatch in dispatch.js — not duplicated here.
5855
5804
  // Only clear _decomposing flag on failure so decompose items don't get permanently stuck.
5856
5805
  if (!effectiveSuccess && meta?.item?.id && type === WORK_TYPE.DECOMPOSE) {
5857
- const wiPath = resolveWorkItemPath(meta);
5858
- if (wiPath) {
5806
+ const wiScope = resolveWorkItemScope(meta);
5807
+ if (wiScope) {
5859
5808
  try {
5860
- mutateJsonFileLocked(wiPath, data => {
5809
+ mutateWorkItems(wiScope, data => {
5861
5810
  if (!Array.isArray(data)) return data;
5862
5811
  const wi = data.find(i => i.id === meta.item.id);
5863
5812
  if (wi) delete wi._decomposing;
5864
5813
  return data;
5865
- }, { skipWriteIfUnchanged: true });
5814
+ });
5866
5815
  } catch (err) { log('warn', `Decompose cleanup: ${err.message}`); }
5867
5816
  }
5868
5817
  }
@@ -5956,7 +5905,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5956
5905
  if (qaSessionId && qaSessionPhase) {
5957
5906
  try {
5958
5907
  const qaSessions = require('./qa-sessions');
5959
- const wiPath = resolveWorkItemPath(meta);
5908
+ const scope = resolveWorkItemScope(meta);
5960
5909
  const project = meta?.project?.name || meta?.item?.project || null;
5961
5910
  const failureReason = typeof resultSummary === 'string' && resultSummary
5962
5911
  ? resultSummary
@@ -5964,7 +5913,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5964
5913
  if (qaSessionPhase === 'setup') {
5965
5914
  qaSessions.handleSetupComplete(qaSessionId, {
5966
5915
  success: !!effectiveSuccess,
5967
- wiPath,
5916
+ scope,
5968
5917
  project,
5969
5918
  failureClass: (structuredCompletion && structuredCompletion.failure_class) || null,
5970
5919
  reason: failureReason,
@@ -6000,7 +5949,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6000
5949
  qaSessions.handleDraftComplete(qaSessionId, {
6001
5950
  success: true,
6002
5951
  testFile,
6003
- wiPath,
5952
+ scope,
6004
5953
  project,
6005
5954
  qaRunId,
6006
5955
  reason: failureReason,
@@ -6078,13 +6027,12 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6078
6027
  }
6079
6028
  }
6080
6029
 
6081
- // Scheduled task back-reference: update schedule-runs.json and write linked inbox note
6030
+ // Scheduled task back-reference: update SQL state and write a linked inbox note.
6082
6031
  if (meta?.item?._scheduleId) {
6083
6032
  try {
6084
6033
  const scheduleId = meta.item._scheduleId;
6085
6034
  const itemId = meta.item.id;
6086
- const schedRunsPath = path.join(ENGINE_DIR, 'schedule-runs.json');
6087
- mutateJsonFileLocked(schedRunsPath, (runs) => {
6035
+ shared.mutateScheduleRuns((runs) => {
6088
6036
  // P-c549d07e — preserve existing fields (esp. recentWorkItemIds ring
6089
6037
  // populated at dispatch time by engine/scheduler.writeScheduleRunEntry).
6090
6038
  // The literal-object replacement that lived here previously silently
@@ -6098,7 +6046,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6098
6046
  lastCompletedAt: ts(),
6099
6047
  };
6100
6048
  return runs;
6101
- }, { defaultValue: {} });
6049
+ });
6102
6050
  const status = effectiveSuccess ? 'succeeded' : 'failed';
6103
6051
  if (effectiveSuccess) {
6104
6052
  // Write a completion note to inbox with back-references only for successful scheduled runs.
@@ -6296,24 +6244,20 @@ function syncPrdFromPrs(config) {
6296
6244
  const allProjects = shared.getProjects(config);
6297
6245
 
6298
6246
  // Exact prdItems match only — no fuzzy matching
6299
- const allPrs = allProjects.flatMap(p => safeJsonArr(shared.projectPrPath(p)));
6247
+ const allPrs = allProjects.flatMap(p => shared.readPullRequests(p));
6300
6248
 
6301
6249
  let totalReconciled = 0;
6302
6250
  for (const project of allProjects) {
6303
- const wiPath = shared.projectWorkItemsPath(project);
6304
- const items = safeJsonArr(wiPath);
6251
+ const items = shared.readWorkItems(project);
6305
6252
  const hasReconcilable = items.some(wi =>
6306
6253
  (wi.status === WI_STATUS.PENDING && !wi._pr) || wi.status === WI_STATUS.FAILED);
6307
6254
  if (!hasReconcilable) continue;
6308
6255
  let reconciled = 0;
6309
- // skipWriteIfUnchanged: per-poll reconcile runs every prPollStatusEvery
6310
- // ticks. Without the flag it'd unconditionally rewrite project work-items.json,
6311
- // tripping the cli.js watcher into a tick storm even with zero reconciliation.
6312
- const reconciledItems = mutateJsonFileLocked(wiPath, data => {
6256
+ const reconciledItems = mutateWorkItems(project, data => {
6313
6257
  if (!Array.isArray(data)) return data;
6314
6258
  reconciled = reconcileItemsWithPrs(data, allPrs);
6315
6259
  return data;
6316
- }, { skipWriteIfUnchanged: true });
6260
+ });
6317
6261
  if (reconciled > 0) {
6318
6262
  // Sync done status to PRD JSON for each newly reconciled item
6319
6263
  for (const wi of (reconciledItems || [])) {
@@ -6336,7 +6280,7 @@ function syncPrdFromPrs(config) {
6336
6280
  let sourcePlan = pr.sourcePlan;
6337
6281
  if (!sourcePlan) {
6338
6282
  for (const project of allProjects) {
6339
- const items = safeJsonArr(shared.projectWorkItemsPath(project));
6283
+ const items = shared.readWorkItems(project);
6340
6284
  const wi = items.find(w => w.id === itemId);
6341
6285
  if (wi?.sourcePlan) { sourcePlan = wi.sourcePlan; break; }
6342
6286
  }
@@ -6377,15 +6321,14 @@ function advancePrdStatusOnAllItemsDone(config) {
6377
6321
  // Collect all PRs by id across all projects for merged-status lookup
6378
6322
  const prById = new Map();
6379
6323
  for (const project of shared.getProjects(config)) {
6380
- for (const pr of safeJsonArr(shared.projectPrPath(project))) {
6324
+ for (const pr of shared.readPullRequests(project)) {
6381
6325
  if (pr?.id) prById.set(pr.id, pr);
6382
6326
  }
6383
6327
  }
6384
6328
 
6385
6329
  for (const file of prdFiles) {
6386
6330
  try {
6387
- const fpath = path.join(PRD_DIR, file);
6388
- const plan = safeJsonNoRestore(fpath);
6331
+ const plan = prdStore.readPrd(file);
6389
6332
  if (!plan?.missing_features?.length) continue;
6390
6333
  // Skip frozen PRDs — paused/rejected/awaiting-approval/completed/archived
6391
6334
  if (_PRD_FROZEN_STATUSES.has(plan.status)) continue;
@@ -6405,13 +6348,13 @@ function advancePrdStatusOnAllItemsDone(config) {
6405
6348
 
6406
6349
  if (!allDone) continue;
6407
6350
 
6408
- mutateJsonFileLocked(fpath, (data) => {
6409
- // Re-check under lock — another writer may have frozen this PRD
6351
+ prdStore.mutatePrd(file, (data) => {
6352
+ // Re-check transactionally — another writer may have frozen this PRD.
6410
6353
  if (_PRD_FROZEN_STATUSES.has(data.status)) return data;
6411
6354
  data.status = PLAN_STATUS.COMPLETED;
6412
6355
  if (!data.completedAt) data.completedAt = ts();
6413
6356
  return data;
6414
- }, { skipWriteIfUnchanged: true });
6357
+ });
6415
6358
  log('info', `PRD auto-advance: ${file} → completed (all items done, all linked PRs merged)`);
6416
6359
  } catch (err) {
6417
6360
  log('warn', `advancePrdStatusOnAllItemsDone error for ${file}: ${err?.message || err}`);
@@ -6419,17 +6362,17 @@ function advancePrdStatusOnAllItemsDone(config) {
6419
6362
  }
6420
6363
  }
6421
6364
 
6422
- // ─── Persist verify/E2E aggregate PRs onto the PRD JSON (W-mqps9jlb) ──────────
6365
+ // ─── Persist verify/E2E aggregate PRs onto the PRD (W-mqps9jlb) ───────────────
6423
6366
  // The PRD view's "E2E Aggregate PRs" section (dashboard/js/render-prd.js#
6424
- // renderE2eSection) sources its PRs from the LIVE tracker (pull-requests.json).
6367
+ // renderE2eSection) sources its PRs from the live tracker.
6425
6368
  // A merged aggregate PR therefore VANISHES from the PRD page once its record is
6426
6369
  // swept — by the daily-remove-merged-prs schedule, a manual delete, or a
6427
6370
  // merge-back/reconcile flow. To make the PRD view survive removal of the live
6428
6371
  // record we durably stamp a compact reference array (`prd.verifyPrs`) onto the
6429
- // owning PRD JSON.
6372
+ // owning PRD.
6430
6373
  //
6431
6374
  // This runs per-tick (right after syncPrdFromPrs). It reads the NORMALIZED
6432
- // pull-requests.json schema, so a single sweep covers BOTH the GitHub and ADO
6375
+ // shared PR schema, so a single sweep covers both GitHub and ADO
6433
6376
  // pollers at once (engine/github.js + engine/ado.js are intentional mirrors
6434
6377
  // writing the same schema) — the durable single-source approach the task
6435
6378
  // allows in lieu of hooking each poller's merged transition separately. Because
@@ -6488,7 +6431,7 @@ function persistVerifyPrsToPrd(config) {
6488
6431
  const refsByPlan = new Map(); // planFile -> Map(id -> ref)
6489
6432
  for (const project of projects) {
6490
6433
  let prs;
6491
- try { prs = safeJsonArr(shared.projectPrPath(project)); } catch { continue; }
6434
+ try { prs = shared.readPullRequests(project); } catch { continue; }
6492
6435
  for (const pr of (prs || [])) {
6493
6436
  if (!pr || !pr.id) continue;
6494
6437
  if (!_isVerifyAggregatePr(pr)) continue;
@@ -6503,20 +6446,19 @@ function persistVerifyPrsToPrd(config) {
6503
6446
  if (refsByPlan.size === 0) return;
6504
6447
 
6505
6448
  for (const [planFile, refMap] of refsByPlan) {
6506
- // Resolve the PRD file (active or archived). sourcePlan is a bare filename.
6507
- const candidates = [path.join(PRD_DIR, planFile), path.join(PRD_DIR, 'archive', planFile)];
6508
- const fpath = candidates.find(p => safeJsonNoRestore(p));
6509
- if (!fpath) continue;
6510
-
6511
- // Lock-free peek (mirrors syncPrdItemStatus): only take the write lock
6512
- // when the persisted refs would actually change. safeJsonNoRestore so an
6513
- // archived PRD's .backup sidecar can't resurrect stale state.
6449
+ let archived = false;
6514
6450
  let plan;
6515
- try { plan = safeJsonNoRestore(fpath); } catch { continue; }
6451
+ try {
6452
+ plan = prdStore.readPrd(planFile);
6453
+ if (!plan) {
6454
+ archived = true;
6455
+ plan = prdStore.readPrd(planFile, { archived: true });
6456
+ }
6457
+ } catch { continue; }
6516
6458
  if (!plan || typeof plan !== 'object') continue;
6517
6459
  if (!_verifyPrsNeedUpdate(plan.verifyPrs, refMap)) continue;
6518
6460
 
6519
- mutateJsonFileLocked(fpath, (fresh) => {
6461
+ prdStore.mutatePrd(planFile, (fresh) => {
6520
6462
  if (!fresh || typeof fresh !== 'object') return fresh;
6521
6463
  const byId = new Map();
6522
6464
  for (const e of (Array.isArray(fresh.verifyPrs) ? fresh.verifyPrs : [])) {
@@ -6528,7 +6470,7 @@ function persistVerifyPrsToPrd(config) {
6528
6470
  }
6529
6471
  fresh.verifyPrs = [...byId.values()];
6530
6472
  return fresh;
6531
- }, { skipWriteIfUnchanged: true });
6473
+ }, { archived });
6532
6474
  }
6533
6475
  } catch (err) {
6534
6476
  try { log('warn', `persistVerifyPrsToPrd error: ${err?.message || err}`); } catch { /* engine not available */ }
@@ -6759,14 +6701,13 @@ function pruneScopeMismatchDuplicatePrs(config) {
6759
6701
  for (const [targetProjectName, entries] of relocationsByTargetProject) {
6760
6702
  const targetProject = projects.find(p => p.name === targetProjectName);
6761
6703
  if (!targetProject) continue;
6762
- const targetPrPath = projectPrPath(targetProject);
6763
6704
  for (const { rec, owningScope } of entries) {
6764
6705
  const entry = { ...rec };
6765
6706
  delete entry._scope;
6766
6707
  delete entry._invalidProjectScope;
6767
6708
  delete entry.project; // let upsertPullRequestRecord backfill from targetProject
6768
6709
  try {
6769
- const result = shared.upsertPullRequestRecord(targetPrPath, entry, {
6710
+ const result = shared.upsertPullRequestRecord(targetProject, entry, {
6770
6711
  project: targetProject,
6771
6712
  itemIds: Array.isArray(rec.prdItems) ? rec.prdItems : null,
6772
6713
  });
@@ -6797,10 +6738,9 @@ function pruneScopeMismatchDuplicatePrs(config) {
6797
6738
  for (const [projectName, idsToDelete] of deletesByProject) {
6798
6739
  const project = projects.find(p => p.name === projectName);
6799
6740
  if (!project) continue;
6800
- const prPath = projectPrPath(project);
6801
6741
  const pruneIds = pruneDeletesByProject.get(projectName) || new Set();
6802
6742
  try {
6803
- shared.mutatePullRequests(prPath, (prs) => {
6743
+ shared.mutatePullRequests(project, (prs) => {
6804
6744
  const deletedIds = prs.filter(p => idsToDelete.has(p?.id)).map(p => p.id);
6805
6745
  const next = prs.filter(p => !idsToDelete.has(p?.id));
6806
6746
  for (const id of deletedIds) {
@@ -6831,9 +6771,8 @@ function collapseAllDuplicatePrRecords(config) {
6831
6771
 
6832
6772
  for (const project of projects) {
6833
6773
  if (!project || !project.name) continue;
6834
- const prPath = projectPrPath(project);
6835
6774
  try {
6836
- const result = shared.collapseDuplicatePrRecords(prPath, { project });
6775
+ const result = shared.collapseDuplicatePrRecords(project, { project });
6837
6776
  collapsed += result.collapsed || 0;
6838
6777
  scanned++;
6839
6778
  } catch (err) {
@@ -6841,14 +6780,13 @@ function collapseAllDuplicatePrRecords(config) {
6841
6780
  }
6842
6781
  }
6843
6782
 
6844
- // Central file (no project context normalization uses URL-only scope).
6845
- const centralPath = path.join(MINIONS_DIR, 'pull-requests.json');
6783
+ // Central scope has no project context; normalization uses URL-only scope.
6846
6784
  try {
6847
- const result = shared.collapseDuplicatePrRecords(centralPath, {});
6785
+ const result = shared.collapseDuplicatePrRecords('central', {});
6848
6786
  collapsed += result.collapsed || 0;
6849
6787
  scanned++;
6850
6788
  } catch (err) {
6851
- log('warn', `collapseAllDuplicatePrRecords: failed for central file: ${err?.message || err}`);
6789
+ log('warn', `collapseAllDuplicatePrRecords: failed for central scope: ${err?.message || err}`);
6852
6790
  }
6853
6791
 
6854
6792
  if (collapsed > 0) {
@@ -6981,11 +6919,11 @@ function autoDispatchLiveValidationWi(meta, config) {
6981
6919
  if (!prRef) return;
6982
6920
 
6983
6921
  const codingWiId = item.id;
6984
- const wiPath = resolveWorkItemPath(meta);
6985
- if (!wiPath) return;
6922
+ const wiScope = resolveWorkItemScope(meta);
6923
+ if (!wiScope) return;
6986
6924
 
6987
6925
  try {
6988
- mutateWorkItems(wiPath, items => {
6926
+ mutateWorkItems(wiScope, items => {
6989
6927
  if (!Array.isArray(items)) return items;
6990
6928
 
6991
6929
  // Dedup: only one validation WI in flight per coding WI. Any non-terminal
@@ -7083,7 +7021,7 @@ module.exports = {
7083
7021
  syncPrdFromPrs,
7084
7022
  advancePrdStatusOnAllItemsDone,
7085
7023
  persistVerifyPrsToPrd,
7086
- resolveWorkItemPath,
7024
+ resolveWorkItemScope,
7087
7025
  isItemCompleted,
7088
7026
  classifyFailure,
7089
7027
  diagnoseEmptyOutput,