@yemi33/minions 0.1.94 → 0.1.96

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,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.96 (2026-04-01)
4
+
5
+ ### Engine
6
+ - engine.js
7
+ - engine/ado.js
8
+ - engine/github.js
9
+ - engine/queries.js
10
+
11
+ ### Dashboard
12
+ - dashboard.js
13
+ - dashboard/js/detail-panel.js
14
+ - dashboard/js/live-stream.js
15
+ - dashboard/js/modal-qa.js
16
+ - dashboard/js/render-agents.js
17
+ - dashboard/js/render-inbox.js
18
+ - dashboard/js/render-meetings.js
19
+ - dashboard/js/render-pinned.js
20
+ - dashboard/js/render-work-items.js
21
+ - dashboard/js/utils.js
22
+
23
+ ### Playbooks
24
+ - meeting-conclude.md
25
+
26
+ ## 0.1.95 (2026-04-01)
27
+
28
+ ### Dashboard
29
+ - dashboard/js/render-plans.js
30
+
3
31
  ## 0.1.94 (2026-04-01)
4
32
 
5
33
  ### Dashboard
@@ -44,21 +44,21 @@ function renderDetailContent(detail, tab) {
44
44
  if (detail.statusData.completed_at) html += 'Completed: ' + detail.statusData.completed_at + '\n';
45
45
  html += '</div>';
46
46
  if (detail.statusData.resultSummary) {
47
- html += '<h4>Last Result</h4><div class="section" style="border-left:3px solid var(--green);padding-left:12px">' + escHtml(detail.statusData.resultSummary) + '</div>';
47
+ html += '<h4>Last Result</h4><div class="section" style="border-left:3px solid var(--green);padding-left:12px">' + renderMd(detail.statusData.resultSummary) + '</div>';
48
48
  }
49
49
  }
50
50
 
51
51
  if (detail.inboxContents && detail.inboxContents.length > 0) {
52
52
  html += '<h4>Notes & Findings (' + detail.inboxContents.length + ')</h4>';
53
53
  detail.inboxContents.forEach(item => {
54
- html += '<div class="section"><strong style="color:var(--purple)">' + escHtml(item.name) + '</strong>\n\n' + escHtml(item.content) + '</div>';
54
+ html += '<div class="section"><strong style="color:var(--purple)">' + escHtml(item.name) + '</strong><div style="margin-top:4px">' + renderMd(item.content) + '</div></div>';
55
55
  });
56
56
  } else {
57
57
  html += '<h4>Notes & Findings</h4><div class="section" style="color:var(--muted);font-style:italic">No notes or findings written yet.</div>';
58
58
  }
59
59
 
60
60
  if (detail.outputLog) {
61
- html += '<h4>Latest Output</h4><div class="section">' + escHtml(detail.outputLog) + '</div>';
61
+ html += '<h4>Latest Output</h4><div class="section">' + renderMd(detail.outputLog) + '</div>';
62
62
  }
63
63
 
64
64
  el.innerHTML = html;
@@ -79,7 +79,7 @@ function renderDetailContent(detail, tab) {
79
79
  '</div>';
80
80
  startLiveStream(currentAgentId);
81
81
  } else if (tab === 'charter') {
82
- el.innerHTML = '<div class="section">' + escHtml(detail.charter || 'No charter found.') + '</div>';
82
+ el.innerHTML = '<div class="section">' + renderMd(detail.charter || 'No charter found.') + '</div>';
83
83
  } else if (tab === 'history') {
84
84
  let html = '';
85
85
  // Recent dispatch results
@@ -14,7 +14,7 @@ function renderLiveChatMessage(raw) {
14
14
  el.innerHTML += '<div style="font-size:10px;color:var(--muted);padding:2px 8px;font-style:italic">\u{1F4AD} Thinking...</div>';
15
15
  }
16
16
  if (block.type === 'text' && block.text) {
17
- el.innerHTML += '<div style="background:var(--surface2);padding:8px 12px;border-radius:12px 12px 12px 2px;max-width:90%;margin:4px 0;font-size:12px;white-space:pre-wrap;word-break:break-word">' + escHtml(block.text) + '</div>';
17
+ el.innerHTML += '<div style="background:var(--surface2);padding:8px 12px;border-radius:12px 12px 12px 2px;max-width:90%;margin:4px 0;font-size:12px;word-break:break-word">' + renderMd(block.text) + '</div>';
18
18
  }
19
19
  if (block.type === 'tool_use') {
20
20
  el.innerHTML += '<div style="background:var(--surface);border:1px solid var(--border);padding:4px 8px;border-radius:4px;margin:2px 0;font-size:10px;color:var(--muted);cursor:pointer" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display===\'none\'?\'block\':\'none\'">' +
@@ -201,7 +201,7 @@ async function _processQaMessage(message, selection) {
201
201
  const suffix = data.edited ? '\n\n\u2713 Document saved.' : '';
202
202
  const qaElapsed = Math.round((Date.now() - qaStartTime) / 1000);
203
203
  const qaTimeLabel = '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right">' + qaElapsed + 's</div>';
204
- thread.innerHTML += '<div class="modal-qa-a" style="border-left-color:' + borderColor + '">' + llmCopyBtn() + escHtml(data.answer + suffix) + qaTimeLabel + '</div>';
204
+ thread.innerHTML += '<div class="modal-qa-a" style="border-left-color:' + borderColor + '">' + llmCopyBtn() + renderMd(data.answer + suffix) + qaTimeLabel + '</div>';
205
205
 
206
206
  // Track conversation history
207
207
  _qaHistory.push({ role: 'user', text: message });
@@ -215,7 +215,15 @@ async function _processQaMessage(message, selection) {
215
215
  // Refresh modal body if document was edited
216
216
  if (data.edited && data.content) {
217
217
  const display = data.content.replace(/^---[\s\S]*?---\n*/m, '');
218
- document.getElementById('modal-body').textContent = display;
218
+ const isJson = capturedFilePath && capturedFilePath.endsWith('.json');
219
+ const body = document.getElementById('modal-body');
220
+ if (isJson) {
221
+ body.textContent = display;
222
+ } else {
223
+ body.innerHTML = '<div style="font-size:12px;line-height:1.6">' + renderMd(display) + '</div>';
224
+ body.style.fontFamily = "'Segoe UI', system-ui, sans-serif";
225
+ body.style.whiteSpace = 'normal';
226
+ }
219
227
  _modalDocContext.content = display;
220
228
  }
221
229
 
@@ -11,7 +11,7 @@ function renderAgents(agents) {
11
11
  </div>
12
12
  <div class="agent-role">${a.role}</div>
13
13
  <div class="agent-action" title="${escHtml(a.lastAction)}">${escHtml(a.lastAction)}</div>
14
- ${a.resultSummary ? `<div class="agent-result" title="${escHtml(a.resultSummary)}">${escHtml(a.resultSummary.slice(0, 200))}${a.resultSummary.length > 200 ? '...' : ''}</div>` : ''}
14
+ ${a.resultSummary ? `<div class="agent-result" title="${escHtml(a.resultSummary)}">${renderMd(a.resultSummary.slice(0, 200))}${a.resultSummary.length > 200 ? '...' : ''}</div>` : ''}
15
15
  </div>
16
16
  `).join('');
17
17
  }
@@ -29,7 +29,7 @@ async function openAgentDetail(id) {
29
29
  document.getElementById('detail-status-line').innerHTML =
30
30
  '<span class="status-badge ' + badgeClass + '">' + agent.status.toUpperCase() + '</span> ' +
31
31
  '<span style="color:var(--muted)">' + escHtml(agent.lastAction) + '</span>' +
32
- (agent.resultSummary ? '<div style="margin-top:4px;font-size:11px;color:var(--text);line-height:1.4">' + escHtml(agent.resultSummary.slice(0, 300)) + '</div>' : '');
32
+ (agent.resultSummary ? '<div style="margin-top:4px;font-size:11px;color:var(--text);line-height:1.4">' + renderMd(agent.resultSummary.slice(0, 300)) + '</div>' : '');
33
33
 
34
34
  try {
35
35
  const detail = await fetch('/api/agent/' + id).then(r => r.json());
@@ -55,7 +55,7 @@ function renderNotes(notes) {
55
55
  }
56
56
 
57
57
  if (!content || !content.trim()) { el.innerHTML = '<p class="empty">No team notes yet.</p>'; return; }
58
- el.innerHTML = '<div class="notes-preview" onclick="openNotesModal()" title="Click to expand">' + escHtml(content) + '</div>';
58
+ el.innerHTML = '<div class="notes-preview" onclick="openNotesModal()" title="Click to expand">' + renderMd(content) + '</div>';
59
59
  }
60
60
 
61
61
  function openNotesModal() {
@@ -136,10 +136,16 @@ function openMeetingDetail(id) {
136
136
  '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red);border-color:var(--red)" onclick="_deleteMeeting(\'' + escHtml(m.id) + '\')">Delete</button>' +
137
137
  '</div>';
138
138
  } else if (m.status === 'completed') {
139
- html += '<div style="display:flex;gap:8px;border-top:1px solid var(--border);padding-top:8px">' +
139
+ const linkedPlan = _findLinkedPlan(m);
140
+ html += '<div style="display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid var(--border);padding-top:8px">' +
141
+ (linkedPlan
142
+ ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--blue);border-color:var(--blue)" onclick="_viewPlanWithBack(\'' + escHtml(linkedPlan.file) + '\',\'' + escHtml(m.id) + '\')">View Plan</button>' +
143
+ '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green);border-color:var(--green)" onclick="_createPlanFromMeeting(\'' + escHtml(m.id) + '\',this)">New Plan</button>'
144
+ : '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green);border-color:var(--green)" onclick="_createPlanFromMeeting(\'' + escHtml(m.id) + '\',this)">Create Plan from Meeting</button>') +
140
145
  '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" onclick="_archiveMeeting(\'' + escHtml(m.id) + '\')">Archive</button>' +
141
146
  '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red);border-color:var(--red)" onclick="_deleteMeeting(\'' + escHtml(m.id) + '\')">Delete</button>' +
142
- '</div>';
147
+ '</div>' +
148
+ '<div style="font-size:9px;color:var(--muted);margin-top:4px">Use the Q&amp;A below to discuss action items' + (linkedPlan ? '' : ', then create a plan to execute them') + '.</div>';
143
149
  } else {
144
150
  html += '<div style="display:flex;gap:8px;border-top:1px solid var(--border);padding-top:8px">' +
145
151
  '<input id="meeting-note-input" type="text" placeholder="Add context for all agents..." style="flex:1;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:12px" onkeydown="if(event.key===\'Enter\')_submitMeetingNote(\'' + escHtml(m.id) + '\')">' +
@@ -165,7 +171,8 @@ function openMeetingDetail(id) {
165
171
  ).join('\n\n---\n\n');
166
172
  const meetingDoc = '# Meeting: ' + m.title + '\n\n**Agenda:** ' + m.agenda + '\n\n' + transcript;
167
173
  _modalDocContext = { title: 'Meeting: ' + m.title, content: meetingDoc, selection: '' };
168
- _modalFilePath = 'meetings/' + m.id + '.json';
174
+ // Completed/archived meetings: read-only Q&A (no file editing to avoid corrupting JSON)
175
+ _modalFilePath = (m.status === 'completed' || m.status === 'archived') ? null : 'meetings/' + m.id + '.json';
169
176
  try { showModalQa(); } catch { /* expected if QA not loaded */ }
170
177
 
171
178
  document.getElementById('modal').classList.add('open');
@@ -277,6 +284,102 @@ async function _unarchiveMeeting(id) {
277
284
  } catch (e) { alert('Error: ' + e.message); }
278
285
  }
279
286
 
287
+ function _viewPlanWithBack(file, meetingId) {
288
+ planView(file);
289
+ // After modal opens, prepend a back button to return to meeting
290
+ setTimeout(function() {
291
+ const title = document.getElementById('modal-title');
292
+ if (title && !title.querySelector('.mtg-back-btn')) {
293
+ const back = document.createElement('button');
294
+ back.className = 'pr-pager-btn mtg-back-btn';
295
+ back.style.cssText = 'font-size:9px;padding:2px 8px;margin-right:8px;vertical-align:middle';
296
+ back.textContent = '\u2190 Back to Meeting';
297
+ back.onclick = function() { openMeetingDetail(meetingId); };
298
+ title.prepend(back);
299
+ }
300
+ }, 100);
301
+ }
302
+
303
+ function _findLinkedPlan(meeting) {
304
+ if (!meeting?.conclusion?.content) return null;
305
+ const match = meeting.conclusion.content.match(/plans\/([\w-]+\.md)/);
306
+ if (!match) return null;
307
+ const file = match[1];
308
+ const plans = window._lastStatus?.plans || [];
309
+ return plans.find(function(p) { return p.file === file; }) || { file, summary: file };
310
+ }
311
+
312
+ async function _createPlanFromMeeting(id, btn) {
313
+ if (btn) { btn.dataset.origText = btn.textContent; btn.textContent = 'Checking...'; btn.style.pointerEvents = 'none'; btn.style.opacity = '0.6'; }
314
+ function resetBtn() { if (btn) { btn.textContent = btn.dataset.origText || 'Create Plan'; btn.style.pointerEvents = ''; btn.style.opacity = ''; } }
315
+ try {
316
+ const res = await fetch('/api/meetings/' + encodeURIComponent(id));
317
+ const data = await res.json();
318
+ if (!data.meeting) { resetBtn(); alert('Meeting not found'); return; }
319
+ const m = data.meeting;
320
+
321
+ // Check if a plan already exists for this meeting
322
+ const existing = _findLinkedPlan(m);
323
+ if (existing) {
324
+ resetBtn();
325
+ if (!confirm('A plan already exists: "' + existing.summary + '"\n\nCreate a new one anyway?')) return;
326
+ }
327
+
328
+ if (btn) btn.textContent = 'Generating plan...';
329
+
330
+ // Use doc-chat to generate a structured plan from the meeting
331
+ const transcript = (m.transcript || []).map(function(t) {
332
+ return '### ' + t.agent + ' (' + t.type + ', Round ' + t.round + ')\n\n' + (t.content || '');
333
+ }).join('\n\n---\n\n');
334
+ const meetingDoc = '# Meeting: ' + m.title + '\n\n**Agenda:** ' + m.agenda + '\n\n' + transcript;
335
+
336
+ // Include Q&A thread if present
337
+ let humanContext = '';
338
+ const qaThread = document.getElementById('modal-qa-thread');
339
+ if (qaThread) {
340
+ const qaText = qaThread.innerText.trim();
341
+ if (qaText.length > 20) humanContext = '\n\n## Human Discussion\n\n' + qaText.slice(0, 3000);
342
+ }
343
+
344
+ const genRes = await fetch('/api/doc-chat', {
345
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
346
+ body: JSON.stringify({
347
+ message: 'Create an actionable implementation plan from this meeting. Extract concrete action items from the conclusion and debates. For each item include: what to do, which files/areas to change, priority (high/medium/low), and estimated complexity (small/medium/large). Structure it as a plan ready for execution. Do NOT include preamble — start with the plan title.' + humanContext,
348
+ document: meetingDoc,
349
+ title: 'Meeting: ' + m.title,
350
+ })
351
+ });
352
+ const genData = await genRes.json();
353
+ if (!genRes.ok || !genData.ok) { resetBtn(); alert('Failed to generate plan: ' + (genData.error || 'unknown')); return; }
354
+
355
+ const planContent = genData.answer || '';
356
+ const title = 'Meeting follow-up: ' + (m.title || id);
357
+ const planRes = await fetch('/api/plans/create', {
358
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
359
+ body: JSON.stringify({ title, content: planContent })
360
+ });
361
+ const planData = await planRes.json();
362
+ if (planRes.ok && planData.ok) {
363
+ showToast('cmd-toast', 'Plan created: ' + planData.file, true);
364
+ if (btn) {
365
+ btn.textContent = 'Plan created';
366
+ btn.style.color = 'var(--green)';
367
+ btn.style.borderColor = 'var(--green)';
368
+ btn.style.opacity = '1';
369
+ const viewLink = document.createElement('button');
370
+ viewLink.className = 'pr-pager-btn';
371
+ viewLink.style.cssText = 'font-size:9px;padding:2px 8px;color:var(--blue);border-color:var(--blue);pointer-events:auto';
372
+ viewLink.textContent = 'View Plan';
373
+ viewLink.onclick = function() { _viewPlanWithBack(planData.file, id); };
374
+ btn.parentElement.insertBefore(viewLink, btn.nextSibling);
375
+ }
376
+ } else {
377
+ resetBtn();
378
+ alert('Failed: ' + (planData.error || 'unknown'));
379
+ }
380
+ } catch (e) { resetBtn(); alert('Error: ' + e.message); }
381
+ }
382
+
280
383
  async function _deleteMeeting(id) {
281
384
  if (!confirm('Delete this meeting? This cannot be undone.')) return;
282
385
  try {
@@ -15,7 +15,7 @@ function renderPinned(entries) {
15
15
  '<strong style="font-size:var(--text-md)">' + escHtml(e.title) + '</strong>' +
16
16
  '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--red);border-color:var(--red)" onclick="removePinnedNote(\'' + escHtml(e.title) + '\')">Unpin</button>' +
17
17
  '</div>' +
18
- '<div style="font-size:var(--text-sm);color:var(--muted);margin-top:4px">' + escHtml(e.content.slice(0, 200)) + '</div>' +
18
+ '<div style="font-size:var(--text-sm);color:var(--muted);margin-top:4px">' + renderMd(e.content.slice(0, 200)) + '</div>' +
19
19
  '</div>'
20
20
  ).join('');
21
21
  }
@@ -80,9 +80,14 @@ function derivePlanStatus(prdFile, mdFile, prdJsonStatus, workItems) {
80
80
  if (allDone && !hasActiveWork) return 'completed';
81
81
  if (hasActiveWork || hasPendingPrd) return 'in-progress';
82
82
  if (hasFailed && !hasActiveWork) return 'has-failures';
83
+
83
84
  if (prdJsonStatus === 'awaiting-approval' && implementWi.length === 0) return 'awaiting-approval';
84
85
  if (prdJsonStatus === 'approved' && implementWi.length === 0) return 'approved';
85
86
 
87
+ // plan-to-prd conversion done but no implement items and PRD is gone — truly completed
88
+ const prdConversionDone = wi.some(w => w.type === 'plan-to-prd' && w.status === 'done');
89
+ if (prdConversionDone && implementWi.length === 0) return 'completed';
90
+
86
91
  return prdJsonStatus || 'active';
87
92
  }
88
93
 
@@ -172,7 +177,14 @@ function renderPlans(plans) {
172
177
  if (prdFile && p.format !== 'prd') {
173
178
  const linkedPrd = plans.find(pp => pp.file === prdFile && pp.format === 'prd');
174
179
  if (linkedPrd) prdJsonStatus = linkedPrd.status || prdJsonStatus;
180
+ // If the linked PRD was archived, treat the .md plan as completed
181
+ else if (!linkedPrd) {
182
+ const archivedPrd = archivedPlans.find(pp => pp.file === prdFile && pp.format === 'prd');
183
+ if (archivedPrd) prdJsonStatus = 'completed';
184
+ }
175
185
  }
186
+ // 'converted' means plan-to-PRD succeeded — treat as 'approved' for status derivation
187
+ if (prdJsonStatus === 'converted') prdJsonStatus = prdFile ? 'approved' : 'completed';
176
188
 
177
189
  // Single source of truth: derive status from work items
178
190
  const effectiveStatus = isArchived ? 'completed' : derivePlanStatus(prdFile, p.file, prdJsonStatus, allWi);
@@ -180,7 +192,8 @@ function renderPlans(plans) {
180
192
  const statusLabelsMap = {
181
193
  'completed': 'Completed', 'in-progress': 'In Progress', 'paused': 'Paused',
182
194
  'awaiting-approval': 'Awaiting Approval', 'approved': 'Approved', 'rejected': 'Rejected',
183
- 'revision-requested': 'Revision Requested', 'has-failures': 'Has Failures', 'active': 'Active'
195
+ 'revision-requested': 'Revision Requested', 'has-failures': 'Has Failures', 'active': 'Active',
196
+ 'converted': 'Converted to PRD', 'draft': 'Draft'
184
197
  };
185
198
  const label = statusLabelsMap[effectiveStatus] || effectiveStatus;
186
199
  const needsAction = (effectiveStatus === 'awaiting-approval' || effectiveStatus === 'paused') && !isArchived;
@@ -222,6 +235,10 @@ function renderPlans(plans) {
222
235
  : '';
223
236
  const verifyBtn = showVerify ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green)" ' +
224
237
  'onclick="event.stopPropagation();triggerVerify(\'' + escHtml(prdFile) + '\',this)">Verify</button>' : '';
238
+ const showArchive = !isArchived;
239
+ const archiveFile = prdFile || p.file;
240
+ const archiveBtn = showArchive ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" ' +
241
+ 'onclick="event.stopPropagation();planArchive(\'' + escHtml(archiveFile) + '\',this)">Archive</button>' : '';
225
242
  const deleteBtn = !isArchived ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red)" ' +
226
243
  'onclick="event.stopPropagation();planDelete(\'' + escHtml(p.file) + '\')">Delete</button>' : '';
227
244
 
@@ -238,7 +255,7 @@ function renderPlans(plans) {
238
255
  (p.updatedAt ? '<span title="Last updated: ' + p.updatedAt + '">Updated ' + timeAgo(p.updatedAt) + '</span>' : '') +
239
256
  (p.completedAt ? '<span>' + p.completedAt.slice(0, 10) + '</span>' : '') +
240
257
  (p.generatedBy ? '<span>by ' + escHtml(p.generatedBy) + '</span>' : '') +
241
- executeBtn + pauseBtn + resumeBtn + verifyBtn + deleteBtn +
258
+ executeBtn + pauseBtn + resumeBtn + verifyBtn + archiveBtn + deleteBtn +
242
259
  '</div>' +
243
260
  '</div>' +
244
261
  '</div>' +
@@ -432,11 +449,12 @@ async function planView(file) {
432
449
  const isPaused = planStatus === 'awaiting-approval' || planStatus === 'paused';
433
450
  // Check if work is in progress for this plan
434
451
  const wi = window._lastWorkItems || [];
452
+ // Find the linked PRD for this .md plan (if any)
453
+ const linkedPrdFile = isMdPlan ? (window._lastStatus?.plans || []).find(p => p.sourcePlan === normalizedFile && p.format === 'prd')?.file : null;
435
454
  const hasActiveWork = wi.some(w =>
436
455
  (w.status === 'pending' || w.status === 'dispatched') &&
437
456
  (w.planFile === normalizedFile || w.sourcePlan === normalizedFile ||
438
- // For .md plans: any work item with a sourcePlan .json means the plan is being executed
439
- (isMdPlan && w.sourcePlan && w.sourcePlan.endsWith('.json')))
457
+ (linkedPrdFile && w.sourcePlan === linkedPrdFile))
440
458
  );
441
459
  const prdCompleted = wi.some(w => w.type === 'plan-to-prd' && w.status === 'done' && w.planFile === normalizedFile);
442
460
  // Check if a PRD already exists for this plan (via plans list sourcePlan linkage)
@@ -454,9 +472,12 @@ async function planView(file) {
454
472
  const modalVerifyBtn = isModalCompleted ? '<button class="pr-pager-btn" style="font-size:10px;padding:2px 10px;color:var(--green)" ' +
455
473
  'onclick="triggerVerify(\'' + escHtml(normalizedFile) + '\',this)">Verify</button>' : '';
456
474
 
475
+ const modalArchiveBtn = '<button class="pr-pager-btn" style="font-size:10px;padding:2px 10px;color:var(--muted)" ' +
476
+ 'onclick="planArchive(\'' + escHtml(normalizedFile) + '\')">Archive</button>';
457
477
  const lastModLabel = lastMod ? '<div style="font-size:10px;color:var(--muted);font-weight:400;margin-top:2px">Last updated: ' + new Date(lastMod).toLocaleString() + '</div>' : '';
458
478
  const actionBtns = '<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:4px">' +
459
479
  (modalCompletedLabel || '') + (modalInProgressLabel || '') + (modalExecuteBtn || '') + (modalPauseBtn || '') + (modalResumeBtn || '') + (modalVerifyBtn || '') +
480
+ ' ' + modalArchiveBtn +
460
481
  ' <button class="pr-pager-btn" style="font-size:10px;padding:2px 10px;color:var(--red)" ' +
461
482
  'onclick="planDelete(\'' + escHtml(normalizedFile) + '\')">Delete</button>' +
462
483
  '</div>';
@@ -516,23 +537,27 @@ async function planDelete(file) {
516
537
  } catch (e) { alert('Error: ' + e.message); }
517
538
  }
518
539
 
519
- async function planArchive(file) {
520
- if (!confirm('Archive PRD "' + file + '"? Work items will be preserved.')) return;
540
+ async function planArchive(file, btn) {
541
+ if (btn) { btn.dataset.origText = btn.textContent; btn.textContent = 'Archiving...'; btn.style.pointerEvents = 'none'; btn.style.opacity = '0.6'; }
542
+ function resetBtn() { if (btn) { btn.textContent = btn.dataset.origText || 'Archive'; btn.style.pointerEvents = ''; btn.style.opacity = ''; } }
521
543
  try {
522
544
  const res = await fetch('/api/plans/archive', {
523
545
  method: 'POST', headers: { 'Content-Type': 'application/json' },
524
546
  body: JSON.stringify({ file })
525
547
  });
526
- if (res.ok) {
527
- closeModal();
528
- showToast('cmd-toast', 'PRD archived', true);
548
+ const ct = res.headers.get('content-type') || '';
549
+ if (!ct.includes('json')) { resetBtn(); alert('Archive failed — dashboard may need a restart'); return; }
550
+ const d = await res.json().catch(() => ({}));
551
+ if (res.ok && d.ok) {
552
+ try { closeModal(); } catch { /* may not be open */ }
553
+ showToast('cmd-toast', 'Archived' + (file.endsWith('.json') ? ' PRD and source plan' : ''), true);
529
554
  refreshPlans();
530
555
  refresh();
531
556
  } else {
532
- const d = await res.json();
557
+ resetBtn();
533
558
  alert('Archive failed: ' + (d.error || 'unknown'));
534
559
  }
535
- } catch (e) { alert('Error: ' + e.message); }
560
+ } catch (e) { resetBtn(); alert('Error: ' + e.message); }
536
561
  }
537
562
 
538
563
  async function planPause(file, btn) {
@@ -674,4 +699,26 @@ async function triggerVerify(file, btn) {
674
699
  } catch (e) { if (btn) { btn.textContent = btn.dataset.origText || 'Verify'; btn.style.pointerEvents = ''; btn.style.opacity = ''; } alert('Error: ' + e.message); }
675
700
  }
676
701
 
677
- window.MinionsPlans = { openCreatePlanModal, refreshPlans, derivePlanStatus, renderPlans, openArchivedPlansModal, qaDisablePrdButtons, showPlanVersionActions, qaJustSave, planExecute, planSubmitRevise, planShowRevise, planHideRevise, planView, planApprove, planArchive, planDelete, planPause, planReject, planDiscuss, planOpenInDocChat, planRegeneratePRD, openVerifyGuide, triggerVerify };
702
+ async function planUnarchive(file, btn) {
703
+ if (btn) { btn.dataset.origText = btn.textContent; btn.textContent = 'Restoring...'; btn.style.pointerEvents = 'none'; btn.style.opacity = '0.6'; }
704
+ function resetBtn() { if (btn) { btn.textContent = btn.dataset.origText || 'Unarchive'; btn.style.pointerEvents = ''; btn.style.opacity = ''; } }
705
+ try {
706
+ const res = await fetch('/api/plans/unarchive', {
707
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
708
+ body: JSON.stringify({ file })
709
+ });
710
+ const ct = res.headers.get('content-type') || '';
711
+ if (!ct.includes('json')) { resetBtn(); alert('Unarchive failed — dashboard may need a restart'); return; }
712
+ const d = await res.json().catch(() => ({}));
713
+ if (res.ok && d.ok) {
714
+ showToast('cmd-toast', 'Restored from archive', true);
715
+ refreshPlans();
716
+ refresh();
717
+ } else {
718
+ resetBtn();
719
+ alert('Unarchive failed: ' + (d.error || 'unknown'));
720
+ }
721
+ } catch (e) { resetBtn(); alert('Error: ' + e.message); }
722
+ }
723
+
724
+ window.MinionsPlans = { openCreatePlanModal, refreshPlans, derivePlanStatus, renderPlans, openArchivedPlansModal, qaDisablePrdButtons, showPlanVersionActions, qaJustSave, planExecute, planSubmitRevise, planShowRevise, planHideRevise, planView, planApprove, planArchive, planUnarchive, planDelete, planPause, planReject, planDiscuss, planOpenInDocChat, planRegeneratePRD, openVerifyGuide, triggerVerify };
@@ -2,7 +2,7 @@
2
2
 
3
3
  let allWorkItems = [];
4
4
  let wiPage = 0;
5
- const WI_PER_PAGE = 6;
5
+ const WI_PER_PAGE = 20;
6
6
 
7
7
  function wiRow(item) {
8
8
  const statusBadge = (s) => {
@@ -23,7 +23,7 @@ function wiRow(item) {
23
23
  '<td>' + priBadge(item.priority) + '</td>' +
24
24
  '<td>' + statusBadge(item.status || 'pending') +
25
25
  (item._pendingReason ? ' <span style="font-size:9px;color:var(--muted);margin-left:4px" title="Pending reason: ' + escHtml(item._pendingReason) + '">' + escHtml(item._pendingReason.replace(/_/g, ' ')) + '</span>' : '') +
26
- (item.status === 'failed' ? ' <button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-left:4px" onclick="event.stopPropagation();retryWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')">Retry</button>' : '') +
26
+ (item.status === 'failed' ? ' <button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-left:4px" onclick="event.stopPropagation();retryWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\',this)">Retry</button>' : '') +
27
27
  '</td>' +
28
28
  '<td>' +
29
29
  (item.completedAgents && item.completedAgents.length > 0
@@ -210,17 +210,23 @@ async function toggleWorkItemArchive() {
210
210
  } catch (e) { el.innerHTML = '<p class="empty">Failed to load archive.</p>'; }
211
211
  }
212
212
 
213
- async function retryWorkItem(id, source) {
213
+ async function retryWorkItem(id, source, btn) {
214
+ if (btn) { btn.dataset.origText = btn.textContent; btn.textContent = 'Retrying...'; btn.style.pointerEvents = 'none'; btn.style.opacity = '0.6'; }
214
215
  try {
215
216
  const res = await fetch('/api/work-items/retry', {
216
217
  method: 'POST', headers: { 'Content-Type': 'application/json' },
217
218
  body: JSON.stringify({ id, source: source || undefined })
218
219
  });
219
- if (res.ok) { wakeEngine(); refresh(); } else {
220
- const d = await res.json();
220
+ if (res.ok) {
221
+ showToast('cmd-toast', 'Work item ' + id + ' reset to pending', true);
222
+ wakeEngine();
223
+ refresh();
224
+ } else {
225
+ if (btn) { btn.textContent = btn.dataset.origText || 'Retry'; btn.style.pointerEvents = ''; btn.style.opacity = ''; }
226
+ const d = await res.json().catch(() => ({}));
221
227
  alert('Retry failed: ' + (d.error || 'unknown'));
222
228
  }
223
- } catch (e) { alert('Retry error: ' + e.message); }
229
+ } catch (e) { if (btn) { btn.textContent = btn.dataset.origText || 'Retry'; btn.style.pointerEvents = ''; btn.style.opacity = ''; } alert('Retry error: ' + e.message); }
224
230
  }
225
231
 
226
232
  function wiPrev() { if (wiPage > 0) { wiPage--; renderWorkItems(allWorkItems); } }
@@ -365,7 +371,7 @@ function openWorkItemDetail(id) {
365
371
  '<span class="dispatch-type ' + (item.type || 'implement') + '">' + escHtml(item.type || 'implement') + '</span>' +
366
372
  '<span class="prd-item-priority ' + (item.priority || '') + '">' + escHtml(item.priority || 'medium') + '</span>' +
367
373
  '</div>';
368
- html += field('Description', '<div style="white-space:pre-wrap;font-size:12px">' + escHtml(item.description || item.title || '—') + '</div>');
374
+ html += field('Description', '<div style="font-size:12px">' + renderMd(item.description || item.title || '—') + '</div>');
369
375
  html += field('Agent', escHtml(item.dispatched_to || item.agent || 'Auto'));
370
376
  html += field('Source', escHtml(item._source || 'central'));
371
377
  if (item.created) html += field('Created', escHtml(new Date(item.created).toLocaleString()));
@@ -41,4 +41,118 @@ function copyLlmText(btn) {
41
41
  setTimeout(() => { btn.innerHTML = '&#x2398;'; }, 1500);
42
42
  }
43
43
 
44
- window.MinionsUtils = { wakeEngine, escHtml, normalizePlanFile, timeAgo, statusColor, llmCopyBtn, copyLlmText };
44
+ /**
45
+ * Lightweight markdown → HTML renderer. XSS-safe (escapes first, then transforms).
46
+ * Handles: headings, bold, italic, inline code, code blocks, links, blockquotes,
47
+ * horizontal rules, ordered/unordered/checkbox lists, and tables.
48
+ */
49
+ function renderMd(s) {
50
+ if (!s) return '';
51
+ let html = escHtml(s);
52
+
53
+ // 1. Extract code blocks and inline code into placeholders (protect from other transforms)
54
+ const codeSlots = [];
55
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
56
+ codeSlots.push('<pre style="background:var(--bg);padding:8px;border-radius:4px;overflow-x:auto;font-size:11px;margin:4px 0"><code>' + code + '</code></pre>');
57
+ return '\x00CB' + (codeSlots.length - 1) + '\x00';
58
+ });
59
+ html = html.replace(/`([^`\n]+)`/g, function(_, code) {
60
+ codeSlots.push('<code style="background:var(--bg);padding:1px 4px;border-radius:3px;font-size:0.9em">' + code + '</code>');
61
+ return '\x00CB' + (codeSlots.length - 1) + '\x00';
62
+ });
63
+
64
+ // 2. Inline transforms (before block processing so they work inside list items etc.)
65
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
66
+ html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
67
+ html = html.replace(/(?<!\w)\*([^*\n]+)\*(?!\w)/g, '<em>$1</em>');
68
+ html = html.replace(/(?<!\w)_([^_\n]+)_(?!\w)/g, '<em>$1</em>');
69
+ html = html.replace(/~~(.+?)~~/g, '<s>$1</s>');
70
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--blue)">$1</a>');
71
+
72
+ // 3. Block-level processing (line by line)
73
+ var lines = html.split('\n');
74
+ var out = [];
75
+ var inList = false;
76
+ var listType = '';
77
+
78
+ function closeList() { if (inList) { out.push(listType === 'ol' ? '</ol>' : '</ul>'); inList = false; } }
79
+ function openList(type) {
80
+ if (inList && listType !== type) closeList();
81
+ if (!inList) {
82
+ var style = type === 'ol' ? 'margin:2px 0 2px 20px;padding:0' : type === 'cb' ? 'margin:2px 0 2px 16px;padding:0;list-style:none' : 'margin:2px 0 2px 16px;padding:0';
83
+ out.push('<' + (type === 'ol' ? 'ol' : 'ul') + ' style="' + style + '">');
84
+ inList = true; listType = type === 'cb' ? 'ul' : type;
85
+ }
86
+ }
87
+
88
+ for (var i = 0; i < lines.length; i++) {
89
+ var line = lines[i];
90
+
91
+ // Code block placeholder — pass through as-is
92
+ if (line.match(/^\x00CB\d+\x00$/)) { closeList(); out.push(line); continue; }
93
+
94
+ // Headings
95
+ var headMatch = line.match(/^(#{1,4})\s+(.+)/);
96
+ if (headMatch) {
97
+ closeList();
98
+ var sizes = { 1: '16px', 2: '14px', 3: '13px', 4: '12px' };
99
+ out.push('<div style="font-weight:600;font-size:' + sizes[headMatch[1].length] + ';margin:8px 0 4px">' + headMatch[2] + '</div>');
100
+ continue;
101
+ }
102
+
103
+ // Horizontal rule (only bare ---, ***, ___ lines)
104
+ if (/^[-*_]{3,}\s*$/.test(line) && !/\S/.test(line.replace(/[-*_]/g, ''))) {
105
+ closeList();
106
+ out.push('<hr style="border:none;border-top:1px solid var(--border);margin:8px 0">');
107
+ continue;
108
+ }
109
+
110
+ // Blockquote
111
+ if (line.match(/^&gt;\s?/)) {
112
+ closeList();
113
+ out.push('<div style="border-left:3px solid var(--border);padding-left:8px;color:var(--muted);margin:2px 0">' + line.replace(/^(&gt;\s?)+/, '') + '</div>');
114
+ continue;
115
+ }
116
+
117
+ // Checkbox list (must come before UL — both start with - )
118
+ var cbMatch = line.match(/^(\s*)[-*]\s\[([ xX])\]\s(.+)/);
119
+ if (cbMatch) {
120
+ openList('cb');
121
+ out.push('<li>' + (cbMatch[2] !== ' ' ? '\u2611' : '\u2610') + ' ' + cbMatch[3] + '</li>');
122
+ continue;
123
+ }
124
+
125
+ // Unordered list (- or * followed by space and content, not bare --- or ***)
126
+ var ulMatch = line.match(/^(\s*)[-*]\s+(.+)/);
127
+ if (ulMatch) {
128
+ openList('ul');
129
+ out.push('<li>' + ulMatch[2] + '</li>');
130
+ continue;
131
+ }
132
+
133
+ // Ordered list
134
+ var olMatch = line.match(/^(\s*)\d+\.\s+(.+)/);
135
+ if (olMatch) {
136
+ openList('ol');
137
+ out.push('<li>' + olMatch[2] + '</li>');
138
+ continue;
139
+ }
140
+
141
+ // Non-list line — close any open list
142
+ closeList();
143
+
144
+ // Blank line → spacer
145
+ if (!line.trim()) { out.push('<div style="height:4px"></div>'); continue; }
146
+
147
+ out.push('<div>' + line + '</div>');
148
+ }
149
+ closeList();
150
+ html = out.join('\n');
151
+
152
+ // 4. Restore code placeholders
153
+ html = html.replace(/\x00CB(\d+)\x00/g, function(_, idx) { return codeSlots[idx]; });
154
+
155
+ return html;
156
+ }
157
+
158
+ window.MinionsUtils = { wakeEngine, escHtml, renderMd, normalizePlanFile, timeAgo, statusColor, llmCopyBtn, copyLlmText };
package/dashboard.js CHANGED
@@ -2082,17 +2082,61 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
2082
2082
  fs.renameSync(planPath, archivePath);
2083
2083
 
2084
2084
  // Mark archived in JSON if PRD
2085
+ let archivedSource = null;
2085
2086
  if (body.file.endsWith('.json')) {
2086
2087
  try {
2087
2088
  const prd = JSON.parse(safeRead(archivePath) || '{}');
2088
2089
  prd.status = 'archived';
2089
2090
  prd.archivedAt = new Date().toISOString();
2090
2091
  safeWrite(archivePath, prd);
2092
+ // Also archive linked source plan
2093
+ if (prd.source_plan) {
2094
+ const mdPath = path.join(PLANS_DIR, prd.source_plan);
2095
+ if (fs.existsSync(mdPath)) {
2096
+ const planArchive = path.join(PLANS_DIR, 'archive');
2097
+ if (!fs.existsSync(planArchive)) fs.mkdirSync(planArchive, { recursive: true });
2098
+ fs.renameSync(mdPath, path.join(planArchive, prd.source_plan));
2099
+ archivedSource = prd.source_plan;
2100
+ }
2101
+ }
2102
+ } catch { /* optional */ }
2103
+ }
2104
+
2105
+ invalidateStatusCache();
2106
+ return jsonReply(res, 200, { ok: true, archived: body.file, archivedSource });
2107
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2108
+ }
2109
+
2110
+ async function handlePlansUnarchive(req, res) {
2111
+ try {
2112
+ const body = await readBody(req);
2113
+ if (!body.file) return jsonReply(res, 400, { error: 'file required' });
2114
+ if (body.file.includes('..') || body.file.includes('\0') || body.file.includes('/') || body.file.includes('\\')) {
2115
+ return jsonReply(res, 400, { error: 'invalid filename' });
2116
+ }
2117
+ const isJson = body.file.endsWith('.json');
2118
+ const targetDir = isJson ? PRD_DIR : PLANS_DIR;
2119
+ const archivePath = path.join(targetDir, 'archive', body.file);
2120
+ if (!fs.existsSync(archivePath)) return jsonReply(res, 404, { error: 'File not found in archive' });
2121
+ fs.renameSync(archivePath, path.join(targetDir, body.file));
2122
+
2123
+ // Also unarchive linked source plan
2124
+ let unarchivedSource = null;
2125
+ if (isJson) {
2126
+ try {
2127
+ const prd = safeJson(path.join(targetDir, body.file));
2128
+ if (prd?.source_plan) {
2129
+ const mdArchivePath = path.join(PLANS_DIR, 'archive', prd.source_plan);
2130
+ if (fs.existsSync(mdArchivePath)) {
2131
+ fs.renameSync(mdArchivePath, path.join(PLANS_DIR, prd.source_plan));
2132
+ unarchivedSource = prd.source_plan;
2133
+ }
2134
+ }
2091
2135
  } catch { /* optional */ }
2092
2136
  }
2093
2137
 
2094
2138
  invalidateStatusCache();
2095
- return jsonReply(res, 200, { ok: true, archived: body.file });
2139
+ return jsonReply(res, 200, { ok: true, unarchivedSource });
2096
2140
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2097
2141
  }
2098
2142
 
@@ -3115,6 +3159,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3115
3159
  { method: 'POST', path: '/api/plans/regenerate', desc: 'Reset pending/failed work items for a plan so they re-materialize', params: 'source', handler: handlePlansRegenerate },
3116
3160
  { method: 'POST', path: '/api/plans/delete', desc: 'Delete a plan file and clean up work items', params: 'file', handler: handlePlansDelete },
3117
3161
  { method: 'POST', path: '/api/plans/archive', desc: 'Move a plan/PRD to archive (preserves work items)', params: 'file', handler: handlePlansArchive },
3162
+ { method: 'POST', path: '/api/plans/unarchive', desc: 'Restore a plan/PRD from archive', params: 'file', handler: handlePlansUnarchive },
3118
3163
  { method: 'POST', path: '/api/plans/revise', desc: 'Request revision with feedback, dispatches agent to revise', params: 'file, feedback, requestedBy?', handler: handlePlansRevise },
3119
3164
  { method: 'POST', path: '/api/plans/discuss', desc: 'Generate a plan discussion session script for Claude CLI', params: 'file', handler: handlePlansDiscuss },
3120
3165
  { method: 'GET', path: /^\/api\/plans\/archive\/([^?]+)$/, desc: 'Read an archived plan file', handler: handlePlansArchiveRead },
package/engine/ado.js CHANGED
@@ -344,8 +344,8 @@ async function reconcilePrs(config) {
344
344
  const branch = (adoPr.sourceRefName || '').replace('refs/heads/', '');
345
345
  const title = adoPr.title || '';
346
346
  // Extract item ID from branch name or PR title (e.g., feat(P-2cafdc2a): ...)
347
- const branchMatch = branch.match(/(P-[a-f0-9]{6,})/i) || branch.match(/(PL-W\d+)/i);
348
- const titleMatch = title.match(/\((P-[a-f0-9]{6,})\)/) || title.match(/\((PL-W\d+)\)/);
347
+ const branchMatch = branch.match(/(P-[a-z0-9]{6,})/i) || branch.match(/(PL-W\d+)/i);
348
+ const titleMatch = title.match(/\((P-[a-z0-9]{6,})\)/) || title.match(/\((PL-W\d+)\)/);
349
349
  const linkedItemId = branchMatch?.[1] || titleMatch?.[1] || null;
350
350
  const linkedItem = linkedItemId ? allItems.find(i => i.id === linkedItemId) : null;
351
351
  const confirmedItemId = linkedItem ? linkedItemId : null;
@@ -368,7 +368,7 @@ async function reconcilePrs(config) {
368
368
  existingPrs.push({
369
369
  id: prId,
370
370
  title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
371
- agent: (adoPr.createdBy?.displayName || 'unknown').toLowerCase(),
371
+ agent: (linkedItem?.dispatched_to || adoPr.createdBy?.displayName || 'unknown').toLowerCase(),
372
372
  branch,
373
373
  reviewStatus: 'pending',
374
374
  status: 'active',
package/engine/github.js CHANGED
@@ -334,7 +334,7 @@ async function reconcilePrs(config) {
334
334
  for (const ghPr of ghPrs) {
335
335
  const prId = `PR-${ghPr.number}`;
336
336
  const branch = ghPr.head?.ref || '';
337
- const wiMatch = branch.match(/(P-[a-f0-9]{6,})/i) || branch.match(/(PL-W\d+)/i);
337
+ const wiMatch = branch.match(/(P-[a-z0-9]{6,})/i) || branch.match(/(PL-W\d+)/i);
338
338
  const linkedItemId = wiMatch ? wiMatch[1] : null;
339
339
  const linkedItem = linkedItemId ? allItems.find(i => i.id === linkedItemId) : null;
340
340
  const confirmedItemId = linkedItem ? linkedItemId : null;
@@ -356,7 +356,7 @@ async function reconcilePrs(config) {
356
356
  existingPrs.push({
357
357
  id: prId,
358
358
  title: (ghPr.title || `PR #${ghPr.number}`).slice(0, 120),
359
- agent: (ghPr.user?.login || 'unknown').toLowerCase(),
359
+ agent: (linkedItem?.dispatched_to || ghPr.user?.login || 'unknown').toLowerCase(),
360
360
  branch,
361
361
  reviewStatus: 'pending',
362
362
  status: 'active',
package/engine/queries.js CHANGED
@@ -187,6 +187,16 @@ function getAgents(config) {
187
187
  ? config.agents
188
188
  : shared.DEFAULT_AGENTS;
189
189
  const roster = Object.entries(agents).map(([id, info]) => ({ id, ...info }));
190
+
191
+ // Include temp agents that are currently active so they show up in agent tiles
192
+ const dispatch = getDispatch();
193
+ const seen = new Set(roster.map(a => a.id));
194
+ for (const d of (dispatch.active || [])) {
195
+ if (d.agent && d.agent.startsWith('temp-') && !seen.has(d.agent)) {
196
+ roster.push({ id: d.agent, name: d.agentName || d.agent, role: d.agentRole || 'Temp Agent', emoji: '\u{1F4A8}', skills: [], _temp: true });
197
+ seen.add(d.agent);
198
+ }
199
+ }
190
200
  const allInboxFiles = safeReadDir(INBOX_DIR);
191
201
 
192
202
  return roster.map(a => {
package/engine.js CHANGED
@@ -262,7 +262,10 @@ function spawnAgent(dispatchItem, config) {
262
262
  const startedAt = ts();
263
263
 
264
264
  // Resolve project context for this dispatch
265
- const project = meta?.project || getProjects(config)[0] || {};
265
+ // meta.project has {name, localPath} — enrich with full config (mainBranch, repoHost, etc.)
266
+ const metaProject = meta?.project || {};
267
+ const fullProject = getProjects(config).find(p => p.name === metaProject.name || p.localPath === metaProject.localPath) || getProjects(config)[0] || {};
268
+ const project = { ...fullProject, ...metaProject };
266
269
  const rootDir = project.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
267
270
 
268
271
  // Determine working directory
@@ -318,47 +321,61 @@ function spawnAgent(dispatchItem, config) {
318
321
  }
319
322
  } else {
320
323
  log('info', `Creating worktree: ${worktreePath} on branch ${branchName}`);
324
+ const mainRef = sanitizeBranch(project.mainBranch || 'main');
321
325
  try {
322
- runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${sanitizeBranch(project.mainBranch || 'main')}`, _worktreeGitOpts, worktreeCreateRetries);
326
+ runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, worktreeCreateRetries);
323
327
  } catch (e1) {
324
- // Branch already exists or checked out elsewhere — try without -b
325
- try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
326
- try {
327
- runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
328
- log('info', `Reusing existing branch: ${branchName}`);
329
- } catch (e2) {
330
- // "already checked out" or "already used by worktree" — find and reuse or recover
331
- const alreadyUsed = e2.message?.includes('already checked out') || e2.message?.includes('already used by worktree')
332
- || e1.message?.includes('already checked out') || e1.message?.includes('already used by worktree');
333
- if (alreadyUsed) {
334
- const existingWtPath = findExistingWorktree(rootDir, branchName);
335
- if (existingWtPath && fs.existsSync(existingWtPath)) {
336
- // Directory exists reuse it if no other active dispatch is using it
337
- const dispatch = safeJson(DISPATCH_PATH) || {};
338
- const activelyUsed = (dispatch.active || []).some(d => {
339
- const dBranch = d.meta?.branch ? sanitizeBranch(d.meta.branch) : '';
340
- return dBranch === branchName && d.id !== id;
341
- });
342
- if (activelyUsed) {
343
- log('warn', `Branch ${branchName} actively used by another agent at ${existingWtPath} — cannot create worktree`);
344
- throw e2;
328
+ const branchExists = e1.message?.includes('already exists');
329
+ log('warn', `Worktree -b failed for ${branchName}: ${e1.message?.split('\n')[0]}`);
330
+ if (!branchExists) {
331
+ // Transient error (lock, timeout) prune, clean, and retry -b once more
332
+ log('info', `Retrying -b create after prune for ${branchName}`);
333
+ try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 15000 }); } catch { /* optional */ }
334
+ removeStaleIndexLock(rootDir);
335
+ // Clean up partial worktree directory from failed attempt
336
+ try { if (fs.existsSync(worktreePath)) fs.rmSync(worktreePath, { recursive: true, force: true }); } catch { /* optional */ }
337
+ try {
338
+ runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, 0);
339
+ } catch (e1b) {
340
+ log('error', `Worktree -b retry also failed for ${branchName}: ${e1b.message?.split('\n')[0]}`);
341
+ throw e1b;
342
+ }
343
+ } else {
344
+ // Branch already exists try checkout without -b
345
+ try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
346
+ try {
347
+ runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
348
+ log('info', `Reusing existing branch: ${branchName}`);
349
+ } catch (e2) {
350
+ // "already checked out" or "already used by worktree" — find and reuse or recover
351
+ const alreadyUsed = e2.message?.includes('already checked out') || e2.message?.includes('already used by worktree')
352
+ || e1.message?.includes('already checked out') || e1.message?.includes('already used by worktree');
353
+ if (alreadyUsed) {
354
+ const existingWtPath = findExistingWorktree(rootDir, branchName);
355
+ if (existingWtPath && fs.existsSync(existingWtPath)) {
356
+ const dispatch = safeJson(DISPATCH_PATH) || {};
357
+ const activelyUsed = (dispatch.active || []).some(d => {
358
+ const dBranch = d.meta?.branch ? sanitizeBranch(d.meta.branch) : '';
359
+ return dBranch === branchName && d.id !== id;
360
+ });
361
+ if (activelyUsed) {
362
+ log('warn', `Branch ${branchName} actively used by another agent at ${existingWtPath} — cannot create worktree`);
363
+ throw e2;
364
+ }
365
+ log('info', `Branch ${branchName} already checked out at ${existingWtPath} — reusing`);
366
+ worktreePath = existingWtPath;
367
+ } else if (existingWtPath && !fs.existsSync(existingWtPath)) {
368
+ log('warn', `Branch ${branchName} tracked in missing dir ${existingWtPath} — pruning and recreating`);
369
+ try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
370
+ runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
371
+ log('info', `Recovered worktree for ${branchName} after stale entry prune`);
372
+ } else {
373
+ try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
374
+ runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
345
375
  }
346
- // Reuse the existing worktree — update path so the rest of spawnAgent uses it
347
- log('info', `Branch ${branchName} already checked out at ${existingWtPath} — reusing`);
348
- worktreePath = existingWtPath;
349
- } else if (existingWtPath && !fs.existsSync(existingWtPath)) {
350
- // Directory gone but git still tracks it — prune and recreate
351
- log('warn', `Branch ${branchName} tracked in missing dir ${existingWtPath} — pruning and recreating`);
352
- try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
353
- runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
354
- log('info', `Recovered worktree for ${branchName} after stale entry prune`);
355
376
  } else {
356
- // Can't find the worktree at all — prune and retry
357
- try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
358
- runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
377
+ throw e2;
359
378
  }
360
- } else {
361
- throw e2;
362
379
  }
363
380
  }
364
381
  }
@@ -1378,6 +1395,7 @@ function discoverFromWorkItems(config, project) {
1378
1395
  safeWrite(projectWorkItemsPath(project), items);
1379
1396
  }
1380
1397
  if (isAlreadyDispatched(key)) {
1398
+ if (item.status === 'pending') { item.status = 'dispatched'; needsWrite = true; }
1381
1399
  if (item._pendingReason !== 'already_dispatched') { item._pendingReason = 'already_dispatched'; needsWrite = true; }
1382
1400
  skipped.gated++; continue;
1383
1401
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.94",
3
+ "version": "0.1.96",
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"
@@ -24,12 +24,52 @@ You are {{agent_name}} ({{agent_role}}), synthesizing the team meeting results.
24
24
 
25
25
  ## Your Task
26
26
 
27
- Write a clear meeting conclusion:
27
+ Write a clear meeting conclusion covering:
28
28
 
29
29
  1. **Areas of consensus** — what does the team agree on?
30
30
  2. **Unresolved disagreements** — where do positions still differ?
31
31
  3. **Recommended decision** — what should we do?
32
- 4. **Action items** — specific next steps with owners
32
+ 4. **Action items** — specific next steps with owners (leave empty if none)
33
33
  5. **Open questions** — what still needs human input?
34
34
 
35
35
  Be decisive. If there's a clear best option, say so.
36
+
37
+ ## Plan Creation (conditional)
38
+
39
+ After writing the conclusion, decide: **are there concrete action items that require implementation work?**
40
+
41
+ - **If YES** — write a plan file to `plans/<slugified-meeting-title>-<YYYY-MM-DD>.md` using the Minions plan format below. Only create this file if there are real, actionable implementation tasks — not just discussion points or observations.
42
+
43
+ - **If NO** — do not create a plan file. A conclusion with only open questions, observations, or decisions that require no code/implementation changes does not need a plan.
44
+
45
+ ### Plan format (only if action items exist)
46
+
47
+ ```markdown
48
+ # <Plan Title>
49
+
50
+ > Source: Meeting {{meeting_title}} — <date>
51
+
52
+ ## Background
53
+
54
+ <1-2 sentence summary of why this plan exists, from the meeting conclusion>
55
+
56
+ ## Tasks
57
+
58
+ ### 1. <Task title>
59
+ - **What**: <what needs to be done>
60
+ - **Why**: <from the meeting — what problem does this fix>
61
+ - **Owner**: <agent role best suited: Engineer / Architect / Analyst>
62
+ - **Files**: <relevant files if known>
63
+
64
+ ### 2. <Task title>
65
+ ...
66
+
67
+ ## Open Questions
68
+
69
+ <Any unresolved items that need human input before work begins>
70
+ ```
71
+
72
+ Do NOT create a plan for:
73
+ - Informational findings with no follow-up work
74
+ - Decisions that have already been made and require no changes
75
+ - Items that are purely "monitor and observe"