@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.
@@ -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
+ };