Working with agents¶
The conclusion first¶
An agent that learns a limit from a rejected commit has already wasted the work. Give it the numbers before it writes the code, and a command it can run on its own output.
Two things do that: agent-rules, which writes the effective limits into the file your agent
already reads, and three skills — scitools-gate to drive the CLI on a change,
scitools-improve to work a grown-over repository back down, and scitools-adapt to change
the rules themselves with evidence. install-skills puts all three into the repository, so
none of it depends on having this project checked out.
agent-rules --write¶
It inserts between <!-- scitools-hook:begin --> and <!-- scitools-hook:end -->, so
everything else in the file is preserved and re-running replaces the block rather than
appending a second one. Run it again whenever the configuration changes. CLAUDE.md,
.cursorrules and anything else your agent reads work the same way.
The block is generated from the effective configuration, so what the agent reads is what the gate will actually enforce. It cannot drift.
The full block, as generated from the shipped defaults
## Maintainability rules (scitools-hook)
This repository is gated by `scitools-hook`, which measures the code a change touches with
SciTools Understand and refuses a commit that makes it worse. These are the rules your work
is judged by; read them before you write code. Regenerate this block with
`scitools-hook agent-rules` whenever the configuration changes.
## Limits
Each limit below is checked on the entities your change touches. An `error` blocks the
commit; a `warning` is reported but does not block.
### Routines (functions and methods)
- `CountLineCode`: at most 60 (error)
- `CountParams`: at most 5 (error)
- `CountPath`: at most 100 (error)
- `CountStmt`: at most 40 (error)
- `CyclomaticModified`: at most 8 (error)
- `CyclomaticStrict`: at most 10 (error)
- `Essential`: at most 4 (warning)
- `MaxNesting`: at most 3 (error)
### Classes
- `CountClassCoupled`: at most 12 (error)
- `CountClassDerived`: at most 8 (error)
- `CountDeclInstanceVariable`: at most 10 (error)
- `CountDeclMethod`: at most 20 (error)
- `CountDeclMethodNonStub`: at most 15 (error)
- `MaxInheritanceTree`: at most 4 (warning)
- `PercentLackOfCohesion`: at most 70 (warning)
### Files
- `CountDeclClass`: at most 3 (error)
- `CountDeclFunction`: at most 25 (error)
- `CountLineCode`: at most 500 (error)
- `MaxCyclomaticStrict`: at most 10 (error)
- `RatioCommentToCode`: at least 0.1 (warning)
### Project-wide
- `AVG:CountLineCode`: at most 30 (error) -- `AVG` over the whole project
- `AVG:CyclomaticStrict`: at most 3 (error) -- `AVG` over the whole project
- `MaxCyclomaticStrict`: at most 15 (error) -- measured over the whole project
- `MaxNesting`: at most 5 (error) -- measured over the whole project
## Structural rules
These are about how the code fits together, not about one entity's own numbers.
The rules that group by architecture use `Directory Structure`; the file-level ones apply to every file.
- New import or include cycles between files are reported (error)
- New cycles between the architecture nodes of `Directory Structure` are reported (error)
- Fan-in of a file (the files that depend on it): at most 50 (warning)
- Fan-out of a file (the files it depends on): at most 20 (warning)
- Fan-in of a class (the classes that depend on it): at most 30 (warning)
- Fan-out of a class (the classes it depends on): at most 12 (warning)
- One file may gain at most 5 new dependencies in a single change (error)
## The ratchet
A metric may not get worse than it was, even when it stays inside its limit. Taking
`CyclomaticStrict` on an existing routine from 4 to 6 is reported although the limit is 10:
the number to beat is the one that routine had before your change. Leave every metric where
it is, or make it better.
Strict mode is off: a violation that was already there before your change is reported
but does not block, as long as you do not make it worse.
These limits are not ratcheted, so a value inside them may move: `class.CountClassCoupled`,
`class.CountClassDerived`, `class.CountDeclInstanceVariable`, `class.CountDeclMethod`,
`class.CountDeclMethodNonStub`, `file.CountDeclClass`, `file.CountDeclFunction`,
`file.CountLineCode`.
## Check your own work
Run the gate yourself rather than learning about a violation from a rejected commit.
```sh
scitools-hook check --worktree # your edits as they stand, before you stage anything
scitools-hook check --staged # what you are about to commit; the hook runs this
Use --worktree while you are still editing and --staged once before you commit. Exit
code 0 means nothing blocks; exit code 1 means the change is blocked.
Read the JSON output¶
That prints one JSON document. findings is the array of everything the run found; each
entry carries rule, scope, path, line, value, before, limit, severity,
blocking, preexisting, message and hint. preexisting marks a violation that was
already there before this change. hint is the remediation text -- it says what to change,
so read it before you edit. blocking_count counts the findings that block the commit;
keep working until it is 0.
When the gate blocks a commit¶
A blocking finding is an error your change introduced: the code was inside the rule
before you touched it and is outside it now. When a commit is blocked:
- Read the
messageand thehintof every finding whoseblockingistrue. - Fix the code. Do not raise a limit, do not add an ignore pattern and do not re-capture the baseline to make a finding disappear -- those change the rules, not the code.
- Re-run
scitools-hook check --worktreeuntil nothing blocks any more. - Stage the change and commit; the hook runs
scitools-hook check --stagedagain.
A warning never blocks, and a pre-existing violation blocks only in strict mode. Fixing
either is welcome, but neither is what a blocked commit is asking you to do.
One blocking finding is not about a limit at all. analysis.parse_error means the analyser
could not read a file your change is adding or editing, so nothing after the line it names
was measured and no rule ran on it -- an empty report about that file is not a clean one. Its
hint names the construct to rewrite. Do not silence it: a file that does not parse is a
file nobody checked.
```
Three things about that block are worth pointing out, because they are the parts that actually change agent behaviour.
It says what to run, and when. --worktree while editing, --staged before committing.
An agent that only learns about a limit from the hook has already produced the wrong code.
It names the ratchet explicitly, with an example that is inside the limit: taking
CyclomaticStrict from 4 to 6 is reported although the maximum is 10. Read "reported"
literally — that finding arrives as a warning and does not block, because the routine is
still inside its limit. Without the sentence an agent optimises against the wrong number;
with it, an agent that treats every warning as a refusal wastes a cycle, which is why the
JSON contract below tells it to work from blocking_count rather than
from the length of findings.
It forbids the shortcut, by name. "Do not raise a limit, do not add an ignore pattern and do not re-capture the baseline to make a finding disappear — those change the rules, not the code." That is the failure mode a capable agent will otherwise find on its own, because it is the cheapest way to make the command exit 0.
The JSON contract¶
One document, schema_version: 1. The keys:
| Key | Contents |
|---|---|
schema_version, tool_version, understand_version |
Provenance. understand_version is the installed build's own string. |
repo_root, selection, started_at, seconds |
What was run, where, and for how long. |
effective_thresholds |
Every threshold that applied, with its limit, severity, ratchet flag and source. |
findings |
The array. |
blocking_count, warning_count, preexisting_count |
The counts the summary line prints. |
parse_errors |
Files the analyser could not read. |
unavailable_metrics |
Keyed language → metrics. Rules that were not evaluated. |
ignored_counts, tightened, highest, analyzed_files |
Skipped entities, baseline movements, worst values, and how much was analysed. |
One finding:
{
"kind": "threshold",
"rule": "file.RatioCommentToCode",
"metric": "RatioCommentToCode",
"scope": "file",
"entity": {
"key": {"scope": "file", "path": "pricing/__init__.py",
"longname": "pricing/__init__.py", "parameters": null},
"kind": "python File", "name": "__init__.py", "line": null
},
"path": "pricing/__init__.py",
"line": null,
"value": 0.0,
"before": null,
"limit": 0.1,
"limit_source": "config",
"severity": "warning",
"blocking": false,
"preexisting": false,
"message": "file pricing/__init__.py RatioCommentToCode is 0, which is below the minimum 0.1",
"hint": "too little explanation: state at the top of the module, and on each exported routine, why it exists -- not what the code already says",
"details": {}
}
kind is one of threshold, ratchet, structural, codecheck, parse. parse is the
odd one out and deliberately so: every other kind is a statement about code the gate read,
while a parse finding says a file in the selection was never read at all. It carries no
value and no limit, because there is no measurement.
The invariant to program against: keep working until blocking_count is 0. Do not
count findings, because warnings and pre-existing violations are in there and neither
decides a commit.
The skills¶
Two skills ship inside the package, so enabling a repository does not mean copying files out of a checkout you do not have:
installed: scitools-gate at /your/repo/.agents/skills/scitools-gate/SKILL.md
installed: scitools-improve at /your/repo/.agents/skills/scitools-improve/SKILL.md
installed: scitools-adapt at /your/repo/.agents/skills/scitools-adapt/SKILL.md
Your agent can now run /scitools-gate to check a change, /scitools-improve to lower this
project's complexity one commit at a time, and /scitools-adapt to change the rules with the
measurement behind each decision.
.agents/skills is the vendor-neutral location. For a host that reads somewhere else, name
it:
Running it twice writes nothing the second time, so it is safe in a setup script. A
SKILL.md you have edited is refused rather than overwritten; --force takes the shipped
version back.
| Skill | Question it answers | May edit the configuration |
|---|---|---|
scitools-onboard |
What is this repository, and what limits fit it? | yes, once, from measurement |
scitools-gate |
May this change land? | no |
scitools-improve |
How does this repository get easier to change? | no |
scitools-adapt |
Are these rules right for this repository? | yes, with evidence |
That last column is the design. The first two skills refuse to touch the configuration,
because an agent that can silence its own findings has no gate; scitools-adapt is where
that decision is made deliberately, and it is a separate invocation on purpose.
scitools-onboard¶
The one-time act of deciding what a repository is. Eight commands in an order that matters, because each answers a question the next one needs — and the point of it is that every line in the resulting configuration that deviates from a default carries the measurement that justifies it.
The shape it enforces, which is the opposite of how a configuration usually accretes:
- Detect before configuring.
init --detectclassifies the tree from what it declares about itself and prints the evidence beside each line. - Install the hooks and the rules first, on shipped defaults — a repository is not enabled until a commit actually meets the gate.
- Measure, and change only what the measurement says. A ceiling reported
keepfits; paste only whatrecommendproposes, with its numbers in a comment above each line. - Capture a baseline and turn
adaptiveon. This is the invariant that makes onboarding safe to do once: limits are derived from evidence at the start, and from then on they can only narrow. - Prove it — a large
--allinventory and0 blockingon--stagedare both expected, and confusing the two is the most common first-day mistake.
Three readings it insists on, each from a measurement rather than a preference:
- A ceiling most of the repository fails is not a limit, it is noise. A third of files
outside
CountDeclFunction = 25means the default is wrong there, not that the codebase is. - A ceiling the repository fits, with a handful outside it, is working. Those are
outliers; the ratchet holds them as
pre-existing. Do not draw a scope around them — scattered outliers do not cluster, and a scope round the three that blocked you has a worse reason than none. - When the routine limits and the file limits disagree, the file-level one yields. Every routine hint asks for extraction and extraction raises the file counts; a file of twelve small named helpers is the outcome the routine limits are asking for.
And one precondition, which is easy to skip and expensive to skip: do not derive limits from
a repository you are still reshaping. recommend measures the shape a project has, so
running it mid-cleanup bakes in the shape somebody is working to change.
scitools-gate¶
It gives an agent a protocol for driving the CLI rather than a list of commands:
- Preconditions. Run
doctorand read three specific rows before trusting any result: the licence, the API mode, and the interpreter Understand will analyse with. An agent that reports a clean run from a machine with no licence has made a false completion claim. - Check.
--worktreewhile editing,--stagedbefore committing,--format jsonfor the machine-readable form. Work untilblocking_countis0. - Explain.
explain --rangefor the structural picture,--graphs --impact --out DIRfor the reviewer-facing artefacts. - Survey.
check --all --show-highestfor the worst value per metric, with ajqrecipe for a ranked list of the worst routines. - Configuration.
config --why PATHfor "why does this file get those numbers".
Two parts of it exist to stop a specific failure:
Only exit 0 and 1 are statements about the code. Exit 3 means no Understand was found and
exit 4 means no licence, and in both cases nothing was measured. The skill's output format
has a NOT_CHECKED status for exactly that, so a run that could not happen is never reported
as a pass.
A rationalisation table, in the style of the other skills in this repository:
| Rationalization | Reality |
|---|---|
| "It only exceeds the limit slightly." | The limit is the limit. Growth inside a limit is only a warning; a blocking finding means the value crossed the limit or worsened something already over it. |
"I'll add it to [ignore] and fix it later." |
That is changing the rules. Fix the code or escalate. |
| "The check exited 3, so there is nothing to fix." | Exit 3 means nothing was checked. It is not a pass. |
"Splitting the routine will trip file.CountDeclFunction." |
It will not. Eight decomposition counts ship with the ratchet off. |
| "The file has no findings, so it is clean." | Not if it is named in parse_errors. |
scitools-improve¶
The gate stops a repository getting worse. It does not, on its own, make one better — and a project that has already grown past what an agent can reason about needs the second thing.
scitools-improve is the iterative loop, and it is deliberately not a clean-up task. Its
premise is the one on The working set: every commit exists to
shrink the neighbourhood a single change has to be understood in.
Five phases, none of which assumes anything about your repository:
- Record where you are.
init --detect, thenbaseline, committed on their own with no code in the commit. It also checksadaptiveunder[baseline], because the default is off and the loop does nothing without it — off, the baseline is a fixed floor; on, the effective limit ismin(configured, recorded)and every whole-project run lowers a value the code has beaten. - Decide where to aim.
recommend, whose output is proposed to a human and never applied. The skill may not propose a line that raises a limit. - Survey, once per batch.
check --all --show-highest, plus ajqrecipe that ranks by value relative to its limit. Routine-scope metrics are picked before file-scope ones, and churn (git log) breaks ties — complexity in a file nobody touches costs a model nothing, because no model reads it. - One entity, one commit. Read the
hint:, change the code, run the repository's own tests — the gate measures shape and has nothing to say about whether the code still works — thencheck --worktree, stage,check --staged, commit with the movement in the message. -
Lock the gain in. This is the part that is easy to get backwards:
Command What it does to the baseline check --all(withadaptive = true)Narrows only. Lowers every recorded value the run beat; never raises one. baselineReplaces the file — including values that got worse. So refinement tightens with
check --all, andgit diffon the baseline file is read before it is staged. A value that went up in that diff is a limit you just relaxed.
Two things in it exist to stop a specific failure:
The goal is not zero findings, and the skill says so before it says anything else. An
agent handed 1286 blocking reads it as a to-do list and either grinds through it or gives
up. --all is an inventory with no before side; the same findings are pre-existing under
--staged. The framing it uses instead: you are never asked to fix the 1286, you are
forbidden from adding the 1287th.
An explicit escape hatch. Some routines cannot be simplified without a design change that
is out of scope. Without permission to say so, an agent contorts the code until the number
moves — three badly-named helpers that satisfy the metric and leave the repository worse. The
skill's rule is that a short, honest list of what was not fixed beats a contorted change, and
its per-session output format has a NOT FIXED field to put it in.
scitools-adapt¶
The other two skills, when they meet a limit they believe is wrong, are required to stop and say so. This is what happens next.
Its question is never how do I make this finding go away. It is:
Is this finding wrong about the code, or wrong about what this repository is?
Only the second is a configuration change. It works down a ladder and stops at the first rung that fits:
| # | Rung | The change |
|---|---|---|
| 1 | The analyser could not read the file | Rewrite the construct, or [[parse.acknowledged]] with a reason |
| 2 | It is not source | [project] exclude, proposed by init --detect with evidence |
| 3 | Another tool owns the question | severity = "warning" — demote, keep visible |
| 4 | The region is different in kind | [scope.X], never [ignore] |
| 5 | A project-scope rule a commit cannot act on | severity = "warning" and read it as a trend |
| 6 | The limit is genuinely wrong | recommend, then change it — in its own commit |
Three things it enforces that are easy to get wrong:
Measure before, measure after. A configuration edit whose effect nobody counted is
indistinguishable from turning off whatever was inconvenient. The skill carries the jq
recipes for grouping findings by rule and for seeing where a rule clusters — because "almost
all in tests/" means the rule's scope is wrong, not the rule.
A demotion that made the count go to zero did not demote anything. It hid it, and that is
the wrong rung. The output format has a STILL VISIBLE field for exactly this check.
Write the reason down, every time. A file full of undocumented overrides is indistinguishable from one where somebody turned off whatever was inconvenient — so only keys that deviate from the defaults, each with its measurement in a comment. Deleting an override when re-measurement shows its reason has gone is as much a part of the skill as adding one.
It also pins the traps this project has actually fallen into: [ignore] is a hole rather than
a quieter scope; max_new_dependencies_per_file = 0 is the strictest setting, not "off";
Metric = false works inside a [scope] and is a configuration error anywhere else; and
re-running baseline replaces the file with today's values, worse ones included.
The human-readable version of the same ladder, worked end to end on a 770-file repository, is Rescuing a problematic project.
The loop¶
# 1. The agent reads the rules. They are already in AGENTS.md.
# 2. The agent writes code.
# 3. The agent checks its own work, before staging anything.
scitools-hook check --worktree --format json
# 4. blocking_count > 0? Read hint, fix the code, go to 3.
# 5. blocking_count == 0? Stage and commit. The hook runs check --staged again.
git add -A && git commit -m "..."
Step 3 is the one that matters. Everything else in this page exists to make step 3 possible before the commit rather than after it.