turntax 0.3.0 → 0.5.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 +53 -7
- package/dist/cli.js +147 -21
- package/dist/core/advice.js +79 -25
- package/dist/core/fix.js +442 -0
- package/dist/core/glob.js +34 -0
- package/dist/core/render.js +63 -6
- package/dist/node/apply.js +43 -0
- package/dist/node/config.js +52 -0
- package/dist/node/discover.js +35 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,9 @@ Coding agents re-send their instruction files with every turn. `turntax` measure
|
|
|
6
6
|
npx turntax
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
Try it in the browser, with nothing uploaded: https://torutdev.github.io/turntax/
|
|
9
|
+
Try it in the browser, with nothing uploaded: https://torutdev.github.io/turntax/ — drop a folder or enter a public GitHub repository. Links like `https://torutdev.github.io/turntax/?repo=owner/name` open straight to that repository's report.
|
|
10
|
+
|
|
11
|
+
[State of agent instruction files, October 2026](https://torutdev.github.io/turntax/state/): what turntax finds in 100 popular repositories, with pinned commits and the script to reproduce it ([`scripts/state-of.mjs`](scripts/state-of.mjs)).
|
|
10
12
|
|
|
11
13
|
## Supported agents
|
|
12
14
|
|
|
@@ -49,7 +51,7 @@ Advice (savings are estimates in o200k tokens per turn)
|
|
|
49
51
|
3 identical lines are loaded twice every turn by cursor. Keep them in one file.
|
|
50
52
|
HIGH contradiction AGENTS.md, CLAUDE.md [saves ~11 tokens/turn]
|
|
51
53
|
Conflicting package manager: "Always use npm ..." (AGENTS.md:3) vs "Always use pnpm ..." (CLAUDE.md:5).
|
|
52
|
-
MEDIUM scopable-section CLAUDE.md:
|
|
54
|
+
MEDIUM scopable-section CLAUDE.md:12 [saves ~128 tokens/turn]
|
|
53
55
|
Section "API package" is mostly about packages/api/. Move it to packages/api/AGENTS.md ...
|
|
54
56
|
```
|
|
55
57
|
|
|
@@ -83,6 +85,41 @@ All checks are deterministic. Each one shows an estimated saving in o200k tokens
|
|
|
83
85
|
- **stale-reference**: referenced files or `npm run` scripts that no longer exist.
|
|
84
86
|
- **non-english**: non-English sections. The token multiplier is measured only against an English version you supply (`--english CLAUDE.md=CLAUDE.en.md`) or, if you opt in, a Claude translation (`--translate`).
|
|
85
87
|
|
|
88
|
+
## Fixing
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npx turntax --fix # print a diff of the safe fixes; nothing is changed
|
|
92
|
+
npx turntax --fix --write # apply them
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`--fix` handles three kinds of advice:
|
|
96
|
+
|
|
97
|
+
- **duplicate**: removes lines from a file when every agent that reads the file also reads the same lines every turn from another file. Skill, command and subagent files are left whole.
|
|
98
|
+
- **scopable-section**: moves a section into the folder it is about: the same file name there (`packages/api/CLAUDE.md`), a nested `AGENTS.md`, or a Claude Code rule with `paths:`. The folder the section is about must already exist, and sections with `@imports` or relative links stay put. A section moves together with its subsections.
|
|
99
|
+
- **misconfigured**: renames `.md` files in `.cursor/rules` to `.mdc`.
|
|
100
|
+
|
|
101
|
+
Before a fix is kept, the files are classified again with the change applied: every agent must still read every line it read before, including files reached through `@imports`, and as often as before. The only exception is a moved section, which may load only when the agent works in that folder. Otherwise the fix is skipped and the reason is printed. A moved section can reach agents that did not read it before (a nested `AGENTS.md` is read by more agents than a `CLAUDE.md`); the summary names them. By default that check covers every supported agent, so a section of `AGENTS.md` usually stays where it is, because some agents read only the root file. `--tools codex,claude-code` limits the check to the agents you use.
|
|
102
|
+
|
|
103
|
+
In preview the diff is on stdout and works with `git apply`, and the summary is on stderr; with `--write` the summary is on stdout. `--write` checks that no file changed since it was read and writes nothing if one did. Symlinked files, their targets and files in symlinked folders are never changed. Contradictions and stale references need a person to decide, so `--fix` leaves them alone.
|
|
104
|
+
|
|
105
|
+
The web page shows the same fixes for any repository you load, with a downloadable `fix.patch`.
|
|
106
|
+
|
|
107
|
+
## Config file
|
|
108
|
+
|
|
109
|
+
Put defaults in `turntax.config.json` in the audited folder (or pass `--config <file>`). Command-line flags override it.
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"tools": ["claude-code", "codex", "cursor"],
|
|
114
|
+
"models": ["claude-sonnet-5.5", "gpt-6.1-sol"],
|
|
115
|
+
"maxTokens": 8000,
|
|
116
|
+
"maxIncrease": 500,
|
|
117
|
+
"ignore": ["docs/agents/archive/", "**/*.draft.md"]
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Keys: `tools`, `models`, `turns`, `maxTokens`, `maxIncrease` (applies only with `--compare` or `--baseline`), `sectionTokens`, and `ignore`: globs of instruction files to leave out, relative to the audited folder (`*` and `?` within a folder name, `**` across folders, a trailing `/` for a whole folder). Ignored files are also left out of `--compare` and are never changed by `--fix`. Unknown keys and ids are errors.
|
|
122
|
+
|
|
86
123
|
## MCP servers
|
|
87
124
|
|
|
88
125
|
Every MCP server an agent starts adds its tool definitions (names, descriptions, input schemas) to the context. `turntax` reads the agents' MCP configs and lists the servers each agent starts:
|
|
@@ -135,7 +172,7 @@ The comparison covers repository instruction files (o200k), per agent and per fi
|
|
|
135
172
|
|
|
136
173
|
## GitHub Action
|
|
137
174
|
|
|
138
|
-
The repository is also an Action. On pull requests it compares with the base branch, posts one comment (updated on every push)
|
|
175
|
+
The repository is also an Action. On pull requests it compares with the base branch, posts one comment (updated on every push), writes the report to the job summary and marks findings on the lines they are about in the pull request's changed files (GitHub shows up to 10 warnings and 10 notices per step):
|
|
139
176
|
|
|
140
177
|
```yaml
|
|
141
178
|
name: turntax
|
|
@@ -148,14 +185,15 @@ jobs:
|
|
|
148
185
|
runs-on: ubuntu-latest
|
|
149
186
|
steps:
|
|
150
187
|
- uses: actions/checkout@v4
|
|
151
|
-
- uses: TorutDev/turntax@v0.
|
|
188
|
+
- uses: TorutDev/turntax@v0.5.0
|
|
152
189
|
with:
|
|
153
190
|
max-increase: 500 # optional: fail when an agent grows by more than 500 tokens per turn
|
|
154
191
|
# max-tokens: 8000 # optional: absolute budget
|
|
155
192
|
# args: --tools claude-code,cursor
|
|
193
|
+
# annotations: false # optional: no findings on the changed lines
|
|
156
194
|
```
|
|
157
195
|
|
|
158
|
-
The comment opens with a table of the change per agent and per file, and
|
|
196
|
+
The comment opens with a table of the change per agent and per file. When `--fix` has safe fixes, it says how many and how many tokens they save, with the diff folded; the full report is folded below. A `turntax.config.json` in the repository is read as well. Pull requests from forks get a read-only token, so the comment step is skipped there; the job summary still has the report.
|
|
159
197
|
|
|
160
198
|
## Options
|
|
161
199
|
|
|
@@ -165,7 +203,8 @@ turntax [path]
|
|
|
165
203
|
--models <ids> see --list-models (default: claude-sonnet-5.5, gpt-6.1-sol, gemini-3.8-flash)
|
|
166
204
|
--turns <n> cost projection (default 100)
|
|
167
205
|
--json | --md | --badge output format (default: terminal table)
|
|
168
|
-
--pr-comment Markdown for a pull request comment (change first, full report folded)
|
|
206
|
+
--pr-comment Markdown for a pull request comment (change first, fixes and full report folded)
|
|
207
|
+
--annotations GitHub Actions annotations, one per finding (--path-prefix <dir> for a subfolder)
|
|
169
208
|
--out <file> write the output to a file
|
|
170
209
|
--max-tokens <n> exit 1 when any tool loads more than n o200k tokens per turn (CI gate)
|
|
171
210
|
--include-user add user-level files and MCP servers from your home directory
|
|
@@ -180,18 +219,25 @@ turntax [path]
|
|
|
180
219
|
--translate opt-in Claude translation for that measurement (needs --claude-api)
|
|
181
220
|
--prices <file.json> your own price table
|
|
182
221
|
--section-tokens <n> threshold for oversized sections (default 600)
|
|
222
|
+
--fix print a diff of the safe fixes (--tools limits the agents whose context is kept)
|
|
223
|
+
--write with --fix, apply the changes
|
|
224
|
+
--config <file> read defaults from this file (default: turntax.config.json, if any)
|
|
183
225
|
```
|
|
184
226
|
|
|
227
|
+
When a selected model's price was checked more than 90 days ago, turntax says so on stderr; check the source or pass `--prices`.
|
|
228
|
+
|
|
185
229
|
## Privacy
|
|
186
230
|
|
|
187
231
|
Offline by default: no network calls, no telemetry. File contents are sent only when you pass `--claude-api`, `--gemini-api` or `--translate`, and only to that vendor's API. Keys are read from environment variables and sent only in request headers.
|
|
188
232
|
|
|
189
|
-
`--mcp` runs the commands in the MCP configs it finds and connects to the configured HTTP servers, as the agents themselves would. Run it only on repositories whose configs you trust. `turntax` prints each command before starting it. `--include-user` reads files in your home directory and never sends them anywhere.
|
|
233
|
+
`--mcp` runs the commands in the MCP configs it finds and connects to the configured HTTP servers, as the agents themselves would. Run it only on repositories whose configs you trust. `turntax` prints each command before starting it. `--include-user` reads files in your home directory and never sends them anywhere. `--fix --write` is the only option that changes files, and only repository files.
|
|
190
234
|
|
|
191
235
|
## Web page
|
|
192
236
|
|
|
193
237
|
[`web/`](web) is a static page that runs the same engine in the browser with the offline tokenizer only. Drop a repository folder or paste files; nothing is uploaded.
|
|
194
238
|
|
|
239
|
+
It can also load a public GitHub repository: it lists the files with the GitHub API (`api.github.com`, unauthenticated, 60 requests per hour) and downloads only the instruction files, and files they `@import`, from `raw.githubusercontent.com`. Those are the only network requests the page makes, and only when you load a repository or open a `?repo=` link.
|
|
240
|
+
|
|
195
241
|
```bash
|
|
196
242
|
npm run build:web
|
|
197
243
|
```
|
package/dist/cli.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { basename, relative, resolve } from 'node:path';
|
|
3
|
+
import { basename, isAbsolute, posix, relative, resolve } from 'node:path';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
5
|
import { hasNonEnglish } from './core/advice.js';
|
|
6
6
|
import { audit } from './core/audit.js';
|
|
7
7
|
import { classify } from './core/classify.js';
|
|
8
8
|
import { asDiffInput, diffReports, maxIncrease } from './core/diff.js';
|
|
9
|
+
import { planFixes, unifiedDiff } from './core/fix.js';
|
|
10
|
+
import { globMatcher } from './core/glob.js';
|
|
9
11
|
import { parseMcpConfigs, serverKey, toolDefinitionsText, toolNamesText } from './core/mcp.js';
|
|
10
12
|
import { DEFAULT_MODEL_IDS, MODELS, validateModels } from './core/prices.js';
|
|
11
|
-
import { badgeMarkdown, headlineTokens, renderMarkdown, renderPrComment, renderText } from './core/render.js';
|
|
13
|
+
import { badgeMarkdown, headlineTokens, renderAnnotations, renderMarkdown, renderPrComment, renderText } from './core/render.js';
|
|
12
14
|
import { countO200k } from './core/tokenize.js';
|
|
13
15
|
import { TOOL_IDS } from './core/tools.js';
|
|
14
|
-
import {
|
|
16
|
+
import { applyChanges } from './node/apply.js';
|
|
17
|
+
import { CONFIG_FILE, loadConfig } from './node/config.js';
|
|
18
|
+
import { discover, discoverMcpConfigs, discoverUser, existsIn, linkedPaths, packageScripts } from './node/discover.js';
|
|
15
19
|
import { filesAtRef } from './node/git.js';
|
|
16
20
|
import { measureServer } from './node/mcp.js';
|
|
17
21
|
import { anthropicCounter, geminiCounter, translateToEnglish } from './node/remote.js';
|
|
@@ -31,6 +35,8 @@ Options:
|
|
|
31
35
|
--pr-comment Print a pull request comment: the change first, the full report folded
|
|
32
36
|
--out <file> Write the report to a file instead of stdout
|
|
33
37
|
--badge Print a shields.io badge snippet
|
|
38
|
+
--annotations Print GitHub Actions annotations for the advice, shown inline on pull requests
|
|
39
|
+
--path-prefix <dir> With --annotations, the audited folder relative to the repository root
|
|
34
40
|
--max-tokens <n> Exit with code 1 if any tool loads more than n o200k tokens per turn
|
|
35
41
|
(repository files plus measured project MCP tools)
|
|
36
42
|
--include-user Also count user-level files and MCP servers in your home directory
|
|
@@ -51,9 +57,21 @@ Options:
|
|
|
51
57
|
(needs --claude-api; sends those files to api.anthropic.com)
|
|
52
58
|
--prices <file.json> Use your own price table (same shape as --list-models --json)
|
|
53
59
|
--section-tokens <n> Flag always-loaded sections at or above n o200k tokens (default: 600)
|
|
60
|
+
--fix Print a diff of the safe fixes: duplicate lines, sections that can load only in
|
|
61
|
+
their folder, misnamed Cursor rules. Every agent keeps reading the moved text;
|
|
62
|
+
--tools limits the agents whose context is kept
|
|
63
|
+
--write With --fix, apply the changes to the files
|
|
64
|
+
--config <file> Read defaults from this file (default: ${CONFIG_FILE} in the audited folder, if any)
|
|
54
65
|
-h, --help Show help
|
|
55
66
|
-v, --version Show version
|
|
56
67
|
|
|
68
|
+
Config file keys (all optional; command-line flags override them):
|
|
69
|
+
tools, models Lists of ids, like --tools and --models
|
|
70
|
+
turns, maxTokens, maxIncrease, sectionTokens
|
|
71
|
+
Integers, like the matching flags (maxIncrease applies only when comparing)
|
|
72
|
+
ignore Globs of instruction files to leave out, relative to the audited folder (* and ? within a
|
|
73
|
+
folder name, ** across folders, a trailing / for a whole folder)
|
|
74
|
+
|
|
57
75
|
Without --claude-api, --gemini-api or --mcp nothing leaves your machine.
|
|
58
76
|
`;
|
|
59
77
|
class UsageError extends Error {
|
|
@@ -72,7 +90,7 @@ function positiveInt(name, value, fallback) {
|
|
|
72
90
|
throw new UsageError(`--${name} must be a positive integer`);
|
|
73
91
|
return n;
|
|
74
92
|
}
|
|
75
|
-
export async function main(argv, env = process.env) {
|
|
93
|
+
export async function main(argv, env = process.env, now = new Date()) {
|
|
76
94
|
let parsed;
|
|
77
95
|
try {
|
|
78
96
|
parsed = parseArgs({
|
|
@@ -88,6 +106,9 @@ export async function main(argv, env = process.env) {
|
|
|
88
106
|
'pr-comment': { type: 'boolean' },
|
|
89
107
|
out: { type: 'string' },
|
|
90
108
|
badge: { type: 'boolean' },
|
|
109
|
+
annotations: { type: 'boolean' },
|
|
110
|
+
'path-prefix': { type: 'string' },
|
|
111
|
+
config: { type: 'string' },
|
|
91
112
|
'max-tokens': { type: 'string' },
|
|
92
113
|
'claude-api': { type: 'boolean' },
|
|
93
114
|
'gemini-api': { type: 'boolean' },
|
|
@@ -101,6 +122,8 @@ export async function main(argv, env = process.env) {
|
|
|
101
122
|
baseline: { type: 'string' },
|
|
102
123
|
compare: { type: 'string' },
|
|
103
124
|
'max-increase': { type: 'string' },
|
|
125
|
+
fix: { type: 'boolean' },
|
|
126
|
+
write: { type: 'boolean' },
|
|
104
127
|
help: { type: 'boolean', short: 'h' },
|
|
105
128
|
version: { type: 'boolean', short: 'v' },
|
|
106
129
|
},
|
|
@@ -132,24 +155,56 @@ export async function main(argv, env = process.env) {
|
|
|
132
155
|
process.stdout.write(o.json ? JSON.stringify(catalog, null, 2) + '\n' : catalog.map(describeModel).join('\n') + '\n');
|
|
133
156
|
return 0;
|
|
134
157
|
}
|
|
135
|
-
const
|
|
158
|
+
const root = resolve(positionals[0] ?? '.');
|
|
159
|
+
let config;
|
|
160
|
+
try {
|
|
161
|
+
config = loadConfig(root, o.config, catalog);
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
throw new UsageError(e.message);
|
|
165
|
+
}
|
|
166
|
+
const modelIds = list(o.models) ?? config.models ?? DEFAULT_MODEL_IDS.filter((id) => catalog.some((m) => m.id === id));
|
|
136
167
|
const models = modelIds.map((id) => {
|
|
137
168
|
const m = catalog.find((x) => x.id === id);
|
|
138
169
|
if (!m)
|
|
139
170
|
throw new UsageError(`Unknown model "${id}". Run --list-models.`);
|
|
140
171
|
return m;
|
|
141
172
|
});
|
|
142
|
-
const tools = list(o.tools);
|
|
173
|
+
const tools = list(o.tools) ?? config.tools;
|
|
143
174
|
for (const t of tools ?? [])
|
|
144
175
|
if (!TOOL_IDS.includes(t))
|
|
145
176
|
throw new UsageError(`Unknown tool "${t}". Use: ${TOOL_IDS.join(', ')}`);
|
|
146
|
-
const
|
|
147
|
-
const
|
|
177
|
+
const ignored = globMatcher(config.ignore ?? []);
|
|
178
|
+
const repoFiles = (files) => files.filter((f) => !ignored(f.path));
|
|
179
|
+
if (o.write && !o.fix)
|
|
180
|
+
throw new UsageError('--write needs --fix.');
|
|
181
|
+
if (o['path-prefix'] !== undefined && !o.annotations)
|
|
182
|
+
throw new UsageError('--path-prefix needs --annotations.');
|
|
183
|
+
if (o.annotations) {
|
|
184
|
+
const other = ['json', 'md', 'pr-comment', 'badge', 'fix'].filter((k) => o[k] !== undefined);
|
|
185
|
+
if (other.length)
|
|
186
|
+
throw new UsageError(`--annotations cannot be combined with --${other.join(', --')}.`);
|
|
187
|
+
}
|
|
188
|
+
if (o.fix) {
|
|
189
|
+
const other = ['json', 'md', 'pr-comment', 'badge', 'baseline', 'compare', 'max-tokens', 'max-increase', 'include-user', 'mcp'].filter((k) => o[k] !== undefined);
|
|
190
|
+
if (o.write && o.out)
|
|
191
|
+
other.push('out');
|
|
192
|
+
if (other.length)
|
|
193
|
+
throw new UsageError(`--fix${o.write ? ' --write' : ''} cannot be combined with --${other.join(', --')}.`);
|
|
194
|
+
return await fix(root, tools, ignored, Boolean(o.write), o.out);
|
|
195
|
+
}
|
|
196
|
+
const discovered = await discover(root);
|
|
197
|
+
const files = repoFiles(discovered);
|
|
198
|
+
// Budgets in the config are for report runs; an --annotations run only prints findings.
|
|
199
|
+
const gates = o.annotations ? {} : config;
|
|
200
|
+
const label = (flag, key) => o[flag] !== undefined ? `--${flag}` : `${key} in ${o.config ?? CONFIG_FILE}`;
|
|
148
201
|
if (o.baseline && o.compare)
|
|
149
202
|
throw new UsageError('Use --baseline or --compare, not both.');
|
|
203
|
+
// The config's maxIncrease applies only when a comparison runs, so plain local runs keep working.
|
|
150
204
|
if (o['max-increase'] !== undefined && !o.baseline && !o.compare)
|
|
151
205
|
throw new UsageError('--max-increase needs --baseline or --compare.');
|
|
152
|
-
const maxIncreaseLimit = o['max-increase'] === undefined ?
|
|
206
|
+
const maxIncreaseLimit = o['max-increase'] === undefined ? gates.maxIncrease : nonNegativeInt('max-increase', o['max-increase']);
|
|
207
|
+
stalePrices(models, now);
|
|
153
208
|
let userFiles = [];
|
|
154
209
|
let mcpFiles = discoverMcpConfigs(root);
|
|
155
210
|
if (o['include-user']) {
|
|
@@ -211,14 +266,14 @@ export async function main(argv, env = process.env) {
|
|
|
211
266
|
tools,
|
|
212
267
|
models,
|
|
213
268
|
counters,
|
|
214
|
-
turns: positiveInt('turns', o.turns, 100),
|
|
269
|
+
turns: positiveInt('turns', o.turns, config.turns ?? 100),
|
|
215
270
|
userFiles,
|
|
216
271
|
mcp: { servers, measured },
|
|
217
272
|
advice: {
|
|
218
273
|
exists: existsIn(root),
|
|
219
274
|
packageScripts: packageScripts(root),
|
|
220
275
|
english,
|
|
221
|
-
sectionTokens: positiveInt('section-tokens', o['section-tokens'], 600),
|
|
276
|
+
sectionTokens: positiveInt('section-tokens', o['section-tokens'], config.sectionTokens ?? 600),
|
|
222
277
|
},
|
|
223
278
|
});
|
|
224
279
|
if (o.baseline) {
|
|
@@ -235,7 +290,7 @@ export async function main(argv, env = process.env) {
|
|
|
235
290
|
const base = await filesAtRef(root, o.compare).catch((e) => {
|
|
236
291
|
throw new UsageError(e.message);
|
|
237
292
|
});
|
|
238
|
-
const before = await audit(base.files, {
|
|
293
|
+
const before = await audit(repoFiles(base.files), {
|
|
239
294
|
tools,
|
|
240
295
|
models: [],
|
|
241
296
|
mcp: { servers: parseMcpConfigs(base.mcpConfigs, root).servers },
|
|
@@ -245,24 +300,26 @@ export async function main(argv, env = process.env) {
|
|
|
245
300
|
const output = o.json
|
|
246
301
|
? JSON.stringify({ root, ...report }, null, 2) + '\n'
|
|
247
302
|
: o['pr-comment']
|
|
248
|
-
? renderPrComment(report)
|
|
249
|
-
: o.
|
|
250
|
-
?
|
|
251
|
-
: o.
|
|
252
|
-
?
|
|
253
|
-
:
|
|
303
|
+
? renderPrComment(report, files.length ? fixPlan(root, discovered, tools, ignored) : undefined, fixCommand(positionals[0], o.config, o.tools))
|
|
304
|
+
: o.annotations
|
|
305
|
+
? renderAnnotations(report, pathPrefix(o['path-prefix']))
|
|
306
|
+
: o.md
|
|
307
|
+
? renderMarkdown(report)
|
|
308
|
+
: o.badge
|
|
309
|
+
? badgeMarkdown(report) + '\n'
|
|
310
|
+
: renderText(report, root) + '\n';
|
|
254
311
|
if (o.out)
|
|
255
312
|
writeFileSync(o.out, output);
|
|
256
313
|
else
|
|
257
314
|
process.stdout.write(output);
|
|
258
315
|
let code = 0;
|
|
259
|
-
const max = o['max-tokens'] === undefined ?
|
|
316
|
+
const max = o['max-tokens'] === undefined ? gates.maxTokens : positiveInt('max-tokens', o['max-tokens'], 0);
|
|
260
317
|
if (max !== undefined && headlineTokens(report) > max) {
|
|
261
|
-
process.stderr.write(`turntax: ${headlineTokens(report)} o200k tokens per turn exceeds
|
|
318
|
+
process.stderr.write(`turntax: ${headlineTokens(report)} o200k tokens per turn exceeds ${label('max-tokens', 'maxTokens')} ${max}\n`);
|
|
262
319
|
code = 1;
|
|
263
320
|
}
|
|
264
321
|
if (maxIncreaseLimit !== undefined && report.diff && maxIncrease(report.diff) > maxIncreaseLimit) {
|
|
265
|
-
process.stderr.write(`turntax: per-turn tokens grew by ${maxIncrease(report.diff)} vs ${report.diff.base}, more than
|
|
322
|
+
process.stderr.write(`turntax: per-turn tokens grew by ${maxIncrease(report.diff)} vs ${report.diff.base}, more than ${label('max-increase', 'maxIncrease')} ${maxIncreaseLimit}\n`);
|
|
266
323
|
code = 1;
|
|
267
324
|
}
|
|
268
325
|
return code;
|
|
@@ -276,6 +333,75 @@ export async function main(argv, env = process.env) {
|
|
|
276
333
|
return 1;
|
|
277
334
|
}
|
|
278
335
|
}
|
|
336
|
+
async function fix(root, tools, ignored, write, out) {
|
|
337
|
+
const discovered = await discover(root);
|
|
338
|
+
const plan = fixPlan(root, discovered, tools, ignored);
|
|
339
|
+
const log = (line) => (write ? process.stdout : process.stderr).write(line + '\n');
|
|
340
|
+
for (const f of plan.fixes)
|
|
341
|
+
log(`fix: ${f.message}${f.savingTokens ? ` [saves ~${f.savingTokens} tokens/turn]` : ''}`);
|
|
342
|
+
for (const s of plan.skipped)
|
|
343
|
+
log(`skipped ${s.kind}: ${s.reason}`);
|
|
344
|
+
if (!plan.changes.length) {
|
|
345
|
+
// An empty patch, so an earlier patch in the same file is not applied again.
|
|
346
|
+
if (out && !write)
|
|
347
|
+
writeFileSync(out, '');
|
|
348
|
+
log('Nothing to fix automatically.');
|
|
349
|
+
return 0;
|
|
350
|
+
}
|
|
351
|
+
if (write) {
|
|
352
|
+
applyChanges(root, plan.changes);
|
|
353
|
+
log(`Changed ${plan.changes.length} file(s): ${plan.changes.map((c) => c.path).join(', ')}`);
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
const diff = unifiedDiff(plan.changes);
|
|
357
|
+
if (out)
|
|
358
|
+
writeFileSync(out, diff);
|
|
359
|
+
else
|
|
360
|
+
process.stdout.write(diff);
|
|
361
|
+
log('Preview only. Run again with --write to apply, or save the diff and use git apply.');
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Safe fixes. The plan sees every repository file, ignored ones included, because the agents still read
|
|
366
|
+
* them and the safety check must see what they read; symlinks, their targets and ignored files are never
|
|
367
|
+
* written.
|
|
368
|
+
*/
|
|
369
|
+
function fixPlan(root, discovered, tools, ignored) {
|
|
370
|
+
const linked = linkedPaths(root, discovered);
|
|
371
|
+
return planFixes(discovered, {
|
|
372
|
+
tools,
|
|
373
|
+
exists: existsIn(root),
|
|
374
|
+
readOnly: (p) => (ignored(p) ? `${p} is in the config's ignore list; it is left alone.` : linked(p)),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/** The --fix command matching the run that made a pull request comment. */
|
|
378
|
+
function fixCommand(path, config, tools) {
|
|
379
|
+
const arg = (s) => (/^[\w./@:,=-]+$/.test(s) ? s : `"${s}"`);
|
|
380
|
+
const parts = ['npx turntax'];
|
|
381
|
+
if (path && path !== '.' && !isAbsolute(path))
|
|
382
|
+
parts.push(arg(path));
|
|
383
|
+
if (config)
|
|
384
|
+
parts.push('--config', arg(config));
|
|
385
|
+
if (tools)
|
|
386
|
+
parts.push('--tools', arg(tools));
|
|
387
|
+
return [...parts, '--fix'].join(' ');
|
|
388
|
+
}
|
|
389
|
+
/** `--path-prefix` as a POSIX folder ending in a slash, or '' for the repository root. */
|
|
390
|
+
function pathPrefix(dir) {
|
|
391
|
+
if (!dir)
|
|
392
|
+
return '';
|
|
393
|
+
const p = posix.normalize(dir.replace(/\\/g, '/') + '/');
|
|
394
|
+
return p === './' ? '' : p;
|
|
395
|
+
}
|
|
396
|
+
/** One notice when a selected model's price was checked more than 90 days before `now`. */
|
|
397
|
+
function stalePrices(models, now) {
|
|
398
|
+
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
399
|
+
const stale = models.filter((m) => today - Date.parse(m.checkedAt) > 90 * 86_400_000);
|
|
400
|
+
if (!stale.length)
|
|
401
|
+
return;
|
|
402
|
+
const dates = [...new Set(stale.map((m) => m.checkedAt))].sort();
|
|
403
|
+
notice(`prices for ${stale.map((m) => m.label).join(', ')} were checked on ${dates.join(', ')}, over 90 days ago; check the sources or pass --prices.`);
|
|
404
|
+
}
|
|
279
405
|
function nonNegativeInt(name, value) {
|
|
280
406
|
const n = Number(value);
|
|
281
407
|
if (!Number.isInteger(n) || n < 0)
|
package/dist/core/advice.js
CHANGED
|
@@ -5,18 +5,57 @@ const DEFAULT_SECTION_TOKENS = 600;
|
|
|
5
5
|
export function buildAdvice(units, files, opts = {}) {
|
|
6
6
|
// User-level files take part in checks across files; moving sections or checking paths only makes sense in the repository.
|
|
7
7
|
const repo = units.filter((u) => !u.scope);
|
|
8
|
+
const raw = rawLines(files);
|
|
8
9
|
const advice = [
|
|
9
10
|
...misconfigured(units),
|
|
10
11
|
...sizeLimits(units, files),
|
|
11
12
|
...duplicates(units),
|
|
12
|
-
...contradictions(units),
|
|
13
|
-
...sections(repo, opts.sectionTokens ?? DEFAULT_SECTION_TOKENS),
|
|
14
|
-
...nonEnglish(units, opts.english),
|
|
15
|
-
...staleReferences(repo, opts),
|
|
13
|
+
...contradictions(units, raw),
|
|
14
|
+
...sections(repo, opts.sectionTokens ?? DEFAULT_SECTION_TOKENS, raw, opts.exists),
|
|
15
|
+
...nonEnglish(units, raw, opts.english),
|
|
16
|
+
...staleReferences(repo, opts, raw),
|
|
16
17
|
];
|
|
17
18
|
const order = { high: 0, medium: 1, low: 2 };
|
|
18
19
|
return advice.sort((a, b) => order[a.severity] - order[b.severity] || (b.savingTokens ?? 0) - (a.savingTokens ?? 0));
|
|
19
20
|
}
|
|
21
|
+
function rawLines(files) {
|
|
22
|
+
const byPath = new Map(files.map((f) => [f.path, f.text]));
|
|
23
|
+
const cache = new Map();
|
|
24
|
+
return (u, line) => {
|
|
25
|
+
const key = `${u.path}\0${u.text}`;
|
|
26
|
+
let map = cache.get(key);
|
|
27
|
+
if (!map)
|
|
28
|
+
cache.set(key, (map = alignLines(u.text, byPath.get(u.path))));
|
|
29
|
+
return map[line - 1];
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* For each line of `text` (what an agent loads: frontmatter, HTML comments or a truncated tail removed),
|
|
34
|
+
* the 1-based line of `raw` it comes from. Lines are only ever removed, so line i is at raw line i or later.
|
|
35
|
+
*/
|
|
36
|
+
function alignLines(text, raw) {
|
|
37
|
+
if (raw === undefined)
|
|
38
|
+
return [];
|
|
39
|
+
const lines = text.split(/\r?\n/);
|
|
40
|
+
const rawLines = raw.split(/\r?\n/);
|
|
41
|
+
// Frontmatter removed: the text is the end of the file.
|
|
42
|
+
const offset = rawLines.length - lines.length;
|
|
43
|
+
if (offset >= 0 && rawLines.slice(offset).join('\n') === lines.join('\n'))
|
|
44
|
+
return lines.map((_, i) => offset + i + 1);
|
|
45
|
+
const out = [];
|
|
46
|
+
let next = 0;
|
|
47
|
+
lines.forEach((line, i) => {
|
|
48
|
+
const want = line.trim();
|
|
49
|
+
for (let j = Math.max(next, i); j < rawLines.length; j++) {
|
|
50
|
+
if (rawLines[j].trim() !== want)
|
|
51
|
+
continue;
|
|
52
|
+
out[i] = j + 1;
|
|
53
|
+
next = j + 1;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
20
59
|
/** Units that carry real instruction text (not skill descriptions), grouped by path. */
|
|
21
60
|
function contentUnits(units, modes = ['always', 'on-demand']) {
|
|
22
61
|
return units.filter((u) => u.part !== 'description' && modes.includes(u.mode));
|
|
@@ -146,6 +185,7 @@ function duplicates(units) {
|
|
|
146
185
|
}
|
|
147
186
|
return out;
|
|
148
187
|
}
|
|
188
|
+
const at = (d) => (d.line ? `${d.path}:${d.line}` : d.path);
|
|
149
189
|
const DIRECTIVES = [
|
|
150
190
|
{
|
|
151
191
|
topic: 'package manager',
|
|
@@ -203,7 +243,7 @@ function conflicts(a, b) {
|
|
|
203
243
|
return a === `not ${b}` || b === `not ${a}`;
|
|
204
244
|
return true;
|
|
205
245
|
}
|
|
206
|
-
function contradictions(units) {
|
|
246
|
+
function contradictions(units, raw) {
|
|
207
247
|
const found = new Map();
|
|
208
248
|
const tools = [...new Set(units.map((u) => u.tool))];
|
|
209
249
|
for (const tool of tools) {
|
|
@@ -213,7 +253,7 @@ function contradictions(units) {
|
|
|
213
253
|
if (inFence)
|
|
214
254
|
return;
|
|
215
255
|
for (const d of directivesIn(line))
|
|
216
|
-
directives.push({ ...d, path: u.path, line: i + 1, text: line.trim() });
|
|
256
|
+
directives.push({ ...d, path: u.path, line: raw(u, i + 1), text: line.trim() });
|
|
217
257
|
});
|
|
218
258
|
}
|
|
219
259
|
for (let i = 0; i < directives.length; i++) {
|
|
@@ -222,7 +262,7 @@ function contradictions(units) {
|
|
|
222
262
|
const b = directives[j];
|
|
223
263
|
if (a.topic !== b.topic || !conflicts(a.value, b.value))
|
|
224
264
|
continue;
|
|
225
|
-
const key = [a.topic,
|
|
265
|
+
const key = [a.topic, at(a), at(b), a.text, b.text].join('|');
|
|
226
266
|
const prev = found.get(key);
|
|
227
267
|
if (prev) {
|
|
228
268
|
prev.tools.push(tool);
|
|
@@ -234,7 +274,7 @@ function contradictions(units) {
|
|
|
234
274
|
severity: 'high',
|
|
235
275
|
tools: [tool],
|
|
236
276
|
paths: [...new Set([a.path, b.path])],
|
|
237
|
-
message: `Conflicting ${a.topic}: "${a.text}" (${a
|
|
277
|
+
message: `Conflicting ${a.topic}: "${a.text}" (${at(a)}) vs "${b.text}" (${at(b)}).`,
|
|
238
278
|
savingTokens: countO200k(shorter),
|
|
239
279
|
savingNote: 'Removing the outdated line.',
|
|
240
280
|
});
|
|
@@ -259,23 +299,21 @@ export function pathRefs(text) {
|
|
|
259
299
|
.map((r) => r.replace(/^\.\//, '').replace(/[),.;:]+$/, ''))
|
|
260
300
|
.filter((r) => r.includes('/') && !/^(https?:|~|\/|[A-Za-z]:|@|<)/.test(r) && !/[*?{}<>$]/.test(r));
|
|
261
301
|
}
|
|
262
|
-
function sections(units, threshold) {
|
|
302
|
+
function sections(units, threshold, raw, exists) {
|
|
263
303
|
const out = [];
|
|
264
304
|
for (const u of uniqueByPath(contentUnits(units, ['always']))) {
|
|
265
305
|
const tools = toolsWith(units, u.path, 'always');
|
|
266
306
|
for (const s of splitSections(u.text)) {
|
|
267
307
|
const tokens = countO200k(s.text);
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
if (tokens >= 100 && top && top.count >= 2 && top.count / dirs.length >= 0.6) {
|
|
272
|
-
const scope = commonDir(refs.filter((r) => r.startsWith(top.value + '/')));
|
|
308
|
+
// A folder that does not exist (a URL path, a placeholder) is not a place the section can move to.
|
|
309
|
+
const scope = sectionScope(s.text);
|
|
310
|
+
if (scope && (!exists || exists(scope))) {
|
|
273
311
|
out.push({
|
|
274
312
|
kind: 'scopable-section',
|
|
275
313
|
severity: 'medium',
|
|
276
314
|
tools,
|
|
277
315
|
paths: [u.path],
|
|
278
|
-
line: s.line,
|
|
316
|
+
line: raw(u, s.line),
|
|
279
317
|
message: `Section "${s.heading}" is mostly about ${scope}/. Move it to ${scope}/AGENTS.md (or CLAUDE.md), a path-scoped rule, or an instructions file with applyTo so it loads only when the agent works there.`,
|
|
280
318
|
savingTokens: tokens,
|
|
281
319
|
savingNote: 'Saved on turns that do not touch that directory.',
|
|
@@ -287,7 +325,7 @@ function sections(units, threshold) {
|
|
|
287
325
|
severity: 'medium',
|
|
288
326
|
tools,
|
|
289
327
|
paths: [u.path],
|
|
290
|
-
line: s.line,
|
|
328
|
+
line: raw(u, s.line),
|
|
291
329
|
message: `Section "${s.heading}" is ${tokens} tokens and loads every turn. If it is a procedure (release, deploy, migration), move it into an on-demand skill or rule and keep a one-line pointer.`,
|
|
292
330
|
savingTokens: Math.max(0, tokens - 30),
|
|
293
331
|
savingNote: 'Assumes about 30 tokens remain for the skill description or pointer.',
|
|
@@ -297,6 +335,17 @@ function sections(units, threshold) {
|
|
|
297
335
|
}
|
|
298
336
|
return out;
|
|
299
337
|
}
|
|
338
|
+
/** The directory a section is mostly about (for example `packages/api`), or null when it is not scopable. */
|
|
339
|
+
export function sectionScope(text) {
|
|
340
|
+
if (countO200k(text) < 100)
|
|
341
|
+
return null;
|
|
342
|
+
const refs = pathRefs(text).filter((r) => !r.startsWith('.'));
|
|
343
|
+
const dirs = refs.map((r) => r.split('/')[0]);
|
|
344
|
+
const top = mostCommon(dirs);
|
|
345
|
+
if (!top || top.count < 2 || top.count / dirs.length < 0.6)
|
|
346
|
+
return null;
|
|
347
|
+
return commonDir(refs.filter((r) => r.startsWith(top.value + '/')));
|
|
348
|
+
}
|
|
300
349
|
/** Longest directory prefix shared by all refs, e.g. `packages/api` for `packages/api/db.ts` and `packages/api/handlers/`. */
|
|
301
350
|
function commonDir(refs) {
|
|
302
351
|
const dirs = refs.map((r) => (r.endsWith('/') ? r.slice(0, -1) : r.slice(0, r.lastIndexOf('/'))).split('/'));
|
|
@@ -349,14 +398,16 @@ export function guessLanguage(text) {
|
|
|
349
398
|
if (words.length < 15)
|
|
350
399
|
return { nonEnglish: false, script: 'Latin' };
|
|
351
400
|
const stop = words.filter((w) => EN_STOPWORDS.has(w)).length / words.length;
|
|
352
|
-
|
|
353
|
-
|
|
401
|
+
// Terse English (bullet lists, commands) has few stopwords too, so Latin text also needs words with
|
|
402
|
+
// accented letters: a few percent of words in German, French, Spanish or Turkish, none in plain English.
|
|
403
|
+
const accented = words.filter((w) => /[^\u0000-\u007F]/.test(w)).length / words.length;
|
|
404
|
+
return { nonEnglish: stop < 0.2 && accented >= 0.04, script: 'Latin' };
|
|
354
405
|
}
|
|
355
406
|
/** True when any heading section of the text looks non-English. */
|
|
356
407
|
export function hasNonEnglish(text) {
|
|
357
408
|
return splitSections(text).some((s) => guessLanguage(s.text).nonEnglish);
|
|
358
409
|
}
|
|
359
|
-
function nonEnglish(units, english) {
|
|
410
|
+
function nonEnglish(units, raw, english) {
|
|
360
411
|
const out = [];
|
|
361
412
|
for (const u of uniqueByPath(contentUnits(units, ['always']))) {
|
|
362
413
|
const parts = splitSections(u.text).filter((s) => guessLanguage(s.text).nonEnglish);
|
|
@@ -386,7 +437,7 @@ function nonEnglish(units, english) {
|
|
|
386
437
|
severity: 'low',
|
|
387
438
|
tools,
|
|
388
439
|
paths: [u.path],
|
|
389
|
-
line: parts[0].line,
|
|
440
|
+
line: raw(u, parts[0].line),
|
|
390
441
|
message: `${parts.length} section(s) (${tokens} tokens) in ${script} script look non-English. Pass --english ${u.path}=<english file> or --translate to measure the multiplier.`,
|
|
391
442
|
savingTokens: null,
|
|
392
443
|
savingNote: 'Not measured without an English version.',
|
|
@@ -400,7 +451,7 @@ function isNested(path) {
|
|
|
400
451
|
const dir = dirname(path);
|
|
401
452
|
return dir !== '' && !/^\.(claude|github|cursor|windsurf|devin|agents|clinerules|cline|roo|kiro|junie|continue)(\/|$)/.test(dir);
|
|
402
453
|
}
|
|
403
|
-
function staleReferences(units, opts) {
|
|
454
|
+
function staleReferences(units, opts, raw) {
|
|
404
455
|
const out = [];
|
|
405
456
|
for (const u of uniqueByPath(contentUnits(units))) {
|
|
406
457
|
const tools = [...new Set(units.filter((x) => x.path === u.path && x.mode !== 'ignored').map((x) => x.tool))];
|
|
@@ -416,18 +467,21 @@ function staleReferences(units, opts) {
|
|
|
416
467
|
const first = ref.split('/')[0];
|
|
417
468
|
if (/^(path|example|foo|your|my)/i.test(first))
|
|
418
469
|
continue;
|
|
470
|
+
// Paths with .. can point outside the repository, where nothing can be checked.
|
|
471
|
+
if (ref.split('/').includes('..'))
|
|
472
|
+
continue;
|
|
419
473
|
// Only flag when the top-level folder exists but the file does not: likely moved or deleted.
|
|
420
474
|
// Paths may be written relative to the repository root or to the instruction file.
|
|
421
475
|
const local = resolvePath(u.path, ref);
|
|
422
476
|
if (opts.exists(first) && !opts.exists(ref) && !(local && opts.exists(local)))
|
|
423
|
-
missing.push({ ref, line, n: i + 1 });
|
|
477
|
+
missing.push({ ref, line, n: raw(u, i + 1) });
|
|
424
478
|
}
|
|
425
479
|
}
|
|
426
480
|
// Root package.json scripts only apply to root-level instruction files, not nested packages.
|
|
427
481
|
if (opts.packageScripts && !isNested(u.path)) {
|
|
428
482
|
for (const m of line.matchAll(/\b(?:npm|pnpm|yarn|bun) run (?!-)([\w:.-]+)/g)) {
|
|
429
483
|
if (!opts.packageScripts.has(m[1]))
|
|
430
|
-
missing.push({ ref: `script "${m[1]}"`, line, n: i + 1 });
|
|
484
|
+
missing.push({ ref: `script "${m[1]}"`, line, n: raw(u, i + 1) });
|
|
431
485
|
}
|
|
432
486
|
}
|
|
433
487
|
});
|
|
@@ -439,8 +493,8 @@ function staleReferences(units, opts) {
|
|
|
439
493
|
severity: 'medium',
|
|
440
494
|
tools,
|
|
441
495
|
paths: [u.path],
|
|
442
|
-
line: missing
|
|
443
|
-
message: `References that no longer exist: ${[...new Set(missing.map((m) => `${m.ref} (line ${m.n})`))].join(', ')}.`,
|
|
496
|
+
line: missing.find((m) => m.n)?.n,
|
|
497
|
+
message: `References that no longer exist: ${[...new Set(missing.map((m) => (m.n ? `${m.ref} (line ${m.n})` : m.ref)))].join(', ')}.`,
|
|
444
498
|
savingTokens: countO200k(uniqueLines.join('\n')),
|
|
445
499
|
savingNote: 'If those lines are obsolete; update them instead if they are still relevant.',
|
|
446
500
|
});
|