@yemi33/minions 0.1.1907 → 0.1.1909
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/bin/minions.js +3 -1
- package/engine/cli.js +97 -0
- package/engine/gh-comment.js +246 -0
- package/engine/lifecycle.js +139 -17
- package/engine/playbook.js +23 -12
- package/engine.js +26 -0
- package/package.json +1 -1
- package/playbooks/shared-rules.md +29 -1
- package/engine/copilot-models.json +0 -5
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
|
+
};
|
package/engine/lifecycle.js
CHANGED
|
@@ -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
|
-
|
|
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';
|
|
@@ -2998,6 +3016,93 @@ function writeNonCleanAgentReport(dispatchItem, agentId, outcome, structuredComp
|
|
|
2998
3016
|
shared.writeToInbox(agentId || 'engine', `agent-${outcome}-${dispatchItem.id}`, content, null, metadata);
|
|
2999
3017
|
}
|
|
3000
3018
|
|
|
3019
|
+
/**
|
|
3020
|
+
* Permissively pull all assistant-message content out of a stream-json log.
|
|
3021
|
+
*
|
|
3022
|
+
* The standard runtime parsers (engine/runtimes/*.parseOutput) intentionally drop
|
|
3023
|
+
* `assistant.message` content when the same event also carries `toolRequests`
|
|
3024
|
+
* (so a "I'll create files now" preamble doesn't leak into the user-visible
|
|
3025
|
+
* result text). For decompose dispatches the JSON block IS the deliverable, so
|
|
3026
|
+
* if the agent emits the block in the same turn it issues a tool call (very
|
|
3027
|
+
* common — the agent writes a sidecar note in the same turn) the standard
|
|
3028
|
+
* parser drops the entire JSON and decomposition silently produces no children
|
|
3029
|
+
* (W-mp3d2e6u000i9ca9). This helper concatenates EVERY content/deltaContent
|
|
3030
|
+
* chunk regardless of toolRequests so the JSON-block regex can find it.
|
|
3031
|
+
*
|
|
3032
|
+
* Returns '' when raw is empty or contains no parseable assistant content.
|
|
3033
|
+
*/
|
|
3034
|
+
function _collectAllAssistantContent(raw) {
|
|
3035
|
+
const safeRaw = raw == null ? '' : String(raw);
|
|
3036
|
+
if (!safeRaw) return '';
|
|
3037
|
+
const parts = [];
|
|
3038
|
+
for (const rawLine of safeRaw.split('\n')) {
|
|
3039
|
+
const line = rawLine.trim();
|
|
3040
|
+
if (!line || !line.startsWith('{')) continue;
|
|
3041
|
+
let obj;
|
|
3042
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
3043
|
+
if (!obj || typeof obj !== 'object') continue;
|
|
3044
|
+
const type = obj.type;
|
|
3045
|
+
// Copilot stream-json: {type:'assistant.message',data:{content,toolRequests}}
|
|
3046
|
+
if (type === 'assistant.message' || type === 'assistant.message_delta') {
|
|
3047
|
+
const c = obj.data?.content;
|
|
3048
|
+
if (typeof c === 'string' && c) parts.push(c);
|
|
3049
|
+
const d = obj.data?.deltaContent;
|
|
3050
|
+
if (typeof d === 'string' && d) parts.push(d);
|
|
3051
|
+
}
|
|
3052
|
+
// Claude stream-json: {type:'assistant',message:{content:[{type:'text',text}]}}
|
|
3053
|
+
if (type === 'assistant' && obj.message?.content) {
|
|
3054
|
+
const blocks = Array.isArray(obj.message.content) ? obj.message.content : [];
|
|
3055
|
+
for (const b of blocks) {
|
|
3056
|
+
if (b?.type === 'text' && typeof b.text === 'string' && b.text) parts.push(b.text);
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
return parts.join('');
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
/**
|
|
3064
|
+
* Extract a decomposition object ({parent_id, sub_items}) from raw agent stdout.
|
|
3065
|
+
*
|
|
3066
|
+
* Strategy:
|
|
3067
|
+
* 1. Try the standard runtime parser first (preserves existing behavior when
|
|
3068
|
+
* the agent emits the block in a content-only turn).
|
|
3069
|
+
* 2. Fall back to a permissive scan that joins ALL assistant content. This
|
|
3070
|
+
* catches the common case where the agent emits the JSON block in the
|
|
3071
|
+
* same turn as a tool call (which the standard parser drops).
|
|
3072
|
+
*
|
|
3073
|
+
* In both passes, scan ALL ```json fenced blocks and pick the first one whose
|
|
3074
|
+
* parsed body has a non-empty `sub_items` (or `subItems`) array. This is more
|
|
3075
|
+
* robust than picking the first match — agents sometimes emit example/spec
|
|
3076
|
+
* JSON blocks before the real decomposition output.
|
|
3077
|
+
*
|
|
3078
|
+
* Returns the parsed decomposition object, or null if none found.
|
|
3079
|
+
* Exported for unit testing.
|
|
3080
|
+
*/
|
|
3081
|
+
function extractDecompositionJson(stdout, runtimeName) {
|
|
3082
|
+
const candidates = [];
|
|
3083
|
+
try {
|
|
3084
|
+
const parsed = shared.parseStreamJsonOutput(stdout, runtimeName);
|
|
3085
|
+
if (parsed?.text) candidates.push(parsed.text);
|
|
3086
|
+
} catch { /* runtime resolution failure — fall through to permissive scan */ }
|
|
3087
|
+
const permissive = _collectAllAssistantContent(stdout);
|
|
3088
|
+
if (permissive && permissive !== candidates[0]) candidates.push(permissive);
|
|
3089
|
+
|
|
3090
|
+
const blockRe = /```json\s*\n([\s\S]*?)```/g;
|
|
3091
|
+
for (const text of candidates) {
|
|
3092
|
+
if (!text) continue;
|
|
3093
|
+
blockRe.lastIndex = 0;
|
|
3094
|
+
let m;
|
|
3095
|
+
while ((m = blockRe.exec(text)) !== null) {
|
|
3096
|
+
let parsed;
|
|
3097
|
+
try { parsed = JSON.parse(m[1]); }
|
|
3098
|
+
catch { continue; }
|
|
3099
|
+
const subs = parsed?.sub_items || parsed?.subItems;
|
|
3100
|
+
if (Array.isArray(subs) && subs.length > 0) return parsed;
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
return null;
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3001
3106
|
/**
|
|
3002
3107
|
* Handle decomposition result — parse sub-items from agent output and create child work items.
|
|
3003
3108
|
* Called from runPostCompletionHooks when type === 'decompose'.
|
|
@@ -3007,19 +3112,9 @@ function handleDecompositionResult(stdout, meta, config, runtimeName) {
|
|
|
3007
3112
|
const parentId = meta?.item?.id;
|
|
3008
3113
|
if (!parentId) return 0;
|
|
3009
3114
|
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
if (!jsonMatch) {
|
|
3014
|
-
log('warn', `Decomposition for ${parentId}: no JSON block found in output`);
|
|
3015
|
-
return 0;
|
|
3016
|
-
}
|
|
3017
|
-
|
|
3018
|
-
let decomposition;
|
|
3019
|
-
try {
|
|
3020
|
-
decomposition = JSON.parse(jsonMatch[1]);
|
|
3021
|
-
} catch (err) {
|
|
3022
|
-
log('warn', `Decomposition for ${parentId}: invalid JSON — ${err.message}`);
|
|
3115
|
+
const decomposition = extractDecompositionJson(stdout, runtimeName);
|
|
3116
|
+
if (!decomposition) {
|
|
3117
|
+
log('warn', `Decomposition for ${parentId}: no usable JSON block found in output`);
|
|
3023
3118
|
return 0;
|
|
3024
3119
|
}
|
|
3025
3120
|
|
|
@@ -3171,8 +3266,33 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
3171
3266
|
let skipDoneStatus = false;
|
|
3172
3267
|
if (type === WORK_TYPE.DECOMPOSE && effectiveSuccess && meta?.item?.id) {
|
|
3173
3268
|
const subCount = handleDecompositionResult(stdout, meta, config, runtimeName);
|
|
3174
|
-
if (subCount > 0)
|
|
3175
|
-
|
|
3269
|
+
if (subCount > 0) {
|
|
3270
|
+
skipDoneStatus = true; // parent already marked 'decomposed' by handler
|
|
3271
|
+
} else {
|
|
3272
|
+
// Decompose claimed success but produced no children. Marking the parent
|
|
3273
|
+
// DONE here would silently terminate an implement:large item with zero
|
|
3274
|
+
// PRs (W-mp3d2e6u000i9ca9). Instead: clear _decomposing so the next
|
|
3275
|
+
// discoverFromWorkItems pass can re-enter the decompose flow, and skip
|
|
3276
|
+
// the DONE update so the parent stays pending. The dispatch retry layer
|
|
3277
|
+
// will requeue it through the standard cooldown/backoff path.
|
|
3278
|
+
skipDoneStatus = true;
|
|
3279
|
+
const wiPath = resolveWorkItemPath(meta);
|
|
3280
|
+
if (wiPath) {
|
|
3281
|
+
try {
|
|
3282
|
+
mutateJsonFileLocked(wiPath, data => {
|
|
3283
|
+
if (!Array.isArray(data)) return data;
|
|
3284
|
+
const wi = data.find(i => i.id === meta.item.id);
|
|
3285
|
+
if (wi) {
|
|
3286
|
+
delete wi._decomposing;
|
|
3287
|
+
wi._lastDecomposeFailure = ts();
|
|
3288
|
+
wi._pendingReason = 'decompose_no_children';
|
|
3289
|
+
}
|
|
3290
|
+
return data;
|
|
3291
|
+
}, { skipWriteIfUnchanged: true });
|
|
3292
|
+
} catch (err) { log('warn', `Decompose retry-prep cleanup: ${err.message}`); }
|
|
3293
|
+
}
|
|
3294
|
+
log('warn', `Decomposition for ${meta.item.id}: success report but no children created — parent left pending for retry`);
|
|
3295
|
+
}
|
|
3176
3296
|
}
|
|
3177
3297
|
|
|
3178
3298
|
// Verify review work items include a verdict — must run BEFORE updateWorkItemStatus(DONE),
|
|
@@ -3764,4 +3884,6 @@ module.exports = {
|
|
|
3764
3884
|
processPendingRebases,
|
|
3765
3885
|
findDependentActivePrs,
|
|
3766
3886
|
isPrAttachmentRequired,
|
|
3887
|
+
extractDecompositionJson,
|
|
3888
|
+
handleDecompositionResult,
|
|
3767
3889
|
};
|
package/engine/playbook.js
CHANGED
|
@@ -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 `
|
|
62
|
-
`-
|
|
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
|
-
`-
|
|
65
|
-
|
|
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."
|
|
106
|
-
`
|
|
107
|
-
`-
|
|
108
|
-
`-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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` +
|
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.
|
|
3
|
+
"version": "0.1.1909",
|
|
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`
|