Status: part of lesscode-core from release 1.2.0 (spec
gradual-refactoring-steps). All commands
below exist, and they work alike on Python, TypeScript, Java and Rust: the declaration names the language
and your project's own test command. Six real steps have been taken with them from start to finish, in
Python, TypeScript, Java and Rust projects (examples).
Why it works this way, for readers new to the idea: testability.md.
A step is one small change on one branch with one small spec. The tooling answers by exit code whether the project's tests still pass after the step and whether they still notice planted faults in the changed code at least as often as before. It writes no test and no refactoring; it judges what a person or a model wrote.
Exit codes of every step command: 0 accepted or done, 1 refused or rejected, 2 not judged because a run
is incomplete, 3 bad input or a void run.
lesscode-core step plan --config run.json --work ../work --out plan.json [--frame-spec frame.json]run.json is a run configuration as for deconvolute run: the files, the test paths and the
project's own test command. The faults run in copies under --work, which must lie outside the source
root; the source tree is only read.
The plan lists every function of the configured files, ranked by unnoticed faults:
| Column | Meaning |
|---|---|
| noticed | planted faults the tests caught |
| unnoticed | planted faults the tests let through; each is a behaviour a change could alter unseen |
| tokens | size of the function, outside comments |
| writes | state writes in it; with --frame-spec, the receipt also counts those the specification does not allow |
| "no fault noticed" | faults were planted and the tests caught none: nothing in this function is protected |
| "no fault planted" | the pack offers no change for this function; the plan says nothing about it |
For a function with no noticed fault the receipt (step-plan-v1) lists what it reaches out to: the names
it calls and its state writes. That is where a test needs a fake, or where the function stays out of the
step.
What to do with a plan: pick a function with few unnoticed faults, write tests that turn them into noticed ones (pins), and only then change the code.
One file in the repository, committed on the step's branch, for example
.kiro/specs/shape-checks/step.json. This one is from a real step in a TypeScript project:
{
"step": "shape-checks", "spec": "shape-checks", "branch": "step/shape-checks",
"base": "37a4e55f1b9397332acc6434783099a631a04918",
"language": "typescript",
"scope": ["src/state.ts"],
"test_paths": ["tests/"],
"setup": [["pnpm", "install", "--frozen-lockfile"]],
"build": ["pnpm", "exec", "tsc", "--noEmit"],
"test": ["node", "--import", "tsx", "--test", "tests/state.test.ts"],
"run": {"timeout_floor_s": 30, "timeout_ceiling_s": 300, "exclusions": ["node_modules", "dist"],
"runner_evidence_patterns": ["tests ", "pass ", "fail "]},
"focus": {"src/state.ts": ["isPersistedWorkflowState"]},
"max_token_growth": 30,
"outputs": {"verdict": ".kiro/specs/shape-checks/verdict.json",
"evidence": ".kiro/specs/shape-checks/evidence.json",
"ledger": ".kiro/steps/ledger.jsonl"}
}Only a few fields depend on the language. These are the ones the real steps used.
language |
test |
Also needed |
|---|---|---|
python |
["uv", "run", "--no-sync", "python", "-m", "pytest", "tests/test_ratelimit.py", "-q", "-x"] |
setup [["uv", "sync", "--quiet"]]; in run: "invalid_exit_codes": [2, 3, 4, 5], "exclusions": [".venv", "__pycache__"] |
typescript |
["node", "--import", "tsx", "--test", "tests/state.test.ts"] |
setup and build as above; in run: "exclusions": ["node_modules", "dist"] |
java |
Maven, offline, one test class: [..., "-o", "-B", "-Dtest=CSVPrinterTest", "test"] |
setup that compiles the tests once; in run: "exclusions": ["target"], "invalid_output_patterns": ["COMPILATION ERROR", ": error:"] |
rust |
["cargo", "nextest", "run", "--locked", "--manifest-path", "native/Cargo.toml", "--test", "recording"] |
"env": {"CARGO_TARGET_DIR": "target"}; in run: "exclusions": ["target"] |
Commands are argument vectors and are not passed through a shell. On Windows that rules out mvn, which
is a script; the Java step called Maven's launcher class with java instead. The full declarations of
the six steps are in docs/experiments/gradual-steps/steps/.
Paths are relative to the repository root and commands run there, in copies of the whole repository.
focus names the functions the step is about; their faults are run even when the step does not change
them. run takes the other fields of a run configuration. Optional: frame_spec, out_of_reach
(functions left unprotected on purpose, each with a reason), criteria (commands tied to acceptance
criteria of the step's spec), setup, build.
lesscode-core step pin --declaration .kiro/specs/shape-checks/step.json --fault 4 --work ../workWrite a test, commit it on the branch by itself, and ask whether it pins an unnoticed fault of the base. The tooling builds a copy with the code of the base and the test files of the branch, and runs the tests with and without the fault.
| Exit | Result |
|---|---|
| 0 | admitted: the tests pass on the unchanged code and fail on the fault |
| 1 | rejected, with which happened: they fail on the unchanged code, or they pass on the fault |
| 2 | not judged |
A fault number that is not an unnoticed fault of the base is an error that lists the ones that are. The output also lists the faults that remain unnoticed; none is required to be gone.
lesscode-core step check --declaration .kiro/specs/shape-checks/step.json --work ../workChecks that need no run come first; the runs follow only if those pass.
| Check | Refused when |
|---|---|
| changes stay in scope | a changed file is neither in scope, nor under a test path, nor the step's own (its spec folder, its outputs) |
| pins apart from the change | one commit changes a test file and a scope file together |
| size | the scope grows by more tokens than max_token_growth |
| state frame | a state write of the step is outside the declared frame specification |
| tests and fault runs | the project's tests do not pass on the base or on the step |
| planted faults | in a function, more faults go unnoticed after the step than before; the faults are named |
| evidence for criteria | a command tied to a criterion does not pass |
Faults run in the functions the step changes, in the functions of the scope that call them, and in the
functions in focus. A function that exists on one side only cannot be matched by name; the totals of the
file are then compared and the verdict says so.
Exit 0 accepted, 1 refused, 2 not judged (a run is void or incomplete, for example because a command could
not be started). The verdict (step-verdict-v1) is written to the declared path in all three cases. It
names the base and step commits and the hashes git has for the committed scope files, test files and
declaration. Work on disk that is not committed is an error (exit 3) and nothing is written: the judgement
would be about another state than the one you see.
The evidence file (step-evidence-provisional-v0) lists per criterion the command, its exit code and the
same hashes. Its format is provisional until it is agreed with openidea-kiro.
lesscode-core step record --declaration .kiro/specs/shape-checks/step.json
lesscode-core step record --declaration .kiro/specs/shape-checks/step.json --override --reason "hotfix; pin follows in step 12"
lesscode-core step record --release 1.2.0 --ledger .kiro/steps/ledger.jsonlThe ledger is a file of JSON lines in the repository, only ever appended. Each entry carries the hash of the line before it; if an earlier entry is edited or removed, every later use of the ledger stops with "the ledger was rewritten".
- Without options, the accepted verdict is recorded with its numbers per function. A verdict that is not accepted, or that no longer matches the committed code, tests and declaration, is not recorded (exit 1).
--overriderecords that the step goes ahead without an accepted verdict. It needs a reason and shows up everywhere as "overridden", never as "accepted". It holds for exactly the committed content it was made for.--releaserecords a release and lists the steps since the one before.
From then on step check holds each function to the best number an accepted step left on the ledger: a
later step may not leave more faults unnoticed than that, even if its own base got worse in between.
Commit the verdict, the evidence and the ledger on the branch. That moves the branch, not what was judged.
lesscode-core step guard --branch step/shape-checks
lesscode-core step install-hookThe guard runs nothing. It reads the commit: is there a verdict that says "accepted" for exactly the scope files, test files and declaration this commit has, or an override on the ledger for exactly that content? If not, it exits 1 and says what changed. It judges what is committed; a verdict or an override that is only on disk counts once it is committed. A branch that no declaration names is not a step branch and passes.
step install-hook writes a pre-push hook that runs lesscode-core step pre-push: the complexity
budgets, the guard for every pushed branch, and, where tools/bin/kiro-cli is installed, the audit of the
step's spec. Any of them stops the push by exit code.
A local hook can be skipped with git push --no-verify. The boundary that cannot be skipped is the same
command in your pipeline, as a required check on the main branch:
lesscode-core step guard --branch "$BRANCH" --revision "$COMMIT"Use a commit or a tag as base in the declaration. A branch name such as main moves, and a verdict is
written for one base commit.
Three steps taken on 2026-11-11, all accepted, recorded and passed by the guard
(R92; declarations, verdicts, evidence and ledgers in
docs/experiments/gradual-steps/steps/). The numbers are noticed / unnoticed faults in the function.
| Step | Change | Function before | after | Tokens of the file | Time |
|---|---|---|---|---|---|
slug-separator, OpenUltraCode, TypeScript |
slugify drops a condition that is always true |
9 / 1 | 8 / 0 | 1,283 to 1,277 | 4 min |
comment-start, commons-csv, Java |
printComment starts every comment line through one private method |
19 / 2 | 17 / 2, new method 2 / 0 | 1,420 to 1,415 | 25 min |
one-exit, this repository, Rust |
verdict() collects its reasons in one pass and returns once |
11 / 0 | 12 / 0 | 7,307 to 7,306 | 10 min |
The three ran at the same time on one machine. In the Java step the new method has no counterpart on the base, so the totals of the file were compared as well. In the TypeScript step the fault that went unnoticed before sat in the condition that could not be false; it is gone with the condition.
A fourth step was refused first (R93). In OpenUltraCode,
isPersistedWorkflowState checked six rules in two conditions; the step shape-checks writes one rule per
statement.
$ lesscode-core step check --declaration .kiro/specs/shape-checks/step.json --work ../work
step shape-checks: refused
src/state.ts: isPersistedWorkflowState: unnoticed faults rise from 1 to 7: #15 line 224 literal:false->true, ...
The tests had one case for a corrupt state file, and it was not valid JSON. No test gave the function a file that parsed and had the wrong shape, so after the split six more single rules could be broken unseen. Nine short tests, one misshapen file each, went into a commit of their own:
$ lesscode-core step pin --declaration .kiro/specs/shape-checks/step.json --fault 4 --work ../work
#4 line 224 literal:false->true: admitted (the tests with the pin pass on the unchanged code and fail on the fault)
$ lesscode-core step check --declaration .kiro/specs/shape-checks/step.json --work ../work
step shape-checks: accepted
Noticed / unnoticed in the function: 3 / 1 before, 11 / 1 after. The one that remains swaps two checks that can stand in either order. The file grew by 25 tokens, inside the 30 the step had declared.
A fifth step, in a Python project, shows why the pins come first
(R94). In x_likes_scraper, _escape_html replaced five
characters with five chained str.replace calls; the step escape-html calls html.escape instead. The
plan listed the function as "no fault noticed": no test called it with any of the five characters.
$ lesscode-core step check --declaration .kiro/specs/escape-html/step.json --work ../work
step escape-html: accepted
That acceptance was wrong for the user. html.escape writes ' for an apostrophe and the old code
wrote ', so the export changed, and nothing noticed. With two tests that fix what the method returns,
committed first and admitted by step pin, the same change was refused ("the project's tests do not pass
on the step"). The change that keeps ' was accepted: 0 noticed / 15 unnoticed before, 3 / 0 after,
seven lines removed and two added. Where the plan says "no fault noticed", an acceptance tells you nothing
until the function is pinned.
A sixth step in the same project, checkpoint-clear, declared a frame specification and was accepted
(R95). It was accepted before its pins as
well: the change adds the argument missing_ok=True, and the Python pack plants no fault in a keyword
argument. The test for a directory without files exists because a person thought of the case.
Two plans. For src/state.ts of OpenUltraCode, a TypeScript project, with the tests of
tests/state.test.ts (2026-11-11; receipt in
docs/experiments/gradual-steps/steps/shape-checks/plan-state.json): 17 entries, 45 faults noticed, 26
not. update leads with 4 unnoticed of 5, and mutate and retryTransient have no noticed fault at all.
isPersistedWorkflowState had 3 noticed and 1 unnoticed, few enough to pin, and became the step
shape-checks above.
For native/src/recording.rs of this repository, a Rust file, with the tests of tests/recording.rs only
(2026-11-11, 191 faults, 13.8 minutes with three copies; receipt in
docs/experiments/gradual-steps/gate2/plan-recording.json): 91 faults noticed, 100 not. Twelve functions have
no noticed fault, led by replay_to_l2 (20 unnoticed) and assemble_meta (17); verdict has 11 noticed and
none unnoticed, which is why it was the first step. A function can look unprotected because the configured
test command does not run the tests that cover it; configure the tests you rely on.
These could not be made mechanical. They stay with the person who takes or reviews the step; the agent skill names them and does not decide them.
- An accepted step is not shown to behave the same. It passed the tests and lost no fault sensitivity in
the functions that were run. Where a function is pure and its inputs are few, compare old and new on all
of them yourself; the
verdict()change was compared on 480 inputs in R89. - A pin is admitted when it passes on the code and fails on the fault. Whether the behaviour it fixes is the one you want is your call.
- Nothing checks that a step is worth taking, small enough, or easier to read afterwards.
- The reason given for "out of reach" is recorded and never verified. The same goes for the reason of an override: it has to be there, and somebody has to read it.
step checkruns the commands a declaration ties to criteria. It does not check that the criteria say what the step is for, or that a criterion without a command is met.kiro-cli checkaudits the form of the spec, in the hook, where it is installed.- Only unnoticed faults refuse a step. A function in which fewer faults are planted afterwards passes, so read the numbers in the verdict.
git push --no-verifyskips the local hook. The pipeline job is the boundary that holds.
- Functions are matched by name. Two methods of one name in a file are counted together.
- A call is a name directly before an opening parenthesis. Calls through values are not seen.
- A whole file of a compiled language can take many minutes; the store under
--work/outcomesmakes a second plan with unchanged code, tests and configuration immediate.