@yemi33/minions 0.1.2379 → 0.1.2381

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/bin/minions.js +19 -9
  2. package/dashboard/js/refresh.js +3 -4
  3. package/dashboard/js/render-other.js +43 -23
  4. package/dashboard/js/render-prd.js +1 -1
  5. package/dashboard/js/render-work-items.js +13 -6
  6. package/dashboard/js/settings.js +40 -17
  7. package/dashboard.js +435 -768
  8. package/docs/architecture-review-2026-07-09.md +2 -4
  9. package/docs/auto-discovery.md +36 -49
  10. package/docs/blog-first-successful-dispatch.md +1 -1
  11. package/docs/branch-derivation.md +2 -2
  12. package/docs/command-center.md +1 -1
  13. package/docs/completion-reports.md +14 -4
  14. package/docs/constellation-bridge.md +59 -10
  15. package/docs/constellation-style-telemetry.md +6 -6
  16. package/docs/cooldown-merge-semantics.md +4 -0
  17. package/docs/copilot-cli-schema.md +3 -3
  18. package/docs/cross-repo-plans.md +17 -17
  19. package/docs/deprecated.json +2 -2
  20. package/docs/design-state-storage.md +1 -1
  21. package/docs/documentation-audit-2026-07-09.md +2 -2
  22. package/docs/engine-restart.md +20 -8
  23. package/docs/harness-mode.md +1 -1
  24. package/docs/live-checkout-mode.md +45 -26
  25. package/docs/managed-spawn.md +4 -4
  26. package/docs/onboarding.md +1 -2
  27. package/docs/pr-comment-followup.md +3 -3
  28. package/docs/pr-review-fix-loop.md +1 -1
  29. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  30. package/docs/qa-runbook-lifecycle.md +4 -4
  31. package/docs/qa-runbooks.md +2 -2
  32. package/docs/rfc-completion-json.md +4 -1
  33. package/docs/runtime-adapters.md +1 -1
  34. package/docs/self-improvement.md +4 -5
  35. package/docs/shared-lifecycle-module-map.md +3 -1
  36. package/docs/slim-ux/architecture-suggestions.md +5 -6
  37. package/docs/slim-ux/concepts.md +23 -25
  38. package/docs/watches.md +7 -7
  39. package/docs/workspace-manifests.md +1 -1
  40. package/docs/worktree-lifecycle.md +1 -1
  41. package/engine/abandoned-pr-reconciliation.js +4 -5
  42. package/engine/ado-status.js +5 -5
  43. package/engine/ado.js +20 -25
  44. package/engine/agent-worker-pool.js +58 -1
  45. package/engine/bridge.js +260 -5
  46. package/engine/cleanup.js +48 -131
  47. package/engine/cli.js +125 -83
  48. package/engine/cooldown.js +9 -16
  49. package/engine/db/index.js +22 -9
  50. package/engine/db/migrations/009-qa.js +1 -1
  51. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  52. package/engine/db/migrations/021-archived-work-items.js +72 -0
  53. package/engine/db/migrations/022-global-cc-session.js +31 -0
  54. package/engine/db/migrations/023-engine-state.js +30 -0
  55. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  56. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  57. package/engine/dispatch-store.js +2 -7
  58. package/engine/dispatch.js +39 -44
  59. package/engine/github.js +20 -27
  60. package/engine/lifecycle.js +279 -354
  61. package/engine/live-checkout.js +193 -149
  62. package/engine/llm.js +1 -1
  63. package/engine/logs-store.js +2 -2
  64. package/engine/managed-spawn.js +2 -23
  65. package/engine/meeting.js +6 -6
  66. package/engine/metrics-store.js +2 -2
  67. package/engine/note-link-backfill.js +6 -11
  68. package/engine/pipeline.js +18 -36
  69. package/engine/playbook.js +13 -16
  70. package/engine/prd-store.js +73 -54
  71. package/engine/preflight.js +2 -5
  72. package/engine/projects.js +15 -62
  73. package/engine/pull-requests-store.js +0 -17
  74. package/engine/qa-runbooks.js +2 -2
  75. package/engine/qa-runs.js +1 -8
  76. package/engine/qa-sessions.js +41 -64
  77. package/engine/queries.js +120 -219
  78. package/engine/routing.js +4 -6
  79. package/engine/scheduler.js +0 -4
  80. package/engine/shared-branch-pr-reconcile.js +2 -3
  81. package/engine/shared.js +268 -699
  82. package/engine/small-state-store.js +89 -10
  83. package/engine/state-operations.js +16 -4
  84. package/engine/stdio-timestamps.js +1 -1
  85. package/engine/timeout.js +5 -12
  86. package/engine/watch-actions.js +20 -22
  87. package/engine/watches-store.js +1 -1
  88. package/engine/watches.js +6 -10
  89. package/engine/work-item-validation.js +52 -0
  90. package/engine/work-items-store.js +127 -29
  91. package/engine/worktree-gc.js +2 -2
  92. package/engine/worktree-pool.js +8 -18
  93. package/engine.js +197 -358
  94. package/minions.js +2 -2
  95. package/package.json +1 -1
  96. package/playbooks/plan-to-prd.md +2 -2
  97. package/playbooks/shared-rules.md +3 -3
  98. package/playbooks/templates/followup-dispatch.md +1 -1
  99. package/playbooks/verify.md +1 -1
  100. package/prompts/cc-system.md +9 -9
package/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;
@@ -2715,7 +2711,7 @@ async function spawnAgent(dispatchItem, config) {
2715
2711
  // auto-stash is disabled, or because `applyLiveCheckoutAutoStash` itself
2716
2712
  // reported `outcome:'unchanged'` (the `git stash push` call failed) — and
2717
2713
  // the fleet/project auto-reset policy resolves true, NOW perform the
2718
- // destructive `fetch` + `reset --hard origin/<ref>` as a fallback, reusing
2714
+ // destructive `reset --hard HEAD` + `clean -fd` WIP cleanup as a fallback, reusing
2719
2715
  // prepareLiveCheckout's existing reset logic by re-invoking it with an
2720
2716
  // explicit `autoReset: true` (bypassing config resolution, which we've
2721
2717
  // already evaluated). This only fires when the first prepareLiveCheckout
@@ -2765,6 +2761,27 @@ async function spawnAgent(dispatchItem, config) {
2765
2761
  if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'dirty') {
2766
2762
  const _dirtyFiles = Array.isArray(_liveResult.dirtyFiles) ? _liveResult.dirtyFiles : [];
2767
2763
  const _branchInfo = typeof _liveResult.branchInfo === 'string' ? _liveResult.branchInfo : '';
2764
+ const _autoResetAttempt = _liveResult.autoResetAttempt;
2765
+ const _resetAuditSlug = typeof _autoResetAttempt?.auditSlug === 'string'
2766
+ ? _autoResetAttempt.auditSlug
2767
+ : `live-checkout-autoreset-${id}`;
2768
+ let _cleanAttemptStatus = 'not run';
2769
+ let _statusCheckStatus = 'failed';
2770
+ if (_autoResetAttempt?.cleanAttempted) {
2771
+ _cleanAttemptStatus = _autoResetAttempt.cleanSucceeded ? 'succeeded' : 'failed or partially applied';
2772
+ }
2773
+ if (_autoResetAttempt?.recheckSucceeded) {
2774
+ _statusCheckStatus = `${_dirtyFiles.length} dirty path(s) remain`;
2775
+ }
2776
+ const _resetAttemptSummary = _autoResetAttempt
2777
+ ? [
2778
+ 'Automatic reset cleanup was attempted before this refusal:',
2779
+ `- \`git reset --hard HEAD\`: ${_autoResetAttempt.resetSucceeded ? 'succeeded' : 'failed'}`,
2780
+ `- \`git clean -fd\`: ${_cleanAttemptStatus}`,
2781
+ `- post-cleanup status check: ${_statusCheckStatus}`,
2782
+ `See the \`${_resetAuditSlug}\` inbox note for the full before/after audit.`,
2783
+ ].join('\n')
2784
+ : 'No automatic reset/clean recovery was attempted before this refusal.';
2768
2785
  // #329 + PL-live-checkout-reliability-hardening: two-strike dirty guard.
2769
2786
  // First dirty failure is retried once (a transient or engine-owned dirty
2770
2787
  // state — e.g. an agent's own leftover WIP, which dispatch-end restore now
@@ -2804,7 +2821,7 @@ async function spawnAgent(dispatchItem, config) {
2804
2821
  `**Dispatch:** ${id}`,
2805
2822
  ...(_branchInfo ? ['', `**Current checkout:** \`${_branchInfo}\``] : []),
2806
2823
  '',
2807
- `The engine refused to spawn the agent because \`${cwd}\` has uncommitted changes. Live-checkout mode runs in your project directly — the engine never \`git reset\` or \`git clean\` your tree.`,
2824
+ `The engine refused to spawn the agent because \`${cwd}\` has uncommitted changes. Live-checkout mode runs in your project directly.`,
2808
2825
  '',
2809
2826
  '## Dirty files (`git status --porcelain=v1 -b`)',
2810
2827
  '',
@@ -2812,6 +2829,8 @@ async function spawnAgent(dispatchItem, config) {
2812
2829
  ...(_dirtyFiles.length > 0 ? _liveCheckout.capDirtyFileLines(_dirtyFiles) : ['(none reported)']),
2813
2830
  '```',
2814
2831
  '',
2832
+ _resetAttemptSummary,
2833
+ '',
2815
2834
  _autoCleanupEnabled
2816
2835
  ? 'Auto-cleanup (`liveCheckoutAutoReset`/`liveCheckoutAutoStash`) is active — the engine re-cleans the tree on each attempt, so this dispatch will be retried automatically (subject to the normal retry cap).'
2817
2836
  : (_alreadyDirtyFailed
@@ -2821,9 +2840,9 @@ async function spawnAgent(dispatchItem, config) {
2821
2840
  try { writeInboxAlert(`live-checkout-dirty-${_wiIdForAlert}`, _alertBody); }
2822
2841
  catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
2823
2842
  try {
2824
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
2825
- if (_wiPath && dispatchItem.meta?.item?.id) {
2826
- mutateJsonFileLocked(_wiPath, (data) => {
2843
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
2844
+ if (_wiScope && dispatchItem.meta?.item?.id) {
2845
+ mutateWorkItems(_wiScope, (data) => {
2827
2846
  if (!Array.isArray(data)) return data;
2828
2847
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
2829
2848
  if (wi) {
@@ -2892,9 +2911,9 @@ async function spawnAgent(dispatchItem, config) {
2892
2911
  catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
2893
2912
  const _pendingReason = _isMidOp ? 'live_checkout_mid_operation' : 'live_checkout_detached_head';
2894
2913
  try {
2895
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
2896
- if (_wiPath && dispatchItem.meta?.item?.id) {
2897
- mutateJsonFileLocked(_wiPath, (data) => {
2914
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
2915
+ if (_wiScope && dispatchItem.meta?.item?.id) {
2916
+ mutateWorkItems(_wiScope, (data) => {
2898
2917
  if (!Array.isArray(data)) return data;
2899
2918
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
2900
2919
  if (wi) wi._pendingReason = _pendingReason;
@@ -2966,9 +2985,9 @@ async function spawnAgent(dispatchItem, config) {
2966
2985
  try { writeInboxAlert(`live-checkout-blocked-${_wiIdForAlert}`, _alertBody); }
2967
2986
  catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
2968
2987
  try {
2969
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
2970
- if (_wiPath && dispatchItem.meta?.item?.id) {
2971
- mutateJsonFileLocked(_wiPath, (data) => {
2988
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
2989
+ if (_wiScope && dispatchItem.meta?.item?.id) {
2990
+ mutateWorkItems(_wiScope, (data) => {
2972
2991
  if (!Array.isArray(data)) return data;
2973
2992
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
2974
2993
  if (wi) wi._pendingReason = 'live_checkout_wrong_base';
@@ -3025,9 +3044,9 @@ async function spawnAgent(dispatchItem, config) {
3025
3044
  try { writeInboxAlert(`live-checkout-blocked-${_wiIdForAlert}`, _alertBody); }
3026
3045
  catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
3027
3046
  try {
3028
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
3029
- if (_wiPath && dispatchItem.meta?.item?.id) {
3030
- mutateJsonFileLocked(_wiPath, (data) => {
3047
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
3048
+ if (_wiScope && dispatchItem.meta?.item?.id) {
3049
+ mutateWorkItems(_wiScope, (data) => {
3031
3050
  if (!Array.isArray(data)) return data;
3032
3051
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
3033
3052
  if (wi) wi._pendingReason = 'live_checkout_blob_fetch';
@@ -3129,9 +3148,9 @@ async function spawnAgent(dispatchItem, config) {
3129
3148
  try { writeInboxAlert(`live-checkout-worktree-conflict-${_wiIdForAlert}`, _alertBody); }
3130
3149
  catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
3131
3150
  try {
3132
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
3133
- if (_wiPath && dispatchItem.meta?.item?.id) {
3134
- mutateJsonFileLocked(_wiPath, (data) => {
3151
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
3152
+ if (_wiScope && dispatchItem.meta?.item?.id) {
3153
+ mutateWorkItems(_wiScope, (data) => {
3135
3154
  if (!Array.isArray(data)) return data;
3136
3155
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
3137
3156
  if (wi) wi._pendingReason = 'live_checkout_worktree_conflict';
@@ -3215,9 +3234,9 @@ async function spawnAgent(dispatchItem, config) {
3215
3234
  try { writeInboxAlert(`live-checkout-stale-base-${_wiIdForAlert}`, _alertBody); }
3216
3235
  catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
3217
3236
  try {
3218
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
3219
- if (_wiPath && dispatchItem.meta?.item?.id) {
3220
- mutateJsonFileLocked(_wiPath, (data) => {
3237
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
3238
+ if (_wiScope && dispatchItem.meta?.item?.id) {
3239
+ mutateWorkItems(_wiScope, (data) => {
3221
3240
  if (!Array.isArray(data)) return data;
3222
3241
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
3223
3242
  if (wi) wi._pendingReason = 'live_checkout_stale_base';
@@ -3244,9 +3263,9 @@ async function spawnAgent(dispatchItem, config) {
3244
3263
  // second-strike on its next dirty encounter. Only write when the stamp is set.
3245
3264
  if (dispatchItem.meta?.item?._liveCheckoutDirtyAttempts) {
3246
3265
  try {
3247
- const _wiPath = resolveWorkItemPath(dispatchItem.meta);
3248
- if (_wiPath && dispatchItem.meta?.item?.id) {
3249
- mutateJsonFileLocked(_wiPath, (data) => {
3266
+ const _wiScope = resolveWorkItemScope(dispatchItem.meta);
3267
+ if (_wiScope && dispatchItem.meta?.item?.id) {
3268
+ mutateWorkItems(_wiScope, (data) => {
3250
3269
  if (!Array.isArray(data)) return data;
3251
3270
  const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
3252
3271
  if (wi) delete wi._liveCheckoutDirtyAttempts;
@@ -3905,7 +3924,7 @@ async function spawnAgent(dispatchItem, config) {
3905
3924
  // Fetch all dependency branches in parallel (git fetches are independent)
3906
3925
  const fetchable = depBranches.filter(d => !_failedRefCache.has(d.branch));
3907
3926
  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))), []) : [];
3927
+ const allPrsForDeps = unfetchable.length > 0 ? shared.getProjects(config).reduce((acc, p) => acc.concat(readPullRequests(p)), []) : [];
3909
3928
  for (const { branch: depBranch, prId } of unfetchable) {
3910
3929
  const pr = allPrsForDeps.find(p => p.id === prId);
3911
3930
  if (pr && (pr.status === PR_STATUS.MERGED || pr.status === PR_STATUS.CLOSED)) {
@@ -3938,7 +3957,7 @@ async function spawnAgent(dispatchItem, config) {
3938
3957
  })
3939
3958
  );
3940
3959
  const hasFetchFailures = fetchResults.some(r => r.status === 'rejected');
3941
- const allPrsForFetch = hasFetchFailures ? shared.getProjects(config).reduce((acc, p) => acc.concat(safeJsonArr(shared.projectPrPath(p))), []) : [];
3960
+ const allPrsForFetch = hasFetchFailures ? shared.getProjects(config).reduce((acc, p) => acc.concat(readPullRequests(p)), []) : [];
3942
3961
  // Track branches recovered by local-only push so they can be merged
3943
3962
  const recoveredBranches = new Set();
3944
3963
  for (let i = 0; i < fetchResults.length; i++) {
@@ -4291,9 +4310,7 @@ async function spawnAgent(dispatchItem, config) {
4291
4310
  // so commits land on the dep's existing PR branch (W-mpcwojgr000a0244).
4292
4311
  if (depConflictBranch && meta?.item?.id && project) {
4293
4312
  try {
4294
- const wiPath = project.name
4295
- ? projectWorkItemsPath(project)
4296
- : path.join(MINIONS_DIR, 'work-items.json');
4313
+ const wiScope = project.name ? project : 'central';
4297
4314
  const newItem = buildDepConflictFixItem({
4298
4315
  depConflictBranch,
4299
4316
  depConflictFiles,
@@ -4303,7 +4320,7 @@ async function spawnAgent(dispatchItem, config) {
4303
4320
  blockedItem: meta.item,
4304
4321
  projectName: project.name || null,
4305
4322
  });
4306
- mutateWorkItems(wiPath, items => {
4323
+ mutateWorkItems(wiScope, items => {
4307
4324
  // Don't create duplicate conflict-fix items
4308
4325
  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
4326
  if (existing) return;
@@ -4400,7 +4417,7 @@ async function spawnAgent(dispatchItem, config) {
4400
4417
  // agent never gets a chance to push over the rebased tip.
4401
4418
  // Read-only and non-fix dispatches are out of scope — implement tasks cut
4402
4419
  // their own branch from main, and review/verify don't push.
4403
- if (type === WORK_TYPE.FIX && branchName && worktreePath && cwd === worktreePath) {
4420
+ if (shared.isFixLikeWorkType(type) && branchName && worktreePath && cwd === worktreePath) {
4404
4421
  _phaseT.staleHeadStart = Date.now();
4405
4422
  try {
4406
4423
  const guard = await assertStaleHeadOk({
@@ -4538,7 +4555,7 @@ async function spawnAgent(dispatchItem, config) {
4538
4555
  log('info', `Spawning agent: ${agentId} (${id}) in ${cwd}`);
4539
4556
  log('info', `Task type: ${type} | Branch: ${branchName || 'none'}`);
4540
4557
 
4541
- // Agent status is derived from dispatch.json — no setAgentStatus needed for working state.
4558
+ // Agent status is derived from dispatch state.
4542
4559
 
4543
4560
  // Spawn the claude process
4544
4561
  const childEnv = shared.cleanChildEnv();
@@ -5412,7 +5429,7 @@ async function spawnAgent(dispatchItem, config) {
5412
5429
  // agent described in its sidecar. This gate (a) rejects malformed
5413
5430
  // sidecars as a hard non-retryable failure with a dedicated failure
5414
5431
  // class + inbox alert, and (b) on success spawns each spec detached and
5415
- // batch-records them in engine/managed-processes.json. Healthcheck loops
5432
+ // batch-records them in SQL managed-process state. Healthcheck loops
5416
5433
  // + dispatch ERROR-on-healthcheck-failure land in the follow-up item;
5417
5434
  // for now a spec that spawns successfully is recorded with
5418
5435
  // healthy:false, alive:true and the engine sweep / item-3 healthcheck
@@ -5911,9 +5928,9 @@ async function spawnAgent(dispatchItem, config) {
5911
5928
  // engine/managed-logs/.
5912
5929
  if (managedSpawnHealthcheckFailure && managedSpawnHealthcheckFailure.annotation && dispatchItem.meta?.item?.id) {
5913
5930
  try {
5914
- const wiPath = resolveWorkItemPath(dispatchItem.meta);
5915
- if (wiPath) {
5916
- mutateWorkItems(wiPath, items => {
5931
+ const wiScope = resolveWorkItemScope(dispatchItem.meta);
5932
+ if (wiScope) {
5933
+ mutateWorkItems(wiScope, items => {
5917
5934
  const wi = items.find(i => i.id === dispatchItem.meta.item.id);
5918
5935
  if (!wi) return items;
5919
5936
  wi._managedSpawnPartial = managedSpawnHealthcheckFailure.annotation;
@@ -5930,9 +5947,9 @@ async function spawnAgent(dispatchItem, config) {
5930
5947
  // rather than a queue gate.
5931
5948
  if (keepProcessesAcceptanceFailure && dispatchItem.meta?.item?.id) {
5932
5949
  try {
5933
- const wiPath = resolveWorkItemPath(dispatchItem.meta);
5934
- if (wiPath) {
5935
- mutateJsonFileLocked(wiPath, data => {
5950
+ const wiScope = resolveWorkItemScope(dispatchItem.meta);
5951
+ if (wiScope) {
5952
+ mutateWorkItems(wiScope, data => {
5936
5953
  if (!Array.isArray(data)) return data;
5937
5954
  const wi = data.find(i => i.id === dispatchItem.meta.item.id);
5938
5955
  if (!wi) return data;
@@ -5949,7 +5966,7 @@ async function spawnAgent(dispatchItem, config) {
5949
5966
  // Cleanup temp files (whole per-dispatch dir, including PID/sysprompt
5950
5967
  // tmp/prompt — P-f6-tmp-toctou). removeDispatchTmpDir validates the path
5951
5968
  // resolves under engine/tmp/dispatch-* before rmSync, so a corrupted
5952
- // dispatch.json field can't redirect this to an arbitrary path.
5969
+ // dispatch field cannot redirect this to an arbitrary path.
5953
5970
  try { shared.removeDispatchTmpDir(dispatchTmpDir); } catch { /* cleanup */ }
5954
5971
 
5955
5972
  log('info', `Agent ${agentId} completed. Output saved to ${archivePath}`);
@@ -6051,7 +6068,7 @@ async function spawnAgent(dispatchItem, config) {
6051
6068
  item.started_at = startedAt;
6052
6069
  // Persist the resolved runtime so post-completion hooks (lifecycle.js) can
6053
6070
  // route output parsing through the right adapter. Also surfaces the choice
6054
- // in dispatch.json for debugging multi-runtime fleets.
6071
+ // in dispatch state for debugging multi-runtime fleets.
6055
6072
  item.runtimeName = runtimeName;
6056
6073
  // P-3f5b7d26 — persist pooled on the DISK record (activeProcesses/_pooled
6057
6074
  // is wiped on restart); cli.js's reattach path needs this since a pooled
@@ -6078,14 +6095,9 @@ async function spawnAgent(dispatchItem, config) {
6078
6095
  // Atomically stamp dispatched_to/dispatched_at on the originating work item (#402).
6079
6096
  if (meta?.item?.id) {
6080
6097
  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) => {
6098
+ const wiScope = resolveWorkItemScope(meta);
6099
+ if (wiScope) {
6100
+ mutateWorkItems(wiScope, (items) => {
6089
6101
  if (!Array.isArray(items)) return items;
6090
6102
  const wi = items.find(i => i.id === meta.item.id);
6091
6103
  if (wi) {
@@ -6094,7 +6106,7 @@ async function spawnAgent(dispatchItem, config) {
6094
6106
  if (wi.status !== WI_STATUS.DISPATCHED) wi.status = WI_STATUS.DISPATCHED;
6095
6107
  }
6096
6108
  return items;
6097
- }, { defaultValue: [] });
6109
+ });
6098
6110
  }
6099
6111
  } catch (e) { log('warn', `stamp dispatched_to on work item ${meta.item.id}: ${e.message}`); }
6100
6112
  }
@@ -6119,12 +6131,9 @@ function areDependenciesMet(item, config) {
6119
6131
  for (const depId of deps) {
6120
6132
  const depItem = allWorkItems.find(w => w.id === depId);
6121
6133
  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).
6134
+ // Fallback: check the PRD store — plan-to-prd agents may pre-set items to done.
6126
6135
  try {
6127
- const plan = safeJsonNoRestore(path.join(PRD_DIR, sourcePlan));
6136
+ const plan = prdStore.readPrd(sourcePlan);
6128
6137
  const prdItem = (plan?.missing_features || []).find(f => f.id === depId);
6129
6138
  if (prdItem && PRD_MET_STATUSES.has(prdItem.status)) continue; // PRD says done — treat as met
6130
6139
  } catch (e) { log('warn', 'check PRD dep status: ' + e.message); }
@@ -6196,8 +6205,8 @@ function detectDependencyCycles(items) {
6196
6205
  }
6197
6206
 
6198
6207
  // 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.
6208
+ // Primary linkage comes from tracked PR prdItems; fallback linkage
6209
+ // uses SQL PR links so matching does not depend on branch/title parsing.
6201
6210
  // onlyIds: if provided, only items whose ID is in this Set are eligible.
6202
6211
  function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
6203
6212
  const prLinks = shared.getPrLinks();
@@ -6289,7 +6298,7 @@ function updateSnapshot(config) {
6289
6298
 
6290
6299
  // ─── Cooldowns (extracted to engine/cooldown.js) ─────────────────────────────
6291
6300
 
6292
- const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
6301
+ const { dispatchCooldowns, loadCooldowns, saveCooldowns,
6293
6302
  isOnCooldown, setCooldown, setCooldownWithContext, drainCoalescedContexts,
6294
6303
  setCooldownFailure, clearCooldown, getPrReviewCooldownKey, clearLegacyPrReviewCooldown,
6295
6304
  isAlreadyDispatched, isBranchActive } = require('./engine/cooldown');
@@ -6297,12 +6306,11 @@ const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
6297
6306
  // Auto-clean pending/failed work items for a PRD so they re-materialize with updated plan data
6298
6307
  function autoCleanPrdWorkItems(prdFile, config) {
6299
6308
  const allProjects = getProjects(config);
6300
- const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
6301
- for (const proj of allProjects) wiPaths.push(projectWorkItemsPath(proj));
6309
+ const workItemScopes = ['central', ...allProjects];
6302
6310
  const deletedIds = [];
6303
- for (const wiPath of wiPaths) {
6311
+ for (const scope of workItemScopes) {
6304
6312
  try {
6305
- mutateWorkItems(wiPath, items => {
6313
+ mutateWorkItems(scope, items => {
6306
6314
  const filtered = items.filter(w => {
6307
6315
  if (w.sourcePlan === prdFile && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.FAILED)) {
6308
6316
  deletedIds.push(w.id); return false;
@@ -6345,51 +6353,6 @@ function safePrdProjectSlug(projectName) {
6345
6353
  return slug || 'project';
6346
6354
  }
6347
6355
 
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
6356
  // Fleet branches the engine/CC create. A worktree-mode operator checkout should
6394
6357
  // NEVER sit on one of these (or any branch); when it does, a flow ran raw
6395
6358
  // `git checkout`/`merge` in the live checkout instead of a worktree.
@@ -6458,16 +6421,15 @@ function restoreHijackedOperatorCheckouts(config) {
6458
6421
  }
6459
6422
 
6460
6423
  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
6424
  const writePrdLocked = (fileName, data) => {
6463
- return mutateJsonFileLocked(path.join(PRD_DIR, fileName), () => data, { defaultValue: data });
6425
+ return prdStore.writePrd(fileName, data);
6464
6426
  };
6465
- const mutatePrdLocked = (fileName, fallback, mutator, options = {}) => {
6466
- return mutateJsonFileLocked(path.join(PRD_DIR, fileName), (current) => {
6427
+ const mutatePrdLocked = (fileName, fallback, mutator) => {
6428
+ return prdStore.mutatePrd(fileName, (current) => {
6467
6429
  if (!current?.missing_features && fallback?.missing_features) current = fallback;
6468
6430
  if (!current || Array.isArray(current) || typeof current !== 'object') current = {};
6469
6431
  return mutator(current) || current;
6470
- }, { defaultValue: fallback || {}, ...options });
6432
+ }, { defaultValue: fallback || {} });
6471
6433
  };
6472
6434
  const enforceDeclaredPlanProject = (fileName, currentPlan) => {
6473
6435
  if (!currentPlan?.source_plan) return { fileName, plan: currentPlan };
@@ -6526,7 +6488,7 @@ function materializePlansAsWorkItems(config) {
6526
6488
  const jsonName = mf.replace(/\.md$/, '.json');
6527
6489
  // Atomic open ('wx' + retry) so two parallel migrations on the
6528
6490
  // same slug don't both overwrite a single PRD (P-9b7e5d3c).
6529
- const reserved = reservePrdFilename(checkDir, jsonName, JSON.stringify(parsed, null, 2));
6491
+ const reserved = prdStore.createPrdUnique(jsonName, parsed);
6530
6492
  try { fs.unlinkSync(path.join(checkDir, mf)); } catch { /* cleanup */ }
6531
6493
  log('info', `Plan enforcement: moved ${mf} → prd/${reserved} (PRDs must be .json in prd/)`);
6532
6494
  }
@@ -6543,7 +6505,7 @@ function materializePlansAsWorkItems(config) {
6543
6505
  if (parsed?.missing_features) {
6544
6506
  // Atomic open ('wx' + retry) so two parallel migrations on the
6545
6507
  // same slug don't both overwrite a single PRD (P-9b7e5d3c).
6546
- const reserved = reservePrdFilename(PRD_DIR, jf, JSON.stringify(parsed, null, 2));
6508
+ const reserved = prdStore.createPrdUnique(jf, parsed);
6547
6509
  try { fs.unlinkSync(path.join(PLANS_DIR, jf)); } catch { /* cleanup */ }
6548
6510
  log('info', `Auto-migrated PRD ${jf} from plans/ to prd/${reserved}`);
6549
6511
  }
@@ -6563,10 +6525,7 @@ function materializePlansAsWorkItems(config) {
6563
6525
  const SEQUENTIAL_ID_RE = /^[A-Za-z]+-?\d+$/;
6564
6526
 
6565
6527
  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));
6528
+ let plan = prdStore.readPrd(file);
6570
6529
  if (!plan?.missing_features) continue;
6571
6530
  // Resurrection-re-execution guard ("old PRDs came back"): never (re)materialize
6572
6531
  // a defunct PRD — archived, or completed with its source plan gone from plans/.
@@ -6713,7 +6672,7 @@ function materializePlansAsWorkItems(config) {
6713
6672
  const defaultProjectName = plan.project || file.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
6714
6673
  const allProjects = getProjects(config);
6715
6674
  const defaultProject = shared.resolveProjectSource(defaultProjectName, allProjects, { allowCentral: false }).project;
6716
- // No project found — use central work-items.json (engine works without projects)
6675
+ // No project found — use central state (engine works without projects).
6717
6676
  const useCentral = !defaultProject;
6718
6677
 
6719
6678
  const statusFilter = PRD_MATERIALIZABLE;
@@ -6737,7 +6696,7 @@ function materializePlansAsWorkItems(config) {
6737
6696
  );
6738
6697
 
6739
6698
  // 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
6699
+ // When no projects are configured, all items use the central scope.
6741
6700
  const itemsByProject = new Map(); // projectName -> { project, items: [] }
6742
6701
  for (const item of items) {
6743
6702
  if (item.project) {
@@ -6787,19 +6746,19 @@ function materializePlansAsWorkItems(config) {
6787
6746
  // A pause/reject/awaiting-approval signal may arrive between the outer-loop initial
6788
6747
  // read and this point (e.g. operator pauses via dashboard while the tick is running).
6789
6748
  // Re-reading here prevents phantom WI creation on a paused, rejected, or unapproved plan.
6790
- const freshPrd = prdStore.readPrd(path.join(PRD_DIR, file));
6749
+ const freshPrd = prdStore.readPrd(file);
6791
6750
  const freshStatus = (freshPrd || {}).status || ((freshPrd || {}).requires_approval ? 'awaiting-approval' : null);
6792
6751
  if (freshStatus === PLAN_STATUS.PAUSED || freshStatus === PLAN_STATUS.REJECTED || freshStatus === 'awaiting-approval') {
6793
6752
  log('warn', `PRD ${file}: status changed to '${freshStatus}' mid-tick — skipping WI batch for ${projName} (P-f1a3b5c7)`);
6794
6753
  break;
6795
6754
  }
6796
6755
 
6797
- const wiPath = project ? projectWorkItemsPath(project) : path.join(MINIONS_DIR, 'work-items.json');
6756
+ const wiScope = project || 'central';
6798
6757
  let created = 0;
6799
6758
  const newlyCreatedIds = new Set(); // tracks IDs created in this pass for reconciliation scoping
6800
6759
  const deferredReopens = []; // cross-project re-opens executed after this lock releases
6801
6760
 
6802
- mutateWorkItems(wiPath, existingItems => {
6761
+ mutateWorkItems(wiScope, existingItems => {
6803
6762
  for (const item of projItems) {
6804
6763
  // Re-open: 'updated' or 'missing' re-opens a done work item (#906)
6805
6764
  // Use composite (sourcePlan, id) key so a WI from another PRD with the
@@ -6821,7 +6780,7 @@ function materializePlansAsWorkItems(config) {
6821
6780
  if (!alreadyExists) {
6822
6781
  for (const p of allProjects) {
6823
6782
  if (String(p.name || '').toLowerCase() === String(projName || '').toLowerCase()) continue;
6824
- const otherItems = safeJsonArr(projectWorkItemsPath(p));
6783
+ const otherItems = readWorkItems(p);
6825
6784
  const otherWi = otherItems.find(w => w.id === item.id && w.sourcePlan === file);
6826
6785
  if (otherWi) {
6827
6786
  if (DONE_STATUSES.has(otherWi.status) && shouldReopen) {
@@ -6867,7 +6826,7 @@ function materializePlansAsWorkItems(config) {
6867
6826
 
6868
6827
  if (created > 0) {
6869
6828
  // Reconciliation: exact prdItems match only, scoped to newly created items
6870
- const allPrsForReconcile = allProjects.flatMap(p => safeJsonArr(projectPrPath(p)));
6829
+ const allPrsForReconcile = allProjects.flatMap(p => readPullRequests(p));
6871
6830
  const reconciled = reconcileItemsWithPrs(existingItems, allPrsForReconcile, { onlyIds: newlyCreatedIds });
6872
6831
  if (reconciled > 0) log('info', `Plan reconciliation: marked ${reconciled} item(s) as done → ${projName}`);
6873
6832
 
@@ -6901,8 +6860,7 @@ function materializePlansAsWorkItems(config) {
6901
6860
  for (const { itemId, projectName: rProjName, item: rItem } of deferredReopens) {
6902
6861
  const rProject = shared.resolveProjectSource(rProjName, allProjects, { allowCentral: false }).project;
6903
6862
  if (!rProject) continue;
6904
- const rPath = projectWorkItemsPath(rProject);
6905
- mutateWorkItems(rPath, items => {
6863
+ mutateWorkItems(rProject, items => {
6906
6864
  const target = items.find(w => w.id === itemId);
6907
6865
  if (target && DONE_STATUSES.has(target.status)) {
6908
6866
  shared.reopenWorkItem(target);
@@ -6982,8 +6940,7 @@ function materializePlansAsWorkItems(config) {
6982
6940
  function clearPendingHumanFeedbackFlag(projectMeta, prId) {
6983
6941
  if (!prId) return;
6984
6942
  try {
6985
- const prsPath = projectPrPath(projectMeta);
6986
- mutatePullRequests(prsPath, prs => {
6943
+ mutatePullRequests(projectMeta, prs => {
6987
6944
  const target = shared.findPrRecord(prs, prId, projectMeta);
6988
6945
  if (!target?.humanFeedback?.pendingFix) return;
6989
6946
  target.humanFeedback.pendingFix = false;
@@ -7058,7 +7015,7 @@ async function isWorktreeHeldPauseSuppressed(pr, project) {
7058
7015
  }
7059
7016
  if (!stillHeld) {
7060
7017
  try {
7061
- mutatePullRequests(projectPrPath(project), (prs) => {
7018
+ mutatePullRequests(project, (prs) => {
7062
7019
  if (!Array.isArray(prs)) return prs;
7063
7020
  const target = shared.findPrRecord(prs, pr, project);
7064
7021
  if (!target) return prs;
@@ -7110,7 +7067,7 @@ function resolvePrBranch(pr) {
7110
7067
  function updatePrBranchResolutionState(project, pr, { branch = '', reason = '' } = {}) {
7111
7068
  let changed = false;
7112
7069
  try {
7113
- mutatePullRequests(projectPrPath(project), prs => {
7070
+ mutatePullRequests(project, prs => {
7114
7071
  const target = shared.findPrRecord(prs, pr, project);
7115
7072
  if (!target) return;
7116
7073
  if (branch) {
@@ -7363,19 +7320,24 @@ function _resetAutoFixPausedLogState() {
7363
7320
  // persist — the warning is for the operator at engine startup/run time).
7364
7321
  const _warnedSilentDiscovery = new Set();
7365
7322
 
7366
- function _warnSilentDiscoveryOnce(kind, project, dataPath, config) {
7323
+ function _warnSilentDiscoveryOnce(kind, project, config) {
7367
7324
  const key = `${kind}:${project?.name || project?.localPath || 'default'}`;
7368
7325
  if (_warnedSilentDiscovery.has(key)) return;
7369
7326
  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
7327
+ try {
7328
+ const records = kind === 'pullRequests'
7329
+ ? shared.readPullRequests(project)
7330
+ : shared.readWorkItems(project);
7331
+ if (Array.isArray(records)) count = records.length;
7332
+ } catch {}
7333
+ if (count <= 0) return;
7372
7334
  _warnedSilentDiscovery.add(key);
7373
7335
  const projName = project?.name || '(unnamed)';
7374
7336
  const hasBlock = !!(project?.workSources?.[kind] || config?.workSources?.[kind]);
7375
7337
  const hint = hasBlock
7376
7338
  ? `Toggle in Dashboard → Settings → Project, or set \`workSources.${kind}.enabled: true\` in config.json.`
7377
7339
  : `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}`);
7340
+ 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
7341
  }
7380
7342
 
7381
7343
  /**
@@ -7396,12 +7358,12 @@ function _logPrDispatchSkipOnce(pr, reason) {
7396
7358
  }
7397
7359
 
7398
7360
  /**
7399
- * Scan pull-requests.json for PRs needing review or fixes
7361
+ * Scan tracked PR state for records needing review or fixes.
7400
7362
  */
7401
7363
  async function discoverFromPrs(config, project) {
7402
7364
  const src = project?.workSources?.pullRequests || config.workSources?.pullRequests;
7403
7365
  if (!src?.enabled) {
7404
- _warnSilentDiscoveryOnce('pullRequests', project, projectPrPath(project), config);
7366
+ _warnSilentDiscoveryOnce('pullRequests', project, config);
7405
7367
  return [];
7406
7368
  }
7407
7369
 
@@ -7549,7 +7511,7 @@ async function discoverFromPrs(config, project) {
7549
7511
  if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = liveStatus;
7550
7512
  // Persist so next tick doesn't re-check
7551
7513
  try {
7552
- mutateJsonFileLocked(projectPrPath(project), data => {
7514
+ mutatePullRequests(project, data => {
7553
7515
  if (!Array.isArray(data)) return data;
7554
7516
  const target = shared.findPrRecord(data, pr, project);
7555
7517
  if (target && target.reviewStatus !== REVIEW_STATUS.APPROVED) target.reviewStatus = liveStatus;
@@ -7765,7 +7727,7 @@ async function discoverFromPrs(config, project) {
7765
7727
  log('info', `Pre-dispatch vote check: ${pr.id} is ${liveStatus} (cached was waiting) — skipping re-review`);
7766
7728
  if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = liveStatus;
7767
7729
  try {
7768
- mutateJsonFileLocked(projectPrPath(project), data => {
7730
+ mutatePullRequests(project, data => {
7769
7731
  if (!Array.isArray(data)) return data;
7770
7732
  const target = shared.findPrRecord(data, pr, project);
7771
7733
  if (target && target.reviewStatus !== REVIEW_STATUS.APPROVED) target.reviewStatus = liveStatus;
@@ -7940,7 +7902,7 @@ async function discoverFromPrs(config, project) {
7940
7902
  const live = await checkBcFn(pr, project);
7941
7903
  if (live?.buildStatusStale) {
7942
7904
  try {
7943
- mutatePullRequests(projectPrPath(project), prs => {
7905
+ mutatePullRequests(project, prs => {
7944
7906
  const target = shared.findPrRecord(prs, pr, project);
7945
7907
  if (!target) return;
7946
7908
  target._buildStatusStale = true;
@@ -7952,7 +7914,7 @@ async function discoverFromPrs(config, project) {
7952
7914
  log('info', `Pre-dispatch build check: ${pr.id} build is ${live.buildStatus} (cached was failing) — skipping build-fix`);
7953
7915
  // Persist the fresh status so subsequent ticks don't re-check on every pass
7954
7916
  try {
7955
- mutatePullRequests(projectPrPath(project), prs => {
7917
+ mutatePullRequests(project, prs => {
7956
7918
  const target = shared.findPrRecord(prs, pr, project);
7957
7919
  if (!target) return;
7958
7920
  target.buildStatus = live.buildStatus;
@@ -8016,8 +7978,7 @@ async function discoverFromPrs(config, project) {
8016
7978
  if (pr.agent && !pr._buildFailNotified) {
8017
7979
  // Mark noted to prevent repeated processing; the fix dispatch and PR state carry the failure details.
8018
7980
  try {
8019
- const prPath = projectPrPath(project);
8020
- mutatePullRequests(prPath, prs => {
7981
+ mutatePullRequests(project, prs => {
8021
7982
  const target = shared.findPrRecord(prs, pr, project);
8022
7983
  if (target) {
8023
7984
  target._buildFailNotified = true;
@@ -8082,7 +8043,7 @@ async function discoverFromPrs(config, project) {
8082
8043
  if (live && live.mergeConflict === false) {
8083
8044
  log('info', `Pre-dispatch conflict check: ${pr.id} reports clean merge (cached was conflict) — skipping conflict-fix`);
8084
8045
  try {
8085
- mutatePullRequests(projectPrPath(project), prs => {
8046
+ mutatePullRequests(project, prs => {
8086
8047
  const target = shared.findPrRecord(prs, pr, project);
8087
8048
  if (!target) return;
8088
8049
  delete target._mergeConflict;
@@ -8111,7 +8072,7 @@ async function discoverFromPrs(config, project) {
8111
8072
  newWork.push(item);
8112
8073
  // Record dispatch timestamp so re-dispatch is suppressed during ADO lag window
8113
8074
  try {
8114
- mutatePullRequests(projectPrPath(project), prs => {
8075
+ mutatePullRequests(project, prs => {
8115
8076
  const target = shared.findPrRecord(prs, pr, project);
8116
8077
  if (target) target._conflictFixedAt = new Date().toISOString();
8117
8078
  });
@@ -8211,7 +8172,7 @@ function _buildRunnerBriefVars(item, project) {
8211
8172
  }
8212
8173
 
8213
8174
  /**
8214
- * Scan work-items.json for manually queued tasks
8175
+ * Scan a project work-item scope for manually queued tasks.
8215
8176
  */
8216
8177
  function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, project, root, branchName, options = {}) {
8217
8178
  const worktreePath = options.worktreePath || path.resolve(root, config.engine?.worktreeRoot || '../worktrees', `${branchName}`);
@@ -8425,7 +8386,7 @@ function resolveWorkItemPrRecord(item, project) {
8425
8386
  if (!project) return null;
8426
8387
  const prRef = getWorkItemPrRef(item);
8427
8388
  if (!prRef) return null;
8428
- const prs = safeJsonArr(projectPrPath(project));
8389
+ const prs = readPullRequests(project);
8429
8390
  shared.normalizePrRecords(prs, project);
8430
8391
  return shared.findPrRecord(prs, prRef, project);
8431
8392
  }
@@ -8492,7 +8453,7 @@ function refreshDeferredReviewPrompt(item, config) {
8492
8453
  // head SHA before rebuilding the prompt.
8493
8454
  let livePr = pr;
8494
8455
  try {
8495
- const prs = safeJsonArr(projectPrPath(project));
8456
+ const prs = readPullRequests(project);
8496
8457
  const found = shared.findPrRecord(prs, pr, project);
8497
8458
  if (found) livePr = found;
8498
8459
  } catch (e) { log('warn', `refreshDeferredReviewPrompt: live PR lookup for ${pr.id} failed: ${e.message}`); }
@@ -8524,12 +8485,12 @@ function refreshDeferredReviewPrompt(item, config) {
8524
8485
  function discoverFromWorkItems(config, project) {
8525
8486
  const src = project?.workSources?.workItems || config.workSources?.workItems;
8526
8487
  if (!src?.enabled) {
8527
- _warnSilentDiscoveryOnce('workItems', project, projectWorkItemsPath(project), config);
8488
+ _warnSilentDiscoveryOnce('workItems', project, config);
8528
8489
  return [];
8529
8490
  }
8530
8491
 
8531
8492
  const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
8532
- const items = safeJsonArr(projectWorkItemsPath(project));
8493
+ const items = readWorkItems(project);
8533
8494
  // W-mq9b7lor (H3): snapshot each item's serializable state BEFORE the
8534
8495
  // discover loop runs so we can compute per-item field deltas at the end
8535
8496
  // and apply them inside the mutateWorkItems lock — the prior
@@ -8639,7 +8600,7 @@ function discoverFromWorkItems(config, project) {
8639
8600
  }
8640
8601
 
8641
8602
  const key = `work-${project?.name || 'default'}-${item.id}`;
8642
- // Self-heal: collect keys for batched dispatch.json cleanup (after the loop)
8603
+ // Self-heal: collect keys for batched dispatch cleanup.
8643
8604
  selfHealKeys.add(key);
8644
8605
  dispatchCooldowns.delete(key);
8645
8606
  // Cooldown bypass for resumed items
@@ -8743,7 +8704,7 @@ function discoverFromWorkItems(config, project) {
8743
8704
  return b && b > 0 && getMonthlySpend(id) >= b && isAgentIdle(id);
8744
8705
  });
8745
8706
  if (!agentId) {
8746
- if (!budgetBlocked && !hardPinRequested && workType !== WORK_TYPE.FIX) {
8707
+ if (!budgetBlocked && !hardPinRequested && !shared.isFixLikeWorkType(workType)) {
8747
8708
  reservedAgentId = resolveAgentReservation(workType, config, { agentHints });
8748
8709
  agentId = reservedAgentId && !hasAgentHints ? routing.ANY_AGENT : reservedAgentId;
8749
8710
  }
@@ -8785,7 +8746,7 @@ function discoverFromWorkItems(config, project) {
8785
8746
  const linkedPr = item.skipPr ? null : resolveWorkItemPrRecord(item, project);
8786
8747
  const promptItem = linkedPr ? withWorkItemPrContext(item, linkedPr) : item;
8787
8748
  const prBranch = linkedPr?.branch || '';
8788
- const isPrTargeted = !!(linkedPr && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST));
8749
+ const isPrTargeted = !!(linkedPr && (shared.isFixLikeWorkType(workType) || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST));
8789
8750
  // Issue #607 — `linkedPr` above resolves through the LOOSE
8790
8751
  // extractWorkItemPrRef (title/first-paragraph-of-description text scan),
8791
8752
  // which is fine for prompt-context enrichment (promptItem, above) that
@@ -8807,7 +8768,7 @@ function discoverFromWorkItems(config, project) {
8807
8768
  // create-time stamp path keeps using the loose form — gate uses
8808
8769
  // structured-only; stamp uses loose. See engine/shared.js
8809
8770
  // extractStructuredWorkItemPrRef for the structured-source list.
8810
- if (!linkedPr && getStructuredWorkItemPrRef(item) && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST)) {
8771
+ if (!linkedPr && getStructuredWorkItemPrRef(item) && (shared.isFixLikeWorkType(workType) || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST)) {
8811
8772
  if (item._pendingReason !== 'pr_not_found') { item._pendingReason = 'pr_not_found'; needsWrite = true; }
8812
8773
  log('warn', `Work item ${item.id} references PR ${getStructuredWorkItemPrRef(item)} but no tracked PR record was found`);
8813
8774
  continue;
@@ -8945,7 +8906,7 @@ function discoverFromWorkItems(config, project) {
8945
8906
  }
8946
8907
  }
8947
8908
  if (patches.size > 0) {
8948
- mutateWorkItems(projectWorkItemsPath(project), (current) => {
8909
+ mutateWorkItems(project, (current) => {
8949
8910
  for (const it of current) {
8950
8911
  const patch = patches.get(it.id);
8951
8912
  if (!patch) continue;
@@ -9110,7 +9071,7 @@ function buildWorkItemDispatchVars(item, vars, config, options = {}) {
9110
9071
  // SHERLOC (P-mqyp0008v022w3x4): inject prior explore context for fix dispatches.
9111
9072
  // When a completed explore WI has a reference to this fix WI, surface its
9112
9073
  // resultSummary so the fix agent starts with pre-localized fault context.
9113
- if (workType === WORK_TYPE.FIX) {
9074
+ if (shared.isFixLikeWorkType(workType)) {
9114
9075
  vars.prior_explore_context = resolvePriorExploreContext(item, config);
9115
9076
  }
9116
9077
 
@@ -9211,10 +9172,9 @@ function materializeSpecsAsWorkItems(config, project) {
9211
9172
 
9212
9173
  if (recentSpecs.length === 0) return;
9213
9174
 
9214
- const wiPath = projectWorkItemsPath(project);
9215
9175
  let created = 0;
9216
9176
 
9217
- mutateWorkItems(wiPath, existingItems => {
9177
+ mutateWorkItems(project, existingItems => {
9218
9178
  for (const pr of mergedPrs) {
9219
9179
  const prBranch = (pr.branch || '').toLowerCase();
9220
9180
  const matchedSpecs = recentSpecs.filter(doc => {
@@ -9301,12 +9261,11 @@ function extractSpecInfo(filePath, projectRoot_) {
9301
9261
  }
9302
9262
 
9303
9263
  /**
9304
- * Scan central ~/.minions/work-items.json for project-agnostic tasks.
9264
+ * Scan central SQL state for project-agnostic tasks.
9305
9265
  * Uses the shared work-item.md playbook with multi-project context injected.
9306
9266
  */
9307
9267
  function discoverCentralWorkItems(config) {
9308
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
9309
- const items = safeJsonArr(centralPath);
9268
+ const items = readWorkItems('central');
9310
9269
  const projects = getProjects(config);
9311
9270
  const dispatchProjects = getCentralDispatchProjects(projects);
9312
9271
  const newWork = [];
@@ -9439,7 +9398,7 @@ function discoverCentralWorkItems(config) {
9439
9398
  const hardPinRequested = !!hardPinnedAgent;
9440
9399
  const agentId = hardPinnedAgent
9441
9400
  || resolveAgent(workType, config, { agentHints })
9442
- || (!hardPinRequested && workType !== WORK_TYPE.FIX ? resolveAgentReservation(workType, config, { agentHints }) : null);
9401
+ || (!hardPinRequested && !shared.isFixLikeWorkType(workType) ? resolveAgentReservation(workType, config, { agentHints }) : null);
9443
9402
  if (!agentId) continue;
9444
9403
 
9445
9404
  const agentName = config.agents[agentId]?.name || agentId;
@@ -9606,7 +9565,6 @@ function discoverCentralWorkItems(config) {
9606
9565
  // Inject plan-to-prd variables — read the plan file content for the playbook
9607
9566
  if (workType === WORK_TYPE.PLAN_TO_PRD && item.planFile) {
9608
9567
  if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
9609
- if (!fs.existsSync(PRD_DIR)) fs.mkdirSync(PRD_DIR, { recursive: true });
9610
9568
  if (planFileContent !== null) {
9611
9569
  vars.plan_content = planFileContent;
9612
9570
  } else {
@@ -9648,7 +9606,7 @@ function discoverCentralWorkItems(config) {
9648
9606
  const prdRows = prdStore.listPrdRows();
9649
9607
  const prdFiles = prdRows.filter(row => !row.archived).map(row => row.filename);
9650
9608
  for (const pf of prdFiles) {
9651
- const prd = prdStore.readPrd(path.join(PRD_DIR, pf));
9609
+ const prd = prdStore.readPrd(pf);
9652
9610
  if (planKey && prd && !shared.isPrdArchived(prd) && shared.prdMatchesSourcePlan(prd.source_plan, planKey)) {
9653
9611
  prdFilename = pf;
9654
9612
  vars.existing_prd_json = JSON.stringify(prd, null, 2);
@@ -9747,53 +9705,20 @@ function discoverCentralWorkItems(config) {
9747
9705
  }
9748
9706
 
9749
9707
  if (mutations.size > 0) {
9750
- // True atomic read-modify-write applies mutations to fresh locked data
9751
- mutateJsonFileLocked(centralPath, (freshItems) => {
9708
+ // Apply field patches to a fresh transactional snapshot.
9709
+ mutateWorkItems('central', (freshItems) => {
9752
9710
  if (!Array.isArray(freshItems)) freshItems = [];
9753
9711
  for (const fi of freshItems) {
9754
9712
  const m = mutations.get(fi.id);
9755
9713
  if (m) Object.assign(fi, m);
9756
9714
  }
9757
9715
  return freshItems;
9758
- }, { defaultValue: [] });
9716
+ });
9759
9717
  }
9760
9718
  return newWork;
9761
9719
  }
9762
9720
 
9763
9721
 
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
9722
  /**
9798
9723
  * Run all work discovery sources and queue new items
9799
9724
  * Priority: fix (0) > ask (1) > review (1) > implement (2) > work-items (3) > central (4)
@@ -9803,7 +9728,7 @@ async function discoverWork(config) {
9803
9728
  const projects = getProjects(config);
9804
9729
  let persistedFixes = 0, persistedReviews = 0, persistedOther = 0;
9805
9730
 
9806
- // Side-effect passes: materialize plans and design docs into work-items.json
9731
+ // Side-effect passes: materialize plans and design docs into SQL work-item state.
9807
9732
  // These write to project work queues — picked up by discoverFromWorkItems below.
9808
9733
  // Gated by config.engine?.planMaterializationEnabled !== false (P-d6f0a2b5) —
9809
9734
  // operators can suppress the PRD reconcile + plan materialization pair
@@ -9827,10 +9752,10 @@ async function discoverWork(config) {
9827
9752
  // projects)` loop below leaves this whole async function permanently
9828
9753
  // unresolved, so anything only collected into a local array and persisted
9829
9754
  // at the very end (the pre-fix allFixes/allReviews/allWorkItems) never
9830
- // reaches dispatch.json — silently discarding valid work already
9755
+ // reaches dispatch state — silently discarding valid work already
9831
9756
  // discovered for unrelated, healthy projects processed earlier in the same
9832
9757
  // loop. Persisting per batch means a healthy project's discoveries land in
9833
- // dispatch.json immediately, before the loop ever reaches a slow project.
9758
+ // dispatch state immediately, before the loop reaches a slow project.
9834
9759
  async function persistBatch(items) {
9835
9760
  if (!items || items.length === 0) return;
9836
9761
  // Gate reviews and fixes: only when at max concurrency — idle agents should pick up reviews
@@ -9855,7 +9780,7 @@ async function discoverWork(config) {
9855
9780
  // type-only filter below would silently start gating those
9856
9781
  // previously-ungated sources too. Scope the gate back down to
9857
9782
  // `meta.source === 'pr'` to preserve the original behavior exactly.
9858
- const isGateEligible = (w) => w.meta?.source === 'pr' && (w.type === WORK_TYPE.FIX || w.type === WORK_TYPE.REVIEW);
9783
+ const isGateEligible = (w) => w.meta?.source === 'pr' && (shared.isFixLikeWorkType(w.type) || w.type === WORK_TYPE.REVIEW);
9859
9784
  const gated = items.filter(isGateEligible);
9860
9785
  if (gated.length > 0) {
9861
9786
  log('info', `Gating ${gated.length} fixes/reviews — implement items in progress and no free slots`);
@@ -9883,7 +9808,7 @@ async function discoverWork(config) {
9883
9808
  } catch (e) {
9884
9809
  // Fail-open contract preserved: any validator throw here means the
9885
9810
  // item is dropped from this discovery batch rather than wedging the
9886
- // whole chunk. The item stays `pending` in work-items.json and the
9811
+ // whole chunk. The item stays pending in SQL and the
9887
9812
  // next tick re-discovers it.
9888
9813
  log('warn', `pre-dispatch-eval: addToDispatchWithValidation threw for ${item?.meta?.item?.id || item?.id || 'unknown'}: ${e?.message || e}`);
9889
9814
  }
@@ -9897,7 +9822,7 @@ async function discoverWork(config) {
9897
9822
  }));
9898
9823
  }
9899
9824
  for (const w of toDispatch) {
9900
- if (w.type === WORK_TYPE.FIX) persistedFixes++;
9825
+ if (shared.isFixLikeWorkType(w.type)) persistedFixes++;
9901
9826
  else if (w.type === WORK_TYPE.REVIEW) persistedReviews++;
9902
9827
  else persistedOther++;
9903
9828
  }
@@ -9916,7 +9841,7 @@ async function discoverWork(config) {
9916
9841
  if (config.engine?.prDiscoveryEnabled !== false) {
9917
9842
  const prWork = await discoverFromPrs(config, project);
9918
9843
  projectWork.push(...prWork.filter(w =>
9919
- w.type === WORK_TYPE.FIX || w.type === WORK_TYPE.REVIEW || w.type === WORK_TYPE.TEST));
9844
+ shared.isFixLikeWorkType(w.type) || w.type === WORK_TYPE.REVIEW || w.type === WORK_TYPE.TEST));
9920
9845
  }
9921
9846
 
9922
9847
  // Side-effect: specs → work items (picked up below)
@@ -9959,7 +9884,6 @@ async function discoverWork(config) {
9959
9884
  const scheduledWork = discoverScheduledWork(config);
9960
9885
  if (scheduledWork.length > 0) {
9961
9886
  const { createMeeting, getMeetings } = require('./engine/meeting');
9962
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
9963
9887
  // Separate meetings (no work-items write) from task items
9964
9888
  const taskItems = [];
9965
9889
  for (const item of scheduledWork) {
@@ -9973,8 +9897,7 @@ async function discoverWork(config) {
9973
9897
  }
9974
9898
  }
9975
9899
  if (taskItems.length > 0) {
9976
- // Atomic write — prevents race with dispatch status updates on central work-items.json
9977
- mutateJsonFileLocked(centralPath, (items) => {
9900
+ mutateWorkItems('central', (items) => {
9978
9901
  if (!Array.isArray(items)) items = [];
9979
9902
  let added = 0;
9980
9903
  // Snapshot active dedup keys BEFORE the loop so multiple items in the
@@ -10010,7 +9933,7 @@ async function discoverWork(config) {
10010
9933
  log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
10011
9934
  }
10012
9935
  return items;
10013
- }, { defaultValue: [] });
9936
+ });
10014
9937
  }
10015
9938
  }
10016
9939
  } catch (e) { log('warn', 'discover scheduled work: ' + e.message); }
@@ -10036,51 +9959,19 @@ async function discoverWork(config) {
10036
9959
  if (tickCount % (ENGINE_DEFAULTS.planCompletionScanEvery || 60) === 0) {
10037
9960
  try {
10038
9961
  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);
9962
+ for (const f of prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename)) {
9963
+ if (completedPlanCache.has(f)) continue;
9964
+ const plan = prdStore.readPrd(f);
9965
+ if (!plan?.missing_features) continue;
9966
+ if (shared.isDefunctPrd(plan, PLANS_DIR)) { completedPlanCache.add(f); continue; }
9967
+ if (plan.status === PLAN_STATUS.COMPLETED) {
9968
+ const done = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
9969
+ if (done) completedPlanCache.add(f);
9970
+ continue;
10083
9971
  }
9972
+ if (plan.status !== PLAN_STATUS.APPROVED && plan.status !== PLAN_STATUS.ACTIVE) continue;
9973
+ const completed = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
9974
+ if (completed) completedPlanCache.add(f);
10084
9975
  }
10085
9976
  } catch (e) { log('warn', 'plan completion sweep: ' + e.message); }
10086
9977
  }
@@ -10170,7 +10061,7 @@ function persistPendingDispatchAgent(item) {
10170
10061
  }
10171
10062
 
10172
10063
  function isSoftFixDispatch(item) {
10173
- return item?.type === WORK_TYPE.FIX && !routing.isAgentHardPinned(item.meta?.item);
10064
+ return shared.isFixLikeWorkType(item?.type) && !routing.isAgentHardPinned(item.meta?.item);
10174
10065
  }
10175
10066
 
10176
10067
  function resolveMaxConcurrent(config) {
@@ -10204,26 +10095,6 @@ const completedPlanCache = new Set();
10204
10095
  // Dedupe the "Skipping defunct PRD" materializer log so a lingering defunct PRD
10205
10096
  // doesn't spam one line per discovery tick; cleared when the file stops being defunct.
10206
10097
  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
10098
  let lastWatchCheckAt = 0;
10228
10099
  let lastPrStatusPollAt = 0;
10229
10100
  let lastPrCommentsPollAt = 0;
@@ -10451,7 +10322,7 @@ async function tickInner() {
10451
10322
  }
10452
10323
 
10453
10324
  // 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
10325
+ // SQL managed-process state, kills TTL-expired specs, drops dead-PID
10455
10326
  // rows, rotates managed-logs/<name>.log past ENGINE_DEFAULTS.managedSpawn
10456
10327
  // .logRotateBytes. Mirrors the keep-processes sweep cadence (sweepEvery=180)
10457
10328
  // so the engine never iterates per-spec on every tick. Healthcheck loops
@@ -10498,16 +10369,12 @@ async function tickInner() {
10498
10369
  safe('checkWatches', () => {
10499
10370
  const { checkWatches } = require('./engine/watches');
10500
10371
  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
- });
10372
+ const pullRequestStore = require('./engine/pull-requests-store');
10373
+ const workItemStore = require('./engine/work-items-store');
10374
+ const pullRequests = projects.flatMap(p => pullRequestStore.readPullRequestsForScope(p.name));
10375
+ const workItems = projects.flatMap(p => workItemStore.readWorkItemsForScope(p.name));
10509
10376
  // Also include central work items
10510
- const centralWi = safeJsonArr(path.join(MINIONS_DIR, 'work-items.json'));
10377
+ const centralWi = workItemStore.readWorkItemsForScope('central');
10511
10378
 
10512
10379
  // Gather state for the new generalized target types. Each block is
10513
10380
  // best-effort — if a module/file is missing the watch evaluator will
@@ -10515,23 +10382,11 @@ async function tickInner() {
10515
10382
  let meetings = [];
10516
10383
  try { meetings = require('./engine/meeting').getMeetings(); } catch { /* optional */ }
10517
10384
 
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 */ }
10385
+ const plans = require('./engine/prd-store').listPrdRows()
10386
+ .map(({ filename, plan }) => ({ ...plan, _source: filename, _sourcePlan: plan.source_plan || '' }));
10532
10387
 
10533
10388
  let scheduleRuns = {};
10534
- try { scheduleRuns = safeJsonObj(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')); } catch { /* optional */ }
10389
+ try { scheduleRuns = require('./engine/small-state-store').readScheduleRuns(); } catch { /* optional */ }
10535
10390
 
10536
10391
  let pipelineRuns = {};
10537
10392
  try { pipelineRuns = require('./engine/pipeline').getPipelineRuns(); } catch { /* optional */ }
@@ -10631,29 +10486,7 @@ async function tickInner() {
10631
10486
  const prdFiles = prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
10632
10487
  for (const file of prdFiles) {
10633
10488
  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));
10489
+ const plan = prdStore.readPrd(file);
10657
10490
  // Resurrection-re-execution guard: a defunct PRD (archived, or completed
10658
10491
  // with its source plan gone from plans/) must NOT have its verify gate
10659
10492
  // re-created. This scan deliberately re-runs completion for completed
@@ -10767,13 +10600,12 @@ async function tickInner() {
10767
10600
  // Check for failed items blocking pending items
10768
10601
  for (const project of projects) {
10769
10602
  try {
10770
- const wiPath = projectWorkItemsPath(project);
10771
10603
  // Collect keys to clear AFTER work-items lock is released (avoid nested locks)
10772
10604
  const dispatchKeysToClear = [];
10773
10605
  const cooldownKeysToClear = [];
10774
10606
 
10775
10607
  if (_isTickStale(myGeneration)) return;
10776
- mutateWorkItems(wiPath, items => {
10608
+ mutateWorkItems(project, items => {
10777
10609
  let changed = false;
10778
10610
  const failedIds = new Set(items.filter(w => w.status === WI_STATUS.FAILED).map(w => w.id));
10779
10611
  const pendingWithBlockedDeps = items.filter(w =>
@@ -11036,7 +10868,7 @@ async function tickInner() {
11036
10868
  assignPendingDispatchAgent(item, altAgent, config);
11037
10869
  pendingAgents.add(altAgent);
11038
10870
  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
10871
+ // Persist reassignment so it survives restarts/ticks.
11040
10872
  persistPendingDispatchAgent(item);
11041
10873
  }
11042
10874
  }
@@ -11136,7 +10968,15 @@ async function tickInner() {
11136
10968
  skipDirtyCheck: !!_precheckProjCfg.skipLiveCheckoutDirtyCheck,
11137
10969
  });
11138
10970
  } catch (e) { log('warn', `live-checkout precheck (dirty) failed for ${item.id}: ${e.message}`); }
11139
- if (_dirtyResult && _dirtyResult.dirty) {
10971
+ const _autoStashEnabled = _liveCheckoutPrecheck.resolveLiveCheckoutAutoStash({
10972
+ project: _precheckProjCfg,
10973
+ engine: config.engine || {},
10974
+ });
10975
+ const _autoResetEnabled = shared.resolveLiveCheckoutAutoReset(
10976
+ _precheckProjCfg,
10977
+ config.engine || {},
10978
+ );
10979
+ if (_dirtyResult && _dirtyResult.dirty && !_autoStashEnabled && !_autoResetEnabled) {
11140
10980
  _persistPendingReason('live_checkout_dirty');
11141
10981
  try {
11142
10982
  const _alertBody = [
@@ -11272,12 +11112,10 @@ async function tickInner() {
11272
11112
  // Defensive: ensure the work item is re-queued if completeDispatch didn't fire
11273
11113
  if (item.meta?.item?.id) {
11274
11114
  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) {
11115
+ const wiScope = resolveWorkItemScope(item.meta);
11116
+ if (wiScope) {
11279
11117
  if (_isTickStale(myGeneration)) return;
11280
- mutateWorkItems(wiPath, items => {
11118
+ mutateWorkItems(wiScope, items => {
11281
11119
  const wi = items.find(i => i.id === item.meta.item.id);
11282
11120
  if (wi && wi.status === WI_STATUS.DISPATCHED) {
11283
11121
  // completeDispatch didn't update the work item — re-queue manually
@@ -11324,11 +11162,12 @@ async function tickInner() {
11324
11162
  // post-dispatch active list so the annotation loop's reason is consistent
11325
11163
  // with whatever actually got spawned this tick.
11326
11164
  const postLiveProjectsInUse = new Set();
11165
+ const postProjects = shared.getProjects(config);
11327
11166
  for (const d of (postDispatch.active || [])) {
11328
11167
  if (READ_ONLY_ROOT_TASK_TYPES.has(d.type)) continue;
11329
11168
  const projName = d.project || d.meta?.project?.name || null;
11330
11169
  if (!projName) continue;
11331
- const projCfg = shared.findProjectByName(shared.getProjects(config), projName);
11170
+ const projCfg = shared.findProjectByName(postProjects, projName);
11332
11171
  if (shared.resolveCheckoutMode(projCfg, d.type) === 'live') {
11333
11172
  postLiveProjectsInUse.add(projName);
11334
11173
  }
@@ -11355,7 +11194,10 @@ async function tickInner() {
11355
11194
  && !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
11356
11195
  && postLiveProjectsInUse.has(itemProjName)
11357
11196
  ) {
11358
- reason = 'live_checkout_busy';
11197
+ const projCfg = shared.findProjectByName(postProjects, itemProjName);
11198
+ if (shared.resolveCheckoutMode(projCfg, item.type) === 'live') {
11199
+ reason = 'live_checkout_busy';
11200
+ }
11359
11201
  }
11360
11202
  }
11361
11203
  }
@@ -11400,8 +11242,8 @@ async function tickInner() {
11400
11242
  // clobbered. Previously the mutator callback assigned dp.pending from the
11401
11243
  // pre-lock postDispatch snapshot, overwriting any freshly loaded queue
11402
11244
  // 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.
11245
+ // mirrors the in-loop `delete` semantics — the SQL store serializes
11246
+ // records without undefined-valued keys.
11405
11247
  const patches = new Map();
11406
11248
  for (const item of (postDispatch.pending || [])) {
11407
11249
  patches.set(item.id, {
@@ -11517,7 +11359,7 @@ function emitMemoryBaseline(tickN) {
11517
11359
  module.exports = {
11518
11360
  // Paths
11519
11361
  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,
11362
+ CONTROL_PATH, INBOX_DIR, KNOWLEDGE_DIR, ARCHIVE_DIR,
11521
11363
  IDENTITY_DIR, CONFIG_PATH, ROUTING_PATH, NOTES_PATH, SKILLS_DIR,
11522
11364
 
11523
11365
  // Utilities
@@ -11542,10 +11384,7 @@ module.exports = {
11542
11384
  discoverWork, discoverFromPrs, discoverFromWorkItems, discoverCentralWorkItems,
11543
11385
  materializePlansAsWorkItems,
11544
11386
  materializeSpecsAsWorkItems, // exported for testing (P-f7-git-log)
11545
- reservePrdFilename, // exported for testing (P-9b7e5d3c)
11546
11387
  restoreHijackedOperatorCheckouts, // exported for testing — operator-checkout self-heal
11547
- sweepStaleArchivedPrdBackups, // exported for testing
11548
- _isGhostPrdRestore, // exported for testing (RC4 — ghost-purge identity check)
11549
11388
 
11550
11389
  // Shared helpers (used by lifecycle.js and tests)
11551
11390
  reconcileItemsWithPrs, detectDependencyCycles,