@yemi33/minions 0.1.2378 → 0.1.2380

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/bin/minions.js +19 -9
  2. package/dashboard/js/refresh.js +2 -3
  3. package/dashboard/js/render-prd.js +1 -1
  4. package/dashboard/js/render-work-items.js +13 -6
  5. package/dashboard.js +393 -721
  6. package/docs/README.md +1 -0
  7. package/docs/architecture-review-2026-07-09.md +2 -4
  8. package/docs/auto-discovery.md +36 -49
  9. package/docs/blog-first-successful-dispatch.md +1 -1
  10. package/docs/branch-derivation.md +2 -2
  11. package/docs/command-center.md +1 -1
  12. package/docs/completion-reports.md +14 -4
  13. package/docs/constellation-bridge.md +59 -10
  14. package/docs/constellation-style-telemetry.md +6 -6
  15. package/docs/cooldown-merge-semantics.md +4 -0
  16. package/docs/copilot-cli-schema.md +3 -3
  17. package/docs/cross-repo-plans.md +17 -17
  18. package/docs/deprecated.json +2 -2
  19. package/docs/design-inbox-entries-schema.md +31 -39
  20. package/docs/design-state-storage.md +1 -1
  21. package/docs/documentation-audit-2026-07-09.md +2 -2
  22. package/docs/engine-restart.md +20 -8
  23. package/docs/harness-mode.md +1 -1
  24. package/docs/managed-spawn.md +4 -4
  25. package/docs/onboarding.md +1 -2
  26. package/docs/pr-comment-followup.md +3 -3
  27. package/docs/pr-review-fix-loop.md +1 -1
  28. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  29. package/docs/qa-runbook-lifecycle.md +4 -4
  30. package/docs/qa-runbooks.md +2 -2
  31. package/docs/rfc-completion-json.md +4 -1
  32. package/docs/runtime-adapters.md +1 -1
  33. package/docs/self-improvement.md +4 -5
  34. package/docs/shared-lifecycle-module-map.md +3 -1
  35. package/docs/slim-ux/architecture-suggestions.md +5 -6
  36. package/docs/slim-ux/concepts.md +23 -25
  37. package/docs/watches.md +7 -7
  38. package/docs/workspace-manifests.md +1 -1
  39. package/docs/worktree-lifecycle.md +1 -1
  40. package/engine/abandoned-pr-reconciliation.js +4 -5
  41. package/engine/acp-transport.js +49 -8
  42. package/engine/ado-status.js +5 -5
  43. package/engine/ado.js +20 -25
  44. package/engine/agent-worker-pool.js +124 -15
  45. package/engine/bridge.js +260 -5
  46. package/engine/cleanup.js +48 -131
  47. package/engine/cli.js +125 -83
  48. package/engine/cooldown.js +9 -16
  49. package/engine/db/index.js +22 -9
  50. package/engine/db/migrations/009-qa.js +1 -1
  51. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  52. package/engine/db/migrations/021-archived-work-items.js +72 -0
  53. package/engine/db/migrations/022-global-cc-session.js +31 -0
  54. package/engine/db/migrations/023-engine-state.js +30 -0
  55. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  56. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  57. package/engine/dispatch-store.js +2 -7
  58. package/engine/dispatch.js +34 -41
  59. package/engine/github.js +20 -27
  60. package/engine/lifecycle.js +221 -283
  61. package/engine/llm.js +1 -1
  62. package/engine/logs-store.js +2 -2
  63. package/engine/managed-spawn.js +2 -23
  64. package/engine/meeting.js +6 -6
  65. package/engine/metrics-store.js +2 -2
  66. package/engine/note-link-backfill.js +6 -11
  67. package/engine/pipeline.js +18 -36
  68. package/engine/playbook.js +1 -1
  69. package/engine/pooled-agent-process.js +46 -26
  70. package/engine/prd-store.js +73 -54
  71. package/engine/preflight.js +2 -5
  72. package/engine/projects.js +15 -62
  73. package/engine/pull-requests-store.js +0 -17
  74. package/engine/qa-runbooks.js +2 -2
  75. package/engine/qa-runs.js +1 -8
  76. package/engine/qa-sessions.js +41 -64
  77. package/engine/queries.js +120 -219
  78. package/engine/routing.js +3 -5
  79. package/engine/scheduler.js +0 -4
  80. package/engine/shared-branch-pr-reconcile.js +2 -3
  81. package/engine/shared.js +132 -637
  82. package/engine/small-state-store.js +89 -10
  83. package/engine/state-operations.js +16 -4
  84. package/engine/stdio-timestamps.js +1 -1
  85. package/engine/timeout.js +5 -12
  86. package/engine/watch-actions.js +20 -22
  87. package/engine/watches-store.js +1 -1
  88. package/engine/watches.js +6 -10
  89. package/engine/work-item-validation.js +52 -0
  90. package/engine/work-items-store.js +127 -29
  91. package/engine/worktree-gc.js +2 -2
  92. package/engine/worktree-pool.js +8 -18
  93. package/engine.js +167 -349
  94. package/minions.js +2 -2
  95. package/package.json +1 -1
  96. package/playbooks/plan-to-prd.md +2 -2
  97. package/playbooks/shared-rules.md +3 -3
  98. package/playbooks/templates/followup-dispatch.md +1 -1
  99. package/playbooks/verify.md +1 -1
  100. package/prompts/cc-system.md +2 -2
package/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();
@@ -4693,13 +4687,24 @@ async function spawnAgent(dispatchItem, config) {
4693
4687
  });
4694
4688
  } else {
4695
4689
  // P-1d8f0b93 — pooled copilot dispatch. No separate spawn-agent.js
4696
- // child process exists, so build the SAME combined prompt text that
4697
- // process would have received via runtime.buildPrompt(...) directly
4698
- // here, and hand it to a PooledAgentProcess facade instead of
4699
- // cold-spawning. Everything downstream (stdout/stderr handlers,
4690
+ // child process exists, so hand the task and trusted system context to
4691
+ // the PooledAgentProcess separately instead of cold-spawning. Everything
4692
+ // downstream (stdout/stderr handlers,
4700
4693
  // onAgentClose, live-output.log, PID-file writing below) treats `proc`
4701
4694
  // identically to a real child_process either way.
4702
- const pooledPromptText = runtime.buildPrompt(fullTaskPrompt, systemPrompt, { sessionId: cachedSessionId });
4695
+ const pooledSessionKeys = [
4696
+ 'MINIONS_COMPLETION_REPORT',
4697
+ 'MINIONS_COMPLETION_NONCE',
4698
+ 'MINIONS_LIVE_OUTPUT_PATH',
4699
+ 'MINIONS_STEERING_ACK_DIR',
4700
+ 'MINIONS_AGENT_CWD',
4701
+ ];
4702
+ const pooledProcessEnv = { ...childEnv };
4703
+ const pooledSessionContext = {};
4704
+ for (const key of pooledSessionKeys) {
4705
+ if (pooledProcessEnv[key] !== undefined) pooledSessionContext[key] = pooledProcessEnv[key];
4706
+ delete pooledProcessEnv[key];
4707
+ }
4703
4708
  proc = new PooledAgentProcess({
4704
4709
  pool: agentWorkerPool,
4705
4710
  dispatchId: id,
@@ -4710,7 +4715,10 @@ async function spawnAgent(dispatchItem, config) {
4710
4715
  // flat top-level config array, not per-agent-resolved.
4711
4716
  mcpServers: (engineConfig && engineConfig.mcpServers) || [],
4712
4717
  hermeticDirs: addDirs,
4713
- promptText: pooledPromptText,
4718
+ processEnv: pooledProcessEnv,
4719
+ sessionContext: pooledSessionContext,
4720
+ systemPromptText: systemPrompt,
4721
+ promptText: fullTaskPrompt,
4714
4722
  });
4715
4723
  // The child_process PID file is normally written asynchronously by
4716
4724
  // spawn-agent.js itself (engine/spawn-agent.js:606) once it starts.
@@ -5398,7 +5406,7 @@ async function spawnAgent(dispatchItem, config) {
5398
5406
  // agent described in its sidecar. This gate (a) rejects malformed
5399
5407
  // sidecars as a hard non-retryable failure with a dedicated failure
5400
5408
  // class + inbox alert, and (b) on success spawns each spec detached and
5401
- // batch-records them in engine/managed-processes.json. Healthcheck loops
5409
+ // batch-records them in SQL managed-process state. Healthcheck loops
5402
5410
  // + dispatch ERROR-on-healthcheck-failure land in the follow-up item;
5403
5411
  // for now a spec that spawns successfully is recorded with
5404
5412
  // healthy:false, alive:true and the engine sweep / item-3 healthcheck
@@ -5897,9 +5905,9 @@ async function spawnAgent(dispatchItem, config) {
5897
5905
  // engine/managed-logs/.
5898
5906
  if (managedSpawnHealthcheckFailure && managedSpawnHealthcheckFailure.annotation && dispatchItem.meta?.item?.id) {
5899
5907
  try {
5900
- const wiPath = resolveWorkItemPath(dispatchItem.meta);
5901
- if (wiPath) {
5902
- mutateWorkItems(wiPath, items => {
5908
+ const wiScope = resolveWorkItemScope(dispatchItem.meta);
5909
+ if (wiScope) {
5910
+ mutateWorkItems(wiScope, items => {
5903
5911
  const wi = items.find(i => i.id === dispatchItem.meta.item.id);
5904
5912
  if (!wi) return items;
5905
5913
  wi._managedSpawnPartial = managedSpawnHealthcheckFailure.annotation;
@@ -5916,9 +5924,9 @@ async function spawnAgent(dispatchItem, config) {
5916
5924
  // rather than a queue gate.
5917
5925
  if (keepProcessesAcceptanceFailure && dispatchItem.meta?.item?.id) {
5918
5926
  try {
5919
- const wiPath = resolveWorkItemPath(dispatchItem.meta);
5920
- if (wiPath) {
5921
- mutateJsonFileLocked(wiPath, data => {
5927
+ const wiScope = resolveWorkItemScope(dispatchItem.meta);
5928
+ if (wiScope) {
5929
+ mutateWorkItems(wiScope, data => {
5922
5930
  if (!Array.isArray(data)) return data;
5923
5931
  const wi = data.find(i => i.id === dispatchItem.meta.item.id);
5924
5932
  if (!wi) return data;
@@ -5935,7 +5943,7 @@ async function spawnAgent(dispatchItem, config) {
5935
5943
  // Cleanup temp files (whole per-dispatch dir, including PID/sysprompt
5936
5944
  // tmp/prompt — P-f6-tmp-toctou). removeDispatchTmpDir validates the path
5937
5945
  // resolves under engine/tmp/dispatch-* before rmSync, so a corrupted
5938
- // dispatch.json field can't redirect this to an arbitrary path.
5946
+ // dispatch field cannot redirect this to an arbitrary path.
5939
5947
  try { shared.removeDispatchTmpDir(dispatchTmpDir); } catch { /* cleanup */ }
5940
5948
 
5941
5949
  log('info', `Agent ${agentId} completed. Output saved to ${archivePath}`);
@@ -6037,7 +6045,7 @@ async function spawnAgent(dispatchItem, config) {
6037
6045
  item.started_at = startedAt;
6038
6046
  // Persist the resolved runtime so post-completion hooks (lifecycle.js) can
6039
6047
  // route output parsing through the right adapter. Also surfaces the choice
6040
- // in dispatch.json for debugging multi-runtime fleets.
6048
+ // in dispatch state for debugging multi-runtime fleets.
6041
6049
  item.runtimeName = runtimeName;
6042
6050
  // P-3f5b7d26 — persist pooled on the DISK record (activeProcesses/_pooled
6043
6051
  // is wiped on restart); cli.js's reattach path needs this since a pooled
@@ -6064,14 +6072,9 @@ async function spawnAgent(dispatchItem, config) {
6064
6072
  // Atomically stamp dispatched_to/dispatched_at on the originating work item (#402).
6065
6073
  if (meta?.item?.id) {
6066
6074
  try {
6067
- let wiPath = null;
6068
- if (meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout') {
6069
- wiPath = path.join(MINIONS_DIR, 'work-items.json');
6070
- } else if (meta.source === 'work-item' && meta.project?.name) {
6071
- wiPath = projectWorkItemsPath({ name: meta.project.name, localPath: meta.project.localPath });
6072
- }
6073
- if (wiPath) {
6074
- mutateJsonFileLocked(wiPath, (items) => {
6075
+ const wiScope = resolveWorkItemScope(meta);
6076
+ if (wiScope) {
6077
+ mutateWorkItems(wiScope, (items) => {
6075
6078
  if (!Array.isArray(items)) return items;
6076
6079
  const wi = items.find(i => i.id === meta.item.id);
6077
6080
  if (wi) {
@@ -6080,7 +6083,7 @@ async function spawnAgent(dispatchItem, config) {
6080
6083
  if (wi.status !== WI_STATUS.DISPATCHED) wi.status = WI_STATUS.DISPATCHED;
6081
6084
  }
6082
6085
  return items;
6083
- }, { defaultValue: [] });
6086
+ });
6084
6087
  }
6085
6088
  } catch (e) { log('warn', `stamp dispatched_to on work item ${meta.item.id}: ${e.message}`); }
6086
6089
  }
@@ -6105,12 +6108,9 @@ function areDependenciesMet(item, config) {
6105
6108
  for (const depId of deps) {
6106
6109
  const depItem = allWorkItems.find(w => w.id === depId);
6107
6110
  if (!depItem) {
6108
- // Fallback: check PRD JSON — plan-to-prd agents may pre-set items to done.
6109
- // safeJsonNoRestore — if the PRD has been archived, treat the dep as
6110
- // unmet rather than resurrecting the active PRD from .backup
6111
- // (W-mouptdh1000h9f39).
6111
+ // Fallback: check the PRD store — plan-to-prd agents may pre-set items to done.
6112
6112
  try {
6113
- const plan = safeJsonNoRestore(path.join(PRD_DIR, sourcePlan));
6113
+ const plan = prdStore.readPrd(sourcePlan);
6114
6114
  const prdItem = (plan?.missing_features || []).find(f => f.id === depId);
6115
6115
  if (prdItem && PRD_MET_STATUSES.has(prdItem.status)) continue; // PRD says done — treat as met
6116
6116
  } catch (e) { log('warn', 'check PRD dep status: ' + e.message); }
@@ -6182,8 +6182,8 @@ function detectDependencyCycles(items) {
6182
6182
  }
6183
6183
 
6184
6184
  // Reconciles work items against known PRs.
6185
- // Primary linkage comes from prdItems in pull-requests.json; fallback linkage
6186
- // 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.
6187
6187
  // onlyIds: if provided, only items whose ID is in this Set are eligible.
6188
6188
  function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
6189
6189
  const prLinks = shared.getPrLinks();
@@ -6275,7 +6275,7 @@ function updateSnapshot(config) {
6275
6275
 
6276
6276
  // ─── Cooldowns (extracted to engine/cooldown.js) ─────────────────────────────
6277
6277
 
6278
- const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
6278
+ const { dispatchCooldowns, loadCooldowns, saveCooldowns,
6279
6279
  isOnCooldown, setCooldown, setCooldownWithContext, drainCoalescedContexts,
6280
6280
  setCooldownFailure, clearCooldown, getPrReviewCooldownKey, clearLegacyPrReviewCooldown,
6281
6281
  isAlreadyDispatched, isBranchActive } = require('./engine/cooldown');
@@ -6283,12 +6283,11 @@ const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
6283
6283
  // Auto-clean pending/failed work items for a PRD so they re-materialize with updated plan data
6284
6284
  function autoCleanPrdWorkItems(prdFile, config) {
6285
6285
  const allProjects = getProjects(config);
6286
- const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
6287
- for (const proj of allProjects) wiPaths.push(projectWorkItemsPath(proj));
6286
+ const workItemScopes = ['central', ...allProjects];
6288
6287
  const deletedIds = [];
6289
- for (const wiPath of wiPaths) {
6288
+ for (const scope of workItemScopes) {
6290
6289
  try {
6291
- mutateWorkItems(wiPath, items => {
6290
+ mutateWorkItems(scope, items => {
6292
6291
  const filtered = items.filter(w => {
6293
6292
  if (w.sourcePlan === prdFile && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.FAILED)) {
6294
6293
  deletedIds.push(w.id); return false;
@@ -6331,51 +6330,6 @@ function safePrdProjectSlug(projectName) {
6331
6330
  return slug || 'project';
6332
6331
  }
6333
6332
 
6334
- /**
6335
- * Atomically reserve a unique PRD filename in `prdDir` (P-9b7e5d3c).
6336
- *
6337
- * Replaces the racy "fs.existsSync(...) then write" pattern: two parallel
6338
- * materializations on the same plan slug previously both observed a 'free'
6339
- * filename and silently overwrote each other. This helper uses
6340
- * `fs.openSync(path, 'wx')` so the OS rejects duplicate creates atomically.
6341
- *
6342
- * Attempt schedule (100 attempts total):
6343
- * attempt = 0 → baseFileName (e.g. "slug.json")
6344
- * attempt = 1 → "${stem}-1${ext}" (e.g. "slug-1.json")
6345
- * attempt = 2 → "${stem}-2${ext}"
6346
- * ...
6347
- * attempt = 99 → "${stem}-99${ext}"
6348
- *
6349
- * Behavior:
6350
- * - On EEXIST: increment attempt and retry.
6351
- * - Any other openSync error propagates immediately.
6352
- * - After 100 EEXIST failures: throw
6353
- * 'Could not reserve unique PRD filename after 100 attempts'.
6354
- * - On success: write `content` to the reserved file, close fd, return basename.
6355
- */
6356
- function reservePrdFilename(prdDir, baseFileName, content = '') {
6357
- const ext = path.extname(baseFileName);
6358
- const stem = ext ? baseFileName.slice(0, -ext.length) : baseFileName;
6359
- for (let attempt = 0; attempt < 100; attempt++) {
6360
- const candidateName = attempt === 0 ? baseFileName : `${stem}-${attempt}${ext}`;
6361
- const candidatePath = path.join(prdDir, candidateName);
6362
- let fd;
6363
- try {
6364
- fd = fs.openSync(candidatePath, 'wx');
6365
- } catch (err) {
6366
- if (err && err.code === 'EEXIST') continue;
6367
- throw err;
6368
- }
6369
- try {
6370
- if (content) fs.writeSync(fd, content);
6371
- } finally {
6372
- fs.closeSync(fd);
6373
- }
6374
- return candidateName;
6375
- }
6376
- throw new Error('Could not reserve unique PRD filename after 100 attempts');
6377
- }
6378
-
6379
6333
  // Fleet branches the engine/CC create. A worktree-mode operator checkout should
6380
6334
  // NEVER sit on one of these (or any branch); when it does, a flow ran raw
6381
6335
  // `git checkout`/`merge` in the live checkout instead of a worktree.
@@ -6444,16 +6398,15 @@ function restoreHijackedOperatorCheckouts(config) {
6444
6398
  }
6445
6399
 
6446
6400
  function materializePlansAsWorkItems(config) {
6447
- if (!fs.existsSync(PRD_DIR)) { try { fs.mkdirSync(PRD_DIR, { recursive: true }); } catch (e) { log('warn', 'create PRD directory: ' + e.message); } }
6448
6401
  const writePrdLocked = (fileName, data) => {
6449
- return mutateJsonFileLocked(path.join(PRD_DIR, fileName), () => data, { defaultValue: data });
6402
+ return prdStore.writePrd(fileName, data);
6450
6403
  };
6451
- const mutatePrdLocked = (fileName, fallback, mutator, options = {}) => {
6452
- return mutateJsonFileLocked(path.join(PRD_DIR, fileName), (current) => {
6404
+ const mutatePrdLocked = (fileName, fallback, mutator) => {
6405
+ return prdStore.mutatePrd(fileName, (current) => {
6453
6406
  if (!current?.missing_features && fallback?.missing_features) current = fallback;
6454
6407
  if (!current || Array.isArray(current) || typeof current !== 'object') current = {};
6455
6408
  return mutator(current) || current;
6456
- }, { defaultValue: fallback || {}, ...options });
6409
+ }, { defaultValue: fallback || {} });
6457
6410
  };
6458
6411
  const enforceDeclaredPlanProject = (fileName, currentPlan) => {
6459
6412
  if (!currentPlan?.source_plan) return { fileName, plan: currentPlan };
@@ -6512,7 +6465,7 @@ function materializePlansAsWorkItems(config) {
6512
6465
  const jsonName = mf.replace(/\.md$/, '.json');
6513
6466
  // Atomic open ('wx' + retry) so two parallel migrations on the
6514
6467
  // same slug don't both overwrite a single PRD (P-9b7e5d3c).
6515
- const reserved = reservePrdFilename(checkDir, jsonName, JSON.stringify(parsed, null, 2));
6468
+ const reserved = prdStore.createPrdUnique(jsonName, parsed);
6516
6469
  try { fs.unlinkSync(path.join(checkDir, mf)); } catch { /* cleanup */ }
6517
6470
  log('info', `Plan enforcement: moved ${mf} → prd/${reserved} (PRDs must be .json in prd/)`);
6518
6471
  }
@@ -6529,7 +6482,7 @@ function materializePlansAsWorkItems(config) {
6529
6482
  if (parsed?.missing_features) {
6530
6483
  // Atomic open ('wx' + retry) so two parallel migrations on the
6531
6484
  // same slug don't both overwrite a single PRD (P-9b7e5d3c).
6532
- const reserved = reservePrdFilename(PRD_DIR, jf, JSON.stringify(parsed, null, 2));
6485
+ const reserved = prdStore.createPrdUnique(jf, parsed);
6533
6486
  try { fs.unlinkSync(path.join(PLANS_DIR, jf)); } catch { /* cleanup */ }
6534
6487
  log('info', `Auto-migrated PRD ${jf} from plans/ to prd/${reserved}`);
6535
6488
  }
@@ -6549,10 +6502,7 @@ function materializePlansAsWorkItems(config) {
6549
6502
  const SEQUENTIAL_ID_RE = /^[A-Za-z]+-?\d+$/;
6550
6503
 
6551
6504
  for (let file of planFiles) {
6552
- // safeJsonNoRestore if a PRD was archived between readdir and this
6553
- // read, do not auto-resurrect it from a stale .backup sidecar
6554
- // (W-mouptdh1000h9f39).
6555
- let plan = safeJsonNoRestore(path.join(PRD_DIR, file));
6505
+ let plan = prdStore.readPrd(file);
6556
6506
  if (!plan?.missing_features) continue;
6557
6507
  // Resurrection-re-execution guard ("old PRDs came back"): never (re)materialize
6558
6508
  // a defunct PRD — archived, or completed with its source plan gone from plans/.
@@ -6699,7 +6649,7 @@ function materializePlansAsWorkItems(config) {
6699
6649
  const defaultProjectName = plan.project || file.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
6700
6650
  const allProjects = getProjects(config);
6701
6651
  const defaultProject = shared.resolveProjectSource(defaultProjectName, allProjects, { allowCentral: false }).project;
6702
- // No project found — use central work-items.json (engine works without projects)
6652
+ // No project found — use central state (engine works without projects).
6703
6653
  const useCentral = !defaultProject;
6704
6654
 
6705
6655
  const statusFilter = PRD_MATERIALIZABLE;
@@ -6723,7 +6673,7 @@ function materializePlansAsWorkItems(config) {
6723
6673
  );
6724
6674
 
6725
6675
  // Group items by target project (per-item project field overrides plan-level project)
6726
- // 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.
6727
6677
  const itemsByProject = new Map(); // projectName -> { project, items: [] }
6728
6678
  for (const item of items) {
6729
6679
  if (item.project) {
@@ -6773,19 +6723,19 @@ function materializePlansAsWorkItems(config) {
6773
6723
  // A pause/reject/awaiting-approval signal may arrive between the outer-loop initial
6774
6724
  // read and this point (e.g. operator pauses via dashboard while the tick is running).
6775
6725
  // Re-reading here prevents phantom WI creation on a paused, rejected, or unapproved plan.
6776
- const freshPrd = prdStore.readPrd(path.join(PRD_DIR, file));
6726
+ const freshPrd = prdStore.readPrd(file);
6777
6727
  const freshStatus = (freshPrd || {}).status || ((freshPrd || {}).requires_approval ? 'awaiting-approval' : null);
6778
6728
  if (freshStatus === PLAN_STATUS.PAUSED || freshStatus === PLAN_STATUS.REJECTED || freshStatus === 'awaiting-approval') {
6779
6729
  log('warn', `PRD ${file}: status changed to '${freshStatus}' mid-tick — skipping WI batch for ${projName} (P-f1a3b5c7)`);
6780
6730
  break;
6781
6731
  }
6782
6732
 
6783
- const wiPath = project ? projectWorkItemsPath(project) : path.join(MINIONS_DIR, 'work-items.json');
6733
+ const wiScope = project || 'central';
6784
6734
  let created = 0;
6785
6735
  const newlyCreatedIds = new Set(); // tracks IDs created in this pass for reconciliation scoping
6786
6736
  const deferredReopens = []; // cross-project re-opens executed after this lock releases
6787
6737
 
6788
- mutateWorkItems(wiPath, existingItems => {
6738
+ mutateWorkItems(wiScope, existingItems => {
6789
6739
  for (const item of projItems) {
6790
6740
  // Re-open: 'updated' or 'missing' re-opens a done work item (#906)
6791
6741
  // Use composite (sourcePlan, id) key so a WI from another PRD with the
@@ -6807,7 +6757,7 @@ function materializePlansAsWorkItems(config) {
6807
6757
  if (!alreadyExists) {
6808
6758
  for (const p of allProjects) {
6809
6759
  if (String(p.name || '').toLowerCase() === String(projName || '').toLowerCase()) continue;
6810
- const otherItems = safeJsonArr(projectWorkItemsPath(p));
6760
+ const otherItems = readWorkItems(p);
6811
6761
  const otherWi = otherItems.find(w => w.id === item.id && w.sourcePlan === file);
6812
6762
  if (otherWi) {
6813
6763
  if (DONE_STATUSES.has(otherWi.status) && shouldReopen) {
@@ -6853,7 +6803,7 @@ function materializePlansAsWorkItems(config) {
6853
6803
 
6854
6804
  if (created > 0) {
6855
6805
  // Reconciliation: exact prdItems match only, scoped to newly created items
6856
- const allPrsForReconcile = allProjects.flatMap(p => safeJsonArr(projectPrPath(p)));
6806
+ const allPrsForReconcile = allProjects.flatMap(p => readPullRequests(p));
6857
6807
  const reconciled = reconcileItemsWithPrs(existingItems, allPrsForReconcile, { onlyIds: newlyCreatedIds });
6858
6808
  if (reconciled > 0) log('info', `Plan reconciliation: marked ${reconciled} item(s) as done → ${projName}`);
6859
6809
 
@@ -6887,8 +6837,7 @@ function materializePlansAsWorkItems(config) {
6887
6837
  for (const { itemId, projectName: rProjName, item: rItem } of deferredReopens) {
6888
6838
  const rProject = shared.resolveProjectSource(rProjName, allProjects, { allowCentral: false }).project;
6889
6839
  if (!rProject) continue;
6890
- const rPath = projectWorkItemsPath(rProject);
6891
- mutateWorkItems(rPath, items => {
6840
+ mutateWorkItems(rProject, items => {
6892
6841
  const target = items.find(w => w.id === itemId);
6893
6842
  if (target && DONE_STATUSES.has(target.status)) {
6894
6843
  shared.reopenWorkItem(target);
@@ -6968,8 +6917,7 @@ function materializePlansAsWorkItems(config) {
6968
6917
  function clearPendingHumanFeedbackFlag(projectMeta, prId) {
6969
6918
  if (!prId) return;
6970
6919
  try {
6971
- const prsPath = projectPrPath(projectMeta);
6972
- mutatePullRequests(prsPath, prs => {
6920
+ mutatePullRequests(projectMeta, prs => {
6973
6921
  const target = shared.findPrRecord(prs, prId, projectMeta);
6974
6922
  if (!target?.humanFeedback?.pendingFix) return;
6975
6923
  target.humanFeedback.pendingFix = false;
@@ -7044,7 +6992,7 @@ async function isWorktreeHeldPauseSuppressed(pr, project) {
7044
6992
  }
7045
6993
  if (!stillHeld) {
7046
6994
  try {
7047
- mutatePullRequests(projectPrPath(project), (prs) => {
6995
+ mutatePullRequests(project, (prs) => {
7048
6996
  if (!Array.isArray(prs)) return prs;
7049
6997
  const target = shared.findPrRecord(prs, pr, project);
7050
6998
  if (!target) return prs;
@@ -7096,7 +7044,7 @@ function resolvePrBranch(pr) {
7096
7044
  function updatePrBranchResolutionState(project, pr, { branch = '', reason = '' } = {}) {
7097
7045
  let changed = false;
7098
7046
  try {
7099
- mutatePullRequests(projectPrPath(project), prs => {
7047
+ mutatePullRequests(project, prs => {
7100
7048
  const target = shared.findPrRecord(prs, pr, project);
7101
7049
  if (!target) return;
7102
7050
  if (branch) {
@@ -7349,19 +7297,24 @@ function _resetAutoFixPausedLogState() {
7349
7297
  // persist — the warning is for the operator at engine startup/run time).
7350
7298
  const _warnedSilentDiscovery = new Set();
7351
7299
 
7352
- function _warnSilentDiscoveryOnce(kind, project, dataPath, config) {
7300
+ function _warnSilentDiscoveryOnce(kind, project, config) {
7353
7301
  const key = `${kind}:${project?.name || project?.localPath || 'default'}`;
7354
7302
  if (_warnedSilentDiscovery.has(key)) return;
7355
7303
  let count = 0;
7356
- try { const arr = safeJson(dataPath); if (Array.isArray(arr)) count = arr.length; } catch {}
7357
- 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;
7358
7311
  _warnedSilentDiscovery.add(key);
7359
7312
  const projName = project?.name || '(unnamed)';
7360
7313
  const hasBlock = !!(project?.workSources?.[kind] || config?.workSources?.[kind]);
7361
7314
  const hint = hasBlock
7362
7315
  ? `Toggle in Dashboard → Settings → Project, or set \`workSources.${kind}.enabled: true\` in config.json.`
7363
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}\`.`;
7364
- 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}`);
7365
7318
  }
7366
7319
 
7367
7320
  /**
@@ -7382,12 +7335,12 @@ function _logPrDispatchSkipOnce(pr, reason) {
7382
7335
  }
7383
7336
 
7384
7337
  /**
7385
- * Scan pull-requests.json for PRs needing review or fixes
7338
+ * Scan tracked PR state for records needing review or fixes.
7386
7339
  */
7387
7340
  async function discoverFromPrs(config, project) {
7388
7341
  const src = project?.workSources?.pullRequests || config.workSources?.pullRequests;
7389
7342
  if (!src?.enabled) {
7390
- _warnSilentDiscoveryOnce('pullRequests', project, projectPrPath(project), config);
7343
+ _warnSilentDiscoveryOnce('pullRequests', project, config);
7391
7344
  return [];
7392
7345
  }
7393
7346
 
@@ -7535,7 +7488,7 @@ async function discoverFromPrs(config, project) {
7535
7488
  if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = liveStatus;
7536
7489
  // Persist so next tick doesn't re-check
7537
7490
  try {
7538
- mutateJsonFileLocked(projectPrPath(project), data => {
7491
+ mutatePullRequests(project, data => {
7539
7492
  if (!Array.isArray(data)) return data;
7540
7493
  const target = shared.findPrRecord(data, pr, project);
7541
7494
  if (target && target.reviewStatus !== REVIEW_STATUS.APPROVED) target.reviewStatus = liveStatus;
@@ -7751,7 +7704,7 @@ async function discoverFromPrs(config, project) {
7751
7704
  log('info', `Pre-dispatch vote check: ${pr.id} is ${liveStatus} (cached was waiting) — skipping re-review`);
7752
7705
  if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = liveStatus;
7753
7706
  try {
7754
- mutateJsonFileLocked(projectPrPath(project), data => {
7707
+ mutatePullRequests(project, data => {
7755
7708
  if (!Array.isArray(data)) return data;
7756
7709
  const target = shared.findPrRecord(data, pr, project);
7757
7710
  if (target && target.reviewStatus !== REVIEW_STATUS.APPROVED) target.reviewStatus = liveStatus;
@@ -7926,7 +7879,7 @@ async function discoverFromPrs(config, project) {
7926
7879
  const live = await checkBcFn(pr, project);
7927
7880
  if (live?.buildStatusStale) {
7928
7881
  try {
7929
- mutatePullRequests(projectPrPath(project), prs => {
7882
+ mutatePullRequests(project, prs => {
7930
7883
  const target = shared.findPrRecord(prs, pr, project);
7931
7884
  if (!target) return;
7932
7885
  target._buildStatusStale = true;
@@ -7938,7 +7891,7 @@ async function discoverFromPrs(config, project) {
7938
7891
  log('info', `Pre-dispatch build check: ${pr.id} build is ${live.buildStatus} (cached was failing) — skipping build-fix`);
7939
7892
  // Persist the fresh status so subsequent ticks don't re-check on every pass
7940
7893
  try {
7941
- mutatePullRequests(projectPrPath(project), prs => {
7894
+ mutatePullRequests(project, prs => {
7942
7895
  const target = shared.findPrRecord(prs, pr, project);
7943
7896
  if (!target) return;
7944
7897
  target.buildStatus = live.buildStatus;
@@ -8002,8 +7955,7 @@ async function discoverFromPrs(config, project) {
8002
7955
  if (pr.agent && !pr._buildFailNotified) {
8003
7956
  // Mark noted to prevent repeated processing; the fix dispatch and PR state carry the failure details.
8004
7957
  try {
8005
- const prPath = projectPrPath(project);
8006
- mutatePullRequests(prPath, prs => {
7958
+ mutatePullRequests(project, prs => {
8007
7959
  const target = shared.findPrRecord(prs, pr, project);
8008
7960
  if (target) {
8009
7961
  target._buildFailNotified = true;
@@ -8068,7 +8020,7 @@ async function discoverFromPrs(config, project) {
8068
8020
  if (live && live.mergeConflict === false) {
8069
8021
  log('info', `Pre-dispatch conflict check: ${pr.id} reports clean merge (cached was conflict) — skipping conflict-fix`);
8070
8022
  try {
8071
- mutatePullRequests(projectPrPath(project), prs => {
8023
+ mutatePullRequests(project, prs => {
8072
8024
  const target = shared.findPrRecord(prs, pr, project);
8073
8025
  if (!target) return;
8074
8026
  delete target._mergeConflict;
@@ -8097,7 +8049,7 @@ async function discoverFromPrs(config, project) {
8097
8049
  newWork.push(item);
8098
8050
  // Record dispatch timestamp so re-dispatch is suppressed during ADO lag window
8099
8051
  try {
8100
- mutatePullRequests(projectPrPath(project), prs => {
8052
+ mutatePullRequests(project, prs => {
8101
8053
  const target = shared.findPrRecord(prs, pr, project);
8102
8054
  if (target) target._conflictFixedAt = new Date().toISOString();
8103
8055
  });
@@ -8197,7 +8149,7 @@ function _buildRunnerBriefVars(item, project) {
8197
8149
  }
8198
8150
 
8199
8151
  /**
8200
- * Scan work-items.json for manually queued tasks
8152
+ * Scan a project work-item scope for manually queued tasks.
8201
8153
  */
8202
8154
  function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, project, root, branchName, options = {}) {
8203
8155
  const worktreePath = options.worktreePath || path.resolve(root, config.engine?.worktreeRoot || '../worktrees', `${branchName}`);
@@ -8411,7 +8363,7 @@ function resolveWorkItemPrRecord(item, project) {
8411
8363
  if (!project) return null;
8412
8364
  const prRef = getWorkItemPrRef(item);
8413
8365
  if (!prRef) return null;
8414
- const prs = safeJsonArr(projectPrPath(project));
8366
+ const prs = readPullRequests(project);
8415
8367
  shared.normalizePrRecords(prs, project);
8416
8368
  return shared.findPrRecord(prs, prRef, project);
8417
8369
  }
@@ -8478,7 +8430,7 @@ function refreshDeferredReviewPrompt(item, config) {
8478
8430
  // head SHA before rebuilding the prompt.
8479
8431
  let livePr = pr;
8480
8432
  try {
8481
- const prs = safeJsonArr(projectPrPath(project));
8433
+ const prs = readPullRequests(project);
8482
8434
  const found = shared.findPrRecord(prs, pr, project);
8483
8435
  if (found) livePr = found;
8484
8436
  } catch (e) { log('warn', `refreshDeferredReviewPrompt: live PR lookup for ${pr.id} failed: ${e.message}`); }
@@ -8510,12 +8462,12 @@ function refreshDeferredReviewPrompt(item, config) {
8510
8462
  function discoverFromWorkItems(config, project) {
8511
8463
  const src = project?.workSources?.workItems || config.workSources?.workItems;
8512
8464
  if (!src?.enabled) {
8513
- _warnSilentDiscoveryOnce('workItems', project, projectWorkItemsPath(project), config);
8465
+ _warnSilentDiscoveryOnce('workItems', project, config);
8514
8466
  return [];
8515
8467
  }
8516
8468
 
8517
8469
  const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
8518
- const items = safeJsonArr(projectWorkItemsPath(project));
8470
+ const items = readWorkItems(project);
8519
8471
  // W-mq9b7lor (H3): snapshot each item's serializable state BEFORE the
8520
8472
  // discover loop runs so we can compute per-item field deltas at the end
8521
8473
  // and apply them inside the mutateWorkItems lock — the prior
@@ -8625,7 +8577,7 @@ function discoverFromWorkItems(config, project) {
8625
8577
  }
8626
8578
 
8627
8579
  const key = `work-${project?.name || 'default'}-${item.id}`;
8628
- // Self-heal: collect keys for batched dispatch.json cleanup (after the loop)
8580
+ // Self-heal: collect keys for batched dispatch cleanup.
8629
8581
  selfHealKeys.add(key);
8630
8582
  dispatchCooldowns.delete(key);
8631
8583
  // Cooldown bypass for resumed items
@@ -8931,7 +8883,7 @@ function discoverFromWorkItems(config, project) {
8931
8883
  }
8932
8884
  }
8933
8885
  if (patches.size > 0) {
8934
- mutateWorkItems(projectWorkItemsPath(project), (current) => {
8886
+ mutateWorkItems(project, (current) => {
8935
8887
  for (const it of current) {
8936
8888
  const patch = patches.get(it.id);
8937
8889
  if (!patch) continue;
@@ -9197,10 +9149,9 @@ function materializeSpecsAsWorkItems(config, project) {
9197
9149
 
9198
9150
  if (recentSpecs.length === 0) return;
9199
9151
 
9200
- const wiPath = projectWorkItemsPath(project);
9201
9152
  let created = 0;
9202
9153
 
9203
- mutateWorkItems(wiPath, existingItems => {
9154
+ mutateWorkItems(project, existingItems => {
9204
9155
  for (const pr of mergedPrs) {
9205
9156
  const prBranch = (pr.branch || '').toLowerCase();
9206
9157
  const matchedSpecs = recentSpecs.filter(doc => {
@@ -9287,12 +9238,11 @@ function extractSpecInfo(filePath, projectRoot_) {
9287
9238
  }
9288
9239
 
9289
9240
  /**
9290
- * Scan central ~/.minions/work-items.json for project-agnostic tasks.
9241
+ * Scan central SQL state for project-agnostic tasks.
9291
9242
  * Uses the shared work-item.md playbook with multi-project context injected.
9292
9243
  */
9293
9244
  function discoverCentralWorkItems(config) {
9294
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
9295
- const items = safeJsonArr(centralPath);
9245
+ const items = readWorkItems('central');
9296
9246
  const projects = getProjects(config);
9297
9247
  const dispatchProjects = getCentralDispatchProjects(projects);
9298
9248
  const newWork = [];
@@ -9592,7 +9542,6 @@ function discoverCentralWorkItems(config) {
9592
9542
  // Inject plan-to-prd variables — read the plan file content for the playbook
9593
9543
  if (workType === WORK_TYPE.PLAN_TO_PRD && item.planFile) {
9594
9544
  if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
9595
- if (!fs.existsSync(PRD_DIR)) fs.mkdirSync(PRD_DIR, { recursive: true });
9596
9545
  if (planFileContent !== null) {
9597
9546
  vars.plan_content = planFileContent;
9598
9547
  } else {
@@ -9634,7 +9583,7 @@ function discoverCentralWorkItems(config) {
9634
9583
  const prdRows = prdStore.listPrdRows();
9635
9584
  const prdFiles = prdRows.filter(row => !row.archived).map(row => row.filename);
9636
9585
  for (const pf of prdFiles) {
9637
- const prd = prdStore.readPrd(path.join(PRD_DIR, pf));
9586
+ const prd = prdStore.readPrd(pf);
9638
9587
  if (planKey && prd && !shared.isPrdArchived(prd) && shared.prdMatchesSourcePlan(prd.source_plan, planKey)) {
9639
9588
  prdFilename = pf;
9640
9589
  vars.existing_prd_json = JSON.stringify(prd, null, 2);
@@ -9733,53 +9682,20 @@ function discoverCentralWorkItems(config) {
9733
9682
  }
9734
9683
 
9735
9684
  if (mutations.size > 0) {
9736
- // True atomic read-modify-write applies mutations to fresh locked data
9737
- mutateJsonFileLocked(centralPath, (freshItems) => {
9685
+ // Apply field patches to a fresh transactional snapshot.
9686
+ mutateWorkItems('central', (freshItems) => {
9738
9687
  if (!Array.isArray(freshItems)) freshItems = [];
9739
9688
  for (const fi of freshItems) {
9740
9689
  const m = mutations.get(fi.id);
9741
9690
  if (m) Object.assign(fi, m);
9742
9691
  }
9743
9692
  return freshItems;
9744
- }, { defaultValue: [] });
9693
+ });
9745
9694
  }
9746
9695
  return newWork;
9747
9696
  }
9748
9697
 
9749
9698
 
9750
- /**
9751
- * Sweep stale `.backup` sidecars in `prd/` whose archived counterpart already
9752
- * lives in `prd/archive/<name>.json` and the active `prd/<name>.json` is
9753
- * absent. Pre-neutralize-era archives left these sidecars behind; without this
9754
- * sweep, any caller that touched `prd/<name>.json` with the restore-enabled
9755
- * `safeJson` would resurrect the archived PRD as active (W-mouptdh1000h9f39).
9756
- *
9757
- * Idempotent and best-effort: readdir / existsSync / unlink failures are
9758
- * swallowed so a single weird filesystem state never blocks the discovery
9759
- * tick. Returns the number of sidecars purged (for tests / logging).
9760
- */
9761
- function sweepStaleArchivedPrdBackups(prdDir, prdArchiveDir) {
9762
- let purged = 0;
9763
- if (!fs.existsSync(prdArchiveDir)) return purged;
9764
- let archivedNames;
9765
- try { archivedNames = fs.readdirSync(prdArchiveDir).filter(f => f.endsWith('.json')); }
9766
- catch { return purged; }
9767
- for (const f of archivedNames) {
9768
- const activePath = path.join(prdDir, f);
9769
- const backupPath = activePath + '.backup';
9770
- // Active PRD wins — the dedicated ghost-PRD purge in discoverWork handles
9771
- // the case where both active.json and active.json.backup are present.
9772
- if (fs.existsSync(activePath)) continue;
9773
- if (!fs.existsSync(backupPath)) continue;
9774
- try {
9775
- fs.unlinkSync(backupPath);
9776
- purged++;
9777
- log('info', `Purged stale .backup sidecar for archived PRD: ${f}`);
9778
- } catch { /* best-effort */ }
9779
- }
9780
- return purged;
9781
- }
9782
-
9783
9699
  /**
9784
9700
  * Run all work discovery sources and queue new items
9785
9701
  * Priority: fix (0) > ask (1) > review (1) > implement (2) > work-items (3) > central (4)
@@ -9789,7 +9705,7 @@ async function discoverWork(config) {
9789
9705
  const projects = getProjects(config);
9790
9706
  let persistedFixes = 0, persistedReviews = 0, persistedOther = 0;
9791
9707
 
9792
- // 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.
9793
9709
  // These write to project work queues — picked up by discoverFromWorkItems below.
9794
9710
  // Gated by config.engine?.planMaterializationEnabled !== false (P-d6f0a2b5) —
9795
9711
  // operators can suppress the PRD reconcile + plan materialization pair
@@ -9813,10 +9729,10 @@ async function discoverWork(config) {
9813
9729
  // projects)` loop below leaves this whole async function permanently
9814
9730
  // unresolved, so anything only collected into a local array and persisted
9815
9731
  // at the very end (the pre-fix allFixes/allReviews/allWorkItems) never
9816
- // reaches dispatch.json — silently discarding valid work already
9732
+ // reaches dispatch state — silently discarding valid work already
9817
9733
  // discovered for unrelated, healthy projects processed earlier in the same
9818
9734
  // loop. Persisting per batch means a healthy project's discoveries land in
9819
- // dispatch.json immediately, before the loop ever reaches a slow project.
9735
+ // dispatch state immediately, before the loop reaches a slow project.
9820
9736
  async function persistBatch(items) {
9821
9737
  if (!items || items.length === 0) return;
9822
9738
  // Gate reviews and fixes: only when at max concurrency — idle agents should pick up reviews
@@ -9869,7 +9785,7 @@ async function discoverWork(config) {
9869
9785
  } catch (e) {
9870
9786
  // Fail-open contract preserved: any validator throw here means the
9871
9787
  // item is dropped from this discovery batch rather than wedging the
9872
- // whole chunk. The item stays `pending` in work-items.json and the
9788
+ // whole chunk. The item stays pending in SQL and the
9873
9789
  // next tick re-discovers it.
9874
9790
  log('warn', `pre-dispatch-eval: addToDispatchWithValidation threw for ${item?.meta?.item?.id || item?.id || 'unknown'}: ${e?.message || e}`);
9875
9791
  }
@@ -9945,7 +9861,6 @@ async function discoverWork(config) {
9945
9861
  const scheduledWork = discoverScheduledWork(config);
9946
9862
  if (scheduledWork.length > 0) {
9947
9863
  const { createMeeting, getMeetings } = require('./engine/meeting');
9948
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
9949
9864
  // Separate meetings (no work-items write) from task items
9950
9865
  const taskItems = [];
9951
9866
  for (const item of scheduledWork) {
@@ -9959,8 +9874,7 @@ async function discoverWork(config) {
9959
9874
  }
9960
9875
  }
9961
9876
  if (taskItems.length > 0) {
9962
- // Atomic write — prevents race with dispatch status updates on central work-items.json
9963
- mutateJsonFileLocked(centralPath, (items) => {
9877
+ mutateWorkItems('central', (items) => {
9964
9878
  if (!Array.isArray(items)) items = [];
9965
9879
  let added = 0;
9966
9880
  // Snapshot active dedup keys BEFORE the loop so multiple items in the
@@ -9996,7 +9910,7 @@ async function discoverWork(config) {
9996
9910
  log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
9997
9911
  }
9998
9912
  return items;
9999
- }, { defaultValue: [] });
9913
+ });
10000
9914
  }
10001
9915
  }
10002
9916
  } catch (e) { log('warn', 'discover scheduled work: ' + e.message); }
@@ -10022,51 +9936,19 @@ async function discoverWork(config) {
10022
9936
  if (tickCount % (ENGINE_DEFAULTS.planCompletionScanEvery || 60) === 0) {
10023
9937
  try {
10024
9938
  const lifecycle = require('./engine/lifecycle');
10025
- const prdDir = path.join(MINIONS_DIR, 'prd');
10026
- const prdArchiveDir = path.join(prdDir, 'archive');
10027
- if (fs.existsSync(prdDir)) {
10028
- // Pass 1 — Burn the landmine: stale .backup sidecars whose archived
10029
- // counterpart already lives in prd/archive/ but no active prd/<f>.json.
10030
- // Without this, any future safeJson(prd/<f>.json) (or stray legacy
10031
- // call) would auto-restore the .backup and resurrect the archived PRD
10032
- // (W-mouptdh1000h9f39). Idempotent and fast — readdir + existsSync.
10033
- sweepStaleArchivedPrdBackups(prdDir, prdArchiveDir);
10034
-
10035
- // Pass 2 — completion sweep. (The ghost-PRD purge that used to live here
10036
- // was a REDUNDANT + UNSAFE duplicate of the plan-completion scan's
10037
- // version: it unlinked prd/<f> whenever prd/archive/<f> existed, with NO
10038
- // `_isGhostPrdRestore` identity check — the exact footgun #7 (#496)
10039
- // hazard of deleting a legitimately re-opened PRD that shares an archived
10040
- // basename. The plan-completion scan does the same purge safely (identity-
10041
- // checked + neutralize); here the isDefunctPrd guard below already skips
10042
- // archived/defunct PRDs, so this loop never re-runs completion on a ghost.)
10043
- for (const f of prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename)) {
10044
- if (completedPlanCache.has(f)) continue;
10045
- // safeJsonNoRestore — defense in depth: if the file vanished between
10046
- // readdir and read (e.g. concurrent archive), do not resurrect it
10047
- // from a stale .backup sidecar (W-mouptdh1000h9f39).
10048
- const plan = safeJsonNoRestore(path.join(prdDir, f));
10049
- if (!plan?.missing_features) continue;
10050
- // Resurrection guard (mirror of completion-scan-B / #523): never re-run
10051
- // completion (which re-creates the verify gate) for a defunct PRD —
10052
- // archived, or completed with its plan gone from plans/.
10053
- if (shared.isDefunctPrd(plan, PLANS_DIR)) { completedPlanCache.add(f); continue; }
10054
- // A completed PRD may still be missing its aggregate verify WI — e.g. it
10055
- // landed on disk pre-completed via the plan-to-prd / pipeline path, so no
10056
- // agent-completion event ever drove checkPlanCompletion (W-mqk5ld3p). The
10057
- // function is idempotent (re-checks for an existing verify WI under lock),
10058
- // so run it for completed plans too and cache only once it reports nothing
10059
- // left to do (truthy return).
10060
- if (plan.status === PLAN_STATUS.COMPLETED) {
10061
- const done = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
10062
- if (done) completedPlanCache.add(f);
10063
- continue;
10064
- }
10065
- if (plan.status !== PLAN_STATUS.APPROVED && plan.status !== PLAN_STATUS.ACTIVE) continue;
10066
- // Simulate the meta object checkPlanCompletion expects
10067
- const completed = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
10068
- 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;
10069
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);
10070
9952
  }
10071
9953
  } catch (e) { log('warn', 'plan completion sweep: ' + e.message); }
10072
9954
  }
@@ -10190,26 +10072,6 @@ const completedPlanCache = new Set();
10190
10072
  // Dedupe the "Skipping defunct PRD" materializer log so a lingering defunct PRD
10191
10073
  // doesn't spam one line per discovery tick; cleared when the file stops being defunct.
10192
10074
  const _defunctPrdSkipLogged = new Set();
10193
- // Filenames where a live PRD shares a basename with an archived PRD but diverges
10194
- // (different source_plan / introduces new work). We refuse to auto-purge those
10195
- // and warn once instead of every tick. (RC4 — footgun #7 collision protection.)
10196
- const _ghostPurgeCollisionWarned = new Set();
10197
-
10198
- // True when a live PRD that shares a basename with an archived one is merely a
10199
- // stale echo of the archive (a .backup ghost-restore), safe to purge. False when
10200
- // it's a genuinely distinct re-opened PRD that must NOT be silently deleted.
10201
- // Conservative: if either file is unreadable we fall back to the legacy
10202
- // "purge the ghost" behavior so we don't regress resurrection cleanup.
10203
- function _isGhostPrdRestore(live, archived) {
10204
- if (!live || !archived) return true;
10205
- const ls = live.source_plan || live.sourcePlan;
10206
- const as = archived.source_plan || archived.sourcePlan;
10207
- if (ls && as && ls !== as) return false; // different plan identity → real PRD
10208
- const archivedIds = new Set((archived.missing_features || []).map(f => f && f.id).filter(Boolean));
10209
- const hasNewWork = (live.missing_features || []).some(f => f && f.id && !archivedIds.has(f.id));
10210
- if (hasNewWork) return false; // introduces work the archive never had → real PRD
10211
- return true; // same identity, no new work → stale echo → ghost
10212
- }
10213
10075
  let lastWatchCheckAt = 0;
10214
10076
  let lastPrStatusPollAt = 0;
10215
10077
  let lastPrCommentsPollAt = 0;
@@ -10437,7 +10299,7 @@ async function tickInner() {
10437
10299
  }
10438
10300
 
10439
10301
  // 2.53. managed-spawn TTL/dead-PID sweep + log rotation (P-8a4d6f29). Walks
10440
- // engine/managed-processes.json, kills TTL-expired specs, drops dead-PID
10302
+ // SQL managed-process state, kills TTL-expired specs, drops dead-PID
10441
10303
  // rows, rotates managed-logs/<name>.log past ENGINE_DEFAULTS.managedSpawn
10442
10304
  // .logRotateBytes. Mirrors the keep-processes sweep cadence (sweepEvery=180)
10443
10305
  // so the engine never iterates per-spec on every tick. Healthcheck loops
@@ -10484,16 +10346,12 @@ async function tickInner() {
10484
10346
  safe('checkWatches', () => {
10485
10347
  const { checkWatches } = require('./engine/watches');
10486
10348
  const projects = getProjects(config);
10487
- const pullRequests = projects.flatMap(p => {
10488
- const prPath = path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json');
10489
- return safeJsonArr(prPath);
10490
- });
10491
- const workItems = projects.flatMap(p => {
10492
- const wiPath = path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json');
10493
- return safeJsonArr(wiPath);
10494
- });
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));
10495
10353
  // Also include central work items
10496
- const centralWi = safeJsonArr(path.join(MINIONS_DIR, 'work-items.json'));
10354
+ const centralWi = workItemStore.readWorkItemsForScope('central');
10497
10355
 
10498
10356
  // Gather state for the new generalized target types. Each block is
10499
10357
  // best-effort — if a module/file is missing the watch evaluator will
@@ -10501,23 +10359,11 @@ async function tickInner() {
10501
10359
  let meetings = [];
10502
10360
  try { meetings = require('./engine/meeting').getMeetings(); } catch { /* optional */ }
10503
10361
 
10504
- // Plans/PRDs read PRD JSON files directly so plan-level watches see
10505
- // status transitions (approved/rejected/completed/etc.). Each entry
10506
- // carries its filename in _source for target lookup.
10507
- const plans = [];
10508
- try {
10509
- const prdDir = path.join(MINIONS_DIR, 'prd');
10510
- for (const dir of [prdDir, path.join(prdDir, 'archive')]) {
10511
- if (!fs.existsSync(dir)) continue;
10512
- for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.json'))) {
10513
- const plan = safeJson(path.join(dir, f));
10514
- if (plan) plans.push({ ...plan, _source: f, _sourcePlan: plan.source_plan || '' });
10515
- }
10516
- }
10517
- } catch { /* optional */ }
10362
+ const plans = require('./engine/prd-store').listPrdRows()
10363
+ .map(({ filename, plan }) => ({ ...plan, _source: filename, _sourcePlan: plan.source_plan || '' }));
10518
10364
 
10519
10365
  let scheduleRuns = {};
10520
- try { scheduleRuns = safeJsonObj(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')); } catch { /* optional */ }
10366
+ try { scheduleRuns = require('./engine/small-state-store').readScheduleRuns(); } catch { /* optional */ }
10521
10367
 
10522
10368
  let pipelineRuns = {};
10523
10369
  try { pipelineRuns = require('./engine/pipeline').getPipelineRuns(); } catch { /* optional */ }
@@ -10617,29 +10463,7 @@ async function tickInner() {
10617
10463
  const prdFiles = prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
10618
10464
  for (const file of prdFiles) {
10619
10465
  if (completedPlanCache.has(file)) continue;
10620
- if (fs.existsSync(path.join(PRD_DIR, 'archive', file))) {
10621
- // A live PRD basename also exists in the archive. Historically this was
10622
- // unconditionally treated as an orphaned .backup ghost-restore and purged
10623
- // — but a re-opened plan can legitimately create a NEW live PRD sharing
10624
- // an archived basename (footgun #7). Identity-check before deleting so we
10625
- // never silently destroy a real divergent PRD.
10626
- const liveP = safeJsonNoRestore(path.join(PRD_DIR, file));
10627
- const archP = safeJsonNoRestore(path.join(PRD_DIR, 'archive', file));
10628
- if (_isGhostPrdRestore(liveP, archP)) {
10629
- // Orphaned backup restore — plan is already archived. Purge the ghost copy.
10630
- try { fs.unlinkSync(path.join(PRD_DIR, file)); } catch { }
10631
- shared.neutralizeJsonBackupSidecar(path.join(PRD_DIR, file));
10632
- completedPlanCache.add(file);
10633
- continue;
10634
- }
10635
- // Divergent live PRD — do NOT delete. Warn once and fall through to the
10636
- // normal completion handling below so it's treated as a real PRD.
10637
- if (!_ghostPurgeCollisionWarned.has(file)) {
10638
- 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`);
10639
- _ghostPurgeCollisionWarned.add(file);
10640
- }
10641
- }
10642
- const plan = safeJson(path.join(PRD_DIR, file));
10466
+ const plan = prdStore.readPrd(file);
10643
10467
  // Resurrection-re-execution guard: a defunct PRD (archived, or completed
10644
10468
  // with its source plan gone from plans/) must NOT have its verify gate
10645
10469
  // re-created. This scan deliberately re-runs completion for completed
@@ -10753,13 +10577,12 @@ async function tickInner() {
10753
10577
  // Check for failed items blocking pending items
10754
10578
  for (const project of projects) {
10755
10579
  try {
10756
- const wiPath = projectWorkItemsPath(project);
10757
10580
  // Collect keys to clear AFTER work-items lock is released (avoid nested locks)
10758
10581
  const dispatchKeysToClear = [];
10759
10582
  const cooldownKeysToClear = [];
10760
10583
 
10761
10584
  if (_isTickStale(myGeneration)) return;
10762
- mutateWorkItems(wiPath, items => {
10585
+ mutateWorkItems(project, items => {
10763
10586
  let changed = false;
10764
10587
  const failedIds = new Set(items.filter(w => w.status === WI_STATUS.FAILED).map(w => w.id));
10765
10588
  const pendingWithBlockedDeps = items.filter(w =>
@@ -11022,7 +10845,7 @@ async function tickInner() {
11022
10845
  assignPendingDispatchAgent(item, altAgent, config);
11023
10846
  pendingAgents.add(altAgent);
11024
10847
  log('info', `Reassigning ${item.id} from unspawned temp ${prevAgent} to ${altAgent} — temp agent never spawned`);
11025
- // Persist reassignment to dispatch.json so it survives restarts/ticks
10848
+ // Persist reassignment so it survives restarts/ticks.
11026
10849
  persistPendingDispatchAgent(item);
11027
10850
  }
11028
10851
  }
@@ -11258,12 +11081,10 @@ async function tickInner() {
11258
11081
  // Defensive: ensure the work item is re-queued if completeDispatch didn't fire
11259
11082
  if (item.meta?.item?.id) {
11260
11083
  try {
11261
- const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
11262
- ? path.join(ENGINE_DIR, '..', 'work-items.json')
11263
- : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
11264
- if (wiPath) {
11084
+ const wiScope = resolveWorkItemScope(item.meta);
11085
+ if (wiScope) {
11265
11086
  if (_isTickStale(myGeneration)) return;
11266
- mutateWorkItems(wiPath, items => {
11087
+ mutateWorkItems(wiScope, items => {
11267
11088
  const wi = items.find(i => i.id === item.meta.item.id);
11268
11089
  if (wi && wi.status === WI_STATUS.DISPATCHED) {
11269
11090
  // completeDispatch didn't update the work item — re-queue manually
@@ -11386,8 +11207,8 @@ async function tickInner() {
11386
11207
  // clobbered. Previously the mutator callback assigned dp.pending from the
11387
11208
  // pre-lock postDispatch snapshot, overwriting any freshly loaded queue
11388
11209
  // entries. Sentinel-undefined for _pendingReason / _agentBusySince
11389
- // mirrors the in-loop `delete` semantics — JSON serialization drops
11390
- // 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.
11391
11212
  const patches = new Map();
11392
11213
  for (const item of (postDispatch.pending || [])) {
11393
11214
  patches.set(item.id, {
@@ -11503,7 +11324,7 @@ function emitMemoryBaseline(tickN) {
11503
11324
  module.exports = {
11504
11325
  // Paths
11505
11326
  MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLAYBOOKS_DIR, PLANS_DIR, PRD_DIR,
11506
- CONTROL_PATH, DISPATCH_PATH, LOG_PATH, INBOX_DIR, KNOWLEDGE_DIR, ARCHIVE_DIR,
11327
+ CONTROL_PATH, INBOX_DIR, KNOWLEDGE_DIR, ARCHIVE_DIR,
11507
11328
  IDENTITY_DIR, CONFIG_PATH, ROUTING_PATH, NOTES_PATH, SKILLS_DIR,
11508
11329
 
11509
11330
  // Utilities
@@ -11528,10 +11349,7 @@ module.exports = {
11528
11349
  discoverWork, discoverFromPrs, discoverFromWorkItems, discoverCentralWorkItems,
11529
11350
  materializePlansAsWorkItems,
11530
11351
  materializeSpecsAsWorkItems, // exported for testing (P-f7-git-log)
11531
- reservePrdFilename, // exported for testing (P-9b7e5d3c)
11532
11352
  restoreHijackedOperatorCheckouts, // exported for testing — operator-checkout self-heal
11533
- sweepStaleArchivedPrdBackups, // exported for testing
11534
- _isGhostPrdRestore, // exported for testing (RC4 — ghost-purge identity check)
11535
11353
 
11536
11354
  // Shared helpers (used by lifecycle.js and tests)
11537
11355
  reconcileItemsWithPrs, detectDependencyCycles,