@yemi33/minions 0.1.347 → 0.1.349

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,6 +1,10 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.347 (2026-04-04)
3
+ ## 0.1.349 (2026-04-04)
4
+
5
+ ### Fixes
6
+ - remove settings Reset button when modal closes
7
+ - comprehensive status mutation guards — prevent done items from being reverted
4
8
 
5
9
  ### Other
6
10
  - docs: update CLAUDE.md with constants, best practices, latest architecture
@@ -6,9 +6,11 @@ function closeModal() {
6
6
  document.getElementById('modal').classList.remove('open');
7
7
  // Hide Q&A section (only shown for document modals)
8
8
  document.getElementById('modal-qa').style.display = 'none';
9
- // Remove settings save button if present
9
+ // Remove settings buttons if present
10
10
  const settingsBtn = document.getElementById('modal-settings-save');
11
11
  if (settingsBtn) settingsBtn.remove();
12
+ const resetBtn = document.getElementById('modal-settings-reset');
13
+ if (resetBtn) resetBtn.remove();
12
14
  // Save Q&A session for this document (persist across modal open/close)
13
15
  if (_qaSessionKey && (_qaHistory.length > 0 || _qaQueue.length > 0)) {
14
16
  _qaSessions.set(_qaSessionKey, {
package/dashboard.js CHANGED
@@ -14,7 +14,7 @@ const shared = require('./engine/shared');
14
14
  const queries = require('./engine/queries');
15
15
  const os = require('os');
16
16
 
17
- const { safeRead, safeReadDir, safeWrite, safeJson, safeUnlink, mutateJsonFileLocked, getProjects: _getProjects } = shared;
17
+ const { safeRead, safeReadDir, safeWrite, safeJson, safeUnlink, mutateJsonFileLocked, getProjects: _getProjects, DONE_STATUSES } = shared;
18
18
  const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
19
19
  getSkills, getInbox, getNotesWithMeta, getPullRequests,
20
20
  getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
@@ -895,6 +895,11 @@ const server = http.createServer(async (req, res) => {
895
895
  if (!Array.isArray(items)) items = [];
896
896
  const item = items.find(i => i.id === id);
897
897
  if (!item) return items;
898
+ // Don't reset completed items unless explicitly forced
899
+ if ((item.status === 'done' || item.completedAt) && !body.force) {
900
+ found = 'already_done';
901
+ return items;
902
+ }
898
903
  found = true;
899
904
  item.status = 'pending';
900
905
  item._retryCount = 0; // Reset retry counter on manual retry
@@ -902,9 +907,11 @@ const server = http.createServer(async (req, res) => {
902
907
  delete item.dispatched_to;
903
908
  delete item.failReason;
904
909
  delete item.failedAt;
910
+ delete item.completedAt;
905
911
  delete item.fanOutAgents;
906
912
  return items;
907
913
  });
914
+ if (found === 'already_done') return jsonReply(res, 409, { error: 'item already completed — use force:true to retry' });
908
915
  if (!found) return jsonReply(res, 404, { error: 'item not found' });
909
916
 
910
917
  // Clear completed dispatch entries so the engine doesn't dedup this item
@@ -1800,20 +1807,19 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
1800
1807
  }
1801
1808
  for (const wiPath of wiPaths) {
1802
1809
  try {
1803
- const items = safeJson(wiPath);
1804
- if (!items) continue;
1805
- let changed = false;
1806
- for (const w of items) {
1807
- if (w.sourcePlan === body.file && w.status === 'paused' && w._pausedBy === 'prd-pause') {
1808
- w.status = 'pending';
1809
- delete w._pausedBy;
1810
- w._resumedAt = new Date().toISOString();
1811
- resumedItemIds.push(w.id);
1812
- resumed++;
1813
- changed = true;
1810
+ mutateJsonFileLocked(wiPath, (items) => {
1811
+ if (!Array.isArray(items)) return items;
1812
+ for (const w of items) {
1813
+ if (w.sourcePlan === body.file && w.status === 'paused' && w._pausedBy === 'prd-pause') {
1814
+ w.status = 'pending';
1815
+ delete w._pausedBy;
1816
+ w._resumedAt = new Date().toISOString();
1817
+ resumedItemIds.push(w.id);
1818
+ resumed++;
1819
+ }
1814
1820
  }
1815
- }
1816
- if (changed) safeWrite(wiPath, items);
1821
+ return items;
1822
+ }, { defaultValue: [] });
1817
1823
  } catch (e) { console.error('resume work items:', e.message); }
1818
1824
  }
1819
1825
 
@@ -1864,7 +1870,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
1864
1870
  for (const w of items) {
1865
1871
  if (w.sourcePlan !== body.file) continue;
1866
1872
  // Keep completed items as-is, reset everything else to pending.
1867
- if (w.status === 'done' || w.status === 'implemented' || w.status === 'complete' || w.status === 'in-pr') continue;
1873
+ if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
1868
1874
 
1869
1875
  if (w.status === 'dispatched') {
1870
1876
  // Kill the agent working on this item, if any.
@@ -2574,7 +2580,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2574
2580
  let changed = false;
2575
2581
  for (const w of items) {
2576
2582
  if (w.sourcePlan !== f) continue;
2577
- if (w.status === 'done' || w.status === 'implemented' || w.status === 'complete' || w.status === 'in-pr') continue;
2583
+ if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
2578
2584
  if (w.status === 'dispatched') {
2579
2585
  const activeEntry = (dispatch.active || []).find(d => d.meta?.item?.id === w.id || d.meta?.dispatchKey?.includes(w.id));
2580
2586
  if (activeEntry) {
@@ -104,9 +104,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
104
104
  if (processWorkItemFailure && result === DISPATCH_RESULT.ERROR && item.meta?.item?.id) {
105
105
  let retries = (item.meta.item._retryCount || 0);
106
106
  try {
107
- const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
108
- ? path.join(MINIONS_DIR, 'work-items.json')
109
- : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
107
+ const wiPath = lifecycle().resolveWorkItemPath(item.meta);
110
108
  if (wiPath) {
111
109
  const items = safeJson(wiPath);
112
110
  if (items && Array.isArray(items)) {
@@ -130,9 +128,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
130
128
  }
131
129
  // Increment retry counter on the source work item
132
130
  try {
133
- const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
134
- ? path.join(MINIONS_DIR, 'work-items.json')
135
- : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
131
+ const wiPath = lifecycle().resolveWorkItemPath(item.meta);
136
132
  if (wiPath) {
137
133
  const items = safeJson(wiPath);
138
134
  if (!items || !Array.isArray(items)) throw new Error('work items unreadable');
@@ -477,6 +477,24 @@ function chainPlanToPrd(dispatchItem, meta, config) {
477
477
  shared.safeWrite(wiPath, items);
478
478
  }
479
479
 
480
+ // ─── Work Item Path Resolution ───────────────────────────────────────────────
481
+
482
+ /** Resolve the work-items.json path from dispatch meta. Reused by retry paths. */
483
+ function resolveWorkItemPath(meta) {
484
+ if (meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout') {
485
+ return path.join(MINIONS_DIR, 'work-items.json');
486
+ }
487
+ if (meta.source === 'work-item' && meta.project?.name) {
488
+ return path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json');
489
+ }
490
+ return null;
491
+ }
492
+
493
+ /** Check if a work item is in a terminal completed state. */
494
+ function isItemCompleted(item) {
495
+ return item.status === WI_STATUS.DONE || !!item.completedAt;
496
+ }
497
+
480
498
  // ─── Work Item Status ────────────────────────────────────────────────────────
481
499
  const _VALID_WI_STATUSES = new Set(Object.values(WI_STATUS));
482
500
  function updateWorkItemStatus(meta, status, reason) {
@@ -488,19 +506,14 @@ function updateWorkItemStatus(meta, status, reason) {
488
506
  return;
489
507
  }
490
508
 
491
- let wiPath;
492
- if (meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout') {
493
- wiPath = path.join(MINIONS_DIR, 'work-items.json');
494
- } else if (meta.source === 'work-item' && meta.project?.name) {
495
- wiPath = path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json');
496
- }
509
+ const wiPath = resolveWorkItemPath(meta);
497
510
  if (!wiPath) return;
498
511
 
499
- const items = safeJson(wiPath);
500
- if (!items || !Array.isArray(items)) return;
512
+ mutateJsonFileLocked(wiPath, (items) => {
513
+ if (!items || !Array.isArray(items)) return items;
514
+ const target = items.find(i => i.id === itemId);
515
+ if (!target) return items;
501
516
 
502
- const target = items.find(i => i.id === itemId);
503
- if (target) {
504
517
  if (meta.source === 'central-work-item-fanout') {
505
518
  if (!target.agentResults) target.agentResults = {};
506
519
  const parts = (meta.dispatchKey || '').split('-');
@@ -538,13 +551,11 @@ function updateWorkItemStatus(meta, status, reason) {
538
551
  target.failedAt = ts();
539
552
  }
540
553
  }
554
+ return items;
555
+ }, { defaultValue: [] });
541
556
 
542
- shared.safeWrite(wiPath, items);
543
- log('info', `Work item ${itemId} → ${status}${reason ? ': ' + reason : ''}`);
544
-
545
- // Sync status to PRD JSON so the two share the same value (work item is source of truth)
546
- syncPrdItemStatus(itemId, status, meta.item?.sourcePlan);
547
- }
557
+ log('info', `Work item ${itemId} → ${status}${reason ? ': ' + reason : ''}`);
558
+ syncPrdItemStatus(itemId, status, meta.item?.sourcePlan);
548
559
  }
549
560
 
550
561
  const _VALID_PRD_STATUSES = new Set([...Object.values(WI_STATUS), 'missing']);
@@ -1371,5 +1382,7 @@ module.exports = {
1371
1382
  parseAgentOutput,
1372
1383
  runPostCompletionHooks,
1373
1384
  syncPrdFromPrs,
1385
+ resolveWorkItemPath,
1386
+ isItemCompleted,
1374
1387
  };
1375
1388
 
package/engine/timeout.js CHANGED
@@ -8,7 +8,7 @@ const path = require('path');
8
8
  const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
- const { safeRead, safeWrite, safeJson, getProjects, projectWorkItemsPath, log, ts,
11
+ const { safeRead, safeWrite, safeJson, mutateJsonFileLocked, getProjects, projectWorkItemsPath, log, ts,
12
12
  ENGINE_DEFAULTS: DEFAULTS, WI_STATUS, DISPATCH_RESULT } = shared;
13
13
  const { getDispatch, getAgentStatus } = queries;
14
14
  const AGENTS_DIR = queries.AGENTS_DIR;
@@ -229,43 +229,43 @@ function checkTimeouts(config) {
229
229
  allWiPaths.push(projectWorkItemsPath(project));
230
230
  }
231
231
  for (const wiPath of allWiPaths) {
232
- const items = safeJson(wiPath);
233
- if (!items || !Array.isArray(items)) continue;
234
- let changed = false;
235
- for (const item of items) {
236
- if (item.status !== WI_STATUS.DISPATCHED) continue;
237
- // Check if any active dispatch references this item
238
- // Dispatch keys include project name: work-{project}-{id} or central-work-{id}
239
- const projectNames = getProjects(config).map(p => p.name);
240
- const possibleKeys = [
241
- `central-work-${item.id}`,
242
- ...projectNames.map(p => `work-${p}-${item.id}`),
243
- ];
244
- const isActive = possibleKeys.some(k => activeKeys.has(k)) ||
245
- (dispatchData.active || []).some(d => d.meta?.item?.id === item.id);
246
- if (!isActive) {
247
- // Don't revive items that were explicitly failed for non-retryable reasons
248
- if (item.status === WI_STATUS.FAILED && item.failReason && !item.failReason.includes('Agent died')) continue;
249
- const retries = (item._retryCount || 0);
250
- const maxRetries = DEFAULTS.maxRetries;
251
- if (retries < maxRetries) {
252
- log('info', `Reconcile: work item ${item.id} agent died — auto-retry ${retries + 1}/${maxRetries}`);
253
- item.status = WI_STATUS.PENDING;
254
- item._retryCount = retries + 1;
255
- delete item.dispatched_at;
256
- delete item.dispatched_to;
257
- delete item._pendingReason;
258
- } else {
259
- log('warn', `Reconcile: work item ${item.id} failed after ${retries} retries — marking as failed`);
260
- item.status = WI_STATUS.FAILED;
261
- item.failReason = `Agent died or was killed (${maxRetries} retries exhausted)`;
262
- item.failedAt = ts();
263
- delete item._pendingReason;
232
+ mutateJsonFileLocked(wiPath, (items) => {
233
+ if (!items || !Array.isArray(items)) return items;
234
+ let changed = false;
235
+ for (const item of items) {
236
+ if (item.status !== WI_STATUS.DISPATCHED) continue;
237
+ // Never revert completed items
238
+ if (item.completedAt || item.status === WI_STATUS.DONE) continue;
239
+ // Check if any active dispatch references this item
240
+ const projectNames = getProjects(config).map(p => p.name);
241
+ const possibleKeys = [
242
+ `central-work-${item.id}`,
243
+ ...projectNames.map(p => `work-${p}-${item.id}`),
244
+ ];
245
+ const isActive = possibleKeys.some(k => activeKeys.has(k)) ||
246
+ (dispatchData.active || []).some(d => d.meta?.item?.id === item.id);
247
+ if (!isActive) {
248
+ const retries = (item._retryCount || 0);
249
+ const maxRetries = DEFAULTS.maxRetries;
250
+ if (retries < maxRetries) {
251
+ log('info', `Reconcile: work item ${item.id} agent died — auto-retry ${retries + 1}/${maxRetries}`);
252
+ item.status = WI_STATUS.PENDING;
253
+ item._retryCount = retries + 1;
254
+ delete item.dispatched_at;
255
+ delete item.dispatched_to;
256
+ delete item._pendingReason;
257
+ } else {
258
+ log('warn', `Reconcile: work item ${item.id} failed after ${retries} retries — marking as failed`);
259
+ item.status = WI_STATUS.FAILED;
260
+ item.failReason = `Agent died or was killed (${maxRetries} retries exhausted)`;
261
+ item.failedAt = ts();
262
+ delete item._pendingReason;
263
+ }
264
+ changed = true;
264
265
  }
265
- changed = true;
266
266
  }
267
- }
268
- if (changed) safeWrite(wiPath, items);
267
+ return items;
268
+ }, { defaultValue: [] });
269
269
  }
270
270
  }
271
271
 
package/engine.js CHANGED
@@ -132,7 +132,8 @@ const { renderPlaybook, buildSystemPrompt, buildAgentContext, selectPlaybook,
132
132
 
133
133
  const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, handlePostMerge, checkPlanCompletion,
134
134
  syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
135
- updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs } = require('./engine/lifecycle');
135
+ updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs,
136
+ isItemCompleted } = require('./engine/lifecycle');
136
137
 
137
138
  // ─── Agent Spawner ──────────────────────────────────────────────────────────
138
139
 
@@ -1381,7 +1382,7 @@ function discoverFromWorkItems(config, project) {
1381
1382
  for (const item of items) {
1382
1383
  try {
1383
1384
  // Re-evaluate failed items: if deps have recovered, reset to pending
1384
- if (item.status === WI_STATUS.FAILED && item.failReason === 'Dependency failed — cannot proceed') {
1385
+ if (item.status === WI_STATUS.FAILED && !isItemCompleted(item) && item.failReason === 'Dependency failed — cannot proceed') {
1385
1386
  const depStatus = areDependenciesMet(item, config);
1386
1387
  if (depStatus === true) {
1387
1388
  item.status = WI_STATUS.PENDING;
@@ -1396,7 +1397,7 @@ function discoverFromWorkItems(config, project) {
1396
1397
  // Dependency gate: skip items whose depends_on are not yet met; propagate failure
1397
1398
  if (item.depends_on && item.depends_on.length > 0) {
1398
1399
  const depStatus = areDependenciesMet(item, config);
1399
- if (depStatus === 'failed') {
1400
+ if (depStatus === 'failed' && !isItemCompleted(item)) {
1400
1401
  item.status = WI_STATUS.FAILED;
1401
1402
  item.failReason = 'Dependency failed — cannot proceed';
1402
1403
  delete item._pendingReason;
@@ -2272,7 +2273,7 @@ async function tickInner() {
2272
2273
  if (pendingWithBlockedDeps.length > 0) {
2273
2274
  // Auto-retry failed items that are blocking others (transient errors)
2274
2275
  for (const item of items) {
2275
- if (item.status !== 'failed') continue;
2276
+ if (item.status !== 'failed' || isItemCompleted(item)) continue;
2276
2277
  // Only retry if something depends on this item
2277
2278
  const isBlocking = items.some(w => w.status === WI_STATUS.PENDING && (w.depends_on || []).includes(item.id));
2278
2279
  if (!isBlocking) continue;
@@ -2310,7 +2311,7 @@ async function tickInner() {
2310
2311
  if (changed) {
2311
2312
  const retriedIds = new Set(items.filter(w => w.status === WI_STATUS.PENDING && w._retryCount === 0).map(w => w.id));
2312
2313
  for (const dep of items) {
2313
- if (dep.status === WI_STATUS.FAILED && dep.failReason === 'Dependency failed — cannot proceed') {
2314
+ if (dep.status === WI_STATUS.FAILED && !isItemCompleted(dep) && dep.failReason === 'Dependency failed — cannot proceed') {
2314
2315
  const blockers = (dep.depends_on || []).filter(d => retriedIds.has(d));
2315
2316
  if (blockers.length > 0) {
2316
2317
  log('info', `Stall recovery: un-failing ${dep.id} (blocker ${blockers.join(',')} retried)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.347",
3
+ "version": "0.1.349",
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"