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.
- package/README.md +125 -15
- package/dist/cli.js +120 -11
- package/dist/core/advice.js +5 -3
- package/dist/core/audit.js +40 -6
- package/dist/core/classify.js +48 -7
- package/dist/core/diff.js +63 -0
- package/dist/core/files.js +26 -5
- package/dist/core/markdown.js +10 -0
- package/dist/core/mcp.js +227 -0
- package/dist/core/render.js +219 -18
- package/dist/core/tools.js +250 -0
- package/dist/core/user.js +80 -0
- package/dist/node/discover.js +45 -0
- package/dist/node/git.js +46 -0
- package/dist/node/mcp.js +212 -0
- package/package.json +14 -3
package/dist/core/render.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
['
|
|
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
|
|
40
|
-
const head = [
|
|
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 = [['
|
|
56
|
-
for (const f of report
|
|
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.
|
|
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
|
|
99
|
-
out.push('|
|
|
100
|
-
for (const t of report
|
|
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(`| ${
|
|
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
|
-
? `| ${
|
|
106
|
-
: `| ${
|
|
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
|
-
|
|
109
|
-
|
|
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.
|
|
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 `[})](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
|
+
}
|
package/dist/core/tools.js
CHANGED
|
@@ -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
|
+
}
|