@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,282 @@
1
+ import {
2
+ MEMORY_TOKEN_WARN, MEMORY_TOKEN_ERROR, TOTAL_MEMORY_TOKEN_WARN,
3
+ MCP_SERVER_WARN_COUNT,
4
+ } from '../constants.js';
5
+
6
+ /**
7
+ * Memory files and MCP tool schemas are re-sent on every single request, so
8
+ * their size is a recurring cost rather than a one-off. These rules quantify
9
+ * that instead of just saying "this file is long".
10
+ */
11
+
12
+ /**
13
+ * Cost model for always-on context.
14
+ *
15
+ * Memory files sit at the very front of the prompt, which is the most stable
16
+ * part of it — so in practice they are cached rather than re-billed in full on
17
+ * every turn. Pricing that ignores this overstates the cost by roughly an order
18
+ * of magnitude, so the model below is explicit about it:
19
+ *
20
+ * per session = 1 cache write (1.25x base) + (turns - 1) cache reads (0.1x base)
21
+ *
22
+ * The numbers are deliberately conservative and are labelled as estimates in
23
+ * every message. The token count is the fact; the euro figure is a model, and
24
+ * the assumptions are stated so a reader can disagree with them.
25
+ */
26
+ const INPUT_USD_PER_MTOK = 3; // Sonnet-tier base input price; Opus-tier is higher
27
+ const CACHE_WRITE_MULTIPLIER = 1.25; // 5-minute TTL
28
+ const CACHE_READ_MULTIPLIER = 0.1;
29
+ const SESSIONS_PER_DAY = 10;
30
+ const TURNS_PER_SESSION = 15;
31
+ const WORKING_DAYS_PER_MONTH = 22;
32
+
33
+ /**
34
+ * Estimated monthly USD cost of keeping `tokens` in always-on context.
35
+ * @param {number} tokens
36
+ * @param {{ cached?: boolean }} [options] pass cached:false for the worst case
37
+ */
38
+ export function monthlyCostEstimate(tokens, options = {}) {
39
+ const cached = options.cached !== false;
40
+ const perSession = cached
41
+ ? CACHE_WRITE_MULTIPLIER + (TURNS_PER_SESSION - 1) * CACHE_READ_MULTIPLIER
42
+ : TURNS_PER_SESSION;
43
+ const billedTokens = tokens * perSession * SESSIONS_PER_DAY * WORKING_DAYS_PER_MONTH;
44
+ return (billedTokens / 1_000_000) * INPUT_USD_PER_MTOK;
45
+ }
46
+
47
+ /** Requests per month implied by the assumptions above, for message text. */
48
+ const REQUESTS_PER_MONTH = SESSIONS_PER_DAY * TURNS_PER_SESSION * WORKING_DAYS_PER_MONTH;
49
+
50
+ const formatUsd = (value) => (value < 1 ? `$${value.toFixed(2)}` : `$${Math.round(value)}`);
51
+
52
+ export const costRules = [
53
+ {
54
+ id: 'cost/memory-file-too-large',
55
+ category: 'cost',
56
+ severity: 'warning',
57
+ title: 'Memory file is large enough to cost real money',
58
+ help: 'Move reference material into a skill or a linked file that gets read on demand. Memory files are prepended to every request, so their size multiplies by every turn you take.',
59
+ check({ files, report, helpers }) {
60
+ for (const file of files) {
61
+ if (file.kind !== 'memory') continue;
62
+ const tokens = helpers.estimateTokens(file.text);
63
+ if (tokens < MEMORY_TOKEN_WARN) continue;
64
+ const severity = tokens >= MEMORY_TOKEN_ERROR ? 'error' : 'warning';
65
+ const monthly = monthlyCostEstimate(tokens);
66
+ report({
67
+ file,
68
+ line: 1,
69
+ severity,
70
+ message: `${file.display} is ~${tokens.toLocaleString('en-US')} tokens of always-on context, sent with every request (~${formatUsd(monthly)}/month at ${REQUESTS_PER_MONTH.toLocaleString('en-US')} requests, assuming it stays prompt-cached).`,
71
+ });
72
+ }
73
+ },
74
+ },
75
+
76
+ {
77
+ id: 'cost/total-memory-budget',
78
+ category: 'cost',
79
+ severity: 'warning',
80
+ title: 'Combined always-on context is heavy',
81
+ help: 'Aim to keep the always-loaded total under a few thousand tokens. Everything here competes with the actual task for the model attention you are paying for.',
82
+ check({ files, report, helpers, workspace }) {
83
+ const memories = files.filter((f) => f.kind === 'memory');
84
+ if (memories.length === 0) return;
85
+ const total = memories.reduce((sum, f) => sum + helpers.estimateTokens(f.text), 0);
86
+ if (total < TOTAL_MEMORY_TOKEN_WARN) return;
87
+ const monthly = monthlyCostEstimate(total);
88
+ const biggest = memories
89
+ .map((f) => ({ f, t: helpers.estimateTokens(f.text) }))
90
+ .sort((a, b) => b.t - a.t)[0];
91
+ report({
92
+ file: biggest.f,
93
+ line: 1,
94
+ message: `${memories.length} memory files total ~${total.toLocaleString('en-US')} tokens of always-on context (~${formatUsd(monthly)}/month cached, ~${formatUsd(monthlyCostEstimate(total, { cached: false }))} if cache misses are common). Largest: ${biggest.f.display} at ~${biggest.t.toLocaleString('en-US')}.`,
95
+ });
96
+ },
97
+ },
98
+
99
+ {
100
+ id: 'cost/duplicated-memory-instructions',
101
+ category: 'cost',
102
+ severity: 'info',
103
+ title: 'The same instruction appears in several memory files',
104
+ help: 'Keep each instruction in exactly one file. Duplicates cost tokens twice and, worse, drift apart until they contradict each other.',
105
+ check({ files, report, helpers }) {
106
+ const memories = files.filter((f) => f.kind === 'memory');
107
+ if (memories.length < 2) return;
108
+ /** @type {Map<string, {file:any, line:number, text:string}[]>} */
109
+ const index = new Map();
110
+ for (const file of memories) {
111
+ file.text.split(/\r?\n/).forEach((raw, i) => {
112
+ const line = raw.trim();
113
+ // Only compare substantive instruction lines, not headings or prose fragments.
114
+ if (line.length < 40) return;
115
+ if (line.startsWith('#') || line.startsWith('```') || line.startsWith('|')) return;
116
+ const key = line.toLowerCase().replace(/\s+/g, ' ');
117
+ if (!index.has(key)) index.set(key, []);
118
+ index.get(key).push({ file, line: i + 1, text: line });
119
+ });
120
+ }
121
+ for (const [key, hits] of index) {
122
+ const byFile = new Map();
123
+ for (const hit of hits) if (!byFile.has(hit.file.path)) byFile.set(hit.file.path, hit);
124
+ if (byFile.size < 2) continue;
125
+ // Report once per duplicated line rather than once per copy, on the
126
+ // second file so the first occurrence reads as the canonical one.
127
+ const [first, second, ...rest] = [...byFile.values()];
128
+ const copies = byFile.size - 1;
129
+ const wasted = helpers.estimateTokens(key) * copies;
130
+ const elsewhere = rest.length > 0
131
+ ? ` and ${rest.length} other file${rest.length === 1 ? '' : 's'}`
132
+ : '';
133
+ report({
134
+ file: second.file,
135
+ line: second.line,
136
+ snippet: second.text.length > 80 ? `${second.text.slice(0, 77)}...` : second.text,
137
+ message: `This line also appears in ${first.file.display}:${first.line}${elsewhere}, costing ~${wasted} duplicate tokens on every request.`,
138
+ });
139
+ }
140
+ },
141
+ },
142
+
143
+ {
144
+ id: 'cost/many-mcp-servers',
145
+ category: 'cost',
146
+ severity: 'warning',
147
+ title: 'Many MCP servers enabled at once',
148
+ help: 'Enable servers per project rather than globally. Every connected server contributes its tool schemas to the context window on every request, whether or not you use it.',
149
+ check({ files, report, helpers }) {
150
+ let count = 0;
151
+ let target = null;
152
+ let targetPath = null;
153
+ for (const file of files) {
154
+ const servers = file.data?.mcpServers;
155
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) continue;
156
+ const names = Object.keys(servers);
157
+ count += names.length;
158
+ if (!target) {
159
+ target = file;
160
+ targetPath = 'mcpServers';
161
+ }
162
+ }
163
+ if (count <= MCP_SERVER_WARN_COUNT || !target) return;
164
+ const position = helpers.at(target, targetPath);
165
+ report({
166
+ file: target,
167
+ line: position.line,
168
+ column: position.column,
169
+ configPath: targetPath,
170
+ message: `${count} MCP servers are configured. Each one adds its tool definitions to every request, which crowds out the task and slows first token.`,
171
+ });
172
+ },
173
+ },
174
+
175
+ {
176
+ id: 'cost/vague-skill-description',
177
+ category: 'cost',
178
+ severity: 'warning',
179
+ title: 'Skill description gives the model nothing to match on',
180
+ help: 'Write descriptions as trigger conditions: "Use when the user asks to X, mentions Y, or is working on Z." The description is the only signal the model has, so a vague one means the skill you wrote is never used.',
181
+ check({ files, report, helpers }) {
182
+ for (const file of files) {
183
+ if (file.kind !== 'skill' || !file.frontmatter) continue;
184
+ const description = file.frontmatter.description;
185
+ if (typeof description !== 'string' || !description.trim()) continue;
186
+ const words = description.trim().split(/\s+/).length;
187
+ const hasTrigger = /\b(use (this )?(skill )?when|whenever|triggers? on|invoke when|for (tasks|requests)|if the user)\b/i.test(description);
188
+ if (words >= 12 && hasTrigger) continue;
189
+ const position = helpers.atFrontmatter(file, 'description');
190
+ const reason = words < 12
191
+ ? `only ${words} words long`
192
+ : 'missing any "use when…" trigger phrasing';
193
+ report({
194
+ file,
195
+ line: position.line,
196
+ configPath: 'description',
197
+ message: `Skill "${file.frontmatter.name ?? file.display}" has a description that is ${reason}, so the model has little reason to load it.`,
198
+ });
199
+ }
200
+ },
201
+ },
202
+
203
+ {
204
+ id: 'cost/vague-agent-description',
205
+ category: 'cost',
206
+ severity: 'info',
207
+ title: 'Subagent description will not attract delegation',
208
+ help: 'State what the agent is for and when to pick it. Orchestrators route on this string alone.',
209
+ check({ files, report, helpers }) {
210
+ for (const file of files) {
211
+ if (file.kind !== 'agent' || !file.frontmatter) continue;
212
+ const description = file.frontmatter.description;
213
+ if (typeof description !== 'string' || !description.trim()) continue;
214
+ if (description.trim().split(/\s+/).length >= 10) continue;
215
+ const position = helpers.atFrontmatter(file, 'description');
216
+ report({
217
+ file,
218
+ line: position.line,
219
+ configPath: 'description',
220
+ message: `Subagent "${file.frontmatter.name ?? file.display}" has a ${description.trim().split(/\s+/).length}-word description, which is rarely enough for a router to choose it correctly.`,
221
+ });
222
+ }
223
+ },
224
+ },
225
+
226
+ {
227
+ id: 'cost/memory-contains-generated-content',
228
+ category: 'cost',
229
+ severity: 'warning',
230
+ title: 'Memory file contains content that belongs in a file, not in context',
231
+ help: 'Reference the file by path instead of pasting it. The agent can read a path in one tool call; pasted content is paid for on every single request forever.',
232
+ check({ files, report, helpers }) {
233
+ for (const file of files) {
234
+ if (file.kind !== 'memory') continue;
235
+ const lines = file.text.split(/\r?\n/);
236
+ let fenceStart = -1;
237
+ let fenceLang = '';
238
+ lines.forEach((line, index) => {
239
+ const fence = /^```(\w+)?/.exec(line.trim());
240
+ if (!fence) return;
241
+ if (fenceStart === -1) {
242
+ fenceStart = index;
243
+ fenceLang = fence[1] ?? '';
244
+ return;
245
+ }
246
+ const length = index - fenceStart - 1;
247
+ // A long code block in a memory file is nearly always pasted source
248
+ // or output that should be read on demand.
249
+ if (length >= 40) {
250
+ const block = lines.slice(fenceStart + 1, index).join('\n');
251
+ const tokens = helpers.estimateTokens(block);
252
+ report({
253
+ file,
254
+ line: fenceStart + 1,
255
+ message: `A ${length}-line ${fenceLang || 'code'} block (~${tokens.toLocaleString('en-US')} tokens) is pasted into always-on context; reference the file path instead.`,
256
+ });
257
+ }
258
+ fenceStart = -1;
259
+ fenceLang = '';
260
+ });
261
+ }
262
+ },
263
+ },
264
+
265
+ {
266
+ id: 'cost/no-cleanup-period',
267
+ category: 'cost',
268
+ severity: 'info',
269
+ title: 'Transcript retention never trimmed',
270
+ help: 'Set cleanupPeriodDays to something like 30. Old transcripts are dead weight on disk and, if they contain customer data, a growing liability.',
271
+ check({ files, report, helpers }) {
272
+ const user = files.find((f) => f.kind === 'settings' && f.scope === 'user' && f.data);
273
+ if (!user) return;
274
+ if (user.data.cleanupPeriodDays !== undefined) return;
275
+ report({
276
+ file: user,
277
+ line: 1,
278
+ message: 'cleanupPeriodDays is unset, so session transcripts accumulate indefinitely.',
279
+ });
280
+ },
281
+ },
282
+ ];
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Rules about config that is legal and safe but will cause avoidable confusion,
3
+ * leakage between machines, or silent drift between team members.
4
+ */
5
+ export const hygieneRules = [
6
+ {
7
+ id: 'hygiene/local-settings-not-ignored',
8
+ category: 'hygiene',
9
+ severity: 'error',
10
+ title: 'Local settings file is not gitignored',
11
+ help: 'Add ".claude/settings.local.json" to .gitignore. That file is where personal overrides and machine-specific paths go, and committing it pushes your permissions onto everyone else.',
12
+ check({ workspace, report }) {
13
+ const local = workspace.files.find((f) => f.kind === 'settings' && f.scope === 'local');
14
+ if (!local || !workspace.isGitRepo) return;
15
+ const ignore = workspace.gitignore ?? '';
16
+ const ignored = ignore
17
+ .split(/\r?\n/)
18
+ .map((line) => line.trim())
19
+ .some((line) => (
20
+ line === '.claude/settings.local.json'
21
+ || line === '**/settings.local.json'
22
+ || line === 'settings.local.json'
23
+ || line === '.claude/'
24
+ || line === '.claude'
25
+ || line === '*.local.json'
26
+ ));
27
+ if (ignored) return;
28
+ report({
29
+ file: local,
30
+ line: 1,
31
+ message: '.claude/settings.local.json exists but is not covered by .gitignore, so personal overrides will be committed.',
32
+ });
33
+ },
34
+ },
35
+
36
+ {
37
+ id: 'hygiene/empty-config',
38
+ category: 'hygiene',
39
+ severity: 'info',
40
+ title: 'Config file has no effective content',
41
+ help: 'Delete it, or fill it in. An empty file reads as "configured" to the next person who opens the repo.',
42
+ check({ files, report }) {
43
+ for (const file of files) {
44
+ if (file.parseError) continue;
45
+ if (file.kind === 'settings' || file.kind === 'mcp' || file.kind === 'keybindings') {
46
+ if (file.data && typeof file.data === 'object' && Object.keys(file.data).length > 0) continue;
47
+ report({ file, line: 1, message: `${file.display} is empty and has no effect.` });
48
+ continue;
49
+ }
50
+ if (file.kind === 'memory' && file.text.trim() === '') {
51
+ report({ file, line: 1, message: `${file.display} is empty.` });
52
+ }
53
+ }
54
+ },
55
+ },
56
+
57
+ {
58
+ id: 'hygiene/skill-body-empty',
59
+ category: 'hygiene',
60
+ severity: 'warning',
61
+ title: 'Skill has frontmatter but no instructions',
62
+ help: 'The body is what the model actually follows once the skill loads. Frontmatter alone advertises a capability that does nothing.',
63
+ check({ files, report }) {
64
+ for (const file of files) {
65
+ if (file.kind !== 'skill' || !file.frontmatter) continue;
66
+ if ((file.body ?? '').trim().length >= 40) continue;
67
+ report({
68
+ file,
69
+ line: (file.frontmatterLines ?? 0) + 1,
70
+ message: `Skill "${file.frontmatter.name ?? file.display}" has an empty body, so loading it adds nothing.`,
71
+ });
72
+ }
73
+ },
74
+ },
75
+
76
+ {
77
+ id: 'hygiene/agent-body-empty',
78
+ category: 'hygiene',
79
+ severity: 'warning',
80
+ title: 'Subagent has no system prompt',
81
+ help: 'The body of an agent file is its system prompt. Without one the subagent behaves like a default agent with a narrower toolset.',
82
+ check({ files, report }) {
83
+ for (const file of files) {
84
+ if (file.kind !== 'agent' || !file.frontmatter) continue;
85
+ if ((file.body ?? '').trim().length >= 40) continue;
86
+ report({
87
+ file,
88
+ line: (file.frontmatterLines ?? 0) + 1,
89
+ message: `Subagent "${file.frontmatter.name ?? file.display}" has an empty body, so it has no instructions of its own.`,
90
+ });
91
+ }
92
+ },
93
+ },
94
+
95
+ {
96
+ id: 'hygiene/no-project-memory',
97
+ category: 'hygiene',
98
+ severity: 'info',
99
+ title: 'No project memory file',
100
+ help: 'A short CLAUDE.md covering build/test commands and project conventions removes the same handful of questions from every session.',
101
+ check({ workspace, report }) {
102
+ if (!workspace.isGitRepo) return;
103
+ const hasProjectMemory = workspace.files.some((f) => f.kind === 'memory' && f.scope !== 'user');
104
+ if (hasProjectMemory) return;
105
+ report({
106
+ file: { path: workspace.root, display: '.' },
107
+ line: 1,
108
+ message: 'This repo has no CLAUDE.md, so every session starts without project conventions.',
109
+ });
110
+ },
111
+ },
112
+
113
+ {
114
+ id: 'hygiene/absolute-home-path',
115
+ category: 'hygiene',
116
+ severity: 'warning',
117
+ title: 'Committed config contains a machine-specific path',
118
+ help: 'Use $CLAUDE_PROJECT_DIR or a relative path so the config works on every machine. Hardcoded home directories break for every other contributor.',
119
+ check({ files, report }) {
120
+ const pattern = /(\/(?:home|Users)\/[A-Za-z0-9._-]+|[A-Z]:\\Users\\[A-Za-z0-9._-]+)/;
121
+ for (const file of files) {
122
+ if (file.scope !== 'project') continue;
123
+ if (file.kind !== 'settings' && file.kind !== 'mcp') continue;
124
+ file.text.split(/\r?\n/).forEach((line, index) => {
125
+ const match = pattern.exec(line);
126
+ if (!match) return;
127
+ report({
128
+ file,
129
+ line: index + 1,
130
+ column: match.index + 1,
131
+ snippet: match[1],
132
+ message: `Committed config hardcodes "${match[1]}", which only exists on one machine.`,
133
+ });
134
+ });
135
+ }
136
+ },
137
+ },
138
+
139
+ {
140
+ id: 'hygiene/settings-scope-conflict',
141
+ category: 'hygiene',
142
+ severity: 'info',
143
+ title: 'Local settings silently override project settings',
144
+ help: 'Not a bug, but worth knowing: this key differs between the committed project config and your local override, so your session behaves differently from your teammates.',
145
+ check({ files, report, helpers }) {
146
+ const project = files.find((f) => f.kind === 'settings' && f.scope === 'project' && f.data);
147
+ const local = files.find((f) => f.kind === 'settings' && f.scope === 'local' && f.data);
148
+ if (!project || !local) return;
149
+ for (const key of Object.keys(local.data)) {
150
+ if (!(key in project.data)) continue;
151
+ const same = JSON.stringify(local.data[key]) === JSON.stringify(project.data[key]);
152
+ if (same) continue;
153
+ const position = helpers.at(local, key);
154
+ report({
155
+ file: local,
156
+ line: position.line,
157
+ column: position.column,
158
+ configPath: key,
159
+ message: `"${key}" is set in both project and local settings; your local value wins and your teammates get the other one.`,
160
+ });
161
+ }
162
+ },
163
+ },
164
+
165
+ {
166
+ id: 'hygiene/keybindings-duplicate',
167
+ category: 'hygiene',
168
+ severity: 'warning',
169
+ title: 'Two actions bound to the same key',
170
+ help: 'One of the two bindings will not fire. Pick a different chord for the loser.',
171
+ check({ files, report, helpers }) {
172
+ for (const file of files) {
173
+ if (file.kind !== 'keybindings' || !file.data) continue;
174
+ const bindings = Array.isArray(file.data) ? file.data : file.data.bindings;
175
+ if (!Array.isArray(bindings)) continue;
176
+ const seen = new Map();
177
+ bindings.forEach((binding, index) => {
178
+ const key = binding?.key ?? binding?.keys;
179
+ if (typeof key !== 'string') return;
180
+ const normalized = key.toLowerCase().replace(/\s+/g, '');
181
+ if (seen.has(normalized)) {
182
+ const configPath = Array.isArray(file.data) ? `[${index}]` : `bindings[${index}]`;
183
+ const position = helpers.at(file, configPath);
184
+ report({
185
+ file,
186
+ line: position.line,
187
+ column: position.column,
188
+ configPath,
189
+ snippet: key,
190
+ message: `"${key}" is bound twice (first at index ${seen.get(normalized)}).`,
191
+ });
192
+ return;
193
+ }
194
+ seen.set(normalized, index);
195
+ });
196
+ }
197
+ },
198
+ },
199
+ ];
@@ -0,0 +1,18 @@
1
+ import { securityRules } from './security.js';
2
+ import { correctnessRules } from './correctness.js';
3
+ import { costRules } from './cost.js';
4
+ import { hygieneRules } from './hygiene.js';
5
+ import { policyRules } from './policy.js';
6
+
7
+ /** Every rule agentdoctor ships. All of them are free. */
8
+ export const allRules = [
9
+ ...correctnessRules,
10
+ ...securityRules,
11
+ ...costRules,
12
+ ...hygieneRules,
13
+ ...policyRules,
14
+ ];
15
+
16
+ export const CATEGORIES = ['correctness', 'security', 'cost', 'hygiene', 'policy'];
17
+
18
+ export { securityRules, correctnessRules, costRules, hygieneRules, policyRules };