@yemi33/minions 0.1.1613 → 0.1.1615

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1615 (2026-04-29)
4
+
5
+ ### Features
6
+ - harden publish workflow cleanup (#1850)
7
+ - isolate unit test state (#1847)
8
+ - clarify ado tooling guidance (#1838)
9
+
10
+ ### Fixes
11
+ - recover ===ACTIONS=== JSON from fences and trailing prose (#1834) (#1837)
12
+
3
13
  ## 0.1.1613 (2026-04-28)
4
14
 
5
15
  ### Features
package/README.md CHANGED
@@ -276,7 +276,7 @@ When you run `minions add <dir>`, it prompts for project details and saves them
276
276
 
277
277
  **Key fields:**
278
278
  - `description` — critical for auto-routing. Agents read this to decide which repo to work in.
279
- - `repoHost` — `"ado"` (Azure DevOps) or `"github"`. Controls which MCP tools agents use for PR creation, review comments, and status checks. Defaults to `"ado"`.
279
+ - `repoHost` — `"ado"` (Azure DevOps) or `"github"`. Controls which repo-host tooling agents use for PR creation, review comments, and status checks. Defaults to `"ado"`.
280
280
  - `repositoryId` — required for ADO (the repo GUID), optional for GitHub.
281
281
  - `adoOrg` — ADO organization or GitHub org/user.
282
282
  - `adoProject` — ADO project name (leave blank for GitHub).
@@ -302,17 +302,17 @@ All detected values are shown as defaults in the interactive prompts — just pr
302
302
 
303
303
  When dispatching agents, the engine reads each project's `CLAUDE.md` and injects it into the agent's system prompt as "Project Conventions". This means agents automatically follow repo-specific rules (logging, build commands, coding style, etc.) without needing to discover them each time. Each project can have different conventions.
304
304
 
305
- ## MCP Server Integration
305
+ ## Repo Host Tooling
306
306
 
307
- Agents need MCP tools to interact with your repo host (create PRs, post review comments, etc.). Agents inherit MCP servers directly from `~/.claude.json` as Claude Code processes add servers there and they're immediately available to all agents on next spawn.
307
+ Agents need repo-host tooling to create PRs, post review comments, check status, and handle review feedback. GitHub repos use `gh`. Azure DevOps repos should use the `az` CLI first and keep the Azure DevOps MCP server available only as a fallback when `az` is unavailable or does not support the required operation.
308
308
 
309
- **Example:** If you use Azure DevOps, configure the `azure-ado` MCP server in your Claude Code settings. If you use GitHub, configure the `github` MCP server. Agents will discover and use whichever tools are available.
309
+ Agents inherit MCP servers directly from `~/.claude.json` as Claude Code processes add fallback servers there and they're immediately available to all agents on next spawn.
310
310
 
311
311
  Manually refresh with `minions mcp-sync`.
312
312
 
313
313
  ### Azure DevOps Users
314
314
 
315
- For the best experience with ADO repos, install the [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) and use the [Azure DevOps MCP server](https://github.com/microsoft/azure-devops-mcp). This gives agents full access to PRs, work items, repos, and pipelines via MCP tools no `gh` CLI needed.
315
+ For the best experience with ADO repos, install the [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) with the Azure DevOps extension. Agents should use the `az` CLI first for Azure DevOps operations such as PR creation, PR lookup, comments, reviewers, work items, and pipelines. Use the Azure DevOps MCP fallback only when `az` is unavailable in the environment or insufficient for a specific action.
316
316
 
317
317
  ```bash
318
318
  # Install Azure CLI
@@ -320,12 +320,13 @@ winget install Microsoft.AzureCLI # Windows
320
320
  brew install azure-cli # macOS
321
321
  curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash # Linux
322
322
 
323
- # Login and set defaults
323
+ # Install/enable the Azure DevOps extension, then login and set defaults
324
+ az extension add --name azure-devops
324
325
  az login
325
326
  az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG project=YOUR_PROJECT
326
327
  ```
327
328
 
328
- Then add the ADO MCP server to your Claude Code settings (`~/.claude.json`). The engine will auto-sync it to all agents on next start.
329
+ Optionally add the [Azure DevOps MCP server](https://github.com/microsoft/azure-devops-mcp) to your Claude Code settings (`~/.claude.json`) as a fallback. The engine will auto-sync it to all agents on next start.
329
330
 
330
331
  ## Work Items
331
332
 
@@ -623,6 +623,11 @@ async function _ccDoSend(message, skipUserMsg, forceTabId) {
623
623
  if (evt.actions && evt.actions.length > 0) {
624
624
  _tagServerExecuted(evt.actions, evt.actionResults);
625
625
  for (var ai = 0; ai < evt.actions.length; ai++) { await ccExecuteAction(evt.actions[ai], activeTabId); }
626
+ } else if (evt.actionParseError) {
627
+ // Issue #1834: server saw ===ACTIONS=== but couldn't parse the JSON.
628
+ // Surface as an inline warning so the user knows actions were dropped
629
+ // (was previously silent — appeared as "actions failed" with no signal).
630
+ addMsg('system', '<div style="padding:6px 12px;font-size:11px;color:var(--red);background:var(--surface2);border-radius:6px;margin:4px 0">⚠️ Actions block emitted but JSON could not be parsed — no actions were executed. Resend or rephrase. (' + escHtml(String(evt.actionParseError).slice(0, 200)) + ')</div>', false, activeTabId);
626
631
  }
627
632
  } else if (evt.type === 'error') {
628
633
  terminalEventSeen = true;
@@ -454,18 +454,51 @@ function _renderMdChunked(fullText) {
454
454
 
455
455
  function openBugReport() {
456
456
  document.getElementById('modal-title').textContent = 'Report a Bug';
457
- document.getElementById('modal-body').innerHTML =
458
- '<div style="display:flex;flex-direction:column;gap:12px">' +
459
- '<p style="color:var(--muted);font-size:12px;margin:0">File a bug on the Minions repo (yemi33/minions).</p>' +
460
- '<label style="color:var(--text);font-size:var(--text-md)">Title' +
461
- '<input id="bug-title" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text)" placeholder="Short description of the bug"></label>' +
462
- '<label style="color:var(--text);font-size:var(--text-md)">Description' +
463
- '<textarea id="bug-desc" rows="6" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);resize:vertical;font-family:inherit" placeholder="Steps to reproduce, expected vs actual behavior..."></textarea></label>' +
464
- '<div style="display:flex;justify-content:flex-end;gap:8px">' +
465
- '<button onclick="closeModal()" class="pr-pager-btn">Cancel</button>' +
466
- '<button onclick="submitBugReport()" style="padding:6px 16px;background:var(--red);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer">File Bug</button>' +
467
- '</div>' +
468
- '</div>';
457
+ var modalBody = document.getElementById('modal-body');
458
+ var container = document.createElement('div');
459
+ container.style.cssText = 'display:flex;flex-direction:column;gap:12px';
460
+ var intro = document.createElement('p');
461
+ intro.style.cssText = 'color:var(--muted);font-size:12px;margin:0';
462
+ intro.textContent = 'File a bug on the Minions repo (yemi33/minions).';
463
+
464
+ var titleLabel = document.createElement('label');
465
+ titleLabel.style.cssText = 'color:var(--text);font-size:var(--text-md)';
466
+ titleLabel.appendChild(document.createTextNode('Title'));
467
+ var titleInput = document.createElement('input');
468
+ titleInput.id = 'bug-title';
469
+ titleInput.style.cssText = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text)';
470
+ titleInput.placeholder = 'Short description of the bug';
471
+ titleLabel.appendChild(titleInput);
472
+
473
+ var descLabel = document.createElement('label');
474
+ descLabel.style.cssText = 'color:var(--text);font-size:var(--text-md)';
475
+ descLabel.appendChild(document.createTextNode('Description'));
476
+ var descInput = document.createElement('textarea');
477
+ descInput.id = 'bug-desc';
478
+ descInput.rows = 6;
479
+ descInput.style.cssText = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);resize:vertical;font-family:inherit';
480
+ descInput.placeholder = 'Steps to reproduce, expected vs actual behavior...';
481
+ descLabel.appendChild(descInput);
482
+
483
+ var actions = document.createElement('div');
484
+ actions.style.cssText = 'display:flex;justify-content:flex-end;gap:8px';
485
+ var cancelBtn = document.createElement('button');
486
+ cancelBtn.className = 'pr-pager-btn';
487
+ cancelBtn.textContent = 'Cancel';
488
+ cancelBtn.onclick = closeModal;
489
+ var submitBtn = document.createElement('button');
490
+ submitBtn.id = 'bug-submit';
491
+ submitBtn.style.cssText = 'padding:6px 16px;background:var(--red);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer';
492
+ submitBtn.textContent = 'File Bug';
493
+ submitBtn.onclick = submitBugReport;
494
+ actions.appendChild(cancelBtn);
495
+ actions.appendChild(submitBtn);
496
+
497
+ container.appendChild(intro);
498
+ container.appendChild(titleLabel);
499
+ container.appendChild(descLabel);
500
+ container.appendChild(actions);
501
+ modalBody.replaceChildren(container);
469
502
  document.getElementById('modal').classList.add('open');
470
503
  }
471
504
 
@@ -475,7 +508,7 @@ async function submitBugReport() {
475
508
  if (!title) { alert('Title is required'); return; }
476
509
 
477
510
  // Show progress inside the modal
478
- var btn = document.querySelector('#modal button[onclick="submitBugReport()"]');
511
+ var btn = document.getElementById('bug-submit') || document.querySelector('#modal button[onclick="submitBugReport()"]');
479
512
  if (btn) { btn.disabled = true; btn.textContent = 'Filing...'; }
480
513
 
481
514
  try {
@@ -487,13 +520,40 @@ async function submitBugReport() {
487
520
  if (!res.ok) throw new Error(d.error || 'Failed');
488
521
 
489
522
  // Show success inside the modal
490
- document.getElementById('modal-body').innerHTML =
491
- '<div style="padding:24px;text-align:center">' +
492
- '<div style="font-size:32px;margin-bottom:12px">&#128027;</div>' +
493
- '<div style="color:var(--green);font-weight:600;margin-bottom:8px">Bug filed!</div>' +
494
- (d.url ? '<a href="' + escHtml(d.url) + '" target="_blank" style="color:var(--blue);font-size:13px">View issue on GitHub</a>' : '<span style="color:var(--muted);font-size:12px">Issue created on yemi33/minions</span>') +
495
- '<div style="margin-top:16px"><button onclick="closeModal()" style="padding:6px 16px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;color:var(--text)">Close</button></div>' +
496
- '</div>';
523
+ var modalBody = document.getElementById('modal-body');
524
+ var container = document.createElement('div');
525
+ container.style.cssText = 'padding:24px;text-align:center';
526
+ var icon = document.createElement('div');
527
+ icon.style.cssText = 'font-size:32px;margin-bottom:12px';
528
+ icon.textContent = '\u{1F41B}';
529
+ var heading = document.createElement('div');
530
+ heading.style.cssText = 'color:var(--green);font-weight:600;margin-bottom:8px';
531
+ heading.textContent = 'Bug filed!';
532
+ container.appendChild(icon);
533
+ container.appendChild(heading);
534
+ if (d.url) {
535
+ var link = document.createElement('a');
536
+ link.href = safeUrl(d.url);
537
+ link.target = '_blank';
538
+ link.rel = 'noopener noreferrer';
539
+ link.style.cssText = 'color:var(--blue);font-size:13px';
540
+ link.textContent = 'View issue on GitHub';
541
+ container.appendChild(link);
542
+ } else {
543
+ var msg = document.createElement('span');
544
+ msg.style.cssText = 'color:var(--muted);font-size:12px';
545
+ msg.textContent = 'Issue created on yemi33/minions';
546
+ container.appendChild(msg);
547
+ }
548
+ var actions = document.createElement('div');
549
+ actions.style.marginTop = '16px';
550
+ var closeBtn = document.createElement('button');
551
+ closeBtn.style.cssText = 'padding:6px 16px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;color:var(--text)';
552
+ closeBtn.textContent = 'Close';
553
+ closeBtn.onclick = closeModal;
554
+ actions.appendChild(closeBtn);
555
+ container.appendChild(actions);
556
+ modalBody.replaceChildren(container);
497
557
  } catch (e) {
498
558
  // Show error inside the modal — let user retry
499
559
  if (btn) { btn.disabled = false; btn.textContent = 'File Bug'; }
package/dashboard.js CHANGED
@@ -758,16 +758,68 @@ function findCCActionsDelimiter(text) {
758
758
  return match.index + match[0].indexOf('===ACTIONS===');
759
759
  }
760
760
 
761
+ // Issue #1834: non-Claude runtimes (Copilot/GPT) routinely wrap the action JSON
762
+ // in ```json fences or append trailing prose ("Let me know if that helps!").
763
+ // JSON.parse on the raw segment fails silently → actions dropped, user sees
764
+ // inert text. This extractor pulls out the balanced JSON value (array or
765
+ // object) regardless of fences, leading whitespace, or trailing junk so the
766
+ // downstream parse can succeed. Returns null if no plausible JSON value is
767
+ // present (caller surfaces the failure via _actionParseError).
768
+ function _extractActionsJson(segment) {
769
+ if (!segment) return null;
770
+ let body = segment.trim();
771
+ // Strip ```json / ``` fences (open + close). The model sometimes only emits
772
+ // an opening fence (truncation), so handle both halves independently.
773
+ body = body.replace(/^```[a-zA-Z0-9_-]*\s*\r?\n?/, '').replace(/\r?\n?```\s*$/, '').trim();
774
+ if (!body) return null;
775
+ const first = body.indexOf('[');
776
+ const firstObj = body.indexOf('{');
777
+ let start = -1;
778
+ let openCh = '';
779
+ let closeCh = '';
780
+ if (first >= 0 && (firstObj < 0 || first <= firstObj)) {
781
+ start = first; openCh = '['; closeCh = ']';
782
+ } else if (firstObj >= 0) {
783
+ start = firstObj; openCh = '{'; closeCh = '}';
784
+ }
785
+ if (start < 0) return null;
786
+ let depth = 0;
787
+ let inString = false;
788
+ let escape = false;
789
+ for (let i = start; i < body.length; i++) {
790
+ const ch = body[i];
791
+ if (escape) { escape = false; continue; }
792
+ if (ch === '\\') { escape = true; continue; }
793
+ if (ch === '"') { inString = !inString; continue; }
794
+ if (inString) continue;
795
+ if (ch === openCh) depth++;
796
+ else if (ch === closeCh) {
797
+ depth--;
798
+ if (depth === 0) return body.slice(start, i + 1);
799
+ }
800
+ }
801
+ return null;
802
+ }
803
+
761
804
  function parseCCActions(text) {
762
805
  let actions = [];
763
806
  let displayText = text;
807
+ let parseError = null;
764
808
  const delimIdx = findCCActionsDelimiter(text);
765
809
  if (delimIdx >= 0) {
766
810
  displayText = text.slice(0, delimIdx).trim();
767
- try {
768
- const parsed = JSON.parse(text.slice(delimIdx + '===ACTIONS==='.length).trim());
769
- actions = Array.isArray(parsed) ? parsed : [parsed];
770
- } catch {}
811
+ const segment = text.slice(delimIdx + '===ACTIONS==='.length);
812
+ const jsonStr = _extractActionsJson(segment);
813
+ if (jsonStr) {
814
+ try {
815
+ const parsed = JSON.parse(jsonStr);
816
+ actions = Array.isArray(parsed) ? parsed : [parsed];
817
+ } catch (e) {
818
+ parseError = e.message || 'invalid JSON';
819
+ }
820
+ } else if (segment.trim()) {
821
+ parseError = 'no JSON value found after ===ACTIONS=== delimiter';
822
+ }
771
823
  }
772
824
  if (actions.length === 0) {
773
825
  const actionRegex = /`{3,}\s*action\s*\r?\n([\s\S]*?)`{3,}/g;
@@ -775,9 +827,24 @@ function parseCCActions(text) {
775
827
  while ((match = actionRegex.exec(displayText)) !== null) {
776
828
  try { actions.push(JSON.parse(match[1].trim())); } catch {}
777
829
  }
778
- if (actions.length > 0) displayText = displayText.replace(/`{3,}\s*action\s*\r?\n[\s\S]*?`{3,}\n?/g, '').trim();
830
+ if (actions.length > 0) {
831
+ displayText = displayText.replace(/`{3,}\s*action\s*\r?\n[\s\S]*?`{3,}\n?/g, '').trim();
832
+ parseError = null; // legacy fallback recovered actions
833
+ }
779
834
  }
780
- return { text: displayText, actions };
835
+ const result = { text: displayText, actions };
836
+ if (parseError && actions.length === 0) {
837
+ result._actionParseError = parseError;
838
+ // Visibility for the engine log — silent failure here previously masked issue #1834.
839
+ try {
840
+ const snippet = (text.slice(delimIdx + '===ACTIONS==='.length).trim() || '').slice(0, 200);
841
+ console.warn(`[CC] action JSON parse failed (${parseError}); raw segment: ${snippet}`);
842
+ if (typeof shared !== 'undefined' && shared && typeof shared.log === 'function') {
843
+ shared.log('warn', `CC action JSON parse failed: ${parseError} — segment: ${snippet}`);
844
+ }
845
+ } catch { /* logging is best-effort */ }
846
+ }
847
+ return result;
781
848
  }
782
849
 
783
850
  // ── /loop → create-watch safety net ──────────────────────────────────────────
@@ -4382,7 +4449,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4382
4449
  if (parsed.actions.length > 0) {
4383
4450
  parsed.actionResults = await executeCCActions(parsed.actions);
4384
4451
  }
4385
- const reply = { ...parsed, sessionId: ccSession.sessionId, newSession: !wasResume };
4452
+ // Issue #1834: rename _actionParseError actionParseError (public field)
4453
+ // so the client can surface a warning when the model emitted ===ACTIONS===
4454
+ // but the JSON couldn't be recovered.
4455
+ const { _actionParseError, ...parsedReply } = parsed;
4456
+ const reply = { ...parsedReply, sessionId: ccSession.sessionId, newSession: !wasResume };
4457
+ if (_actionParseError) reply.actionParseError = _actionParseError;
4386
4458
  if (sessionReset) reply.sessionReset = true;
4387
4459
  return jsonReply(res, 200, reply);
4388
4460
  } finally {
@@ -4639,7 +4711,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4639
4711
  }
4640
4712
 
4641
4713
  // Send final result with actions — execute server-side first
4642
- const { text: displayText, actions } = parseCCActions(result.text);
4714
+ const { text: displayText, actions, _actionParseError } = parseCCActions(result.text);
4643
4715
  // Safety net: detect /loop invocation and convert to create-watch
4644
4716
  const _loopWatch = _detectLoopInvocation(displayText, actions, toolUses);
4645
4717
  if (_loopWatch) {
@@ -4652,6 +4724,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4652
4724
  actionResults = await executeCCActions(actions);
4653
4725
  }
4654
4726
  const donePayload = { type: 'done', text: displayText, actions, actionResults, sessionId: responseSessionId, newSession: !wasResume };
4727
+ // Issue #1834: surface action JSON parse failures so the UI can warn
4728
+ // instead of silently dropping. Client renders this as a small notice.
4729
+ if (_actionParseError) donePayload.actionParseError = _actionParseError;
4655
4730
  if (sessionReset) donePayload.sessionReset = true;
4656
4731
  liveState.donePayload = donePayload;
4657
4732
  if (liveState.writer) liveState.writer(donePayload);
@@ -0,0 +1,5 @@
1
+ {
2
+ "runtime": "copilot",
3
+ "models": null,
4
+ "cachedAt": "2026-04-29T00:02:27.565Z"
5
+ }
@@ -8,12 +8,22 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const shared = require('./shared');
10
10
  const queries = require('./queries');
11
- const { safeJson, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, ENGINE_DEFAULTS } = shared;
11
+ const { safeJson, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
12
12
  const http = require('http');
13
13
  const { parseCronExpr, shouldRunNow } = require('./scheduler');
14
14
 
15
- const PIPELINES_DIR = path.join(__dirname, '..', 'pipelines');
16
- const PIPELINE_RUNS_PATH = path.join(__dirname, 'pipeline-runs.json');
15
+ // All module-relative paths flow through MINIONS_DIR so MINIONS_TEST_DIR
16
+ // (set by test/unit.test.js createTestMinionsDir) consistently redirects
17
+ // pipeline writes into the temp root instead of the live runtime root.
18
+ const PIPELINES_DIR = path.join(MINIONS_DIR, 'pipelines');
19
+ const PIPELINE_RUNS_PATH = path.join(MINIONS_DIR, 'engine', 'pipeline-runs.json');
20
+ const CENTRAL_WI_PATH = path.join(MINIONS_DIR, 'work-items.json');
21
+ const MEETINGS_DIR = path.join(MINIONS_DIR, 'meetings');
22
+ const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
23
+ const PRD_DIR = path.join(MINIONS_DIR, 'prd');
24
+ const NOTES_INBOX_DIR = path.join(MINIONS_DIR, 'notes', 'inbox');
25
+ const NOTES_ARCHIVE_DIR = path.join(MINIONS_DIR, 'notes', 'archive');
26
+ const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
17
27
 
18
28
  function truncatePipelineContext(text, maxBytes, label) {
19
29
  return shared.truncateTextBytes(text, maxBytes, `\n\n_...${label} truncated — inspect the upstream artifacts if needed._`);
@@ -213,7 +223,7 @@ function evaluateCondition(condition, ctx) {
213
223
  case 'noFailedItems': {
214
224
  // True when all work items created by the pipeline are done (not failed)
215
225
  if (!run) return false;
216
- const wiPath = path.join(__dirname, '..', 'work-items.json');
226
+ const wiPath = CENTRAL_WI_PATH;
217
227
  const workItems = safeJson(wiPath) || [];
218
228
  const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
219
229
  return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
@@ -284,7 +294,7 @@ function executeTaskStage(stage, stageState, run, config) {
284
294
  // Create work item(s) for the task
285
295
  const items = stage.items || [{ title: stage.title, description: stage.description || '', type: stage.taskType || 'explore', agent: stage.agent }];
286
296
  const count = stage.count || items.length;
287
- const wiPath = path.join(__dirname, '..', 'work-items.json');
297
+ const wiPath = CENTRAL_WI_PATH;
288
298
  const createdIds = [];
289
299
 
290
300
  mutateWorkItems(wiPath, workItems => {
@@ -352,7 +362,7 @@ function _findExistingPlanForMeeting(meetingIds, plansDir) {
352
362
  // Build slug prefixes for both pipeline and dashboard naming conventions
353
363
  const slugPrefixes = [];
354
364
  for (const mid of meetingIds) {
355
- const mtg = safeJson(path.join(__dirname, '..', 'meetings', mid + '.json'));
365
+ const mtg = safeJson(path.join(MEETINGS_DIR, mid + '.json'));
356
366
  if (mtg?.title) {
357
367
  // Dashboard convention: "Meeting follow-up: {title}" → slug
358
368
  const dashSlug = slugify('meeting-follow-up-' + mtg.title);
@@ -383,11 +393,11 @@ function _findExistingPrdForPlan(planFile, prdDir) {
383
393
  }
384
394
 
385
395
  async function executePlanStage(stage, stageState, run, config) {
386
- const plansDir = path.join(__dirname, '..', 'plans');
396
+ const plansDir = PLANS_DIR;
387
397
  if (!fs.existsSync(plansDir)) fs.mkdirSync(plansDir, { recursive: true });
388
398
 
389
399
  const slug = slugify(stage.title || 'pipeline-plan');
390
- const wiPath = path.join(__dirname, '..', 'work-items.json');
400
+ const wiPath = CENTRAL_WI_PATH;
391
401
  const wiId = `PL-${run.runId.slice(4, 12)}-${stage.id}-prd`;
392
402
 
393
403
  // ── Reconciliation: check if a plan already exists for a meeting in this run ──
@@ -398,7 +408,7 @@ async function executePlanStage(stage, stageState, run, config) {
398
408
  log('info', `Pipeline ${run.pipelineId}: reconciling plan stage — adopting existing plan "${existingPlanFile}"`);
399
409
 
400
410
  // Check if a PRD already exists for this plan (skip plan-to-prd entirely)
401
- const prdDir = path.join(__dirname, '..', 'prd');
411
+ const prdDir = PRD_DIR;
402
412
  const existingPrdFile = _findExistingPrdForPlan(existingPlanFile, prdDir);
403
413
  if (existingPrdFile) {
404
414
  log('info', `Pipeline ${run.pipelineId}: PRD "${existingPrdFile}" already exists for plan "${existingPlanFile}" — skipping plan-to-prd`);
@@ -436,7 +446,7 @@ async function executePlanStage(stage, stageState, run, config) {
436
446
  let meetingContext = '';
437
447
  for (const mid of meetingIds) {
438
448
  try {
439
- const mtg = safeJson(path.join(__dirname, '..', 'meetings', mid + '.json'));
449
+ const mtg = safeJson(path.join(MEETINGS_DIR, mid + '.json'));
440
450
  if (mtg) {
441
451
  const transcript = truncatePipelineContext(
442
452
  (mtg.transcript || []).map(formatTranscriptEntry).join('\n\n---\n\n'),
@@ -586,7 +596,7 @@ function executeScheduleStage(stage, stageState, config) {
586
596
  config.schedules.push({ ...sched, enabled: true });
587
597
  }
588
598
  }
589
- safeWrite(path.join(__dirname, '..', 'config.json'), config);
599
+ safeWrite(CONFIG_PATH, config);
590
600
  return { status: PIPELINE_STATUS.COMPLETED, completedAt: ts() };
591
601
  }
592
602
 
@@ -647,7 +657,7 @@ function isStageComplete(stage, stageState, run, config) {
647
657
  switch (stage.type) {
648
658
  case STAGE_TYPE.TASK: {
649
659
  // Check root + all project work-items.json (WIs may be moved to project paths)
650
- const wiPath = path.join(__dirname, '..', 'work-items.json');
660
+ const wiPath = CENTRAL_WI_PATH;
651
661
  const workItems = safeJson(wiPath) || [];
652
662
  const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
653
663
  return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
@@ -671,7 +681,7 @@ function isStageComplete(stage, stageState, run, config) {
671
681
  }
672
682
  case STAGE_TYPE.PLAN: {
673
683
  // Plan stage completion: PRD conversion done + all materialized work items done
674
- const wiPath = path.join(__dirname, '..', 'work-items.json');
684
+ const wiPath = CENTRAL_WI_PATH;
675
685
  const workItems = safeJson(wiPath) || [];
676
686
  const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
677
687
  return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
@@ -687,7 +697,7 @@ function isStageComplete(stage, stageState, run, config) {
687
697
  if (!prdDone) return false;
688
698
 
689
699
  // Discover PRDs and their work items — collect into local arrays, then merge into artifacts
690
- const prdDir = path.join(__dirname, '..', 'prd');
700
+ const prdDir = PRD_DIR;
691
701
  const plans = artifacts.plans || [];
692
702
  const discoveredPrds = [];
693
703
  const discoveredWiIds = [];
@@ -811,7 +821,7 @@ async function discoverPipelineWork(config) {
811
821
  // Collect output
812
822
  let output = '';
813
823
  if (stage.type === STAGE_TYPE.TASK) {
814
- const wiPath = path.join(__dirname, '..', 'work-items.json');
824
+ const wiPath = CENTRAL_WI_PATH;
815
825
  const workItems = safeJson(wiPath) || [];
816
826
  const projWi = shared.getProjects(config).reduce((acc, p) => acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []), []);
817
827
  const allWi = [...workItems, ...projWi];
@@ -830,8 +840,8 @@ async function discoverPipelineWork(config) {
830
840
  // Scan for inbox/archive notes created by this stage's agents
831
841
  try {
832
842
  const notesDirs = [
833
- path.join(__dirname, '..', 'notes', 'inbox'),
834
- path.join(__dirname, '..', 'notes', 'archive'),
843
+ NOTES_INBOX_DIR,
844
+ NOTES_ARCHIVE_DIR,
835
845
  ];
836
846
  const stageWiIds = stageState.artifacts?.workItems || [];
837
847
  const notes = [];
@@ -871,7 +881,7 @@ async function discoverPipelineWork(config) {
871
881
  if (nextPlanStage) {
872
882
  const meetingIds = _findMeetingsInRun(activeRun);
873
883
  if (meetingIds.length > 0) {
874
- const plansDir = path.join(__dirname, '..', 'plans');
884
+ const plansDir = PLANS_DIR;
875
885
  if (fs.existsSync(plansDir)) {
876
886
  const existingPlan = _findExistingPlanForMeeting(meetingIds, plansDir);
877
887
  if (existingPlan) {
@@ -12,7 +12,7 @@ const queries = require('./queries');
12
12
  const { safeJson, safeRead, getProjects, log, ts, dateStamp, truncateTextBytes, ENGINE_DEFAULTS, WI_STATUS, WORK_TYPE, PR_STATUS, DISPATCH_RESULT } = shared;
13
13
  const { getConfig, getDispatch, getNotes, getAgentCharter, getPrs, AGENTS_DIR } = queries;
14
14
 
15
- const MINIONS_DIR = path.resolve(__dirname, '..');
15
+ const MINIONS_DIR = shared.MINIONS_DIR;
16
16
  const PLAYBOOKS_DIR = path.join(MINIONS_DIR, 'playbooks');
17
17
 
18
18
  // Import tempAgents from routing module
package/engine/queries.js CHANGED
@@ -97,6 +97,10 @@ function timeSince(ms) {
97
97
  return `${Math.floor(s / 3600)}h ago`;
98
98
  }
99
99
 
100
+ function readJsonNoRestore(filePath) {
101
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
102
+ }
103
+
100
104
  // ── Core State Readers ──────────────────────────────────────────────────────
101
105
 
102
106
  let _configPollKeyMigrationChecked = false;
@@ -146,7 +150,7 @@ function getConfig() {
146
150
  }
147
151
 
148
152
  function getControl() {
149
- return safeJson(CONTROL_PATH) || { state: 'stopped', pid: null };
153
+ return readJsonNoRestore(CONTROL_PATH) || { state: 'stopped', pid: null };
150
154
  }
151
155
 
152
156
  let _dispatchCache = null;
@@ -155,7 +159,7 @@ function getDispatch() {
155
159
  // Short-lived cache — dispatch.json is read 10+ times per tick but only changes on mutateDispatch
156
160
  const now = Date.now();
157
161
  if (_dispatchCache && (now - _dispatchCacheAt) < 2000) return _dispatchCache;
158
- _dispatchCache = safeJson(DISPATCH_PATH) || { pending: [], active: [], completed: [] };
162
+ _dispatchCache = readJsonNoRestore(DISPATCH_PATH) || { pending: [], active: [], completed: [] };
159
163
  _dispatchCacheAt = now;
160
164
  return _dispatchCache;
161
165
  }
@@ -165,7 +169,7 @@ function getDispatchQueue() {
165
169
  const d = getDispatch();
166
170
  const allCompleted = d.completed || [];
167
171
  // Lifetime total from metrics (dispatch.completed is capped at 100)
168
- const metrics = safeJson(path.join(ENGINE_DIR, 'metrics.json')) || {};
172
+ const metrics = readJsonNoRestore(path.join(ENGINE_DIR, 'metrics.json')) || {};
169
173
  d.completedTotal = Object.entries(metrics).filter(([k]) => !k.startsWith('_')).reduce((sum, [, m]) => sum + (m.tasksCompleted || 0) + (m.tasksErrored || 0), 0);
170
174
  d.completed = allCompleted.slice(-20);
171
175
  return d;
@@ -194,7 +198,7 @@ function getEngineLog() {
194
198
  }
195
199
 
196
200
  function getMetrics() {
197
- const metrics = safeJson(path.join(ENGINE_DIR, 'metrics.json')) || {};
201
+ const metrics = readJsonNoRestore(path.join(ENGINE_DIR, 'metrics.json')) || {};
198
202
 
199
203
  for (const [agentId, m] of Object.entries(metrics)) {
200
204
  if (agentId.startsWith('_')) continue;
@@ -234,8 +238,10 @@ function getMetrics() {
234
238
  }
235
239
 
236
240
  // Apply enrichments to agent metrics
237
- for (const [agentId, m] of Object.entries(metrics)) {
241
+ for (const [agentId, existing] of Object.entries(metrics)) {
238
242
  if (agentId.startsWith('_')) continue;
243
+ const m = { ...DEFAULT_AGENT_METRICS, ...(existing && typeof existing === 'object' ? existing : {}) };
244
+ metrics[agentId] = m;
239
245
  const lower = agentId.toLowerCase();
240
246
  if (prCountByAgent[lower] !== undefined) {
241
247
  m.prsCreated = prCountByAgent[lower];
@@ -474,7 +480,7 @@ function getAgentDetail(id) {
474
480
 
475
481
  function getPrs(project) {
476
482
  if (project) {
477
- const prs = safeJson(projectPrPath(project)) || [];
483
+ const prs = readJsonNoRestore(projectPrPath(project)) || [];
478
484
  shared.normalizePrRecords(prs, project);
479
485
  return prs;
480
486
  }
@@ -510,7 +516,7 @@ function getPullRequests(config) {
510
516
  for (const dirName of projectDirs) {
511
517
  const project = projectByName.get(dirName) || null;
512
518
  const prPath = project ? projectPrPath(project) : path.join(MINIONS_DIR, 'projects', dirName, 'pull-requests.json');
513
- const prs = safeJson(prPath);
519
+ const prs = readJsonNoRestore(prPath);
514
520
  if (!Array.isArray(prs)) continue;
515
521
  shared.normalizePrRecords(prs, project);
516
522
  const base = project?.prUrlBase || '';
@@ -527,7 +533,7 @@ function getPullRequests(config) {
527
533
  }
528
534
  }
529
535
  // Central pull-requests.json — manually linked PRs without a project
530
- const centralPrs = safeJson(path.join(MINIONS_DIR, 'pull-requests.json'));
536
+ const centralPrs = readJsonNoRestore(path.join(MINIONS_DIR, 'pull-requests.json'));
531
537
  if (centralPrs) {
532
538
  shared.normalizePrRecords(centralPrs, null);
533
539
  for (const pr of centralPrs) {
@@ -1023,7 +1029,7 @@ function getPrdInfo(config) {
1023
1029
  if (cached && cached.mtimeMs === stat.mtimeMs) {
1024
1030
  plan = cached.plan;
1025
1031
  } else {
1026
- plan = safeJson(filePath);
1032
+ plan = readJsonNoRestore(filePath);
1027
1033
  _prdFileCache.set(filePath, { mtimeMs: stat.mtimeMs, plan });
1028
1034
  }
1029
1035
  if (!plan || !plan.missing_features) continue;
@@ -1068,13 +1074,13 @@ function getPrdInfo(config) {
1068
1074
  const wiById = {};
1069
1075
  for (const project of projects) {
1070
1076
  try {
1071
- const workItems = safeJson(projectWorkItemsPath(project)) || [];
1077
+ const workItems = readJsonNoRestore(projectWorkItemsPath(project)) || [];
1072
1078
  for (const wi of workItems) { if (!wi?.id) { console.warn(`[queries] Skipping work item without id in ${project.name}:`, JSON.stringify(wi).slice(0, 120)); continue; } if (wi.sourcePlan) wiById[wi.id] = wi; }
1073
1079
  } catch { /* optional */ }
1074
1080
  }
1075
1081
  // Also check central work-items.json
1076
1082
  try {
1077
- const centralWi = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
1083
+ const centralWi = readJsonNoRestore(path.join(MINIONS_DIR, 'work-items.json')) || [];
1078
1084
  for (const wi of centralWi) { if (!wi?.id) { console.warn('[queries] Skipping central work item without id:', JSON.stringify(wi).slice(0, 120)); continue; } if (wi.sourcePlan && !wiById[wi.id]) wiById[wi.id] = wi; }
1079
1085
  } catch { /* optional */ }
1080
1086
 
package/engine/routing.js CHANGED
@@ -11,7 +11,7 @@ const queries = require('./queries');
11
11
  const { safeJson, safeRead, log, ts } = shared;
12
12
  const { ENGINE_DIR, DISPATCH_PATH } = queries;
13
13
 
14
- const MINIONS_DIR = path.resolve(__dirname, '..');
14
+ const MINIONS_DIR = shared.MINIONS_DIR;
15
15
  const ROUTING_PATH = path.join(MINIONS_DIR, 'routing.md');
16
16
 
17
17
  // ─── Temp Agents ─────────────────────────────────────────────────────────────
@@ -117,16 +117,16 @@ function setTempBudget(n) {
117
117
  function getTempBudget() { return _tempBudget; }
118
118
 
119
119
  function normalizeAgentHints(agentHints, authorAgent = null) {
120
- const raw = Array.isArray(agentHints) ? agentHints : (agentHints ? [agentHints] : []);
120
+ const raw = Array.isArray(agentHints) ? agentHints : (agentHints ? String(agentHints).split(',') : []);
121
121
  return raw
122
- .map(id => id === '_author_' ? authorAgent : id)
123
- .map(id => typeof id === 'string' ? id.trim().toLowerCase() : '')
122
+ .map(id => String(id).trim().toLowerCase())
123
+ .map(id => id === '_author_' && authorAgent ? String(authorAgent).trim().toLowerCase() : id)
124
124
  .filter(Boolean);
125
125
  }
126
126
 
127
127
  function resolveAgent(workType, config, authorAgent = null, agentHints = null) {
128
128
  const routes = getRoutingTableCached();
129
- const route = routes[workType] || routes['implement'];
129
+ const route = routes[workType] || routes['implement'] || { preferred: '_any_', fallback: '_any_' };
130
130
  const agents = config.agents || {};
131
131
 
132
132
  // Resolve _author_ token
@@ -26,7 +26,7 @@ const path = require('path');
26
26
  const shared = require('./shared');
27
27
  const { safeJson, safeWrite, mutateJsonFileLocked, ts, dateStamp, WI_STATUS } = shared;
28
28
 
29
- const SCHEDULE_RUNS_PATH = path.join(__dirname, 'schedule-runs.json');
29
+ const SCHEDULE_RUNS_PATH = path.join(shared.MINIONS_DIR, 'engine', 'schedule-runs.json');
30
30
 
31
31
  /**
32
32
  * Substitute schedule-time template variables in a string.
package/engine/shared.js CHANGED
@@ -1099,8 +1099,8 @@ function trackReviewMetric(pr, newReviewStatus, config) {
1099
1099
  const authorId = (pr.agent || '').toLowerCase();
1100
1100
  if (!authorId || !config?.agents?.[authorId]) return;
1101
1101
  try {
1102
- mutateJsonFileLocked(path.join(__dirname, 'metrics.json'), (metrics) => {
1103
- if (!metrics[authorId]) metrics[authorId] = {};
1102
+ mutateJsonFileLocked(path.join(MINIONS_DIR, 'engine', 'metrics.json'), (metrics) => {
1103
+ if (!metrics[authorId]) metrics[authorId] = { ...DEFAULT_AGENT_METRICS };
1104
1104
  if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
1105
1105
  else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
1106
1106
  return metrics;
@@ -1110,7 +1110,10 @@ function trackReviewMetric(pr, newReviewStatus, config) {
1110
1110
 
1111
1111
  /** Queue a plan-to-prd work item with dedup check inside lock. Returns true if queued. */
1112
1112
  function queuePlanToPrd({ planFile, prdFile, title, description, project, createdBy, extra }) {
1113
- const centralWiPath = path.join(__dirname, '..', 'work-items.json');
1113
+ // Use MINIONS_DIR (honors MINIONS_TEST_DIR override) instead of resolving from
1114
+ // __dirname — otherwise tests that exercise this helper leak work items into
1115
+ // the real package-root work-items.json even after createTestMinionsDir().
1116
+ const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
1114
1117
  let queued = false;
1115
1118
  mutateJsonFileLocked(centralWiPath, items => {
1116
1119
  if (!Array.isArray(items)) items = [];
package/minions.js CHANGED
@@ -462,6 +462,7 @@ async function initMinions({ skipScan = false, scanRoot, scanDepth } = {}) {
462
462
  const config = loadConfig();
463
463
  if (!config.projects) config.projects = [];
464
464
  const removedPlaceholders = cleanupPlaceholderProjects(config);
465
+ const hadConfiguredDefaultCli = config.engine?.defaultCli !== undefined;
465
466
  // Merge defaults — fills in new fields from upgrades while preserving user customizations
466
467
  if (!config.engine) config.engine = {};
467
468
  for (const [k, v] of Object.entries(ENGINE_DEFAULTS)) {
@@ -479,7 +480,7 @@ async function initMinions({ skipScan = false, scanRoot, scanDepth } = {}) {
479
480
  // Auto-detect available runtime CLIs and pin engine.defaultCli to whichever
480
481
  // is installed. Only set if the user hasn't already configured one — never
481
482
  // overwrite an explicit choice on `init --force` upgrades.
482
- if (!config.engine.defaultCli) {
483
+ if (!hadConfiguredDefaultCli) {
483
484
  const detected = _detectAvailableRuntimes();
484
485
  if (detected.length === 1) {
485
486
  config.engine.defaultCli = detected[0];
@@ -579,4 +580,3 @@ if (cmd && commands[cmd]) {
579
580
  console.log(' cleans worktrees, archives data dir to projects/.archived/');
580
581
  console.log(' list List linked projects\n');
581
582
  }
582
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1613",
3
+ "version": "0.1.1615",
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"
@@ -59,7 +59,7 @@ Use subagents only for genuinely parallel, independent tasks. For reading files,
59
59
 
60
60
  ## Rules
61
61
  - Do NOT modify existing code unless the task explicitly asks for it.
62
- - Use the appropriate MCP tools for PR creation check available tools before starting.
62
+ - Use the appropriate repo-host tooling for PR creation. For Azure DevOps, prefer the `az` CLI first and use ADO MCP only as a fallback when `az` is unavailable or insufficient.
63
63
  - Do NOT checkout branches in the main working tree — use worktrees.
64
64
  - Read `notes.md` for all team rules before starting.
65
65
  - Only emit a ```skill block if you uncovered a durable reusable workflow that is not already documented and is likely to help future tasks; zero skills is the default, and one-off findings belong in the inbox notes instead.
package/playbooks/fix.md CHANGED
@@ -72,7 +72,7 @@ After pushing, respond to each review comment/thread:
72
72
  - **If you fixed it**: Reply confirming the fix, then resolve the thread
73
73
  - **If you chose not to fix it**: Reply with your rationale explaining why the current approach is preferred — leave the thread open for the reviewer to decide
74
74
  - **GitHub**: Reply to each review comment, resolve conversations you've fixed
75
- - **ADO**: Reply to each thread, set status to `fixed` or `closed` for fixes; leave `active` for rationale replies
75
+ - **ADO**: Use `az` CLI first to reply to each thread and update status when supported; use ADO MCP only as a fallback when `az` is unavailable or insufficient. Set status to `fixed` or `closed` for fixes; leave `active` for rationale replies
76
76
 
77
77
  ## When to Stop
78
78
 
@@ -94,4 +94,3 @@ pending: <any remaining work, or none>
94
94
  ```
95
95
 
96
96
  Replace the values with your actual results. This block MUST appear in your final output.
97
-
@@ -107,3 +107,9 @@ Output is JSON with the same fields. Exit 0 on success, 1 if not found.
107
107
  **Never make raw `curl` calls to ADO APIs directly.** Use `node engine/ado-status.js` which routes through `ado.js` — authenticated, retried, circuit-broken. Raw `azureauth` + curl bypasses all of that.
108
108
 
109
109
  **If you must run `azureauth` directly, ALWAYS include `--timeout 1`.** Without this flag, `azureauth ado token` can hang indefinitely waiting for interactive broker UI that never appears in headless agent sessions. This causes the Claude Code process to silently exit and the engine to declare the agent orphaned. Example: `azureauth ado token --mode iwa --mode broker --output token --timeout 1`.
110
+
111
+ ## Azure DevOps Tooling
112
+
113
+ For Azure DevOps repo operations, use the `az` CLI first. Prefer commands such as `az repos pr create`, `az repos pr show`, `az repos pr list`, `az repos pr comment`, `az repos pr reviewer`, `az boards work-item`, and `az pipelines` after setting defaults with `az devops configure`.
114
+
115
+ Use ADO MCP fallback tools (`mcp__azure-ado__*`) only when `az` is unavailable in the environment or insufficient for a specific operation. Do not choose MCP first just because it exists, and do not use `gh` for Azure DevOps repositories.
@@ -111,7 +111,7 @@ For each project worktree:
111
111
 
112
112
  2. **Check for an existing E2E PR** before creating a new one:
113
113
  - For GitHub: `gh pr list --head e2e/{{plan_slug}} --state open`
114
- - For ADO: search for PRs with source branch `e2e/{{plan_slug}}`
114
+ - For ADO: use `az` CLI first to search for PRs with source branch `e2e/{{plan_slug}}`; use ADO MCP only as a fallback when `az` is unavailable or insufficient
115
115
  - If found, **update the existing PR** description with latest build/test results. Do NOT create a duplicate.
116
116
  - If not found, create a new PR targeting the project's main branch:
117
117
  - **Title:** `[E2E] <plan summary>`
@@ -52,6 +52,8 @@ When in doubt, delegate. You are the dispatcher, not the worker. Agents have iso
52
52
  ## Actions
53
53
  Append actions at the END of your response. Write your response first, then `===ACTIONS===` on its own line, then a JSON array. No text after the JSON. Omit entirely if no actions needed.
54
54
 
55
+ **CRITICAL — emit RAW JSON only.** Do NOT wrap the JSON array in ```json fences, ``` fences, or any other markdown. Do NOT add commentary or "Let me know if that helps" lines after the JSON. The JSON array must start with `[` on the line immediately after `===ACTIONS===` and end with `]` as the very last character of the response. Anything else (fences, prose, trailing commas) breaks server-side action parsing and your actions will be silently dropped.
56
+
55
57
  Example:
56
58
  I'll dispatch dallas to fix that bug.
57
59