@yemi33/minions 0.1.1670 → 0.1.1672

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.
package/.claudeignore ADDED
@@ -0,0 +1,5 @@
1
+ agents/
2
+ engine/tmp/
3
+ engine/contexts/
4
+ prd/*.backup
5
+ plans/archive/
package/CHANGELOG.md CHANGED
@@ -1,9 +1,26 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1670 (2026-05-02)
3
+ ## 0.1.1672 (2026-05-02)
4
+
5
+ ### Features
6
+ - fleet model validation + Copilot alias map + simplify pass
7
+
8
+ ## 0.1.1671 (2026-05-02)
9
+
10
+ ### Features
11
+ - durable meeting artifact ingestion (#1972)
12
+
13
+ ### Fixes
14
+ - shared-rules.md — declare done after push, do not wait for remote pipelines (#1970)
15
+ - sync pipeline card with modal after retrigger/abort/continue
16
+ - harden maxConcurrent parsing, routing case, and runtime exit codes
17
+
18
+ ### Other
19
+ - docs: condense CLAUDE.md
20
+
21
+ ## 0.1.1669 (2026-05-02)
4
22
 
5
23
  ### Features
6
- - make ADO PR titles authoritative (#1965)
7
24
  - gate pendingFix clear (#1964)
8
25
 
9
26
  ## 0.1.1668 (2026-05-01)
@@ -434,6 +434,7 @@ async function _refreshPipelineDetail(id) {
434
434
  if (fresh) {
435
435
  _pipelinesData = _pipelinesData.map(function(x) { return x.id === id ? fresh : x; });
436
436
  _pipelinePollHash = JSON.stringify({ runs: fresh.runs || [], enabled: fresh.enabled, _stoppedBy: fresh._stoppedBy, _stopReason: fresh._stopReason });
437
+ renderPipelines(_pipelinesData);
437
438
  openPipelineDetail(id);
438
439
  }
439
440
  } catch (e) { /* silent — next poll will catch up */ }
package/dashboard.js CHANGED
@@ -3357,7 +3357,7 @@ const server = http.createServer(async (req, res) => {
3357
3357
  async function _runKbSweepBackground(body, sweepToken) {
3358
3358
  try {
3359
3359
  const { runKbSweep } = require('./engine/kb-sweep');
3360
- const result = await runKbSweep({ pinnedKeys: body.pinnedKeys });
3360
+ const result = await runKbSweep({ pinnedKeys: body.pinnedKeys, engineConfig: CONFIG.engine });
3361
3361
  global._kbSweepLastResult = result;
3362
3362
  global._kbSweepLastCompletedAt = Date.now();
3363
3363
  } catch (e) {
@@ -5286,7 +5286,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5286
5286
 
5287
5287
  const prompt = `Convert this schedule description to a 3-field cron expression (minute hour dayOfWeek, where dayOfWeek is 0=Sun..6=Sat or ranges like 1-5). Return JSON only: {"cron": "...", "description": "..."}. Input: ${text.trim()}`;
5288
5288
  try {
5289
- const result = await llm.callLLM(prompt, '', { model: 'haiku', maxTurns: 1, timeout: 30000, label: 'schedule-parse', direct: true });
5289
+ const result = await llm.callLLM(prompt, '', {
5290
+ model: 'haiku', maxTurns: 1, timeout: 30000, label: 'schedule-parse', direct: true,
5291
+ engineConfig: CONFIG.engine,
5292
+ });
5290
5293
  const parsed = JSON.parse(result.text.trim());
5291
5294
  if (!parsed.cron) return jsonReply(res, 422, { error: 'Could not parse schedule' });
5292
5295
  return jsonReply(res, 200, { cron: parsed.cron, description: parsed.description || '' });
@@ -5379,6 +5382,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5379
5382
  if (!config.agents) config.agents = {};
5380
5383
 
5381
5384
  const _clamped = [];
5385
+ const _engineModelDiscovery = require('./engine/model-discovery');
5386
+ const _engineRuntimes = require('./engine/runtimes');
5387
+ function _resolveModelForRuntime(modelStr, runtimeName) {
5388
+ try {
5389
+ const adapter = _engineRuntimes.resolveRuntime(runtimeName);
5390
+ if (adapter && typeof adapter.resolveModel === 'function') {
5391
+ return adapter.resolveModel(modelStr) || modelStr;
5392
+ }
5393
+ } catch { /* unknown/free-text runtime */ }
5394
+ return modelStr;
5395
+ }
5382
5396
  if (body.engine) {
5383
5397
  const e = body.engine;
5384
5398
  const D = shared.ENGINE_DEFAULTS;
@@ -5440,10 +5454,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5440
5454
  // (where gpt-5.5 doesn't actually exist) cascaded into every agent
5441
5455
  // that didn't pin its own model. Reject when the model is known to
5442
5456
  // belong to a different runtime than the one it'll spawn against.
5443
- const _engineModelDiscovery = require('./engine/model-discovery');
5444
- const _engineRuntimes = require('./engine/runtimes');
5445
5457
  async function _validateFleetModel(modelStr, resolvedRuntime) {
5446
5458
  if (!modelStr) return null;
5459
+ const runtimeModelStr = _resolveModelForRuntime(modelStr, resolvedRuntime);
5447
5460
  let knownForResolved = null;
5448
5461
  try {
5449
5462
  const list = await _engineModelDiscovery.getRuntimeModels(resolvedRuntime, { config });
@@ -5451,11 +5464,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5451
5464
  knownForResolved = new Set(list.models.map(m => m.id || m.name).filter(Boolean));
5452
5465
  }
5453
5466
  } catch { /* unknown runtime */ }
5454
- if (knownForResolved && !knownForResolved.has(modelStr)) {
5467
+ if (knownForResolved && !knownForResolved.has(runtimeModelStr)) {
5455
5468
  return `not a valid model for runtime "${resolvedRuntime}" (known: ${[...knownForResolved].slice(0, 4).join(', ')}${knownForResolved.size > 4 ? '…' : ''})`;
5456
5469
  }
5457
5470
  if (!knownForResolved) {
5458
- // Free-text runtime (Claude). Reject only if model belongs to a different runtime's published list.
5471
+ // Free-text runtime (Claude). Reject only if the raw model belongs to a different runtime's published list.
5459
5472
  for (const rt of _engineRuntimes.listRuntimes()) {
5460
5473
  if (rt === resolvedRuntime) continue;
5461
5474
  try {
@@ -5548,13 +5561,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5548
5561
  // claude+gpt-* / copilot+claude-* combinations before they crash a
5549
5562
  // dispatch (see #model-validation: a stray engine.defaultModel='gpt-5.5'
5550
5563
  // pinned every Claude agent into a 404 spawn loop).
5551
- const _modelDiscovery = require('./engine/model-discovery');
5552
5564
  const _runtimeModelsCache = new Map(); // runtimeName → Set<modelId> (or null when unknown / Claude)
5553
5565
  async function _modelsFor(runtimeName) {
5554
5566
  if (_runtimeModelsCache.has(runtimeName)) return _runtimeModelsCache.get(runtimeName);
5555
5567
  let set = null;
5556
5568
  try {
5557
- const list = await _modelDiscovery.getRuntimeModels(runtimeName, { config });
5569
+ const list = await _engineModelDiscovery.getRuntimeModels(runtimeName, { config });
5558
5570
  if (Array.isArray(list?.models) && list.models.length > 0) {
5559
5571
  set = new Set(list.models.map(m => m.id || m.name).filter(Boolean));
5560
5572
  }
@@ -5562,11 +5574,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5562
5574
  _runtimeModelsCache.set(runtimeName, set);
5563
5575
  return set;
5564
5576
  }
5565
- // Returns the runtime that "owns" this model, or null if no other
5577
+ // Returns the runtime that "owns" this raw model, or null if no other
5566
5578
  // runtime claims it. Catches "claude + gpt-5.5" by spotting that
5567
5579
  // gpt-5.5 belongs to copilot's list.
5568
5580
  async function _ownerOfModel(modelId) {
5569
- for (const rt of require('./engine/runtimes').listRuntimes()) {
5581
+ for (const rt of _engineRuntimes.listRuntimes()) {
5570
5582
  const set = await _modelsFor(rt);
5571
5583
  if (set && set.has(modelId)) return rt;
5572
5584
  }
@@ -5594,6 +5606,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5594
5606
  else {
5595
5607
  const candidate = String(updates.model);
5596
5608
  const resolvedCli = config.agents[id].cli || config.engine.defaultCli || 'claude';
5609
+ const runtimeModelStr = _resolveModelForRuntime(candidate, resolvedCli);
5597
5610
  const knownModels = await _modelsFor(resolvedCli);
5598
5611
  // Two validation paths:
5599
5612
  // 1. If the runtime publishes a model list, enforce membership.
@@ -5601,7 +5614,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5601
5614
  // model belongs to a DIFFERENT runtime's list — that's how
5602
5615
  // we catch claude+gpt-5.5 (gpt-5.5 is in Copilot's list).
5603
5616
  let rejection = null;
5604
- if (knownModels && !knownModels.has(candidate)) {
5617
+ if (knownModels && !knownModels.has(runtimeModelStr)) {
5605
5618
  rejection = `not a valid model for runtime "${resolvedCli}" (known: ${[...knownModels].slice(0, 4).join(', ')}${knownModels.size > 4 ? '…' : ''})`;
5606
5619
  } else if (!knownModels) {
5607
5620
  const owner = await _ownerOfModel(candidate);
@@ -20,7 +20,7 @@
20
20
  | `capabilities.systemPromptFile` | **`false`** | No `--system-prompt-file` flag exists. Inject system prompt via a `<system>` block prepended to stdin. |
21
21
  | `capabilities.effortLevels` | **`true`** | `--effort` accepts `low|medium|high|xhigh` (no `max`). Adapter must map `'max' → 'xhigh'`. |
22
22
  | `capabilities.costTracking` | **`false`** | `result.usage` contains `premiumRequests` (count, not USD), no token counts, no cost. |
23
- | `capabilities.modelShorthands` | **`false`** | Models are full IDs (`claude-sonnet-4.5`, `gpt-5.4`). No `sonnet`/`opus`/`haiku` aliasing on the Copilot side. |
23
+ | `capabilities.modelShorthands` | **`false`** | The Copilot CLI requires full model IDs (`claude-sonnet-4.5`, `gpt-5.4`). Minions may accept internal aliases (`haiku`, `sonnet`, `opus`), but the adapter translates them to Copilot model IDs before invoking the CLI. |
24
24
  | `capabilities.budgetCap` | **`false`** | No `--max-budget-usd` flag. |
25
25
  | `capabilities.bareMode` | **`false`** | No `--bare`. Closest equivalent is `--no-custom-instructions` (suppresses AGENTS.md only, not all auto-discovery). |
26
26
  | `capabilities.fallbackModel` | **`false`** | No `--fallback-model` flag. |
@@ -594,9 +594,11 @@ When implementing `engine/runtimes/copilot.js`:
594
594
  **Never** emit `--verbose`.
595
595
  4. `buildPrompt()` injects `<system>...</system>\n\n` block when sysprompt is
596
596
  non-empty; passthrough otherwise (§2).
597
- 5. `resolveModel()` is verbatim passthrough; emit a one-time `console.warn`
598
- when input is `'sonnet' | 'opus' | 'haiku'` (Claude shorthand the user
599
- probably meant to set on the Claude adapter).
597
+ 5. `resolveModel()` translates Minions internal aliases before the CLI boundary:
598
+ `'haiku'` `'claude-haiku-4.5'`, `'sonnet'` `'claude-sonnet-4.5'`, and
599
+ `'opus'` `'claude-opus-4.5'`. All other model IDs pass through unchanged.
600
+ Keep `capabilities.modelShorthands` false because aliases are never passed
601
+ to the Copilot CLI.
600
602
  6. `_mapEffort()` private helper does `'max' → 'xhigh'`; pass through otherwise.
601
603
  7. `parseOutput(raw)` produces:
602
604
  - `text`: concatenation of all `assistant.message.data.content` (multi-turn
package/engine/cleanup.js CHANGED
@@ -39,6 +39,30 @@ function worktreeDirMatchesBranch(dirLower, branch) {
39
39
  return dirLower === branchSlug || dirLower.includes(branchSlug + '-') || dirLower.endsWith('-' + branchSlug);
40
40
  }
41
41
 
42
+ let _orphanPidProcessNamesCache = null;
43
+ function _orphanPidProcessNames() {
44
+ if (_orphanPidProcessNamesCache) return _orphanPidProcessNamesCache;
45
+ const names = new Set(['node']);
46
+ try {
47
+ for (const name of require('./runtimes').listRuntimes()) names.add(String(name).toLowerCase());
48
+ // Copilot can run through the GitHub CLI fallback (`gh copilot`), so allow
49
+ // gh only when the copilot runtime is registered.
50
+ if (names.has('copilot')) names.add('gh');
51
+ } catch {
52
+ names.add('claude');
53
+ }
54
+ _orphanPidProcessNamesCache = names;
55
+ return names;
56
+ }
57
+
58
+ function _processNameAllowedForOrphanKill(processText) {
59
+ const firstLine = String(processText || '').trim().split(/\r?\n/).find(Boolean) || '';
60
+ const imageName = path.basename(firstLine.trim().split(/\s+/)[0] || '').toLowerCase().replace(/\.exe$/, '');
61
+ if (!imageName) return false;
62
+ return _orphanPidProcessNames().has(imageName);
63
+ }
64
+
65
+
42
66
  /**
43
67
  * Kill orphaned processes whose dispatch ID appears in the worktree dir name.
44
68
  * Only kills processes NOT in the active dispatch queue — never kills live agents.
@@ -68,18 +92,18 @@ function _killProcessInWorktree(dir, activeProcesses, activeIds) {
68
92
  if (isActive) continue; // still active — do not kill
69
93
  const pid = parseInt(fs.readFileSync(path.join(tmpDir, f), 'utf8').trim(), 10);
70
94
  if (pid > 0) {
71
- // Verify the process is actually a node/claude process before killing
95
+ // Verify the PID still belongs to a Minions runtime process before killing
72
96
  try {
73
97
  if (process.platform === 'win32') {
74
98
  const taskInfo = exec(`tasklist /FI "PID eq ${pid}" /NH`, { encoding: 'utf8', timeout: 3000, windowsHide: true });
75
99
  const taskLower = taskInfo.toLowerCase();
76
- if (!taskLower.includes('node') && !taskLower.includes('claude')) continue;
100
+ if (!_processNameAllowedForOrphanKill(taskLower)) continue;
77
101
  exec(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe', timeout: 5000, windowsHide: true });
78
102
  } else {
79
- // Verify it's a node process before killing (prevent recycled PID kill)
103
+ // Verify the process name before killing (prevent recycled PID kill)
80
104
  try {
81
105
  const psOut = exec(`ps -p ${pid} -o comm=`, { encoding: 'utf8', timeout: 3000 }).trim();
82
- if (!psOut.includes('node') && !psOut.includes('claude')) continue;
106
+ if (!_processNameAllowedForOrphanKill(psOut)) continue;
83
107
  } catch { continue; } // process dead or ps failed
84
108
  try { process.kill(-pid, 'SIGKILL'); } catch { process.kill(pid, 'SIGKILL'); }
85
109
  }
@@ -331,7 +355,7 @@ function runCleanup(config, verbose = false) {
331
355
  } // end worktreeRoots loop
332
356
  }
333
357
 
334
- // 4. Kill zombie claude processes not tracked by the engine
358
+ // 4. Kill zombie agent processes not tracked by the engine
335
359
  // List all node processes, check if any are running spawn-agent.js for our minions
336
360
  try {
337
361
  const dispatch = getDispatch();
package/engine/cli.js CHANGED
@@ -157,8 +157,8 @@ function _modelLooksIncompatible(runtime, model) {
157
157
  return true; // gpt-*, o3-*, codex, etc. — wrong CLI for these
158
158
  }
159
159
  if (runtime === 'copilot') {
160
- // Copilot accepts the full catalog by ID; only Claude shorthands are wrong.
161
- return m === 'sonnet' || m === 'opus' || m === 'haiku';
160
+ // Copilot adapter maps Minions' family aliases before spawning.
161
+ return false;
162
162
  }
163
163
  return false;
164
164
  }
@@ -8,11 +8,11 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const crypto = require('crypto');
10
10
  const shared = require('./shared');
11
- const { safeRead, safeWrite, safeUnlink, runFile, cleanChildEnv,
12
- parseStreamJsonOutput, classifyInboxItem, KB_CATEGORIES, log, ts, dateStamp } = shared;
13
- const { trackEngineUsage } = require('./llm');
11
+ const { safeRead, safeWrite,
12
+ classifyInboxItem, KB_CATEGORIES, log, ts, dateStamp } = shared;
13
+ const { callLLM, trackEngineUsage } = require('./llm');
14
14
  const queries = require('./queries');
15
- const { getInboxFiles, getNotes, INBOX_DIR, ENGINE_DIR, MINIONS_DIR,
15
+ const { getInboxFiles, getNotes, INBOX_DIR, ENGINE_DIR,
16
16
  NOTES_PATH, KNOWLEDGE_DIR, ARCHIVE_DIR } = queries;
17
17
 
18
18
  // Track in-flight LLM consolidation to prevent concurrent runs
@@ -150,51 +150,18 @@ function consolidateWithLLM(items, existingNotes, files, config) {
150
150
  });
151
151
 
152
152
  const prompt = buildConsolidationPrompt(items, existingNotes, kbPaths);
153
-
154
- const tmpDir = path.join(ENGINE_DIR, 'tmp');
155
- if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
156
- const promptPath = path.join(tmpDir, 'consolidate-prompt.md');
157
- safeWrite(promptPath, prompt);
158
-
159
153
  const sysPrompt = 'You are a concise knowledge manager. Output only markdown. No preamble. No code fences around your output.';
160
- const sysPromptPath = path.join(tmpDir, 'consolidate-sysprompt.md');
161
- safeWrite(sysPromptPath, sysPrompt);
162
-
163
- const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
164
- const args = [
165
- '--output-format', 'stream-json',
166
- '--max-turns', '1',
167
- '--model', 'haiku',
168
- '--permission-mode', 'bypassPermissions',
169
- '--verbose',
170
- ];
171
-
172
- log('info', 'Spawning Haiku for LLM consolidation...');
173
-
174
- const proc = runFile(process.execPath, [spawnScript, promptPath, sysPromptPath, ...args], {
175
- cwd: MINIONS_DIR,
176
- stdio: ['pipe', 'pipe', 'pipe'],
177
- env: cleanChildEnv()
178
- });
179
154
 
180
- let stdout = '';
181
- let stderr = '';
182
- proc.stdout.on('data', d => { stdout += d.toString(); if (stdout.length > 100000) stdout = stdout.slice(-50000); });
183
- proc.stderr.on('data', d => { stderr += d.toString(); if (stderr.length > 50000) stderr = stderr.slice(-25000); });
155
+ log('info', 'Starting LLM consolidation...');
184
156
 
185
- const timeout = setTimeout(() => {
186
- log('warn', 'LLM consolidation timed out after 3m — killing and falling back to regex');
187
- shared.killGracefully(proc, 10000);
188
- _forceResetTimeout = setTimeout(() => {
189
- if (!_cleared) log('warn', 'Consolidation flag force-reset after SIGKILL');
190
- _clearProcessingState();
191
- }, 10000);
192
- }, 180000);
157
+ let _cleared = false; // idempotency guard — timeout and promise resolution can race
158
+ let timeoutHandle = null;
159
+ let fallbackDone = false;
193
160
 
194
- let _cleared = false; // idempotency guard — both 'error' and 'close' can fire for the same process
195
161
  function _clearProcessingState() {
196
162
  if (_cleared) return;
197
163
  _cleared = true;
164
+ clearTimeout(timeoutHandle);
198
165
  clearTimeout(_forceResetTimeout);
199
166
  _forceResetTimeout = null;
200
167
  for (const f of files) _processingFiles.delete(f);
@@ -202,17 +169,43 @@ function consolidateWithLLM(items, existingNotes, files, config) {
202
169
  _consolidationStartedAt = 0;
203
170
  }
204
171
 
205
- proc.on('close', (code) => {
206
- clearTimeout(timeout);
207
- try { safeUnlink(promptPath); } catch (err) { log('warn', `Temp file cleanup failed: ${promptPath} — ${err.message}`); }
208
- try { safeUnlink(sysPromptPath); } catch (err) { log('warn', `Temp file cleanup failed: ${sysPromptPath} — ${err.message}`); }
172
+ function _fallback(message, err) {
173
+ if (_cleared || fallbackDone) return;
174
+ fallbackDone = true;
175
+ if (message) log('warn', message);
176
+ if (err?.message) log('debug', `LLM error: ${err.message}`);
177
+ consolidateWithRegex(items, files);
178
+ }
209
179
 
210
- const parsed = parseStreamJsonOutput(stdout);
211
- const extractedText = parsed.text;
212
- trackEngineUsage('consolidation', parsed.usage);
180
+ const llmCall = callLLM(prompt, sysPrompt, {
181
+ timeout: 180000,
182
+ label: 'consolidation',
183
+ model: 'haiku',
184
+ maxTurns: 1,
185
+ direct: true,
186
+ engineConfig: config.engine,
187
+ });
213
188
 
214
- if (code === 0 && (extractedText || stdout).trim().length > 50) {
215
- let digest = (extractedText || stdout).trim();
189
+ timeoutHandle = setTimeout(() => {
190
+ if (_cleared) return;
191
+ log('warn', 'LLM consolidation timed out after 3m — aborting and falling back to regex');
192
+ try { if (llmCall && typeof llmCall.abort === 'function') llmCall.abort(); } catch { /* ignore */ }
193
+ _forceResetTimeout = setTimeout(() => {
194
+ if (!_cleared) log('warn', 'Consolidation flag force-reset after timeout');
195
+ _fallback();
196
+ _clearProcessingState();
197
+ }, 10000);
198
+ }, 180000);
199
+
200
+ llmCall.then((result) => {
201
+ if (_cleared) return;
202
+ clearTimeout(timeoutHandle);
203
+ trackEngineUsage('consolidation', result.usage);
204
+
205
+ const extractedText = result.text || '';
206
+ const rawText = result.raw || '';
207
+ if (result.code === 0 && (extractedText || rawText).trim().length > 50) {
208
+ let digest = (extractedText || rawText).trim();
216
209
  digest = digest.replace(/^\`\`\`\w*\n?/gm, '').replace(/\n?\`\`\`$/gm, '').trim();
217
210
 
218
211
  if (!digest.startsWith('### ')) {
@@ -220,9 +213,7 @@ function consolidateWithLLM(items, existingNotes, files, config) {
220
213
  if (sectionIdx >= 0) {
221
214
  digest = digest.slice(sectionIdx);
222
215
  } else {
223
- log('warn', 'LLM consolidation output missing expected format — falling back to regex');
224
- consolidateWithRegex(items, files);
225
- _clearProcessingState();
216
+ _fallback('LLM consolidation output missing expected format — falling back to regex');
226
217
  return;
227
218
  }
228
219
  }
@@ -257,21 +248,15 @@ function consolidateWithLLM(items, existingNotes, files, config) {
257
248
  });
258
249
  classifyToKnowledgeBase(items);
259
250
  archiveInboxFiles(files);
260
- log('info', `LLM consolidation complete: ${files.length} notes processed by Haiku`);
251
+ log('info', `LLM consolidation complete: ${files.length} notes processed`);
261
252
  } else {
262
- log('warn', `LLM consolidation failed (code=${code}) — falling back to regex`);
263
- if (stderr) log('debug', `LLM stderr: ${stderr.slice(0, 500)}`);
264
- consolidateWithRegex(items, files);
253
+ _fallback(`LLM consolidation failed (code=${result.code}) — falling back to regex`, result.stderr ? { message: result.stderr.slice(0, 500) } : null);
265
254
  }
266
- _clearProcessingState();
267
- });
268
-
269
- proc.on('error', (err) => {
270
- clearTimeout(timeout);
271
- log('warn', `LLM consolidation spawn error: ${err.message} — falling back to regex`);
272
- try { safeUnlink(promptPath); } catch (unlinkErr) { log('warn', `Temp file cleanup failed: ${promptPath} — ${unlinkErr.message}`); }
273
- try { safeUnlink(sysPromptPath); } catch (unlinkErr) { log('warn', `Temp file cleanup failed: ${sysPromptPath} — ${unlinkErr.message}`); }
274
- consolidateWithRegex(items, files);
255
+ }).catch((err) => {
256
+ if (_cleared) return;
257
+ clearTimeout(timeoutHandle);
258
+ _fallback(`LLM consolidation error: ${err.message} falling back to regex`, err);
259
+ }).finally(() => {
275
260
  _clearProcessingState();
276
261
  });
277
262
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-02T00:03:38.474Z"
4
+ "cachedAt": "2026-05-02T03:35:46.216Z"
5
5
  }
@@ -104,7 +104,7 @@ function _hashDedup(manifest, opts = {}) {
104
104
  }
105
105
 
106
106
  /** Batched LLM sweep — finds within-batch dupes, reclassifies, removes stale. */
107
- async function _llmBatchSweep(manifest, callLLM, trackEngineUsage) {
107
+ async function _llmBatchSweep(manifest, callLLM, trackEngineUsage, opts = {}) {
108
108
  const plan = { duplicates: [], reclassify: [], remove: [] };
109
109
  const batches = [];
110
110
  for (let i = 0; i < manifest.length; i += LLM_BATCH_SIZE) {
@@ -130,7 +130,10 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
130
130
 
131
131
  let result;
132
132
  try {
133
- result = await callLLM(prompt, 'Output only JSON.', { timeout: 120000, label: 'kb-sweep', model: 'haiku', maxTurns: 1, direct: true });
133
+ result = await callLLM(prompt, 'Output only JSON.', {
134
+ timeout: 120000, label: 'kb-sweep', model: 'haiku', maxTurns: 1, direct: true,
135
+ engineConfig: opts.engineConfig,
136
+ });
134
137
  trackEngineUsage('kb-sweep', result.usage);
135
138
  } catch (e) { log('warn', `[kb-sweep] batch ${b + 1} LLM error: ${e.message}`); continue; }
136
139
 
@@ -206,6 +209,7 @@ ${body}`;
206
209
  try {
207
210
  const result = await callLLM(REWRITE_PROMPT(c.entry, c.body), 'Output ONLY the template body.', {
208
211
  timeout: 120000, label: 'kb-rewrite', model: 'haiku', maxTurns: 1, direct: true,
212
+ engineConfig: opts.engineConfig,
209
213
  });
210
214
  trackEngineUsage('kb-sweep', result.usage);
211
215
  let newBody = (result.text || '').trim();
@@ -326,7 +330,7 @@ async function runKbSweep(opts = {}) {
326
330
  // 2. LLM batch sweep — within-batch dupes + reclassify + remove stale
327
331
  // Only runs against survivors, but we need indices that match the LIST sent to the LLM
328
332
  const llmManifest = afterHash;
329
- const plan = await _llmBatchSweep(llmManifest, callLLM, trackEngineUsage);
333
+ const plan = await _llmBatchSweep(llmManifest, callLLM, trackEngineUsage, opts);
330
334
  const llmActions = _applyLlmPlan(plan, llmManifest, opts);
331
335
  summary.llmDuplicatesArchived = llmActions.merged;
332
336
  summary.staleRemoved = llmActions.removed;
@@ -2529,7 +2529,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2529
2529
  if (type === WORK_TYPE.MEETING && meta?.meetingId) {
2530
2530
  try {
2531
2531
  const { collectMeetingFindings } = require('./meeting');
2532
- collectMeetingFindings(meta.meetingId, agentId, meta.roundName, stdout);
2532
+ collectMeetingFindings(meta.meetingId, agentId, meta.roundName, stdout, structuredCompletion);
2533
2533
  } catch (err) { log('warn', `Meeting collect: ${err.message}`); }
2534
2534
  }
2535
2535
 
package/engine/llm.js CHANGED
@@ -403,6 +403,12 @@ function _resolveModelFor(callOpts) {
403
403
  return undefined;
404
404
  }
405
405
 
406
+ function _resolveModelForRuntime(runtime, callOpts) {
407
+ const selected = _resolveModelFor(callOpts || {});
408
+ if (!runtime || typeof runtime.resolveModel !== 'function') return selected;
409
+ return runtime.resolveModel(selected);
410
+ }
411
+
406
412
  function _resolveRuntimeFeatureOpts({
407
413
  stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries, engineConfig,
408
414
  } = {}) {
@@ -431,7 +437,7 @@ function callLLM(promptText, sysPromptText, opts = {}) {
431
437
  } = opts;
432
438
 
433
439
  const runtime = _resolveRuntimeFor({ cli: cliOverride, engineConfig });
434
- const model = _resolveModelFor({ model: modelOverride, engineConfig });
440
+ const model = _resolveModelForRuntime(runtime, { model: modelOverride, engineConfig });
435
441
  const runtimeFeatureOpts = _resolveRuntimeFeatureOpts({
436
442
  stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries, engineConfig,
437
443
  });
@@ -528,7 +534,7 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
528
534
  } = opts;
529
535
 
530
536
  const runtime = _resolveRuntimeFor({ cli: cliOverride, engineConfig });
531
- const model = _resolveModelFor({ model: modelOverride, engineConfig });
537
+ const model = _resolveModelForRuntime(runtime, { model: modelOverride, engineConfig });
532
538
  const runtimeFeatureOpts = _resolveRuntimeFeatureOpts({
533
539
  stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries, engineConfig,
534
540
  });
@@ -619,5 +625,6 @@ module.exports = {
619
625
  _resetBinCache,
620
626
  _resolveRuntimeFor,
621
627
  _resolveModelFor,
628
+ _resolveModelForRuntime,
622
629
  _resolveRuntimeFeatureOpts,
623
630
  };
package/engine/meeting.js CHANGED
@@ -19,6 +19,86 @@ const EMPTY_OUTPUT_PATTERNS = ['(no output)', '(no findings)', '(no response)'];
19
19
  // Derive from shared.MINIONS_DIR so createTestMinionsDir()/MINIONS_TEST_DIR
20
20
  // tests can redirect the meetings directory without patching module internals.
21
21
  const MEETINGS_DIR = path.join(shared.MINIONS_DIR, 'meetings');
22
+ const MEETING_NOTE_ARTIFACT_ROOT = path.join(shared.MINIONS_DIR, 'notes', 'inbox');
23
+
24
+ function isEmptyMeetingContent(text) {
25
+ const value = String(text || '').trim();
26
+ return !value || EMPTY_OUTPUT_PATTERNS.includes(value);
27
+ }
28
+
29
+ function isSuccessfulStructuredCompletion(completion) {
30
+ const status = String(completion?.status || completion?.outcome || '').trim().toLowerCase();
31
+ return ['success', 'succeeded', 'complete', 'completed', 'done', 'ok', 'passed'].includes(status);
32
+ }
33
+
34
+ function getStructuredNoteArtifacts(structuredCompletion) {
35
+ const artifacts = structuredCompletion?.artifacts;
36
+ if (!Array.isArray(artifacts)) return [];
37
+ return artifacts.filter(artifact =>
38
+ artifact &&
39
+ typeof artifact === 'object' &&
40
+ String(artifact.type || '').toLowerCase() === 'note' &&
41
+ typeof artifact.path === 'string' &&
42
+ artifact.path.trim()
43
+ );
44
+ }
45
+
46
+ function isPathInside(parent, child) {
47
+ const rel = path.relative(parent, child);
48
+ return rel && !rel.startsWith('..') && !path.isAbsolute(rel);
49
+ }
50
+
51
+ function resolveMeetingNoteArtifactPath(artifactPath) {
52
+ const raw = String(artifactPath || '').trim();
53
+ if (!raw || raw.includes('\0')) return null;
54
+ const resolved = path.resolve(path.isAbsolute(raw) ? raw : path.join(shared.MINIONS_DIR, raw));
55
+ const root = path.resolve(MEETING_NOTE_ARTIFACT_ROOT);
56
+ if (!isPathInside(root, resolved)) return null;
57
+ if (path.extname(resolved).toLowerCase() !== '.md') return null;
58
+ return resolved;
59
+ }
60
+
61
+ function readMeetingNoteArtifact(artifactPath) {
62
+ const resolved = resolveMeetingNoteArtifactPath(artifactPath);
63
+ if (!resolved) {
64
+ log('warn', `Ignoring unsafe meeting note artifact path: ${artifactPath || '(empty)'}`);
65
+ return '';
66
+ }
67
+ try {
68
+ const realRoot = fs.realpathSync(MEETING_NOTE_ARTIFACT_ROOT);
69
+ const realPath = fs.realpathSync(resolved);
70
+ if (!isPathInside(realRoot, realPath)) {
71
+ log('warn', `Ignoring meeting note artifact outside notes/inbox: ${artifactPath}`);
72
+ return '';
73
+ }
74
+ const content = fs.readFileSync(realPath, 'utf8');
75
+ return isEmptyMeetingContent(content) ? '' : content;
76
+ } catch (err) {
77
+ log('warn', `Meeting note artifact unreadable (${artifactPath}): ${err.message}`);
78
+ return '';
79
+ }
80
+ }
81
+
82
+ function resolveStructuredMeetingContent(structuredCompletion) {
83
+ if (!isSuccessfulStructuredCompletion(structuredCompletion)) return '';
84
+ const noteArtifacts = getStructuredNoteArtifacts(structuredCompletion);
85
+ if (noteArtifacts.length === 0) return '';
86
+
87
+ for (const artifact of noteArtifacts) {
88
+ const content = readMeetingNoteArtifact(artifact.path);
89
+ if (content) return content;
90
+ }
91
+
92
+ const summary = String(structuredCompletion.summary || '').trim();
93
+ return isEmptyMeetingContent(summary) ? '' : summary;
94
+ }
95
+
96
+ function resolveMeetingContributionContent(output, structuredCompletion) {
97
+ const { text } = shared.parseStreamJsonOutput(output, { maxTextLength: 50000 });
98
+ const rawContent = (text || '').trim();
99
+ if (!isEmptyMeetingContent(rawContent)) return rawContent;
100
+ return resolveStructuredMeetingContent(structuredCompletion);
101
+ }
22
102
 
23
103
  function truncateMeetingContext(text, maxBytes, label) {
24
104
  return shared.truncateTextBytes(text, maxBytes, `\n\n_...${label} truncated — review the meeting transcript if needed._`);
@@ -323,7 +403,7 @@ function discoverMeetingWork(config) {
323
403
  * Collect findings from a completed meeting agent.
324
404
  * Called from runPostCompletionHooks when type === 'meeting'.
325
405
  */
326
- function collectMeetingFindings(meetingId, agentId, roundName, output) {
406
+ function collectMeetingFindings(meetingId, agentId, roundName, output, structuredCompletion = null) {
327
407
  const meeting = getMeeting(meetingId);
328
408
  if (!meeting) return;
329
409
  if (meeting.status === 'completed' || meeting.status === 'archived') {
@@ -331,17 +411,15 @@ function collectMeetingFindings(meetingId, agentId, roundName, output) {
331
411
  return;
332
412
  }
333
413
 
334
- const { text } = shared.parseStreamJsonOutput(output, { maxTextLength: 50000 });
335
- const rawContent = (text || '').trim();
414
+ const content = resolveMeetingContributionContent(output, structuredCompletion);
336
415
 
337
416
  // Validate output — reject empty or placeholder responses
338
- if (!rawContent || EMPTY_OUTPUT_PATTERNS.includes(rawContent)) {
417
+ if (isEmptyMeetingContent(content)) {
339
418
  log('warn', `Meeting ${meetingId}: agent ${agentId} returned empty output for ${roundName} — rejecting`);
340
419
  // Don't record it — agent will be re-dispatched on next tick
341
420
  saveMeeting(meeting);
342
421
  return;
343
422
  }
344
- const content = rawContent;
345
423
 
346
424
  if (roundName === 'investigate') {
347
425
  meeting.findings[agentId] = { content, submittedAt: ts() };
@@ -486,6 +486,7 @@ async function executePlanStage(stage, stageState, run, config) {
486
486
  const fullPrompt = meetingContext + '\n\n---\n\n' + planPrompt;
487
487
  const result = await llm.callLLM(fullPrompt, '', {
488
488
  timeout: 120000, label: 'pipeline-plan', model: 'sonnet', maxTurns: 1,
489
+ engineConfig: config.engine,
489
490
  });
490
491
  if (result.text) {
491
492
  content = result.text;
package/engine/routing.js CHANGED
@@ -125,7 +125,8 @@ function normalizeWorkType(workType, fallback = WORK_TYPE.IMPLEMENT) {
125
125
 
126
126
  function routeForWorkType(workType) {
127
127
  const routes = getRoutingTableCached();
128
- return routes[normalizeWorkType(workType)] || routes[WORK_TYPE.IMPLEMENT] || { preferred: ANY_AGENT, fallback: ANY_AGENT };
128
+ const routeKey = normalizeWorkType(workType).toLowerCase();
129
+ return routes[routeKey] || routes[WORK_TYPE.IMPLEMENT] || { preferred: ANY_AGENT, fallback: ANY_AGENT };
129
130
  }
130
131
 
131
132
  function isAgentHardPinned(item) {
@@ -15,10 +15,10 @@
15
15
  * buildPrompt() prepends a <system> block.
16
16
  * - costTracking: false — result.usage has premiumRequests count
17
17
  * and durations only; no USD or per-token.
18
- * - modelShorthands: false — full model IDs like "claude-sonnet-4.5",
19
- * "gpt-5.4". Bare "sonnet" / "opus" / "haiku"
20
- * is a Claude-ism log a one-time warning
21
- * when seen so the user notices the mistake.
18
+ * - modelShorthands: false — Copilot itself expects full model IDs like
19
+ * "claude-sonnet-4.5" or "gpt-5.4". The
20
+ * adapter translates Minions' internal family
21
+ * aliases before argv construction.
22
22
  * - modelDiscovery: true — GET https://api.githubcopilot.com/models
23
23
  * with `gh auth token` Bearer returns the
24
24
  * catalog (24 models on the test account).
@@ -152,26 +152,23 @@ function resolveBinary({ env = process.env } = {}) {
152
152
 
153
153
  // ── Model Resolution ────────────────────────────────────────────────────────
154
154
  //
155
- // Copilot models are full IDs (`claude-sonnet-4.5`, `gpt-5.4`, ...). The
156
- // adapter passes them through verbatim. When we see a Claude shorthand
157
- // ('sonnet', 'opus', 'haiku') we log ONCE a stronger signal than silently
158
- // passing it to Copilot, which would respond with an unknown-model error.
159
-
160
- const _CLAUDE_SHORTHANDS = new Set(['sonnet', 'opus', 'haiku']);
161
- let _shorthandWarningLogged = false;
162
-
163
- function _resetShorthandWarning() { _shorthandWarningLogged = false; }
155
+ // Copilot models are full IDs (`claude-sonnet-4.5`, `gpt-5.4`, ...). Minions
156
+ // still uses the family aliases `haiku` / `sonnet` / `opus` internally, so this
157
+ // adapter maps those aliases to Copilot model IDs while passing all other input
158
+ // through verbatim. The capability remains false: Copilot CLI does not accept
159
+ // these aliases directly.
160
+
161
+ const _MINIONS_MODEL_ALIASES = {
162
+ haiku: 'claude-haiku-4.5',
163
+ sonnet: 'claude-sonnet-4.5',
164
+ opus: 'claude-opus-4.5',
165
+ };
164
166
 
165
- function resolveModel(input, { logger = console } = {}) {
167
+ function resolveModel(input) {
166
168
  if (input == null || input === '') return undefined;
167
169
  const s = String(input);
168
- if (_CLAUDE_SHORTHANDS.has(s.toLowerCase()) && !_shorthandWarningLogged) {
169
- _shorthandWarningLogged = true;
170
- try {
171
- const warn = (logger && typeof logger.warn === 'function') ? logger.warn.bind(logger) : null;
172
- if (warn) warn(`[copilot] "${s}" is a Claude family shorthand; Copilot expects a full model id (e.g. claude-sonnet-4.5). Passing through verbatim — Copilot will likely reject it.`);
173
- } catch { /* logger may be unwired during tests */ }
174
- }
170
+ const mapped = _MINIONS_MODEL_ALIASES[s.toLowerCase()];
171
+ if (mapped) return mapped;
175
172
  return s;
176
173
  }
177
174
 
@@ -783,8 +780,7 @@ module.exports = {
783
780
  createStreamConsumer,
784
781
  // Exposed for unit tests — engine code MUST go through resolveRuntime + the
785
782
  // adapter contract; never reach into these helpers directly.
786
- _CLAUDE_SHORTHANDS,
787
- _resetShorthandWarning,
783
+ _MINIONS_MODEL_ALIASES,
788
784
  _mapEffort,
789
785
  _copilotAssistantMessageHasTools,
790
786
  KNOWN_EVENT_TYPES,
package/engine/shared.js CHANGED
@@ -445,12 +445,15 @@ function mutateCooldowns(mutator) {
445
445
  }, { defaultValue: {}, skipWriteIfUnchanged: true });
446
446
  }
447
447
 
448
+ let _uidCounter = 0;
449
+
448
450
  /**
449
- * Generate a unique ID suffix: timestamp + 4 random chars.
451
+ * Generate a unique ID suffix: timestamp + monotonic counter + random chars.
450
452
  * Use for filenames that could collide (dispatch IDs, temp files, etc.)
451
453
  */
452
454
  function uid() {
453
- return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
455
+ _uidCounter = (_uidCounter + 1) % 0x1000000;
456
+ return Date.now().toString(36) + _uidCounter.toString(36).padStart(4, '0') + crypto.randomBytes(2).toString('hex');
454
457
  }
455
458
 
456
459
  /**
@@ -122,6 +122,12 @@ function buildSpawnInvocation({ runtime, resolved, promptText, sysPromptText, op
122
122
  };
123
123
  }
124
124
 
125
+ function normalizeRuntimeExit(code, signal) {
126
+ if (Number.isInteger(code)) return code;
127
+ if (signal) return 128;
128
+ return 1;
129
+ }
130
+
125
131
  // ─── Main script execution ──────────────────────────────────────────────────
126
132
 
127
133
  function _installHint(name, runtime) {
@@ -278,12 +284,13 @@ function main() {
278
284
  }, MCP_STARTUP_TIMEOUT);
279
285
  proc.stdout.once('data', () => { gotFirstOutput = true; clearTimeout(startupTimer); });
280
286
 
281
- proc.on('close', (code) => {
287
+ proc.on('close', (code, signal) => {
282
288
  clearTimeout(startupTimer);
289
+ const exitCode = normalizeRuntimeExit(code, signal);
283
290
  // Write process-exit sentinel to stdout so the engine can detect completion (#716).
284
- try { process.stdout.write(`\n[process-exit] code=${code}\n`); } catch { /* stdout may be closed */ }
285
- fs.appendFileSync(debugPath, `EXIT: code=${code}\nSTDERR: ${stderrBuf.slice(0, 500)}\n`);
286
- process.exit(code || 0);
291
+ try { process.stdout.write(`\n[process-exit] code=${exitCode}${signal ? ` signal=${signal}` : ''}\n`); } catch { /* stdout may be closed */ }
292
+ fs.appendFileSync(debugPath, `EXIT: code=${exitCode}${signal ? ` signal=${signal}` : ''}\nSTDERR: ${stderrBuf.slice(0, 500)}\n`);
293
+ process.exit(exitCode);
287
294
  });
288
295
  proc.on('error', (err) => {
289
296
  fs.appendFileSync(debugPath, `ERROR: ${err.message}\n`);
@@ -291,6 +298,6 @@ function main() {
291
298
  });
292
299
  }
293
300
 
294
- module.exports = { parseSpawnArgs, buildSpawnInvocation };
301
+ module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit };
295
302
 
296
303
  if (require.main === module) main();
package/engine.js CHANGED
@@ -3543,7 +3543,7 @@ async function discoverWork(config) {
3543
3543
  );
3544
3544
  if (hasIncompleteImplements) {
3545
3545
  const activeCount = (getDispatch().active || []).length;
3546
- const maxConcurrent = config.engine?.maxConcurrent ?? ENGINE_DEFAULTS.maxConcurrent;
3546
+ const maxConcurrent = resolveMaxConcurrent(config);
3547
3547
  const freeSlots = Math.max(0, maxConcurrent - activeCount);
3548
3548
  if (freeSlots === 0) {
3549
3549
  if (allReviews.length > 0) {
@@ -3630,6 +3630,13 @@ function isSoftFixDispatch(item) {
3630
3630
  return item?.type === WORK_TYPE.FIX && !routing.isAgentHardPinned(item.meta?.item);
3631
3631
  }
3632
3632
 
3633
+ function resolveMaxConcurrent(config) {
3634
+ const raw = config?.engine?.maxConcurrent;
3635
+ if (raw === undefined || raw === null || raw === '') return ENGINE_DEFAULTS.maxConcurrent;
3636
+ const value = Number(raw);
3637
+ return Number.isFinite(value) && value >= 0 ? value : ENGINE_DEFAULTS.maxConcurrent;
3638
+ }
3639
+
3633
3640
  // ─── Main Tick ──────────────────────────────────────────────────────────────
3634
3641
 
3635
3642
  let tickCount = 0;
@@ -3936,7 +3943,7 @@ async function tickInner() {
3936
3943
  {
3937
3944
  const dispatchPre = getDispatch();
3938
3945
  const activeCountPre = (dispatchPre.active || []).length;
3939
- const maxC = config.engine?.maxConcurrent ?? ENGINE_DEFAULTS.maxConcurrent;
3946
+ const maxC = resolveMaxConcurrent(config);
3940
3947
  setTempBudget(Math.max(0, maxC - activeCountPre));
3941
3948
  }
3942
3949
  try { pruneStalePrDispatches(config); } catch (e) { log('warn', 'prune stale PR dispatches: ' + e.message); }
@@ -3954,7 +3961,7 @@ async function tickInner() {
3954
3961
  // 5. Process pending dispatches — auto-spawn agents
3955
3962
  const dispatch = getDispatch();
3956
3963
  const activeCount = (dispatch.active || []).length;
3957
- const maxConcurrent = config.engine?.maxConcurrent || 5;
3964
+ const maxConcurrent = resolveMaxConcurrent(config);
3958
3965
 
3959
3966
  const slotsAvailable = Math.max(0, maxConcurrent - activeCount);
3960
3967
 
@@ -4254,7 +4261,7 @@ module.exports = {
4254
4261
 
4255
4262
  // Tick
4256
4263
  tick,
4257
- _pollIntervalMsFromTicks, _shouldRunPeriodicPhase, // exported for testing
4264
+ resolveMaxConcurrent, _pollIntervalMsFromTicks, _shouldRunPeriodicPhase, // exported for testing
4258
4265
  };
4259
4266
 
4260
4267
  // ─── Entrypoint ─────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1670",
3
+ "version": "0.1.1672",
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"
@@ -64,6 +64,18 @@ Use `status: "failed"` plus an accurate `failure_class`, `retryable`, and `needs
64
64
 
65
65
  Builds, dependency installs, tests, and local servers can be quiet for long periods. Run the repo's normal CLI commands and let them finish; do not add artificial progress output, heartbeat loops, or command-specific workarounds just to keep Minions active.
66
66
 
67
+ ## Done = pushed + local validation. Do NOT wait for remote pipelines.
68
+
69
+ Your dispatch is **done** the moment (1) your fix is pushed to the branch and (2) any local validation you ran has finished. Write the completion JSON and exit immediately. **Do not** wait for the remote PR pipeline (Android OCM PR build, Espresso CloudTest, GitHub Actions, etc.) to finish before declaring done.
70
+
71
+ This applies to **every** fix dispatch, including `build-fix` tasks. Pipeline failures route back through separate engine paths (a new `build-fix` dispatch will be queued if the remote build fails); your job ends at push.
72
+
73
+ Concretely:
74
+ - After `git push`, write the completion report and exit. Do not start a `monitor`/`read_powershell`/`watch` loop on the pipeline.
75
+ - Do not sleep or busy-wait for `mergeStatus`, `buildStatus`, or any ADO/GitHub API to flip from `running` to `passing`.
76
+ - If you skipped local validation, say so in the completion JSON (e.g. `tests: skipped — relying on PR pipeline`) and still exit.
77
+ - Holding a slot to watch a pipeline is wasted capacity; the engine has its own pipeline-monitoring path.
78
+
67
79
  ## Checking PR and Build Status
68
80
 
69
81
  When asked to check build status, CI results, or review state for a PR: