@yemi33/minions 0.1.526 → 0.1.528

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.
package/CHANGELOG.md CHANGED
@@ -1,9 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.528 (2026-04-07)
4
+
5
+ ### Features
6
+ - Convert engine/ safeWrite calls on work-items.json to mutateWorkItems() (#415)
7
+
8
+ ## 0.1.527 (2026-04-07)
9
+
10
+ ### Features
11
+ - Replace direct safeJson reads of work-items.json with getWorkItems() (#424)
12
+
3
13
  ## 0.1.526 (2026-04-07)
4
14
 
5
15
  ### Features
6
- - Replace new Date().toISOString() with ts() from shared.js (#411)
7
16
  - Add Concurrency & Lock Ordering section to CLAUDE.md (#409)
8
17
  - 9 test isolation verification tests — run last to detect pollution
9
18
 
@@ -175,8 +175,8 @@ async function submitWorkItemEdit(id, source, e) {
175
175
  const refsRaw = document.getElementById('wi-edit-refs')?.value || '';
176
176
  const references = refsRaw.split('\n').filter(function(l) { return l.trim(); }).map(function(l) {
177
177
  var parts = l.split('|').map(function(s) { return s.trim(); });
178
- return { url: parts[0], title: parts[1] || parts[0], type: parts[2] || 'link' };
179
- });
178
+ return { url: parts[0] || '', title: parts[1] || parts[0] || '', type: parts[2] || 'link' };
179
+ }).filter(function(r) { return r.url; });
180
180
  const acRaw = document.getElementById('wi-edit-ac')?.value || '';
181
181
  const acceptanceCriteria = acRaw.split('\n').filter(function(l) { return l.trim(); });
182
182
  if (!title) { if (btn) { btn.disabled = false; btn.textContent = 'Save'; } alert('Title is required'); return; }
@@ -387,8 +387,8 @@ async function _submitCreateWorkItem(e) {
387
387
  const refsRaw = document.getElementById('wi-new-refs')?.value || '';
388
388
  const references = refsRaw.split('\n').filter(l => l.trim()).map(l => {
389
389
  const parts = l.split('|').map(s => s.trim());
390
- return { url: parts[0], title: parts[1] || parts[0], type: parts[2] || 'link' };
391
- });
390
+ return { url: parts[0] || '', title: parts[1] || parts[0] || '', type: parts[2] || 'link' };
391
+ }).filter(r => r.url);
392
392
 
393
393
  try {
394
394
  const body = { title, description: desc, type, priority };
package/dashboard.js CHANGED
@@ -192,8 +192,8 @@ async function checkNpmVersion() {
192
192
  _npmVersionCache = { latest: version || null, checkedAt: new Date().toISOString() };
193
193
  _npmVersionCacheTs = now;
194
194
  } catch (e) {
195
- console.error('[version-check] npm view failed:', e.message?.split('\n')[0]);
196
- _npmVersionCache = _npmVersionCache || { latest: null, checkedAt: null, error: e.message?.split('\n')[0] || 'check failed' };
195
+ console.error('[version-check] npm view failed:', e.message?.split('\n')?.[0]);
196
+ _npmVersionCache = _npmVersionCache || { latest: null, checkedAt: null, error: e.message?.split('\n')?.[0] || 'check failed' };
197
197
  }
198
198
  return _npmVersionCache;
199
199
  }
@@ -3816,7 +3816,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3816
3816
  // Determine project
3817
3817
  reloadConfig();
3818
3818
  const projects = shared.getProjects(CONFIG);
3819
- const targetProject = projectName ? projects.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) : projects[0];
3819
+ const targetProject = projectName ? projects.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) : (projects[0] || null);
3820
3820
  const prPath = targetProject ? shared.projectPrPath(targetProject) : path.join(MINIONS_DIR, 'pull-requests.json');
3821
3821
 
3822
3822
  // Extract PR number from URL
package/engine/cleanup.js CHANGED
@@ -9,7 +9,7 @@ const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
11
  const { exec, execSilent, log, ts } = shared;
12
- const { safeJson, safeWrite, safeReadDir, getProjects, projectWorkItemsPath, projectPrPath,
12
+ const { safeJson, safeWrite, safeReadDir, mutateWorkItems, getProjects, projectWorkItemsPath, projectPrPath,
13
13
  sanitizeBranch, KB_CATEGORIES } = shared;
14
14
  const { getDispatch, getAgentStatus } = queries;
15
15
 
@@ -308,15 +308,9 @@ function runCleanup(config, verbose = false) {
308
308
  // Collect all work item IDs across all sources
309
309
  const allWiIds = new Set();
310
310
  try {
311
- const central = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
312
- central.forEach(w => allWiIds.add(w.id));
313
- } catch (e) { log('warn', 'read central work items for orphan check: ' + e.message); }
314
- for (const project of projects) {
315
- try {
316
- const projItems = safeJson(projectWorkItemsPath(project)) || [];
317
- projItems.forEach(w => allWiIds.add(w.id));
318
- } catch (e) { log('warn', 'read project work items for orphan check: ' + e.message); }
319
- }
311
+ const allItems = queries.getWorkItems();
312
+ allItems.forEach(w => allWiIds.add(w.id));
313
+ } catch (e) { log('warn', 'read work items for orphan check: ' + e.message); }
320
314
 
321
315
  let changed = false;
322
316
  for (const queue of ['pending', 'active']) {
@@ -460,17 +454,17 @@ function runCleanup(config, verbose = false) {
460
454
  for (const project of projects) {
461
455
  try {
462
456
  const wiPath = projectWorkItemsPath(project);
463
- const items = safeJson(wiPath) || [];
464
457
  let migrated = 0;
465
- for (const item of items) {
466
- if (LEGACY_DONE_ALIASES.has(item.status)) {
467
- item.status = shared.WI_STATUS.DONE;
468
- delete item._pendingReason;
469
- migrated++;
458
+ mutateWorkItems(wiPath, items => {
459
+ for (const item of items) {
460
+ if (LEGACY_DONE_ALIASES.has(item.status)) {
461
+ item.status = shared.WI_STATUS.DONE;
462
+ delete item._pendingReason;
463
+ migrated++;
464
+ }
470
465
  }
471
- }
466
+ });
472
467
  if (migrated > 0) {
473
- safeWrite(wiPath, items);
474
468
  log('info', `Migrated ${migrated} legacy status(es) → done in ${project.name} work items`);
475
469
  }
476
470
  } catch (e) { log('warn', 'migrate legacy statuses: ' + e.message); }
@@ -478,17 +472,17 @@ function runCleanup(config, verbose = false) {
478
472
  // Central work items
479
473
  try {
480
474
  const centralPath = path.join(MINIONS_DIR, 'work-items.json');
481
- const centralItems = safeJson(centralPath) || [];
482
475
  let migrated = 0;
483
- for (const item of centralItems) {
484
- if (LEGACY_DONE_ALIASES.has(item.status)) {
485
- item.status = shared.WI_STATUS.DONE;
486
- delete item._pendingReason;
487
- migrated++;
476
+ mutateWorkItems(centralPath, items => {
477
+ for (const item of items) {
478
+ if (LEGACY_DONE_ALIASES.has(item.status)) {
479
+ item.status = shared.WI_STATUS.DONE;
480
+ delete item._pendingReason;
481
+ migrated++;
482
+ }
488
483
  }
489
- }
484
+ });
490
485
  if (migrated > 0) {
491
- safeWrite(centralPath, centralItems);
492
486
  log('info', `Migrated ${migrated} legacy status(es) → done in central work items`);
493
487
  }
494
488
  } catch (e) { log('warn', 'migrate central legacy statuses: ' + e.message); }
package/engine/cli.js CHANGED
@@ -6,7 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const shared = require('./shared');
9
- const { safeRead, safeJson, safeWrite, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
9
+ const { safeRead, safeJson, safeWrite, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
10
10
  const queries = require('./queries');
11
11
  const { getConfig, getControl, getDispatch, getAgentStatus,
12
12
  MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH, DISPATCH_PATH } = queries;
@@ -165,18 +165,18 @@ const commands = {
165
165
  if (sj?.sessionId) sessionId = sj.sessionId;
166
166
  } catch {}
167
167
  e.activeProcesses.set(item.id, { proc: { pid: agentPid > 0 ? agentPid : null }, agentId, startedAt: item.created_at, reattached: true, sessionId });
168
- // Sync work item status to dispatched — direct file write to avoid lifecycle lazy init issues
168
+ // Sync work item status to dispatched — atomic write to avoid lifecycle lazy init issues
169
169
  if (item.meta?.item?.id && item.meta?.project?.localPath) {
170
170
  try {
171
171
  const wiPath = path.join(MINIONS_DIR, "projects", item.meta.project.name, "work-items.json");
172
- const wiItems = safeJson(wiPath) || [];
173
- const wi = wiItems.find(w => w.id === item.meta.item.id);
174
- if (wi && wi.status !== WI_STATUS.DISPATCHED) {
175
- wi.status = WI_STATUS.DISPATCHED;
176
- wi.dispatched_to = wi.dispatched_to || agentId;
177
- wi.dispatched_at = wi.dispatched_at || ts();
178
- safeWrite(wiPath, wiItems);
179
- }
172
+ mutateWorkItems(wiPath, items => {
173
+ const wi = items.find(w => w.id === item.meta.item.id);
174
+ if (wi && wi.status !== WI_STATUS.DISPATCHED) {
175
+ wi.status = WI_STATUS.DISPATCHED;
176
+ wi.dispatched_to = wi.dispatched_to || agentId;
177
+ wi.dispatched_at = wi.dispatched_at || ts();
178
+ }
179
+ });
180
180
  } catch (err) { console.log(` Warning: failed to sync work item status: ${err.message}`); }
181
181
  }
182
182
  reattached++;
@@ -264,19 +264,19 @@ const commands = {
264
264
  try {
265
265
  lifecycle.updateWorkItemStatus(item.meta, status, isSuccess ? '' : 'Completed while engine was down');
266
266
  } catch {
267
- // Direct file write fallback
267
+ // Atomic file write fallback
268
268
  try {
269
269
  const projName = item.meta.project?.name;
270
270
  if (projName) {
271
271
  const wiPath = path.join(MINIONS_DIR, 'projects', projName, 'work-items.json');
272
- const items = safeJson(wiPath) || [];
273
- const wi = items.find(w => w.id === item.meta.item.id);
274
- if (wi) {
275
- wi.status = status;
276
- if (isSuccess) { wi.completedAt = ts(); delete wi.failReason; }
277
- else { wi.failedAt = ts(); wi.failReason = 'Completed while engine was down'; }
278
- safeWrite(wiPath, items);
279
- }
272
+ mutateWorkItems(wiPath, items => {
273
+ const wi = items.find(w => w.id === item.meta.item.id);
274
+ if (wi) {
275
+ wi.status = status;
276
+ if (isSuccess) { wi.completedAt = ts(); delete wi.failReason; }
277
+ else { wi.failedAt = ts(); wi.failReason = 'Completed while engine was down'; }
278
+ }
279
+ });
280
280
  }
281
281
  } catch (err) { e.log('warn', `Orphan WI fallback: ${err.message}`); }
282
282
  }
@@ -321,19 +321,17 @@ const commands = {
321
321
  }
322
322
  for (const wiPath of allWiPaths) {
323
323
  try {
324
- const items = safeJson(wiPath) || [];
325
- let changed = false;
326
- for (const item of items) {
327
- if (item.status === WI_STATUS.DISPATCHED && !activeIds.has(item.id)) {
328
- item.status = WI_STATUS.PENDING;
329
- delete item.dispatched_at;
330
- delete item.dispatched_to;
331
- changed = true;
332
- fixes++;
333
- e.log('info', `Recovery: reset stuck item ${item.id} from dispatched → pending`);
324
+ mutateWorkItems(wiPath, items => {
325
+ for (const item of items) {
326
+ if (item.status === WI_STATUS.DISPATCHED && !activeIds.has(item.id)) {
327
+ item.status = WI_STATUS.PENDING;
328
+ delete item.dispatched_at;
329
+ delete item.dispatched_to;
330
+ fixes++;
331
+ e.log('info', `Recovery: reset stuck item ${item.id} from dispatched → pending`);
332
+ }
334
333
  }
335
- }
336
- if (changed) safeWrite(wiPath, items);
334
+ });
337
335
  } catch (err) { e.log('warn', `Recovery WI reset: ${err.message}`); }
338
336
  }
339
337
 
@@ -691,24 +689,23 @@ const commands = {
691
689
  ? projects.find(p => p.name?.toLowerCase() === opts.project?.toLowerCase()) || projects[0]
692
690
  : projects[0];
693
691
  const wiPath = projectWorkItemsPath(targetProject);
694
- const items = safeJson(wiPath) || [];
695
-
696
- const item = {
697
- id: `W${String(items.length + 1).padStart(3, '0')}`,
698
- title: title,
699
- type: opts.type || 'implement',
700
- status: WI_STATUS.QUEUED,
701
- priority: opts.priority || 'medium',
702
- complexity: opts.complexity || 'medium',
703
- description: opts.description || title,
704
- agent: opts.agent || null,
705
- branch: opts.branch || null,
706
- prompt: opts.prompt || null,
707
- created_at: e.ts()
708
- };
709
-
710
- items.push(item);
711
- safeWrite(wiPath, items);
692
+ let item;
693
+ mutateWorkItems(wiPath, items => {
694
+ item = {
695
+ id: `W${String(items.length + 1).padStart(3, '0')}`,
696
+ title: title,
697
+ type: opts.type || 'implement',
698
+ status: WI_STATUS.QUEUED,
699
+ priority: opts.priority || 'medium',
700
+ complexity: opts.complexity || 'medium',
701
+ description: opts.description || title,
702
+ agent: opts.agent || null,
703
+ branch: opts.branch || null,
704
+ prompt: opts.prompt || null,
705
+ created_at: e.ts()
706
+ };
707
+ items.push(item);
708
+ });
712
709
 
713
710
  console.log(`Queued work item: ${item.id} — ${item.title} (project: ${targetProject.name || 'default'})`);
714
711
  console.log(` Type: ${item.type} | Priority: ${item.priority} | Agent: ${item.agent || 'auto'}`);
@@ -903,16 +900,16 @@ const commands = {
903
900
  ? shared.projectWorkItemsPath({ localPath: item.meta.project.localPath, name: item.meta.project.name, workSources: config.projects?.find(p => p.name === item.meta.project.name)?.workSources })
904
901
  : null;
905
902
  if (wiPath) {
906
- const items = safeJson(wiPath) || [];
907
- const target = items.find(i => i.id === itemId);
908
- if (target) {
909
- target.status = WI_STATUS.PENDING;
910
- delete target.dispatched_at;
911
- delete target.dispatched_to;
912
- delete target.failReason;
913
- delete target.failedAt;
914
- safeWrite(wiPath, items);
915
- }
903
+ mutateWorkItems(wiPath, items => {
904
+ const target = items.find(i => i.id === itemId);
905
+ if (target) {
906
+ target.status = WI_STATUS.PENDING;
907
+ delete target.dispatched_at;
908
+ delete target.dispatched_to;
909
+ delete target.failReason;
910
+ delete target.failedAt;
911
+ }
912
+ });
916
913
  }
917
914
  }
918
915
  }
@@ -9,7 +9,7 @@ const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
  const { setCooldownFailure } = require('./cooldown');
11
11
 
12
- const { safeJson, safeWrite, safeReadDir, mutateJsonFileLocked,
12
+ const { safeJson, safeWrite, safeReadDir, mutateJsonFileLocked, mutateWorkItems,
13
13
  getProjects, projectWorkItemsPath, log, ts, dateStamp,
14
14
  WI_STATUS, DISPATCH_RESULT, ENGINE_DEFAULTS } = shared;
15
15
  const { getConfig, getDispatch, DISPATCH_PATH, INBOX_DIR } = queries;
@@ -126,14 +126,8 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
126
126
  if (processWorkItemFailure && result === DISPATCH_RESULT.ERROR && item.meta?.item?.id) {
127
127
  let retries = (item.meta.item._retryCount || 0);
128
128
  try {
129
- const wiPath = lifecycle().resolveWorkItemPath(item.meta);
130
- if (wiPath) {
131
- const items = safeJson(wiPath);
132
- if (items && Array.isArray(items)) {
133
- const wi = items.find(i => i.id === item.meta.item.id);
134
- if (wi) retries = wi._retryCount || 0;
135
- }
136
- }
129
+ const wi = queries.getWorkItems().find(i => i.id === item.meta.item.id);
130
+ if (wi) retries = wi._retryCount || 0;
137
131
  } catch (e) { log('warn', 'read retry count: ' + e.message); }
138
132
  const maxRetries = ENGINE_DEFAULTS.maxRetries;
139
133
  if (retryableFailure && retries < maxRetries) {
@@ -152,20 +146,19 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
152
146
  try {
153
147
  const wiPath = lifecycle().resolveWorkItemPath(item.meta);
154
148
  if (wiPath) {
155
- const items = safeJson(wiPath);
156
- if (!items || !Array.isArray(items)) throw new Error('work items unreadable');
157
- const wi = items.find(i => i.id === item.meta.item.id);
158
- if (wi && wi.status !== WI_STATUS.PAUSED && wi.status !== WI_STATUS.DONE && !wi.completedAt) {
159
- wi._retryCount = retries + 1;
160
- wi.status = WI_STATUS.PENDING;
161
- wi._lastRetryReason = reason || '';
162
- wi._lastRetryAt = ts();
163
- delete wi.failReason;
164
- delete wi.failedAt;
165
- delete wi.dispatched_at;
166
- delete wi.dispatched_to;
167
- safeWrite(wiPath, items);
168
- }
149
+ mutateWorkItems(wiPath, items => {
150
+ const wi = items.find(i => i.id === item.meta.item.id);
151
+ if (wi && wi.status !== WI_STATUS.PAUSED && wi.status !== WI_STATUS.DONE && !wi.completedAt) {
152
+ wi._retryCount = retries + 1;
153
+ wi.status = WI_STATUS.PENDING;
154
+ wi._lastRetryReason = reason || '';
155
+ wi._lastRetryAt = ts();
156
+ delete wi.failReason;
157
+ delete wi.failedAt;
158
+ delete wi.dispatched_at;
159
+ delete wi.dispatched_to;
160
+ }
161
+ });
169
162
  }
170
163
  } catch (e) { log('warn', 'increment retry counter: ' + e.message); }
171
164
  } else {
@@ -178,13 +171,8 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
178
171
  const config = getConfig();
179
172
  const failedId = item.meta.item.id;
180
173
  const blockedItems = [];
181
- for (const p of getProjects(config)) {
182
- const items = safeJson(projectWorkItemsPath(p)) || [];
183
- items.filter(w => w.status === WI_STATUS.PENDING && (w.depends_on || []).includes(failedId))
184
- .forEach(w => blockedItems.push(`- \`${w.id}\` — ${w.title}`));
185
- }
186
- const centralItems = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
187
- centralItems.filter(w => w.status === WI_STATUS.PENDING && (w.depends_on || []).includes(failedId))
174
+ const allItems = queries.getWorkItems(config);
175
+ allItems.filter(w => w.status === WI_STATUS.PENDING && (w.depends_on || []).includes(failedId))
188
176
  .forEach(w => blockedItems.push(`- \`${w.id}\` — ${w.title}`));
189
177
 
190
178
  writeInboxAlert(`failed-${failedId}`,
@@ -7,7 +7,7 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
9
  const shared = require('./shared');
10
- const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, execSilent, projectPrPath, getPrLinks, addPrLink,
10
+ const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, mutateWorkItems, execSilent, projectPrPath, getPrLinks, addPrLink,
11
11
  log, ts, dateStamp, WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT,
12
12
  ENGINE_DEFAULTS } = shared;
13
13
  const { trackEngineUsage } = require('./llm');
@@ -30,20 +30,7 @@ function checkPlanCompletion(meta, config) {
30
30
  const projects = shared.getProjects(config);
31
31
 
32
32
  // Collect work items from ALL projects + central (PRD items can be in either)
33
- let allWorkItems = [];
34
- for (const p of projects) {
35
- try {
36
- const wi = safeJson(shared.projectWorkItemsPath(p)) || [];
37
- allWorkItems = allWorkItems.concat(wi);
38
- } catch { /* optional */ }
39
- }
40
- // Also check central work-items.json (for no-project setups)
41
- try {
42
- const central = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
43
- for (const w of central) {
44
- if (!allWorkItems.some(existing => existing.id === w.id)) allWorkItems.push(w);
45
- }
46
- } catch { /* optional */ }
33
+ const allWorkItems = queries.getWorkItems(config);
47
34
  const planItems = allWorkItems.filter(w => w.sourcePlan === planFile && w.itemType !== 'pr' && w.itemType !== 'verify');
48
35
  if (planItems.length === 0) return;
49
36
 
@@ -158,7 +145,6 @@ function checkPlanCompletion(meta, config) {
158
145
  return;
159
146
  }
160
147
  const wiPath = shared.projectWorkItemsPath(primaryProject);
161
- const workItems = safeJson(wiPath) || [];
162
148
 
163
149
  // 3. For shared-branch plans, create PR work item
164
150
  if (plan.branch_strategy === 'shared-branch' && plan.feature_branch && wiPath) {
@@ -168,15 +154,16 @@ function checkPlanCompletion(meta, config) {
168
154
  const featureBranch = plan.feature_branch;
169
155
  const mainBranch = shared.resolveMainBranch(primaryProject.localPath, primaryProject.mainBranch);
170
156
  const itemSummary = doneItems.map(w => '- ' + w.id + ': ' + w.title.replace('Implement: ', '')).join('\n');
171
- workItems.push({
172
- id, title: `Create PR for plan: ${plan.plan_summary || planFile}`,
173
- type: 'implement', priority: 'high',
174
- description: `All plan items from \`${planFile}\` are complete on branch \`${featureBranch}\`.\n\n**Branch:** \`${featureBranch}\`\n**Target:** \`${mainBranch}\`\n\n## Completed Items\n${itemSummary}`,
175
- status: WI_STATUS.PENDING, created: ts(), createdBy: 'engine:plan-completion',
176
- sourcePlan: planFile, itemType: 'pr',
177
- branch: featureBranch, branchStrategy: 'shared-branch', project: projectName,
157
+ mutateWorkItems(wiPath, workItems => {
158
+ workItems.push({
159
+ id, title: `Create PR for plan: ${plan.plan_summary || planFile}`,
160
+ type: 'implement', priority: 'high',
161
+ description: `All plan items from \`${planFile}\` are complete on branch \`${featureBranch}\`.\n\n**Branch:** \`${featureBranch}\`\n**Target:** \`${mainBranch}\`\n\n## Completed Items\n${itemSummary}`,
162
+ status: WI_STATUS.PENDING, created: ts(), createdBy: 'engine:plan-completion',
163
+ sourcePlan: planFile, itemType: 'pr',
164
+ branch: featureBranch, branchStrategy: 'shared-branch', project: projectName,
165
+ });
178
166
  });
179
- shared.safeWrite(wiPath, workItems);
180
167
  }
181
168
  }
182
169
 
@@ -257,20 +244,21 @@ function checkPlanCompletion(meta, config) {
257
244
  prSummary,
258
245
  ].join('\n');
259
246
 
260
- workItems.push({
261
- id: verifyId,
262
- title: `Verify plan: ${(plan.plan_summary || planFile).slice(0, 80)}`,
263
- type: 'verify',
264
- priority: 'high',
265
- description,
266
- status: WI_STATUS.PENDING,
267
- created: ts(),
268
- createdBy: 'engine:plan-verification',
269
- sourcePlan: planFile,
270
- itemType: 'verify',
271
- project: projectName,
247
+ mutateWorkItems(wiPath, workItems => {
248
+ workItems.push({
249
+ id: verifyId,
250
+ title: `Verify plan: ${(plan.plan_summary || planFile).slice(0, 80)}`,
251
+ type: 'verify',
252
+ priority: 'high',
253
+ description,
254
+ status: WI_STATUS.PENDING,
255
+ created: ts(),
256
+ createdBy: 'engine:plan-verification',
257
+ sourcePlan: planFile,
258
+ itemType: 'verify',
259
+ project: projectName,
260
+ });
272
261
  });
273
- shared.safeWrite(wiPath, workItems);
274
262
  log('info', `Created verification work item ${verifyId} for plan ${planFile}`);
275
263
  }
276
264
 
@@ -330,19 +318,7 @@ function archivePlan(planFile, plan, projects, config) {
330
318
  if (plan.feature_branch) branchSlugs.add(shared.sanitizeBranch(plan.feature_branch).toLowerCase());
331
319
 
332
320
  // Collect work items for this plan
333
- let allWorkItems = [];
334
- for (const p of projects) {
335
- try {
336
- const wi = safeJson(shared.projectWorkItemsPath(p)) || [];
337
- allWorkItems = allWorkItems.concat(wi);
338
- } catch { /* optional */ }
339
- }
340
- try {
341
- const central = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
342
- for (const w of central) {
343
- if (!allWorkItems.some(existing => existing.id === w.id)) allWorkItems.push(w);
344
- }
345
- } catch { /* optional */ }
321
+ const allWorkItems = queries.getWorkItems(config);
346
322
  const planItems = allWorkItems.filter(w => w.sourcePlan === planFile);
347
323
  const doneItems = planItems.filter(w => DONE_STATUSES.has(w.status));
348
324
 
@@ -836,17 +812,18 @@ async function handlePostMerge(pr, project, config, newStatus) {
836
812
  for (const p of shared.getProjects(config)) wiPaths.push(shared.projectWorkItemsPath(p));
837
813
  for (const wiPath of wiPaths) {
838
814
  try {
839
- const items = safeJson(wiPath);
840
- if (!items) continue;
841
- const item = items.find(i => i.id === mergedItemId);
842
- if (item && item.status !== WI_STATUS.DONE) {
843
- log('info', `Post-merge: marking work item ${mergedItemId} as done (was ${item.status}) for ${pr.id}`);
844
- item.status = WI_STATUS.DONE;
845
- item.completedAt = ts();
846
- item._mergedVia = pr.id;
847
- shared.safeWrite(wiPath, items);
848
- break;
849
- }
815
+ let found = false;
816
+ mutateWorkItems(wiPath, items => {
817
+ const item = items.find(i => i.id === mergedItemId);
818
+ if (item && item.status !== WI_STATUS.DONE) {
819
+ log('info', `Post-merge: marking work item ${mergedItemId} as done (was ${item.status}) for ${pr.id}`);
820
+ item.status = WI_STATUS.DONE;
821
+ item.completedAt = ts();
822
+ item._mergedVia = pr.id;
823
+ found = true;
824
+ }
825
+ });
826
+ if (found) break;
850
827
  } catch (err) { log('warn', `Post-merge work item update: ${err.message}`); }
851
828
  }
852
829
  }
@@ -7,7 +7,7 @@
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
9
  const shared = require('./shared');
10
- const { safeJson, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, ENGINE_DEFAULTS } = shared;
10
+ const { safeJson, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, ENGINE_DEFAULTS } = shared;
11
11
  const http = require('http');
12
12
  const { parseCronExpr, shouldRunNow } = require('./scheduler');
13
13
 
@@ -159,32 +159,32 @@ function executeTaskStage(stage, stageState, run, config) {
159
159
  const items = stage.items || [{ title: stage.title, description: stage.description || '', type: stage.taskType || 'explore', agent: stage.agent }];
160
160
  const count = stage.count || items.length;
161
161
  const wiPath = path.join(__dirname, '..', 'work-items.json');
162
- const workItems = safeJson(wiPath) || [];
163
162
  const createdIds = [];
164
163
 
165
- for (let i = 0; i < count; i++) {
166
- const item = items[i % items.length];
167
- const id = `PL-${run.runId.slice(4, 12)}-${stage.id}-${i}`;
168
- if (workItems.some(w => w.id === id)) { createdIds.push(id); continue; }
169
- workItems.push({
170
- id,
171
- title: item.title || stage.title,
172
- description: item.description || stage.description || '',
173
- type: item.type || stage.taskType || 'explore',
174
- priority: item.priority || stage.priority || 'medium',
175
- // Only set agent if explicitly specified — otherwise engine routing assigns any available agent
176
- ...(item.agent || stage.agent ? { agent: item.agent || stage.agent } : {}),
177
- status: WI_STATUS.PENDING,
178
- created: ts(),
179
- createdBy: 'pipeline:' + run.pipelineId,
180
- branch: `pipeline/${run.pipelineId}/${stage.id}`,
181
- _pipelineRun: run.runId,
182
- _pipelineStage: stage.id,
183
- });
184
- createdIds.push(id);
185
- }
164
+ mutateWorkItems(wiPath, workItems => {
165
+ for (let i = 0; i < count; i++) {
166
+ const item = items[i % items.length];
167
+ const id = `PL-${run.runId.slice(4, 12)}-${stage.id}-${i}`;
168
+ if (workItems.some(w => w.id === id)) { createdIds.push(id); continue; }
169
+ workItems.push({
170
+ id,
171
+ title: item.title || stage.title,
172
+ description: item.description || stage.description || '',
173
+ type: item.type || stage.taskType || 'explore',
174
+ priority: item.priority || stage.priority || 'medium',
175
+ // Only set agent if explicitly specified otherwise engine routing assigns any available agent
176
+ ...(item.agent || stage.agent ? { agent: item.agent || stage.agent } : {}),
177
+ status: WI_STATUS.PENDING,
178
+ created: ts(),
179
+ createdBy: 'pipeline:' + run.pipelineId,
180
+ branch: `pipeline/${run.pipelineId}/${stage.id}`,
181
+ _pipelineRun: run.runId,
182
+ _pipelineStage: stage.id,
183
+ });
184
+ createdIds.push(id);
185
+ }
186
+ });
186
187
 
187
- safeWrite(wiPath, workItems);
188
188
  return { status: PIPELINE_STATUS.RUNNING, artifacts: { workItems: createdIds } };
189
189
  }
190
190
 
@@ -280,24 +280,24 @@ async function executePlanStage(stage, stageState, run, config) {
280
280
 
281
281
  // Create plan-to-prd work item
282
282
  const wiPath = path.join(__dirname, '..', 'work-items.json');
283
- const workItems = safeJson(wiPath) || [];
284
283
  const wiId = `PL-${run.runId.slice(4, 12)}-${stage.id}-prd`;
285
- if (!workItems.some(w => w.id === wiId)) {
286
- workItems.push({
287
- id: wiId,
288
- title: `Convert plan to PRD: ${path.basename(filePath)}`,
289
- type: WORK_TYPE.PLAN_TO_PRD,
290
- priority: 'high',
291
- status: WI_STATUS.PENDING,
292
- planFile: path.basename(filePath),
293
- created: ts(),
294
- createdBy: 'pipeline:' + run.pipelineId,
295
- branch: `pipeline/${run.pipelineId}/${stage.id}`,
296
- _pipelineRun: run.runId,
297
- _pipelineStage: stage.id,
298
- });
299
- safeWrite(wiPath, workItems);
300
- }
284
+ mutateWorkItems(wiPath, workItems => {
285
+ if (!workItems.some(w => w.id === wiId)) {
286
+ workItems.push({
287
+ id: wiId,
288
+ title: `Convert plan to PRD: ${path.basename(filePath)}`,
289
+ type: WORK_TYPE.PLAN_TO_PRD,
290
+ priority: 'high',
291
+ status: WI_STATUS.PENDING,
292
+ planFile: path.basename(filePath),
293
+ created: ts(),
294
+ createdBy: 'pipeline:' + run.pipelineId,
295
+ branch: `pipeline/${run.pipelineId}/${stage.id}`,
296
+ _pipelineRun: run.runId,
297
+ _pipelineStage: stage.id,
298
+ });
299
+ }
300
+ });
301
301
 
302
302
  return {
303
303
  status: PIPELINE_STATUS.RUNNING,
@@ -133,7 +133,7 @@ function resolveTaskContext(item, config) {
133
133
  try {
134
134
  const plans = fs.readdirSync(path.join(MINIONS_DIR, 'plans')).filter(f => f.endsWith('.md') || f.endsWith('.json'));
135
135
  // Check work-items to find which plan file this agent created
136
- const workItems = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
136
+ const workItems = queries.getWorkItems();
137
137
  const agentPlanItems = workItems.filter(w =>
138
138
  w.type === WORK_TYPE.PLAN && w.dispatched_to === agent.id && w.status === WI_STATUS.DONE && w._planFileName
139
139
  ).sort((a, b) => (b.completedAt || '').localeCompare(a.completedAt || ''));
package/engine/shared.js CHANGED
@@ -738,6 +738,34 @@ function killImmediate(proc) {
738
738
  }
739
739
  }
740
740
 
741
+ // ─── Work Items & Pull Requests Mutation Helpers ────────────────────────────
742
+
743
+ /**
744
+ * Atomic read-modify-write for work-items JSON files.
745
+ * Wraps mutateJsonFileLocked with defaultValue of [].
746
+ * @param {string} filePath - Path to the work-items JSON file
747
+ * @param {Function} mutator - Receives the array, mutates in place or returns new value
748
+ */
749
+ function mutateWorkItems(filePath, mutator) {
750
+ return mutateJsonFileLocked(filePath, (data) => {
751
+ if (!Array.isArray(data)) data = [];
752
+ return mutator(data) || data;
753
+ }, { defaultValue: [] });
754
+ }
755
+
756
+ /**
757
+ * Atomic read-modify-write for pull-requests JSON files.
758
+ * Wraps mutateJsonFileLocked with defaultValue of [].
759
+ * @param {string} filePath - Path to the pull-requests JSON file
760
+ * @param {Function} mutator - Receives the array, mutates in place or returns new value
761
+ */
762
+ function mutatePullRequests(filePath, mutator) {
763
+ return mutateJsonFileLocked(filePath, (data) => {
764
+ if (!Array.isArray(data)) data = [];
765
+ return mutator(data) || data;
766
+ }, { defaultValue: [] });
767
+ }
768
+
741
769
  module.exports = {
742
770
  MINIONS_DIR,
743
771
  PR_LINKS_PATH,
@@ -753,6 +781,8 @@ module.exports = {
753
781
  safeUnlink,
754
782
  withFileLock,
755
783
  mutateJsonFileLocked,
784
+ mutateWorkItems,
785
+ mutatePullRequests,
756
786
  uid,
757
787
  uniquePath,
758
788
  writeToInbox,
package/engine.js CHANGED
@@ -149,16 +149,8 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
149
149
  const projects = shared.getProjects(config);
150
150
 
151
151
  // Find work items for each dependency plan item
152
- const depWorkItems = [];
153
- for (const p of projects) {
154
- const wiPath = shared.projectWorkItemsPath(p);
155
- const items = safeJson(wiPath) || [];
156
- for (const wi of items) {
157
- if (depIds.includes(wi.id)) {
158
- depWorkItems.push(wi);
159
- }
160
- }
161
- }
152
+ const allItems = queries.getWorkItems(config);
153
+ const depWorkItems = allItems.filter(wi => depIds.includes(wi.id));
162
154
 
163
155
  // Find PR branches for each dependency work item
164
156
  for (const p of projects) {
@@ -837,13 +829,7 @@ function areDependenciesMet(item, config) {
837
829
  const projects = getProjects(config);
838
830
 
839
831
  // Collect work items from ALL projects (dependencies can be cross-project)
840
- let allWorkItems = [];
841
- for (const p of projects) {
842
- try {
843
- const wi = safeJson(projectWorkItemsPath(p)) || [];
844
- allWorkItems = allWorkItems.concat(wi);
845
- } catch (e) { log('warn', 'read project work items for deps: ' + e.message); }
846
- }
832
+ const allWorkItems = queries.getWorkItems(config);
847
833
  // PRD item statuses that count as "done" for dep resolution
848
834
  const PRD_MET_STATUSES = DONE_STATUSES;
849
835
 
@@ -1195,13 +1181,7 @@ function materializePlansAsWorkItems(config) {
1195
1181
  const statusFilter = ['missing', 'planned'];
1196
1182
  // Also materialize in-pr/done items that never got a work item (race with PR status sync)
1197
1183
  const allExistingWiIds = new Set();
1198
- for (const p of allProjects) {
1199
- for (const w of (safeJson(projectWorkItemsPath(p)) || [])) {
1200
- if (w.id) allExistingWiIds.add(w.id);
1201
- }
1202
- }
1203
- // Also check central work-items.json
1204
- for (const w of (safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [])) {
1184
+ for (const w of queries.getWorkItems()) {
1205
1185
  if (w.id) allExistingWiIds.add(w.id);
1206
1186
  }
1207
1187
  const items = plan.missing_features.filter(f =>
@@ -2307,10 +2287,9 @@ function discoverWork(config) {
2307
2287
  }
2308
2288
 
2309
2289
  // Gate reviews and fixes: do not dispatch until all implement items are complete
2310
- const hasIncompleteImplements = projects.some(project => {
2311
- const items = safeJson(projectWorkItemsPath(project)) || [];
2312
- return items.some(i => ['queued', 'pending', 'dispatched'].includes(i.status) && (i.type || '').startsWith('implement'));
2313
- });
2290
+ const hasIncompleteImplements = queries.getWorkItems(config).some(i =>
2291
+ ['queued', 'pending', 'dispatched'].includes(i.status) && (i.type || '').startsWith('implement')
2292
+ );
2314
2293
  if (hasIncompleteImplements) {
2315
2294
  if (allReviews.length > 0) {
2316
2295
  log('info', `Gating ${allReviews.length} reviews — implement items still in progress`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.526",
3
+ "version": "0.1.528",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"