turntax 0.1.0 → 0.3.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.
@@ -1,3 +1,4 @@
1
+ import { DEFERRED_NOTE } from './mcp.js';
1
2
  export const METHOD_LABEL = {
2
3
  'offline-exact': 'exact (offline tokenizer)',
3
4
  'offline-proxy': 'estimate (o200k_base proxy)',
@@ -21,9 +22,149 @@ function table(rows, indent = '') {
21
22
  const widths = rows[0].map((_, c) => Math.max(...rows.map((r) => (r[c] ?? '').length)));
22
23
  return rows.map((r) => indent + r.map((cell, c) => cell.padEnd(widths[c])).join(' ').trimEnd()).join('\n');
23
24
  }
24
- /** Highest always-loaded o200k total across the reported tools. */
25
+ /**
26
+ * Agents that load exactly the same files with the same results share one row,
27
+ * so a repository with only AGENTS.md does not print a dozen identical lines.
28
+ */
29
+ export function groupTools(report) {
30
+ const groups = new Map();
31
+ for (const t of report.tools) {
32
+ const files = report.files
33
+ .filter((f) => f.tool === t.tool && f.mode !== 'ignored')
34
+ .map((f) => [f.path, f.part, f.mode, f.o200k])
35
+ .sort();
36
+ const key = JSON.stringify([
37
+ files,
38
+ t.models.map(({ model, perTurn, method, reason }) => [model, perTurn, method, reason]),
39
+ report.mcp.filter((m) => m.tool === t.tool).map((m) => [m.configPath, m.name, m.status, m.o200k]),
40
+ ]);
41
+ const g = groups.get(key);
42
+ if (g)
43
+ g.names.push(t.name);
44
+ else
45
+ groups.set(key, { names: [t.name], tool: t });
46
+ }
47
+ return [...groups.values()];
48
+ }
49
+ /** File rows that are identical across agents, merged with the list of agents. */
50
+ export function groupFiles(report) {
51
+ const groups = new Map();
52
+ for (const f of report.files) {
53
+ const key = JSON.stringify([f.path, f.part, f.mode, f.trigger, f.o200k, f.importedFrom ?? '']);
54
+ const g = groups.get(key);
55
+ if (g)
56
+ g.tools.push(f.tool);
57
+ else
58
+ groups.set(key, { file: f, tools: [f.tool] });
59
+ }
60
+ return [...groups.values()];
61
+ }
62
+ /**
63
+ * Highest per-turn o200k total across the reported tools, for the badge and --max-tokens:
64
+ * repository files plus measured project MCP tool definitions. User-level files never count here.
65
+ */
25
66
  export function headlineTokens(report) {
26
- return Math.max(0, ...report.tools.map((t) => t.perTurnO200k));
67
+ return Math.max(0, ...report.tools.map((t) => t.perTurnO200k + (t.mcpPerTurnO200k ?? 0)));
68
+ }
69
+ /** MCP cell: measured tokens, a "+" when some servers could not be measured, or how many are unmeasured. */
70
+ export function mcpCell(t) {
71
+ if (!t.mcpServers)
72
+ return '-';
73
+ if (t.mcpMeasured)
74
+ return fmtTokens(t.mcpPerTurnO200k);
75
+ return t.mcpPerTurnO200k > 0 ? `${fmtTokens(t.mcpPerTurnO200k)}+` : `${t.mcpServers} not measured`;
76
+ }
77
+ function signed(n) {
78
+ return n > 0 ? `+${fmtTokens(n)}` : n < 0 ? `-${fmtTokens(-n)}` : '0';
79
+ }
80
+ function fmtMaybe(n) {
81
+ return n === null ? '-' : fmtTokens(n);
82
+ }
83
+ /** Agents with the same before/after numbers share one line. */
84
+ function groupDeltas(diff) {
85
+ const groups = new Map();
86
+ for (const t of diff.tools) {
87
+ const key = `${t.before}|${t.after}`;
88
+ const g = groups.get(key);
89
+ if (g)
90
+ g.names.push(t.name);
91
+ else
92
+ groups.set(key, { names: [t.name], before: t.before, after: t.after, delta: t.delta });
93
+ }
94
+ return [...groups.values()].sort((a, b) => b.delta - a.delta);
95
+ }
96
+ export function renderDiffText(diff) {
97
+ const out = [`Change vs ${diff.base} (o200k tokens per turn, repository files)`];
98
+ const changed = groupDeltas(diff).filter((g) => g.delta !== 0);
99
+ if (!changed.length)
100
+ out.push(' No change in per-turn tokens.');
101
+ else {
102
+ const rows = [['before', 'after', 'change', 'agents']];
103
+ for (const g of changed)
104
+ rows.push([fmtMaybe(g.before), fmtMaybe(g.after), signed(g.delta), g.names.join(', ')]);
105
+ out.push(table(rows, ' '));
106
+ }
107
+ if (diff.files.length) {
108
+ out.push(' Files loaded every turn');
109
+ const rows = [['before', 'after', 'change', 'file']];
110
+ for (const f of diff.files)
111
+ rows.push([fmtMaybe(f.before), fmtMaybe(f.after), signed(f.delta), f.part === 'full' ? f.path : `${f.path} (${f.part})`]);
112
+ out.push(table(rows, ' '));
113
+ }
114
+ for (const s of diff.mcp.added)
115
+ out.push(` MCP server added: ${s}`);
116
+ for (const s of diff.mcp.removed)
117
+ out.push(` MCP server removed: ${s}`);
118
+ return out.join('\n');
119
+ }
120
+ export function renderDiffMarkdown(diff) {
121
+ const out = [`## Change vs \`${diff.base}\``, ''];
122
+ const changed = groupDeltas(diff).filter((g) => g.delta !== 0);
123
+ if (!changed.length)
124
+ out.push('No change in per-turn tokens (o200k, repository files).');
125
+ else {
126
+ out.push('o200k tokens added to every turn by repository instruction files.', '');
127
+ out.push('| Agents | Before | After | Change |', '| --- | ---: | ---: | ---: |');
128
+ for (const g of changed)
129
+ out.push(`| ${g.names.join(', ')} | ${fmtMaybe(g.before)} | ${fmtMaybe(g.after)} | **${signed(g.delta)}** |`);
130
+ }
131
+ if (diff.files.length) {
132
+ out.push('', '| File loaded every turn | Before | After | Change |', '| --- | ---: | ---: | ---: |');
133
+ for (const f of diff.files)
134
+ out.push(`| \`${f.part === 'full' ? f.path : `${f.path} (${f.part})`}\` | ${fmtMaybe(f.before)} | ${fmtMaybe(f.after)} | ${signed(f.delta)} |`);
135
+ }
136
+ if (diff.mcp.added.length || diff.mcp.removed.length) {
137
+ out.push('');
138
+ for (const s of diff.mcp.added)
139
+ out.push(`- MCP server added: ${s}`);
140
+ for (const s of diff.mcp.removed)
141
+ out.push(`- MCP server removed: ${s}`);
142
+ }
143
+ return out.join('\n');
144
+ }
145
+ /** MCP rows that are identical apart from the agent, merged. */
146
+ function groupMcp(rows) {
147
+ const groups = new Map();
148
+ for (const r of rows) {
149
+ const key = JSON.stringify([r.configPath, r.name, r.status, r.tools, r.o200k, r.error]);
150
+ const g = groups.get(key);
151
+ if (g)
152
+ g.tools.push(r.tool);
153
+ else
154
+ groups.set(key, { row: r, tools: [r.tool] });
155
+ }
156
+ return [...groups.values()];
157
+ }
158
+ function mcpDetail(r) {
159
+ if (r.status === 'measured')
160
+ return r.deferredO200k === undefined
161
+ ? `${r.tools} tools`
162
+ : `${r.tools} tools; names only, full definitions (${fmtTokens(r.deferredO200k)}) load when used`;
163
+ if (r.status === 'disabled')
164
+ return 'disabled in the config';
165
+ if (r.status === 'failed')
166
+ return r.error ?? 'failed';
167
+ return 'run with --mcp to count its tool definitions';
27
168
  }
28
169
  export function renderText(report, root) {
29
170
  const out = [];
@@ -33,31 +174,55 @@ export function renderText(report, root) {
33
174
  out.push('No agent instruction files found.');
34
175
  return out.join('\n');
35
176
  }
177
+ if (report.diff) {
178
+ out.push(renderDiffText(report.diff));
179
+ out.push('');
180
+ }
181
+ const showMcp = report.tools.some((t) => t.mcpServers > 0);
182
+ const showUser = report.tools.some((t) => t.userAlwaysFiles > 0 || t.userPerTurnO200k > 0);
183
+ const extra = [...(showMcp ? ['mcp/turn'] : []), ...(showUser ? ['user/turn'] : []), ...(showMcp || showUser ? ['total'] : [])];
36
184
  const rows = [
37
- ['tool', 'always', 'o200k/turn', 'on-demand', 'model', 'tokens/turn', 'counted by', `${report.turns} turns`, 'cached'],
185
+ ['always', 'o200k/turn', 'on-demand', ...extra, 'model', 'tokens/turn', 'counted by', `${report.turns} turns`, 'cached', 'agents'],
38
186
  ];
39
- for (const t of report.tools) {
40
- const head = [t.name, String(t.alwaysFiles), fmtTokens(t.perTurnO200k), fmtTokens(t.onDemandO200k)];
187
+ for (const { names, tool: t } of groupTools(report)) {
188
+ const head = [
189
+ String(t.alwaysFiles),
190
+ fmtTokens(t.perTurnO200k),
191
+ fmtTokens(t.onDemandO200k),
192
+ ...(showMcp ? [mcpCell(t)] : []),
193
+ ...(showUser ? [fmtTokens(t.userPerTurnO200k)] : []),
194
+ ...(showMcp || showUser ? [fmtTokens(t.totalPerTurnO200k) + (t.mcpMeasured ? '' : '+')] : []),
195
+ ];
41
196
  const blank = head.map(() => '');
197
+ const agents = names.join(', ');
42
198
  if (!t.models.length)
43
- rows.push([...head, '(no selected model)', '', '', '', '']);
199
+ rows.push([...head, '(no selected model)', '', '', '', '', agents]);
44
200
  t.models.forEach((m, i) => rows.push([
45
201
  ...(i === 0 ? head : blank),
46
202
  m.label,
47
203
  ...(m.perTurn === null
48
204
  ? ['-', m.reason ?? METHOD_LABEL[m.method], '-', '-']
49
205
  : [fmtTokens(m.perTurn), METHOD_LABEL[m.method], fmtUsd(m.uncached), fmtUsd(m.cached)]),
206
+ i === 0 ? agents : '',
50
207
  ]));
51
208
  }
52
209
  out.push(table(rows));
53
210
  out.push('');
54
211
  out.push('Files');
55
- const fileRows = [['tool', 'mode', 'file', 'o200k', 'loads']];
56
- for (const f of report.files) {
212
+ const fileRows = [['mode', 'file', 'o200k', 'loads', 'agents']];
213
+ for (const { file: f, tools } of groupFiles(report)) {
57
214
  const name = f.part === 'full' ? f.path : `${f.path} (${f.part})`;
58
- fileRows.push([f.tool, f.mode, name + (f.importedFrom ? ` <- @${f.importedFrom}` : ''), fmtTokens(f.o200k), f.trigger]);
215
+ fileRows.push([f.mode, name + (f.importedFrom ? ` <- @${f.importedFrom}` : ''), fmtTokens(f.o200k), f.trigger, tools.join(', ')]);
59
216
  }
60
217
  out.push(table(fileRows, ' '));
218
+ if (report.mcp.length) {
219
+ out.push('');
220
+ out.push('MCP servers (tool definitions are sent every turn)');
221
+ const mcpRows = [['status', 'server', 'config', 'o200k', 'detail', 'agents']];
222
+ for (const { row: r, tools } of groupMcp(report.mcp))
223
+ mcpRows.push([r.status, r.name, r.configPath, r.o200k === undefined ? '-' : fmtTokens(r.o200k), mcpDetail(r), tools.join(', ')]);
224
+ out.push(table(mcpRows, ' '));
225
+ }
61
226
  const notes = [...new Set(report.files.flatMap((f) => f.notes.map((n) => `${f.path}: ${n}`)))];
62
227
  if (notes.length) {
63
228
  out.push('');
@@ -84,6 +249,10 @@ function footer(report) {
84
249
  'o200k = OpenAI o200k_base, counted offline. It is a reference for every file, not a Claude or Gemini count.',
85
250
  'Claude and Gemini tokenizers are not public: their counts come only from the vendor APIs (--claude-api, --gemini-api).',
86
251
  `Cost = tokens/turn x ${report.turns} turns x input price. "Cached" assumes every turn reads the context from the prompt cache.`,
252
+ ...(report.mcp.length
253
+ ? ['MCP tool definitions are counted as JSON with o200k (an estimate of what the agent sends) and are not included in model costs.']
254
+ : []),
255
+ ...(report.mcp.some((m) => m.deferredO200k !== undefined) ? [DEFERRED_NOTE] : []),
87
256
  'Prices (USD per 1M input tokens):',
88
257
  ];
89
258
  for (const m of report.models) {
@@ -94,21 +263,35 @@ function footer(report) {
94
263
  }
95
264
  export function renderMarkdown(report) {
96
265
  const out = ['# Agent context tax report', ''];
266
+ if (report.diff)
267
+ out.push(renderDiffMarkdown(report.diff), '');
97
268
  out.push(`Every agent re-sends its always-loaded instruction files with each turn. Costs are for ${report.turns} turns.`, '');
98
- out.push('## Per tool', '');
99
- out.push('| Tool | Model | Tokens / turn | Counted by | Uncached | Cached |', '| --- | --- | ---: | --- | ---: | ---: |');
100
- for (const t of report.tools) {
269
+ out.push('## Per agent', '');
270
+ out.push('| Agents | Model | Tokens / turn | Counted by | Uncached | Cached |', '| --- | --- | ---: | --- | ---: | ---: |');
271
+ for (const { names, tool: t } of groupTools(report)) {
272
+ const agents = names.join(', ');
101
273
  if (!t.models.length)
102
- out.push(`| ${t.name} | - | ${fmtTokens(t.perTurnO200k)} (o200k) | reference | - | - |`);
274
+ out.push(`| ${agents} | - | ${fmtTokens(t.perTurnO200k)} (o200k) | reference | - | - |`);
103
275
  for (const m of t.models)
104
276
  out.push(m.perTurn === null
105
- ? `| ${t.name} | ${m.label} | - | ${m.reason ?? ''} | - | - |`
106
- : `| ${t.name} | ${m.label} | ${fmtTokens(m.perTurn)} | ${METHOD_LABEL[m.method]} | ${fmtUsd(m.uncached)} | ${fmtUsd(m.cached)} |`);
277
+ ? `| ${agents} | ${m.label} | - | ${m.reason ?? ''} | - | - |`
278
+ : `| ${agents} | ${m.label} | ${fmtTokens(m.perTurn)} | ${METHOD_LABEL[m.method]} | ${fmtUsd(m.uncached)} | ${fmtUsd(m.cached)} |`);
107
279
  }
108
- out.push('', '## Files', '', '| Tool | Mode | File | o200k tokens | Loads |', '| --- | --- | --- | ---: | --- |');
109
- for (const f of report.files) {
280
+ if (report.tools.some((t) => t.mcpServers > 0 || t.userAlwaysFiles > 0 || t.userPerTurnO200k > 0)) {
281
+ out.push('', '### Every turn, by source (o200k)', '');
282
+ out.push('| Agents | Repository files | MCP tools | User level | Total |', '| --- | ---: | ---: | ---: | ---: |');
283
+ for (const { names, tool: t } of groupTools(report))
284
+ out.push(`| ${names.join(', ')} | ${fmtTokens(t.perTurnO200k)} | ${mcpCell(t)} | ${fmtTokens(t.userPerTurnO200k)} | ${fmtTokens(t.totalPerTurnO200k)}${t.mcpMeasured ? '' : '+'} |`);
285
+ }
286
+ if (report.mcp.length) {
287
+ out.push('', '## MCP servers', '', '| Status | Server | Config | o200k tokens | Detail | Agents |', '| --- | --- | --- | ---: | --- | --- |');
288
+ for (const { row: r, tools } of groupMcp(report.mcp))
289
+ out.push(`| ${r.status} | ${escapeCell(r.name)} | \`${r.configPath}\` | ${r.o200k === undefined ? '-' : fmtTokens(r.o200k)} | ${escapeCell(mcpDetail(r))} | ${tools.join(', ')} |`);
290
+ }
291
+ out.push('', '## Files', '', '| Mode | File | o200k tokens | Loads | Agents |', '| --- | --- | ---: | --- | --- |');
292
+ for (const { file: f, tools } of groupFiles(report)) {
110
293
  const name = f.part === 'full' ? f.path : `${f.path} (${f.part})`;
111
- out.push(`| ${f.tool} | ${f.mode} | \`${name}\` | ${fmtTokens(f.o200k)} | ${escapeCell(f.trigger)} |`);
294
+ out.push(`| ${f.mode} | \`${name}\` | ${fmtTokens(f.o200k)} | ${escapeCell(f.trigger)} | ${tools.join(', ')} |`);
112
295
  }
113
296
  out.push('', '## Advice', '');
114
297
  if (!report.advice.length)
@@ -121,6 +304,10 @@ export function renderMarkdown(report) {
121
304
  out.push('- o200k = OpenAI `o200k_base`, counted offline; a reference, not a Claude or Gemini count.');
122
305
  out.push('- Claude counts come only from Anthropic `count_tokens`; Gemini counts only from Google `countTokens`.');
123
306
  out.push('- "Cached" assumes every turn reads the context from the prompt cache.');
307
+ if (report.mcp.length)
308
+ out.push('- MCP tool definitions are counted as JSON with o200k, an estimate of what the agent sends; they are not in the model costs.');
309
+ if (report.mcp.some((m) => m.deferredO200k !== undefined))
310
+ out.push(`- ${DEFERRED_NOTE}`);
124
311
  for (const m of report.models)
125
312
  out.push(`- ${m.label}: $${m.inputPerMTok ?? 'n/a'} input / $${m.cachedInputPerMTok ?? 'n/a'} cached per 1M tokens ([source](${m.source}), checked ${m.checkedAt}).`);
126
313
  out.push('', badgeMarkdown(report));
@@ -141,3 +328,17 @@ export function badgeUrl(report) {
141
328
  export function badgeMarkdown(report) {
142
329
  return `[![context tax (o200k)](${badgeUrl(report)})](https://github.com/TorutDev/turntax)`;
143
330
  }
331
+ export const PR_COMMENT_MARKER = '<!-- turntax-report -->';
332
+ const COMMENT_LIMIT = 60_000;
333
+ /**
334
+ * A pull request comment: the change against the base branch first, the full report folded below.
335
+ * Starts with a marker so the GitHub Action can update its own comment instead of adding new ones.
336
+ */
337
+ export function renderPrComment(report) {
338
+ const top = report.diff ? renderDiffMarkdown(report.diff) : '## Agent context tax';
339
+ const headline = `Largest per-turn cost: **${fmtTokens(headlineTokens(report))}** o200k tokens (repository files and measured project MCP tools).`;
340
+ let full = renderMarkdown({ ...report, diff: undefined }).replace(/^# .*\n\n/, '');
341
+ if (full.length > COMMENT_LIMIT)
342
+ full = full.slice(0, COMMENT_LIMIT) + '\n\n_Report cut to fit in a comment; the job summary has all of it._\n';
343
+ return [PR_COMMENT_MARKER, top, '', headline, '', '<details><summary>Full report</summary>', '', full, '</details>', ''].join('\n');
344
+ }
@@ -291,6 +291,256 @@ export const TOOLS = [
291
291
  ],
292
292
  notes: ['Global rules (~/.codeium/windsurf/memories/global_rules.md) are outside the repository and not counted.'],
293
293
  },
294
+ {
295
+ id: 'cline',
296
+ name: 'Cline',
297
+ families: [],
298
+ sources: ['https://docs.cline.bot/features/cline-rules'],
299
+ checkedAt: '2026-10-10',
300
+ rules: [
301
+ {
302
+ kind: 'frontmatter',
303
+ pattern: /^\.(?:clinerules|cline\/rules)\/.+\.md$/,
304
+ rootPrefixes: ['.clinerules/', '.cline/rules/'],
305
+ decide: (fm) => {
306
+ if (Array.isArray(fm.paths) && fm.paths.length === 0)
307
+ return { mode: 'ignored', trigger: 'paths: [] (never applies)' };
308
+ if (has(fm, 'paths'))
309
+ return { mode: 'on-demand', trigger: `paths: ${str(fm.paths)}` };
310
+ return { mode: 'always', trigger: 'rule without paths' };
311
+ },
312
+ },
313
+ ],
314
+ limits: [],
315
+ notes: [
316
+ 'Each rule can be toggled off in Cline; the audit assumes rules are on.',
317
+ 'Cline also detects AGENTS.md, .cursorrules and .windsurfrules; whether they are on by default is not documented, so they are not counted.',
318
+ 'A single .clinerules file (instead of a folder) is not documented and is not counted.',
319
+ ],
320
+ },
321
+ {
322
+ id: 'roo-code',
323
+ name: 'Roo Code',
324
+ families: [],
325
+ sources: ['https://roocodeinc.github.io/Roo-Code/features/custom-instructions'],
326
+ checkedAt: '2026-10-10',
327
+ rules: [
328
+ {
329
+ kind: 'precedence',
330
+ groups: [
331
+ { label: '.roo/rules/', patterns: [/^\.roo\/rules\/.+/] },
332
+ { label: '.roorules', patterns: [/^\.roorules$/] },
333
+ { label: '.clinerules', patterns: [/^\.clinerules$/] },
334
+ ],
335
+ note: 'Legacy .roorules and .clinerules are read only when .roo/rules/ has no content; their order between each other is assumed.',
336
+ },
337
+ { kind: 'pattern', pattern: /^\.roo\/rules-[\w-]+\/.+/, mode: 'on-demand', trigger: 'only in that Roo mode' },
338
+ { kind: 'pattern', pattern: /^\.roorules-[\w-]+$/, mode: 'on-demand', trigger: 'only in that Roo mode (legacy)' },
339
+ {
340
+ kind: 'hierarchical',
341
+ names: ['AGENTS.md', 'AGENT.md'],
342
+ onePerDirectory: true,
343
+ nested: 'skip',
344
+ nestedTrigger: '',
345
+ note: 'Read by default; the roo-cline.useAgentRules setting turns it off.',
346
+ },
347
+ ],
348
+ limits: [],
349
+ notes: ['Global ~/.roo/rules/ is outside the repository and not counted.'],
350
+ },
351
+ {
352
+ id: 'kiro',
353
+ name: 'Kiro',
354
+ families: [],
355
+ sources: ['https://kiro.dev/docs/steering/'],
356
+ checkedAt: '2026-10-10',
357
+ rules: [
358
+ {
359
+ kind: 'frontmatter',
360
+ pattern: /^\.kiro\/steering\/[^/]+\.md$/,
361
+ rootPrefixes: ['.kiro/steering/'],
362
+ decide: (fm) => {
363
+ switch (str(fm.inclusion).trim()) {
364
+ case '':
365
+ case 'always':
366
+ return { mode: 'always', trigger: 'inclusion: always' };
367
+ case 'fileMatch':
368
+ return { mode: 'on-demand', trigger: `fileMatch: ${str(fm.fileMatchPattern)}` };
369
+ case 'manual':
370
+ return { mode: 'manual', trigger: 'inclusion: manual (#name)' };
371
+ case 'auto':
372
+ return {
373
+ mode: 'on-demand',
374
+ trigger: 'inclusion: auto (matched on description)',
375
+ descriptionOnly: true,
376
+ note: 'Description assumed to be in context so Kiro can match it (inferred, not documented).',
377
+ };
378
+ default:
379
+ return { mode: 'always', trigger: `inclusion: ${str(fm.inclusion)} (unknown, treated as always)` };
380
+ }
381
+ },
382
+ },
383
+ { kind: 'hierarchical', names: ['AGENTS.md'], nested: 'skip', nestedTrigger: '' },
384
+ ],
385
+ limits: [],
386
+ notes: [
387
+ 'fileMatch and manual modes need Kiro CLI V3; older versions skip those files.',
388
+ 'Global ~/.kiro/steering/ is outside the repository and not counted.',
389
+ ],
390
+ },
391
+ {
392
+ id: 'junie',
393
+ name: 'JetBrains Junie',
394
+ families: [],
395
+ sources: ['https://junie.jetbrains.com/docs/guidelines-and-memory.html'],
396
+ checkedAt: '2026-10-10',
397
+ rules: [
398
+ {
399
+ kind: 'precedence',
400
+ groups: [
401
+ { label: '.junie/AGENTS.md', patterns: [/^\.junie\/AGENTS\.md$/] },
402
+ { label: 'AGENTS.md', patterns: [/^AGENTS\.md$/, /^\.junie\/playbook\.md$/, /^\.junie\/rules\/[^/]+\.md$/] },
403
+ { label: '.junie/guidelines', patterns: [/^\.junie\/guidelines\.md$/, /^\.junie\/guidelines\/[^/]+\.md$/] },
404
+ ],
405
+ note: 'Follows the IDE plugin order; a custom guidelines path set in project settings is not modelled.',
406
+ },
407
+ ],
408
+ limits: [],
409
+ notes: ['Global ~/.junie/AGENTS.md is outside the repository and not counted.'],
410
+ },
411
+ {
412
+ id: 'zed',
413
+ name: 'Zed',
414
+ families: [],
415
+ sources: ['https://zed.dev/docs/ai/instructions'],
416
+ checkedAt: '2026-10-10',
417
+ rules: [
418
+ {
419
+ kind: 'precedence',
420
+ groups: [
421
+ '.rules',
422
+ '.cursorrules',
423
+ '.windsurfrules',
424
+ '.clinerules',
425
+ '.github/copilot-instructions.md',
426
+ 'AGENT.md',
427
+ 'AGENTS.md',
428
+ 'CLAUDE.md',
429
+ 'GEMINI.md',
430
+ ].map((path) => ({ label: path, patterns: [new RegExp(`^${path.replace(/\./g, '\\.')}$`)] })),
431
+ note: 'Zed uses only the first of these files it finds at the project root.',
432
+ },
433
+ ],
434
+ limits: [],
435
+ notes: ['Personal ~/.config/zed/AGENTS.md is outside the repository and not counted.'],
436
+ },
437
+ {
438
+ id: 'continue',
439
+ name: 'Continue',
440
+ families: [],
441
+ sources: ['https://docs.continue.dev/customize/deep-dives/rules'],
442
+ checkedAt: '2026-10-10',
443
+ rules: [
444
+ {
445
+ kind: 'frontmatter',
446
+ pattern: /^\.continue\/rules\/[^/]+\.md$/,
447
+ rootPrefixes: ['.continue/rules/'],
448
+ decide: (fm) => {
449
+ const always = fm.alwaysApply === true || str(fm.alwaysApply) === 'true';
450
+ const never = fm.alwaysApply === false || str(fm.alwaysApply) === 'false';
451
+ if (always)
452
+ return { mode: 'always', trigger: 'alwaysApply: true' };
453
+ if (has(fm, 'globs'))
454
+ return { mode: 'on-demand', trigger: `globs: ${str(fm.globs)}` };
455
+ if (!never)
456
+ return { mode: 'always', trigger: 'no globs' };
457
+ if (has(fm, 'description'))
458
+ return {
459
+ mode: 'on-demand',
460
+ trigger: 'agent decides from description',
461
+ descriptionOnly: true,
462
+ note: 'Description assumed to be in context so the agent can decide (inferred, not documented).',
463
+ };
464
+ return { mode: 'on-demand', trigger: 'alwaysApply: false (agent decides)' };
465
+ },
466
+ },
467
+ ],
468
+ limits: [],
469
+ notes: ['Rules in config.yaml or from the Continue hub are not counted.'],
470
+ },
471
+ {
472
+ id: 'aider',
473
+ name: 'Aider',
474
+ families: [],
475
+ sources: ['https://aider.chat/docs/usage/conventions.html'],
476
+ checkedAt: '2026-10-10',
477
+ rules: [
478
+ { kind: 'config-list', configPath: '.aider.conf.yml', key: 'read', trigger: 'read: in .aider.conf.yml' },
479
+ { kind: 'pattern', pattern: /^CONVENTIONS\.md$/, mode: 'manual', trigger: 'only with --read, /read or read: in .aider.conf.yml' },
480
+ ],
481
+ limits: [],
482
+ notes: ['Aider loads no instruction file by itself; files given with --read are sent with every request.'],
483
+ },
484
+ {
485
+ id: 'goose',
486
+ name: 'Goose',
487
+ families: [],
488
+ sources: ['https://goose-docs.ai/docs/guides/context-engineering/using-goosehints'],
489
+ checkedAt: '2026-10-10',
490
+ rules: [
491
+ {
492
+ kind: 'hierarchical',
493
+ names: ['AGENTS.md', '.goosehints'],
494
+ nested: 'on-demand',
495
+ nestedTrigger: 'when goose accesses files in',
496
+ },
497
+ ],
498
+ limits: [],
499
+ notes: [
500
+ 'Needs the Developer extension; CONTEXT_FILE_NAMES can change which files are read.',
501
+ 'Global ~/.config/goose/.goosehints is outside the repository and not counted.',
502
+ ],
503
+ },
504
+ {
505
+ id: 'amp',
506
+ name: 'Amp',
507
+ families: [],
508
+ sources: ['https://ampcode.com/docs/customize/agents-md'],
509
+ checkedAt: '2026-10-10',
510
+ rules: [
511
+ {
512
+ kind: 'hierarchical',
513
+ names: ['AGENTS.md', 'AGENT.md', 'CLAUDE.md'],
514
+ onePerDirectory: true,
515
+ nested: 'on-demand',
516
+ nestedTrigger: 'when Amp reads a file in',
517
+ importDepth: 1,
518
+ importGlobs: true,
519
+ note: 'AGENT.md and CLAUDE.md are fallbacks for directories without AGENTS.md; their order between each other, and following @-mentions one level deep, are assumptions.',
520
+ },
521
+ ],
522
+ limits: [],
523
+ notes: ['User and system AGENTS.md files (~/.config/amp, /etc/ampcode) are outside the repository and not counted.'],
524
+ },
525
+ {
526
+ id: 'opencode',
527
+ name: 'OpenCode',
528
+ families: [],
529
+ sources: ['https://opencode.ai/docs/rules/'],
530
+ checkedAt: '2026-10-10',
531
+ rules: [
532
+ {
533
+ kind: 'hierarchical',
534
+ names: ['AGENTS.md', 'CLAUDE.md'],
535
+ onePerDirectory: true,
536
+ nested: 'skip',
537
+ nestedTrigger: '',
538
+ note: 'CLAUDE.md is read only when there is no AGENTS.md.',
539
+ },
540
+ ],
541
+ limits: [],
542
+ notes: ['Extra files listed under instructions in opencode.json are not counted.'],
543
+ },
294
544
  ];
295
545
  export const TOOL_IDS = TOOLS.map((t) => t.id);
296
546
  export function getTool(id) {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * User-level instruction files: the ones in the home directory that an agent adds to every project.
3
+ * Each is classified by the same rules as its project counterpart, through a virtual path
4
+ * (for example ~/.codex/AGENTS.md is read like a root AGENTS.md, but only by Codex).
5
+ */
6
+ import { classify } from './classify.js';
7
+ const same = (p) => p;
8
+ export const USER_FILES = [
9
+ { tool: 'claude-code', pattern: /^\.claude\/CLAUDE\.md$/, as: same, source: 'https://code.claude.com/docs/en/memory' },
10
+ { tool: 'claude-code', pattern: /^\.claude\/rules\/.+\.md$/, as: same, source: 'https://code.claude.com/docs/en/memory' },
11
+ { tool: 'claude-code', pattern: /^\.claude\/skills\/[^/]+\/SKILL\.md$/, as: same, source: 'https://code.claude.com/docs/en/skills' },
12
+ { tool: 'claude-code', pattern: /^\.claude\/commands\/.+\.md$/, as: same, source: 'https://code.claude.com/docs/en/skills' },
13
+ { tool: 'claude-code', pattern: /^\.claude\/agents\/[^/]+\.md$/, as: same, source: 'https://code.claude.com/docs/en/sub-agents' },
14
+ { tool: 'codex', pattern: /^\.codex\/AGENTS(\.override)?\.md$/, as: (p) => p.slice('.codex/'.length), source: 'https://learn.chatgpt.com/docs/agent-configuration/agents-md' },
15
+ { tool: 'codex', pattern: /^\.agents\/skills\/[^/]+\/SKILL\.md$/, as: same, source: 'https://learn.chatgpt.com/docs/build-skills' },
16
+ { tool: 'gemini-cli', pattern: /^\.gemini\/GEMINI\.md$/, as: () => 'GEMINI.md', source: 'https://geminicli.com/docs/cli/gemini-md/' },
17
+ { tool: 'windsurf', pattern: /^\.codeium\/windsurf\/memories\/global_rules\.md$/, as: () => '.windsurfrules', source: 'https://docs.devin.ai/desktop/cascade/memories' },
18
+ { tool: 'cline', pattern: /^(Documents\/Cline\/Rules|Cline\/Rules|\.cline\/rules)\/.+\.md$/, as: (p) => '.clinerules/' + p.replace(/^(Documents\/Cline\/Rules|Cline\/Rules|\.cline\/rules)\//, ''), source: 'https://docs.cline.bot/features/cline-rules' },
19
+ { tool: 'roo-code', pattern: /^\.roo\/rules(-[\w-]+)?\/.+\.(md|txt)$/, as: same, source: 'https://roocodeinc.github.io/Roo-Code/features/custom-instructions' },
20
+ { tool: 'kiro', pattern: /^\.kiro\/steering\/[^/]+\.md$/, as: same, source: 'https://kiro.dev/docs/steering/' },
21
+ { tool: 'goose', pattern: /^\.config\/goose\/\.goosehints$/, as: () => '.goosehints', source: 'https://goose-docs.ai/docs/guides/context-engineering/using-goosehints' },
22
+ { tool: 'amp', pattern: /^\.config\/(amp\/)?AGENTS\.md$/, as: () => 'AGENTS.md', source: 'https://ampcode.com/docs/customize/agents-md' },
23
+ { tool: 'opencode', pattern: /^\.config\/opencode\/AGENTS\.md$/, as: () => 'AGENTS.md', source: 'https://opencode.ai/docs/rules/' },
24
+ // OpenCode falls back to ~/.claude/CLAUDE.md when ~/.config/opencode/AGENTS.md is missing.
25
+ { tool: 'opencode', pattern: /^\.claude\/CLAUDE\.md$/, as: () => 'CLAUDE.md', source: 'https://opencode.ai/docs/rules/' },
26
+ ];
27
+ /** Home-relative folders to search, and how deep. Kept narrow so nothing else in the home directory is read. */
28
+ export const USER_ROOTS = [
29
+ { dir: '.claude', depth: 4 },
30
+ { dir: '.codex', depth: 0 },
31
+ { dir: '.agents/skills', depth: 1 },
32
+ { dir: '.gemini', depth: 0 },
33
+ { dir: '.codeium/windsurf/memories', depth: 0 },
34
+ { dir: 'Documents/Cline/Rules', depth: 3 },
35
+ { dir: 'Cline/Rules', depth: 3 },
36
+ { dir: '.cline/rules', depth: 3 },
37
+ { dir: '.roo', depth: 3 },
38
+ { dir: '.kiro/steering', depth: 0 },
39
+ { dir: '.config/goose', depth: 0 },
40
+ { dir: '.config', depth: 0 },
41
+ { dir: '.config/amp', depth: 0 },
42
+ { dir: '.config/opencode', depth: 0 },
43
+ ];
44
+ export function isUserInstructionFile(homePath) {
45
+ return USER_FILES.some((r) => r.pattern.test(homePath));
46
+ }
47
+ /**
48
+ * Classifies user-level files (paths given as `~/...`). Each tool sees only its own files,
49
+ * so ~/.codex/AGENTS.md never counts for Claude Code.
50
+ */
51
+ export function classifyUser(files, toolIds) {
52
+ const units = [];
53
+ const tools = [...new Set(USER_FILES.map((r) => r.tool))].filter((t) => !toolIds || toolIds.includes(t));
54
+ for (const tool of tools) {
55
+ // Files that map to the same place are all read (Amp loads both ~/.config/amp/AGENTS.md and
56
+ // ~/.config/AGENTS.md), so each collision is classified in a batch of its own.
57
+ const batches = [];
58
+ for (const f of files) {
59
+ const home = f.path.replace(/^~\//, '');
60
+ const rule = USER_FILES.find((r) => r.tool === tool && r.pattern.test(home));
61
+ if (!rule)
62
+ continue;
63
+ const as = rule.as(home);
64
+ let batch = batches.find((b) => !b.virtual.has(as));
65
+ if (!batch)
66
+ batches.push((batch = { virtual: new Map(), files: [] }));
67
+ batch.virtual.set(as, f.path);
68
+ batch.files.push({ path: as, text: f.text });
69
+ }
70
+ for (const batch of batches) {
71
+ for (const u of classify(batch.files, [tool])) {
72
+ const real = batch.virtual.get(u.path);
73
+ if (!real)
74
+ continue; // an @import resolved inside the virtual tree; user-level imports are not followed
75
+ units.push({ ...u, path: real, trigger: `user level, ${u.trigger}`, scope: 'user' });
76
+ }
77
+ }
78
+ }
79
+ return units;
80
+ }