Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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/
91 changes: 91 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading