@yemi33/minions 0.1.2322 → 0.1.2323

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.
@@ -69,8 +69,24 @@ function _renderProjectBranch(p) {
69
69
  ? ' <span class="muted">→ ' + mainBranch + '</span>'
70
70
  : '';
71
71
  const detachedSuffix = p.gitDetached ? ' <span class="muted">(detached)</span>' : '';
72
+ // opg-microsoft/minions#664 — the counts are always vs origin/<comparator>
73
+ // (configured mainBranch, falling back to remoteDefaultBranch; see
74
+ // engine/queries.js _probeProjectGitStatus), never vs the checked-out
75
+ // branch's own upstream. When branchDiffersFromMain the bare "↑N ↓M" chip
76
+ // reads as ambiguous (looks like it could be vs the current branch's
77
+ // tracking ref), so append the comparator name to the VISIBLE text too —
78
+ // not just the hover title — so the disambiguation doesn't require a
79
+ // hover. Skip the suffix when the checked-out branch IS the comparator
80
+ // since there's nothing to disambiguate.
81
+ const comparatorBranch = mainBranch || remoteDefault;
82
+ // Deliberately separate from branchDiffersFromMain (which only fires when a
83
+ // configured mainBranch is set, driving the "→ main" suffix): the ahead/
84
+ // behind comparator falls back to remoteDefaultBranch too, so the chip must
85
+ // disambiguate against whichever one actually produced the counts.
86
+ const branchDiffersFromComparator = !!(comparatorBranch && p.gitBranch !== (p.mainBranch || p.remoteDefaultBranch));
87
+ const comparatorSuffix = branchDiffersFromComparator ? ' vs ' + comparatorBranch : '';
72
88
  const aheadBehind = (ahead !== null && behind !== null && (ahead > 0 || behind > 0))
73
- ? ' <span class="project-ab" title="' + escHtml('Ahead ' + ahead + ' / behind ' + behind + ' vs origin/' + (p.mainBranch || p.remoteDefaultBranch)) + '">↑' + ahead + ' ↓' + behind + '</span>'
89
+ ? ' <span class="project-ab" title="' + escHtml('Ahead ' + ahead + ' / behind ' + behind + ' vs origin/' + (p.mainBranch || p.remoteDefaultBranch)) + '">↑' + ahead + ' ↓' + behind + comparatorSuffix + '</span>'
74
90
  : '';
75
91
  // Warning chip surfaces when the configured main disagrees with the remote
76
92
  // default branch (the #2732 case: config=master, origin/HEAD=main) OR when
@@ -202,8 +202,13 @@ function prRow(pr) {
202
202
  // clearly-labeled indicator — a still-red build after an unchanged fix
203
203
  // attempt usually means the failure isn't fixable by another agent pass
204
204
  // (flaky infra, external outage, etc.), hence "infra issue suspected".
205
- // No resume action is wired here (clearing this record is an engine-side
206
- // change out of scope for this UI-only follow-up) — informational only.
205
+ // Issue #671 clicking resumes it via /api/pull-requests/clear-build-fix-ineffective
206
+ // (resumeBuildFixIneffective below), which clears _buildFixIneffective so
207
+ // BUILD_FAILURE auto-fix dispatch can resume. A new push already resets
208
+ // this pause automatically (see shared.isBuildFixIneffectivePaused); the
209
+ // click handler exists for the case where an operator has already
210
+ // confirmed (e.g. a fresh CI run queued against a rebased/retried head)
211
+ // and doesn't want to wait for the next poll to notice.
207
212
  var buildFixIneffective = pr._buildFixIneffective;
208
213
  if (buildFixIneffective && typeof buildFixIneffective === 'object' && buildFixIneffective.paused === true) {
209
214
  var bfiTitle = 'Build-fix reported no branch change, and a live CI re-poll confirmed the build is still "'
@@ -211,11 +216,14 @@ function prRow(pr) {
211
216
  + (buildFixIneffective.headSha || 'unknown head') + ') after '
212
217
  + (buildFixIneffective.count || '?') + ' ineffective attempt(s). Reason: '
213
218
  + (buildFixIneffective.reason || 'unknown')
214
- + '. A new push to this branch will reset this pause.';
215
- pausedChips += ' <span class="pr-badge rejected pr-build-fix-ineffective-chip" '
216
- + 'style="font-size:var(--text-xs)" '
217
- + 'title="' + escapeHtml(bfiTitle) + '">'
218
- + ' infra issue suspected ▶</span>';
219
+ + '. A new push to this branch will reset this pause automatically, or click to resume now.';
220
+ pausedChips += ' <button class="pr-badge rejected pr-build-fix-ineffective-chip" '
221
+ + 'style="font-size:var(--text-xs);cursor:pointer;border:none" '
222
+ + 'title="' + escapeHtml(bfiTitle) + '" '
223
+ + 'data-pr-id="' + escapeHtml(String(pr.id || '')) + '" '
224
+ + 'data-pr-head-sha="' + escapeHtml(String(buildFixIneffective.headSha || '')) + '" '
225
+ + 'onclick="event.stopPropagation();resumeBuildFixIneffective(this)">'
226
+ + '⏸ infra issue suspected ▶</button>';
219
227
  }
220
228
  const titleText = pr.title || 'Untitled';
221
229
  // Polish (W-mqv5ccn7): flatten Markdown in the body so the preview shows
@@ -749,4 +757,38 @@ async function resumePausedCause(btn) {
749
757
  }
750
758
  }
751
759
 
752
- window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal, openPrDetail, unlinkPr, togglePrObserve, resumePausedCause };
760
+ // Issue #671 resume a paused build-fix-ineffective ("infra issue
761
+ // suspected") pause. Mirrors resumePausedCause above but targets the
762
+ // headSha-keyed _buildFixIneffective record via its own endpoint. Sends the
763
+ // headSha the chip was rendered with so the server can reject the clear as
764
+ // stale if the pause has since moved to a different commit.
765
+ async function resumeBuildFixIneffective(btn) {
766
+ if (!btn || btn.disabled) return;
767
+ const prId = btn.dataset.prId;
768
+ const headSha = btn.dataset.prHeadSha || '';
769
+ if (!prId) return;
770
+ if (!confirm('Resume build-fix automation on ' + prId + '?\n\nThis clears the "infra issue suspected" pause so the engine can re-dispatch build fixes on the next discovery pass.')) return;
771
+ btn.disabled = true;
772
+ const prevDisplay = btn.style.display;
773
+ btn.style.display = 'none';
774
+ showToast('pr-toast', 'Resumed build-fix automation on ' + prId, true);
775
+ try {
776
+ const res = await fetch('/api/pull-requests/clear-build-fix-ineffective', {
777
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
778
+ body: JSON.stringify({ prId, headSha })
779
+ });
780
+ if (!res.ok) {
781
+ const d = await res.json().catch(() => ({}));
782
+ btn.style.display = prevDisplay;
783
+ btn.disabled = false;
784
+ showToast('pr-toast', 'Failed: ' + (d.error || 'unknown'), false);
785
+ return;
786
+ }
787
+ } catch (e) {
788
+ btn.style.display = prevDisplay;
789
+ btn.disabled = false;
790
+ showToast('pr-toast', 'Error: ' + e.message, false);
791
+ }
792
+ }
793
+
794
+ window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal, openPrDetail, unlinkPr, togglePrObserve, resumePausedCause, resumeBuildFixIneffective };
@@ -231,7 +231,12 @@
231
231
  the standard TILE_VIEWS path in modals-tiles.js#renderPlansBody. There is no
232
232
  slim-specific Plans/PRD flyout markup anymore (W-mr28ko750010199f / "reuse,
233
233
  don't fork"): the classic /plans screen is the single source of truth for the
234
- Plans + PRD tabs and every lifecycle action. -->
234
+ Plans + PRD tabs and every lifecycle action.
235
+ The "Active PRs" cockpit tile follows the same iframe-embed pattern
236
+ (W-mr3l7y0m0007102a): it opens the LITERAL classic /prs screen in a chrome-off
237
+ iframe (/prs?embed=1) via modals-tiles.js#renderPrsBody, so the full PR
238
+ filter/sort UI + row actions (comment/track/link/pr-action) are the classic
239
+ screen — no slim-specific PR list to maintain. -->
235
240
 
236
241
  <!-- Pin editor — create (Pin Content action / "+ Pin") or edit an existing
237
242
  note. Submits to POST /api/pinned (create) or /api/pinned/update (edit). -->
@@ -81,17 +81,6 @@
81
81
  return card;
82
82
  }
83
83
 
84
- // Parse the org/project slug from a canonical PR id, dropping the host prefix
85
- // and the trailing "#<num>". E.g. "ado:office/iss/constellation#5315603" →
86
- // "office/iss/constellation", "github:yemi33/minions#2911" → "yemi33/minions".
87
- function parsePrSlug(id) {
88
- if (!id || typeof id !== 'string') return '';
89
- var hash = id.indexOf('#');
90
- var head = hash >= 0 ? id.slice(0, hash) : id;
91
- var colon = head.indexOf(':');
92
- return colon >= 0 ? head.slice(colon + 1) : head;
93
- }
94
-
95
84
  // Per-tile body renderers for the detail modal. Each reads the latest status
96
85
  // snapshot and fills the modal body; openTileModal looks them up by key.
97
86
  function renderEngineTileBody(body, data) {
@@ -160,107 +149,23 @@
160
149
  body.appendChild(frame);
161
150
  }
162
151
 
163
- function renderPrTileBody(body, data) {
164
- // Emoji glyphs make build/review status scannable at a glance; the
165
- // accompanying label keeps the row readable when the glyph alone is
166
- // ambiguous (✅ vs ✅, vs ❌, vs 💬). The raw status string also
167
- // stays in the title attribute for hover discoverability.
168
- // Keyed by normalized (lowercased) status; missing/unknown ➖.
169
- var BUILD_STATUS = {
170
- success: { glyph: '✅', label: 'passing' },
171
- succeeded: { glyph: '✅', label: 'passing' },
172
- passing: { glyph: '✅', label: 'passing' },
173
- failing: { glyph: '❌', label: 'failing' },
174
- failed: { glyph: '❌', label: 'failing' },
175
- error: { glyph: '❌', label: 'failing' },
176
- pending: { glyph: '', label: 'pending' },
177
- running: { glyph: '', label: 'running' },
178
- queued: { glyph: '⏳', label: 'queued' },
179
- in_progress: { glyph: '⏳', label: 'in progress' },
180
- };
181
- var REVIEW_STATUS = {
182
- approved: { glyph: '✅', label: 'approved' },
183
- 'changes-requested': { glyph: '❌', label: 'changes requested' },
184
- changes_requested: { glyph: '❌', label: 'changes requested' },
185
- 'requested-changes': { glyph: '❌', label: 'changes requested' },
186
- rejected: { glyph: '❌', label: 'rejected' },
187
- pending: { glyph: '💬', label: 'pending' },
188
- commented: { glyph: '💬', label: 'commented' },
189
- };
190
- function statusSpan(subsystem, raw, table) {
191
- var info = table[String(raw || '').toLowerCase()];
192
- var glyph = info ? info.glyph : '➖';
193
- var label = info ? info.label : (raw ? String(raw) : 'none');
194
- var span = document.createElement('span');
195
- span.className = 'pr-status';
196
- span.textContent = glyph + ' ' + subsystem + ' ' + label;
197
- span.title = subsystem + ': ' + (raw || 'unknown');
198
- return span;
199
- }
200
- var prs = Array.isArray(data.pullRequests) ? data.pullRequests : [];
201
- if (!prs.length) { tileEmpty(body, 'No tracked pull requests.'); return; }
202
- function prCard(p) {
203
- var num = p.prNumber || p.number || p.id;
204
- var build = p.buildStatus || '';
205
- var review = p.reviewStatus || '';
206
- var cls = p.status === 'merged' ? 'green'
207
- : (build === 'failing' ? 'red'
208
- : ((p.status === 'active' || p.status === 'linked') ? 'blue' : ''));
209
- var statusText = document.createElement('span');
210
- statusText.textContent = p.status || 'unknown';
211
- return tileCard({
212
- title: (num ? '#' + num + ' ' : '') + (p.title || '(untitled PR)'),
213
- // Project slug first (under title), then build/review row below — the
214
- // project is the strongest disambiguator across PRs and benefits from
215
- // being adjacent to the title.
216
- subtle: parsePrSlug(p.id),
217
- subtleBeforeMeta: true,
218
- metaNodes: [statusText, statusSpan('build', build, BUILD_STATUS), statusSpan('review', review, REVIEW_STATUS)],
219
- chip: { text: p.status || '—', cls: cls },
220
- href: p.url || null,
221
- });
222
- }
223
- // Partition once into three fixed buckets (status normalized to lowercase),
224
- // preserving each PR's relative order within its bucket. Mirrors shared
225
- // PR_STATUS (active/merged/abandoned/closed/linked); anything that does not
226
- // match the merged/abandoned buckets defaults to Active so a PR with an
227
- // unexpected status never silently disappears.
228
- var buckets = { active: [], merged: [], abandoned: [] };
229
- prs.forEach(function(p) {
230
- var s = String(p.status || '').toLowerCase();
231
- if (s === 'abandoned') buckets.abandoned.push(p);
232
- else if (s === 'merged' || s === 'completed' || s === 'closed') buckets.merged.push(p);
233
- else buckets.active.push(p);
234
- });
235
- // Fixed section order: Active (open) → Merged / Completed → Abandoned.
236
- // Empty sections are omitted entirely.
237
- var sections = [
238
- { label: 'Active', prs: buckets.active, open: true },
239
- { label: 'Merged / Completed', prs: buckets.merged, open: false },
240
- { label: 'Abandoned', prs: buckets.abandoned, open: false },
241
- ];
242
- sections.forEach(function(section) {
243
- if (!section.prs.length) return;
244
- var details = document.createElement('details');
245
- details.className = 'tile-section';
246
- if (section.open) details.open = true;
247
- var summary = document.createElement('summary');
248
- summary.className = 'tile-section-summary';
249
- var label = document.createElement('span');
250
- label.className = 'tile-section-label';
251
- label.textContent = section.label;
252
- var count = document.createElement('span');
253
- count.className = 'tile-section-count';
254
- count.textContent = '(' + section.prs.length + ')';
255
- summary.appendChild(label);
256
- summary.appendChild(count);
257
- details.appendChild(summary);
258
- var sectionBody = document.createElement('div');
259
- sectionBody.className = 'tile-section-body';
260
- section.prs.forEach(function(p) { sectionBody.appendChild(prCard(p)); });
261
- details.appendChild(sectionBody);
262
- body.appendChild(details);
263
- });
152
+ // Pull-requests tile body reuses the LITERAL classic dashboard /prs screen
153
+ // instead of a slim-specific bucketed PR list, so there is one PRs UI, not two
154
+ // (W-mr3l7y0m0007102a / "reuse, don't fork" mirrors renderQueuedWorkBody for
155
+ // the Work tile and renderPlansBody for the Plans tile). Slim and classic are
156
+ // two separate IIFE bundles with no shared scope, so we embed the real /prs
157
+ // screen in an iframe with the chrome-off ?embed=1 mode. The iframed page IS
158
+ // the classic screen — the full PR list with every filter/sort + row action
159
+ // (comment/track/link/pr-action), backed by the same /api/status pullRequests
160
+ // data + endpoints with zero duplicated rendering logic. Classic is reachable
161
+ // at /prs even with slim-ux ON (only / is taken over). Built with createElement
162
+ // (no innerHTML) to satisfy the dashboard no-unsanitized lint gate.
163
+ function renderPrsBody(body) {
164
+ var frame = document.createElement('iframe');
165
+ frame.className = 'slim-prs-embed';
166
+ frame.src = '/prs?embed=1';
167
+ frame.title = 'Pull Requests';
168
+ body.appendChild(frame);
264
169
  }
265
170
 
266
171
  function renderWatchTileBody(body, data) {
@@ -283,7 +188,7 @@
283
188
  dispatches: { title: 'Active dispatches', render: function(body, data) { renderDispatchTileBody(body, data, true); } },
284
189
  queued: { title: 'Queued work', render: function(body) { renderQueuedWorkBody(body); } },
285
190
  plans: { title: 'Plans', render: function(body) { renderPlansBody(body); } },
286
- prs: { title: 'Pull requests', render: renderPrTileBody },
191
+ prs: { title: 'Pull requests', render: function(body) { renderPrsBody(body); } },
287
192
  watches: { title: 'Watches', render: renderWatchTileBody },
288
193
  };
289
194
 
@@ -304,11 +209,13 @@
304
209
  // The "+ Link PR" header chip only applies to the PR list.
305
210
  var headerChip = document.getElementById('slim-tile-modal-linkpr');
306
211
  if (headerChip) headerChip.style.display = (key === 'prs') ? '' : 'none';
307
- // The queued + plans tiles embed a full classic screen (/work, /plans) in an
308
- // iframe — widen the modal + drop the body padding so the embedded screen
309
- // gets real estate. Both reuse the shared wide-modal treatment.
212
+ // The queued, plans + prs tiles embed a full classic screen (/work, /plans,
213
+ // /prs) in an iframe — widen the modal + drop the body padding so the
214
+ // embedded screen gets real estate. All three reuse the shared wide-modal
215
+ // treatment.
310
216
  modal.classList.toggle('tile-modal--work', key === 'queued');
311
217
  modal.classList.toggle('tile-modal--plans', key === 'plans');
218
+ modal.classList.toggle('tile-modal--prs', key === 'prs');
312
219
  view.render(body, lastStatusData || {});
313
220
  modal.classList.add('open');
314
221
  }
@@ -680,15 +680,19 @@
680
680
  /* Cockpit-tile detail modal: list of dispatches / PRs / watches, or a
681
681
  key/value engine readout (reuses .agent-detail-row). */
682
682
  #slim-tile-modal .modal { width: 720px; max-width: calc(100vw - 32px); }
683
- /* W-mqrdggys000f94a4 / W-mr28ko750010199f the Queued-work + Plans tiles
684
- embed a full classic screen (/work, /plans) in an iframe; widen the modal +
685
- remove body padding so the embedded screen gets the full width/height. */
683
+ /* W-mqrdggys000f94a4 / W-mr28ko750010199f / W-mr3l7y0m0007102a the
684
+ Queued-work + Plans + PRs tiles embed a full classic screen (/work,
685
+ /plans, /prs) in an iframe; widen the modal + remove body padding so the
686
+ embedded screen gets the full width/height. */
686
687
  #slim-tile-modal.tile-modal--work .modal,
687
- #slim-tile-modal.tile-modal--plans .modal { width: 1180px; }
688
+ #slim-tile-modal.tile-modal--plans .modal,
689
+ #slim-tile-modal.tile-modal--prs .modal { width: 1180px; }
688
690
  #slim-tile-modal.tile-modal--work .modal-body,
689
- #slim-tile-modal.tile-modal--plans .modal-body { padding: 0; }
691
+ #slim-tile-modal.tile-modal--plans .modal-body,
692
+ #slim-tile-modal.tile-modal--prs .modal-body { padding: 0; }
690
693
  .slim-work-embed,
691
- .slim-plans-embed {
694
+ .slim-plans-embed,
695
+ .slim-prs-embed {
692
696
  display: block; width: 100%; height: calc(100vh - 160px); min-height: 360px;
693
697
  border: 0; background: var(--bg);
694
698
  }
@@ -711,7 +715,6 @@
711
715
  }
712
716
  .tile-item-meta { font-size: var(--text-base); color: var(--muted); margin-top: 3px; word-break: break-word; }
713
717
  .tile-item-subtle { font-size: var(--text-sm); color: var(--muted); margin-top: 2px; word-break: break-word; }
714
- .pr-status { display: inline-block; margin: 0 1px; text-decoration: none; }
715
718
  .tile-chip {
716
719
  flex: 0 0 auto;
717
720
  font-size: var(--text-base); font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase;
@@ -724,26 +727,6 @@
724
727
  .tile-chip.blue { background: rgba(88, 166, 255, 0.12); color: var(--blue); border-color: var(--blue); }
725
728
  .tile-empty { color: var(--muted); font-style: italic; font-size: var(--text-md); }
726
729
 
727
- /* Collapsible PR sections (Active / Merged-Completed / Abandoned). Uses
728
- native <details>/<summary> — no JS wiring needed for the toggle. */
729
- .tile-section { margin-bottom: 10px; }
730
- .tile-section:last-child { margin-bottom: 0; }
731
- .tile-section-summary {
732
- display: flex; align-items: center; gap: 6px;
733
- cursor: pointer; user-select: none;
734
- padding: 4px 2px; margin-bottom: 6px;
735
- list-style: none;
736
- font-size: var(--text-base); color: var(--muted);
737
- }
738
- .tile-section-summary::-webkit-details-marker { display: none; }
739
- .tile-section-summary::before {
740
- content: "▸"; color: var(--muted); font-size: var(--text-sm);
741
- transition: transform 0.15s ease;
742
- }
743
- .tile-section[open] > .tile-section-summary::before { content: "▾"; }
744
- .tile-section-label { font-weight: 700; letter-spacing: 0.3px; text-transform: uppercase; color: var(--text); }
745
- .tile-section-count { color: var(--muted); }
746
-
747
730
  /* Pinned-context list rows (Knowledge modal → Pinned Context tab). */
748
731
  .pinned-row {
749
732
  border: 1px solid var(--border);
package/dashboard.js CHANGED
@@ -3959,6 +3959,7 @@ function _buildSyntheticActionResultsForTurn(turnId, message, requestedAt) {
3959
3959
  if (entry.id) result.id = entry.id;
3960
3960
  if (entry.project) result.project = entry.project;
3961
3961
  if (entry.path) result.path = entry.path;
3962
+ if (entry.duplicate) result.duplicate = true;
3962
3963
  if (Array.isArray(entry.followups) && entry.followups.length) result.followups = entry.followups;
3963
3964
  results.push(result);
3964
3965
  }
@@ -6914,6 +6915,14 @@ const server = http.createServer(async (req, res) => {
6914
6915
  const createResult = createWorkItemWithDedup(wiPath, item);
6915
6916
  if (!createResult.created) {
6916
6917
  const duplicateId = createResult.duplicateOf || createResult.item?.id;
6918
+ // W-mr466ocx000l1191 — record the CC turn creation on the dedup path
6919
+ // too. The API call still succeeded (ok:true) from the caller's
6920
+ // perspective; without this, a CC turn that dispatches a work item
6921
+ // matching an existing one silently loses its confirmation chip
6922
+ // (_buildSyntheticActionResultsForTurn drains an empty list for the
6923
+ // turn, so donePayload.actions/actionResults come back empty even
6924
+ // though the LLM's tool call succeeded).
6925
+ recordCcTurnIfPresent(req, { kind: 'work-item', id: duplicateId, title: item.title, project: item.project || null, duplicate: true });
6917
6926
  return jsonReply(res, 200, { ok: true, id: duplicateId, duplicate: true, duplicateOf: duplicateId });
6918
6927
  }
6919
6928
  recordCcTurnIfPresent(req, { kind: 'work-item', id, title: item.title, project: item.project || null });
@@ -13902,6 +13911,55 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13902
13911
  return jsonReply(res, 200, { ok: true, cleared: cause, prId });
13903
13912
  }},
13904
13913
 
13914
+ // Issue #671 — sibling to clear-paused-cause above, but for the
13915
+ // headSha-keyed `_buildFixIneffective` ("infra issue suspected") pause
13916
+ // (see engine/lifecycle.js#recordBuildFixIneffective), which is tracked
13917
+ // separately from `_noOpFixes` and so isn't reachable via the endpoint
13918
+ // above. Once a fresh CI build is queued against a rebased/retried head,
13919
+ // this lets an operator clear the stale pause without hand-editing
13920
+ // pull-requests.json.
13921
+ { method: 'POST', path: '/api/pull-requests/clear-build-fix-ineffective', desc: 'Resume a paused build-fix-ineffective ("infra issue suspected") pause (issue #671) — clears _buildFixIneffective so BUILD_FAILURE auto-fix dispatch can resume', params: 'prId (canonical host:slug#number), headSha? (optional — if provided, must match the paused record\'s headSha or the clear is rejected as stale)', handler: async (req, res) => {
13922
+ const body = await readBody(req);
13923
+ const prId = typeof body?.prId === 'string' ? body.prId.trim() : '';
13924
+ const headSha = typeof body?.headSha === 'string' ? body.headSha.trim() : '';
13925
+ if (!prId) return jsonReply(res, 400, { error: 'prId required' });
13926
+ reloadConfig();
13927
+ const lifecycle = require('./engine/lifecycle');
13928
+ const prPaths = [
13929
+ ...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
13930
+ shared.centralPullRequestsPath(MINIONS_DIR),
13931
+ ];
13932
+ let prFound = false;
13933
+ let recordCleared = false;
13934
+ let staleHead = false;
13935
+ for (const prPath of prPaths) {
13936
+ if (recordCleared || staleHead) break;
13937
+ shared.mutatePullRequests(prPath, (prs) => {
13938
+ if (!Array.isArray(prs)) return prs;
13939
+ const target = prs.find(p => p && p.id === prId);
13940
+ if (!target) return prs;
13941
+ prFound = true;
13942
+ const record = target._buildFixIneffective;
13943
+ if (!record || typeof record !== 'object') return prs;
13944
+ // If the caller told us which head they saw the pause on (the
13945
+ // dashboard chip stamps this at render time), refuse to clear a
13946
+ // pause that has since moved on to a different head — that would
13947
+ // silently apply a stale click's intent to a newer commit.
13948
+ if (headSha && record.headSha && record.headSha !== headSha) {
13949
+ staleHead = true;
13950
+ return prs;
13951
+ }
13952
+ recordCleared = lifecycle.clearBuildFixIneffective(target);
13953
+ return prs;
13954
+ });
13955
+ }
13956
+ if (!prFound) return jsonReply(res, 404, { error: `PR ${prId} not found` });
13957
+ if (staleHead) return jsonReply(res, 409, { error: `build-fix-ineffective pause on PR ${prId} is for a different head than provided — refresh and retry` });
13958
+ if (!recordCleared) return jsonReply(res, 400, { error: `PR ${prId} has no tracked build-fix-ineffective pause` });
13959
+ invalidateStatusCache();
13960
+ return jsonReply(res, 200, { ok: true, cleared: 'build-fix-ineffective', prId });
13961
+ }},
13962
+
13905
13963
  // ─── P-f3c9d0e7: convenience pause/resume endpoints ─────────────────────
13906
13964
  // Single-call wrappers over `POST /api/settings { engine: { <flag>: bool } }`
13907
13965
  // for the two operator kill-switches:
@@ -1153,6 +1153,52 @@ function hasReusableSignal(content) {
1153
1153
  return REUSABLE_SIGNAL_RE.test(content || '');
1154
1154
  }
1155
1155
 
1156
+ // ── Engine-alert KB dedup (W-mr3pi9de, sibling of W-mr3lokxs) ────────────────
1157
+ // Recurring engine-system alerts (e.g. the worktree-skip-live guard firing
1158
+ // across many different worktrees, or the same live-checkout-dirty incident
1159
+ // firing every tick) used to be written as full-size KB copies with -2/-3/…
1160
+ // numeric suffixes via uniquePath, multiplying storage N times per day.
1161
+ // Instead we content-hash each alert and skip writing when a same-hash entry
1162
+ // already exists in the target category. The hash is stored in the entry's
1163
+ // frontmatter (`alertHash:`) so future passes can dedup against it cheaply.
1164
+
1165
+ // Normalized, length-bounded content hash. Collapses whitespace so trivially
1166
+ // re-rendered dumps (e.g. differing timestamps) still collapse to one hash.
1167
+ function _alertContentHash(content) {
1168
+ const raw = String(content || '');
1169
+ const normalized = raw.replace(/\s+/g, ' ').trim().slice(0, 4000);
1170
+ return crypto.createHash('sha256').update(normalized + ':' + raw.length).digest('hex');
1171
+ }
1172
+
1173
+ // Read only the leading bytes of a file (frontmatter lives at the very top) so
1174
+ // scanning a directory that also holds huge legacy full-body dumps never loads
1175
+ // those multi-MB bodies into memory.
1176
+ function _readFileHead(fp, bytes = 512) {
1177
+ let fd;
1178
+ try {
1179
+ fd = fs.openSync(fp, 'r');
1180
+ const buf = Buffer.alloc(bytes);
1181
+ const n = fs.readSync(fd, buf, 0, bytes, 0);
1182
+ return buf.slice(0, n).toString('utf8');
1183
+ } catch { return ''; }
1184
+ finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* best-effort */ } } }
1185
+ }
1186
+
1187
+ // Does an existing KB entry in `dir` already carry this alertHash frontmatter?
1188
+ function _alertHashExists(dir, hash) {
1189
+ if (!hash || !dir || !fs.existsSync(dir)) return false;
1190
+ let files;
1191
+ try { files = fs.readdirSync(dir); } catch { return false; }
1192
+ const re = new RegExp(`^alertHash:\\s*${hash}\\s*$`, 'm');
1193
+ for (const f of files) {
1194
+ if (!f.endsWith('.md')) continue;
1195
+ const head = _readFileHead(path.join(dir, f));
1196
+ const fm = head.match(/^---\r?\n([\s\S]*?)\r?\n---/);
1197
+ if (fm && re.test(fm[1])) return true;
1198
+ }
1199
+ return false;
1200
+ }
1201
+
1156
1202
  /**
1157
1203
  * Build a condensed KB stub for narrowly-scoped, one-off notes instead of
1158
1204
  * duplicating the full body (issue #604 — classifyToKnowledgeBase was copying
@@ -1223,18 +1269,32 @@ async function classifyToKnowledgeBase(items, config) {
1223
1269
  // Reserve full-body KB copies for content that's actually general/reusable
1224
1270
  // (patterns, conventions, gotchas); narrowly-scoped one-off notes get a
1225
1271
  // condensed stub + link back to the archived note instead (issue #604).
1226
- const reusable = category === 'conventions' || hasReusableSignal(content);
1272
+ //
1273
+ // W-mr3pi9de — engine-authored system alerts (worktree-skip-live guard
1274
+ // fires, dirty-tree refusals, blocked dispatches, managed-spawn failures)
1275
+ // are operational noise, NEVER durable reusable knowledge. Force them
1276
+ // non-reusable regardless of category or any incidental heading-shaped
1277
+ // line in the body that would otherwise trip hasReusableSignal, and
1278
+ // content-hash-dedup recurring identical/near-identical incidents instead
1279
+ // of appending -2/-3 full copies with a colliding title.
1280
+ const isSystemAlert = shared.isEngineSystemAlert(item.name, content);
1281
+ const reusable = !isSystemAlert && (category === 'conventions' || hasReusableSignal(content));
1227
1282
  const archiveRelPath = `notes/archive/${dateStamp()}-${item.name}`;
1228
1283
  const kbBody = reusable
1229
1284
  ? content
1230
1285
  : buildCondensedKbBody(content, titleMatch ? titleMatch[0] : null, archiveRelPath);
1231
1286
 
1232
- const frontmatter = `---\nsource: ${item.name}\nagent: ${agent}\ncategory: ${category}\ndate: ${dateStamp()}\nreusable: ${reusable}\n---\n\n`;
1233
- try {
1234
- safeWrite(kbPath, frontmatter + kbBody);
1235
- classified++;
1236
- } catch (err) {
1237
- log('warn', `Failed to classify ${item.name} to knowledge base: ${err.message}`);
1287
+ const alertHash = isSystemAlert ? _alertContentHash(content) : null;
1288
+ if (isSystemAlert && _alertHashExists(categoryDirs[category], alertHash)) {
1289
+ log('info', `KB dedup: skipping recurring engine-alert ${item.name} — duplicate of an existing knowledge/${category} entry`);
1290
+ } else {
1291
+ const frontmatter = `---\nsource: ${item.name}\nagent: ${agent}\ncategory: ${category}\ndate: ${dateStamp()}\nreusable: ${reusable}${isSystemAlert ? `\nalertHash: ${alertHash}` : ''}\n---\n\n`;
1292
+ try {
1293
+ safeWrite(kbPath, frontmatter + kbBody);
1294
+ classified++;
1295
+ } catch (err) {
1296
+ log('warn', `Failed to classify ${item.name} to knowledge base: ${err.message}`);
1297
+ }
1238
1298
  }
1239
1299
 
1240
1300
  // Per-agent memory routing — strict superset of broadcast consolidation.
@@ -1397,4 +1457,6 @@ module.exports = {
1397
1457
  NOTES_MAX_BYTES,
1398
1458
  hasReusableSignal,
1399
1459
  buildCondensedKbBody,
1460
+ _alertContentHash,
1461
+ _alertHashExists,
1400
1462
  };
@@ -2943,6 +2943,18 @@ function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChang
2943
2943
  return target._noOpFixes[cause];
2944
2944
  }
2945
2945
 
2946
+ // #671 — Clears the `_buildFixIneffective` ("infra issue suspected") pause so
2947
+ // BUILD_FAILURE auto-fix dispatch can resume. Distinct from
2948
+ // `clearPrNoOpFixAttempt` (below) because this pause is headSha-keyed rather
2949
+ // than cause-keyed — see `recordBuildFixIneffective` / `shared.isBuildFixIneffectivePaused`.
2950
+ // Returns true when a record was actually removed so callers (the dashboard
2951
+ // resume endpoint) can distinguish "cleared" from "nothing to clear".
2952
+ function clearBuildFixIneffective(target) {
2953
+ if (!target?._buildFixIneffective) return false;
2954
+ delete target._buildFixIneffective;
2955
+ return true;
2956
+ }
2957
+
2946
2958
  function clearPrNoOpFixAttempt(target, cause) {
2947
2959
  if (!target?._noOpFixes || !target._noOpFixes[cause]) return;
2948
2960
  delete target._noOpFixes[cause];
@@ -6821,6 +6833,9 @@ module.exports = {
6821
6833
  // Issue #2969 — exported so dashboard.js can clear a paused cause from the
6822
6834
  // resume endpoint.
6823
6835
  clearPrNoOpFixAttempt,
6836
+ // Issue #671 — exported so dashboard.js can clear the build-fix-ineffective
6837
+ // ("infra issue suspected") pause from the resume endpoint.
6838
+ clearBuildFixIneffective,
6824
6839
  // W-mpx44p05000ze8d8 — exported so engine/ado.js + engine/github.js can
6825
6840
  // drop stale `_noOpFixes` records during their PR status poll, and so
6826
6841
  // unit tests can exercise the sweep helper directly.
@@ -220,6 +220,25 @@ function _collectAutoCleanablePaths(dirtyFiles) {
220
220
  return cleanable.length > 0 ? cleanable : null;
221
221
  }
222
222
 
223
+ // W-mr3lokxs — Cap a porcelain dirty-file list before it is embedded verbatim
224
+ // into a live-checkout-dirty inbox alert body. On GVFS/virtual repos (see the
225
+ // "~120k virtual dirty files" note in prepareLiveCheckout below) a single
226
+ // refusal can report tens of thousands of "modified" paths; embedding the full
227
+ // list produced multi-MB / tens-of-MB alert bodies that then bloated the
228
+ // knowledge base (240 dirty-worktree dumps ≈ 113 MB). Keep the first `maxLines`
229
+ // entries and replace the remainder with a single truncation-notice line so the
230
+ // alert stays human-scannable and bounded. Pure/deterministic — exported for
231
+ // unit testing.
232
+ const DIRTY_ALERT_MAX_LINES = 200;
233
+ function capDirtyFileLines(dirtyFiles, maxLines = DIRTY_ALERT_MAX_LINES) {
234
+ const lines = Array.isArray(dirtyFiles) ? dirtyFiles : [];
235
+ const cap = Number.isInteger(maxLines) && maxLines > 0 ? maxLines : DIRTY_ALERT_MAX_LINES;
236
+ if (lines.length <= cap) return lines.slice();
237
+ const kept = lines.slice(0, cap);
238
+ kept.push(`... and ${lines.length - cap} more (run \`git status --porcelain=v1 -b\` locally for the full list)`);
239
+ return kept;
240
+ }
241
+
223
242
  // issue #608 — detects a branch name that the ENGINE created for a live-mode
224
243
  // dispatch (never the operator's own branch). Matches ONLY the literal
225
244
  // `work/<wi-id>` convention produced by `shared.deriveWorkItemBranchName` —
@@ -1251,5 +1270,7 @@ module.exports = {
1251
1270
  _looksLikeAgentBranch,
1252
1271
  _extractBranchFromPorcelainHeader,
1253
1272
  _commitAgentWipWithRetry,
1273
+ capDirtyFileLines,
1274
+ DIRTY_ALERT_MAX_LINES,
1254
1275
  LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS,
1255
1276
  };
package/engine/shared.js CHANGED
@@ -2827,11 +2827,44 @@ const KB_READABLE_CATEGORIES = Object.freeze([
2827
2827
  'consolidated', 'consolidation', 'consolidations',
2828
2828
  'team-memory', 'general', 'patterns',
2829
2829
  ]);
2830
+ /**
2831
+ * Detect engine-authored system alerts (dirty-tree refusals, worktree-skip-live
2832
+ * guard notices, blocked/conflicted live-checkout dispatches, managed-spawn
2833
+ * failures, ADO-auth alerts, …). These are operational noise, NOT durable
2834
+ * reusable knowledge, and the same underlying incident recurs verbatim across
2835
+ * many different inbox filenames (one per worktree/date), so consolidation
2836
+ * must never copy their full body into knowledge/ as a distinct "new" entry
2837
+ * per recurrence — see W-mr3pi9de (worktree-skip-live note titles were
2838
+ * identical across every firing regardless of worktree, producing 30+
2839
+ * duplicate-titled knowledge/project-notes/*.md entries on a single day) and
2840
+ * the sibling W-mr3lokxs fix for live-checkout-dirty alerts.
2841
+ *
2842
+ * `engine/dispatch.js#writeInboxAlert` names every file `engine-alert-<slug>-
2843
+ * <date>.md`, so the filename prefix is one signal. `shared.js#_writeWorktree
2844
+ * SkipLiveInboxNote` (and any other engine-authored note) stamps frontmatter
2845
+ * `agent: engine`, accepted as a second signal so alerts written under a
2846
+ * different filename convention are still recognized.
2847
+ */
2848
+ function isEngineSystemAlert(name, content) {
2849
+ const nameLower = (name || '').toLowerCase();
2850
+ if (nameLower.includes('engine-alert-') || nameLower.includes('engine-worktree-skip-live-')) return true;
2851
+ const fmMatch = String(content || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
2852
+ if (fmMatch && /^agent:\s*engine\s*$/im.test(fmMatch[1])) return true;
2853
+ return false;
2854
+ }
2855
+
2830
2856
  /**
2831
2857
  * Classify an inbox item into a knowledge base category.
2832
2858
  * Single source of truth — used by consolidation.js (both LLM and regex paths).
2833
2859
  */
2834
2860
  function classifyInboxItem(name, content) {
2861
+ // W-mr3pi9de / W-mr3lokxs — engine-authored system alerts are operational
2862
+ // noise. Route them to 'project-notes' BEFORE the content-substring
2863
+ // heuristics below, so a note whose body incidentally contains substrings
2864
+ // like 'lint', 'review', or 'pr-' can't be misclassified into
2865
+ // build-reports/reviews. classifyToKnowledgeBase additionally forces these
2866
+ // non-reusable and content-hash-dedups them regardless of category.
2867
+ if (isEngineSystemAlert(name, content)) return 'project-notes';
2835
2868
  const nameLower = (name || '').toLowerCase();
2836
2869
  const contentLower = (content || '').toLowerCase();
2837
2870
  if (nameLower.includes('review') || nameLower.includes('pr-') || nameLower.includes('pr4') || nameLower.includes('feedback')) return 'reviews';
@@ -8893,7 +8926,13 @@ function _writeWorktreeSkipLiveInboxNote(worktreePath, callerTag, blockingInfo)
8893
8926
  `date: ${date}`,
8894
8927
  '---',
8895
8928
  '',
8896
- `# Engine skipped worktree wipe live dispatch guard fired (W-mq5rwwss000f30a7)`,
8929
+ // W-mr3pi9de embed the actual worktree basename, not a hardcoded
8930
+ // literal. The previous fixed string made every note (and therefore
8931
+ // every knowledge/project-notes/*.md entry consolidation wrote from
8932
+ // it) share byte-identical titles regardless of which worktree fired
8933
+ // the guard, defeating uniquePath's -2/-3/… suffixing and flooding the
8934
+ // KB with near-duplicate-content entries under different numbers.
8935
+ `# Engine skipped worktree wipe — live dispatch guard fired (${base})`,
8897
8936
  '',
8898
8937
  `- caller: ${callerTag || 'unknown'}`,
8899
8938
  `- worktree: ${worktreePath}`,
@@ -9434,6 +9473,7 @@ module.exports = {
9434
9473
  KB_CATEGORIES,
9435
9474
  KB_READABLE_CATEGORIES,
9436
9475
  classifyInboxItem,
9476
+ isEngineSystemAlert,
9437
9477
  ENGINE_DEFAULTS,
9438
9478
  resolvePollFlag, // P-c4d8e1a3 — granular per-poller flag resolution
9439
9479
  resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentModel, resolveCcModel,
package/engine.js CHANGED
@@ -2583,7 +2583,7 @@ async function spawnAgent(dispatchItem, config) {
2583
2583
  '## Dirty files (`git status --porcelain=v1 -b`)',
2584
2584
  '',
2585
2585
  '```',
2586
- ...(_dirtyFiles.length > 0 ? _dirtyFiles : ['(none reported)']),
2586
+ ...(_dirtyFiles.length > 0 ? _liveCheckout.capDirtyFileLines(_dirtyFiles) : ['(none reported)']),
2587
2587
  '```',
2588
2588
  '',
2589
2589
  _autoCleanupEnabled
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2322",
3
+ "version": "0.1.2323",
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"