Skip to content

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 five skills: scitools-onboard to enable the gate on a repository from measurement, scitools-gate to drive the CLI on a change, scitools-improve to work a grown-over repository back down, scitools-adapt to change the rules themselves with evidence, and scitools-tune to calibrate duplicate and dead code detection. install-skills puts all five into the repository, so none of it depends on having this project checked out.

The agentic lifecycle: setup, loop, and skills

Enabling a repository for agents takes five idempotent commands that establish the foundation:

scitools-hook init --detect                   # 1. Inspect repository and propose configuration
scitools-hook baseline                        # 2. Record today's baseline values
scitools-hook install-hook                    # 3. Install the pre-commit shim
scitools-hook agent-rules --write AGENTS.md   # 4. Write effective limits into agent context
scitools-hook install-skills                  # 5. Write the five agent skills to .agents/skills

Once installed, coding agents interact with the repository across five distinct operational use cases:

Use case Phase Mechanism Skill or command
Pre-code bounds Before generating In-context limits and lean ladder in AGENTS.md agent-rules
Self-check loop While coding check --worktree --format json scitools-gate
Onboarding & baseline Repository enablement Empirical measurement of ceilings scitools-onboard
Continuous debt reduction Iterative refactoring Narrowing-only ratchet on complexity scitools-improve
Rule & lean calibration Policy adjustment Empirical calibration of thresholds and floors scitools-adapt, scitools-tune

agent-rules --write

scitools-hook agent-rules --write AGENTS.md
wrote the rules block into AGENTS.md

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)
- `CountLineComment`: at most 20 (warning)
- `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)
- `LinesPerStatement`: 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)

## Lean code

Every limit above asks whether a piece of code is too complex. None of them asks whether it
should exist at all: a routine nothing calls once its replacement landed, a parameter nothing
reads, a wrapper that only forwards, an abstraction with one implementation, a file that
exports one name, the same twelve lines in three places. Each of those is inside every limit
above and costs a reader anyway, so this section is about cutting rather than simplifying.

### Before you write it

Walk these in order and stop at the first that answers; only the last one writes code.

1. Does this need to exist at all?
2. Does it already exist in this codebase?
3. Does the standard library do it?
4. Does a native platform feature cover it?
5. Does an already-installed dependency solve it?
6. Can it be one line?
7. Then write the minimum that works.

Rungs 3, 4 and 5 are yours alone. No finding will ever carry either `stdlib:` or `native:`:
whether the standard library, the platform or a dependency this project already installs
would have done the job is a semantic question a reference database cannot ask. Their
absence from a report is not a clearance -- nobody checked them but you.

### The net line

A check with a before side ends with one line for the whole change:
`net: +12 lloc (+30 lines) over 7 routines`. That is logical lines added minus logical lines
removed, the source-line delta beside it, and the number of routines it was summed over.
`--all` has no before side, so it prints no net line.

A positive number is not a violation -- a feature costs code -- but it is the figure to argue
with. The usual reason it is positive is that the path the change replaced is still there:
delete that, and the tests that only covered it, before you accept the number.

## 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

scitools-hook check --worktree --format json

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:

  1. Read the message and the hint of every finding whose blocking is true.
  2. 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.
  3. Re-run scitools-hook check --worktree until nothing blocks any more.
  4. Stage the change and commit; the hook runs scitools-hook check --staged again.

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. ```

Four 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.

It carries the ladder and the net line even when every lean rule is off. The ## Lean code section prints on the shipped defaults, because rungs 3, 4 and 5 of the ladder are the agent's own work whatever the configuration says, and the net line is printed by every check with a before side whether or not a [lean] rule is on. A line the report prints and the document declines to explain would be the worse failure. What it does not print on the defaults is a rule nobody enabled: naming one is what teaches an agent to distrust the document. Where the repository has rules on, a list appears between the ladder and the net line, one line per enabled rule with its severity and the tag its hint opens with. With the two duplication rules on as warnings:

### The rules in force

The rules this repository has enabled: the severity each reports at, and the tag its hint
opens with. The tag is the edit being asked for -- `delete:` it is not used, `yagni:` it
should not have been written, `shrink:` the same work fits in less code.

- `structure.duplicate_block` (warning), `delete:` -- keep one copy of these lines, call it
  from the others and delete the rest -- the finding names where every copy is
- `structure.similar_routine` (warning), `delete:` -- keep one routine of the family, pass what
  differs as an argument, and delete the others -- they are the same code under different names

The hint is quoted whole rather than cut to its first sentence, because three of the family's hints carry a behavioural exclusion in their second sentence: an overridden signature keeps its parameter, a constant read by an importer outside the project stays, a class a registry reaches is not dead. Only the clause naming an ignore list is dropped, since a list is a configuration decision and this document is read before any code is written. The ladder, the tags and how to read the net line are on Lean code.

The JSON contract

scitools-hook check --staged --format json

One document, schema_version: 2. 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.
understand_sarif Understand's own SARIF documents, one entry per kind: written where it went beside --sarif, source where it was prepared, problem why there is none. Empty unless understand.sarif is on.
net_delta The change's net LLOC delta as an object, statements, lines and routines; null when the run has no before side (--all). How to read it is on Lean code.

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

Five skills ship inside the package, so enabling a repository does not mean copying files out of a checkout you do not have:

scitools-hook install-skills
installed: scitools-onboard at /your/repo/.agents/skills/scitools-onboard/SKILL.md
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
installed: scitools-tune at /your/repo/.agents/skills/scitools-tune/SKILL.md

Start with /scitools-onboard if this repository is new to the Gate. After that:
/scitools-gate checks a change, /scitools-improve lowers this project's complexity one
commit at a time, /scitools-adapt changes the rules with the measurement behind each,
and /scitools-tune calibrates duplicate and dead code detection.

.agents/skills is the vendor-neutral location. For a host that reads somewhere else, name it:

scitools-hook install-skills --dir .claude/skills

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
scitools-tune How do I calibrate duplicate and dead code detection? yes, with evidence

That last column is the design. scitools-gate and scitools-improve refuse to touch the configuration, because an agent that can silence its own findings has no gate; scitools-onboard sets it once from measurement; scitools-adapt and scitools-tune are where decisions to modify or calibrate rules are made deliberately, each with evidence.

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:

  1. Detect before configuring. init --detect classifies the tree from what it declares about itself and prints the evidence beside each line.
  2. Install the hooks and the rules first, on shipped defaults — a repository is not enabled until a commit actually meets the gate.
  3. Measure, and change only what the measurement says. A ceiling reported keep fits; paste only what recommend proposes, with its numbers in a comment above each line.
  4. Capture a baseline and turn adaptive on. 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.
  5. Prove it — a large --all inventory and 0 blocking on --staged are 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 = 25 means 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 doctor and 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. --worktree while editing, --staged before committing, --format json for the machine-readable form. Work until blocking_count is 0.
  • Explain. explain --range for the structural picture, --graphs --impact --out DIR for the reviewer-facing artefacts.
  • Survey. check --all --show-highest for the worst value per metric, with a jq recipe for a ranked list of the worst routines.
  • Configuration. config --why PATH for "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:

  1. Record where you are. init --detect, then baseline, committed on their own with no code in the commit. It also checks adaptive under [baseline], because the default is off and the loop does nothing without it — off, the baseline is a fixed floor; on, the effective limit is min(configured, recorded) and every whole-project run lowers a value the code has beaten.
  2. 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.
  3. Survey, once per batch. check --all --show-highest, plus a jq recipe 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.
  4. 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 — then check --worktree, stage, check --staged, commit with the movement in the message.
  5. Lock the gain in. This is the part that is easy to get backwards:

    Command What it does to the baseline
    check --all (with adaptive = true) Narrows only. Lowers every recorded value the run beat; never raises one.
    baseline Replaces the file — including values that got worse.

    So refinement tightens with check --all, and git diff on 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.

scitools-tune

The lean-code family ([lean]) targets duplicate code blocks, similar routine twins, dead parameters, and layering wrappers. Because these rules ship with conservative defaults and sit behind two safety floors, an agent or human operator uses this skill to calibrate them for the codebase:

  1. Diagnose the substrate: Run scitools-hook doctor to see whether Understand parsed files with clean accuracy and resolved internal call sites.
  2. Audit whole-project duplication: Run scitools-hook check --all --format json to see duplication across the whole repository, not only the current git diff.
  3. Calibrate duplication thresholds: Lower duplicates_min_lines (e.g. 12 to 8) or similar_threshold (e.g. 0.90 to 0.82) when smaller blocks or structural clones should be caught, and exclude framework idioms via similar_name_ignore and duplicates_ignore.
  4. Calibrate dead code and safety floors: Understand the false-positive risks of dynamic dispatch before lowering resolution_floor or accuracy_floor, and use parameter ignore patterns for test fixtures.

The detailed reference and calibration guide is Tuning duplicate and dead code.

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.