@yemi33/minions 0.1.11 → 0.1.12

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.12 (2026-03-26)
4
+
5
+ ### Engine
6
+ - engine.js
7
+ - engine/ado.js
8
+ - engine/cli.js
9
+ - engine/github.js
10
+ - engine/lifecycle.js
11
+
12
+ ### Dashboard
13
+ - dashboard.html
14
+ - dashboard.js
15
+
16
+ ### Other
17
+ - TODO.md
18
+ - routing.md
19
+ - test/unit.test.js
20
+
3
21
  ## 0.1.11 (2026-03-26)
4
22
 
5
23
  ### Engine
package/dashboard.html CHANGED
@@ -45,9 +45,19 @@
45
45
  @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
46
46
  .timestamp { color: var(--muted); font-size: var(--text-md); font-variant-numeric: tabular-nums; }
47
47
 
48
- .layout { display: grid; grid-template-columns: 1fr 1fr; gap: 0; max-width: 100vw; overflow-x: hidden; }
48
+ .layout { display: none; } /* Replaced by page-layout */
49
49
  section { padding: var(--space-8) var(--space-9); border-bottom: 1px solid var(--border); overflow: hidden; min-width: 0; }
50
- section:nth-child(odd) { border-right: 1px solid var(--border); }
50
+
51
+ /* Sidebar navigation */
52
+ .page-layout { display: flex; height: calc(100vh - 44px); overflow: hidden; }
53
+ .sidebar { width: 150px; min-width: 150px; background: var(--surface); border-right: 1px solid var(--border); padding: var(--space-4) 0; overflow-y: auto; position: sticky; top: 0; }
54
+ .sidebar-link { display: flex; align-items: center; justify-content: space-between; padding: 8px 14px; color: var(--muted); text-decoration: none; font-size: var(--text-sm); border-left: 3px solid transparent; transition: all var(--transition-fast); cursor: pointer; }
55
+ .sidebar-link:hover { color: var(--text); background: var(--surface2); }
56
+ .sidebar-link.active { color: var(--blue); border-left-color: var(--blue); background: var(--surface2); font-weight: 600; }
57
+ .sidebar-count { font-size: 9px; color: var(--muted); background: var(--surface2); padding: 1px 5px; border-radius: 8px; min-width: 16px; text-align: center; }
58
+ .page-content { flex: 1; overflow-y: auto; min-width: 0; }
59
+ .page { display: none; }
60
+ .page.active { display: block; }
51
61
  section h2 { font-size: var(--text-base); font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-bottom: 14px; display: flex; align-items: center; gap: var(--space-4); }
52
62
  section h2 .count { background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius-xl); padding: var(--space-1) 7px; font-size: var(--text-base); color: var(--text); }
53
63
 
@@ -189,7 +199,7 @@
189
199
  .inbox-name { font-weight: 500; font-size: var(--text-md); color: var(--purple); margin-bottom: var(--space-2); display: flex; justify-content: space-between; }
190
200
  .inbox-preview { font-size: var(--text-base); color: var(--muted); line-height: 1.5; max-height: 60px; overflow: hidden; }
191
201
 
192
- .prd-panel, .pr-panel { grid-column: 1 / -1; border-bottom: 1px solid var(--border); overflow: visible; min-width: 0; }
202
+ .prd-panel, .pr-panel { border-bottom: 1px solid var(--border); overflow: visible; min-width: 0; }
193
203
  .prd-inner { display: flex; gap: 16px; align-items: flex-start; }
194
204
  .prd-stats { display: flex; gap: 16px; }
195
205
  .prd-stat { text-align: center; background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: var(--space-6) var(--space-8); }
@@ -331,7 +341,7 @@
331
341
  .empty { color: var(--muted); font-style: italic; font-size: var(--text-md); padding: var(--space-4) 0; }
332
342
 
333
343
  /* Command Center — Unified Input */
334
- .cmd-center { grid-column: 1 / -1; overflow: visible !important; }
344
+ .cmd-center { overflow: visible !important; }
335
345
  .cmd-input-wrap {
336
346
  position: relative; display: flex; align-items: flex-start; gap: 0;
337
347
  background: var(--bg); border: 2px solid var(--border); border-radius: var(--radius-xl);
@@ -642,111 +652,134 @@
642
652
  <code style="background:var(--bg);padding:6px 16px;border-radius:4px;font-size:14px;color:var(--blue);border:1px solid var(--border)">minions init</code>
643
653
  </div>
644
654
 
645
- <div class="layout">
646
- <section class="cmd-center">
647
- <h2>Command Center</h2>
648
- <div class="cmd-input-wrap" id="cmd-input-wrap">
649
- <div class="cmd-highlight-layer" id="cmd-highlight" aria-hidden="true"></div>
650
- <textarea id="cmd-input" rows="1" placeholder='What do you need? e.g. "Fix the auth bug @dallas", "explain the dispatch flow", or "/note always use feature flags"'
651
- oninput="cmdInputChanged()" onkeydown="cmdKeyDown(event)" onscroll="syncHighlightScroll()"></textarea>
652
- <button class="cmd-send-btn" id="cmd-send-btn" onclick="cmdSubmit()">Send <kbd>Ctrl+Enter</kbd></button>
655
+ <!-- Layout replaced by page-layout sidebar navigation -->
656
+
657
+ <div class="page-layout">
658
+ <nav class="sidebar" id="sidebar">
659
+ <a class="sidebar-link" data-page="home" href="/">Home</a>
660
+ <a class="sidebar-link" data-page="work" href="/work">Work Items <span class="sidebar-count" id="sidebar-wi"></span></a>
661
+ <a class="sidebar-link" data-page="prd" href="/prd">PRD</a>
662
+ <a class="sidebar-link" data-page="prs" href="/prs">Pull Requests <span class="sidebar-count" id="sidebar-pr"></span></a>
663
+ <a class="sidebar-link" data-page="plans" href="/plans">Plans</a>
664
+ <a class="sidebar-link" data-page="inbox" href="/inbox">Notes & KB</a>
665
+ <a class="sidebar-link" data-page="schedule" href="/schedule">Schedules</a>
666
+ <a class="sidebar-link" data-page="engine" href="/engine">Engine</a>
667
+ </nav>
668
+ <div class="page-content" id="page-content">
669
+
670
+ <div class="page active" id="page-home">
671
+ <section class="cmd-center">
672
+ <h2>Command Center</h2>
673
+ <div class="cmd-input-wrap" id="cmd-input-wrap">
674
+ <div class="cmd-highlight-layer" id="cmd-highlight" aria-hidden="true"></div>
675
+ <textarea id="cmd-input" rows="1" placeholder='What do you need? e.g. "Fix the auth bug @dallas", "explain the dispatch flow", or "/note always use feature flags"'
676
+ oninput="cmdInputChanged()" onkeydown="cmdKeyDown(event)" onscroll="syncHighlightScroll()"></textarea>
677
+ <button class="cmd-send-btn" id="cmd-send-btn" onclick="cmdSubmit()">Send <kbd>Ctrl+Enter</kbd></button>
678
+ </div>
679
+ <div class="cmd-mention-popup" id="cmd-mention-popup"></div>
680
+ <div class="cmd-meta" id="cmd-meta" style="display:none"></div>
681
+ <div class="cmd-hints">
682
+ <span style="color:var(--blue);font-weight:600">Command Center</span>
683
+ <span>Ask anything, dispatch work, manage plans — powered by Sonnet</span>
684
+ <button class="cmd-history-btn" onclick="cmdShowHistory()">Past Commands</button>
685
+ </div>
686
+ <div class="cmd-toast" id="cmd-toast"></div>
687
+ </section>
688
+ <section>
689
+ <h2>Minions Members <span style="font-size:10px;color:var(--border);font-weight:400;text-transform:none;letter-spacing:0">click for details</span></h2>
690
+ <div class="agents" id="agents-grid">Loading...</div>
691
+ </section>
692
+ <section>
693
+ <h2>Dispatch Queue</h2>
694
+ <div class="dispatch-stats" id="dispatch-stats"></div>
695
+ <div id="dispatch-active"></div>
696
+ <div id="dispatch-pending"></div>
697
+ </section>
698
+ <section class="pr-panel" id="completed-section">
699
+ <h2>Recent Completions <span class="count" id="completed-count">0</span></h2>
700
+ <div id="completed-content"><p class="empty">No completed dispatches yet.</p></div>
701
+ </section>
702
+ </div>
703
+
704
+ <div class="page" id="page-work">
705
+ <section id="work-items-section" style="overflow:visible">
706
+ <h2>Work Items <span class="count" id="wi-count">0</span> <button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;margin-left:8px" onclick="toggleWorkItemArchive()">See Archive</button></h2>
707
+ <div id="work-items-content"><p class="empty">No work items. Add tasks via Command Center above.</p></div>
708
+ <div id="work-items-archive" style="display:none;margin-top:12px"></div>
709
+ </section>
710
+ </div>
711
+
712
+ <div class="page" id="page-prd">
713
+ <section class="prd-panel" id="prd-section">
714
+ <h2>PRD <span class="count" id="prd-progress-count">0%</span> <span id="prd-badge"></span> <span id="archive-btns"></span></h2>
715
+ <div id="prd-content"><p class="prd-pending">No PRD found.</p></div>
716
+ <div id="prd-progress-content" style="margin-top:12px"></div>
717
+ </section>
718
+ </div>
719
+
720
+ <div class="page" id="page-prs">
721
+ <section class="pr-panel" id="pr-section">
722
+ <h2>Pull Requests <span class="count" id="pr-count">0</span></h2>
723
+ <div id="pr-content"><p class="pr-empty">No pull requests yet.</p></div>
724
+ </section>
725
+ </div>
726
+
727
+ <div class="page" id="page-plans">
728
+ <section>
729
+ <h2>Plans <span class="count" id="plans-count">0</span></h2>
730
+ <div id="plans-list"><p class="empty">No plans yet. Use /plan in the command center to create one.</p></div>
731
+ </section>
732
+ </div>
733
+
734
+ <div class="page" id="page-inbox">
735
+ <section>
736
+ <h2>Notes Inbox <span class="count" id="inbox-count">0</span> <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">auto-consolidates at 3 notes</span></h2>
737
+ <div class="inbox-list" id="inbox-list">Loading...</div>
738
+ </section>
739
+ <section>
740
+ <h2 data-file="notes.md" style="position:relative">Team Notes <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0" id="notes-updated"></span></h2>
741
+ <div id="notes-list">Loading...</div>
742
+ </section>
743
+ <section>
744
+ <h2>Knowledge Base <span class="count" id="kb-count">0</span> <button id="kb-sweep-btn" onclick="kbSweep()" style="font-size:9px;padding:2px 8px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);border-radius:4px;cursor:pointer;margin-left:8px;vertical-align:middle">sweep</button><span id="kb-swept-time" style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0;margin-left:8px"></span></h2>
745
+ <div class="kb-tabs" id="kb-tabs"></div>
746
+ <div class="kb-list" id="kb-list"><p class="empty">No knowledge entries yet. Notes are classified here after consolidation.</p></div>
747
+ </section>
748
+ <section>
749
+ <h2>Minions Skills <span class="count" id="skills-count">0</span></h2>
750
+ <div id="skills-list"><p class="empty">No skills yet. Agents create these when they discover repeatable workflows.</p></div>
751
+ </section>
752
+ </div>
753
+
754
+ <div class="page" id="page-schedule">
755
+ <section id="scheduled-section">
756
+ <h2>Scheduled Tasks <span class="count" id="scheduled-count">0</span>
757
+ <button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openCreateScheduleModal()">+ New</button>
758
+ </h2>
759
+ <div id="scheduled-content"><p class="empty">No scheduled tasks. Add one to automate recurring work.</p></div>
760
+ </section>
761
+ <section>
762
+ <h2>MCP Servers <span class="count" id="mcp-count">0</span></h2>
763
+ <div id="mcp-list"><p class="empty">No MCP servers synced.</p></div>
764
+ </section>
653
765
  </div>
654
- <div class="cmd-mention-popup" id="cmd-mention-popup"></div>
655
- <div class="cmd-meta" id="cmd-meta" style="display:none"></div>
656
- <div class="cmd-hints">
657
- <span style="color:var(--blue);font-weight:600">Command Center</span>
658
- <span>Ask anything, dispatch work, manage plans — powered by Sonnet</span>
659
- <button class="cmd-history-btn" onclick="cmdShowHistory()">Past Commands</button>
766
+
767
+ <div class="page" id="page-engine">
768
+ <section>
769
+ <h2>Engine Log</h2>
770
+ <div class="log-list" id="engine-log">No log entries yet.</div>
771
+ </section>
772
+ <section>
773
+ <h2>Agent Metrics</h2>
774
+ <div id="metrics-content"><p class="empty">No metrics yet. Metrics appear after agents complete tasks.</p></div>
775
+ </section>
776
+ <section>
777
+ <h2>Token Usage</h2>
778
+ <div id="token-usage-content"><p class="empty">No usage data yet.</p></div>
779
+ </section>
660
780
  </div>
661
- <div class="cmd-toast" id="cmd-toast"></div>
662
- </section>
663
-
664
- <section>
665
- <h2>Minions Members <span style="font-size:10px;color:var(--border);font-weight:400;text-transform:none;letter-spacing:0">click for details</span></h2>
666
- <div class="agents" id="agents-grid">Loading...</div>
667
- </section>
668
-
669
- <section id="work-items-section" style="overflow:visible">
670
- <h2>Work Items <span class="count" id="wi-count">0</span> <button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;margin-left:8px" onclick="toggleWorkItemArchive()">See Archive</button></h2>
671
- <div id="work-items-content"><p class="empty">No work items. Add tasks via Command Center above.</p></div>
672
- <div id="work-items-archive" style="display:none;margin-top:12px"></div>
673
- </section>
674
-
675
- <section class="prd-panel" id="prd-section">
676
- <h2>PRD <span class="count" id="prd-progress-count">0%</span> <span id="prd-badge"></span> <span id="archive-btns"></span></h2>
677
- <div id="prd-content"><p class="prd-pending">No PRD found.</p></div>
678
- <div id="prd-progress-content" style="margin-top:12px"></div>
679
- </section>
680
-
681
- <section class="pr-panel" id="pr-section">
682
- <h2>Pull Requests <span class="count" id="pr-count">0</span></h2>
683
- <div id="pr-content"><p class="pr-empty">No pull requests yet.</p></div>
684
- </section>
685
-
686
- <section>
687
- <h2>Plans <span class="count" id="plans-count">0</span></h2>
688
- <div id="plans-list"><p class="empty">No plans yet. Use /plan in the command center to create one.</p></div>
689
- </section>
690
-
691
- <section>
692
- <h2>Notes Inbox <span class="count" id="inbox-count">0</span> <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">auto-consolidates at 3 notes</span></h2>
693
- <div class="inbox-list" id="inbox-list">Loading...</div>
694
- </section>
695
-
696
- <section>
697
- <h2 data-file="notes.md" style="position:relative">Team Notes <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0" id="notes-updated"></span></h2>
698
- <div id="notes-list">Loading...</div>
699
- </section>
700
-
701
- <section>
702
- <h2>Knowledge Base <span class="count" id="kb-count">0</span> <button id="kb-sweep-btn" onclick="kbSweep()" style="font-size:9px;padding:2px 8px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);border-radius:4px;cursor:pointer;margin-left:8px;vertical-align:middle">sweep</button><span id="kb-swept-time" style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0;margin-left:8px"></span></h2>
703
- <div class="kb-tabs" id="kb-tabs"></div>
704
- <div class="kb-list" id="kb-list"><p class="empty">No knowledge entries yet. Notes are classified here after consolidation.</p></div>
705
- </section>
706
-
707
- <section>
708
- <h2>Minions Skills <span class="count" id="skills-count">0</span></h2>
709
- <div id="skills-list"><p class="empty">No skills yet. Agents create these when they discover repeatable workflows.</p></div>
710
- </section>
711
-
712
- <section>
713
- <h2>MCP Servers <span class="count" id="mcp-count">0</span></h2>
714
- <div id="mcp-list"><p class="empty">No MCP servers synced.</p></div>
715
- </section>
716
-
717
- <section id="scheduled-section">
718
- <h2>Scheduled Tasks <span class="count" id="scheduled-count">0</span>
719
- <button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openCreateScheduleModal()">+ New</button>
720
- </h2>
721
- <div id="scheduled-content"><p class="empty">No scheduled tasks. Add one to automate recurring work.</p></div>
722
- </section>
723
-
724
- <section>
725
- <h2>Dispatch Queue</h2>
726
- <div class="dispatch-stats" id="dispatch-stats"></div>
727
- <div id="dispatch-active"></div>
728
- <div id="dispatch-pending"></div>
729
- </section>
730
-
731
- <section>
732
- <h2>Engine Log</h2>
733
- <div class="log-list" id="engine-log">No log entries yet.</div>
734
- </section>
735
-
736
- <section>
737
- <h2>Agent Metrics</h2>
738
- <div id="metrics-content"><p class="empty">No metrics yet. Metrics appear after agents complete tasks.</p></div>
739
- </section>
740
-
741
- <section>
742
- <h2>Token Usage</h2>
743
- <div id="token-usage-content"><p class="empty">No usage data yet.</p></div>
744
- </section>
745
-
746
- <section class="pr-panel" id="completed-section">
747
- <h2>Recent Completions <span class="count" id="completed-count">0</span></h2>
748
- <div id="completed-content"><p class="empty">No completed dispatches yet.</p></div>
749
- </section>
781
+
782
+ </div>
750
783
  </div>
751
784
 
752
785
  <!-- Agent Detail Panel -->
@@ -803,6 +836,34 @@ let inboxData = [];
803
836
  let agentData = [];
804
837
  let currentAgentId = null;
805
838
  let currentTab = 'thought-process';
839
+
840
+ // Sidebar page navigation — URL-routed
841
+ function getPageFromUrl() {
842
+ const path = window.location.pathname.replace(/^\//, '') || 'home';
843
+ if (document.querySelector('.sidebar-link[data-page="' + path + '"]')) return path;
844
+ return 'home';
845
+ }
846
+
847
+ let currentPage = getPageFromUrl();
848
+
849
+ function switchPage(page, pushState) {
850
+ currentPage = page;
851
+ document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
852
+ const target = document.getElementById('page-' + page);
853
+ if (target) target.classList.add('active');
854
+ document.querySelectorAll('.sidebar-link').forEach(l => l.classList.remove('active'));
855
+ const link = document.querySelector('.sidebar-link[data-page="' + page + '"]');
856
+ if (link) link.classList.add('active');
857
+ if (pushState !== false) {
858
+ const url = page === 'home' ? '/' : '/' + page;
859
+ history.pushState({ page }, '', url);
860
+ }
861
+ }
862
+
863
+ // Browser back/forward navigation
864
+ window.addEventListener('popstate', (e) => {
865
+ switchPage(e.state?.page || getPageFromUrl(), false);
866
+ });
806
867
  window._prdRequeueUi = window._prdRequeueUi || {};
807
868
 
808
869
  function getPrdRequeueState(workItemId) {
@@ -2388,6 +2449,11 @@ async function refresh() {
2388
2449
  renderSkills(data.skills || []);
2389
2450
  renderMcpServers(data.mcpServers || []);
2390
2451
  renderSchedules(data.schedules || []);
2452
+ // Update sidebar counts
2453
+ const swi = document.getElementById('sidebar-wi');
2454
+ if (swi) swi.textContent = (data.workItems || []).length || '';
2455
+ const spr = document.getElementById('sidebar-pr');
2456
+ if (spr) spr.textContent = (data.pullRequests || []).length || '';
2391
2457
  // Refresh KB and plans less frequently (every 3rd cycle = ~12s)
2392
2458
  if (!window._kbRefreshCount) window._kbRefreshCount = 0;
2393
2459
  if (window._kbRefreshCount++ % 3 === 0) { refreshKnowledgeBase(); refreshPlans(); }
@@ -2397,6 +2463,12 @@ async function refresh() {
2397
2463
  refresh();
2398
2464
  setInterval(refresh, 4000);
2399
2465
 
2466
+ // Wire sidebar navigation
2467
+ document.querySelectorAll('.sidebar-link').forEach(link => {
2468
+ link.addEventListener('click', e => { e.preventDefault(); switchPage(link.dataset.page); });
2469
+ });
2470
+ switchPage(currentPage);
2471
+
2400
2472
  // -- Projects --
2401
2473
  function renderProjects(projects) {
2402
2474
  const header = document.getElementById('header-projects');
package/dashboard.js CHANGED
@@ -2767,6 +2767,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2767
2767
  { method: 'POST', path: '/api/schedules/delete', desc: 'Delete a schedule', params: 'id', handler: handleSchedulesDelete },
2768
2768
 
2769
2769
  // Engine
2770
+ { method: 'POST', path: '/api/engine/wakeup', desc: 'Trigger immediate engine tick via control.json signal', handler: async (req, res) => {
2771
+ const controlPath = path.join(MINIONS_DIR, 'engine', 'control.json');
2772
+ const control = shared.safeJson(controlPath) || {};
2773
+ control._wakeupAt = Date.now();
2774
+ shared.safeWrite(controlPath, control);
2775
+ return jsonReply(res, 200, { ok: true, message: 'Wakeup signal sent' });
2776
+ }},
2770
2777
  { method: 'POST', path: '/api/engine/restart', desc: 'Force-kill engine and restart immediately', handler: handleEngineRestart },
2771
2778
 
2772
2779
  // Settings
package/engine/ado.js CHANGED
@@ -149,6 +149,20 @@ async function pollPrStatus(config) {
149
149
  e.log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
150
150
  pr.reviewStatus = newReviewStatus;
151
151
  updated = true;
152
+ // Update author metrics when verdict changes to approved/rejected
153
+ if (newReviewStatus === 'approved' || newReviewStatus === 'changes-requested') {
154
+ const authorId = (pr.agent || '').toLowerCase();
155
+ if (authorId) {
156
+ try {
157
+ const metricsPath = path.join(__dirname, 'metrics.json');
158
+ const metrics = shared.safeJson(metricsPath) || {};
159
+ if (!metrics[authorId]) metrics[authorId] = {};
160
+ if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
161
+ else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
162
+ shared.safeWrite(metricsPath, metrics);
163
+ } catch {}
164
+ }
165
+ }
152
166
  }
153
167
 
154
168
  if (newStatus !== 'active') return updated;
package/engine/cli.js CHANGED
@@ -306,6 +306,17 @@ const commands = {
306
306
 
307
307
  // Start tick loop
308
308
  const tickTimer = setInterval(() => e.tick(), interval);
309
+
310
+ // Fast poll for immediate wakeup signals (checks control.json every 2s)
311
+ setInterval(() => {
312
+ const ctrl = getControl();
313
+ if (ctrl._wakeupAt && Date.now() - ctrl._wakeupAt < 5000) {
314
+ delete ctrl._wakeupAt;
315
+ safeWrite(CONTROL_PATH, ctrl);
316
+ e.tick();
317
+ }
318
+ }, 2000);
319
+
309
320
  console.log(`Tick interval: ${interval / 1000}s | Max concurrent: ${config.engine?.maxConcurrent || 5}`);
310
321
  console.log('Press Ctrl+C to stop');
311
322
 
package/engine/github.js CHANGED
@@ -127,6 +127,20 @@ async function pollPrStatus(config) {
127
127
  e.log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
128
128
  pr.reviewStatus = newReviewStatus;
129
129
  updated = true;
130
+ // Update author metrics when verdict changes to approved/rejected
131
+ if (newReviewStatus === 'approved' || newReviewStatus === 'changes-requested') {
132
+ const authorId = (pr.agent || '').toLowerCase();
133
+ if (authorId) {
134
+ try {
135
+ const metricsPath = path.join(__dirname, 'metrics.json');
136
+ const metrics = shared.safeJson(metricsPath) || {};
137
+ if (!metrics[authorId]) metrics[authorId] = {};
138
+ if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
139
+ else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
140
+ shared.safeWrite(metricsPath, metrics);
141
+ } catch {}
142
+ }
143
+ }
130
144
  }
131
145
  }
132
146
 
@@ -617,28 +617,25 @@ function updatePrAfterReview(agentId, pr, project) {
617
617
  const dispatch = getDispatch();
618
618
  const completedEntry = (dispatch.completed || []).find(d => d.agent === agentId && d.type === 'review');
619
619
 
620
+ // Set reviewStatus to 'waiting' (single source of truth — synced from ADO/GitHub votes on next poll)
621
+ target.reviewStatus = 'waiting';
620
622
  target.minionsReview = {
621
- status: 'waiting',
622
623
  reviewer: reviewerName,
623
624
  reviewedAt: e.ts(),
624
625
  note: completedEntry?.task || ''
625
626
  };
626
- const minionsVerdict = target.minionsReview.status;
627
+ // Metrics update: don't track 'waiting' as a verdict — metrics are updated
628
+ // when pollPrStatus syncs the actual vote to minionsReview.status.
629
+ // The reviewer's reviewsDone counter is incremented in the main updateMetrics call.
627
630
 
631
+ // Track reviewer for metrics purposes
628
632
  const authorAgentId = (pr.agent || '').toLowerCase();
629
633
  if (authorAgentId && config.agents?.[authorAgentId]) {
630
634
  const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
631
635
  const metrics = safeJson(metricsPath) || {};
632
636
  if (!metrics[authorAgentId]) metrics[authorAgentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
633
- if (!metrics[authorAgentId]._reviewedPrs) metrics[authorAgentId]._reviewedPrs = {};
634
- const prevVerdict = metrics[authorAgentId]._reviewedPrs[pr.id];
635
- if (prevVerdict !== minionsVerdict) {
636
- if (prevVerdict === 'approved') metrics[authorAgentId].prsApproved = Math.max(0, (metrics[authorAgentId].prsApproved || 0) - 1);
637
- else if (prevVerdict === 'changes-requested') metrics[authorAgentId].prsRejected = Math.max(0, (metrics[authorAgentId].prsRejected || 0) - 1);
638
- if (minionsVerdict === 'approved') metrics[authorAgentId].prsApproved++;
639
- else if (minionsVerdict === 'changes-requested') metrics[authorAgentId].prsRejected++;
640
- metrics[authorAgentId]._reviewedPrs[pr.id] = minionsVerdict;
641
- }
637
+ if (!metrics[agentId]) metrics[agentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
638
+ metrics[agentId].reviewsDone = (metrics[agentId].reviewsDone || 0) + 1;
642
639
  shared.safeWrite(metricsPath, metrics);
643
640
  }
644
641
 
@@ -654,25 +651,15 @@ function updatePrAfterFix(pr, project, source) {
654
651
  const target = prs.find(p => p.id === pr.id);
655
652
  if (!target) return;
656
653
 
654
+ // Reset reviewStatus to 'waiting' for re-review (single source of truth)
655
+ target.reviewStatus = 'waiting';
657
656
  if (source === 'pr-human-feedback') {
658
- // Human feedback fix: clear pendingFix AND reset to waiting for re-review
659
657
  if (target.humanFeedback) target.humanFeedback.pendingFix = false;
660
- target.minionsReview = {
661
- ...target.minionsReview,
662
- status: 'waiting',
663
- note: 'Fixed human feedback, awaiting re-review',
664
- fixedAt: e.ts()
665
- };
658
+ target.minionsReview = { ...target.minionsReview, note: 'Fixed human feedback, awaiting re-review', fixedAt: e.ts() };
666
659
  e.log('info', `Updated ${pr.id} → cleared humanFeedback.pendingFix, reset to waiting for re-review`);
667
660
  } else {
668
- // Review fix: reset to waiting for re-review
669
- target.minionsReview = {
670
- ...target.minionsReview,
671
- status: 'waiting',
672
- note: 'Fixed, awaiting re-review',
673
- fixedAt: e.ts()
674
- };
675
- e.log('info', `Updated ${pr.id} → minions review: waiting (fix pushed)`);
661
+ target.minionsReview = { ...target.minionsReview, note: 'Fixed, awaiting re-review', fixedAt: e.ts() };
662
+ e.log('info', `Updated ${pr.id} → reviewStatus: waiting (fix pushed)`);
676
663
  }
677
664
 
678
665
  shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
@@ -932,8 +919,8 @@ function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount
932
919
  // ─── Agent Output Parsing ────────────────────────────────────────────────────
933
920
 
934
921
  function parseAgentOutput(stdout) {
935
- const parsed = shared.parseStreamJsonOutput(stdout, { maxTextLength: 500 });
936
- return { resultSummary: parsed.text, taskUsage: parsed.usage };
922
+ const { text, usage, sessionId } = shared.parseStreamJsonOutput(stdout, { maxTextLength: 2000 });
923
+ return { resultSummary: text, taskUsage: usage, sessionId };
937
924
  }
938
925
 
939
926
  /**
@@ -1018,7 +1005,16 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1018
1005
  const meta = dispatchItem.meta;
1019
1006
  const isSuccess = code === 0;
1020
1007
  const result = isSuccess ? 'success' : 'error';
1021
- const { resultSummary, taskUsage } = parseAgentOutput(stdout);
1008
+ const { resultSummary, taskUsage, sessionId } = parseAgentOutput(stdout);
1009
+
1010
+ // Save session for potential resume on next dispatch
1011
+ if (isSuccess && sessionId && agentId && !agentId.startsWith('temp-')) {
1012
+ try {
1013
+ shared.safeWrite(path.join(AGENTS_DIR, agentId, 'session.json'), {
1014
+ sessionId, dispatchId: dispatchItem.id, savedAt: new Date().toISOString()
1015
+ });
1016
+ } catch {}
1017
+ }
1022
1018
 
1023
1019
  // Handle decomposition results — create sub-items from decompose agent output
1024
1020
  let skipDoneStatus = false;
package/engine.js CHANGED
@@ -166,6 +166,25 @@ function getRoutingTableCached() {
166
166
  return _routingCache;
167
167
  }
168
168
 
169
+ function getMonthlySpend(agentId) {
170
+ const metrics = safeJson(path.join(ENGINE_DIR, 'metrics.json')) || {};
171
+ const daily = metrics._daily || {};
172
+ const now = new Date();
173
+ const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
174
+ let total = 0;
175
+ for (const [date, data] of Object.entries(daily)) {
176
+ if (date.startsWith(monthPrefix)) {
177
+ total += (data.perAgent?.[agentId]?.costUsd || 0);
178
+ }
179
+ }
180
+ // Fallback: if no per-agent daily data, use cumulative (less accurate for monthly)
181
+ if (total === 0 && metrics[agentId]?.totalCostUsd) {
182
+ // Can't distinguish monthly from cumulative — treat as monthly estimate
183
+ // This path is for backward compat before per-agent daily tracking was added
184
+ }
185
+ return total;
186
+ }
187
+
169
188
  function getAgentErrorRate(agentId) {
170
189
  const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
171
190
  const metrics = safeJson(metricsPath) || {};
@@ -194,7 +213,15 @@ function resolveAgent(workType, config, authorAgent = null) {
194
213
  let preferred = route.preferred === '_author_' ? authorAgent : route.preferred;
195
214
  let fallback = route.fallback === '_author_' ? authorAgent : route.fallback;
196
215
 
197
- const isAvailable = (id) => agents[id] && isAgentIdle(id) && !_claimedAgents.has(id);
216
+ const isAvailable = (id) => {
217
+ if (!agents[id] || !isAgentIdle(id) || _claimedAgents.has(id)) return false;
218
+ // Budget check — no budget means infinite (no limit)
219
+ const budget = agents[id].monthlyBudgetUsd;
220
+ if (budget && budget > 0) {
221
+ if (getMonthlySpend(id) >= budget) return false;
222
+ }
223
+ return true;
224
+ };
198
225
 
199
226
  // Check preferred and fallback first (routing table order)
200
227
  if (preferred && isAvailable(preferred)) { _claimedAgents.add(preferred); return preferred; }
@@ -905,6 +932,20 @@ function spawnAgent(dispatchItem, config) {
905
932
  args.push('--allowedTools', claudeConfig.allowedTools);
906
933
  }
907
934
 
935
+ // Session resume: reuse last session if recent enough (< 2 hours)
936
+ if (!agentId.startsWith('temp-')) {
937
+ try {
938
+ const sessionFile = safeJson(path.join(AGENTS_DIR, agentId, 'session.json'));
939
+ if (sessionFile?.sessionId && sessionFile.savedAt) {
940
+ const sessionAge = Date.now() - new Date(sessionFile.savedAt).getTime();
941
+ if (sessionAge < 2 * 60 * 60 * 1000) { // 2 hour TTL
942
+ args.push('--resume', sessionFile.sessionId);
943
+ log('info', `Resuming session ${sessionFile.sessionId} for ${agentId} (age: ${Math.round(sessionAge / 60000)}min)`);
944
+ }
945
+ }
946
+ } catch {}
947
+ }
948
+
908
949
  // MCP servers: agents inherit from ~/.claude.json directly as Claude Code processes.
909
950
  // No --mcp-config needed — avoids redundant config and ensures agents always have latest servers.
910
951
 
@@ -1968,6 +2009,27 @@ function setCooldown(key) {
1968
2009
  saveCooldowns();
1969
2010
  }
1970
2011
 
2012
+ function setCooldownWithContext(key, context) {
2013
+ const existing = dispatchCooldowns.get(key);
2014
+ const pendingContexts = existing?.pendingContexts || [];
2015
+ if (context) pendingContexts.push(context);
2016
+ dispatchCooldowns.set(key, {
2017
+ timestamp: Date.now(),
2018
+ failures: existing?.failures || 0,
2019
+ pendingContexts
2020
+ });
2021
+ saveCooldowns();
2022
+ }
2023
+
2024
+ function getCoalescedContexts(key) {
2025
+ const entry = dispatchCooldowns.get(key);
2026
+ const contexts = entry?.pendingContexts || [];
2027
+ if (contexts.length > 0 && entry) {
2028
+ entry.pendingContexts = []; // Clear after retrieval
2029
+ }
2030
+ return contexts;
2031
+ }
2032
+
1971
2033
  function setCooldownFailure(key) {
1972
2034
  const existing = dispatchCooldowns.get(key);
1973
2035
  const failures = (existing?.failures || 0) + 1;
@@ -2390,19 +2452,16 @@ function discoverFromPrs(config, project) {
2390
2452
  if (activePrIds.has(pr.id)) continue; // Skip PRs with active dispatch (prevent race)
2391
2453
 
2392
2454
  const prNumber = (pr.id || '').replace(/^PR-/, '');
2393
- const minionsStatus = pr.minionsReview?.status;
2455
+ // Use reviewStatus as single source of truth (synced from ADO/GitHub votes)
2456
+ // minionsReview tracks metadata (reviewer, note) but not the authoritative status
2457
+ const reviewStatus = pr.reviewStatus || 'pending';
2394
2458
 
2395
- // PRs needing review
2396
- const needsReview = !minionsStatus || minionsStatus === 'waiting';
2459
+ // PRs needing review: pending or waiting (review dispatched but no verdict yet)
2460
+ const needsReview = reviewStatus === 'pending' || reviewStatus === 'waiting';
2397
2461
  if (needsReview) {
2398
2462
  const key = `review-${project?.name || 'default'}-${pr.id}`;
2399
2463
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2400
- // No self-review: exclude the PR author from review assignment
2401
- const prAuthor = (pr.agent || '').toLowerCase();
2402
- let agentId = resolveAgent('review', config);
2403
- if (agentId && agentId === prAuthor) {
2404
- agentId = resolveAgent('review', config); // retry — prAuthor now claimed, gets skipped
2405
- }
2464
+ const agentId = resolveAgent('review', config);
2406
2465
  if (!agentId) continue;
2407
2466
 
2408
2467
  const item = buildPrDispatch(agentId, config, project, pr, 'review', {
@@ -2413,7 +2472,7 @@ function discoverFromPrs(config, project) {
2413
2472
  }
2414
2473
 
2415
2474
  // PRs with changes requested → route back to author for fix
2416
- if (minionsStatus === 'changes-requested') {
2475
+ if (reviewStatus === 'changes-requested') {
2417
2476
  const key = `fix-${project?.name || 'default'}-${pr.id}`;
2418
2477
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2419
2478
  const agentId = resolveAgent('fix', config, pr.agent);
@@ -2426,22 +2485,37 @@ function discoverFromPrs(config, project) {
2426
2485
  if (item) { newWork.push(item); setCooldown(key); }
2427
2486
  }
2428
2487
 
2429
- // PRs with pending human feedback
2430
- if (pr.humanFeedback?.pendingFix) {
2431
- const key = `human-fix-${project?.name || 'default'}-${pr.id}`;
2432
- if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2488
+ // PRs with pending human feedback (or coalesced comments from while agent was fixing)
2489
+ const humanFixKey = `human-fix-${project?.name || 'default'}-${pr.id}`;
2490
+ const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
2491
+ if (pr.humanFeedback?.pendingFix || hasCoalescedFeedback) {
2492
+ const key = humanFixKey;
2493
+ if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) {
2494
+ // Coalesce: save feedback for next dispatch
2495
+ if (pr.humanFeedback?.feedbackContent) {
2496
+ setCooldownWithContext(key, { feedbackContent: pr.humanFeedback.feedbackContent, timestamp: new Date().toISOString() });
2497
+ }
2498
+ continue;
2499
+ }
2433
2500
  const agentId = resolveAgent('fix', config, pr.agent);
2434
2501
  if (!agentId) continue;
2435
2502
 
2503
+ const coalesced = getCoalescedContexts(key);
2504
+ let reviewNote = pr.humanFeedback.feedbackContent || 'See PR thread comments';
2505
+ if (coalesced.length > 0) {
2506
+ const earlier = coalesced.map(c => c.feedbackContent).filter(Boolean).join('\n\n---\n\n');
2507
+ if (earlier) reviewNote = earlier + '\n\n---\n\n' + reviewNote;
2508
+ }
2509
+
2436
2510
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2437
2511
  pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
2438
2512
  reviewer: 'Human Reviewer',
2439
- review_note: pr.humanFeedback.feedbackContent || 'See PR thread comments',
2513
+ review_note: reviewNote,
2440
2514
  }, `Fix PR ${pr.id} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: pr.branch, project: projMeta });
2441
2515
  if (item) { newWork.push(item); setCooldown(key); }
2442
2516
  }
2443
2517
 
2444
- // PRs with build failures
2518
+ // PRs with build failures — any agent can pick this up
2445
2519
  if (pr.status === 'active' && pr.buildStatus === 'failing') {
2446
2520
  const key = `build-fix-${project?.name || 'default'}-${pr.id}`;
2447
2521
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
@@ -2565,7 +2639,17 @@ function discoverFromWorkItems(config, project) {
2565
2639
  }
2566
2640
  const agentId = item.agent || resolveAgent(workType, config);
2567
2641
  if (!agentId) {
2568
- if (item._pendingReason !== 'no_agent') { item._pendingReason = 'no_agent'; needsWrite = true; }
2642
+ // Check if reason is budget
2643
+ const cfgAgents = config.agents || {};
2644
+ const budgetBlocked = Object.keys(cfgAgents).some(id => {
2645
+ const b = cfgAgents[id].monthlyBudgetUsd;
2646
+ return b && b > 0 && getMonthlySpend(id) >= b && isAgentIdle(id);
2647
+ });
2648
+ if (budgetBlocked) {
2649
+ if (item._pendingReason !== 'budget_exceeded') { item._pendingReason = 'budget_exceeded'; needsWrite = true; }
2650
+ } else {
2651
+ if (item._pendingReason !== 'no_agent') { item._pendingReason = 'no_agent'; needsWrite = true; }
2652
+ }
2569
2653
  skipped.noAgent++; continue;
2570
2654
  }
2571
2655
 
@@ -3396,7 +3480,10 @@ module.exports = {
3396
3480
  updateWorkItemStatus, runCleanup, handlePostMerge,
3397
3481
 
3398
3482
  // Cooldowns
3399
- loadCooldowns,
3483
+ loadCooldowns, setCooldownWithContext, getCoalescedContexts,
3484
+
3485
+ // Budget
3486
+ getMonthlySpend,
3400
3487
 
3401
3488
  // Tick
3402
3489
  tick,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
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"
package/routing.md CHANGED
@@ -27,7 +27,7 @@ Notes:
27
27
  ## Rules
28
28
 
29
29
  1. **Eager by default** — spawn all agents who can start work, not one at a time
30
- 2. **No self-review** — author cannot review their own PR
30
+ 2. **Self-review is allowed** — agents can review their own PRs (useful for single-agent setups)
31
31
  3. **Exploration gates implementation** — when exploring, finish before implementing
32
32
  4. **Implementation informs PRD** — Lambert reads build summaries before writing PRD
33
33
  5. **All rules in `notes.md` apply** — engine injects them into every playbook