@yemi33/minions 0.1.2305 → 0.1.2307

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.
Files changed (39) hide show
  1. package/bin/minions.js +1 -0
  2. package/dashboard/js/refresh.js +17 -13
  3. package/dashboard/js/render-other.js +251 -3
  4. package/dashboard/js/render-plans.js +1 -1
  5. package/dashboard/js/render-prs.js +4 -1
  6. package/dashboard/js/render-utils.js +13 -0
  7. package/dashboard/js/render-work-items.js +11 -13
  8. package/dashboard/js/settings.js +30 -3
  9. package/dashboard/slim/body.html +6 -22
  10. package/dashboard/slim/js/modals-tiles.js +32 -22
  11. package/dashboard/slim/js/plans.js +24 -516
  12. package/dashboard/slim/styles.css +10 -7
  13. package/dashboard/styles.css +58 -0
  14. package/dashboard.js +66 -9
  15. package/docs/completion-reports.md +27 -2
  16. package/docs/copilot-cli-schema.md +31 -1
  17. package/docs/design-state-storage.md +1 -1
  18. package/docs/harness-transparency.md +18 -0
  19. package/docs/live-checkout-mode.md +57 -8
  20. package/engine/ado-comment.js +5 -2
  21. package/engine/ado.js +37 -0
  22. package/engine/cli.js +58 -3
  23. package/engine/comment-format.js +82 -2
  24. package/engine/consolidation.js +59 -2
  25. package/engine/discover-project-skills.js +4 -0
  26. package/engine/dispatch.js +36 -1
  27. package/engine/gh-comment.js +8 -3
  28. package/engine/lifecycle.js +241 -25
  29. package/engine/live-checkout.js +335 -7
  30. package/engine/playbook.js +2 -2
  31. package/engine/pre-dispatch-eval.js +54 -5
  32. package/engine/queries.js +11 -7
  33. package/engine/shared.js +96 -6
  34. package/engine/supervisor.js +144 -19
  35. package/engine/watchdog.js +10 -0
  36. package/engine.js +210 -5
  37. package/package.json +1 -1
  38. package/playbooks/review.md +2 -0
  39. package/playbooks/shared-rules.md +9 -0
package/bin/minions.js CHANGED
@@ -1580,6 +1580,7 @@ ${fs.existsSync(path.join(PKG_ROOT, '.git')) ? `
1580
1580
  isPortListening,
1581
1581
  confirmPortUp: tcpPortAccepts,
1582
1582
  isStopIntentSet: shared.isStopIntentSet || (() => false),
1583
+ recordRespawn: shared.recordEngineRespawn,
1583
1584
  }).then(result => {
1584
1585
  // ALWAYS exit 0 — the scheduler must never see a failure for the
1585
1586
  // recovery we're trying to make boring. The `result` is logged by tick
@@ -134,7 +134,8 @@ const RENDER_VERSIONS = {
134
134
  prdProgress: 2,
135
135
  prdPrs: 1,
136
136
  inbox: 2,
137
- projects: 4,
137
+ // Bumped 4→5 for the clickable checkout-mode pill + picker (W-mr1b67zi0006b788).
138
+ projects: 5,
138
139
  notes: 1,
139
140
  prd: 3,
140
141
  prs: 3,
@@ -147,7 +148,7 @@ const RENDER_VERSIONS = {
147
148
  dispatch: 2,
148
149
  engineLog: 2,
149
150
  metrics: 1,
150
- workItems: 6,
151
+ workItems: 7,
151
152
  skills: 1,
152
153
  commands: 1,
153
154
  mcpServers: 1,
@@ -1066,8 +1067,17 @@ let _refreshInFlight = false;
1066
1067
  // it's not obvious the dashboard itself is dead (vs. wedged, vs. the
1067
1068
  // engine being down).
1068
1069
  //
1069
- // Trip conditions: 2 consecutive failed polls OR >12 s since the last
1070
- // success (3× the 4 s poll cadence, so a single flaky tick doesn't fire).
1070
+ // Trip conditions (2026-06-30): SUSTAINED failure only the banner must never
1071
+ // flash while the dashboard is merely slow. 3 consecutive failed polls
1072
+ // (timeout/abort or 5xx). At the 4s cadence a wedged dashboard aborts each poll
1073
+ // at safeFetch's 15s timeout, so 3 in a row is ~45s of continuous
1074
+ // unresponsiveness — well beyond the legitimate 15-25s cold getStatus() rebuild
1075
+ // documented in restart-health.js. A truly-dead dashboard still trips promptly
1076
+ // (~12s) because connection-refused rejects fast rather than waiting out the
1077
+ // 15s timeout. The old "> N seconds since last success" OR side was REMOVED:
1078
+ // aborts during a healthy cold rebuild pushed elapsed-time past the budget and
1079
+ // surfaced the banner while the dashboard was alive-but-busy. Consecutive
1080
+ // failures are the only reliable "actually unreachable" signal.
1071
1081
  // On trip we show a sticky red banner with two recovery actions:
1072
1082
  // 1. "Restart Minions" → ccRestartMinions (works when dashboard is
1073
1083
  // alive-but-stale; falls through to copy-to-clipboard when the POST
@@ -1080,14 +1090,7 @@ let _lastStatusOkAt = Date.now();
1080
1090
  let _consecutiveStatusFails = 0;
1081
1091
  let _unreachableSince = 0; // 0 = currently reachable
1082
1092
  let _unreachableAgeTimer = null;
1083
- // 3 + 20s (was 2 + 12s) — the prior thresholds tripped a banner on a single
1084
- // safeFetch abort (timeout 15s in state.js) because the OR'd age side was
1085
- // already satisfied. With safeFetch=15s, the age budget needs >15s to avoid
1086
- // the trip-on-first-slow-response footgun. 3 consecutive fails ≈ 12s of real
1087
- // outage at the 4s poll cadence, which still surfaces the banner promptly
1088
- // during a real dashboard crash but tolerates one slow rebuild + retry.
1089
1093
  const _UNREACHABLE_FAIL_THRESHOLD = 3;
1090
- const _UNREACHABLE_AGE_MS = 20000;
1091
1094
  // Once the banner is up we throttle polls (exponential, capped at 30s) to
1092
1095
  // avoid hammering a struggling dashboard / network. Reset on recovery.
1093
1096
  let _nextPollAllowedAt = 0;
@@ -1345,8 +1348,9 @@ async function refresh(opts) {
1345
1348
  _diagEntry.error_message = String((e && e.message) || e);
1346
1349
  }
1347
1350
  _consecutiveStatusFails++;
1348
- const ageMs = Date.now() - _lastStatusOkAt;
1349
- if (_consecutiveStatusFails >= _UNREACHABLE_FAIL_THRESHOLD || ageMs > _UNREACHABLE_AGE_MS) {
1351
+ // Sustained failure only see the detector header for why elapsed time
1352
+ // alone must not trip (it flashed the banner on healthy-but-slow rebuilds).
1353
+ if (_consecutiveStatusFails >= _UNREACHABLE_FAIL_THRESHOLD) {
1350
1354
  _markDashboardUnreachable(e);
1351
1355
  }
1352
1356
  // Backoff: once we've tripped the banner, throttle subsequent polls
@@ -96,18 +96,266 @@ function _renderProjectBranch(p) {
96
96
  // is set, coding WIs author in isolated worktrees and only the named validation
97
97
  // type runs in-place on the live checkout. This is a materially different
98
98
  // dispatch profile from full-live, so it gets its own pill.
99
+ // W-mr1b67zi0006b788: the pill is clickable — opens an inline picker (see
100
+ // _openCheckoutModeMenu below) to switch between Worktrees / Live checkout /
101
+ // Hybrid dispatch modes without leaving the Projects view. Shared
102
+ // data-checkout-pill / role="button" / tabindex / aria-haspopup attributes
103
+ // are attached to all three pill variants; the delegated listener wired at
104
+ // the bottom of this file reads data-checkout-pill to know which project to
105
+ // open the menu for.
99
106
  function _renderWorktreeModePill(p) {
100
107
  if (!p) return '';
108
+ const name = escHtml(p.name);
109
+ const common = ' data-checkout-pill="' + name + '" role="button" tabindex="0" aria-haspopup="true" aria-label="Change checkout mode"';
101
110
  if (p.checkoutMode === 'live') {
102
111
  if (p.liveValidationType) {
103
112
  const vt = escapeHtml(p.liveValidationType);
104
- return ' <span class="project-mode-pill project-mode-hybrid" title="Hybrid live-validation — coding work items author in isolated worktrees; only the &quot;' + vt + '&quot; validation type runs in-place on the live checkout (capped to one mutating dispatch, refused on a dirty tree)">⚡ Hybrid · ' + vt + '</span>';
113
+ return ' <span class="project-mode-pill project-mode-hybrid project-mode-pill-clickable"' + common + ' title="Hybrid live-validation — coding work items author in isolated worktrees; only the &quot;' + vt + '&quot; validation type runs in-place on the live checkout (capped to one mutating dispatch, refused on a dirty tree). Click to change.">⚡ Hybrid · ' + vt + '</span>';
105
114
  }
106
- return ' <span class="project-mode-pill project-mode-live" title="Live-checkout dispatch mode — agents run in-place inside the project working tree (no isolated worktree); capped to one mutating dispatch and refused on a dirty tree">⚡ Live checkout</span>';
115
+ return ' <span class="project-mode-pill project-mode-live project-mode-pill-clickable"' + common + ' title="Live-checkout dispatch mode — agents run in-place inside the project working tree (no isolated worktree); capped to one mutating dispatch and refused on a dirty tree. Click to change.">⚡ Live checkout</span>';
107
116
  }
108
- return ' <span class="project-mode-pill project-mode-isolated" title="Worktree dispatch mode (default) — each agent runs in its own git worktree">Worktrees</span>';
117
+ return ' <span class="project-mode-pill project-mode-isolated project-mode-pill-clickable"' + common + ' title="Worktree dispatch mode (default) — each agent runs in its own git worktree. Click to change.">Worktrees</span>';
109
118
  }
110
119
 
120
+ // Work-item types eligible for hybrid live-validation (W-mr1b67zi0006b788).
121
+ // Mirrors the set the server documents for project.liveValidation.type;
122
+ // intentionally excludes the illustrative "build-and-test" example from the
123
+ // server comments since that isn't a real WORK_TYPE value.
124
+ const CHECKOUT_MODE_HYBRID_TYPES = ['implement', 'fix', 'docs', 'decompose', 'explore', 'ask', 'review', 'test', 'verify', 'setup'];
125
+ const CHECKOUT_MODE_HYBRID_DEFAULT_TYPE = 'test';
126
+
127
+ function _findProjectInLastStatus(name) {
128
+ const projects = window._lastStatus && window._lastStatus.projects;
129
+ if (!Array.isArray(projects)) return null;
130
+ return projects.find(function(p) { return p.name === name; }) || null;
131
+ }
132
+
133
+ function _closeCheckoutModeMenu() {
134
+ const existing = document.getElementById('checkout-mode-menu');
135
+ if (existing) existing.remove();
136
+ document.removeEventListener('mousedown', _checkoutModeMenuOutsideClick, true);
137
+ document.removeEventListener('keydown', _checkoutModeMenuKeydown, true);
138
+ }
139
+
140
+ function _checkoutModeMenuOutsideClick(e) {
141
+ const menu = document.getElementById('checkout-mode-menu');
142
+ if (!menu) return;
143
+ if (menu.contains(e.target)) return;
144
+ if (e.target.closest && e.target.closest('[data-checkout-pill]')) return;
145
+ _closeCheckoutModeMenu();
146
+ }
147
+
148
+ function _checkoutModeMenuKeydown(e) {
149
+ if (e.key === 'Escape') _closeCheckoutModeMenu();
150
+ }
151
+
152
+ // Builds and positions the checkout-mode picker popover beneath the clicked
153
+ // pill. No innerHTML is used here (createElement/textContent throughout) to
154
+ // stay clear of the innerHTML lint gate (npm run lint).
155
+ function _openCheckoutModeMenu(projectName, anchorEl) {
156
+ _closeCheckoutModeMenu();
157
+ const project = _findProjectInLastStatus(projectName);
158
+ if (!project) return;
159
+
160
+ const menu = document.createElement('div');
161
+ menu.id = 'checkout-mode-menu';
162
+ menu.className = 'checkout-mode-menu';
163
+ menu.setAttribute('role', 'menu');
164
+
165
+ const title = document.createElement('div');
166
+ title.className = 'checkout-mode-menu-title';
167
+ title.textContent = 'Checkout mode — ' + projectName;
168
+ menu.appendChild(title);
169
+
170
+ function addItem(labelText, mode, descText) {
171
+ const item = document.createElement('div');
172
+ item.className = 'checkout-mode-menu-item';
173
+ item.setAttribute('role', 'menuitemradio');
174
+ item.tabIndex = 0;
175
+ const label = document.createElement('div');
176
+ label.className = 'checkout-mode-menu-item-label';
177
+ label.textContent = labelText;
178
+ item.appendChild(label);
179
+ if (descText) {
180
+ const desc = document.createElement('div');
181
+ desc.className = 'checkout-mode-menu-item-desc';
182
+ desc.textContent = descText;
183
+ item.appendChild(desc);
184
+ }
185
+ const isActive = (mode === 'worktree' && project.checkoutMode !== 'live')
186
+ || (mode === 'live' && project.checkoutMode === 'live' && !project.liveValidationType)
187
+ || (mode === 'hybrid' && project.checkoutMode === 'live' && !!project.liveValidationType);
188
+ if (isActive) {
189
+ item.setAttribute('aria-checked', 'true');
190
+ item.classList.add('checkout-mode-menu-item-active');
191
+ } else {
192
+ item.setAttribute('aria-checked', 'false');
193
+ }
194
+ item.addEventListener('click', function() {
195
+ if (mode === 'hybrid') {
196
+ _renderCheckoutModeHybridStep(menu, projectName);
197
+ return;
198
+ }
199
+ _closeCheckoutModeMenu();
200
+ _applyCheckoutModeChange(projectName, mode === 'live' ? 'live' : 'worktree', null);
201
+ });
202
+ item.addEventListener('keydown', function(e) {
203
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); item.click(); }
204
+ });
205
+ menu.appendChild(item);
206
+ }
207
+
208
+ addItem('Worktrees (default)', 'worktree', 'Each dispatch runs in its own isolated git worktree. Fully parallel, never touches your working directory.');
209
+ addItem('Live checkout', 'live', 'Agents run in-place in the project folder. One mutating dispatch at a time; refuses to run on a dirty tree.');
210
+ addItem('Hybrid dispatch…', 'hybrid', 'Coding work authors in isolated worktrees; only a chosen validation step (e.g. build-and-test) runs on the real checkout.');
211
+
212
+ document.body.appendChild(menu);
213
+ const rect = anchorEl.getBoundingClientRect();
214
+ menu.style.position = 'absolute';
215
+ menu.style.top = (window.scrollY + rect.bottom + 4) + 'px';
216
+ menu.style.left = (window.scrollX + rect.left) + 'px';
217
+
218
+ document.addEventListener('mousedown', _checkoutModeMenuOutsideClick, true);
219
+ document.addEventListener('keydown', _checkoutModeMenuKeydown, true);
220
+ const first = menu.querySelector('.checkout-mode-menu-item');
221
+ if (first) first.focus();
222
+ }
223
+
224
+ // Second step of the picker for hybrid mode: pick which work-item type runs
225
+ // in-place on the live checkout while everything else authors in isolated
226
+ // worktrees. Renders a <select> + Back/Apply into the existing menu element.
227
+ function _renderCheckoutModeHybridStep(menu, projectName) {
228
+ const project = _findProjectInLastStatus(projectName);
229
+ while (menu.firstChild) menu.removeChild(menu.firstChild);
230
+
231
+ const title = document.createElement('div');
232
+ title.className = 'checkout-mode-menu-title';
233
+ title.textContent = 'Hybrid dispatch — ' + projectName;
234
+ menu.appendChild(title);
235
+
236
+ const label = document.createElement('div');
237
+ label.className = 'checkout-mode-menu-item';
238
+ label.textContent = 'Work-item type to validate live:';
239
+ menu.appendChild(label);
240
+
241
+ const select = document.createElement('select');
242
+ select.className = 'checkout-mode-menu-select';
243
+ CHECKOUT_MODE_HYBRID_TYPES.forEach(function(t) {
244
+ const opt = document.createElement('option');
245
+ opt.value = t;
246
+ opt.textContent = t;
247
+ select.appendChild(opt);
248
+ });
249
+ select.value = (project && project.liveValidationType && CHECKOUT_MODE_HYBRID_TYPES.includes(project.liveValidationType))
250
+ ? project.liveValidationType
251
+ : CHECKOUT_MODE_HYBRID_DEFAULT_TYPE;
252
+ menu.appendChild(select);
253
+
254
+ const actions = document.createElement('div');
255
+ actions.className = 'checkout-mode-menu-actions';
256
+
257
+ const backBtn = document.createElement('button');
258
+ backBtn.type = 'button';
259
+ backBtn.className = 'checkout-mode-menu-btn';
260
+ backBtn.textContent = 'Back';
261
+ backBtn.addEventListener('click', function() {
262
+ const anchor = document.querySelector('[data-checkout-pill="' + CSS.escape(projectName) + '"]');
263
+ _closeCheckoutModeMenu();
264
+ if (anchor) _openCheckoutModeMenu(projectName, anchor);
265
+ });
266
+ actions.appendChild(backBtn);
267
+
268
+ const applyBtn = document.createElement('button');
269
+ applyBtn.type = 'button';
270
+ applyBtn.className = 'checkout-mode-menu-btn checkout-mode-menu-btn-primary';
271
+ applyBtn.textContent = 'Apply';
272
+ applyBtn.addEventListener('click', function() {
273
+ const type = select.value;
274
+ _closeCheckoutModeMenu();
275
+ _applyCheckoutModeChange(projectName, 'live', type);
276
+ });
277
+ actions.appendChild(applyBtn);
278
+
279
+ menu.appendChild(actions);
280
+ }
281
+
282
+ // Applies a checkout-mode change: confirms via confirmDialog(), optimistically
283
+ // flips the cached project entry and re-renders BEFORE awaiting the network
284
+ // call (mirrors projectChipRemove / removePinnedNote), then POSTs to
285
+ // /api/settings. On failure, reverts the optimistic flip, re-renders, and
286
+ // shows an error toast.
287
+ async function _applyCheckoutModeChange(projectName, newMode, liveValidationType) {
288
+ const project = _findProjectInLastStatus(projectName);
289
+ if (!project) return;
290
+ const label = newMode === 'worktree'
291
+ ? 'Worktrees'
292
+ : (liveValidationType ? ('Hybrid (' + liveValidationType + ')') : 'Live checkout');
293
+ const ok = await confirmDialog({
294
+ title: 'Change checkout mode?',
295
+ message: 'Switch "' + projectName + '" to ' + label + '? This changes how every future dispatch on this project runs.',
296
+ confirmLabel: 'Change mode',
297
+ cancelLabel: 'Cancel',
298
+ danger: newMode !== 'worktree',
299
+ });
300
+ if (!ok) return;
301
+
302
+ const prevMode = project.checkoutMode;
303
+ const prevType = project.liveValidationType;
304
+ const nextType = (newMode === 'live' && liveValidationType) ? liveValidationType : null;
305
+ // Optimistic flip BEFORE awaiting the API call (per dashboard convention —
306
+ // see projectChipRemove / removePinnedNote); reverted in the catch below.
307
+ project.checkoutMode = newMode;
308
+ project.liveValidationType = nextType;
309
+ if (window._lastStatus && Array.isArray(window._lastStatus.projects)) renderProjects(window._lastStatus.projects);
310
+
311
+ try {
312
+ const res = await safeFetch('/api/settings', {
313
+ method: 'POST',
314
+ headers: { 'Content-Type': 'application/json' },
315
+ body: JSON.stringify({
316
+ projects: [{
317
+ name: projectName,
318
+ checkoutMode: newMode,
319
+ liveValidation: nextType ? { type: nextType } : null,
320
+ }],
321
+ }),
322
+ });
323
+ const result = await res.json().catch(function() { return {}; });
324
+ if (!res.ok) throw new Error((result && result.error) || ('HTTP ' + res.status));
325
+ showToast('cmd-toast', 'Checkout mode updated for "' + projectName + '".', true);
326
+ } catch (err) {
327
+ project.checkoutMode = prevMode;
328
+ project.liveValidationType = prevType;
329
+ if (window._lastStatus && Array.isArray(window._lastStatus.projects)) renderProjects(window._lastStatus.projects);
330
+ showToast('cmd-toast', 'Failed to update checkout mode: ' + (err && err.message || err), false);
331
+ }
332
+ }
333
+
334
+ // Delegated click/keydown on the persistent #projects-list container (its
335
+ // children are replaced wholesale on every renderProjects() call, but the
336
+ // container itself survives re-renders — see the Long-Running Commands /
337
+ // event-delegation convention referenced in the playbook).
338
+ (function _wireCheckoutModePillDelegation() {
339
+ const list = document.getElementById('projects-list');
340
+ if (!list) return;
341
+ list.addEventListener('click', function(e) {
342
+ const pill = e.target.closest('[data-checkout-pill]');
343
+ if (!pill) return;
344
+ e.stopPropagation();
345
+ const name = pill.getAttribute('data-checkout-pill');
346
+ _openCheckoutModeMenu(name, pill);
347
+ });
348
+ list.addEventListener('keydown', function(e) {
349
+ if (e.key !== 'Enter' && e.key !== ' ') return;
350
+ const pill = e.target.closest('[data-checkout-pill]');
351
+ if (!pill) return;
352
+ e.preventDefault();
353
+ e.stopPropagation();
354
+ const name = pill.getAttribute('data-checkout-pill');
355
+ _openCheckoutModeMenu(name, pill);
356
+ });
357
+ })();
358
+
111
359
  function _projectCachePath(project) {
112
360
  return String((project && (project.localPath || project.path)) || '').replace(/\\/g, '/');
113
361
  }
@@ -371,7 +371,7 @@ function renderPlans(plans) {
371
371
  'onclick="event.stopPropagation();planDelete(\'' + escapeHtml(p.file) + '\')">Delete</button>' : '';
372
372
 
373
373
  const versionBadge = p.version ? ' <span style="font-size:var(--text-xs);font-weight:700;padding:1px 5px;border-radius:3px;background:rgba(56,139,253,0.15);color:var(--blue);vertical-align:middle">v' + p.version + '</span>' : '';
374
- const statusColors = { 'completed': 'var(--green)', 'dispatched': 'var(--blue)', 'converting': 'var(--yellow)', 'paused': 'var(--muted)', 'awaiting-approval': 'var(--yellow)', 'approved': 'var(--green)', 'rejected': 'var(--red)', 'has-failures': 'var(--red)', 'revision-requested': 'var(--purple,#a855f7)', 'active': 'var(--muted)', 'draft': 'var(--muted)' };
374
+ const statusColors = { 'completed': 'var(--green)', 'dispatched': 'var(--blue)', 'converting': 'var(--yellow)', 'paused': 'var(--muted)', 'awaiting-approval': 'var(--yellow)', 'approved': 'var(--purple,#a855f7)', 'rejected': 'var(--red)', 'has-failures': 'var(--red)', 'revision-requested': 'var(--purple,#a855f7)', 'active': 'var(--muted)', 'draft': 'var(--muted)' };
375
375
  const cardClass = effectiveStatus === 'dispatched' || effectiveStatus === 'converting' ? 'working' : effectiveStatus === 'awaiting-approval' || effectiveStatus === 'paused' ? 'awaiting' : effectiveStatus;
376
376
  // P-e8d49105 — cross-repo plans surface every touched project as its
377
377
  // own badge. Single-project plans (and old PRDs without _projects)
@@ -646,7 +646,10 @@ async function togglePrObserve(btn) {
646
646
  try {
647
647
  const res = await fetch('/api/pull-requests/observe', {
648
648
  method: 'POST', headers: { 'Content-Type': 'application/json' },
649
- body: JSON.stringify({ host, slug, number, observe: next })
649
+ // Canonical contextOnly is the primary payload (P-2b6e4d81); the
650
+ // legacy `observe` body param remains a server-side back-compat
651
+ // fallback for any other caller, so the client no longer sends it.
652
+ body: JSON.stringify({ host, slug, number, contextOnly: !next })
650
653
  });
651
654
  if (!res.ok) {
652
655
  const d = await res.json().catch(() => ({}));
@@ -114,6 +114,19 @@ function ccSegmentsApplyChunk(segments, text, segmentId) {
114
114
  var last = segments.length ? segments[segments.length - 1] : null;
115
115
  if (hasId) {
116
116
  if (last && last.kind === 'text' && last.id === segmentId) { last.text = text; return segments; }
117
+ // W-mr1bgfyr — a reconnect replay (or any other id-less push, see the
118
+ // heuristic branch below) leaves the trailing text segment with `id:
119
+ // null` even though the resumed live stream now carries a real
120
+ // segmentId for that SAME logical block. Without this, the id
121
+ // mismatch (null !== segmentId) opened a brand-new duplicate text
122
+ // segment on every following chunk, rendering as a second bubble/block
123
+ // stacked under the first with a little more text each time instead of
124
+ // updating in place. Adopt the id and merge into the existing segment.
125
+ if (last && last.kind === 'text' && last.id === null) {
126
+ last.text = _ccSegMergeText(last.text, text);
127
+ last.id = segmentId;
128
+ return segments;
129
+ }
117
130
  segments.push({ kind: 'text', id: segmentId, text: text });
118
131
  return segments;
119
132
  }
@@ -876,10 +876,17 @@ function _wiRenderDetail(item) {
876
876
  // Artifacts — branch (non-navigation) + navigation chips routed through
877
877
  // renderArtifactLink so chip click → openArtifact() → modal-stack push +
878
878
  // URL hash + browser-Back integration come for free (P-e265cd31).
879
- // P-79b47b0c also surface item._source (the WI's raw source file) as a
880
- // dedicated chip when it isn't already covered by arts.plan / arts.prd /
881
- // arts.sourcePlan. Chip type is inferred from the file extension (.md →
882
- // plan, .json prd).
879
+ // NOTE (issue #603): item._source is the WI's *project name* (stamped by
880
+ // fetchWorkItemsFromDisk above / used by every action button to route
881
+ // retry/cancel/delete/etc.), NOT a plan/PRD file id. A prior "Source" chip
882
+ // here treated item._source as if it held a genuine source-plan/PRD
883
+ // filename, which rendered a bogus chip labeled with the project name on
884
+ // nearly every work item and 404'd when clicked (there is no plan/PRD
885
+ // named e.g. "src"). The genuine source-plan/PRD case is already fully
886
+ // covered by arts.plan / arts.prd / arts.sourcePlan below, which are
887
+ // populated server-side from the WI's real sourcePlan/_planFileName/
888
+ // planFile fields (engine/queries.js) — no separate item._source-based
889
+ // chip is needed.
883
890
  var arts = item._artifacts || {};
884
891
  var artPills = '';
885
892
  var branchStyle = 'display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:10px;font-size:var(--text-sm);background:var(--surface2);border:1px solid var(--border);color:var(--text);cursor:default';
@@ -887,15 +894,6 @@ function _wiRenderDetail(item) {
887
894
  if (arts.plan) artPills += renderArtifactLink({ type: 'plan', id: arts.plan, label: 'Plan' }) + ' ';
888
895
  if (arts.prd) artPills += renderArtifactLink({ type: 'prd', id: arts.prd, label: 'PRD' }) + ' ';
889
896
  if (arts.sourcePlan) artPills += renderArtifactLink({ type: 'plan', id: arts.sourcePlan, label: 'Source Plan' }) + ' ';
890
- // Source chip (item._source) — render when it's a file path that isn't
891
- // already represented in arts.plan / arts.prd / arts.sourcePlan.
892
- if (item._source && typeof item._source === 'string' && item._source !== 'central') {
893
- var sourceAlreadyShown = arts.plan === item._source || arts.prd === item._source || arts.sourcePlan === item._source;
894
- if (!sourceAlreadyShown) {
895
- var sourceType = item._source.endsWith('.json') ? 'prd' : 'plan';
896
- artPills += renderArtifactLink({ type: sourceType, id: item._source, label: 'Source', title: item._source }) + ' ';
897
- }
898
- }
899
897
  if (arts.notes && arts.notes.length > 0) {
900
898
  arts.notes.forEach(function(n) {
901
899
  var noteFile = (n && typeof n === 'object') ? (n.file || n) : String(n || '');
@@ -282,13 +282,30 @@ async function openSettings() {
282
282
  '<div data-search="' + escHtml(wtModeSearch) + '" style="margin-bottom:6px">' +
283
283
  '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Checkout mode</label>' +
284
284
  '<select id="set-checkoutMode-' + escHtml(p.name) + '" data-checkout-mode-select="' + escHtml(p.name) + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
285
- '<option value="worktree"' + (currentWtMode === 'worktree' ? ' selected' : '') + '>Worktree (default)</option>' +
286
- '<option value="live"' + (currentWtMode === 'live' ? ' selected' : '') + '>Live checkout</option>' +
285
+ '<option value="worktree" title="Each dispatch runs in its own isolated git worktree. Fully parallel, never touches your working directory."' + (currentWtMode === 'worktree' ? ' selected' : '') + '>Worktree (default)</option>' +
286
+ '<option value="live" title="Agents run in-place in the project folder. One mutating dispatch at a time; refuses to run on a dirty tree."' + (currentWtMode === 'live' ? ' selected' : '') + '>Live checkout</option>' +
287
287
  '</select>' +
288
288
  '<div data-checkout-mode-chip="' + escHtml(p.name) + '" style="' + (currentWtMode === 'live' ? '' : 'display:none;') + 'margin-top:6px;padding:6px 8px;background:rgba(234,179,8,0.12);border:1px solid var(--yellow);border-radius:4px;color:var(--yellow);font-size:var(--text-xs);line-height:1.4">' +
289
289
  '⚠ Live mode: dispatches run directly in this repo\'s checkout. Only one mutating dispatch runs at a time. Dirty working trees block dispatch — commit or stash before running.' +
290
290
  '</div>' +
291
291
  '</div>';
292
+ // W-mqtvnnj1000357fa — per-project live-checkout auto-stash override.
293
+ // Tri-state: "" (use fleet default → engine.liveCheckoutAutoStash), "on"
294
+ // (force stash), "off" (force fail-on-dirty). Only an explicit on/off
295
+ // overrides the fleet setting; the default option leaves p.liveCheckoutAutoStash
296
+ // unset so resolveLiveCheckoutAutoStash falls back to engine.
297
+ var autoStashRaw = (p.liveCheckoutAutoStash === true) ? 'on' : (p.liveCheckoutAutoStash === false) ? 'off' : '';
298
+ var autoStashSearch = 'live checkout auto-stash dirty tree stash dispatch';
299
+ var autoStashBlock =
300
+ '<div data-search="' + escHtml(autoStashSearch) + '" style="margin-bottom:6px">' +
301
+ '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Live-checkout auto-stash</label>' +
302
+ '<select id="set-liveCheckoutAutoStash-' + escHtml(p.name) + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
303
+ '<option value=""' + (autoStashRaw === '' ? ' selected' : '') + '>Use fleet default</option>' +
304
+ '<option value="on"' + (autoStashRaw === 'on' ? ' selected' : '') + '>On — auto-stash dirty tree</option>' +
305
+ '<option value="off"' + (autoStashRaw === 'off' ? ' selected' : '') + '>Off — fail on dirty tree</option>' +
306
+ '</select>' +
307
+ '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:2px;line-height:1.4">Live mode only. On = `git stash push --include-untracked` a dirty tree before dispatch instead of failing; the engine never pops the stash (run `git stash pop` manually). Overrides the fleet-wide setting under Worktrees.</div>' +
308
+ '</div>';
292
309
  return '<div data-settings-project="' + escHtml(p.name) + '" data-search="project ' + escHtml(p.name.toLowerCase()) + '" style="border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:12px">' +
293
310
  '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
294
311
  '<div style="font-size:var(--text-md);font-weight:600">' + escHtml(p.name) + '</div>' +
@@ -297,6 +314,7 @@ async function openSettings() {
297
314
  pathRow +
298
315
  branchGrid +
299
316
  worktreeModeBlock +
317
+ autoStashBlock +
300
318
  driftNote +
301
319
  '<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">' +
302
320
  settingsToggle('Discover from PRs', 'set-ws-prs-' + p.name, p.workSources.pullRequests.enabled, 'Discovery gate: scan repo for open PRs and surface them as review tasks. Independent of ADO/GitHub polling — does not affect already-tracked PRs.') +
@@ -384,7 +402,7 @@ async function openSettings() {
384
402
  settingsToggle('Auto-reap orphan worktree holders', 'set-autoReapOrphanWorktreeHolders', !!e.autoReapOrphanWorktreeHolders,
385
403
  'When ON, the periodic orphan-worktree sweep automatically kills unambiguous orphan `spawn-agent.js` processes holding a stuck worktree\'s cwd (Windows file-lock pattern, e.g. W-mq1k8z6o003acd89 / PID 16828). Safe-reap criteria: cmdline must match spawn-agent.js, reference the worktree basename, AND process age must exceed `engine.agentTimeout * 2`. Default OFF because killing a foreign process is destructive. The holder SCAN runs either way and is appended to the escalation inbox note.') +
386
404
  settingsToggle('Live-checkout auto-reset (fleet-wide)', 'set-liveCheckoutAutoReset', !!e.liveCheckoutAutoReset,
387
- 'When ON, a dirty or broken live-checkout (in-place) tree is force-recovered before dispatch via `git fetch origin` + `git reset --hard origin/<branch>` instead of failing the dispatch with non-retryable LIVE_CHECKOUT_DIRTY. This is the FLEET-WIDE fallback; a per-project `liveCheckoutAutoReset` in config.json overrides it. Default OFF because the reset is DESTRUCTIVE it discards the operator\'s uncommitted changes in the live checkout (a `live-checkout-autoreset-<workItem>` inbox note records exactly what was discarded for reflog recovery). Only affects projects running in live/in-place checkout mode.') +
405
+ 'When ON, a dirty or broken live-checkout (in-place) tree is force-recovered before dispatch via `git fetch origin` + `git reset --hard origin/<branch>` instead of failing the dispatch with non-retryable LIVE_CHECKOUT_DIRTY. This is the FLEET-WIDE fallback; a per-project `liveCheckoutAutoReset` in config.json overrides it. Default ON discarding disposable dirt keeps live-checkout branches aligned with origin instead of accumulating auto-save WIP commits; the reset is DESTRUCTIVE (it discards the operator\'s uncommitted changes in the live checkout, and a `live-checkout-autoreset-<workItem>` inbox note records exactly what was discarded for reflog recovery), so set it OFF for live checkouts whose local drift you want preserved. Only affects projects running in live/in-place checkout mode.') +
388
406
  '</div>' +
389
407
  '<div class="settings-grid-2">' +
390
408
  settingsField('CC Session Idle Timeout', 'set-ccWorkerIdleTimeoutMs', Math.round((e.ccWorkerIdleTimeoutMs || 1800000) / 60000), 'minutes', 'How long a persistent `copilot --acp` CC worker stays warm with no activity before the idle reaper kills it. After a reap the next message cold-spawns a FRESH session with no memory of prior turns and CC shows a "context cleared after inactivity" notice. Shorter = less idle memory/process footprint; longer = more context durability across gaps between messages. Only affects the Copilot worker-pool path (CC Worker Pool ON). Clamped 1–480 minutes.') +
@@ -1050,6 +1068,7 @@ async function saveSettings() {
1050
1068
  autoCompletePrs: document.getElementById('set-autoCompletePrs').checked,
1051
1069
  ccUseWorkerPool: !!document.getElementById('set-ccUseWorkerPool')?.checked,
1052
1070
  autoReapOrphanWorktreeHolders: !!document.getElementById('set-autoReapOrphanWorktreeHolders')?.checked,
1071
+ liveCheckoutAutoStash: !!document.getElementById('set-liveCheckoutAutoStash')?.checked,
1053
1072
  liveCheckoutAutoReset: !!document.getElementById('set-liveCheckoutAutoReset')?.checked,
1054
1073
  orphanHolderScanTimeoutMs: document.getElementById('set-orphanHolderScanTimeoutMs')?.value,
1055
1074
  statusProbeKillTimeoutMs: document.getElementById('set-statusProbeKillTimeoutMs')?.value,
@@ -1155,10 +1174,18 @@ async function saveSettings() {
1155
1174
  // values.
1156
1175
  const wtModeInput = document.getElementById('set-checkoutMode-' + p.name);
1157
1176
  const wtModeValue = (wtModeInput && wtModeInput.value === 'live') ? 'live' : 'worktree';
1177
+ // W-mqtvnnj1000357fa — per-project live-checkout auto-stash override.
1178
+ // '' → null (clear override, fall back to engine fleet setting); 'on' →
1179
+ // true; 'off' → false. The server deletes the field on null and validates
1180
+ // booleans.
1181
+ const autoStashInput = document.getElementById('set-liveCheckoutAutoStash-' + p.name);
1182
+ const autoStashRaw = autoStashInput ? autoStashInput.value : '';
1183
+ const autoStashValue = autoStashRaw === 'on' ? true : autoStashRaw === 'off' ? false : null;
1158
1184
  return {
1159
1185
  name: p.name,
1160
1186
  mainBranch: mainBranchValue || null,
1161
1187
  checkoutMode: wtModeValue,
1188
+ liveCheckoutAutoStash: autoStashValue,
1162
1189
  workSources: {
1163
1190
  pullRequests: { enabled: document.getElementById('set-ws-prs-' + p.name)?.checked ?? true },
1164
1191
  workItems: { enabled: document.getElementById('set-ws-wi-' + p.name)?.checked ?? true }
@@ -226,28 +226,12 @@
226
226
  </div>
227
227
  </div>
228
228
 
229
- <!-- Plans + PRD control panel — opened from the "Plans" status tile. Two tabs:
230
- Plans (.md plan drafts) and PRD (materialized PRDs). Each tab is rendered
231
- lazily by dashboard/slim/js/plans.js into #slim-plans-body. Backed by the
232
- existing endpoints (no new server routes):
233
- Plans /api/plans (list), /api/plans/:file (read), and the lifecycle
234
- POSTs (/api/plans/{approve,execute,reject,pause,regenerate,
235
- archive,delete,unarchive}).
236
- PRD — the .json entries from /api/plans plus /api/prd and the
237
- read-only subset of /api/prd-items. -->
238
- <div class="modal-bg" id="slim-plans-modal">
239
- <div class="modal slim-plans-modal-inner">
240
- <div class="modal-header">
241
- <h3>Plans</h3>
242
- <div class="kn-tabs" id="slim-plans-tabs" role="tablist">
243
- <button class="kn-tab active" id="slim-plans-tab-plans" data-plans-tab="plans" type="button" role="tab" aria-selected="true" aria-controls="slim-plans-body">Plans</button>
244
- <button class="kn-tab" id="slim-plans-tab-prd" data-plans-tab="prd" type="button" role="tab" aria-selected="false" aria-controls="slim-plans-body">PRD</button>
245
- </div>
246
- <button id="slim-plans-close" class="icon-btn" title="Close" style="margin-left:auto">&times;</button>
247
- </div>
248
- <div class="modal-body" id="slim-plans-body" role="tabpanel" tabindex="0" aria-labelledby="slim-plans-tab-plans"></div>
249
- </div>
250
- </div>
229
+ <!-- Plans + PRD control panel — the "Plans" cockpit tile opens the LITERAL
230
+ classic /plans screen in a chrome-off iframe (/plans?embed=1), routed through
231
+ the standard TILE_VIEWS path in modals-tiles.js#renderPlansBody. There is no
232
+ slim-specific Plans/PRD flyout markup anymore (W-mr28ko750010199f / "reuse,
233
+ don't fork"): the classic /plans screen is the single source of truth for the
234
+ Plans + PRD tabs and every lifecycle action. -->
251
235
 
252
236
  <!-- Pin editor — create (Pin Content action / "+ Pin") or edit an existing
253
237
  note. Submits to POST /api/pinned (create) or /api/pinned/update (edit). -->