@yemi33/minions 0.1.2146 → 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/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
  };
package/engine/shared.js CHANGED
@@ -5919,6 +5919,97 @@ function migratePrGateFlags(projectsRoot) {
5919
5919
  return summary;
5920
5920
  }
5921
5921
 
5922
+ // ─── PR Reference → URL Derivation ───────────────────────────────────────────
5923
+ //
5924
+ // W-mq5wfh1v000e0da9 — Given a PR ref (URL, canonical `host:scope#N` id, or
5925
+ // bare number / legacy `PR-N`), derive a usable URL. Used by the fix-WI auto-
5926
+ // enrollment helper below so untracked PRs can be linked without forcing the
5927
+ // caller to construct a URL themselves.
5928
+ //
5929
+ // Returns null when no URL can be derived (e.g. bare number with no
5930
+ // project.prUrlBase to anchor against).
5931
+ function deriveUrlForPrRef(prRef, project) {
5932
+ const ref = String(prRef || '').trim();
5933
+ if (!ref) return null;
5934
+ if (/^https?:\/\//i.test(ref)) return ref;
5935
+ const canonical = parseCanonicalPrId(ref);
5936
+ if (canonical) {
5937
+ if (canonical.scope.startsWith('github:')) {
5938
+ const slug = canonical.scope.slice('github:'.length);
5939
+ return `https://github.com/${slug}/pull/${canonical.prNumber}`;
5940
+ }
5941
+ if (canonical.scope.startsWith('ado:')) {
5942
+ const parts = canonical.scope.slice('ado:'.length).split('/');
5943
+ if (parts.length >= 3) {
5944
+ return `https://dev.azure.com/${parts[0]}/${parts[1]}/_git/${parts[2]}/pullrequest/${canonical.prNumber}`;
5945
+ }
5946
+ }
5947
+ }
5948
+ // Bare number / PR-N — derive from project.prUrlBase if available.
5949
+ const numMatch = ref.match(/^(?:PR-)?(\d+)$/i);
5950
+ if (numMatch && project && project.prUrlBase) {
5951
+ return `${project.prUrlBase}${numMatch[1]}`;
5952
+ }
5953
+ return null;
5954
+ }
5955
+
5956
+ // W-mq5wfh1v000e0da9 — Auto-enroll a PR into pull-requests.json when a
5957
+ // `type: fix` work item is created with a structured PR pointer. Without
5958
+ // this, fix WIs against untracked PRs bypass the polling / review / build
5959
+ // pipelines (the engine `pr_not_found` gate blocks dispatch entirely, and
5960
+ // human PR comments / build failures never surface) until an operator hits
5961
+ // POST /api/pull-requests/link manually.
5962
+ //
5963
+ // Idempotent: no-ops if the PR is already enrolled. Concurrent calls are
5964
+ // safe because the underlying upsertPullRequestRecord serializes via
5965
+ // mutatePullRequests.
5966
+ //
5967
+ // Only inspects STRUCTURED refs (targetPr / pr_id / prId / sourcePr /
5968
+ // pullRequest / prUrl / prNumber / references[*].url / meta.pr_followup
5969
+ // .parent_pr_url). Description-only PR mentions are INTENTIONALLY skipped
5970
+ // per the "structured-vs-loose split" — enrollment must be intentional.
5971
+ //
5972
+ // Returns:
5973
+ // { skipped: true, reason } — not a fix / no ref / no URL / upsert error
5974
+ // { alreadyEnrolled: true, id } — PR already in pull-requests.json
5975
+ // { enrolled: true, id, prPath } — newly enrolled
5976
+ function autoEnrollPrFromFixWorkItem(item, project, minionsDir) {
5977
+ if (!item || item.type !== WORK_TYPE.FIX) return { skipped: true, reason: 'not-fix' };
5978
+ const prRef = extractStructuredWorkItemPrRef(item);
5979
+ if (!prRef) return { skipped: true, reason: 'no-structured-ref' };
5980
+ const url = deriveUrlForPrRef(prRef, project);
5981
+ if (!url) return { skipped: true, reason: 'no-url' };
5982
+ const prPath = project ? projectPrPath(project) : centralPullRequestsPath(minionsDir);
5983
+ const existing = safeJsonArr(prPath);
5984
+ if (findPrRecord(existing, prRef, project)) {
5985
+ return { alreadyEnrolled: true, id: getCanonicalPrId(project, prRef, url) };
5986
+ }
5987
+ const parsedUrl = parsePrUrl(url);
5988
+ const prNum = parsedUrl ? parsedUrl.prNumber : null;
5989
+ const prId = getCanonicalPrId(project, prRef, url);
5990
+ try {
5991
+ const result = upsertPullRequestRecord(prPath, {
5992
+ id: prId,
5993
+ prNumber: prNum,
5994
+ title: `PR #${prNum != null ? prNum : '?'} (polling...)`,
5995
+ description: '',
5996
+ agent: 'human',
5997
+ branch: '',
5998
+ reviewStatus: 'pending',
5999
+ status: 'active',
6000
+ created: new Date().toISOString(),
6001
+ url,
6002
+ contextOnly: false,
6003
+ }, { project, itemId: item.id });
6004
+ return result.created
6005
+ ? { enrolled: true, id: result.id, prPath }
6006
+ : { alreadyEnrolled: true, id: result.id };
6007
+ } catch (e) {
6008
+ log('warn', `autoEnrollPrFromFixWorkItem ${item.id}: ${e.message}`);
6009
+ return { skipped: true, reason: 'upsert-error', error: e.message };
6010
+ }
6011
+ }
6012
+
5922
6013
  // ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
5923
6014
 
5924
6015
  function normalizeKillPid(proc) {
@@ -7091,6 +7182,8 @@ module.exports = {
7091
7182
  upsertPullRequestRecord,
7092
7183
  isAutoManagedPrRecord, // W-mq5s5ttx000j7ab8-a — exported for engine + watch-plugin gate consolidation
7093
7184
  migratePrGateFlags, // W-mq5s5ttx000j7ab8-a — boot migration wired from engine/cli.js
7185
+ autoEnrollPrFromFixWorkItem,
7186
+ deriveUrlForPrRef, // exported for testing
7094
7187
  nextWorkItemId,
7095
7188
  getProjectOrg,
7096
7189
  getAdoOrgBase,
package/engine.js CHANGED
@@ -5885,6 +5885,12 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
5885
5885
  qa_artifacts_dir: item.meta && item.meta.qaRunId
5886
5886
  ? path.posix.join('engine', 'qa-artifacts', String(item.meta.qaRunId))
5887
5887
  : '',
5888
+ // W-mq16xtdx001a347e — escape hatch for review dispatches that should NOT
5889
+ // be steered toward project-local review skills (e.g. when the diff
5890
+ // under review IS that skill, so a meta-review needs first-principles).
5891
+ // Default OFF — the new "Project review skills" block is the whole point
5892
+ // of W-mq16xtdx and should surface on every review by default.
5893
+ skip_project_review_skills: !!(item.meta && item.meta.skipProjectReviewSkills),
5888
5894
  // P-e6b3c2d8 — QA Session template vars. The qa-sessions chain helpers
5889
5895
  // (engine/qa-sessions.js#_baseWorkItem) stamp meta.sessionId,
5890
5896
  // meta.sessionPhase, and meta.qaSession.{target,flowsRaw,mode,capture,runner}
@@ -6319,6 +6325,18 @@ function discoverFromWorkItems(config, project) {
6319
6325
  skipped.noAgent++; continue;
6320
6326
  }
6321
6327
 
6328
+ // W-mq5wfh1v000e0da9 — defense-in-depth: auto-enroll the PR for fix WIs
6329
+ // carrying a structured PR pointer. Primary enrollment runs in the
6330
+ // dashboard `POST /api/work-items` handler, but WIs created via CLI / restored
6331
+ // from disk / older code paths might land here without the PR record.
6332
+ // No-op if the PR is already tracked. Failures swallowed — the gate below
6333
+ // still trips on missing PR records and surfaces `pr_not_found`.
6334
+ try {
6335
+ if (item.type === WORK_TYPE.FIX) {
6336
+ shared.autoEnrollPrFromFixWorkItem(item, project, MINIONS_DIR);
6337
+ }
6338
+ } catch (e) { log('warn', `auto-enroll PR for ${item.id}: ${e.message}`); }
6339
+
6322
6340
  const linkedPr = resolveWorkItemPrRecord(item, project);
6323
6341
  const promptItem = linkedPr ? withWorkItemPrContext(item, linkedPr) : item;
6324
6342
  const prBranch = linkedPr?.branch || '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2146",
3
+ "version": "0.1.2147",
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"
@@ -27,6 +27,10 @@ Use subagents only for genuinely parallel, independent tasks (e.g., reviewing un
27
27
  git diff {{main_branch}}...origin/{{pr_branch}}
28
28
  ```
29
29
 
30
+ {{#project_review_skills_block}}
31
+ {{project_review_skills_block}}
32
+
33
+ {{/project_review_skills_block}}
30
34
  2. Think about deploy risk before commenting:
31
35
  - What user-visible behavior changed?
32
36
  - What dependencies, callers, or tests could be affected?