lap is a CLI that records fine-grained, human-interpretable edit history
for AI agents. It sits below git: agents record every small edit with its
intent and what it does; git keeps its normal human-scale history.
- A commit is one edit of one file. An edit is one contiguous run of changed lines (insertions, deletions, replacements). Two changed areas separated by at least one non-blank unchanged line are two edits and must be committed separately. Blank (whitespace-only) lines carry no identity, so a gap made only of them never splits an edit: the changed runs merge into one region that spans the gap, blank lines included.
- Every commit states its intent and its behavior, both required (§Messages). The intent says why the edit exists — the goal it serves. The behavior says what this edit makes the code do. Edits that serve one goal share an intent; each still describes its own behavior, so a hunk that cannot say what it does stands out.
- Commits belong to sessions. A session is a named group of commits with
a purpose ("fix the parser bug"). Committing requires an active session
unless
--no-sessionis passed explicitly. - lap never writes to tracked files. The working tree is authoritative; history is a recording. There is no checkout or staging. Branches (§Branches) are lines of history in other folders, never a switch of the working tree.
- Commit ids are short and sequential (
L1,L2, ...; sessionsS1,S2, ...). Integrity comes from a hash chain, not content-addressed ids. Every commit also has a hash (§The log), which is how one commit's text refers to another. - One folder, one line of history. Parallel work happens in other folders, as branches (§Branches). When work arrives from elsewhere (a merged pull request), it lands in the working tree like any other change and is committed edit by edit.
<root>/
.lapignore ignore patterns (tracked like any file)
.lap/
log/ TRUTH — the append-only record log, hash-chained, as
chunk files (§Chunks)
state.json cache: counters, active session, last chain hash
shadow/ cache: last-committed content of every tracked file
index cache: one fixed-width entry per record (§acceleration)
paths cache: path table; file_id = line number
heads cache: per-file chain tail + snapshot byte budget
snapshots/ cache: periodic full-content snapshots, per file
statcache cache: stat of every file status last found clean
lock exclusive lock file taken by writing commands
lineage a branch folder's id (§Branches): machine-local
parent a branch folder's parent path, for other tools (the
board): machine-local, a hint lap never reads
branches.json the branches started from this folder: machine-local
forgotten the branches forgotten here, one id a line: machine-local
- Commands find the repository by walking upward from the cwd (like git).
- The cache contract: everything in
.lap/exceptlog/and the four machine-local files below is a derived, disposable cache. Any of it may be deleted at any time; the next writing command (orlap rebuild) reconstructs it from the log. Machine-local state is not derived and not rebuilt, and never goes through git:lineagemakes the folder a branch — deleted, the folder takes itself formain(writers andrebuildthen warn when its chunks show it was a branch, naming the id to restore);parentis a hint for other tools, and a branch folder without it cannot be told from a copy or a clone of its parent's checkout;branches.jsonlists this folder's branches — deleted, they are known only by their chunks here, and their folders are no longer found or tended from here;forgottenkeeps forgotten branches off the list (§Registry) — deleted, they are listed again. Cache formats may change between versions with no migrations — an unrecognized or stale cache is rebuilt. Caches are native-endian and single-machine: transport a repo as itslog/(plus working tree) and rebuild on arrival. - Writing commands hold the lock for their whole run and keep every cache
in step; readers never lock and never write — with one exception, the
stat cache below, which
statusrefreshes. A reader that finds a cache missing or stale falls back to the log — correct, just slower — and heals counters in memory only. This holds per file: deleting any single cache must change speed only, never output. In particular a missing shadow entry is reconstructed by replay rather than reported as an untracked file, and an index whosepaths/headssidecars are missing or inconsistent is rejected whole rather than half-used. - Cache publication is ordered so a crash can only ever understate what is
cached: the index writes its entries and sidecars first and its header —
the
coveredfreshness marker readers trust — last, each step durable before the next.
- Index (
.lap/index): an 88-byte header then one 64-byte entry per log record — byte offset/length into the history (its chunks read as one stream, §Chunks), kind, op, numeric session,file_id, epoch timestamp, the region coordinates, a running commit counter (ids are dense integers, so findingL<n>is a binary search and per-record access is a seek), andprev_same_file: the entry index of the file's previous commit. Blame, per-file log, and replay walk that chain and never touch unrelated records.amendrecords are entries of their own kind: a reader decodes them once and gives each commit it fetches its latest text, as a full scan would. The header also keeps where the last record it covers is and that record's SHA-256: an index is used only while the history still holds that record there, so a history rewritten to the same size (a git merge, a hand edit) is read without it and rebuilt by the next writer. - Snapshots (
.lap/snapshots/<path>.jsonl): one JSON line per snapshot{"at": <record index>, "eof_nl": …, "content": "…"}. Policy (tunable, cache-only): a writer emits one after a commit when the file has accumulated more than 2× its current size in record bytes and at least 16 commits since the last snapshot. - The replay contract (normative): replay of a file to record k starts from the nearest available snapshot at or before k and applies the file's deltas after it; with no snapshot available it replays from birth. The results are identical either way — snapshots change speed, never outcome — so any reader may ignore snapshot containers it does not understand.
- Stat cache (
.lap/statcache): for each tracked filestatuslast found equal to its committed state, the file's size and mtime (with nanoseconds where the platform keeps them) and the index entry of the file's last commit.statusreads a file only when its size, mtime or last commit differs from its entry; with an index, it also finds deleted files from the index rather than by walking the shadow store.- The reader exception.
statusis a reader, and this is the one cache a reader writes: only when its entries changed, only under the writer lock taken with a non-blocking try (a held lock means no refresh, never a wait), and atomically (a temp file renamed over). Only files this run read and found clean are added. - Racy entries. A file is entered only when its mtime is older than the second the run started, so an edit that lands within the same timestamp tick as a check — same size, same mtime — is never taken for clean.
- Like every cache, deleting it changes speed only. It trusts size and mtime, as git's index does: content changed with both put back is not seen until the entry goes.
- The reader exception.
lap verify --deepaudits the layer: every shadow and every snapshot must equal a from-birth replay of the truth.- History is linear by construction in each folder: a branch is another folder's line (§Branches); within a folder, concurrency is serialized by the writer lock and expressed as interleaved sessions.
- Resolving a commit by hash (§References) scans the log's commit records; the index does not store hashes. A cache may be added for it later under the cache contract, without changing any output.
One JSON object per line. Field order is fixed at encode time. Every record
carries prev: the SHA-256 (lowercase hex) of the previous record's exact
bytes; the first record chains from 64 zeros. lap verify walks the chain.
A record's hash is the SHA-256 of its own exact bytes — the value the
next record carries as prev. It is never stored in the record itself.
A commit's hash is its stable name for references (§References); its
short hash is the first 7 hex digits.
Record types:
A commit, session_start or session_end that lap merge adopted carries
"from":"<hash of the branch's record>" (after forced, after meta,
after id respectively) and keeps the branch record's ts and user.
A commit record without both intent and behavior is malformed,
including one that carries a single msg in their place.
Amendments. An amend record corrects what a commit says, never what
it did: lap amend appends it, and nothing already written changes — a
rewritten record would change its hash and every hash after it, and with
them branches' bases, from links, #hash citations, chunks sealed under
git and the chain's evidence. of always names the commit (never an
earlier amendment); a commit amended twice has two records with the same
of, and the last in log order is the latest. Its intent,
behavior and forced replace the commit's wherever the commit is shown:
log, show, search (--msg matches the latest text), rr and every
JSON shape ("amended":<n> counts the amendments). show also lists the
earlier texts, oldest first, each with who wrote it and when (JSON
earlier, with amended_by and amended_ts for the latest). An amend
record is no history entry of its own: log, rr, search and the views
never list one. The commit → latest-text link is derived by readers, and
cached only under the cache contract (the index keeps amend records as
entries of their own kind); it is never written into the log. An amend
without of, intent or behavior is malformed. One that lap merge
carried from a branch has from, after of (§Merging).
Sessions keep msg: a session's purpose is already an intent.
user identifies the committer on commit and session_start records.
Resolution order: $LAP_USER (agents/orchestrators set this to tag their
identity, e.g. LAP_USER="claude/refactor-worker-2") > git config user.name (best-effort shell-out; git is NOT a dependency) >
$USER/$USERNAME > "unknown". Old logs without the field stay valid.
Region semantics: replace old lines [old_start, old_start+old_lines) with
new_text. Starts are 1-based; old_lines == 0 is a pure insertion before
old_start, new_lines == 0 a pure deletion. create records the whole
file as one insertion (empty history = one big change, like git); delete
records the entire removed content. untrack records no text at all
(every count 0, both arrays empty): lap stopped tracking the file, which
was not changed (§lap untrack). For what is tracked it means what a
delete means — the file leaves the tracked set, and a later create
starts it afresh — but nothing in it says the file is gone, and no lap
command ever touches a working file for it. new_start is a committed-file
coordinate: the committed state is the previous state with only this region
applied, so new_start == old_start by construction — never the
working-file position, which may be shifted by other still-pending edits.
Blame and hunk headers rely on this.
Timestamps are stored as UTC ISO-8601 (2026-09-20T12:34:56Z), and JSON
output carries them as stored. Human output shows them in the machine's
time zone, to the second, year first: 2026-09-20 14:34:56; lap show
adds the offset (2026-09-20 14:34:56 +02:00) so a shared copy still names
one moment. --since/--until take what the output shows: a date, or a
date and time (T or a space between, seconds optional), read as local
time; with Z or an offset (+02:00, -0400) it is taken as given. In
the hour that repeats when clocks go back, a local time means the earlier
of its two moments. Anything else is refused with bad_time. Bounds are
converted to UTC and compared as strings, which sort as times.
Records of a newer type. From this version on, a lap reads a history a
newer lap wrote as far as it understands it. A record whose type it does
not know is kept in the chain (its prev, ts and hash are checked like
any record's) and otherwise skipped, with one notice per command: "this
history has records of a newer type ("annotate") … Update lap to see them".
verify checks their chain and reports them (unknown_records,
unknown_type in JSON), for this folder's history and for each branch's
whose chunks are here. A writer refuses such a history
(newer_history) before it repairs, heals or writes anything: what those
records mean — a merge's, an amendment's — could make its write wrong.
lap merge (and its dry run) refuses the same way when the branch part
it would adopt holds them.
The rule protects the versions from this one on; an older lap refuses the
whole history, as it always did.
Version note. A history holding amend records needs the lap that
added lap amend, or a later one. A lap from the version above reads it
(commits show their original text) and writes nothing to it; an older one
refuses it whole.
The log is kept as chunk files in .lap/log/, read in order as one stream:
.lap/log/
main.000001.jsonl sealed
main.000002.jsonl sealed
main.000003.jsonl open: appends go here
- A chunk is named
<lineage>.<n>.jsonl: the lineage ismain(other lineages come with branches, §Branches), andncounts its chunks from 1, six digits, zero-padded. Other files in the directory are ignored. - A lineage's open chunk is its highest
n; every lower one is sealed and never written again. When an append would take the open chunk past 4 MB, the record starts chunkn + 1instead; a single record larger than that is a chunk of its own. 4 MB is a constant, not a setting, so every folder chunks alike. (LAP_TEST_CHUNK_BYTESoverrides it, for tests only.) Chunk numbers have six digits, so a lineage holds at most 999,999 chunks (about 4 TB); a write that would need the next one is refused. - Order is the hash chain. Chunks follow
n, and a chunk's first record carries the previous chunk's last hash asprev, so the chain runs on across chunks exactly as it would in one file. No manifest lists the chunks: there is nothing for git to conflict on. A missing number (chunks 1 and 3 but not 2) is refused, naming the missing chunk. Every command, reader or writer, refuses a history it cannot open whole — a chunk missing, a sealed one cut short, none at all — withhistory_broken(no_repois only for a folder with no.lap/above it). - Positions in the history — the index's offsets — are offsets into the chunks read as one stream. Only the open chunk grows, so a position never moves.
- Torn tails can occur only in the open chunk (§Concurrency).
lap verifywalks the chain across chunks and names positions by chunk and line. A break that points at a sealed chunk is reported as "sealed chunkmain.000002.jsonlwas modified": lap never writes a sealed chunk, so only a mistake (a bad conflict resolution, a repository-wide replace) can have changed one. At a chunk's first record the boundary alone cannot say which side changed, so itsprevdecides: a record inside the chunk before means that chunk was appended to (it is the one modified); the last record of another chunk means this chunk is a stray or a duplicate ("chunkmain.000006.jsonldoes not belong aftermain.000005.jsonl: it continuesmain.000002.jsonl"); anything else names both ("chunkmain.000003.jsonldoes not continue sealed chunkmain.000002.jsonl").- git tracks
.lap/log/. Appending changes one file, the open chunk; a sealed chunk never changes again. A chunk written whole (a branch's first, onelap mergecopies) goes through a temp file in.lap/and one rename, so.lap/log/never holds a temp file; one left there by an older lap is not a chunk to readers, and the next writer, holding the lock, removes it. - The single-file log of older versions. A folder holding
.lap/log.jsonland no main chunk is read as it is, and readers never change it. The first writing command splits it at record boundaries intomain.000001.jsonl, … at the limit, then removes it (a torn final line is dropped, as any writer drops one; an old file with no complete record becomes one emptymain.000001.jsonl, a history with nothing in it yet, never none). Records and hashes are unchanged, soverifypasses before and after, and git sees one deleted file and some new ones, once. The chunks are written into.lap/log.converting/and published aslog/with one rename (an existinglog/moved aside first); only then is the old file removed. So a reader finds the old file or all the chunks, never some — and a reader finding both reads the old file unless the chunks hold all of it, and reads it whole when it opens it, since a conversion may remove it meanwhile. When the chunks are not a start of the old file either, the two differ: readers say so in a note, andlap verifyfails naming both (history_errorin JSON), as a writer refuses them. A writer finding both finishes a split whose chunks are a prefix of the old file, removes an old file that is a prefix of the chunks when what follows it continues its chain (records appended since), splits it again when what follows breaks the chain and repeats its records (leftover chunks of a run under another chunk limit), and refuses anything else, keeping both.
A branch is a lap repository in another folder — a git worktree or a
plain copy — whose history is its parent's up to a base (the hash of
the parent's last record when the branch started), then its own. Its own
records form a lineage named by a 12-hex-digit id; the first folder's
lineage is main. A branch's chunks are <id>.<n>.jsonl beside its
parent's in .lap/log/, so two lines of history never write one file,
and a git merge of the branch's code brings its chunks as new files.
- A branch folder's history is the parent's chunks
1 … base_chunk, then its own. The branch record opens its own chunk 1: it names the branch, its parent lineage, the base and the parent's chunk the base ends, and chains from the base. The base is always the last record of a sealed parent chunk (below), so the history is whole chunks. - Which lineage a folder writes is
main, unless.lap/lineagenames a branch id. That file is machine-local, like the registry: after agit mergethe parent holds the branch's chunks too, and the chunks alone cannot say which lineage is the folder's own. So it never goes through git:lap init,lap branch startand the conversion of a single-file log write.lap/.gitignore(/*,!/.gitignore,!/log/, kept if one is there), leaving git only the history. At conversion it also brings the chunks back into git's view under a root.gitignoreof older docs (.lap/*,!.lap/log.jsonl); lap never runs git, so the conversion says to commit.lap/.gitignoreand.lap/log/with the old file's removal. Should alineagearrive anyway, it is ignored with a notice and the folder staysmainwhen it plainly came through git: its.lap/parentnames this very folder (compared as a directory on disk); or the parent holds the samelineagefile and its registry places the branch in another folder that still is it, or nowhere (a clone of the parent); or the parent is gone andmain's chunks here run past the branch's base, which a branch folder's never do. A branch folder, one moved away (before or afterlap branch move) and a plain copy (below) keep their lineage. - Ids go on from the base: the branch's next
LandSnumbers follow its parent's at the base, so one folder never shows an id twice. Two folders do — the parent goes on from the base too — so text that leaves a folder names commits by hash and sessions as<branch>/S<n>(§Naming sessions). - A branch starts with no session open, whatever its parent had open.
- A branch of a branch starts from a branch folder the same way: its
record's parent is that branch's lineage, and its base chunk one of
that branch's own chunks. A branch record holds one hop, so a nested
branch's history is found by following branch records one at a time up
to
main: main's chunks to the first branch's base chunk, that branch's own chunks to the next one's base chunk, and so on, then its own. A chain that leads back to itself (more than 32 hops) is a broken history. Every branch a history spans is labelled by its name.
The folder is made by whoever wants it — git worktree add, or a plain
copy of the parent folder; lap does not create folders or run git. Then, in
the new folder, lap branch start [name] --from <parent folder>, which,
holding the parent's lock throughout:
- History. A folder with no history gets the parent's. One that has a
history (a copy, or a worktree whose git commit carries
.lap/log/) must hold a prefix of the parent's, byte for byte, elseunrelated_history; it is brought up to the parent's head. - Files. Every file must equal the parent's committed state at the
base, as
lap statuswould find it there; elsenot_clean, listing apart the files the parent never recorded (git tracks them, lap never did: commit them in the parent, or add them to its.lapignore) and the files changed from what it recorded, with their usual causes: a worktree checked out from a git commit older than lap's history, and files lap tracks that git ignores. A file lap cannot read here (no permission, over 64 MB) is listed as unreadable, never taken for equal. - Sealing. The parent's open chunk is sealed (its next chunk created,
empty). The chunk this folder copies is then final on both sides, and a
later
git mergefinds it unchanged. An open chunk that is still empty is not sealed again: a second branch started before the parent appended anything shares the first one's base. - Registration. The branch is added to the parent's
branches.json(§Registry). A parent that cannot be written refuses the start (parent_read_only) before anything is written in this folder. - The branch record starts the folder's lineage, and
.lap/lineagenames it. A registry copied along with the folder is dropped.
A plain copy of a branch folder is not that branch. A copy (cp -R)
keeps .lap/lineage and .lap/parent; two folders appending to one
lineage would diverge. A folder is a copy when its .lap/parent's
registry places its lineage in another folder that is there and still that
branch. A branch start from a copy goes ahead as from any folder copied
from that branch (its copied lineage and parent replaced), and every
writer refuses in it (copied_branch, naming both folders) until then. A
branch moved away — its registered folder gone or no longer it, lap branch move not run yet — is that branch, not a copy.
The id is the first 12 hex digits of SHA-256 over the base, the name, the
time and a nonce, so two copies of one folder never make the same one. The
name is 1–64 letters, digits, ., _ or -, not main, unique among
the branches main can see (name_taken, below); it defaults to the id.
The recommended layout keeps the first folder quiet: agents work in branch folders, and the first folder only merges. A branch then always starts from a folder nobody is editing.
In a folder that is a branch, or that has branches registered, lap commit (dry runs included) and lap session start need --branch <name> — the branch's name or id, or main — or else branch_required.
A name that is not this folder's is wrong_branch, in any folder. Both
errors name this folder's branch, so the fix is in the message. The
environment variable LAP_BRANCH counts as the flag when the flag is not
given (the flag wins), so an orchestrator sets it once per agent and tools
that call lap need not pass it. A folder with no branches at all keeps its
commands unchanged, and ignores LAP_BRANCH whatever it names: a run that
sets it everywhere does not fail in plain repositories. An explicit
--branch is checked there too.
It costs a flag and catches the one mistake that corrupts a merge: an agent recording in a folder it thinks it is not in.
git merges the code; lap adopts the history. The order is always: commit
your own pending work, git merge the branch's code, then, in the parent
folder, lap merge <branch> [--dry-run].
-
Finding it. The branch is named by its name or id, found in the registry — this folder's, or the registry of one of its branches, for a branch of a branch — or among the branch chunks in
.lap/log/(git mergebrings them); none →branch_not_found. A branch folder merges the branches started from it, as main does. The chain is the branch and each branch it started from, up to the first whose history this folder already holds: the branch alone when it merges into the branch it started from; the branches between as well when a branch of a branch merges straight into main — git's merge of it brings their code up to its base, so lap adopts their history up to it too. A branch whose history this folder already holds (its own, or one it started from) →merge_in_branch. The outer branch record's base must be in this folder's history, elseunrelated_history. -
Its chunks: the ones here first. lap reads the branch's history from this folder's copies of its chunks, as they are:
git mergebrought them (or an earlier merge wrote them). Adoption runs as far as those copies reach, which under git is exactly the history whose codegit mergebrought, so history and files agree at every step; the nextgit mergeandlap mergeadopt the rest. lap never rewrites a copy git brought, so the nextgit mergefinds that file unchanged on this side and never conflicts on.lap/log/. Nothing in the branch folder is sealed or written. A chain is read as one history: this folder's up to the outer branch's base chunk, then each branch's own chunks up to the next one's base chunk, each branch's chunks found as below.The registered folder (reachable: it exists and its
.lap/lineagenames the branch) fills in only what is not here:- A git checkout (the branch folder has a
.gitentry, a directory or a worktree's file; lap never runs git): with no chunk of the branch here,lap mergerefuses withgit_merge_firstand writes nothing — rungit mergefirst. Taken from the folder, the history would run ahead of the code, and the nextgit mergewould conflict on the copies.--copy-from-foldertakes the folder's missing chunks anyway, for a user who wants the history before the code, knowingly — but never extends a chunk git brought: that copy is where the history stops (a note says the rest comes with the nextgit merge), so git still finds it unchanged on this side. - No git (plain copies): nothing brings the history by itself, so
lap takes from the folder every chunk missing here, and extends a copy
here that is a byte prefix of the folder's. History may then run ahead
of files the user has not copied back yet;
lap statusshows that difference.
Chunks taken from the folder are written here once every check has passed (a dry run writes none), so the history stays readable after the folder is gone. Only complete lines are taken: a line the branch is still writing waits for the next merge.
- A git checkout (the branch folder has a
-
Placing, file by file. A merge takes the branch's records after the last merge's
head(after the branch record, the first time); for a chain, after the latest head this folder took in of any of its branches — a branch the chain passes through that was merged here past the chain's base is taken in whole. The chain's commits are placed as one run, in history order, so a conflict in an outer branch's part stops the file for the branches after it too. For each file those commits touch, the three versions are the branch's version at that head (for the first merge, the file at the base), the parent's version now, and the commits. The parent's changes are the diff between the first two, lap's own (§Edit detection, effort cap included: a file the parent rewrote past the cap is one change, spanning the lines between its unchanged top and bottom; a branch commit to that span conflicts, and one above or below it is placed as usual). A commit whose region neither overlaps nor touches a parent change moves by the net lines the parent added or removed above it; each placed commit shifts the later ones. Overlapping or touching — both sides inserting at one point, or one at the edge of the other's change — is a conflict, and so is a commit whose old text is not the parent's text where it would land (compared blind to CRLF\r). The first conflict stops that file: none of its later commits are adopted, in this merge or later ones. Other files carry on.Whether the file exists is judged first, since an empty file and a missing one have the same (no) lines: a commit that edits a file the parent deleted is a conflict (
the parent deleted the file); a create of a file the parent has is already done when the lines (and the final newline) are the same, else a conflict (both sides create it); a delete of a file the parent deleted is already done.Identical changes are already done. A commit that overlaps exactly one parent change, of exactly its region and size, and lands where the parent's text already is its new text (and, when it reaches the file's last line, with the parent's final newline: a different one is a conflict) is not a conflict: the parent made the same change. Nothing is adopted for it and nothing stops. That change still counts as the parent's for the file's later commits: one that overlaps or touches it (the branch undoing its copy, say) is a conflict like any other, and the parent's text there is kept, as
git mergekeeps it. A file deleted on both sides, or created on both with the same lines, is this case. So is a change made in several steps: consecutive edits that stay inside one parent change and, taken together, leave the parent's text there are all already done, however the parent's side split it. Identical means the text, as lap's diff aligns it: a change one line different, or steps that stop short of the parent's text, is a conflict. A rename (delete one path, create another) is two changes, each judged by itself. -
Appending, under the lock, in the branch's order:
- a branch
session_startnot adopted before, as a new session with the nextSid, the same purpose and meta (so--meta ticket=T-12still finds the work) andfrom. Adopted before counts both ways work can arrive: straight from its branch, or by way of a branch that had adopted it (its ownfromnames one adopted here); its commits then belong to that session, and its end is not appended twice; - each placed commit, with the next
Lid, its region at the placed start, its session's adopted id, and the same text, intent, behavior,forcedanduser— the message checks are not run again; - a branch
session_end, for its adopted session; - a branch
amend, withofnaming the commit adopted here for the one it amends — found in one step through that commit'sfrom(or, when the branch had itself adopted the commit, through thefromof that) — andfromnaming the branch's amend record. An amendment of a commit not adopted here (a stopped file, a change already done here) stays in the branch and is counted as left; one already here, straight or by way of a branch between, is not appended again. Adopted records keep the branch'sts. They never change this folder's own active session: asession_startorsession_endwithfromis history, not state. A session still open at the branch's head stays open among the adopted ones until a later merge carries its end, and that merge appends to the session already adopted. The shadow takes the placed commits; the working tree is never written (core rule 4).
- a branch
-
The merge record closes the run: the branch, its head, the commits adopted and left, the files a conflict stopped with the first commit not adopted in each, and
already: the hashes of the commits already done here (left out when there are none), so the history says they were seen, not lost. A later merge of the branch starts afterheadand keeps every stopped file stopped: it leaves the file's new commits, reports the file again ("an earlier merge stopped it here") and records it in its own merge record, at the commit where it first stopped. A chain writes one merge record per branch with anything new, outer first, each with its own counts and stops: a branch the chain passed through advances to the base of the next, so its own later merge adopts only what came after — and a file stopped in an outer branch's part is stopped in the inner branches' records too, at that same commit, as a later merge records it. A run cut part-way reads the merge records it wrote before the cut as its own, not as earlier merges, and does not write them again, so running it again records what the whole run would.
Work that came by another route. A branch's work can reach this folder
through a branch that had merged it (b2 merged into b1, b1 into main, then
b2 straight into main, or the other way round). A merge record in an
adopted branch's history, up to the head adopted here, counts as a merge
made here — its head and its stops — and from links are followed through
every copy (a copy of a copy leads to the original). So a commit, session,
session end or amendment whose original is already here, by any route, is
never adopted again.
What is left. The working tree holds what git merge made; the shadow
holds this history plus what was adopted. The difference — conflict
resolutions, stopped files, anything git did that lap could not place —
shows in lap status and is committed as usual, with a behavior that cites
the branch commits it stands for (#9f3e21a). When everything was adopted
and git merged cleanly, there is no difference.
--dry-run reports what would be adopted, what is already done and where
each file would stop, reading exactly what the merge would read (rule 2),
and writes nothing.
A branch and its parent both go on counting sessions from the base, and an
adopted session gets a new id in the parent, so a bare S<n> is ambiguous
once it leaves its folder — in a ticket comment, say. <branch>/S<n>
(the branch's name or id) names a session of that branch:
- Every command that takes a session (
lap rr <session>,lap log --session,lap search --session) accepts it, reading that branch as--branchdoes — from any folder it descends from, so a branch of a branch is named the same way from main. A bareS<n>still means this folder's own. - Once
lap mergeadopted that session into this folder, the name leads to the adopted session here, found by itsfromlink:lap rr feat/S4in the parent reviews the adopted one, labelledS9 (adopted from feat/S4), its commits showing theirfrom. An old comment written in the branch still finds the work. - lap prints sessions in that form wherever they are a branch's: in a
branch folder (
session feat/S4 startedandended,session current,session list,status, whatcommitand its dry run print, log and search rows,show), and for another branch's history read with--branch. JSON carries it asref(sessions) andsession_ref(commits);idandsessionstay the bare ids. - A name that is no branch here is
unknown_branch; a session the branch does not have isunknown_session— including one from before its base, which its history holds but which is its parent's:<branch>/S<n>names only the branch's own sessions, after its branch record. - A branch name names one branch for good:
lap branch startrefuses a name any branch main can see ever had (name_taken). It goes up from the parent through each folder's.lap/parentto the top, and in each folder looks in the registry and those of its branches, nested ones included, among the branch chunks there, and in its merge records — so a name in an old ticket comment never comes to mean another branch, and a nested branch never shares a name with one main reads. - Two branches that share a name all the same (given before this check
covered nested branches) are named by their ids: a reader given the name
is
ambiguous_branch, its message listing the ids, never one picked.
lap log, lap show, lap rr and lap session list take --branch <name> to read another
branch's history instead of this folder's: a registered branch from its
folder when that is reachable, else from its chunks here (a git merge
brought them, or lap merge copied them). In a branch folder, --branch main reads the parent's history up to the base. Naming this folder's own
branch changes nothing; a name found nowhere is unknown_branch, and one
two branches share is ambiguous_branch (as for lap merge). It is a
reader's flag: LAP_BRANCH does not set it.
lap show <hash> without --branch also looks, when this history lacks
the hash, in every branch whose chunks are here, so an adopted commit's
from link opens its original. Commits in JSON output carry branch: the
branch whose chunk holds the record (main, or the branch's name); lap show prints a branch: line for a commit that is not main's. An
adopted commit carries its from in JSON, a from: line in lap show,
and (from #<short hash>) after its behavior in lap rr's trajectory.
The parent's .lap/branches.json lists the branches started from it:
[{"id","name","path","base","started"}], the path absolute and canonical (symlinks resolved, one spelling however it was typed). It is
machine-local (not committed: a path means nothing elsewhere), neither
history nor a cache — nothing rebuilds it — and hints only: a missing
or malformed registry reads as no branches, and no command fails because
of what it says. The one error about a branch's whereabouts is lap merge
finding its history nowhere.
- Checked, never trusted. An entry's folder counts only while it
exists and its
.lap/lineagenames that branch; a folder moved away, or reused for something else, is not the branch. - States.
merged: a merge adopted it up to its head, with no file stopped.partly merged: a merge stopped a file.missing: its folder is gone and it was not merged up to its head.active: anything else. - Merged and gone → dropped silently by the next
lap branch start,move,forgetorlap merge, or by a write that would otherwise be refused asbranch_required, so a folder whose branches are all merged and gone records as before. No other write does it: they must not pay for reading every branch's history (gone: its folder is missing, or holds another branch now): its history is in this folder's chunks, nothing is lost. Everything else stays — an unmerged or stopped branch whose folder is gone is shown asmissinguntillap branch forgetdrops it orlap branch movepoints it at the folder's new place. - Branches of branches. A branch folder has its own registry, of the
branches started from it. Readers here (
branch list,merge,--branch,<branch>/S<n>) also read the registry of each listed branch whose folder is still that branch, and so on down; nothing is ever copied between registries.lap branch listshows a nested branch indented under the branch it started from, withvia(that branch's id) in--json. It is merged, as a folder lists it, when its head was adopted into that folder or into the branch it started from: of its state in either history, the one that went further is shown. So one merged straight into main ismergedin main's list, and, once its folder is gone, drops out of it without a word, as a registered branch of main's own is pruned; the branch between still lists it until it adopts that work. It is tended (forget,move) in that branch's folder. - Known by its chunks only. A branch whose chunks are in
.lap/log/but which no registry lists — brought bygit mergefrom a clone that started it, or dropped once merged and gone — is listed too, after the registered ones, from its branch record:registered: false, no path, nevermissing(no folder of it is known here; one not merged isactive, and one whose merge stopped a filepartly merged), andviaits parent when that is another branch listed here. Its sessions read with--branch, and the sessions adopted from it keep its name. - Forgotten.
lap branch forgetof a branch with chunks here also writes its id to.lap/forgotten, machine-local like the registry, and takes a branch known only by its chunks. The text list then leaves it out, naming how many it left out;--allshows them, marked. The JSON always holds them, withforgotten: true, so what reads tickets' sessions is unchanged. Its chunks are never touched:log,rrand--branchread it as before.
- Bringing a parent's newer work into a branch (merging the other way): merge the branch into its parent, then start a fresh one.
- Creating or deleting folders, or running git: lap records; git and the user move code.
- Adopting across unrelated histories, or merging two branches with each other without their common parent.
- Retrying a stopped file (
lap merge --resume <file>) once the parent has resolved the conflict: a stopped file stays stopped for its branch.
The chunk limit stays a constant (4 MB) so every folder chunks alike; it is revisited only with evidence.
A commit's text is two fields, each a non-empty string, multiline welcome, trailing whitespace trimmed. The first line of each is its summary.
- intent — why the edit exists. Written as the goal, not the mechanics: "Route the graph place to its view."
- behavior — what this edit makes the code do: "Render Graph when the target place is graph; every other place still gets Collections." For a supporting edit, behavior says what it supports: "Import Graph for the graph route in #fa9cebd."
lap commit refuses a message that fails any of these, with the error code
shown, before anything is written. Words are counted after lowercasing and
splitting on every character that is not a letter or a digit (bytes ≥ 0x80
count as letters, so non-ASCII words stay whole).
- Length (
message_too_short): intent and behavior each have at least 3 words. - Behavior is not the intent (
behavior_repeats_intent): the word-set Jaccard similarity of behavior and intent is below 0.8. - Behavior is not the previous behavior (
behavior_repeats_previous): the Jaccard similarity of behavior and the behavior of the most recent commit in the same session is below 0.8. Skipped when there is none, and under--no-session. - Behavior is not the code (
behavior_restates_code): the Jaccard similarity of behavior and the words of the edit's changed lines (itsnew_text, or itsold_textfor a pure deletion) is below 0.8.
Intent may repeat freely: it is shared by design.
A whole-file delete skips checks 2–4: deleting many files for one reason
says the same of each, rightly. Its behavior may be left out (no -b),
and is then Deletes <file>..
--force-message skips checks 2–4, never check 1, for the rare honest
near-duplicate. The commit record then carries "forced":true, and show
and rr mark it, so a reviewer sees which messages bypassed the checks.
The thresholds are constants, chosen against this repository's own history (§Unresolved).
A commit's text refers to another commit by its hash, written as #
followed by 7 to 64 hex digits (#fa9cebd), in any case. lap does not
parse or validate references when committing: they are plain text, and the
words around them ("needs", "fixes", "reverts") are the author's. They are
for readers — an agent following the chain with lap show, or a UI that
turns each one into a link.
Wherever a command takes a commit, it accepts an id (L42), a hash, or a
hash prefix of at least 7 hex digits, with or without the #. A prefix
that matches more than one commit is refused with ambiguous_ref, listing
the matches; one that matches none is unknown_ref. Only commit records
are addressable by hash.
- Diff runs between the shadow copy (last-committed state) and the working file: common prefix/suffix trim, then Myers O(ND) on line hashes with content verification.
- Effort cap: past 1024 edit steps the changed span collapses into a single region (a rewrite that large is one big change; the record marks nothing special, it is simply one region).
- Region merging: after detection, adjacent regions whose separating
unchanged gap consists only of blank lines (empty, or spaces/tabs/
\r) merge into one region spanning the gap. Rationale: diff alignment on content-free lines is coincidence, and it fragmented whole-block rewrites into artificial edit sequences. - A change only in the trailing-newline state of the last line is a 1-line edit on that line.
- lap reads CRLF as LF, as git stores text: a working file's lines lose the
\rof a CRLF ending before they are compared or recorded, so new commits hold LF, and a trailing\ron a shadow line is ignored when comparing. A checkout whose line endings git converted (Windows,core.autocrlf) therefore shows the same edits as one that was not, and a change of line endings alone is no change. lap does not read.gitattributes. The shadow is still exactly what the log replays to: lines recorded with a\rby an older lap keep it, andverify --deepcompares bytes. A\ron a last line that no\nends is content, not a line ending. - Files with a NUL byte in the first 8 KB are binary: shown in status once tracked, refused by commit.
- Files larger than 64 MB are refused. Symlinks are skipped entirely.
Every command accepts --json for machine-readable output on stdout.
Exit codes: 0 success, 1 user/repo error, 2 internal failure.
JSON errors are {"ok":false,"error":"<code>","message":"..."}.
Every JSON object that describes a commit carries its full hash, and every
human listing of commits prints the short hash beside the id
(L42 fa9cebd).
Argument parsing: flags that take values consume the next word entirely, so
a message like -i "--no-session" is never misread as a flag; a literal
-- ends flag parsing, letting file names that start with - be committed
(lap commit -i "..." -b "..." -- -weird.txt). A word before -- that
starts with - and is not one of the command's flags (or lap's own colour
flags) is refused with unknown_flag, naming it; nothing is run. A
skipped flag would let its value pass for an argument — -x "text" f.c
would name a file text. A long flag that takes a value also takes it
joined, --branch=feat for --branch feat (short ones only apart:
-i "..."); a value-taking flag that ends the command line, or is given an
empty value (--branch=, -n ""), is refused with usage (" needs
a value"), naming it: an empty value is never read as none given.
Help: lap --help lists every command and subcommand with its synopsis.
lap <command> --help, -h anywhere before -- (never as a flag's value)
and lap help <command> [<subcommand>] print that command's synopsis, what
it does and every flag it takes, one line each, to stdout, exit 0, and
run nothing — no repository is needed. A group (session, branch) answers
for the subcommand named on the line, else for itself with its subcommands
listed. The help, each command's usage error and the flags each command
accepts are read from one table (src/help.c); a flag the parser takes is
in its command's help by construction, and the unit and e2e suites check it.
--color=auto|always|never and --no-color are lap's own flags rather
than any command's, and may appear on either side of the command name; the
last one given wins. They are spelled with = so that the mode is part of
the flag's own word and can never be mistaken for a positional argument;
every command's parser accepts them. The scan that finds them applies the
same rule every command does: a value-taking flag's value is data, so
lap commit -i "--color=always" records that intent and changes nothing
about the display.
Colour is bound by one contract: it never changes what the output says, only how it looks. Concretely —
auto(the default) styles a stream only when that stream is an interactive terminal. stdout and stderr are decided independently, solap log | lessstill shows a human a rederror:.- A non-empty
NO_COLORdisables colour unconditionally, overriding even--color=always: it is the environment's kill switch, and honouring it is what lets a caller guarantee escape-free output. TERM=dumbdisables colour inauto.--jsonoutput is never styled, in any mode. This is enforced where the mode is resolved rather than in each command's JSON branch, so a styled helper reached from a JSON path emits nothing instead of depending on every branch to remember.- Removing the escape sequences from styled output yields the unstyled output byte for byte. Nothing is padded, truncated, or re-ordered for colour, and every aligned column is padded by visible width rather than by byte count.
Creates .lap/ in the cwd plus a starter .lapignore (kept if present):
*.o, *.tmp and .coboard/, the board's own history.
Active session, then every file with pending changes: new (line count),
modified (numbered edit list with line ranges), deleted, or binary.
A file lap cannot read — no permission on it, or on a folder above it — is
unreadable, never deleted or clean, and so is a folder it cannot open
(listed as <folder>/, since what is in it is unknown). Only a path that
is not there is deleted. A tracked file is one whose last commit is not a delete or an untrack, as the
index says or, without it (a fresh clone's .lap/ holds only log/), as
the history says: the output is the same with every cache or none.
A folder holding its own .lap/ is another repository, as git skips a
nested repository: a branch folder put inside this one (git worktree add sub, a tool's .claude/worktrees/), or an unrelated lap repository. The
walk of the working tree (status, a branch start's check) does not enter
it, and lap commit refuses a path in it as outside this repository. It
is the downward half of the rule every command follows upward: the nearest
.lap/ owns a file. Files this repository recorded there before that
folder got its own .lap/ stay tracked, as files ignored after they were
recorded do.
A tracked file .lapignore now names is still tracked (§.lapignore),
which is easy to miss, so status lists each such file still on disk under
tracked but ignored by .lapignore, with the command that stops tracking
it (lap untrack <path>). Only tracked files the walk did not see are
candidates, so a tree without any costs nothing. --json gives them as
"tracked_ignored":[…], empty when there are none.
lap commit <file> (-i "intent" -b "behavior" | -F <file|->) [--edit N | --lines A-B | --whole-file] [--force-message] [--no-session] [--dry-run] [--branch B]
Records exactly one edit. The message comes from -i/--intent and
-b/--behavior together, or from -F, never a mix:
-F <file>reads both fields from a file (-F -reads stdin — the reliable path for long, multiline text that would fight shell quoting). The file holds two sections, each opened by a line that is exactlyIntent:orBehavior:(any order, each once); a section's text is the lines up to the next header or the end. Text before the first header, a missing or repeated section, or an empty one is refused withbad_message_file.- A missing field is
missing_intentormissing_behavior; only a deleted file may leave out-b(§Checks). - The message then passes the checks in §Messages, or the commit is refused.
Then, by the number of pending edits in the file:
- 0 → error
no_changes. - 1 → committed.
- >1 → error
multiple_editslisting numbered regions; retry with--edit N(pick from the list) or--lines A-B.--linesnames one region whole by its range as shown bylap status(current-file lines, or last-committed lines for pure deletions), or any part of a pure insertion (a region that adds lines and replaces none): lines A–B, all inside it, committed on their own at the place the whole insertion would take. Parts can be taken in any number and any order. A range that runs past an insertion's edge, or touches a region that replaces lines (which old lines would go with a part of the new?), is refused withlines_mismatch, naming the edit. Remaining edits stay pending and are re-detected (with fresh coordinates) on the next run: a part taken from the middle of an insertion leaves two. - New file (lap has never recorded it) →
create:- Without flags, its whole content — up to 50 lines
(
LAP_LARGE_CREATE_LINES). A longer one is refused withlarge_create("f.c is a new file of 312 lines: commit it in parts with --lines A-B, or pass --whole-file");--whole-filecommits it whole, for files that are one piece (generated files, fixtures, data).--whole-fileanywhere else — with--lines/--edit, or on a file that is not new — is refused withusage. - With
--lines A-B, just lines A–B, any range inside the file (bad_linesotherwise); the create's text is exactly those lines, ending with a newline unless B is the file's last line, where it takes the file's own final-newline state. From then on the file is known: the rest shows as ordinary pending edits against that part (lines above, lines below; blank gap lines join their neighbouring edit), and--linesthere has its usual meaning: any part of them, in any order. --edit Nis refused withbad_edit_index: a new file has no pending edits to number.
- Without flags, its whole content — up to 50 lines
(
- Deleted file →
delete, the file whole:--lines(bad_lines) and--edit(bad_edit_index) are refused. Nothing is written when a flag is refused — never ignored, which would record the whole file under a message meant for part of it. - A file lap cannot read (as for
lap status) → errorunreadable: it is never recorded as deleted, nor compared as unchanged.
On success it prints the new commit's id and short hash (L42 fa9cebd);
--json returns the id and the full hash. This is how an agent learns
the hash to cite in its next commit.
--dry-run does all of the above except write: it resolves the file and
the edit, runs the message checks, and prints what would be recorded — id,
session, op, file, region, intent and behavior — failing exactly as the
commit would. --json returns {"ok":true,"dry_run":true,"record":{…}},
the record as it would be written without prev (and so without a hash,
which neither exists until it is written). A dry run is a reader: it takes
no lock and repairs nothing (no torn-tail cut, no state rewrite), and its
predicted id is the one a commit made next would get.
Corrects what a commit says (§The log → Amendments): appends an amend
record, and changes nothing already written. The code is never touched —
a wrong edit is fixed by a new commit. <commit> is an id, a hash or a
hash prefix (§References). The message is given as for lap commit: both
fields, always (repeat one to keep it), from -i/-b or -F, and it
passes the same checks (§Messages) — behavior_repeats_previous against
the commit before it in its session, behavior_restates_code against the
commit's own changed lines. --branch (or LAP_BRANCH) is required where
branches exist, as for commits. Needs no session.
Only commits of this folder's own line of history can be amended: in
a branch folder, a commit from before its base is its parent's
(not_own_commit: amend it there). Also refused: not_a_commit (the
reference names another record: a hash of a session's start or an
amendment, or S<n>, naming the record's type), same_message (the commit already says
that), unknown_ref/ambiguous_ref. Prints [L42 fa9cebd] amended (<n>)
and the new intent's first line; --json returns the commit's id,
hash and amended (the count).
Stops tracking files lap recorded, as git rm --cached does, without
starting a new history: the way out for a file recorded before
.lapignore named it. A path is a tracked file, or a folder (the
repository's root included), which means every tracked file under it;
patterns are the shell's. Each file gets one commit, op untrack, in the
active session (or with --no-session outside any), with the intent
given and the behavior "lap stops tracking this file; it stays on disk
and its history stays readable". The file's shadow copy goes; the file
itself is never touched, here or wherever the record is replayed (a
clone, a lap rebuild, a branch's lap merge), which is where it parts
from git, whose removal deletes the file for everyone who pulls it.
Everything is checked before anything is written, and one refusal writes
nothing: not_tracked (a path that is no tracked file and holds none),
pending_edits (a file with edits lap has not recorded — changed,
deleted or unreadable — which would leave lap unseen; --force untracks
it anyway, and the edits stay on disk), usage (no path, or no intent),
no_session. A file .lapignore does not name will show in lap status
as new; untrack says so (--json: "not_ignored":[…]). Prints
[L42 fa9cebd] S3 <file>: untracked "<intent>" per file; --json
returns {"ok":true,"session":…,"untracked":[{"id","hash","file"}], "not_ignored":[…]}.
Earlier history stays readable: lap log --file, lap show and
lap search --msg find the file's commits, and lap show of the untrack
says it has no diff. Blame (search --line) answers for a file's current
lines, and an untracked file has none. To track the file again, take it
out of .lapignore (or never put it there): it shows as new, and
committing it records a create. A branch's untrack is adopted by
lap merge whatever this folder's lines are, and is already done where
this folder no longer tracks the file.
Commits newest-first: id, short hash, timestamp, session, op, file, range;
under each, the first line of its intent and of its behavior, labelled
intent: and behavior: (an amended commit shows its latest texts, the
amend marker after the intent). --intent-only or --behavior-only shows
that one alone, unlabelled; both together are bad_args. --json always
carries both texts in full.
Full record: metadata with the full hash, the complete intent and behavior,
forced when set, for an amended commit how often and last when and by
whom with its earlier texts after the behavior, and a unified-diff-style
hunk.
<commit> is an id, a hash or a hash prefix (§References).
--full-file additionally reconstructs the whole file as of that commit by
replaying its history.
Asks the history questions; criteria AND together:
--file F --line N— which commit introduced/last touched current line N (blame). Pending lines are reported as pending. Line numbers are traced back through both pending edits and every commit's line shifts.--text STR [--added|--removed]— commits whose changed lines contain STR.--msg STR— substring of the intent or the behavior.--session S,--since TS,--until TS,--limit N.
lap session [start "purpose" | end [--done/--decided/--left "…" | -F <file|->] | list | current] [--meta key=value]...
One active session at a time; start requires a purpose, given as its
argument or read with -F <file|-> (the whole text); a crashed
session simply stays open. list shows every session with commit counts.
start takes --branch where branches exist (§Branches → Committing: say
where).
--meta key=value (repeatable) on start tags the session with metadata,
stored as a flat object on the session_start record — always present, {}
when there is none. Keys are identifiers ([A-Za-z_][A-Za-z0-9_]*); a key
given twice is refused. A value is written as a JSON number when it is one
(n=1 → "n":1), as true/false when it is one of those, and as a string
otherwise (ticket=T-12 → "ticket":"T-12"). list --meta key=value keeps only the sessions
carrying every given pair, and list --json / current --json always carry
a meta object. This is how other tools link work to a session: coboard
starts sessions with --meta ticket=T-12 and finds a ticket's sessions with
lap session list --meta ticket=T-12.
The end summary. end may say how the session went, in three parts,
each optional — ending with none is fine, and is written as before:
--done "…"— what the session actually achieved (the purpose atstartwas the plan; this is the outcome);--decided "…"— choices made along the way that someone may question later, with the reason: what no single commit shows;--left "…"— what is unfinished, known broken, or deliberately not done.
Or -F <file|-> with Done:, Decided: and Left: sections, as a
commit's -F has Intent: and Behavior: (§Messages): a header is the
whole line, sections come in any order, each trimmed; text before the first
header, a section named twice, an empty one, or a file with none is
refused (bad_message_file), and so are the flags together with -F, or a
blank flag (usage). The parts go on the session_end record as done,
decided and left, each present only when given. There is no free-form
summary. Only one session is active at a time and end closes it, so a
summary always belongs to the session just before it. lap merge carries a
branch session's summary onto the adopted session's end.
list --json and rr --json give each session its summary:
{"done":…,"decided":…,"left":…} with null for a part not given, or
null when the session is open or ended without one. rr prints the parts,
labelled, under its heading and before the trajectory.
list --json gives each session its hash (its session_start record's)
and, for one lap merge adopted, its from. list --branch <name> lists
another branch's sessions (§Branches → Reading another branch); there a
session is active when it is still open. Together they let a tool follow
a ticket's work into its branches and see each piece once: a branch
session whose hash is an adopted session's from was adopted.
A review request: what a run of work changed, and why. Two halves — the trajectory (every commit in the order the work happened) and the net change (each touched file replayed to just before the range and again at its end, then diffed). Edits that cancelled out show as no net change; ten commits to one function show as one coherent change.
In the human trajectory, consecutive commits with the same intent print under one intent heading, each followed by its own behavior. The JSON trajectory stays one entry per commit, in order, and leaves grouping to the reader.
The target is a session, an inclusive commit range (<from> and <to>
are commits, §References), or — with no argument — the most recent
session. Targets are given only as arguments; there are no --session,
--from or --to flags. --no-diff keeps the per-file summary and drops
the hunks, in both the human and JSON shapes. Read-only.
Walks the hash chain across every chunk, naming a modified sealed chunk
(§Chunks), then the chain of every other branch with any chunk here (one
whose first chunk is missing is broken, naming it, never left out), one
line each (branch b: chain ok: 8 records; in JSON, branches: [{branch, records, chain_ok, chain_error?, unknown_records?, unknown_type?}]). A broken one fails the command. --deep also replays
every file's history from birth and compares the result byte-for-byte with
the shadow store and every snapshot. Verification never uses the caches it
is checking: the index leads the replay (one file at a time) only after one
pass over the history finds it as a rebuild would make it — each record's
entry, each commit's file and the file's commit before it, each file's
last. An index that differs is a mismatch of its own (index: …; in JSON,
.lap/index among mismatched_files), and the whole history is replayed
instead. --json: {ok, records, chain_ok, chain_error?, history_error?, torn_tail_bytes?, unknown_records?, unknown_type?, branches}, and with
--deep also deep_checked (the files and snapshots compared),
deep_mismatched (how many differ) and mismatched_files (their paths).
Makes the current folder a branch of <folder> (§Branches → Starting one),
which may itself be a branch folder. Prints the name, the id, the parent
and the base's short hash; --json returns id, name, parent,
base, base_chunk and parent_file: false when .lap/parent (the hint
the board follows to the parent's board) could not be written, which the
text output also warns of — the start itself stands, lap never reads it.
Errors: missing_from, bad_name, same_folder, already_branch,
has_branches, no_parent, unrelated_history, not_clean,
name_taken, parent_read_only, and whatever opening the parent refuses
with keeps its own code (newer_history, history_broken; no_parent only
when no lap repository is there); already_branch is not given for a
plain copy of a branch folder, which starts a branch (above). Any writer in
such a copy is copied_branch. has_branches: this folder's registry
lists a live branch of it (its folder is that branch and its .lap/parent
names this folder) — a parent cannot become a branch, above all of its own
child; a registry that came along with a copied folder names another
folder's branches and is dropped as before. same_folder is the folder itself under
any spelling — another case, a symlink, a .. path — told by the
directory on disk (device and inode; volume and file id on Windows), not by
the path's text. The start holds this folder's lock throughout (making
.lap/ for it, and removing it again when the start is refused) and the
parent's from its checks to its registration, so of two starts in one
folder exactly one succeeds and the other is already_branch.
list shows, in a branch folder, its own name, id, parent and base, then
each branch this folder started (§Registry), and under each the branches
started from it, indented: its name, state and last known path ((gone)
when its folder is not there), its commits since its base and since the
last merge, the files a merge stopped, and for a missing one the two
fixes. --json: {"ok":true,"self":{id, name, parent, base} | null,"branches":[{id, name, state, present, path, registered, base, started, since_base, since_merge, merged, stopped, stops, via}]}, registered
false (and path empty) for a branch known here only by its chunks, the counts
null when the branch's history is nowhere to be read, merged the last
merged head or null, stopped the stopped files, stops the same with
the first commit not adopted in each ([{file, at}]), and via the id of
the branch a nested one started from (null for this folder's own).
list reads each branch's own chunks and not what it holds of its parent,
so its cost follows what the branches recorded, not how long the project's
history is.
lap session list --all-branches (with --json, not with --branch, and
--meta as usual) answers {ok, branches: [{id, sessions, error?}]}, one
entry for every branch list can show. Each holds only that branch's own
sessions, read from its own chunks: what it holds of its parent is the
parent's to list. A branch that cannot be read has error, and the others
are answered.
forget drops an entry; move points it at a folder that holds that
branch (else not_that_branch). Both are writers; an entry not in the
registry is unknown_branch, but for forget of a branch known by its
chunks here (§Registry).
Adopts a branch's history into this folder's (§Branches → Merging).
--copy-from-folder takes a git checkout's missing chunks from its folder
instead of refusing with git_merge_first; a chunk git brought is never
rewritten, and what the folder has past it waits for the next git merge. Prints
what was adopted of how many commits, with the new ids, then each stopped
file with the first commit not adopted and why, then each commit already
done here, then how many of the branch's amendments were carried and how
many stayed in the branch (a dry run does not count them); no commits to adopt when only sessions or amendments came; nothing new to adopt when the branch has nothing after the
last merge. A branch never merged that has nothing after its branch record
(no commits, no sessions) is nothing to adopt: it has no commits, and its
merge is recorded all the same (adopted 0), so it counts as merged and is
pruned from the registry once its folder is gone. --json returns dry_run, branch, name, new, adopted,
left, head, stopped ([{file, at, why}]), already (hashes),
amendments ({carried, left}) and
commits ([{id, from}]: the ids given to adopted commits, so empty in a
dry run, which gives none; adopted counts them either way). Errors:
branch_not_found, ambiguous_branch, merge_in_branch,
unrelated_history, history_broken (the branch's history is broken or
lacks what the merge needs), newer_history, git_merge_first,
log_unreadable, io_error, and the open's own codes (§Error codes).
Deletes and reconstructs every derived cache from the log — the executable
proof of the cache contract. --verify additionally fails when the hash
chain is broken. Run it after transporting a bare log, deleting caches, or
upgrading across a cache-format change.
Every error lap reports, in JSON as {"ok":false,"error":"<code>","message":…}
and in text as the message. The commands named are where each is given;
"any" is every command that opens the repository. A code marked
internal means a write or read failed underneath (a full disk, a file
lap cannot write): the message says which file.
| code | commands | meaning |
|---|---|---|
already_branch |
branch start | this folder is already a branch |
ambiguous_branch |
readers with --branch, merge |
a name two branches share; give the id |
ambiguous_ref |
show, rr, amend | a hash prefix matches more than one commit (or record, for amend) |
append_failed |
commit, session, amend | internal: the record could not be appended |
bad_args |
branch | arguments that do not fit together |
bad_color |
any | --color= other than auto, always, never |
bad_edit_index |
commit | --edit N names no edit, or the file is new or deleted (no pending edits to number) |
bad_line |
search | --line is not a line of the file |
bad_lineage |
branch start | .lap/lineage does not hold a branch id |
bad_lines |
commit | --lines A-B is malformed, lies outside a new file, or is given for a deleted file |
bad_message_file |
commit, session | a -F file that is not the two sections |
bad_meta |
session start | a --meta that is not key=value |
bad_name |
branch start | a branch name outside the allowed letters, or main |
bad_path |
commit, log, search, branch | a path outside this repository (or in a nested one), or not a file |
bad_time |
search, log | a time lap cannot read |
behavior_repeats_intent |
commit, amend | the behavior is the intent again |
behavior_repeats_previous |
commit, amend | the behavior is the session's previous one again |
behavior_restates_code |
commit, amend | the behavior is the changed lines' words |
binary |
commit | the file looks binary |
branch_not_found |
merge | no branch of that name or id here |
branch_required |
commit, session, amend | this folder has branches or is one: say --branch |
chain_broken |
rebuild --verify |
the hash chain is broken |
copied_branch |
any writer | this folder is a plain copy of a branch folder |
empty_range |
rr | the range holds no commit |
git_merge_first |
merge | the branch's history has not come through git yet |
has_branches |
branch start | this folder has live branches of its own |
history_broken |
any, merge | a chunk missing, cut, emptied or changed, a chain broken, or two shapes that differ |
ignored |
commit | the file is ignored by .lapignore |
init_failed |
init | internal: .lap/ could not be made |
internal |
init | internal: an unexpected failure |
io_error |
branch, merge | internal: a file lap needed to write |
large_create |
commit | a new file of more than 50 lines committed whole without --whole-file |
lines_mismatch |
commit | the range names no edit, runs past an insertion's edge, or touches a replacement without naming it whole |
log_unreadable |
most readers, merge, rebuild | the history could not be read or parsed |
merge_in_branch |
merge | the merge target is itself a branch that must merge first |
message_required |
session start | no purpose given |
message_too_short |
commit, amend | intent or behavior under three words |
missing_behavior |
commit, amend | no -b (on commit, for a file that is not deleted) |
missing_from |
branch start | no --from |
missing_intent |
commit, amend | no -i |
name_taken |
branch start | the name is, or was, a branch main can see |
newer_history |
any writer, merge, branch start | records of a type a newer lap wrote |
no_active_session |
session end | no session to end |
no_changes |
commit | the file has no pending edit |
no_parent |
branch start | no lap repository at --from |
no_repo |
any | not inside a lap repository |
no_session |
commit | no active session and no --no-session |
no_target |
rr | nothing to review |
not_a_commit |
amend | the reference names another record |
not_clean |
branch start | this folder's files are not the parent's committed state |
not_found |
search | nothing matches |
not_own_commit |
amend | the commit is from before this branch started |
not_that_branch |
branch move | the folder given is not that branch |
parent_read_only |
branch start | the parent cannot be written |
read_failed |
commit, search | internal: a file could not be read |
rebuild_failed |
rebuild | internal: a cache could not be rebuilt |
replay_failed |
show | internal: a file's state could not be replayed |
same_folder |
branch start | --from is this folder itself |
same_message |
amend | the commit already says that |
session_active |
session start | a session is already active |
shadow_failed |
commit | internal: the shadow copy could not be written |
state_failed |
commit, amend | internal: state.json could not be written |
unknown_branch |
readers with --branch, branch |
no such branch here |
unknown_command |
any | no such command |
unknown_file |
commit | lap never recorded the file and it is not here |
unknown_flag |
any | a flag the command does not take |
unknown_ref |
show, rr, amend | no commit (or record) with that id or hash |
unknown_session |
readers of a session | no such session |
unreadable |
commit | the file is there but cannot be read |
unrelated_history |
branch start, merge | the histories do not share a start |
usage |
most | the command line is wrong: a missing or empty value, a missing argument |
wrong_branch |
commit, session, amend | --branch names another line than this folder's |
gitignore subset: # comments; trailing / = directories only; a pattern
containing / (or starting with /) is anchored to the root; otherwise it
matches basenames at any depth; *, ? within a segment; ** spans
segments. Negation (!) is not supported. Always ignored: .lap/, .git
(a directory, or the file a git worktree has in its place), .hg/,
.svn/, .DS_Store.
.lapignore keeps lap from recording files it has not recorded yet. A
file recorded before it was ignored stays tracked: its edits still show
in lap status, so none is lost unseen, and status names it under
tracked but ignored. lap untrack <path> stops tracking it, keeping its
history and the file.
- One exclusive lock (
.lap/lock) serializes writers; readers never lock and never write. - Torn tail: a crash mid-append leaves an unterminated final line in the
open chunk. Readers drop it (it was never acknowledged) and continue;
lap verifynotes it; the next writing command truncates it away under the lock before appending. - Write order: log append (fsync) → shadow update → state write (atomic,
per-process temp name). A crash at any point leaves
state.jsonbehind the log tail; the next writer detects the mismatch and heals — rebuilding counters, the active session, and the entire shadow tree from a full replay. Readers that hit the mismatch heal in memory only. - An interrupt waits for the write. From the moment a command holds
the lock until it has released it, SIGINT, SIGTERM and SIGHUP (on
Windows, Ctrl-C and Ctrl-Break) are remembered, not acted on. The
command finishes its writes, releases the lock, then ends as the first
signal would have ended it (130 for SIGINT, 143 for SIGTERM), after
what it printed is flushed. Waiting for the lock can still be
interrupted, and so can every reader. A signal the process was started
ignoring stays ignored;
kill -9cannot be held off, and is left to the heal above. Tests pause a commit between its log append and its shadow update withLAP_TEST_PAUSE=commit, to interrupt it exactly there. - Names are durable before anything depends on them. After creating a
chunk, writing one whole into
.lap/log/, publishing a conversion'slog/or writing.lap/lineage, the folder itself is synced, so a power loss cannot keep a later step (a record appended to the chunk, the oldlog.jsonlremoved) while losing the name it stands on. On macOS, wherefsyncdoes not empty the drive's own cache, sealing a chunk (creating the next) and publishing a conversion useF_FULLFSYNC, which carries everything written before them through it; a commit's append keeps plainfsync, since an interrupted append is a torn tail lap already repairs. - Damage is not a crash. A torn line anywhere but the open chunk's end
was not made by a crash (lap repairs its own before sealing or merging):
a sealed chunk cut or copied short. Nor is a sealed chunk that is empty
or does not open with a record (a blank line left in it). Every command
refuses such a history, naming that chunk, never the one after it. Writers also refuse a chain broken between
chunks (a stray chunk, one truncated at a line, a wrong
prev), or inside the open chunk they append to (a line repeated or dropped, as a union-style git resolution leaves it), naming the line (history_broken): lap never builds on a history it cannot vouch for. The checks read a few bytes per sealed chunk and hash the open one (at most its 4 MB, about 20 ms);lap verifyreads everything. - An interrupted
lap merge(a crash, a full disk) leaves some adopted records and no merge record; everything else heals as above. Running the same merge again finds those records by theirfromlinks (and, for a chain, the merge records it wrote for the chain's own branches): when they are the last thing recorded here, it places the branch's commits against this folder's history from before them and appends only what they lack, so the result is the uninterrupted merge's, ids included. When other work was recorded since, their commits come out already done (§Merging, rule 3) and the rest is adopted. - The log itself has no size cap and its tail is hashed through a bounded tail-window read, so growth never makes the repo unreadable. (User files keep the 64 MB cap.)
lap verify --deepchecks both directions: every shadow file must match its replay, and every file the log says exists must have a shadow.
C11, zero dependencies. POSIX (macOS/Linux) is the fully tested platform.
The _WIN32 branches (paths, locking, directory walking) build with MSVC
through CMake, and the unit suite runs there under ctest; the e2e suite, a
shell script, runs on macOS and Linux only. All internal paths use /
separators.
- Thresholds. 3 words and 0.8 are starting values. Before they are
fixed, run the checks over the messages this repository recorded before
commits had an intent and a behavior (each
msgtaken as a behavior, paired with its session neighbour and its hunk) and look at what they would have refused. Those messages stay in git history, so this can run fromgit show <rev>:.lap/log.jsonl.
{"type":"init","version":1,"ts":"...","prev":"..."} {"type":"commit","id":"L7","user":"jane", // who committed (see below); // absent in pre-user logs "session":"S2", // session null if --no-session "file":"src/foo.c","op":"edit", // op: edit | create | delete | untrack "old_start":10,"old_lines":2,"new_start":10,"new_lines":3, "eof_nl":true, // trailing-\n state after commit "old_text":["..."],"new_text":["..."], // full replaced/replacement lines "intent":"...","behavior":"...", // §Messages "forced":true, // present only with --force-message "ts":"...","prev":"..."} {"type":"session_start","id":"S2","user":"jane","msg":"purpose", "meta":{"ticket":"T-12"},"ts":"...","prev":"..."} // meta: optional {"type":"session_end","id":"S2", "done":"...","decided":"...","left":"...", // each present only when given "ts":"...","prev":"..."} {"type":"branch","id":"7c1e9a02d4b8","name":"parser-fix", // §Branches "parent":"main","base":"<parent head>","base_chunk":3, "user":"jane","ts":"...","prev":"<the same base>"} {"type":"merge","branch":"7c1e9a02d4b8","name":"parser-fix", // §Merging "head":"<branch hash adopted up to>","adopted":41,"left":6, "stopped":[{"file":"src/foo.c","at":"<first commit not adopted>"}], "user":"jane","ts":"...","prev":"..."} {"type":"amend","of":"<the commit's hash>", // §Amendments "user":"jane","intent":"...","behavior":"...", "forced":true, // present only with --force-message "ts":"...","prev":"..."}