le0c

Introducing tripact

A while back I was rebuilding the frontend repo at GScan from prior art whose features were already well described (our old app). Two things I wanted out of that rebuild: the features captured as acceptance criteria I could point at, and a user manual that stayed current without much ongoing overhead.

The end-to-end tests fell out of the join between those two. A test follows the manual's instructions and asserts the app reaches the state the manual says it will. If the manual is right the test passes, and if the test fails then one of the two is wrong. That interlock turned out to be really useful! Without much additional work, I was then also able to build a system for diff-driven release notes, and another tool for generating training video scripts.

Keeping four artefacts in agreement by hand is quite laborious. So I wrote a /sync skill for my coding agent: after a feature change, go read the acceptance criteria, the manual, the video scripts and the test specs, and bring them back into agreement. It worked, and I kept using it, but I felt like it could be doing more. It also re-read everything on every run, which was slow and expensive. There was nothing in it I could put in CI.

tripact is what came out of tinkering and improving that /sync workflow.

TLDR:

What it is

tripact divides the files in your repository into three kinds of layer:

Role Typical files What gets tracked
prescriptive product spec, acceptance criteria each list item, numbered item, or requirement paragraph becomes a claim
descriptive user manuals, guides, tutorials list items and requirement prose become claims; coverage is per section
verificatory unit, integration and end-to-end tests tags in test titles link tests to claims and sections

Those layers are declared in a tripact.yaml at the repository root, along with the edges to be checked between them. [specs, tests] answers "do tests exist for everything my spec asks for?". [docs, tests] answers "can a user do everything my manual describes?". Only declared edges get checked, so it works fine on a repo that has a spec and tests but no manual yet.

Each claim gets an id minted from its heading path and its own text, so a requirement "add(a, b) returns the sum of two integers" sitting under an "Addition" heading becomes addition.adda-b-returns-sum. The tag carries the id, never the prose, because prose drifts. Here is an example of a test being tagged:

test("@specs:addition.adda-b-returns-sum - adds two integers", () => {
  expect(add(2, 3)).toBe(5);
});

Then check reports where the edge stands:

$ tripact check                 # a spec claim exists, but no test references it
βœ— drift detected β€” 1 new-uncovered
edge specs ↔ tests: 0/1 covered
  NEW-UNCOVERED addition.adda-b-returns-sum "add(a, b) returns the sum of two integers"
βœ— drift detected                # exit 1

# …tag the test, baseline once with `tripact accept`…
$ tripact check
edge specs ↔ tests: 1/1 covered
βœ“ level                         # exit 0 - the requirement is provably tested

There are other commands like detect and audit but this is the core loop: check for claims, tag the tests, link them together & save.

Why I built it

There was a specific problem I encounter a couple of times.

Reword an acceptance criterion so it means something subtly different. Don't touch the test. The problem now is that the unit test passes, CI checks are green, and nothing tells you the requirement no longer matches the product.

A diff-based checker misses it because the test file didn't change. An LLM reviewer can catch it, and can also catch it on a run where nothing happened, and miss it on a run where something did. When I was running the /sync skill, a lot of time & tokens when to checking & rechecking the code+specs every time I was updating or adding specs.

tripact mostly resolve this issue, but I built it with one rule in mind: if something can be checked without judgement, the engine should do it. Where judgement is required, the engine refuses to guess and writes a structured question for me or an agent to answer. This ensures that I can always trust the result; the deterministic portion is highly accurate, anything which requires intelligent evaluation is done is escalate. The escalated questions arrive with their context already attached, which makes it simple for an agent to check.

I also think that as of writing, there isn't really a good option for agent traceability that isn't part of a bigger package. My goal with tripact was that you can use it as a plain old CLI program, meaning you can run it anywhere, inside a loop or harness or sandbox. By not shipping a harness, I can also focus on things like test framework adapters, performance, and building up the spec evaluation heuristics.

How it works

Claim IDs persist through a rewording

I spent a lot of time thinking and designing here. If an id were just a hash of the claim text, then fixing a typo in a requirement would invalidate the claim, mint a newly uncovered one in its place, and orphan the tag in the test. I wanted the tool to be resilient in the face of this.

So on every check the engine re-derives claims from the files and re-anchors each one to the identity it already had, through a cascade of progressively less certain matches:

Reword the claim and the id stays put; the test linked to it flips to stale for re-verification instead of being dropped.

One downside of this is that if you build a specific number into a spec ("the user can click on one of three buttons: next, previous, save"), this number gets persisted forever. If you then need to add another item to this list of three, the ID would reference three but the spec would talk about four. I have plans to build out the "edit this specific ID but don't re-baseline the claim" feature too.

Judgement escalation

Below the similarity threshold, the engine won't link candidate pairs that it isn't sure about. It writes a question into a queue, with the two texts and the score inlined, in one of a few kinds: is this reworded claim the same one as before, has one claim split into three, has a heading lost a claim and gained a claim in the same edit. The answer goes back in with tripact resolve, and accept refuses to baseline while an identity question is still open, so an unanswered one can never be baselined away.

The work comes out as briefs

I don't have to read the check output and work out what to do with it. tripact tasks derives the queue of repair work, and tripact prompt <id> prints one brief with its payload already inlined, so a harness hands an agent a self-contained job rather than a pointer into a codebase:

# tripact task: Addition β€” Tag or write a test for these claims

## What to do
Write tests that genuinely assert each listed claim, tagging each with the payload's
`tagFormat` in the test title. Never tag a test that does not assert the claim.

## Payload (self-contained)
{ "group": "Addition",
  "claims": [ { "id": "addition.adda-b-returns-sum",
                "text": "add(a, b) returns the sum of two integers" } ],
  "tagFormat": "@specs:<id>" }

## When done
Validate with `tripact check` and the repo's own test command; the check must not regress.

Agent agnostic

tripact runs no agents. It's an executable that emits documents, and every read surface is available identically through the CLI's --json flag and through an MCP server. Exit codes carry the verdict: 0 level, 1 drift, 2 a usage or environment error. A workflow can branch on that without understanding a single thing about claims or hashes.

I want tripact to be drivable by whatever harness someone already has, whether that's a coding agent or a CI job. The payload shapes are versioned like a public API, and a breaking change bumps a schema version, because breaking the contract breaks every harness driving the engine.

What it deliberately does not check

tripact is naive by design. A test that never calls add() still counts as covered once it carries the tag and the link has been accepted. The engine checks that a link between a requirement and a test was declared and reviewed, and it defers the question of whether that test is a good test to whoever wrote it.

Eventually I would like to use function maps that various testing frameworks expose to back trace if a tagged test is calling code with the right tag, but this feature depends greatly on how the language / runtime / framework is built. Ideally I would have adapter modules for popular frameworks, so that you can programmatically check test<->code links.

Experiments & testing

I've run tripact on repositories I work on, and also a number of open-source repos that have well defined specs. Generally speaking it worked welll, with a few hiccups.

The friction was the same in almost every repo: the layer detection couldn't find the spec. Spec-driven repos increasingly keep their requirements in a known layout, and I was asking people to hand-declare paths that a preset could have filled in. tripact now takes a top-level kind: (spec-kit, openspec, strictdoc, kiro, cursor) that fills in layers, edges and excludes, plus a read-only detect command that reports which systems a repository matches without writing anything.

One interesting finding was a StrictDoc repo, whose .sdoc files got parsed as though they were markdown. It produced several hundred nonsense claims out of the grammar's own syntax, while the couple of hundred requirement nodes in the file were invisible to it entirely, and it printed no warning about any of it. There's a .sdoc parser now, along with a Gherkin one for repos whose specs are feature files.

In one trial, tripact worked on a repo that already has tests. It flooded the queue with write-tests tasks for claims whose test already existed and merely wasn't tagged, so now there's a reconcile command that proposes existing tests for uncovered claims by similarity. And a check that parsed zero claims across every authoring layer used to report βœ“ level, which is the worst possible output: a green tick over a mistyped glob. A vacuous check is now drift, and it names the likely cause.

What next?

The largest gap is the one in the naivety above. Knowing a test is tagged with a claim tells me a link was reviewed; it doesn't tell me the test exercises the code that implements the claim. Closing that needs runtime coverage data fed in as an input artefact, per language, which is a bigger dependency than anything in the engine today and would be deterministic only if the coverage report comes in from outside.

Linking claims to the functions that implement them is the other direction I am interested in, so that hovering a function in the editor shows you which requirement it serves. I spiked this and got a partial approach; a markdown link whose target is a {@link} tag renders as a label and navigates, but this was a bit brittle and fiddly and specific to an IDE. In the end I decided that the link should go to the spec file with the section name in the label, since a line number reference rots without anything re-checking it.

Smaller things on the list: a short-hash id modality alongside the readable slugs, since descriptive ids are nice for me and pure overhead for an agent, and better deterministic disambiguation when two claims in a group would mint the same slug. More spec formats, driven by what the next batch of repositories turns out to be written in.

Final thoughts

When tripact check exits 0 on our frontend I know every requirement I've written has a test tagged against it, and I know it because a program counted the tags rather than because a model told me so. When it exits 1 I get a list I can hand to an agent one job at a time, with the context already attached.

I do wonder how much specification this model can hold. A markdown list item is a low bar for a requirement, and plenty of what I'd like to guarantee doesn't fit on one line. It may well turn out that some claims can't be checked this way at all, and that the tool should say so rather than track them badly. The ones that do fit, however, are the ones I feel quite confident about.

tripact is on npm and GitHub, with an Apache-2.0 licence. If you would like to contribute then feel free to try it out and make some suggestions! I would love to hear how it worked for you.