@yemi33/minions 0.1.1708 → 0.1.1710

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1710 (2026-05-04)
4
+
5
+ ### Other
6
+ - Fix meeting lifecycle edge cases
7
+
8
+ ## 0.1.1709 (2026-05-04)
9
+
10
+ ### Fixes
11
+ - auto-heal projects with missing workSources at engine boot
12
+
3
13
  ## 0.1.1708 (2026-05-04)
4
14
 
5
15
  ### Fixes
@@ -969,7 +969,7 @@ async function ccExecuteAction(action, targetTabId) {
969
969
  status.style.color = 'var(--green)';
970
970
  }
971
971
  ccAddMessage('action', status.outerHTML, false, targetTabId);
972
- if (['dispatch','fix','implement','explore','review','test'].includes(action.type)) wakeEngine();
972
+ if (['dispatch','fix','implement','explore','review','test','create-meeting'].includes(action.type)) wakeEngine();
973
973
  refresh();
974
974
  return;
975
975
  }
@@ -1175,9 +1175,10 @@ async function ccExecuteAction(action, targetTabId) {
1175
1175
  break;
1176
1176
  }
1177
1177
  case 'create-meeting': {
1178
+ var meetingParticipants = (Array.isArray(action.participants) && action.participants.length > 0) ? action.participants : (action.agents || []);
1178
1179
  var res6 = await fetch('/api/meetings', {
1179
1180
  method: 'POST', headers: { 'Content-Type': 'application/json' },
1180
- body: JSON.stringify({ title: action.title, agenda: action.agenda, participants: action.agents, rounds: action.rounds, project: action.project })
1181
+ body: JSON.stringify({ title: action.title, agenda: action.agenda, participants: meetingParticipants, rounds: action.rounds, project: action.project })
1181
1182
  });
1182
1183
  if (!res6.ok) { var d6 = await res6.json().catch(function() { return {}; }); throw new Error(d6.error || 'Meeting create failed'); }
1183
1184
  var d6r = await res6.json();
package/dashboard.js CHANGED
@@ -1539,6 +1539,27 @@ function _parseWatchInterval(val) {
1539
1539
  return Math.max(60000, Math.round(u === 's' ? n * 1000 : u === 'm' ? n * 60000 : n * 3600000));
1540
1540
  }
1541
1541
 
1542
+ function normalizeMeetingParticipants(participants) {
1543
+ if (!Array.isArray(participants)) return [];
1544
+ const seen = new Set();
1545
+ const normalized = [];
1546
+ for (const participant of participants) {
1547
+ const id = String(participant || '').trim();
1548
+ if (!id || seen.has(id)) continue;
1549
+ seen.add(id);
1550
+ normalized.push(id);
1551
+ }
1552
+ return normalized;
1553
+ }
1554
+
1555
+ function meetingParticipantsFromAction(action) {
1556
+ return normalizeMeetingParticipants(
1557
+ Array.isArray(action?.participants) && action.participants.length > 0
1558
+ ? action.participants
1559
+ : action?.agents
1560
+ );
1561
+ }
1562
+
1542
1563
  // Required-field validator for CC actions. Returns null when valid, an error string when not.
1543
1564
  // Centralises field-required checks so the model can't quietly emit a malformed action and have
1544
1565
  // the server silently fall back to placeholder values (e.g. "Untitled"). The handler invokes this
@@ -1567,6 +1588,12 @@ function _ccValidateAction(action) {
1567
1588
  case 'plan':
1568
1589
  if (!action.title) return 'plan action missing required field: title';
1569
1590
  return null;
1591
+ case 'create-meeting': {
1592
+ if (!action.title || typeof action.title !== 'string' || !action.title.trim()) return 'create-meeting action missing required field: title';
1593
+ if (!action.agenda || typeof action.agenda !== 'string' || !action.agenda.trim()) return 'create-meeting action missing required field: agenda';
1594
+ if (meetingParticipantsFromAction(action).length < 2) return 'create-meeting action requires at least 2 participants';
1595
+ return null;
1596
+ }
1570
1597
  default:
1571
1598
  return null; // unknown types fall through to existing handler / generic fallback
1572
1599
  }
@@ -1794,6 +1821,17 @@ async function executeCCActions(actions) {
1794
1821
  results.push({ type: 'create-watch', id: watch.id, ok: true });
1795
1822
  break;
1796
1823
  }
1824
+ case 'create-meeting': {
1825
+ const { createMeeting } = require('./engine/meeting');
1826
+ const meeting = createMeeting({
1827
+ title: action.title.trim(),
1828
+ agenda: action.agenda.trim(),
1829
+ participants: meetingParticipantsFromAction(action),
1830
+ });
1831
+ invalidateStatusCache();
1832
+ results.push({ type: 'create-meeting', id: meeting.id, ok: true });
1833
+ break;
1834
+ }
1797
1835
  case 'delete-watch': {
1798
1836
  const deleted = watchesMod.deleteWatch(action.id);
1799
1837
  if (deleted) invalidateStatusCache();
@@ -6721,9 +6759,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6721
6759
  { method: 'POST', path: '/api/meetings', desc: 'Create a team meeting', params: 'title, agenda, participants[]', handler: async (req, res) => {
6722
6760
  const body = await readBody(req);
6723
6761
  const { title, agenda, participants } = body;
6724
- if (!title || !agenda) return jsonReply(res, 400, { error: 'title and agenda required' });
6762
+ if (typeof title !== 'string' || !title.trim() || typeof agenda !== 'string' || !agenda.trim()) {
6763
+ return jsonReply(res, 400, { error: 'title and agenda required' });
6764
+ }
6765
+ if (!Array.isArray(participants)) return jsonReply(res, 400, { error: 'participants must be an array' });
6766
+ const meetingParticipants = normalizeMeetingParticipants(participants);
6767
+ if (meetingParticipants.length < 2) return jsonReply(res, 400, { error: 'at least 2 participants required' });
6725
6768
  const { createMeeting } = require('./engine/meeting');
6726
- const meeting = createMeeting({ title, agenda, participants: participants || [] });
6769
+ const meeting = createMeeting({ title: title.trim(), agenda: agenda.trim(), participants: meetingParticipants });
6727
6770
  invalidateStatusCache();
6728
6771
  return jsonReply(res, 200, { ok: true, meeting });
6729
6772
  }},
@@ -6923,6 +6966,8 @@ module.exports = {
6923
6966
  _filterCcTabSessions,
6924
6967
  _getVersionCheckInterval,
6925
6968
  _parseWatchInterval,
6969
+ _normalizeMeetingParticipants: normalizeMeetingParticipants,
6970
+ _meetingParticipantsFromAction: meetingParticipantsFromAction,
6926
6971
  parsePinnedEntries,
6927
6972
  _parseDocChatResultText,
6928
6973
  _messageRequestsOrchestration,
package/engine/cli.js CHANGED
@@ -379,6 +379,28 @@ const commands = {
379
379
  // refactor. No disk write — the on-disk config still carries `ccModel`.
380
380
  try { shared.applyLegacyCcModelMigration(config, { logger: e.log }); }
381
381
  catch (err) { e.log('warn', `legacy ccModel migration failed: ${err.message}`); }
382
+
383
+ // Auto-heal projects missing workSources (cloned-repo / hand-rolled-config
384
+ // footgun): without this block, discoverFromWorkItems / discoverFromPrs
385
+ // bail silently and the engine looks healthy but never dispatches. The
386
+ // disk-side mutation re-derives heal state from the on-disk copy so we
387
+ // don't clobber a concurrent dashboard write between the in-memory check
388
+ // and the lock acquire. skipWriteIfUnchanged makes the write a no-op when
389
+ // nothing needed healing (e.g. dashboard already fixed it).
390
+ try {
391
+ const heal = shared.backfillProjectWorkSourceDefaults(config);
392
+ if (heal.changed) {
393
+ const configPath = path.join(shared.MINIONS_DIR, 'config.json');
394
+ shared.mutateJsonFileLocked(configPath, (onDisk) => {
395
+ shared.backfillProjectWorkSourceDefaults(onDisk);
396
+ return onDisk;
397
+ }, { defaultValue: {}, skipWriteIfUnchanged: true });
398
+ for (const h of heal.healed) {
399
+ e.log('info', `Auto-healed project "${h.project}" — backfilled missing workSources: ${h.sources.join(', ')}`);
400
+ }
401
+ console.log(` Auto-healed ${heal.healed.length} project(s) with missing workSources defaults — engine will now dispatch their work.`);
402
+ }
403
+ } catch (err) { e.log('warn', `workSources auto-heal failed: ${err.message}`); }
382
404
  const interval = config.engine?.tickInterval || shared.ENGINE_DEFAULTS.tickInterval;
383
405
 
384
406
  const { getProjects } = require('./shared');
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-04T16:42:47.140Z"
4
+ "cachedAt": "2026-05-04T17:10:04.905Z"
5
5
  }
@@ -2662,7 +2662,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2662
2662
  if (type === WORK_TYPE.MEETING && meta?.meetingId) {
2663
2663
  try {
2664
2664
  const { collectMeetingFindings } = require('./meeting');
2665
- collectMeetingFindings(meta.meetingId, agentId, meta.roundName, stdout, structuredCompletion);
2665
+ collectMeetingFindings(meta.meetingId, agentId, meta.roundName, stdout, structuredCompletion, meta.round);
2666
2666
  } catch (err) { log('warn', `Meeting collect: ${err.message}`); }
2667
2667
  }
2668
2668
 
package/engine/meeting.js CHANGED
@@ -20,6 +20,21 @@ const EMPTY_OUTPUT_PATTERNS = ['(no output)', '(no findings)', '(no response)'];
20
20
  // tests can redirect the meetings directory without patching module internals.
21
21
  const MEETINGS_DIR = path.join(shared.MINIONS_DIR, 'meetings');
22
22
  const MEETING_NOTE_ARTIFACT_ROOT = path.join(shared.MINIONS_DIR, 'notes', 'inbox');
23
+ const TERMINAL_MEETING_STATUSES = new Set(['completed', 'archived']);
24
+ const ROUND_STATUS_BY_NAME = {
25
+ investigate: 'investigating',
26
+ debate: 'debating',
27
+ conclude: 'concluding',
28
+ };
29
+ const ACTIVE_MEETING_STATUSES = new Set(Object.values(ROUND_STATUS_BY_NAME));
30
+
31
+ function isTerminalMeetingStatus(status) {
32
+ return TERMINAL_MEETING_STATUSES.has(String(status || '').toLowerCase());
33
+ }
34
+
35
+ function expectedMeetingStatusForRound(roundName) {
36
+ return ROUND_STATUS_BY_NAME[String(roundName || '').toLowerCase()] || null;
37
+ }
23
38
 
24
39
  function isEmptyMeetingContent(text) {
25
40
  const value = String(text || '').trim();
@@ -278,10 +293,11 @@ function discoverMeetingWork(config) {
278
293
  );
279
294
 
280
295
  for (const meeting of meetings) {
281
- if (meeting.status === 'completed') continue;
296
+ if (isTerminalMeetingStatus(meeting.status)) continue;
282
297
 
283
298
  const round = meeting.round || 1;
284
299
  const roundName = meeting.status; // investigating, debating, concluding
300
+ if (!ACTIVE_MEETING_STATUSES.has(roundName)) continue;
285
301
  const agents = config.agents || {};
286
302
 
287
303
  if (roundName === 'concluding') {
@@ -413,14 +429,28 @@ function discoverMeetingWork(config) {
413
429
  * Collect findings from a completed meeting agent.
414
430
  * Called from runPostCompletionHooks when type === 'meeting'.
415
431
  */
416
- function collectMeetingFindings(meetingId, agentId, roundName, output, structuredCompletion = null) {
432
+ function collectMeetingFindings(meetingId, agentId, roundName, output, structuredCompletion = null, expectedRound = null) {
417
433
  const meeting = getMeeting(meetingId);
418
434
  if (!meeting) return;
419
- if (meeting.status === 'completed' || meeting.status === 'archived') {
435
+ if (isTerminalMeetingStatus(meeting.status)) {
420
436
  log('info', `Ignoring late findings from ${agentId} for completed meeting ${meetingId}`);
421
437
  return;
422
438
  }
423
439
 
440
+ const expectedStatus = expectedMeetingStatusForRound(roundName);
441
+ if (!expectedStatus) {
442
+ log('warn', `Meeting ${meetingId}: ignoring ${agentId} output for unknown round "${roundName || '(empty)'}"`);
443
+ return;
444
+ }
445
+ if (meeting.status !== expectedStatus) {
446
+ log('info', `Ignoring stale ${roundName} output from ${agentId} for meeting ${meetingId} currently ${meeting.status}`);
447
+ return;
448
+ }
449
+ if (expectedRound !== null && expectedRound !== undefined && Number(meeting.round || 1) !== Number(expectedRound)) {
450
+ log('info', `Ignoring stale round ${expectedRound} output from ${agentId} for meeting ${meetingId} currently on round ${meeting.round || 1}`);
451
+ return;
452
+ }
453
+
424
454
  const content = resolveMeetingContributionContent(output, structuredCompletion);
425
455
 
426
456
  // Validate output — reject empty or placeholder responses
@@ -494,21 +524,54 @@ function addMeetingNote(meetingId, note) {
494
524
  function _killMeetingDispatches(meetingId) {
495
525
  try {
496
526
  const DISPATCH_PATH = path.join(shared.MINIONS_DIR, 'engine', 'dispatch.json');
497
- const dispatch = safeJson(DISPATCH_PATH) || {};
498
- const toKill = (dispatch.active || []).filter(d => d.meta?.meetingId === meetingId);
499
- if (toKill.length === 0) return 0;
500
- // Remove from active and move to completed
527
+ const tmpDir = path.join(shared.MINIONS_DIR, 'engine', 'tmp');
528
+ const entriesToStop = [];
529
+ const filesToDelete = [];
501
530
  shared.mutateJsonFileLocked(DISPATCH_PATH, (dp) => {
502
- dp.active = (dp.active || []).filter(d => d.meta?.meetingId !== meetingId);
503
- dp.completed = dp.completed || [];
504
- for (const d of toKill) {
531
+ dp.pending = Array.isArray(dp.pending) ? dp.pending : [];
532
+ dp.active = Array.isArray(dp.active) ? dp.active : [];
533
+ dp.completed = Array.isArray(dp.completed) ? dp.completed : [];
534
+
535
+ for (const queue of ['pending', 'active']) {
536
+ const kept = [];
537
+ for (const d of dp[queue]) {
538
+ if (d.meta?.meetingId !== meetingId) {
539
+ kept.push(d);
540
+ continue;
541
+ }
542
+ entriesToStop.push(d);
543
+ filesToDelete.push(path.join(tmpDir, `pid-${d.id}.pid`));
544
+ filesToDelete.push(path.join(tmpDir, `prompt-${d.id}.md`));
545
+ filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md`));
546
+ filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md.tmp`));
547
+ }
548
+ dp[queue] = kept;
549
+ }
550
+
551
+ for (const d of entriesToStop) {
505
552
  dp.completed.push({ ...d, result: DISPATCH_RESULT.ERROR, reason: 'Meeting ended/advanced by human', completed_at: ts() });
506
553
  }
507
554
  if (dp.completed.length > 100) dp.completed = dp.completed.slice(-100);
508
555
  return dp;
509
556
  }, { defaultValue: { pending: [], active: [], completed: [] } });
510
- log('info', `Killed ${toKill.length} active meeting dispatch(es) for ${meetingId}`);
511
- return toKill.length;
557
+
558
+ const pidsToKill = [];
559
+ for (const d of entriesToStop) {
560
+ try {
561
+ const pidFile = path.join(tmpDir, `pid-${d.id}.pid`);
562
+ const pid = shared.validatePid(fs.readFileSync(pidFile, 'utf8').trim());
563
+ pidsToKill.push(pid);
564
+ } catch { /* pending entries and already-finished agents may not have PID files */ }
565
+ }
566
+ for (const pid of pidsToKill) {
567
+ try { shared.killGracefully({ pid }); } catch { /* process may already be dead */ }
568
+ }
569
+ for (const fp of filesToDelete) {
570
+ try { fs.unlinkSync(fp); } catch { /* sidecar may not exist */ }
571
+ }
572
+
573
+ if (entriesToStop.length > 0) log('info', `Killed ${entriesToStop.length} meeting dispatch(es) for ${meetingId}`);
574
+ return entriesToStop.length;
512
575
  } catch (e) { log('warn', 'kill meeting dispatches: ' + e.message); return 0; }
513
576
  }
514
577
 
@@ -572,10 +635,13 @@ function checkMeetingTimeouts(config) {
572
635
  || ENGINE_DEFAULTS.meetingRoundTimeout;
573
636
 
574
637
  for (const meeting of meetings) {
575
- if (meeting.status === 'completed') continue;
638
+ if (isTerminalMeetingStatus(meeting.status)) continue;
639
+ if (!ACTIVE_MEETING_STATUSES.has(meeting.status)) continue;
576
640
  if (!meeting.roundStartedAt) continue;
577
641
 
578
- const elapsed = Date.now() - new Date(meeting.roundStartedAt).getTime();
642
+ const roundStartedMs = new Date(meeting.roundStartedAt).getTime();
643
+ if (!Number.isFinite(roundStartedMs)) continue;
644
+ const elapsed = Date.now() - roundStartedMs;
579
645
  if (elapsed < timeout) continue;
580
646
 
581
647
  const respondedCount = meeting.status === 'investigating'
package/engine/shared.js CHANGED
@@ -1185,6 +1185,44 @@ function projectWorkSourceWarnings(config, getDataCounts) {
1185
1185
  return warnings;
1186
1186
  }
1187
1187
 
1188
+ /**
1189
+ * Boot-time auto-heal for the cloned-repo / hand-rolled-config footgun: any
1190
+ * project missing a `workSources.workItems` or `workSources.pullRequests`
1191
+ * sub-block gets the dashboard's default backfilled. We only touch *missing*
1192
+ * sub-blocks — an explicit `enabled: false` is treated as user intent and
1193
+ * left alone.
1194
+ *
1195
+ * Pure helper — mutates `config` in place and returns
1196
+ * `{ changed: boolean, healed: Array<{ project, sources }> }` so the caller
1197
+ * can decide whether to persist + log. Returns `{ changed: false, healed: [] }`
1198
+ * for null/empty/malformed input.
1199
+ */
1200
+ function backfillProjectWorkSourceDefaults(config) {
1201
+ const result = { changed: false, healed: [] };
1202
+ if (!config || typeof config !== 'object') return result;
1203
+ const projects = Array.isArray(config.projects) ? config.projects : [];
1204
+ for (const project of projects) {
1205
+ if (!project || typeof project !== 'object') continue;
1206
+ const filled = [];
1207
+ if (!project.workSources || typeof project.workSources !== 'object') {
1208
+ project.workSources = {};
1209
+ }
1210
+ if (!project.workSources.pullRequests) {
1211
+ project.workSources.pullRequests = { enabled: true, cooldownMinutes: 30 };
1212
+ filled.push('pullRequests');
1213
+ }
1214
+ if (!project.workSources.workItems) {
1215
+ project.workSources.workItems = { enabled: true, cooldownMinutes: 0 };
1216
+ filled.push('workItems');
1217
+ }
1218
+ if (filled.length > 0) {
1219
+ result.changed = true;
1220
+ result.healed.push({ project: project.name, sources: filled });
1221
+ }
1222
+ }
1223
+ return result;
1224
+ }
1225
+
1188
1226
  // ─── Status & Type Constants ─────────────────────────────────────────────────
1189
1227
 
1190
1228
  const WI_STATUS = {
@@ -2581,6 +2619,7 @@ module.exports = {
2581
2619
  applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
2582
2620
  runtimeConfigWarnings,
2583
2621
  projectWorkSourceWarnings,
2622
+ backfillProjectWorkSourceDefaults,
2584
2623
  WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd,
2585
2624
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS,
2586
2625
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1708",
3
+ "version": "0.1.1710",
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"