A tiny, file-based protocol for planning and tending chains of work.
A stitch is one small intention. A thread is a goal and everything that decomposes from it.
When you open .loom/threads/, you are looking at the work you have on the loom.
.loom/
loom.sh
threads/
tied/
dropped/
Install the released v2 files into the current repository:
loom_source="$(mktemp -d)"
git clone --depth 1 --branch v2.1.0 https://github.com/zealtv/loom.git "$loom_source"
"$loom_source/install.sh" "$PWD"This copies loom.sh, README.md, and the v2 protocol document into the
project's .loom/ directory, then runs .loom/loom.sh init.
For a fresh loom, init creates threads/, tied/, and dropped/ next to
itself, seeds the two archive trays so they survive a commit and clone, and
writes format-version with value 2. It does not mark a markerless loom
containing existing history as v2.
loom.sh operates on the .loom/ directory it lives in, so each copy is
self-contained.
Installing over an existing markerless v1 loom updates only the executable and
documentation; it does not migrate task state or write format-version.
Inspect and run the explicit migration after taking a backup:
.loom/loom.sh migrate-v2 --dry-run
.loom/loom.sh migrate-v2From a local clone of Loom, the equivalent install is:
./install.sh /path/to/host-repositoryA loom holds work that has shape.
Every thread has a goal stitch at its root β the outcome you want. The goal decomposes into child stitches. A loose end is a plain stitch whose children are resolved, whose hard dependencies are tied, and whose ancestors are not waiting β a concrete action ready to be worked.
To work a loom, pick a loose end, tend it, and tie it off. When every sibling of a stitch is resolved, its parent becomes a loose end in turn. You keep tying off up the thread until the goal stitch is tied β then the thread is done.
A stitch is a directory with an instructions.md file.
.loom/
threads/
goal-stitch/
instructions.md
child-stitch/
instructions.md
- Top-level entries in
threads/are goal stitches β one per thread. - Only immediate child directories containing
instructions.mdare decomposition children. Other directories are opaque supporting material. - A stitch has zero or one parent.
- Threads may branch.
tied/ and dropped/ each carry a .gitkeep. Git cannot track an empty
directory, so without it a loom committed before its first tie or drop loses
both trays on clone β and nothing notices until a goal tie fails weeks later.
The lifecycle commands recreate and reseed the trays as needed, so an
already-cloned loom heals on its next tie or drop. status and map report a
missing tray but never repair one; they are strictly read-only.
- One stitch, one place.
- Claim by suffix:
stitch-001/βstitch-001.stitching/. Only loose ends can be claimed. - Wait by suffix:
stitch-001/βstitch-001.waiting/. A waiting stitch explicitly parks that stitch and its whole subtree. - Tend by suffix:
parent/βparent.tending/. A tended stitch has children and a visible steward; it does not lock its branch. - Tie off in place: a child becomes
stitch-001.tied/; a completed goal and its whole tree move totied/stitch-001/. A stitch can only be tied off when all its children are tied or dropped. - Drop in place: a child becomes
stitch-001.dropped/; a dropped goal and its whole tree move todropped/stitch-001/. The stitch containsreason.md. - Every tie or drop writes
completed-atin local ISO-8601 seconds before the terminal rename or move.
The file system is the protocol.
The .stitching suffix is a claim β "this one is mine." POSIX mv is atomic, so claims are race-free. Only loose ends are claimed; the claim moves down with the work as you split.
The .waiting suffix explicitly parks a leaf, branch, or whole goal β for
example while blocked on a build, a review, or another person. Waiting is
inherited: every descendant beneath a waiting ancestor is excluded from
loose-ends and next, while siblings outside that subtree remain available.
Resume a directly waiting stitch with resume <stitch-id>. Resuming removes
only that stitch's .waiting suffix and leaves it unclaimed. It does not
silently resume an explicitly waiting descendant; resume that descendant
separately when appropriate. claim never resumes waiting work.
The .tending suffix means "I am stewarding this branch." It is only for stitches with children. Stewardship is visible coordination, not an exclusive lock: loose-end children beneath a tended parent remain visible in loose-ends and next, and other workers may claim them normally.
Use tend <stitch-id> to take stewardship and release <stitch-id> to return
the parent to its plain state. Adding another child preserves the parent's
.tending suffix. Waiting a tended branch ends its stewardship and parks the
subtree without changing descendant state.
After the final child is tied or dropped, a tended parent becomes childless. Either tie it directly if no final work remains, or release it and then claim it for final work. Claiming does not implicitly convert .tending to .stitching.
- Run
./loom.sh next(or./loom.sh loose-endsto see all of them). Ready stitches named in.loom/queuecome first in queue order, followed deterministically by the unqueued ready work (see Ordering). - Claim it:
./loom.sh claim <stitch-id>. - Read its
instructions.md. Ask: what is the next concrete action? - Decide:
- the outcome is no longer wanted β drop with a reason
- you can name the next action β do it and tie off
- the next step or subtree is blocked on something external β wait (excluded from loose ends until explicitly resumed)
- you can't yet name the next step β split into child stitches; the parent is unclaimed automatically, then claim one of the children
For longer decomposed work, tend the parent to make stewardship visible while its child loose ends remain available.
Keep loose ends small and direct. If a stitch is trying to do too much, split it.
Siblings are parallel. A parent waits for its children.
Nesting means decomposition only. To express A must finish before B, create an empty regular file named for A's globally unique ID:
<B>/needs/<A>
Or use the canonical mutation boundary:
./loom.sh anchor B A
./loom.sh unanchor B Aanchor validates both ends and refuses dependency cycles. unanchor removes
the last empty needs/ directory as well as the edge.
The file contents are reserved and ignored. A tied child or tied archived goal
satisfies the dependency. Active or waiting targets block B; missing, dropped,
or ambiguous targets are broken and reported by status. Dependency cycles
are reported once per cycle, and their members are never ready.
needs/ is supporting material, not decomposition. This lets dependencies
cross branches and threads and represent fan-out and diamond-shaped work
without inventing parentage.
When siblings can happen in either order, do not add dependency files between them.
Stitch IDs should be stable and semantic: fetch-source, parse-catalog, or
publish-report. Use needs/<stitch-id> when one stitch truly cannot proceed
until another is tied.
For softer preference, keep only the IDs that matter in .loom/queue:
./loom.sh queue parse-catalog
./loom.sh first urgent-repair
./loom.sh before publish-report parse-catalog
./loom.sh after fetch-source urgent-repair
./loom.sh unqueue urgent-repair
./loom.sh queue --set parse-catalog urgent-repair publish-reportThe first argument to before and after is the ID being moved. A queued
stitch that is waiting, claimed, or dependency-blocked is skipped, so it never
prevents later ready work. Unqueued ready stitches follow in lexical path
order. Reprioritising therefore never requires renaming an ID or repairing
dependency references.
queue --set validates and replaces the whole effective ID order atomically,
while retaining existing comment and blank records. status and map --json
also warn when queued work precedes an unsatisfied dependency or leaves that
dependency unqueued; those preference warnings never make the loom unhealthy.
loom.sh map gives a compact human view of recently completed work, the
current ready frontier, coming or blocked work, and the complete decomposition
tree. loom.sh map --json emits the deterministic schema documented in
docs/protocol-v2.md.
Use map --json --active to omit goal archives and migrated legacy records.
For polling, revision provides a cheap opaque change token so a consumer only
pays for a map when viewer-relevant filesystem state has changed.
The JSON snapshot is the sole supported integration boundary for a future browser, TUI, or other viewer. Viewers derive their display from that snapshot and perform mutations by invoking Loom commands; they never edit or maintain a second state model. Both map forms are strictly read-only.
Every lifecycle, dependency, and single-ID queue command takes --json before
its stitch ID and then reports its own result as one object instead of prose.
That includes new, whose result carries the created path:
./loom.sh tie --json http-server{"schema_version":1,"format_version":2,"command":"tie","ok":true,
"changed":true,"id":"http-server","state":"tied","path":"tied/http-server",
"tray":"tied","queue_position":null,"completed_at":"2026-08-03T17:29:18+10:00"}A failure emits an object with ok:false and a stable error.code, so a
viewer can tell "not ready" from "not found" without reading prose. That is
enough to apply the change locally instead of re-running a whole map --json
for a mutation that renamed one directory. The schema is in
docs/protocol-v2.md.
Human output is the default and is unchanged; --json is purely additive.
Notes, logs, decisions, intermediate files β put them inside the stitch
directory. Supporting directories do not become stitches merely because they
contain deeper directories or files named instructions.md. Artifacts travel
with retained terminal children and with the complete goal archive, leaving a
durable record of what happened.
A deployed markerless loom stays v1 until an operator explicitly migrates it.
init, status, next, install/update, and lifecycle commands never trigger
migration. Lifecycle and queue mutations on a non-empty markerless loom stop
with a migration hint.
First inspect the complete plan:
./loom.sh migrate-v2 --dry-runThe dry run validates the source and prints every backup, move, and marker
write without changing file bytes, paths, mtimes, or the format marker. Resolve
every reported orphan reason, name collision, malformed archive, or ambiguous
active directory before proceeding. V1 treated every directory under
threads/ as a stitch, so a directory without instructions.md cannot be
silently reclassified during migration: either add instructions if it really
is a stitch, or move it aside and restore it as v2 support material after the
migration.
Then migrate:
./loom.sh migrate-v2Active threads/ remain in place with their lifecycle suffixes and artifacts.
Flat v1 history moves to legacy-v1/tied/ and legacy-v1/dropped/; old
dropped/<id>.reason.md sidecars move inside the corresponding legacy record
as reason.md. Legacy records do not receive invented ancestry or
completed-at values. The summary counts active and legacy records and prints
warnings for top-level support entries retained unchanged.
Before the first move, migration creates .migrate-v2-staging/ containing an
immutable plan, an atomic completed-step journal, and recoverable copies of
every path it will move. If a run stops before format-version is committed,
ordinary mutations remain blocked:
./loom.sh migrate-v2 # validate the journal and resume
./loom.sh migrate-v2 --rollback # restore the original markerless v1 pathsRollback processes moves in reverse order, so an embedded dropped reason is
restored before its containing directory. Keep the staging directory intact
until one of these commands succeeds; it is the recovery material. If the v2
marker was committed but final staging cleanup was interrupted, rerun
migrate-v2 to validate all destinations and finish cleanup. At that point
rollback is intentionally unavailable because the loom is already declared
v2.
instructions.md is the conventional file that tells a human or agent what a stitch is for.
Keep it short. Keep it concrete.
It can contain:
- a brief
- notes
- links
- constraints
- a checklist
./loom.sh init
./loom.sh new [--json] <stitch-id> [parent-stitch-id]
./loom.sh claim [--json] <stitch-id>
./loom.sh tend [--json] <stitch-id>
./loom.sh release [--json] <stitch-id>
./loom.sh wait [--json] <stitch-id>
./loom.sh resume [--json] <stitch-id>
./loom.sh tie [--json] <stitch-id>
./loom.sh drop [--json] <stitch-id> [reason...]
./loom.sh queue [--json] <stitch-id>
./loom.sh first [--json] <stitch-id>
./loom.sh before [--json] <stitch-id> <anchor-stitch-id>
./loom.sh after [--json] <stitch-id> <anchor-stitch-id>
./loom.sh unqueue [--json] <stitch-id>
./loom.sh queue --set <stitch-id>...
./loom.sh anchor [--json] <stitch-id> <target-stitch-id>
./loom.sh unanchor [--json] <stitch-id> <target-stitch-id>
./loom.sh loose-ends
./loom.sh tending
./loom.sh waiting
./loom.sh next
./loom.sh status
./loom.sh revision
./loom.sh map [--json] [--active]
./loom.sh migrate-v2 [--dry-run|--rollback]
./loom.sh sweep [days] # remove whole goal archives older than N days (default 14)
status, next, loose-ends, waiting, tending, revision, and all map
forms are read-only. Lifecycle, dependency, queue, migration, and sweep
commands mutate only the .loom/ beside the invoked script. Run commands
through that deployed copy;
the examples above assume the current directory is .loom/.
Run the complete test entry point from the repository root:
./test/run.shThe runner exercises the lifecycle suite, every v2 protocol stage, fresh
installation, and disposable v1 migration acceptance. The authoritative format
contract is
docs/protocol-v2.md.