Git as the database

Every stage commits, the run is reconstructable from refs alone, and the app owns exactly two branch families. Why an autonomous builder should store its work in the versioning system its users already have — and what that choice costs at review time.

Autonomy has a consequence that took me embarrassingly long to design for: if it worked, you were not there.

Which means the interesting question about Dex is never “what is it doing right now”. Watching a progress bar for eight hours is not a product. The question is what did it do, and how do I take some of it back — and that is a storage question before it is a UI question.

The answer Dex settles on: git is the database. Not a place results get exported to at the end. The actual storage model, load-bearing, with the application’s own state file demoted to a cache of it.

Every stage commits

The unit of work is a stage, so the unit of storage is a stage. Each completed stage commits with a two-line message — one line for the human, one for the machine:

dex: plan completed [cycle:2] [feature:product-catalogue]
[checkpoint:plan:2]

The subject is readable in git log by someone who has never heard of Dex. The body is a parseable checkpoint marker naming the stage and the cycle.

That is a small format decision with a large consequence: a finished run is fully reconstructable from git, by anyone, without the app. Not exportable — reconstructable. Two commands tell you everything about what happened:

git log --oneline --grep='\[checkpoint:'   # every stage commit
git branch --list 'dex/*'                  # every run

If Dex disappeared tomorrow, its output would still be a normal repository with an unusually well-annotated history. I consider that a design requirement rather than a nice property. A tool that runs unattended for eight hours has to leave behind something that outlives it, or nobody sane would let it run unattended.

Two branch families, and that is the whole namespace

Ref Purpose
dex/<date>-<id> One per autonomous loop run
selected-<ts> Transient navigation fork from a mid-branch jump, auto-pruned when left empty

That is it. There was a period when the app minted four families — the other two were an attempt-branch family and a capture family for auto-promoted baselines — and cutting back to two was one of the highest-value changes in the project. That deletion has its own post; what matters here is the principle.

A namespace an application writes into someone else’s repository is a permanent tax on that repository. Every family needs creation rules, pruning rules, a story for what happens when a user deletes one by hand, and a line in everybody’s mental model of what git branch output means. Two families fit in a sentence. Four did not, and I could not explain them without a diagram.

Named checkpoint/<name> tags still exist as a convention, but the running app no longer creates them — a script mints one when you want a shareable save point for a fixture or a baseline, and they travel with git push --tags like any other tag. Machinery the app runs constantly and machinery you invoke deliberately should not be the same machinery.

What is tracked, and why that is the interesting bit

Dex keeps its per-project state inside the project, and the tracked/ignored split is the design:

<projectDir>/.dex/
├── state.json             tracked — run state, rides git checkout on a jump
├── feature-manifest.json  tracked — extracted once, drives feature selection
├── learnings.md           tracked — accumulates across cycles
├── runs/<runId>.json      tracked — one audit record per run
├── state.lock             gitignored (PID)
├── ui.json                gitignored (per-machine UI prefs)
├── dex-config.json        gitignored (agent backend, conflict-resolver knobs)
└── mock-config.json       gitignored (mock-runner script)

Read the rule off that list: anything that describes the work is tracked; anything that describes this machine is not.

state.json being tracked is what makes navigation coherent. Jump the timeline to cycle 2’s plan stage and the checkout brings that cycle’s state file with it — the app’s idea of where the run is arrives as part of the same atomic operation that moved the code. There is no second restore step to get wrong, and no window in which the tree says one thing and the state file says another.

The gitignored half is equally deliberate. A per-machine UI preference or a local choice of agent backend has no business appearing in a teammate’s diff.

The Timeline tab renders those commits as a branching DAG — one column per ref, one node per stage commit, edges for parent → child, colour for what is on the active path. A plain left-click on any node performs the correct git operation for that node, without asking which one you meant:

You clicked What happens
A branch tip Check out that branch
A commit mid-history Fork a throwaway selected-<ts> branch from it
Anything, with a dirty tree Confirm first: Save or Discard

Save is a normal commit on the branch you are already on (dex: pre-jump autosave). Discard is a discard. There is no fifth concept to learn, and critically no Dex-specific vocabulary — a user who knows git sees exactly the operations they would have typed, and a user who does not never has to find out that git rebase exists.

Two further gestures cover the things that otherwise force you into a terminal.

Remove this saved version deletes a branch Dex owns. If that branch holds work existing nowhere else, the confirmation names the stages in plain English — “Cycle 2 — Plan” — rather than listing SHAs and wishing you luck. main and branches you created yourself have no remove control at all, so the destructive gesture is structurally unavailable where it would hurt most. That is worth more than any confirmation dialog: the safest control is the one that is not rendered.

Make this the new main promotes a saved version into the primary line.

The one genuinely risky part

Real promotion means real merge conflicts, and resolving them is the only place in Dex where an agent touches your code outside a stage. It runs per file, under a deliberately tiny tool surface:

const ALLOWED_TOOLS = ["Read", "Edit"];

const SYSTEM_PROMPT_OVERRIDE = [
  "You are resolving a merge conflict in a single file.",
  "You MUST use the Edit tool to modify the file. Do NOT only describe the resolution in text",
  "Goal: remove all conflict markers (<<<<<<<, =======, >>>>>>>) so the file parses cleanly,",
  "while preserving the intent of both branches when possible.",
  "Do not modify any other file. Do not add commentary outside the Edit tool call.",
].join("\n");

Read and Edit. No Bash, no Write, no ability to touch a file it was not handed. Every way it can fail is a named value rather than a stack trace:

export type ResolverFailReason =
  | "max_iterations"
  | "cost_cap"
  | "verify_failed"
  | "agent_gave_up"
  | "user_cancelled";

An iteration cap so it cannot loop, a dollar cap so it cannot run away, and the project’s own verify command as the thing it must satisfy — if the merged file does not build, the resolution does not count, however confident the explanation was. If any cap trips, the merge stops and hands back a working tree with the conflicts still in it, which is exactly where you would have been without it.

Why git rather than a database

Dex used to keep its audit trail in SQLite at ~/.dex/db/data.db. Answering “what did that run cost” meant opening a SQL shell, in a home directory, against a schema you had to remember. Nobody does that, so nobody looked at the audit trail, so it may as well not have existed.

It is now one JSON file per run, inside the project it describes:

jq '.totalCostUsd, (.phases | length)' .dex/runs/<runId>.json

The ergonomic win was the point. The build win was the bigger surprise: better-sqlite3 is a native module, and native modules in Electron mean rebuilds against the right ABI, on every machine, on every version bump. Deleting it removed an entire category of “works on my machine”. The engine’s state dependencies are now node:fs, node:path and node:crypto.

But the real argument is narrower than “files are simpler than databases”. It is this: your users already run a versioned, branchable, diffable, shareable state store with a twenty-year track record and universal tooling, and it is already open in the directory you are writing to. Building a second one means reimplementing branching, reimplementing history, reimplementing “show me what changed” — and then explaining to users why your history and their history are different things that can disagree.

What this costs

The history is machine noise. A cycle produces seven commits that correspond to stages, not to logical changes. git log on a Dex run branch is a procedural transcript, and reading the diff of “what did this feature actually change” means reading across all of them or diffing endpoints and ignoring the middle. Conventional history is a curated argument for a change; this is an execution trace. Both are useful; they are not the same artifact, and Dex produces only the second.

Promotion is where the two models collide. main wants curated commits. A dex/* branch carries seven-per-cycle stage commits. “Make this the new main” therefore imports the trace into the curated line, and Dex does not squash, because squashing would destroy the checkpoint markers that make the run navigable. You get correctness at the cost of a messy main, and which of those you want is a matter of taste that the tool currently decides for you.

Writing into a user’s repo is an imposition. Two branch families and a .dex/ directory is small, but it is not zero, and it lands in their git status, their diffs, and their review tooling. That is defensible for a tool whose entire purpose is to produce commits, and it would not be defensible for much else.

And git is not a transaction log. A commit per stage gives durability, not atomicity. If the process dies between finishing a stage’s work and writing the commit, the work is on disk and unrecorded — which is exactly the seam the next post is about.

Next: three counters, three answers — one Stop click, three sources of truth about the same run, and the reconciliation ritual that should have existed.