diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..239589a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,81 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.11", "3.12", "3.13"]
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - run: pip install -e ".[dev]"
+
+ # Neither of these touches the network or spends a token: the synthesis loop is
+ # driven by a scripted client, and the corpus cases carry their implementations.
+ - name: Unit tests
+ run: pytest -q
+
+ # The corpus is the one that would notice a gate going quiet: each case carries a
+ # deliberately planted bug and names which gate has to catch it. A gate that stops
+ # firing, or starts firing on the correct implementation, fails here.
+ - name: Corpus selftest
+ run: jitagent selftest
+
+ # audit_traps reads the committed audit_result.json, so it needs no model. It is
+ # the check that the recorded audit still says what the README claims it says.
+ - name: Audit traps (against the committed audit results)
+ run: python tools/audit_traps.py
+
+ package:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - run: pip install build twine
+
+ - name: Build sdist and wheel
+ run: python -m build
+
+ # Catches the metadata problems that only surface at upload time — a malformed
+ # README, a missing license, a bad classifier — rather than at `twine upload`.
+ - name: Check the built metadata
+ run: twine check --strict dist/*
+
+ # A wheel that imports but whose console script is broken is a bad release, and
+ # nothing above would notice: the tests all run against the source tree.
+ - name: Install the wheel and run it
+ run: |
+ python -m venv /tmp/wheeltest
+ /tmp/wheeltest/bin/pip install --quiet dist/*.whl
+ /tmp/wheeltest/bin/jitagent --help
+ /tmp/wheeltest/bin/jitagent selftest
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: dist
+ path: dist/
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..38badec
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,91 @@
+name: Release
+
+# Publishing is deliberate: it runs when a GitHub Release is *published*, not on every
+# tag push. A tag is cheap to create by accident; a release is not, and a version on
+# PyPI can never be reused even after it is deleted.
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ repository:
+ description: Where to publish
+ type: choice
+ default: testpypi
+ options: [testpypi, pypi]
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - run: pip install -e ".[dev]"
+
+ # Never publish something that has not passed its own checks. This duplicates CI
+ # on purpose: CI green on a branch says nothing about the commit being released.
+ - run: pytest -q
+ - run: jitagent selftest
+
+ # The tag and the version must agree. Getting this wrong means shipping 0.0.1 as
+ # v0.0.2, which cannot be undone — PyPI does not allow a version to be re-uploaded.
+ - name: Check the tag matches the version
+ if: github.event_name == 'release'
+ run: |
+ version=$(python -c "import tomllib,pathlib;print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
+ tag="${GITHUB_REF_NAME#v}"
+ if [ "$version" != "$tag" ]; then
+ echo "::error::tag $GITHUB_REF_NAME does not match pyproject version $version"
+ exit 1
+ fi
+ echo "tag $GITHUB_REF_NAME matches version $version"
+
+ - run: pip install build twine
+ - run: python -m build
+ - run: twine check --strict dist/*
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: dist
+ path: dist/
+
+ publish-testpypi:
+ needs: build
+ if: github.event_name == 'workflow_dispatch' && inputs.repository == 'testpypi'
+ runs-on: ubuntu-latest
+ environment:
+ name: testpypi
+ url: https://test.pypi.org/p/jitagent
+ permissions:
+ id-token: write # Trusted Publishing: OIDC, no stored secret
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ name: dist
+ path: dist/
+ - uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ repository-url: https://test.pypi.org/legacy/
+
+ publish-pypi:
+ needs: build
+ if: github.event_name == 'release' || inputs.repository == 'pypi'
+ runs-on: ubuntu-latest
+ environment:
+ name: pypi
+ url: https://pypi.org/p/jitagent
+ permissions:
+ id-token: write # Trusted Publishing: OIDC, no stored secret
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ name: dist
+ path: dist/
+ - uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.gitignore b/.gitignore
index ee8f745..708dac3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,9 @@ build/
# the raw capture from tools/capture_demo.sh; docs/demo.txt is the curated one
docs/demo.raw.txt
+
+# build output
+dist/
+
+# build metadata
+src/*.egg-info/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b26d28b
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,51 @@
+# Changelog
+
+This project follows [Semantic Versioning](https://semver.org/). While the major version
+is 0, the API may change in a minor release.
+
+## [0.0.1] — 2026-09-16
+
+First release. The loop round-trips: **a sentence goes in, code comes out, and you fetch
+it back by name.**
+
+### The surface
+
+- `compile_function` / `get_code` / `call_function` / `search_functions` /
+ `inspect_function`, and an `jitagent` CLI over the same five operations.
+- Three synthesis backends behind one method (`complete(system=, user=)`): the local
+ `claude` CLI, the Anthropic API, and a scripted client that makes the whole loop
+ testable with no network and no tokens.
+- A registry organised around **the test set**, not the code: one requirement can hold
+ several versions, and the cases belong to the requirement so a regeneration accepts
+ against everything learned so far.
+- Three-level lookup — exact hash, then candidate retrieval with re-verification against
+ *this run's* examples, then a miss. Retrieval affects the hit rate; re-verification
+ decides correctness.
+
+### Correctness
+
+- `jitagent` writes the test cases out in full **before** the code exists, so the code
+ cannot shape the cases. Your seed examples anchor them, and a generated case that
+ contradicts one is dropped.
+- A failure that occurs *only* on generated cases is handed back for adjudication rather
+ than reported as a bug.
+- A decision the requirement never made must be declared in an `assumes` field.
+- Three gates can reject an implementation: the static check, your cases, and the return
+ schema inferred from your examples.
+
+### Verified against an independent oracle
+
+Six reference implementations, transcribed literally from the requirement text and
+written before any generated case or generated code existed:
+
+- 48/48 generated expectations agreed with them, with 18/18 planted traps exercised
+- 24 compiles across three runs, every one on the first attempt
+- 4800 random inputs, no disagreement
+
+### Known limitations
+
+One model (Claude Haiku 4.5); the requirements and the audit oracle come from the same
+person; the sandbox is a **correctness** sandbox, not a security one;
+`datetime.strptime` does not work inside it. See the README.
+
+[0.0.1]: https://github.com/Birfy/jitagent/releases/tag/v0.0.1
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..b72e4ce
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,95 @@
+# Contributing
+
+```bash
+pip install -e ".[dev]"
+pytest # 102 unit tests — no network, no tokens
+jitagent selftest # 6 corpus cases, each with a deliberately planted bug
+```
+
+Both must pass before anything else is worth discussing. Neither touches the network: the
+synthesis loop is driven by `ScriptedClient`, and every corpus case carries its own
+implementation.
+
+## The one rule that matters
+
+**A gate that fires on correct code costs more than a gate that misses a bug.** Nothing in
+a false positive points at the real cause — it took reading two nearly identical
+implementations side by side to find that `datetime.strptime` cannot work inside the
+sandbox, and that cost a wasted round of synthesis every time it happened.
+
+So the corpus checks both directions. `01_correct` exists to make sure a correct
+implementation sails through; a change that makes it red has failed, however many bugs it
+catches elsewhere.
+
+## Adding a verification gate
+
+Seven gates were built here and five were removed, because **not one of them ever caught a
+mistake a real model made** — everything they caught was a bug planted by hand. They had
+been built by reasoning from a document rather than forced into existence by a real
+failure. The reasoning is preserved in [docs/correctness.md](docs/correctness.md); it is
+what a new gate has to beat.
+
+A new gate needs:
+
+1. **A real failure it would have caught** — a model output, not a hypothetical.
+2. **A corpus case** in `src/jitagent/corpus/`, whose `case.json` names the gate that must
+ reject it and why.
+3. **Evidence it does not fire on correct code.** `jitagent selftest` covers the corpus;
+ for anything subtle, `tools/audit_e2e.py` runs real compiles against independent
+ reference implementations.
+
+"Is your test set strong enough?" is **advice to the caller, not a verdict.** A gate that
+answers that question belongs in a report, not in the pass/fail path.
+
+## Changing a prompt
+
+`prompts.py` has one rule at the top: **only promise what is actually checked.** The prompt
+once threatened the model with a branch-coverage threshold that had already been deleted.
+If you change what can reject an implementation, change the prompt in the same commit.
+
+Prompt changes are not covered by the unit tests — they are measured. `tools/audit_e2e.py`
+is the measurement, and it costs real tokens.
+
+## The audits
+
+```bash
+python tools/audit_tests.py # are the generated expectations right? (spends tokens)
+python tools/audit_traps.py # did the generated cases go after the hard parts? (free)
+python tools/audit_e2e.py # is the compiled function right? (spends tokens)
+```
+
+The protocol in `tools/audit_tests.py` matters more than the numbers: **the reference
+implementation has to be written before any generated case is seen.** Write it afterwards
+and you start finding reasons why the model's answer was fine, and the audit is worthless.
+
+`audit_traps.py` is why the agreement rate means anything. A model that only ever writes
+`f([]) == []` scores 100% and has tested nothing, so each trap is checked as a predicate
+over the generated case's *input* — a note claiming to test a tie cannot pass without a tie
+in the data.
+
+## The demo in the README
+
+Three steps, and the middle one is yours:
+
+```bash
+tools/capture_demo.sh # runs the CLI for real, writes docs/demo.raw.txt
+$EDITOR docs/demo.txt # pick the lines, trim to <= 92 columns
+python tools/make_demo_svg.py # docs/demo.txt -> docs/demo.svg
+```
+
+Everything the animation shows is real captured output. A hand-written demo shows what
+someone hoped the tool would do.
+
+## Style
+
+Match the file you are editing. Comments here carry the *reasoning* — why a thing is the
+way it is, what was tried and rejected, what would make it wrong — because the mechanisms
+are easy to read and the trade-offs behind them are not. A comment that restates the code
+is noise; one that records a measurement or a discarded alternative is the point.
+
+## Releasing
+
+Maintainers only. Bump `version` in `pyproject.toml`, add a `CHANGELOG.md` entry, then
+publish a GitHub Release tagged `vX.Y.Z`. The release workflow checks the tag against the
+version, re-runs the tests, builds, and publishes to PyPI over Trusted Publishing — there
+is no API token stored anywhere. `workflow_dispatch` publishes to TestPyPI for a rehearsal.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ad62975
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Birfy
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/NEXT.md b/NEXT.md
index 3a7da30..17fe1e7 100644
--- a/NEXT.md
+++ b/NEXT.md
@@ -90,13 +90,13 @@ Not many left; after the cull there are four:
## Read before starting
-1. The header of `src/agentjit/propose.py` — one model writing both the code and the tests
+1. The header of `src/jitagent/propose.py` — one model writing both the code and the tests
that judge it is circular; which part of that each of the three mitigations solves, and
which part cannot be solved
-2. The header of `src/agentjit/verify.py` — which five gates were cut, why, and which one is
+2. The header of `src/jitagent/verify.py` — which five gates were cut, why, and which one is
most likely to come back
3. The header of `tools/audit_tests.py` — the audit protocol. The reference implementation
has to be written **before** any generated case is seen, or it starts finding reasons why
the model's answer was fine
-4. The header of `src/agentjit/lookup.py` — why retrieval is allowed to be crude, and where
+4. The header of `src/jitagent/lookup.py` — why retrieval is allowed to be crude, and where
being crude does not matter
diff --git a/README.md b/README.md
index 788898a..d3babcc 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,17 @@
-# agentjit
+# jitagent
**Stop your agent re-deriving the same function 200 times. Compile it once.**
+[](https://github.com/Birfy/jitagent/actions/workflows/ci.yml)
+[](https://pypi.org/project/jitagent/)
[](https://www.python.org/downloads/)
-[](tests/)
-[](tests/corpus/)
+[](src/jitagent/corpus/)
+[](LICENSE)
[](#known-gaps)
-
+
@@ -17,16 +19,16 @@
The same session as text (it is a real transcript — tools/capture_demo.sh produced it)
```console
-$ agentjit compile examples/rank.json --name rank
+$ jitagent compile examples/rank.json --name rank
requirement Rank {name, score} records from highest score to lowest. Records with the same…
seeds 5 model claude-haiku-4-5 backend cli cache auto
miss: nothing reusable found
wrote 8 extra test cases
- {"records": [{"name": "alice",… (Three-way tie causes rank to skip from 1 to 4)
- {"records": [{"name": "alice",… (Negative scores ranked lower than zero)
- ! assumes something the requirement did not say: Scores can be negative and follow st…
- {"records": [{"name": "zoe", "… (Names sorted alphabetically within same rank, regard…
- {"records": [{"name": "alice",… (Four-way tie causes rank to skip from 1 to 5)
+ {"records": [{"name": "bob", "… (Two separate tie groups; each sorted by name within…
+ {"records": [{"name": "z", "sc… (Negative scores rank below positive; highest to lowe…
+ ! assumes something the requirement did not say: Scores can be floating-point numbers…
+ {"records": [{"name": "a", "sc… (Zero score is a valid boundary; tied zeros rank belo…
+ {"records": [{"name": "a", "sc… (Decimal scores compared with exact equality; 85.5 ≠…
attempt 1 passed
PASS static passed
@@ -34,12 +36,12 @@ miss: nothing reusable found
PASS examples 13/13 passed
PASS return_schema passed
- level: VERIFIED took: 47ms
+ level: VERIFIED took: 65ms
-result: ok tokens 3569in/2154out
+result: ok tokens 3692in/1680out
cache: miss no hit; synthesised a new one
-name rank handle fn_69889ea0d4c5 v1 level VERIFIED
-$ agentjit call rank '{"records":[{"name":"zoe","score":7},{"name":"amy","score":9}]}'
+name rank handle fn_8617fc1e2b62 v1 level VERIFIED
+$ jitagent call rank '{"records":[{"name":"zoe","score":7},{"name":"amy","score":9}]}'
ok v1
[
{
@@ -51,7 +53,7 @@ ok v1
"rank": 2
}
]
-$ agentjit list
+$ jitagent list
NAME VER CASES REQUIREMENT
rank v1 13 Rank {name, score} records from highest score to lowest.
```
@@ -70,11 +72,11 @@ group, sum, format — and a function is something you can read, test, version a
model's judgement is worth paying for *once*, to write that function. It is not worth
paying for two hundred times to re-derive it.
-So hand `agentjit` a sentence and a couple of examples. It writes the test cases out in
+So hand `jitagent` a sentence and a couple of examples. It writes the test cases out in
full, writes the code, runs it against every case in a sandbox, and stores the lot under a
name you choose.
-| | in the agent | after `agentjit compile` |
+| | in the agent | after `jitagent compile` |
| --- | --- | --- |
| per call | seconds, thousands of tokens | ~30ms, zero tokens |
| same input twice | may differ | identical |
@@ -92,15 +94,22 @@ regeneration of everything you have.
## Quickstart
```bash
-git clone https://github.com/Birfy/agentjit && cd agentjit
-pip install -e ".[dev]"
+pip install jitagent # the import name and the CLI are `jitagent`
+jitagent selftest # 6 corpus cases, each with a deliberately planted bug
+```
+
+`selftest` ships with the package and spends nothing. It is worth running on a new
+machine: how the sandbox contains a memory bomb is platform-dependent, so "does this
+behave here?" is a real question. To work on `jitagent` itself:
-pytest # 102 unit tests — no network, no tokens
-agentjit selftest # 6 corpus cases, each with a deliberately planted bug
+```bash
+git clone https://github.com/Birfy/jitagent && cd jitagent
+pip install -e ".[dev]"
+pytest # 102 unit tests — no network, no tokens
```
Synthesis needs a model. If you have [Claude Code](https://claude.ai/code) installed,
-`agentjit` shells out to it and uses its authorisation; otherwise set `ANTHROPIC_API_KEY`
+`jitagent` shells out to it and uses its authorisation; otherwise set `ANTHROPIC_API_KEY`
and pass `--via api`.
A requirement is a JSON file: a sentence, plus a few examples that pin down what you mean.
@@ -122,24 +131,25 @@ The examples are the spec, not decoration — nothing without them reaches the c
}
```
-That is [`examples/rank.json`](examples/rank.json), so this runs as it stands:
+That is [`examples/rank.json`](examples/rank.json) — from a checkout, this runs as it
+stands:
```bash
-agentjit compile examples/rank.json --name rank # write the cases, then the code
-agentjit get rank # print the source
-agentjit call rank '{"records": [...]}' # run it in the sandbox
-agentjit list # what is in the registry
-agentjit search "rank records by score" # is there one already?
-agentjit inspect rank # cases, versions, verification report
+jitagent compile examples/rank.json --name rank # write the cases, then the code
+jitagent get rank # print the source
+jitagent call rank '{"records": [...]}' # run it in the sandbox
+jitagent list # what is in the registry
+jitagent search "rank records by score" # is there one already?
+jitagent inspect rank # cases, versions, verification report
```
-The registry lives in `~/.agentjit/registry`; `AGENTJIT_HOME` or `--home` moves it.
+The registry lives in `~/.jitagent/registry`; `JITAGENT_HOME` or `--home` moves it.
### From Python
```python
-from agentjit import compile_function, get_code, call_function, Example
-from agentjit.llm import ClaudeCliClient
+from jitagent import compile_function, get_code, call_function, Example
+from jitagent.llm import ClaudeCliClient
compile_function(
"Rank {name, score} records from highest score to lowest...",
@@ -283,11 +293,11 @@ precise.
## Reuse, and why the lookup is allowed to be crude
-Compiling once saves nothing; the saving is in not compiling the second time. `agentjit
+Compiling once saves nothing; the saving is in not compiling the second time. `jitagent
inspect` shows what is actually stored:
```console
-$ agentjit inspect rank
+$ jitagent inspect rank
-- test cases (13) --
[caller] {"records": [{"name": "alice", "score": 90}, … -> [{"name": "alice", "rank": 1}, …
[caller] {"records": []} -> []
@@ -369,7 +379,7 @@ cases it can see; `verify.py` records what would trigger that.
## The code
```
-src/agentjit/
+src/jitagent/
jit.py the product surface: compile / get_code / call / search / inspect
propose.py get the model to write the cases out — before the code, separate call
prompts.py the two prompts; the requirement sits in an untrusted data region
@@ -383,8 +393,9 @@ src/agentjit/
lookup.py three-level lookup — retrieval narrows, re-verification decides
runtime.py calling: input guard → sandbox → return guard. A pure read
llm.py backends: the API, the local claude CLI, a scripted replay
+ corpus/ 6 corpus cases, one per gate — shipped, so selftest runs anywhere
tools/ the three audits, plus the demo capture and the SVG builder
-tests/corpus/ 6 corpus cases, one per gate
+tests/ unit tests; every one runs with no network and no tokens
```
| Document | Contents |
@@ -393,6 +404,7 @@ tests/corpus/ 6 corpus cases, one per gate
| [docs/correctness.md](docs/correctness.md) | correctness and testing — **what decides whether this stands up** |
| [docs/tracing-frontend.md](docs/tracing-frontend.md) | the front end that spots repetition and triggers a compile (not started) |
| [NEXT.md](NEXT.md) | what to do next, and what is deliberately not being done |
+| [CONTRIBUTING.md](CONTRIBUTING.md) | how to run things, and what a new verification gate has to prove |
**Where to start reading:** the headers of `propose.py` (why one model judging itself is
circular, and which part of that cannot be fixed), `verify.py` (which five gates were cut
diff --git a/docs/correctness.md b/docs/correctness.md
index f94874f..70e97d2 100644
--- a/docs/correctness.md
+++ b/docs/correctness.md
@@ -12,7 +12,7 @@
> coverage, fuzzing and mutation testing were all built and then removed, because across
> the whole corpus not one of them ever caught a mistake a real model made. See the
> "What was cut, and why" section of the [README](../README.md) and the header of
-> `src/agentjit/verify.py`. The reasoning here is kept because it is what any future
+> `src/jitagent/verify.py`. The reasoning here is kept because it is what any future
> addition has to argue against.
---
diff --git a/docs/demo.svg b/docs/demo.svg
index b238ef2..ad4157f 100644
--- a/docs/demo.svg
+++ b/docs/demo.svg
@@ -1,22 +1,22 @@
-