@yemi33/minions 0.1.2147 → 0.1.2149

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.
@@ -1,279 +1,25 @@
1
1
  /**
2
- * engine/discover-review-skills.js — W-mq16xtdx001a347e
2
+ * engine/discover-review-skills.js — backward-compat shim for PR #82
3
+ * (W-mq16xtdx001a347e).
3
4
  *
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.
5
+ * PR #82 introduced `discover-review-skills.js` as a review-only helper. The
6
+ * follow-on W-mq1cczi90006b21f generalized it into
7
+ * `engine/discover-project-skills.js`, which discovers every project skill
8
+ * and classifies them into an intent vocabulary.
8
9
  *
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
10
+ * This file re-exports the two public PR-82 names so existing imports keep
11
+ * resolving without forcing every consumer to migrate in lockstep. The shim
12
+ * filters to the `review` intent and renders the original "## Project review
13
+ * skills" header copy. New code should import directly from
14
+ * engine/discover-project-skills.js and call `filterByIntents` itself.
23
15
  */
24
16
 
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
- }
17
+ const {
18
+ discoverReviewSkills,
19
+ renderReviewSkillsBlock,
20
+ } = require('./discover-project-skills');
267
21
 
268
22
  module.exports = {
269
23
  discoverReviewSkills,
270
24
  renderReviewSkillsBlock,
271
- // exported for tests
272
- _internal: {
273
- KEYWORD_RE,
274
- SLASH_COMMAND_RE,
275
- DEFAULTS,
276
- _parseFrontmatter,
277
- _extractSlashCommandsFromDoc,
278
- },
279
25
  };
@@ -400,6 +400,7 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
400
400
  FAILURE_CLASS.WORKTREE_PREFLIGHT, // pre-spawn worktree validation — recompute will produce the same failure
401
401
  FAILURE_CLASS.WORKTREE_DIRTY, // #2996: reused worktree was dirty and could not be auto-healed — non-retryable for this dispatch attempt; the engine quarantined the worktree so the next discovery cycle creates a fresh one
402
402
  FAILURE_CLASS.WORKTREE_DIVERGENT, // #2996: reused worktree's local branch had unpushed commits — engine quarantined + backed up the local ref; non-retryable for this dispatch (next discovery creates fresh worktree on origin/<branch>)
403
+ FAILURE_CLASS.WORKTREE_QUARANTINE_ENV_BLOCKED, // W-mq5n1zx5: quarantine rename couldn't release the worktree dir even after retry + force-remove fallback. Non-retryable at the dispatch level so the per-agent retry counter isn't bumped (environmental, not the agent's fault); the WI auto-recovery loop in engine.js#discoverFromWorkItems re-queues without touching _retriesByAgent.
403
404
  FAILURE_CLASS.INVALID_KEEP_PROCESSES_WORKDIR, // W-mp6k7ywi000fa33c — keep-pids cwd is not a real git worktree; re-running won't fix the structural issue
404
405
  FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA, // W-mp7i902u000l991f — keep-pids.json failed shape validation; re-running with the same wrong file won't fix it
405
406
  FAILURE_CLASS.INVALID_MANAGED_SPAWN, // W-mpbhxg3b000u8411 — managed-spawn.json failed validation; re-running with the same wrong file won't fix it
@@ -770,6 +771,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
770
771
  [FAILURE_CLASS.WORKTREE_PREFLIGHT]: 'worktree preflight rejected (nested in project root or rootDir collapsed to drive root)',
771
772
  [FAILURE_CLASS.WORKTREE_DIRTY]: 'reused worktree had uncommitted edits and could not be auto-healed (#2996) — engine quarantined the dir so the next dispatch creates a fresh worktree',
772
773
  [FAILURE_CLASS.WORKTREE_DIVERGENT]: 'reused worktree had unpushed local commits ahead of origin (#2996) — engine backed up the local ref and quarantined the dir so the next dispatch starts from origin/<branch>',
774
+ [FAILURE_CLASS.WORKTREE_QUARANTINE_ENV_BLOCKED]: 'quarantine rename was blocked by the OS (Windows EBUSY/EPERM) even after retry + force-remove fallback — environmental, not the agent\'s fault; auto-recovery loop will re-queue without bumping per-agent retries',
773
775
  [FAILURE_CLASS.INVALID_KEEP_PROCESSES_WORKDIR]: 'keep_processes cwd is not a real git worktree (rerun in a `git worktree add` directory)',
774
776
  [FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA]: 'keep-pids.json failed shape validation (wrong keys/types/values — see inbox alert for the canonical shape)',
775
777
  [FAILURE_CLASS.INVALID_MANAGED_SPAWN]: 'managed-spawn.json failed validation (bad schema, workdir, or allowlist — see inbox alert)',
package/engine/github.js CHANGED
@@ -748,6 +748,20 @@ async function pollPrStatus(config) {
748
748
  updated = true;
749
749
  }
750
750
 
751
+ // #3079 — Track GitHub target branch name + clear stale merge-conflict
752
+ // dispatch state when the PR is retargeted (e.g. `gh pr edit --base
753
+ // master` after parent PR merges). Mirrors ado.js applyAdoPrMetadata.
754
+ // First-poll seeding (no prior baseRefName) is a no-op so we don't
755
+ // strip state immediately after upsert.
756
+ const nextBaseRefName = String(prData.base?.ref || '').trim();
757
+ if (nextBaseRefName && pr.baseRefName !== nextBaseRefName) {
758
+ if (shared.resetMergeConflictStateOnRetarget(pr, nextBaseRefName)) {
759
+ log('info', `GitHub: PR ${pr.id} retargeted ${pr.baseRefName} → ${nextBaseRefName}, clearing stale MERGE_CONFLICT dispatch records`);
760
+ }
761
+ pr.baseRefName = nextBaseRefName;
762
+ updated = true;
763
+ }
764
+
751
765
  // P-w1a3f9b2 — Phase 1.1: plumb mergeable / isDraft / mergeStateStatus /
752
766
  // headRefOid onto the PR object so watches captureState (engine/watches.js)
753
767
  // and future predicates (Phase 2.1: head-commit-change, mergeable-flipped,
@@ -2291,6 +2291,15 @@ function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChang
2291
2291
  return out;
2292
2292
  })()
2293
2293
  : {}),
2294
+ // #3079 — MERGE_CONFLICT noops record the composite guard key (source
2295
+ // head + base SHA + target ref name). The same-head guard at
2296
+ // engine.js:5676 reads this back and compares against the current
2297
+ // PR's prMergeConflictGuardKey, so a retarget (target ref change) or
2298
+ // a parent-merge (base SHA change) naturally releases the pause even
2299
+ // when the source head didn't move.
2300
+ ...(cause === shared.PR_FIX_CAUSE.MERGE_CONFLICT
2301
+ ? { mergeConflictKey: shared.prMergeConflictGuardKey(target) }
2302
+ : {}),
2294
2303
  };
2295
2304
  target.lastDispatchedAt = now;
2296
2305
  target.lastDispatchOutcome = 'noop';
@@ -2664,6 +2673,14 @@ function updatePrAfterFixError(pr, project, source, options = {}) {
2664
2673
  || '');
2665
2674
  if (commentKey) next.lastProcessedCommentKey = commentKey;
2666
2675
  }
2676
+ // #3079 — Refresh mergeConflictKey from live target state so a
2677
+ // mid-flight retarget reflects in the agent-error record too. The
2678
+ // engine.js skipConflictFix guard prefers mergeConflictKey over
2679
+ // legacy headSha; without this refresh, the prior record's stale
2680
+ // key could re-fire the suppression even after a retarget.
2681
+ if (cause === shared.PR_FIX_CAUSE.MERGE_CONFLICT) {
2682
+ next.mergeConflictKey = shared.prMergeConflictGuardKey(target);
2683
+ }
2667
2684
  target._lastDispatchByCause[cause] = next;
2668
2685
  result = { cause, indeterminate: true, errorClass };
2669
2686
  log('warn', `Updated ${pr.id} → recorded ${cause} agent-error fix attempt (indeterminate=true) — same-head guard relaxed for next tick${errorMessage ? ` (${errorMessage.slice(0, 80)})` : ''}`);
@@ -0,0 +1,76 @@
1
+ /**
2
+ * engine/playbook-intents.js — W-mq1cczi90006b21f
3
+ *
4
+ * Maps Minions playbook names (work-item types) to the intent buckets each
5
+ * playbook should surface from project-local skill discovery. The mapping is
6
+ * the single source of truth for "which playbook gets which slice of the
7
+ * skills block" — engine/playbook.js consults this at render time.
8
+ *
9
+ * Intent vocabulary (defined in engine/discover-project-skills.js):
10
+ * review | build | fix | test | plan | research | deploy | observability | meta
11
+ *
12
+ * Adding a new playbook? Add an entry here. Playbooks not in this map fall
13
+ * through to NO skills block (the engine treats undefined as the empty set,
14
+ * which is the safe default — agents see no header rather than a noisy one).
15
+ *
16
+ * Adding a new intent? Add the keyword regex to INTENT_KEYWORDS in
17
+ * engine/discover-project-skills.js AND a row to this map (or update an
18
+ * existing playbook to include it). Then document the verb in
19
+ * docs/project-skills.md.
20
+ */
21
+
22
+ // Canonical mapping. Lower-cased playbook names; intent values must be
23
+ // members of INTENT_VOCABULARY in engine/discover-project-skills.js.
24
+ const PLAYBOOK_INTENTS = Object.freeze({
25
+ // Review-flavored playbooks — preserves PR-82 behavior verbatim.
26
+ review: ['review'],
27
+
28
+ // Fix dispatches often need to re-run review tooling after the fix lands,
29
+ // and may also touch tests; surface review+test alongside debug/triage.
30
+ fix: ['fix', 'test', 'review'],
31
+
32
+ // Implementation surfaces build/scaffold/codemod skills plus the matching
33
+ // test skills, and research for codebase investigation.
34
+ implement: ['build', 'test', 'research'],
35
+ 'implement-shared': ['build', 'test', 'research'],
36
+
37
+ // Planning surfaces planning + research (codebase exploration is part of
38
+ // good planning).
39
+ plan: ['plan', 'research'],
40
+ 'plan-to-prd': ['plan', 'research'],
41
+ decompose: ['plan', 'research'],
42
+
43
+ // Doc / verify / build-and-test playbooks — keep tight intent sets so
44
+ // we don't dump build-bumping skills into a docs prompt.
45
+ docs: ['research'],
46
+ verify: ['test', 'review'],
47
+ 'build-and-test': ['build', 'test'],
48
+ test: ['test'],
49
+
50
+ // Exploration / asks: research-heavy.
51
+ explore: ['research'],
52
+
53
+ // qa-validate: targeted at test/qa skills.
54
+ 'qa-validate': ['test'],
55
+
56
+ // Followup-dispatch is a template (not a top-level playbook); the engine
57
+ // inherits the parent dispatch's intent set when rendering follow-ups.
58
+ // See engine/playbook.js → renderPlaybook for the inheritance plumb.
59
+ });
60
+
61
+ /**
62
+ * Look up the intent set for a playbook name. Returns [] for unknown
63
+ * playbooks (caller treats empty as "render no block").
64
+ *
65
+ * @param {string} playbookName
66
+ * @returns {string[]}
67
+ */
68
+ function intentsForPlaybook(playbookName) {
69
+ if (!playbookName || typeof playbookName !== 'string') return [];
70
+ return PLAYBOOK_INTENTS[playbookName.toLowerCase()] || [];
71
+ }
72
+
73
+ module.exports = {
74
+ PLAYBOOK_INTENTS,
75
+ intentsForPlaybook,
76
+ };
@@ -339,6 +339,17 @@ const PLAYBOOK_OPTIONAL_VARS = new Set([
339
339
  'role', // 'primary' | 'co-service'
340
340
  'primary_project', // canonical primary project name; empty on single-project sessions
341
341
  'co_services_json', // JSON-encoded co-service project names (e.g. ["api","worker"]); empty when none
342
+ // W-mq16xtdx001a347e (PR-82) — project-local review-skill discovery vars.
343
+ // Always rendered (empty string when no discoveries); listed here so the
344
+ // unresolved-var check doesn't complain when discovery returns nothing or
345
+ // projects legitimately resolve it to ''.
346
+ 'project_review_skills_block',
347
+ 'skip_project_review_skills',
348
+ // W-mq1cczi90006b21f — generic, intent-filtered project skills block.
349
+ // Spliced by review/fix/implement/plan/etc. when discovery + intent map
350
+ // produce matches. Optional for the same reason as the review-only alias.
351
+ 'project_skills_block',
352
+ 'skip_project_skills',
342
353
  ]);
343
354
 
344
355
  const PLAYBOOK_REQUIRED_VARS = {
@@ -695,30 +706,57 @@ function renderPlaybook(type, vars) {
695
706
  } catch (e) { log('warn', `managed-spawn live-processes inject failed: ${e.message}`); }
696
707
  }
697
708
 
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) {
709
+ // W-mq16xtdx001a347e (PR #82) + W-mq1cczi90006b21f (generalization) —
710
+ // surface project-local skills relevant to the playbook being dispatched.
711
+ //
712
+ // PR-82 hard-scoped this to type === 'review'. This wiring generalizes it:
713
+ // 1. Discovery walks every project skill / command / documented slash-
714
+ // command (engine/discover-project-skills.js).
715
+ // 2. engine/playbook-intents.js maps playbook intent set.
716
+ // 3. Discovered entries are filtered to the playbook's intent set and
717
+ // rendered into `project_skills_block`. Empty intent set or zero
718
+ // matches → empty string (no stray header).
719
+ //
720
+ // Header copy: review dispatches keep PR-82's "## Project review skills"
721
+ // header (existing agents/tests grep for it). All other playbooks use the
722
+ // generalized "## Project skills" header.
723
+ //
724
+ // Backward-compat: `project_review_skills_block` and
725
+ // `vars.skip_project_review_skills` remain wired so external consumers of
726
+ // the PR-82 names keep working. They alias to the generic var/flag.
727
+ const skipProjectSkills = !!(vars.skip_project_skills || vars.skip_project_review_skills);
728
+ let projectSkillsBlock = '';
729
+ if (!skipProjectSkills) {
706
730
  try {
707
- const discover = require('./discover-review-skills');
731
+ const discover = require('./discover-project-skills');
732
+ const { intentsForPlaybook } = require('./playbook-intents');
708
733
  const projectPath = matchedProject?.localPath || '';
709
734
  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 = '';
735
+ // Inherit intent set from parent dispatch type when the engine
736
+ // passes vars.parent_dispatch_type (followup-dispatch flow).
737
+ const effectiveType = vars.parent_dispatch_type || type;
738
+ const intents = intentsForPlaybook(effectiveType);
739
+ if (intents.length > 0) {
740
+ const entries = discover.discoverProjectSkills({ projectPath });
741
+ const filtered = discover.filterByIntents(entries, intents);
742
+ // type === 'review' keeps PR-82 header copy + meta.review.* outcome
743
+ // guidance; everything else uses the generic header that points at
744
+ // meta.skill.* in the completion report.
745
+ projectSkillsBlock = (type === 'review')
746
+ ? discover.renderReviewSkillsBlock(filtered)
747
+ : discover.renderProjectSkillsBlock(filtered);
748
+ }
714
749
  }
715
750
  } catch (e) {
716
- log('warn', `discover-review-skills failed: ${e.message}`);
717
- vars.project_review_skills_block = '';
751
+ log('warn', `discover-project-skills failed: ${e.message}`);
718
752
  }
719
- } else {
720
- vars.project_review_skills_block = '';
721
753
  }
754
+ vars.project_skills_block = projectSkillsBlock;
755
+ // Backward-compat alias for any consumer that still references
756
+ // {{project_review_skills_block}} directly. Same content; cheaper than
757
+ // re-rendering. Non-review playbooks that don't reference the alias
758
+ // ignore it (it's in PLAYBOOK_OPTIONAL_VARS).
759
+ vars.project_review_skills_block = projectSkillsBlock;
722
760
 
723
761
  // W-mpeiwz6k0005bf34-c — opt-in qa-validate context block. Injected only
724
762
  // when the dispatcher set vars.qa_run_id (truthy) from the work item's
@@ -1129,10 +1167,11 @@ function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabe
1129
1167
  const vars = {
1130
1168
  ...buildBaseVars(agentId, config, project),
1131
1169
  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.
1170
+ // W-mq16xtdx001a347e + W-mq1cczi90006b21f — honor both meta flag names
1171
+ // (skipProjectReviewSkills is the PR-82 alias). Default OFF: review/fix
1172
+ // dispatches surface the project-local skills block.
1135
1173
  skip_project_review_skills: !!(meta && meta.skipProjectReviewSkills),
1174
+ skip_project_skills: !!(meta && (meta.skipProjectSkills || meta.skipProjectReviewSkills)),
1136
1175
  ...extraVars,
1137
1176
  task_id: dispatchId,
1138
1177
  };