@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
@@ -1,9 +1,6 @@
1
1
  // engine/prd-store.js
2
2
  //
3
- // SQL-authoritative PRD state. Paths are retained as stable identifiers for
4
- // existing callers, but no runtime PRD JSON is read or written here.
5
-
6
- const path = require('path');
3
+ // SQL-authoritative PRD state.
7
4
 
8
5
  function _toMs(v) {
9
6
  if (v == null) return null;
@@ -13,28 +10,12 @@ function _toMs(v) {
13
10
  }
14
11
  function _bool01(v) { return v ? 1 : 0; }
15
12
 
16
- // Resolve <MINIONS_DIR>/prd/<f>.json → {filename, archived:0}; the
17
- // prd/archive/<f>.json variant → {filename, archived:1}; anything else → null.
18
- // Path comparison is normalized + case-insensitive on the directory prefix so
19
- // Windows drive-letter / separator differences don't cause a miss.
20
- function parsePrdPath(filePath) {
21
- if (!filePath || typeof filePath !== 'string') return null;
22
- let minionsDir;
23
- try { minionsDir = require('./shared').MINIONS_DIR; } catch { return null; }
24
- if (!minionsDir) return null;
25
- const norm = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
26
- const full = norm(filePath);
27
- const prdRoot = norm(path.join(minionsDir, 'prd'));
28
- const archiveRoot = norm(path.join(minionsDir, 'prd', 'archive'));
29
- if (!full.endsWith('.json')) return null;
30
- const base = path.basename(filePath);
31
- if (full === norm(path.join(archiveRoot, base)) && full.startsWith(archiveRoot + '/')) {
32
- return { filename: base, archived: 1 };
33
- }
34
- if (full === norm(path.join(prdRoot, base)) && full.startsWith(prdRoot + '/')) {
35
- return { filename: base, archived: 0 };
13
+ function _validatePrdFilename(filename) {
14
+ if (typeof filename !== 'string' || !filename.endsWith('.json')
15
+ || filename.includes('/') || filename.includes('\\') || filename === '.json') {
16
+ throw new TypeError('PRD filename must be a basename ending in .json');
36
17
  }
37
- return null;
18
+ return filename;
38
19
  }
39
20
 
40
21
  // (filename, archived) → plans.id, preferring the same archived bucket.
@@ -48,6 +29,22 @@ function _resolvePlanId(db, sourcePlan, archived) {
48
29
 
49
30
  // Core upsert: write/replace the prds row + its prd_items + prd_verify_prs for
50
31
  // (filename, archived). Returns the prds.id. Runs inside a transaction.
32
+ function _clearWorkItemLinks(db, prdId, predicate = '', params = []) {
33
+ db.prepare(`
34
+ UPDATE work_items SET prd_item_id=NULL
35
+ WHERE prd_item_id IN (
36
+ SELECT id FROM prd_items WHERE prd_id=? ${predicate}
37
+ )
38
+ `).run(prdId, ...params);
39
+ }
40
+
41
+ function _deletePrdRow(db, prdId) {
42
+ _clearWorkItemLinks(db, prdId);
43
+ db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(prdId);
44
+ db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(prdId);
45
+ db.prepare('DELETE FROM prds WHERE id=?').run(prdId);
46
+ }
47
+
51
48
  function _upsertPrd(db, filename, archived, prd, now) {
52
49
  const sourcePlan = prd.source_plan || prd.sourcePlan || null;
53
50
  const planId = _resolvePlanId(db, sourcePlan, archived);
@@ -98,10 +95,13 @@ function _upsertPrd(db, filename, archived, prd, now) {
98
95
  for (const ft of features) { if (ft && typeof ft === 'object' && ft.id) keepIds.push(String(ft.id)); }
99
96
  // Drop id-bearing items no longer on the PRD, plus all NULL-feature_id rows.
100
97
  if (keepIds.length) {
98
+ const predicate = `AND feature_id IS NOT NULL AND feature_id NOT IN (${keepIds.map(() => '?').join(',')})`;
99
+ _clearWorkItemLinks(db, prdId, predicate, keepIds);
101
100
  db.prepare(
102
101
  `DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NOT NULL AND feature_id NOT IN (${keepIds.map(() => '?').join(',')})`
103
102
  ).run(prdId, ...keepIds);
104
103
  } else {
104
+ _clearWorkItemLinks(db, prdId, 'AND feature_id IS NOT NULL');
105
105
  db.prepare('DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NOT NULL').run(prdId);
106
106
  }
107
107
  db.prepare('DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NULL').run(prdId);
@@ -181,27 +181,51 @@ function _upsertPrd(db, filename, archived, prd, now) {
181
181
  return prdId;
182
182
  }
183
183
 
184
- function writePrd(filePath, prd) {
185
- const parsed = parsePrdPath(filePath);
186
- if (!parsed || !prd || typeof prd !== 'object' || Array.isArray(prd)) {
187
- throw new TypeError('prd-store.writePrd requires a PRD path and object');
184
+ function writePrd(filename, prd, { archived = false } = {}) {
185
+ _validatePrdFilename(filename);
186
+ if (!prd || typeof prd !== 'object' || Array.isArray(prd)) {
187
+ throw new TypeError('prd-store.writePrd requires a filename and object');
188
188
  }
189
189
  const { getDb, withTransaction } = require('./db');
190
190
  const db = getDb();
191
- const prdId = withTransaction(db, () => _upsertPrd(db, parsed.filename, parsed.archived, prd, Date.now()));
192
- require('./db-events').emitStateEvent('prds', { filename: parsed.filename, archived: parsed.archived });
191
+ const archivedValue = archived ? 1 : 0;
192
+ const prdId = withTransaction(db, () => _upsertPrd(db, filename, archivedValue, prd, Date.now()));
193
+ require('./db-events').emitStateEvent('prds', { filename, archived: archivedValue });
193
194
  return { ok: true, id: prdId, plan: prd };
194
195
  }
195
196
 
196
- function mutatePrd(filePath, mutator, { defaultValue = {} } = {}) {
197
- const parsed = parsePrdPath(filePath);
198
- if (!parsed || typeof mutator !== 'function') {
199
- throw new TypeError('prd-store.mutatePrd requires a PRD path and mutator');
197
+ function createPrdUnique(baseFilename, prd) {
198
+ _validatePrdFilename(baseFilename);
199
+ if (!prd || typeof prd !== 'object' || Array.isArray(prd)) {
200
+ throw new TypeError('prd-store.createPrdUnique requires a PRD object');
201
+ }
202
+ const stem = baseFilename.slice(0, -'.json'.length);
203
+ const { getDb, withTransaction } = require('./db');
204
+ const db = getDb();
205
+ const filename = withTransaction(db, () => {
206
+ const exists = db.prepare('SELECT 1 FROM prds WHERE filename=? LIMIT 1');
207
+ for (let attempt = 0; attempt < 100; attempt++) {
208
+ const candidate = attempt === 0 ? baseFilename : `${stem}-${attempt}.json`;
209
+ if (exists.get(candidate)) continue;
210
+ _upsertPrd(db, candidate, 0, prd, Date.now());
211
+ return candidate;
212
+ }
213
+ throw new Error('Could not reserve unique PRD filename after 100 attempts');
214
+ });
215
+ require('./db-events').emitStateEvent('prds', { filename, archived: 0 });
216
+ return filename;
217
+ }
218
+
219
+ function mutatePrd(filename, mutator, { archived = false, defaultValue = {} } = {}) {
220
+ _validatePrdFilename(filename);
221
+ if (typeof mutator !== 'function') {
222
+ throw new TypeError('prd-store.mutatePrd requires a filename and mutator');
200
223
  }
201
224
  const { getDb, withTransaction } = require('./db');
202
225
  const db = getDb();
203
226
  const result = withTransaction(db, () => {
204
- const row = db.prepare('SELECT data FROM prds WHERE filename=? AND archived=?').get(parsed.filename, parsed.archived);
227
+ const archivedValue = archived ? 1 : 0;
228
+ const row = db.prepare('SELECT data FROM prds WHERE filename=? AND archived=?').get(filename, archivedValue);
205
229
  let current = row ? JSON.parse(row.data) : structuredClone(defaultValue);
206
230
  if (!current || typeof current !== 'object' || Array.isArray(current)) current = structuredClone(defaultValue);
207
231
  const next = mutator(current);
@@ -209,36 +233,33 @@ function mutatePrd(filePath, mutator, { defaultValue = {} } = {}) {
209
233
  if (!finalData || typeof finalData !== 'object' || Array.isArray(finalData)) {
210
234
  throw new TypeError('PRD mutator must return an object or mutate in place');
211
235
  }
212
- _upsertPrd(db, parsed.filename, parsed.archived, finalData, Date.now());
236
+ _upsertPrd(db, filename, archivedValue, finalData, Date.now());
213
237
  return finalData;
214
238
  });
215
- require('./db-events').emitStateEvent('prds', { filename: parsed.filename, archived: parsed.archived });
239
+ require('./db-events').emitStateEvent('prds', { filename, archived: archived ? 1 : 0 });
216
240
  return result;
217
241
  }
218
242
 
219
- function deletePrd(filePath) {
220
- const parsed = parsePrdPath(filePath);
221
- if (!parsed) throw new TypeError('prd-store.deletePrd requires a PRD path');
243
+ function deletePrd(filename, { archived = false } = {}) {
244
+ _validatePrdFilename(filename);
222
245
  const { getDb, withTransaction } = require('./db');
223
246
  const db = getDb();
224
247
  const removed = withTransaction(db, () => {
225
- const row = db.prepare('SELECT id FROM prds WHERE filename=? AND archived=?').get(parsed.filename, parsed.archived);
248
+ const archivedValue = archived ? 1 : 0;
249
+ const row = db.prepare('SELECT id FROM prds WHERE filename=? AND archived=?').get(filename, archivedValue);
226
250
  if (!row) return false;
227
- db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(row.id);
228
- db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(row.id);
229
- db.prepare('DELETE FROM prds WHERE id=?').run(row.id);
251
+ _deletePrdRow(db, row.id);
230
252
  return true;
231
253
  });
232
- if (removed) require('./db-events').emitStateEvent('prds', { filename: parsed.filename, archived: parsed.archived, removed: true });
254
+ if (removed) require('./db-events').emitStateEvent('prds', { filename, archived: archived ? 1 : 0, removed: true });
233
255
  return removed;
234
256
  }
235
257
 
236
- function readPrd(filePath) {
237
- const parsed = parsePrdPath(filePath);
238
- if (!parsed) return null;
258
+ function readPrd(filename, { archived = false } = {}) {
259
+ _validatePrdFilename(filename);
239
260
  const row = require('./db').getDb()
240
261
  .prepare('SELECT data FROM prds WHERE filename=? AND archived=?')
241
- .get(parsed.filename, parsed.archived);
262
+ .get(filename, archived ? 1 : 0);
242
263
  return row ? JSON.parse(row.data) : null;
243
264
  }
244
265
 
@@ -262,9 +283,7 @@ function restorePrdFromArchive(filename) {
262
283
  if (!row) return null;
263
284
  const prd = JSON.parse(row.data);
264
285
  _upsertPrd(db, filename, 0, prd, Date.now());
265
- db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(row.id);
266
- db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(row.id);
267
- db.prepare('DELETE FROM prds WHERE id=?').run(row.id);
286
+ _deletePrdRow(db, row.id);
268
287
  return prd;
269
288
  });
270
289
  if (restored) {
@@ -332,8 +351,8 @@ function listPrdRows() {
332
351
  }
333
352
 
334
353
  module.exports = {
335
- parsePrdPath,
336
354
  writePrd,
355
+ createPrdUnique,
337
356
  mutatePrd,
338
357
  deletePrd,
339
358
  readPrd,
@@ -266,11 +266,8 @@ function runPreflight(opts = {}) {
266
266
  // discoverFromWorkItems / discoverFromPrs return [] without logging.
267
267
  try {
268
268
  const projectWarns = shared.projectWorkSourceWarnings(opts.config, (project) => {
269
- const wiPath = shared.projectWorkItemsPath(project);
270
- const prPath = shared.projectPrPath(project);
271
- const safeJson = shared.safeJson;
272
- const wi = (() => { try { const a = safeJson(wiPath); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
273
- const pr = (() => { try { const a = safeJson(prPath); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
269
+ const wi = (() => { try { return shared.readWorkItems(project).length; } catch { return 0; } })();
270
+ const pr = (() => { try { return shared.readPullRequests(project).length; } catch { return 0; } })();
274
271
  return { workItems: wi, pullRequests: pr };
275
272
  });
276
273
  for (const w of projectWarns) {
@@ -9,6 +9,7 @@ const fs = require('fs');
9
9
  const path = require('path');
10
10
  const shared = require('./shared');
11
11
  const dispatch = require('./dispatch');
12
+ const queries = require('./queries');
12
13
  const projectDiscovery = require('./project-discovery');
13
14
  const { MINIONS_DIR } = shared;
14
15
 
@@ -86,9 +87,8 @@ function _centralDispatchDefaultedToProject(d, removedProject, projects) {
86
87
  }
87
88
 
88
89
  function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
89
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
90
90
  const ids = new Set();
91
- const state = shared.safeJsonObj(dispatchPath);
91
+ const state = queries.getDispatch();
92
92
  for (const queue of ['pending', 'active']) {
93
93
  for (const d of Array.isArray(state?.[queue]) ? state[queue] : []) {
94
94
  if (_centralDispatchDefaultedToProject(d, removedProject, projects)) ids.add(d.meta.item.id);
@@ -99,9 +99,8 @@ function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
99
99
 
100
100
  function _requeueProjectlessCentralWorkItems(itemIds) {
101
101
  if (!itemIds || itemIds.size === 0) return 0;
102
- const wiPath = path.join(MINIONS_DIR, 'work-items.json');
103
102
  let requeued = 0;
104
- shared.mutateWorkItems(wiPath, items => {
103
+ shared.mutateWorkItems('central', items => {
105
104
  if (!Array.isArray(items)) return items;
106
105
  for (const w of items) {
107
106
  if (!itemIds.has(w?.id) || w.status !== shared.WI_STATUS.DISPATCHED) continue;
@@ -124,10 +123,7 @@ function _requeueProjectlessCentralWorkItems(itemIds) {
124
123
  * @param {object} [options]
125
124
  * @param {'archive'|'keep'|'purge'} [options.dataMode='archive']
126
125
  * archive: move projects/<name>/ to projects/.archived/<name>-YYYYMMDD/
127
- * keep: leave projects/<name>/ in place, but tombstone work-items.json /
128
- * pull-requests.json into projects/<name>/.dropped/ so dropScope()
129
- * (step 9) isn't immediately undone by the *-store.js resync-on-
130
- * read path re-hydrating the still-present JSON (W-mr96oira000g979a)
126
+ * keep: leave projects/<name>/ in place
131
127
  * purge: rm -rf projects/<name>/
132
128
  * @returns {object} summary { ok, project, cancelledItems, killedAgents,
133
129
  * drainedDispatches, cleanedWorktrees, disabledSchedules, archivedTo,
@@ -147,7 +143,6 @@ function removeProject(target, options = {}) {
147
143
  pipelineRefs: [],
148
144
  archivedTo: null,
149
145
  purgedDataDir: false,
150
- tombstonedDataFiles: false,
151
146
  // P-bfa2d-remove-project-cleanup — soft-delete tag counts for entries in
152
147
  // the three central state files that survived the hard-cancel passes
153
148
  // (steps 1, 2). See step 7.5 for the orphan-tag policy rationale.
@@ -168,15 +163,14 @@ function removeProject(target, options = {}) {
168
163
  }
169
164
  summary.project = { name: project.name, localPath: project.localPath };
170
165
 
171
- // 1. Cancel pending/queued work items linked to this project (project-local
172
- // file + central). Done items are preserved as history.
166
+ // 1. Cancel pending/queued work items linked to this project.
173
167
  summary.cancelledItems += dispatch.cancelPendingWorkItems(
174
- shared.projectWorkItemsPath(project),
168
+ project,
175
169
  () => true,
176
170
  'project-removed',
177
171
  );
178
172
  summary.cancelledItems += dispatch.cancelPendingWorkItems(
179
- path.join(MINIONS_DIR, 'work-items.json'),
173
+ 'central',
180
174
  w => _workItemMatchesProject(w, project, projects),
181
175
  'project-removed',
182
176
  );
@@ -190,7 +184,7 @@ function removeProject(target, options = {}) {
190
184
  _requeueProjectlessCentralWorkItems(projectlessCentralItemIds);
191
185
 
192
186
  // 2.5. Managed-spawn cleanup (P-8a4d6f29). Centralised in managed-spawn.js
193
- // so no other module needs to know about engine/managed-processes.json.
187
+ // so no other module needs to know the managed-process storage schema.
194
188
  // Kills + unlinks every spec owned by this project, including the .log
195
189
  // and .log.1 rotated sibling. Best-effort: failures only warn.
196
190
  try {
@@ -316,11 +310,8 @@ function removeProject(target, options = {}) {
316
310
  } catch (e) { writeError = e; }
317
311
  if (writeError) return { ...summary, error: 'Failed to write config: ' + writeError.message };
318
312
 
319
- // 7.5. P-bfa2d-remove-project-cleanup — Tag dangling refs in the three
320
- // CENTRAL state files (`pull-requests.json`, `work-items.json`,
321
- // `engine/dispatch.json`) so downstream consumers can filter or sweep
322
- // them. Project-local files in `projects/<name>/` are handled by
323
- // step 8 (archive or purge), so this step only touches central state.
313
+ // 7.5. Tag dangling refs in central state so downstream consumers can filter
314
+ // or sweep them. Project-scoped rows are removed in step 9.
324
315
  //
325
316
  // Policy is soft-delete (`_orphaned_at` + `_orphaned_project` tags)
326
317
  // rather than hard-delete: preserves history for debugging, lets users
@@ -330,17 +321,11 @@ function removeProject(target, options = {}) {
330
321
  // survivors (non-pending WIs, central PRs, defense-in-depth dispatch
331
322
  // survivors).
332
323
  //
333
- // Lock acquisition is sequential and uses the mutate-* helpers from
334
- // engine/dispatch.js and engine/shared.js so we never hold two file
335
- // locks simultaneously. Order: dispatch.json → pull-requests.json →
336
- // work-items.json (alphabetical by basename). Each helper acquires
337
- // its own lock, runs the synchronous mutator, then releases before
338
- // the next helper runs.
339
324
  try {
340
325
  const orphanedAt = new Date().toISOString();
341
326
  const orphanedProject = project.name;
342
327
 
343
- // (a) engine/dispatch.json — defense-in-depth. cleanDispatchEntries in
328
+ // (a) Dispatch state — defense-in-depth. cleanDispatchEntries in
344
329
  // step 2 should have already removed every matching entry; tag any
345
330
  // survivor so the operator can see the residue.
346
331
  try {
@@ -361,11 +346,10 @@ function removeProject(target, options = {}) {
361
346
 
362
347
  // (b) Central PR registry. PRs in this scope lack an explicit project field; match by canonical scope
363
348
  // (e.g. `github:owner/repo`) against the removed project's PR scope.
364
- const centralPrPath = shared.centralPullRequestsPath();
365
349
  try {
366
350
  const projectScope = shared.getProjectPrScope(project);
367
351
  if (projectScope) {
368
- shared.mutatePullRequests(centralPrPath, (prs) => {
352
+ shared.mutatePullRequests('central', (prs) => {
369
353
  for (const pr of prs) {
370
354
  if (!pr || pr._orphaned_at) continue;
371
355
  const canonical = shared.parseCanonicalPrId(pr.id);
@@ -383,9 +367,8 @@ function removeProject(target, options = {}) {
383
367
  // were already CANCELLED by cancelPendingWorkItems in step 1; tag
384
368
  // the remaining matchers (dispatched / done / failed / etc.) so
385
369
  // downstream consumers can filter them.
386
- const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
387
370
  try {
388
- shared.mutateWorkItems(centralWiPath, (items) => {
371
+ shared.mutateWorkItems('central', (items) => {
389
372
  for (const w of items) {
390
373
  if (!w || w._orphaned_at) continue;
391
374
  if (w.status === shared.WI_STATUS.CANCELLED) continue;
@@ -424,40 +407,10 @@ function removeProject(target, options = {}) {
424
407
  summary.archiveError = e.code ? `${e.code}: ${e.message}` : e.message;
425
408
  summary.warnings.push('archive data dir: ' + e.message);
426
409
  }
427
- } else if (dataMode === 'keep') {
428
- // W-mr96oira000g979a — 'keep' leaves projects/<name>/ (and therefore
429
- // work-items.json / pull-requests.json) at its normal, live-resolving
430
- // path. dropScope() below DELETEs the SQL rows for this scope, but the
431
- // *-store.js resync-on-read path (_resyncScopeIfJsonDiverged) treats a
432
- // scope with an empty SQL table + a still-present JSON file as a
433
- // fresh-install first-touch and re-hydrates straight from that JSON —
434
- // silently undoing the drop the very next time anything reads this
435
- // scope. Tombstone the two live-state files into a `.dropped/`
436
- // subdirectory so `_filePathForScope(scope)` (and the read-side
437
- // resync/fallback logic keyed off it) no longer resolves to them,
438
- // while keeping the historical data on disk for manual inspection —
439
- // exactly what 'keep' promises, minus the auto-resurrection.
440
- try {
441
- const droppedDir = path.join(dataDir, '.dropped');
442
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
443
- for (const fname of ['work-items.json', 'pull-requests.json']) {
444
- const src = path.join(dataDir, fname);
445
- if (!fs.existsSync(src)) continue;
446
- fs.mkdirSync(droppedDir, { recursive: true });
447
- let dest = path.join(droppedDir, `${fname}.${stamp}`);
448
- let n = 1;
449
- while (fs.existsSync(dest)) dest = path.join(droppedDir, `${fname}.${stamp}-${++n}`);
450
- _renameWithRetry(src, dest);
451
- summary.tombstonedDataFiles = true;
452
- }
453
- } catch (e) {
454
- summary.warnings.push('tombstone kept data files: ' + e.message);
455
- }
456
410
  }
457
411
  }
458
412
 
459
- // 9. Drop SQL rows for the project's scope so safeJson shim reads no longer
460
- // surface the removed project after the JSON file is archived.
413
+ // 9. Drop SQL rows for the removed project scope.
461
414
  try { require('./work-items-store').dropScope(project.name); } catch { /* best-effort */ }
462
415
  try { require('./pull-requests-store').dropScope(project.name); } catch { /* best-effort */ }
463
416
 
@@ -571,7 +524,7 @@ async function addProject(target, options = {}) {
571
524
  assertProjectLinkOk(project);
572
525
 
573
526
  // 7. Ensure project state files (projects/<name>/...).
574
- shared.ensureProjectStateFiles(project);
527
+ shared.initializeProjectState(project);
575
528
 
576
529
  // 8. Persist under config lock. Re-check inside the lock so a concurrent
577
530
  // add can't insert a duplicate between our pre-check and the write.
@@ -13,21 +13,6 @@ function _toMs(v) {
13
13
  return Number.isFinite(parsed) ? parsed : null;
14
14
  }
15
15
 
16
- function scopeForFilePath(filePath) {
17
- const norm = String(filePath).replace(/\\/g, '/');
18
- const m = norm.match(/projects\/([^/]+)\/pull-requests\.json$/);
19
- if (m) return m[1];
20
- return 'central';
21
- }
22
-
23
- // Compatibility adapter for callers that still pass a path-shaped scope token
24
- // into shared.mutatePullRequests. No file is read or written.
25
- function _filePathForScope(scope) {
26
- const shared = require('./shared');
27
- if (!scope || scope === 'central') return shared.centralPullRequestsPath(shared.MINIONS_DIR);
28
- return require('path').join(shared.MINIONS_DIR, 'projects', scope, 'pull-requests.json');
29
- }
30
-
31
16
  function _parseRow(row) {
32
17
  if (!row || !row.data) return null;
33
18
  try { return JSON.parse(row.data); }
@@ -169,8 +154,6 @@ function dropScope(scope) {
169
154
  }
170
155
 
171
156
  module.exports = {
172
- _filePathForScope,
173
- scopeForFilePath,
174
157
  readPullRequestsForScope,
175
158
  readAllPullRequests,
176
159
  applyPullRequestsMutation,
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Per-project QA runbook persistence + CRUD helpers. Runbooks are test plans
5
5
  * that travel with a project entry, mirroring the
6
- * projects/<name>/pull-requests.json precedent. Each runbook is one JSON file
6
+ * project-local definition precedent. Each runbook is one JSON file
7
7
  * at <MINIONS_DIR>/projects/<project>/runbooks/<id>.json.
8
8
  *
9
9
  * Pure persistence + validation only — this module does NOT spawn agents,
@@ -27,7 +27,7 @@ const _KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
27
27
  // keep this module self-contained for path-traversal hardening (review feedback
28
28
  // on PR #2694: id/project params previously flowed into path.join without
29
29
  // validation, so `..%2F..%2F..%2Fconfig` could read MINIONS_DIR/config.json
30
- // and DELETE could wipe dispatch.json).
30
+ // and DELETE could wipe unrelated runtime state).
31
31
  const _PROJECT_NAME_RE = /^[a-zA-Z0-9_\-]{1,64}$/;
32
32
 
33
33
  const ARTIFACT_TYPES = ['screenshot', 'video', 'log', 'other'];
package/engine/qa-runs.js CHANGED
@@ -33,7 +33,7 @@ const { mutateQaRuns, uid, ts, log } = shared;
33
33
 
34
34
  // Cap qa_runs so the table doesn't grow unboundedly
35
35
  // over months of nightly QA dispatch. Mirrors the 2500-entry cap on
36
- // engine/log.json. createRun trims oldest-by-createdAt when crossing the
36
+ // the engine log. createRun trims oldest-by-createdAt when crossing the
37
37
  // threshold; terminal-status runs that have already shipped completion
38
38
  // notifications are safe to drop.
39
39
  const QA_RUNS_MAX_RECORDS = 2000;
@@ -80,12 +80,6 @@ const ALLOWED_TRANSITIONS = {
80
80
  [QA_RUN_STATUS.ERRORED]: new Set(),
81
81
  };
82
82
 
83
- // Dynamic paths — respect MINIONS_TEST_DIR for test isolation. Kept exported
84
- // for back-compat with tests that probe the JSON sidecar on disk.
85
- function qaRunsPath() {
86
- return path.join(shared.MINIONS_DIR, 'engine', 'qa-runs.json');
87
- }
88
-
89
83
  function qaArtifactsDir() {
90
84
  return path.join(shared.MINIONS_DIR, 'engine', 'qa-artifacts');
91
85
  }
@@ -416,7 +410,6 @@ module.exports = {
416
410
  QA_RUN_STATUS,
417
411
  TERMINAL_STATUSES,
418
412
  ALLOWED_TRANSITIONS,
419
- qaRunsPath,
420
413
  qaArtifactsDir,
421
414
  qaArtifactsDirForRun,
422
415
  createRun,