@yemi33/minions 0.1.2041 → 0.1.2042

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.
@@ -14,7 +14,15 @@ const _pageCounters = {
14
14
  // triggers or the count changes; triggerCount removed because it advances
15
15
  // on the same event as last_triggered (F9/S4).
16
16
  watches: function(d) { return (d.watches || []).length + '|' + (d.watches || []).reduce(function(m, w) { return Math.max(m, new Date(w.last_triggered || 0).getTime() || 0); }, 0); },
17
- meetings: function(d) { return (d.meetings || []).length + '|' + (d.meetings || []).reduce(function(s, m) { return s + (m.round || 0); }, 0); },
17
+ // meetings signature: full count + sum of all rounds. Uses meetingsTotal
18
+ // (top-level full count of meetings on disk) NOT meetings.length — the
19
+ // latter is the slim slice which drops terminal meetings >7d via
20
+ // statusMeetingsRetentionDays, so an archived meeting reaching round 3
21
+ // would silently fail to flip the dot. Round-sum stays on the slim slice
22
+ // (we don't track per-meeting round in meetingsTotal) — operators who
23
+ // care about archived-meeting round transitions can crank the retention
24
+ // window or set it to 0. (W-mphlrxx6000a8760)
25
+ meetings: function(d) { return (d.meetingsTotal ?? (d.meetings || []).length) + '|' + (d.meetings || []).reduce(function(s, m) { return s + (m.round || 0); }, 0); },
18
26
  pipelines: function(d) { return (d.pipelines || []).length + '|' + (d.pipelines || []).reduce(function(s, p) { return s + (p.runs || []).length; }, 0); },
19
27
  schedule: function(d) { return (d.schedules || []).length; },
20
28
  // tools signature: skills count + mcp servers count.
@@ -89,6 +89,7 @@ async function openSettings() {
89
89
  settingsField('Meeting Round Timeout', 'set-meetingRoundTimeout', e.meetingRoundTimeout || 900000, 'ms', 'Auto-advance meeting round after this') +
90
90
  settingsField('Operator login (used in branch names)', 'set-operatorLogin', e.operatorLogin || '', '', 'Override the human operator login used in user/<loginname>/<wi-id>-<slug> branches. Empty = auto-resolve via gh / git email / OS username (currently resolves to: ' + (e._resolvedOperatorLogin || 'unknown') + ')') +
91
91
  settingsField('Status WorkItems Retention', 'set-statusWorkItemsRetentionDays', e.statusWorkItemsRetentionDays ?? 7, 'days', 'Trim done/failed/cancelled work items older than N days from the /api/status workItems slice (active items are always shipped). Cuts SPA payload from ~3MB to <500KB. Set to 0 to disable trimming (full list shipped, restoring legacy behavior).') +
92
+ settingsField('Status Meetings Retention', 'set-statusMeetingsRetentionDays', e.statusMeetingsRetentionDays ?? 7, 'days', 'Trim completed/archived meetings older than N days from the /api/status meetings slice (active meetings are always shipped). Cuts SPA payload from ~4.3MB to <500KB. Detail modal still fetches full transcripts via /api/meetings/:id. Set to 0 to disable trimming (full list shipped, restoring legacy behavior).') +
92
93
  '</div>' +
93
94
  '<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">Automation</h3>' +
94
95
  '<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:16px">' +
@@ -599,6 +600,7 @@ async function saveSettings() {
599
600
  meetingRoundTimeout: document.getElementById('set-meetingRoundTimeout').value,
600
601
  operatorLogin: (document.getElementById('set-operatorLogin')?.value ?? '').trim(),
601
602
  statusWorkItemsRetentionDays: document.getElementById('set-statusWorkItemsRetentionDays').value,
603
+ statusMeetingsRetentionDays: document.getElementById('set-statusMeetingsRetentionDays').value,
602
604
  autoApprovePlans: document.getElementById('set-autoApprovePlans').checked,
603
605
  evalLoop: document.getElementById('set-evalLoop').checked,
604
606
  autoDecompose: document.getElementById('set-autoDecompose').checked,
package/dashboard.js CHANGED
@@ -23,6 +23,7 @@ const shared = require('./engine/shared');
23
23
  const queries = require('./engine/queries');
24
24
  const ado = require('./engine/ado');
25
25
  const gh = require('./engine/github');
26
+ const ghToken = require('./engine/gh-token');
26
27
  const issues = require('./engine/issues');
27
28
  const watchesMod = require('./engine/watches');
28
29
  const meetingMod = require('./engine/meeting');
@@ -1710,7 +1711,14 @@ function _buildStatusFastState() {
1710
1711
  metrics: getMetrics(),
1711
1712
  workItems: _slimWorkItemsForStatus(getWorkItems()),
1712
1713
  watches: watchesMod.getWatches(),
1713
- meetings: _safeStatusSlice('meetings', () => meetingMod.getMeetings(), []),
1714
+ meetings: _safeStatusSlice('meetings', () => _slimMeetingsForStatus(meetingMod.getMeetings()), []),
1715
+ // Top-level full meeting count (NOT slim slice length). Surfaced so the
1716
+ // sidebar activity-dot counter (dashboard/js/refresh.js _pageCounters.meetings)
1717
+ // still fires when ANY meeting — including old/archived ones dropped from
1718
+ // the slim slice by statusMeetingsRetentionDays — gains a new round.
1719
+ // Without this, completing the third round of an archived meeting would
1720
+ // silently fail to light the sidebar dot. (W-mphlrxx6000a8760)
1721
+ meetingsTotal: _safeStatusSlice('meetingsTotal', () => _countMeetingsForStatus(), 0),
1714
1722
  // QA runs — surfaced for the sidebar activity-dot counter and any future
1715
1723
  // CC/aggregate view. Tab-level rendering keeps its own /api/qa/runs poll
1716
1724
  // (5 s while the QA page is mounted). qa-runs.json is in the mtime tracker
@@ -1844,6 +1852,101 @@ function _slimWorkItemsForStatus(items) {
1844
1852
  return surviving;
1845
1853
  }
1846
1854
 
1855
+ // ── /api/status meetings slimming (W-mphlrxx6000a8760) ──────────────────────
1856
+ // Mirrors the workItems trim above (PR #2816). Meetings are the second
1857
+ // largest /api/status slice after workItems — live measurement: 22 meetings
1858
+ // / 4.3MB (60% of the 7.2MB payload). The list renderer in
1859
+ // dashboard/js/render-meetings.js:renderMeetings only needs:
1860
+ // - id, title, status, round, participants, agenda(short), createdAt,
1861
+ // completedAt
1862
+ // - per-participant booleans of findings/debate (used to pick the
1863
+ // ✓/⏳/○ icon — `m.findings?.[p]` truthy check, line 48-50)
1864
+ // The detail modal calls `/api/meetings/:id` which serves the full record
1865
+ // (findings.content + debate.content + conclusion + transcript bodies), so
1866
+ // dropping those from the slice is safe.
1867
+ //
1868
+ // Active meetings (investigating/debating/concluding) are ALWAYS kept
1869
+ // regardless of age. Terminal meetings (completed/archived) only survive
1870
+ // if their completedAt/roundStartedAt/createdAt is within the window.
1871
+ // Set engine.statusMeetingsRetentionDays = 0 to disable trimming entirely
1872
+ // (returns the full list — but still slim-shaped — restoring legacy size).
1873
+ const _ACTIVE_MEETING_STATUSES_FOR_STATUS = new Set(['investigating', 'debating', 'concluding']);
1874
+ const _TERMINAL_MEETING_STATUSES_FOR_STATUS = new Set(['completed', 'archived']);
1875
+ function _resolveStatusMeetingsRetentionDays() {
1876
+ const raw = CONFIG?.engine?.statusMeetingsRetentionDays;
1877
+ if (raw === 0 || raw === '0') return 0;
1878
+ const n = Number(raw);
1879
+ if (Number.isFinite(n) && n >= 0) return n;
1880
+ return shared.ENGINE_DEFAULTS.statusMeetingsRetentionDays;
1881
+ }
1882
+ function _slimMeetingForStatus(meeting) {
1883
+ // Reduce findings/debate objects to {agentId: true} sentinels — the list
1884
+ // renderer only checks `m.findings?.[p]` for truthiness when picking the
1885
+ // participant-badge icon. Keeping just the keys preserves that contract
1886
+ // while dropping the per-round agent transcript bodies (~95KB+ each).
1887
+ const findingsKeys = meeting.findings && typeof meeting.findings === 'object'
1888
+ ? Object.keys(meeting.findings) : [];
1889
+ const debateKeys = meeting.debate && typeof meeting.debate === 'object'
1890
+ ? Object.keys(meeting.debate) : [];
1891
+ const findings = {};
1892
+ for (const k of findingsKeys) findings[k] = true;
1893
+ const debate = {};
1894
+ for (const k of debateKeys) debate[k] = true;
1895
+ const slim = {
1896
+ id: meeting.id,
1897
+ title: meeting.title,
1898
+ status: meeting.status,
1899
+ round: meeting.round,
1900
+ participants: Array.isArray(meeting.participants) ? meeting.participants : [],
1901
+ agenda: meeting.agenda,
1902
+ createdAt: meeting.createdAt,
1903
+ findings,
1904
+ debate,
1905
+ };
1906
+ if (meeting.completedAt !== undefined) slim.completedAt = meeting.completedAt;
1907
+ if (meeting.roundStartedAt !== undefined) slim.roundStartedAt = meeting.roundStartedAt;
1908
+ if (meeting.createdBy !== undefined) slim.createdBy = meeting.createdBy;
1909
+ return slim;
1910
+ }
1911
+ function _slimMeetingsForStatus(meetings) {
1912
+ if (!Array.isArray(meetings)) return meetings;
1913
+ const retentionDays = _resolveStatusMeetingsRetentionDays();
1914
+ if (retentionDays <= 0) {
1915
+ // Trimming disabled — keep full list but still flatten via slim shape
1916
+ // so wire format is consistent and the heavy bodies never ship via
1917
+ // /api/status regardless of operator config.
1918
+ return meetings.map(_slimMeetingForStatus);
1919
+ }
1920
+ const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
1921
+ const surviving = [];
1922
+ for (const meeting of meetings) {
1923
+ if (!meeting) continue;
1924
+ const status = meeting.status || 'investigating';
1925
+ if (_TERMINAL_MEETING_STATUSES_FOR_STATUS.has(status)) {
1926
+ const ts = meeting.completedAt || meeting.roundStartedAt || meeting.createdAt || '';
1927
+ const tsMs = ts ? Date.parse(ts) : NaN;
1928
+ // Drop only when we have a parseable timestamp and it's beyond the
1929
+ // window. Meetings with missing/unparseable timestamps stay visible —
1930
+ // we'd rather over-include than silently hide them.
1931
+ if (Number.isFinite(tsMs) && tsMs < cutoffMs) {
1932
+ continue;
1933
+ }
1934
+ } else if (!_ACTIVE_MEETING_STATUSES_FOR_STATUS.has(status)) {
1935
+ // Unknown status — keep, so a future round name isn't silently
1936
+ // hidden until the constant set is updated.
1937
+ }
1938
+ surviving.push(_slimMeetingForStatus(meeting));
1939
+ }
1940
+ return surviving;
1941
+ }
1942
+ // Count meetings on disk without rehydrating bodies — backs the sidebar
1943
+ // activity dot signature so new rounds in trimmed/archived meetings still
1944
+ // flip the counter (refresh.js _pageCounters.meetings reads meetingsTotal).
1945
+ function _countMeetingsForStatus() {
1946
+ const meetings = meetingMod.getMeetings();
1947
+ return Array.isArray(meetings) ? meetings.length : 0;
1948
+ }
1949
+
1847
1950
  // Build the slow-state slice (rarely-changing data: ~60s TTL).
1848
1951
  function _buildStatusSlowState() {
1849
1952
  const prdInfo = getPrdInfo();
@@ -8538,6 +8641,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8538
8641
  _setEngineConfig('statusWorkItemsRetentionDays', val);
8539
8642
  }
8540
8643
  }
8644
+ // W-mphlrxx6000a8760: /api/status meetings retention window. Same
8645
+ // shape as statusWorkItemsRetentionDays — 0 must persist literally
8646
+ // (disables trim), so handled outside the numericFields loop.
8647
+ if (e.statusMeetingsRetentionDays !== undefined) {
8648
+ const raw = e.statusMeetingsRetentionDays;
8649
+ if (raw === '' || raw === null) {
8650
+ _deleteEngineConfig('statusMeetingsRetentionDays');
8651
+ } else {
8652
+ let val = Number(raw);
8653
+ if (!Number.isFinite(val) || val < 0) val = D.statusMeetingsRetentionDays;
8654
+ if (val > 365) { _clamped.push(`statusMeetingsRetentionDays: ${val} → 365 (range: 0–365)`); val = 365; }
8655
+ _setEngineConfig('statusMeetingsRetentionDays', val);
8656
+ }
8657
+ }
8541
8658
  // W-mpejf0fq000e84d6: operator login override. Empty string clears
8542
8659
  // the override (engine falls back to gh/git/os resolution); any other
8543
8660
  // value pins the login used in `user/<login>/<wi-id>-<slug>` branches.
@@ -9915,7 +10032,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9915
10032
  // a regex match against untrusted PR-link input (the body of POST
9916
10033
  // /api/pull-requests/link); validate before exec. `prNum` is already
9917
10034
  // a number; coerce to string for argv.
9918
- const result = await shared.shellSafeGh(['api', `repos/${shared.validateGhSlug(slug)}/pulls/${String(prNum)}`], { timeout: 15000 });
10035
+ //
10036
+ // W-mphm0kt0000cebc3 (Bug A): route the GH PAT per slug. Without this,
10037
+ // cross-account scopes (e.g. opg-microsoft/minions when active gh is
10038
+ // yemi33) 404 silently and enrichment never lands — title stays
10039
+ // "PR #N (polling...)" indefinitely. Mirrors engine/github.js:316.
10040
+ const token = ghToken.resolveTokenForSlug(slug);
10041
+ const ghOpts = { timeout: 15000 };
10042
+ if (token) ghOpts.env = { ...process.env, GH_TOKEN: token };
10043
+ const result = await shared.shellSafeGh(['api', `repos/${shared.validateGhSlug(slug)}/pulls/${String(prNum)}`], ghOpts);
9919
10044
  const d = JSON.parse(result);
9920
10045
  prData = { title: d.title, description: d.body, branch: d.head?.ref, author: d.user?.login };
9921
10046
  } else if (adoTarget && !initialPrData) {
@@ -111,14 +111,28 @@ function getBranchDispatchLockKey(entry) {
111
111
  }
112
112
 
113
113
  function findActivePrOrBranchLock(dispatch, item) {
114
- if (item?.type !== WORK_TYPE.FIX) return null;
115
114
  const active = dispatch.active || [];
116
- const prTargetKey = getPrDispatchTargetKey(item);
117
- if (prTargetKey) {
118
- const existing = active.find(d => getPrDispatchTargetKey(d) === prTargetKey);
119
- if (existing) return { existing, reason: `active PR dispatch ${prTargetKey}` };
115
+
116
+ // PR-target dedup is FIX-only: a FIX shouldn't stack on top of another FIX
117
+ // for the same PR, but a REVIEW + FIX pair targeting the same PR is the
118
+ // normal review-then-fix flow and must not be dedup'd here.
119
+ if (item?.type === WORK_TYPE.FIX) {
120
+ const prTargetKey = getPrDispatchTargetKey(item);
121
+ if (prTargetKey) {
122
+ const existing = active.find(d => getPrDispatchTargetKey(d) === prTargetKey);
123
+ if (existing) return { existing, reason: `active PR dispatch ${prTargetKey}` };
124
+ }
120
125
  }
121
126
 
127
+ // Branch-lock applies to EVERY type, not just FIX (W-mphll3py0006234e —
128
+ // issue #2817). Any dispatch that carries meta.branch is, by definition,
129
+ // claiming ownership of that branch for the duration of its run — if two
130
+ // such dispatches overlap on the same branch they race the eventual
131
+ // `git push` and the spawn-time stale-HEAD guard fails whichever push
132
+ // lost the race. Mirrors the dispatch-loop's `lockedBranches` mutex
133
+ // (engine.js ~6577-6731) at queue-time so maintenance-class dispatches
134
+ // (setup / test / docs / implement / verify / decompose / review …) get
135
+ // the same coordination FIX already had.
122
136
  const branchLockKey = getBranchDispatchLockKey(item);
123
137
  if (!branchLockKey) return null;
124
138
  const existing = active.find(d => getBranchDispatchLockKey(d) === branchLockKey);
package/engine/github.js CHANGED
@@ -669,6 +669,35 @@ async function pollPrStatus(config) {
669
669
  }
670
670
  }
671
671
 
672
+ // W-mphm0kt0000cebc3 (Bug B): backfill title/description/agent for
673
+ // project-local PRs that are still on the link-time placeholder. The
674
+ // central poller (above, ~lines 561-583) does this already; without
675
+ // parity here a manually-linked project-local PR whose initial
676
+ // enrichment IIFE failed (cross-account auth, transient blip) would
677
+ // stay stuck on "PR #N (polling...)" forever. `prData` is already in
678
+ // hand from line 622 — no extra API call needed.
679
+ const currentTitleForBackfill = pr.title || '';
680
+ if (!currentTitleForBackfill
681
+ || currentTitleForBackfill.includes('polling...')
682
+ || /[{}"\[\]]/.test(currentTitleForBackfill)
683
+ || /^[0-9a-f-]{8,}$/i.test(currentTitleForBackfill)) {
684
+ if (prData.title) {
685
+ const nextTitle = String(prData.title).slice(0, 120);
686
+ if (pr.title !== nextTitle) {
687
+ pr.title = nextTitle;
688
+ updated = true;
689
+ }
690
+ }
691
+ }
692
+ if (pr.description === undefined) {
693
+ pr.description = (prData.body || '').slice(0, 500);
694
+ updated = true;
695
+ }
696
+ if (pr.agent === 'human' && prData.user?.login) {
697
+ pr.agent = prData.user.login;
698
+ updated = true;
699
+ }
700
+
672
701
  // Map GitHub PR state to minions status
673
702
  let newStatus = pr.status;
674
703
  if (prData.merged) newStatus = PR_STATUS.MERGED;
package/engine/shared.js CHANGED
@@ -2041,6 +2041,19 @@ const ENGINE_DEFAULTS = {
2041
2041
  // via GET /api/work-items/<id> when description/references/AC are needed.
2042
2042
  // 0 disables the trim (full list shipped, restoring legacy behavior).
2043
2043
  statusWorkItemsRetentionDays: 7,
2044
+
2045
+ // ── /api/status meetings retention (W-mphlrxx6000a8760) ─────────────────────
2046
+ // Same shape as statusWorkItemsRetentionDays — mirrors the trim+slim pass
2047
+ // for the meetings slice (live: 22 meetings / 4.3MB → ~5 meetings / <500KB
2048
+ // typical). Active meetings (investigating/debating/concluding) are ALWAYS
2049
+ // shipped regardless of age — only terminal meetings (completed/archived)
2050
+ // past the window are dropped. The detail modal fetches the full record
2051
+ // (findings, debate, conclusion, transcript bodies) on demand via
2052
+ // GET /api/meetings/<id> when opened. A top-level meetingsTotal field is
2053
+ // synthesized so the sidebar activity dot still fires when ANY meeting
2054
+ // (including those dropped from the slim slice) gains a new round.
2055
+ // 0 disables the trim (full list shipped, restoring legacy behavior).
2056
+ statusMeetingsRetentionDays: 7,
2044
2057
  };
2045
2058
 
2046
2059
  // ─── Runtime Fleet Resolution (P-3b8e5f1d) ──────────────────────────────────
package/engine.js CHANGED
@@ -4151,6 +4151,18 @@ function ensurePrBranchForDispatch(project, pr, automationType) {
4151
4151
  }
4152
4152
  return branch;
4153
4153
  }
4154
+ // W-mphm0kt0000cebc3 (Bug C): suppress the red "missing branch" badge during
4155
+ // the link grace window. User reports the badge "is so loud as a warning -
4156
+ // makes the user think something is wrong when it's just taking its time".
4157
+ // A just-linked PR has `created` set within the last few seconds and has
4158
+ // never been polled (no `headSha`); the enrichment IIFE + first poll cycle
4159
+ // need 10-30s to land the real `branch`. During that window, return ''
4160
+ // silently so dispatch is deferred without flipping any UI state. The next
4161
+ // tick retries; if branch still missing past the grace window, fall through
4162
+ // to the existing _branchResolutionError path (red badge as before).
4163
+ if (isWithinLinkGraceWindow(pr)) {
4164
+ return '';
4165
+ }
4154
4166
  const reason = `Cannot dispatch ${automationType} for ${shared.getPrDisplayId(pr)}: missing pr_branch/source branch metadata. Link or refresh the PR so the source branch is known.`;
4155
4167
  if (updatePrBranchResolutionState(project, pr, { reason })) {
4156
4168
  log('warn', `PR ${pr.id}: ${reason}`);
@@ -4158,6 +4170,21 @@ function ensurePrBranchForDispatch(project, pr, automationType) {
4158
4170
  return '';
4159
4171
  }
4160
4172
 
4173
+ // W-mphm0kt0000cebc3 (Bug C): a PR is "freshly linked" if it was created in
4174
+ // the last 120s AND has never been successfully polled (no `headSha`). The
4175
+ // enrichment IIFE in dashboard.js's POST /api/pull-requests/link runs async
4176
+ // after returning the response, and the first GH/ADO poll completes within
4177
+ // 10-30s. The 120s window comfortably covers both without masking PRs whose
4178
+ // branch is genuinely missing.
4179
+ const PR_LINK_GRACE_WINDOW_MS = 120 * 1000;
4180
+ function isWithinLinkGraceWindow(pr) {
4181
+ if (!pr || !pr.created) return false;
4182
+ if (pr.headSha) return false; // poll has run successfully — past the grace window
4183
+ const createdMs = Date.parse(pr.created);
4184
+ if (!Number.isFinite(createdMs)) return false;
4185
+ return (Date.now() - createdMs) < PR_LINK_GRACE_WINDOW_MS;
4186
+ }
4187
+
4161
4188
  function prCausePart(value, fallback = 'unknown') {
4162
4189
  const raw = String(value || '').trim();
4163
4190
  return shared.safeSlugComponent(raw || fallback, 80);
@@ -6983,6 +7010,7 @@ module.exports = {
6983
7010
  _maxTurnsForType, buildProjectContext, normalizeAc, _buildAgentSpawnFlags, _classifyAgentFailure, // exported for testing
6984
7011
  promoteCheckpointSteeringForClose, // exported for testing
6985
7012
  normalizePrBranch, resolvePrBranch, prCausePart, getPrCauseHead, getPrCauseBase, getPrAutomationCauseKey, getPrAutomationDispatchKey, // exported for testing
7013
+ ensurePrBranchForDispatch, isWithinLinkGraceWindow, PR_LINK_GRACE_WINDOW_MS, // exported for testing (W-mphm0kt0000cebc3)
6986
7014
 
6987
7015
  // Playbooks
6988
7016
  renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS, buildWorkItemDispatchVars,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2041",
3
+ "version": "0.1.2042",
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"