@yemi33/minions 0.1.2379 → 0.1.2380

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/bin/minions.js +19 -9
  2. package/dashboard/js/refresh.js +2 -3
  3. package/dashboard/js/render-prd.js +1 -1
  4. package/dashboard/js/render-work-items.js +13 -6
  5. package/dashboard.js +393 -721
  6. package/docs/architecture-review-2026-07-09.md +2 -4
  7. package/docs/auto-discovery.md +36 -49
  8. package/docs/blog-first-successful-dispatch.md +1 -1
  9. package/docs/branch-derivation.md +2 -2
  10. package/docs/command-center.md +1 -1
  11. package/docs/completion-reports.md +14 -4
  12. package/docs/constellation-bridge.md +59 -10
  13. package/docs/constellation-style-telemetry.md +6 -6
  14. package/docs/cooldown-merge-semantics.md +4 -0
  15. package/docs/copilot-cli-schema.md +3 -3
  16. package/docs/cross-repo-plans.md +17 -17
  17. package/docs/deprecated.json +2 -2
  18. package/docs/design-state-storage.md +1 -1
  19. package/docs/documentation-audit-2026-07-09.md +2 -2
  20. package/docs/engine-restart.md +20 -8
  21. package/docs/harness-mode.md +1 -1
  22. package/docs/managed-spawn.md +4 -4
  23. package/docs/onboarding.md +1 -2
  24. package/docs/pr-comment-followup.md +3 -3
  25. package/docs/pr-review-fix-loop.md +1 -1
  26. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  27. package/docs/qa-runbook-lifecycle.md +4 -4
  28. package/docs/qa-runbooks.md +2 -2
  29. package/docs/rfc-completion-json.md +4 -1
  30. package/docs/runtime-adapters.md +1 -1
  31. package/docs/self-improvement.md +4 -5
  32. package/docs/shared-lifecycle-module-map.md +3 -1
  33. package/docs/slim-ux/architecture-suggestions.md +5 -6
  34. package/docs/slim-ux/concepts.md +23 -25
  35. package/docs/watches.md +7 -7
  36. package/docs/workspace-manifests.md +1 -1
  37. package/docs/worktree-lifecycle.md +1 -1
  38. package/engine/abandoned-pr-reconciliation.js +4 -5
  39. package/engine/ado-status.js +5 -5
  40. package/engine/ado.js +20 -25
  41. package/engine/agent-worker-pool.js +58 -1
  42. package/engine/bridge.js +260 -5
  43. package/engine/cleanup.js +48 -131
  44. package/engine/cli.js +125 -83
  45. package/engine/cooldown.js +9 -16
  46. package/engine/db/index.js +22 -9
  47. package/engine/db/migrations/009-qa.js +1 -1
  48. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  49. package/engine/db/migrations/021-archived-work-items.js +72 -0
  50. package/engine/db/migrations/022-global-cc-session.js +31 -0
  51. package/engine/db/migrations/023-engine-state.js +30 -0
  52. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  53. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  54. package/engine/dispatch-store.js +2 -7
  55. package/engine/dispatch.js +34 -41
  56. package/engine/github.js +20 -27
  57. package/engine/lifecycle.js +221 -283
  58. package/engine/llm.js +1 -1
  59. package/engine/logs-store.js +2 -2
  60. package/engine/managed-spawn.js +2 -23
  61. package/engine/meeting.js +6 -6
  62. package/engine/metrics-store.js +2 -2
  63. package/engine/note-link-backfill.js +6 -11
  64. package/engine/pipeline.js +18 -36
  65. package/engine/playbook.js +1 -1
  66. package/engine/prd-store.js +73 -54
  67. package/engine/preflight.js +2 -5
  68. package/engine/projects.js +15 -62
  69. package/engine/pull-requests-store.js +0 -17
  70. package/engine/qa-runbooks.js +2 -2
  71. package/engine/qa-runs.js +1 -8
  72. package/engine/qa-sessions.js +41 -64
  73. package/engine/queries.js +120 -219
  74. package/engine/routing.js +3 -5
  75. package/engine/scheduler.js +0 -4
  76. package/engine/shared-branch-pr-reconcile.js +2 -3
  77. package/engine/shared.js +132 -637
  78. package/engine/small-state-store.js +89 -10
  79. package/engine/state-operations.js +16 -4
  80. package/engine/stdio-timestamps.js +1 -1
  81. package/engine/timeout.js +5 -12
  82. package/engine/watch-actions.js +20 -22
  83. package/engine/watches-store.js +1 -1
  84. package/engine/watches.js +6 -10
  85. package/engine/work-item-validation.js +52 -0
  86. package/engine/work-items-store.js +127 -29
  87. package/engine/worktree-gc.js +2 -2
  88. package/engine/worktree-pool.js +8 -18
  89. package/engine.js +147 -343
  90. package/minions.js +2 -2
  91. package/package.json +1 -1
  92. package/playbooks/plan-to-prd.md +2 -2
  93. package/playbooks/shared-rules.md +3 -3
  94. package/playbooks/templates/followup-dispatch.md +1 -1
  95. package/playbooks/verify.md +1 -1
  96. package/prompts/cc-system.md +2 -2
package/engine.js CHANGED
@@ -57,7 +57,7 @@ const IDENTITY_DIR = path.join(MINIONS_DIR, 'identity');
57
57
 
58
58
  // Re-export from queries for internal use (avoid changing every call site)
59
59
  const { CONFIG_PATH, NOTES_PATH, AGENTS_DIR, ENGINE_DIR, CONTROL_PATH,
60
- DISPATCH_PATH, LOG_PATH, INBOX_DIR, KNOWLEDGE_DIR, PLANS_DIR, PRD_DIR } = queries;
60
+ INBOX_DIR, KNOWLEDGE_DIR, PLANS_DIR, PRD_DIR } = queries;
61
61
 
62
62
  // ─── Multi-Project Support ──────────────────────────────────────────────────
63
63
  // Config can have either:
@@ -102,7 +102,7 @@ function validateConfig(config) {
102
102
  }
103
103
  }
104
104
 
105
- const { getProjects, projectRoot, projectStateDir, projectWorkItemsPath, projectPrPath, getAdoOrgBase, sanitizeBranch, parseSkillFrontmatter, safeReadDir,
105
+ const { getProjects, projectRoot, projectStateDir, getAdoOrgBase, sanitizeBranch, parseSkillFrontmatter, safeReadDir,
106
106
  logTs, dateStamp, log } = shared;
107
107
 
108
108
  // ─── Utilities ──────────────────────────────────────────────────────────────
@@ -110,12 +110,13 @@ const { getProjects, projectRoot, projectStateDir, projectWorkItemsPath, project
110
110
  const safeJson = shared.safeJson;
111
111
  const safeJsonArr = shared.safeJsonArr;
112
112
  const safeJsonObj = shared.safeJsonObj;
113
- const safeJsonNoRestore = shared.safeJsonNoRestore;
114
113
  const safeRead = shared.safeRead;
115
114
  const safeWrite = shared.safeWrite;
116
115
  const safeUnlink = shared.safeUnlink;
117
116
  const mutateJsonFileLocked = shared.mutateJsonFileLocked;
118
117
  const mutateControl = shared.mutateControl;
118
+ const readWorkItems = shared.readWorkItems;
119
+ const readPullRequests = shared.readPullRequests;
119
120
  const mutateWorkItems = shared.mutateWorkItems;
120
121
  const mutatePullRequests = shared.mutatePullRequests;
121
122
  const withFileLock = shared.withFileLock;
@@ -175,7 +176,7 @@ const ghToken = require('./engine/gh-token');
175
176
  const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, reconcilePrdStatuses, handlePostMerge, checkPlanCompletion,
176
177
  syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
177
178
  updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs, persistVerifyPrsToPrd,
178
- isItemCompleted, classifyFailure: classifyFailureFallback, diagnoseEmptyOutput, processPendingRebases, resolveWorkItemPath,
179
+ isItemCompleted, classifyFailure: classifyFailureFallback, diagnoseEmptyOutput, processPendingRebases, resolveWorkItemScope,
179
180
  mergeArtifactNotes, promoteCompletionArtifacts, pruneScopeMismatchDuplicatePrs, collapseAllDuplicatePrRecords,
180
181
  recordWorktreeHeldPause, clearWorktreeHeldPause } = require('./engine/lifecycle');
181
182
 
@@ -701,8 +702,7 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
701
702
 
702
703
  // Find PR branches for each dependency work item
703
704
  for (const p of projects) {
704
- const prPath = shared.projectPrPath(p);
705
- const prs = safeJsonArr(prPath);
705
+ const prs = readPullRequests(p);
706
706
  for (const pr of prs) {
707
707
  if (!pr.branch || pr.status !== PR_STATUS.ACTIVE) continue;
708
708
  const linked = (pr.prdItems || []).some(id =>
@@ -2159,7 +2159,7 @@ function _tryAutoResolveLiveCheckoutWorktreeConflict({ conflictPath, gitRoot, en
2159
2159
  async function spawnAgent(dispatchItem, config) {
2160
2160
  const { id, agent: agentId, type, meta } = dispatchItem;
2161
2161
  // Resolve prompt — prefers sidecar file when dispatchItem._promptFile is set
2162
- // (large prompts are written to engine/contexts/<id>.md to keep dispatch.json
2162
+ // (large prompts are written to engine/contexts/<id>.md to keep dispatch rows
2163
2163
  // small — see shared.sidecarDispatchPrompt / #1167).
2164
2164
  let taskPrompt = shared.resolveDispatchPrompt(dispatchItem);
2165
2165
  const claudeConfig = config.claude || {};
@@ -2415,12 +2415,8 @@ async function spawnAgent(dispatchItem, config) {
2415
2415
  // dir via shared.dispatchPidCandidates / findDispatchPidFile. Best-effort —
2416
2416
  // backward-compat fallbacks still scan dispatch-<safeId>-* dirs by id.
2417
2417
  //
2418
- // W-mq9b7lor (H1): must route through mutateDispatch (SQL writer) a direct
2419
- // mutateJsonFileLocked on engine/dispatch.json is dropped on the next
2420
- // mutateDispatch call because engine/dispatch.js#mutateDispatch regenerates
2421
- // dispatch.json from SQL after every successful mutation (see
2422
- // engine/dispatch.js:98-101). Without the SQL write, the tmpDir pointer is
2423
- // lost and orphan-reap/kill/cleanup paths can't find the prompt directory.
2418
+ // W-mq9b7lor (H1): persist through mutateDispatch so the tmpDir pointer
2419
+ // survives and orphan-reap/kill/cleanup paths can find the prompt directory.
2424
2420
  try {
2425
2421
  dispatchItem.tmpDir = dispatchTmpDir;
2426
2422
  mutateDispatch((dispatch) => {
@@ -2519,7 +2515,7 @@ async function spawnAgent(dispatchItem, config) {
2519
2515
  if ((meta?.source === 'pr' || meta?.source === 'pr-human-feedback') && meta?.pr) {
2520
2516
  try {
2521
2517
  const pauseProject = meta.project || project;
2522
- mutatePullRequests(projectPrPath(pauseProject), (prs) => {
2518
+ mutatePullRequests(pauseProject, (prs) => {
2523
2519
  if (!Array.isArray(prs)) return prs;
2524
2520
  const target = shared.findPrRecord(prs, meta.pr, pauseProject);
2525
2521
  if (!target) return prs;
@@ -2581,7 +2577,7 @@ async function spawnAgent(dispatchItem, config) {
2581
2577
  // validation WI auto-created by the post-completion hook that carries a PR
2582
2578
  // reference but no explicit branch), resolve the target branch from the work
2583
2579
  // item's PR reference so prepareLiveCheckout can check out the correct PR
2584
- // source branch. resolveWorkItemPrRecord loads pull-requests.json + finds the
2580
+ // source branch. resolveWorkItemPrRecord loads tracked PR state and finds the
2585
2581
  // PR record; resolvePrBranch extracts the source branch from it. Both helpers
2586
2582
  // are hoisted function declarations so the order-of-definition is safe.
2587
2583
  if (liveMode && !branchName && !READ_ONLY_ROOT_TASK_TYPES.has(type)) {
@@ -2682,9 +2678,9 @@ async function spawnAgent(dispatchItem, config) {
2682
2678
  wiId: _wiIdForAlert,
2683
2679
  log: (msg, lvl) => log(lvl || 'info', msg),
2684
2680
  clearDirtyStamp: () => {
2685
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
2686
- if (_wiPath && dispatchItem.meta?.item?.id) {
2687
- mutateJsonFileLocked(_wiPath, (data) => {
2681
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
2682
+ if (_wiScope && dispatchItem.meta?.item?.id) {
2683
+ mutateWorkItems(_wiScope, (data) => {
2688
2684
  if (!Array.isArray(data)) return data;
2689
2685
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
2690
2686
  if (wi && wi._pendingReason === 'live_checkout_dirty') delete wi._pendingReason;
@@ -2821,9 +2817,9 @@ async function spawnAgent(dispatchItem, config) {
2821
2817
  try { writeInboxAlert(`live-checkout-dirty-${_wiIdForAlert}`, _alertBody); }
2822
2818
  catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
2823
2819
  try {
2824
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
2825
- if (_wiPath && dispatchItem.meta?.item?.id) {
2826
- mutateJsonFileLocked(_wiPath, (data) => {
2820
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
2821
+ if (_wiScope && dispatchItem.meta?.item?.id) {
2822
+ mutateWorkItems(_wiScope, (data) => {
2827
2823
  if (!Array.isArray(data)) return data;
2828
2824
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
2829
2825
  if (wi) {
@@ -2892,9 +2888,9 @@ async function spawnAgent(dispatchItem, config) {
2892
2888
  catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
2893
2889
  const _pendingReason = _isMidOp ? 'live_checkout_mid_operation' : 'live_checkout_detached_head';
2894
2890
  try {
2895
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
2896
- if (_wiPath && dispatchItem.meta?.item?.id) {
2897
- mutateJsonFileLocked(_wiPath, (data) => {
2891
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
2892
+ if (_wiScope && dispatchItem.meta?.item?.id) {
2893
+ mutateWorkItems(_wiScope, (data) => {
2898
2894
  if (!Array.isArray(data)) return data;
2899
2895
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
2900
2896
  if (wi) wi._pendingReason = _pendingReason;
@@ -2966,9 +2962,9 @@ async function spawnAgent(dispatchItem, config) {
2966
2962
  try { writeInboxAlert(`live-checkout-blocked-${_wiIdForAlert}`, _alertBody); }
2967
2963
  catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
2968
2964
  try {
2969
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
2970
- if (_wiPath && dispatchItem.meta?.item?.id) {
2971
- mutateJsonFileLocked(_wiPath, (data) => {
2965
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
2966
+ if (_wiScope && dispatchItem.meta?.item?.id) {
2967
+ mutateWorkItems(_wiScope, (data) => {
2972
2968
  if (!Array.isArray(data)) return data;
2973
2969
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
2974
2970
  if (wi) wi._pendingReason = 'live_checkout_wrong_base';
@@ -3025,9 +3021,9 @@ async function spawnAgent(dispatchItem, config) {
3025
3021
  try { writeInboxAlert(`live-checkout-blocked-${_wiIdForAlert}`, _alertBody); }
3026
3022
  catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
3027
3023
  try {
3028
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
3029
- if (_wiPath && dispatchItem.meta?.item?.id) {
3030
- mutateJsonFileLocked(_wiPath, (data) => {
3024
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
3025
+ if (_wiScope && dispatchItem.meta?.item?.id) {
3026
+ mutateWorkItems(_wiScope, (data) => {
3031
3027
  if (!Array.isArray(data)) return data;
3032
3028
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
3033
3029
  if (wi) wi._pendingReason = 'live_checkout_blob_fetch';
@@ -3129,9 +3125,9 @@ async function spawnAgent(dispatchItem, config) {
3129
3125
  try { writeInboxAlert(`live-checkout-worktree-conflict-${_wiIdForAlert}`, _alertBody); }
3130
3126
  catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
3131
3127
  try {
3132
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
3133
- if (_wiPath && dispatchItem.meta?.item?.id) {
3134
- mutateJsonFileLocked(_wiPath, (data) => {
3128
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
3129
+ if (_wiScope && dispatchItem.meta?.item?.id) {
3130
+ mutateWorkItems(_wiScope, (data) => {
3135
3131
  if (!Array.isArray(data)) return data;
3136
3132
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
3137
3133
  if (wi) wi._pendingReason = 'live_checkout_worktree_conflict';
@@ -3215,9 +3211,9 @@ async function spawnAgent(dispatchItem, config) {
3215
3211
  try { writeInboxAlert(`live-checkout-stale-base-${_wiIdForAlert}`, _alertBody); }
3216
3212
  catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
3217
3213
  try {
3218
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
3219
- if (_wiPath && dispatchItem.meta?.item?.id) {
3220
- mutateJsonFileLocked(_wiPath, (data) => {
3214
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
3215
+ if (_wiScope && dispatchItem.meta?.item?.id) {
3216
+ mutateWorkItems(_wiScope, (data) => {
3221
3217
  if (!Array.isArray(data)) return data;
3222
3218
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
3223
3219
  if (wi) wi._pendingReason = 'live_checkout_stale_base';
@@ -3244,9 +3240,9 @@ async function spawnAgent(dispatchItem, config) {
3244
3240
  // second-strike on its next dirty encounter. Only write when the stamp is set.
3245
3241
  if (dispatchItem.meta?.item?._liveCheckoutDirtyAttempts) {
3246
3242
  try {
3247
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
3248
- if (_wiPath && dispatchItem.meta?.item?.id) {
3249
- mutateJsonFileLocked(_wiPath, (data) => {
3243
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
3244
+ if (_wiScope && dispatchItem.meta?.item?.id) {
3245
+ mutateWorkItems(_wiScope, (data) => {
3250
3246
  if (!Array.isArray(data)) return data;
3251
3247
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
3252
3248
  if (wi) delete wi._liveCheckoutDirtyAttempts;
@@ -3905,7 +3901,7 @@ async function spawnAgent(dispatchItem, config) {
3905
3901
  // Fetch all dependency branches in parallel (git fetches are independent)
3906
3902
  const fetchable = depBranches.filter(d => !_failedRefCache.has(d.branch));
3907
3903
  const unfetchable = depBranches.filter(d => _failedRefCache.has(d.branch));
3908
- const allPrsForDeps = unfetchable.length > 0 ? shared.getProjects(config).reduce((acc, p) => acc.concat(safeJsonArr(shared.projectPrPath(p))), []) : [];
3904
+ const allPrsForDeps = unfetchable.length > 0 ? shared.getProjects(config).reduce((acc, p) => acc.concat(readPullRequests(p)), []) : [];
3909
3905
  for (const { branch: depBranch, prId } of unfetchable) {
3910
3906
  const pr = allPrsForDeps.find(p => p.id === prId);
3911
3907
  if (pr && (pr.status === PR_STATUS.MERGED || pr.status === PR_STATUS.CLOSED)) {
@@ -3938,7 +3934,7 @@ async function spawnAgent(dispatchItem, config) {
3938
3934
  })
3939
3935
  );
3940
3936
  const hasFetchFailures = fetchResults.some(r => r.status === 'rejected');
3941
- const allPrsForFetch = hasFetchFailures ? shared.getProjects(config).reduce((acc, p) => acc.concat(safeJsonArr(shared.projectPrPath(p))), []) : [];
3937
+ const allPrsForFetch = hasFetchFailures ? shared.getProjects(config).reduce((acc, p) => acc.concat(readPullRequests(p)), []) : [];
3942
3938
  // Track branches recovered by local-only push so they can be merged
3943
3939
  const recoveredBranches = new Set();
3944
3940
  for (let i = 0; i < fetchResults.length; i++) {
@@ -4291,9 +4287,7 @@ async function spawnAgent(dispatchItem, config) {
4291
4287
  // so commits land on the dep's existing PR branch (W-mpcwojgr000a0244).
4292
4288
  if (depConflictBranch && meta?.item?.id && project) {
4293
4289
  try {
4294
- const wiPath = project.name
4295
- ? projectWorkItemsPath(project)
4296
- : path.join(MINIONS_DIR, 'work-items.json');
4290
+ const wiScope = project.name ? project : 'central';
4297
4291
  const newItem = buildDepConflictFixItem({
4298
4292
  depConflictBranch,
4299
4293
  depConflictFiles,
@@ -4303,7 +4297,7 @@ async function spawnAgent(dispatchItem, config) {
4303
4297
  blockedItem: meta.item,
4304
4298
  projectName: project.name || null,
4305
4299
  });
4306
- mutateWorkItems(wiPath, items => {
4300
+ mutateWorkItems(wiScope, items => {
4307
4301
  // Don't create duplicate conflict-fix items
4308
4302
  const existing = items.find(i => i.id === newItem.id && i.status !== WI_STATUS.DONE && i.status !== WI_STATUS.FAILED && i.status !== WI_STATUS.CANCELLED);
4309
4303
  if (existing) return;
@@ -4538,7 +4532,7 @@ async function spawnAgent(dispatchItem, config) {
4538
4532
  log('info', `Spawning agent: ${agentId} (${id}) in ${cwd}`);
4539
4533
  log('info', `Task type: ${type} | Branch: ${branchName || 'none'}`);
4540
4534
 
4541
- // Agent status is derived from dispatch.json — no setAgentStatus needed for working state.
4535
+ // Agent status is derived from dispatch state.
4542
4536
 
4543
4537
  // Spawn the claude process
4544
4538
  const childEnv = shared.cleanChildEnv();
@@ -5412,7 +5406,7 @@ async function spawnAgent(dispatchItem, config) {
5412
5406
  // agent described in its sidecar. This gate (a) rejects malformed
5413
5407
  // sidecars as a hard non-retryable failure with a dedicated failure
5414
5408
  // class + inbox alert, and (b) on success spawns each spec detached and
5415
- // batch-records them in engine/managed-processes.json. Healthcheck loops
5409
+ // batch-records them in SQL managed-process state. Healthcheck loops
5416
5410
  // + dispatch ERROR-on-healthcheck-failure land in the follow-up item;
5417
5411
  // for now a spec that spawns successfully is recorded with
5418
5412
  // healthy:false, alive:true and the engine sweep / item-3 healthcheck
@@ -5911,9 +5905,9 @@ async function spawnAgent(dispatchItem, config) {
5911
5905
  // engine/managed-logs/.
5912
5906
  if (managedSpawnHealthcheckFailure && managedSpawnHealthcheckFailure.annotation && dispatchItem.meta?.item?.id) {
5913
5907
  try {
5914
- const wiPath = resolveWorkItemPath(dispatchItem.meta);
5915
- if (wiPath) {
5916
- mutateWorkItems(wiPath, items => {
5908
+ const wiScope = resolveWorkItemScope(dispatchItem.meta);
5909
+ if (wiScope) {
5910
+ mutateWorkItems(wiScope, items => {
5917
5911
  const wi = items.find(i => i.id === dispatchItem.meta.item.id);
5918
5912
  if (!wi) return items;
5919
5913
  wi._managedSpawnPartial = managedSpawnHealthcheckFailure.annotation;
@@ -5930,9 +5924,9 @@ async function spawnAgent(dispatchItem, config) {
5930
5924
  // rather than a queue gate.
5931
5925
  if (keepProcessesAcceptanceFailure && dispatchItem.meta?.item?.id) {
5932
5926
  try {
5933
- const wiPath = resolveWorkItemPath(dispatchItem.meta);
5934
- if (wiPath) {
5935
- mutateJsonFileLocked(wiPath, data => {
5927
+ const wiScope = resolveWorkItemScope(dispatchItem.meta);
5928
+ if (wiScope) {
5929
+ mutateWorkItems(wiScope, data => {
5936
5930
  if (!Array.isArray(data)) return data;
5937
5931
  const wi = data.find(i => i.id === dispatchItem.meta.item.id);
5938
5932
  if (!wi) return data;
@@ -5949,7 +5943,7 @@ async function spawnAgent(dispatchItem, config) {
5949
5943
  // Cleanup temp files (whole per-dispatch dir, including PID/sysprompt
5950
5944
  // tmp/prompt — P-f6-tmp-toctou). removeDispatchTmpDir validates the path
5951
5945
  // resolves under engine/tmp/dispatch-* before rmSync, so a corrupted
5952
- // dispatch.json field can't redirect this to an arbitrary path.
5946
+ // dispatch field cannot redirect this to an arbitrary path.
5953
5947
  try { shared.removeDispatchTmpDir(dispatchTmpDir); } catch { /* cleanup */ }
5954
5948
 
5955
5949
  log('info', `Agent ${agentId} completed. Output saved to ${archivePath}`);
@@ -6051,7 +6045,7 @@ async function spawnAgent(dispatchItem, config) {
6051
6045
  item.started_at = startedAt;
6052
6046
  // Persist the resolved runtime so post-completion hooks (lifecycle.js) can
6053
6047
  // route output parsing through the right adapter. Also surfaces the choice
6054
- // in dispatch.json for debugging multi-runtime fleets.
6048
+ // in dispatch state for debugging multi-runtime fleets.
6055
6049
  item.runtimeName = runtimeName;
6056
6050
  // P-3f5b7d26 — persist pooled on the DISK record (activeProcesses/_pooled
6057
6051
  // is wiped on restart); cli.js's reattach path needs this since a pooled
@@ -6078,14 +6072,9 @@ async function spawnAgent(dispatchItem, config) {
6078
6072
  // Atomically stamp dispatched_to/dispatched_at on the originating work item (#402).
6079
6073
  if (meta?.item?.id) {
6080
6074
  try {
6081
- let wiPath = null;
6082
- if (meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout') {
6083
- wiPath = path.join(MINIONS_DIR, 'work-items.json');
6084
- } else if (meta.source === 'work-item' && meta.project?.name) {
6085
- wiPath = projectWorkItemsPath({ name: meta.project.name, localPath: meta.project.localPath });
6086
- }
6087
- if (wiPath) {
6088
- mutateJsonFileLocked(wiPath, (items) => {
6075
+ const wiScope = resolveWorkItemScope(meta);
6076
+ if (wiScope) {
6077
+ mutateWorkItems(wiScope, (items) => {
6089
6078
  if (!Array.isArray(items)) return items;
6090
6079
  const wi = items.find(i => i.id === meta.item.id);
6091
6080
  if (wi) {
@@ -6094,7 +6083,7 @@ async function spawnAgent(dispatchItem, config) {
6094
6083
  if (wi.status !== WI_STATUS.DISPATCHED) wi.status = WI_STATUS.DISPATCHED;
6095
6084
  }
6096
6085
  return items;
6097
- }, { defaultValue: [] });
6086
+ });
6098
6087
  }
6099
6088
  } catch (e) { log('warn', `stamp dispatched_to on work item ${meta.item.id}: ${e.message}`); }
6100
6089
  }
@@ -6119,12 +6108,9 @@ function areDependenciesMet(item, config) {
6119
6108
  for (const depId of deps) {
6120
6109
  const depItem = allWorkItems.find(w => w.id === depId);
6121
6110
  if (!depItem) {
6122
- // Fallback: check PRD JSON — plan-to-prd agents may pre-set items to done.
6123
- // safeJsonNoRestore — if the PRD has been archived, treat the dep as
6124
- // unmet rather than resurrecting the active PRD from .backup
6125
- // (W-mouptdh1000h9f39).
6111
+ // Fallback: check the PRD store — plan-to-prd agents may pre-set items to done.
6126
6112
  try {
6127
- const plan = safeJsonNoRestore(path.join(PRD_DIR, sourcePlan));
6113
+ const plan = prdStore.readPrd(sourcePlan);
6128
6114
  const prdItem = (plan?.missing_features || []).find(f => f.id === depId);
6129
6115
  if (prdItem && PRD_MET_STATUSES.has(prdItem.status)) continue; // PRD says done — treat as met
6130
6116
  } catch (e) { log('warn', 'check PRD dep status: ' + e.message); }
@@ -6196,8 +6182,8 @@ function detectDependencyCycles(items) {
6196
6182
  }
6197
6183
 
6198
6184
  // Reconciles work items against known PRs.
6199
- // Primary linkage comes from prdItems in pull-requests.json; fallback linkage
6200
- // uses engine/pr-links.json so matching does not depend on branch/title parsing.
6185
+ // Primary linkage comes from tracked PR prdItems; fallback linkage
6186
+ // uses SQL PR links so matching does not depend on branch/title parsing.
6201
6187
  // onlyIds: if provided, only items whose ID is in this Set are eligible.
6202
6188
  function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
6203
6189
  const prLinks = shared.getPrLinks();
@@ -6289,7 +6275,7 @@ function updateSnapshot(config) {
6289
6275
 
6290
6276
  // ─── Cooldowns (extracted to engine/cooldown.js) ─────────────────────────────
6291
6277
 
6292
- const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
6278
+ const { dispatchCooldowns, loadCooldowns, saveCooldowns,
6293
6279
  isOnCooldown, setCooldown, setCooldownWithContext, drainCoalescedContexts,
6294
6280
  setCooldownFailure, clearCooldown, getPrReviewCooldownKey, clearLegacyPrReviewCooldown,
6295
6281
  isAlreadyDispatched, isBranchActive } = require('./engine/cooldown');
@@ -6297,12 +6283,11 @@ const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
6297
6283
  // Auto-clean pending/failed work items for a PRD so they re-materialize with updated plan data
6298
6284
  function autoCleanPrdWorkItems(prdFile, config) {
6299
6285
  const allProjects = getProjects(config);
6300
- const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
6301
- for (const proj of allProjects) wiPaths.push(projectWorkItemsPath(proj));
6286
+ const workItemScopes = ['central', ...allProjects];
6302
6287
  const deletedIds = [];
6303
- for (const wiPath of wiPaths) {
6288
+ for (const scope of workItemScopes) {
6304
6289
  try {
6305
- mutateWorkItems(wiPath, items => {
6290
+ mutateWorkItems(scope, items => {
6306
6291
  const filtered = items.filter(w => {
6307
6292
  if (w.sourcePlan === prdFile && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.FAILED)) {
6308
6293
  deletedIds.push(w.id); return false;
@@ -6345,51 +6330,6 @@ function safePrdProjectSlug(projectName) {
6345
6330
  return slug || 'project';
6346
6331
  }
6347
6332
 
6348
- /**
6349
- * Atomically reserve a unique PRD filename in `prdDir` (P-9b7e5d3c).
6350
- *
6351
- * Replaces the racy "fs.existsSync(...) then write" pattern: two parallel
6352
- * materializations on the same plan slug previously both observed a 'free'
6353
- * filename and silently overwrote each other. This helper uses
6354
- * `fs.openSync(path, 'wx')` so the OS rejects duplicate creates atomically.
6355
- *
6356
- * Attempt schedule (100 attempts total):
6357
- * attempt = 0 → baseFileName (e.g. "slug.json")
6358
- * attempt = 1 → "${stem}-1${ext}" (e.g. "slug-1.json")
6359
- * attempt = 2 → "${stem}-2${ext}"
6360
- * ...
6361
- * attempt = 99 → "${stem}-99${ext}"
6362
- *
6363
- * Behavior:
6364
- * - On EEXIST: increment attempt and retry.
6365
- * - Any other openSync error propagates immediately.
6366
- * - After 100 EEXIST failures: throw
6367
- * 'Could not reserve unique PRD filename after 100 attempts'.
6368
- * - On success: write `content` to the reserved file, close fd, return basename.
6369
- */
6370
- function reservePrdFilename(prdDir, baseFileName, content = '') {
6371
- const ext = path.extname(baseFileName);
6372
- const stem = ext ? baseFileName.slice(0, -ext.length) : baseFileName;
6373
- for (let attempt = 0; attempt < 100; attempt++) {
6374
- const candidateName = attempt === 0 ? baseFileName : `${stem}-${attempt}${ext}`;
6375
- const candidatePath = path.join(prdDir, candidateName);
6376
- let fd;
6377
- try {
6378
- fd = fs.openSync(candidatePath, 'wx');
6379
- } catch (err) {
6380
- if (err && err.code === 'EEXIST') continue;
6381
- throw err;
6382
- }
6383
- try {
6384
- if (content) fs.writeSync(fd, content);
6385
- } finally {
6386
- fs.closeSync(fd);
6387
- }
6388
- return candidateName;
6389
- }
6390
- throw new Error('Could not reserve unique PRD filename after 100 attempts');
6391
- }
6392
-
6393
6333
  // Fleet branches the engine/CC create. A worktree-mode operator checkout should
6394
6334
  // NEVER sit on one of these (or any branch); when it does, a flow ran raw
6395
6335
  // `git checkout`/`merge` in the live checkout instead of a worktree.
@@ -6458,16 +6398,15 @@ function restoreHijackedOperatorCheckouts(config) {
6458
6398
  }
6459
6399
 
6460
6400
  function materializePlansAsWorkItems(config) {
6461
- if (!fs.existsSync(PRD_DIR)) { try { fs.mkdirSync(PRD_DIR, { recursive: true }); } catch (e) { log('warn', 'create PRD directory: ' + e.message); } }
6462
6401
  const writePrdLocked = (fileName, data) => {
6463
- return mutateJsonFileLocked(path.join(PRD_DIR, fileName), () => data, { defaultValue: data });
6402
+ return prdStore.writePrd(fileName, data);
6464
6403
  };
6465
- const mutatePrdLocked = (fileName, fallback, mutator, options = {}) => {
6466
- return mutateJsonFileLocked(path.join(PRD_DIR, fileName), (current) => {
6404
+ const mutatePrdLocked = (fileName, fallback, mutator) => {
6405
+ return prdStore.mutatePrd(fileName, (current) => {
6467
6406
  if (!current?.missing_features && fallback?.missing_features) current = fallback;
6468
6407
  if (!current || Array.isArray(current) || typeof current !== 'object') current = {};
6469
6408
  return mutator(current) || current;
6470
- }, { defaultValue: fallback || {}, ...options });
6409
+ }, { defaultValue: fallback || {} });
6471
6410
  };
6472
6411
  const enforceDeclaredPlanProject = (fileName, currentPlan) => {
6473
6412
  if (!currentPlan?.source_plan) return { fileName, plan: currentPlan };
@@ -6526,7 +6465,7 @@ function materializePlansAsWorkItems(config) {
6526
6465
  const jsonName = mf.replace(/\.md$/, '.json');
6527
6466
  // Atomic open ('wx' + retry) so two parallel migrations on the
6528
6467
  // same slug don't both overwrite a single PRD (P-9b7e5d3c).
6529
- const reserved = reservePrdFilename(checkDir, jsonName, JSON.stringify(parsed, null, 2));
6468
+ const reserved = prdStore.createPrdUnique(jsonName, parsed);
6530
6469
  try { fs.unlinkSync(path.join(checkDir, mf)); } catch { /* cleanup */ }
6531
6470
  log('info', `Plan enforcement: moved ${mf} → prd/${reserved} (PRDs must be .json in prd/)`);
6532
6471
  }
@@ -6543,7 +6482,7 @@ function materializePlansAsWorkItems(config) {
6543
6482
  if (parsed?.missing_features) {
6544
6483
  // Atomic open ('wx' + retry) so two parallel migrations on the
6545
6484
  // same slug don't both overwrite a single PRD (P-9b7e5d3c).
6546
- const reserved = reservePrdFilename(PRD_DIR, jf, JSON.stringify(parsed, null, 2));
6485
+ const reserved = prdStore.createPrdUnique(jf, parsed);
6547
6486
  try { fs.unlinkSync(path.join(PLANS_DIR, jf)); } catch { /* cleanup */ }
6548
6487
  log('info', `Auto-migrated PRD ${jf} from plans/ to prd/${reserved}`);
6549
6488
  }
@@ -6563,10 +6502,7 @@ function materializePlansAsWorkItems(config) {
6563
6502
  const SEQUENTIAL_ID_RE = /^[A-Za-z]+-?\d+$/;
6564
6503
 
6565
6504
  for (let file of planFiles) {
6566
- // safeJsonNoRestore if a PRD was archived between readdir and this
6567
- // read, do not auto-resurrect it from a stale .backup sidecar
6568
- // (W-mouptdh1000h9f39).
6569
- let plan = safeJsonNoRestore(path.join(PRD_DIR, file));
6505
+ let plan = prdStore.readPrd(file);
6570
6506
  if (!plan?.missing_features) continue;
6571
6507
  // Resurrection-re-execution guard ("old PRDs came back"): never (re)materialize
6572
6508
  // a defunct PRD — archived, or completed with its source plan gone from plans/.
@@ -6713,7 +6649,7 @@ function materializePlansAsWorkItems(config) {
6713
6649
  const defaultProjectName = plan.project || file.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
6714
6650
  const allProjects = getProjects(config);
6715
6651
  const defaultProject = shared.resolveProjectSource(defaultProjectName, allProjects, { allowCentral: false }).project;
6716
- // No project found — use central work-items.json (engine works without projects)
6652
+ // No project found — use central state (engine works without projects).
6717
6653
  const useCentral = !defaultProject;
6718
6654
 
6719
6655
  const statusFilter = PRD_MATERIALIZABLE;
@@ -6737,7 +6673,7 @@ function materializePlansAsWorkItems(config) {
6737
6673
  );
6738
6674
 
6739
6675
  // Group items by target project (per-item project field overrides plan-level project)
6740
- // When no projects are configured, all items go to central work-items.json
6676
+ // When no projects are configured, all items use the central scope.
6741
6677
  const itemsByProject = new Map(); // projectName -> { project, items: [] }
6742
6678
  for (const item of items) {
6743
6679
  if (item.project) {
@@ -6787,19 +6723,19 @@ function materializePlansAsWorkItems(config) {
6787
6723
  // A pause/reject/awaiting-approval signal may arrive between the outer-loop initial
6788
6724
  // read and this point (e.g. operator pauses via dashboard while the tick is running).
6789
6725
  // Re-reading here prevents phantom WI creation on a paused, rejected, or unapproved plan.
6790
- const freshPrd = prdStore.readPrd(path.join(PRD_DIR, file));
6726
+ const freshPrd = prdStore.readPrd(file);
6791
6727
  const freshStatus = (freshPrd || {}).status || ((freshPrd || {}).requires_approval ? 'awaiting-approval' : null);
6792
6728
  if (freshStatus === PLAN_STATUS.PAUSED || freshStatus === PLAN_STATUS.REJECTED || freshStatus === 'awaiting-approval') {
6793
6729
  log('warn', `PRD ${file}: status changed to '${freshStatus}' mid-tick — skipping WI batch for ${projName} (P-f1a3b5c7)`);
6794
6730
  break;
6795
6731
  }
6796
6732
 
6797
- const wiPath = project ? projectWorkItemsPath(project) : path.join(MINIONS_DIR, 'work-items.json');
6733
+ const wiScope = project || 'central';
6798
6734
  let created = 0;
6799
6735
  const newlyCreatedIds = new Set(); // tracks IDs created in this pass for reconciliation scoping
6800
6736
  const deferredReopens = []; // cross-project re-opens executed after this lock releases
6801
6737
 
6802
- mutateWorkItems(wiPath, existingItems => {
6738
+ mutateWorkItems(wiScope, existingItems => {
6803
6739
  for (const item of projItems) {
6804
6740
  // Re-open: 'updated' or 'missing' re-opens a done work item (#906)
6805
6741
  // Use composite (sourcePlan, id) key so a WI from another PRD with the
@@ -6821,7 +6757,7 @@ function materializePlansAsWorkItems(config) {
6821
6757
  if (!alreadyExists) {
6822
6758
  for (const p of allProjects) {
6823
6759
  if (String(p.name || '').toLowerCase() === String(projName || '').toLowerCase()) continue;
6824
- const otherItems = safeJsonArr(projectWorkItemsPath(p));
6760
+ const otherItems = readWorkItems(p);
6825
6761
  const otherWi = otherItems.find(w => w.id === item.id && w.sourcePlan === file);
6826
6762
  if (otherWi) {
6827
6763
  if (DONE_STATUSES.has(otherWi.status) && shouldReopen) {
@@ -6867,7 +6803,7 @@ function materializePlansAsWorkItems(config) {
6867
6803
 
6868
6804
  if (created > 0) {
6869
6805
  // Reconciliation: exact prdItems match only, scoped to newly created items
6870
- const allPrsForReconcile = allProjects.flatMap(p => safeJsonArr(projectPrPath(p)));
6806
+ const allPrsForReconcile = allProjects.flatMap(p => readPullRequests(p));
6871
6807
  const reconciled = reconcileItemsWithPrs(existingItems, allPrsForReconcile, { onlyIds: newlyCreatedIds });
6872
6808
  if (reconciled > 0) log('info', `Plan reconciliation: marked ${reconciled} item(s) as done → ${projName}`);
6873
6809
 
@@ -6901,8 +6837,7 @@ function materializePlansAsWorkItems(config) {
6901
6837
  for (const { itemId, projectName: rProjName, item: rItem } of deferredReopens) {
6902
6838
  const rProject = shared.resolveProjectSource(rProjName, allProjects, { allowCentral: false }).project;
6903
6839
  if (!rProject) continue;
6904
- const rPath = projectWorkItemsPath(rProject);
6905
- mutateWorkItems(rPath, items => {
6840
+ mutateWorkItems(rProject, items => {
6906
6841
  const target = items.find(w => w.id === itemId);
6907
6842
  if (target && DONE_STATUSES.has(target.status)) {
6908
6843
  shared.reopenWorkItem(target);
@@ -6982,8 +6917,7 @@ function materializePlansAsWorkItems(config) {
6982
6917
  function clearPendingHumanFeedbackFlag(projectMeta, prId) {
6983
6918
  if (!prId) return;
6984
6919
  try {
6985
- const prsPath = projectPrPath(projectMeta);
6986
- mutatePullRequests(prsPath, prs => {
6920
+ mutatePullRequests(projectMeta, prs => {
6987
6921
  const target = shared.findPrRecord(prs, prId, projectMeta);
6988
6922
  if (!target?.humanFeedback?.pendingFix) return;
6989
6923
  target.humanFeedback.pendingFix = false;
@@ -7058,7 +6992,7 @@ async function isWorktreeHeldPauseSuppressed(pr, project) {
7058
6992
  }
7059
6993
  if (!stillHeld) {
7060
6994
  try {
7061
- mutatePullRequests(projectPrPath(project), (prs) => {
6995
+ mutatePullRequests(project, (prs) => {
7062
6996
  if (!Array.isArray(prs)) return prs;
7063
6997
  const target = shared.findPrRecord(prs, pr, project);
7064
6998
  if (!target) return prs;
@@ -7110,7 +7044,7 @@ function resolvePrBranch(pr) {
7110
7044
  function updatePrBranchResolutionState(project, pr, { branch = '', reason = '' } = {}) {
7111
7045
  let changed = false;
7112
7046
  try {
7113
- mutatePullRequests(projectPrPath(project), prs => {
7047
+ mutatePullRequests(project, prs => {
7114
7048
  const target = shared.findPrRecord(prs, pr, project);
7115
7049
  if (!target) return;
7116
7050
  if (branch) {
@@ -7363,19 +7297,24 @@ function _resetAutoFixPausedLogState() {
7363
7297
  // persist — the warning is for the operator at engine startup/run time).
7364
7298
  const _warnedSilentDiscovery = new Set();
7365
7299
 
7366
- function _warnSilentDiscoveryOnce(kind, project, dataPath, config) {
7300
+ function _warnSilentDiscoveryOnce(kind, project, config) {
7367
7301
  const key = `${kind}:${project?.name || project?.localPath || 'default'}`;
7368
7302
  if (_warnedSilentDiscovery.has(key)) return;
7369
7303
  let count = 0;
7370
- try { const arr = safeJson(dataPath); if (Array.isArray(arr)) count = arr.length; } catch {}
7371
- if (count <= 0) return; // empty file is fine — no warning worth surfacing
7304
+ try {
7305
+ const records = kind === 'pullRequests'
7306
+ ? shared.readPullRequests(project)
7307
+ : shared.readWorkItems(project);
7308
+ if (Array.isArray(records)) count = records.length;
7309
+ } catch {}
7310
+ if (count <= 0) return;
7372
7311
  _warnedSilentDiscovery.add(key);
7373
7312
  const projName = project?.name || '(unnamed)';
7374
7313
  const hasBlock = !!(project?.workSources?.[kind] || config?.workSources?.[kind]);
7375
7314
  const hint = hasBlock
7376
7315
  ? `Toggle in Dashboard → Settings → Project, or set \`workSources.${kind}.enabled: true\` in config.json.`
7377
7316
  : `Run \`minions doctor\` to inspect, \`minions init\` if you cloned the repo without setup, or re-link the project: \`minions add ${project?.localPath || projName}\`.`;
7378
- log('warn', `Silent-discovery footgun: project "${projName}" has ${count} record(s) in ${path.basename(dataPath)} but workSources.${kind}.enabled is not true — engine will not pick them up. ${hint}`);
7317
+ log('warn', `Silent-discovery footgun: project "${projName}" has ${count} ${kind} record(s) but workSources.${kind}.enabled is not true — engine will not pick them up. ${hint}`);
7379
7318
  }
7380
7319
 
7381
7320
  /**
@@ -7396,12 +7335,12 @@ function _logPrDispatchSkipOnce(pr, reason) {
7396
7335
  }
7397
7336
 
7398
7337
  /**
7399
- * Scan pull-requests.json for PRs needing review or fixes
7338
+ * Scan tracked PR state for records needing review or fixes.
7400
7339
  */
7401
7340
  async function discoverFromPrs(config, project) {
7402
7341
  const src = project?.workSources?.pullRequests || config.workSources?.pullRequests;
7403
7342
  if (!src?.enabled) {
7404
- _warnSilentDiscoveryOnce('pullRequests', project, projectPrPath(project), config);
7343
+ _warnSilentDiscoveryOnce('pullRequests', project, config);
7405
7344
  return [];
7406
7345
  }
7407
7346
 
@@ -7549,7 +7488,7 @@ async function discoverFromPrs(config, project) {
7549
7488
  if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = liveStatus;
7550
7489
  // Persist so next tick doesn't re-check
7551
7490
  try {
7552
- mutateJsonFileLocked(projectPrPath(project), data => {
7491
+ mutatePullRequests(project, data => {
7553
7492
  if (!Array.isArray(data)) return data;
7554
7493
  const target = shared.findPrRecord(data, pr, project);
7555
7494
  if (target && target.reviewStatus !== REVIEW_STATUS.APPROVED) target.reviewStatus = liveStatus;
@@ -7765,7 +7704,7 @@ async function discoverFromPrs(config, project) {
7765
7704
  log('info', `Pre-dispatch vote check: ${pr.id} is ${liveStatus} (cached was waiting) — skipping re-review`);
7766
7705
  if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = liveStatus;
7767
7706
  try {
7768
- mutateJsonFileLocked(projectPrPath(project), data => {
7707
+ mutatePullRequests(project, data => {
7769
7708
  if (!Array.isArray(data)) return data;
7770
7709
  const target = shared.findPrRecord(data, pr, project);
7771
7710
  if (target && target.reviewStatus !== REVIEW_STATUS.APPROVED) target.reviewStatus = liveStatus;
@@ -7940,7 +7879,7 @@ async function discoverFromPrs(config, project) {
7940
7879
  const live = await checkBcFn(pr, project);
7941
7880
  if (live?.buildStatusStale) {
7942
7881
  try {
7943
- mutatePullRequests(projectPrPath(project), prs => {
7882
+ mutatePullRequests(project, prs => {
7944
7883
  const target = shared.findPrRecord(prs, pr, project);
7945
7884
  if (!target) return;
7946
7885
  target._buildStatusStale = true;
@@ -7952,7 +7891,7 @@ async function discoverFromPrs(config, project) {
7952
7891
  log('info', `Pre-dispatch build check: ${pr.id} build is ${live.buildStatus} (cached was failing) — skipping build-fix`);
7953
7892
  // Persist the fresh status so subsequent ticks don't re-check on every pass
7954
7893
  try {
7955
- mutatePullRequests(projectPrPath(project), prs => {
7894
+ mutatePullRequests(project, prs => {
7956
7895
  const target = shared.findPrRecord(prs, pr, project);
7957
7896
  if (!target) return;
7958
7897
  target.buildStatus = live.buildStatus;
@@ -8016,8 +7955,7 @@ async function discoverFromPrs(config, project) {
8016
7955
  if (pr.agent && !pr._buildFailNotified) {
8017
7956
  // Mark noted to prevent repeated processing; the fix dispatch and PR state carry the failure details.
8018
7957
  try {
8019
- const prPath = projectPrPath(project);
8020
- mutatePullRequests(prPath, prs => {
7958
+ mutatePullRequests(project, prs => {
8021
7959
  const target = shared.findPrRecord(prs, pr, project);
8022
7960
  if (target) {
8023
7961
  target._buildFailNotified = true;
@@ -8082,7 +8020,7 @@ async function discoverFromPrs(config, project) {
8082
8020
  if (live && live.mergeConflict === false) {
8083
8021
  log('info', `Pre-dispatch conflict check: ${pr.id} reports clean merge (cached was conflict) — skipping conflict-fix`);
8084
8022
  try {
8085
- mutatePullRequests(projectPrPath(project), prs => {
8023
+ mutatePullRequests(project, prs => {
8086
8024
  const target = shared.findPrRecord(prs, pr, project);
8087
8025
  if (!target) return;
8088
8026
  delete target._mergeConflict;
@@ -8111,7 +8049,7 @@ async function discoverFromPrs(config, project) {
8111
8049
  newWork.push(item);
8112
8050
  // Record dispatch timestamp so re-dispatch is suppressed during ADO lag window
8113
8051
  try {
8114
- mutatePullRequests(projectPrPath(project), prs => {
8052
+ mutatePullRequests(project, prs => {
8115
8053
  const target = shared.findPrRecord(prs, pr, project);
8116
8054
  if (target) target._conflictFixedAt = new Date().toISOString();
8117
8055
  });
@@ -8211,7 +8149,7 @@ function _buildRunnerBriefVars(item, project) {
8211
8149
  }
8212
8150
 
8213
8151
  /**
8214
- * Scan work-items.json for manually queued tasks
8152
+ * Scan a project work-item scope for manually queued tasks.
8215
8153
  */
8216
8154
  function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, project, root, branchName, options = {}) {
8217
8155
  const worktreePath = options.worktreePath || path.resolve(root, config.engine?.worktreeRoot || '../worktrees', `${branchName}`);
@@ -8425,7 +8363,7 @@ function resolveWorkItemPrRecord(item, project) {
8425
8363
  if (!project) return null;
8426
8364
  const prRef = getWorkItemPrRef(item);
8427
8365
  if (!prRef) return null;
8428
- const prs = safeJsonArr(projectPrPath(project));
8366
+ const prs = readPullRequests(project);
8429
8367
  shared.normalizePrRecords(prs, project);
8430
8368
  return shared.findPrRecord(prs, prRef, project);
8431
8369
  }
@@ -8492,7 +8430,7 @@ function refreshDeferredReviewPrompt(item, config) {
8492
8430
  // head SHA before rebuilding the prompt.
8493
8431
  let livePr = pr;
8494
8432
  try {
8495
- const prs = safeJsonArr(projectPrPath(project));
8433
+ const prs = readPullRequests(project);
8496
8434
  const found = shared.findPrRecord(prs, pr, project);
8497
8435
  if (found) livePr = found;
8498
8436
  } catch (e) { log('warn', `refreshDeferredReviewPrompt: live PR lookup for ${pr.id} failed: ${e.message}`); }
@@ -8524,12 +8462,12 @@ function refreshDeferredReviewPrompt(item, config) {
8524
8462
  function discoverFromWorkItems(config, project) {
8525
8463
  const src = project?.workSources?.workItems || config.workSources?.workItems;
8526
8464
  if (!src?.enabled) {
8527
- _warnSilentDiscoveryOnce('workItems', project, projectWorkItemsPath(project), config);
8465
+ _warnSilentDiscoveryOnce('workItems', project, config);
8528
8466
  return [];
8529
8467
  }
8530
8468
 
8531
8469
  const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
8532
- const items = safeJsonArr(projectWorkItemsPath(project));
8470
+ const items = readWorkItems(project);
8533
8471
  // W-mq9b7lor (H3): snapshot each item's serializable state BEFORE the
8534
8472
  // discover loop runs so we can compute per-item field deltas at the end
8535
8473
  // and apply them inside the mutateWorkItems lock — the prior
@@ -8639,7 +8577,7 @@ function discoverFromWorkItems(config, project) {
8639
8577
  }
8640
8578
 
8641
8579
  const key = `work-${project?.name || 'default'}-${item.id}`;
8642
- // Self-heal: collect keys for batched dispatch.json cleanup (after the loop)
8580
+ // Self-heal: collect keys for batched dispatch cleanup.
8643
8581
  selfHealKeys.add(key);
8644
8582
  dispatchCooldowns.delete(key);
8645
8583
  // Cooldown bypass for resumed items
@@ -8945,7 +8883,7 @@ function discoverFromWorkItems(config, project) {
8945
8883
  }
8946
8884
  }
8947
8885
  if (patches.size > 0) {
8948
- mutateWorkItems(projectWorkItemsPath(project), (current) => {
8886
+ mutateWorkItems(project, (current) => {
8949
8887
  for (const it of current) {
8950
8888
  const patch = patches.get(it.id);
8951
8889
  if (!patch) continue;
@@ -9211,10 +9149,9 @@ function materializeSpecsAsWorkItems(config, project) {
9211
9149
 
9212
9150
  if (recentSpecs.length === 0) return;
9213
9151
 
9214
- const wiPath = projectWorkItemsPath(project);
9215
9152
  let created = 0;
9216
9153
 
9217
- mutateWorkItems(wiPath, existingItems => {
9154
+ mutateWorkItems(project, existingItems => {
9218
9155
  for (const pr of mergedPrs) {
9219
9156
  const prBranch = (pr.branch || '').toLowerCase();
9220
9157
  const matchedSpecs = recentSpecs.filter(doc => {
@@ -9301,12 +9238,11 @@ function extractSpecInfo(filePath, projectRoot_) {
9301
9238
  }
9302
9239
 
9303
9240
  /**
9304
- * Scan central ~/.minions/work-items.json for project-agnostic tasks.
9241
+ * Scan central SQL state for project-agnostic tasks.
9305
9242
  * Uses the shared work-item.md playbook with multi-project context injected.
9306
9243
  */
9307
9244
  function discoverCentralWorkItems(config) {
9308
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
9309
- const items = safeJsonArr(centralPath);
9245
+ const items = readWorkItems('central');
9310
9246
  const projects = getProjects(config);
9311
9247
  const dispatchProjects = getCentralDispatchProjects(projects);
9312
9248
  const newWork = [];
@@ -9606,7 +9542,6 @@ function discoverCentralWorkItems(config) {
9606
9542
  // Inject plan-to-prd variables — read the plan file content for the playbook
9607
9543
  if (workType === WORK_TYPE.PLAN_TO_PRD && item.planFile) {
9608
9544
  if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
9609
- if (!fs.existsSync(PRD_DIR)) fs.mkdirSync(PRD_DIR, { recursive: true });
9610
9545
  if (planFileContent !== null) {
9611
9546
  vars.plan_content = planFileContent;
9612
9547
  } else {
@@ -9648,7 +9583,7 @@ function discoverCentralWorkItems(config) {
9648
9583
  const prdRows = prdStore.listPrdRows();
9649
9584
  const prdFiles = prdRows.filter(row => !row.archived).map(row => row.filename);
9650
9585
  for (const pf of prdFiles) {
9651
- const prd = prdStore.readPrd(path.join(PRD_DIR, pf));
9586
+ const prd = prdStore.readPrd(pf);
9652
9587
  if (planKey && prd && !shared.isPrdArchived(prd) && shared.prdMatchesSourcePlan(prd.source_plan, planKey)) {
9653
9588
  prdFilename = pf;
9654
9589
  vars.existing_prd_json = JSON.stringify(prd, null, 2);
@@ -9747,53 +9682,20 @@ function discoverCentralWorkItems(config) {
9747
9682
  }
9748
9683
 
9749
9684
  if (mutations.size > 0) {
9750
- // True atomic read-modify-write applies mutations to fresh locked data
9751
- mutateJsonFileLocked(centralPath, (freshItems) => {
9685
+ // Apply field patches to a fresh transactional snapshot.
9686
+ mutateWorkItems('central', (freshItems) => {
9752
9687
  if (!Array.isArray(freshItems)) freshItems = [];
9753
9688
  for (const fi of freshItems) {
9754
9689
  const m = mutations.get(fi.id);
9755
9690
  if (m) Object.assign(fi, m);
9756
9691
  }
9757
9692
  return freshItems;
9758
- }, { defaultValue: [] });
9693
+ });
9759
9694
  }
9760
9695
  return newWork;
9761
9696
  }
9762
9697
 
9763
9698
 
9764
- /**
9765
- * Sweep stale `.backup` sidecars in `prd/` whose archived counterpart already
9766
- * lives in `prd/archive/<name>.json` and the active `prd/<name>.json` is
9767
- * absent. Pre-neutralize-era archives left these sidecars behind; without this
9768
- * sweep, any caller that touched `prd/<name>.json` with the restore-enabled
9769
- * `safeJson` would resurrect the archived PRD as active (W-mouptdh1000h9f39).
9770
- *
9771
- * Idempotent and best-effort: readdir / existsSync / unlink failures are
9772
- * swallowed so a single weird filesystem state never blocks the discovery
9773
- * tick. Returns the number of sidecars purged (for tests / logging).
9774
- */
9775
- function sweepStaleArchivedPrdBackups(prdDir, prdArchiveDir) {
9776
- let purged = 0;
9777
- if (!fs.existsSync(prdArchiveDir)) return purged;
9778
- let archivedNames;
9779
- try { archivedNames = fs.readdirSync(prdArchiveDir).filter(f => f.endsWith('.json')); }
9780
- catch { return purged; }
9781
- for (const f of archivedNames) {
9782
- const activePath = path.join(prdDir, f);
9783
- const backupPath = activePath + '.backup';
9784
- // Active PRD wins — the dedicated ghost-PRD purge in discoverWork handles
9785
- // the case where both active.json and active.json.backup are present.
9786
- if (fs.existsSync(activePath)) continue;
9787
- if (!fs.existsSync(backupPath)) continue;
9788
- try {
9789
- fs.unlinkSync(backupPath);
9790
- purged++;
9791
- log('info', `Purged stale .backup sidecar for archived PRD: ${f}`);
9792
- } catch { /* best-effort */ }
9793
- }
9794
- return purged;
9795
- }
9796
-
9797
9699
  /**
9798
9700
  * Run all work discovery sources and queue new items
9799
9701
  * Priority: fix (0) > ask (1) > review (1) > implement (2) > work-items (3) > central (4)
@@ -9803,7 +9705,7 @@ async function discoverWork(config) {
9803
9705
  const projects = getProjects(config);
9804
9706
  let persistedFixes = 0, persistedReviews = 0, persistedOther = 0;
9805
9707
 
9806
- // Side-effect passes: materialize plans and design docs into work-items.json
9708
+ // Side-effect passes: materialize plans and design docs into SQL work-item state.
9807
9709
  // These write to project work queues — picked up by discoverFromWorkItems below.
9808
9710
  // Gated by config.engine?.planMaterializationEnabled !== false (P-d6f0a2b5) —
9809
9711
  // operators can suppress the PRD reconcile + plan materialization pair
@@ -9827,10 +9729,10 @@ async function discoverWork(config) {
9827
9729
  // projects)` loop below leaves this whole async function permanently
9828
9730
  // unresolved, so anything only collected into a local array and persisted
9829
9731
  // at the very end (the pre-fix allFixes/allReviews/allWorkItems) never
9830
- // reaches dispatch.json — silently discarding valid work already
9732
+ // reaches dispatch state — silently discarding valid work already
9831
9733
  // discovered for unrelated, healthy projects processed earlier in the same
9832
9734
  // loop. Persisting per batch means a healthy project's discoveries land in
9833
- // dispatch.json immediately, before the loop ever reaches a slow project.
9735
+ // dispatch state immediately, before the loop reaches a slow project.
9834
9736
  async function persistBatch(items) {
9835
9737
  if (!items || items.length === 0) return;
9836
9738
  // Gate reviews and fixes: only when at max concurrency — idle agents should pick up reviews
@@ -9883,7 +9785,7 @@ async function discoverWork(config) {
9883
9785
  } catch (e) {
9884
9786
  // Fail-open contract preserved: any validator throw here means the
9885
9787
  // item is dropped from this discovery batch rather than wedging the
9886
- // whole chunk. The item stays `pending` in work-items.json and the
9788
+ // whole chunk. The item stays pending in SQL and the
9887
9789
  // next tick re-discovers it.
9888
9790
  log('warn', `pre-dispatch-eval: addToDispatchWithValidation threw for ${item?.meta?.item?.id || item?.id || 'unknown'}: ${e?.message || e}`);
9889
9791
  }
@@ -9959,7 +9861,6 @@ async function discoverWork(config) {
9959
9861
  const scheduledWork = discoverScheduledWork(config);
9960
9862
  if (scheduledWork.length > 0) {
9961
9863
  const { createMeeting, getMeetings } = require('./engine/meeting');
9962
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
9963
9864
  // Separate meetings (no work-items write) from task items
9964
9865
  const taskItems = [];
9965
9866
  for (const item of scheduledWork) {
@@ -9973,8 +9874,7 @@ async function discoverWork(config) {
9973
9874
  }
9974
9875
  }
9975
9876
  if (taskItems.length > 0) {
9976
- // Atomic write — prevents race with dispatch status updates on central work-items.json
9977
- mutateJsonFileLocked(centralPath, (items) => {
9877
+ mutateWorkItems('central', (items) => {
9978
9878
  if (!Array.isArray(items)) items = [];
9979
9879
  let added = 0;
9980
9880
  // Snapshot active dedup keys BEFORE the loop so multiple items in the
@@ -10010,7 +9910,7 @@ async function discoverWork(config) {
10010
9910
  log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
10011
9911
  }
10012
9912
  return items;
10013
- }, { defaultValue: [] });
9913
+ });
10014
9914
  }
10015
9915
  }
10016
9916
  } catch (e) { log('warn', 'discover scheduled work: ' + e.message); }
@@ -10036,51 +9936,19 @@ async function discoverWork(config) {
10036
9936
  if (tickCount % (ENGINE_DEFAULTS.planCompletionScanEvery || 60) === 0) {
10037
9937
  try {
10038
9938
  const lifecycle = require('./engine/lifecycle');
10039
- const prdDir = path.join(MINIONS_DIR, 'prd');
10040
- const prdArchiveDir = path.join(prdDir, 'archive');
10041
- if (fs.existsSync(prdDir)) {
10042
- // Pass 1 — Burn the landmine: stale .backup sidecars whose archived
10043
- // counterpart already lives in prd/archive/ but no active prd/<f>.json.
10044
- // Without this, any future safeJson(prd/<f>.json) (or stray legacy
10045
- // call) would auto-restore the .backup and resurrect the archived PRD
10046
- // (W-mouptdh1000h9f39). Idempotent and fast — readdir + existsSync.
10047
- sweepStaleArchivedPrdBackups(prdDir, prdArchiveDir);
10048
-
10049
- // Pass 2 — completion sweep. (The ghost-PRD purge that used to live here
10050
- // was a REDUNDANT + UNSAFE duplicate of the plan-completion scan's
10051
- // version: it unlinked prd/<f> whenever prd/archive/<f> existed, with NO
10052
- // `_isGhostPrdRestore` identity check — the exact footgun #7 (#496)
10053
- // hazard of deleting a legitimately re-opened PRD that shares an archived
10054
- // basename. The plan-completion scan does the same purge safely (identity-
10055
- // checked + neutralize); here the isDefunctPrd guard below already skips
10056
- // archived/defunct PRDs, so this loop never re-runs completion on a ghost.)
10057
- for (const f of prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename)) {
10058
- if (completedPlanCache.has(f)) continue;
10059
- // safeJsonNoRestore — defense in depth: if the file vanished between
10060
- // readdir and read (e.g. concurrent archive), do not resurrect it
10061
- // from a stale .backup sidecar (W-mouptdh1000h9f39).
10062
- const plan = safeJsonNoRestore(path.join(prdDir, f));
10063
- if (!plan?.missing_features) continue;
10064
- // Resurrection guard (mirror of completion-scan-B / #523): never re-run
10065
- // completion (which re-creates the verify gate) for a defunct PRD —
10066
- // archived, or completed with its plan gone from plans/.
10067
- if (shared.isDefunctPrd(plan, PLANS_DIR)) { completedPlanCache.add(f); continue; }
10068
- // A completed PRD may still be missing its aggregate verify WI — e.g. it
10069
- // landed on disk pre-completed via the plan-to-prd / pipeline path, so no
10070
- // agent-completion event ever drove checkPlanCompletion (W-mqk5ld3p). The
10071
- // function is idempotent (re-checks for an existing verify WI under lock),
10072
- // so run it for completed plans too and cache only once it reports nothing
10073
- // left to do (truthy return).
10074
- if (plan.status === PLAN_STATUS.COMPLETED) {
10075
- const done = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
10076
- if (done) completedPlanCache.add(f);
10077
- continue;
10078
- }
10079
- if (plan.status !== PLAN_STATUS.APPROVED && plan.status !== PLAN_STATUS.ACTIVE) continue;
10080
- // Simulate the meta object checkPlanCompletion expects
10081
- const completed = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
10082
- if (completed) completedPlanCache.add(f);
9939
+ for (const f of prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename)) {
9940
+ if (completedPlanCache.has(f)) continue;
9941
+ const plan = prdStore.readPrd(f);
9942
+ if (!plan?.missing_features) continue;
9943
+ if (shared.isDefunctPrd(plan, PLANS_DIR)) { completedPlanCache.add(f); continue; }
9944
+ if (plan.status === PLAN_STATUS.COMPLETED) {
9945
+ const done = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
9946
+ if (done) completedPlanCache.add(f);
9947
+ continue;
10083
9948
  }
9949
+ if (plan.status !== PLAN_STATUS.APPROVED && plan.status !== PLAN_STATUS.ACTIVE) continue;
9950
+ const completed = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
9951
+ if (completed) completedPlanCache.add(f);
10084
9952
  }
10085
9953
  } catch (e) { log('warn', 'plan completion sweep: ' + e.message); }
10086
9954
  }
@@ -10204,26 +10072,6 @@ const completedPlanCache = new Set();
10204
10072
  // Dedupe the "Skipping defunct PRD" materializer log so a lingering defunct PRD
10205
10073
  // doesn't spam one line per discovery tick; cleared when the file stops being defunct.
10206
10074
  const _defunctPrdSkipLogged = new Set();
10207
- // Filenames where a live PRD shares a basename with an archived PRD but diverges
10208
- // (different source_plan / introduces new work). We refuse to auto-purge those
10209
- // and warn once instead of every tick. (RC4 — footgun #7 collision protection.)
10210
- const _ghostPurgeCollisionWarned = new Set();
10211
-
10212
- // True when a live PRD that shares a basename with an archived one is merely a
10213
- // stale echo of the archive (a .backup ghost-restore), safe to purge. False when
10214
- // it's a genuinely distinct re-opened PRD that must NOT be silently deleted.
10215
- // Conservative: if either file is unreadable we fall back to the legacy
10216
- // "purge the ghost" behavior so we don't regress resurrection cleanup.
10217
- function _isGhostPrdRestore(live, archived) {
10218
- if (!live || !archived) return true;
10219
- const ls = live.source_plan || live.sourcePlan;
10220
- const as = archived.source_plan || archived.sourcePlan;
10221
- if (ls && as && ls !== as) return false; // different plan identity → real PRD
10222
- const archivedIds = new Set((archived.missing_features || []).map(f => f && f.id).filter(Boolean));
10223
- const hasNewWork = (live.missing_features || []).some(f => f && f.id && !archivedIds.has(f.id));
10224
- if (hasNewWork) return false; // introduces work the archive never had → real PRD
10225
- return true; // same identity, no new work → stale echo → ghost
10226
- }
10227
10075
  let lastWatchCheckAt = 0;
10228
10076
  let lastPrStatusPollAt = 0;
10229
10077
  let lastPrCommentsPollAt = 0;
@@ -10451,7 +10299,7 @@ async function tickInner() {
10451
10299
  }
10452
10300
 
10453
10301
  // 2.53. managed-spawn TTL/dead-PID sweep + log rotation (P-8a4d6f29). Walks
10454
- // engine/managed-processes.json, kills TTL-expired specs, drops dead-PID
10302
+ // SQL managed-process state, kills TTL-expired specs, drops dead-PID
10455
10303
  // rows, rotates managed-logs/<name>.log past ENGINE_DEFAULTS.managedSpawn
10456
10304
  // .logRotateBytes. Mirrors the keep-processes sweep cadence (sweepEvery=180)
10457
10305
  // so the engine never iterates per-spec on every tick. Healthcheck loops
@@ -10498,16 +10346,12 @@ async function tickInner() {
10498
10346
  safe('checkWatches', () => {
10499
10347
  const { checkWatches } = require('./engine/watches');
10500
10348
  const projects = getProjects(config);
10501
- const pullRequests = projects.flatMap(p => {
10502
- const prPath = path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json');
10503
- return safeJsonArr(prPath);
10504
- });
10505
- const workItems = projects.flatMap(p => {
10506
- const wiPath = path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json');
10507
- return safeJsonArr(wiPath);
10508
- });
10349
+ const pullRequestStore = require('./engine/pull-requests-store');
10350
+ const workItemStore = require('./engine/work-items-store');
10351
+ const pullRequests = projects.flatMap(p => pullRequestStore.readPullRequestsForScope(p.name));
10352
+ const workItems = projects.flatMap(p => workItemStore.readWorkItemsForScope(p.name));
10509
10353
  // Also include central work items
10510
- const centralWi = safeJsonArr(path.join(MINIONS_DIR, 'work-items.json'));
10354
+ const centralWi = workItemStore.readWorkItemsForScope('central');
10511
10355
 
10512
10356
  // Gather state for the new generalized target types. Each block is
10513
10357
  // best-effort — if a module/file is missing the watch evaluator will
@@ -10515,23 +10359,11 @@ async function tickInner() {
10515
10359
  let meetings = [];
10516
10360
  try { meetings = require('./engine/meeting').getMeetings(); } catch { /* optional */ }
10517
10361
 
10518
- // Plans/PRDs read PRD JSON files directly so plan-level watches see
10519
- // status transitions (approved/rejected/completed/etc.). Each entry
10520
- // carries its filename in _source for target lookup.
10521
- const plans = [];
10522
- try {
10523
- const prdDir = path.join(MINIONS_DIR, 'prd');
10524
- for (const dir of [prdDir, path.join(prdDir, 'archive')]) {
10525
- if (!fs.existsSync(dir)) continue;
10526
- for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.json'))) {
10527
- const plan = safeJson(path.join(dir, f));
10528
- if (plan) plans.push({ ...plan, _source: f, _sourcePlan: plan.source_plan || '' });
10529
- }
10530
- }
10531
- } catch { /* optional */ }
10362
+ const plans = require('./engine/prd-store').listPrdRows()
10363
+ .map(({ filename, plan }) => ({ ...plan, _source: filename, _sourcePlan: plan.source_plan || '' }));
10532
10364
 
10533
10365
  let scheduleRuns = {};
10534
- try { scheduleRuns = safeJsonObj(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')); } catch { /* optional */ }
10366
+ try { scheduleRuns = require('./engine/small-state-store').readScheduleRuns(); } catch { /* optional */ }
10535
10367
 
10536
10368
  let pipelineRuns = {};
10537
10369
  try { pipelineRuns = require('./engine/pipeline').getPipelineRuns(); } catch { /* optional */ }
@@ -10631,29 +10463,7 @@ async function tickInner() {
10631
10463
  const prdFiles = prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
10632
10464
  for (const file of prdFiles) {
10633
10465
  if (completedPlanCache.has(file)) continue;
10634
- if (fs.existsSync(path.join(PRD_DIR, 'archive', file))) {
10635
- // A live PRD basename also exists in the archive. Historically this was
10636
- // unconditionally treated as an orphaned .backup ghost-restore and purged
10637
- // — but a re-opened plan can legitimately create a NEW live PRD sharing
10638
- // an archived basename (footgun #7). Identity-check before deleting so we
10639
- // never silently destroy a real divergent PRD.
10640
- const liveP = safeJsonNoRestore(path.join(PRD_DIR, file));
10641
- const archP = safeJsonNoRestore(path.join(PRD_DIR, 'archive', file));
10642
- if (_isGhostPrdRestore(liveP, archP)) {
10643
- // Orphaned backup restore — plan is already archived. Purge the ghost copy.
10644
- try { fs.unlinkSync(path.join(PRD_DIR, file)); } catch { }
10645
- shared.neutralizeJsonBackupSidecar(path.join(PRD_DIR, file));
10646
- completedPlanCache.add(file);
10647
- continue;
10648
- }
10649
- // Divergent live PRD — do NOT delete. Warn once and fall through to the
10650
- // normal completion handling below so it's treated as a real PRD.
10651
- if (!_ghostPurgeCollisionWarned.has(file)) {
10652
- log('warn', `PRD ${file} shares a basename with an archived PRD but diverges (different source_plan or new work) — NOT purging; resolve the collision manually`);
10653
- _ghostPurgeCollisionWarned.add(file);
10654
- }
10655
- }
10656
- const plan = safeJson(path.join(PRD_DIR, file));
10466
+ const plan = prdStore.readPrd(file);
10657
10467
  // Resurrection-re-execution guard: a defunct PRD (archived, or completed
10658
10468
  // with its source plan gone from plans/) must NOT have its verify gate
10659
10469
  // re-created. This scan deliberately re-runs completion for completed
@@ -10767,13 +10577,12 @@ async function tickInner() {
10767
10577
  // Check for failed items blocking pending items
10768
10578
  for (const project of projects) {
10769
10579
  try {
10770
- const wiPath = projectWorkItemsPath(project);
10771
10580
  // Collect keys to clear AFTER work-items lock is released (avoid nested locks)
10772
10581
  const dispatchKeysToClear = [];
10773
10582
  const cooldownKeysToClear = [];
10774
10583
 
10775
10584
  if (_isTickStale(myGeneration)) return;
10776
- mutateWorkItems(wiPath, items => {
10585
+ mutateWorkItems(project, items => {
10777
10586
  let changed = false;
10778
10587
  const failedIds = new Set(items.filter(w => w.status === WI_STATUS.FAILED).map(w => w.id));
10779
10588
  const pendingWithBlockedDeps = items.filter(w =>
@@ -11036,7 +10845,7 @@ async function tickInner() {
11036
10845
  assignPendingDispatchAgent(item, altAgent, config);
11037
10846
  pendingAgents.add(altAgent);
11038
10847
  log('info', `Reassigning ${item.id} from unspawned temp ${prevAgent} to ${altAgent} — temp agent never spawned`);
11039
- // Persist reassignment to dispatch.json so it survives restarts/ticks
10848
+ // Persist reassignment so it survives restarts/ticks.
11040
10849
  persistPendingDispatchAgent(item);
11041
10850
  }
11042
10851
  }
@@ -11272,12 +11081,10 @@ async function tickInner() {
11272
11081
  // Defensive: ensure the work item is re-queued if completeDispatch didn't fire
11273
11082
  if (item.meta?.item?.id) {
11274
11083
  try {
11275
- const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
11276
- ? path.join(ENGINE_DIR, '..', 'work-items.json')
11277
- : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
11278
- if (wiPath) {
11084
+ const wiScope = resolveWorkItemScope(item.meta);
11085
+ if (wiScope) {
11279
11086
  if (_isTickStale(myGeneration)) return;
11280
- mutateWorkItems(wiPath, items => {
11087
+ mutateWorkItems(wiScope, items => {
11281
11088
  const wi = items.find(i => i.id === item.meta.item.id);
11282
11089
  if (wi && wi.status === WI_STATUS.DISPATCHED) {
11283
11090
  // completeDispatch didn't update the work item — re-queue manually
@@ -11400,8 +11207,8 @@ async function tickInner() {
11400
11207
  // clobbered. Previously the mutator callback assigned dp.pending from the
11401
11208
  // pre-lock postDispatch snapshot, overwriting any freshly loaded queue
11402
11209
  // entries. Sentinel-undefined for _pendingReason / _agentBusySince
11403
- // mirrors the in-loop `delete` semantics — JSON serialization drops
11404
- // undefined-valued keys, so SQL → JSON mirror stays consistent.
11210
+ // mirrors the in-loop `delete` semantics — the SQL store serializes
11211
+ // records without undefined-valued keys.
11405
11212
  const patches = new Map();
11406
11213
  for (const item of (postDispatch.pending || [])) {
11407
11214
  patches.set(item.id, {
@@ -11517,7 +11324,7 @@ function emitMemoryBaseline(tickN) {
11517
11324
  module.exports = {
11518
11325
  // Paths
11519
11326
  MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLAYBOOKS_DIR, PLANS_DIR, PRD_DIR,
11520
- CONTROL_PATH, DISPATCH_PATH, LOG_PATH, INBOX_DIR, KNOWLEDGE_DIR, ARCHIVE_DIR,
11327
+ CONTROL_PATH, INBOX_DIR, KNOWLEDGE_DIR, ARCHIVE_DIR,
11521
11328
  IDENTITY_DIR, CONFIG_PATH, ROUTING_PATH, NOTES_PATH, SKILLS_DIR,
11522
11329
 
11523
11330
  // Utilities
@@ -11542,10 +11349,7 @@ module.exports = {
11542
11349
  discoverWork, discoverFromPrs, discoverFromWorkItems, discoverCentralWorkItems,
11543
11350
  materializePlansAsWorkItems,
11544
11351
  materializeSpecsAsWorkItems, // exported for testing (P-f7-git-log)
11545
- reservePrdFilename, // exported for testing (P-9b7e5d3c)
11546
11352
  restoreHijackedOperatorCheckouts, // exported for testing — operator-checkout self-heal
11547
- sweepStaleArchivedPrdBackups, // exported for testing
11548
- _isGhostPrdRestore, // exported for testing (RC4 — ghost-purge identity check)
11549
11353
 
11550
11354
  // Shared helpers (used by lifecycle.js and tests)
11551
11355
  reconcileItemsWithPrs, detectDependencyCycles,