@yemi33/minions 0.1.2309 → 0.1.2311

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.
@@ -135,7 +135,8 @@ const RENDER_VERSIONS = {
135
135
  prdPrs: 1,
136
136
  inbox: 2,
137
137
  // Bumped 4→5 for the clickable checkout-mode pill + picker (W-mr1b67zi0006b788).
138
- projects: 5,
138
+ // Bumped 5→6 for multi-select hybrid liveValidation.type support (W-mr2m1ute000a9c01).
139
+ projects: 6,
139
140
  notes: 1,
140
141
  prd: 3,
141
142
  prs: 3,
@@ -158,7 +159,7 @@ const RENDER_VERSIONS = {
158
159
  schedules: 1,
159
160
  watches: 3,
160
161
  meetings: 1,
161
- pipelines: 1,
162
+ pipelines: 2,
162
163
  pinned: 1,
163
164
  kbPayload: 2,
164
165
  // settings is a modal-driven section, not a tick-driven render — but the
@@ -108,9 +108,11 @@ function _renderWorktreeModePill(p) {
108
108
  const name = escHtml(p.name);
109
109
  const common = ' data-checkout-pill="' + name + '" role="button" tabindex="0" aria-haspopup="true" aria-label="Change checkout mode"';
110
110
  if (p.checkoutMode === 'live') {
111
- if (p.liveValidationType) {
112
- const vt = escapeHtml(p.liveValidationType);
113
- return ' <span class="project-mode-pill project-mode-hybrid project-mode-pill-clickable"' + common + ' title="Hybrid live-validation — coding work items author in isolated worktrees; only the &quot;' + vt + '&quot; validation type runs in-place on the live checkout (capped to one mutating dispatch, refused on a dirty tree). Click to change.">⚡ Hybrid · ' + vt + '</span>';
111
+ const types = _liveValidationTypesArray(p);
112
+ if (types.length > 0) {
113
+ const typesText = types.join(', ');
114
+ const vt = escapeHtml(typesText);
115
+ return ' <span class="project-mode-pill project-mode-hybrid project-mode-pill-clickable"' + common + ' title="Hybrid live-validation — coding work items author in isolated worktrees; only the &quot;' + vt + '&quot; validation type(s) run in-place on the live checkout (capped to one mutating dispatch, refused on a dirty tree). Click to change.">⚡ Hybrid · ' + vt + '</span>';
114
116
  }
115
117
  return ' <span class="project-mode-pill project-mode-live project-mode-pill-clickable"' + common + ' title="Live-checkout dispatch mode — agents run in-place inside the project working tree (no isolated worktree); capped to one mutating dispatch and refused on a dirty tree. Click to change.">⚡ Live checkout</span>';
116
118
  }
@@ -130,6 +132,16 @@ function _findProjectInLastStatus(name) {
130
132
  return projects.find(function(p) { return p.name === name; }) || null;
131
133
  }
132
134
 
135
+ // Normalize p.liveValidationType (single string legacy shape, OR array of
136
+ // strings — W-mr2m1ute000a9c01 multi-select) into an array of non-empty
137
+ // strings for uniform handling by menu/pill rendering code.
138
+ function _liveValidationTypesArray(p) {
139
+ const raw = p && p.liveValidationType;
140
+ if (!raw) return [];
141
+ const arr = Array.isArray(raw) ? raw : [raw];
142
+ return arr.filter(function(t) { return typeof t === 'string' && t.length > 0; });
143
+ }
144
+
133
145
  function _closeCheckoutModeMenu() {
134
146
  const existing = document.getElementById('checkout-mode-menu');
135
147
  if (existing) existing.remove();
@@ -183,8 +195,8 @@ function _openCheckoutModeMenu(projectName, anchorEl) {
183
195
  item.appendChild(desc);
184
196
  }
185
197
  const isActive = (mode === 'worktree' && project.checkoutMode !== 'live')
186
- || (mode === 'live' && project.checkoutMode === 'live' && !project.liveValidationType)
187
- || (mode === 'hybrid' && project.checkoutMode === 'live' && !!project.liveValidationType);
198
+ || (mode === 'live' && project.checkoutMode === 'live' && _liveValidationTypesArray(project).length === 0)
199
+ || (mode === 'hybrid' && project.checkoutMode === 'live' && _liveValidationTypesArray(project).length > 0);
188
200
  if (isActive) {
189
201
  item.setAttribute('aria-checked', 'true');
190
202
  item.classList.add('checkout-mode-menu-item-active');
@@ -221,9 +233,12 @@ function _openCheckoutModeMenu(projectName, anchorEl) {
221
233
  if (first) first.focus();
222
234
  }
223
235
 
224
- // Second step of the picker for hybrid mode: pick which work-item type runs
236
+ // Second step of the picker for hybrid mode: pick which work-item type(s) run
225
237
  // in-place on the live checkout while everything else authors in isolated
226
- // worktrees. Renders a <select> + Back/Apply into the existing menu element.
238
+ // worktrees. Renders a checkbox list (multi-select, W-mr2m1ute000a9c01) +
239
+ // Back/Apply into the existing menu element. A checkbox list (rather than a
240
+ // native <select multiple>) keeps the existing "click one row" affordance
241
+ // discoverable while allowing more than one type to be selected at once.
227
242
  function _renderCheckoutModeHybridStep(menu, projectName) {
228
243
  const project = _findProjectInLastStatus(projectName);
229
244
  while (menu.firstChild) menu.removeChild(menu.firstChild);
@@ -235,21 +250,31 @@ function _renderCheckoutModeHybridStep(menu, projectName) {
235
250
 
236
251
  const label = document.createElement('div');
237
252
  label.className = 'checkout-mode-menu-item';
238
- label.textContent = 'Work-item type to validate live:';
253
+ label.textContent = 'Work-item type(s) to validate live:';
239
254
  menu.appendChild(label);
240
255
 
241
- const select = document.createElement('select');
242
- select.className = 'checkout-mode-menu-select';
256
+ const currentTypes = _liveValidationTypesArray(project);
257
+ const preselect = currentTypes.length > 0 ? currentTypes : [CHECKOUT_MODE_HYBRID_DEFAULT_TYPE];
258
+
259
+ const checkboxList = document.createElement('div');
260
+ checkboxList.className = 'checkout-mode-menu-checkboxes';
261
+ const checkboxes = [];
243
262
  CHECKOUT_MODE_HYBRID_TYPES.forEach(function(t) {
244
- const opt = document.createElement('option');
245
- opt.value = t;
246
- opt.textContent = t;
247
- select.appendChild(opt);
263
+ const row = document.createElement('label');
264
+ row.className = 'checkout-mode-menu-checkbox-row';
265
+ const cb = document.createElement('input');
266
+ cb.type = 'checkbox';
267
+ cb.value = t;
268
+ cb.className = 'checkout-mode-menu-checkbox';
269
+ cb.checked = preselect.includes(t);
270
+ checkboxes.push(cb);
271
+ const text = document.createElement('span');
272
+ text.textContent = t;
273
+ row.appendChild(cb);
274
+ row.appendChild(text);
275
+ checkboxList.appendChild(row);
248
276
  });
249
- select.value = (project && project.liveValidationType && CHECKOUT_MODE_HYBRID_TYPES.includes(project.liveValidationType))
250
- ? project.liveValidationType
251
- : CHECKOUT_MODE_HYBRID_DEFAULT_TYPE;
252
- menu.appendChild(select);
277
+ menu.appendChild(checkboxList);
253
278
 
254
279
  const actions = document.createElement('div');
255
280
  actions.className = 'checkout-mode-menu-actions';
@@ -270,9 +295,10 @@ function _renderCheckoutModeHybridStep(menu, projectName) {
270
295
  applyBtn.className = 'checkout-mode-menu-btn checkout-mode-menu-btn-primary';
271
296
  applyBtn.textContent = 'Apply';
272
297
  applyBtn.addEventListener('click', function() {
273
- const type = select.value;
298
+ const types = checkboxes.filter(function(cb) { return cb.checked; }).map(function(cb) { return cb.value; });
299
+ if (types.length === 0) return; // require at least one selected type
274
300
  _closeCheckoutModeMenu();
275
- _applyCheckoutModeChange(projectName, 'live', type);
301
+ _applyCheckoutModeChange(projectName, 'live', types);
276
302
  });
277
303
  actions.appendChild(applyBtn);
278
304
 
@@ -284,12 +310,21 @@ function _renderCheckoutModeHybridStep(menu, projectName) {
284
310
  // call (mirrors projectChipRemove / removePinnedNote), then POSTs to
285
311
  // /api/settings. On failure, reverts the optimistic flip, re-renders, and
286
312
  // shows an error toast.
313
+ //
314
+ // `liveValidationType` accepts a single type string (legacy call sites) OR an
315
+ // array of type strings (W-mr2m1ute000a9c01 — multi-select hybrid types);
316
+ // whichever shape is passed through is what gets POSTed as
317
+ // liveValidation.type, so shared.validateLiveValidation on the server decides
318
+ // the final persisted shape.
287
319
  async function _applyCheckoutModeChange(projectName, newMode, liveValidationType) {
288
320
  const project = _findProjectInLastStatus(projectName);
289
321
  if (!project) return;
322
+ const typesForLabel = liveValidationType
323
+ ? (Array.isArray(liveValidationType) ? liveValidationType : [liveValidationType])
324
+ : [];
290
325
  const label = newMode === 'worktree'
291
326
  ? 'Worktrees'
292
- : (liveValidationType ? ('Hybrid (' + liveValidationType + ')') : 'Live checkout');
327
+ : (typesForLabel.length > 0 ? ('Hybrid (' + typesForLabel.join(', ') + ')') : 'Live checkout');
293
328
  const ok = await confirmDialog({
294
329
  title: 'Change checkout mode?',
295
330
  message: 'Switch "' + projectName + '" to ' + label + '? This changes how every future dispatch on this project runs.',
@@ -352,6 +352,11 @@ function renderPipelines(pipelines, opts) {
352
352
  '<span style="color:' + statusColor + ';font-size:var(--text-base);font-weight:600">' + escHtml(statusLabel) + '</span>' +
353
353
  (p.stopWhen ? '<span style="font-size:var(--text-xs);color:var(--yellow)" title="Auto-stops when condition met: ' + escHtml(typeof p.stopWhen === 'string' ? p.stopWhen : (p.stopWhen.check || 'condition')) + '">STOP-WHEN</span>' : '') +
354
354
  (p.enabled === false ? '<span style="font-size:var(--text-xs);color:var(--red)"' + (p._stopReason ? ' title="' + escHtml(p._stopReason) + '"' : '') + '>' + (p._stoppedBy ? 'AUTO-STOPPED' : 'DISABLED') + '</span>' : '') +
355
+ (p.enabled !== false
356
+ ? (activeRun
357
+ ? '<button class="pr-pager-btn" style="font-size:var(--text-xs);padding:2px 8px;color:var(--yellow);border-color:var(--yellow)" onclick="event.stopPropagation();_retriggerPipeline(\'' + escHtml(p.id) + '\',this)">Retrigger</button>'
358
+ : '<button class="btn-action btn-action-md" onclick="event.stopPropagation();_triggerPipeline(\'' + escHtml(p.id) + '\',this)">Run Now</button>')
359
+ : '') +
355
360
  '</div>' +
356
361
  '</div>' +
357
362
  resourcesHtml +
@@ -403,10 +408,12 @@ function openPipelineDetail(id) {
403
408
  html += '<div style="display:flex;justify-content:space-between;align-items:center">' +
404
409
  '<span style="font-size:var(--text-sm);color:var(--muted)">' + _renderPipelineTriggerLabel(p.trigger?.cron) + ' · ' + escHtml(_getPipelineStageLabel(p)) + '</span>' +
405
410
  '<div style="display:flex;gap:6px">' +
406
- (activeRun
407
- ? '<button class="btn-destructive btn-destructive-md" onclick="_abortPipeline(\'' + escHtml(id) + '\',this)">Abort</button>' +
408
- '<button class="pr-pager-btn" style="font-size:var(--text-xs);padding:2px 8px;color:var(--yellow);border-color:var(--yellow)" onclick="_retriggerPipeline(\'' + escHtml(id) + '\',this)">Retrigger</button>'
409
- : '<button class="btn-action btn-action-md" onclick="_triggerPipeline(\'' + escHtml(id) + '\',this)">Run Now</button>') +
411
+ (activeRun ? '<button class="btn-destructive btn-destructive-md" onclick="_abortPipeline(\'' + escHtml(id) + '\',this)">Abort</button>' : '') +
412
+ (p.enabled !== false
413
+ ? (activeRun
414
+ ? '<button class="pr-pager-btn" style="font-size:var(--text-xs);padding:2px 8px;color:var(--yellow);border-color:var(--yellow)" onclick="_retriggerPipeline(\'' + escHtml(id) + '\',this)">Retrigger</button>'
415
+ : '<button class="btn-action btn-action-md" onclick="_triggerPipeline(\'' + escHtml(id) + '\',this)">Run Now</button>')
416
+ : '') +
410
417
  '<button class="pr-pager-btn" style="font-size:var(--text-xs);padding:2px 8px;color:var(--blue);border-color:var(--blue)" onclick="openEditPipelineModal(\'' + escHtml(id) + '\')">Edit</button>' +
411
418
  '<button class="pr-pager-btn' + (_pipelineToggleInFlight.has(id) ? ' disabled' : '') + '" style="font-size:var(--text-xs);padding:2px 8px" onclick="_togglePipelineEnabled(\'' + escHtml(id) + '\',' + !p.enabled + ',this)">' + (p.enabled !== false ? 'Disable' : 'Enable') + '</button>' +
412
419
  '<button class="btn-destructive btn-destructive-md" onclick="_deletePipelineConfirm(\'' + escHtml(id) + '\')">Delete</button>' +
@@ -1330,11 +1330,17 @@
1330
1330
  font-size: var(--text-xs); color: var(--muted); font-weight: 400;
1331
1331
  margin-top: 2px; line-height: 1.35;
1332
1332
  }
1333
- .checkout-mode-menu-select {
1334
- width: 100%; margin: 4px 0 8px; padding: 4px 6px;
1335
- background: var(--surface); color: var(--text); border: 1px solid var(--border);
1336
- border-radius: 4px;
1333
+ .checkout-mode-menu-checkboxes {
1334
+ display: flex; flex-direction: column; gap: 2px;
1335
+ margin: 4px 0 8px; max-height: 220px; overflow-y: auto;
1337
1336
  }
1337
+ .checkout-mode-menu-checkbox-row {
1338
+ display: flex; align-items: center; gap: 6px;
1339
+ padding: 4px 6px; border-radius: 4px; cursor: pointer;
1340
+ color: var(--text); font-size: var(--text-sm);
1341
+ }
1342
+ .checkout-mode-menu-checkbox-row:hover { background: var(--surface); }
1343
+ .checkout-mode-menu-checkbox { cursor: pointer; }
1338
1344
  .checkout-mode-menu-actions {
1339
1345
  display: flex; justify-content: flex-end; gap: 6px; padding-top: 4px;
1340
1346
  }
package/dashboard.js CHANGED
@@ -4300,7 +4300,10 @@ function _resetPreambleCache() {
4300
4300
  function _projectCheckoutModeLabel(p) {
4301
4301
  const mode = shared.resolveCheckoutMode(p);
4302
4302
  if (mode === 'live' && p && p.liveValidation && p.liveValidation.type) {
4303
- return `hybrid (live-validation: ${p.liveValidation.type})`;
4303
+ // liveValidation.type may be a single string (legacy) or an array of
4304
+ // strings (W-mr2m1ute000a9c01 — multi-select hybrid types); render all.
4305
+ const types = Array.isArray(p.liveValidation.type) ? p.liveValidation.type : [p.liveValidation.type];
4306
+ return `hybrid (live-validation: ${types.join(', ')})`;
4304
4307
  }
4305
4308
  return mode;
4306
4309
  }
package/engine/ado.js CHANGED
@@ -527,6 +527,35 @@ function votesToReviewStatus(votes) {
527
527
  return REVIEW_STATUS.PENDING;
528
528
  }
529
529
 
530
+ /**
531
+ * Issue #633 — ADO never clears a reviewer's numeric vote when new commits
532
+ * land on a PR, so a hard-reject -10 vote stays live forever unless the
533
+ * reviewer explicitly re-votes. Without this check, `reviewStatus` gets
534
+ * permanently stuck at 'changes-requested' after a fix is pushed, and the
535
+ * auto re-review-after-fix flow (engine.js needsReReview, which only fires on
536
+ * reviewStatus === 'waiting') can never dispatch again for that PR.
537
+ *
538
+ * Treat a live -10 vote as stale/superseded — i.e. resolve to 'waiting'
539
+ * instead of 'changes-requested' — once a fix has completed after the vote
540
+ * was (most recently) cast AND no further push has landed since that fix:
541
+ * - `pr.minionsReview.fixedAt` is set (a fix dispatch completed), and
542
+ * - the fix happened after the last minions review that produced the
543
+ * rejection (`!pr.lastReviewedAt || fixedAt > pr.lastReviewedAt` —
544
+ * mirrors the `fixedAfterReview` gate in engine.js needsReReview), and
545
+ * - no push has landed since that fix completed
546
+ * (`!pr.lastPushedAt || pr.lastPushedAt <= fixedAt`).
547
+ * If a new push lands after the fix without a following fix completion, or
548
+ * the last review (and its -10) postdates the recorded fix, the reject is
549
+ * still live/fresh and reviewStatus correctly stays 'changes-requested'.
550
+ */
551
+ function isStaleAdoRejectVote(pr) {
552
+ const fixedAt = pr?.minionsReview?.fixedAt;
553
+ if (!fixedAt) return false;
554
+ const fixedAfterReview = !pr.lastReviewedAt || fixedAt > pr.lastReviewedAt;
555
+ const noPushSinceFix = !pr.lastPushedAt || pr.lastPushedAt <= fixedAt;
556
+ return fixedAfterReview && noPushSinceFix;
557
+ }
558
+
530
559
  // ─── Reviewer Vote Snapshots (W-mpg58wv3) ────────────────────────────────────
531
560
  //
532
561
  // ADO's reviewer API does NOT include the source commit at which a reviewer
@@ -1569,11 +1598,7 @@ async function pollPrStatus(config) {
1569
1598
  }
1570
1599
  } else if (votes.length > 0) {
1571
1600
  if (votes.some(v => v === -10)) {
1572
- if (pr.reviewStatus === REVIEW_STATUS.WAITING && pr.minionsReview?.fixedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.minionsReview.fixedAt)) {
1573
- newReviewStatus = REVIEW_STATUS.WAITING;
1574
- } else {
1575
- newReviewStatus = REVIEW_STATUS.CHANGES_REQUESTED;
1576
- }
1601
+ newReviewStatus = isStaleAdoRejectVote(pr) ? REVIEW_STATUS.WAITING : REVIEW_STATUS.CHANGES_REQUESTED;
1577
1602
  }
1578
1603
  else if (votes.some(v => v >= 5)) newReviewStatus = REVIEW_STATUS.APPROVED;
1579
1604
  else if (votes.some(v => v === -5)) newReviewStatus = REVIEW_STATUS.WAITING;
@@ -2346,7 +2371,16 @@ async function checkLiveReviewStatus(pr, project) {
2346
2371
  if (!prData) return null;
2347
2372
  const votes = (prData.reviewers || []).map(r => r.vote).filter(v => v !== undefined);
2348
2373
  if (votes.length === 0) return 'pending';
2349
- return votesToReviewStatus(votes);
2374
+ const liveStatus = votesToReviewStatus(votes);
2375
+ // Issue #633: the live vote check is the pre-dispatch gate for
2376
+ // engine.js needsReReview — it must apply the same stale-reject-vote
2377
+ // override as pollPrStatus, or a cached 'waiting' status set below by
2378
+ // the poller gets immediately reverted back to 'changes-requested' here
2379
+ // before a re-review can ever be dispatched.
2380
+ if (liveStatus === REVIEW_STATUS.CHANGES_REQUESTED && isStaleAdoRejectVote(pr)) {
2381
+ return REVIEW_STATUS.WAITING;
2382
+ }
2383
+ return liveStatus;
2350
2384
  } catch (e) {
2351
2385
  log('warn', `Live review check for ${pr.id}: ${e.message}`);
2352
2386
  return null;
package/engine/github.js CHANGED
@@ -705,6 +705,24 @@ async function forEachActiveGhPr(config, callback) {
705
705
  return totalUpdated;
706
706
  }
707
707
 
708
+ /**
709
+ * Issue #633 (mirror of engine/ado.js isStaleAdoRejectVote) — GitHub reviews
710
+ * generally get dismissed/replaced when a reviewer re-reviews, but a stale
711
+ * CHANGES_REQUESTED review from a login that never re-reviews after a fix can
712
+ * still pin `reviewStatus` at 'changes-requested' forever (the reviewer's
713
+ * review state persists until dismissed). Mirror the ADO fix so the same
714
+ * fixedAfterReview + no-push-since-fix override applies here: treat a live
715
+ * CHANGES_REQUESTED review as stale/superseded once a fix has completed after
716
+ * the review that produced it and no further push has landed since that fix.
717
+ */
718
+ function isStaleGithubChangesRequested(pr) {
719
+ const fixedAt = pr?.minionsReview?.fixedAt;
720
+ if (!fixedAt) return false;
721
+ const fixedAfterReview = !pr.lastReviewedAt || fixedAt > pr.lastReviewedAt;
722
+ const noPushSinceFix = !pr.lastPushedAt || pr.lastPushedAt <= fixedAt;
723
+ return fixedAfterReview && noPushSinceFix;
724
+ }
725
+
708
726
  // ─── PR Status Polling ──────────────────────────────────────────────────────
709
727
 
710
728
  async function pollPrStatus(config) {
@@ -999,11 +1017,7 @@ async function pollPrStatus(config) {
999
1017
  if (pr.reviewStatus === REVIEW_STATUS.APPROVED) {
1000
1018
  newReviewStatus = REVIEW_STATUS.APPROVED;
1001
1019
  } else if (states.some(s => s === 'CHANGES_REQUESTED')) {
1002
- if (pr.reviewStatus === REVIEW_STATUS.WAITING && pr.minionsReview?.fixedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.minionsReview.fixedAt)) {
1003
- newReviewStatus = REVIEW_STATUS.WAITING;
1004
- } else {
1005
- newReviewStatus = REVIEW_STATUS.CHANGES_REQUESTED;
1006
- }
1020
+ newReviewStatus = isStaleGithubChangesRequested(pr) ? REVIEW_STATUS.WAITING : REVIEW_STATUS.CHANGES_REQUESTED;
1007
1021
  }
1008
1022
  else if (states.some(s => s === 'APPROVED')) newReviewStatus = REVIEW_STATUS.APPROVED;
1009
1023
  else if (states.length > 0) newReviewStatus = REVIEW_STATUS.PENDING;
@@ -1575,7 +1589,13 @@ async function checkLiveReviewStatus(pr, project) {
1575
1589
  latestByUser.set(r.user?.login || '', r.state);
1576
1590
  }
1577
1591
  const states = [...latestByUser.values()];
1578
- if (states.some(s => s === 'CHANGES_REQUESTED')) return REVIEW_STATUS.CHANGES_REQUESTED;
1592
+ // Issue #633: mirror the pollPrStatus stale-reject override here — this
1593
+ // live check is the pre-dispatch gate for engine.js needsReReview, and
1594
+ // without the override it immediately reverts a cached 'waiting' status
1595
+ // back to 'changes-requested' before a re-review can ever be dispatched.
1596
+ if (states.some(s => s === 'CHANGES_REQUESTED')) {
1597
+ return isStaleGithubChangesRequested(pr) ? REVIEW_STATUS.WAITING : REVIEW_STATUS.CHANGES_REQUESTED;
1598
+ }
1579
1599
  if (states.some(s => s === 'APPROVED')) return REVIEW_STATUS.APPROVED;
1580
1600
  if (states.length > 0) return REVIEW_STATUS.PENDING;
1581
1601
  return REVIEW_STATUS.PENDING;
@@ -6503,9 +6503,20 @@ function collapseAllDuplicatePrRecords(config) {
6503
6503
 
6504
6504
  // M003 — After a coding WI completes successfully, auto-dispatch a live-validation
6505
6505
  // WI when project.liveValidation.autoDispatch === true and the completed item is
6506
- // a coding WI (not the validation type itself). Skips if the coding WI has no PR.
6507
- // Deduplicates: a non-terminal WI with meta.liveValidationFor === codingWiId
6508
- // blocks a second dispatch.
6506
+ // a coding WI (not one of the validation types itself). Skips if the coding WI
6507
+ // has no PR.
6508
+ //
6509
+ // liveValidation.type may be a single string (legacy / back-compat) or an
6510
+ // array of strings (W-mr2m1ute000a9c01 — multi-select hybrid types). When an
6511
+ // array, ONE validation WI is dispatched per configured type (each validation
6512
+ // WI still carries a single canonical `type` field so downstream routing —
6513
+ // playbooks, agent resolution, resolveCheckoutMode's membership check — keeps
6514
+ // working per-type).
6515
+ //
6516
+ // Deduplicates per (codingWiId, validationType) pair: a non-terminal WI with
6517
+ // meta.liveValidationFor === codingWiId AND meta.liveValidationType === type
6518
+ // blocks a second dispatch of that specific type, so multiple configured
6519
+ // types don't collide or skip each other.
6509
6520
  function autoDispatchLiveValidationWi(meta, config) {
6510
6521
  const item = meta?.item;
6511
6522
  if (!item?.id || !item?.type) return;
@@ -6521,8 +6532,12 @@ function autoDispatchLiveValidationWi(meta, config) {
6521
6532
  const lv = project.liveValidation;
6522
6533
  if (!lv || lv.autoDispatch !== true || !lv.type) return;
6523
6534
 
6524
- // Only auto-dispatch for coding WIs skip if this IS the validation type.
6525
- if (item.type === lv.type) return;
6535
+ const lvTypes = Array.isArray(lv.type) ? lv.type : [lv.type];
6536
+
6537
+ // Only auto-dispatch for coding WIs — skip types that ARE one of the
6538
+ // configured validation types (avoid an infinite validate-the-validation loop).
6539
+ const typesToDispatch = lvTypes.filter(t => t !== item.type);
6540
+ if (typesToDispatch.length === 0) return;
6526
6541
 
6527
6542
  // Resolve PR reference: prefer the canonical stamped _pr field (set by
6528
6543
  // stampWiPrRef which runs earlier in runPostCompletionHooks), then fall
@@ -6538,34 +6553,41 @@ function autoDispatchLiveValidationWi(meta, config) {
6538
6553
  mutateWorkItems(wiPath, items => {
6539
6554
  if (!Array.isArray(items)) return items;
6540
6555
 
6541
- // Dedup: skip if a non-terminal WI already tracks this coding WI.
6542
- const existing = items.find(i =>
6543
- i &&
6544
- i.meta &&
6545
- i.meta.liveValidationFor === codingWiId &&
6546
- !PLAN_TERMINAL_STATUSES.has(i.status)
6547
- );
6548
- if (existing) {
6549
- log('info', `liveValidation: dedup — non-terminal validation WI ${existing.id} already exists for ${codingWiId}`);
6550
- return items;
6551
- }
6556
+ for (const validationType of typesToDispatch) {
6557
+ // Dedup: skip if a non-terminal WI already tracks this (coding WI, type)
6558
+ // pair. Falls back to the WI's own `type` field when meta.liveValidationType
6559
+ // is absent (back-compat with validation WIs created before this field
6560
+ // existed, e.g. single-type configs from before W-mr2m1ute000a9c01).
6561
+ const existing = items.find(i =>
6562
+ i &&
6563
+ i.meta &&
6564
+ i.meta.liveValidationFor === codingWiId &&
6565
+ (i.meta.liveValidationType === validationType ||
6566
+ (!i.meta.liveValidationType && i.type === validationType)) &&
6567
+ !PLAN_TERMINAL_STATUSES.has(i.status)
6568
+ );
6569
+ if (existing) {
6570
+ log('info', `liveValidation: dedup — non-terminal validation WI ${existing.id} (type=${validationType}) already exists for ${codingWiId}`);
6571
+ continue;
6572
+ }
6552
6573
 
6553
- const validationWi = {
6554
- id: 'W-' + shared.uid(),
6555
- title: 'Validate: ' + (item.title || codingWiId),
6556
- type: lv.type,
6557
- status: WI_STATUS.PENDING,
6558
- depends_on: [codingWiId],
6559
- references: [prRef],
6560
- meta: { liveValidationFor: codingWiId },
6561
- project: projectName,
6562
- priority: item.priority || 'medium',
6563
- created: ts(),
6564
- createdBy: 'lifecycle:live-validation-auto-dispatch',
6565
- };
6574
+ const validationWi = {
6575
+ id: 'W-' + shared.uid(),
6576
+ title: 'Validate: ' + (item.title || codingWiId),
6577
+ type: validationType,
6578
+ status: WI_STATUS.PENDING,
6579
+ depends_on: [codingWiId],
6580
+ references: [prRef],
6581
+ meta: { liveValidationFor: codingWiId, liveValidationType: validationType },
6582
+ project: projectName,
6583
+ priority: item.priority || 'medium',
6584
+ created: ts(),
6585
+ createdBy: 'lifecycle:live-validation-auto-dispatch',
6586
+ };
6566
6587
 
6567
- items.push(validationWi);
6568
- log('info', `liveValidation: auto-dispatched validation WI ${validationWi.id} (type=${lv.type}) for coding WI ${codingWiId}`);
6588
+ items.push(validationWi);
6589
+ log('info', `liveValidation: auto-dispatched validation WI ${validationWi.id} (type=${validationType}) for coding WI ${codingWiId}`);
6590
+ }
6569
6591
  return items;
6570
6592
  });
6571
6593
  } catch (err) {
package/engine/shared.js CHANGED
@@ -2853,8 +2853,14 @@ function resolveCheckoutMode(project, workItemType) {
2853
2853
  }
2854
2854
  if (canonical === CHECKOUT_MODES.LIVE) {
2855
2855
  // Apply liveValidation routing when the block is present and workItemType is provided.
2856
+ // liveValidation.type may be a single string (legacy / back-compat) or an
2857
+ // array of strings (W-mr2m1ute000a9c01, multi-select hybrid types) —
2858
+ // normalize to an array and do a membership check so either shape works.
2856
2859
  if (project.liveValidation && workItemType !== undefined) {
2857
- return workItemType === project.liveValidation.type
2860
+ const lvTypes = Array.isArray(project.liveValidation.type)
2861
+ ? project.liveValidation.type
2862
+ : [project.liveValidation.type];
2863
+ return lvTypes.includes(workItemType)
2858
2864
  ? CHECKOUT_MODES.LIVE
2859
2865
  : CHECKOUT_MODES.WORKTREE;
2860
2866
  }
@@ -2910,13 +2916,26 @@ function validateCheckoutMode(value) {
2910
2916
  // Validate + normalize a per-project `liveValidation` block (hybrid mode).
2911
2917
  // Hybrid = checkoutMode:'live' + liveValidation:{ type, autoDispatch }: coding
2912
2918
  // work items author in isolated worktrees (escaping the live cap) while only
2913
- // work items whose type === liveValidation.type run in-place on the live
2919
+ // work items whose type is IN liveValidation.type run in-place on the live
2914
2920
  // checkout. See resolveCheckoutMode (which routes per work-item type) and
2915
2921
  // docs/live-checkout-mode.md.
2916
2922
  //
2923
+ // `type` may be a single work-item-type string (legacy / back-compat) OR a
2924
+ // non-empty array of non-empty-string work-item types
2925
+ // (W-mr2m1ute000a9c01 — multi-select hybrid types, e.g. both "implement" and
2926
+ // "fix" validate live while other types stay in worktrees). Every reader of
2927
+ // liveValidation.type (resolveCheckoutMode, lifecycle.autoDispatchLiveValidationWi,
2928
+ // dashboard project mapper / pill / CC label) must handle BOTH shapes —
2929
+ // existing config.json entries with a plain string `type` continue to work
2930
+ // unchanged, no migration required.
2931
+ //
2917
2932
  // Returns:
2918
2933
  // - undefined → caller should clear the field (empty / null / '' input)
2919
- // - { type, autoDispatch? } normalized object on success
2934
+ // - { type, autoDispatch? } normalized object on success, where `type` is
2935
+ // whatever shape the caller passed in (string stays a string; array stays
2936
+ // a deduped array) — we intentionally do NOT force single-string input
2937
+ // into an array so already-persisted single-type configs round-trip
2938
+ // byte-identical through a Settings save that doesn't touch this field.
2920
2939
  // Throws HTTP 400 (via _httpError) on malformed input, or when the effective
2921
2940
  // checkout mode is not 'live' (liveValidation is meaningless without it — it is
2922
2941
  // silently ignored at resolve time, so we refuse to persist a misconfiguration).
@@ -2933,15 +2952,33 @@ function validateLiveValidation(value, opts) {
2933
2952
  if (checkoutMode !== CHECKOUT_MODES.LIVE) {
2934
2953
  throw _httpError(400, 'liveValidation requires checkoutMode "live" (hybrid mode). Set checkoutMode to "live" first, or clear liveValidation.');
2935
2954
  }
2936
- const type = typeof value.type === 'string' ? value.type.trim() : '';
2937
- if (!type) {
2938
- throw _httpError(400, 'Invalid liveValidation: "type" is required and must be a non-empty string — the work-item type that runs on the live checkout (e.g. "build-and-test").');
2955
+ const typeErrorMsg = 'Invalid liveValidation: "type" is required and must be a non-empty string, or a non-empty array of non-empty-string work-item types — the work-item type(s) that run on the live checkout (e.g. "build-and-test" or ["implement", "fix"]).';
2956
+ const rawType = value.type;
2957
+ let out;
2958
+ if (typeof rawType === 'string') {
2959
+ const trimmed = rawType.trim();
2960
+ if (!trimmed) throw _httpError(400, typeErrorMsg);
2961
+ out = { type: trimmed };
2962
+ } else if (Array.isArray(rawType)) {
2963
+ if (!rawType.every(t => typeof t === 'string' && t.trim().length > 0)) {
2964
+ throw _httpError(400, typeErrorMsg);
2965
+ }
2966
+ // Dedupe (preserving first-seen order) and cap at the number of distinct
2967
+ // work-item types the engine recognizes — a longer array can only be
2968
+ // full of duplicates or invented types.
2969
+ const deduped = [...new Set(rawType.map(t => t.trim()))];
2970
+ if (deduped.length === 0) throw _httpError(400, typeErrorMsg);
2971
+ if (deduped.length > VALID_WORK_TYPES.size) {
2972
+ throw _httpError(400, `Invalid liveValidation: "type" array has ${deduped.length} entries, more than the ${VALID_WORK_TYPES.size} distinct work-item types the engine recognizes — remove duplicates or invalid types.`);
2973
+ }
2974
+ out = { type: deduped };
2975
+ } else {
2976
+ throw _httpError(400, typeErrorMsg);
2939
2977
  }
2940
- const out = { type };
2941
2978
  // autoDispatch (optional): when true, lifecycle auto-creates a validation WI
2942
- // of `type` after each coding WI completes with a PR (engine/lifecycle.js
2943
- // autoDispatchLiveValidationWi). Coerce to a strict boolean; absent leaves it
2944
- // off so the operator opts in explicitly.
2979
+ // per configured `type` after each coding WI completes with a PR
2980
+ // (engine/lifecycle.js autoDispatchLiveValidationWi). Coerce to a strict
2981
+ // boolean; absent leaves it off so the operator opts in explicitly.
2945
2982
  if (Object.prototype.hasOwnProperty.call(value, 'autoDispatch')) {
2946
2983
  out.autoDispatch = !!value.autoDispatch;
2947
2984
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2309",
3
+ "version": "0.1.2311",
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"
@@ -325,7 +325,7 @@ Every configured project has an effective **checkout mode** — surfaced in your
325
325
 
326
326
  - **`worktree`** (default) — each dispatch gets its own isolated `git worktree`. Agents run fully in parallel; nothing touches the operator's working tree. Use for normal repos.
327
327
  - **`live`** — agents run **in-place** inside the project's `localPath` (no worktree). The engine caps this to **one mutating dispatch at a time** per project and **refuses on a dirty tree**. Use only when worktrees are unworkable (e.g. Android `repo`, submodules, deep Windows paths, emulators that bind the real checkout).
328
- - **`hybrid`** — `live` **plus** a `liveValidation: { type, autoDispatch }` block. Coding work items (`implement`/`fix`/`docs`/`decompose`) author in **isolated worktrees** (full parallelism), while **only** work items whose type matches `liveValidation.type` (e.g. `build-and-test`) run **in-place on the live checkout**. This is the best of both: parallel code authoring + a real on-disk build/validation that can't run in a worktree.
328
+ - **`hybrid`** — `live` **plus** a `liveValidation: { type, autoDispatch }` block. Coding work items (`implement`/`fix`/`docs`/`decompose`) author in **isolated worktrees** (full parallelism), while **only** work items whose type matches `liveValidation.type` (e.g. `build-and-test`) run **in-place on the live checkout**. This is the best of both: parallel code authoring + a real on-disk build/validation that can't run in a worktree. `type` accepts a single string (e.g. `"build-and-test"`) or an array of strings (e.g. `["implement","fix"]`) to route multiple work-item types to the live checkout at once.
329
329
 
330
330
  **When to recommend hybrid:** the project's build/test/validation genuinely cannot run in an isolated worktree (it needs the real checkout — submodules, a `repo`-managed tree, an emulator/dev-server bound to `localPath`, deep-path tooling), **but** you still want coding agents to work in parallel rather than serialize through the single live checkout. If the *whole* workflow must run on the real tree, use plain `live`. If nothing needs the real tree, stay on `worktree`.
331
331