Tutorial · Getting started

Start a new project with Morph

A 20-minute walkthrough that takes you from an empty directory to a Morph repo with metric-bearing commits, a recorded agent session, and a clean revert. Along the way you'll meet the parts of Morph that don't exist in Git: runs, traces, and behavioral commits.

Time: ~20 min Mode: Reference (Morph + Git) Stack: Python 3 · pytest · Cursor Difficulty: Intro

01Before you start

Morph is a behavioral version control system that rides on top of Git. Every git commit on this project will be mirrored into a Morph commit automatically; on top of that Git backbone, Morph layers the things Git can't represent — runs, traces, and behavioral commits. So the tutorial starts with a one-line git init and never asks you to think about Git again. The interesting work all happens through morph.

If you already have a Git project with history and teammates and want to layer Morph on top of that, the right path is Tutorial 2.

You'll need four things on your machine before you start:

  • morph and morph-mcp on your PATH — see installation.
  • Git 2.28 or later (for git init -b main support) and a configured identity. git config --global user.name && git config --global user.email should each print something. If they don't, set them with git config --global user.name "Your Name" and git config --global user.email "you@example.com" — Morph mirrors every commit through git commit, which refuses without an identity.
  • Python 3 with pytest available (python3 -m pip install --user pytest).
  • Cursor, Claude Code, or OpenCode — the agent step is the whole point of recording, so we treat the IDE as a real prerequisite. The screenshots and commands here use Cursor; the others differ only in morph setup <ide>.
  • A free directory to scribble in. We'll use ~/morph-tutorial.
Verify your install: morph --version should print something like morph 0.48.0 (built …). If it doesn't, fix that before going further — the rest of the tutorial assumes the binary is on your PATH.

02Build a tiny project

We need something small enough to fit in a tutorial and interesting enough to write tests against. A currency converter works: a function and three tests, one Python file each.

$ mkdir morph-tutorial && cd morph-tutorial $ git init -b main -q

That's the only Git command in the tutorial. -b main pins the initial branch to main so it matches Morph's contract regardless of your host's init.defaultBranch setting. From here on, every Git operation will happen for you: morph commit wraps git commit, morph add wraps git add, and the four hooks Morph installs in the next step keep the two histories in lockstep.

If you forget the git init: morph init will detect there's no .git/ and prompt "morph requires a git repository here. Run `git init` for you? [y/N]". Answering yes is equivalent to running git init -b main yourself, or you can pass morph init --git-init for the same shortcut without the prompt.

Create converter.py:

# converter.py RATES = { "USD": 1.0, "EUR": 0.92, } def convert(amount, src, dst): if src not in RATES or dst not in RATES: raise ValueError(f"unsupported currency: {src} -> {dst}") usd = amount / RATES[src] return round(usd * RATES[dst], 2)

And test_converter.py:

# test_converter.py from converter import convert def test_usd_to_eur(): assert convert(10, "USD", "EUR") == 9.20 def test_eur_to_usd(): assert convert(9.20, "EUR", "USD") == 10.00 def test_same_currency(): assert convert(5, "USD", "USD") == 5.00

Sanity-check that the tests pass before we bring Morph into the picture:

$ python3 -m pytest ============================= test session starts ============================== collected 3 items test_converter.py ... [100%] ============================== 3 passed in 0.00s ===============================

03morph init

Now turn this directory into a Morph repo:

$ morph init Initialized empty Morph repository in /Users/you/morph-tutorial/.morph/ bound to empty git repository (no commits yet) installed 4 git hook(s) at .git/hooks/ (4 present, 0 already up to date) morph state is local to this clone (.morph/ added to .git/info/exclude) teammates not using morph are unaffected — your git workflow is unchanged

Four lines worth reading carefully:

  • "bound to empty git repository" — Morph noticed the .git/ you just created and snapshotted it as the starting point. With a non-empty repo it would say "bound to git HEAD <sha>".
  • "installed 4 git hook(s)"post-commit, post-checkout, post-merge, post-rewrite. Each is a tiny shell stub that calls morph hook <event> and exits zero whatever happens, so a broken Morph never blocks a Git commit.
  • ".morph/ added to .git/info/exclude" — local-only. git status won't see .morph/, even with no .gitignore change. Morph state never travels through Git.
  • "teammates not using morph are unaffected" — you'll only feel this in Tutorial 2 (where there are teammates), but it's the same setup for both.

The .morph/ directory is the moral equivalent of .git/ — a content-addressed Merkle DAG of objects, plus refs, config, and (this is where it diverges from Git) folders for runs/, traces/, prompts/, and evals/. Those are the things Git can't represent.

$ ls .morph/ config.json evals/ objects/ prompts/ refs/ runs/ traces/

Now look at the default policy. This is the most opinionated thing Morph does, and it's better to meet it head-on than to be surprised by it later:

$ morph policy show { "required_metrics": [], "merge_policy": "dominance", "exempt_origins": ["git-hook"], }

The default required_metrics is empty — Morph rides on top of Git, and every git commit automatically mirrors into a Morph commit through the post-commit hook. If the policy required metrics on every mirror, every plain git commit would fail. The exempt_origins: ["git-hook"] carve-out is the explicit way to say "passive mirrors don't have to carry evidence; only commits I make through morph commit do."

For the rest of this tutorial we want the gate. Tell Morph to require test evidence on every morph commit:

$ morph policy require-metrics tests_total tests_passed required_metrics = [tests_total, tests_passed]

That's the project's promise to itself: every commit must arrive carrying tests_total and tests_passed as evidence, and merges will be gated on dominance — the merged code has to be at least as good as both parents on every declared metric. You can relax it later with morph policy require-metrics (pass no names to disable the gate entirely), but the default once you've turned it on is "you don't ship code without proof it works." We're going to honor it.

04Your first commit (the honest version)

Let's stage everything and try the most obvious commit:

$ morph add . 2 files staged $ morph commit -m "scaffold currency converter" Error: policy requires metrics that are missing: [tests_total, tests_passed]. Pass --metrics with these keys, run `morph eval record`, or override with --allow-empty-metrics.

That's the policy you just turned on biting us. The error tells you exactly what to do — there are three real ways out:

  1. --metrics '{"tests_passed": 3, "tests_total": 3}' — supply them by hand.
  2. Run the test suite under Morph (we'll do this in step 8) so the metrics flow in automatically.
  3. --allow-empty-metrics — the audited escape hatch. The commit still gets recorded, just without behavioral evidence.

For a scaffold commit, option 3 is the right call: there's nothing interesting to evaluate yet, and the commit message says so. From now on, though, we'll always have evidence.

$ morph commit -m "scaffold currency converter" --allow-empty-metrics warning: commit has no observed_metrics. Morph cannot enforce behavioral merge gating without evidence. Pass --metrics, run `morph eval record` / `morph eval run`, … [f7077862 (cli)] scaffold currency converter git: a92430d4f0c9 $ morph log --oneline f7077862 scaffold currency converter

Two things just happened in one step: morph commit shelled out to git commit (that's the git: a92430d4f0c9 line — Git's hash for the same change), then mirrored the new Git commit into a Morph commit (f7077862) tagged (cli) to distinguish it from the passive (git-hook) mirrors that ride along plain git commit. git log and morph log stay in lockstep from here.

About morph status: if you run morph status after a morph add, you'll still see your files listed under "Changes not staged for commit." That's a label quirk — Morph's status compares the working tree to HEAD, not to the staging area, so the section header is misleading. Your files are staged. The next morph commit will pick them up.

05Wire up your IDE

One command installs the MCP server config, the recording hooks, and the behavioral-commit rules for your IDE. Pick the one you use:

$ morph setup cursor # writes .cursor/ (MCP, hooks, rules) $ morph setup claude-code # writes .claude/ $ morph setup opencode # writes opencode/ + AGENTS.md

What just happened: the hooks parse the agent's full transcript — every prompt, response, tool call, file read, file edit, shell command — into structured Trace events stored under .morph/traces/. Recording is always-on. You don't need the agent to remember to record; the hooks see the transcript and Morph picks up the rest.

From here on, when an agent works on this project in your IDE, every session becomes an immutable Run with a full Trace. Restart your IDE so it picks up the new MCP config, then come back to the terminal.

For the per-IDE details — what files get written, how to verify the hook is firing, troubleshooting — see Cursor, Claude Code, or OpenCode.

06Edit by hand

Make a small change so we have something to commit. Add a docstring to convert, or anything trivial — the goal is just a diff:

$ morph status Changes not staged for commit: modified: converter.py

We could stage and commit this right now with another --allow-empty-metrics, the way we did the scaffold — and the human loop on its own really is identical to Git's. But that's not why you came. The next two steps wire up the part Git can't do: capture evidence alongside the diff, and let it flow into the commit automatically.

07Capture metrics with morph eval run

Your test runner already produces the numbers Morph's policy wants — pass / fail counts, totals, pass rate. morph eval run is just a wrapper around any test command: it shells out, captures stdout, parses it (cargo, pytest, vitest, jest, and go are auto-detected), and writes a Run object into the store linked to HEAD. The Run carries the parsed metrics and a Trace of the invocation. It also drops a .morph/LAST_RUN.json breadcrumb that the next morph commit knows to pick up.

One ordering detail matters: stage first, then run the eval. The breadcrumb captures the staging-index fingerprint at the moment morph eval run finishes, and the next morph commit only auto-attaches if that fingerprint is still fresh — so any edit between the run and the commit invalidates the breadcrumb.

$ morph add . 1 file staged $ morph eval run -- python3 -m pytest dff09ec109d469274644f4212f71d0d9c359a429315e6d80c2c833a21f773104

That hash is the new run. You can inspect it directly:

$ morph show dff09ec1 { "type": "run", "commit": "f7077862…", "metrics": { "pass_rate": 1.0, "tests_failed": 0.0, "tests_passed": 3.0, "tests_total": 3.0 }, "trace": "24c92cf3…", "agent": { "id": "morph-eval-run", "version": "1.0", … } }

The metrics from your test run are now sitting in a Run object, waiting to be attached to a commit. morph eval gaps tells you what evidence is still missing relative to HEAD:

$ morph eval gaps Found 2 gap(s): - empty_head_metrics: Run `morph eval run -- <test command>` then `morph commit …`. - empty_default_suite: Add YAML/cucumber acceptance cases via `morph eval add <file>`.

Two gaps. The first (empty_head_metrics) is the one we'll close in the next step — it just means HEAD hasn't yet been stamped with metrics. The run carries them; the commit doesn't yet.

About empty_default_suite: this gap will stick around for the rest of the tutorial — that's expected. It means we haven't registered a YAML acceptance suite, a separate Morph concept where you write down what the project promises in plain English. It's optional for a single-branch project (your test runner already produces the numeric metrics the policy needs), but it earns its keep when you start merging branches and want the merge gate to compare richer claims than raw numbers — see MERGE.md and EVAL-DRIVEN.md for the merge-gate and case-provenance flow. Ignore the gap for now.
Use the full pytest output, not -q. The pytest parser keys off the === N passed in Xs === summary line, which pytest -q in recent versions doesn't emit. If morph eval run warns "no metrics extracted," drop the -q.
Skip the two-step? (v0.44+) You'll see this two-step (eval run → commit) repeatedly in this tutorial because it makes the underlying mechanics obvious. Once you internalize them, run morph config commit.test_command "python3 -m pytest" once per repo, and from then on plain morph commit -m "…" runs your suite, parses the metrics, and attaches them — one command, same evidence. --no-test opts out for a single commit; --rerun forces a fresh run when the breadcrumb is stale.

08Commit with evidence

The change is staged, the run is recorded, the breadcrumb is fresh. Now commit. You don't need --from-run or --metrics — Morph notices the LAST_RUN breadcrumb left by morph eval run and attaches its metrics to the commit automatically:

$ morph commit -m "add a docstring to convert()" attaching evidence from run dff09ec1: pass_rate=1, tests_failed=0, tests_passed=3, tests_total=3 [00d89f7a (cli)] add a docstring to convert() git: e44e54801333

That "attaching evidence from run …" line is the moment of payoff. The commit you just made is no longer a snapshot — it's a snapshot plus a behavioral claim, with the run that produced it linked as evidence.

See for yourself. morph log defaults to a compact one-liner; the metrics live one level deeper, on the commit object itself or in the --json view of the log:

$ morph show 00d89f7a { "type": "commit", "tree": "19d1190e…", "message": "add a docstring to convert()", "contributors": [ { "id": "you <you@example.com>", "role": "human-author" }, { "id": "morph-eval-run", "role": "primary" } ], "eval_contract": { "suite": "5abb5131…", "observed_metrics": { "pass_rate": 1.0, "tests_failed": 0.0, "tests_passed": 3.0, "tests_total": 3.0 } }, "evidence_refs": [ "dff09ec1…", "9cd62067…" ], "morph_origin": "cli", "git_origin_sha": "e44e54801333…", }

Three new fields, all things Git can't represent. eval_contract carries the behavioral claim and the suite it was evaluated against. contributors distinguishes the human (role: "human-author") from the morph-eval-run agent that produced the metrics (role: "primary") — if you'd used --from-run with a recorded agent session instead, the agent would land there alongside you. evidence_refs point back to the runs that justify the metrics.

The git_origin_sha matches the git: e44e54801333 line from the commit output — that's the bridge between the two histories. morph_origin: "cli" distinguishes this from passive git-hook mirrors that ride along plain git commit. The suite hash 5abb5131… is Morph's canonical empty-suite hash — it's the stand-in until you register a real one in Tutorial 3.

09Let an agent take a turn

Open this project in Cursor (or your IDE of choice) and ask the agent for something concrete:

"Add support for GBP at rate 0.79. Update the rate table and add a test that converts 10 USD to GBP and asserts the result is 7.90."

While the agent works, the hooks you installed in step 5 are quietly recording its transcript into .morph/traces/ — every prompt and response, every tool call, every file read, every file edit, every shell invocation. You don't have to do anything; recording is background-noise.

When the agent finishes, switch back to the terminal. Your working tree should look something like this:

$ morph status Changes not staged for commit: modified: converter.py modified: test_converter.py Morph activity: 3 runs, 3 traces Eval suite: 0 cases registered

That "Morph activity" line is new. Two of those runs are from your earlier morph eval run calls; the third is the agent's session — a Run object with a Trace containing every event from the IDE turn. (The "0 cases registered" is the same empty_default_suite gap from step 7 — still expected, still fine.)

10Inspect what the agent did

This is the part Git literally cannot do. morph inspect (v0.45+) is the read surface for recorded agent work — runs, prompts, tool calls, file edits, aggregated by trace.

$ morph inspect summary === Tap Repository Summary === Runs: 5 Traces: 5 Total events: 38 Multi-step: 1 With metrics: 2 Event kinds: prompt 1 response 1 tool_call 14 file_edit 6 shell 4 test_output 4 Models: claude-opus-4 1 test-runner 4

Drill into a specific run to see its grouped steps:

$ morph inspect show 52b35819 === Run 52b35819 === model: claude-opus-4 agent: cursor events: 32 steps: 1 --- Step 1 --- Prompt: Add support for GBP at rate 0.79… Tool: read(converter.py) Tool: edit(converter.py, +1/-0) Tool: edit(test_converter.py, +5/-0) Shell: python3 -m pytest Response: Added GBP at 0.79 with a USD→GBP test. Tests pass (4/4).

Or look at it through the trace lens, which understands phases and target-context:

$ morph inspect recent === Recent Traces (5 shown) === 52b35819 2026-04-28T23:44Z phase=create_code scope=converter.py prompt: Add support for GBP at rate 0.79… $ morph inspect target 52b35819 # the snippet the agent was working on $ morph inspect artifact 52b35819 # what the agent produced

Every one of those events is content-addressed and immutable. Six months from now, when someone asks "why does the converter handle GBP this way?", the answer is a single hash away.

Now finish the loop. The agent already ran pytest via shell: events during its turn, but we want a fresh LAST_RUN breadcrumb tied to the staged tree, so re-run it explicitly:

$ morph add . $ morph eval run -- python3 -m pytest 32eef028… $ morph commit -m "add GBP support" --from-run 32eef028 attaching evidence from run 32eef028: pass_rate=1, tests_failed=0, tests_passed=4, tests_total=4 [526f83ca (cli)] add GBP support git: 18c3a0f97a2b $ morph log --oneline 526f83ca add GBP support 00d89f7a add a docstring to convert() f7077862 scaffold currency converter

Three commits, two with metrics, one (the latest) with the agent's full Trace attached as evidence. morph log --json will show you the metrics inline if you want to verify.

Why --from-run here? The auto-attach breadcrumb from §08 only fires when the staging-index fingerprint hasn't moved since the run was recorded. The agent's tool calls mutated files outside the standard "stage, then run" rhythm (it edited and ran pytest from inside its turn), so we record a fresh run and pass it explicitly. --from-run <hash> is the always-works form — the run gets attached to the commit and lands in commit.contributors alongside you (with role: "primary" for the agent and role: "human-author" for you). The auto-attach in §08 is just a convenience for the simple case.

11Make a regression, then revert

Morph is a full VCS, not just an additive recorder. Reverts work, and the interesting twist is that you can see the regression in the metrics before you decide to undo it.

Break something on purpose. Edit converter.py and change the EUR rate to 0.95 (sloppy "fix" — it'll fail two tests). Then run the eval and commit:

$ morph add . && morph eval run -- python3 -m pytest 3b8f00c1… $ morph commit -m "tweak EUR rate" attaching evidence from run 3b8f00c1: pass_rate=0.5, tests_failed=2, tests_passed=2, tests_total=4 [887b835d (cli)] tweak EUR rate git: c0d4e8f1ab3c

The commit succeeded — Morph's policy requires evidence, not good evidence. The bad commit is recorded with its real metrics, exactly as it should be (suppressing it would defeat the point of behavioral version control). You'd see this regression in morph log --json:

$ morph log --json | python3 -c "import json,sys; [print(c['short'],'->',c['metrics']) for c in json.load(sys.stdin)['commits']]" 887b835d -> {'pass_rate': 0.5, 'tests_failed': 2, 'tests_passed': 2, 'tests_total': 4} 526f83ca -> {'pass_rate': 1.0, 'tests_failed': 0, 'tests_passed': 4, 'tests_total': 4} 00d89f7a -> {'pass_rate': 1.0, 'tests_failed': 0, 'tests_passed': 3, 'tests_total': 3} f7077862 -> {}

Now revert it. morph revert creates a new commit that undoes the target. One quirk to know: revert doesn't update your working tree on its own — you'll need morph checkout <branch> to bring the files back into line:

$ morph revert 887b835d 18a5f076bc1da8e25b561165089d9f918e2ff6853a6d89f872e3ad24c296262d $ morph checkout main Switched to branch main $ grep EUR converter.py "EUR": 0.92, $ morph eval run -- python3 -m pytest && morph commit -m "re-run suite after revert" attaching evidence from run 20622b3a: pass_rate=1, tests_failed=0, tests_passed=4, tests_total=4

Both the bad commit and the revert are preserved in history — the bad commit is still inspectable, its run still shows the regression, and the new HEAD is back to a green metric. The Trace of why you made the regression and how you backed out is part of the repo forever. That's the point.

12Browse it visually

The CLI gets you everywhere, but for showing the result to a teammate (or to yourself, after a long session), the hosted browser UI is a much nicer way to see the graph. Run it from the repo:

$ morph serve Listening on http://127.0.0.1:8765

Open that URL. You'll see the commit graph laid out with metrics on every node, click into a commit to see its eval contract and evidence refs, click an evidence ref to see the run, click into the run to see the trace, click a trace event to see the prompt or tool call. Same data morph tap showed in the terminal — just easier to share.

Stop it with Ctrl-C. morph serve is read-only over the underlying repo; nothing it does mutates state.

13Where to next

You now have a project that's tracked end-to-end by Morph: every commit carries metrics, every agent session is a Run with a Trace, the working tree can be reverted cleanly, and the whole graph is browsable. From here:

  • Tutorial 2 — Adding Morph to an existing Git project: run Morph alongside Git as a behavioral safety net.
  • MERGE.md and EVAL-DRIVEN.md: behavioral merge gating, dominance, case provenance, and what happens when two branches genuinely conflict. This is where YAML acceptance suites (the empty_default_suite gap you saw throughout this tutorial) start earning their keep — they give the merge gate something more than raw numbers to compare against. (A standalone "Tutorial 3" page is on the roadmap; for now the authoritative writeup lives in those two docs.)
  • Eval-driven development: the full guide to spec-first workflows, suite evolution, and CI integration.
  • The theory: pipelines, certificate vectors, partial orders — the algebra under the CLI.