@yemi33/minions 0.1.2136 → 0.1.2138

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.
@@ -124,6 +124,24 @@ function startTerminalEventSource(agentId) {
124
124
  try { payload = JSON.parse(ev.data); } catch { return; }
125
125
  renderTerminalBannerForAgent(agentId, payload);
126
126
  });
127
+ // W-mq1m1wo5000nd78d — when the same agent starts a new dispatch, clear
128
+ // the prior dispatch's terminal banner. Without this, the stale banner
129
+ // from the previous dispatch lingers across same-agent re-dispatch
130
+ // because startLiveStream() only clears on tab switch / agent change.
131
+ liveTerminalSource.addEventListener('dispatch.started', function(ev) {
132
+ let payload;
133
+ try { payload = JSON.parse(ev.data); } catch { return; }
134
+ // Mirror the same guards used by renderTerminalBannerForAgent —
135
+ // a late-arriving started event for a stale agent / non-live tab
136
+ // must not wipe the current banner.
137
+ if (typeof currentAgentId !== 'undefined' && currentAgentId !== agentId) return;
138
+ if (typeof currentTab !== 'undefined' && currentTab !== 'live') return;
139
+ // Server-side guard already filters by agentId, but defend in depth
140
+ // for replay paths or future per-stream routing changes.
141
+ if (payload && payload.agentId && payload.agentId !== agentId) return;
142
+ const bannerEl = document.getElementById('live-terminal-banner');
143
+ if (bannerEl) bannerEl.innerHTML = '';
144
+ });
127
145
  // Ignore default `data:` log frames — log content arrives via polling.
128
146
  liveTerminalSource.onerror = function() {
129
147
  // EventSource auto-reconnects; nothing to do.
package/dashboard.js CHANGED
@@ -6092,6 +6092,46 @@ const server = http.createServer(async (req, res) => {
6092
6092
  };
6093
6093
  fs.watchFile(eventsFile, { interval: 500 }, terminalWatcher);
6094
6094
 
6095
+ // W-mq1m1wo5000nd78d — parallel watcher for `dispatch.started` events.
6096
+ // Symmetric with the terminal watcher above; the dashboard re-broadcasts
6097
+ // each new entry as an SSE `dispatch.started` frame. The live-view
6098
+ // client uses this to clear a stale terminal banner when the same agent
6099
+ // immediately starts a new dispatch. NO replay-on-connect: the
6100
+ // banner-clear is the only consumer and replaying a stale started event
6101
+ // after a viewer connects post-terminal would wipe the terminal banner.
6102
+ const _sentStartedDispatchIds = new Set();
6103
+ const sendStartedEvent = (event) => {
6104
+ if (!event || !event.dispatchId) return;
6105
+ if (_sentStartedDispatchIds.has(event.dispatchId)) return;
6106
+ _sentStartedDispatchIds.add(event.dispatchId);
6107
+ safeWrite(`event: dispatch.started\ndata: ${JSON.stringify(event)}\n\n`);
6108
+ };
6109
+ const startedEventsFile = dispatchEvents._startedEventsFile();
6110
+ let _startedEventsOffset = 0;
6111
+ try { _startedEventsOffset = fs.statSync(startedEventsFile).size; }
6112
+ catch { _startedEventsOffset = 0; }
6113
+ const startedWatcher = () => {
6114
+ if (_cleanedUp) return;
6115
+ let stat;
6116
+ try { stat = fs.statSync(startedEventsFile); } catch { return; }
6117
+ if (stat.size === _startedEventsOffset) return;
6118
+ if (stat.size < _startedEventsOffset) _startedEventsOffset = 0;
6119
+ try {
6120
+ const fd = fs.openSync(startedEventsFile, 'r');
6121
+ const buf = Buffer.alloc(stat.size - _startedEventsOffset);
6122
+ fs.readSync(fd, buf, 0, buf.length, _startedEventsOffset);
6123
+ fs.closeSync(fd);
6124
+ _startedEventsOffset = stat.size;
6125
+ const lines = buf.toString('utf8').split('\n').filter(Boolean);
6126
+ for (const line of lines) {
6127
+ let evt;
6128
+ try { evt = JSON.parse(line); } catch { continue; }
6129
+ if (evt && evt.agentId === agentId) sendStartedEvent(evt);
6130
+ }
6131
+ } catch { /* read race; next watcher tick retries */ }
6132
+ };
6133
+ fs.watchFile(startedEventsFile, { interval: 500 }, startedWatcher);
6134
+
6095
6135
  // Idempotent cleanup helper to prevent handle leaks
6096
6136
  const cleanup = () => {
6097
6137
  if (_cleanedUp) return;
@@ -6099,6 +6139,7 @@ const server = http.createServer(async (req, res) => {
6099
6139
  try { clearInterval(doneCheck); } catch { /* optional */ }
6100
6140
  try { fs.unwatchFile(liveLogPath, watcher); } catch { /* optional */ }
6101
6141
  try { fs.unwatchFile(eventsFile, terminalWatcher); } catch { /* optional */ }
6142
+ try { fs.unwatchFile(startedEventsFile, startedWatcher); } catch { /* optional */ }
6102
6143
  };
6103
6144
 
6104
6145
  // Check if agent is still active (poll every 5s)
@@ -7429,6 +7470,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7429
7470
  }
7430
7471
  safeWrite(notesPath, notes);
7431
7472
 
7473
+ // W-mq1j85cj00055a8f — rewrite WI references that pointed at the
7474
+ // now-archived inbox note to the persisted destination (notes.md).
7475
+ try { shared.rewriteInboxRefsAcrossProjects(name, 'notes.md'); }
7476
+ catch (e) { console.error('inbox-ref rewrite (persist):', e.message); }
7477
+
7432
7478
  // Move to archive
7433
7479
  const archiveDir = path.join(MINIONS_DIR, 'notes', 'archive');
7434
7480
  if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
@@ -7468,6 +7514,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7468
7514
  safeWrite(kbFile, kbContent);
7469
7515
  queries.invalidateKnowledgeBaseCache();
7470
7516
 
7517
+ // W-mq1j85cj00055a8f — rewrite WI references that pointed at the
7518
+ // now-archived inbox note to the KB destination.
7519
+ try { shared.rewriteInboxRefsAcrossProjects(name, `knowledge/${category}/${name}`); }
7520
+ catch (e) { console.error('inbox-ref rewrite (promote-kb):', e.message); }
7521
+
7471
7522
  // Move inbox item to archive
7472
7523
  const archiveDir = path.join(MINIONS_DIR, 'notes', 'archive');
7473
7524
  if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
@@ -1155,7 +1155,22 @@ function archiveInboxFiles(files) {
1155
1155
 
1156
1156
  if (!fs.existsSync(ARCHIVE_DIR)) fs.mkdirSync(ARCHIVE_DIR, { recursive: true });
1157
1157
  for (const f of files) {
1158
- try { fs.renameSync(path.join(INBOX_DIR, f), shared.uniquePath(path.join(ARCHIVE_DIR, `${dateStamp()}-${f}`))); } catch (err) { log('warn', `Inbox archive: ${err.message}`); }
1158
+ try {
1159
+ // Resolve the final destination path BEFORE rename so the WI-ref
1160
+ // rewrite can point at the actual on-disk location (uniquePath may
1161
+ // suffix `-2`, `-3` … if a same-day collision exists).
1162
+ const dest = shared.uniquePath(path.join(ARCHIVE_DIR, `${dateStamp()}-${f}`));
1163
+ // W-mq1j85cj00055a8f — rewrite WI references that pointed at the
1164
+ // now-archived inbox note to the archive destination. Computed
1165
+ // relative to MINIONS_DIR so it matches the dashboard's relative-URL
1166
+ // render convention. Wrapped so a rewrite failure can't break the
1167
+ // archive step itself (which is the load-bearing operation here).
1168
+ try {
1169
+ const rel = path.relative(shared.MINIONS_DIR, dest).replace(/\\/g, '/');
1170
+ shared.rewriteInboxRefsAcrossProjects(f, rel);
1171
+ } catch (e) { log('warn', `Inbox-ref rewrite (${f}): ${e.message}`); }
1172
+ fs.renameSync(path.join(INBOX_DIR, f), dest);
1173
+ } catch (err) { log('warn', `Inbox archive: ${err.message}`); }
1159
1174
  }
1160
1175
  }
1161
1176
 
@@ -46,21 +46,33 @@ function _eventsFile() {
46
46
  return path.join(shared.MINIONS_DIR, 'engine', 'dispatch-terminal-events.jsonl');
47
47
  }
48
48
 
49
+ function _startedEventsFile() {
50
+ // W-mq1m1wo5000nd78d — parallel ring for `dispatch.started` events. Kept
51
+ // separate from the terminal ring so a same-agent re-dispatch can clear
52
+ // the prior terminal banner without us having to peek/merge two event
53
+ // types in the dashboard tailer or worry about per-dispatch ordering
54
+ // when one ring rolls over before the other.
55
+ return path.join(shared.MINIONS_DIR, 'engine', 'dispatch-started-events.jsonl');
56
+ }
57
+
49
58
  function _now() { return Date.now(); }
50
59
 
51
- function _readLines() {
60
+ function _readLinesFrom(file) {
52
61
  try {
53
- const raw = fs.readFileSync(_eventsFile(), 'utf8');
62
+ const raw = fs.readFileSync(file, 'utf8');
54
63
  return raw.split('\n').filter(Boolean);
55
64
  } catch { return []; }
56
65
  }
57
66
 
58
- function _appendEventLine(event) {
59
- const file = _eventsFile();
67
+ function _readLines() {
68
+ return _readLinesFrom(_eventsFile());
69
+ }
70
+
71
+ function _appendEventLineTo(file, event) {
60
72
  const dir = path.dirname(file);
61
73
  try {
62
74
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
63
- let lines = _readLines();
75
+ let lines = _readLinesFrom(file);
64
76
  lines.push(JSON.stringify(event));
65
77
  // Drop entries older than REPLAY_TTL_MS, then cap by entry count.
66
78
  const cutoff = _now() - REPLAY_TTL_MS;
@@ -78,6 +90,10 @@ function _appendEventLine(event) {
78
90
  }
79
91
  }
80
92
 
93
+ function _appendEventLine(event) {
94
+ _appendEventLineTo(_eventsFile(), event);
95
+ }
96
+
81
97
  /**
82
98
  * Build + broadcast a dispatch terminal event. Called from completeDispatch()
83
99
  * after the dispatch record's `result` is committed. The event payload is the
@@ -163,6 +179,67 @@ function buildTerminalEvent({ dispatchId, agentId, result, reason, failureClass,
163
179
  };
164
180
  }
165
181
 
182
+ /**
183
+ * W-mq1m1wo5000nd78d — symmetric counterpart to `dispatch.terminal`. Called
184
+ * exactly once per FRESH spawn from engine.js#spawnAgent (NOT from cli.js
185
+ * re-attach paths, which inherit a still-live previous-dispatch banner that
186
+ * must not be clobbered by a synthetic restart-time started event).
187
+ *
188
+ * Sole consumer today: dashboard/js/live-stream.js — clears
189
+ * `#live-terminal-banner` so a stale "✓ Task complete" / "✗ Task ended"
190
+ * banner from the agent's prior dispatch doesn't linger across same-agent
191
+ * re-dispatch (e.g. Ripley finishing one scheduled run and immediately
192
+ * starting another).
193
+ *
194
+ * Shape:
195
+ * {
196
+ * type: 'dispatch.started',
197
+ * ts: <epoch-ms>,
198
+ * dispatchId: <string>,
199
+ * agentId: <string>,
200
+ * taskTitle: <string>,
201
+ * startedAt: <ISO string>,
202
+ * }
203
+ *
204
+ * No `replay-on-connect`: the banner-clear is the only consumer, and
205
+ * replaying a stale started event when a viewer opens AFTER terminal has
206
+ * already rendered would silently wipe the terminal banner. Live broadcast
207
+ * only — there's no stale banner to clear when the page just loaded.
208
+ */
209
+ function emitDispatchStarted(event) {
210
+ if (!event || typeof event !== 'object') return;
211
+ if (!event.type) event.type = 'dispatch.started';
212
+ if (!event.ts) event.ts = _now();
213
+ _appendEventLineTo(_startedEventsFile(), event);
214
+ try { _emitter.emit('dispatch.started', event); } catch { /* never throw */ }
215
+ }
216
+
217
+ function onDispatchStarted(listener) {
218
+ _emitter.on('dispatch.started', listener);
219
+ return () => _emitter.off('dispatch.started', listener);
220
+ }
221
+
222
+ function readRecentStartedEvents() {
223
+ const cutoff = _now() - REPLAY_TTL_MS;
224
+ return _readLinesFrom(_startedEventsFile())
225
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
226
+ .filter(e => e && (e.ts || 0) >= cutoff);
227
+ }
228
+
229
+ function buildStartedEvent({ dispatchId, agentId, taskTitle, startedAt }) {
230
+ let safeTitle = '';
231
+ if (typeof taskTitle === 'string' && taskTitle) {
232
+ safeTitle = taskTitle.split('\n')[0].slice(0, 240);
233
+ }
234
+ return {
235
+ type: 'dispatch.started',
236
+ dispatchId,
237
+ agentId: agentId || null,
238
+ taskTitle: safeTitle,
239
+ startedAt: startedAt || null,
240
+ };
241
+ }
242
+
166
243
  module.exports = {
167
244
  emitDispatchTerminal,
168
245
  onDispatchTerminal,
@@ -170,8 +247,14 @@ module.exports = {
170
247
  getLastTerminalForAgent,
171
248
  getLastTerminalForDispatch,
172
249
  buildTerminalEvent,
250
+ // dispatch.started (W-mq1m1wo5000nd78d)
251
+ emitDispatchStarted,
252
+ onDispatchStarted,
253
+ readRecentStartedEvents,
254
+ buildStartedEvent,
173
255
  // exposed for testing / dashboard tailer
174
256
  _eventsFile,
257
+ _startedEventsFile,
175
258
  MAX_RING_ENTRIES,
176
259
  REPLAY_TTL_MS,
177
260
  };
package/engine/shared.js CHANGED
@@ -5090,6 +5090,87 @@ function extractStructuredWorkItemPrRef(item) {
5090
5090
  return null;
5091
5091
  }
5092
5092
 
5093
+ // W-mq1j85cj00055a8f — when an inbox note (notes/inbox/<name>) leaves the
5094
+ // inbox (persisted to notes.md, promoted to knowledge/, or auto-archived by
5095
+ // consolidation), any work-item reference pointing at the old inbox path
5096
+ // turns into a broken link. Rewrite those references across every project's
5097
+ // work-items.json + the central one to the new canonical location.
5098
+ //
5099
+ // Match semantics (intentionally narrow per the WI scope):
5100
+ // - Scope is strictly item.references[]; description prose is NOT scanned.
5101
+ // - String entries: 'notes/inbox/<inboxName>' (anchored on start or '/').
5102
+ // - Object entries: { url | path | href }. Other keys (label, kind, …) are
5103
+ // preserved.
5104
+ // - Trailing `?query` or `#fragment` is tolerated; substring overlaps in
5105
+ // unrelated path segments (e.g. .../notes/inbox/foobar for foo.md) are
5106
+ // NOT rewritten.
5107
+ // - Archived work-items files are skipped — only live work-items.json paths
5108
+ // surfaced by getProjects() + the central path are touched.
5109
+ //
5110
+ // Idempotent: re-running with the same inboxName after the rewrite is a
5111
+ // no-op (the references already read newLocation, which won't match the
5112
+ // notes/inbox/<inboxName> regex).
5113
+ //
5114
+ // Returns the count of references rewritten across all files (for logging).
5115
+ // Each file's mutate is wrapped in try/catch so one corrupt project can't
5116
+ // block the others.
5117
+ //
5118
+ // `opts._mutate` is an undocumented test seam — production callers always
5119
+ // take the default (the in-file `mutateWorkItems` closure reference). Tests
5120
+ // inject a wrapper to exercise the per-file try/catch boundary without
5121
+ // having to manufacture a real SQL-store failure.
5122
+ function rewriteInboxRefsAcrossProjects(inboxName, newLocation, opts = {}) {
5123
+ if (!inboxName || typeof newLocation !== 'string' || !newLocation) return 0;
5124
+ const baseName = String(inboxName).replace(/^.*[/\\]/, '').trim();
5125
+ if (!baseName) return 0;
5126
+ const escaped = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
5127
+ const inboxRefRe = new RegExp(`(?:^|/)notes/inbox/${escaped}(?=$|[?#])`);
5128
+ const _mutate = (opts && typeof opts._mutate === 'function') ? opts._mutate : mutateWorkItems;
5129
+
5130
+ let rewritten = 0;
5131
+ const config = safeJson(path.join(MINIONS_DIR, 'config.json')) || {};
5132
+ const candidates = [];
5133
+ try {
5134
+ for (const project of getProjects(config)) {
5135
+ try { candidates.push(projectWorkItemsPath(project)); } catch { /* skip bad project entry */ }
5136
+ }
5137
+ } catch { /* getProjects failed — fall back to central only */ }
5138
+ candidates.push(centralWorkItemsPath());
5139
+
5140
+ for (const wiPath of candidates) {
5141
+ try {
5142
+ _mutate(wiPath, items => {
5143
+ if (!Array.isArray(items)) return items;
5144
+ for (const item of items) {
5145
+ if (!item || !Array.isArray(item.references)) continue;
5146
+ for (let i = 0; i < item.references.length; i++) {
5147
+ const ref = item.references[i];
5148
+ if (typeof ref === 'string') {
5149
+ if (inboxRefRe.test(ref)) {
5150
+ item.references[i] = newLocation;
5151
+ rewritten++;
5152
+ }
5153
+ } else if (ref && typeof ref === 'object') {
5154
+ for (const key of ['url', 'path', 'href']) {
5155
+ const v = ref[key];
5156
+ if (typeof v === 'string' && inboxRefRe.test(v)) {
5157
+ ref[key] = newLocation;
5158
+ rewritten++;
5159
+ break;
5160
+ }
5161
+ }
5162
+ }
5163
+ }
5164
+ }
5165
+ return items;
5166
+ });
5167
+ } catch (e) {
5168
+ try { console.warn(`rewriteInboxRefsAcrossProjects(${wiPath}): ${e.message}`); } catch { /* logging best-effort */ }
5169
+ }
5170
+ }
5171
+ return rewritten;
5172
+ }
5173
+
5093
5174
  function extractWorkItemPrRef(item) {
5094
5175
  if (!item || typeof item !== 'object') return null;
5095
5176
  const fromStructured = extractStructuredWorkItemPrRef(item);
@@ -6500,6 +6581,7 @@ module.exports = {
6500
6581
  extractPrRefFromText,
6501
6582
  extractWorkItemPrRef,
6502
6583
  extractStructuredWorkItemPrRef,
6584
+ rewriteInboxRefsAcrossProjects,
6503
6585
  getProjectPrScope,
6504
6586
  getPrNumber,
6505
6587
  getPrDisplayId,
package/engine/watches.js CHANGED
@@ -320,7 +320,10 @@ function evaluateWatch(watch, state) {
320
320
  if (!tt.conditions.includes(condition)) return { triggered: false, message: `Unknown condition: ${condition}` };
321
321
 
322
322
  const entity = tt.fetchEntity(target, state || {});
323
- if (!entity) return { triggered: false, message: `${tt.label} ${target} not found` };
323
+ if (!entity) {
324
+ const targetStr = typeof target === 'string' ? target : JSON.stringify(target);
325
+ return { triggered: false, message: `${tt.label} ${targetStr} not found` };
326
+ }
324
327
 
325
328
  const prevState = watch._lastState || {};
326
329
  let primary;
@@ -645,21 +648,35 @@ async function _runActionTask(task) {
645
648
  /**
646
649
  * Internal: capture state snapshot for a watch target.
647
650
  * Dispatches to the registered target type's captureState.
651
+ *
652
+ * W-mq1n83pw000844b8 — Preserve prior state on transient fetchEntity null
653
+ * (and on captureState exceptions / unknown target types). The old behavior
654
+ * wiped to {} whenever the entity could not be resolved, which then made
655
+ * line 431 re-initialize via captureState on the next tick, losing
656
+ * type-specific dedup keys (gh-author-prs `numbers`, work-item
657
+ * `_unchangedTicks`, pipeline `_stuckStageTicks`). The result was watches
658
+ * re-firing for already-seen entities after every engine restart while a
659
+ * plugin's background fetch cache warmed up. Preserving prevState is
660
+ * harmless for types where fetchEntity-null means "entity deleted" because
661
+ * evaluate already gates on entity being non-null, and is the bug-free
662
+ * behavior for plugins with transient nulls (cache miss, network hiccup).
648
663
  */
649
664
  function _captureState(watch, state) {
665
+ const prevState = watch._lastState || {};
650
666
  const tt = TARGET_TYPES[watch.targetType];
651
- if (!tt) return {};
667
+ if (!tt) return prevState;
652
668
  const entity = tt.fetchEntity(watch.target, state || {});
653
- if (!entity) return {};
669
+ if (!entity) return prevState;
654
670
  try {
655
671
  // P-w5b8d2c9 — Phase 2.2: pass prevState so captureState can carry
656
672
  // forward unchanged-tick counters (e.g. _unchangedTicks for work-item
657
673
  // stalled, _stuckStageTicks for pipeline stuck-in-stage). Existing
658
674
  // captureState fns that take only 1 arg ignore this — backward-compat.
659
- return tt.captureState(entity, watch._lastState || {}) || {};
675
+ const out = tt.captureState(entity, prevState);
676
+ return (out && typeof out === 'object') ? out : prevState;
660
677
  } catch (err) {
661
678
  log('warn', `_captureState ${watch.targetType}: ${err.message}`);
662
- return {};
679
+ return prevState;
663
680
  }
664
681
  }
665
682
 
package/engine.js CHANGED
@@ -37,6 +37,7 @@ const { resolveRuntime } = require('./engine/runtimes');
37
37
  const { assertStaleHeadOk } = require('./engine/spawn-agent');
38
38
  const adoGitAuth = require('./engine/ado-git-auth');
39
39
  const queries = require('./engine/queries');
40
+ const dispatchEvents = require('./engine/dispatch-events');
40
41
 
41
42
  // ─── Paths ──────────────────────────────────────────────────────────────────
42
43
 
@@ -2684,6 +2685,28 @@ async function spawnAgent(dispatchItem, config) {
2684
2685
  };
2685
2686
  activeProcesses.set(id, initialProcInfo);
2686
2687
  registeredInActiveProcesses = true;
2688
+
2689
+ // W-mq1m1wo5000nd78d — emit dispatch.started symmetric with the
2690
+ // dispatch.terminal event emitted from dispatch.js#completeDispatch. The
2691
+ // dashboard's live-view client uses this to clear the prior dispatch's
2692
+ // terminal banner when the same agent immediately starts a new run
2693
+ // (otherwise the "✓ Task complete" / "✗ Task ended" banner from the
2694
+ // previous dispatch lingers because startLiveStream only clears the
2695
+ // banner on tab switch / agent change).
2696
+ //
2697
+ // ONLY emitted on fresh spawns — re-attach after engine restart goes
2698
+ // through engine/cli.js and does NOT pass through here, so a re-attached
2699
+ // dispatch (procInfo.reattached === true) won't synthesise a spurious
2700
+ // started event that could clobber a still-live previous-dispatch
2701
+ // banner that the user is mid-read on.
2702
+ try {
2703
+ dispatchEvents.emitDispatchStarted(dispatchEvents.buildStartedEvent({
2704
+ dispatchId: id,
2705
+ agentId,
2706
+ taskTitle: dispatchItem.task,
2707
+ startedAt,
2708
+ }));
2709
+ } catch (e) { log('warn', `emit dispatch.started: ${e.message}`); }
2687
2710
  } catch (spawnErr) {
2688
2711
  // Partial-setup cleanup (P-f4d2e8a1): tear down every artifact in the
2689
2712
  // reverse order it was created. Each step is conditional + best-effort
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2136",
3
+ "version": "0.1.2138",
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"