@yemi33/minions 0.1.208 → 0.1.210

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,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.210 (2026-04-02)
4
+
5
+ ### Dashboard
6
+ - dashboard/js/settings.js
7
+
8
+ ## 0.1.209 (2026-04-02)
9
+
10
+ ### Dashboard
11
+ - dashboard.js
12
+ - dashboard/js/settings.js
13
+
3
14
  ## 0.1.208 (2026-04-02)
4
15
 
5
16
  ### Dashboard
@@ -57,11 +57,13 @@ async function openSettings() {
57
57
  '<div style="margin-bottom:16px">' +
58
58
  '<label style="font-size:10px;color:var(--muted);display:block;margin-bottom:2px">Permission Mode <span style="opacity:0.6">(how agents handle tool approvals)</span></label>' +
59
59
  '<select id="set-permissionMode" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:12px">' +
60
- '<option value="bypassPermissions"' + ((c.permissionMode || 'bypassPermissions') === 'bypassPermissions' ? ' selected' : '') + '>Bypass (recommended) — agents run without permission prompts</option>' +
61
- '<option value="auto"' + ((c.permissionMode) === 'auto' ? ' selected' : '') + '>Auto — agents auto-approve safe tools, prompt for risky ones</option>' +
62
- '<option value="default"' + ((c.permissionMode) === 'default' ? ' selected' : '') + '>Default — agents prompt for every tool (will hang without a human)</option>' +
60
+ '<option value="bypassPermissions"' + ((c.permissionMode || 'bypassPermissions') === 'bypassPermissions' ? ' selected' : '') + '>Bypass (recommended) — agents run autonomously without permission prompts</option>' +
61
+ '<option value="auto"' + ((c.permissionMode) === 'auto' ? ' selected' : '') + '>Auto — auto-approve safe tools, prompt for risky ones (agents may hang on risky tools)</option>' +
62
+ '<option value="default"' + ((c.permissionMode) === 'default' ? ' selected' : '') + '>Default — prompt for every tool (agents WILL hang not recommended)</option>' +
63
63
  '</select>' +
64
- '<div style="font-size:9px;color:var(--muted);margin-top:2px">Non-bypass modes require a human watching the agent terminal to approve tool calls</div>' +
64
+ '<div id="set-permissionMode-warn" style="font-size:9px;margin-top:4px;padding:4px 8px;border-radius:4px;' + ((c.permissionMode && c.permissionMode !== 'bypassPermissions') ? 'display:block;background:rgba(248,81,73,0.1);color:var(--red)' : 'display:none') + '">' +
65
+ '\u26A0 Tools listed in Allowed Tools above are auto-approved even in non-bypass modes. Agents will only hang if they try to use a tool NOT on that list (e.g. MCP tools). In bypass mode, all tools are approved automatically.' +
66
+ '</div>' +
65
67
  '</div>' +
66
68
 
67
69
  '<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">Agents</h3>' +
@@ -96,6 +98,20 @@ async function openSettings() {
96
98
  document.getElementById('modal-body').style.fontFamily = '';
97
99
  document.getElementById('modal-body').style.whiteSpace = '';
98
100
  document.getElementById('modal').classList.add('open');
101
+
102
+ // Wire permission mode warning toggle
103
+ var pmSelect = document.getElementById('set-permissionMode');
104
+ if (pmSelect) pmSelect.addEventListener('change', function() {
105
+ var warn = document.getElementById('set-permissionMode-warn');
106
+ if (!warn) return;
107
+ if (this.value !== 'bypassPermissions') {
108
+ warn.style.display = 'block';
109
+ warn.style.background = 'rgba(248,81,73,0.1)';
110
+ warn.style.color = 'var(--red)';
111
+ } else {
112
+ warn.style.display = 'none';
113
+ }
114
+ });
99
115
  }
100
116
 
101
117
  function settingsToggle(label, id, checked, hint) {
package/dashboard.js CHANGED
@@ -370,16 +370,7 @@ I'll save that as a note and dispatch dallas to fix the bug.
370
370
  If no actions are needed (just answering a question, or you handled it directly), do NOT include the ===ACTIONS=== line.
371
371
 
372
372
  Available action types:
373
- - **dispatch**: Create a work item for an agent. Fields: title, workType, priority (low/medium/high), agents (array of IDs, optional), project, description.
374
- workType values — choose carefully, this determines the playbook and whether a PR is expected:
375
- - \`explore\` — research, investigate, read code, gather information, write findings (NO PR expected)
376
- - \`ask\` — answer a question, analyze something, produce a report (NO PR expected)
377
- - \`implement\` — write new code, add a feature, create something (PR expected)
378
- - \`fix\` — fix a bug, address review feedback on an existing PR (PR expected)
379
- - \`review\` — code review, quality assessment, evaluate completed work, verify acceptance criteria (NO PR expected)
380
- - \`test\` — run tests, write test cases (PR expected if new tests written)
381
- - \`verify\` — build PRs locally, merge branches, start dev server, get localhost URL to test (NO PR expected)
382
- If unsure, prefer \`explore\` for read-only tasks and \`implement\` for write tasks.
373
+ - **dispatch**: Create a work item for an agent. Fields: title, workType (ask/explore/fix/review/test/implement/verify), priority (low/medium/high), agents (array of IDs, optional), project, description. Use \`verify\` when the user wants to build PRs locally, merge branches together, start a dev server, and get a localhost URL to test.
383
374
  - **note**: Save a note/decision. Fields: title, content
384
375
  - **plan**: Create a multi-step plan. Fields: title, description, project, branchStrategy (parallel/shared-branch)
385
376
  - **cancel**: Cancel a running agent. Fields: agent (agent ID), reason
@@ -676,7 +667,7 @@ function readBody(req) {
676
667
  return new Promise((resolve, reject) => {
677
668
  let body = '';
678
669
  req.on('data', chunk => { body += chunk; if (body.length > 1e6) reject(new Error('Too large')); });
679
- req.on('end', () => { try { resolve(body ? JSON.parse(body) : {}); } catch(e) { reject(e); } });
670
+ req.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
680
671
  });
681
672
  }
682
673
 
@@ -823,7 +814,7 @@ const server = http.createServer(async (req, res) => {
823
814
  // If archived, temporarily restore to active so checkPlanCompletion can find it
824
815
  const activePath = path.join(prdDir, body.file);
825
816
  if (fromArchive) {
826
- const plan = JSON.parse(safeRead(prdPath) || '{}');
817
+ const plan = JSON.parse(safeRead(prdPath));
827
818
  plan.status = 'approved';
828
819
  delete plan.completedAt;
829
820
  safeWrite(activePath, plan);
@@ -869,22 +860,18 @@ const server = http.createServer(async (req, res) => {
869
860
  }
870
861
  if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
871
862
 
872
- let found = false;
873
- mutateJsonFileLocked(wiPath, (items) => {
874
- if (!Array.isArray(items)) items = [];
875
- const item = items.find(i => i.id === id);
876
- if (!item) return items;
877
- found = true;
878
- item.status = 'pending';
879
- item._retryCount = 0;
880
- delete item.dispatched_at;
881
- delete item.dispatched_to;
882
- delete item.failReason;
883
- delete item.failedAt;
884
- delete item.fanOutAgents;
885
- return items;
886
- }, { defaultValue: [] });
887
- if (!found) return jsonReply(res, 404, { error: 'item not found' });
863
+ const items = JSON.parse(safeRead(wiPath) || '[]');
864
+ const item = items.find(i => i.id === id);
865
+ if (!item) return jsonReply(res, 404, { error: 'item not found' });
866
+
867
+ item.status = 'pending';
868
+ item._retryCount = 0; // Reset retry counter on manual retry
869
+ delete item.dispatched_at;
870
+ delete item.dispatched_to;
871
+ delete item.failReason;
872
+ delete item.failedAt;
873
+ delete item.fanOutAgents;
874
+ safeWrite(wiPath, items);
888
875
 
889
876
  // Clear completed dispatch entries so the engine doesn't dedup this item
890
877
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
@@ -902,10 +889,11 @@ const server = http.createServer(async (req, res) => {
902
889
  // Clear cooldown so item isn't blocked by exponential backoff
903
890
  try {
904
891
  const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
905
- mutateJsonFileLocked(cooldownPath, (cooldowns) => {
906
- if (cooldowns[dispatchKey]) delete cooldowns[dispatchKey];
907
- return cooldowns;
908
- });
892
+ const cooldowns = JSON.parse(safeRead(cooldownPath) || '{}');
893
+ if (cooldowns[dispatchKey]) {
894
+ delete cooldowns[dispatchKey];
895
+ safeWrite(cooldownPath, cooldowns);
896
+ }
909
897
  } catch (e) { console.error('cooldown cleanup:', e.message); }
910
898
 
911
899
  return jsonReply(res, 200, { ok: true, id });
@@ -930,16 +918,15 @@ const server = http.createServer(async (req, res) => {
930
918
  }
931
919
  if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
932
920
 
933
- let found = false;
934
- mutateJsonFileLocked(wiPath, (items) => {
935
- if (!Array.isArray(items)) items = [];
936
- const idx = items.findIndex(i => i.id === id);
937
- if (idx === -1) return items;
938
- found = true;
939
- items.splice(idx, 1);
940
- return items;
941
- }, { defaultValue: [] });
942
- if (!found) return jsonReply(res, 404, { error: 'item not found' });
921
+ const items = JSON.parse(safeRead(wiPath) || '[]');
922
+ const idx = items.findIndex(i => i.id === id);
923
+ if (idx === -1) return jsonReply(res, 404, { error: 'item not found' });
924
+
925
+ const item = items[idx];
926
+
927
+ // Remove item from work-items file
928
+ items.splice(idx, 1);
929
+ safeWrite(wiPath, items);
943
930
 
944
931
  // Clean dispatch entries + kill running agent
945
932
  const dispatchRemoved = cleanDispatchEntries(d =>
@@ -950,12 +937,12 @@ const server = http.createServer(async (req, res) => {
950
937
  // Clean cooldown entries so item can be re-created immediately
951
938
  try {
952
939
  const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
953
- mutateJsonFileLocked(cooldownPath, (cooldowns) => {
954
- for (const key of Object.keys(cooldowns)) {
955
- if (key.includes(id)) delete cooldowns[key];
956
- }
957
- return cooldowns;
958
- });
940
+ const cooldowns = JSON.parse(safeRead(cooldownPath) || '{}');
941
+ let cleaned = false;
942
+ for (const key of Object.keys(cooldowns)) {
943
+ if (key.includes(id)) { delete cooldowns[key]; cleaned = true; }
944
+ }
945
+ if (cleaned) safeWrite(cooldownPath, cooldowns);
959
946
  } catch (e) { console.error('cooldown cleanup:', e.message); }
960
947
 
961
948
  invalidateStatusCache();
@@ -1037,6 +1024,9 @@ const server = http.createServer(async (req, res) => {
1037
1024
  // Write to central queue — agent decides which project
1038
1025
  wiPath = path.join(MINIONS_DIR, 'work-items.json');
1039
1026
  }
1027
+ let items = [];
1028
+ const existing = safeRead(wiPath);
1029
+ if (existing) { try { items = JSON.parse(existing); } catch {} }
1040
1030
  const id = 'W-' + shared.uid();
1041
1031
  const item = {
1042
1032
  id, title: body.title, type: body.type || 'implement',
@@ -1048,12 +1038,8 @@ const server = http.createServer(async (req, res) => {
1048
1038
  if (body.agents) item.agents = body.agents;
1049
1039
  if (body.references) item.references = body.references;
1050
1040
  if (body.acceptanceCriteria) item.acceptanceCriteria = body.acceptanceCriteria;
1051
- if (body.skipPr === true) item.skipPr = true;
1052
- mutateJsonFileLocked(wiPath, (items) => {
1053
- if (!Array.isArray(items)) items = [];
1054
- items.push(item);
1055
- return items;
1056
- }, { defaultValue: [] });
1041
+ items.push(item);
1042
+ safeWrite(wiPath, items);
1057
1043
  return jsonReply(res, 200, { ok: true, id });
1058
1044
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1059
1045
  }
@@ -1075,27 +1061,29 @@ const server = http.createServer(async (req, res) => {
1075
1061
  }
1076
1062
  if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
1077
1063
 
1078
- let result = null;
1079
- let error = null;
1080
- mutateJsonFileLocked(wiPath, (items) => {
1081
- if (!Array.isArray(items)) items = [];
1082
- const item = items.find(i => i.id === id);
1083
- if (!item) { error = 'item not found'; return items; }
1084
- if (item.status === 'dispatched') { error = 'Cannot edit dispatched items'; return items; }
1085
- if (title !== undefined) item.title = title;
1086
- if (description !== undefined) item.description = description;
1087
- if (type !== undefined) item.type = type;
1088
- if (priority !== undefined) item.priority = priority;
1089
- if (agent !== undefined) item.agent = agent || null;
1090
- if (body.references !== undefined) item.references = body.references;
1091
- if (body.acceptanceCriteria !== undefined) item.acceptanceCriteria = body.acceptanceCriteria;
1092
- if (body.skipPr !== undefined) item.skipPr = body.skipPr === true;
1093
- item.updatedAt = new Date().toISOString();
1094
- result = { ...item };
1095
- return items;
1096
- }, { defaultValue: [] });
1097
- if (error) return jsonReply(res, error === 'item not found' ? 404 : 400, { error });
1098
- return jsonReply(res, 200, { ok: true, item: result });
1064
+ const items = JSON.parse(safeRead(wiPath) || '[]');
1065
+ const item = items.find(i => i.id === id);
1066
+ if (!item) return jsonReply(res, 404, { error: 'item not found' });
1067
+
1068
+ if (item.status === 'dispatched') {
1069
+ return jsonReply(res, 400, { error: 'Cannot edit dispatched items' });
1070
+ }
1071
+
1072
+ if (title !== undefined) item.title = title;
1073
+ if (description !== undefined) item.description = description;
1074
+ if (type !== undefined) item.type = type;
1075
+ if (priority !== undefined) item.priority = priority;
1076
+ if (agent !== undefined) {
1077
+ item.agent = agent || null;
1078
+ // Clear stale pending dispatch entries so the engine re-queues with the new agent
1079
+ cleanDispatchEntries(d => d.meta?.item?.id === id);
1080
+ }
1081
+ if (body.references !== undefined) item.references = body.references;
1082
+ if (body.acceptanceCriteria !== undefined) item.acceptanceCriteria = body.acceptanceCriteria;
1083
+ item.updatedAt = new Date().toISOString();
1084
+
1085
+ safeWrite(wiPath, items);
1086
+ return jsonReply(res, 200, { ok: true, item });
1099
1087
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1100
1088
  }
1101
1089
 
@@ -1179,18 +1167,24 @@ const server = http.createServer(async (req, res) => {
1179
1167
  const item = (plan.missing_features || []).find(f => f.id === body.itemId);
1180
1168
  if (!item) return jsonReply(res, 404, { error: 'item not found in plan' });
1181
1169
 
1182
- // Update plan item under lock
1183
- mutateJsonFileLocked(planPath, (freshPlan) => {
1184
- const freshItem = (freshPlan.missing_features || []).find(f => f.id === body.itemId);
1185
- if (freshItem) {
1186
- if (body.name !== undefined) freshItem.name = body.name;
1187
- if (body.description !== undefined) freshItem.description = body.description;
1188
- if (body.priority !== undefined) freshItem.priority = body.priority;
1189
- if (body.estimated_complexity !== undefined) freshItem.estimated_complexity = body.estimated_complexity;
1190
- if (body.status !== undefined) freshItem.status = body.status;
1191
- }
1192
- return freshPlan;
1193
- });
1170
+ // Update allowed fields
1171
+ if (body.name !== undefined) item.name = body.name;
1172
+ if (body.description !== undefined) item.description = body.description;
1173
+ if (body.priority !== undefined) item.priority = body.priority;
1174
+ if (body.estimated_complexity !== undefined) item.estimated_complexity = body.estimated_complexity;
1175
+ if (body.status !== undefined) item.status = body.status;
1176
+
1177
+ // Re-read plan before writing to minimize race window with engine
1178
+ const freshPlan = safeJson(planPath) || plan;
1179
+ const freshItem = (freshPlan.missing_features || []).find(f => f.id === body.itemId);
1180
+ if (freshItem) {
1181
+ if (body.name !== undefined) freshItem.name = body.name;
1182
+ if (body.description !== undefined) freshItem.description = body.description;
1183
+ if (body.priority !== undefined) freshItem.priority = body.priority;
1184
+ if (body.estimated_complexity !== undefined) freshItem.estimated_complexity = body.estimated_complexity;
1185
+ if (body.status !== undefined) freshItem.status = body.status;
1186
+ }
1187
+ safeWrite(planPath, freshPlan);
1194
1188
 
1195
1189
  // Feature 3: Sync edits to materialized work item if still pending
1196
1190
  let workItemSynced = false;
@@ -1200,20 +1194,18 @@ const server = http.createServer(async (req, res) => {
1200
1194
  }
1201
1195
  for (const wiPath of wiSyncPaths) {
1202
1196
  try {
1203
- mutateJsonFileLocked(wiPath, (items) => {
1204
- if (!Array.isArray(items)) return items;
1205
- const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
1206
- if (wi && wi.status === 'pending') {
1207
- if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
1208
- if (body.description !== undefined) wi.description = body.description;
1209
- if (body.priority !== undefined) wi.priority = body.priority;
1210
- if (body.estimated_complexity !== undefined) {
1211
- wi.type = body.estimated_complexity === 'large' ? 'implement:large' : 'implement';
1212
- }
1213
- workItemSynced = true;
1197
+ const items = safeJson(wiPath);
1198
+ const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
1199
+ if (wi && wi.status === 'pending') {
1200
+ if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
1201
+ if (body.description !== undefined) wi.description = body.description;
1202
+ if (body.priority !== undefined) wi.priority = body.priority;
1203
+ if (body.estimated_complexity !== undefined) {
1204
+ wi.type = body.estimated_complexity === 'large' ? 'implement:large' : 'implement';
1214
1205
  }
1215
- return items;
1216
- }, { defaultValue: [] });
1206
+ safeWrite(wiPath, items);
1207
+ workItemSynced = true;
1208
+ }
1217
1209
  } catch (e) { console.error('work item sync:', e.message); }
1218
1210
  }
1219
1211
 
@@ -1348,7 +1340,6 @@ const server = http.createServer(async (req, res) => {
1348
1340
 
1349
1341
  // Watch for changes using fs.watchFile (cross-platform, works on Windows)
1350
1342
  const watcher = () => {
1351
- if (res.writableEnded) return;
1352
1343
  try {
1353
1344
  const stat = fs.statSync(liveLogPath);
1354
1345
  if (stat.size > offset) {
@@ -1367,17 +1358,14 @@ const server = http.createServer(async (req, res) => {
1367
1358
 
1368
1359
  // Check if agent is still active (poll every 5s)
1369
1360
  const doneCheck = setInterval(() => {
1370
- if (res.writableEnded) { clearInterval(doneCheck); return; }
1371
1361
  const dispatch = getDispatchQueue();
1372
1362
  const isActive = (dispatch.active || []).some(d => d.agent === agentId);
1373
1363
  if (!isActive) {
1374
1364
  watcher(); // flush final content
1365
+ res.write(`event: done\ndata: complete\n\n`);
1375
1366
  clearInterval(doneCheck);
1376
1367
  fs.unwatchFile(liveLogPath, watcher);
1377
- if (!res.writableEnded) {
1378
- res.write(`event: done\ndata: complete\n\n`);
1379
- res.end();
1380
- }
1368
+ res.end();
1381
1369
  }
1382
1370
  }, 5000);
1383
1371
 
@@ -1401,7 +1389,7 @@ const server = http.createServer(async (req, res) => {
1401
1389
  } else {
1402
1390
  // Return last N bytes via ?tail=N param (default last 8KB)
1403
1391
  const params = new URL(req.url, 'http://localhost').searchParams;
1404
- const tailBytes = Math.min(parseInt(params.get('tail')) || 8192, 1024 * 1024);
1392
+ const tailBytes = parseInt(params.get('tail')) || 8192;
1405
1393
  res.end(content.length > tailBytes ? content.slice(-tailBytes) : content);
1406
1394
  }
1407
1395
  return;
@@ -1659,13 +1647,12 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
1659
1647
  file: f, format: 'draft', archived,
1660
1648
  project: projectMatch ? projectMatch[1].trim() : '',
1661
1649
  summary: titleMatch ? titleMatch[1].trim() : f.replace('.md', ''),
1662
- status: archived ? 'completed' : completedPrdFiles.has(f) ? 'approved' : 'active',
1650
+ status: archived ? 'completed' : completedPrdFiles.has(f) ? 'converted' : 'draft',
1663
1651
  branchStrategy: '',
1664
1652
  featureBranch: '',
1665
1653
  itemCount: (content.match(/^\d+\.\s+\*\*/gm) || []).length,
1666
1654
  generatedBy: authorMatch ? authorMatch[1].trim() : '',
1667
1655
  generatedAt: dateMatch ? dateMatch[1].trim() : '',
1668
- completedAt: archived ? updatedAt : '',
1669
1656
  updatedAt,
1670
1657
  requiresApproval: false,
1671
1658
  revisionFeedback: null,
@@ -1874,7 +1861,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
1874
1861
  for (const w of items) {
1875
1862
  if (w.sourcePlan !== body.file) continue;
1876
1863
  // Keep completed items as-is, reset everything else to pending.
1877
- if (w.status === 'done') continue;
1864
+ if (w.status === 'done' || w.status === 'implemented' || w.status === 'complete' || w.status === 'in-pr') continue;
1878
1865
 
1879
1866
  if (w.status === 'dispatched') {
1880
1867
  // Kill the agent working on this item, if any.
@@ -2276,6 +2263,148 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2276
2263
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2277
2264
  }
2278
2265
 
2266
+ // POST /api/plans/revise-and-regenerate — REMOVED: plan versioning now handled by /api/doc-chat
2267
+ // The "Replace old PRD" flow uses qaReplacePrd (frontend) which calls /api/plans/pause + /api/plans/regenerate + planExecute
2268
+ async function handlePlansReviseAndRegenerate(req, res) {
2269
+ try {
2270
+ const body = await readBody(req);
2271
+ if (!body.source || !body.instruction) return jsonReply(res, 400, { error: 'source and instruction required' });
2272
+
2273
+ // Find the source plan .md file for this PRD
2274
+ // Convention: PRD JSON references plan via plan_summary containing the work item ID,
2275
+ // or the .md file has a matching name prefix
2276
+ const prdPath = path.join(PRD_DIR, body.source);
2277
+ if (!fs.existsSync(prdPath)) return jsonReply(res, 404, { error: 'PRD file not found' });
2278
+
2279
+ // Look for corresponding .md plan file
2280
+ let sourcePlanFile = null;
2281
+ const planFiles = safeReadDir(PLANS_DIR).filter(f => f.endsWith('.md'));
2282
+ if (body.sourcePlan) {
2283
+ // Explicit source plan provided
2284
+ sourcePlanFile = body.sourcePlan;
2285
+ } else {
2286
+ // Heuristic: find .md plan by matching prefix or by reading PRD's generated_from field
2287
+ const prd = JSON.parse(safeRead(prdPath) || '{}');
2288
+ if (prd.source_plan) {
2289
+ sourcePlanFile = prd.source_plan;
2290
+ } else {
2291
+ // Match by prefix: officeagent-2026-03-15.json → plan-*officeagent* or plan-w025*.md
2292
+ const prdBase = body.source.replace('.json', '');
2293
+ for (const f of planFiles) {
2294
+ // Check if plan file mentions the same project or was created around same time
2295
+ const content = safeRead(path.join(PLANS_DIR, f)) || '';
2296
+ if (content.includes(prd.project || '___nomatch___') || content.includes(prd.plan_summary?.slice(0, 40) || '___nomatch___')) {
2297
+ sourcePlanFile = f;
2298
+ break;
2299
+ }
2300
+ }
2301
+ // Last resort: most recent .md plan
2302
+ if (!sourcePlanFile && planFiles.length > 0) {
2303
+ sourcePlanFile = planFiles.sort((a, b) => {
2304
+ try { return fs.statSync(path.join(PLANS_DIR, b)).mtimeMs - fs.statSync(path.join(PLANS_DIR, a)).mtimeMs; } catch { return 0; }
2305
+ })[0];
2306
+ }
2307
+ }
2308
+ }
2309
+
2310
+ if (!sourcePlanFile) {
2311
+ return jsonReply(res, 404, { error: 'No source plan (.md) found for this PRD. You can edit the PRD JSON directly using "Edit Plan".' });
2312
+ }
2313
+
2314
+ const sourcePlanPath = path.join(PLANS_DIR, sourcePlanFile);
2315
+ const planContent = safeRead(sourcePlanPath);
2316
+ if (!planContent) return jsonReply(res, 404, { error: 'Source plan file not readable: ' + sourcePlanFile });
2317
+
2318
+ // Step 1: Steer the source plan with the user's instruction via CC
2319
+ const result = await ccDocCall({
2320
+ message: body.instruction,
2321
+ document: planContent,
2322
+ title: sourcePlanFile,
2323
+ filePath: 'plans/' + sourcePlanFile,
2324
+ selection: body.selection || '',
2325
+ canEdit: true,
2326
+ isJson: false,
2327
+ });
2328
+
2329
+ if (!result.content) {
2330
+ return jsonReply(res, 200, { ok: true, answer: result.answer, updated: false });
2331
+ }
2332
+
2333
+ // Save the revised plan
2334
+ safeWrite(sourcePlanPath, result.content);
2335
+
2336
+ // Step 2: Pause the old PRD so it stops materializing items
2337
+ const prd = JSON.parse(safeRead(prdPath) || '{}');
2338
+ prd.status = 'revision-requested';
2339
+ prd.revision_feedback = body.instruction;
2340
+ prd.revisionRequestedAt = new Date().toISOString();
2341
+ safeWrite(prdPath, prd);
2342
+
2343
+ // Step 3: Clean up pending/failed work items from old PRD
2344
+ let reset = 0, kept = 0;
2345
+ const wiPaths = [{ path: path.join(MINIONS_DIR, 'work-items.json'), label: 'central' }];
2346
+ for (const proj of PROJECTS) {
2347
+ wiPaths.push({ path: shared.projectWorkItemsPath(proj), label: proj.name });
2348
+ }
2349
+ const deletedItemIds = [];
2350
+ for (const wiInfo of wiPaths) {
2351
+ try {
2352
+ const items = safeJson(wiInfo.path);
2353
+ const filtered = [];
2354
+ for (const w of items) {
2355
+ if (w.sourcePlan === body.source) {
2356
+ if (w.status === 'pending' || w.status === 'failed') {
2357
+ reset++;
2358
+ deletedItemIds.push(w.id);
2359
+ } else {
2360
+ kept++;
2361
+ filtered.push(w);
2362
+ }
2363
+ } else {
2364
+ filtered.push(w);
2365
+ }
2366
+ }
2367
+ if (filtered.length < items.length) safeWrite(wiInfo.path, filtered);
2368
+ } catch (e) { console.error('work item deletion:', e.message); }
2369
+ }
2370
+ for (const itemId of deletedItemIds) {
2371
+ cleanDispatchEntries(d =>
2372
+ d.meta?.item?.sourcePlan === body.source && d.meta?.item?.id === itemId
2373
+ );
2374
+ }
2375
+
2376
+ // Step 4: Dispatch plan-to-prd to regenerate PRD from revised plan
2377
+ const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
2378
+ let centralItems = [];
2379
+ try { centralItems = JSON.parse(safeRead(centralWiPath) || '[]'); } catch {}
2380
+ const wiId = 'W-' + shared.uid();
2381
+ centralItems.push({
2382
+ id: wiId,
2383
+ title: 'Regenerate PRD from revised plan: ' + sourcePlanFile,
2384
+ type: 'plan-to-prd',
2385
+ priority: 'high',
2386
+ description: `The source plan \`${sourcePlanFile}\` has been revised. Convert it into a fresh PRD JSON.\n\nRevision instruction: ${body.instruction}\n\nRead the revised plan, generate updated PRD items (missing_features), and write to \`prd/${body.source}\`. Set status to "approved". Include \`"source_plan": "${sourcePlanFile}"\` in the JSON root.\n\nPreserve items that are already done (status "implemented" or "complete"). Reset or replace items that were pending/failed.`,
2387
+ status: 'pending',
2388
+ created: new Date().toISOString(),
2389
+ createdBy: 'dashboard:revise-and-regenerate',
2390
+ project: prd.project || '',
2391
+ planFile: sourcePlanFile,
2392
+ });
2393
+ safeWrite(centralWiPath, centralItems);
2394
+
2395
+ return jsonReply(res, 200, {
2396
+ ok: true,
2397
+ answer: result.answer,
2398
+ updated: true,
2399
+ sourcePlan: sourcePlanFile,
2400
+ prdPaused: true,
2401
+ reset,
2402
+ kept,
2403
+ workItemId: wiId,
2404
+ });
2405
+ } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2406
+ }
2407
+
2279
2408
  async function handlePlansDiscuss(req, res) {
2280
2409
  try {
2281
2410
  const body = await readBody(req);
@@ -2445,7 +2574,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2445
2574
  let changed = false;
2446
2575
  for (const w of items) {
2447
2576
  if (w.sourcePlan !== f) continue;
2448
- if (w.status === 'done') continue;
2577
+ if (w.status === 'done' || w.status === 'implemented' || w.status === 'complete' || w.status === 'in-pr') continue;
2449
2578
  if (w.status === 'dispatched') {
2450
2579
  const activeEntry = (dispatch.active || []).find(d => d.meta?.item?.id === w.id || d.meta?.dispatchKey?.includes(w.id));
2451
2580
  if (activeEntry) {
@@ -2711,12 +2840,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2711
2840
  const { execSync: ex } = require('child_process');
2712
2841
  const detected = { name: path.basename(target), _found: [] };
2713
2842
  try {
2714
- let head = '';
2715
- try {
2716
- head = ex('git symbolic-ref refs/remotes/origin/HEAD', { cwd: target, encoding: 'utf8', timeout: 5000 }).trim();
2717
- } catch {
2718
- head = ex('git symbolic-ref HEAD', { cwd: target, encoding: 'utf8', timeout: 5000 }).trim();
2719
- }
2843
+ const head = ex('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || git symbolic-ref HEAD', { cwd: target, encoding: 'utf8', timeout: 5000 }).trim();
2720
2844
  detected.mainBranch = head.replace('refs/remotes/origin/', '').replace('refs/heads/', '');
2721
2845
  } catch { detected.mainBranch = 'main'; }
2722
2846
  try {
@@ -2795,7 +2919,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2795
2919
 
2796
2920
  // Concurrency guard — only one CC call at a time, with auto-release for stuck requests
2797
2921
  if (ccInFlight && (Date.now() - ccInFlightSince) < CC_INFLIGHT_TIMEOUT_MS) {
2798
- return jsonReply(res, 429, { error: 'Command Center is busy — wait for the current request to finish.' });
2922
+ return jsonReply(res, 429, { error: 'Command Center is busy — wait for the current request to finish, or click "New Session" to reset.' });
2799
2923
  }
2800
2924
  if (ccInFlight) console.log('[CC] Auto-releasing stuck in-flight guard after timeout');
2801
2925
  ccInFlight = true;
@@ -2960,6 +3084,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2960
3084
  if (body.engine) {
2961
3085
  const e = body.engine;
2962
3086
  const D = shared.ENGINE_DEFAULTS;
3087
+ // Numeric fields: { key: [min, max?] }
2963
3088
  const numericFields = {
2964
3089
  tickInterval: [10000], maxConcurrent: [1, 10], inboxConsolidateThreshold: [1],
2965
3090
  agentTimeout: [60000], maxTurns: [5, 500], heartbeatTimeout: [60000],
@@ -2975,22 +3100,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2975
3100
  config.engine[key] = val;
2976
3101
  }
2977
3102
  }
3103
+ // String fields
2978
3104
  if (e.worktreeRoot !== undefined) config.engine.worktreeRoot = String(e.worktreeRoot || D.worktreeRoot);
2979
- for (const key of ['autoApprovePlans', 'evalLoop', 'autoDecompose', 'allowTempAgents']) {
3105
+ // Boolean fields
3106
+ for (const key of ['autoApprovePlans', 'autoReview', 'autoDecompose', 'allowTempAgents']) {
2980
3107
  if (e[key] !== undefined) config.engine[key] = !!e[key];
2981
3108
  }
2982
- if (e.evalMaxIterations !== undefined) config.engine.evalMaxIterations = Math.max(1, Math.min(10, Number(e.evalMaxIterations) || D.evalMaxIterations));
2983
- if (e.evalMaxCost !== undefined) config.engine.evalMaxCost = e.evalMaxCost === null || e.evalMaxCost === '' ? null : Math.max(0, Number(e.evalMaxCost) || 0);
2984
3109
  }
2985
3110
 
2986
3111
  if (body.claude) {
2987
3112
  for (const key of ['allowedTools', 'outputFormat']) {
2988
3113
  if (body.claude[key] !== undefined) config.claude[key] = String(body.claude[key]);
2989
3114
  }
2990
- if (body.claude.permissionMode !== undefined) {
2991
- const valid = ['bypassPermissions', 'auto', 'default'];
2992
- config.claude.permissionMode = valid.includes(body.claude.permissionMode) ? body.claude.permissionMode : 'bypassPermissions';
2993
- }
2994
3115
  }
2995
3116
 
2996
3117
  if (body.agents) {
@@ -2998,11 +3119,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2998
3119
  if (!config.agents[id]) continue;
2999
3120
  if (updates.role !== undefined) config.agents[id].role = String(updates.role);
3000
3121
  if (updates.skills !== undefined) config.agents[id].skills = Array.isArray(updates.skills) ? updates.skills : String(updates.skills).split(',').map(s => s.trim()).filter(Boolean);
3001
- if (updates.monthlyBudgetUsd !== undefined) {
3002
- const val = updates.monthlyBudgetUsd === '' || updates.monthlyBudgetUsd === null ? undefined : Number(updates.monthlyBudgetUsd);
3003
- if (val === undefined || isNaN(val)) delete config.agents[id].monthlyBudgetUsd;
3004
- else config.agents[id].monthlyBudgetUsd = Math.max(0, val);
3005
- }
3006
3122
  }
3007
3123
  }
3008
3124
 
@@ -3090,7 +3206,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3090
3206
  }},
3091
3207
 
3092
3208
  // Work items
3093
- { method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?, references?, acceptanceCriteria?, skipPr?', handler: handleWorkItemsCreate },
3209
+ { method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?, references?, acceptanceCriteria?', handler: handleWorkItemsCreate },
3094
3210
  { method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, references?, acceptanceCriteria?', handler: handleWorkItemsUpdate },
3095
3211
  { method: 'POST', path: '/api/work-items/retry', desc: 'Reset a failed/dispatched item to pending', params: 'id, source?', handler: handleWorkItemsRetry },
3096
3212
  { method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
@@ -3103,34 +3219,24 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3103
3219
  const projects = shared.getProjects(CONFIG);
3104
3220
  const paths = [path.join(MINIONS_DIR, 'work-items.json')];
3105
3221
  for (const p of projects) paths.push(shared.projectWorkItemsPath(p));
3106
- let found = false;
3107
- let feedbackAgent = 'unknown';
3108
- let feedbackTitle = id;
3109
3222
  for (const wiPath of paths) {
3110
- try {
3111
- mutateJsonFileLocked(wiPath, (items) => {
3112
- if (!Array.isArray(items)) return items;
3113
- const item = items.find(i => i.id === id);
3114
- if (!item) return items;
3115
- found = true;
3116
- item._humanFeedback = { rating, comment: comment || '', at: new Date().toISOString() };
3117
- feedbackAgent = item.dispatched_to || item.agent || 'unknown';
3118
- feedbackTitle = item.title || id;
3119
- return items;
3120
- }, { defaultValue: [] });
3121
- } catch { /* optional */ }
3122
- if (found) break;
3123
- }
3124
- if (!found) return jsonReply(res, 404, { error: 'Work item not found' });
3125
- const feedbackNote = '# Human Feedback on ' + id + '\n\n' +
3126
- '**Rating:** ' + (rating === 'up' ? '👍 Good' : '👎 Needs improvement') + '\n' +
3127
- '**Item:** ' + feedbackTitle + '\n' +
3128
- '**Agent:** ' + feedbackAgent + '\n' +
3129
- (comment ? '**Feedback:** ' + comment + '\n' : '');
3130
- const inboxPath = path.join(MINIONS_DIR, 'notes', 'inbox', feedbackAgent + '-feedback-' + new Date().toISOString().slice(0, 10) + '-' + shared.uid().slice(0, 4) + '.md');
3131
- safeWrite(inboxPath, feedbackNote);
3132
- invalidateStatusCache();
3133
- return jsonReply(res, 200, { ok: true });
3223
+ const items = JSON.parse(safeRead(wiPath) || '[]');
3224
+ const item = items.find(i => i.id === id);
3225
+ if (!item) continue;
3226
+ item._humanFeedback = { rating, comment: comment || '', at: new Date().toISOString() };
3227
+ safeWrite(wiPath, items);
3228
+ const agent = item.dispatched_to || item.agent || 'unknown';
3229
+ const feedbackNote = '# Human Feedback on ' + id + '\n\n' +
3230
+ '**Rating:** ' + (rating === 'up' ? '👍 Good' : '👎 Needs improvement') + '\n' +
3231
+ '**Item:** ' + (item.title || id) + '\n' +
3232
+ '**Agent:** ' + agent + '\n' +
3233
+ (comment ? '**Feedback:** ' + comment + '\n' : '');
3234
+ const inboxPath = path.join(MINIONS_DIR, 'notes', 'inbox', agent + '-feedback-' + new Date().toISOString().slice(0, 10) + '-' + shared.uid().slice(0, 4) + '.md');
3235
+ safeWrite(inboxPath, feedbackNote);
3236
+ invalidateStatusCache();
3237
+ return jsonReply(res, 200, { ok: true });
3238
+ }
3239
+ return jsonReply(res, 404, { error: 'Work item not found' });
3134
3240
  }},
3135
3241
 
3136
3242
  // Pinned notes
@@ -3179,10 +3285,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3179
3285
  { method: 'POST', path: '/api/plans/reject', desc: 'Reject a plan', params: 'file, rejectedBy?, reason?', handler: handlePlansReject },
3180
3286
  { method: 'POST', path: '/api/plans/regenerate', desc: 'Reset pending/failed work items for a plan so they re-materialize', params: 'source', handler: handlePlansRegenerate },
3181
3287
  { method: 'POST', path: '/api/plans/delete', desc: 'Delete a plan file and clean up work items', params: 'file', handler: handlePlansDelete },
3182
- { method: 'POST', path: '/api/plans/archive', desc: 'Archive a plan/PRD (move to archive folder, also archives source .md plan if PRD)', params: 'file', handler: handlePlansArchiveMove },
3288
+ { method: 'POST', path: '/api/plans/archive', desc: 'Move a plan/PRD to archive (preserves work items)', params: 'file', handler: handlePlansArchive },
3183
3289
  { method: 'POST', path: '/api/plans/unarchive', desc: 'Restore a plan/PRD from archive', params: 'file', handler: handlePlansUnarchive },
3184
3290
  { method: 'POST', path: '/api/plans/revise', desc: 'Request revision with feedback, dispatches agent to revise', params: 'file, feedback, requestedBy?', handler: handlePlansRevise },
3185
3291
  { method: 'POST', path: '/api/plans/discuss', desc: 'Generate a plan discussion session script for Claude CLI', params: 'file', handler: handlePlansDiscuss },
3292
+ { method: 'POST', path: '/api/plans/archive', desc: 'Archive a plan/PRD (move to archive folder)', params: 'file', handler: handlePlansArchiveMove },
3293
+ { method: 'POST', path: '/api/plans/unarchive', desc: 'Unarchive a plan/PRD (restore from archive folder)', params: 'file', handler: handlePlansUnarchive },
3186
3294
  { method: 'GET', path: /^\/api\/plans\/archive\/([^?]+)$/, desc: 'Read an archived plan file', handler: handlePlansArchiveRead },
3187
3295
  { method: 'GET', path: /^\/api\/plans\/([^?]+)$/, desc: 'Read a full plan (JSON from prd/ or markdown from plans/)', handler: handlePlansRead },
3188
3296
 
@@ -3502,7 +3610,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3502
3610
 
3503
3611
  const pathname = req.url.split('?')[0];
3504
3612
  const _reqStart = Date.now();
3505
- try {
3506
3613
  for (const route of ROUTES) {
3507
3614
  if (route.method !== req.method) continue;
3508
3615
  if (typeof route.path === 'string') {
@@ -3548,12 +3655,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3548
3655
  } else {
3549
3656
  res.end(HTML);
3550
3657
  }
3551
- } catch (err) {
3552
- console.error(`[ERROR] ${req.method} ${req.url}: ${err.message}`);
3553
- if (!res.headersSent) {
3554
- try { jsonReply(res, 500, { error: 'Internal server error' }); } catch { res.end(); }
3555
- }
3556
- }
3557
3658
  });
3558
3659
 
3559
3660
  server.listen(PORT, '127.0.0.1', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.208",
3
+ "version": "0.1.210",
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"