@yemi33/minions 0.1.2369 → 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.
Files changed (79) hide show
  1. package/bin/minions.js +38 -11
  2. package/dashboard/js/memory-search.js +112 -0
  3. package/dashboard/js/render-kb.js +15 -0
  4. package/dashboard/js/render-other.js +2 -30
  5. package/dashboard/js/render-work-items.js +0 -34
  6. package/dashboard/js/settings.js +27 -27
  7. package/dashboard/pages/inbox.html +1 -1
  8. package/dashboard/pages/tools.html +1 -1
  9. package/dashboard.js +148 -258
  10. package/docs/README.md +2 -3
  11. package/docs/architecture.excalidraw +2 -2
  12. package/docs/auto-discovery.md +3 -3
  13. package/docs/completion-reports.md +0 -121
  14. package/docs/copilot-cli-schema.md +9 -17
  15. package/docs/deprecated.json +0 -51
  16. package/docs/design-state-storage.md +4 -4
  17. package/docs/harness-propagation.md +33 -263
  18. package/docs/human-vs-automated.md +1 -1
  19. package/docs/named-agents.md +0 -2
  20. package/docs/plan-lifecycle.md +5 -6
  21. package/docs/qa-runbook-lifecycle.md +10 -25
  22. package/docs/shared-lifecycle-module-map.md +2 -10
  23. package/docs/team-memory.md +18 -4
  24. package/engine/ado-comment.js +5 -11
  25. package/engine/ado.js +10 -5
  26. package/engine/cleanup.js +16 -36
  27. package/engine/cli.js +13 -175
  28. package/engine/comment-format.js +8 -182
  29. package/engine/consolidation.js +72 -1
  30. package/engine/db/index.js +60 -10
  31. package/engine/db/migrations/017-agent-memory.js +208 -0
  32. package/engine/db/migrations/018-sql-only-cutover.js +56 -0
  33. package/engine/dispatch-store.js +0 -114
  34. package/engine/dispatch.js +1 -6
  35. package/engine/features.js +6 -0
  36. package/engine/gh-comment.js +2 -10
  37. package/engine/github.js +8 -6
  38. package/engine/lifecycle.js +141 -173
  39. package/engine/llm.js +9 -11
  40. package/engine/managed-spawn.js +1 -6
  41. package/engine/memory-retrieval.js +165 -0
  42. package/engine/memory-store.js +367 -0
  43. package/engine/metrics-store.js +4 -128
  44. package/engine/pipeline.js +4 -7
  45. package/engine/playbook.js +64 -198
  46. package/engine/prd-store.js +115 -141
  47. package/engine/preflight.js +8 -55
  48. package/engine/projects.js +34 -41
  49. package/engine/pull-requests-store.js +9 -234
  50. package/engine/qa-runs.js +4 -15
  51. package/engine/qa-sessions.js +5 -17
  52. package/engine/queries.js +23 -103
  53. package/engine/routing.js +1 -1
  54. package/engine/runtimes/claude.js +1 -2
  55. package/engine/runtimes/codex.js +0 -2
  56. package/engine/runtimes/copilot.js +1 -4
  57. package/engine/shared-branch-pr-reconcile.js +2 -5
  58. package/engine/shared.js +179 -533
  59. package/engine/small-state-store.js +12 -647
  60. package/engine/spawn-agent.js +5 -96
  61. package/engine/state-operations.js +166 -0
  62. package/engine/supervisor.js +0 -1
  63. package/engine/watch-actions.js +2 -3
  64. package/engine/watches-store.js +2 -128
  65. package/engine/work-items-store.js +3 -254
  66. package/engine.js +102 -334
  67. package/package.json +2 -2
  68. package/playbooks/build-fix-complex.md +0 -4
  69. package/playbooks/fix.md +0 -4
  70. package/playbooks/implement-shared.md +0 -4
  71. package/playbooks/implement.md +0 -4
  72. package/playbooks/plan-to-prd.md +16 -11
  73. package/playbooks/plan.md +0 -4
  74. package/playbooks/review.md +0 -6
  75. package/playbooks/shared-rules.md +0 -65
  76. package/docs/harness-transparency.md +0 -199
  77. package/docs/project-skills.md +0 -193
  78. package/engine/discover-project-skills.js +0 -673
  79. 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
- };