@yemi33/minions 0.1.2436 → 0.1.2438

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.
@@ -1300,6 +1300,14 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1300
1300
  var pendingEventName = '';
1301
1301
 
1302
1302
  async function _handleEvent(evt) {
1303
+ // W-ms4z164i01fo438d — terminal frames are idempotent per connection.
1304
+ // A failed turn used to put two `event: error` frames on the wire and
1305
+ // render two identical error bubbles; the backend now emits one, and
1306
+ // this guard keeps the UI correct against any future double-emit or a
1307
+ // terminal frame that arrives twice. `terminalEventSeen` is scoped to
1308
+ // one _ccConsumeStream call, so a reconnect that replays donePayload
1309
+ // still renders it exactly once on the new connection.
1310
+ if (terminalEventSeen && evt && (evt.type === 'done' || evt.type === 'error')) return;
1303
1311
  // First event after a reconnect: the server replays the full accumulated
1304
1312
  // snapshot (all prior tools, then the whole text as one chunk — see the
1305
1313
  // reconnect branch in dashboard.js). Swap the pre-reconnect segments out
@@ -205,17 +205,32 @@ async function kbSweep() {
205
205
  }
206
206
 
207
207
  let _memorySearchUiLoading = false;
208
+ // Lazily loads /assets/memory-search.js, then opens the modal.
209
+ //
210
+ // Both failure paths matter (W-ms5dlone012i457a-c):
211
+ // - script.onerror -> the network/404 case
212
+ // - script.onload but window.MinionsMemorySearch still undefined -> the
213
+ // script was served but did not register (e.g. a truncated/empty body, or
214
+ // it threw while evaluating). Dereferencing .open() here used to raise an
215
+ // uncaught TypeError.
216
+ // In every path the loading latch must be cleared, otherwise the early-return
217
+ // below permanently and silently bricks the button for the rest of the session.
208
218
  function openMemorySearchModal() {
209
219
  if (window.MinionsMemorySearch) return window.MinionsMemorySearch.open();
210
220
  if (_memorySearchUiLoading) return;
211
221
  _memorySearchUiLoading = true;
212
222
  const script = document.createElement('script');
213
223
  script.src = '/assets/memory-search.js';
214
- script.onload = () => window.MinionsMemorySearch.open();
215
- script.onerror = () => {
224
+ const fail = () => {
216
225
  _memorySearchUiLoading = false;
217
226
  showToast('kb-sweep-toast', 'Memory search UI failed to load', false);
218
227
  };
228
+ script.onload = () => {
229
+ _memorySearchUiLoading = false;
230
+ if (!window.MinionsMemorySearch) return fail();
231
+ window.MinionsMemorySearch.open();
232
+ };
233
+ script.onerror = fail;
219
234
  document.head.appendChild(script);
220
235
  }
221
236
 
@@ -262,7 +277,24 @@ async function submitKbEntry(e) {
262
277
 
263
278
  async function kbOpenItem(category, file) {
264
279
  try {
265
- const content = await fetch('/api/knowledge/' + category + '/' + encodeURIComponent(file)).then(r => r.text());
280
+ const res = await fetch('/api/knowledge/' + category + '/' + encodeURIComponent(file));
281
+ // Without this check a 404/500 body was rendered as if it were the entry,
282
+ // and a 200-with-empty-body opened a completely blank modal with no error
283
+ // (W-ms5dlone012i457a-c). Surface the failure instead of rendering nothing.
284
+ if (!res.ok) {
285
+ document.getElementById('modal-title').textContent = file;
286
+ const failBody = document.getElementById('modal-body');
287
+ failBody.replaceChildren();
288
+ const msg = document.createElement('p');
289
+ msg.className = 'empty';
290
+ msg.textContent = res.status === 404
291
+ ? 'This knowledge entry no longer exists on disk (404). It may have been renamed or swept.'
292
+ : 'Could not load this knowledge entry (HTTP ' + res.status + ').';
293
+ failBody.appendChild(msg);
294
+ document.getElementById('modal').classList.add('open');
295
+ return;
296
+ }
297
+ const content = await res.text();
266
298
  const display = content.replace(/^---[\s\S]*?---\n*/m, '');
267
299
  document.getElementById('modal-title').textContent = file;
268
300
  const modalBody = document.getElementById('modal-body');
@@ -10,6 +10,54 @@ const { safeRead } = shared;
10
10
  const MINIONS_DIR = __dirname;
11
11
  const DASHBOARD_SHARED_JS = ['pr-merge-state', 'pr-filters', 'cc-suggestions', 'model-display', 'watches-source', 'project-git-summary', 'welcome-popup'];
12
12
 
13
+ // ── Canonical classic-dashboard assembly manifest ──────────────────────────
14
+ // Single source of truth, consumed by BOTH assemblers: buildDashboardHtml()
15
+ // below and dashboard.js#buildDashboardHtml() (the one the real server ships).
16
+ // These used to be duplicated array literals inside each function; they drifted
17
+ // (dashboard.js lost 'memory-panel' and never gained sub-fragment substitution
18
+ // at all), which silently shipped an /engine page with no Memory panel and no
19
+ // working "Capture heap snapshot" button. Keep them here and import them —
20
+ // never re-declare a local copy. Pinned by test/unit/dashboard-assembly-parity.test.js.
21
+ const DASHBOARD_PAGES = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'qa', 'engine'];
22
+
23
+ // Sub-fragments substituted into a parent page at assembly time via
24
+ // <!-- __MARKER__ --> tokens. Lets large panels live in their own
25
+ // fragment file without inflating the parent page. P-d4e5f6a7 introduced
26
+ // engine-memory-panel.html as the first such sub-fragment; add more here
27
+ // by mapping page -> { marker -> fragment basename }.
28
+ const DASHBOARD_PAGE_SUB_FRAGMENTS = {
29
+ engine: { '<!-- __ENGINE_MEMORY_PANEL__ -->': 'engine-memory-panel' },
30
+ };
31
+
32
+ // JS module bundle (order matters: utils → state → renderers → commands → refresh).
33
+ // Every dashboard/js/*.js file belongs here unless it is lazy-loaded through a
34
+ // dedicated route (only memory-search.js today, served at /assets/memory-search.js).
35
+ const DASHBOARD_JS_FILES = [
36
+ 'utils', 'state', 'features-client', 'render-utils', 'charter-editor', 'detail-panel', 'live-stream',
37
+ 'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
38
+ 'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
39
+ 'render-other', 'render-managed', 'memory-panel', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
40
+ 'command-parser', 'command-input', 'command-center', 'command-history',
41
+ 'confirm-dialog', 'modal', 'modal-qa', 'settings', 'qa', 'fre', 'refresh',
42
+ ];
43
+
44
+ /**
45
+ * Read a page fragment and substitute any sub-fragment markers declared for it.
46
+ * Shared by both assemblers so a sub-fragment can never be wired into one only.
47
+ */
48
+ function readPageFragment(dashDir, page) {
49
+ let content = safeRead(path.join(dashDir, 'pages', page + '.html'));
50
+ const subs = DASHBOARD_PAGE_SUB_FRAGMENTS[page];
51
+ if (subs) {
52
+ for (const [marker, basename] of Object.entries(subs)) {
53
+ const fragment = safeRead(path.join(dashDir, 'pages', basename + '.html'));
54
+ // Function replacer: fragment text may contain `$&`/`$1` patterns.
55
+ content = content.replace(marker, () => fragment);
56
+ }
57
+ }
58
+ return content;
59
+ }
60
+
13
61
  function buildDashboardHtml() {
14
62
  const dashDir = path.join(MINIONS_DIR, 'dashboard');
15
63
  const layoutPath = path.join(dashDir, 'layout.html');
@@ -21,37 +69,15 @@ function buildDashboardHtml() {
21
69
  const layout = safeRead(layoutPath);
22
70
  const css = safeRead(path.join(dashDir, 'styles.css'));
23
71
 
24
- const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'qa', 'engine'];
25
- // Sub-fragments substituted into a parent page at assembly time via
26
- // <!-- __MARKER__ --> tokens. Lets large panels live in their own
27
- // fragment file without inflating the parent page. P-d4e5f6a7 introduced
28
- // engine-memory-panel.html as the first such sub-fragment; add more here
29
- // by mapping marker -> fragment basename.
30
- const pageSubFragments = {
31
- engine: { '<!-- __ENGINE_MEMORY_PANEL__ -->': 'engine-memory-panel' },
32
- };
72
+ const pages = DASHBOARD_PAGES;
33
73
  let pageHtml = '';
34
74
  for (const p of pages) {
35
- let content = safeRead(path.join(dashDir, 'pages', p + '.html'));
36
- const subs = pageSubFragments[p];
37
- if (subs) {
38
- for (const [marker, basename] of Object.entries(subs)) {
39
- const fragment = safeRead(path.join(dashDir, 'pages', basename + '.html'));
40
- content = content.replace(marker, () => fragment);
41
- }
42
- }
75
+ const content = readPageFragment(dashDir, p);
43
76
  const activeClass = p === 'home' ? ' active' : '';
44
77
  pageHtml += ` <div class="page${activeClass}" id="page-${p}">\n${content}\n </div>\n\n`;
45
78
  }
46
79
 
47
- const jsFiles = [
48
- 'utils', 'state', 'features-client', 'render-utils', 'charter-editor', 'detail-panel', 'live-stream',
49
- 'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
50
- 'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
51
- 'render-other', 'render-managed', 'memory-panel', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
52
- 'command-parser', 'command-input', 'command-center', 'command-history',
53
- 'confirm-dialog', 'modal', 'modal-qa', 'settings', 'qa', 'fre', 'refresh'
54
- ];
80
+ const jsFiles = DASHBOARD_JS_FILES;
55
81
  let jsHtml = '';
56
82
  for (const f of DASHBOARD_SHARED_JS) {
57
83
  const content = safeRead(path.join(dashDir, 'shared', f + '.js'));
@@ -175,6 +201,10 @@ module.exports = {
175
201
  buildDashboardHtml,
176
202
  buildSlimHtml,
177
203
  DASHBOARD_SHARED_JS,
204
+ DASHBOARD_PAGES,
205
+ DASHBOARD_PAGE_SUB_FRAGMENTS,
206
+ DASHBOARD_JS_FILES,
207
+ readPageFragment,
178
208
  SLIM_JS_ORDER,
179
209
  _resetSlimPartsCacheForTest,
180
210
  };
package/dashboard.js CHANGED
@@ -68,7 +68,12 @@ const prFixTarget = require('./engine/pr-fix-target');
68
68
  const prTrack = require('./engine/pr-track');
69
69
  const resolveArea = require('./engine/resolve-area');
70
70
  const features = require('./engine/features');
71
- const { DASHBOARD_SHARED_JS } = require('./dashboard-build');
71
+ const {
72
+ DASHBOARD_SHARED_JS,
73
+ DASHBOARD_PAGES,
74
+ DASHBOARD_JS_FILES,
75
+ readPageFragment,
76
+ } = require('./dashboard-build');
72
77
  const { normalizeExecutionModel } = require('./engine/execution-model');
73
78
  const ccWorkerPool = require('./engine/cc-worker-pool');
74
79
  const diagnosticsMemory = require('./engine/diagnostics-memory');
@@ -1920,30 +1925,23 @@ function buildDashboardHtml() {
1920
1925
  // Assemble page fragments. Each wrapper gets a `.page-toast` inline slot at
1921
1926
  // the top — showToast('cmd-toast', …) auto-routes here when a page is active,
1922
1927
  // so feedback lands near the action instead of the floating top-right toast.
1923
- const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'qa', 'engine'];
1928
+ // Page list, sub-fragment map and fragment reader all come from
1929
+ // dashboard-build.js so this assembler cannot drift from the standalone one.
1924
1930
  const pageToast = ' <div class="cmd-toast cmd-toast-inline page-toast" style="margin:6px 16px"></div>\n';
1925
1931
  let pageHtml = '';
1926
- for (const p of pages) {
1927
- const content = safeRead(path.join(dashDir, 'pages', p + '.html'));
1932
+ for (const p of DASHBOARD_PAGES) {
1933
+ const content = readPageFragment(dashDir, p);
1928
1934
  const activeClass = p === 'home' ? ' active' : '';
1929
1935
  pageHtml += ` <div class="page${activeClass}" id="page-${p}">\n${pageToast}${content}\n </div>\n\n`;
1930
1936
  }
1931
1937
 
1932
1938
  // Assemble JS modules (order matters: utils → state → renderers → commands → refresh)
1933
- const jsFiles = [
1934
- 'utils', 'state', 'features-client', 'render-utils', 'charter-editor', 'detail-panel', 'live-stream',
1935
- 'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
1936
- 'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
1937
- 'render-other', 'render-managed', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
1938
- 'command-parser', 'command-input', 'command-center', 'command-history',
1939
- 'confirm-dialog', 'modal', 'modal-qa', 'settings', 'qa', 'fre', 'refresh'
1940
- ];
1941
1939
  let jsHtml = '';
1942
1940
  for (const f of DASHBOARD_SHARED_JS) {
1943
1941
  const content = safeRead(path.join(dashDir, 'shared', f + '.js'));
1944
1942
  jsHtml += `\n// ─── shared/${f}.js ────────────────────────────────────────\n${content}\n`;
1945
1943
  }
1946
- for (const f of jsFiles) {
1944
+ for (const f of DASHBOARD_JS_FILES) {
1947
1945
  const content = safeRead(path.join(dashDir, 'js', f + '.js'));
1948
1946
  jsHtml += `\n// ─── ${f}.js ────────────────────────────────────────\n${content}\n`;
1949
1947
  }
@@ -3800,6 +3798,49 @@ const CC_ERROR_CODES = Object.freeze([
3800
3798
  // surfaces this typed code via _buildCcErrorEnvelope, never a 500.
3801
3799
  'invalid-image',
3802
3800
  ]);
3801
+ // W-ms4z164i01fo438d — a `done` or `error` frame is the LAST thing a CC or
3802
+ // doc-chat SSE consumer will ever see, so neither writer may shed one under
3803
+ // backpressure. Shared by writeCcEvent and writeDocEvent so the rule lives in
3804
+ // one place instead of being restated per stream.
3805
+ function _isTerminalSseFrameType(type) {
3806
+ return type === 'done' || type === 'error';
3807
+ }
3808
+
3809
+ // W-mpmwxni2000c25c7-d / W-ms4z164i01fo438d — render exactly ONE SSE frame.
3810
+ // Terminal error frames get a named `event: error` line so consumers using
3811
+ // addEventListener('error', …) (and tests matching the raw wire) can target
3812
+ // them; the JSON payload still carries `type: 'error'` so data-line-only
3813
+ // parsers keep working. Shared by writeCcEvent and writeDocEvent.
3814
+ //
3815
+ // One call == one frame. A caller that emits a terminal error through a writer
3816
+ // AND raw-writes its own `event: error` frame puts two frames on the wire, and
3817
+ // the CC client backfills `type` from the `event:` line — so both render as
3818
+ // error bubbles. That was the duplicate-error-bubble bug; keep this the single
3819
+ // wire-rendering seam.
3820
+ function _sseFrame(payload) {
3821
+ const eventLine = (payload && payload.type === 'error') ? 'event: error\n' : '';
3822
+ return eventLine + 'data: ' + JSON.stringify(payload) + '\n\n';
3823
+ }
3824
+
3825
+ // W-ms4z164i01fo438d — canonical terminal-error payload for a failed CC stream
3826
+ // turn. Stored on `liveState.donePayload` (so the reconnect branch replays the
3827
+ // exact same terminal frame to a reattaching client) and handed to the writer
3828
+ // once. `message` is the canonical envelope field; `error` is kept as a legacy
3829
+ // alias for clients predating the typed envelope. `stderr` carries the tail
3830
+ // that used to ride only on the now-removed raw frame.
3831
+ function _buildCcTerminalErrorPayload(envelope, stderrTail) {
3832
+ const message = (envelope && envelope.message) || 'Command Center reported an unknown error.';
3833
+ return {
3834
+ type: 'error',
3835
+ message,
3836
+ error: message,
3837
+ code: (envelope && envelope.code) || 'crash',
3838
+ retriable: !!(envelope && envelope.retriable),
3839
+ sessionId: null,
3840
+ ...(stderrTail ? { stderr: String(stderrTail).slice(0, 500) } : {}),
3841
+ };
3842
+ }
3843
+
3803
3844
  function _buildCcErrorEnvelope({ message, code, retryable, ...extra } = {}) {
3804
3845
  const normalizedCode = CC_ERROR_CODES.includes(code) ? code : 'crash';
3805
3846
  return {
@@ -9039,7 +9080,11 @@ const server = http.createServer(async (req, res) => {
9039
9080
  MINIONS_DIR,
9040
9081
  );
9041
9082
  const kbCatDir = path.join(MINIONS_DIR, 'knowledge', cat);
9042
- const content = safeRead(path.join(kbCatDir, file));
9083
+ // safeReadOrNull (not safeRead): safeRead collapses ENOENT to '', which
9084
+ // made this 404 guard dead code and served a missing entry as 200 with an
9085
+ // empty body — render-kb.js#kbOpenItem then opened a blank modal with no
9086
+ // error. Present-but-empty still reads as '' and correctly returns 200.
9087
+ const content = safeReadOrNull(path.join(kbCatDir, file));
9043
9088
  if (content === null) return jsonReply(res, 404, { error: 'not found', code: 'not-found' });
9044
9089
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
9045
9090
  res.end(content);
@@ -10318,7 +10363,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10318
10363
  } catch { /* listener registration is best-effort */ }
10319
10364
  const writeDocEvent = (payload) => {
10320
10365
  const type = payload && payload.type;
10321
- const isTerminal = type === 'done' || type === 'error';
10366
+ const isTerminal = _isTerminalSseFrameType(type);
10322
10367
  const _logFail = (reason) => {
10323
10368
  try {
10324
10369
  shared.log('warn', `[doc-sse-fail] ${JSON.stringify({ doc: docKey || 'unknown', type, reason, destroyed: !!res.destroyed, writableEnded: !!res.writableEnded, streamEnded: _docStreamEnded })}`);
@@ -10330,14 +10375,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10330
10375
  }
10331
10376
  let wire;
10332
10377
  try {
10333
- // W-mpmwxni2000c25c7-d / W-mqevl09s000i9989 — terminal error frames go
10334
- // out as a named SSE `event: error` frame so consumers using
10335
- // addEventListener('error', …) can target them, mirroring the
10336
- // handleCommandCenterStream contract. The JSON payload still carries
10337
- // `type: 'error'` for the data-line parser in modal-qa.js.
10338
- wire = (type === 'error')
10339
- ? `event: error\ndata: ${JSON.stringify(payload)}\n\n`
10340
- : `data: ${JSON.stringify(payload)}\n\n`;
10378
+ // W-mpmwxni2000c25c7-d / W-mqevl09s000i9989 — one frame per call via
10379
+ // the shared _sseFrame renderer (mirrors handleCommandCenterStream):
10380
+ // terminal errors go out as a named `event: error` frame, and the JSON
10381
+ // payload still carries `type: 'error'` for the data-line parser in
10382
+ // modal-qa.js.
10383
+ wire = _sseFrame(payload);
10341
10384
  } catch {
10342
10385
  _logFail('json-serialize-failed');
10343
10386
  return false;
@@ -11629,6 +11672,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11629
11672
  const writeCcEvent = (payload) => {
11630
11673
  const type = payload && payload.type;
11631
11674
  const isUserFacing = type === 'chunk' || type === 'done' || type === 'tool' || type === 'tool-update' || type === 'error';
11675
+ const isTerminal = _isTerminalSseFrameType(type);
11632
11676
  const _logFail = (reason, extra) => {
11633
11677
  if (!isUserFacing) return;
11634
11678
  try {
@@ -11651,14 +11695,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11651
11695
  }
11652
11696
  let wire;
11653
11697
  try {
11654
- // W-mpmwxni2000c25c7-d terminal error frames go out as `event: error`
11655
- // so SSE consumers using addEventListener('error', …) and tests
11656
- // matching the raw wire format can target them directly. The JSON
11657
- // payload still carries `type: 'error'` so the existing
11658
- // data-line parser (and any client code that only reads `data:`
11659
- // lines) keeps working.
11660
- const eventLine = (type === 'error') ? 'event: error\n' : '';
11661
- wire = eventLine + 'data: ' + JSON.stringify(payload) + '\n\n';
11698
+ // W-mpmwxni2000c25c7-d / W-ms4z164i01fo438d one frame per call via
11699
+ // the shared _sseFrame renderer. This is the ONLY place a CC terminal
11700
+ // error reaches the wire: callers must not also `res.write` their own
11701
+ // `event: error` frame, because the client backfills `type` from the
11702
+ // `event:` line and would render a second error bubble.
11703
+ wire = _sseFrame(payload);
11662
11704
  }
11663
11705
  catch (err) {
11664
11706
  _logFail('json-serialize-failed', { error: String((err && err.message) || err).slice(0, 200) });
@@ -11676,7 +11718,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11676
11718
  // bump _ccTelemetry counters so the [cc-stream] outcome log line stays
11677
11719
  // truthful about what the orchestrator produced — only the wire was
11678
11720
  // shed, the work happened.
11679
- if (_queuedBytes > SSE_MAX_QUEUE_BYTES) {
11721
+ // W-ms4z164i01fo438d terminal `done` / `error` frames are NEVER shed
11722
+ // (mirrors writeDocEvent). The terminal error used to also go out via a
11723
+ // raw res.write that bypassed this cap entirely; now that the writer is
11724
+ // the single emitter, shedding it would drop the failure off the wire.
11725
+ if (!isTerminal && _queuedBytes > SSE_MAX_QUEUE_BYTES) {
11680
11726
  try {
11681
11727
  shared.log('warn', `[cc-sse-shed] tab=${tabId || _ccTelemetry.tabId || 'unknown'} type=${type} queuedBytes=${_queuedBytes} wireBytes=${wire.length}`);
11682
11728
  } catch { /* telemetry is best-effort */ }
@@ -12117,12 +12163,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12117
12163
  trackErr('command-center', envelope.code);
12118
12164
  const stderrTail = (result.stderr || '').trim().split('\n').filter(Boolean).slice(-3).join(' | ');
12119
12165
  console.error(`[CC-stream] Failed code=${envelope.code} retriable=${envelope.retriable}: ${(result.stderr || '').slice(0, 500)}; stdout_tail=${(result.raw || '').slice(-500)}`);
12120
- // Emit `event: error` (named SSE frame), then a `done`-style frame
12121
- // for clients that only handle the default message channel, then
12122
- // close cleanly so the EventSource exits its read loop without
12123
- // throwing a connection-reset.
12124
- try { res.write(`event: error\ndata: ${JSON.stringify({ message: envelope.message, code: envelope.code, retriable: !!envelope.retriable, ...(stderrTail ? { stderr: stderrTail.slice(0, 500) } : {}) })}\n\n`); } catch {}
12125
- liveState.donePayload = { type: 'error', error: envelope.message, code: envelope.code, retriable: !!envelope.retriable, sessionId: null };
12166
+ // W-ms4z164i01fo438d emit exactly ONE terminal frame. writeCcEvent
12167
+ // prefixes `event: error\n` for `type: 'error'`, so this single call
12168
+ // satisfies both the named-SSE-frame contract and the `data:`-only
12169
+ // parser. This block previously ALSO did a raw
12170
+ // `res.write('event: error\ndata: …')` before calling the writer,
12171
+ // which put two `event: error` frames on the wire and rendered two
12172
+ // identical error bubbles (each with its own Retry / New Session
12173
+ // controls) in the Command Center. donePayload keeps carrying the
12174
+ // canonical envelope so the reconnect branch above replays the same
12175
+ // single terminal frame to a reattaching client.
12176
+ liveState.donePayload = _buildCcTerminalErrorPayload(envelope, stderrTail);
12126
12177
  if (liveState.writer) liveState.writer(liveState.donePayload);
12127
12178
  if (liveState.endResponse) liveState.endResponse();
12128
12179
  _scheduleCcLiveCleanup(tabId);
@@ -15778,7 +15829,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
15778
15829
  catch { return jsonReply(res, 400, { error: 'invalid path' }); }
15779
15830
  const agentsDir = path.join(MINIONS_DIR, 'agents');
15780
15831
  if (!safePath.startsWith(agentsDir + path.sep)) return jsonReply(res, 400, { error: 'path must be within agents/' });
15781
- const content = _agentApiCall('readOutputFile', safeRead, safePath);
15832
+ // safeReadOrNull (not safeRead) so a missing log 404s instead of being
15833
+ // served as 200 with an empty body — see the KB read above.
15834
+ const content = _agentApiCall('readOutputFile', safeReadOrNull, safePath);
15782
15835
  if (content === null) return jsonReply(res, 404, { error: 'not found' });
15783
15836
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
15784
15837
  res.setHeader('Cache-Control', 'no-cache');
@@ -15787,7 +15840,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
15787
15840
 
15788
15841
  // Knowledge base
15789
15842
  { method: 'GET', path: '/assets/memory-search.js', desc: 'Serve the lazy-loaded structured memory search UI', handler: (req, res) => {
15790
- const content = safeRead(path.join(MINIONS_DIR, 'dashboard', 'js', 'memory-search.js'));
15843
+ // safeReadOrNull (not safeRead): a 200 with an empty body still fires
15844
+ // the loader's script.onload, leaving window.MinionsMemorySearch
15845
+ // undefined so openMemorySearchModal() throws. 404 lets script.onerror
15846
+ // surface the intended "failed to load" toast instead.
15847
+ const content = safeReadOrNull(path.join(MINIONS_DIR, 'dashboard', 'js', 'memory-search.js'));
15791
15848
  if (content == null) { res.statusCode = 404; return res.end('not found'); }
15792
15849
  res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
15793
15850
  res.setHeader('Cache-Control', 'no-cache');
@@ -16340,6 +16397,9 @@ function _installCrashHandlers() {
16340
16397
  // Production entry points use the closures directly; tests import via require('./dashboard').
16341
16398
  module.exports = {
16342
16399
  getMcpServers,
16400
+ // W-ms5dlone012i457a-d — lets tests assert the HTML the SERVER actually ships,
16401
+ // not just dashboard-build.js's standalone copy (the two silently drifted).
16402
+ _buildDashboardHtmlForTest: buildDashboardHtml,
16343
16403
  _setPrRefVerifierForTest, // issue #246 — inject a fake loose-PR-ref verifier in handler tests
16344
16404
  _prRefVerifyCache, // W-mqtzrix100060c9f — test seam for cache size inspection
16345
16405
  _PR_REF_VERIFY_TTL_MS, // W-mqtzrix100060c9f — exported for test assertions
@@ -16390,6 +16450,11 @@ module.exports = {
16390
16450
  // P-9c5f1a83 — CC image-attachment validation surface (test seams)
16391
16451
  _validateCcImages,
16392
16452
  _buildCcErrorEnvelope,
16453
+ // W-ms4z164i01fo438d — SSE terminal-frame seam (exported for testing).
16454
+ // See test/unit/cc-error-envelopes.test.js.
16455
+ _sseFrame,
16456
+ _isTerminalSseFrameType,
16457
+ _buildCcTerminalErrorPayload,
16393
16458
  CC_ERROR_CODES,
16394
16459
  CC_IMAGE_MAX_COUNT,
16395
16460
  CC_IMAGE_MAX_DECODED_BYTES,
@@ -27,6 +27,16 @@ const AUTO_SWEEP_INTERVAL_MS = 4 * 60 * 60 * 1000;
27
27
  const AUTO_SWEEP_FAILURE_RETRY_MS = 15 * 60 * 1000;
28
28
  const KB_SWEPT_PATH = path.join(ENGINE_DIR, 'kb-swept.json');
29
29
  const COMPRESS_THRESHOLD_BYTES = 5000;
30
+ // Hard input ceiling for the per-entry LLM rewrite pass (W-ms25l4pr000kb9db).
31
+ // The rewrite pass sends an entry's FULL body to the LLM; a pathological entry
32
+ // (e.g. a live-checkout dirty alert that embedded a whole `git status` dump —
33
+ // observed at 10 MB each) can OOM/timeout/hang the call and, without per-pass
34
+ // isolation, abort the entire sweep before the cheap age-expiry pass reclaims
35
+ // space. Entries whose on-disk size exceeds this ceiling are skipped from the
36
+ // rewrite (left for age-expiry / structural consolidation to reclaim) so one
37
+ // giant entry can never choke the sweep. 20x the compress threshold (~100 KB)
38
+ // is far above every normal entry yet well below the multi-MB monsters.
39
+ const REWRITE_MAX_INPUT_BYTES = COMPRESS_THRESHOLD_BYTES * 20;
30
40
  const LLM_BATCH_SIZE = 30;
31
41
  const NORMALIZE_CONCURRENCY = 5;
32
42
  const SWEPT_FLAG_KEY = '_swept'; // frontmatter key — entries with this skip the rewrite pass
@@ -610,6 +620,13 @@ ${body}`;
610
620
  const fp = path.join(KB_DIR, e.category, e.file);
611
621
  const content = safeRead(fp);
612
622
  if (content == null) continue;
623
+ // Oversized-entry guard (W-ms25l4pr000kb9db): never send a multi-MB entry to
624
+ // the LLM — it risks OOM/timeout/hang. Skip it; age-expiry / consolidation
625
+ // reclaim it instead.
626
+ if (content.length > REWRITE_MAX_INPUT_BYTES) {
627
+ log('warn', `[kb-sweep] rewrite skip ${e.category}/${e.file}: ${content.length} bytes exceeds ${REWRITE_MAX_INPUT_BYTES}B ceiling — left for age-expiry/consolidation`);
628
+ continue;
629
+ }
613
630
  const { fm, body } = _parseFrontmatter(content);
614
631
  // Skip already-processed unless the file was modified after the sweep flag was set
615
632
  if (fm[SWEPT_FLAG_KEY]) {
@@ -984,9 +1001,19 @@ async function _runKbSweepImpl(opts = {}) {
984
1001
  }
985
1002
  if (manifest.length < 2) { summary.summary = 'nothing to sweep (< 2 unpinned entries)'; summary.durationMs = Date.now() - t0; return summary; }
986
1003
 
1004
+ // Every pass below is wrapped in its own try/catch (W-ms25l4pr000kb9db). A
1005
+ // throw/OOM/hang in one pass must NOT abort the whole sweep and starve the
1006
+ // later reclaimers — in particular the cheap age-expiry pass and
1007
+ // _pruneOldSwept must always get to run. Each pass degrades to "no-op on this
1008
+ // pass, carry the prior survivors forward".
1009
+
987
1010
  // 1. Hash-based dedup (cheap, catches cross-batch duplicates)
988
- const { survivors: afterHash, archived: hashArchived } = _hashDedup(manifest, opts);
989
- summary.hashDuplicatesArchived = hashArchived;
1011
+ let afterHash = manifest;
1012
+ try {
1013
+ const { survivors, archived } = _hashDedup(manifest, opts);
1014
+ afterHash = survivors;
1015
+ summary.hashDuplicatesArchived = archived;
1016
+ } catch (e) { log('warn', `[kb-sweep] hash-dedup pass failed: ${e && e.message}`); }
990
1017
 
991
1018
  // 1.5. Structural consolidation (W-mr973knu) — PRIMARY balloon fix. Collapse
992
1019
  // large groups of near-identical boilerplate notes (agent-failure dumps, no-op
@@ -994,47 +1021,70 @@ async function _runKbSweepImpl(opts = {}) {
994
1021
  // week). Runs on recent entries too, so it shrinks the KB continuously rather
995
1022
  // than merely aging files out. Downstream passes operate on the survivors so
996
1023
  // the LLM/rewrite passes no longer waste calls on the collapsed notes.
997
- const consolidateResult = _structuralConsolidate(afterHash, opts);
998
- summary.notesConsolidated = consolidateResult.consolidated;
999
- summary.consolidationGroups = consolidateResult.groupsCollapsed;
1000
- summary.digestsWritten = consolidateResult.digestsWritten;
1001
- const afterConsolidate = consolidateResult.survivors.filter(
1002
- e => e._digest || fs.existsSync(path.join(KB_DIR, e.category, e.file)),
1003
- );
1004
-
1005
- // 2. LLM batch sweep — within-batch dupes + reclassify + remove stale
1006
- // Only runs against survivors, but we need indices that match the LIST sent to the LLM
1007
- const llmManifest = afterConsolidate;
1008
- const { plan, scannedIdx: llmScannedIdx } = await _llmBatchSweep(llmManifest, callLLM, trackEngineUsage, opts);
1009
- const llmActions = _applyLlmPlan(plan, llmManifest, llmScannedIdx, opts);
1010
- summary.llmDuplicatesArchived = llmActions.merged;
1011
- summary.staleRemoved = llmActions.removed;
1012
- summary.reclassified = llmActions.reclassified;
1013
-
1014
- // 2.5. Age-based TTL expiry (W-mr48yth5) SECONDARY safety net. Archives
1015
- // transient-category entries older than their per-category TTL to
1016
- // knowledge/_swept/. Structural consolidation above is the primary reducer;
1017
- // this catches genuinely stale one-offs that never clustered into a digest.
1018
- // Durable categories (no TTL) are untouched.
1019
- const survivingAfterLlm = afterConsolidate.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
1020
- const ageResult = _expireByAge(survivingAfterLlm, opts);
1021
- summary.ageExpired = ageResult.expired;
1022
-
1023
- // 3. Per-entry rewrite (compress + normalize)
1024
- // Filter to entries that survived hash + consolidation + LLM + age passes (still on disk)
1025
- const stillOnDisk = afterConsolidate.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
1026
- const rewriteResult = await _rewritePass(stillOnDisk, callLLM, trackEngineUsage, opts);
1027
- summary.rewritten = rewriteResult.processed;
1028
- summary.rewriteBytesBefore = rewriteResult.bytesBefore;
1029
- summary.rewriteBytesAfter = rewriteResult.bytesAfter;
1024
+ let afterConsolidate = afterHash;
1025
+ try {
1026
+ const consolidateResult = _structuralConsolidate(afterHash, opts);
1027
+ summary.notesConsolidated = consolidateResult.consolidated;
1028
+ summary.consolidationGroups = consolidateResult.groupsCollapsed;
1029
+ summary.digestsWritten = consolidateResult.digestsWritten;
1030
+ afterConsolidate = consolidateResult.survivors.filter(
1031
+ e => e._digest || fs.existsSync(path.join(KB_DIR, e.category, e.file)),
1032
+ );
1033
+ } catch (e) { log('warn', `[kb-sweep] structural-consolidation pass failed: ${e && e.message}`); }
1034
+
1035
+ // 2. Age-based TTL expiry (W-mr48yth5) MOVED AHEAD of the LLM passes
1036
+ // (W-ms25l4pr000kb9db). This is the cheapest reducer AND the one that reclaims
1037
+ // the most space from oversized transient entries, so it MUST run before the
1038
+ // LLM batch sweep + rewrite pass — either of which can choke on a multi-MB
1039
+ // entry. Running pure aging first guarantees stale entries are reclaimed even
1040
+ // if a later LLM/rewrite pass throws or hangs. Archives transient-category
1041
+ // entries older than their per-category TTL to knowledge/_swept/; durable
1042
+ // categories (no TTL) and consolidation digests are untouched.
1043
+ let afterAge = afterConsolidate;
1044
+ try {
1045
+ const survivingForAge = afterConsolidate.filter(
1046
+ e => e._digest || fs.existsSync(path.join(KB_DIR, e.category, e.file)),
1047
+ );
1048
+ const ageResult = _expireByAge(survivingForAge, opts);
1049
+ summary.ageExpired = ageResult.expired;
1050
+ afterAge = ageResult.survivors;
1051
+ } catch (e) { log('warn', `[kb-sweep] age-expiry pass failed: ${e && e.message}`); }
1052
+
1053
+ // 3. LLM batch sweep within-batch dupes + reclassify + remove stale.
1054
+ // Only runs against survivors, but we need indices that match the LIST sent to
1055
+ // the LLM. Deferred LLM_SCAN_FLAG_KEY stamping (below) needs llmManifest +
1056
+ // llmScannedIdx in scope, so declare them outside the try.
1057
+ let llmManifest = afterAge.filter(e => e._digest || fs.existsSync(path.join(KB_DIR, e.category, e.file)));
1058
+ let llmScannedIdx = [];
1059
+ try {
1060
+ const { plan, scannedIdx } = await _llmBatchSweep(llmManifest, callLLM, trackEngineUsage, opts);
1061
+ llmScannedIdx = scannedIdx;
1062
+ const llmActions = _applyLlmPlan(plan, llmManifest, llmScannedIdx, opts);
1063
+ summary.llmDuplicatesArchived = llmActions.merged;
1064
+ summary.staleRemoved = llmActions.removed;
1065
+ summary.reclassified = llmActions.reclassified;
1066
+ } catch (e) { log('warn', `[kb-sweep] LLM batch-sweep pass failed: ${e && e.message}`); }
1067
+
1068
+ // 4. Per-entry rewrite (compress + normalize). Filter to entries that survived
1069
+ // the earlier passes (still on disk). Oversized entries are skipped inside
1070
+ // _rewritePass (REWRITE_MAX_INPUT_BYTES) so they never choke the LLM.
1071
+ try {
1072
+ const stillOnDisk = afterAge.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
1073
+ const rewriteResult = await _rewritePass(stillOnDisk, callLLM, trackEngineUsage, opts);
1074
+ summary.rewritten = rewriteResult.processed;
1075
+ summary.rewriteBytesBefore = rewriteResult.bytesBefore;
1076
+ summary.rewriteBytesAfter = rewriteResult.bytesAfter;
1077
+ } catch (e) { log('warn', `[kb-sweep] rewrite pass failed: ${e && e.message}`); }
1030
1078
 
1031
1079
  // Stamp LLM_SCAN_FLAG_KEY on entries analyzed by _llmBatchSweep this run —
1032
1080
  // deferred until after the rewrite pass so its content/mtime update doesn't
1033
1081
  // race the freshness marker we're about to write (see _markLlmScanned doc).
1034
- _markLlmScanned(llmManifest, llmScannedIdx, opts);
1082
+ try { _markLlmScanned(llmManifest, llmScannedIdx, opts); }
1083
+ catch (e) { log('warn', `[kb-sweep] mark-llm-scanned failed: ${e && e.message}`); }
1035
1084
 
1036
- // 4. Prune old swept files (>30 days)
1037
- summary.sweptArchivePruned = _pruneOldSwept();
1085
+ // 5. Prune old swept files (>30 days)
1086
+ try { summary.sweptArchivePruned = _pruneOldSwept(); }
1087
+ catch (e) { log('warn', `[kb-sweep] prune-old-swept failed: ${e && e.message}`); }
1038
1088
 
1039
1089
  // Final tallies — re-walk surviving entries for accurate bytesAfter
1040
1090
  const finalEntries = await queries.getKnowledgeBaseEntries();
@@ -1318,5 +1368,7 @@ module.exports = {
1318
1368
  LLM_SCAN_FLAG_KEY,
1319
1369
  CONSOLIDATION_DIGEST_PREFIX,
1320
1370
  COMPRESS_THRESHOLD_BYTES,
1371
+ REWRITE_MAX_INPUT_BYTES,
1321
1372
  SWEPT_FLAG_KEY,
1373
+ _rewritePass,
1322
1374
  };
@@ -287,12 +287,32 @@ function _collectAutoCleanablePaths(dirtyFiles) {
287
287
  // alert stays human-scannable and bounded. Pure/deterministic — exported for
288
288
  // unit testing.
289
289
  const DIRTY_ALERT_MAX_LINES = 200;
290
- function capDirtyFileLines(dirtyFiles, maxLines = DIRTY_ALERT_MAX_LINES) {
290
+ // Companion byte budget for the embedded dirty-file block. The line cap alone
291
+ // still leaves a hole: 200 arbitrarily-long paths (deeply-nested Windows paths,
292
+ // rename `->` pairs, quoted UTF-8 escapes) could each be hundreds of bytes, so
293
+ // bound the total bytes too. Belt-and-suspenders so a single alert body can
294
+ // never embed a multi-MB git-status dump under any input. 16 KiB is far above a
295
+ // realistic ≤200-file sample yet keeps the whole note human-scannable.
296
+ const DIRTY_ALERT_MAX_BYTES = 16 * 1024;
297
+ function capDirtyFileLines(dirtyFiles, maxLines = DIRTY_ALERT_MAX_LINES, maxBytes = DIRTY_ALERT_MAX_BYTES) {
291
298
  const lines = Array.isArray(dirtyFiles) ? dirtyFiles : [];
292
- const cap = Number.isInteger(maxLines) && maxLines > 0 ? maxLines : DIRTY_ALERT_MAX_LINES;
293
- if (lines.length <= cap) return lines.slice();
294
- const kept = lines.slice(0, cap);
295
- kept.push(`... and ${lines.length - cap} more (run \`git status --porcelain=v1 -b\` locally for the full list)`);
299
+ const lineCap = Number.isInteger(maxLines) && maxLines > 0 ? maxLines : DIRTY_ALERT_MAX_LINES;
300
+ const byteCap = Number.isInteger(maxBytes) && maxBytes > 0 ? maxBytes : DIRTY_ALERT_MAX_BYTES;
301
+ // First cap by line count, then enforce the byte budget over the kept lines
302
+ // (each line plus its joining newline). Either cap can trigger the notice.
303
+ const byLine = lines.slice(0, lineCap);
304
+ const kept = [];
305
+ let bytes = 0;
306
+ let byteLimited = false;
307
+ for (const line of byLine) {
308
+ const lineBytes = Buffer.byteLength(String(line), 'utf8') + 1;
309
+ if (bytes + lineBytes > byteCap) { byteLimited = true; break; }
310
+ bytes += lineBytes;
311
+ kept.push(line);
312
+ }
313
+ const omitted = lines.length - kept.length;
314
+ if (omitted <= 0 && !byteLimited) return lines.slice();
315
+ kept.push(`... and ${omitted} more (run \`git status --porcelain=v1 -b\` locally for the full list)`);
296
316
  return kept;
297
317
  }
298
318
 
@@ -920,6 +940,10 @@ async function prepareLiveCheckout(opts = {}) {
920
940
  if (autoResetAttempt.recheckSucceeded) {
921
941
  verifyOutcome = stillDirty.length === 0 ? 'clean' : `${stillDirty.length} dirty path(s) remain`;
922
942
  }
943
+ // The audit note embeds TWO dumps (before + after), so they share ONE
944
+ // body budget: capping each at the full DIRTY_ALERT_MAX_BYTES would let
945
+ // this note reach 2x the ceiling every other live-checkout alert obeys.
946
+ const blockMaxBytes = Math.floor(DIRTY_ALERT_MAX_BYTES / 2);
923
947
  const body = [
924
948
  `# Live-checkout auto-reset ${recoveryVerified ? 'completed' : 'incomplete'} before '${branchName}'`,
925
949
  '',
@@ -938,7 +962,7 @@ async function prepareLiveCheckout(opts = {}) {
938
962
  '## Changes present before the attempt',
939
963
  '',
940
964
  '```',
941
- ...dirtyFiles,
965
+ ...capDirtyFileLines(dirtyFiles, DIRTY_ALERT_MAX_LINES, blockMaxBytes),
942
966
  '```',
943
967
  ...(autoResetAttempt.recheckSucceeded
944
968
  ? [
@@ -946,7 +970,9 @@ async function prepareLiveCheckout(opts = {}) {
946
970
  '## Changes remaining after the attempt',
947
971
  '',
948
972
  '```',
949
- ...(stillDirty.length > 0 ? stillDirty : ['(clean)']),
973
+ ...(stillDirty.length > 0
974
+ ? capDirtyFileLines(stillDirty, DIRTY_ALERT_MAX_LINES, blockMaxBytes)
975
+ : ['(clean)']),
950
976
  '```',
951
977
  ]
952
978
  : []),
@@ -2093,5 +2119,6 @@ module.exports = {
2093
2119
  _maybeAutoFreshenLocalMain,
2094
2120
  capDirtyFileLines,
2095
2121
  DIRTY_ALERT_MAX_LINES,
2122
+ DIRTY_ALERT_MAX_BYTES,
2096
2123
  LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS,
2097
2124
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2436",
3
+ "version": "0.1.2438",
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"
@@ -57,15 +57,15 @@ You are primarily a dispatcher. Agents have full Claude Code + worktrees + MCP t
57
57
 
58
58
  ### Step 1 — Estimate difficulty before responding
59
59
  State the size in 3-4 words to yourself, then act:
60
- - **Small** (≤3 tool calls, 1-2 files, no cross-module reasoning): you MAY do it yourself.
61
- - **Medium** (4-10 tool calls, 3+ files, multi-file reasoning, real refactor): you MUST delegate.
60
+ - **Small** (≤3 tool calls, 1-2 files, no cross-module reasoning): you MAY do it yourself. A bounded read-only lookup or investigation may also be handled directly even when confirming the answer takes a few additional `GET`, search, or file-read calls.
61
+ - **Medium** (4-10 tool calls for mutating/open-ended work, 3+ files, multi-file reasoning, or a real refactor; excludes bounded read-only confirmation): you MUST delegate.
62
62
  - **Large** (10+ tool calls, cross-cutting, multi-stage): you MUST delegate, consider a plan with decomposition.
63
63
  - **Direct-handling override**: if the human explicitly says to answer directly, handle it here, do it yourself, not dispatch/delegate, or not create a work item, do it yourself within the normal safety/protected-path rules.
64
64
 
65
65
  ### Step 2 — Delegate when ≥ Medium (the hard stop)
66
66
  Always delegate these to an agent — do not attempt them yourself even if they look small at first:
67
67
  - Code changes, fixes, refactors, new features → `POST /api/work-items` with `type: "implement"` (use `"fix"` only when the work targets a specific tracked PR — see the type-selection note under "Calling the Minions API" below)
68
- - Exploration, investigation, research, audits → `POST /api/work-items` with `type: "explore"`
68
+ - Substantial exploration, investigation, research, or audits that require broad repository traversal, prolonged execution, or durable follow-up → `POST /api/work-items` with `type: "explore"`
69
69
  - Code reviews → `POST /api/work-items` with `type: "review"`
70
70
  - Testing → `POST /api/work-items` with `type: "test"`
71
71
  - Architecture analysis → `POST /api/work-items` with `type: "explore"`
@@ -79,15 +79,16 @@ Exception: the direct-handling override wins. If the human explicitly asks you t
79
79
  ### Step 3 — Small tasks: do them yourself when it's faster than dispatching
80
80
  Examples (not an exhaustive whitelist — apply Step 1 to anything not listed):
81
81
  - Quick status lookups (reading 1-2 state files, or a `GET /api/...`)
82
+ - Basic read-only investigations with a bounded question and locally available evidence, including checking several related logs, API records, or source locations to identify a concrete cause
82
83
  - Notes, plan edits, KB entries, routing updates
83
84
  - Git ops the user explicitly asked CC to do
84
85
  - Simple config changes
85
86
  - Answering questions from context you already have
86
87
  - One-line edits to non-protected files when the change is unambiguous
87
88
 
88
- If you start a small task and discover it's actually Medium (3+ files, more tool calls than expected, surprising complexity), STOP and delegate instead of pushing through.
89
+ If you start a small task and discover it needs broad cross-module reasoning, long-running commands, code changes, or durable follow-up, STOP and delegate instead of pushing through. Do not delegate a bounded read-only investigation solely because confirming the answer took more than three tool calls.
89
90
 
90
- When genuinely in doubt about the size, delegate — agents have isolated worktrees, full tool access, durable work-item tracking, and no turn limits.
91
+ When genuinely in doubt about a mutating or open-ended task, delegate — agents have isolated worktrees, full tool access, durable work-item tracking, and no turn limits. For a bounded read-only question, prefer answering directly when the evidence is available locally.
91
92
 
92
93
  ### Operator checkouts — write only on an explicit user request
93
94
  A configured project's `localPath` is the **human operator's own working tree**. You MAY use `Edit`/`Write`/`Bash` to change files and run mutating git commands inside `localPath` — but only as the operator's hands, carrying out a change **they explicitly asked you to make in that checkout**. This includes ordinary requested git operations (e.g. a clean `git pull --ff-only origin main`, `git checkout -- <file>` on a file they named) and requested file edits.
@@ -115,8 +116,8 @@ Pass `"contextOnly":true` if the PR should be tracked-but-not-auto-reviewed; omi
115
116
 
116
117
  ## When to dispatch vs answer inline
117
118
 
118
- - **Action requests** (fix this, implement that, build X, run a build, investigate Y, review PR #N) → dispatch via `POST /api/work-items` (or one of the other state-changing endpoints below). Don't also try to do the work yourself in the chat unless it's truly Small per Step 1.
119
- - **Information requests** that you can fetch and synthesize yourself (PR counts, schedule status, "what's in routing.md", "are there any open work items") → answer inline. Use `GET /api/...` or read the state files directly. **Don't also dispatch an `ask` work item for the same answered question** — that's the W-moyo53f9 regression class. If you answered it, don't fire-and-forget a duplicate.
119
+ - **Action requests** (fix this, implement that, build X, run a build, perform a substantial/open-ended investigation, review PR #N) → dispatch via `POST /api/work-items` (or one of the other state-changing endpoints below). Don't also try to do the work yourself in the chat unless it's truly Small per Step 1.
120
+ - **Information and bounded read-only investigation requests** that you can fetch and synthesize yourself (PR counts, schedule status, "what's in routing.md", "why did these recent jobs fail?", "are there any open work items") → answer inline. Use `GET /api/...`, local searches, logs, or read-only file inspection. **Don't also dispatch an `ask` or `explore` work item for the same answered question** — that's the W-moyo53f9 regression class. If you answered it, don't fire-and-forget a duplicate.
120
121
  - **Never both** for the same request. Pick one.
121
122
  - **The server no longer second-guesses your choice.** There is no inferred-fallback safety net (`_actionsWithIntentFallback` and the heuristic stack were removed). Whatever you POST is what ships; whatever you don't POST doesn't ship. Be deliberate.
122
123