@yemi33/minions 0.1.525 → 0.1.527

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,8 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.525 (2026-04-07)
3
+ ## 0.1.527 (2026-04-07)
4
4
 
5
5
  ### Features
6
+ - Replace direct safeJson reads of work-items.json with getWorkItems() (#424)
7
+
8
+ ## 0.1.526 (2026-04-07)
9
+
10
+ ### Features
11
+ - Add Concurrency & Lock Ordering section to CLAUDE.md (#409)
6
12
  - 9 test isolation verification tests — run last to detect pollution
7
13
 
8
14
  ### Fixes
@@ -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/ado.js CHANGED
@@ -173,7 +173,7 @@ async function pollPrStatus(config) {
173
173
  const headCommit = prData.lastMergeSourceCommit?.commitId || prData.sourceRefName || '';
174
174
  if (headCommit && pr._adoHeadCommit !== headCommit) {
175
175
  if (pr._adoHeadCommit) { // skip first detection — only track changes
176
- pr.lastPushedAt = new Date().toISOString();
176
+ pr.lastPushedAt = ts();
177
177
  }
178
178
  pr._adoHeadCommit = headCommit;
179
179
  updated = true;
package/engine/cleanup.js CHANGED
@@ -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']) {
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, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
9
+ const { safeRead, safeJson, safeWrite, 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;
@@ -174,7 +174,7 @@ const commands = {
174
174
  if (wi && wi.status !== WI_STATUS.DISPATCHED) {
175
175
  wi.status = WI_STATUS.DISPATCHED;
176
176
  wi.dispatched_to = wi.dispatched_to || agentId;
177
- wi.dispatched_at = wi.dispatched_at || new Date().toISOString();
177
+ wi.dispatched_at = wi.dispatched_at || ts();
178
178
  safeWrite(wiPath, wiItems);
179
179
  }
180
180
  } catch (err) { console.log(` Warning: failed to sync work item status: ${err.message}`); }
@@ -273,8 +273,8 @@ const commands = {
273
273
  const wi = items.find(w => w.id === item.meta.item.id);
274
274
  if (wi) {
275
275
  wi.status = status;
276
- if (isSuccess) { wi.completedAt = new Date().toISOString(); delete wi.failReason; }
277
- else { wi.failedAt = new Date().toISOString(); wi.failReason = 'Completed while engine was down'; }
276
+ if (isSuccess) { wi.completedAt = ts(); delete wi.failReason; }
277
+ else { wi.failedAt = ts(); wi.failReason = 'Completed while engine was down'; }
278
278
  safeWrite(wiPath, items);
279
279
  }
280
280
  }
@@ -9,7 +9,7 @@ const path = require('path');
9
9
  const crypto = require('crypto');
10
10
  const shared = require('./shared');
11
11
  const { safeRead, safeWrite, safeUnlink, runFile, cleanChildEnv,
12
- parseStreamJsonOutput, classifyInboxItem, KB_CATEGORIES, log, dateStamp } = shared;
12
+ parseStreamJsonOutput, classifyInboxItem, KB_CATEGORIES, log, ts, dateStamp } = shared;
13
13
  const { trackEngineUsage } = require('./llm');
14
14
  const queries = require('./queries');
15
15
  const { getInboxFiles, getNotes, INBOX_DIR, ENGINE_DIR, MINIONS_DIR,
@@ -440,7 +440,7 @@ function classifyToKnowledgeBase(items) {
440
440
  const dir = path.join(KNOWLEDGE_DIR, cat);
441
441
  if (fs.existsSync(dir)) count += fs.readdirSync(dir).length;
442
442
  }
443
- safeWrite(path.join(ENGINE_DIR, 'kb-checkpoint.json'), JSON.stringify({ count, updatedAt: new Date().toISOString() }));
443
+ safeWrite(path.join(ENGINE_DIR, 'kb-checkpoint.json'), JSON.stringify({ count, updatedAt: ts() }));
444
444
  } catch (err) { log('warn', `KB checkpoint: ${err.message}`); }
445
445
  }
446
446
 
@@ -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) {
@@ -178,13 +172,8 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
178
172
  const config = getConfig();
179
173
  const failedId = item.meta.item.id;
180
174
  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))
175
+ const allItems = queries.getWorkItems(config);
176
+ allItems.filter(w => w.status === WI_STATUS.PENDING && (w.depends_on || []).includes(failedId))
188
177
  .forEach(w => blockedItems.push(`- \`${w.id}\` — ${w.title}`));
189
178
 
190
179
  writeInboxAlert(`failed-${failedId}`,
package/engine/github.js CHANGED
@@ -217,7 +217,7 @@ async function pollPrStatus(config) {
217
217
  // Track head SHA changes to detect new pushes (used for review re-dispatch gating)
218
218
  if (prData.head?.sha && pr.headSha !== prData.head.sha) {
219
219
  pr.headSha = prData.head.sha;
220
- pr.lastPushedAt = new Date().toISOString();
220
+ pr.lastPushedAt = ts();
221
221
  updated = true;
222
222
  }
223
223
 
@@ -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
 
@@ -121,7 +108,7 @@ function checkPlanCompletion(meta, config) {
121
108
  ``,
122
109
  `**Project:** ${plan.project || 'Unknown'}`,
123
110
  `**Strategy:** ${plan.branch_strategy || 'parallel'}`,
124
- `**Completed:** ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`,
111
+ `**Completed:** ${ts().slice(0, 16).replace('T', ' ')}`,
125
112
  `**Runtime:** ${runtimeMin >= 60 ? Math.floor(runtimeMin / 60) + 'h ' + (runtimeMin % 60) + 'm' : runtimeMin + 'm'}`,
126
113
  ``,
127
114
  `## Results`,
@@ -330,19 +317,7 @@ function archivePlan(planFile, plan, projects, config) {
330
317
  if (plan.feature_branch) branchSlugs.add(shared.sanitizeBranch(plan.feature_branch).toLowerCase());
331
318
 
332
319
  // 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 */ }
320
+ const allWorkItems = queries.getWorkItems(config);
346
321
  const planItems = allWorkItems.filter(w => w.sourcePlan === planFile);
347
322
  const doneItems = planItems.filter(w => DONE_STATUSES.has(w.status));
348
323
 
@@ -1136,7 +1111,7 @@ function handleDecompositionResult(stdout, meta, config) {
1136
1111
  sourcePlan: p.sourcePlan,
1137
1112
  branchStrategy: p.branchStrategy,
1138
1113
  featureBranch: p.featureBranch,
1139
- created: new Date().toISOString(),
1114
+ created: ts(),
1140
1115
  createdBy: 'decomposition',
1141
1116
  });
1142
1117
  }
@@ -1162,7 +1137,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1162
1137
  if (isSuccess && sessionId && agentId && !agentId.startsWith('temp-')) {
1163
1138
  try {
1164
1139
  shared.safeWrite(path.join(AGENTS_DIR, agentId, 'session.json'), {
1165
- sessionId, dispatchId: dispatchItem.id, savedAt: new Date().toISOString(),
1140
+ sessionId, dispatchId: dispatchItem.id, savedAt: ts(),
1166
1141
  branch: dispatchItem.meta?.branch || null,
1167
1142
  });
1168
1143
  } catch (err) { log('warn', `Session save: ${err.message}`); }
package/engine/llm.js CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  const path = require('path');
7
7
  const shared = require('./shared');
8
- const { safeWrite, safeUnlink, uid, runFile, cleanChildEnv, parseStreamJsonOutput, mutateJsonFileLocked } = shared;
8
+ const { safeWrite, safeUnlink, uid, ts, runFile, cleanChildEnv, parseStreamJsonOutput, mutateJsonFileLocked } = shared;
9
9
 
10
10
  const MINIONS_DIR = shared.MINIONS_DIR;
11
11
  const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
@@ -28,7 +28,7 @@ function trackEngineUsage(category, usage) {
28
28
  cat.cacheRead += usage.cacheRead || 0;
29
29
  cat.cacheCreation = (cat.cacheCreation || 0) + (usage.cacheCreation || 0);
30
30
 
31
- const today = new Date().toISOString().slice(0, 10);
31
+ const today = ts().slice(0, 10);
32
32
  if (!metrics._daily) metrics._daily = {};
33
33
  if (!metrics._daily[today]) metrics._daily[today] = { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, tasks: 0 };
34
34
  const daily = metrics._daily[today];
package/engine/meeting.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 { safeJson, safeWrite, safeRead, uid, log, ENGINE_DEFAULTS, WORK_TYPE, DISPATCH_RESULT } = shared;
9
+ const { safeJson, safeWrite, safeRead, uid, log, ts, ENGINE_DEFAULTS, WORK_TYPE, DISPATCH_RESULT } = shared;
10
10
  const queries = require('./queries');
11
11
  const { getDispatch, getConfig } = queries;
12
12
  const { renderPlaybook } = require('./playbook');
@@ -51,8 +51,8 @@ function createMeeting({ title, agenda, participants }) {
51
51
  round: 1,
52
52
  participants: participants || [],
53
53
  createdBy: 'human',
54
- createdAt: new Date().toISOString(),
55
- roundStartedAt: new Date().toISOString(),
54
+ createdAt: ts(),
55
+ roundStartedAt: ts(),
56
56
  findings: {},
57
57
  debate: {},
58
58
  conclusion: null,
@@ -209,16 +209,16 @@ function collectMeetingFindings(meetingId, agentId, roundName, output) {
209
209
  const content = rawContent;
210
210
 
211
211
  if (roundName === 'investigate') {
212
- meeting.findings[agentId] = { content, submittedAt: new Date().toISOString() };
213
- meeting.transcript.push({ round: meeting.round, agent: agentId, type: 'finding', content, at: new Date().toISOString() });
212
+ meeting.findings[agentId] = { content, submittedAt: ts() };
213
+ meeting.transcript.push({ round: meeting.round, agent: agentId, type: 'finding', content, at: ts() });
214
214
  } else if (roundName === 'debate') {
215
- meeting.debate[agentId] = { content, submittedAt: new Date().toISOString() };
216
- meeting.transcript.push({ round: meeting.round, agent: agentId, type: 'debate', content, at: new Date().toISOString() });
215
+ meeting.debate[agentId] = { content, submittedAt: ts() };
216
+ meeting.transcript.push({ round: meeting.round, agent: agentId, type: 'debate', content, at: ts() });
217
217
  } else if (roundName === 'conclude') {
218
- meeting.conclusion = { content, agent: agentId, submittedAt: new Date().toISOString() };
219
- meeting.transcript.push({ round: meeting.round, agent: agentId, type: 'conclusion', content, at: new Date().toISOString() });
218
+ meeting.conclusion = { content, agent: agentId, submittedAt: ts() };
219
+ meeting.transcript.push({ round: meeting.round, agent: agentId, type: 'conclusion', content, at: ts() });
220
220
  meeting.status = 'completed';
221
- meeting.completedAt = new Date().toISOString();
221
+ meeting.completedAt = ts();
222
222
 
223
223
  // Write transcript to inbox so agents learn from it (slug-based dedup)
224
224
  try {
@@ -246,12 +246,12 @@ function collectMeetingFindings(meetingId, agentId, roundName, output) {
246
246
  if (meeting.status === 'investigating') {
247
247
  meeting.status = 'debating';
248
248
  meeting.round = 2;
249
- meeting.roundStartedAt = new Date().toISOString();
249
+ meeting.roundStartedAt = ts();
250
250
  log('info', `Meeting ${meetingId}: all findings in — advancing to debate`);
251
251
  } else if (meeting.status === 'debating') {
252
252
  meeting.status = 'concluding';
253
253
  meeting.round = 3;
254
- meeting.roundStartedAt = new Date().toISOString();
254
+ meeting.roundStartedAt = ts();
255
255
  log('info', `Meeting ${meetingId}: all debate responses in — advancing to conclusion`);
256
256
  }
257
257
  }
@@ -263,7 +263,7 @@ function addMeetingNote(meetingId, note) {
263
263
  const meeting = getMeeting(meetingId);
264
264
  if (!meeting) return null;
265
265
  meeting.humanNotes.push(note);
266
- meeting.transcript.push({ round: meeting.round, agent: 'human', type: 'note', content: note, at: new Date().toISOString() });
266
+ meeting.transcript.push({ round: meeting.round, agent: 'human', type: 'note', content: note, at: ts() });
267
267
  saveMeeting(meeting);
268
268
  return meeting;
269
269
  }
@@ -279,7 +279,7 @@ function _killMeetingDispatches(meetingId) {
279
279
  dp.active = (dp.active || []).filter(d => d.meta?.meetingId !== meetingId);
280
280
  dp.completed = dp.completed || [];
281
281
  for (const d of toKill) {
282
- dp.completed.push({ ...d, result: DISPATCH_RESULT.ERROR, reason: 'Meeting ended/advanced by human', completed_at: new Date().toISOString() });
282
+ dp.completed.push({ ...d, result: DISPATCH_RESULT.ERROR, reason: 'Meeting ended/advanced by human', completed_at: ts() });
283
283
  }
284
284
  if (dp.completed.length > 100) dp.completed = dp.completed.slice(-100);
285
285
  return dp;
@@ -295,9 +295,9 @@ function advanceMeetingRound(meetingId) {
295
295
  _killMeetingDispatches(meetingId);
296
296
  if (meeting.status === 'investigating') { meeting.status = 'debating'; meeting.round = 2; }
297
297
  else if (meeting.status === 'debating') { meeting.status = 'concluding'; meeting.round = 3; }
298
- else if (meeting.status === 'concluding') { meeting.status = 'completed'; meeting.completedAt = new Date().toISOString(); }
298
+ else if (meeting.status === 'concluding') { meeting.status = 'completed'; meeting.completedAt = ts(); }
299
299
  else return meeting; // no change
300
- meeting.roundStartedAt = new Date().toISOString();
300
+ meeting.roundStartedAt = ts();
301
301
  saveMeeting(meeting);
302
302
  return meeting;
303
303
  }
@@ -307,7 +307,7 @@ function endMeeting(meetingId) {
307
307
  if (!meeting) return null;
308
308
  _killMeetingDispatches(meetingId);
309
309
  meeting.status = 'completed';
310
- meeting.completedAt = new Date().toISOString();
310
+ meeting.completedAt = ts();
311
311
  saveMeeting(meeting);
312
312
  return meeting;
313
313
  }
@@ -316,7 +316,7 @@ function archiveMeeting(id) {
316
316
  const meeting = getMeeting(id);
317
317
  if (!meeting) return null;
318
318
  meeting.status = 'archived';
319
- meeting.archivedAt = new Date().toISOString();
319
+ meeting.archivedAt = ts();
320
320
  saveMeeting(meeting);
321
321
  return meeting;
322
322
  }
@@ -364,17 +364,17 @@ function checkMeetingTimeouts(config) {
364
364
 
365
365
  if (meeting.status === 'investigating') {
366
366
  log('warn', `Meeting ${meeting.id}: round 1 timed out after ${Math.round(elapsed / 60000)}min — ${respondedCount}/${totalCount} responded, advancing to debate`);
367
- meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: `Round 1 timed out — ${respondedCount}/${totalCount} findings received`, at: new Date().toISOString() });
367
+ meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: `Round 1 timed out — ${respondedCount}/${totalCount} findings received`, at: ts() });
368
368
  meeting.status = 'debating';
369
369
  meeting.round = 2;
370
- meeting.roundStartedAt = new Date().toISOString();
370
+ meeting.roundStartedAt = ts();
371
371
  saveMeeting(meeting);
372
372
  } else if (meeting.status === 'debating') {
373
373
  log('warn', `Meeting ${meeting.id}: round 2 timed out after ${Math.round(elapsed / 60000)}min — ${respondedCount}/${totalCount} responded, advancing to conclusion`);
374
- meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: `Round 2 timed out — ${respondedCount}/${totalCount} debate responses received`, at: new Date().toISOString() });
374
+ meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: `Round 2 timed out — ${respondedCount}/${totalCount} debate responses received`, at: ts() });
375
375
  meeting.status = 'concluding';
376
376
  meeting.round = 3;
377
- meeting.roundStartedAt = new Date().toISOString();
377
+ meeting.roundStartedAt = ts();
378
378
  saveMeeting(meeting);
379
379
  } else if (meeting.status === 'concluding') {
380
380
  log('warn', `Meeting ${meeting.id}: conclusion round timed out after ${Math.round(elapsed / 60000)}min — auto-summarizing`);
@@ -386,10 +386,10 @@ function checkMeetingTimeouts(config) {
386
386
  `**${(config.agents || {})[agent]?.name || agent}**: ${(d.content || '').slice(0, 200)}`
387
387
  ).join('\n');
388
388
  const autoConclusion = `*Auto-generated — conclusion round timed out.*\n\n## Key Findings\n${findingsSummary || '(none)'}\n\n## Debate Summary\n${debateSummary || '(none)'}`;
389
- meeting.conclusion = { content: autoConclusion, agent: 'system', submittedAt: new Date().toISOString() };
390
- meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'conclusion', content: autoConclusion, at: new Date().toISOString() });
389
+ meeting.conclusion = { content: autoConclusion, agent: 'system', submittedAt: ts() };
390
+ meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'conclusion', content: autoConclusion, at: ts() });
391
391
  meeting.status = 'completed';
392
- meeting.completedAt = new Date().toISOString();
392
+ meeting.completedAt = ts();
393
393
 
394
394
  // Write transcript to inbox (same as normal conclusion path)
395
395
  try {
@@ -9,7 +9,7 @@ const path = require('path');
9
9
  const shared = require('./shared');
10
10
  const queries = require('./queries');
11
11
 
12
- const { safeJson, safeRead, getProjects, log, dateStamp, WI_STATUS, WORK_TYPE, PR_STATUS, DISPATCH_RESULT } = shared;
12
+ const { safeJson, safeRead, getProjects, log, ts, dateStamp, WI_STATUS, WORK_TYPE, PR_STATUS, DISPATCH_RESULT } = shared;
13
13
  const { getConfig, getDispatch, getNotes, getAgentCharter, getPrs, AGENTS_DIR } = queries;
14
14
 
15
15
  const MINIONS_DIR = path.resolve(__dirname, '..');
@@ -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 || ''));
@@ -241,7 +241,7 @@ function renderPlaybook(type, vars) {
241
241
  content += `**Never delete, move, or overwrite files in \`knowledge/\`.** The sweep (consolidation engine) is the only process that writes to \`knowledge/\`. If you think a KB file is wrong, note it in your learnings file — do not touch \`knowledge/\` directly.\n`;
242
242
 
243
243
  // Inject learnings requirement
244
- const timeStamp = new Date().toISOString().slice(11, 16).replace(':', '');
244
+ const timeStamp = ts().slice(11, 16).replace(':', '');
245
245
  const inboxSlug = [vars.agent_id || 'agent', vars.task_id || '', dateStamp(), timeStamp].filter(Boolean).join('-');
246
246
  content += `\n\n---\n\n## REQUIRED: Write Learnings\n\n`;
247
247
  content += `After completing your task, write **one** findings file to:\n`;
@@ -24,7 +24,7 @@
24
24
  const fs = require('fs');
25
25
  const path = require('path');
26
26
  const shared = require('./shared');
27
- const { safeJson, safeWrite, mutateJsonFileLocked, WI_STATUS } = shared;
27
+ const { safeJson, safeWrite, mutateJsonFileLocked, ts, WI_STATUS } = shared;
28
28
 
29
29
  const SCHEDULE_RUNS_PATH = path.join(__dirname, 'schedule-runs.json');
30
30
 
@@ -126,7 +126,7 @@ function discoverScheduledWork(config) {
126
126
  priority: sched.priority || 'medium',
127
127
  description: sched.description || sched.title,
128
128
  status: WI_STATUS.PENDING,
129
- created: new Date().toISOString(),
129
+ created: ts(),
130
130
  createdBy: 'scheduler',
131
131
  agent: sched.agent || null,
132
132
  project: sched.project || null,
@@ -134,7 +134,7 @@ function discoverScheduledWork(config) {
134
134
  });
135
135
 
136
136
  // Record run time inside the lock
137
- runs[sched.id] = new Date().toISOString();
137
+ runs[sched.id] = ts();
138
138
  }
139
139
  }, { defaultValue: {} });
140
140
 
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,
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { exec, runFile, cleanChildEnv, killGracefully, killImmediate } = require('./shared');
11
+ const { exec, runFile, cleanChildEnv, killGracefully, killImmediate, ts } = require('./shared');
12
12
 
13
13
  const [,, promptFile, sysPromptFile, ...extraArgs] = process.argv;
14
14
 
@@ -83,7 +83,7 @@ if (!claudeBin) {
83
83
  const tmpDir = path.join(__dirname, 'tmp');
84
84
  if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
85
85
  const debugPath = path.join(tmpDir, 'spawn-debug.log');
86
- fs.writeFileSync(debugPath, `spawn-agent.js at ${new Date().toISOString()}\nclaudeBin=${claudeBin || 'not found'}\nnative=${claudeIsNative}\nprompt=${promptFile}\nsysPrompt=${sysPromptFile}\nextraArgs=${extraArgs.join(' ')}\n`);
86
+ fs.writeFileSync(debugPath, `spawn-agent.js at ${ts()}\nclaudeBin=${claudeBin || 'not found'}\nnative=${claudeIsNative}\nprompt=${promptFile}\nsysPrompt=${sysPromptFile}\nextraArgs=${extraArgs.join(' ')}\n`);
87
87
 
88
88
  // When resuming a session, skip system prompt (it's baked into the session)
89
89
  const isResume = extraArgs.includes('--resume');
@@ -119,7 +119,7 @@ if (_sysPromptFileSupported === null) {
119
119
  ? spawnSync(claudeBin, ['--help'], { encoding: 'utf8', timeout: 10000, windowsHide: true })
120
120
  : spawnSync(process.execPath, [claudeBin, '--help'], { encoding: 'utf8', timeout: 10000, windowsHide: true });
121
121
  _sysPromptFileSupported = (testResult.stdout || '').includes('system-prompt-file');
122
- try { fs.writeFileSync(capsCachePath, JSON.stringify({ claudeBin, sysPromptFile: _sysPromptFileSupported, checkedAt: new Date().toISOString() })); } catch { /* optional */ }
122
+ try { fs.writeFileSync(capsCachePath, JSON.stringify({ claudeBin, sysPromptFile: _sysPromptFileSupported, checkedAt: ts() })); } catch { /* optional */ }
123
123
  } catch { _sysPromptFileSupported = true; /* assume supported */ }
124
124
  }
125
125
  if (!isResume) try {
package/engine.js CHANGED
@@ -24,7 +24,7 @@
24
24
  const fs = require('fs');
25
25
  const path = require('path');
26
26
  const shared = require('./engine/shared');
27
- const { exec, execSilent, runFile, ENGINE_DEFAULTS: DEFAULTS,
27
+ const { exec, execSilent, runFile, ts, ENGINE_DEFAULTS: DEFAULTS,
28
28
  WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
29
29
  const queries = require('./engine/queries');
30
30
 
@@ -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) {
@@ -542,7 +534,7 @@ function spawnAgent(dispatchItem, config) {
542
534
  if (obj.session_id) {
543
535
  procInfo.sessionId = obj.session_id;
544
536
  safeWrite(path.join(AGENTS_DIR, agentId, 'session.json'), {
545
- sessionId: obj.session_id, dispatchId: id, savedAt: new Date().toISOString(), branch: branchName
537
+ sessionId: obj.session_id, dispatchId: id, savedAt: ts(), branch: branchName
546
538
  });
547
539
  break;
548
540
  }
@@ -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
 
@@ -1098,7 +1084,7 @@ function materializePlansAsWorkItems(config) {
1098
1084
  log('info', `Source plan ${plan.source_plan} updated — re-syncing PRD ${file}`);
1099
1085
  autoCleanPrdWorkItems(file, config);
1100
1086
  plan.sourcePlanModifiedAt = new Date(sourceMtime).toISOString();
1101
- plan.lastSyncedFromPlan = new Date().toISOString();
1087
+ plan.lastSyncedFromPlan = ts();
1102
1088
 
1103
1089
  // Handle PRD based on current status
1104
1090
  const prdStatus = plan.status || (plan.requires_approval ? 'awaiting-approval' : null);
@@ -1170,7 +1156,7 @@ function materializePlansAsWorkItems(config) {
1170
1156
  if (planStatus === 'awaiting-approval') {
1171
1157
  if (config.engine?.autoApprovePlans) {
1172
1158
  plan.status = 'approved';
1173
- plan.approvedAt = new Date().toISOString();
1159
+ plan.approvedAt = ts();
1174
1160
  plan.approvedBy = 'auto-mode';
1175
1161
  safeWrite(path.join(PRD_DIR, file), plan);
1176
1162
  log('info', `Auto-approved plan: ${file}`);
@@ -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 =>
@@ -1452,7 +1432,7 @@ function discoverFromPrs(config, project) {
1452
1432
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) {
1453
1433
  // Coalesce: save feedback for next dispatch
1454
1434
  if (pr.humanFeedback?.feedbackContent) {
1455
- setCooldownWithContext(key, { feedbackContent: pr.humanFeedback.feedbackContent, timestamp: new Date().toISOString() });
1435
+ setCooldownWithContext(key, { feedbackContent: pr.humanFeedback.feedbackContent, timestamp: ts() });
1456
1436
  }
1457
1437
  continue;
1458
1438
  }
@@ -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.525",
3
+ "version": "0.1.527",
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"