@yemi33/minions 0.1.2305 → 0.1.2307

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/bin/minions.js +1 -0
  2. package/dashboard/js/refresh.js +17 -13
  3. package/dashboard/js/render-other.js +251 -3
  4. package/dashboard/js/render-plans.js +1 -1
  5. package/dashboard/js/render-prs.js +4 -1
  6. package/dashboard/js/render-utils.js +13 -0
  7. package/dashboard/js/render-work-items.js +11 -13
  8. package/dashboard/js/settings.js +30 -3
  9. package/dashboard/slim/body.html +6 -22
  10. package/dashboard/slim/js/modals-tiles.js +32 -22
  11. package/dashboard/slim/js/plans.js +24 -516
  12. package/dashboard/slim/styles.css +10 -7
  13. package/dashboard/styles.css +58 -0
  14. package/dashboard.js +66 -9
  15. package/docs/completion-reports.md +27 -2
  16. package/docs/copilot-cli-schema.md +31 -1
  17. package/docs/design-state-storage.md +1 -1
  18. package/docs/harness-transparency.md +18 -0
  19. package/docs/live-checkout-mode.md +57 -8
  20. package/engine/ado-comment.js +5 -2
  21. package/engine/ado.js +37 -0
  22. package/engine/cli.js +58 -3
  23. package/engine/comment-format.js +82 -2
  24. package/engine/consolidation.js +59 -2
  25. package/engine/discover-project-skills.js +4 -0
  26. package/engine/dispatch.js +36 -1
  27. package/engine/gh-comment.js +8 -3
  28. package/engine/lifecycle.js +241 -25
  29. package/engine/live-checkout.js +335 -7
  30. package/engine/playbook.js +2 -2
  31. package/engine/pre-dispatch-eval.js +54 -5
  32. package/engine/queries.js +11 -7
  33. package/engine/shared.js +96 -6
  34. package/engine/supervisor.js +144 -19
  35. package/engine/watchdog.js +10 -0
  36. package/engine.js +210 -5
  37. package/package.json +1 -1
  38. package/playbooks/review.md +2 -0
  39. package/playbooks/shared-rules.md +9 -0
package/engine.js CHANGED
@@ -138,7 +138,7 @@ const worktreePool = require('./engine/worktree-pool');
138
138
  // ─── State Readers (delegated to engine/queries.js) ─────────────────────────
139
139
 
140
140
  const { getConfig, getControl, getDispatch, getNotes,
141
- getAgentStatus, getAgentCharter, getInboxFiles,
141
+ getAgentStatus, getInboxFiles,
142
142
  collectSkillFiles, getSkillIndex, getKnowledgeBaseIndex,
143
143
  getPrs, SKILLS_DIR } = queries;
144
144
 
@@ -1974,6 +1974,43 @@ function _applyAgentTempEnv(childEnv, project) {
1974
1974
  } catch { /* leave inherited TEMP untouched */ }
1975
1975
  }
1976
1976
 
1977
+ // W-mr28h2j2000y0de1 review (calebt_microsoft, 2026-07-01): "classify as
1978
+ // non-retryable" alone leaves the operator to manually clean up every time.
1979
+ // Before refusing, ATTEMPT to actually resolve the conflict — remove the
1980
+ // conflicting worktree and let the caller retry the checkout — but ONLY when
1981
+ // it is safe to do so. Every one of these gates must pass or we leave the
1982
+ // conflicting worktree untouched and fall back to the existing refusal:
1983
+ // 1. conflictPath resolves inside THIS project's configured worktreeRoot —
1984
+ // the standard location for engine-created worktrees. Anything outside
1985
+ // it might be a human's own tree; shared.removeWorktree's containment
1986
+ // check would refuse it anyway, but we check first for a clearer reason.
1987
+ // 2. it carries the engine ownership marker (shared.hasWorktreeOwnerMarker)
1988
+ // — a hand-made worktree (`git worktree add /tmp/foo …`) has no marker.
1989
+ // 3. shared.removeWorktree succeeds. It independently refuses to touch a
1990
+ // worktree with a LIVE dispatch running inside it (isWorktreePathLive,
1991
+ // fails open) and refuses real-repo-root / non-linked-worktree paths.
1992
+ // Returns { removed, reason } for alert/log enrichment either way.
1993
+ function _tryAutoResolveLiveCheckoutWorktreeConflict({ conflictPath, gitRoot, engineConfig, log }) {
1994
+ try {
1995
+ const _wtRoot = path.resolve(gitRoot, (engineConfig && engineConfig.worktreeRoot) || ENGINE_DEFAULTS.worktreeRoot);
1996
+ const _resolvedConflict = path.resolve(conflictPath);
1997
+ if (_resolvedConflict !== _wtRoot && !_resolvedConflict.startsWith(_wtRoot + path.sep)) {
1998
+ return { removed: false, reason: 'outside the configured worktreeRoot — not engine-managed, left untouched' };
1999
+ }
2000
+ if (!shared.hasWorktreeOwnerMarker(_resolvedConflict)) {
2001
+ return { removed: false, reason: 'no engine ownership marker — likely human-created, left untouched' };
2002
+ }
2003
+ const _removed = shared.removeWorktree(_resolvedConflict, gitRoot, _wtRoot);
2004
+ if (!_removed) {
2005
+ return { removed: false, reason: 'shared.removeWorktree declined (live dispatch active inside it, real-repo guard, or removal failure — see engine logs)' };
2006
+ }
2007
+ return { removed: true, reason: 'removed stale engine-owned worktree' };
2008
+ } catch (e) {
2009
+ log('warn', `live-checkout: auto-resolve threw: ${e.message}`);
2010
+ return { removed: false, reason: `auto-resolve threw: ${e.message}` };
2011
+ }
2012
+ }
2013
+
1977
2014
  async function spawnAgent(dispatchItem, config) {
1978
2015
  const { id, agent: agentId, type, meta } = dispatchItem;
1979
2016
  // Resolve prompt — prefers sidecar file when dispatchItem._promptFile is set
@@ -2420,6 +2457,56 @@ async function spawnAgent(dispatchItem, config) {
2420
2457
  cleanupTempAgent(agentId);
2421
2458
  return null;
2422
2459
  }
2460
+ // ── Auto-stash a dirty live-checkout tree (W-mqtvnnj1000357fa) ──────────
2461
+ // When the tree is dirty AND auto-stash is enabled (per-project field wins,
2462
+ // else engine fleet-wide fallback), `git stash push --include-untracked` the
2463
+ // operator's changes so dispatch can proceed instead of failing with
2464
+ // FAILURE_CLASS.LIVE_CHECKOUT_DIRTY. The full flow (stash → re-preflight →
2465
+ // clear retry stamp → operator inbox note) lives in live-checkout.js to keep
2466
+ // spawnAgent lean; here we only handle the engine-specific outcomes. The
2467
+ // engine NEVER pops the stash automatically (operator choice).
2468
+ // NOTE: the helper gates on `liveResult.reason === 'dirty'` internally so the
2469
+ // confirmed-dirty refusal `if` below remains the first textual match for that
2470
+ // dirty-reason check in this file.
2471
+ const _autoStash = await _liveCheckout.applyLiveCheckoutAutoStash({
2472
+ liveResult: _liveResult,
2473
+ project,
2474
+ engine: engineConfig,
2475
+ localPath: cwd,
2476
+ branchName,
2477
+ mainRef: _liveMainRef,
2478
+ gitOpts: _gitOpts,
2479
+ dispatchId: id,
2480
+ wiId: _wiIdForAlert,
2481
+ log: (msg, lvl) => log(lvl || 'info', msg),
2482
+ clearDirtyStamp: () => {
2483
+ const _wiPath = resolveWorkItemPath(dispatchItem.meta);
2484
+ if (_wiPath && dispatchItem.meta?.item?.id) {
2485
+ mutateJsonFileLocked(_wiPath, (data) => {
2486
+ if (!Array.isArray(data)) return data;
2487
+ const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
2488
+ if (wi && wi._pendingReason === 'live_checkout_dirty') delete wi._pendingReason;
2489
+ return data;
2490
+ });
2491
+ }
2492
+ },
2493
+ writeStashNote: (key, body) => writeInboxAlert(key, body),
2494
+ });
2495
+ if (_autoStash.outcome === 'threw') {
2496
+ const stashLiveErr = _autoStash.error;
2497
+ log('error', `spawnAgent: live-checkout helper threw after auto-stash for ${id}: ${stashLiveErr.message}`);
2498
+ _cleanupPromptFiles();
2499
+ completeDispatch(
2500
+ id,
2501
+ DISPATCH_RESULT.ERROR,
2502
+ `live-checkout failed: ${stashLiveErr.message}`.slice(0, 800),
2503
+ 'Live-checkout helper threw after auto-stash (not proof of a dirty tree). Auto-retried with bounded backoff; transient git/branch-lock state usually clears on the next attempt.',
2504
+ { failureClass: FAILURE_CLASS.LIVE_CHECKOUT_FAILED },
2505
+ );
2506
+ cleanupTempAgent(agentId);
2507
+ return null;
2508
+ }
2509
+ _liveResult = _autoStash.liveResult;
2423
2510
  if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'dirty') {
2424
2511
  const _dirtyFiles = Array.isArray(_liveResult.dirtyFiles) ? _liveResult.dirtyFiles : [];
2425
2512
  const _branchInfo = typeof _liveResult.branchInfo === 'string' ? _liveResult.branchInfo : '';
@@ -2637,6 +2724,109 @@ async function spawnAgent(dispatchItem, config) {
2637
2724
  cleanupTempAgent(agentId);
2638
2725
  return null;
2639
2726
  }
2727
+ // W-mr28h2j2000y0de1: worktree-conflict refusal. The existing-branch
2728
+ // `git checkout` refused because the target branch is ALSO checked out in a
2729
+ // SECOND worktree elsewhere (a leftover from a prior isolated-worktree
2730
+ // dispatch, a manually-created worktree, or a stale worktree left by a
2731
+ // checkoutMode change). This is a STRUCTURAL conflict — it does NOT clear on
2732
+ // retry, ever, until a human or the engine removes/reassigns the other
2733
+ // worktree — so it gets its own non-retryable FAILURE_CLASS instead of
2734
+ // LIVE_CHECKOUT_FAILED's bounded retry-storm. prepareLiveCheckout already
2735
+ // best-effort switched HEAD back to the operator's original ref.
2736
+ if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'worktree-conflict') {
2737
+ // W-mr28h2j2000y0de1 review (calebt_microsoft): try to actually RESOLVE
2738
+ // the conflict before refusing. Only proceeds when the conflicting
2739
+ // worktree is engine-owned and not live-in-use (see
2740
+ // _tryAutoResolveLiveCheckoutWorktreeConflict); on success, retry the
2741
+ // checkout once and fall through to the normal success path below.
2742
+ const _conflictPathForResolve = typeof _liveResult.conflictingWorktreePath === 'string' && _liveResult.conflictingWorktreePath
2743
+ ? _liveResult.conflictingWorktreePath
2744
+ : null;
2745
+ if (_conflictPathForResolve) {
2746
+ const _resolveOutcome = _tryAutoResolveLiveCheckoutWorktreeConflict({
2747
+ conflictPath: _conflictPathForResolve, gitRoot: cwd, engineConfig, log,
2748
+ });
2749
+ if (_resolveOutcome.removed) {
2750
+ log('info', `live-checkout: auto-resolved worktree conflict — ${_resolveOutcome.reason} at ${_conflictPathForResolve}; retrying checkout`);
2751
+ try {
2752
+ _liveResult = await _liveCheckout.prepareLiveCheckout({
2753
+ localPath: cwd,
2754
+ branchName,
2755
+ mainRef: _liveMainRef,
2756
+ gitOpts: _gitOpts,
2757
+ dispatchId: id,
2758
+ wiId: _wiIdForAlert,
2759
+ skipDirtyCheck: !!project.skipLiveCheckoutDirtyCheck,
2760
+ log: (msg, lvl) => log(lvl || 'info', msg),
2761
+ });
2762
+ } catch (retryErr) {
2763
+ log('warn', `live-checkout: retry after auto-resolve threw: ${retryErr.message}`);
2764
+ _liveResult = { ok: false, reason: 'worktree-conflict', conflictingWorktreePath: _conflictPathForResolve, message: retryErr.message };
2765
+ }
2766
+ } else {
2767
+ log('info', `live-checkout: worktree conflict at ${_conflictPathForResolve} not auto-resolved (${_resolveOutcome.reason})`);
2768
+ }
2769
+ }
2770
+ }
2771
+ if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'worktree-conflict') {
2772
+ const _conflictMsg = typeof _liveResult.message === 'string' ? _liveResult.message : '';
2773
+ const _conflictPath = typeof _liveResult.conflictingWorktreePath === 'string' && _liveResult.conflictingWorktreePath
2774
+ ? _liveResult.conflictingWorktreePath
2775
+ : null;
2776
+ const _pathPhrase = _conflictPath ? `\`${_conflictPath}\`` : 'an unknown location';
2777
+ const _alertBody = [
2778
+ '# Live-checkout blocked: branch already used by another worktree',
2779
+ '',
2780
+ `**Project:** ${project.name || '(unknown)'}`,
2781
+ `**Local path:** ${cwd}`,
2782
+ `**Branch:** ${branchName}`,
2783
+ `**Conflicting worktree:** ${_conflictPath || '(unknown location)'}`,
2784
+ `**Work item:** ${_wiIdForAlert}`,
2785
+ `**Dispatch:** ${id}`,
2786
+ '',
2787
+ `The engine could not switch \`${cwd}\` onto \`${branchName}\` because that branch is already checked out in another worktree at ${_pathPhrase}. This is a structural conflict — **it will NOT clear on retry**. Your tree was left on its original ref (the engine never forces, resets, or cleans it).`,
2788
+ ...(_conflictMsg ? ['', '```', _conflictMsg, '```'] : []),
2789
+ '',
2790
+ '## Recovery',
2791
+ '',
2792
+ `Pick ONE, depending on whether the other worktree still holds work you need:`,
2793
+ '',
2794
+ `1. **If that worktree is stale / no longer needed** — remove it to free the branch for the live checkout:`,
2795
+ '',
2796
+ '```',
2797
+ `git worktree remove ${_conflictPath || '<path>'}`,
2798
+ `# or, if it has uncommitted changes you are SURE you can discard (this DISCARDS that WIP):`,
2799
+ `git worktree remove --force ${_conflictPath || '<path>'}`,
2800
+ '```',
2801
+ '',
2802
+ `2. **If that worktree holds real work-in-progress** — do NOT re-dispatch this work item in live mode. Finish, commit, and push directly from ${_pathPhrase} instead, or rename/retarget the branch so the two no longer collide.`,
2803
+ ].join('\n');
2804
+ try { writeInboxAlert(`live-checkout-worktree-conflict-${_wiIdForAlert}`, _alertBody); }
2805
+ catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
2806
+ try {
2807
+ const _wiPath = resolveWorkItemPath(dispatchItem.meta);
2808
+ if (_wiPath && dispatchItem.meta?.item?.id) {
2809
+ mutateJsonFileLocked(_wiPath, (data) => {
2810
+ if (!Array.isArray(data)) return data;
2811
+ const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
2812
+ if (wi) wi._pendingReason = 'live_checkout_worktree_conflict';
2813
+ return data;
2814
+ });
2815
+ }
2816
+ } catch (e) { log('warn', `live-checkout: failed to stamp _pendingReason: ${e.message}`); }
2817
+ const _shortMsg = `live-checkout blocked: ${branchName} already used by worktree at ${_conflictPath || 'unknown location'}`;
2818
+ log('error', `spawnAgent: ${_shortMsg}`);
2819
+ _cleanupPromptFiles();
2820
+ completeDispatch(
2821
+ id,
2822
+ DISPATCH_RESULT.ERROR,
2823
+ _shortMsg.slice(0, 800),
2824
+ 'Live-checkout could not switch onto the branch because it is already checked out in another worktree. Structural — operator must remove/reassign the conflicting worktree (or finish its WIP) before re-dispatch. Retrying is provably useless.',
2825
+ { failureClass: FAILURE_CLASS.LIVE_CHECKOUT_WORKTREE_CONFLICT, agentRetryable: false },
2826
+ );
2827
+ cleanupTempAgent(agentId);
2828
+ return null;
2829
+ }
2640
2830
  log('info', `live-checkout: ${_liveResult.created ? 'created' : 'switched to'} branch ${branchName} in ${cwd} (in-place; no worktree)`);
2641
2831
  // PL-live-checkout-reliability-hardening: a clean spawn clears the two-strike
2642
2832
  // dirty counter so a project that was transiently dirty once isn't treated as
@@ -8049,6 +8239,20 @@ function discoverFromWorkItems(config, project) {
8049
8239
  const promptItem = linkedPr ? withWorkItemPrContext(item, linkedPr) : item;
8050
8240
  const prBranch = linkedPr?.branch || '';
8051
8241
  const isPrTargeted = !!(linkedPr && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST));
8242
+ // Issue #607 — `linkedPr` above resolves through the LOOSE
8243
+ // extractWorkItemPrRef (title/first-paragraph-of-description text scan),
8244
+ // which is fine for prompt-context enrichment (promptItem, above) that
8245
+ // downgrades gracefully on a miss. But `dispatch.meta.pr` is a hard signal:
8246
+ // `_isPrBackedDispatch`/`getStalePrDispatchReason` (engine/dispatch.js)
8247
+ // treat ANY dispatch carrying it as PR-backed and will cancel the source
8248
+ // work item outright if the tracked PR turns out to be merged/abandoned/
8249
+ // context-only or branch-mismatched. A loose text match against an
8250
+ // unrelated already-tracked PR (e.g. an ordinary implement item whose
8251
+ // description says "see PR #99 for context") must never bind meta.pr —
8252
+ // only a STRUCTURED PR pointer (targetPr/pr/references[]/pr_followup —
8253
+ // see extractStructuredWorkItemPrRef) is strong enough evidence of real
8254
+ // operator intent to make this dispatch cancellable on PR staleness.
8255
+ const dispatchLinkedPr = (linkedPr && getStructuredWorkItemPrRef(item)) ? linkedPr : null;
8052
8256
  // W-mq18ec6h000p7b87: gate on the STRUCTURED-only ref. Loose
8053
8257
  // getWorkItemPrRef would also pick up description-scan refs (e.g. a
8054
8258
  // refactor item whose prose mentioned "PR #3015 for context") and trip
@@ -8130,7 +8334,7 @@ function discoverFromWorkItems(config, project) {
8130
8334
  agentRole: config.agents[agentId]?.role || tempAgents.get(agentId)?.role || 'Agent',
8131
8335
  task: `[${project?.name || 'project'}] ${item.title || item.description?.slice(0, 80) || item.id}`,
8132
8336
  prompt,
8133
- meta: { dispatchKey: key, cooldownKey: key, source: 'work-item', branch: branchName, branchStrategy: item.branchStrategy || 'parallel', useExistingBranch: !!(isPrTargeted || (item.branchStrategy === 'shared-branch' && item.featureBranch)), item: promptItem, project: { name: project?.name, localPath: project?.localPath }, deferAgentResolution: deferredAgentResolution, ...(linkedPr ? { pr: linkedPr } : {}) }
8337
+ meta: { dispatchKey: key, cooldownKey: key, source: 'work-item', branch: branchName, branchStrategy: item.branchStrategy || 'parallel', useExistingBranch: !!(isPrTargeted || (item.branchStrategy === 'shared-branch' && item.featureBranch)), item: promptItem, project: { name: project?.name, localPath: project?.localPath }, deferAgentResolution: deferredAgentResolution, ...(dispatchLinkedPr ? { pr: dispatchLinkedPr } : {}) }
8134
8338
  });
8135
8339
 
8136
8340
  } catch (err) { log('warn', `discoverFromWorkItems: skipping ${item.id}: ${err.message}`); }
@@ -9914,8 +10118,8 @@ async function tickInner() {
9914
10118
  // sibling-less stamps are preserved as tracking signals.
9915
10119
  try {
9916
10120
  const result = pruneScopeMismatchDuplicatePrs(config);
9917
- if (result?.pruned > 0) {
9918
- log('info', `[pull-requests] scope-mismatch sweep pruned ${result.pruned} duplicate record(s) across ${result.scanned} scanned`);
10121
+ if (result?.pruned > 0 || result?.relocated > 0) {
10122
+ log('info', `[pull-requests] scope-mismatch sweep pruned ${result.pruned} duplicate record(s), relocated ${result.relocated || 0} record(s) across ${result.scanned} scanned`);
9919
10123
  }
9920
10124
  } catch (err) {
9921
10125
  log('warn', `[pull-requests] scope-mismatch sweep error: ${err?.message || err}`);
@@ -10564,7 +10768,7 @@ module.exports = {
10564
10768
 
10565
10769
  // State readers/writers
10566
10770
  getConfig, getControl, getDispatch, getRouting, getNotes,
10567
- getAgentStatus, getAgentCharter, getInboxFiles, getPrs,
10771
+ getAgentStatus, getInboxFiles, getPrs,
10568
10772
  validateConfig,
10569
10773
 
10570
10774
  // Dispatch management (re-exported from engine/dispatch.js)
@@ -10602,6 +10806,7 @@ module.exports = {
10602
10806
  findExistingWorktree, // exported for testing
10603
10807
  probeBranchOnRemote, // exported for testing (W-mphnm6a1000281b8)
10604
10808
  _maxTurnsForType, buildProjectContext, normalizeAc, _buildAgentSpawnFlags, _classifyAgentFailure, // exported for testing
10809
+ _tryAutoResolveLiveCheckoutWorktreeConflict, // exported for testing (W-mr28h2j2000y0de1 review — auto-resolve worktree conflicts)
10605
10810
  _isOutputTruncated, AGENT_OUTPUT_CAP_BYTES, // P-8e4c2a17: exported for testing (OUTPUT_TRUNCATED detection)
10606
10811
  promoteCheckpointSteeringForClose, // exported for testing
10607
10812
  normalizePrBranch, resolvePrBranch, prCausePart, getPrCauseHead, getPrCauseBase, getPrAutomationCauseKey, getPrAutomationDispatchKey, // exported for testing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2305",
3
+ "version": "0.1.2307",
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"
@@ -30,6 +30,8 @@ Use subagents only for genuinely parallel, independent tasks (e.g., reviewing un
30
30
  {{#project_skills_block}}
31
31
  {{project_skills_block}}
32
32
 
33
+ > **Skipping an in-scope review skill is gated.** Before you record a `meta.review.skillSkipped` (or `meta.skill.skipped`) outcome for any skill listed above, re-read that skill's own SKILL.md checklist and confirm your skip reason does not contradict any explicit "Flag if…" / "Verify that…" / "Requirements" item it documents. If the diff trips ANY such item, do NOT skip — apply the skill (or at least that item). When you do skip, surface the exact same `{name, reason}` in your PR review comment via `minions pr comment … --skill-skipped-json '{"name":"…","reason":"…"}'` so a human sees it alongside your verdict before merge (no drift between the report and the comment). See `playbooks/shared-rules.md` → "Skipped an in-scope project skill?".
34
+
33
35
  {{/project_skills_block}}
34
36
  2. Think about deploy risk before commenting:
35
37
  - What user-visible behavior changed?
@@ -176,6 +176,15 @@ When you author a **PR review / fix summary comment**, the same harness footprin
176
176
 
177
177
  One bullet per affordance, in the order skills → MCP servers → commands → docs; `N` is the bullet count. Whether the CLI folds it in or you hand-render it on a raw fallback, the section is the human-readable mirror of your `harnessUsed` completion field — keep the two consistent.
178
178
 
179
+ ### Skipped an in-scope project skill? Validate the skip, then surface it in the PR comment
180
+
181
+ `meta.review.skillSkipped` / `meta.skill.skipped` (shape `{name, reason}`, see `docs/completion-reports.md`) lets you self-report that you intentionally skipped an available, **in-scope** project (review) skill. Two hard rules apply — this is not aspirational:
182
+
183
+ 1. **Validate the skip against the skill's own scope before recording it.** Re-read the skipped skill's SKILL.md review criteria / checklist (the content already injected via the `## Project skills` / `## Project review skills` block). Confirm your skip reason does **not** contradict any explicit "Flag if…" / "Verify that…" / "Requirements" item the skill documents. If the diff trips ANY explicit checklist item the skill covers, skipping is **not** permitted — apply the skill (or at minimum that specific checklist item). A skip reason that waves away a gate the skill explicitly documents (e.g. "no CHANGELOG needed for this diff" when the skill's CHANGELOG Requirements section says otherwise) is invalid.
184
+ 2. **Surface the skip in the PR comment, not just the JSON.** When you post a review/fix summary comment, the skip must be visible in the same comment as the verdict so a human sees it **before** merge. Via `minions pr comment` (GitHub and ADO `--host ado`), pass `--skill-skipped-file <path>` (JSON `{name, reason}`) or inline `--skill-skipped-json <json>`; the shared builder (`engine/comment-format.js#buildSkippedSkillSection`, consumed by both `engine/gh-comment.js` and `engine/ado-comment.js`) folds a byte-identical `> ⚠️ Skipped project skill: <name> — <reason>` callout in for you. On raw fallbacks (`gh pr comment` / `az repos pr comment` / ADO REST) render that exact one-line blockquote callout yourself.
185
+
186
+ The **same** `{name, reason}` you record in `meta.review.skillSkipped` / `meta.skill.skipped` must be the one rendered in the PR comment — no drift between the two.
187
+
179
188
  ## Minions API access
180
189
 
181
190
  The Minions dashboard runs at `http://localhost:7331` whenever the engine is up. Agents may call its HTTP endpoints when (and only when) the playbook explicitly authorizes it for a particular task — most dispatches do not need any API access, and uninvited writes to engine-managed state are still prohibited (see "Do NOT write to `agents/*/status.json`" above).