Everybody has the same model.
Nobody has the same results.
An agentic SDLC framework, written in Python, for a repository in any language. It runs its own agent loop against a model API, on your laptop or in CI.
$ uv tool install 'in-lockstep[anthropic]'
That gap is the only AI strategy most teams have. in-lockstep makes it a discipline instead: one Python file, gates that refuse, and a record of every run with the cost on it.
config none (no .lockstep/lockstep.py at local working tree. Running on
detected defaults; `in-lockstep init` scaffolds one.)
replaying the shipped fixture: in-lockstep/lockstep#48, security lens
note: the system prompt moved since this fixture was recorded, so what
follows is the model's answer to the prompt as recorded — not to
the one composed just now, which `in-lockstep show-prompt
review/security` prints. Re-recording is a real model call, which
is the thing a reader trying this offline does not have.
review/security succeeded
actions/save/action.yml:29 review.security: Unquoted variable in `find`
command allows word-splitting on paths with spaces or glob characters
actions/save/action.yml:23 review.security: GitHub Actions expression
`${{ inputs.paths }}` is interpolated directly into a shell script
before variable assignment
tokens 5361 in, 443 out
cost $0.0000 (replayed; nothing was billed)
Two real security findings in a real workflow file, from a clean PyPI install in an empty directory, with no API key, no network and no bill. A recording made against a real merged pull request ships inside the package. Note that the tool tells you its own recording has gone stale rather than hiding it.
We have watched this happen once already.
The loop you already run, expressed as Python.
Clone it, change something, build it, run it, test it. You already do this, with scripts already in the repository. in-lockstep reads those and writes the loop down, so the lifecycle becomes one file you can read, diff and review. See which command serves which verb, and the runtime around it.
in-lockstep init reads the repository and binds these verbs to what is already
there: pytest and ruff here, npm test and eslint in a Node repository. It reads
your Makefile as well, and binds build and run to the targets that are in it.from in_lockstep import Lockstep from in_lockstep.adapters import ( CommandProvision, Provision, PytestTest, RuffValidate, Test, Validate, ) from in_lockstep.adapters.ai import TDD, Implement from in_lockstep.middleware import CostBudget, otel from in_lockstep.privileged.egress import ( EgressPolicy, UnsandboxedEgress, ) lockstep = Lockstep.detect() # deterministic work stays deterministic lockstep.bind(Test, PytestTest(args=["-q"])) lockstep.bind(Validate, RuffValidate()) lockstep.bind(Provision, CommandProvision([["uv", "sync", "--locked"]])) # the scaffold's one opt-out: fine for read-only review; # re-decide it before any verb of yours writes lockstep.bind(EgressPolicy, UnsandboxedEgress()) # a model is asked only where judgment is needed lockstep.bind(Implement, TDD()) lockstep.models.route("implement", "anthropic:claude-opus-4-6") lockstep.models.route("triage", "local:qwen3-8b") # cross-cutting concerns are middleware lockstep.middleware += [otel(), CostBudget(usd=2.00)]
There is no second configuration file, and nothing here generates a pipeline. This module is the thing that runs, so a change to how your team works is a diff in a pull request, with blame, history and rollback.
wrote .lockstep/lockstep.py detected stack: node; tests: npm test; lint: npx eslint .; provision: npm ci wrote .github/workflows/lockstep.yml One job, because reviewing is read-only. Add the privileged `apply` job the day a verb of yours produces a change to write; the file says where.
It found npm test and eslint and bound them.
Three lines it wrote: lockstep.bind(Test, CommandTest(['npm', 'test'])),
lockstep.bind(Validate, CommandValidate(['npx', 'eslint', '.'])) and
lockstep.bind(Provision, CommandProvision([['npm', 'ci']])). The file on the
left is the same scaffold for a Python repository, plus the strategy and the model
routing a person adds afterwards.
The prompt is assembled, not typed.
Every call composes the same named fragments in the same order, with your guardrails first, and you can print the result or diff it against the shipped version before anything runs. How the layers compose, drawn.
config local working tree guardrail:baseline guardrail:review/reviewing body:review/security-reviewer skill:review/review-format skill:review/review-revision
Read off the bound adapter, so this is what a run would be sent. No key, no network, no spend. How the fragments compose, and what an organization can put ahead of yours.
Test first, checked by running the tests.
Asking a model to write the test first is a request. Running the suite and reading the exit code makes it a requirement.
The fourth panel is the one nobody else draws. After red and then green, the framework computes the inverse of the change the model wrote, applies it in a throwaway worktree, and requires the suite to fail again.
If it stays green with the code removed, the run is refused and the record says
tdd.fix_not_load_bearing. The test was weakened, not satisfied. That is mutation
testing, sitting inside a coding agent, decided by an exit code rather than by good
intentions.
tdd.fix_verified reverting the implementation returns the suite
to red, so the change is load-bearing for its test.
What stops it pushing to your main branch?
Nothing inside one process could. So there are three, and the credential that can talk to a model is never in the same process as the one that can write. The three jobs as a sequence; the whole security model.
green is the machine working amber is a person deciding
config local working tree validate failed .lockstep/lockstep.py:645 validate.f821: Undefined name `issue` test blocked (approval.required) approval.required: ActionCall(test, step=None) grants executes_code and no approval was granted. Locally, `--approve` says you are the human watching this run. spend $0.0000, 0.06s ledger lockstep-history:records/selfcheck-...json
The framework running its own lifecycle: it finds a real undefined name in its own config file, then stops dead because nobody approved running code. A control stopping a run is the control working, so it is never counted as a failure.
The workflow file driving all of this is nine shell statements long: seven bare calls to the tool, and two that install it. A test rejects the tenth, because lifecycle logic that leaks into YAML is lifecycle logic nobody can run on their laptop.
Your configuration is also loaded from the base branch, never the branch under review. A change cannot rewrite the rules that decide whether to accept it.
You cannot improve what you did not record.
So every run writes one: which commit, which model, what it cost, what it decided. This is the whole published ledger, failures included. How a record reaches the branch.
One unattended run of implement wrote
src/in_lockstep/metrics.py and its test, from ticket #146.
That module produces the report output further down this page, and it is
merged in main today.
- cost
- $41.59
- tokens
- 2,598,882
- suite
- 1,631 passed
- wall time
- 654.9s
Every write-verb run on the published ledger: 20 runs, all of them real model calls
| Outcome | What happened | Cost, tokens at list price |
|---|---|---|
| succeeded | wrote the metrics module; merged after its lost pull request was recovered by hand | $41.59 |
| succeeded | fixed issue #109, unattended, after a person labeled it | $9.62 |
| blocked | refused at turn 73 by a $100 ceiling set in advance | $97.54 |
| blocked | refused at turn 15 by a $25 ceiling set in advance | $24.23 |
| blocked | refused at turn 10 by a $25 ceiling set in advance | $23.07 |
| errored | 2.1M tokens in, then the short-lived credential failed to renew | $33.80 |
| failed | tdd.not_red, the test never failed | $31.53 |
| failed | tdd.not_green, the fix did not land | $13.84 |
| blocked | the improve loop, twice: improve.no_trend, then improve.nothing_to_improve, each refused before a model call | $0.00 |
| blocked, failed | eight /fix attempts at #319: two wall-clock ceilings, three fix.not_fixed, two fix.no_progress, one fix.not_reproduced | $52.56 |
| succeeded | the ninth attempt at #319 staged the fix that merged as #343, the first framework-authored change in this repository; fix/propose opened it for nothing | $1.48 |
| Twenty runs, summed before rounding | $329.27 | |
The $31.53 run failed because this repository names test classes
*Tests rather than Test*, so a file full of tests collected
nothing and a green suite meant nothing had run. That cost real money twice before anyone
noticed. It is in the record because that is what the record is for.
The $41.59 run has an asterisk too. It staged the change and passed the revert check, then failed to open its pull request, because the title it sent was a thousand characters of the model's own commentary and GitHub caps titles at 256. The work survived only because it was already in the run's artifact, which is the reason the artifact exists. Three defects came out of that one failure and all three are fixed.
It will not print a number it has not earned.
Every AI dashboard you have been shown averages over whatever happened to be present. These figures carry their denominator, and one nobody measured stays a dash. Every number in the page below was read back from the ledger as it stood at 0.2.2, twenty-six records; the excerpt beside it is the same command over the ledger today. What the record then teaches.
records 522 2026-08-30 → 2026-09-08 outcomes failed 4% (20 of 503) decided none 0% (2 of 520) blocked 17 (a control stopping a run is the control working; not in a rate) learning 2 (the improve loop's own runs, counted apart) no verdict 2 (written before schema 5 by a workflow that returned no Outcome; not counted as anything) provider.authentication 5 review.unparseable 5 approval.required 4 fix.not_fixed 3 cost.budget_exceeded 3 killswitch 3 fix.no_progress 2 budget:wall:2196.9>1800.0 1 spend total $362.6034 per run $0.6946 tokens 21,330,111 attempts per ticket #319 10 run(s) $54.0399 #139 5 run(s) $210.1739 #109 1 run(s) $9.6244 #146 1 run(s) $41.5928 #150 1 run(s) $13.8411 who and how actor-1 469 run(s) actor-2 1 run(s) actor-3 3 run(s) actor-4 4 run(s) — 45 run(s) (carried no identity: a local run; `lockstep.identity = GitAuthor()` records one) unattended 3% of runs, with nobody watching dirty tree 7% of runs saw uncommitted changes (pseudonyms, by first appearance; `--names` to name them; `report --by actor` for the spread) A dash is a number nobody measured. It is not a zero. history append-only across the retained chain, except 2 rewrite(s) acknowledged by name (see above)
Twenty of five hundred and three failed, and it says so. Two records are older than the schema that carries a verdict, so they are counted as neither passed nor failed. Five attempts at one ticket cost $210 and ten at another cost $54, and it says that too. The last line is the ledger checking itself: two published records were rewritten after they were appended, and the report names the commits and the reasons somebody stood behind.
fix 5 case(s) implement 5 case(s) retro 4 case(s) review 9 case(s) triage 4 case(s) cases 27 decided 0 outstanding 27 (need a judge) pass rate n/a — nothing decided A rubric nobody judged is outstanding, not passed.
Twenty-seven evaluation cases ship with the package and not one has been judged, so there is no score to show. A tool that will not print a score it has not earned is a tool whose scores mean something when it does.
Where it does not reach.
A page like this usually stops before here. These are the limits, unhedged, and they are the same list the tool checks itself against.
- The environment is built only from a layout it already knows.
provisionrunsuv sync --lockedfrom a uv.lock,npm cifrom a package-lock.json, a requirements.txt into a virtual environment of its own, or the Makefile's owndepstarget. A Poetry, PDM, Pipfile, Yarn, pnpm, Bundler, Composer, Mix or Swift lock binds its own tool's frozen install the same way. A pyproject with no lock at all binds nothing, and saysnot boundrather than guessing at it. - It improves its own prompts, one finding at a time.
improvereads the ledger for a finding that keeps coming back, drafts a change to the one body it is attributed to, measures both arms against the promoted corpus with every rubric put to thejudgeverb, and parks on a person's review of the proposal.improve --explainsays what would stop a proposal, spending nothing. - The report compares askers, not names.
report --by actorsplits the ledger by who asked, every number carrying the runs it came from; askers are stable pseudonyms unless--names, and a run nobody is recorded as asking for is a dash, never an unknown bucket. - One ledger per repository, not one per organization.
GitLedger(shared=True)swaps on the remote's own ref, so eight runners claiming one key produce one success and a parked run resumes from any machine; the default construction stays local and refuses. There is still no rollup across repositories. - The four bugs the first outside users hit are fixed. A record takes its status
from its steps, an installed copy runs your repository's tools rather than its own
interpreter,
run selfcheckaccepts--approve(issue 189), and the moduleinit --implementwrites passes ruff on its first selfcheck (issue 190). - A red doctor fails the pipeline it writes. The scaffolds run
doctorwithoutcontinue-on-error, and a test over this repository's own workflows refuses the flag on any step whose verdict something acts on; the scaffolds decline where detection declined rather than binding pytest over a repository that runs something else. - No flaky-test handling. The adapter is not built, and a test in the repository asserts that it has not shipped.
- GitLab is partial. The protocols and host-aware setup ship. Nobody has run a merge-request pipeline end to end, so nobody should say one works.
- The spend ceiling runs in-process. It lives inside the process holding the API key, so a bug there is a bug in the ceiling. Your provider account limit is the real backstop, and the tool refuses to pass its own checks until you attest that you set one.
- It does not enforce where a run connects. It prints the list of hosts a run may dial and verifies that something outside the process is enforcing it. That is a manifest for a proxy, not a firewall.
- Every policy field reaches the loop. Turn limits, idle-turn limits, denied tools and input scanning are the fields, and a test pins that set so a new one cannot arrive inert. The fields that were merged and read by nothing were deleted rather than wired.
- No adoption to point at. No users, no logos, no case studies. It is new, and the runs on this page are its own.
ERROR DOC101 no provider-side organisation spend limit is attested Set a hard monthly cap in the provider console and record it as IN_LOCKSTEP_ORG_SPEND_LIMIT=<amount>. A per-run budget cannot bound a runaway trigger. ERROR DOC167 lockstep-history is not append-only: 1 record(s) rewritten records/review-security.json was modified after being appended (commit adca58e73a2b). Note the check reads the retained chain only: a force-push that replaced the chain discards the contradiction. WARNING DOC130 no egress enforcement is declared 3 finding(s), 2 error(s)
Thirty-five checks, run against its own repository, exiting non-zero. It names the rewritten record with its commit hash, and then tells you the limit of its own tamper check.
What runs today.
This list is held by a test. A capability that claims to run must name a command that exists, and one marked as missing must name nothing, so overclaiming and stale claims both turn CI red. Every one of them is drawn.
Eight verbs
- review
- four lenses in one run, each posting its own comment; replayable offline for nothing
- implement
- one-shot or test-first, from a ticket
- fix
- a failed run files the bug that an agent then picks up
- triage
- place a ticket, cheap enough for a local model
- backport
- plain cherry-pick, a model only on conflict
- rfe
- draft a ticket from an idea, a person files it
- improve
- read the ledger, measure a prompt change, park on your review
- judge
- settle a rubric: deterministic first, one turn per ask, verdict kept
And the environment they run in
provision builds the repository's own environment before
anything runs in it: uv sync --locked from a uv.lock, npm ci
from a package-lock.json, the Makefile's own deps target. Nothing to
provision reports not bound, never a success.
Find out in an afternoon, for nothing.
No key, no account, no platform team. --offline and
--dry-run prove the whole path before you spend anything.
$ uv tool install 'in-lockstep[anthropic]' && in-lockstep init
One bug, filed to merged
What a person types, what the framework does, and what stops an agent that gets stuck.
Rules that only tighten
How one package reaches every repository, and what a team can still decide for itself.
Share what one team worked out
Packaging a strategy so forty other teams get it, and checking one before you trust it.