@yemi33/minions 0.1.816 → 0.1.818

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.816 (2026-04-11)
3
+ ## 0.1.818 (2026-04-11)
4
4
 
5
5
  ### Features
6
+ - verify workflow handles shared-branch plans and existing E2E PRs
7
+ - doc-chat drag-to-resize, queued message UX, insertAdjacentHTML
8
+ - re-open done work items when PRD item set to updated/missing
9
+ - diff-aware plan-to-prd dispatch for completed/approved PRDs
6
10
  - Bidirectional Teams integration for Command Center (#764)
7
11
  - show branch strategy on PRD view (#762)
8
12
  - Restructure decompose.md sub-task output format (#655)
@@ -13,6 +17,9 @@
13
17
  - cap review→fix cycles at evalMaxIterations (default 3)
14
18
 
15
19
  ### Fixes
20
+ - strip ===ACTIONS=== from CC streamed text during rendering
21
+ - ccExecuteAction routes action status to correct tab via targetTabId
22
+ - clear _completionNotified on plan resume for re-completion
16
23
  - handle local-only dependency branches in dep merge (closes #782) (#825)
17
24
  - reset PRD item status when work item is deleted (closes #779) (#823)
18
25
  - add smoke test for checkTimeouts ReferenceError regression (closes #775) (#822)
@@ -30,9 +37,9 @@
30
37
  - approved is permanent — no path can ever downgrade it
31
38
  - allow fix dispatch on approved PRs (only guard is in updatePrAfterFix)
32
39
  - bot comment on approved PR can no longer reset vote
33
- - remove redundant 'PR' prefix from dispatch labels (id already has it)
34
- - include PR title in all dispatch labels and escalation alerts
35
- - never downgrade reviewStatus from 'approved' unless explicitly rejected
40
+
41
+ ### Other
42
+ - docs: diff-aware PRD update playbook + prioritize team memory in shared rules
36
43
 
37
44
  ## 0.1.782 (2026-04-10)
38
45
 
@@ -149,7 +149,10 @@ function ccSwitchTab(id) {
149
149
  html += '</div>';
150
150
  }
151
151
  var text = tab._streamedText || '';
152
- if (text) html += renderMd(text);
152
+ if (text) {
153
+ var displayRestore = text.indexOf('===ACTIONS===') >= 0 ? text.slice(0, text.indexOf('===ACTIONS===')).trim() : text;
154
+ html += renderMd(displayRestore);
155
+ }
153
156
  var ms = Date.now() - restoreStart;
154
157
  var label = 'Thinking...';
155
158
  for (var pi = phases.length - 1; pi >= 0; pi--) { if (ms >= phases[pi][0]) { label = phases[pi][1]; break; } }
@@ -510,7 +513,8 @@ async function _ccDoSend(message, skipUserMsg) {
510
513
  html += '</div>';
511
514
  }
512
515
  if (streamedText) {
513
- html += renderMd(streamedText);
516
+ var displayText = streamedText.indexOf('===ACTIONS===') >= 0 ? streamedText.slice(0, streamedText.indexOf('===ACTIONS===')).trim() : streamedText;
517
+ html += renderMd(displayText);
514
518
  }
515
519
  html += '<div style="margin-top:' + (streamedText ? '6px' : '0') + '">' + _getThinkingHtml() + '</div>';
516
520
  streamDiv.innerHTML = html;
@@ -579,7 +583,7 @@ async function _ccDoSend(message, skipUserMsg) {
579
583
  ccSaveState(); ccUpdateSessionIndicator();
580
584
  }
581
585
  if (evt.actions && evt.actions.length > 0) {
582
- for (var ai = 0; ai < evt.actions.length; ai++) { await ccExecuteAction(evt.actions[ai]); }
586
+ for (var ai = 0; ai < evt.actions.length; ai++) { await ccExecuteAction(evt.actions[ai], activeTabId); }
583
587
  }
584
588
  } else if (evt.type === 'error') {
585
589
  _cleanupStreamDiv();
@@ -609,7 +613,7 @@ async function _ccDoSend(message, skipUserMsg) {
609
613
  ccSaveState(); ccUpdateSessionIndicator();
610
614
  }
611
615
  if (revt.actions && revt.actions.length > 0) {
612
- for (var ai2 = 0; ai2 < revt.actions.length; ai2++) { await ccExecuteAction(revt.actions[ai2]); }
616
+ for (var ai2 = 0; ai2 < revt.actions.length; ai2++) { await ccExecuteAction(revt.actions[ai2], activeTabId); }
613
617
  }
614
618
  } else if (revt.type === 'chunk') {
615
619
  streamedText = revt.text;
@@ -688,8 +692,7 @@ async function _ccFetch(url, body) {
688
692
  return res;
689
693
  }
690
694
 
691
- async function ccExecuteAction(action) {
692
- var msgs = document.getElementById('cc-messages');
695
+ async function ccExecuteAction(action, targetTabId) {
693
696
  var status = document.createElement('div');
694
697
  status.style.cssText = 'padding:4px 10px;border-radius:4px;font-size:10px;align-self:flex-start;border:1px dashed var(--border);color:var(--muted)';
695
698
 
@@ -1052,8 +1055,7 @@ async function ccExecuteAction(action) {
1052
1055
  status.style.color = 'var(--red)';
1053
1056
  }
1054
1057
 
1055
- msgs.appendChild(status);
1056
- if (msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 150) msgs.scrollTop = msgs.scrollHeight;
1058
+ ccAddMessage('assistant', status.outerHTML, false, targetTabId);
1057
1059
  refresh();
1058
1060
  }
1059
1061
 
@@ -28,6 +28,17 @@ let _qaAbortController = null;
28
28
  let _qaQueue = []; // queued messages while processing
29
29
  const QA_QUEUE_CAP = 10; // max queued messages
30
30
  let _qaSessionKey = ''; // key for current conversation (title or filePath)
31
+
32
+ function _renderQaUserMessage(thread, message, selection) {
33
+ let qHtml = '<div class="modal-qa-q">' + escHtml(message);
34
+ if (selection) {
35
+ qHtml += '<span class="selection-ref">Re: "' + escHtml(selection.slice(0, 100)) + ((selection.length > 100) ? '...' : '') + '"</span>';
36
+ }
37
+ qHtml += '</div>';
38
+ thread.insertAdjacentHTML('beforeend', qHtml);
39
+ thread.scrollTop = thread.scrollHeight;
40
+ _showThreadWrap();
41
+ }
31
42
  const _qaSessions = new Map(); // persist conversations across modal open/close {key → {history, threadHtml}}
32
43
  // Restore from localStorage
33
44
  try {
@@ -100,6 +111,11 @@ function _initQaSession() {
100
111
  }
101
112
  if (prior.filePath) _modalFilePath = prior.filePath;
102
113
  _showThreadWrap();
114
+ // Defer scroll — container just transitioned from display:none, layout not yet computed
115
+ requestAnimationFrame(function() {
116
+ var thread = document.getElementById('modal-qa-thread');
117
+ if (thread) thread.scrollTop = thread.scrollHeight;
118
+ });
103
119
  } else {
104
120
  _qaHistory = [];
105
121
  document.getElementById('modal-qa-thread').innerHTML = '';
@@ -145,16 +161,6 @@ function modalSend() {
145
161
  var thread = document.getElementById('modal-qa-thread');
146
162
  const selection = _modalDocContext.selection || '';
147
163
 
148
- // Show message in thread immediately
149
- let qHtml = '<div class="modal-qa-q">' + escHtml(message);
150
- if (selection) {
151
- qHtml += '<span class="selection-ref">Re: "' + escHtml(selection.slice(0, 100)) + ((selection.length > 100) ? '...' : '') + '"</span>';
152
- }
153
- qHtml += '</div>';
154
- thread.innerHTML += qHtml;
155
- thread.scrollTop = thread.scrollHeight;
156
- _showThreadWrap();
157
-
158
164
  // Clear input immediately so user can type next message
159
165
  input.value = '';
160
166
  _modalDocContext.selection = '';
@@ -165,14 +171,18 @@ function modalSend() {
165
171
  showToast('cmd-toast', 'Queue full — wait for current response', false);
166
172
  return;
167
173
  }
168
- // Queue the message — show it as "queued" in the thread
174
+ // Queue the message — show only grey queued indicator (blue bubble shows when processing starts)
169
175
  _qaQueue.push({ message, selection });
170
176
  const preview = escHtml(message.length > 60 ? message.slice(0, 57) + '...' : message);
171
- thread.innerHTML += '<div class="qa-queued-item" style="color:var(--muted);font-size:10px;padding:4px 8px">Queued: "' + preview + '"</div>';
177
+ thread.insertAdjacentHTML('beforeend', '<div class="qa-queued-item" style="color:var(--muted);font-size:10px;padding:4px 8px">Queued: "' + preview + '"</div>');
172
178
  thread.scrollTop = thread.scrollHeight;
179
+ _showThreadWrap();
173
180
  return;
174
181
  }
175
182
 
183
+ // Show message in thread when processing starts (not when queued)
184
+ _renderQaUserMessage(thread, message, selection);
185
+
176
186
  _processQaMessage(message, selection);
177
187
  }
178
188
 
@@ -192,12 +202,12 @@ async function _processQaMessage(message, selection) {
192
202
  const loadingId = 'chat-loading-' + Date.now();
193
203
  const qaQueueBadge = _qaQueue.length > 0 ? ' <span style="font-size:9px;color:var(--muted);background:var(--surface);padding:1px 5px;border-radius:8px;border:1px solid var(--border)">+' + _qaQueue.length + ' queued</span>' : '';
194
204
  _qaAbortController = new AbortController();
195
- thread.innerHTML += '<div class="modal-qa-loading" id="' + loadingId + '">' +
205
+ thread.insertAdjacentHTML('beforeend', '<div class="modal-qa-loading" id="' + loadingId + '">' +
196
206
  '<div class="dot-pulse"><span></span><span></span><span></span></div> ' +
197
207
  '<span id="' + loadingId + '-text">Thinking...</span> ' +
198
208
  '<span id="' + loadingId + '-time" style="font-size:10px;color:var(--muted)"></span>' +
199
209
  ' <button onclick="qaAbort()" style="font-size:9px;padding:2px 8px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--red);cursor:pointer">Stop</button>' +
200
- qaQueueBadge + '</div>';
210
+ qaQueueBadge + '</div>');
201
211
  thread.scrollTop = thread.scrollHeight;
202
212
 
203
213
  const isPlanEdit = _modalFilePath && _modalFilePath.match(/^plans\/.*\.md$/);
@@ -237,7 +247,7 @@ async function _processQaMessage(message, selection) {
237
247
  const suffix = data.edited ? '\n\n\u2713 Document saved.' : '';
238
248
  const qaElapsed = Math.round((Date.now() - qaStartTime) / 1000);
239
249
  const qaTimeLabel = '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right;padding-right:24px">' + qaElapsed + 's</div>';
240
- thread.innerHTML += '<div class="modal-qa-a" style="border-left-color:' + borderColor + '">' + llmCopyBtn() + renderMd(data.answer + suffix) + qaTimeLabel + '</div>';
250
+ thread.insertAdjacentHTML('beforeend', '<div class="modal-qa-a" style="border-left-color:' + borderColor + '">' + llmCopyBtn() + renderMd(data.answer + suffix) + qaTimeLabel + '</div>');
241
251
 
242
252
  // Track conversation history
243
253
  _qaHistory.push({ role: 'user', text: message });
@@ -281,7 +291,7 @@ async function _processQaMessage(message, selection) {
281
291
  }
282
292
  } else {
283
293
  const qaElapsedErr = Math.round((Date.now() - qaStartTime) / 1000);
284
- thread.innerHTML += '<div class="modal-qa-a" style="color:var(--red)">Error: ' + escHtml(data.error || 'Failed') + '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right">' + qaElapsedErr + 's</div></div>';
294
+ thread.insertAdjacentHTML('beforeend', '<div class="modal-qa-a" style="color:var(--red)">Error: ' + escHtml(data.error || 'Failed') + '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right">' + qaElapsedErr + 's</div></div>');
285
295
  }
286
296
  } catch (e) {
287
297
  clearInterval(qaTimer);
@@ -289,9 +299,9 @@ async function _processQaMessage(message, selection) {
289
299
  if (loadingEl) loadingEl.remove();
290
300
  const qaElapsedExc = Math.round((Date.now() - qaStartTime) / 1000);
291
301
  if (e.name === 'AbortError') {
292
- thread.innerHTML += '<div class="modal-qa-a" style="color:var(--muted)">Stopped<div style="font-size:9px;margin-top:4px;text-align:right">' + qaElapsedExc + 's</div></div>';
302
+ thread.insertAdjacentHTML('beforeend', '<div class="modal-qa-a" style="color:var(--muted)">Stopped<div style="font-size:9px;margin-top:4px;text-align:right">' + qaElapsedExc + 's</div></div>');
293
303
  } else {
294
- thread.innerHTML += '<div class="modal-qa-a" style="color:var(--red)">Error: ' + escHtml(e.message) + '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right">' + qaElapsedExc + 's</div></div>';
304
+ thread.insertAdjacentHTML('beforeend', '<div class="modal-qa-a" style="color:var(--red)">Error: ' + escHtml(e.message) + '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right">' + qaElapsedExc + 's</div></div>');
295
305
  }
296
306
  }
297
307
 
@@ -329,9 +339,10 @@ async function _processQaMessage(message, selection) {
329
339
  // Process next queued message
330
340
  if (_qaQueue.length > 0) {
331
341
  const next = _qaQueue.shift();
332
- // Remove the first queued indicator (matches the message being sent now)
342
+ // Remove the queued indicator and show the blue user bubble now that it's processing
333
343
  const queuedEl = thread.querySelector('.qa-queued-item');
334
344
  if (queuedEl) queuedEl.remove();
345
+ _renderQaUserMessage(thread, next.message, next.selection);
335
346
  _processQaMessage(next.message, next.selection);
336
347
  } else if (modalIsOpen) {
337
348
  document.getElementById('modal-qa-input')?.focus();
@@ -361,4 +372,46 @@ function _showThreadWrap() {
361
372
  if (expandBar) expandBar.style.display = 'none';
362
373
  }
363
374
 
375
+ // ── Drag-to-resize doc chat thread ──────────────────────────────────────────
376
+ (function() {
377
+ var _dragging = false, _startY = 0, _startH = 0, _thread = null;
378
+ var COLLAPSE_THRESHOLD = 40;
379
+ var MIN_HEIGHT = 60;
380
+ var MAX_HEIGHT = 500;
381
+
382
+ document.addEventListener('pointerdown', function(e) {
383
+ var handle = e.target.closest('#qa-resize-handle');
384
+ if (!handle) return;
385
+ _thread = document.getElementById('modal-qa-thread');
386
+ if (!_thread) return;
387
+ _dragging = true;
388
+ _startY = e.clientY;
389
+ _startH = _thread.offsetHeight || 200;
390
+ handle.setPointerCapture(e.pointerId);
391
+ document.body.style.userSelect = 'none';
392
+ e.preventDefault();
393
+ });
394
+
395
+ document.addEventListener('pointermove', function(e) {
396
+ if (!_dragging || !_thread) return;
397
+ var delta = _startY - e.clientY;
398
+ var newH = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, _startH + delta));
399
+ _thread.style.maxHeight = newH + 'px';
400
+ });
401
+
402
+ document.addEventListener('pointerup', function(e) {
403
+ if (!_dragging) return;
404
+ _dragging = false;
405
+ document.body.style.userSelect = '';
406
+ if (!_thread) return;
407
+ var delta = _startY - e.clientY;
408
+ var finalH = _startH + delta;
409
+ if (finalH < COLLAPSE_THRESHOLD) {
410
+ _thread.style.maxHeight = '';
411
+ toggleDocChat();
412
+ }
413
+ _thread = null;
414
+ });
415
+ })();
416
+
364
417
  window.MinionsQA = { showModalQa, modalAskAboutSelection, clearQaSelection, clearQaConversation, modalSend, qaAbort, toggleDocChat, _showThreadWrap };
@@ -113,6 +113,9 @@
113
113
  <button style="background:none;border:none;color:var(--muted);font-size:10px;cursor:pointer;padding:2px 6px" onclick="toggleDocChat()" title="Expand thread">&#x25B2; Expand chat</button>
114
114
  </div>
115
115
  <div id="modal-qa-thread-wrap" style="display:none">
116
+ <div id="qa-resize-handle" style="height:6px;cursor:ns-resize;display:flex;align-items:center;justify-content:center;user-select:none;touch-action:none" title="Drag to resize">
117
+ <div style="width:32px;height:3px;border-radius:2px;background:var(--border)"></div>
118
+ </div>
116
119
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
117
120
  <button id="qa-clear-btn" style="background:none;border:none;color:var(--muted);font-size:10px;cursor:pointer;padding:2px 6px" onclick="clearQaConversation()" title="Clear conversation">Clear</button>
118
121
  <button style="background:none;border:none;color:var(--muted);font-size:10px;cursor:pointer;padding:2px 6px" onclick="toggleDocChat()" id="qa-toggle-btn" title="Collapse thread">&#x25BC; Collapse</button>
package/dashboard.js CHANGED
@@ -2186,6 +2186,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2186
2186
  plan.approvedAt = new Date().toISOString();
2187
2187
  plan.approvedBy = body.approvedBy || os.userInfo().username;
2188
2188
  delete plan.pausedAt;
2189
+ delete plan._completionNotified; // Allow re-completion after new work finishes
2189
2190
  safeWrite(planPath, plan);
2190
2191
 
2191
2192
  // Resume paused work items across all projects
@@ -204,8 +204,22 @@ function checkPlanCompletion(meta, config) {
204
204
  }
205
205
  }
206
206
 
207
+ // Shared-branch plans already have all changes on a single feature branch — no merge needed
208
+ const isSharedBranch = plan.branch_strategy === 'shared-branch' && plan.feature_branch;
209
+
207
210
  // Build per-project checkout commands: one worktree, merge all PR branches into it
208
211
  const checkoutBlocks = Object.entries(projectPrs).map(([name, { project: p, prs, mainBranch }]) => {
212
+ if (isSharedBranch) {
213
+ const featureBranch = plan.feature_branch;
214
+ const wtPath = `${p.localPath}/../worktrees/verify-${name}-${planSlug}`;
215
+ const lines = [
216
+ `# ${name} — shared-branch: use existing feature branch directly`,
217
+ `cd "${p.localPath.replace(/\\/g, '/')}"`,
218
+ `git fetch origin "${featureBranch}"`,
219
+ `git worktree add "${wtPath}" "origin/${featureBranch}" 2>/dev/null || (cd "${wtPath}" && git checkout "${featureBranch}" && git pull origin "${featureBranch}")`,
220
+ ];
221
+ return lines.join('\n');
222
+ }
209
223
  const wtPath = `${p.localPath}/../worktrees/verify-${name}-${planSlug}-${shared.uid()}`;
210
224
  const branches = prs.map(pr => pr.branch).filter(Boolean);
211
225
  const lines = [
@@ -235,9 +249,13 @@ function checkPlanCompletion(meta, config) {
235
249
  `- **${name}**: \`${p.localPath}/../worktrees/verify-${planSlug}\``
236
250
  ).join('\n');
237
251
 
252
+ const sharedBranchNote = isSharedBranch
253
+ ? `\n**Shared-branch plan** — all changes are already on branch \`${plan.feature_branch}\`. Use this branch directly for the E2E PR instead of creating a new \`e2e/\` branch. Check if a PR already exists for this branch before creating one.\n`
254
+ : '';
255
+
238
256
  const description = [
239
257
  `Verification task for completed plan \`${planFile}\`.`,
240
- ``,
258
+ sharedBranchNote,
241
259
  `## Projects & Worktrees`,
242
260
  ``,
243
261
  `Each project gets ONE worktree with all PR branches merged in:`,
package/engine.js CHANGED
@@ -1227,6 +1227,12 @@ function autoCleanPrdWorkItems(prdFile, config) {
1227
1227
  }
1228
1228
  }
1229
1229
 
1230
+ function buildWiDescription(item, planFile) {
1231
+ const criteria = (item.acceptance_criteria || []).map(c => `- ${c}`).join('\n');
1232
+ const complexity = item.estimated_complexity || 'medium';
1233
+ return `${item.description || ''}\n\n**Plan:** ${planFile}\n**Plan Item:** ${item.id}\n**Complexity:** ${complexity}${criteria ? '\n\n**Acceptance Criteria:**\n' + criteria : ''}`;
1234
+ }
1235
+
1230
1236
  function materializePlansAsWorkItems(config) {
1231
1237
  if (!fs.existsSync(PRD_DIR)) { try { fs.mkdirSync(PRD_DIR, { recursive: true }); } catch (e) { log('warn', 'create PRD directory: ' + e.message); } }
1232
1238
 
@@ -1325,19 +1331,72 @@ function materializePlansAsWorkItems(config) {
1325
1331
  // Handle PRD based on current status
1326
1332
  const prdStatus = plan.status || (plan.requires_approval ? 'awaiting-approval' : null);
1327
1333
 
1328
- // Approved/paused/completed PRDs: flag stale user must click Re-execute to regenerate
1329
- if (prdStatus && prdStatus !== 'awaiting-approval') {
1334
+ // Approved/completed PRDs with existing work: auto-dispatch diff-aware plan-to-prd update
1335
+ if (prdStatus === PLAN_STATUS.APPROVED || prdStatus === PLAN_STATUS.COMPLETED) {
1336
+ const allWorkItems = queries.getWorkItems(config);
1337
+ const planWis = allWorkItems.filter(w => w.sourcePlan === file && w.itemType !== 'pr' && w.itemType !== 'verify');
1338
+ const doneWis = planWis.filter(w => DONE_STATUSES.has(w.status));
1339
+ if (doneWis.length > 0) {
1340
+ const allPrs = getProjects(config).flatMap(p => safeJson(projectPrPath(p)) || []);
1341
+ const prLinks = shared.getPrLinks();
1342
+ const implContext = (plan.missing_features || []).map(f => {
1343
+ const wi = planWis.find(w => w.id === f.id);
1344
+ const pr = allPrs.find(p => prLinks[p.id] === f.id || (p.prdItems || []).includes(f.id));
1345
+ return `- **${f.id}**: ${f.name} [status: ${wi?.status || f.status}]${pr ? ` (PR: ${pr.id}, branch: \`${pr.branch}\`)` : ''}`;
1346
+ }).join('\n');
1347
+
1348
+ const planContent = safeRead(path.join(PLANS_DIR, plan.source_plan));
1349
+ if (planContent) {
1350
+ const projectName = plan.project || file.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
1351
+ const allProjects = getProjects(config);
1352
+ const targetProject = allProjects.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) || allProjects[0];
1353
+ if (targetProject) {
1354
+ const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
1355
+ let queued = false;
1356
+ mutateJsonFileLocked(centralWiPath, items => {
1357
+ if (!Array.isArray(items)) items = [];
1358
+ if (items.some(w => w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === plan.source_plan && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.DISPATCHED))) return items;
1359
+ items.push({
1360
+ id: 'W-' + shared.uid(),
1361
+ title: `Update PRD from revised plan: ${plan.source_plan}`,
1362
+ type: 'plan-to-prd',
1363
+ priority: 'high',
1364
+ description: `Plan file: plans/${plan.source_plan}\nPRD file: prd/${file}\n\n` +
1365
+ `Source plan was revised. Compare the updated plan against existing implementation and update the PRD.\n\n` +
1366
+ `**Existing implementation state:**\n${implContext}\n\n` +
1367
+ `**Rules for updating:**\n` +
1368
+ `- Items that are done and unchanged in the plan → keep status "done" (preserve their ID)\n` +
1369
+ `- Items that are done but modified in the plan (new requirements) → set status "updated" (engine will re-open)\n` +
1370
+ `- New items in the plan → set status "missing" (engine will materialize)\n` +
1371
+ `- Items removed from the plan → drop from PRD (engine will cancel pending WIs)\n` +
1372
+ `- Preserve all existing item IDs for unchanged/modified items — do NOT generate new IDs for them`,
1373
+ status: 'pending',
1374
+ created: ts(),
1375
+ createdBy: 'engine:plan-revision',
1376
+ project: targetProject.name,
1377
+ planFile: plan.source_plan,
1378
+ _existingPrdFile: file,
1379
+ });
1380
+ queued = true;
1381
+ return items;
1382
+ }, { defaultValue: [] });
1383
+ if (queued) log('info', `Queued diff-aware plan-to-prd update for ${plan.source_plan} (${doneWis.length} done, ${planWis.length} total items)`);
1384
+ }
1385
+ }
1386
+ } else {
1387
+ plan.planStale = true;
1388
+ log('info', `PRD ${file} flagged as stale (plan revised while ${prdStatus}, no done items) — user can re-execute from dashboard`);
1389
+ }
1390
+ } else if (prdStatus === PLAN_STATUS.PAUSED) {
1330
1391
  plan.planStale = true;
1331
- log('info', `PRD ${file} flagged as stale (plan revised while ${prdStatus}) — user can re-execute from dashboard`);
1332
- // Falls through to safeWrite below (writes planStale + sourcePlanModifiedAt together)
1392
+ log('info', `PRD ${file} flagged as stale (plan revised while paused) — user can re-execute from dashboard`);
1333
1393
  }
1334
1394
 
1335
1395
  // Awaiting-approval PRDs: auto-regenerate (no work started yet, safe to replace)
1336
1396
  if (prdStatus === 'awaiting-approval') {
1337
1397
  log('info', `PRD ${file} invalidated (was awaiting-approval) — queuing regeneration from revised plan`);
1338
1398
 
1339
- // Collect completed items to carry over to new PRD
1340
- const completedStatuses = new Set(['done', 'in-pr', 'implemented']); // in-pr kept for backward compat
1399
+ const completedStatuses = new Set(['done', 'in-pr', 'implemented']);
1341
1400
  const completedItems = (plan.missing_features || [])
1342
1401
  .filter(f => completedStatuses.has(f.status))
1343
1402
  .map(f => ({ id: f.id, name: f.name, status: f.status }));
@@ -1346,10 +1405,8 @@ function materializePlansAsWorkItems(config) {
1346
1405
  ? `\nPreviously completed items (preserve their status in the new PRD):\n${completedItems.map(i => `- ${i.id}: ${i.name} [${i.status}]`).join('\n')}`
1347
1406
  : '';
1348
1407
 
1349
- // Delete old PRD — agent will write replacement at same path
1350
1408
  try { fs.unlinkSync(path.join(PRD_DIR, file)); } catch { /* cleanup */ }
1351
1409
 
1352
- // Queue plan-to-prd regeneration
1353
1410
  const planContent = safeRead(path.join(PLANS_DIR, plan.source_plan));
1354
1411
  if (planContent) {
1355
1412
  const projectName = plan.project || file.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
@@ -1357,12 +1414,11 @@ function materializePlansAsWorkItems(config) {
1357
1414
  const targetProject = allProjects.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) || allProjects[0];
1358
1415
  if (targetProject) {
1359
1416
  const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
1360
- const centralItems = safeJson(centralWiPath) || [];
1361
- const alreadyQueued = centralItems.some(w =>
1362
- w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === plan.source_plan && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.DISPATCHED)
1363
- );
1364
- if (!alreadyQueued) {
1365
- centralItems.push({
1417
+ let queued = false;
1418
+ mutateJsonFileLocked(centralWiPath, items => {
1419
+ if (!Array.isArray(items)) items = [];
1420
+ if (items.some(w => w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === plan.source_plan && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.DISPATCHED))) return items;
1421
+ items.push({
1366
1422
  id: 'W-' + shared.uid(),
1367
1423
  title: `Regenerate PRD from revised plan: ${plan.source_plan}`,
1368
1424
  type: 'plan-to-prd',
@@ -1374,9 +1430,10 @@ function materializePlansAsWorkItems(config) {
1374
1430
  project: targetProject.name,
1375
1431
  planFile: plan.source_plan,
1376
1432
  });
1377
- safeWrite(centralWiPath, centralItems);
1378
- log('info', `Queued plan-to-prd regeneration for revised plan ${plan.source_plan} (${completedItems.length} completed items to carry over)`);
1379
- }
1433
+ queued = true;
1434
+ return items;
1435
+ }, { defaultValue: [] });
1436
+ if (queued) log('info', `Queued plan-to-prd regeneration for revised plan ${plan.source_plan} (${completedItems.length} completed items to carry over)`);
1380
1437
  }
1381
1438
  }
1382
1439
  continue; // Old PRD deleted — skip safeWrite below
@@ -1415,7 +1472,7 @@ function materializePlansAsWorkItems(config) {
1415
1472
  // No project found — use central work-items.json (engine works without projects)
1416
1473
  const useCentral = !defaultProject;
1417
1474
 
1418
- const statusFilter = ['missing', 'planned'];
1475
+ const statusFilter = ['missing', 'planned', 'updated'];
1419
1476
  // Also materialize in-pr/done items that never got a work item (race with PR status sync)
1420
1477
  const allExistingWiIds = new Set();
1421
1478
  for (const w of queries.getWorkItems()) {
@@ -1466,32 +1523,53 @@ function materializePlansAsWorkItems(config) {
1466
1523
  const wiPath = project ? projectWorkItemsPath(project) : path.join(MINIONS_DIR, 'work-items.json');
1467
1524
  let created = 0;
1468
1525
  const newlyCreatedIds = new Set(); // tracks IDs created in this pass for reconciliation scoping
1526
+ const deferredReopens = []; // cross-project re-opens executed after this lock releases
1469
1527
 
1470
1528
  mutateWorkItems(wiPath, existingItems => {
1471
1529
  for (const item of projItems) {
1530
+ // Re-open: PRD item set back to missing/planned/updated but work item is done → reset to pending
1531
+ const existingWi = existingItems.find(w => w.id === item.id);
1532
+ const shouldReopen = item.status === 'missing' || item.status === 'planned' || item.status === 'updated';
1533
+ if (existingWi && DONE_STATUSES.has(existingWi.status) && shouldReopen) {
1534
+ existingWi.status = WI_STATUS.PENDING;
1535
+ existingWi._reopened = true;
1536
+ const criteria = (item.acceptance_criteria || []).map(c => `- ${c}`).join('\n');
1537
+ existingWi.description = buildWiDescription(item, file);
1538
+ existingWi.title = `Implement: ${item.name}`;
1539
+ created++;
1540
+ log('info', `Re-opened work item ${item.id} (PRD item set back to ${item.status}) — will dispatch to existing branch`);
1541
+ continue;
1542
+ }
1543
+
1472
1544
  // Skip if already materialized — work item ID = PRD item ID, check all projects
1473
- let alreadyExists = existingItems.some(w => w.id === item.id);
1545
+ let alreadyExists = !!existingWi;
1474
1546
  if (!alreadyExists) {
1475
1547
  for (const p of allProjects) {
1476
1548
  if (p.name === projName) continue;
1477
1549
  const otherItems = safeJson(projectWorkItemsPath(p)) || [];
1478
- if (otherItems.some(w => w.id === item.id)) { alreadyExists = true; break; }
1550
+ const otherWi = otherItems.find(w => w.id === item.id);
1551
+ if (otherWi) {
1552
+ if (DONE_STATUSES.has(otherWi.status) && shouldReopen) {
1553
+ deferredReopens.push({ itemId: item.id, projectName: p.name, item });
1554
+ created++;
1555
+ }
1556
+ alreadyExists = true; break;
1557
+ }
1479
1558
  }
1480
1559
  }
1481
1560
  if (alreadyExists) continue;
1482
1561
  // Skip items involved in dependency cycles
1483
1562
  if (cycleSet.has(item.id)) continue;
1484
1563
 
1485
- const id = item.id; // Work item ID = PRD item ID — no indirection
1564
+ const id = item.id;
1486
1565
  const complexity = item.estimated_complexity || 'medium';
1487
- const criteria = (item.acceptance_criteria || []).map(c => `- ${c}`).join('\n');
1488
1566
 
1489
1567
  const newItem = {
1490
1568
  id,
1491
1569
  title: `Implement: ${item.name}`,
1492
1570
  type: complexity === 'large' ? 'implement:large' : 'implement',
1493
1571
  priority: item.priority || 'medium',
1494
- description: `${item.description || ''}\n\n**Plan:** ${file}\n**Plan Item:** ${item.id}\n**Complexity:** ${complexity}${criteria ? '\n\n**Acceptance Criteria:**\n' + criteria : ''}`,
1572
+ description: buildWiDescription(item, file),
1495
1573
  status: 'pending',
1496
1574
  created: ts(),
1497
1575
  createdBy: 'engine:plan-discovery',
@@ -1530,6 +1608,24 @@ function materializePlansAsWorkItems(config) {
1530
1608
  log('info', `Plan discovery: created ${created} work item(s) from ${file} → ${projName}`);
1531
1609
  }
1532
1610
  });
1611
+
1612
+ // Process cross-project re-opens outside the lock (no nested locks)
1613
+ for (const { itemId, projectName: rProjName, item: rItem } of deferredReopens) {
1614
+ const rProject = allProjects.find(p => p.name === rProjName);
1615
+ if (!rProject) continue;
1616
+ const rPath = projectWorkItemsPath(rProject);
1617
+ mutateWorkItems(rPath, items => {
1618
+ const target = items.find(w => w.id === itemId);
1619
+ if (target && DONE_STATUSES.has(target.status)) {
1620
+ target.status = WI_STATUS.PENDING;
1621
+ target._reopened = true;
1622
+ target.description = buildWiDescription(rItem, file);
1623
+ target.title = `Implement: ${rItem.name}`;
1624
+ }
1625
+ });
1626
+ log('info', `Re-opened work item ${itemId} in ${rProjName} (cross-project, PRD item set to ${rItem.status})`);
1627
+ }
1628
+
1533
1629
  totalCreated += created;
1534
1630
  }
1535
1631
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.816",
3
+ "version": "0.1.818",
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"
@@ -91,6 +91,20 @@ Rules for items:
91
91
  - Include `acceptance_criteria` so reviewers know when it's done
92
92
  - Aim for 5-25 items depending on plan scope. If more than 25, group related work
93
93
 
94
+ ## Updating an Existing PRD
95
+
96
+ If the task description mentions **"existing implementation state"**, you are updating an existing PRD, not creating one from scratch. The plan was revised and you need to produce an updated PRD that reflects the changes while preserving existing work.
97
+
98
+ **Rules for diff-aware updates:**
99
+ - **Read the existing PRD file first** — it's at `{{team_root}}/prd/{{prd_filename}}`
100
+ - **Preserve item IDs** — do NOT generate new IDs for items that already exist. The engine maps work items by ID.
101
+ - **Done + unchanged** → keep `"status": "done"` and the same ID (no work dispatched)
102
+ - **Done + modified** (plan added requirements/scope) → set `"status": "updated"` with same ID (engine re-opens the work item and dispatches to existing branch)
103
+ - **New items** in the updated plan → generate new `P-<uuid>` IDs, set `"status": "missing"`
104
+ - **Removed items** (in old PRD but not in updated plan) → drop from the PRD entirely (engine cancels pending work items)
105
+ - **Pending/failed items** → reset to `"missing"` with updated description
106
+ - Preserve `branch_strategy`, `feature_branch`, `project`, and other plan-level fields from the existing PRD unless the plan explicitly changes them
107
+
94
108
  ## Important
95
109
 
96
110
  - Write ONLY the single `.json` PRD file to `{{team_root}}/prd/` — do NOT write any `.md` files there
@@ -10,14 +10,14 @@ Your context window may be compacted or summarized mid-task by Claude's automati
10
10
  - Do NOT remove worktrees — the engine handles cleanup automatically.
11
11
  - Do NOT checkout branches in the main working tree — use worktrees or `git diff`/`git show`.
12
12
  - Read `notes.md` for team rules and decisions before starting.
13
- - **Check minions state before starting fresh.** Before researching from scratch, check what the team already knows:
14
- - `notes.md` — consolidated team knowledge, decisions, and context
15
- - `knowledge/` — categorized KB entries (architecture, conventions, build-reports, reviews)
16
- - `notes/inbox/`recent agent findings not yet consolidated
17
- - Previous agent output in `agents/*/live-output.log` for related tasks
18
- - Work item descriptions and `resultSummary` for prior completed work on the same topic
19
- - `pinned.md` critical context flagged by the human teammate
20
- This avoids duplicating research another agent already completed.
13
+ - **Check team memory first, then look outside.** Before researching from scratch, check what the team already knows — in this order:
14
+ 1. `pinned.md` — critical context flagged by the human teammate (READ FIRST)
15
+ 2. `knowledge/` — categorized KB entries (architecture, conventions, build-reports, reviews)
16
+ 3. `notes.md`consolidated team knowledge, decisions, and context
17
+ 4. `notes/inbox/` recent agent findings not yet consolidated
18
+ 5. Previous agent output in `agents/*/live-output.log` for related tasks
19
+ 6. Work item descriptions and `resultSummary` for prior completed work on the same topic
20
+ Only after exhausting team memory should you look outside (web search, codebase exploration, external docs). This avoids duplicating research another agent already completed and ensures team decisions are respected.
21
21
  - If you discover a repeatable workflow, output it as a fenced skill block. The engine auto-extracts it to `~/.claude/skills/<name>/SKILL.md`. Required format:
22
22
  ````
23
23
  ```skill
@@ -92,30 +92,39 @@ For each project that has changes, create a single **aggregate PR** that combine
92
92
 
93
93
  For each project worktree:
94
94
 
95
- 1. Push the combined branch:
95
+ 1. **Check for an existing E2E branch/PR first** — a prior verify run may have already created one:
96
96
  ```bash
97
97
  cd <worktree-path>
98
- git checkout -b e2e/{{plan_slug}}
99
- git push origin e2e/{{plan_slug}}
98
+ git fetch origin
99
+ # Check if the E2E branch already exists on remote
100
+ git ls-remote --heads origin e2e/{{plan_slug}}
100
101
  ```
101
-
102
- 2. Create a PR targeting the project's main branch using `mcp__azure-ado__repo_create_pull_request` (or `gh pr create` for GitHub):
103
- - **Title:** `[E2E] <plan summary>`
104
- - **Description:** Include:
105
- - The plan summary
106
- - List of all individual PRs merged into this branch
107
- - Build/test status from Step 3
108
- - Link to the testing guide
109
- - **Target branch:** the project's main branch (e.g., `main` or `master`)
110
- - **Do NOT auto-complete** this is for review only
111
- - **Mark as draft** if the option is available
112
-
113
- 3. Add the E2E PR to the project's `.minions/pull-requests.json`:
102
+ - If the branch **already exists**: check out the existing branch (`git checkout e2e/{{plan_slug}}`), reset it to your current worktree state, and force-push to update it. Then check if a PR already exists for this branch — if so, **update that PR** instead of creating a new one.
103
+ - If the branch **does not exist**: create it fresh:
104
+ ```bash
105
+ git checkout -b e2e/{{plan_slug}}
106
+ git push origin e2e/{{plan_slug}}
107
+ ```
108
+
109
+ 2. **Check for an existing E2E PR** before creating a new one:
110
+ - For GitHub: `gh pr list --head e2e/{{plan_slug}} --state open`
111
+ - For ADO: search for PRs with source branch `e2e/{{plan_slug}}`
112
+ - If found, **update the existing PR** description with latest build/test results. Do NOT create a duplicate.
113
+ - If not found, create a new PR targeting the project's main branch:
114
+ - **Title:** `[E2E] <plan summary>`
115
+ - **Description:** Include the plan summary, list of all individual PRs merged, build/test status from Step 3, and link to the testing guide
116
+ - **Target branch:** the project's main branch (e.g., `main` or `master`)
117
+ - **Do NOT auto-complete** — this is for review only
118
+ - **Mark as draft** if the option is available
119
+
120
+ 3. Add the E2E PR to the project's `.minions/pull-requests.json` (skip if it's already tracked):
114
121
  ```bash
115
122
  node -e "
116
123
  const fs = require('fs');
117
124
  const p = '<project-path>/.minions/pull-requests.json';
118
125
  const prs = JSON.parse(fs.readFileSync(p, 'utf8'));
126
+ // Skip if already tracked
127
+ if (prs.some(pr => pr.branch === 'e2e/{{plan_slug}}')) process.exit(0);
119
128
  prs.push({
120
129
  id: 'PR-<number>',
121
130
  title: '[E2E] <plan summary>',