@yemi33/minions 0.1.2145 → 0.1.2147

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.
@@ -84,6 +84,39 @@ Do **not** invent, regenerate, or share the nonce across dispatches — each spa
84
84
  | `tests` | string | `pass`, `fail`, `skipped`, `N/A`, or a free-form note like `skipped — relying on PR pipeline`. |
85
85
  | `pending` | string | Any remaining work, or `none`. |
86
86
  | `followups` | array | Optional. PR-comment follow-up work items the agent dispatched via `POST /api/work-items` with `meta.pr_followup` set. Each entry: `{wi_id, title, reason, parent_comment_id}`. See [PR-comment follow-ups](#pr-comment-follow-ups). |
87
+ | `meta.review` | object | Optional, review tasks only. Records project-local review-skill outcome — see [Review skill outcomes](#review-skill-outcomes). |
88
+
89
+ ## Review skill outcomes
90
+
91
+ W-mq16xtdx001a347e. Review playbook renders a `## Project review skills` block at dispatch time when the target project ships `.claude/skills/*` or `.claude/commands/*` with `review` / `swarm` in the name or description (or when `.github/copilot-instructions.md` / `CLAUDE.md` mention a canonical `/…review…` slash-command). When the agent acts on that block, it records the outcome under `meta.review` so later evaluation can compare skill-driven reviews vs. first-principles reviews.
92
+
93
+ All `meta.review` fields are optional and backward-compatible — older agents that never set them stay valid.
94
+
95
+ ```json
96
+ {
97
+ "status": "success",
98
+ "verdict": "approved",
99
+ "pr": "https://github.com/owner/repo/pull/123",
100
+ "meta": {
101
+ "review": {
102
+ "skillInvoked": {
103
+ "name": "/review-swarm",
104
+ "path": ".claude/commands/review-swarm.md",
105
+ "kind": "command"
106
+ },
107
+ "skillFindings": 4
108
+ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ | Field | Type | Notes |
114
+ |---|---|---|
115
+ | `meta.review.skillInvoked` | object | The project review skill the agent actually ran. Shape: `{name, path, kind}` where `kind` is one of `skill`, `command`, `slash-command` (mirrors the discovery layer in `engine/discover-review-skills.js`). Omit when no skill was invoked. |
116
+ | `meta.review.skillFindings` | number | Count of findings the invoked skill returned. Used later to measure skill quality and the value-add of first-principles review on top. Omit when no skill was invoked. |
117
+ | `meta.review.skillSkipped` | object | Set when a project review skill was available but the agent intentionally chose not to run it (trivial diff, out-of-scope diff, meta-review of the skill itself, etc.). Shape: `{name, reason}`. Mirror the skip rationale into the `Automated checks` section of the PR comment so a human reviewer sees the reasoning. |
118
+
119
+ Dispatchers can suppress the block entirely by setting `meta.skipProjectReviewSkills: true` on the review work item — the playbook then renders identically to the pre-W-mq16xtdx 8-step contract. Use this for meta-reviews of the review skill itself; the engine still accepts `meta.review.skillSkipped` in the report regardless.
87
120
 
88
121
  ## `failure_class` enum
89
122
 
package/engine/cleanup.js CHANGED
@@ -634,6 +634,15 @@ async function runCleanup(config, verbose = false) {
634
634
  if (registered.has(full)) continue;
635
635
  let stat; try { stat = fs.statSync(full); } catch { continue; }
636
636
  if (stat.mtimeMs >= _twoHoursAgo) continue;
637
+ // W-mq5rwwss000f30a7 — even an "orphan" dir (one git doesn't know
638
+ // about) may still host a live agent if dispatch persistence ran
639
+ // before `git worktree add` finished. Skip when a live dispatch
640
+ // claims it.
641
+ if (shared.isWorktreePathLive(full)) {
642
+ log('info', `Cleanup: skip orphan worktree dir ${full} — live dispatch claims it`);
643
+ shared._writeWorktreeSkipLiveInboxNote(full, 'cleanup.orphanWorktreeDirSweep');
644
+ continue;
645
+ }
637
646
  try {
638
647
  fs.rmSync(full, { recursive: true, force: true });
639
648
  cleaned.orphanWorktreeDirs++;
package/engine/cli.js CHANGED
@@ -163,18 +163,30 @@ function handleCommand(cmd, args) {
163
163
  //
164
164
  // `minions work --help` used to create ghost work items with title='--help'
165
165
  // because the bare-string `title` was truthy and bypassed the `!title`
166
- // usage check. The fix lives in per-command guards (`_isHelpArg` /
167
- // `looksLikeFlagOrHelp`) on `work`/`spawn`/`plan`/`complete`, which print
168
- // command-specific `Usage:` output. `pr` and `bridge` handle help inline.
166
+ // usage check. Same class of bug exists in `spawn`/`plan`/`complete`
167
+ // every command that takes a positional arg and tests it with `if (!arg)`.
169
168
  //
170
- // For other commands (start/stop/status/etc.), fall back to printing the
171
- // global command list they take no positional args so `--help` is
172
- // otherwise harmless. Commands with per-command help handling are excluded
173
- // so their `Usage:` text is what the user sees.
174
- const COMMANDS_WITH_OWN_HELP = new Set(['pr', 'bridge', 'work', 'spawn', 'plan', 'complete']);
175
- if (!COMMANDS_WITH_OWN_HELP.has(cmd) && isHelpToken(args && args[0])) {
176
- console.log('Commands:');
177
- for (const line of formatCliCommandHelpLines()) console.log(line);
169
+ // Intercept here so a single guard covers the whole command set. `pr` and
170
+ // `bridge` already handle `help`/`--help`/`-h` inline (see their own
171
+ // first-arg branches), so let them route through unchanged.
172
+ //
173
+ // W-mq60aco9: print the *per-command* usage (matching the convention from
174
+ // PR #3054 which added per-positional `Usage:` for work/spawn/plan/complete).
175
+ // Previously this branch printed the global `Commands:` list and short-
176
+ // circuited the per-command guards — so `minions work --help` advertised
177
+ // the wrong help text. Fall back to the global list only for commands
178
+ // missing from CLI_COMMAND_DOCS. The per-command `_isHelpArg` guards in
179
+ // work/spawn/plan/complete remain as defense-in-depth for any caller that
180
+ // bypasses handleCommand.
181
+ if (cmd !== 'pr' && cmd !== 'bridge' && isHelpToken(args && args[0])) {
182
+ const doc = CLI_COMMAND_DOCS[cmd];
183
+ if (doc) {
184
+ console.log(`Usage: minions ${cmd}${doc.args ? ' ' + doc.args : ''}`);
185
+ if (doc.summary) console.log(` ${doc.summary}`);
186
+ } else {
187
+ console.log('Commands:');
188
+ for (const line of formatCliCommandHelpLines()) console.log(line);
189
+ }
178
190
  return;
179
191
  }
180
192
  return commands[cmd](...args);
@@ -0,0 +1,279 @@
1
+ /**
2
+ * engine/discover-review-skills.js — W-mq16xtdx001a347e
3
+ *
4
+ * Cheap, bounded filesystem walk that surfaces project-local review tooling
5
+ * (skills, slash-commands, copilot-instructions slash-command mentions) so
6
+ * Minions review dispatches reliably steer agents toward the right skill
7
+ * instead of reviewing every PR from first principles.
8
+ *
9
+ * Walk surfaces:
10
+ * - .claude/skills/*\/SKILL.md — name/description frontmatter
11
+ * - .claude/commands/*.md — filename + first heading
12
+ * - .github/copilot-instructions.md — slash-command mentions (top N bytes)
13
+ * - CLAUDE.md — slash-command mentions (top N bytes)
14
+ *
15
+ * Returns a deduplicated array of:
16
+ * { kind: 'skill'|'command'|'slash-command', name, path, oneLineDescription }
17
+ *
18
+ * Contract:
19
+ * - Worktree missing / unreadable → returns [] (never throws)
20
+ * - Single bounded readdir per surface (no recursive grep)
21
+ * - File-count + walltime budget caps the walk in pathological repos
22
+ * - Pure: no engine state mutations, no logging beyond debug
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ // Heuristic match — "review", "swarm", or the explicit constellation-review id.
29
+ // Word-bounded so we don't match arbitrary substrings like "previewer" or
30
+ // "swarmm" inside unrelated names.
31
+ const KEYWORD_RE = /\b(review|review-swarm|swarm|code-review|pr-review|constellation-review)\b/i;
32
+
33
+ // Slash-command extractor — pulls `/foo-review`, `/review-swarm`, etc. out of
34
+ // markdown prose. Anchored so `/path/to/file` doesn't match (must look like a
35
+ // slash-command token: leading `/` immediately followed by an id with no `/`).
36
+ const SLASH_COMMAND_RE = /(?<![A-Za-z0-9/_-])\/([a-z][a-z0-9-]*)(?![A-Za-z0-9/])/g;
37
+
38
+ const DEFAULTS = {
39
+ maxFilesPerSurface: 50, // hard cap on skill packs / command files we'll read
40
+ maxBytesPerFile: 8 * 1024, // only need frontmatter + first heading
41
+ docsScanMaxBytes: 32 * 1024, // CLAUDE.md / copilot-instructions.md top window
42
+ walltimeMs: 250, // bail rather than block dispatch on pathological FS
43
+ };
44
+
45
+ function _now() { return Date.now(); }
46
+
47
+ function _safeReadHead(filePath, maxBytes) {
48
+ let fd;
49
+ try {
50
+ fd = fs.openSync(filePath, 'r');
51
+ const buf = Buffer.alloc(maxBytes);
52
+ const n = fs.readSync(fd, buf, 0, maxBytes, 0);
53
+ return buf.slice(0, n).toString('utf8');
54
+ } catch { return ''; }
55
+ finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* ignore */ } } }
56
+ }
57
+
58
+ function _parseFrontmatter(content) {
59
+ const m = String(content || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
60
+ if (!m) return {};
61
+ const out = {};
62
+ for (const line of m[1].split(/\r?\n/)) {
63
+ const lm = line.match(/^([\w-]+):\s*(.*)$/);
64
+ if (!lm) continue;
65
+ out[lm[1].toLowerCase()] = lm[2].trim().replace(/^["']|["']$/g, '');
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function _firstHeading(content) {
71
+ const lines = String(content || '').split(/\r?\n/);
72
+ for (const line of lines) {
73
+ const m = line.match(/^#+\s+(.+?)\s*$/);
74
+ if (m) return m[1].trim();
75
+ }
76
+ return '';
77
+ }
78
+
79
+ function _firstNonEmptyLine(content) {
80
+ const lines = String(content || '').split(/\r?\n/);
81
+ for (const line of lines) {
82
+ const t = line.trim();
83
+ if (t && !t.startsWith('---') && !t.startsWith('#')) return t;
84
+ }
85
+ return '';
86
+ }
87
+
88
+ function _truncate(s, max) {
89
+ const text = String(s || '').trim();
90
+ if (text.length <= max) return text;
91
+ return text.slice(0, max - 1).trim() + '…';
92
+ }
93
+
94
+ function _discoverSkills(projectPath, opts, deadline) {
95
+ const out = [];
96
+ const skillsDir = path.join(projectPath, '.claude', 'skills');
97
+ let entries;
98
+ try { entries = fs.readdirSync(skillsDir, { withFileTypes: true }); } catch { return out; }
99
+ let scanned = 0;
100
+ for (const ent of entries) {
101
+ if (scanned >= opts.maxFilesPerSurface) break;
102
+ if (_now() > deadline) break;
103
+ if (!ent.isDirectory()) continue;
104
+ const skillPath = path.join(skillsDir, ent.name, 'SKILL.md');
105
+ let stat;
106
+ try { stat = fs.statSync(skillPath); } catch { continue; }
107
+ if (!stat.isFile()) continue;
108
+ scanned += 1;
109
+ const head = _safeReadHead(skillPath, opts.maxBytesPerFile);
110
+ if (!head) continue;
111
+ const fm = _parseFrontmatter(head);
112
+ const name = fm.name || ent.name;
113
+ const desc = fm.description || '';
114
+ const hay = `${name} ${desc}`;
115
+ if (!KEYWORD_RE.test(hay)) continue;
116
+ out.push({
117
+ kind: 'skill',
118
+ name: String(name),
119
+ path: path.relative(projectPath, skillPath).split(path.sep).join('/'),
120
+ oneLineDescription: _truncate(desc || _firstHeading(head) || name, 200),
121
+ });
122
+ }
123
+ return out;
124
+ }
125
+
126
+ function _discoverCommands(projectPath, opts, deadline) {
127
+ const out = [];
128
+ const cmdDir = path.join(projectPath, '.claude', 'commands');
129
+ let entries;
130
+ try { entries = fs.readdirSync(cmdDir, { withFileTypes: true }); } catch { return out; }
131
+ let scanned = 0;
132
+ for (const ent of entries) {
133
+ if (scanned >= opts.maxFilesPerSurface) break;
134
+ if (_now() > deadline) break;
135
+ if (!ent.isFile()) continue;
136
+ if (!/\.md$/i.test(ent.name)) continue;
137
+ scanned += 1;
138
+ const base = ent.name.replace(/\.md$/i, '');
139
+ const cmdPath = path.join(cmdDir, ent.name);
140
+ const head = _safeReadHead(cmdPath, opts.maxBytesPerFile);
141
+ const heading = _firstHeading(head);
142
+ const hay = `${base} ${heading}`;
143
+ if (!KEYWORD_RE.test(hay)) continue;
144
+ out.push({
145
+ kind: 'command',
146
+ name: `/${base}`,
147
+ path: path.relative(projectPath, cmdPath).split(path.sep).join('/'),
148
+ oneLineDescription: _truncate(heading || _firstNonEmptyLine(head) || base, 200),
149
+ });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ function _extractSlashCommandsFromDoc(content) {
155
+ const found = new Map();
156
+ const text = String(content || '');
157
+ let m;
158
+ SLASH_COMMAND_RE.lastIndex = 0;
159
+ while ((m = SLASH_COMMAND_RE.exec(text)) !== null) {
160
+ const id = m[1];
161
+ if (!KEYWORD_RE.test(id)) continue;
162
+ if (!found.has(id)) found.set(id, m.index);
163
+ }
164
+ return [...found.keys()];
165
+ }
166
+
167
+ function _discoverDocSlashCommands(projectPath, opts, deadline, alreadySeen) {
168
+ const out = [];
169
+ const docPaths = [
170
+ path.join(projectPath, '.github', 'copilot-instructions.md'),
171
+ path.join(projectPath, 'CLAUDE.md'),
172
+ ];
173
+ for (const docPath of docPaths) {
174
+ if (_now() > deadline) break;
175
+ let stat;
176
+ try { stat = fs.statSync(docPath); } catch { continue; }
177
+ if (!stat.isFile()) continue;
178
+ const head = _safeReadHead(docPath, opts.docsScanMaxBytes);
179
+ if (!head) continue;
180
+ const ids = _extractSlashCommandsFromDoc(head);
181
+ const relDoc = path.relative(projectPath, docPath).split(path.sep).join('/');
182
+ for (const id of ids) {
183
+ const fullName = `/${id}`;
184
+ if (alreadySeen.has(fullName)) continue;
185
+ alreadySeen.add(fullName);
186
+ out.push({
187
+ kind: 'slash-command',
188
+ name: fullName,
189
+ path: relDoc,
190
+ oneLineDescription: `Documented review entrypoint in ${relDoc}`,
191
+ });
192
+ }
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /**
198
+ * @param {object} args
199
+ * @param {string} args.projectPath — absolute path to the project worktree / checkout
200
+ * @param {object} [args.opts] — override defaults (mostly for tests)
201
+ * @returns {Array<{kind:string,name:string,path:string,oneLineDescription:string}>}
202
+ */
203
+ function discoverReviewSkills(args) {
204
+ const projectPath = args && args.projectPath;
205
+ if (!projectPath || typeof projectPath !== 'string') return [];
206
+ try { if (!fs.statSync(projectPath).isDirectory()) return []; } catch { return []; }
207
+
208
+ const opts = Object.assign({}, DEFAULTS, args.opts || {});
209
+ const deadline = _now() + Math.max(1, opts.walltimeMs);
210
+
211
+ const seenKey = new Set(); // dedupe across surfaces by `${kind}:${name}`
212
+ const seenSlash = new Set();
213
+ const all = [];
214
+
215
+ for (const entry of _discoverSkills(projectPath, opts, deadline)) {
216
+ const key = `skill:${entry.name}`;
217
+ if (seenKey.has(key)) continue;
218
+ seenKey.add(key);
219
+ all.push(entry);
220
+ }
221
+
222
+ for (const entry of _discoverCommands(projectPath, opts, deadline)) {
223
+ const key = `command:${entry.name}`;
224
+ if (seenKey.has(key)) continue;
225
+ seenKey.add(key);
226
+ seenSlash.add(entry.name); // /foo from .claude/commands subsumes doc-mention
227
+ all.push(entry);
228
+ }
229
+
230
+ for (const entry of _discoverDocSlashCommands(projectPath, opts, deadline, seenSlash)) {
231
+ const key = `slash-command:${entry.name}`;
232
+ if (seenKey.has(key)) continue;
233
+ seenKey.add(key);
234
+ all.push(entry);
235
+ }
236
+
237
+ return all;
238
+ }
239
+
240
+ /**
241
+ * Format a discovery result list as a Markdown block ready to splice into the
242
+ * review playbook. Returns empty string when the list is empty so the caller
243
+ * can no-op cleanly (no stray header, no blank padding lines).
244
+ *
245
+ * @param {Array} entries — output of discoverReviewSkills()
246
+ * @returns {string}
247
+ */
248
+ function renderReviewSkillsBlock(entries) {
249
+ if (!Array.isArray(entries) || entries.length === 0) return '';
250
+ const lines = [];
251
+ lines.push('## Project review skills (prefer these when applicable)');
252
+ lines.push('');
253
+ lines.push("This project ships purpose-built review tooling. When the diff under review is within scope of one of these skills, INVOKE IT FIRST and use its findings as the primary signal — your verdict can then be anchored to what the skill returned plus any gaps you spot on top.");
254
+ lines.push('');
255
+ for (const e of entries) {
256
+ const kindHint = e.kind === 'skill' ? `skill: \`${e.name}\``
257
+ : e.kind === 'command' ? `\`${e.name}\``
258
+ : `\`${e.name}\``;
259
+ const pathHint = e.path ? ` (\`${e.path}\`)` : '';
260
+ const desc = e.oneLineDescription ? ` — ${e.oneLineDescription}` : '';
261
+ lines.push(`- ${kindHint}${pathHint}${desc}`);
262
+ }
263
+ lines.push('');
264
+ lines.push("Record the skill outcome in your completion report's `meta.review` block (`skillInvoked` or `skillSkipped` — see `docs/completion-reports.md`) so the engine can later measure skill-vs-first-principles signal.");
265
+ return lines.join('\n');
266
+ }
267
+
268
+ module.exports = {
269
+ discoverReviewSkills,
270
+ renderReviewSkillsBlock,
271
+ // exported for tests
272
+ _internal: {
273
+ KEYWORD_RE,
274
+ SLASH_COMMAND_RE,
275
+ DEFAULTS,
276
+ _parseFrontmatter,
277
+ _extractSlashCommandsFromDoc,
278
+ },
279
+ };
@@ -301,6 +301,12 @@ const PLAYBOOK_OPTIONAL_VARS = new Set([
301
301
  // PR-context vars on non-PR tasks (implement/explore/etc.)
302
302
  'pr_id', 'pr_number', 'pr_title', 'pr_branch', 'pr_author', 'pr_url',
303
303
  'reviewer',
304
+ // W-mq16xtdx001a347e — review dispatches inject this block (or empty string
305
+ // when discovery finds no project-local review skills). Optional because
306
+ // non-review playbooks never reference it and review-against-skill-less
307
+ // projects legitimately resolve it to ''.
308
+ 'project_review_skills_block',
309
+ 'skip_project_review_skills',
304
310
  // P-e6b3c2d8 — QA Session template vars. session_id / target_kind /
305
311
  // flows_raw / managed_spawn_name are required (declared in
306
312
  // PLAYBOOK_REQUIRED_VARS['qa-session-setup']); these target_* sub-fields
@@ -689,6 +695,31 @@ function renderPlaybook(type, vars) {
689
695
  } catch (e) { log('warn', `managed-spawn live-processes inject failed: ${e.message}`); }
690
696
  }
691
697
 
698
+ // W-mq16xtdx001a347e — review dispatches: discover project-local review
699
+ // tooling (.claude/skills, .claude/commands, copilot-instructions slash-
700
+ // command mentions) and render a "Project review skills" block near the top
701
+ // of the playbook. Default-ON for `review` type; suppressed when the
702
+ // dispatcher passes `meta.skipProjectReviewSkills` (threaded as
703
+ // vars.skip_project_review_skills). Renders empty string when discovery
704
+ // returns no matches so first-principles reviewers see no stray header.
705
+ if (type === 'review' && !vars.skip_project_review_skills) {
706
+ try {
707
+ const discover = require('./discover-review-skills');
708
+ const projectPath = matchedProject?.localPath || '';
709
+ if (projectPath) {
710
+ const entries = discover.discoverReviewSkills({ projectPath });
711
+ vars.project_review_skills_block = discover.renderReviewSkillsBlock(entries);
712
+ } else {
713
+ vars.project_review_skills_block = '';
714
+ }
715
+ } catch (e) {
716
+ log('warn', `discover-review-skills failed: ${e.message}`);
717
+ vars.project_review_skills_block = '';
718
+ }
719
+ } else {
720
+ vars.project_review_skills_block = '';
721
+ }
722
+
692
723
  // W-mpeiwz6k0005bf34-c — opt-in qa-validate context block. Injected only
693
724
  // when the dispatcher set vars.qa_run_id (truthy) from the work item's
694
725
  // `meta.qaRunId`. Mirrors the managed_spawn hint pattern: the playbook is
@@ -1098,6 +1129,10 @@ function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabe
1098
1129
  const vars = {
1099
1130
  ...buildBaseVars(agentId, config, project),
1100
1131
  branch_name: extraVars?.pr_branch || '',
1132
+ // W-mq16xtdx001a347e — honor meta.skipProjectReviewSkills on PR-built
1133
+ // dispatches (auto re-reviews, conflict-fix follow-ups, etc.). Default
1134
+ // OFF: review dispatches surface the project-local review skills block.
1135
+ skip_project_review_skills: !!(meta && meta.skipProjectReviewSkills),
1101
1136
  ...extraVars,
1102
1137
  task_id: dispatchId,
1103
1138
  };