@yemi33/minions 0.1.95 → 0.1.97

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,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.97 (2026-04-01)
4
+
5
+ ### Engine
6
+ - engine/ado.js
7
+ - engine/github.js
8
+
9
+ ### Dashboard
10
+ - dashboard.js
11
+ - dashboard/js/render-prd.js
12
+ - dashboard/js/render-prs.js
13
+
14
+ ## 0.1.96 (2026-04-01)
15
+
16
+ ### Engine
17
+ - engine.js
18
+ - engine/ado.js
19
+ - engine/github.js
20
+ - engine/queries.js
21
+
22
+ ### Dashboard
23
+ - dashboard.js
24
+ - dashboard/js/detail-panel.js
25
+ - dashboard/js/live-stream.js
26
+ - dashboard/js/modal-qa.js
27
+ - dashboard/js/render-agents.js
28
+ - dashboard/js/render-inbox.js
29
+ - dashboard/js/render-meetings.js
30
+ - dashboard/js/render-pinned.js
31
+ - dashboard/js/render-work-items.js
32
+ - dashboard/js/utils.js
33
+
34
+ ### Playbooks
35
+ - meeting-conclude.md
36
+
3
37
  ## 0.1.95 (2026-04-01)
4
38
 
5
39
  ### 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
  }
@@ -238,9 +238,7 @@ function renderPrdProgress(prog) {
238
238
  : isCompleted
239
239
  ? '<span onclick="event.stopPropagation();triggerVerify(\'' + escHtml(g.file) + '\',this)" style="color:var(--green);cursor:pointer;font-size:9px;padding:1px 6px;background:rgba(63,185,80,0.1);border:1px solid rgba(63,185,80,0.3);border-radius:3px">Verify</span>'
240
240
  : '<span onclick="event.stopPropagation();planPause(\'' + escHtml(g.file) + '\',this)" style="color:var(--yellow);cursor:pointer;font-size:9px;padding:1px 6px;background:rgba(210,153,34,0.1);border:1px solid rgba(210,153,34,0.3);border-radius:3px">Pause</span>';
241
- const archiveBtn = isCompleted
242
- ? '<span onclick="event.stopPropagation();planArchive(\'' + escHtml(g.file) + '\',this)" style="color:var(--muted);cursor:pointer;font-size:9px;padding:1px 6px;background:var(--surface);border:1px solid var(--border);border-radius:3px">Archive</span>'
243
- : '';
241
+ const archiveBtn = (isCompleted || isPaused) ? '<span onclick="event.stopPropagation();planArchive(\'' + escHtml(g.file) + '\',this)" style="color:var(--muted);cursor:pointer;font-size:9px;padding:1px 6px;background:rgba(139,148,158,0.1);border:1px solid rgba(139,148,158,0.3);border-radius:3px">Archive</span>' : '';
244
242
  const deleteBtn = '<span onclick="event.stopPropagation();planDelete(\'' + escHtml(g.file) + '\')" style="color:var(--red);cursor:pointer;font-size:9px;padding:1px 6px;background:rgba(248,81,73,0.1);border:1px solid rgba(248,81,73,0.3);border-radius:3px">Delete</span>';
245
243
  const sourcePlanLink = g.sourcePlan
246
244
  ? '<span onclick="event.stopPropagation();planView(\'' + escHtml(g.sourcePlan) + '\')" style="color:var(--blue);cursor:pointer;font-size:9px;padding:1px 6px;background:rgba(56,139,253,0.1);border:1px solid rgba(56,139,253,0.3);border-radius:3px" title="View source plan">&#x1F4C4; Plan</span>'
@@ -509,6 +507,7 @@ function showArchivedPrdDetail(idx) {
509
507
  '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;' + (isGraph ? 'background:var(--blue);color:#fff;border-color:var(--blue)' : '') + '" onclick="window._archivedPrdViewMode=\'graph\';showArchivedPrdDetail(' + idx + ')">Graph</button>' +
510
508
  '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;' + (!isGraph ? 'background:var(--blue);color:#fff;border-color:var(--blue)' : '') + '" onclick="window._archivedPrdViewMode=\'list\';showArchivedPrdDetail(' + idx + ')">List</button>' +
511
509
  '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green);margin-left:auto" onclick="triggerVerify(\'' + escHtml(g.file) + '\')">Trigger Verify</button>' +
510
+ '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" onclick="planUnarchive(\'' + escHtml(g.file) + '\',this)">Unarchive</button>' +
512
511
  '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" onclick="openArchivedPrdModal()">Back</button>' +
513
512
  '</div>';
514
513
 
@@ -7,9 +7,11 @@ const PR_PER_PAGE = 25;
7
7
  function prRow(pr) {
8
8
  // Minions review (agent) state — separate from ADO human review
9
9
  const sq = pr.minionsReview || {};
10
- const reviewSource = sq.status || pr.reviewStatus || 'pending';
10
+ // If PR is merged/abandoned, treat 'waiting' review as resolved
11
+ const effectiveReviewStatus = (pr.status === 'merged' || pr.status === 'abandoned') && pr.reviewStatus === 'waiting' ? (pr.status === 'merged' ? 'approved' : 'pending') : pr.reviewStatus;
12
+ const reviewSource = sq.status || effectiveReviewStatus || 'pending';
11
13
  const reviewClass = reviewSource === 'approved' ? 'approved' : (reviewSource === 'changes-requested' || reviewSource === 'rejected') ? 'rejected' : reviewSource === 'waiting' ? 'building' : 'draft';
12
- const reviewLabel = sq.status === 'waiting' ? 'reviewing (minions)' : sq.status ? sq.status + ' (minions)' : (pr.reviewStatus || 'pending');
14
+ const reviewLabel = sq.status === 'waiting' ? 'reviewing (minions)' : sq.status ? sq.status + ' (minions)' : (effectiveReviewStatus || 'pending');
13
15
  const buildClass = pr.buildStatus === 'passing' ? 'build-pass' : pr.buildStatus === 'failing' ? 'build-fail' : pr.buildStatus === 'running' ? 'building' : 'no-build';
14
16
  const buildLabel = pr.buildStatus || 'none';
15
17
  const statusClass = pr.status === 'merged' ? 'merged' : pr.status === 'abandoned' ? 'rejected' : pr.status === 'active' ? 'active' : 'draft';
@@ -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
@@ -1647,6 +1647,80 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1647
1647
  return jsonReply(res, 200, plans);
1648
1648
  }
1649
1649
 
1650
+ async function handlePlansArchiveMove(req, res) {
1651
+ try {
1652
+ const body = await readBody(req);
1653
+ if (!body.file) return jsonReply(res, 400, { error: 'file required' });
1654
+ const file = body.file;
1655
+ if (file.includes('..') || file.includes('\0')) return jsonReply(res, 400, { error: 'invalid' });
1656
+
1657
+ const isJson = file.endsWith('.json');
1658
+ const sourceDir = isJson ? PRD_DIR : PLANS_DIR;
1659
+ const archiveDir = path.join(sourceDir, 'archive');
1660
+ const sourcePath = path.join(sourceDir, file);
1661
+
1662
+ if (!fs.existsSync(sourcePath)) return jsonReply(res, 404, { error: 'File not found' });
1663
+ if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
1664
+
1665
+ fs.renameSync(sourcePath, path.join(archiveDir, file));
1666
+
1667
+ // If archiving a PRD .json, also archive its source .md plan
1668
+ let archivedSource = null;
1669
+ if (isJson) {
1670
+ try {
1671
+ const prd = safeJson(path.join(archiveDir, file));
1672
+ if (prd?.source_plan) {
1673
+ const mdPath = path.join(PLANS_DIR, prd.source_plan);
1674
+ if (fs.existsSync(mdPath)) {
1675
+ const planArchive = path.join(PLANS_DIR, 'archive');
1676
+ if (!fs.existsSync(planArchive)) fs.mkdirSync(planArchive, { recursive: true });
1677
+ fs.renameSync(mdPath, path.join(planArchive, prd.source_plan));
1678
+ archivedSource = prd.source_plan;
1679
+ }
1680
+ }
1681
+ } catch { /* optional — source plan may not exist */ }
1682
+ }
1683
+
1684
+ invalidateStatusCache();
1685
+ return jsonReply(res, 200, { ok: true, archivedSource });
1686
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1687
+ }
1688
+
1689
+ async function handlePlansUnarchive(req, res) {
1690
+ try {
1691
+ const body = await readBody(req);
1692
+ if (!body.file) return jsonReply(res, 400, { error: 'file required' });
1693
+ const file = body.file;
1694
+ if (file.includes('..') || file.includes('\0')) return jsonReply(res, 400, { error: 'invalid' });
1695
+
1696
+ const isJson = file.endsWith('.json');
1697
+ const targetDir = isJson ? PRD_DIR : PLANS_DIR;
1698
+ const archiveDir = path.join(targetDir, 'archive');
1699
+ const archivePath = path.join(archiveDir, file);
1700
+
1701
+ if (!fs.existsSync(archivePath)) return jsonReply(res, 404, { error: 'File not found in archive' });
1702
+ fs.renameSync(archivePath, path.join(targetDir, file));
1703
+
1704
+ // If unarchiving a PRD .json, also unarchive its source .md plan
1705
+ let unarchivedSource = null;
1706
+ if (isJson) {
1707
+ try {
1708
+ const prd = safeJson(path.join(targetDir, file));
1709
+ if (prd?.source_plan) {
1710
+ const mdArchivePath = path.join(PLANS_DIR, 'archive', prd.source_plan);
1711
+ if (fs.existsSync(mdArchivePath)) {
1712
+ fs.renameSync(mdArchivePath, path.join(PLANS_DIR, prd.source_plan));
1713
+ unarchivedSource = prd.source_plan;
1714
+ }
1715
+ }
1716
+ } catch { /* optional */ }
1717
+ }
1718
+
1719
+ invalidateStatusCache();
1720
+ return jsonReply(res, 200, { ok: true, unarchivedSource });
1721
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1722
+ }
1723
+
1650
1724
  async function handlePlansArchiveRead(req, res, match) {
1651
1725
  const file = decodeURIComponent(match[1]);
1652
1726
  if (file.includes('..') || file.includes('\0')) return jsonReply(res, 400, { error: 'invalid' });
@@ -2082,17 +2156,61 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
2082
2156
  fs.renameSync(planPath, archivePath);
2083
2157
 
2084
2158
  // Mark archived in JSON if PRD
2159
+ let archivedSource = null;
2085
2160
  if (body.file.endsWith('.json')) {
2086
2161
  try {
2087
2162
  const prd = JSON.parse(safeRead(archivePath) || '{}');
2088
2163
  prd.status = 'archived';
2089
2164
  prd.archivedAt = new Date().toISOString();
2090
2165
  safeWrite(archivePath, prd);
2166
+ // Also archive linked source plan
2167
+ if (prd.source_plan) {
2168
+ const mdPath = path.join(PLANS_DIR, prd.source_plan);
2169
+ if (fs.existsSync(mdPath)) {
2170
+ const planArchive = path.join(PLANS_DIR, 'archive');
2171
+ if (!fs.existsSync(planArchive)) fs.mkdirSync(planArchive, { recursive: true });
2172
+ fs.renameSync(mdPath, path.join(planArchive, prd.source_plan));
2173
+ archivedSource = prd.source_plan;
2174
+ }
2175
+ }
2176
+ } catch { /* optional */ }
2177
+ }
2178
+
2179
+ invalidateStatusCache();
2180
+ return jsonReply(res, 200, { ok: true, archived: body.file, archivedSource });
2181
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2182
+ }
2183
+
2184
+ async function handlePlansUnarchive(req, res) {
2185
+ try {
2186
+ const body = await readBody(req);
2187
+ if (!body.file) return jsonReply(res, 400, { error: 'file required' });
2188
+ if (body.file.includes('..') || body.file.includes('\0') || body.file.includes('/') || body.file.includes('\\')) {
2189
+ return jsonReply(res, 400, { error: 'invalid filename' });
2190
+ }
2191
+ const isJson = body.file.endsWith('.json');
2192
+ const targetDir = isJson ? PRD_DIR : PLANS_DIR;
2193
+ const archivePath = path.join(targetDir, 'archive', body.file);
2194
+ if (!fs.existsSync(archivePath)) return jsonReply(res, 404, { error: 'File not found in archive' });
2195
+ fs.renameSync(archivePath, path.join(targetDir, body.file));
2196
+
2197
+ // Also unarchive linked source plan
2198
+ let unarchivedSource = null;
2199
+ if (isJson) {
2200
+ try {
2201
+ const prd = safeJson(path.join(targetDir, body.file));
2202
+ if (prd?.source_plan) {
2203
+ const mdArchivePath = path.join(PLANS_DIR, 'archive', prd.source_plan);
2204
+ if (fs.existsSync(mdArchivePath)) {
2205
+ fs.renameSync(mdArchivePath, path.join(PLANS_DIR, prd.source_plan));
2206
+ unarchivedSource = prd.source_plan;
2207
+ }
2208
+ }
2091
2209
  } catch { /* optional */ }
2092
2210
  }
2093
2211
 
2094
2212
  invalidateStatusCache();
2095
- return jsonReply(res, 200, { ok: true, archived: body.file });
2213
+ return jsonReply(res, 200, { ok: true, unarchivedSource });
2096
2214
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2097
2215
  }
2098
2216
 
@@ -3115,8 +3233,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3115
3233
  { method: 'POST', path: '/api/plans/regenerate', desc: 'Reset pending/failed work items for a plan so they re-materialize', params: 'source', handler: handlePlansRegenerate },
3116
3234
  { method: 'POST', path: '/api/plans/delete', desc: 'Delete a plan file and clean up work items', params: 'file', handler: handlePlansDelete },
3117
3235
  { method: 'POST', path: '/api/plans/archive', desc: 'Move a plan/PRD to archive (preserves work items)', params: 'file', handler: handlePlansArchive },
3236
+ { method: 'POST', path: '/api/plans/unarchive', desc: 'Restore a plan/PRD from archive', params: 'file', handler: handlePlansUnarchive },
3118
3237
  { method: 'POST', path: '/api/plans/revise', desc: 'Request revision with feedback, dispatches agent to revise', params: 'file, feedback, requestedBy?', handler: handlePlansRevise },
3119
3238
  { method: 'POST', path: '/api/plans/discuss', desc: 'Generate a plan discussion session script for Claude CLI', params: 'file', handler: handlePlansDiscuss },
3239
+ { method: 'POST', path: '/api/plans/archive', desc: 'Archive a plan/PRD (move to archive folder)', params: 'file', handler: handlePlansArchiveMove },
3240
+ { method: 'POST', path: '/api/plans/unarchive', desc: 'Unarchive a plan/PRD (restore from archive folder)', params: 'file', handler: handlePlansUnarchive },
3120
3241
  { method: 'GET', path: /^\/api\/plans\/archive\/([^?]+)$/, desc: 'Read an archived plan file', handler: handlePlansArchiveRead },
3121
3242
  { method: 'GET', path: /^\/api\/plans\/([^?]+)$/, desc: 'Read a full plan (JSON from prd/ or markdown from plans/)', handler: handlePlansRead },
3122
3243
 
package/engine/ado.js CHANGED
@@ -131,6 +131,10 @@ async function pollPrStatus(config) {
131
131
  updated = true;
132
132
 
133
133
  if (newStatus === 'merged' || newStatus === 'abandoned') {
134
+ if (pr.reviewStatus === 'waiting') {
135
+ pr.reviewStatus = newStatus === 'merged' ? 'approved' : 'pending';
136
+ e.log('info', `PR ${pr.id} reviewStatus: waiting → ${pr.reviewStatus} (${newStatus})`);
137
+ }
134
138
  await engine().handlePostMerge(pr, project, config, newStatus);
135
139
  }
136
140
  }
@@ -344,8 +348,8 @@ async function reconcilePrs(config) {
344
348
  const branch = (adoPr.sourceRefName || '').replace('refs/heads/', '');
345
349
  const title = adoPr.title || '';
346
350
  // 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+)\)/);
351
+ const branchMatch = branch.match(/(P-[a-z0-9]{6,})/i) || branch.match(/(PL-W\d+)/i);
352
+ const titleMatch = title.match(/\((P-[a-z0-9]{6,})\)/) || title.match(/\((PL-W\d+)\)/);
349
353
  const linkedItemId = branchMatch?.[1] || titleMatch?.[1] || null;
350
354
  const linkedItem = linkedItemId ? allItems.find(i => i.id === linkedItemId) : null;
351
355
  const confirmedItemId = linkedItem ? linkedItemId : null;
@@ -368,7 +372,7 @@ async function reconcilePrs(config) {
368
372
  existingPrs.push({
369
373
  id: prId,
370
374
  title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
371
- agent: (adoPr.createdBy?.displayName || 'unknown').toLowerCase(),
375
+ agent: (linkedItem?.dispatched_to || adoPr.createdBy?.displayName || 'unknown').toLowerCase(),
372
376
  branch,
373
377
  reviewStatus: 'pending',
374
378
  status: 'active',
package/engine/github.js CHANGED
@@ -135,6 +135,11 @@ async function pollPrStatus(config) {
135
135
  updated = true;
136
136
 
137
137
  if (newStatus === 'merged' || newStatus === 'abandoned') {
138
+ // Resolve stale 'waiting' review status — won't be polled again after this
139
+ if (pr.reviewStatus === 'waiting') {
140
+ pr.reviewStatus = newStatus === 'merged' ? 'approved' : 'pending';
141
+ e.log('info', `PR ${pr.id} reviewStatus: waiting → ${pr.reviewStatus} (${newStatus})`);
142
+ }
138
143
  await engine().handlePostMerge(pr, project, config, newStatus);
139
144
  }
140
145
  }
@@ -334,7 +339,7 @@ async function reconcilePrs(config) {
334
339
  for (const ghPr of ghPrs) {
335
340
  const prId = `PR-${ghPr.number}`;
336
341
  const branch = ghPr.head?.ref || '';
337
- const wiMatch = branch.match(/(P-[a-f0-9]{6,})/i) || branch.match(/(PL-W\d+)/i);
342
+ const wiMatch = branch.match(/(P-[a-z0-9]{6,})/i) || branch.match(/(PL-W\d+)/i);
338
343
  const linkedItemId = wiMatch ? wiMatch[1] : null;
339
344
  const linkedItem = linkedItemId ? allItems.find(i => i.id === linkedItemId) : null;
340
345
  const confirmedItemId = linkedItem ? linkedItemId : null;
@@ -356,7 +361,7 @@ async function reconcilePrs(config) {
356
361
  existingPrs.push({
357
362
  id: prId,
358
363
  title: (ghPr.title || `PR #${ghPr.number}`).slice(0, 120),
359
- agent: (ghPr.user?.login || 'unknown').toLowerCase(),
364
+ agent: (linkedItem?.dispatched_to || ghPr.user?.login || 'unknown').toLowerCase(),
360
365
  branch,
361
366
  reviewStatus: 'pending',
362
367
  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.95",
3
+ "version": "0.1.97",
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"