@mthanh/vespercli 1.7.1 → 1.8.0

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 (27) hide show
  1. package/README.md +38 -0
  2. package/assets/claude/agents/clickup-agent.md +1 -1
  3. package/assets/claude/agents/context7.md +1 -1
  4. package/assets/claude/agents/modernization.md +1 -0
  5. package/assets/claude/skills/java/SKILL.md +1 -1
  6. package/assets/claude/skills/nextjs-tailwind/SKILL.md +1 -1
  7. package/assets/claude/skills/reactjs/SKILL.md +1 -1
  8. package/assets/claude/skills/self-explanatory-code-commenting/SKILL.md +1 -1
  9. package/assets/claude/skills/tailwind-v4-vite/SKILL.md +1 -1
  10. package/assets/claude/skills/task-implementation/SKILL.md +1 -1
  11. package/assets/claude/skills/vesper-catalog-editing-safety/SKILL.md +2 -1
  12. package/assets/claude/vesper-orchestrator.md +2 -0
  13. package/assets/instructions/flutter/dart-n-flutter.instructions.md +447 -0
  14. package/assets/instructions/self-explanatory-code-commenting/self-explanatory-code-commenting.instructions.md +1 -1
  15. package/assets/skills/vesper-catalog-editing-safety/SKILL.md +2 -1
  16. package/assets/vendor/i-have-adhd/LICENSE +21 -0
  17. package/assets/vendor/i-have-adhd/SKILL.md +142 -0
  18. package/assets/vendor/i-have-adhd/VENDOR.md +56 -0
  19. package/assets/vendor/ponytail/LICENSE +21 -0
  20. package/assets/vendor/ponytail/SKILL.md +120 -0
  21. package/assets/vendor/ponytail/VENDOR.md +115 -0
  22. package/dist/cli.min.js +62 -26
  23. package/package.json +1 -1
  24. package/scripts/generate-claude-assets.js +273 -20
  25. package/scripts/generate-claude-assets.test.js +228 -0
  26. package/src/mcp/vesper-indexing/package-lock.json +2 -2
  27. package/src/mcp/vesper-indexing/package.json +1 -1
@@ -13,12 +13,20 @@
13
13
  * anything at install time. The 12 core agents + the orchestrator playbook are hand-authored
14
14
  * separately in assets/claude/ and are never touched by this script.
15
15
  *
16
+ * NOT scanned or written by this script (by design): `assets/vendor/` — reviewed,
17
+ * pinned snapshots of third-party assets (e.g. `assets/vendor/i-have-adhd/`). It is
18
+ * outside the four scan roots (agents/prompts/skills/instructions) and the three write
19
+ * targets (assets/claude/{agents,commands,skills}), so the generator can never read,
20
+ * transform, or clobber it. Vendored assets are installed project-level at init time
21
+ * (see installAdhdProjectAsset in src/cli/utils/claude-target.js), never generated here.
22
+ *
16
23
  * Run: node scripts/generate-claude-assets.js
17
24
  */
18
25
 
19
26
  import { readdir, readFile, writeFile, mkdir, cp, stat } from 'node:fs/promises';
20
27
  import { join, dirname } from 'node:path';
21
- import { fileURLToPath } from 'node:url';
28
+ import { fileURLToPath, pathToFileURL } from 'node:url';
29
+ import { MCP_SERVERS } from '../src/cli/utils/settings.js';
22
30
 
23
31
  const __filename = fileURLToPath(import.meta.url);
24
32
  const __dirname = dirname(__filename);
@@ -50,13 +58,20 @@ const TOOL_MAP = {
50
58
  'search': 'Grep, Glob',
51
59
  'todo': 'TodoWrite',
52
60
  'agent': 'Agent',
61
+ 'agent/runSubagent': 'Agent', // namespaced Copilot spelling of the subagent-invocation capability; equivalent to bare `agent`
53
62
  'vscode/askQuestions': 'AskUserQuestion',
54
63
  'web/fetch': 'WebFetch',
55
64
  'web': 'WebFetch, WebSearch',
56
65
  };
57
66
 
58
- /** Pure-VS-Code tokens with no Claude Code equivalent — dropped, not mapped */
67
+ /**
68
+ * Copilot / VS Code-only tokens with no Claude Code equivalent — INTENTIONALLY dropped, not mapped.
69
+ * Every entry here is reviewable, greppable policy: a token in this set is dropped silently and does
70
+ * NOT trip the unknown-token warning below. A token that is neither here, in TOOL_MAP, nor in
71
+ * MCP_PASSTHROUGH is treated as a hazard and warned about (see mapToolTokens).
72
+ */
59
73
  const DROP_TOKENS = new Set([
74
+ // VS Code workspace/editor capabilities with no Claude Code analogue.
60
75
  'vscode/getProjectSetupInfo',
61
76
  'vscode/installExtension',
62
77
  'vscode/newWorkspace',
@@ -65,10 +80,25 @@ const DROP_TOKENS = new Set([
65
80
  'vscode/vscodeAPI',
66
81
  'vscode/extensions',
67
82
  'vscode',
83
+ // Copilot-only task/terminal/diagnostic capabilities — no Claude Code equivalent.
84
+ 'read/problems', // VS Code Problems panel; Claude has no diagnostics feed
85
+ 'execute/runTask', // VS Code task runner; Claude uses Bash directly
86
+ 'execute/getTaskOutput', // paired with runTask; N/A in Claude
87
+ 'read/getTaskOutput', // Copilot task-runner readback (read a VS Code task's output); sibling of execute/getTaskOutput. No Claude equivalent.
88
+ 'read/terminalLastCommand', // VS Code terminal introspection
89
+ 'read/terminalSelection', // VS Code terminal introspection
90
+ // Copilot-only search/context capabilities with no Claude Code analogue.
91
+ 'web/githubRepo', // Copilot GitHub repo context provider
92
+ 'findTestFiles', // Copilot test-file locator; use Glob/Grep in Claude
93
+ 'search/searchResults', // Copilot search-results context provider
94
+ 'search/usages', // Copilot symbol-usages provider
95
+ // Copilot-only MCP/context providers with no Claude equivalent in this catalog.
96
+ 'microsoft.docs.mcp', // Copilot-bundled Microsoft Docs MCP
97
+ 'github', // Copilot GitHub context provider
68
98
  ]);
69
99
 
70
100
  /** MCP-namespaced tokens pass through as mcp__<server>__* wildcards (kept as a note, not expanded) */
71
- const MCP_PASSTHROUGH = ['vesper-indexing/*', 'memory/*', 'sequential-thinking/*', 'context7/*'];
101
+ const MCP_PASSTHROUGH = ['vesper-indexing/*', 'memory/*', 'sequential-thinking/*', 'context7/*', 'clickup/*'];
72
102
 
73
103
  // Pinned model IDs, not floating aliases (`opus`/`sonnet`/`haiku`) — aliases
74
104
  // silently track the newest release of each family, which means unreviewed
@@ -82,25 +112,83 @@ const MODEL_MAP = {
82
112
  };
83
113
 
84
114
  /**
85
- * Parse a simple `tools: [...]` frontmatter array (single or multi-line) into a token list.
115
+ * Normalize a single tool item: trim, then strip a matching pair of surrounding quotes.
116
+ * @param {string} raw
117
+ * @returns {string}
118
+ */
119
+ function normalizeToolItem(raw) {
120
+ return raw.trim().replace(/^['"]|['"]$/g, '');
121
+ }
122
+
123
+ /**
124
+ * Parse a `tools:` frontmatter value into a token list. Accepts BOTH forms:
125
+ * - bracket array: `tools: [a, b, c]` (single or multi-line)
126
+ * - YAML dash-list: `tools:` followed by ` - a` / ` - b` lines
127
+ *
128
+ * The dash-list branch is DEFECT 1's fix: without it, a `tools:` written as a
129
+ * YAML sequence matches no branch and the generated agent ships with no `tools:`
130
+ * key at all — which, per Claude Code docs, silently inherits ALL tools (including
131
+ * every live MCP tool). Parser-only fix: authored dash-list sources are NOT rewritten.
86
132
  * @param {string} frontmatter
87
133
  * @returns {string[]}
88
134
  */
89
135
  function extractToolTokens(frontmatter) {
90
- const match = frontmatter.match(/tools:\s*\[([\s\S]*?)\]/);
91
- if (!match) return [];
92
- return match[1]
93
- .split(',')
94
- .map(t => t.trim().replace(/^['"]|['"]$/g, ''))
95
- .filter(Boolean);
136
+ // Bracket-array form first (unchanged behavior).
137
+ const bracket = frontmatter.match(/tools:\s*\[([\s\S]*?)\]/);
138
+ if (bracket) {
139
+ return bracket[1]
140
+ .split(',')
141
+ .map(normalizeToolItem)
142
+ .filter(Boolean);
143
+ }
144
+
145
+ // YAML dash-list form: a bare `tools:` line (optionally carrying a trailing
146
+ // `# comment`, tolerated for the same reason per-item comments are), then one
147
+ // or more `- <token>` items. Tolerating the header comment avoids a silent
148
+ // fall-through to `[]` that would be indistinguishable from a genuinely empty
149
+ // list — the exact silent-token-loss shape this whole change exists to close.
150
+ const lines = frontmatter.split('\n');
151
+ const startIdx = lines.findIndex(l => /^\s*tools:\s*(#.*)?$/.test(l));
152
+ if (startIdx === -1) return [];
153
+
154
+ const tokens = [];
155
+ for (let i = startIdx + 1; i < lines.length; i++) {
156
+ const line = lines[i];
157
+ const dash = line.match(/^\s*-\s+(.*)$/); // tolerate mixed indentation
158
+ if (dash) {
159
+ // Strip a trailing inline `# comment`, then normalize (trim + unquote).
160
+ const item = normalizeToolItem(dash[1].replace(/\s+#.*$/, ''));
161
+ if (item) tokens.push(item);
162
+ continue;
163
+ }
164
+ if (line.trim() === '') continue; // tolerate blank lines within the block
165
+ break; // next non-indented, non-dash key ends the block (empty list -> [])
166
+ }
167
+ return tokens;
96
168
  }
97
169
 
170
+ /**
171
+ * Tracks whether ANY unknown tool token was seen during this process's generation run.
172
+ * Consulted by main() to fail the build under CI / --strict. Warn-and-continue means a
173
+ * single stray token never aborts generation mid-run and corrupts every other agent's output.
174
+ */
175
+ let sawUnknownToken = false;
176
+
177
+ /**
178
+ * Tracks whether ANY `applyTo` glob could not be translated to natural language during this run.
179
+ * Consulted by main() to fail the build under CI / --strict, mirroring `sawUnknownToken`. Emitting
180
+ * raw glob syntax into a semantically-matched Claude skill description is a regression we refuse to
181
+ * ship silently — an unmappable token warns loudly and hard-fails strict runs.
182
+ */
183
+ let sawUnmappableGlob = false;
184
+
98
185
  /**
99
186
  * Convert a Copilot tool-token list into a Claude Code `tools:` frontmatter value.
100
187
  * @param {string[]} tokens
188
+ * @param {string} [agentName] - source agent, threaded through for useful warnings
101
189
  * @returns {string}
102
190
  */
103
- function mapToolTokens(tokens) {
191
+ function mapToolTokens(tokens, agentName) {
104
192
  const claudeTools = new Set();
105
193
  const mcpNotes = new Set();
106
194
 
@@ -113,15 +201,33 @@ function mapToolTokens(tokens) {
113
201
  const mapped = TOOL_MAP[token];
114
202
  if (mapped) {
115
203
  mapped.split(',').forEach(t => claudeTools.add(t.trim()));
204
+ continue;
116
205
  }
206
+ // Unknown token: not mapped, not an MCP passthrough, not an intentional drop.
207
+ // This is exactly the diagnostic that would have surfaced DEFECT 2 (`clickup/*`)
208
+ // instead of letting it vanish. Warn and continue; CI/--strict escalates in main().
209
+ sawUnknownToken = true;
210
+ const where = agentName ? ` (in agent "${agentName}")` : '';
211
+ console.warn(
212
+ ` [unknown-tool-token] "${token}"${where} matches no TOOL_MAP, MCP_PASSTHROUGH, or DROP_TOKENS entry — dropped. ` +
213
+ `Map it, add it to MCP_PASSTHROUGH, or add it to DROP_TOKENS if intentional.`
214
+ );
117
215
  }
118
216
 
119
217
  // Expand known MCP servers to their concrete tool names (best-effort; unknown servers noted as-is).
218
+ //
219
+ // The four entries below are a DELIBERATELY CURATED read-mostly SUBSET — NOT a mirror of
220
+ // MCP_SERVERS (which is the full auto-approval superset: memory has 9 tools incl. delete_*/
221
+ // read_graph, context7 has 3). Deriving these from MCP_SERVERS by rule would grant destructive
222
+ // tools to every agent declaring `memory/*`, re-creating the fail-open class this file fixes.
223
+ // Leave them literal. `clickup` is the sole exception: it has no curated subset, so its full
224
+ // set is sourced from the single source of truth in settings.js (MCP_SERVERS.clickup.tools).
120
225
  const MCP_TOOL_NAMES = {
121
226
  'vesper-indexing': ['mcp__vesper-indexing__vesper_search', 'mcp__vesper-indexing__vesper_symbols', 'mcp__vesper-indexing__vesper_status', 'mcp__vesper-indexing__vesper_reindex'],
122
227
  'memory': ['mcp__memory__create_entities', 'mcp__memory__create_relations', 'mcp__memory__add_observations', 'mcp__memory__search_nodes', 'mcp__memory__open_nodes'],
123
228
  'sequential-thinking': ['mcp__sequential-thinking__sequentialthinking'],
124
229
  'context7': ['mcp__context7__resolve-library-id', 'mcp__context7__get-library-docs'],
230
+ 'clickup': MCP_SERVERS.clickup.tools.map(t => `mcp__clickup__${t}`),
125
231
  };
126
232
  for (const server of mcpNotes) {
127
233
  (MCP_TOOL_NAMES[server] || []).forEach(t => claudeTools.add(t));
@@ -146,7 +252,7 @@ function convertAgent(content, kebabName) {
146
252
 
147
253
  const description = descriptionMatch ? descriptionMatch[1] : `${kebabName} specialist agent`;
148
254
  const model = modelMatch ? (MODEL_MAP[modelMatch[1]] || 'claude-sonnet-5') : 'claude-sonnet-5';
149
- const tools = mapToolTokens(extractToolTokens(frontmatter));
255
+ const tools = mapToolTokens(extractToolTokens(frontmatter), kebabName);
150
256
 
151
257
  const newFrontmatter = [
152
258
  `name: ${kebabName}`,
@@ -183,6 +289,128 @@ function convertPrompt(content) {
183
289
  return newFrontmatter ? `---\n${newFrontmatter}\n---\n${cleanedBody}` : cleanedBody;
184
290
  }
185
291
 
292
+ /**
293
+ * Map a bare file extension (no dot) to a human-readable language/format name. Deliberately literal
294
+ * and reviewable — it covers exactly the extensions the six in-tree instruction sources reference,
295
+ * plus a small margin of common ones. Do NOT grow this speculatively: an unmapped extension trips the
296
+ * LOUD `sawUnmappableGlob` path in describeApplyTo(), which is the signal to extend this map on demand.
297
+ */
298
+ const EXT_LANG = {
299
+ py: 'Python',
300
+ js: 'JavaScript',
301
+ ts: 'TypeScript',
302
+ tsx: 'TSX',
303
+ jsx: 'JSX',
304
+ java: 'Java',
305
+ cs: 'C#',
306
+ cpp: 'C++',
307
+ c: 'C',
308
+ go: 'Go',
309
+ rb: 'Ruby',
310
+ css: 'CSS',
311
+ scss: 'SCSS',
312
+ md: 'Markdown',
313
+ json: 'JSON',
314
+ html: 'HTML',
315
+ };
316
+
317
+ /**
318
+ * Curated natural-language phrases for path-scoped applyTo globs — tokens that carry an embedded
319
+ * directory path (beyond a leading globstar) and therefore have no honest trailing-extension translation.
320
+ * A naive "trailing .md -> Markdown files" guess for the task-implementation entry would wrongly
321
+ * re-introduce over-breadth, so path-scoped globs are looked up here or fail LOUD. A stale/unused key
322
+ * is harmless residue (it simply never matches); prune only when a source stops using it.
323
+ */
324
+ const GLOB_OVERRIDES = {
325
+ '**/.vesper/.tracking/changes/*.md': 'task change-record files under .vesper/.tracking/changes/',
326
+ };
327
+
328
+ /**
329
+ * Render a list as an Oxford-style human phrase: "A", "A or B", "A, B, or C".
330
+ * @param {string[]} items
331
+ * @returns {string}
332
+ */
333
+ function oxfordList(items) {
334
+ if (items.length === 0) return '';
335
+ if (items.length === 1) return items[0];
336
+ if (items.length === 2) return `${items[0]} or ${items[1]}`;
337
+ return `${items.slice(0, -1).join(', ')}, or ${items[items.length - 1]}`;
338
+ }
339
+
340
+ /**
341
+ * Translate an `applyTo` glob list into a natural-language scope clause for a Claude skill
342
+ * description. Pure and exported for unit testing — no I/O, no writes.
343
+ *
344
+ * Contract:
345
+ * - Extension-mappable tokens (no embedded directory path — a leading globstar is allowed — ending in
346
+ * a dotted extension, incl. bare filenames like `vite.config.ts`) resolve via EXT_LANG to a name.
347
+ * - Path-scoped tokens (an embedded slash beyond a leading globstar, e.g. the task-implementation
348
+ * tracking-changes glob) resolve via GLOB_OVERRIDES to a curated phrase; a miss is
349
+ * pushed to `unmapped` — we NEVER emit raw glob syntax and NEVER naively guess the trailing ext.
350
+ * - Any unmappable token warns (naming skill + token + remediation), sets the module-level
351
+ * `sawUnmappableGlob` flag (which main() escalates under CI/--strict), and is omitted from the
352
+ * clause — degrade loudly, never silently.
353
+ *
354
+ * @param {string} applyTo - raw comma-separated applyTo value ('' when absent)
355
+ * @param {string} [skillName] - source skill name, threaded through for useful warnings
356
+ * @returns {{ clause: string, unmapped: string[] }} clause includes a leading space (or '' when empty)
357
+ */
358
+ function describeApplyTo(applyTo, skillName) {
359
+ const unmapped = [];
360
+ if (!applyTo || !applyTo.trim()) return { clause: '', unmapped };
361
+
362
+ const tokens = applyTo.split(',').map(t => t.trim()).filter(Boolean);
363
+ const languages = [];
364
+ const phrases = [];
365
+
366
+ for (const token of tokens) {
367
+ // A single leading `**/` is a "match anywhere" prefix, not an embedded path — strip it before
368
+ // deciding whether the remainder is path-scoped.
369
+ const stripped = token.replace(/^\*\*\//, '');
370
+
371
+ if (stripped.includes('/')) {
372
+ // Path-scoped: only a curated override is acceptable; a miss is LOUD, never a raw-glob leak.
373
+ const override = GLOB_OVERRIDES[token];
374
+ if (override) {
375
+ if (!phrases.includes(override)) phrases.push(override);
376
+ } else {
377
+ unmapped.push(token);
378
+ }
379
+ continue;
380
+ }
381
+
382
+ const extMatch = stripped.match(/\.([A-Za-z0-9]+)$/);
383
+ if (extMatch) {
384
+ const lang = EXT_LANG[extMatch[1].toLowerCase()];
385
+ if (lang) {
386
+ if (!languages.includes(lang)) languages.push(lang);
387
+ } else {
388
+ unmapped.push(token);
389
+ }
390
+ continue;
391
+ }
392
+
393
+ // Neither path-scoped nor a recognizable `.<ext>` token.
394
+ unmapped.push(token);
395
+ }
396
+
397
+ for (const token of unmapped) {
398
+ sawUnmappableGlob = true;
399
+ const where = skillName ? ` (in skill "${skillName}")` : '';
400
+ console.warn(
401
+ ` [unmappable-applyto-glob] "${token}"${where} could not be translated to natural language — omitted. ` +
402
+ `Extend EXT_LANG (for a new extension) or GLOB_OVERRIDES (for a path-scoped glob) to map it.`
403
+ );
404
+ }
405
+
406
+ const pieces = [];
407
+ if (languages.length) pieces.push(`${oxfordList(languages)} files`);
408
+ for (const phrase of phrases) pieces.push(phrase);
409
+
410
+ const clause = pieces.length ? ` Use when working with ${oxfordList(pieces)}.` : '';
411
+ return { clause, unmapped };
412
+ }
413
+
186
414
  /**
187
415
  * Convert an `.instructions.md` file into a new Skill (SKILL.md).
188
416
  * @param {string} content
@@ -197,11 +425,15 @@ function convertInstructionToSkill(content, kebabName) {
197
425
  const descriptionMatch = frontmatter.match(/description:\s*['"]?(.*?)['"]?\s*(\n|$)/);
198
426
  const applyToMatch = frontmatter.match(/applyTo:\s*['"]?(.*?)['"]?\s*(\n|$)/);
199
427
 
200
- const baseDescription = descriptionMatch ? descriptionMatch[1] : `${kebabName} conventions`;
428
+ const rawBaseDescription = descriptionMatch ? descriptionMatch[1] : `${kebabName} conventions`;
201
429
  const applyTo = applyToMatch ? applyToMatch[1] : '';
202
- const description = applyTo
203
- ? `${baseDescription} Use when working with files matching: ${applyTo}.`
204
- : baseDescription;
430
+ const { clause } = describeApplyTo(applyTo, kebabName);
431
+ // Normalize the base description before appending the scope clause: trim trailing whitespace and
432
+ // ensure it ends in terminal punctuation, so the clause's leading space doesn't run the two
433
+ // sentences together (e.g. "…Java base applications Use when…"). Only append when there IS a clause.
434
+ const trimmedBase = rawBaseDescription.trimEnd();
435
+ const normalizedBase = clause && !/[.!?]$/.test(trimmedBase) ? `${trimmedBase}.` : trimmedBase;
436
+ const description = `${normalizedBase}${clause}`;
205
437
 
206
438
  const newFrontmatter = [`name: ${kebabName}`, `description: ${description}`].join('\n');
207
439
  const cleanedBody = applyBodyTokenReplacements(body);
@@ -375,9 +607,30 @@ async function main() {
375
607
  console.log(`✓ instructions: ${instructionSkillCount} converted to skills -> assets/claude/skills/`);
376
608
 
377
609
  console.log('\nDone. Hand-authored content (assets/claude/agents/vesper-*.md, vesper-orchestrator.md, *.tpl, commands/vesper-*.md) is untouched by this script.');
610
+
611
+ // Escalate unknown tokens in CI (or when --strict is passed): an unmapped token means a
612
+ // mis-permissioned agent would ship. Locally we warn-and-continue; CI must hard-fail.
613
+ const strict = process.argv.includes('--strict') || Boolean(process.env.CI);
614
+ if (sawUnknownToken && strict) {
615
+ console.error('\n✗ Unknown tool token(s) encountered (see warnings above). Failing build (CI/--strict).');
616
+ process.exitCode = 1;
617
+ }
618
+ if (sawUnmappableGlob && strict) {
619
+ console.error('\n✗ Unmappable applyTo glob(s) encountered (see warnings above). Failing build (CI/--strict).');
620
+ process.exitCode = 1;
621
+ }
622
+ }
623
+
624
+ // Only run generation when executed directly as a script — importing the module
625
+ // (e.g. from vitest) must NOT trigger a full catalog regeneration. Guard on
626
+ // `process.argv[1]` first: when unset (e.g. `node --input-type=module -e`),
627
+ // `pathToFileURL(undefined)` throws ERR_INVALID_ARG_TYPE, so the import-safety
628
+ // guarantee must not depend on argv[1] being populated.
629
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
630
+ main().catch((error) => {
631
+ console.error('Generation failed:', error);
632
+ process.exit(1);
633
+ });
378
634
  }
379
635
 
380
- main().catch((error) => {
381
- console.error('Generation failed:', error);
382
- process.exit(1);
383
- });
636
+ export { extractToolTokens, mapToolTokens, convertAgent, describeApplyTo, convertInstructionToSkill };
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ import { extractToolTokens, mapToolTokens, convertAgent, describeApplyTo, convertInstructionToSkill } from './generate-claude-assets.js';
8
+ import { MCP_SERVERS } from '../src/cli/utils/settings.js';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const readInstructionFixture = (name) =>
12
+ readFileSync(join(__dirname, '..', 'assets', 'instructions', name, `${name}.instructions.md`), 'utf-8');
13
+ const descriptionLineOf = (skillMd) =>
14
+ skillMd.split('\n').find((l) => l.startsWith('description:'));
15
+
16
+ describe('extractToolTokens', () => {
17
+ it('parses a bracket-array tools list (unchanged behavior)', () => {
18
+ const fm = `name: x\ntools: ['clickup/*', 'search', "read", 'memory/*']\nagents: []`;
19
+ expect(extractToolTokens(fm)).toEqual(['clickup/*', 'search', 'read', 'memory/*']);
20
+ });
21
+
22
+ it('parses a YAML dash-list tools block equivalently to the bracket form (DEFECT 1)', () => {
23
+ const dashList = [
24
+ 'name: x',
25
+ 'tools:',
26
+ ' - search',
27
+ ' - read',
28
+ ' - edit',
29
+ ' - execute',
30
+ ' - agent',
31
+ ' - todo',
32
+ " - 'vesper-indexing/*'",
33
+ " - 'memory/*'",
34
+ 'agents: []',
35
+ ].join('\n');
36
+
37
+ const bracket = "name: x\ntools: [search, read, edit, execute, agent, todo, 'vesper-indexing/*', 'memory/*']\nagents: []";
38
+
39
+ expect(extractToolTokens(dashList)).toEqual(extractToolTokens(bracket));
40
+ expect(extractToolTokens(dashList)).toContain('vesper-indexing/*');
41
+ expect(extractToolTokens(dashList)).toContain('memory/*');
42
+ });
43
+
44
+ it('strips surrounding quotes on dash items', () => {
45
+ const fm = `tools:\n - 'memory/*'\n - "search"\nagents: []`;
46
+ expect(extractToolTokens(fm)).toEqual(['memory/*', 'search']);
47
+ });
48
+
49
+ it('strips trailing inline # comments on dash items', () => {
50
+ const fm = `tools:\n - read # legacy\n - search # keep\nagents: []`;
51
+ expect(extractToolTokens(fm)).toEqual(['read', 'search']);
52
+ });
53
+
54
+ it('tolerates mixed indentation across dash items', () => {
55
+ const fm = `tools:\n - read\n - search\n - edit\nnext: []`;
56
+ expect(extractToolTokens(fm)).toEqual(['read', 'search', 'edit']);
57
+ });
58
+
59
+ it('returns [] for an empty tools list without consuming the next key', () => {
60
+ const fm = `tools:\nagents: []`;
61
+ expect(extractToolTokens(fm)).toEqual([]);
62
+ });
63
+
64
+ it('tolerates a trailing inline comment on the tools: header line itself', () => {
65
+ const fm = `tools: # see below\n - read\n - search\nagents: []`;
66
+ expect(extractToolTokens(fm)).toEqual(['read', 'search']);
67
+ });
68
+ });
69
+
70
+ describe('convertAgent (end-to-end frontmatter emission — locks DEFECT 1 at the layer that broke)', () => {
71
+ it('emits a real tools: line for a dash-list source with real tokens (the actual defect)', () => {
72
+ const src = [
73
+ '---',
74
+ "description: 'demo'",
75
+ "model: 'Claude Haiku 4.5'",
76
+ 'name: Demo',
77
+ 'tools:',
78
+ ' - read',
79
+ ' - edit',
80
+ " - 'memory/*'",
81
+ 'agents: []',
82
+ '---',
83
+ 'Body prose.',
84
+ ].join('\n');
85
+
86
+ const out = convertAgent(src, 'demo');
87
+ // The regression lived in convertAgent's `tools ? ... : null` ternary: a dropped
88
+ // key here is exactly what produced inherit-all. Assert the key is present and populated.
89
+ const toolsLine = out.split('\n').find(l => l.startsWith('tools:'));
90
+ expect(toolsLine).toBeDefined();
91
+ expect(toolsLine).toContain('Read');
92
+ expect(toolsLine).toContain('Edit');
93
+ expect(toolsLine).toContain('mcp__memory__create_entities');
94
+ });
95
+
96
+ it('omits the tools: key entirely when the source declares genuinely no tools', () => {
97
+ const src = [
98
+ '---',
99
+ "description: 'no tools'",
100
+ "model: 'Claude Haiku 4.5'",
101
+ 'name: Bare',
102
+ 'agents: []',
103
+ '---',
104
+ 'Body prose.',
105
+ ].join('\n');
106
+
107
+ const out = convertAgent(src, 'bare');
108
+ expect(out.split('\n').some(l => l.startsWith('tools:'))).toBe(false);
109
+ });
110
+ });
111
+
112
+ describe('mapToolTokens', () => {
113
+ it('expands clickup/* to all mcp__clickup__* names from the settings.js source of truth (DEFECT 2)', () => {
114
+ const expected = MCP_SERVERS.clickup.tools.map(t => `mcp__clickup__${t}`);
115
+ const result = mapToolTokens(['clickup/*']).split(', ');
116
+ expect(result).toEqual(expected);
117
+ expect(expected).toHaveLength(32);
118
+ });
119
+
120
+ it('maps the namespaced agent/runSubagent token to the Agent tool (fail-closed capability restore)', () => {
121
+ expect(mapToolTokens(['agent/runSubagent']).split(', ')).toContain('Agent');
122
+ });
123
+
124
+ it('does not warn on intentional DROP_TOKENS entries', () => {
125
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
126
+ const drops = ['read/problems', 'execute/runTask', 'web/githubRepo', 'findTestFiles', 'github'];
127
+ const result = mapToolTokens(drops);
128
+ expect(result).toBe('');
129
+ expect(warn).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it('drops read/getTaskOutput silently (intentional Copilot-only drop, no warning)', () => {
133
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
134
+ const result = mapToolTokens(['read/getTaskOutput']);
135
+ expect(result).toBe('');
136
+ expect(warn).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it('warns (naming token + agent) on an unrecognized token', () => {
140
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
141
+ mapToolTokens(['totally/madeUpTool'], 'demo-agent');
142
+ expect(warn).toHaveBeenCalledTimes(1);
143
+ const msg = warn.mock.calls[0][0];
144
+ expect(msg).toContain('totally/madeUpTool');
145
+ expect(msg).toContain('demo-agent');
146
+ });
147
+ });
148
+
149
+ describe('describeApplyTo', () => {
150
+ it('translates a single extension glob to a language clause', () => {
151
+ expect(describeApplyTo('**/*.java', 'java')).toEqual({
152
+ clause: ' Use when working with Java files.',
153
+ unmapped: [],
154
+ });
155
+ });
156
+
157
+ it('dedupes and preserves first-seen order across a multi-extension list (reactjs)', () => {
158
+ expect(describeApplyTo('**/*.jsx, **/*.tsx, **/*.js, **/*.ts, **/*.css, **/*.scss', 'reactjs')).toEqual({
159
+ clause: ' Use when working with JSX, TSX, JavaScript, TypeScript, CSS, or SCSS files.',
160
+ unmapped: [],
161
+ });
162
+ });
163
+
164
+ it('maps a bare filename via its extension (no path component)', () => {
165
+ expect(describeApplyTo('vite.config.ts', 'tailwind-v4-vite')).toEqual({
166
+ clause: ' Use when working with TypeScript files.',
167
+ unmapped: [],
168
+ });
169
+ });
170
+
171
+ it('returns an empty clause for empty applyTo', () => {
172
+ expect(describeApplyTo('', 'anything')).toEqual({ clause: '', unmapped: [] });
173
+ });
174
+
175
+ it('uses the GLOB_OVERRIDES phrase for a path-scoped hit', () => {
176
+ expect(describeApplyTo('**/.vesper/.tracking/changes/*.md', 'task-implementation')).toEqual({
177
+ clause: ' Use when working with task change-record files under .vesper/.tracking/changes/.',
178
+ unmapped: [],
179
+ });
180
+ });
181
+
182
+ it('reports a path-scoped miss as unmapped (LOUD, not silent) and omits it from the clause', () => {
183
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
184
+ const result = describeApplyTo('src/weird/**/*.md', 'fabricated');
185
+ expect(result.unmapped).toContain('src/weird/**/*.md');
186
+ expect(result.clause).toBe('');
187
+ expect(warn).toHaveBeenCalled();
188
+ expect(warn.mock.calls[0][0]).toContain('src/weird/**/*.md');
189
+ });
190
+
191
+ it('reports an unknown extension as unmapped', () => {
192
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
193
+ const result = describeApplyTo('**/*.zig', 'fabricated');
194
+ expect(result.unmapped).toContain('**/*.zig');
195
+ expect(result.clause).toBe('');
196
+ expect(warn).toHaveBeenCalled();
197
+ });
198
+ });
199
+
200
+ // Caller-layer coverage: asserts on convertInstructionToSkill's ACTUAL emitted description, not just
201
+ // the helper in isolation. A regression that bypasses describeApplyTo and reintroduces a raw-glob paste
202
+ // directly in the caller (the helper-vs-caller gap that hid the tool-token defect) would pass every
203
+ // describeApplyTo unit test but fail here.
204
+ describe('convertInstructionToSkill (caller-layer output over real instruction fixtures)', () => {
205
+ it('emits a translated language clause and NO raw glob for an extension-scoped instruction (reactjs)', () => {
206
+ const out = convertInstructionToSkill(readInstructionFixture('reactjs'), 'reactjs');
207
+ const desc = descriptionLineOf(out);
208
+ expect(desc).toContain('Use when working with JSX, TSX, JavaScript, TypeScript, CSS, or SCSS files.');
209
+ expect(desc).not.toMatch(/\*\*\//); // no globstar
210
+ expect(desc).not.toMatch(/\*\./); // no `*.ext` raw glob
211
+ });
212
+
213
+ it('emits the GLOB_OVERRIDES phrase and NO raw glob for the path-scoped instruction (task-implementation)', () => {
214
+ const out = convertInstructionToSkill(readInstructionFixture('task-implementation'), 'task-implementation');
215
+ const desc = descriptionLineOf(out);
216
+ expect(desc).toContain('Use when working with task change-record files under .vesper/.tracking/changes/.');
217
+ expect(desc).not.toMatch(/\*\*\//);
218
+ expect(desc).not.toMatch(/\*\./);
219
+ });
220
+
221
+ it('normalizes a base description lacking terminal punctuation so the clause does not run on (java)', () => {
222
+ const out = convertInstructionToSkill(readInstructionFixture('java'), 'java');
223
+ const desc = descriptionLineOf(out);
224
+ // Base is "Guidelines for building Java base applications" (no period) — must gain one at the seam.
225
+ expect(desc).toContain('Java base applications. Use when working with Java files.');
226
+ expect(desc).not.toContain('applications Use when'); // the run-on the fix removes
227
+ });
228
+ });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@vesper/indexing",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@vesper/indexing",
9
- "version": "1.4.1",
9
+ "version": "1.5.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@modelcontextprotocol/sdk": "^1.12.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vesper/indexing",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Local MCP server for fast codebase indexing and semantic search",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",