@jqntn/agentdoctor 0.1.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.
@@ -0,0 +1,849 @@
1
+ import {
2
+ SETTINGS_KEYS, PERMISSION_KEYS, HOOK_EVENTS, MATCHER_EVENTS,
3
+ TOOL_NAMES, MODEL_ALIASES,
4
+ } from '../constants.js';
5
+ import { basename, dirname } from 'node:path';
6
+
7
+ /** Levenshtein distance, capped for speed — only used for typo suggestions. */
8
+ export function editDistance(a, b, max = 3) {
9
+ if (a === b) return 0;
10
+ if (Math.abs(a.length - b.length) > max) return max + 1;
11
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
12
+ for (let i = 1; i <= a.length; i += 1) {
13
+ const row = [i];
14
+ let best = i;
15
+ for (let j = 1; j <= b.length; j += 1) {
16
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
17
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + cost);
18
+ best = Math.min(best, row[j]);
19
+ }
20
+ if (best > max) return max + 1;
21
+ prev = row;
22
+ }
23
+ return prev[b.length];
24
+ }
25
+
26
+ /** Closest known name, or null when nothing is plausibly a typo of `name`. */
27
+ export function suggest(name, candidates, max = 3) {
28
+ let best = null;
29
+ let bestScore = max + 1;
30
+ for (const candidate of candidates) {
31
+ const score = editDistance(name.toLowerCase(), candidate.toLowerCase(), max);
32
+ if (score < bestScore) {
33
+ bestScore = score;
34
+ best = candidate;
35
+ }
36
+ }
37
+ // A one-character difference on a short key is a typo; a large distance is
38
+ // just an unknown key and should not produce a misleading suggestion.
39
+ const threshold = name.length <= 6 ? 2 : 3;
40
+ return bestScore <= threshold ? best : null;
41
+ }
42
+
43
+ const slugify = (value) => String(value).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
44
+
45
+ export const correctnessRules = [
46
+ {
47
+ id: 'correctness/invalid-json',
48
+ category: 'correctness',
49
+ severity: 'error',
50
+ title: 'Config file is not valid JSON',
51
+ help: 'The harness cannot read this file, so every setting in it is silently ignored — including any permission rules you thought were protecting you.',
52
+ check({ files, report }) {
53
+ for (const file of files) {
54
+ if (!file.parseError) continue;
55
+ report({
56
+ file,
57
+ line: file.parseError.line,
58
+ column: file.parseError.column,
59
+ message: `${file.display} failed to parse: ${file.parseError.message}. The entire file is ignored.`,
60
+ });
61
+ }
62
+ },
63
+ },
64
+
65
+ {
66
+ id: 'correctness/unknown-settings-key',
67
+ category: 'correctness',
68
+ severity: 'warning',
69
+ title: 'Unrecognised settings key',
70
+ help: 'Unknown keys are ignored without warning, so a typo means the setting never applies.',
71
+ check({ files, report, helpers }) {
72
+ for (const file of files) {
73
+ if (file.kind !== 'settings' || !file.data || typeof file.data !== 'object') continue;
74
+ for (const key of Object.keys(file.data)) {
75
+ if (SETTINGS_KEYS.has(key)) continue;
76
+ const hint = suggest(key, SETTINGS_KEYS);
77
+ const position = helpers.at(file, key);
78
+ report({
79
+ file,
80
+ line: position.line,
81
+ column: position.column,
82
+ configPath: key,
83
+ severity: hint ? 'warning' : 'info',
84
+ message: hint
85
+ ? `"${key}" is not a known setting. Did you mean "${hint}"?`
86
+ : `"${key}" is not a setting agentdoctor recognises; it will be ignored unless a newer harness version added it.`,
87
+ });
88
+ }
89
+ }
90
+ },
91
+ },
92
+
93
+ {
94
+ id: 'correctness/unknown-permission-key',
95
+ category: 'correctness',
96
+ severity: 'warning',
97
+ title: 'Unrecognised key under permissions',
98
+ help: `Valid keys are: ${[...PERMISSION_KEYS].join(', ')}.`,
99
+ check({ files, report, helpers }) {
100
+ for (const file of files) {
101
+ if (file.kind !== 'settings') continue;
102
+ const perms = file.data?.permissions;
103
+ if (!perms || typeof perms !== 'object' || Array.isArray(perms)) continue;
104
+ for (const key of Object.keys(perms)) {
105
+ if (PERMISSION_KEYS.has(key)) continue;
106
+ const hint = suggest(key, PERMISSION_KEYS);
107
+ const position = helpers.at(file, `permissions.${key}`);
108
+ report({
109
+ file,
110
+ line: position.line,
111
+ column: position.column,
112
+ configPath: `permissions.${key}`,
113
+ message: hint
114
+ ? `permissions.${key} is not valid. Did you mean "${hint}"?`
115
+ : `permissions.${key} is not a recognised permission key and is ignored.`,
116
+ });
117
+ }
118
+ }
119
+ },
120
+ },
121
+
122
+ {
123
+ id: 'correctness/permissions-wrong-type',
124
+ category: 'correctness',
125
+ severity: 'error',
126
+ title: 'Permission bucket is not an array',
127
+ help: 'allow, deny and ask must each be an array of rule strings. A string or object here means the rules never load.',
128
+ check({ files, report, helpers }) {
129
+ for (const file of files) {
130
+ if (file.kind !== 'settings') continue;
131
+ const perms = file.data?.permissions;
132
+ if (!perms || typeof perms !== 'object') continue;
133
+ for (const bucket of ['allow', 'deny', 'ask', 'additionalDirectories']) {
134
+ const value = perms[bucket];
135
+ if (value === undefined || Array.isArray(value)) continue;
136
+ const position = helpers.at(file, `permissions.${bucket}`);
137
+ report({
138
+ file,
139
+ line: position.line,
140
+ column: position.column,
141
+ configPath: `permissions.${bucket}`,
142
+ message: `permissions.${bucket} must be an array, got ${describeType(value)}.`,
143
+ });
144
+ }
145
+ }
146
+ },
147
+ },
148
+
149
+ {
150
+ id: 'correctness/permission-unknown-tool',
151
+ category: 'correctness',
152
+ severity: 'warning',
153
+ title: 'Permission rule names an unknown tool',
154
+ help: 'Tool names are case-sensitive. A rule naming a tool that does not exist never matches anything, so a deny rule written this way protects nothing.',
155
+ check({ files, report, helpers }) {
156
+ for (const file of files) {
157
+ if (file.kind !== 'settings') continue;
158
+ for (const bucket of ['allow', 'deny', 'ask']) {
159
+ const rules = file.data?.permissions?.[bucket];
160
+ if (!Array.isArray(rules)) continue;
161
+ rules.forEach((rule, index) => {
162
+ if (typeof rule !== 'string') return;
163
+ const parsed = helpers.parsePermission(rule);
164
+ const tool = parsed.tool;
165
+ if (!tool) return;
166
+ if (tool.startsWith('mcp__')) return;
167
+ if (TOOL_NAMES.has(tool)) return;
168
+ const hint = suggest(tool, TOOL_NAMES);
169
+ const configPath = `permissions.${bucket}[${index}]`;
170
+ const position = helpers.at(file, configPath);
171
+ report({
172
+ file,
173
+ line: position.line,
174
+ column: position.column,
175
+ configPath,
176
+ snippet: rule,
177
+ severity: bucket === 'deny' ? 'error' : 'warning',
178
+ message: hint
179
+ ? `"${rule}" targets unknown tool "${tool}". Did you mean "${hint}"?${bucket === 'deny' ? ' This deny rule currently blocks nothing.' : ''}`
180
+ : `"${rule}" targets unknown tool "${tool}", so the rule never matches.`,
181
+ });
182
+ });
183
+ }
184
+ }
185
+ },
186
+ },
187
+
188
+ {
189
+ id: 'correctness/permission-non-string',
190
+ category: 'correctness',
191
+ severity: 'error',
192
+ title: 'Permission rule is not a string',
193
+ help: 'Each entry must be a string like "Bash(npm test:*)".',
194
+ check({ files, report, helpers }) {
195
+ for (const file of files) {
196
+ if (file.kind !== 'settings') continue;
197
+ for (const bucket of ['allow', 'deny', 'ask']) {
198
+ const rules = file.data?.permissions?.[bucket];
199
+ if (!Array.isArray(rules)) continue;
200
+ rules.forEach((rule, index) => {
201
+ if (typeof rule === 'string') return;
202
+ const configPath = `permissions.${bucket}[${index}]`;
203
+ const position = helpers.at(file, configPath);
204
+ report({
205
+ file,
206
+ line: position.line,
207
+ column: position.column,
208
+ configPath,
209
+ message: `permissions.${bucket}[${index}] is ${describeType(rule)}, expected a rule string.`,
210
+ });
211
+ });
212
+ }
213
+ }
214
+ },
215
+ },
216
+
217
+ {
218
+ id: 'correctness/duplicate-permission',
219
+ category: 'correctness',
220
+ severity: 'info',
221
+ title: 'Duplicate permission rule',
222
+ help: 'Harmless, but usually a sign of a merge that went wrong or a rule that was meant to be edited rather than added.',
223
+ check({ files, report, helpers }) {
224
+ for (const file of files) {
225
+ if (file.kind !== 'settings') continue;
226
+ for (const bucket of ['allow', 'deny', 'ask']) {
227
+ const rules = file.data?.permissions?.[bucket];
228
+ if (!Array.isArray(rules)) continue;
229
+ const seen = new Map();
230
+ rules.forEach((rule, index) => {
231
+ if (typeof rule !== 'string') return;
232
+ const key = rule.trim();
233
+ if (seen.has(key)) {
234
+ const configPath = `permissions.${bucket}[${index}]`;
235
+ const position = helpers.at(file, configPath);
236
+ report({
237
+ file,
238
+ line: position.line,
239
+ column: position.column,
240
+ configPath,
241
+ snippet: rule,
242
+ message: `"${rule}" is listed twice in permissions.${bucket} (first at index ${seen.get(key)}).`,
243
+ });
244
+ return;
245
+ }
246
+ seen.set(key, index);
247
+ });
248
+ }
249
+ }
250
+ },
251
+ },
252
+
253
+ {
254
+ id: 'correctness/allow-deny-conflict',
255
+ category: 'correctness',
256
+ severity: 'warning',
257
+ title: 'Same rule in both allow and deny',
258
+ help: 'Deny wins, so the allow entry is dead config. Remove it so the intent is unambiguous to the next reader.',
259
+ check({ files, report, helpers }) {
260
+ for (const file of files) {
261
+ if (file.kind !== 'settings') continue;
262
+ const perms = file.data?.permissions;
263
+ const allow = Array.isArray(perms?.allow) ? perms.allow : [];
264
+ const deny = Array.isArray(perms?.deny) ? perms.deny : [];
265
+ if (!allow.length || !deny.length) continue;
266
+ const denySet = new Set(deny.filter((r) => typeof r === 'string').map((r) => r.trim()));
267
+ allow.forEach((rule, index) => {
268
+ if (typeof rule !== 'string' || !denySet.has(rule.trim())) return;
269
+ const configPath = `permissions.allow[${index}]`;
270
+ const position = helpers.at(file, configPath);
271
+ report({
272
+ file,
273
+ line: position.line,
274
+ column: position.column,
275
+ configPath,
276
+ snippet: rule,
277
+ message: `"${rule}" appears in both allow and deny; deny takes precedence, so the allow entry has no effect.`,
278
+ });
279
+ });
280
+ }
281
+ },
282
+ },
283
+
284
+ {
285
+ id: 'correctness/unknown-hook-event',
286
+ category: 'correctness',
287
+ severity: 'error',
288
+ title: 'Unknown hook event',
289
+ help: `Valid events: ${[...HOOK_EVENTS].join(', ')}. Events are case-sensitive and a misspelled one never fires.`,
290
+ check({ files, report, helpers }) {
291
+ for (const file of files) {
292
+ if (file.kind !== 'settings') continue;
293
+ const hooks = file.data?.hooks;
294
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) continue;
295
+ for (const event of Object.keys(hooks)) {
296
+ if (HOOK_EVENTS.has(event)) continue;
297
+ const hint = suggest(event, HOOK_EVENTS);
298
+ const position = helpers.at(file, `hooks.${event}`);
299
+ report({
300
+ file,
301
+ line: position.line,
302
+ column: position.column,
303
+ configPath: `hooks.${event}`,
304
+ message: hint
305
+ ? `"${event}" is not a hook event. Did you mean "${hint}"? As written, this hook never runs.`
306
+ : `"${event}" is not a recognised hook event, so this hook never runs.`,
307
+ });
308
+ }
309
+ }
310
+ },
311
+ },
312
+
313
+ {
314
+ id: 'correctness/hook-malformed',
315
+ category: 'correctness',
316
+ severity: 'error',
317
+ title: 'Hook entry has the wrong shape',
318
+ help: 'Each event maps to an array of { matcher, hooks: [{ type: "command", command: "..." }] }. A near-miss shape is dropped silently.',
319
+ check({ files, report, helpers }) {
320
+ for (const file of files) {
321
+ if (file.kind !== 'settings') continue;
322
+ const hooks = file.data?.hooks;
323
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) continue;
324
+ for (const [event, matchers] of Object.entries(hooks)) {
325
+ const eventPath = `hooks.${event}`;
326
+ if (!Array.isArray(matchers)) {
327
+ const position = helpers.at(file, eventPath);
328
+ report({
329
+ file,
330
+ line: position.line,
331
+ column: position.column,
332
+ configPath: eventPath,
333
+ message: `hooks.${event} must be an array of matcher objects, got ${describeType(matchers)}.`,
334
+ });
335
+ continue;
336
+ }
337
+ matchers.forEach((entry, matcherIndex) => {
338
+ const entryPath = `${eventPath}[${matcherIndex}]`;
339
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
340
+ const position = helpers.at(file, entryPath);
341
+ report({
342
+ file,
343
+ line: position.line,
344
+ column: position.column,
345
+ configPath: entryPath,
346
+ message: `${entryPath} must be an object, got ${describeType(entry)}.`,
347
+ });
348
+ return;
349
+ }
350
+ if (!Array.isArray(entry.hooks)) {
351
+ const position = helpers.at(file, entryPath);
352
+ report({
353
+ file,
354
+ line: position.line,
355
+ column: position.column,
356
+ configPath: entryPath,
357
+ message: `${entryPath} is missing a "hooks" array, so nothing runs for this matcher.`,
358
+ });
359
+ return;
360
+ }
361
+ entry.hooks.forEach((hook, hookIndex) => {
362
+ const hookPath = `${entryPath}.hooks[${hookIndex}]`;
363
+ const position = helpers.at(file, hookPath);
364
+ if (hook === null || typeof hook !== 'object' || Array.isArray(hook)) {
365
+ report({
366
+ file, line: position.line, column: position.column, configPath: hookPath,
367
+ message: `${hookPath} must be an object with type and command.`,
368
+ });
369
+ return;
370
+ }
371
+ if (hook.type !== 'command') {
372
+ report({
373
+ file, line: position.line, column: position.column, configPath: hookPath,
374
+ message: `${hookPath}.type is ${JSON.stringify(hook.type)}; only "command" is supported.`,
375
+ });
376
+ }
377
+ if (typeof hook.command !== 'string' || hook.command.trim() === '') {
378
+ report({
379
+ file, line: position.line, column: position.column, configPath: `${hookPath}.command`,
380
+ message: `${hookPath}.command is missing or empty.`,
381
+ });
382
+ }
383
+ if (hook.timeout !== undefined && (typeof hook.timeout !== 'number' || hook.timeout <= 0)) {
384
+ report({
385
+ file, line: position.line, column: position.column, configPath: `${hookPath}.timeout`,
386
+ severity: 'warning',
387
+ message: `${hookPath}.timeout must be a positive number of seconds.`,
388
+ });
389
+ }
390
+ });
391
+ });
392
+ }
393
+ }
394
+ },
395
+ },
396
+
397
+ {
398
+ id: 'correctness/hook-matcher-ignored',
399
+ category: 'correctness',
400
+ severity: 'info',
401
+ title: 'Matcher set on an event that has no tool',
402
+ help: `Only ${[...MATCHER_EVENTS].join(', ')} use a matcher. Elsewhere it is ignored, which can look like the hook is scoped when it is not.`,
403
+ check({ files, report, helpers }) {
404
+ for (const file of files) {
405
+ if (file.kind !== 'settings') continue;
406
+ const hooks = file.data?.hooks;
407
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) continue;
408
+ for (const [event, matchers] of Object.entries(hooks)) {
409
+ if (!Array.isArray(matchers)) continue;
410
+ if (MATCHER_EVENTS.has(event) || !HOOK_EVENTS.has(event)) continue;
411
+ matchers.forEach((entry, index) => {
412
+ if (!entry || typeof entry.matcher !== 'string' || entry.matcher === '') return;
413
+ const configPath = `hooks.${event}[${index}].matcher`;
414
+ const position = helpers.at(file, configPath);
415
+ report({
416
+ file,
417
+ line: position.line,
418
+ column: position.column,
419
+ configPath,
420
+ message: `${event} has no associated tool, so matcher "${entry.matcher}" is ignored and the hook fires every time.`,
421
+ });
422
+ });
423
+ }
424
+ }
425
+ },
426
+ },
427
+
428
+ {
429
+ id: 'correctness/hook-matcher-invalid-regex',
430
+ category: 'correctness',
431
+ severity: 'error',
432
+ title: 'Hook matcher is not a valid pattern',
433
+ help: 'Matchers are treated as regular expressions. An invalid pattern means the hook silently never matches.',
434
+ check({ files, report, helpers }) {
435
+ for (const file of files) {
436
+ if (file.kind !== 'settings') continue;
437
+ const hooks = file.data?.hooks;
438
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) continue;
439
+ for (const [event, matchers] of Object.entries(hooks)) {
440
+ if (!Array.isArray(matchers)) continue;
441
+ matchers.forEach((entry, index) => {
442
+ const matcher = entry?.matcher;
443
+ if (typeof matcher !== 'string' || matcher === '' || matcher === '*') return;
444
+ try {
445
+ new RegExp(matcher);
446
+ } catch (error) {
447
+ const configPath = `hooks.${event}[${index}].matcher`;
448
+ const position = helpers.at(file, configPath);
449
+ report({
450
+ file,
451
+ line: position.line,
452
+ column: position.column,
453
+ configPath,
454
+ snippet: matcher,
455
+ message: `Matcher "${matcher}" is not a valid regular expression: ${error.message}.`,
456
+ });
457
+ }
458
+ });
459
+ }
460
+ }
461
+ },
462
+ },
463
+
464
+ {
465
+ id: 'correctness/hook-matcher-unknown-tool',
466
+ category: 'correctness',
467
+ severity: 'warning',
468
+ title: 'Hook matcher names no existing tool',
469
+ help: 'Check the spelling and casing of the tool name. A matcher that matches nothing is a hook that never fires.',
470
+ check({ files, report, helpers }) {
471
+ for (const file of files) {
472
+ if (file.kind !== 'settings') continue;
473
+ const hooks = file.data?.hooks;
474
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) continue;
475
+ for (const [event, matchers] of Object.entries(hooks)) {
476
+ if (!Array.isArray(matchers) || !MATCHER_EVENTS.has(event)) continue;
477
+ matchers.forEach((entry, index) => {
478
+ const matcher = entry?.matcher;
479
+ if (typeof matcher !== 'string' || matcher === '' || matcher === '*') return;
480
+ // '|' is alternation between tool names, which we handle below; any
481
+ // other regex metacharacter means we cannot enumerate the matches.
482
+ if (/[\\^$.[\]()?+{}]/.test(matcher)) return;
483
+ const names = matcher.split('|').map((n) => n.trim()).filter(Boolean);
484
+ const unknown = names.filter((n) => !TOOL_NAMES.has(n) && !n.startsWith('mcp__'));
485
+ if (unknown.length === 0) return;
486
+ const hint = suggest(unknown[0], TOOL_NAMES);
487
+ const configPath = `hooks.${event}[${index}].matcher`;
488
+ const position = helpers.at(file, configPath);
489
+ report({
490
+ file,
491
+ line: position.line,
492
+ column: position.column,
493
+ configPath,
494
+ snippet: matcher,
495
+ message: hint
496
+ ? `Matcher "${matcher}" names unknown tool "${unknown[0]}". Did you mean "${hint}"?`
497
+ : `Matcher "${matcher}" names unknown tool "${unknown[0]}", so this hook never fires.`,
498
+ });
499
+ });
500
+ }
501
+ }
502
+ },
503
+ },
504
+
505
+ {
506
+ id: 'correctness/invalid-model',
507
+ category: 'correctness',
508
+ severity: 'warning',
509
+ title: 'Unrecognised model name',
510
+ help: 'Use an alias (opus, sonnet, haiku) or a full model id. An unknown value falls back to the default without telling you.',
511
+ check({ files, report, helpers }) {
512
+ for (const file of files) {
513
+ if (file.kind !== 'settings') continue;
514
+ const model = file.data?.model;
515
+ if (model === undefined) continue;
516
+ if (typeof model !== 'string') {
517
+ const position = helpers.at(file, 'model');
518
+ report({ file, line: position.line, column: position.column, configPath: 'model', severity: 'error',
519
+ message: `model must be a string, got ${describeType(model)}.` });
520
+ continue;
521
+ }
522
+ if (MODEL_ALIASES.has(model)) continue;
523
+ if (/^claude-[a-z0-9.-]+$/i.test(model)) continue;
524
+ if (/^(us|eu|apac)\.anthropic\./i.test(model) || /^anthropic\./i.test(model)) continue;
525
+ const position = helpers.at(file, 'model');
526
+ const hint = suggest(model, MODEL_ALIASES);
527
+ report({
528
+ file, line: position.line, column: position.column, configPath: 'model',
529
+ message: hint
530
+ ? `model "${model}" is not recognised. Did you mean "${hint}"?`
531
+ : `model "${model}" does not look like a valid alias or model id.`,
532
+ });
533
+ }
534
+ },
535
+ },
536
+
537
+ {
538
+ id: 'correctness/agent-missing-frontmatter',
539
+ category: 'correctness',
540
+ severity: 'error',
541
+ title: 'Subagent definition has no frontmatter',
542
+ help: 'A subagent file needs a --- delimited frontmatter block with at least name and description. Without it the agent is not registered.',
543
+ check({ files, report }) {
544
+ for (const file of files) {
545
+ if (file.kind !== 'agent') continue;
546
+ if (file.frontmatter) continue;
547
+ report({
548
+ file,
549
+ line: 1,
550
+ message: `${file.display} has no frontmatter block, so this subagent will not be loaded.`,
551
+ });
552
+ }
553
+ },
554
+ },
555
+
556
+ {
557
+ id: 'correctness/agent-missing-field',
558
+ category: 'correctness',
559
+ severity: 'error',
560
+ title: 'Subagent is missing a required field',
561
+ help: 'Both name and description are required. The description is what the orchestrating model reads to decide whether to delegate, so an empty one means the agent is never chosen.',
562
+ check({ files, report, helpers }) {
563
+ for (const file of files) {
564
+ if (file.kind !== 'agent' || !file.frontmatter) continue;
565
+ for (const field of ['name', 'description']) {
566
+ const value = file.frontmatter[field];
567
+ if (typeof value === 'string' && value.trim() !== '') continue;
568
+ const position = helpers.atFrontmatter(file, field);
569
+ report({
570
+ file,
571
+ line: position.line,
572
+ configPath: field,
573
+ message: `${file.display} is missing a "${field}" in its frontmatter.`,
574
+ });
575
+ }
576
+ }
577
+ },
578
+ },
579
+
580
+ {
581
+ id: 'correctness/agent-name-mismatch',
582
+ category: 'correctness',
583
+ severity: 'warning',
584
+ title: 'Subagent name does not match its filename',
585
+ help: 'Keep the frontmatter name and the filename in sync; mismatches make agents hard to find and, depending on the harness version, can shadow each other.',
586
+ check({ files, report, helpers }) {
587
+ for (const file of files) {
588
+ if (file.kind !== 'agent' || !file.frontmatter) continue;
589
+ const name = file.frontmatter.name;
590
+ if (typeof name !== 'string' || !name.trim()) continue;
591
+ const expected = basename(file.path).replace(/\.md$/, '');
592
+ if (slugify(name) === slugify(expected)) continue;
593
+ const position = helpers.atFrontmatter(file, 'name');
594
+ report({
595
+ file,
596
+ line: position.line,
597
+ configPath: 'name',
598
+ message: `Subagent is named "${name}" but the file is "${expected}.md".`,
599
+ });
600
+ }
601
+ },
602
+ },
603
+
604
+ {
605
+ id: 'correctness/agent-unknown-tool',
606
+ category: 'correctness',
607
+ severity: 'warning',
608
+ title: 'Subagent grants a tool that does not exist',
609
+ help: 'Tool names in the tools list are case-sensitive. An unknown entry is dropped, so the agent quietly runs without the capability you meant to give it.',
610
+ check({ files, report, helpers }) {
611
+ for (const file of files) {
612
+ if (file.kind !== 'agent' || !file.frontmatter) continue;
613
+ const tools = file.frontmatter.tools;
614
+ const list = Array.isArray(tools) ? tools : (typeof tools === 'string' && tools !== '*' ? tools.split(',').map((t) => t.trim()) : []);
615
+ for (const tool of list) {
616
+ if (!tool || tool === '*') continue;
617
+ if (TOOL_NAMES.has(tool) || tool.startsWith('mcp__')) continue;
618
+ const hint = suggest(tool, TOOL_NAMES);
619
+ const position = helpers.atFrontmatter(file, 'tools');
620
+ report({
621
+ file,
622
+ line: position.line,
623
+ configPath: 'tools',
624
+ snippet: tool,
625
+ message: hint
626
+ ? `Subagent "${file.frontmatter.name ?? file.display}" lists unknown tool "${tool}". Did you mean "${hint}"?`
627
+ : `Subagent "${file.frontmatter.name ?? file.display}" lists unknown tool "${tool}", which will be ignored.`,
628
+ });
629
+ }
630
+ }
631
+ },
632
+ },
633
+
634
+ {
635
+ id: 'correctness/duplicate-agent-name',
636
+ category: 'correctness',
637
+ severity: 'error',
638
+ title: 'Two subagents share a name',
639
+ help: 'Names must be unique; the loser is unreachable. Project-scope agents shadow user-scope agents with the same name.',
640
+ check({ files, report, helpers }) {
641
+ const seen = new Map();
642
+ for (const file of files) {
643
+ if (file.kind !== 'agent' || !file.frontmatter) continue;
644
+ const name = file.frontmatter.name;
645
+ if (typeof name !== 'string' || !name.trim()) continue;
646
+ const key = slugify(name);
647
+ if (seen.has(key)) {
648
+ const previous = seen.get(key);
649
+ const position = helpers.atFrontmatter(file, 'name');
650
+ report({
651
+ file,
652
+ line: position.line,
653
+ configPath: 'name',
654
+ message: `Subagent name "${name}" is already defined in ${previous.display}.`,
655
+ });
656
+ continue;
657
+ }
658
+ seen.set(key, file);
659
+ }
660
+ },
661
+ },
662
+
663
+ {
664
+ id: 'correctness/skill-name-mismatch',
665
+ category: 'correctness',
666
+ severity: 'error',
667
+ title: 'Skill name does not match its directory',
668
+ help: 'A skill is invoked by its directory name, so a mismatched frontmatter name makes the skill impossible to invoke by the name it advertises.',
669
+ check({ files, report, helpers }) {
670
+ for (const file of files) {
671
+ if (file.kind !== 'skill' || !file.frontmatter) continue;
672
+ const name = file.frontmatter.name;
673
+ if (typeof name !== 'string' || !name.trim()) continue;
674
+ const dir = basename(dirname(file.path));
675
+ if (slugify(name) === slugify(dir)) continue;
676
+ const position = helpers.atFrontmatter(file, 'name');
677
+ report({
678
+ file,
679
+ line: position.line,
680
+ configPath: 'name',
681
+ message: `Skill declares name "${name}" but lives in directory "${dir}".`,
682
+ });
683
+ }
684
+ },
685
+ },
686
+
687
+ {
688
+ id: 'correctness/skill-missing-field',
689
+ category: 'correctness',
690
+ severity: 'error',
691
+ title: 'Skill is missing a required field',
692
+ help: 'name and description are both required. The description is the only thing the model sees when deciding whether to load the skill.',
693
+ check({ files, report, helpers }) {
694
+ for (const file of files) {
695
+ if (file.kind !== 'skill') continue;
696
+ if (!file.frontmatter) {
697
+ report({ file, line: 1, message: `${file.display} has no frontmatter, so the skill will not load.` });
698
+ continue;
699
+ }
700
+ for (const field of ['name', 'description']) {
701
+ const value = file.frontmatter[field];
702
+ if (typeof value === 'string' && value.trim() !== '') continue;
703
+ const position = helpers.atFrontmatter(file, field);
704
+ report({ file, line: position.line, configPath: field,
705
+ message: `${file.display} is missing "${field}" in its frontmatter.` });
706
+ }
707
+ }
708
+ },
709
+ },
710
+
711
+ {
712
+ id: 'correctness/duplicate-skill-name',
713
+ category: 'correctness',
714
+ severity: 'error',
715
+ title: 'Two skills share a name',
716
+ help: 'Only one wins. Rename one, or move it under a directory-scoped path if the collision is deliberate.',
717
+ check({ files, report, helpers }) {
718
+ const seen = new Map();
719
+ for (const file of files) {
720
+ if (file.kind !== 'skill' || !file.frontmatter) continue;
721
+ const name = file.frontmatter.name;
722
+ if (typeof name !== 'string' || !name.trim()) continue;
723
+ const key = slugify(name);
724
+ if (seen.has(key)) {
725
+ const position = helpers.atFrontmatter(file, 'name');
726
+ report({ file, line: position.line, configPath: 'name',
727
+ message: `Skill name "${name}" is already defined in ${seen.get(key).display}.` });
728
+ continue;
729
+ }
730
+ seen.set(key, file);
731
+ }
732
+ },
733
+ },
734
+
735
+ {
736
+ id: 'correctness/mcp-server-incomplete',
737
+ category: 'correctness',
738
+ severity: 'error',
739
+ title: 'MCP server has no way to start',
740
+ help: 'A server needs either "command" (stdio) or "url" (SSE/HTTP). Without one the server fails to connect on every session start.',
741
+ check({ files, report, helpers }) {
742
+ for (const file of files) {
743
+ const servers = file.data?.mcpServers;
744
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) continue;
745
+ for (const [name, server] of Object.entries(servers)) {
746
+ const basePath = `mcpServers.${name}`;
747
+ const position = helpers.at(file, basePath);
748
+ if (server === null || typeof server !== 'object' || Array.isArray(server)) {
749
+ report({ file, line: position.line, column: position.column, configPath: basePath,
750
+ message: `MCP server "${name}" must be an object, got ${describeType(server)}.` });
751
+ continue;
752
+ }
753
+ const hasCommand = typeof server.command === 'string' && server.command.trim() !== '';
754
+ const hasUrl = typeof server.url === 'string' && server.url.trim() !== '';
755
+ if (!hasCommand && !hasUrl) {
756
+ report({ file, line: position.line, column: position.column, configPath: basePath,
757
+ message: `MCP server "${name}" defines neither "command" nor "url".` });
758
+ }
759
+ if (server.args !== undefined && !Array.isArray(server.args)) {
760
+ report({ file, line: position.line, column: position.column, configPath: `${basePath}.args`,
761
+ message: `MCP server "${name}" has args of type ${describeType(server.args)}, expected an array.` });
762
+ }
763
+ }
764
+ }
765
+ },
766
+ },
767
+
768
+ {
769
+ id: 'correctness/mcp-server-toggled-both-ways',
770
+ category: 'correctness',
771
+ severity: 'warning',
772
+ title: 'MCP server both enabled and disabled',
773
+ help: 'Remove it from one of the two lists so the intended state is obvious.',
774
+ check({ files, report, helpers }) {
775
+ for (const file of files) {
776
+ if (file.kind !== 'settings') continue;
777
+ const enabled = file.data?.enabledMcpjsonServers;
778
+ const disabled = file.data?.disabledMcpjsonServers;
779
+ if (!Array.isArray(enabled) || !Array.isArray(disabled)) continue;
780
+ const disabledSet = new Set(disabled);
781
+ enabled.forEach((name, index) => {
782
+ if (!disabledSet.has(name)) return;
783
+ const configPath = `enabledMcpjsonServers[${index}]`;
784
+ const position = helpers.at(file, configPath);
785
+ report({ file, line: position.line, column: position.column, configPath, snippet: String(name),
786
+ message: `MCP server "${name}" is in both enabledMcpjsonServers and disabledMcpjsonServers.` });
787
+ });
788
+ }
789
+ },
790
+ },
791
+
792
+ {
793
+ id: 'correctness/statusline-malformed',
794
+ category: 'correctness',
795
+ severity: 'warning',
796
+ title: 'statusLine is misconfigured',
797
+ help: 'statusLine must be an object with type "command" and a command string.',
798
+ check({ files, report, helpers }) {
799
+ for (const file of files) {
800
+ if (file.kind !== 'settings') continue;
801
+ const status = file.data?.statusLine;
802
+ if (status === undefined) continue;
803
+ const position = helpers.at(file, 'statusLine');
804
+ if (status === null || typeof status !== 'object' || Array.isArray(status)) {
805
+ report({ file, line: position.line, column: position.column, configPath: 'statusLine',
806
+ message: `statusLine must be an object, got ${describeType(status)}.` });
807
+ continue;
808
+ }
809
+ if (status.type !== 'command') {
810
+ report({ file, line: position.line, column: position.column, configPath: 'statusLine.type',
811
+ message: `statusLine.type is ${JSON.stringify(status.type)}; expected "command".` });
812
+ }
813
+ if (typeof status.command !== 'string' || !status.command.trim()) {
814
+ report({ file, line: position.line, column: position.column, configPath: 'statusLine.command',
815
+ message: 'statusLine.command is missing or empty.' });
816
+ }
817
+ }
818
+ },
819
+ },
820
+
821
+ {
822
+ id: 'correctness/env-non-string-value',
823
+ category: 'correctness',
824
+ severity: 'warning',
825
+ title: 'Environment value is not a string',
826
+ help: 'Environment variables are strings. Numbers and booleans here may be dropped or coerced unpredictably — quote them.',
827
+ check({ files, report, helpers }) {
828
+ for (const file of files) {
829
+ if (file.kind !== 'settings') continue;
830
+ const env = file.data?.env;
831
+ if (!env || typeof env !== 'object' || Array.isArray(env)) continue;
832
+ for (const [key, value] of Object.entries(env)) {
833
+ if (typeof value === 'string') continue;
834
+ const position = helpers.at(file, `env.${key}`);
835
+ report({ file, line: position.line, column: position.column, configPath: `env.${key}`,
836
+ message: `env.${key} is ${describeType(value)}; write it as a string ("${String(value)}").` });
837
+ }
838
+ }
839
+ },
840
+ },
841
+ ];
842
+
843
+ function describeType(value) {
844
+ if (value === null) return 'null';
845
+ if (Array.isArray(value)) return 'an array';
846
+ if (typeof value === 'object') return 'an object';
847
+ if (typeof value === 'string') return 'a string';
848
+ return `a ${typeof value}`;
849
+ }