@yemi33/minions 0.1.1908 → 0.1.1910

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/README.md CHANGED
@@ -15,6 +15,8 @@ Inspired by and initially scaffolded from [Brady Gaster's Squad](https://bradyga
15
15
  - **Anthropic API key** or Claude Max subscription (agents spawn Claude Code sessions)
16
16
  - **Git** — agents create worktrees for all code changes
17
17
 
18
+ > **Note:** you do **not** need to configure your CLI for "autopilot" / "bypass permissions" / "dangerous mode". Minions passes the right bypass flag per spawn (`--dangerously-skip-permissions` for Claude; `--autopilot --allow-all --no-ask-user` for Copilot), independent of your global CLI config. Run `minions doctor` to verify your installed CLI accepts those flags.
19
+
18
20
  ## Installation
19
21
 
20
22
  ```bash
package/bin/minions.js CHANGED
@@ -659,7 +659,7 @@ const engineCmds = new Set([
659
659
  'start', 'stop', 'status', 'pause', 'resume',
660
660
  'queue', 'sources', 'discover', 'dispatch',
661
661
  'spawn', 'work', 'cleanup', 'mcp-sync', 'plan',
662
- 'kill', 'complete', 'config',
662
+ 'kill', 'complete', 'config', 'pr',
663
663
  ]);
664
664
 
665
665
  if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
@@ -694,6 +694,8 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
694
694
  Persist default runtime/model without starting
695
695
  minions mcp-sync Sync MCP servers from ~/.claude.json
696
696
  minions cleanup Clean temp files, worktrees, zombies
697
+ minions pr comment <repo> <n> Post a marker-prepended PR comment via gh
698
+ --agent <id> --kind <k> [--wi <id>] (--body-file <f> | --body <text>)
697
699
  minions nuke --confirm Factory reset (delete state, reset config to defaults)
698
700
  minions uninstall --confirm Remove everything + uninstall npm package
699
701
 
package/engine/cli.js CHANGED
@@ -161,6 +161,7 @@ const CLI_COMMAND_DOCS = Object.freeze({
161
161
  'mcp-sync': { args: '', summary: 'Sync MCP servers from ~/.claude.json' },
162
162
  doctor: { args: '', summary: 'Check prerequisites and runtime health' },
163
163
  config: { args: 'set-cli <R> [--model M]', summary: 'Persist defaultCli/defaultModel without starting' },
164
+ pr: { args: 'comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] [--body-file <f>|--body <text>]', summary: 'Post a marker-prepended PR comment via gh' },
164
165
  });
165
166
 
166
167
  function formatCliCommandHelpLines() {
@@ -1535,6 +1536,102 @@ const commands = {
1535
1536
  }
1536
1537
  }
1537
1538
  console.log('');
1539
+ },
1540
+
1541
+ // `minions pr <subcommand> ...` — wraps engine/gh-comment.js so playbook-
1542
+ // driven shells have a single command that automatically prepends the hidden
1543
+ // minions marker (so engine classifiers can identify agent-authored PR
1544
+ // comments by structure, not body shape).
1545
+ //
1546
+ // Currently supports:
1547
+ // minions pr comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>]
1548
+ // (--body-file <path> | --body <inline-text>)
1549
+ //
1550
+ // Reserved for future subcommands: `review`, `review-comment`. Adding them
1551
+ // here is a one-line dispatch change — no shell wiring needed.
1552
+ pr(subcmd, ...rest) {
1553
+ const ghComment = require('./gh-comment');
1554
+
1555
+ if (!subcmd || subcmd === 'help' || subcmd === '--help' || subcmd === '-h') {
1556
+ console.log('Usage:');
1557
+ console.log(' minions pr comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] (--body-file <path> | --body <text>)');
1558
+ console.log('');
1559
+ console.log('Posts a PR comment via `gh pr comment`, prepending the hidden minions');
1560
+ console.log('marker so engine classifiers can identify agent-authored comments by');
1561
+ console.log('structure rather than body shape.');
1562
+ return;
1563
+ }
1564
+
1565
+ if (subcmd !== 'comment') {
1566
+ console.error(`Unknown pr subcommand: ${subcmd}`);
1567
+ console.error('Run: minions pr help');
1568
+ process.exit(2);
1569
+ }
1570
+
1571
+ // Positional: <repo> <prNumber>
1572
+ const positional = [];
1573
+ const flags = {};
1574
+ let i = 0;
1575
+ while (i < rest.length) {
1576
+ const a = rest[i];
1577
+ if (typeof a === 'string' && a.startsWith('--')) {
1578
+ const key = a.slice(2);
1579
+ const val = rest[i + 1];
1580
+ if (val === undefined || (typeof val === 'string' && val.startsWith('--'))) {
1581
+ console.error(`error: --${key} requires a value`);
1582
+ process.exit(2);
1583
+ }
1584
+ flags[key] = val;
1585
+ i += 2;
1586
+ } else {
1587
+ positional.push(a);
1588
+ i++;
1589
+ }
1590
+ }
1591
+
1592
+ const [repo, prNumberRaw] = positional;
1593
+ if (!repo || prNumberRaw === undefined) {
1594
+ console.error('Usage: minions pr comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] (--body-file <path> | --body <text>)');
1595
+ process.exit(2);
1596
+ }
1597
+
1598
+ const prNumber = Number(prNumberRaw);
1599
+ if (!Number.isInteger(prNumber) || prNumber <= 0) {
1600
+ console.error(`error: invalid prNumber: ${JSON.stringify(prNumberRaw)} (expected positive integer)`);
1601
+ process.exit(2);
1602
+ }
1603
+
1604
+ const agentId = flags.agent;
1605
+ const kind = flags.kind;
1606
+ const workItemId = flags.wi;
1607
+ if (!agentId || !kind) {
1608
+ console.error('error: --agent and --kind are required');
1609
+ process.exit(2);
1610
+ }
1611
+
1612
+ let body;
1613
+ if (flags['body-file']) {
1614
+ try { body = fs.readFileSync(flags['body-file'], 'utf8'); }
1615
+ catch (e) {
1616
+ console.error(`error: could not read --body-file ${flags['body-file']}: ${e.message}`);
1617
+ process.exit(2);
1618
+ }
1619
+ } else if (flags.body !== undefined) {
1620
+ body = String(flags.body);
1621
+ } else {
1622
+ console.error('error: must supply either --body-file <path> or --body <text>');
1623
+ process.exit(2);
1624
+ }
1625
+
1626
+ try {
1627
+ const result = ghComment.postPrComment({
1628
+ repo, prNumber, body, agentId, kind, workItemId,
1629
+ });
1630
+ if (result.output) console.log(result.output);
1631
+ } catch (e) {
1632
+ console.error(`error: ${e.message}`);
1633
+ process.exit(1);
1634
+ }
1538
1635
  }
1539
1636
  };
1540
1637
 
@@ -0,0 +1,246 @@
1
+ /**
2
+ * engine/gh-comment.js — wraps `gh pr comment` / `gh pr review` with a hidden
3
+ * HTML-comment marker so downstream classifiers (engine/github.js) can identify
4
+ * minions-authored PR comments by structure rather than by body shape.
5
+ *
6
+ * Marker format (single line, ASCII):
7
+ * <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> -->
8
+ *
9
+ * Followed by `\n\n` and then the caller-provided body. `wi=` is omitted when
10
+ * no workItemId is supplied. The marker is intentionally minimal so it round-
11
+ * trips through GitHub's Markdown rendering and survives quoting.
12
+ *
13
+ * Validation rules (all enforced before any shell call):
14
+ * - agentId /^[a-z][a-z0-9-]{0,30}$/ (lowercase, hyphenated, ≤31 chars)
15
+ * - kind /^[a-z][a-z0-9-]{0,30}$/ (same shape — categorical tag)
16
+ * - workItemId /^[A-Z]-[a-z0-9]+$/ (e.g. W-mp3bp0ha000997ab, P-d5a8c9b6)
17
+ * - no field may contain `--` (would close the HTML comment early)
18
+ * - no field may contain `=` `<` `>` `\n` `"` `'` `` ` `` ` ` (whitespace)
19
+ *
20
+ * Idempotency: if the caller body already starts with a `<!-- minions:agent=`
21
+ * marker, the helper returns the body unchanged — never double-prepends, even
22
+ * when the existing marker disagrees with the helper's parameters. The
23
+ * caller's pre-marked body is treated as the source of truth.
24
+ *
25
+ * `gh` invocation: argv form with `--body-file <tmp>` (NOT `--body <inline>`).
26
+ * Avoids platform-specific shell-quoting bugs for bodies that contain quotes,
27
+ * backticks, or `$(…)`. Temp files are cleaned up in `finally`.
28
+ */
29
+
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const os = require('os');
33
+ const { execFileSync: _execFileSync } = require('child_process');
34
+
35
+ // ── Validation ───────────────────────────────────────────────────────────────
36
+
37
+ const AGENT_ID_RE = /^[a-z][a-z0-9-]{0,30}$/;
38
+ const KIND_RE = /^[a-z][a-z0-9-]{0,30}$/;
39
+ const WORK_ITEM_ID_RE = /^[A-Z]-[a-z0-9]+$/;
40
+ const REPO_SLUG_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
41
+
42
+ // Marker line (single-line HTML comment). Anchored to start-of-line; capture
43
+ // groups: 1 = agentId, 2 = kind, 3 = workItemId (optional, undefined if
44
+ // absent). Multiline flag so it matches at the start of a line anywhere in
45
+ // the body — required for round-trip detection of the builder's output.
46
+ const MINIONS_COMMENT_MARKER_RE =
47
+ /^<!--\s*minions:agent=([^\s]+)\s+kind=([^\s]+)(?:\s+wi=([^\s]+))?\s*-->/m;
48
+
49
+ // Cheaper "is this body already marked?" check that matches only at position 0
50
+ // (for idempotency in buildMinionsCommentBody). Kept separate from the
51
+ // exported regex so the public regex can be used by downstream classifiers
52
+ // that scan the entire body.
53
+ const _LEADING_MARKER_RE = /^<!--\s*minions:agent=/;
54
+
55
+ function _hasNoDoubleDash(value) {
56
+ return !String(value).includes('--');
57
+ }
58
+
59
+ function _validateField(name, value, re) {
60
+ if (typeof value !== 'string' || !re.test(value)) {
61
+ throw new Error(`invalid ${name}: ${JSON.stringify(value)}`);
62
+ }
63
+ if (!_hasNoDoubleDash(value)) {
64
+ throw new Error(`invalid ${name}: must not contain "--" (HTML-comment safety)`);
65
+ }
66
+ }
67
+
68
+ function _validateMarkerInputs({ agentId, kind, workItemId }) {
69
+ _validateField('agentId', agentId, AGENT_ID_RE);
70
+ _validateField('kind', kind, KIND_RE);
71
+ if (workItemId !== undefined && workItemId !== null) {
72
+ _validateField('workItemId', workItemId, WORK_ITEM_ID_RE);
73
+ }
74
+ }
75
+
76
+ function _validateRepo(repo) {
77
+ if (typeof repo !== 'string' || !REPO_SLUG_RE.test(repo)) {
78
+ throw new Error(`invalid repo: ${JSON.stringify(repo)} (expected "owner/name")`);
79
+ }
80
+ }
81
+
82
+ function _validatePrNumber(prNumber) {
83
+ if (typeof prNumber !== 'number' || !Number.isInteger(prNumber) || prNumber <= 0) {
84
+ throw new Error(`invalid prNumber: ${JSON.stringify(prNumber)} (expected positive integer)`);
85
+ }
86
+ }
87
+
88
+ // ── Marker construction / parsing ────────────────────────────────────────────
89
+
90
+ function _buildMarker({ agentId, kind, workItemId }) {
91
+ _validateMarkerInputs({ agentId, kind, workItemId });
92
+ const wi = (workItemId !== undefined && workItemId !== null) ? ` wi=${workItemId}` : '';
93
+ return `<!-- minions:agent=${agentId} kind=${kind}${wi} -->`;
94
+ }
95
+
96
+ function buildMinionsCommentBody({ agentId, kind, workItemId, body }) {
97
+ // Idempotency: if the body already starts with a minions marker, leave it
98
+ // alone — the caller's marker is authoritative. Validate the inputs anyway
99
+ // so callers don't silently bypass validation by pre-marking their body.
100
+ _validateMarkerInputs({ agentId, kind, workItemId });
101
+ const safeBody = body == null ? '' : String(body);
102
+ if (_LEADING_MARKER_RE.test(safeBody)) return safeBody;
103
+ const marker = _buildMarker({ agentId, kind, workItemId });
104
+ return `${marker}\n\n${safeBody}`;
105
+ }
106
+
107
+ function parseMinionsMarker(body) {
108
+ if (typeof body !== 'string' || body.length === 0) return null;
109
+ const m = body.match(MINIONS_COMMENT_MARKER_RE);
110
+ if (!m) return null;
111
+ return {
112
+ agentId: m[1],
113
+ kind: m[2],
114
+ workItemId: m[3] === undefined ? undefined : m[3],
115
+ };
116
+ }
117
+
118
+ // ── gh invocation ────────────────────────────────────────────────────────────
119
+
120
+ function _writeTempBodyFile(content) {
121
+ const dir = path.join(os.tmpdir(), 'minions-gh-comment');
122
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
123
+ const file = path.join(
124
+ dir,
125
+ `body-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`,
126
+ );
127
+ fs.writeFileSync(file, content);
128
+ return file;
129
+ }
130
+
131
+ function _runGh(execFileSync, args, timeoutMs) {
132
+ return execFileSync('gh', args, {
133
+ encoding: 'utf8',
134
+ timeout: timeoutMs,
135
+ windowsHide: true,
136
+ });
137
+ }
138
+
139
+ function postPrComment({
140
+ repo,
141
+ prNumber,
142
+ body,
143
+ agentId,
144
+ kind,
145
+ workItemId,
146
+ timeoutMs = 30000,
147
+ execFileSync = _execFileSync,
148
+ } = {}) {
149
+ _validateRepo(repo);
150
+ _validatePrNumber(prNumber);
151
+ const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body });
152
+ const file = _writeTempBodyFile(finalBody);
153
+ try {
154
+ const output = _runGh(
155
+ execFileSync,
156
+ ['pr', 'comment', String(prNumber), '--repo', repo, '--body-file', file],
157
+ timeoutMs,
158
+ );
159
+ return { output: String(output || '').trim(), bodyFile: file };
160
+ } finally {
161
+ try { fs.unlinkSync(file); } catch {}
162
+ }
163
+ }
164
+
165
+ function postPrReviewComment({
166
+ repo,
167
+ prNumber,
168
+ body,
169
+ agentId,
170
+ kind,
171
+ workItemId,
172
+ timeoutMs = 30000,
173
+ execFileSync = _execFileSync,
174
+ } = {}) {
175
+ _validateRepo(repo);
176
+ _validatePrNumber(prNumber);
177
+ const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body });
178
+ const file = _writeTempBodyFile(finalBody);
179
+ try {
180
+ const output = _runGh(
181
+ execFileSync,
182
+ ['pr', 'review', String(prNumber), '--comment', '--repo', repo, '--body-file', file],
183
+ timeoutMs,
184
+ );
185
+ return { output: String(output || '').trim(), bodyFile: file };
186
+ } finally {
187
+ try { fs.unlinkSync(file); } catch {}
188
+ }
189
+ }
190
+
191
+ const _REVIEW_EVENT_FLAGS = Object.freeze({
192
+ APPROVE: '--approve',
193
+ REQUEST_CHANGES: '--request-changes',
194
+ COMMENT: '--comment',
195
+ });
196
+
197
+ function postPrReview({
198
+ event,
199
+ repo,
200
+ prNumber,
201
+ body,
202
+ agentId,
203
+ kind,
204
+ workItemId,
205
+ timeoutMs = 30000,
206
+ execFileSync = _execFileSync,
207
+ } = {}) {
208
+ const flag = _REVIEW_EVENT_FLAGS[event];
209
+ if (!flag) {
210
+ throw new Error(
211
+ `invalid event: ${JSON.stringify(event)} (expected APPROVE | REQUEST_CHANGES | COMMENT)`,
212
+ );
213
+ }
214
+ _validateRepo(repo);
215
+ _validatePrNumber(prNumber);
216
+ const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body });
217
+ const file = _writeTempBodyFile(finalBody);
218
+ try {
219
+ const output = _runGh(
220
+ execFileSync,
221
+ ['pr', 'review', String(prNumber), flag, '--repo', repo, '--body-file', file],
222
+ timeoutMs,
223
+ );
224
+ return { output: String(output || '').trim(), bodyFile: file };
225
+ } finally {
226
+ try { fs.unlinkSync(file); } catch {}
227
+ }
228
+ }
229
+
230
+ module.exports = {
231
+ // Builders / parsers (pure functions — usable from anywhere)
232
+ buildMinionsCommentBody,
233
+ parseMinionsMarker,
234
+ MINIONS_COMMENT_MARKER_RE,
235
+ // Validation regexes (exported for downstream consumers)
236
+ AGENT_ID_RE,
237
+ KIND_RE,
238
+ WORK_ITEM_ID_RE,
239
+ // gh wrappers (argv-form, --body-file)
240
+ postPrComment,
241
+ postPrReviewComment,
242
+ postPrReview,
243
+ // Internal helpers exported for tests / advanced callers
244
+ _buildMarker,
245
+ _writeTempBodyFile,
246
+ };
@@ -923,11 +923,22 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
923
923
  });
924
924
  if (duplicateOnBranch) {
925
925
  log('warn', `Duplicate PR detected: ${fullId} on branch ${entry.branch || entryBranch} — already tracked as ${duplicateOnBranch.id}. Skipping.`);
926
- // Best-effort close the duplicate on GitHub (non-blocking, fire-and-forget)
926
+ // Best-effort close the duplicate on GitHub (non-blocking, fire-and-forget).
927
+ // The closing comment is wrapped with the minions marker so the PR-comment
928
+ // classifier (engine/github.js _isMinionsAuthoredComment, sub-task -b)
929
+ // recognizes it as engine-authored and never queues a fix-dispatch on it.
927
930
  try {
928
931
  const ghSlug = outputText.match(/github\.com\/([^/]+\/[^/]+)/)?.[1];
929
932
  if (ghSlug) {
930
- execAsync(`gh pr close ${prId} --repo ${ghSlug} --comment "Closing duplicate — ${duplicateOnBranch.id} already tracks this branch."`, { timeout: 15000 })
933
+ const { buildMinionsCommentBody } = require('./gh-comment');
934
+ const dupComment = buildMinionsCommentBody({
935
+ agentId: 'engine',
936
+ kind: 'positive-signal',
937
+ body: `Closing duplicate — ${duplicateOnBranch.id} already tracks this branch.`,
938
+ });
939
+ // Shell-quote the body — `dupComment` contains only ascii safe chars
940
+ // (the marker is `<!-- minions:agent=engine kind=positive-signal -->`).
941
+ execAsync(`gh pr close ${prId} --repo ${ghSlug} --comment ${JSON.stringify(dupComment)}`, { timeout: 15000 })
931
942
  .catch(() => {});
932
943
  }
933
944
  } catch { /* best-effort */ }
@@ -1868,6 +1879,13 @@ function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChang
1868
1879
  reason: reasonText,
1869
1880
  dispatchedAt: now,
1870
1881
  dispatchId: dispatchItem?.id || null,
1882
+ // W-mp3bp0ha000997ab-d: capture the triggering comment id on HUMAN_FEEDBACK
1883
+ // noops so the symmetric same-head guard at engine.js:~2847 can short-circuit
1884
+ // when both the head SHA AND the lastProcessedCommentId match the recorded
1885
+ // dispatch. Other causes have no comment-id concept, so the field is omitted.
1886
+ ...(cause === shared.PR_FIX_CAUSE.HUMAN_FEEDBACK
1887
+ ? { lastProcessedCommentId: String(target.humanFeedback?.lastProcessedCommentId || '') }
1888
+ : {}),
1871
1889
  };
1872
1890
  target.lastDispatchedAt = now;
1873
1891
  target.lastDispatchOutcome = 'noop';
@@ -58,11 +58,18 @@ function getPrCommentInstructions(project) {
58
58
  if (host === 'github') {
59
59
  const org = getProjectOrg(project);
60
60
  const repo = project?.repoName || '';
61
- return `Use \`gh pr comment\` to post a comment on the PR:\n` +
62
- `- Write the Markdown comment to a temporary file, then run: \`gh pr comment <number> --body-file <body-file.md> --repo ${org}/${repo}\`\n` +
61
+ return `Post the comment via \`minions pr comment\` (preferred) it prepends the hidden \`<!-- minions:agent=… kind=… -->\` marker that the engine's classifier needs to recognize agent posts and avoid spurious fix-dispatches:\n` +
62
+ `- \`minions pr comment ${org}/${repo} <number> --agent <your-agent-id> --kind <kind> [--wi <work-item-id>] --body-file <body-file.md>\`\n` +
63
+ `- Pick \`--kind\` from: \`review-decision\` | \`verify-report\` | \`rebase-report\` | \`positive-signal\` | \`fix-summary\` | \`other\`\n` +
63
64
  `- Replace <number> with the PR number\n` +
64
- `- Always set --repo to \`${org}/${repo}\` to target the correct repository\n` +
65
- `- Use --body-file so Markdown, quotes, and newlines are passed safely`;
65
+ `- Use --body-file so Markdown, quotes, and newlines are passed safely\n\n` +
66
+ `If \`minions pr comment\` is unavailable, fall back to raw \`gh pr comment\` BUT the FIRST line of your body file MUST be the marker:\n` +
67
+ "```\n" +
68
+ "<!-- minions:agent=<your-agent-id> kind=<kind> wi=<work-item-id> -->\n" +
69
+ "\n" +
70
+ "<your markdown body here>\n" +
71
+ "```\n" +
72
+ `Then run: \`gh pr comment <number> --body-file <body-file.md> --repo ${org}/${repo}\`. Without the marker, the engine cannot tell your post from a real human comment and will queue redundant fix-dispatches.`;
66
73
  }
67
74
  // Azure DevOps — prefer `az` CLI first, ADO MCP only as fallback
68
75
  const repoName = project?.repoName || '';
@@ -102,14 +109,18 @@ function getPrVoteInstructions(project) {
102
109
  if (host === 'github') {
103
110
  const org = getProjectOrg(project);
104
111
  const repo = project?.repoName || '';
105
- return `**IMPORTANT: GitHub blocks self-approval** — all agents share the same credentials, so \`--approve\` and \`--request-changes\` will fail with "can't approve your own PR." Use \`--comment\` instead.\n\n` +
106
- `Submit your review verdict using \`gh pr review\` with \`--comment\`:\n` +
107
- `- Write a Markdown review body file whose first line is \`VERDICT: APPROVE\`, then run: \`gh pr review <number> --comment --body-file <body-file.md> --repo ${org}/${repo}\`\n` +
108
- `- For requested changes, use \`VERDICT: REQUEST_CHANGES\` as the first line in that same --body-file flow\n` +
109
- `- Replace <number> with the PR number\n` +
110
- `- Always set --repo to \`${org}/${repo}\` to target the correct repository\n` +
111
- `- **Your comment body MUST start with \`VERDICT: APPROVE\` or \`VERDICT: REQUEST_CHANGES\`** on its own line — the engine parses this to record your vote\n` +
112
- `- Do NOT use \`--approve\` or \`--request-changes\` flags — they will fail`;
112
+ return `**IMPORTANT: GitHub blocks self-approval** — all agents share the same credentials, so \`--approve\` and \`--request-changes\` will fail with "can't approve your own PR." Submit your verdict as a comment instead, prefixed with the marker so the engine recognizes you as a minion.\n\n` +
113
+ `Preferred \`minions pr comment\` (auto-prepends the marker; pick \`--kind review-decision\`):\n` +
114
+ `- \`minions pr comment ${org}/${repo} <number> --agent <your-agent-id> --kind review-decision [--wi <work-item-id>] --body-file <verdict.md>\`\n` +
115
+ `- The verdict body file's first non-marker line MUST be \`VERDICT: APPROVE\` or \`VERDICT: REQUEST_CHANGES\` the engine parses this to record your vote\n\n` +
116
+ `Fallback raw \`gh pr review --comment\` with the marker as the first line of \`<verdict.md>\`:\n` +
117
+ "```\n" +
118
+ "<!-- minions:agent=<your-agent-id> kind=review-decision wi=<work-item-id> -->\n" +
119
+ "VERDICT: APPROVE\n" +
120
+ "\n" +
121
+ "<your review body here>\n" +
122
+ "```\n" +
123
+ `Then run: \`gh pr review <number> --comment --body-file <verdict.md> --repo ${org}/${repo}\`. Do NOT use \`--approve\` or \`--request-changes\` flags — they will fail.`;
113
124
  }
114
125
  // Azure DevOps — prefer `az` CLI first, ADO MCP only as fallback
115
126
  return `For Azure DevOps, use the \`az\` CLI first to set your reviewer vote:\n` +
@@ -10,7 +10,7 @@
10
10
 
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
- const { execSync } = require('child_process');
13
+ const { execSync, execFileSync } = require('child_process');
14
14
 
15
15
  /**
16
16
  * Resolve the Claude Code CLI binary path. Legacy helper preserved for back-
@@ -421,6 +421,115 @@ async function _modelDiscoveryResults(config) {
421
421
  return results;
422
422
  }
423
423
 
424
+ /**
425
+ * Spawn `<resolved.bin> --help` and return the combined stdout/stderr, or null
426
+ * if the binary couldn't be invoked at all. Used only by the bypass-flag check
427
+ * in `_bypassFlagResults`; never on the hot path of `runPreflight` because
428
+ * it adds a subprocess invocation per runtime.
429
+ *
430
+ * Many CLIs emit `--help` to stderr or return a non-zero code when invoked in
431
+ * a non-TTY context, so we tolerate both — `execFileSync` populates `stdout`
432
+ * and `stderr` on the thrown error and we read them back.
433
+ */
434
+ function _fetchCliHelpText(resolved, { timeoutMs = 5000 } = {}) {
435
+ if (!resolved || !resolved.bin) return null;
436
+ const leading = Array.isArray(resolved.leadingArgs) ? resolved.leadingArgs : [];
437
+ let cmd;
438
+ let args;
439
+ if (resolved.native === false) {
440
+ // Node shim (e.g. claude/cli.js). Invoke via the current Node so we don't
441
+ // depend on a `node` on PATH and we honor the same execPath the engine
442
+ // uses for spawn-agent.
443
+ cmd = process.execPath;
444
+ args = [resolved.bin, ...leading, '--help'];
445
+ } else {
446
+ cmd = resolved.bin;
447
+ args = [...leading, '--help'];
448
+ }
449
+ try {
450
+ const out = execFileSync(cmd, args, {
451
+ encoding: 'utf8',
452
+ windowsHide: true,
453
+ timeout: timeoutMs,
454
+ stdio: ['ignore', 'pipe', 'pipe'],
455
+ });
456
+ return String(out || '');
457
+ } catch (e) {
458
+ const stdout = e && e.stdout ? String(e.stdout) : '';
459
+ const stderr = e && e.stderr ? String(e.stderr) : '';
460
+ const combined = stdout + stderr;
461
+ return combined.length > 0 ? combined : null;
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Pure helper — takes the adapter and the help text and returns a doctor
467
+ * result entry. Splitting this from the spawn keeps the helper unit-testable
468
+ * without mocking `execFileSync`. The check is `warn`-level (not critical) so
469
+ * a false positive from a CLI that renames flags or paginates help doesn't
470
+ * block existing installs; the engine spawns will still error loudly if the
471
+ * flag really isn't honored.
472
+ */
473
+ function _checkBypassFlagSupported(runtimeName, adapter, helpText) {
474
+ const name = `Permission bypass: ${runtimeName}`;
475
+ const flags = Array.isArray(adapter && adapter.permissionBypassFlags)
476
+ ? adapter.permissionBypassFlags.filter(f => typeof f === 'string' && f.length > 0)
477
+ : [];
478
+ if (flags.length === 0) {
479
+ return {
480
+ name,
481
+ ok: 'warn',
482
+ message: `adapter did not declare permissionBypassFlags — cannot verify ${runtimeName} CLI accepts headless bypass`,
483
+ };
484
+ }
485
+ if (helpText == null || helpText === '') {
486
+ return {
487
+ name,
488
+ ok: 'warn',
489
+ message: `could not invoke ${runtimeName} --help to verify ${flags.join(' ')} support — Minions will still pass the flag(s) but you may see permission prompts if the CLI doesn't accept them`,
490
+ };
491
+ }
492
+ const missing = flags.filter(f => !helpText.includes(f));
493
+ if (missing.length > 0) {
494
+ return {
495
+ name,
496
+ ok: 'warn',
497
+ message: `${runtimeName} --help does not list expected flag(s): ${missing.join(', ')} — your CLI may be outdated. Agents will hang on permission prompts. Update with the CLI's package manager (npm i -g @anthropic-ai/claude-code for Claude; winget upgrade Microsoft.CopilotCLI for Copilot)`,
498
+ };
499
+ }
500
+ return {
501
+ name,
502
+ ok: true,
503
+ message: `${flags.join(' ')} accepted`,
504
+ };
505
+ }
506
+
507
+ /**
508
+ * Build the per-runtime bypass-flag verification entries for `minions doctor`.
509
+ * One entry per distinct configured runtime. Skipped silently when the binary
510
+ * itself didn't resolve (the upstream `Runtime: <name>` check already surfaces
511
+ * that failure — no point shouting twice).
512
+ */
513
+ function _bypassFlagResults(config) {
514
+ const results = [];
515
+ if (!config || typeof config !== 'object') return results;
516
+ let registry;
517
+ try { registry = require('./runtimes'); } catch { return results; }
518
+ const runtimes = _distinctRuntimes(config);
519
+ for (const runtimeName of runtimes) {
520
+ let adapter;
521
+ try { adapter = registry.resolveRuntime(runtimeName); }
522
+ catch { continue; }
523
+ let resolved = null;
524
+ try { resolved = adapter.resolveBinary({ env: process.env }); }
525
+ catch { /* upstream Runtime check already reported this */ }
526
+ if (!resolved || !resolved.bin) continue;
527
+ const helpText = _fetchCliHelpText(resolved);
528
+ results.push(_checkBypassFlagSupported(runtimeName, adapter, helpText));
529
+ }
530
+ return results;
531
+ }
532
+
424
533
  /**
425
534
  * Run extended doctor checks (preflight + runtime health + fleet summary +
426
535
  * per-runtime model discovery).
@@ -552,6 +661,11 @@ function doctor(minionsHome) {
552
661
  runtimeResults.push(...fleetSummary);
553
662
  const modelResults = await _modelDiscoveryResults(preflightConfig);
554
663
  runtimeResults.push(...modelResults);
664
+ // Verify each runtime CLI still recognizes the headless bypass flags the
665
+ // adapters inject. Catches "user installed an outdated CLI" before the
666
+ // first agent silently hangs on a permission prompt.
667
+ const bypassResults = _bypassFlagResults(preflightConfig);
668
+ runtimeResults.push(...bypassResults);
555
669
 
556
670
  // Print all
557
671
  const allResults = [...results, ...runtimeResults];
@@ -584,4 +698,7 @@ module.exports = {
584
698
  _warmModelCache,
585
699
  _fleetSummaryResults,
586
700
  _modelDiscoveryResults,
701
+ _fetchCliHelpText,
702
+ _checkBypassFlagSupported,
703
+ _bypassFlagResults,
587
704
  };
@@ -689,6 +689,14 @@ function createStreamConsumer(ctx) {
689
689
  return { consume, reset };
690
690
  }
691
691
 
692
+ // ── Permission Bypass ───────────────────────────────────────────────────────
693
+ //
694
+ // Flags the engine relies on for headless operation. `preflight.doctor()`
695
+ // shells out to `<bin> --help` and verifies each literal appears in the help
696
+ // text — surfaces "your CLI is too old / has been renamed" before agents
697
+ // silently hang on permission prompts. Must stay in sync with `buildArgs`.
698
+ const PERMISSION_BYPASS_FLAGS = ['--dangerously-skip-permissions'];
699
+
692
700
  // ── Capability Block ────────────────────────────────────────────────────────
693
701
 
694
702
  const capabilities = {
@@ -806,6 +814,7 @@ module.exports = {
806
814
  parseStreamChunk,
807
815
  parseError,
808
816
  createStreamConsumer,
817
+ permissionBypassFlags: PERMISSION_BYPASS_FLAGS,
809
818
  // Exposed for unit tests — never imported by engine code
810
819
  _CLAUDE_SHORTHANDS,
811
820
  THINKING_BLOCK_TYPES,
@@ -1062,6 +1062,14 @@ function createStreamConsumer(ctx) {
1062
1062
  return { consume, reset };
1063
1063
  }
1064
1064
 
1065
+ // ── Permission Bypass ───────────────────────────────────────────────────────
1066
+ //
1067
+ // Flags the engine relies on for headless operation. `preflight.doctor()`
1068
+ // shells out to `<bin> --help` and verifies each literal appears in the help
1069
+ // text — surfaces "your CLI is too old / has been renamed" before agents
1070
+ // silently hang on permission prompts. Must stay in sync with `buildArgs`.
1071
+ const PERMISSION_BYPASS_FLAGS = ['--autopilot', '--allow-all', '--no-ask-user'];
1072
+
1065
1073
  // ── Capability Block ────────────────────────────────────────────────────────
1066
1074
 
1067
1075
  const capabilities = {
@@ -1170,6 +1178,7 @@ module.exports = {
1170
1178
  parseStreamChunk,
1171
1179
  parseError,
1172
1180
  createStreamConsumer,
1181
+ permissionBypassFlags: PERMISSION_BYPASS_FLAGS,
1173
1182
  // Exposed for unit tests — engine code MUST go through resolveRuntime + the
1174
1183
  // adapter contract; never reach into these helpers directly.
1175
1184
  _MINIONS_MODEL_ALIASES,
package/engine.js CHANGED
@@ -2845,6 +2845,32 @@ async function discoverFromPrs(config, project) {
2845
2845
  const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
2846
2846
  if (pollEnabled && autoFixHumanComments && (pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !fixDispatched
2847
2847
  && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.HUMAN_FEEDBACK)) {
2848
+ // W-mp3bp0ha000997ab-d: skip when the most recent HUMAN-FEEDBACK dispatch
2849
+ // already noop'd against the same head SHA AND the same triggering
2850
+ // comment id. Mirrors the BUILD_FAILURE same-head guard below
2851
+ // (engine.js:~2972). Without this, the engine re-fires fix dispatches
2852
+ // every poll cycle on the same dispatchKey when headSha and
2853
+ // lastProcessedCommentId are both unchanged, accumulating noop attempts
2854
+ // until prNoOpFixPauseAttempts trips pause (live repro: PR #2440 fired
2855
+ // 3 noop dispatches on the same comment id within ~2h).
2856
+ //
2857
+ // Tracking is per-cause (`_lastDispatchByCause['human-feedback']`); the
2858
+ // PR-wide legacy `lastDispatch*` fields are intentionally NOT consulted
2859
+ // here — they are cross-cause and would mis-suppress unrelated dispatches
2860
+ // (same lesson as W-mp2vohea00112739 for build-failure).
2861
+ const currentHeadSha = String(pr.headSha || pr._adoSourceCommit || pr._adoHeadCommit || '').trim();
2862
+ const lastHumanDispatch = pr._lastDispatchByCause?.[shared.PR_FIX_CAUSE.HUMAN_FEEDBACK];
2863
+ const currentCommentId = String(pr.humanFeedback?.lastProcessedCommentId || '');
2864
+ if (lastHumanDispatch?.outcome === 'noop'
2865
+ && lastHumanDispatch.headSha
2866
+ && currentHeadSha
2867
+ && lastHumanDispatch.headSha === currentHeadSha
2868
+ && lastHumanDispatch.lastProcessedCommentId
2869
+ && currentCommentId
2870
+ && lastHumanDispatch.lastProcessedCommentId === currentCommentId) {
2871
+ log('info', `Skipping human-feedback fix for ${pr.id}: last human-feedback dispatch was noop on the same head ${currentHeadSha.slice(0, 8)} and same comment ${currentCommentId.slice(0, 32)} (${(lastHumanDispatch.reason || '').slice(0, 120)})`);
2872
+ continue;
2873
+ }
2848
2874
  const key = humanFixKey;
2849
2875
  if (isPrAutomationCauseHandledOrPending(project, pr, humanCauseKey)) continue;
2850
2876
  let staleCoalesced = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1908",
3
+ "version": "0.1.1910",
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"
@@ -113,9 +113,37 @@ Find the PR in `projects/<project-name>/pull-requests.json` by `prNumber`. Key f
113
113
  gh pr view <prNumber> --json number,title,state,mergeable,reviewDecision,headRefName,baseRefName,statusCheckRollup --repo OWNER/REPO
114
114
  ```
115
115
 
116
+ ## Posting PR Comments and Reviews (MANDATORY MARKER)
117
+
118
+ Every PR comment or review you post is sent through the shared `gh` PAT identity (`yemi33`). The engine has no way to tell your post apart from a real human comment unless you embed the minions marker. Without the marker, the engine queues a redundant fix-dispatch every time you post — see PR #2440 (3 consecutive noop dispatches on a single rebase status comment).
119
+
120
+ **Preferred path — `minions pr comment` (auto-prepends the marker):**
121
+ ```bash
122
+ minions pr comment OWNER/REPO <number> --agent <your-agent-id> --kind <kind> [--wi <work-item-id>] --body-file <body.md>
123
+ ```
124
+ Pick `--kind` from: `review-decision` | `verify-report` | `rebase-report` | `positive-signal` | `fix-summary` | `other`.
125
+
126
+ **Fallback — raw `gh pr comment` / `gh pr review --comment`:** the FIRST line of your body file MUST be the marker. Copy this template verbatim and replace the placeholders:
127
+ ```
128
+ <!-- minions:agent=<your-agent-id> kind=<kind> wi=<work-item-id> -->
129
+
130
+ <your markdown body here>
131
+ ```
132
+ - `<your-agent-id>` — your agent name (e.g. `lambert`, `ralph`, `temp-mp3dop1v…`)
133
+ - `<kind>` — one of the kinds listed above
134
+ - `<wi=…>` — optional but include it when you have a work-item id
135
+
136
+ Then run:
137
+ ```bash
138
+ gh pr comment <number> --body-file <body.md> --repo OWNER/REPO
139
+ gh pr review <number> --comment --body-file <verdict.md> --repo OWNER/REPO # for VERDICT: APPROVE / REQUEST_CHANGES
140
+ ```
141
+
142
+ The marker is an HTML comment on its own line — GitHub's renderer strips it before display, so reviewers see nothing. The body round-trips verbatim through the API so the engine can match it.
143
+
116
144
  ## GitHub Tooling and Auth
117
145
 
118
- For GitHub repo operations, use GitHub MCP tools or the `gh` CLI. Prefer commands such as `gh pr create`, `gh pr view`, `gh pr comment`, `gh pr review --comment`, `gh issue view`, and `gh run view`.
146
+ For GitHub repo operations, use GitHub MCP tools or the `gh` CLI. Prefer commands such as `gh pr create`, `gh pr view`, `gh pr comment`, `gh pr review --comment`, `gh issue view`, and `gh run view`. **For comment / review posts, always go through `minions pr comment` or include the marker template above.**
119
147
 
120
148
  If GitHub or Copilot auth fails, check GitHub/Copilot credentials only:
121
149
  - `gh auth status`