@yemi33/minions 0.1.2370 → 0.1.2371
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 +38 -11
- package/dashboard/js/render-other.js +2 -30
- package/dashboard/js/render-work-items.js +0 -34
- package/dashboard/js/settings.js +12 -27
- package/dashboard/pages/tools.html +1 -1
- package/dashboard.js +80 -257
- package/docs/README.md +2 -3
- package/docs/architecture.excalidraw +2 -2
- package/docs/auto-discovery.md +3 -3
- package/docs/completion-reports.md +0 -121
- package/docs/copilot-cli-schema.md +9 -17
- package/docs/deprecated.json +0 -51
- package/docs/design-state-storage.md +4 -4
- package/docs/harness-propagation.md +33 -263
- package/docs/human-vs-automated.md +1 -1
- package/docs/named-agents.md +0 -2
- package/docs/plan-lifecycle.md +5 -6
- package/docs/qa-runbook-lifecycle.md +10 -25
- package/docs/shared-lifecycle-module-map.md +2 -10
- package/engine/ado-comment.js +5 -11
- package/engine/ado.js +10 -5
- package/engine/cleanup.js +16 -36
- package/engine/cli.js +13 -175
- package/engine/comment-format.js +8 -182
- package/engine/db/index.js +60 -10
- package/engine/db/migrations/018-sql-only-cutover.js +56 -0
- package/engine/dispatch-store.js +0 -114
- package/engine/dispatch.js +1 -6
- package/engine/gh-comment.js +2 -10
- package/engine/github.js +8 -6
- package/engine/lifecycle.js +58 -173
- package/engine/llm.js +9 -11
- package/engine/managed-spawn.js +1 -6
- package/engine/memory-store.js +8 -6
- package/engine/metrics-store.js +4 -128
- package/engine/pipeline.js +4 -7
- package/engine/playbook.js +8 -196
- package/engine/prd-store.js +115 -141
- package/engine/preflight.js +8 -55
- package/engine/projects.js +34 -41
- package/engine/pull-requests-store.js +9 -234
- package/engine/qa-runs.js +4 -15
- package/engine/qa-sessions.js +5 -17
- package/engine/queries.js +23 -103
- package/engine/routing.js +1 -1
- package/engine/runtimes/claude.js +1 -2
- package/engine/runtimes/codex.js +0 -2
- package/engine/runtimes/copilot.js +1 -4
- package/engine/shared-branch-pr-reconcile.js +2 -5
- package/engine/shared.js +174 -533
- package/engine/small-state-store.js +12 -647
- package/engine/spawn-agent.js +5 -96
- package/engine/state-operations.js +166 -0
- package/engine/supervisor.js +0 -1
- package/engine/watch-actions.js +2 -3
- package/engine/watches-store.js +2 -128
- package/engine/work-items-store.js +3 -254
- package/engine.js +102 -334
- package/package.json +2 -2
- package/playbooks/build-fix-complex.md +0 -4
- package/playbooks/fix.md +0 -4
- package/playbooks/implement-shared.md +0 -4
- package/playbooks/implement.md +0 -4
- package/playbooks/plan-to-prd.md +16 -11
- package/playbooks/plan.md +0 -4
- package/playbooks/review.md +0 -6
- package/playbooks/shared-rules.md +0 -65
- package/docs/harness-transparency.md +0 -199
- package/docs/project-skills.md +0 -193
- package/engine/discover-project-skills.js +0 -673
- package/engine/playbook-intents.js +0 -76
|
@@ -1,673 +0,0 @@
|
|
|
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 the PR #82 review-only helper
|
|
11
|
-
* (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
|
-
dominantSubprojectMinRatio: 0.6, // detectDominantSubproject: top sub-project must hold >= this share of sub-project-matched files
|
|
66
|
-
dominantSubprojectMinFiles: 2, // detectDominantSubproject: top sub-project must have >= this many matched files
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
// Intent vocabulary — CLOSED. Each entry maps an intent to the keyword/regex
|
|
70
|
-
// list that must be searched in skill name + description + filename + first
|
|
71
|
-
// heading. Multi-intent is allowed: a skill matching multiple keyword sets
|
|
72
|
-
// will carry multiple intents.
|
|
73
|
-
//
|
|
74
|
-
// IMPORTANT: keep keyword lists tight. False-positives here become noise in
|
|
75
|
-
// every playbook's prompt. Use \b word boundaries to avoid matching inside
|
|
76
|
-
// unrelated tokens (e.g. `previewer` should NOT match `review`).
|
|
77
|
-
const INTENT_KEYWORDS = {
|
|
78
|
-
review: /\b(review|review-swarm|swarm|code-review|pr-review|constellation-review|code-reviewer|review-pr|cover-pr)\b/i,
|
|
79
|
-
build: /\b(build|scaffold|generate|bump|migrate|codemod|refactor|workspace|setup-steps)\b/i,
|
|
80
|
-
fix: /\b(fix|debug|triage|repro|regress|flake|deflake)\b/i,
|
|
81
|
-
test: /\b(test|tests|coverage|qa|validate|validation|integration-tests|unit-tests)\b/i,
|
|
82
|
-
plan: /\b(plan|prd|adr|design-doc|design|decompose|roadmap)\b/i,
|
|
83
|
-
research: /\b(research|investigate|gather-impact|explore|audit|analyze|reflect-and-grow)\b/i,
|
|
84
|
-
deploy: /\b(deploy|release|publish|buddy-build|rollout|ship|track-pr)\b/i,
|
|
85
|
-
observability: /\b(telemetry|kusto|logs|monitoring|metrics|observability|dashboard|weekly-repo-summary)\b/i,
|
|
86
|
-
// meta is intentionally absent from the heuristic — only authors who
|
|
87
|
-
// explicitly tag a skill as `meta` via frontmatter get the meta intent.
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const INTENT_VOCABULARY = Object.freeze([
|
|
91
|
-
'review', 'build', 'fix', 'test', 'plan', 'research', 'deploy', 'observability', 'meta',
|
|
92
|
-
]);
|
|
93
|
-
|
|
94
|
-
function _now() { return Date.now(); }
|
|
95
|
-
|
|
96
|
-
function _safeReadHead(filePath, maxBytes) {
|
|
97
|
-
let fd;
|
|
98
|
-
try {
|
|
99
|
-
fd = fs.openSync(filePath, 'r');
|
|
100
|
-
const buf = Buffer.alloc(maxBytes);
|
|
101
|
-
const n = fs.readSync(fd, buf, 0, maxBytes, 0);
|
|
102
|
-
return buf.slice(0, n).toString('utf8');
|
|
103
|
-
} catch { return ''; }
|
|
104
|
-
finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* ignore */ } } }
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function _parseFrontmatter(content) {
|
|
108
|
-
const m = String(content || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
109
|
-
if (!m) return {};
|
|
110
|
-
const out = {};
|
|
111
|
-
for (const line of m[1].split(/\r?\n/)) {
|
|
112
|
-
const lm = line.match(/^([\w-]+):\s*(.*)$/);
|
|
113
|
-
if (!lm) continue;
|
|
114
|
-
out[lm[1].toLowerCase()] = lm[2].trim().replace(/^["']|["']$/g, '');
|
|
115
|
-
}
|
|
116
|
-
return out;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function _firstHeading(content) {
|
|
120
|
-
const lines = String(content || '').split(/\r?\n/);
|
|
121
|
-
for (const line of lines) {
|
|
122
|
-
const m = line.match(/^#+\s+(.+?)\s*$/);
|
|
123
|
-
if (m) return m[1].trim();
|
|
124
|
-
}
|
|
125
|
-
return '';
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function _firstNonEmptyLine(content) {
|
|
129
|
-
const lines = String(content || '').split(/\r?\n/);
|
|
130
|
-
for (const line of lines) {
|
|
131
|
-
const t = line.trim();
|
|
132
|
-
if (t && !t.startsWith('---') && !t.startsWith('#')) return t;
|
|
133
|
-
}
|
|
134
|
-
return '';
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function _truncate(s, max) {
|
|
138
|
-
const text = String(s || '').trim();
|
|
139
|
-
if (text.length <= max) return text;
|
|
140
|
-
return text.slice(0, max - 1).trim() + '…';
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Parse an "intents:" or "tags:" frontmatter line into a normalized array of
|
|
144
|
-
// intent strings restricted to INTENT_VOCABULARY. Accepts either YAML inline
|
|
145
|
-
// list ("[a, b]"), bracket-less csv ("a, b, c"), or single value ("a").
|
|
146
|
-
function _parseExplicitIntents(fm) {
|
|
147
|
-
const raw = (fm && (fm.intents || fm.tags || fm.intent || fm.tag)) || '';
|
|
148
|
-
if (!raw) return [];
|
|
149
|
-
const stripped = String(raw).replace(/^\[|\]$/g, '').trim();
|
|
150
|
-
if (!stripped) return [];
|
|
151
|
-
const parts = stripped.split(/[,\s]+/).map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
152
|
-
const out = [];
|
|
153
|
-
for (const p of parts) {
|
|
154
|
-
if (INTENT_VOCABULARY.includes(p) && !out.includes(p)) out.push(p);
|
|
155
|
-
}
|
|
156
|
-
return out;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Classify a (name, description, extraText) tuple into the closed intent
|
|
160
|
-
// vocabulary. Always returns a (possibly empty) array. NEVER returns ['meta']
|
|
161
|
-
// by default — empty array is the honest signal that the heuristic didn't fire.
|
|
162
|
-
function classifyIntents({ name = '', description = '', extra = '', explicit = [] } = {}) {
|
|
163
|
-
const hay = `${name} ${description} ${extra}`;
|
|
164
|
-
const intents = [];
|
|
165
|
-
// Honor explicit frontmatter first (preserves author intent).
|
|
166
|
-
for (const i of explicit) if (!intents.includes(i)) intents.push(i);
|
|
167
|
-
for (const intent of Object.keys(INTENT_KEYWORDS)) {
|
|
168
|
-
if (intents.includes(intent)) continue;
|
|
169
|
-
if (INTENT_KEYWORDS[intent].test(hay)) intents.push(intent);
|
|
170
|
-
}
|
|
171
|
-
return intents;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Record that a scanned surface exceeded opts.maxFilesPerSurface so callers can
|
|
175
|
-
// surface an overflow diagnostic instead of silently dropping entries.
|
|
176
|
-
function _recordTruncation(truncations, surface, projectPath, dir, scanned, total) {
|
|
177
|
-
if (!Array.isArray(truncations)) return;
|
|
178
|
-
if (total <= scanned) return;
|
|
179
|
-
truncations.push({
|
|
180
|
-
surface,
|
|
181
|
-
dir: path.relative(projectPath, dir).split(path.sep).join('/'),
|
|
182
|
-
scanned,
|
|
183
|
-
total,
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Walk <baseDir>/.claude/skills/*\/SKILL.md
|
|
188
|
-
function _discoverSkillsAt(baseDir, projectPath, opts, deadline, originLabel, truncations) {
|
|
189
|
-
const out = [];
|
|
190
|
-
const skillsDir = path.join(baseDir, '.claude', 'skills');
|
|
191
|
-
let entries;
|
|
192
|
-
try { entries = fs.readdirSync(skillsDir, { withFileTypes: true }); } catch { return out; }
|
|
193
|
-
// Stable alphabetical sort by entry name BEFORE applying the
|
|
194
|
-
// maxFilesPerSurface cap so the surviving subset is deterministic and
|
|
195
|
-
// reproducible across runs / OSes (raw readdir order is FS-dependent, so
|
|
196
|
-
// capping pre-sort silently dropped a non-deterministic slice).
|
|
197
|
-
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
198
|
-
const cap = opts.maxFilesPerSurface;
|
|
199
|
-
let scanned = 0;
|
|
200
|
-
let total = 0;
|
|
201
|
-
for (const ent of entries) {
|
|
202
|
-
if (_now() > deadline) break;
|
|
203
|
-
if (!ent.isDirectory()) continue;
|
|
204
|
-
const skillPath = path.join(skillsDir, ent.name, 'SKILL.md');
|
|
205
|
-
let stat;
|
|
206
|
-
try { stat = fs.statSync(skillPath); } catch { continue; }
|
|
207
|
-
if (!stat.isFile()) continue;
|
|
208
|
-
total += 1;
|
|
209
|
-
// Beyond the cap we still count `total` (for an accurate overflow
|
|
210
|
-
// diagnostic) but stop reading/emitting entries.
|
|
211
|
-
if (scanned >= cap) continue;
|
|
212
|
-
scanned += 1;
|
|
213
|
-
const head = _safeReadHead(skillPath, opts.maxBytesPerFile);
|
|
214
|
-
if (!head) continue;
|
|
215
|
-
const fm = _parseFrontmatter(head);
|
|
216
|
-
const name = fm.name || ent.name;
|
|
217
|
-
const desc = fm.description || '';
|
|
218
|
-
const explicit = _parseExplicitIntents(fm);
|
|
219
|
-
const heading = _firstHeading(head);
|
|
220
|
-
const intents = classifyIntents({ name, description: desc, extra: heading, explicit });
|
|
221
|
-
out.push({
|
|
222
|
-
kind: 'skill',
|
|
223
|
-
name: String(name),
|
|
224
|
-
path: path.relative(projectPath, skillPath).split(path.sep).join('/'),
|
|
225
|
-
oneLineDescription: _truncate(desc || heading || name, 200),
|
|
226
|
-
intents,
|
|
227
|
-
_originLabel: originLabel,
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
_recordTruncation(truncations, 'skill', projectPath, skillsDir, scanned, total);
|
|
231
|
-
return out;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Walk <baseDir>/.claude/commands/*.md
|
|
235
|
-
function _discoverCommandsAt(baseDir, projectPath, opts, deadline, originLabel, truncations) {
|
|
236
|
-
const out = [];
|
|
237
|
-
const cmdDir = path.join(baseDir, '.claude', 'commands');
|
|
238
|
-
let entries;
|
|
239
|
-
try { entries = fs.readdirSync(cmdDir, { withFileTypes: true }); } catch { return out; }
|
|
240
|
-
// Stable alphabetical sort by entry name BEFORE applying the
|
|
241
|
-
// maxFilesPerSurface cap (see _discoverSkillsAt for the determinism rationale).
|
|
242
|
-
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
243
|
-
const cap = opts.maxFilesPerSurface;
|
|
244
|
-
let scanned = 0;
|
|
245
|
-
let total = 0;
|
|
246
|
-
for (const ent of entries) {
|
|
247
|
-
if (_now() > deadline) break;
|
|
248
|
-
if (!ent.isFile()) continue;
|
|
249
|
-
if (!/\.md$/i.test(ent.name)) continue;
|
|
250
|
-
total += 1;
|
|
251
|
-
if (scanned >= cap) continue;
|
|
252
|
-
scanned += 1;
|
|
253
|
-
const base = ent.name.replace(/\.md$/i, '');
|
|
254
|
-
const cmdPath = path.join(cmdDir, ent.name);
|
|
255
|
-
const head = _safeReadHead(cmdPath, opts.maxBytesPerFile);
|
|
256
|
-
const heading = _firstHeading(head);
|
|
257
|
-
const intents = classifyIntents({ name: base, description: heading, extra: _firstNonEmptyLine(head) });
|
|
258
|
-
out.push({
|
|
259
|
-
kind: 'command',
|
|
260
|
-
name: `/${base}`,
|
|
261
|
-
path: path.relative(projectPath, cmdPath).split(path.sep).join('/'),
|
|
262
|
-
oneLineDescription: _truncate(heading || _firstNonEmptyLine(head) || base, 200),
|
|
263
|
-
intents,
|
|
264
|
-
_originLabel: originLabel,
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
_recordTruncation(truncations, 'command', projectPath, cmdDir, scanned, total);
|
|
268
|
-
return out;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function _extractSlashCommandsFromDoc(content) {
|
|
272
|
-
const found = new Map();
|
|
273
|
-
const text = String(content || '');
|
|
274
|
-
let m;
|
|
275
|
-
SLASH_COMMAND_RE.lastIndex = 0;
|
|
276
|
-
while ((m = SLASH_COMMAND_RE.exec(text)) !== null) {
|
|
277
|
-
const id = m[1];
|
|
278
|
-
if (!found.has(id)) found.set(id, m.index);
|
|
279
|
-
}
|
|
280
|
-
return [...found.keys()];
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function _discoverDocSlashCommands(projectPath, opts, deadline, alreadySeen) {
|
|
284
|
-
const out = [];
|
|
285
|
-
const docPaths = [
|
|
286
|
-
path.join(projectPath, '.github', 'copilot-instructions.md'),
|
|
287
|
-
path.join(projectPath, 'CLAUDE.md'),
|
|
288
|
-
];
|
|
289
|
-
for (const docPath of docPaths) {
|
|
290
|
-
if (_now() > deadline) break;
|
|
291
|
-
let stat;
|
|
292
|
-
try { stat = fs.statSync(docPath); } catch { continue; }
|
|
293
|
-
if (!stat.isFile()) continue;
|
|
294
|
-
const head = _safeReadHead(docPath, opts.docsScanMaxBytes);
|
|
295
|
-
if (!head) continue;
|
|
296
|
-
const ids = _extractSlashCommandsFromDoc(head);
|
|
297
|
-
const relDoc = path.relative(projectPath, docPath).split(path.sep).join('/');
|
|
298
|
-
for (const id of ids) {
|
|
299
|
-
const fullName = `/${id}`;
|
|
300
|
-
if (alreadySeen.has(fullName)) continue;
|
|
301
|
-
// For doc-scrape slash-commands, only surface entries that classify to
|
|
302
|
-
// at least one intent — keeps the discovery output bounded even when
|
|
303
|
-
// CLAUDE.md / copilot-instructions.md mention many slash-commands.
|
|
304
|
-
const intents = classifyIntents({ name: id });
|
|
305
|
-
if (intents.length === 0) continue;
|
|
306
|
-
alreadySeen.add(fullName);
|
|
307
|
-
out.push({
|
|
308
|
-
kind: 'slash-command',
|
|
309
|
-
name: fullName,
|
|
310
|
-
path: relDoc,
|
|
311
|
-
oneLineDescription: `Documented entrypoint in ${relDoc}`,
|
|
312
|
-
intents,
|
|
313
|
-
_originLabel: 'docs',
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
return out;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// First-level subdirs that look like project "areas" (e.g. loop/, officemobile/,
|
|
321
|
-
// fluid-client-framework/). Returns dirent names only — caller probes for
|
|
322
|
-
// .claude/ underneath. Bounded by opts.maxAreas.
|
|
323
|
-
function _listAreas(projectPath, opts, deadline) {
|
|
324
|
-
const out = [];
|
|
325
|
-
let entries;
|
|
326
|
-
try { entries = fs.readdirSync(projectPath, { withFileTypes: true }); } catch { return out; }
|
|
327
|
-
// Sort for deterministic ordering across OSes.
|
|
328
|
-
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
329
|
-
for (const ent of entries) {
|
|
330
|
-
if (out.length >= opts.maxAreas) break;
|
|
331
|
-
if (_now() > deadline) break;
|
|
332
|
-
if (!ent.isDirectory()) continue;
|
|
333
|
-
// Skip dot-dirs (.git, .claude itself, .github, node_modules-like).
|
|
334
|
-
if (ent.name.startsWith('.')) continue;
|
|
335
|
-
if (ent.name === 'node_modules') continue;
|
|
336
|
-
out.push(ent.name);
|
|
337
|
-
}
|
|
338
|
-
return out;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Pure dominant-sub-project (area) detector.
|
|
343
|
-
*
|
|
344
|
-
* Given a set of changed paths, decide whether a single first-level project
|
|
345
|
-
* sub-project (e.g. `ocm/`, `loop/`, `officemobile/`) dominates the diff strongly
|
|
346
|
-
* enough that skill discovery / harness surfacing should be scoped to it.
|
|
347
|
-
* Reuses `_listAreas` for the authoritative set of valid sub-projects (sorted,
|
|
348
|
-
* dot-dir / node_modules filtered, capped at opts.maxAreas) so a stray first
|
|
349
|
-
* path segment that is NOT a real top-level directory never counts as a sub-project.
|
|
350
|
-
*
|
|
351
|
-
* Purity: no engine state, no fs beyond the single `_listAreas` readdir. The
|
|
352
|
-
* result is fully determined by (projectPath's first-level dirs, changedPaths,
|
|
353
|
-
* opts) — unit-testable against a fixture.
|
|
354
|
-
*
|
|
355
|
-
* @param {object} args
|
|
356
|
-
* @param {string} args.projectPath — absolute path to the project worktree / checkout
|
|
357
|
-
* @param {string[]} args.changedPaths — repo-relative changed file paths (any separator)
|
|
358
|
-
* @param {object} [args.opts] — override thresholds / caps (mostly for tests)
|
|
359
|
-
* @returns {{subproject:string|null, matched:number, total:number, confident:boolean}}
|
|
360
|
-
* total — count of changed paths considered
|
|
361
|
-
* matched — count of changed paths that fall under a known sub-project
|
|
362
|
-
* subproject — the dominant sub-project when `confident`, else null (flat-discovery fallback)
|
|
363
|
-
* confident — top sub-project holds >= minRatio of matched files AND >= minFiles files
|
|
364
|
-
*/
|
|
365
|
-
function detectDominantSubproject(args) {
|
|
366
|
-
const projectPath = args && args.projectPath;
|
|
367
|
-
const changedPaths = args && args.changedPaths;
|
|
368
|
-
const opts = Object.assign({}, DEFAULTS, (args && args.opts) || {});
|
|
369
|
-
|
|
370
|
-
const total = Array.isArray(changedPaths) ? changedPaths.length : 0;
|
|
371
|
-
if (!projectPath || typeof projectPath !== 'string' || total === 0) {
|
|
372
|
-
return { subproject: null, matched: 0, total, confident: false };
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const deadline = _now() + Math.max(1, opts.walltimeMs);
|
|
376
|
-
const subprojects = new Set(_listAreas(projectPath, opts, deadline));
|
|
377
|
-
if (subprojects.size === 0) {
|
|
378
|
-
return { subproject: null, matched: 0, total, confident: false };
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Count changed files per known sub-project, keyed on the normalized first segment.
|
|
382
|
-
const counts = new Map();
|
|
383
|
-
let matched = 0;
|
|
384
|
-
for (const raw of changedPaths) {
|
|
385
|
-
if (typeof raw !== 'string' || raw.length === 0) continue;
|
|
386
|
-
// Normalize to POSIX and strip leading ./ and / so the first segment is
|
|
387
|
-
// the true top-level directory.
|
|
388
|
-
let p = raw.replace(/\\/g, '/');
|
|
389
|
-
while (p.startsWith('./')) p = p.slice(2);
|
|
390
|
-
while (p.startsWith('/')) p = p.slice(1);
|
|
391
|
-
const seg = p.split('/')[0];
|
|
392
|
-
if (!seg || !subprojects.has(seg)) continue;
|
|
393
|
-
matched += 1;
|
|
394
|
-
counts.set(seg, (counts.get(seg) || 0) + 1);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
if (matched === 0) {
|
|
398
|
-
return { subproject: null, matched: 0, total, confident: false };
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Dominant sub-project = highest count; ties broken alphabetically for determinism.
|
|
402
|
-
let topSubproject = null;
|
|
403
|
-
let topCount = -1;
|
|
404
|
-
for (const [sub, count] of counts) {
|
|
405
|
-
if (count > topCount || (count === topCount && (topSubproject === null || sub.localeCompare(topSubproject) < 0))) {
|
|
406
|
-
topSubproject = sub;
|
|
407
|
-
topCount = count;
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const minRatio = typeof opts.dominantSubprojectMinRatio === 'number' ? opts.dominantSubprojectMinRatio : DEFAULTS.dominantSubprojectMinRatio;
|
|
412
|
-
const minFiles = typeof opts.dominantSubprojectMinFiles === 'number' ? opts.dominantSubprojectMinFiles : DEFAULTS.dominantSubprojectMinFiles;
|
|
413
|
-
const confident = topCount >= minFiles && (topCount / matched) >= minRatio;
|
|
414
|
-
|
|
415
|
-
return { subproject: confident ? topSubproject : null, matched, total, confident };
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// Normalize a caller-supplied scopeSubproject into a bare POSIX first-level dir name
|
|
419
|
-
// (backslashes → slashes, leading/trailing slashes + whitespace stripped).
|
|
420
|
-
// Returns '' when unset/empty so the sort can cheaply skip scope grouping.
|
|
421
|
-
function _normalizeScopeSubproject(scopeSubproject) {
|
|
422
|
-
if (typeof scopeSubproject !== 'string') return '';
|
|
423
|
-
return scopeSubproject.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '').trim();
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// The project sub-project an entry's (repo-relative POSIX) path lives under, or
|
|
427
|
-
// null for root-level entries. Sub-project skill/command paths look like
|
|
428
|
-
// `<area>/.claude/…`; root skills/commands (`.claude/…`) and root docs
|
|
429
|
-
// (`CLAUDE.md`, `.github/copilot-instructions.md`) have no owning sub-project.
|
|
430
|
-
function _entrySubproject(entryPath) {
|
|
431
|
-
const m = /^([^/]+)\/\.claude\//.exec(String(entryPath || '').replace(/\\/g, '/'));
|
|
432
|
-
return m ? m[1] : null;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Scope-ordering group for a scopeSubproject-aware sort. Lower ranks lead the list:
|
|
436
|
-
// 0 = entry is under <scopeSubproject>/, 1 = root-level, 2 = other-sub-project.
|
|
437
|
-
function _scopeGroup(entryPath, scopeSubproject) {
|
|
438
|
-
const p = String(entryPath || '').replace(/\\/g, '/');
|
|
439
|
-
if (scopeSubproject && (p === scopeSubproject || p.startsWith(scopeSubproject + '/'))) return 0;
|
|
440
|
-
return _entrySubproject(p) === null ? 1 : 2;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/**
|
|
444
|
-
* Diagnostics-returning discovery. Same walk as discoverProjectSkills but also
|
|
445
|
-
* reports per-surface overflow records so callers can tell when a directory
|
|
446
|
-
* exceeded opts.maxFilesPerSurface (and therefore had entries dropped) instead
|
|
447
|
-
* of silently losing them.
|
|
448
|
-
*
|
|
449
|
-
* @param {object} args
|
|
450
|
-
* @param {string} args.projectPath — absolute path to the project worktree / checkout
|
|
451
|
-
* @param {string} [args.scopeSubproject] — when set/non-empty, surface entries under
|
|
452
|
-
* `<scopeSubproject>/` FIRST (name-sorted within group), then root-level entries,
|
|
453
|
-
* then other-sub-project entries. Discovery still walks everything (no skills
|
|
454
|
-
* dropped); only ordering changes. Unset/empty ⇒ today's global name-sort.
|
|
455
|
-
* @param {object} [args.opts] — override defaults (mostly for tests)
|
|
456
|
-
* @returns {{entries:Array<{kind:string,name:string,path:string,oneLineDescription:string,intents:string[]}>, truncations:Array<{surface:string,dir:string,scanned:number,total:number}>}}
|
|
457
|
-
*/
|
|
458
|
-
function discoverProjectSkillsWithDiagnostics(args) {
|
|
459
|
-
const projectPath = args && args.projectPath;
|
|
460
|
-
if (!projectPath || typeof projectPath !== 'string') return { entries: [], truncations: [] };
|
|
461
|
-
try { if (!fs.statSync(projectPath).isDirectory()) return { entries: [], truncations: [] }; } catch { return { entries: [], truncations: [] }; }
|
|
462
|
-
|
|
463
|
-
const scopeSubproject = _normalizeScopeSubproject(args.scopeSubproject);
|
|
464
|
-
const opts = Object.assign({}, DEFAULTS, args.opts || {});
|
|
465
|
-
const deadline = _now() + Math.max(1, opts.walltimeMs);
|
|
466
|
-
|
|
467
|
-
const seenKey = new Set(); // dedupe across surfaces by `${kind}:${name}`
|
|
468
|
-
const seenSlash = new Set();
|
|
469
|
-
const all = [];
|
|
470
|
-
const truncations = [];
|
|
471
|
-
|
|
472
|
-
// 1. Root-level skills.
|
|
473
|
-
for (const entry of _discoverSkillsAt(projectPath, projectPath, opts, deadline, 'root', truncations)) {
|
|
474
|
-
const key = `skill:${entry.name}`;
|
|
475
|
-
if (seenKey.has(key)) continue;
|
|
476
|
-
seenKey.add(key);
|
|
477
|
-
all.push(entry);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// 2. Root-level commands.
|
|
481
|
-
for (const entry of _discoverCommandsAt(projectPath, projectPath, opts, deadline, 'root', truncations)) {
|
|
482
|
-
const key = `command:${entry.name}`;
|
|
483
|
-
if (seenKey.has(key)) continue;
|
|
484
|
-
seenKey.add(key);
|
|
485
|
-
seenSlash.add(entry.name); // /foo from .claude/commands subsumes doc-mention
|
|
486
|
-
all.push(entry);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// 3. Per-area (one level deep) skills + commands. This is how
|
|
490
|
-
// OCM/loop/officemobile organize their skill packs today and is the
|
|
491
|
-
// primary gap PR 82's root-only walk had.
|
|
492
|
-
if (_now() <= deadline) {
|
|
493
|
-
const areas = _listAreas(projectPath, opts, deadline);
|
|
494
|
-
for (const area of areas) {
|
|
495
|
-
if (_now() > deadline) break;
|
|
496
|
-
const areaBase = path.join(projectPath, area);
|
|
497
|
-
for (const entry of _discoverSkillsAt(areaBase, projectPath, opts, deadline, `area:${area}`, truncations)) {
|
|
498
|
-
const key = `skill:${entry.name}`;
|
|
499
|
-
if (seenKey.has(key)) continue;
|
|
500
|
-
seenKey.add(key);
|
|
501
|
-
all.push(entry);
|
|
502
|
-
}
|
|
503
|
-
// No deadline break here — skills and commands for the same area are
|
|
504
|
-
// scanned as an atomic unit. The deadline guards *starting* a new area
|
|
505
|
-
// (line above, `if (_now() > deadline) break`) but must not split a
|
|
506
|
-
// half-scanned area: doing so drops commands while keeping skills from
|
|
507
|
-
// the same area, causing test/prod divergence under load (PR-82 blind spot
|
|
508
|
-
// regression, CI failure yemi33#28135287117).
|
|
509
|
-
for (const entry of _discoverCommandsAt(areaBase, projectPath, opts, deadline, `area:${area}`, truncations)) {
|
|
510
|
-
const key = `command:${entry.name}`;
|
|
511
|
-
if (seenKey.has(key)) continue;
|
|
512
|
-
seenKey.add(key);
|
|
513
|
-
seenSlash.add(entry.name);
|
|
514
|
-
all.push(entry);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// 4. Doc-scrape slash-commands (only surface entries that classify to an
|
|
520
|
-
// intent — see _discoverDocSlashCommands).
|
|
521
|
-
for (const entry of _discoverDocSlashCommands(projectPath, opts, deadline, seenSlash)) {
|
|
522
|
-
const key = `slash-command:${entry.name}`;
|
|
523
|
-
if (seenKey.has(key)) continue;
|
|
524
|
-
seenKey.add(key);
|
|
525
|
-
all.push(entry);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// Deterministic ordering. When a scopeSubproject is set, entries under
|
|
529
|
-
// `<scopeSubproject>/` lead the list, then root-level, then other-sub-project entries;
|
|
530
|
-
// within each group the original name-then-path sort applies. When scopeSubproject
|
|
531
|
-
// is empty the group ranks are all equal so this reduces byte-for-byte to the
|
|
532
|
-
// historical global name-then-path sort (no regression for existing callers).
|
|
533
|
-
all.sort((a, b) => {
|
|
534
|
-
if (scopeSubproject) {
|
|
535
|
-
const ga = _scopeGroup(a.path, scopeSubproject);
|
|
536
|
-
const gb = _scopeGroup(b.path, scopeSubproject);
|
|
537
|
-
if (ga !== gb) return ga - gb;
|
|
538
|
-
}
|
|
539
|
-
if (a.name !== b.name) return a.name.localeCompare(b.name);
|
|
540
|
-
return String(a.path || '').localeCompare(String(b.path || ''));
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
// Strip the internal _originLabel before returning (debugging-only).
|
|
544
|
-
const entries = all.map(({ _originLabel, ...rest }) => rest); // eslint-disable-line no-unused-vars
|
|
545
|
-
return { entries, truncations };
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* @param {object} args
|
|
550
|
-
* @param {string} args.projectPath — absolute path to the project worktree / checkout
|
|
551
|
-
* @param {string} [args.scopeSubproject] — when set/non-empty, surface entries under
|
|
552
|
-
* `<scopeSubproject>/` first, then root-level, then other-sub-project entries (see
|
|
553
|
-
* discoverProjectSkillsWithDiagnostics). Unset/empty ⇒ global name-sort.
|
|
554
|
-
* @param {object} [args.opts] — override defaults (mostly for tests)
|
|
555
|
-
* @returns {Array<{kind:string,name:string,path:string,oneLineDescription:string,intents:string[]}>}
|
|
556
|
-
*/
|
|
557
|
-
function discoverProjectSkills(args) {
|
|
558
|
-
// Delegates to the diagnostics variant and returns only the entries array so
|
|
559
|
-
// existing array-returning callers keep working unchanged.
|
|
560
|
-
return discoverProjectSkillsWithDiagnostics(args).entries;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* Filter a discovery result list to a given intent set. An entry passes if
|
|
565
|
-
* any of its intents is in the requested set. Empty-intent entries do NOT
|
|
566
|
-
* pass an intent-filtered render (the honest signal from classifyIntents).
|
|
567
|
-
*
|
|
568
|
-
* @param {Array} entries — output of discoverProjectSkills()
|
|
569
|
-
* @param {string[]|null} intents — intent vocabulary subset; null/empty/['*']
|
|
570
|
-
* means "all" (no filtering).
|
|
571
|
-
* @returns {Array}
|
|
572
|
-
*/
|
|
573
|
-
function filterByIntents(entries, intents) {
|
|
574
|
-
if (!Array.isArray(entries) || entries.length === 0) return [];
|
|
575
|
-
if (!intents || intents.length === 0 || intents.includes('*')) {
|
|
576
|
-
return entries.slice();
|
|
577
|
-
}
|
|
578
|
-
const want = new Set(intents);
|
|
579
|
-
return entries.filter(e => Array.isArray(e.intents) && e.intents.some(i => want.has(i)));
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/**
|
|
583
|
-
* Format a (pre-filtered) discovery result list as a Markdown block ready to
|
|
584
|
-
* splice into a playbook. Returns empty string when the list is empty so the
|
|
585
|
-
* caller can no-op cleanly (no stray header, no blank padding lines).
|
|
586
|
-
*
|
|
587
|
-
* @param {Array} entries — pre-filtered output of discoverProjectSkills()
|
|
588
|
-
* @returns {string}
|
|
589
|
-
*/
|
|
590
|
-
function renderProjectSkillsBlock(entries) {
|
|
591
|
-
if (!Array.isArray(entries) || entries.length === 0) return '';
|
|
592
|
-
const lines = [];
|
|
593
|
-
lines.push('## Project skills (prefer these when applicable)');
|
|
594
|
-
lines.push('');
|
|
595
|
-
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.");
|
|
596
|
-
lines.push('');
|
|
597
|
-
for (const e of entries) {
|
|
598
|
-
const kindHint = e.kind === 'skill' ? `skill: \`${e.name}\``
|
|
599
|
-
: e.kind === 'command' ? `\`${e.name}\``
|
|
600
|
-
: `\`${e.name}\``;
|
|
601
|
-
const pathHint = e.path ? ` (\`${e.path}\`)` : '';
|
|
602
|
-
const desc = e.oneLineDescription ? ` — ${e.oneLineDescription}` : '';
|
|
603
|
-
const intentTag = Array.isArray(e.intents) && e.intents.length > 0
|
|
604
|
-
? ` [intent: ${e.intents.join(', ')}]`
|
|
605
|
-
: '';
|
|
606
|
-
lines.push(`- ${kindHint}${pathHint}${desc}${intentTag}`);
|
|
607
|
-
}
|
|
608
|
-
lines.push('');
|
|
609
|
-
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.");
|
|
610
|
-
lines.push('');
|
|
611
|
-
lines.push("**Before you record a `skipped` outcome for an in-scope skill, this is a hard constraint, not a suggestion:** re-read that skill's own SKILL.md review criteria / checklist (the content injected above) and confirm your skip reason does not contradict any explicit \"Flag if…\" / \"Verify that…\" / \"Requirements\" item it documents. If the diff trips ANY explicit checklist item the skill covers, skipping is NOT permitted — apply the skill (or at minimum that specific checklist item). A skip reason that waves away a gate the skill explicitly documents (e.g. \"no CHANGELOG needed here\" when the skill's CHANGELOG Requirements section says otherwise) is invalid. When you do skip, the SAME `{name, reason}` you record in `meta.skill.skipped` must be the reason surfaced in the PR comment — no drift between the two.");
|
|
612
|
-
return lines.join('\n');
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// ── Backward-compatibility aliases for PR #82 callers (W-mq16xtdx001a347e) ──
|
|
616
|
-
// These provide the original PR-82 function names as direct exports.
|
|
617
|
-
|
|
618
|
-
function discoverReviewSkills(args) {
|
|
619
|
-
// Old API: returns review-flavored entries only, without the new `intents`
|
|
620
|
-
// field shape that callers might not understand. We still return entries
|
|
621
|
-
// with `intents` populated (additive change — old callers ignore the field).
|
|
622
|
-
return filterByIntents(discoverProjectSkills(args), ['review']);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
function renderReviewSkillsBlock(entries) {
|
|
626
|
-
// Old API renders the original PR-82 header copy verbatim so existing
|
|
627
|
-
// tests/agents that grep on "## Project review skills" keep matching.
|
|
628
|
-
if (!Array.isArray(entries) || entries.length === 0) return '';
|
|
629
|
-
const lines = [];
|
|
630
|
-
lines.push('## Project review skills (prefer these when applicable)');
|
|
631
|
-
lines.push('');
|
|
632
|
-
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.");
|
|
633
|
-
lines.push('');
|
|
634
|
-
for (const e of entries) {
|
|
635
|
-
const kindHint = e.kind === 'skill' ? `skill: \`${e.name}\``
|
|
636
|
-
: e.kind === 'command' ? `\`${e.name}\``
|
|
637
|
-
: `\`${e.name}\``;
|
|
638
|
-
const pathHint = e.path ? ` (\`${e.path}\`)` : '';
|
|
639
|
-
const desc = e.oneLineDescription ? ` — ${e.oneLineDescription}` : '';
|
|
640
|
-
lines.push(`- ${kindHint}${pathHint}${desc}`);
|
|
641
|
-
}
|
|
642
|
-
lines.push('');
|
|
643
|
-
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.");
|
|
644
|
-
lines.push('');
|
|
645
|
-
lines.push("**Before you record a `skillSkipped` outcome for an in-scope review skill, this is a hard constraint, not a suggestion:** re-read that skill's own SKILL.md review criteria / checklist (the content injected above) and confirm your skip reason does not contradict any explicit \"Flag if…\" / \"Verify that…\" / \"Requirements\" item it documents. If the diff under review trips ANY explicit checklist item the skill covers, skipping is NOT permitted — apply the skill (or at minimum that specific checklist item) and let it inform your verdict. A skip reason that waves away a gate the skill explicitly documents (e.g. \"no CHANGELOG needed for this diff\" when the skill's CHANGELOG Requirements section says otherwise) is invalid. When you do skip, the SAME `{name, reason}` you record in `meta.review.skillSkipped` must be the reason surfaced in the PR comment — no drift between the two.");
|
|
646
|
-
return lines.join('\n');
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
module.exports = {
|
|
650
|
-
discoverProjectSkills,
|
|
651
|
-
discoverProjectSkillsWithDiagnostics,
|
|
652
|
-
filterByIntents,
|
|
653
|
-
renderProjectSkillsBlock,
|
|
654
|
-
classifyIntents,
|
|
655
|
-
INTENT_VOCABULARY,
|
|
656
|
-
// Backward-compat aliases (PR #82, W-mq16xtdx001a347e).
|
|
657
|
-
discoverReviewSkills,
|
|
658
|
-
renderReviewSkillsBlock,
|
|
659
|
-
// exported for tests
|
|
660
|
-
_internal: {
|
|
661
|
-
INTENT_KEYWORDS,
|
|
662
|
-
SLASH_COMMAND_RE,
|
|
663
|
-
DEFAULTS,
|
|
664
|
-
_parseFrontmatter,
|
|
665
|
-
_parseExplicitIntents,
|
|
666
|
-
_extractSlashCommandsFromDoc,
|
|
667
|
-
_listAreas,
|
|
668
|
-
detectDominantSubproject,
|
|
669
|
-
_normalizeScopeSubproject,
|
|
670
|
-
_entrySubproject,
|
|
671
|
-
_scopeGroup,
|
|
672
|
-
},
|
|
673
|
-
};
|