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.

$ in-lockstep review --offline
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 same picture twice: deploy scripts in 2009, and prompts in 2026 Two stacked bands with identical geometry. In each, five labeled boxes on the left send five arrows of differing weights toward a single box on the right. Three arrows arrive; two stop short. The upper band is labeled 2009, before DevOps: its boxes are named deploy dot sh, push dash prod dot sh, deploy underscore v2 underscore FINAL dot sh, rollout dot sh and fix dot sh, and the target is production. The lower band is labeled 2026, writing code: its boxes are the prompts fix the failing test, just make CI green, refactor this, add the feature and try again, and the target is main. 2009 · before DevOps deploy.sh push-prod.sh deploy_v2_FINAL.sh rollout.sh fix.sh production 2026 · writing code "fix the failing test" "just make CI green" "refactor this" "add the feature" "try again" main
The second picture is the first picture.

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.

The developer loop, and what serves each step Four boxes in a row, joined left to right and closed by a return arrow across the top, forming a loop: change, build, run, test. Under each box is the command that already does it in a repository: you or a model for change, and make build, make run and pytest for the rest. Under that is the verb each step serves: Implement for the change, then Build, Run and Test, which detection binds to the commands already in the repository. change you, or a model Implement build make build Build run make run Run test pytest -q Test
It does not bring its own tooling. 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.
.lockstep/lockstep.pythe whole configuration
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.

$ in-lockstep initon a Node repository
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.

$ in-lockstep show-prompt review/security --projection
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.

Red, green, revert, red Four panels. First, a failing suite with only the new test present. Second, a passing suite with the test and the implementation. Third, the same pair with the implementation struck out. Fourth, a failing suite again, marked t d d dot fix underscore verified. Write the test. 1 failed Write the code. 1 passed Take the code back out. in a throwaway worktree It has to go red again. tdd.fix_verified

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.

the finding that step wrotefrom the run in the next section
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.

Three jobs, three credentials Three boxes, left to right. Gate holds no credential and no write token, and asks whether this person may ask for a run. Work holds the model key with read-only repository access, and writes the change and runs the suite. Propose holds a write token, never installs a provider SDK, and opens the pull request. A change set artifact passes from work to propose and is checked again on arrival. gate credentialnone write tokennone may this person ask for a run at all? exit 0 if yes, 3 if no work credentialmodel key repo accesscontents:read reads the ticket, writes the change, runs your suite cannot write to the repository ChangeSet untrusted propose credentialwrite token model SDKnever installed checks the artifact again, then opens the pull request cannot reach a model
The job that can talk to a model cannot write. The job that can write cannot import an SDK.

green is the machine working amber is a person deciding

$ in-lockstep run selfcheckexit 3
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

Twenty unattended runs, by ticket, with outcome, reason and cost
OutcomeWhat happenedCost, tokens at list price
succeededwrote the metrics module; merged after its lost pull request was recovered by hand$41.59
succeededfixed issue #109, unattended, after a person labeled it$9.62
blockedrefused at turn 73 by a $100 ceiling set in advance$97.54
blockedrefused at turn 15 by a $25 ceiling set in advance$24.23
blockedrefused at turn 10 by a $25 ceiling set in advance$23.07
errored2.1M tokens in, then the short-lived credential failed to renew$33.80
failedtdd.not_red, the test never failed$31.53
failedtdd.not_green, the fix did not land$13.84
blockedthe improve loop, twice: improve.no_trend, then improve.nothing_to_improve, each refused before a model call$0.00
blocked, failedeight /fix attempts at #319: two wall-clock ceilings, three fix.not_fixed, two fix.no_progress, one fix.not_reproduced$52.56
succeededthe 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.

$ in-lockstep report --html report.htmlthe published ledger: 26 records, $275.23 billed
The report page in-lockstep writes, titled What the ledger says, for twenty-six published runs: 12 percent failed, 4 stopped by a control, 2 with no verdict, $275.2322 total, $10.5859 per run, 64 findings. A runs-per-week line, a where-the-runs-go bar, an attempts-per-ticket bar with ticket 139 at five runs, a what-it-keeps-finding bar, a by-kind-of-work table, a note that 16 injection signals were caught in text people wrote at it, and a footer: a dash is a number nobody measured.
One self-contained file, inline style and inline SVG, nothing to fetch. The same numbers the terminal prints, with the same rule under them: a chart it cannot draw yet says so in a box, and a run a control stopped is listed as working as intended, not as a failure.
$ in-lockstep reportexcerpt, the published ledger, from a fresh clone
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.

$ in-lockstep eval report
  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. provision runs uv sync --locked from a uv.lock, npm ci from a package-lock.json, a requirements.txt into a virtual environment of its own, or the Makefile's own deps target. 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 says not bound rather than guessing at it.
  • It improves its own prompts, one finding at a time. improve reads 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 the judge verb, and parks on a person's review of the proposal. improve --explain says what would stop a proposal, spending nothing.
  • The report compares askers, not names. report --by actor splits 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 selfcheck accepts --approve (issue 189), and the module init --implement writes passes ruff on its first selfcheck (issue 190).
  • A red doctor fails the pipeline it writes. The scaffolds run doctor without continue-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.
$ in-lockstep doctorthis project, today
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.