@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.
- package/bin/minions.js +8 -0
- package/dashboard/js/settings.js +9 -0
- package/dashboard/slim/body.html +2 -1
- package/dashboard/slim/js/command-send.js +3 -2
- package/dashboard/slim/js/history.js +9 -2
- package/dashboard/slim/styles.css +88 -10
- package/dashboard.js +5 -0
- package/docs/README.md +1 -0
- package/docs/completion-reports.md +44 -12
- package/docs/project-skills.md +133 -0
- package/engine/ado.js +19 -0
- package/engine/discover-project-skills.js +490 -0
- package/engine/discover-review-skills.js +15 -269
- package/engine/dispatch.js +2 -0
- package/engine/github.js +14 -0
- package/engine/lifecycle.js +17 -0
- package/engine/playbook-intents.js +76 -0
- package/engine/playbook.js +59 -20
- package/engine/shared.js +288 -0
- package/engine/worktree-gc.js +241 -21
- package/engine.js +294 -32
- package/package.json +1 -1
- package/playbooks/fix.md +4 -0
- package/playbooks/implement-shared.md +4 -0
- package/playbooks/implement.md +4 -0
- package/playbooks/plan-to-prd.md +4 -0
- package/playbooks/plan.md +4 -0
- package/playbooks/review.md +3 -3
- package/bin/minions.js.rej +0 -16
- package/dashboard/slim/body.html.rej +0 -11
- package/dashboard/slim/js/command-send.js.rej +0 -12
- package/dashboard/slim/js/history.js.rej +0 -26
- package/dashboard/slim/styles.css.rej +0 -124
- package/docs/README.md.rej +0 -9
- package/docs/onboarding.md.rej +0 -10
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/discover-project-skills.js — W-mq1cczi90006b21f
|
|
3
|
+
*
|
|
4
|
+
* Cheap, bounded filesystem walk that surfaces project-local agent tooling
|
|
5
|
+
* (skills, slash-commands, copilot-instructions slash-command mentions) so
|
|
6
|
+
* Minions dispatches across the FULL lifecycle (review / implement / fix /
|
|
7
|
+
* plan / etc.) can reliably steer agents toward the right purpose-built
|
|
8
|
+
* tooling instead of reinventing flows from first principles.
|
|
9
|
+
*
|
|
10
|
+
* This module is the generalized successor to engine/discover-review-skills.js
|
|
11
|
+
* (PR #82, W-mq16xtdx001a347e). PR 82 hard-scoped discovery to review-flavored
|
|
12
|
+
* skills via a single KEYWORD_RE; this module discovers EVERY project skill /
|
|
13
|
+
* command / documented slash-command, classifies each one into a small closed
|
|
14
|
+
* intent vocabulary, and lets callers filter to the intents they care about.
|
|
15
|
+
*
|
|
16
|
+
* Walk surfaces (root-level):
|
|
17
|
+
* - <projectRoot>/.claude/skills/*\/SKILL.md
|
|
18
|
+
* - <projectRoot>/.claude/commands/*.md
|
|
19
|
+
* - <projectRoot>/.github/copilot-instructions.md (slash-command mentions)
|
|
20
|
+
* - <projectRoot>/CLAUDE.md (slash-command mentions)
|
|
21
|
+
*
|
|
22
|
+
* Plus per-area (one level deep — NOT recursive):
|
|
23
|
+
* - <projectRoot>/<area>/.claude/skills/*\/SKILL.md
|
|
24
|
+
* - <projectRoot>/<area>/.claude/commands/*.md
|
|
25
|
+
*
|
|
26
|
+
* Returns a deterministically-ordered, deduplicated array of:
|
|
27
|
+
* { kind: 'skill'|'command'|'slash-command',
|
|
28
|
+
* name, path, oneLineDescription,
|
|
29
|
+
* intents: string[] }
|
|
30
|
+
*
|
|
31
|
+
* Intent vocabulary (CLOSED — do not let this sprawl):
|
|
32
|
+
* review | build | fix | test | plan | research | deploy | observability | meta
|
|
33
|
+
*
|
|
34
|
+
* Multi-intent classification is allowed (e.g. `code-reviewer` may be both
|
|
35
|
+
* `review` AND `test`). When a skill matches zero intent keywords, `intents`
|
|
36
|
+
* is `[]` — the skill is still discovered, just not surfaced by any
|
|
37
|
+
* intent-filtered playbook. NEVER default to `meta`; empty intents is the
|
|
38
|
+
* honest signal that the heuristic didn't fire.
|
|
39
|
+
*
|
|
40
|
+
* See docs/project-skills.md for the intent vocabulary spec.
|
|
41
|
+
*
|
|
42
|
+
* Contract:
|
|
43
|
+
* - Worktree missing / unreadable → returns [] (never throws)
|
|
44
|
+
* - Single bounded readdir per surface (no recursive grep)
|
|
45
|
+
* - File-count + walltime budget caps the walk in pathological repos
|
|
46
|
+
* - Pure: no engine state mutations, no logging beyond debug
|
|
47
|
+
* - Deterministic ordering: alphabetical by name (stable across runs / OSes)
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
const fs = require('fs');
|
|
51
|
+
const path = require('path');
|
|
52
|
+
|
|
53
|
+
// Slash-command extractor — pulls `/foo`, `/bump-android-deps`, etc. out of
|
|
54
|
+
// markdown prose. Anchored so `/path/to/file` doesn't match (must look like
|
|
55
|
+
// a slash-command token: leading `/` immediately followed by an id with no
|
|
56
|
+
// trailing `/`).
|
|
57
|
+
const SLASH_COMMAND_RE = /(?<![A-Za-z0-9/_-])\/([a-z][a-z0-9-]*)(?![A-Za-z0-9/])/g;
|
|
58
|
+
|
|
59
|
+
const DEFAULTS = {
|
|
60
|
+
maxFilesPerSurface: 50, // hard cap on skill packs / command files we'll read
|
|
61
|
+
maxBytesPerFile: 8 * 1024, // only need frontmatter + first heading
|
|
62
|
+
docsScanMaxBytes: 32 * 1024, // CLAUDE.md / copilot-instructions.md top window
|
|
63
|
+
walltimeMs: 250, // bail rather than block dispatch on pathological FS
|
|
64
|
+
maxAreas: 50, // cap how many <area>/ dirs we'll probe for nested .claude
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Intent vocabulary — CLOSED. Each entry maps an intent to the keyword/regex
|
|
68
|
+
// list that must be searched in skill name + description + filename + first
|
|
69
|
+
// heading. Multi-intent is allowed: a skill matching multiple keyword sets
|
|
70
|
+
// will carry multiple intents.
|
|
71
|
+
//
|
|
72
|
+
// IMPORTANT: keep keyword lists tight. False-positives here become noise in
|
|
73
|
+
// every playbook's prompt. Use \b word boundaries to avoid matching inside
|
|
74
|
+
// unrelated tokens (e.g. `previewer` should NOT match `review`).
|
|
75
|
+
const INTENT_KEYWORDS = {
|
|
76
|
+
review: /\b(review|review-swarm|swarm|code-review|pr-review|constellation-review|code-reviewer|review-pr|cover-pr)\b/i,
|
|
77
|
+
build: /\b(build|scaffold|generate|bump|migrate|codemod|refactor|workspace|setup-steps)\b/i,
|
|
78
|
+
fix: /\b(fix|debug|triage|repro|regress|flake|deflake)\b/i,
|
|
79
|
+
test: /\b(test|tests|coverage|qa|validate|validation|integration-tests|unit-tests)\b/i,
|
|
80
|
+
plan: /\b(plan|prd|adr|design-doc|design|decompose|roadmap)\b/i,
|
|
81
|
+
research: /\b(research|investigate|gather-impact|explore|audit|analyze|reflect-and-grow)\b/i,
|
|
82
|
+
deploy: /\b(deploy|release|publish|buddy-build|rollout|ship|track-pr)\b/i,
|
|
83
|
+
observability: /\b(telemetry|kusto|logs|monitoring|metrics|observability|dashboard|weekly-repo-summary)\b/i,
|
|
84
|
+
// meta is intentionally absent from the heuristic — only authors who
|
|
85
|
+
// explicitly tag a skill as `meta` via frontmatter get the meta intent.
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const INTENT_VOCABULARY = Object.freeze([
|
|
89
|
+
'review', 'build', 'fix', 'test', 'plan', 'research', 'deploy', 'observability', 'meta',
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
function _now() { return Date.now(); }
|
|
93
|
+
|
|
94
|
+
function _safeReadHead(filePath, maxBytes) {
|
|
95
|
+
let fd;
|
|
96
|
+
try {
|
|
97
|
+
fd = fs.openSync(filePath, 'r');
|
|
98
|
+
const buf = Buffer.alloc(maxBytes);
|
|
99
|
+
const n = fs.readSync(fd, buf, 0, maxBytes, 0);
|
|
100
|
+
return buf.slice(0, n).toString('utf8');
|
|
101
|
+
} catch { return ''; }
|
|
102
|
+
finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* ignore */ } } }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function _parseFrontmatter(content) {
|
|
106
|
+
const m = String(content || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
107
|
+
if (!m) return {};
|
|
108
|
+
const out = {};
|
|
109
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
110
|
+
const lm = line.match(/^([\w-]+):\s*(.*)$/);
|
|
111
|
+
if (!lm) continue;
|
|
112
|
+
out[lm[1].toLowerCase()] = lm[2].trim().replace(/^["']|["']$/g, '');
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function _firstHeading(content) {
|
|
118
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
const m = line.match(/^#+\s+(.+?)\s*$/);
|
|
121
|
+
if (m) return m[1].trim();
|
|
122
|
+
}
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function _firstNonEmptyLine(content) {
|
|
127
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
const t = line.trim();
|
|
130
|
+
if (t && !t.startsWith('---') && !t.startsWith('#')) return t;
|
|
131
|
+
}
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function _truncate(s, max) {
|
|
136
|
+
const text = String(s || '').trim();
|
|
137
|
+
if (text.length <= max) return text;
|
|
138
|
+
return text.slice(0, max - 1).trim() + '…';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Parse an "intents:" or "tags:" frontmatter line into a normalized array of
|
|
142
|
+
// intent strings restricted to INTENT_VOCABULARY. Accepts either YAML inline
|
|
143
|
+
// list ("[a, b]"), bracket-less csv ("a, b, c"), or single value ("a").
|
|
144
|
+
function _parseExplicitIntents(fm) {
|
|
145
|
+
const raw = (fm && (fm.intents || fm.tags || fm.intent || fm.tag)) || '';
|
|
146
|
+
if (!raw) return [];
|
|
147
|
+
const stripped = String(raw).replace(/^\[|\]$/g, '').trim();
|
|
148
|
+
if (!stripped) return [];
|
|
149
|
+
const parts = stripped.split(/[,\s]+/).map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const p of parts) {
|
|
152
|
+
if (INTENT_VOCABULARY.includes(p) && !out.includes(p)) out.push(p);
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Classify a (name, description, extraText) tuple into the closed intent
|
|
158
|
+
// vocabulary. Always returns a (possibly empty) array. NEVER returns ['meta']
|
|
159
|
+
// by default — empty array is the honest signal that the heuristic didn't fire.
|
|
160
|
+
function classifyIntents({ name = '', description = '', extra = '', explicit = [] } = {}) {
|
|
161
|
+
const hay = `${name} ${description} ${extra}`;
|
|
162
|
+
const intents = [];
|
|
163
|
+
// Honor explicit frontmatter first (preserves author intent).
|
|
164
|
+
for (const i of explicit) if (!intents.includes(i)) intents.push(i);
|
|
165
|
+
for (const intent of Object.keys(INTENT_KEYWORDS)) {
|
|
166
|
+
if (intents.includes(intent)) continue;
|
|
167
|
+
if (INTENT_KEYWORDS[intent].test(hay)) intents.push(intent);
|
|
168
|
+
}
|
|
169
|
+
return intents;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Walk <baseDir>/.claude/skills/*\/SKILL.md
|
|
173
|
+
function _discoverSkillsAt(baseDir, projectPath, opts, deadline, originLabel) {
|
|
174
|
+
const out = [];
|
|
175
|
+
const skillsDir = path.join(baseDir, '.claude', 'skills');
|
|
176
|
+
let entries;
|
|
177
|
+
try { entries = fs.readdirSync(skillsDir, { withFileTypes: true }); } catch { return out; }
|
|
178
|
+
let scanned = 0;
|
|
179
|
+
for (const ent of entries) {
|
|
180
|
+
if (scanned >= opts.maxFilesPerSurface) break;
|
|
181
|
+
if (_now() > deadline) break;
|
|
182
|
+
if (!ent.isDirectory()) continue;
|
|
183
|
+
const skillPath = path.join(skillsDir, ent.name, 'SKILL.md');
|
|
184
|
+
let stat;
|
|
185
|
+
try { stat = fs.statSync(skillPath); } catch { continue; }
|
|
186
|
+
if (!stat.isFile()) continue;
|
|
187
|
+
scanned += 1;
|
|
188
|
+
const head = _safeReadHead(skillPath, opts.maxBytesPerFile);
|
|
189
|
+
if (!head) continue;
|
|
190
|
+
const fm = _parseFrontmatter(head);
|
|
191
|
+
const name = fm.name || ent.name;
|
|
192
|
+
const desc = fm.description || '';
|
|
193
|
+
const explicit = _parseExplicitIntents(fm);
|
|
194
|
+
const heading = _firstHeading(head);
|
|
195
|
+
const intents = classifyIntents({ name, description: desc, extra: heading, explicit });
|
|
196
|
+
out.push({
|
|
197
|
+
kind: 'skill',
|
|
198
|
+
name: String(name),
|
|
199
|
+
path: path.relative(projectPath, skillPath).split(path.sep).join('/'),
|
|
200
|
+
oneLineDescription: _truncate(desc || heading || name, 200),
|
|
201
|
+
intents,
|
|
202
|
+
_originLabel: originLabel,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Walk <baseDir>/.claude/commands/*.md
|
|
209
|
+
function _discoverCommandsAt(baseDir, projectPath, opts, deadline, originLabel) {
|
|
210
|
+
const out = [];
|
|
211
|
+
const cmdDir = path.join(baseDir, '.claude', 'commands');
|
|
212
|
+
let entries;
|
|
213
|
+
try { entries = fs.readdirSync(cmdDir, { withFileTypes: true }); } catch { return out; }
|
|
214
|
+
let scanned = 0;
|
|
215
|
+
for (const ent of entries) {
|
|
216
|
+
if (scanned >= opts.maxFilesPerSurface) break;
|
|
217
|
+
if (_now() > deadline) break;
|
|
218
|
+
if (!ent.isFile()) continue;
|
|
219
|
+
if (!/\.md$/i.test(ent.name)) continue;
|
|
220
|
+
scanned += 1;
|
|
221
|
+
const base = ent.name.replace(/\.md$/i, '');
|
|
222
|
+
const cmdPath = path.join(cmdDir, ent.name);
|
|
223
|
+
const head = _safeReadHead(cmdPath, opts.maxBytesPerFile);
|
|
224
|
+
const heading = _firstHeading(head);
|
|
225
|
+
const intents = classifyIntents({ name: base, description: heading, extra: _firstNonEmptyLine(head) });
|
|
226
|
+
out.push({
|
|
227
|
+
kind: 'command',
|
|
228
|
+
name: `/${base}`,
|
|
229
|
+
path: path.relative(projectPath, cmdPath).split(path.sep).join('/'),
|
|
230
|
+
oneLineDescription: _truncate(heading || _firstNonEmptyLine(head) || base, 200),
|
|
231
|
+
intents,
|
|
232
|
+
_originLabel: originLabel,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function _extractSlashCommandsFromDoc(content) {
|
|
239
|
+
const found = new Map();
|
|
240
|
+
const text = String(content || '');
|
|
241
|
+
let m;
|
|
242
|
+
SLASH_COMMAND_RE.lastIndex = 0;
|
|
243
|
+
while ((m = SLASH_COMMAND_RE.exec(text)) !== null) {
|
|
244
|
+
const id = m[1];
|
|
245
|
+
if (!found.has(id)) found.set(id, m.index);
|
|
246
|
+
}
|
|
247
|
+
return [...found.keys()];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function _discoverDocSlashCommands(projectPath, opts, deadline, alreadySeen) {
|
|
251
|
+
const out = [];
|
|
252
|
+
const docPaths = [
|
|
253
|
+
path.join(projectPath, '.github', 'copilot-instructions.md'),
|
|
254
|
+
path.join(projectPath, 'CLAUDE.md'),
|
|
255
|
+
];
|
|
256
|
+
for (const docPath of docPaths) {
|
|
257
|
+
if (_now() > deadline) break;
|
|
258
|
+
let stat;
|
|
259
|
+
try { stat = fs.statSync(docPath); } catch { continue; }
|
|
260
|
+
if (!stat.isFile()) continue;
|
|
261
|
+
const head = _safeReadHead(docPath, opts.docsScanMaxBytes);
|
|
262
|
+
if (!head) continue;
|
|
263
|
+
const ids = _extractSlashCommandsFromDoc(head);
|
|
264
|
+
const relDoc = path.relative(projectPath, docPath).split(path.sep).join('/');
|
|
265
|
+
for (const id of ids) {
|
|
266
|
+
const fullName = `/${id}`;
|
|
267
|
+
if (alreadySeen.has(fullName)) continue;
|
|
268
|
+
// For doc-scrape slash-commands, only surface entries that classify to
|
|
269
|
+
// at least one intent — keeps the discovery output bounded even when
|
|
270
|
+
// CLAUDE.md / copilot-instructions.md mention many slash-commands.
|
|
271
|
+
const intents = classifyIntents({ name: id });
|
|
272
|
+
if (intents.length === 0) continue;
|
|
273
|
+
alreadySeen.add(fullName);
|
|
274
|
+
out.push({
|
|
275
|
+
kind: 'slash-command',
|
|
276
|
+
name: fullName,
|
|
277
|
+
path: relDoc,
|
|
278
|
+
oneLineDescription: `Documented entrypoint in ${relDoc}`,
|
|
279
|
+
intents,
|
|
280
|
+
_originLabel: 'docs',
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// First-level subdirs that look like project "areas" (e.g. loop/, officemobile/,
|
|
288
|
+
// fluid-client-framework/). Returns dirent names only — caller probes for
|
|
289
|
+
// .claude/ underneath. Bounded by opts.maxAreas.
|
|
290
|
+
function _listAreas(projectPath, opts, deadline) {
|
|
291
|
+
const out = [];
|
|
292
|
+
let entries;
|
|
293
|
+
try { entries = fs.readdirSync(projectPath, { withFileTypes: true }); } catch { return out; }
|
|
294
|
+
// Sort for deterministic ordering across OSes.
|
|
295
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
296
|
+
for (const ent of entries) {
|
|
297
|
+
if (out.length >= opts.maxAreas) break;
|
|
298
|
+
if (_now() > deadline) break;
|
|
299
|
+
if (!ent.isDirectory()) continue;
|
|
300
|
+
// Skip dot-dirs (.git, .claude itself, .github, node_modules-like).
|
|
301
|
+
if (ent.name.startsWith('.')) continue;
|
|
302
|
+
if (ent.name === 'node_modules') continue;
|
|
303
|
+
out.push(ent.name);
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* @param {object} args
|
|
310
|
+
* @param {string} args.projectPath — absolute path to the project worktree / checkout
|
|
311
|
+
* @param {object} [args.opts] — override defaults (mostly for tests)
|
|
312
|
+
* @returns {Array<{kind:string,name:string,path:string,oneLineDescription:string,intents:string[]}>}
|
|
313
|
+
*/
|
|
314
|
+
function discoverProjectSkills(args) {
|
|
315
|
+
const projectPath = args && args.projectPath;
|
|
316
|
+
if (!projectPath || typeof projectPath !== 'string') return [];
|
|
317
|
+
try { if (!fs.statSync(projectPath).isDirectory()) return []; } catch { return []; }
|
|
318
|
+
|
|
319
|
+
const opts = Object.assign({}, DEFAULTS, args.opts || {});
|
|
320
|
+
const deadline = _now() + Math.max(1, opts.walltimeMs);
|
|
321
|
+
|
|
322
|
+
const seenKey = new Set(); // dedupe across surfaces by `${kind}:${name}`
|
|
323
|
+
const seenSlash = new Set();
|
|
324
|
+
const all = [];
|
|
325
|
+
|
|
326
|
+
// 1. Root-level skills.
|
|
327
|
+
for (const entry of _discoverSkillsAt(projectPath, projectPath, opts, deadline, 'root')) {
|
|
328
|
+
const key = `skill:${entry.name}`;
|
|
329
|
+
if (seenKey.has(key)) continue;
|
|
330
|
+
seenKey.add(key);
|
|
331
|
+
all.push(entry);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 2. Root-level commands.
|
|
335
|
+
for (const entry of _discoverCommandsAt(projectPath, projectPath, opts, deadline, 'root')) {
|
|
336
|
+
const key = `command:${entry.name}`;
|
|
337
|
+
if (seenKey.has(key)) continue;
|
|
338
|
+
seenKey.add(key);
|
|
339
|
+
seenSlash.add(entry.name); // /foo from .claude/commands subsumes doc-mention
|
|
340
|
+
all.push(entry);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 3. Per-area (one level deep) skills + commands. This is how
|
|
344
|
+
// OCM/loop/officemobile organize their skill packs today and is the
|
|
345
|
+
// primary gap PR 82's root-only walk had.
|
|
346
|
+
if (_now() <= deadline) {
|
|
347
|
+
const areas = _listAreas(projectPath, opts, deadline);
|
|
348
|
+
for (const area of areas) {
|
|
349
|
+
if (_now() > deadline) break;
|
|
350
|
+
const areaBase = path.join(projectPath, area);
|
|
351
|
+
for (const entry of _discoverSkillsAt(areaBase, projectPath, opts, deadline, `area:${area}`)) {
|
|
352
|
+
const key = `skill:${entry.name}`;
|
|
353
|
+
if (seenKey.has(key)) continue;
|
|
354
|
+
seenKey.add(key);
|
|
355
|
+
all.push(entry);
|
|
356
|
+
}
|
|
357
|
+
if (_now() > deadline) break;
|
|
358
|
+
for (const entry of _discoverCommandsAt(areaBase, projectPath, opts, deadline, `area:${area}`)) {
|
|
359
|
+
const key = `command:${entry.name}`;
|
|
360
|
+
if (seenKey.has(key)) continue;
|
|
361
|
+
seenKey.add(key);
|
|
362
|
+
seenSlash.add(entry.name);
|
|
363
|
+
all.push(entry);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 4. Doc-scrape slash-commands (only surface entries that classify to an
|
|
369
|
+
// intent — see _discoverDocSlashCommands).
|
|
370
|
+
for (const entry of _discoverDocSlashCommands(projectPath, opts, deadline, seenSlash)) {
|
|
371
|
+
const key = `slash-command:${entry.name}`;
|
|
372
|
+
if (seenKey.has(key)) continue;
|
|
373
|
+
seenKey.add(key);
|
|
374
|
+
all.push(entry);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Deterministic ordering: alphabetical by name, then by path as tiebreaker
|
|
378
|
+
// so prompt rendering is stable across runs and OSes (readdir order varies).
|
|
379
|
+
all.sort((a, b) => {
|
|
380
|
+
if (a.name !== b.name) return a.name.localeCompare(b.name);
|
|
381
|
+
return String(a.path || '').localeCompare(String(b.path || ''));
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Strip the internal _originLabel before returning (debugging-only).
|
|
385
|
+
return all.map(({ _originLabel, ...rest }) => rest); // eslint-disable-line no-unused-vars
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Filter a discovery result list to a given intent set. An entry passes if
|
|
390
|
+
* any of its intents is in the requested set. Empty-intent entries do NOT
|
|
391
|
+
* pass an intent-filtered render (the honest signal from classifyIntents).
|
|
392
|
+
*
|
|
393
|
+
* @param {Array} entries — output of discoverProjectSkills()
|
|
394
|
+
* @param {string[]|null} intents — intent vocabulary subset; null/empty/['*']
|
|
395
|
+
* means "all" (no filtering).
|
|
396
|
+
* @returns {Array}
|
|
397
|
+
*/
|
|
398
|
+
function filterByIntents(entries, intents) {
|
|
399
|
+
if (!Array.isArray(entries) || entries.length === 0) return [];
|
|
400
|
+
if (!intents || intents.length === 0 || intents.includes('*')) {
|
|
401
|
+
return entries.slice();
|
|
402
|
+
}
|
|
403
|
+
const want = new Set(intents);
|
|
404
|
+
return entries.filter(e => Array.isArray(e.intents) && e.intents.some(i => want.has(i)));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Format a (pre-filtered) discovery result list as a Markdown block ready to
|
|
409
|
+
* splice into a playbook. Returns empty string when the list is empty so the
|
|
410
|
+
* caller can no-op cleanly (no stray header, no blank padding lines).
|
|
411
|
+
*
|
|
412
|
+
* @param {Array} entries — pre-filtered output of discoverProjectSkills()
|
|
413
|
+
* @returns {string}
|
|
414
|
+
*/
|
|
415
|
+
function renderProjectSkillsBlock(entries) {
|
|
416
|
+
if (!Array.isArray(entries) || entries.length === 0) return '';
|
|
417
|
+
const lines = [];
|
|
418
|
+
lines.push('## Project skills (prefer these when applicable)');
|
|
419
|
+
lines.push('');
|
|
420
|
+
lines.push("This project ships purpose-built tooling for this kind of work. When your task is within scope of one of these skills, INVOKE IT FIRST and use its findings/output as the primary signal — your own work can then build on top of what the skill produced.");
|
|
421
|
+
lines.push('');
|
|
422
|
+
for (const e of entries) {
|
|
423
|
+
const kindHint = e.kind === 'skill' ? `skill: \`${e.name}\``
|
|
424
|
+
: e.kind === 'command' ? `\`${e.name}\``
|
|
425
|
+
: `\`${e.name}\``;
|
|
426
|
+
const pathHint = e.path ? ` (\`${e.path}\`)` : '';
|
|
427
|
+
const desc = e.oneLineDescription ? ` — ${e.oneLineDescription}` : '';
|
|
428
|
+
const intentTag = Array.isArray(e.intents) && e.intents.length > 0
|
|
429
|
+
? ` [intent: ${e.intents.join(', ')}]`
|
|
430
|
+
: '';
|
|
431
|
+
lines.push(`- ${kindHint}${pathHint}${desc}${intentTag}`);
|
|
432
|
+
}
|
|
433
|
+
lines.push('');
|
|
434
|
+
lines.push("Record the skill outcome in your completion report's `meta.skill` block (`invoked` or `skipped` — see `docs/completion-reports.md`) so the engine can later measure skill-vs-first-principles signal.");
|
|
435
|
+
return lines.join('\n');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── Backward-compatibility shims for PR #82 callers (W-mq16xtdx001a347e) ──
|
|
439
|
+
// These keep the discover-review-skills.js import surface working without
|
|
440
|
+
// requiring every consumer to migrate in lockstep.
|
|
441
|
+
|
|
442
|
+
function discoverReviewSkills(args) {
|
|
443
|
+
// Old API: returns review-flavored entries only, without the new `intents`
|
|
444
|
+
// field shape that callers might not understand. We still return entries
|
|
445
|
+
// with `intents` populated (additive change — old callers ignore the field).
|
|
446
|
+
return filterByIntents(discoverProjectSkills(args), ['review']);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function renderReviewSkillsBlock(entries) {
|
|
450
|
+
// Old API renders the original PR-82 header copy verbatim so existing
|
|
451
|
+
// tests/agents that grep on "## Project review skills" keep matching.
|
|
452
|
+
if (!Array.isArray(entries) || entries.length === 0) return '';
|
|
453
|
+
const lines = [];
|
|
454
|
+
lines.push('## Project review skills (prefer these when applicable)');
|
|
455
|
+
lines.push('');
|
|
456
|
+
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.");
|
|
457
|
+
lines.push('');
|
|
458
|
+
for (const e of entries) {
|
|
459
|
+
const kindHint = e.kind === 'skill' ? `skill: \`${e.name}\``
|
|
460
|
+
: e.kind === 'command' ? `\`${e.name}\``
|
|
461
|
+
: `\`${e.name}\``;
|
|
462
|
+
const pathHint = e.path ? ` (\`${e.path}\`)` : '';
|
|
463
|
+
const desc = e.oneLineDescription ? ` — ${e.oneLineDescription}` : '';
|
|
464
|
+
lines.push(`- ${kindHint}${pathHint}${desc}`);
|
|
465
|
+
}
|
|
466
|
+
lines.push('');
|
|
467
|
+
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.");
|
|
468
|
+
return lines.join('\n');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
module.exports = {
|
|
472
|
+
discoverProjectSkills,
|
|
473
|
+
filterByIntents,
|
|
474
|
+
renderProjectSkillsBlock,
|
|
475
|
+
classifyIntents,
|
|
476
|
+
INTENT_VOCABULARY,
|
|
477
|
+
// Backward-compat (PR #82) — see discover-review-skills.js shim.
|
|
478
|
+
discoverReviewSkills,
|
|
479
|
+
renderReviewSkillsBlock,
|
|
480
|
+
// exported for tests
|
|
481
|
+
_internal: {
|
|
482
|
+
INTENT_KEYWORDS,
|
|
483
|
+
SLASH_COMMAND_RE,
|
|
484
|
+
DEFAULTS,
|
|
485
|
+
_parseFrontmatter,
|
|
486
|
+
_parseExplicitIntents,
|
|
487
|
+
_extractSlashCommandsFromDoc,
|
|
488
|
+
_listAreas,
|
|
489
|
+
},
|
|
490
|
+
};
|