@yemi33/minions 0.1.920 → 0.1.922

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,9 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.920 (2026-04-13)
3
+ ## 0.1.922 (2026-04-13)
4
4
 
5
5
  ### Features
6
+ - add /api/work-items/reopen endpoint and reopen-work-item CC action (#982)
6
7
  - CC tab unread dot + reopened badge on work items
7
8
  - fix dep re-merge failure on retry with existing commits (#977)
8
9
  - audit and harden log buffering implementation (#971)
@@ -18,6 +19,8 @@
18
19
  - add red dot notification on CC tab when response completes (#934) (#946)
19
20
 
20
21
  ### Fixes
22
+ - show actionable failure context instead of Unknown error (#1003)
23
+ - prioritize error_max_turns over permission-blocked in classifyFailure (#1001)
21
24
  - optimistic archive hides both PRD and linked source plan simultaneously
22
25
  - align CC tab unread dot to same position as working dot
23
26
  - pipeline artifact links use pushModalBack instead of setTimeout race (#993)
@@ -35,6 +38,7 @@
35
38
  - prohibit agents from self-merging their own PRs
36
39
 
37
40
  ### Other
41
+ - test(meeting): add unit tests for key functions (#957)
38
42
  - [E2E] Architecture meeting Tier 1+2: 6 correctness bugs + 2 nested lock violations + log buffering (#972)
39
43
 
40
44
  ## 0.1.901 (2026-04-12)
@@ -1122,6 +1122,13 @@ async function ccExecuteAction(action, targetTabId) {
1122
1122
  status.style.color = 'var(--green)';
1123
1123
  break;
1124
1124
  }
1125
+ case 'reopen-work-item': {
1126
+ await _ccFetch('/api/work-items/reopen', { id: action.id, project: action.project || action.source || '', description: action.description });
1127
+ status.innerHTML = '&#10003; Work item reopened: <strong>' + escHtml(action.id) + '</strong>';
1128
+ status.style.color = 'var(--green)';
1129
+ wakeEngine();
1130
+ break;
1131
+ }
1125
1132
  case 'reopen-prd-item': {
1126
1133
  await _ccFetch('/api/prd-items/update', { source: action.file, itemId: action.id, status: 'updated' });
1127
1134
  status.innerHTML = '&#10003; PRD item reopened: <strong>' + escHtml(action.id) + '</strong>';
package/dashboard.js CHANGED
@@ -23,7 +23,7 @@ const queries = require('./engine/queries');
23
23
  const teams = require('./engine/teams');
24
24
  const os = require('os');
25
25
 
26
- const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS } = shared;
26
+ const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, reopenWorkItem } = shared;
27
27
  const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
28
28
  getSkills, getInbox, getNotesWithMeta, getPullRequests,
29
29
  getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
@@ -616,6 +616,41 @@ async function executeCCActions(actions) {
616
616
  results.push({ type: 'knowledge', ok: true });
617
617
  break;
618
618
  }
619
+ case 'reopen-work-item': {
620
+ const project = action.project || '';
621
+ const targetProject = project ? PROJECTS.find(p => p.name?.toLowerCase() === project.toLowerCase()) : PROJECTS[0];
622
+ const wiPath = targetProject ? shared.projectWorkItemsPath(targetProject) : path.join(MINIONS_DIR, 'work-items.json');
623
+ let reopenResult = null;
624
+ mutateJsonFileLocked(wiPath, items => {
625
+ if (!Array.isArray(items)) items = [];
626
+ const item = items.find(i => i.id === action.id);
627
+ if (!item) { reopenResult = { error: 'item not found' }; return items; }
628
+ if (item.status !== WI_STATUS.DONE && item.status !== WI_STATUS.FAILED && !DONE_STATUSES.has(item.status)) {
629
+ reopenResult = { error: 'can only reopen done or failed items' }; return items;
630
+ }
631
+ reopenWorkItem(item);
632
+ if (action.description) item.description = action.description;
633
+ reopenResult = { ok: true };
634
+ return items;
635
+ }, { defaultValue: [] });
636
+ if (reopenResult?.ok) {
637
+ // Clear dispatch history outside lock
638
+ const sourcePrefix = targetProject ? `work-${targetProject.name}-` : 'central-work-';
639
+ const dispatchKey = sourcePrefix + action.id;
640
+ try {
641
+ const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
642
+ mutateJsonFileLocked(dispatchPath, dispatch => {
643
+ dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
644
+ dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
645
+ dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
646
+ return dispatch;
647
+ }, { defaultValue: { pending: [], active: [], completed: [] } });
648
+ } catch { /* best effort */ }
649
+ invalidateStatusCache();
650
+ }
651
+ results.push({ type: 'reopen-work-item', id: action.id, ...(reopenResult || { error: 'unexpected' }) });
652
+ break;
653
+ }
619
654
  default:
620
655
  // Server didn't handle — frontend must execute
621
656
  results.push({ type: action.type });
@@ -1358,6 +1393,66 @@ const server = http.createServer(async (req, res) => {
1358
1393
  } catch (e) { console.error('Archive fetch error:', e.message); return jsonReply(res, 500, { error: e.message }); }
1359
1394
  }
1360
1395
 
1396
+ async function handleWorkItemsReopen(req, res) {
1397
+ try {
1398
+ const body = await readBody(req);
1399
+ const { id, description } = body;
1400
+ const project = body.project || body.source;
1401
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
1402
+
1403
+ // Find the right work-items file
1404
+ let wiPath;
1405
+ if (!project || project === 'central') {
1406
+ wiPath = path.join(MINIONS_DIR, 'work-items.json');
1407
+ } else {
1408
+ const proj = PROJECTS.find(p => p.name === project);
1409
+ if (proj) wiPath = shared.projectWorkItemsPath(proj);
1410
+ }
1411
+ if (!wiPath) return jsonReply(res, 404, { error: 'project not found' });
1412
+
1413
+ let result = null;
1414
+ mutateJsonFileLocked(wiPath, (items) => {
1415
+ if (!Array.isArray(items)) items = [];
1416
+ const item = items.find(i => i.id === id);
1417
+ if (!item) { result = { code: 404, body: { error: 'item not found' } }; return items; }
1418
+ if (item.status !== WI_STATUS.DONE && item.status !== WI_STATUS.FAILED && !DONE_STATUSES.has(item.status)) {
1419
+ result = { code: 400, body: { error: 'can only reopen done or failed items (current: ' + item.status + ')' } };
1420
+ return items;
1421
+ }
1422
+ reopenWorkItem(item);
1423
+ if (description !== undefined) item.description = description;
1424
+ result = { code: 200, body: { ok: true, item } };
1425
+ return items;
1426
+ });
1427
+ if (!result) return jsonReply(res, 500, { error: 'unexpected state' });
1428
+ if (result.code !== 200) return jsonReply(res, result.code, result.body);
1429
+
1430
+ // Clear dispatch history and cooldowns outside lock
1431
+ const sourcePrefix = (!project || project === 'central') ? 'central-work-' : `work-${project}-`;
1432
+ const dispatchKey = sourcePrefix + id;
1433
+ try {
1434
+ const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
1435
+ mutateJsonFileLocked(dispatchPath, (dispatch) => {
1436
+ dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
1437
+ dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
1438
+ dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
1439
+ return dispatch;
1440
+ }, { defaultValue: { pending: [], active: [], completed: [] } });
1441
+ } catch (e) { console.error('dispatch cleanup on reopen:', e.message); }
1442
+ try {
1443
+ const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
1444
+ const cooldowns = safeJsonObj(cooldownPath);
1445
+ if (cooldowns[dispatchKey]) {
1446
+ delete cooldowns[dispatchKey];
1447
+ safeWrite(cooldownPath, cooldowns);
1448
+ }
1449
+ } catch (e) { console.error('cooldown cleanup on reopen:', e.message); }
1450
+
1451
+ invalidateStatusCache();
1452
+ return jsonReply(res, result.code, result.body);
1453
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1454
+ }
1455
+
1361
1456
  async function handleWorkItemsCreate(req, res) {
1362
1457
  try {
1363
1458
  const body = await readBody(req);
@@ -3895,6 +3990,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3895
3990
  { method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
3896
3991
  { method: 'POST', path: '/api/work-items/archive', desc: 'Move a completed/failed work item to archive', params: 'id, source?', handler: handleWorkItemsArchive },
3897
3992
  { method: 'GET', path: '/api/work-items/archive', desc: 'List archived work items', handler: handleWorkItemsArchiveList },
3993
+ { method: 'POST', path: '/api/work-items/reopen', desc: 'Reopen a done/failed work item back to pending', params: 'id, project?, description?', handler: handleWorkItemsReopen },
3898
3994
  { method: 'POST', path: '/api/work-items/feedback', desc: 'Add human feedback on completed work', params: 'id, rating, comment?', handler: async (req, res) => {
3899
3995
  const body = await readBody(req);
3900
3996
  const { id, source, rating, comment } = body;
@@ -172,9 +172,26 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
172
172
  }
173
173
  } catch (e) { log('warn', 'increment retry counter: ' + e.message); }
174
174
  } else {
175
+ // Human-readable labels for each failure class — used as fallback when reason is empty
176
+ const CLASS_LABELS = {
177
+ [FAILURE_CLASS.EMPTY_OUTPUT]: 'agent produced no output \u2014 likely crashed on startup',
178
+ [FAILURE_CLASS.BUILD_FAILURE]: 'build/test/lint failure in output',
179
+ [FAILURE_CLASS.MERGE_CONFLICT]: 'merge conflict',
180
+ [FAILURE_CLASS.MAX_TURNS]: 'reached max turn limit',
181
+ [FAILURE_CLASS.TIMEOUT]: 'timed out waiting for agent',
182
+ [FAILURE_CLASS.SPAWN_ERROR]: 'agent process failed to start',
183
+ [FAILURE_CLASS.NETWORK_ERROR]: 'network or API error',
184
+ [FAILURE_CLASS.OUT_OF_CONTEXT]: 'context window exhausted',
185
+ [FAILURE_CLASS.CONFIG_ERROR]: 'configuration error',
186
+ [FAILURE_CLASS.PERMISSION_BLOCKED]: 'permission or auth failure',
187
+ [FAILURE_CLASS.UNKNOWN]: 'unknown error',
188
+ };
189
+ const classLabel = failureClass ? (CLASS_LABELS[failureClass] || failureClass) : '';
190
+ const effectiveReason = reason || classLabel || 'Unknown error';
191
+ const classSuffix = failureClass ? ` [${failureClass.toUpperCase().replace(/-/g, '_')}]` : '';
175
192
  const finalReason = !retryableFailure
176
- ? `Non-retryable failure: ${reason || 'Unknown error'}`
177
- : (reason || `Failed after ${maxRetries} retries`);
193
+ ? `Non-retryable failure: ${effectiveReason}${classSuffix}`
194
+ : (reason || `Failed after ${maxRetries} retries${classSuffix}`);
178
195
  lifecycle().updateWorkItemStatus(item.meta, WI_STATUS.FAILED, finalReason);
179
196
  // Alert: find items blocked by this failure and write inbox note
180
197
  try {
@@ -1910,6 +1910,14 @@ function classifyFailure(code, stdout = '', stderr = '') {
1910
1910
  // Exit code 78 — configuration error (Claude CLI not found, bad setup)
1911
1911
  if (code === 78) return FAILURE_CLASS.CONFIG_ERROR;
1912
1912
 
1913
+ // Max turns exhausted (error_max_turns) — definitive stop reason, retryable
1914
+ // Must be checked FIRST — hook startup failures (e.g. curl exit code 28) can inject
1915
+ // permission/auth text into stderr, but if the agent ran to turn exhaustion that's the
1916
+ // real cause. Checked before PERMISSION_BLOCKED and OUT_OF_CONTEXT.
1917
+ if (/error_max_turns|"subtype"\s*:\s*"error_max_turns"|terminal_reason.*max_turns|max.*turns.*reached/i.test(combined)) {
1918
+ return FAILURE_CLASS.MAX_TURNS;
1919
+ }
1920
+
1913
1921
  // Permission / trust / auth failures
1914
1922
  if (/permission denied|access denied|unauthorized|403 forbidden|trust.*blocked|auth.*fail/i.test(combined)) {
1915
1923
  return FAILURE_CLASS.PERMISSION_BLOCKED;
@@ -1920,12 +1928,6 @@ function classifyFailure(code, stdout = '', stderr = '') {
1920
1928
  return FAILURE_CLASS.MERGE_CONFLICT;
1921
1929
  }
1922
1930
 
1923
- // Max turns exhausted (error_max_turns) — work in progress, retryable
1924
- // Must be checked BEFORE OUT_OF_CONTEXT to avoid misclassification as non-retryable
1925
- if (/error_max_turns|"subtype"\s*:\s*"error_max_turns"|terminal_reason.*max_turns|max.*turns.*reached/i.test(combined)) {
1926
- return FAILURE_CLASS.MAX_TURNS;
1927
- }
1928
-
1929
1931
  // Context window exhausted (token limit, context length — NOT max turns)
1930
1932
  if (/context window|token limit|conversation.*too long|context.*length.*exceeded/i.test(combined)) {
1931
1933
  return FAILURE_CLASS.OUT_OF_CONTEXT;
package/engine.js CHANGED
@@ -1141,7 +1141,12 @@ async function spawnAgent(dispatchItem, config) {
1141
1141
  // autoRecovered: agent failed (e.g. heartbeat timeout) but created PRs — treat as success
1142
1142
  const effectiveResult = (code === 0 || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
1143
1143
  const completeOpts = effectiveResult === DISPATCH_RESULT.ERROR && failureClass ? { failureClass } : {};
1144
- completeDispatch(id, effectiveResult, '', resultSummary, completeOpts);
1144
+ // Extract last 5 non-empty stderr lines as error context when exit code is non-zero
1145
+ let errorReason = '';
1146
+ if (effectiveResult === DISPATCH_RESULT.ERROR) {
1147
+ errorReason = stderr.split('\n').filter(l => l.trim()).slice(-5).join(' | ').trim().slice(0, 300);
1148
+ }
1149
+ completeDispatch(id, effectiveResult, errorReason, resultSummary, completeOpts);
1145
1150
 
1146
1151
  // Cleanup temp files (including PID file now that dispatch is complete)
1147
1152
  try { fs.unlinkSync(sysPromptPath); } catch { /* cleanup */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.920",
3
+ "version": "0.1.922",
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"
@@ -76,7 +76,7 @@ Additional actions (all take `id` or `file` as primary key):
76
76
  - Schedules: schedule (id, title, cron, workType, project, agent, description, priority, enabled), delete-schedule (id)
77
77
  - Pipelines: create-pipeline (id, title, stages[], trigger, stopWhen, monitoredResources), edit-pipeline (id, title, stages, trigger), delete-pipeline (id), trigger-pipeline (id), abort-pipeline (id), retrigger-pipeline (id)
78
78
  - Meetings: add-meeting-note (id, note), advance-meeting (id), end-meeting (id), archive-meeting (id), unarchive-meeting (id), delete-meeting (id)
79
- - Work item ops: delete-work-item (id, source), archive-work-item (id), work-item-feedback (id, rating: up/down, comment)
79
+ - Work item ops: delete-work-item (id, source), archive-work-item (id), work-item-feedback (id, rating: up/down, comment), reopen-work-item (id, project[, description] — reopen a done/failed item back to pending)
80
80
  - PRD ops: edit-prd-item, remove-prd-item, reopen-prd-item (id, file — re-dispatches on existing branch)
81
81
  - KB/Inbox: promote-to-kb (file, category), kb-sweep, toggle-kb-pin (key)
82
82
  - Plan lifecycle: revise-plan (file, feedback — dispatches agent to revise)