@yemi33/minions 0.1.2304 → 0.1.2305

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.
@@ -147,7 +147,7 @@ const RENDER_VERSIONS = {
147
147
  dispatch: 2,
148
148
  engineLog: 2,
149
149
  metrics: 1,
150
- workItems: 5,
150
+ workItems: 6,
151
151
  skills: 1,
152
152
  commands: 1,
153
153
  mcpServers: 1,
@@ -42,6 +42,7 @@ const _WI_ENRICHMENT_FIELDS = [
42
42
  // * already_dispatched — engine reconciles on next tick
43
43
  // * branch_locked — wait for the holding dispatch
44
44
  // * dependency_unmet — auto-clears when upstream dep finishes (engine discovery loop + DEPENDENCY_MET watch)
45
+ // * live_checkout_busy — one live-mode dispatch at a time; clears when running dispatch finishes
45
46
  //
46
47
  // Returns { kind, short, full } when the item needs attention, else null.
47
48
  // Pure helper (no DOM / escapeHtml deps) so it can be unit-tested in Node.
@@ -219,7 +220,7 @@ function wiRow(item) {
219
220
  + escapeHtml(((item._managedSpawnPartial.healthy || []).length) + '/' + (((item._managedSpawnPartial.healthy || []).length) + item._managedSpawnPartial.failed.length))
220
221
  + ' healthy</span>'
221
222
  : '') +
222
- (item._skipReason && item.status === 'pending' ? ' <span style="font-size:var(--text-xs);color:var(--yellow);margin-left:4px" title="Dispatch blocked: ' + escapeHtml(item._skipReason) + (item._blockedBy ? ' (by ' + escapeHtml(item._blockedBy) + ')' : '') + '">' + escapeHtml(item._skipReason.replace(/_/g, ' ')) + (item._blockedBy ? ' <span style="color:var(--muted)">(' + escapeHtml(item._blockedBy) + ')</span>' : '') + '</span>' : '') +
223
+ (item._skipReason && item.status === 'pending' && item._skipReason !== 'live_checkout_busy' ? ' <span style="font-size:var(--text-xs);color:var(--yellow);margin-left:4px" title="Dispatch blocked: ' + escapeHtml(item._skipReason) + (item._blockedBy ? ' (by ' + escapeHtml(item._blockedBy) + ')' : '') + '">' + escapeHtml(item._skipReason.replace(/_/g, ' ')) + (item._blockedBy ? ' <span style="color:var(--muted)">(' + escapeHtml(item._blockedBy) + ')</span>' : '') + '</span>' : '') +
223
224
  (item.status === 'failed' ? ' ' + wiRetryBtn(item) : '') +
224
225
  '</td>' +
225
226
  '<td>' +
@@ -17,6 +17,19 @@
17
17
  document.documentElement.setAttribute('data-font-size', (v && valid[v]) ? v : 'small');
18
18
  } catch (e) { /* private mode / disabled storage — fall through to default */ }
19
19
  })();
20
+ // W-mqrdggys000f94a4 — chrome-off "embed" mode. When the SPA is loaded with
21
+ // ?embed=1 (e.g. inside the slim cockpit's Queued-work iframe pointed at
22
+ // /work?embed=1), tag <html> so the embed CSS hides the global chrome
23
+ // (header, sidebar, projects bar, banners) and the iframed page renders as
24
+ // just its panel. Applied in <head> so the first paint already omits the
25
+ // chrome (no flash of the full layout).
26
+ (function() {
27
+ try {
28
+ if (/[?&]embed=1(?:&|$)/.test(window.location.search)) {
29
+ document.documentElement.classList.add('embed');
30
+ }
31
+ } catch (e) { /* defensive — never block boot on a URL parse quirk */ }
32
+ })();
20
33
  </script>
21
34
  <style>/* __CSS__ */</style>
22
35
  </head>
@@ -123,135 +123,23 @@
123
123
  });
124
124
  }
125
125
 
126
- // Collapsible "Completed work" section appended to the queued popout (only).
127
- // Collapsed by DEFAULT; clicking (or Enter/Space on) the header toggles the
128
- // list. Sources data.dispatch.completed the same recent window the slim
129
- // History feed reads (capped to the last 20 by the /api/status snapshot)
130
- // so it adds no new API calls. Status semantics reuse completionStatus /
131
- // completionTitle for parity with the History feed.
132
- function renderCompletedSection(body, data) {
133
- var dispatch = data.dispatch || {};
134
- // Snapshot delivers completed oldest→newest; reverse for most-recent-first.
135
- var completed = Array.isArray(dispatch.completed) ? dispatch.completed.slice().reverse() : [];
136
-
137
- var section = document.createElement('div');
138
- section.className = 'tile-completed-section collapsed';
139
-
140
- var head = document.createElement('div');
141
- head.className = 'tile-completed-section-head';
142
- head.setAttribute('role', 'button');
143
- head.setAttribute('tabindex', '0');
144
- head.setAttribute('aria-expanded', 'false');
145
- var caret = document.createElement('span');
146
- caret.className = 'tile-caret';
147
- caret.textContent = '▸';
148
- var label = document.createElement('span');
149
- label.className = 'tile-completed-section-title';
150
- label.textContent = 'Completed work (' + completed.length + ')';
151
- head.appendChild(caret);
152
- head.appendChild(label);
153
-
154
- var listWrap = document.createElement('div');
155
- listWrap.className = 'tile-completed-section-body';
156
- listWrap.hidden = true;
157
-
158
- if (!completed.length) {
159
- tileEmpty(listWrap, 'No completed work yet.');
160
- } else {
161
- completed.forEach(function(c) {
162
- var who = c.agentName || c.agent || c.dispatched_to || '—';
163
- var when = c.completed_at || c.completedAt || c.endedAt || c.started_at || c.startedAt;
164
- var status = completionStatus(c);
165
- var chip = status === 'ok' ? { text: 'done', cls: 'green' }
166
- : status === 'warn' ? { text: 'partial', cls: 'amber' }
167
- : { text: 'failed', cls: 'red' };
168
- listWrap.appendChild(tileCard({
169
- title: completionTitle(c),
170
- meta: who + (when ? ' · completed ' + relTime(when) : ''),
171
- chip: chip,
172
- }));
173
- });
174
- }
175
-
176
- function toggle() {
177
- var open = !section.classList.toggle('collapsed');
178
- head.setAttribute('aria-expanded', open ? 'true' : 'false');
179
- caret.textContent = open ? '▾' : '▸';
180
- listWrap.hidden = !open;
181
- }
182
- head.addEventListener('click', toggle);
183
- head.addEventListener('keydown', function(ev) {
184
- if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); toggle(); }
185
- });
186
-
187
- section.appendChild(head);
188
- section.appendChild(listWrap);
189
- body.appendChild(section);
190
- }
191
-
192
- // Collapsible "Pending work items" section appended to the queued popout
193
- // (W-mqv1xbcm). Sources data.workItems filtered to status === 'pending' — the
194
- // same /api/work-items slice the slim poll merges onto the snapshot — so it
195
- // adds no new API calls and refreshes whenever the popout is re-opened.
196
- // Expanded by DEFAULT (more prominent than the collapsed Completed section)
197
- // since these are the queued WIs the operator most wants to scan. Mirrors
198
- // renderCompletedSection's structure + tileCard usage; all text via
199
- // textContent (no innerHTML) to keep the SEC-03 baseline clean. Each card
200
- // shows title, id, project (project || _source), type, and the engine's
201
- // _pendingReason as a chip when set.
202
- function renderPendingSection(body, data) {
203
- var pending = (Array.isArray(data.workItems) ? data.workItems : []).filter(function(w) {
204
- return w && w.status === 'pending';
205
- });
206
-
207
- var section = document.createElement('div');
208
- section.className = 'tile-completed-section';
209
-
210
- var head = document.createElement('div');
211
- head.className = 'tile-completed-section-head';
212
- head.setAttribute('role', 'button');
213
- head.setAttribute('tabindex', '0');
214
- head.setAttribute('aria-expanded', 'true');
215
- var caret = document.createElement('span');
216
- caret.className = 'tile-caret';
217
- caret.textContent = '▾';
218
- var label = document.createElement('span');
219
- label.className = 'tile-completed-section-title';
220
- label.textContent = 'Pending work items (' + pending.length + ')';
221
- head.appendChild(caret);
222
- head.appendChild(label);
223
-
224
- var listWrap = document.createElement('div');
225
- listWrap.className = 'tile-completed-section-body';
226
-
227
- if (!pending.length) {
228
- tileEmpty(listWrap, 'No pending work items.');
229
- } else {
230
- pending.forEach(function(w) {
231
- var project = w.project || w._source;
232
- var meta = [w.id || null, project || null, w.type || null].filter(Boolean).join(' · ');
233
- var card = { title: w.title || w.id || '(untitled)', meta: meta };
234
- if (w._pendingReason) {
235
- card.chip = { text: String(w._pendingReason).replace(/_/g, ' '), cls: 'amber' };
236
- }
237
- listWrap.appendChild(tileCard(card));
238
- });
239
- }
240
-
241
- function toggle() {
242
- var open = !section.classList.toggle('collapsed');
243
- head.setAttribute('aria-expanded', open ? 'true' : 'false');
244
- caret.textContent = open ? '▾' : '▸';
245
- listWrap.hidden = !open;
246
- }
247
- head.addEventListener('click', toggle);
248
- head.addEventListener('keydown', function(ev) {
249
- if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); toggle(); }
250
- });
251
-
252
- section.appendChild(head);
253
- section.appendChild(listWrap);
254
- body.appendChild(section);
126
+ // Queued-work tile body reuses the LITERAL classic dashboard Work Items
127
+ // screen instead of a slim-specific list, so there is one work-items UI, not
128
+ // two (W-mqrdggys000f94a4 / "reuse, don't fork"). Slim and classic are two
129
+ // separate IIFE bundles with no shared scope, so we can't call the classic
130
+ // renderWorkItems() in-process; instead we embed the real /work screen in an
131
+ // iframe with the chrome-off ?embed=1 mode. The iframed page IS the classic
132
+ // screen full filter/sort + every row action (edit/cancel/retry/archive/
133
+ // delete/feedback), backed by the same /api/work-items data + endpoints —
134
+ // with zero duplicated rendering logic. Classic is reachable at /work even
135
+ // with slim-ux ON (only / is taken over). Built with createElement (no
136
+ // innerHTML) to satisfy the dashboard no-unsanitized lint gate.
137
+ function renderQueuedWorkBody(body) {
138
+ var frame = document.createElement('iframe');
139
+ frame.className = 'slim-work-embed';
140
+ frame.src = '/work?embed=1';
141
+ frame.title = 'Work Items';
142
+ body.appendChild(frame);
255
143
  }
256
144
 
257
145
  function renderPrTileBody(body, data) {
@@ -375,7 +263,7 @@
375
263
  var TILE_VIEWS = {
376
264
  engine: { title: 'Engine', render: renderEngineTileBody },
377
265
  dispatches: { title: 'Active dispatches', render: function(body, data) { renderDispatchTileBody(body, data, true); } },
378
- queued: { title: 'Queued work', render: function(body, data) { renderDispatchTileBody(body, data, false); renderPendingSection(body, data); renderCompletedSection(body, data); } },
266
+ queued: { title: 'Queued work', render: function(body) { renderQueuedWorkBody(body); } },
379
267
  prs: { title: 'Pull requests', render: renderPrTileBody },
380
268
  watches: { title: 'Watches', render: renderWatchTileBody },
381
269
  };
@@ -400,6 +288,9 @@
400
288
  // The "+ Link PR" header chip only applies to the PR list.
401
289
  var headerChip = document.getElementById('slim-tile-modal-linkpr');
402
290
  if (headerChip) headerChip.style.display = (key === 'prs') ? '' : 'none';
291
+ // The queued tile embeds the full classic /work screen in an iframe — widen
292
+ // the modal + drop the body padding so the embedded table gets real estate.
293
+ modal.classList.toggle('tile-modal--work', key === 'queued');
403
294
  view.render(body, lastStatusData || {});
404
295
  modal.classList.add('open');
405
296
  }
@@ -680,6 +680,15 @@
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 — the Queued-work tile embeds the full classic /work
684
+ screen in an iframe; widen the modal + remove body padding so the
685
+ embedded work-items table gets the full width/height. */
686
+ #slim-tile-modal.tile-modal--work .modal { width: 1180px; }
687
+ #slim-tile-modal.tile-modal--work .modal-body { padding: 0; }
688
+ .slim-work-embed {
689
+ display: block; width: 100%; height: calc(100vh - 160px); min-height: 360px;
690
+ border: 0; background: var(--bg);
691
+ }
683
692
  .tile-item {
684
693
  display: block;
685
694
  text-decoration: none;
@@ -712,20 +721,6 @@
712
721
  .tile-chip.blue { background: rgba(88, 166, 255, 0.12); color: var(--blue); border-color: var(--blue); }
713
722
  .tile-empty { color: var(--muted); font-style: italic; font-size: var(--text-md); }
714
723
 
715
- /* Collapsible "Completed work" section in the queued popout. Collapsed by
716
- default; the caret rotates and the body unhides on toggle. */
717
- .tile-completed-section { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 10px; }
718
- .tile-completed-section-head {
719
- display: flex; align-items: center; gap: 6px;
720
- cursor: pointer; user-select: none;
721
- font-size: var(--text-md); font-weight: 600; color: var(--text);
722
- padding: 2px 0;
723
- }
724
- .tile-completed-section-head:hover { color: var(--blue); }
725
- .tile-completed-section-head:focus-visible { outline: 1px solid var(--blue); outline-offset: 2px; border-radius: var(--radius); }
726
- .tile-caret { font-size: var(--text-sm); color: var(--muted); width: 12px; display: inline-block; text-align: center; }
727
- .tile-completed-section-body { margin-top: 8px; }
728
-
729
724
  /* Collapsible PR sections (Active / Merged-Completed / Abandoned). Uses
730
725
  native <details>/<summary> — no JS wiring needed for the toggle. */
731
726
  .tile-section { margin-bottom: 10px; }
@@ -1547,3 +1547,24 @@
1547
1547
  .cc-error strong { color: var(--red); }
1548
1548
  .cc-error code { font-family: monospace; font-size: var(--text-xs);
1549
1549
  color: var(--muted); }
1550
+
1551
+ /* W-mqrdggys000f94a4 — chrome-off "embed" mode (?embed=1). Strips the global
1552
+ dashboard chrome so a single page (e.g. /work?embed=1) can be iframed inside
1553
+ the slim cockpit's Queued-work modal and render as just its panel. The
1554
+ html.embed class is applied in layout.html's head before first paint. */
1555
+ html.embed body > header,
1556
+ html.embed #projects-bar,
1557
+ html.embed #setup-banner,
1558
+ html.embed #fre-banner,
1559
+ html.embed .engine-alert,
1560
+ html.embed .paused-banner,
1561
+ html.embed .sidebar,
1562
+ html.embed #cc-drawer,
1563
+ html.embed #cc-overlay { display: none !important; }
1564
+ /* Do NOT override .page-layout to display:block here — that detaches
1565
+ .page-content from its bounded-height flex parent, so it grows to full
1566
+ content height and the fixed-height iframe clips it with no scrollbar (the
1567
+ Queued-work popup "doesn't scroll"). The sidebar is hidden above, so the
1568
+ base .page-layout flex row already gives .page-content full width plus a
1569
+ bounded height to scroll within. */
1570
+ html.embed .page-content { padding: 12px 16px; overflow-y: auto; }
package/dashboard.js CHANGED
@@ -14463,6 +14463,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14463
14463
  "img-src 'self' data:; " +
14464
14464
  "connect-src 'self'"
14465
14465
  );
14466
+ // The Slim UX "Queued work" tile (W-mqrdggys000f94a4) embeds this classic SPA
14467
+ // at /work?embed=1 in a SAME-ORIGIN iframe. buildSecurityHeaders() stamped
14468
+ // X-Frame-Options: DENY on every response above, which makes the browser
14469
+ // refuse to render the iframe ("localhost refused to connect"). Relax to
14470
+ // SAMEORIGIN for the chrome-off embed request only — the standalone classic
14471
+ // dashboard keeps DENY. Mirrors serveSlimUx's embeddable relaxation for /slim.
14472
+ if (/[?&]embed=1(?:&|$)/.test(req.url)) {
14473
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
14474
+ }
14466
14475
  if (req.headers['if-none-match'] === HTML_ETAG) {
14467
14476
  res.statusCode = 304;
14468
14477
  res.end();
@@ -132,6 +132,62 @@ function _isPartialCloneBlobError(message = '') {
132
132
  );
133
133
  }
134
134
 
135
+ // W-mqva907q — auto-clean safe untracked build artifacts before a live-checkout
136
+ // dirty bail. A `git status --porcelain` line for a stray `__pycache__/` (or
137
+ // other regenerable cache) should NOT block dispatch the way a real edit does.
138
+ // These patterns describe untracked-only paths that are always safe to delete
139
+ // because the build/test toolchain regenerates them. Matching is intentionally
140
+ // implemented with plain string checks (no glob dep — Minions ships zero runtime
141
+ // deps). dist/ and build/ are deliberately EXCLUDED: they can hold real,
142
+ // non-regenerable output, so auto-deleting them would be unsafe.
143
+ const LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS = Object.freeze([
144
+ '**/__pycache__/',
145
+ '**/*.pyc',
146
+ '**/*.pyo',
147
+ '**/.pytest_cache/',
148
+ '**/node_modules/.cache/',
149
+ '**/*.egg-info/',
150
+ '.tox/',
151
+ ]);
152
+
153
+ // Returns true when a single untracked path (relative, git-porcelain form) is a
154
+ // known regenerable build artifact safe to delete. Normalizes separators and a
155
+ // trailing slash, and conservatively unquotes git's C-style quoting for paths
156
+ // with special chars. Directory artifacts match on their final path segment so
157
+ // nested copies (e.g. a/b/__pycache__/) are covered like the `**/` globs above.
158
+ function _isAutoCleanableArtifact(rawPath) {
159
+ if (!rawPath || typeof rawPath !== 'string') return false;
160
+ let p = rawPath.trim();
161
+ if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) {
162
+ try { p = JSON.parse(p); } catch { /* keep raw on parse failure */ }
163
+ }
164
+ p = p.replace(/\\/g, '/').replace(/\/+$/, '');
165
+ if (!p) return false;
166
+ if (p.endsWith('.pyc') || p.endsWith('.pyo')) return true;
167
+ if (p.endsWith('.egg-info')) return true;
168
+ if (p === 'node_modules/.cache' || p.endsWith('/node_modules/.cache')) return true;
169
+ const seg = p.split('/').pop();
170
+ if (seg === '__pycache__' || seg === '.pytest_cache' || seg === '.tox') return true;
171
+ return false;
172
+ }
173
+
174
+ // Classifies the porcelain dirty lines for auto-clean. Returns the list of
175
+ // untracked artifact paths ONLY when EVERY dirty entry is an untracked (`?? `)
176
+ // line matching a safe artifact pattern; returns null otherwise (a tracked
177
+ // modification, a staged/deleted change, or any untracked path that is NOT a
178
+ // known artifact). Null means "do not auto-clean — fail dirty as before", which
179
+ // enforces the all-or-nothing contract: partial dirt is never silently cleaned.
180
+ function _collectAutoCleanablePaths(dirtyFiles) {
181
+ const cleanable = [];
182
+ for (const line of dirtyFiles) {
183
+ if (!line.startsWith('?? ')) return null;
184
+ const p = line.slice(3);
185
+ if (!_isAutoCleanableArtifact(p)) return null;
186
+ cleanable.push(p);
187
+ }
188
+ return cleanable.length > 0 ? cleanable : null;
189
+ }
190
+
135
191
  // W-mqvejug6000eeb20 — production default for resolving the live-checkout
136
192
  // auto-reset decision when the caller (engine.js spawnAgent) does not pass an
137
193
  // explicit `autoReset` boolean. engine.js is intentionally not threading the
@@ -178,6 +234,7 @@ async function prepareLiveCheckout(opts = {}) {
178
234
  // defers to `_resolveAutoReset`.
179
235
  _git, // private injection for testing — defaults to shared.shellSafeGit
180
236
  _exists, // private injection for testing — defaults to fs.existsSync
237
+ _rm, // private injection for testing — defaults to fs.rmSync (recursive, force)
181
238
  _resolveAutoReset, // private injection for testing — defaults to config-based resolver
182
239
  _writeInboxNote, // private injection for testing — defaults to dispatch.writeInboxAlert
183
240
  } = opts;
@@ -206,6 +263,8 @@ async function prepareLiveCheckout(opts = {}) {
206
263
 
207
264
  const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
208
265
  const exists = (typeof _exists === 'function') ? _exists : fs.existsSync;
266
+ const rm = (typeof _rm === 'function') ? _rm : ((absPath) => fs.rmSync(absPath, { recursive: true, force: true }));
267
+ const logFn = (typeof log === 'function') ? log : () => {};
209
268
  // W-mqvejug6000eeb20 — default maxBuffer (50 MB) so a large `git status
210
269
  // --porcelain` never overflows execFile's 1 MB ceiling. A caller-supplied
211
270
  // gitOpts.maxBuffer still wins (spread after the default).
@@ -230,91 +289,114 @@ async function prepareLiveCheckout(opts = {}) {
230
289
  // before the dirtyFiles check so callers receive it as `branchInfo`.
231
290
  if (!skipDirtyCheck) {
232
291
  const statusRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
233
- const statusStr = typeof statusRaw === 'string' ? statusRaw : '';
234
- const statusLines = statusStr
235
- .split(/\r?\n/)
236
- .map((line) => line.replace(/\s+$/, ''))
237
- .filter((line) => line.length > 0);
238
- const branchInfo = statusLines.find((line) => line.startsWith('## ')) || '';
239
- const dirtyFiles = statusLines.filter((line) => !line.startsWith('## '));
292
+ const parsePorcelain = (raw) => {
293
+ const lines = (typeof raw === 'string' ? raw : '')
294
+ .split(/\r?\n/)
295
+ .map((line) => line.replace(/\s+$/, ''))
296
+ .filter((line) => line.length > 0);
297
+ return {
298
+ branchInfo: lines.find((line) => line.startsWith('## ')) || '',
299
+ dirtyFiles: lines.filter((line) => !line.startsWith('## ')),
300
+ };
301
+ };
302
+ let { branchInfo, dirtyFiles } = parsePorcelain(statusRaw);
240
303
  if (dirtyFiles.length > 0) {
241
- // W-mqvejug6000eeb20opt-in auto-reset. Default behavior is to bail with
242
- // reason:'dirty' (spawnAgent translates to non-retryable LIVE_CHECKOUT_DIRTY
243
- // and alerts the operator). When auto-reset is enabled either the caller
244
- // passed an explicit `autoReset` boolean, or the config-based resolver says
245
- // so we DISCARD the dirty state via `git fetch origin` + `git reset --hard
246
- // origin/<branch>` and continue. This is destructive (the operator's
247
- // uncommitted work is gone), which is why it is strictly opt-in.
248
- let wantAutoReset = false;
249
- if (typeof autoReset === 'boolean') {
250
- wantAutoReset = autoReset;
251
- } else {
252
- const resolver = (typeof _resolveAutoReset === 'function') ? _resolveAutoReset : _defaultResolveAutoReset;
253
- try { wantAutoReset = !!resolver(localPath); } catch { wantAutoReset = false; }
254
- }
255
-
256
- if (!wantAutoReset) {
257
- return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
304
+ // W-mqva907q — auto-clean safe untracked build artifacts BEFORE bailing. This
305
+ // runs unconditionally (no config flag deleting regenerable caches is always
306
+ // safe) and BEFORE the spawnAgent auto-stash path. Only fires when EVERY dirty
307
+ // entry is an untracked artifact (all-or-nothing); a single tracked edit or
308
+ // non-artifact untracked file means zero cleaning and the original dirty bail.
309
+ const cleanablePaths = _collectAutoCleanablePaths(dirtyFiles);
310
+ if (cleanablePaths) {
311
+ for (const rel of cleanablePaths) {
312
+ const abs = path.isAbsolute(rel) ? rel : path.join(localPath, rel);
313
+ rm(abs);
314
+ }
315
+ logFn(`[live-checkout] auto-cleaned ${cleanablePaths.length} artifact path(s) before dirty check: ${cleanablePaths.join(', ')}`, 'debug');
316
+ // Re-run the dirty check. If cleaning fully resolved the tree we fall
317
+ // through and proceed; if anything remains (e.g. a race re-created a file)
318
+ // we still bail dirty with the post-clean snapshot.
319
+ const reRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
320
+ ({ branchInfo, dirtyFiles } = parsePorcelain(reRaw));
258
321
  }
322
+ if (dirtyFiles.length > 0) {
323
+ // W-mqvejug6000eeb20 — opt-in auto-reset. Default behavior is to bail with
324
+ // reason:'dirty' (spawnAgent translates to non-retryable LIVE_CHECKOUT_DIRTY
325
+ // and alerts the operator). When auto-reset is enabled — either the caller
326
+ // passed an explicit `autoReset` boolean, or the config-based resolver says
327
+ // so — we DISCARD the dirty state via `git fetch origin` + `git reset --hard
328
+ // origin/<branch>` and continue. This is destructive (the operator’s
329
+ // uncommitted work is gone), which is why it is strictly opt-in.
330
+ let wantAutoReset = false;
331
+ if (typeof autoReset === 'boolean') {
332
+ wantAutoReset = autoReset;
333
+ } else {
334
+ const resolver = (typeof _resolveAutoReset === 'function') ? _resolveAutoReset : _defaultResolveAutoReset;
335
+ try { wantAutoReset = !!resolver(localPath); } catch { wantAutoReset = false; }
336
+ }
337
+ if (!wantAutoReset) {
338
+ return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
339
+ }
259
340
 
260
- if (typeof log === 'function') {
261
- log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard origin/${branchName}`);
262
- }
263
- let resetOk = true;
264
- try {
265
- await git(['fetch', 'origin'], baseOpts);
266
- await git(['reset', '--hard', `origin/${branchName}`], baseOpts);
267
- } catch (e) {
268
- resetOk = false;
269
341
  if (typeof log === 'function') {
270
- log(`live-checkout auto-reset FAILED (fetch/reset): ${e && e.message ? e.message : e}`);
342
+ log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard origin/${branchName}`);
271
343
  }
272
- }
273
-
274
- // Re-run the porcelain preflight once. If the tree is now clean we proceed;
275
- // otherwise (reset failed, or something is still dirty) we fall back to the
276
- // safe dirty refusal so we never dispatch onto an unexpected tree.
277
- let stillDirty = dirtyFiles;
278
- if (resetOk) {
344
+ let resetOk = true;
279
345
  try {
280
- const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
281
- const recheckStr = typeof recheckRaw === 'string' ? recheckRaw : '';
282
- stillDirty = recheckStr
283
- .split(/\r?\n/)
284
- .map((line) => line.replace(/\s+$/, ''))
285
- .filter((line) => line.length > 0 && !line.startsWith('## '));
346
+ await git(['fetch', 'origin'], baseOpts);
347
+ await git(['reset', '--hard', `origin/${branchName}`], baseOpts);
286
348
  } catch (e) {
287
349
  resetOk = false;
288
350
  if (typeof log === 'function') {
289
- log(`live-checkout auto-reset re-check FAILED: ${e && e.message ? e.message : e}`);
351
+ log(`live-checkout auto-reset FAILED (fetch/reset): ${e && e.message ? e.message : e}`);
290
352
  }
291
353
  }
292
- }
293
354
 
294
- if (!resetOk || stillDirty.length > 0) {
295
- return { ok: false, reason: 'dirty', dirtyFiles: stillDirty, branchInfo };
296
- }
355
+ // Re-run the porcelain preflight once. If the tree is now clean we proceed;
356
+ // otherwise (reset failed, or something is still dirty) we fall back to the
357
+ // safe dirty refusal so we never dispatch onto an unexpected tree.
358
+ let stillDirty = dirtyFiles;
359
+ if (resetOk) {
360
+ try {
361
+ const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
362
+ const recheckStr = typeof recheckRaw === 'string' ? recheckRaw : '';
363
+ stillDirty = recheckStr
364
+ .split(/\r?\n/)
365
+ .map((line) => line.replace(/\s+$/, ''))
366
+ .filter((line) => line.length > 0 && !line.startsWith('## '));
367
+ } catch (e) {
368
+ resetOk = false;
369
+ if (typeof log === 'function') {
370
+ log(`live-checkout auto-reset re-check FAILED: ${e && e.message ? e.message : e}`);
371
+ }
372
+ }
373
+ }
297
374
 
298
- // Audit trail: record the discarded paths so the operator can recover from
299
- // reflog / understand why their tree changed. Best-effort, never throws.
300
- try {
301
- const writeNote = (typeof _writeInboxNote === 'function') ? _writeInboxNote : _defaultWriteInboxNote;
302
- const slug = `live-checkout-autoreset-${wiId || dispatchId || 'unknown'}`;
303
- const body = [
304
- `# Live-checkout auto-reset on '${branchName}'`,
305
- '',
306
- `⚠️ The live-checkout tree at \`${localPath}\` was dirty at dispatch time and`,
307
- '`liveCheckoutAutoReset` is enabled, so it was force-reset to',
308
- `\`origin/${branchName}\`. **The following uncommitted changes were DISCARDED**`,
309
- '(recover from `git reflog` / `git fsck --lost-found` if needed):',
310
- '',
311
- '```',
312
- ...dirtyFiles,
313
- '```',
314
- ].join('\n');
315
- writeNote(slug, body);
316
- } catch { /* best-effort audit note */ }
317
- // Fall through — tree is now clean, continue with normal preparation.
375
+ if (!resetOk || stillDirty.length > 0) {
376
+ return { ok: false, reason: 'dirty', dirtyFiles: stillDirty, branchInfo };
377
+ }
378
+
379
+ // Audit trail: record the discarded paths so the operator can recover from
380
+ // reflog / understand why their tree changed. Best-effort, never throws.
381
+ try {
382
+ const writeNote = (typeof _writeInboxNote === 'function') ? _writeInboxNote : _defaultWriteInboxNote;
383
+ const slug = `live-checkout-autoreset-${wiId || dispatchId || 'unknown'}`;
384
+ const body = [
385
+ `# Live-checkout auto-reset on '${branchName}'`,
386
+ '',
387
+ `⚠️ The live-checkout tree at \`${localPath}\` was dirty at dispatch time and`,
388
+ '`liveCheckoutAutoReset` is enabled, so it was force-reset to',
389
+ `\`origin/${branchName}\`. **The following uncommitted changes were DISCARDED**`,
390
+ '(recover from `git reflog` / `git fsck --lost-found` if needed):',
391
+ '',
392
+ '```',
393
+ ...dirtyFiles,
394
+ '```',
395
+ ].join('\n');
396
+ writeNote(slug, body);
397
+ } catch { /* best-effort audit note */ }
398
+ // Fall through — tree is now clean, continue with normal preparation.
399
+ }
318
400
  }
319
401
  }
320
402
 
@@ -817,4 +899,12 @@ async function maybeRestoreLiveCheckoutFromRecord(opts = {}) {
817
899
  }
818
900
  }
819
901
 
820
- module.exports = { prepareLiveCheckout, restoreLiveCheckoutAtDispatchEnd, maybeRestoreLiveCheckoutFromRecord, _isPartialCloneBlobError };
902
+ module.exports = {
903
+ prepareLiveCheckout,
904
+ restoreLiveCheckoutAtDispatchEnd,
905
+ maybeRestoreLiveCheckoutFromRecord,
906
+ _isPartialCloneBlobError,
907
+ _isAutoCleanableArtifact,
908
+ _collectAutoCleanablePaths,
909
+ LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS,
910
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2304",
3
+ "version": "0.1.2305",
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"