Skip to content
EnderRealmPublic

About

Capture coding-agent sessions, summarize activity, and extract durable project knowledge through a CLI and terminal dashboard.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Loom

Loom captures agent session transcripts, ships them to a central receiver, folds them into a normalized summary database, and surfaces both raw state and durable knowledge through an interactive TUI. See docs/overview.md for the project philosophy.

Status

Subsystem State What it does
transport v1, usable Ships agent session JSONL files to a central receiver
summarizer v1, usable Folds received sessions into ~/.loom/summaries.db (sqlite)
extractor v1, usable Runs extractors/extract.py over newly summarized sessions
tui v1, usable Interactive dashboard for projects, sessions, and knowledge
loom CLI v1, usable Unified entry point (tui, summarize, install, status, ...)

Agents supported: Claude Code (sessions at ~/.claude/projects/<sanitized-cwd>/<uuid>.jsonl) and Codex CLI (rollouts at ~/.codex/sessions/**/rollout-<ts>-<uuid>.jsonl). Cursor CLI SQLite sessions at ~/.cursor/chats/<workspace-hash>/<uuid>/store.db and their children are captured and summarized too. See Cursor summary mappings and coverage.

extractors/ is a Python project (truth/decision extraction from session summaries) that operates over the durable knowledge store at ~/.loom/knowledge/ — its own git repo, see that store's SCHEMA.md. The TUI reads that store and the loom extract agent invokes extract.py against new sessions. Every write to the store — from the TUI's promote/reject/edit gestures and from the Python extractor alike — goes through one Go entry point that commits what it wrote and pushes the commit; see docs/knowledge-store-writes.md. What the extraction path strips out of transcript text, where that text is allowed to flow, and what a consumer may trust it for are in docs/transcript-trust-and-redaction.md. Extraction is gated per project on a scope directory in that store, and which name a session resolves to — plus how to onboard a project that has none — is in docs/knowledge-scopes.md. The extractor routes each candidate to one of two destinations — a truth for promotion, or a ticket for filing into tk after a duplicate check — both behind human review; see docs/candidate-destinations.md.

Prerequisites

  • macOS (the shipper uses launchd; Linux support is not planned until there is a real reason)
  • Go 1.22 or newer (1.26 tested)

Repo layout

loom/
  go.mod                               # module "loom"
  install.sh                           # transitional forwarder over `loom install`
  cmd/loom/                            # the only binary; cobra-based subcommand surface
  internal/
    config/                            # loom-wide config (Home, config.json schema)
    launchd/                           # plist generation + bootout/bootstrap helpers
    parse/                             # session-transcript parsers + sqlite store
    summarize/                         # received → summaries.db sweep
    tui/                               # interactive dashboard
  transport/
    shipper/                           # client logic (capture, ship, daemon)
    receiver/                          # server logic (HTTP ingest)
    internal/                          # wire, cursor, source, staging, notify
  docs/  extractors/                   # non-Go subsystems
  knowledge/                           # eval fixtures only — durable store is ~/.loom/knowledge/

Everything is driven by the unified loom binary.

Install (any machine, no toolchain)

Download the latest release tarball for your OS/arch from GitHub Releases and extract loom to ~/.local/bin/loom:

OS=$(uname -s | tr '[:upper:]' '[:lower:]')          # darwin | linux
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')   # amd64 | arm64
VER=$(curl -fsSL https://api.github.com/repos/EnderRealm/loom/releases/latest | grep -m1 '"tag_name"' | cut -d'"' -f4 | sed 's/^v//')
mkdir -p ~/.local/bin
curl -fsSL "https://github.com/EnderRealm/loom/releases/download/v${VER}/loom_${VER}_${OS}_${ARCH}.tar.gz" | tar -xzf - -C ~/.local/bin loom

Then install the launchd agents you want and the updater that keeps the binary on the latest release:

loom install server     # or remote / receiver / summarizer / extractor / shipper
loom install updater     # downloads + installs each new release; see Auto-update

Develop

Build the binary locally to run unreleased changes — this is independent of the installed/daemon binary:

git clone git@github.com:EnderRealm/loom.git
cd loom
go build -o ./loom ./cmd/loom    # or: make dev
./loom <command>

Install launchd agents

loom install server             # receiver + summarizer + extractor; records role=server
loom install remote             # shipper (client side); records role=remote
loom install receiver           # receiver only
loom install summarizer         # summarizer only
loom install extractor          # knowledge extraction trigger only
loom install shipper            # shipper only (no role change)
loom install updater            # keep the binary on the latest release — see Auto-update
loom uninstall                  # remove all loom launchd agents
loom status                     # show state of all installed components
loom ui                         # open the dashboard (alias: loom tui)
loom ui --run <id>              # open on one run's detail; w lists recent runs

The server and remote profiles also record the machine's role under $LOOM_HOME/role. loom dev and loom status scope their health rollup to the daemons that role expects, so a remote machine running only the shipper no longer reports "degraded" for the receiver/summarizer it was never meant to run. Installing individual components (receiver, summarizer, extractor, shipper, updater) leaves the role untouched.

install.sh is preserved as a thin forwarder for muscle memory: each --install-X flag does go build -o $LOOM_BIN_DIR/loom ./cmd/loom then loom install X. Either entry point works; new docs prefer the loom binary directly.

State lives under $LOOM_HOME (default ~/.loom); the binary lives at ~/.local/bin/loom, the path the launchd plists pin and the updater installs new releases over.

LOOM_BIN_DIR=/usr/local/bin ./install.sh --install-shipper
LOOM_HOME=/srv/loom         loom install receiver

If ~/.local/bin isn't in your $PATH, add it to your shell rc file. launchd runs the binary by absolute path regardless, so this is purely for interactive use.

Auto-update

The loom updater daemon polls EnderRealm/loom's latest GitHub Release on an interval. When a newer release than the running binary ships, it downloads the platform tarball, verifies its checksums.txt entry, installs the extracted binary over ~/.local/bin/loom, and kickstarts every loom agent (itself last). No git checkout and no Go toolchain are needed on the host — the installed binary is always a published semver release.

loom install updater
tail -f ~/.loom/updater.log

Default: 5-minute poll. Override via plist environment:

env var default purpose
LOOM_UPDATER_INTERVAL_MINUTES 5 poll cadence

The reusable pattern (Go binary on launchd that updates itself) is documented in docs/auto-update-pattern.md; loom runs its artifact-fetch variant.

Knowledge extraction

The loom extract agent closes the loop between capture and the durable knowledge store: every sweep it walks summarized sessions that have not been extracted yet and runs extractors/extract.py over each one, so candidates land in ~/.loom/knowledge/_candidates/<type>s/<scope>/ and show up in the TUI's candidate review screen.

loom install extractor
tail -f ~/.loom/extractor.log
  • Scope comes from the repo's .loom-project marker whenever the session's recorded cwd names a checkout on this host: the walk runs from that directory up to the repo root and the nearest usable marker wins, the same walk extractors/resolve_project.py does. Otherwise it is the basename of the session's normalized git remote (github.com/enderrealm/loom → loom). Either name must be one safe path segment with a knowledge/truths/<scope>/ directory behind it; a marker that isn't is skipped and the walk continues, so a session whose marker chain yields nothing still resolves through its remote. A session that resolves through neither is not extracted — there is no default scope, since a wrong default files a project's truths under another project's name irreversibly. That exclusion is counted in the sweep's summary rather than recorded in the ledger, so creating the missing directory later rescues the sessions it was created for: loom status lists the scopes this host's sessions resolve to that have none, and loom knowledge scope add <name> creates one (committed and pushed). See docs/knowledge-scopes.md, which also covers what collides and why the .loom-project marker is the tiebreaker. Two deliberate divergences from resolve_project.py: a marker's value is lowercased here (a resolved scope is lowercase by construction), and the truths/<scope>/ check has no counterpart there, since it has no knowledge store to consult.
  • The log accounts for the derivation. Every extraction line carries source=marker or source=git-remote. A marker that names a different scope than the remote does is logged outright, naming both — one of the two is stale and only you can say which — as are a marker the store can't accept (the walk continues up past it), a marker that exists but can't be read at all (a symlink, which is never followed; a non-regular file; bytes that aren't UTF-8; more than 4 KiB, which is not a one-line marker), and a winning marker below the repo root (as likely a vendored subtree's declaration as the project's). Those lines state a fact about a repo rather than about a session, so each is stated once per run however many of that repo's sessions are resolved, and a rejected name is echoed truncated — it is arbitrary file content, and the log is an audit record. "No marker here" is silent; it is most of every walk.
  • Forward only. A watermark is stamped in ~/.loom/extract.state the first time the agent runs; sessions summarized before it are left alone. The historical backlog is loom extract --backfill's job (below).
  • Claude Code and Cursor CLI transcripts are supported. Cursor journals use the installed Loom parser through LOOM_BIN (or loom on PATH), followed by the same Python redaction and extraction stages. Codex rollouts remain unsupported and are skipped with a logged reason. Transcript runtime does not select the extraction provider. See Cursor extraction.
  • Stub sessions are below the bar. A session the summarizer folded fewer than --min-turns turns for (default 3, tunable through LOOM_EXTRACT_MIN_TURNS) is passed over, on both the sweep and the backfill. Measured over 467 extracted sessions here, 1- and 2-turn sessions return zero candidates 44% and 50% of the time against 13% at 3–4 turns and 7% at 5+ — and they are 1371 of this machine's 1850 summarized sessions, so the default drops a full backfill from 510 round trips to 192. A session at the threshold is extracted, 0 disables the filter entirely, and a session whose turn count the DB doesn't carry is never treated as a stub. The exclusion is not recorded in extract.state, unlike the sweep's other skips: lowering the threshold later re-admits every session a higher one passed over.
  • At most once: every visited session is recorded in ~/.loom/extract.state with its outcome (extracted / skipped / failed), so re-running a sweep is a no-op. Re-running one session means deleting its entry. The two reversible exclusions — below --min-turns, and no scope directory — are deliberately not recorded, and the scope-skip records written before that was true are retired from the ledger on read. A run that completed but scored below the extractor's coverage threshold is extracted, not failed — only a run that never produced a result is a failure.
  • extract.py is not in the release tarball. The agent resolves it from a checkout; a missing script makes each sweep a logged no-op instead of a crash loop, and loom status shows the resolved path.
env var default purpose
LOOM_EXTRACTORS_DIR ~/code/loom/extractors where extract.py lives
LOOM_KNOWLEDGE_ROOT $LOOM_HOME/knowledge the knowledge store to write into
LOOM_EXTRACT_PROVIDER claude extractor LLM provider
LOOM_EXTRACT_MODEL sonnet extractor model
LOOM_EXTRACT_MIN_TURNS 3 --min-turns default (0 disables it)

Set them before loom install extractor. Install persists them to $LOOM_HOME/extract-env and bakes them into the plist, since launchd jobs don't inherit a login shell's environment and the auto-updater re-installs the agent from its own. loom status prints what the agent resolves, which is the persisted value unless the invoking environment overrides it.

Backfilling the historical backlog

loom extract --backfill is the operator-run pass over the sessions the watermark excludes. It runs once — --watch is rejected — and ignores the per-sweep cap, so clearing hundreds of sessions isn't paced at four per quarter hour. It shares ~/.loom/extract.state and the same provider/model tunables with the trigger, so neither re-extracts what the other already visited, and an interrupted run resumes where it stopped.

loom extract --backfill --dry-run                 # what a real run would do, spending nothing
loom extract --backfill --scope loom --limit 20
  • --dry-run reports the selection — how many sessions resolve to each scope, by which derivation (via marker=…, git-remote=…), and how many are excluded and why — and spends nothing: no LLM call, no write to the knowledge store, no ledger entry (not even the watermark, on a host where the trigger has never run).
  • --scope restricts the run to named scopes (repeatable, or comma-separated) so one can be judged before committing to the rest. It is matched case-insensitively, and a value with no knowledge/truths/<scope>/ directory is rejected up front — no scope resolves to it, so the run would otherwise report "0 to extract" and exit 0, which reads exactly like an already-cleared backlog.
  • --limit bounds the run, stopping between sessions rather than mid-session. A negative value is rejected; 0 is the default and means unbounded. All three flags require --backfill, and that is judged on the flag being passed, so --limit 0 or --dry-run=false handed to a sweep is an error rather than a silent no-op. --min-turns is not one of them — the threshold means the same thing to a sweep — but a negative value is rejected there too, since 0 is the one spelling of a disabled threshold.
  • Skips are logged but not recorded — none of them, where a sweep still records the agent and artifact ones. The scope skips are unrecorded on both paths, for the reason most of this backlog's are unknown scope: recording them would mean creating knowledge/truths/<scope>/ later could never rescue the sessions it was created for. The backfill logs its skips one per session, unlike the sweep's aggregate — it is operator-initiated and runs in the foreground, so the report is what it is read from.
  • Progress — the selection report, one line per session as it completes, and a running count every 10 — prints to the terminal and is appended to ~/.loom/extractor.log, so a run that spends hours of LLM time leaves its trail in the same audit log the agent writes, without a shell redirect.
  • Safe to run while the agent sweeps, in this specific sense: ledger writes merge under a file lock, so a backfill holding its snapshot for hours and a quarter-hour sweep holding its own don't erase each other's records; and the backfill re-reads the ledger immediately before each extraction, dropping — with a logged line, and a skipped= count in its final tally — any session the trigger recorded since the plan was computed. The plan's up-front counts are therefore a forecast: a run can extract fewer sessions than it announced. What is not covered is the overlap window itself — neither side records a session until it finishes, so the trigger can still start on one the backfill is mid-way through.

Retrospecting a closed ticket

loom retrospect <namespaced-ticket-id> pushes new memory out of completed work: it resolves every summarized session whose commits carry the ticket's [<id>] subject marker and runs the extractor over each, for truths and for decisions. An epic id — a project's or Root's (_root/<id>) — expands through tk to its exact children across every project; each session is extracted once however many of them it landed for, and files under the scope its own checkout resolves to. Root is never a scope.

loom retrospect loom/add-loom-retrospect-e222
  • Candidates only. Output lands in ~/.loom/knowledge/_candidates/<type>s/<scope>/ with session: and ticket: sources filled in, and each run appends one ## [date] retrospect <ticket-id> | <scope> | N truth candidates, M decision candidates line to the store's log.md, with , K ticket candidates appended when the run produced any. truths/ and decisions/ are never written — promotion stays human-gated.
  • The ledger is bypassed on purpose. ~/.loom/extract.state exists to stop the unattended trigger double-spending, and the sweep has usually already visited a just-closed ticket's sessions; honoring it would make the command a no-op in exactly the case it exists for. Candidate filenames carry a run timestamp, so a re-run files siblings rather than overwriting.
  • A ticket with no commits in the summary DB is reported and exits 0. A missing extract.py, an extraction backend that isn't installed at the absolute path extract.py invokes it by, or a malformed ticket id are errors and exit non-zero — this is a foreground command, not a daemon that must not crash-loop. Sessions skipped for the sweep's own reasons (unsupported agent, unresolvable scope) are reported and don't fail the run.

/work compliance report

loom work-report measures how the /work runs in summaries.db actually behaved: for each one it reports whether the review fan-out was dispatched, how many review rounds ran, how many lens verdicts reported contaminated context, how many acceptance criteria the contract lens left unverified, how long passed between the run's first and last ticket edit, and whether the run committed. It is the baseline a change to the skill gets measured against instead of argued about.

loom work-report --since 2026-08-01 > before.json
loom work-report --since 2026-08-01 --until 2026-09-01 | jq .totals
  • A run is one /work invocation, not one session: a session that invoked /work three times holds three runs, each spanning from its invocation turn to the turn before the next one. Invocations are recognized from transcript content — the command tags at the start of a Claude prompt; on Codex the expanded <skill> block naming work, with the typed invocation and its ticket id trailing it, or the older typed #work <ticket> its earlier rollouts recorded — rather than from a marker a run has to write, so history nobody instrumented is covered. The anchoring is deliberate: a summarizer session quotes the whole skill body, tags included, and a lens session is handed that body mid-turn; a substring match would count every one of those as a run.
  • Runtimes are distinguished because the fan-out's shape is. Claude dispatches lens subagents and leaves subagent tool rows behind — named Task by one client and Agent by another, so they are matched on the normalized tool kind; Codex and Cursor inline the passes and leave only a codex-lens.sh --lens call or the verdict blocks themselves. Reading Codex against Claude's shape would report every Codex run as non-compliant by construction. Cursor journals are ingested through the same invocation and lens-response model.
  • Weak evidence is discounted, but the report does not resist forgery. A fenced block counts as a verdict only when it names one of the three lenses and, when it parsed whole, a satisfied/findings verdict — so a quoted verdict template or a junk block is neither a metric nor a dispatch. Inlined passes stand as dispatch evidence only across distinct lenses, contract and quality among them, which one verdict re-quoted by the merge cannot produce; and a lens router call counts only when the command matches, script plus --lens, and the call's recorded output carries a lens verdict, so echoing the invocation does not count as making it. The residual limitation is plain: on Codex and Cursor the inlined passes exist only as assistant-authored verdict blocks, so a run that fabricated two well-formed verdicts plus a commitment line would read as compliant. The report grades honest runs and detects skipped work; it does not withstand deliberate self-forgery.
  • A Claude lens verdict arrives one of two ways, and both are read: asynchronously, as a task notification on a later turn, or synchronously, as the dispatching call's own tool result. A stored tool result is cut at 800 characters and on a contract verdict the cut usually lands inside criteria[], so criteria_unverified is null on most synchronous runs — null means "no contract verdict parsed whole", never "zero unverified". A partial criteria list would under-report, which is worse than reporting nothing. review_iterations is null on the same principle: it counts the rounds the run's own fan-out commitment lines named, so a run whose fan-out is on the record only as tool calls reports null rather than a self-contradictory zero rounds.
  • open_to_done_ms is the span from the run's first ticket edit to its last, measured from the earliest and latest timestamps rather than the first and last row, since tool rows are sequenced as recorded and can run out of order. It is not a proven open→done transition: the report sees that ticket_edit was called, never which status the call set. It is null when the run made fewer than two ticket edits — one edit is a point, not a span — and on Codex it can be unmeasurable outright: an MCP call recorded only at call-end is stored under the tool name mcp, carrying no name to match.
  • Unknown is never compliant. A run whose runtime the parser cannot name, whose commitment line names a round it cannot count, whose invocation is followed by nothing, or whose signals conflict is classified unknown. The other classifications are compliant (a fan-out with this runtime's evidence behind it), skipped_fanout (a commit landed with no fan-out at all) and incomplete (no commit and no fan-out — blocked at the contract gate or abandoned, which is not a compliance failure).
  • JSON only, and diff-stable. Runs are ordered by invocation time then session id — a stable sort, so runs that tie (two in one session, or two carrying no invocation timestamp) hold their agent/session/turn order — and the document carries no generation timestamp, so two reports over the same range are byte-identical and a before/after diff shows only what changed. --since is inclusive, --until exclusive; both take YYYY-MM-DD (local midnight) or RFC3339. --db overrides the database read.
  • A missing or pre-v4 summaries.db is an error, not an empty report: "no runs" is the number being measured, and a zero meaning "nothing to read" reads exactly like a zero meaning "nobody ran /work".

Per-/work-run cost report

loom cost-report measures what each /work run in summaries.db cost: turns, input/output/cache tokens and the dollars they price to, tool calls broken down by kind, subagents dispatched, how long they ran and what they cost, whether the run committed, and two measures of elapsed time. It is the evidence behind "tickets feel slower than they did", which cannot otherwise be confirmed or refuted.

loom cost-report --since 2026-08-01 > before.json
loom cost-report --since 2026-08-01 | jq '[.runs[].wall_clock_ms | values] | add / length'
  • A run is one /work invocation, recognized and bounded exactly as work-report does it: from the invocation turn to the turn before the next invocation in the same session, else to the session's end. Session duration cannot stand in for ticket cost — a third of the sessions that commit a ticket commit two or more, one of them 22 — so attributing a whole session to a ticket over-counts by up to an order of magnitude. Cost is an intra-session span.
  • Two measures of time, on purpose. wall_clock_ms runs from the invocation to the run's commit and is what is actually felt, idle included: every minute the human was asleep, in a meeting or reading the diff. active_ms sums the span's turn wall clock and its tool durations, so it is immune to that idle but is not the thing on the calendar. The gap between the two is itself the signal — it separates "the agent got slower" from "I was slower to respond". The two terms of active_ms overlap by construction (a tool call runs inside its turn's wall clock), which makes it an upper bound on attention rather than a disjoint sum.
  • Null means unmeasurable, never zero. A run that never committed reports wall_clock_ms as null rather than running its span to the session end, so an abandoned run cannot inflate the trend; so does a run whose invocation or whose commit carries no usable timestamp, one whose commit timestamp precedes its invocation, and one whose only ticket-named commit landed after the next invocation — committed stays true there, but the commit belongs to a later run and cannot time this one. subagent_duration_ms is null when no dispatch in the span carried a duration — a background dispatch's span often cannot be resolved, and "not measured" must not read as "returned instantly". The counts beside them (turns, tool_calls, subagents) are still reported for a run that never committed: it cost what it cost.
  • Every recognized run is reported, including the ones work-report classifies unknown. Cost is not a compliance grade, and dropping the runs that are hardest to classify would bias the trend toward the easy ones.
  • Each run says under what conditions it ran. models, efforts and cli_versions are every distinct value the span's turns carried, in the order first seen — taken per turn, not per session, so a run whose model or effort changed mid-span lists both, in the order it happened — and are null when no turn in the span recorded the field, never an empty list. errors counts the error rows whose turn falls inside the span, so a session holding three runs attributes each error to at most one of them. human_interactions counts the turns in the span where the human acted: the stored user message, with any leading <system-reminder> blocks stripped, is non-empty and does not start with a harness envelope — a slash-command tag (<command-name>, <command-message>; the /work invocation itself does not count), a <task-notification>, local command output (<local-command-stdout>, <local-command-caveat>, <bash-stdout>, <bash-stderr>), Codex's expanded <skill> body, or Codex's <recommended_plugins>/<environment_context> preamble. Tool results never open a turn and Claude's injected skill body is dropped as a meta record, so neither reaches the rule. <bash-input> counts: the human ran a ! command. The rule is one function, applied to Claude and Codex turns alike. The per-turn columns arrived in schema 6.
  • Each run is priced, from a table in the repo. Transcripts carry tokens, never cost, so cost_usd is computed: the run's own turns, each at its turn's model and speed, at the rate in force at the run's invocation — internal/pricing/rates.json is keyed by model and effective date, so re-running the report over an old window does not reprice history. cache_read_tokens and cache_creation_tokens are priced at their own rates, not the input rate; a cache write is split by TTL, the share the transcript labelled 1-hour at the 1h write rate and the rest (5-minute or unlabelled) at the 5-minute rate, the API's default. A turn recorded at speed: fast prices input and output at the model's fast rates, with the cache multipliers stacked on the fast input rate. subagent_cost_usd is a separate figure: each dispatch in the span priced from its own transcript's usage split and model, never from the parent's blended count. Cost is null, never a default or zero, whenever anything in it could not be priced — a model the table does not carry, an invocation before the model's first effective date, a turn whose model was not recorded, a turn or dispatch whose records disagree on model or speed, fast mode on a model with no fast rate, or a dispatch with no shipped transcript — and pricing_warnings then names every cause (unpriced model "codex-auto-review" at 2026-09-15, subagent 2 (reviewer): no transcript); it is null when nothing went unpriced. Each turn is priced under its runtime's cache accounting: Claude records input exclusive of the cache buckets, so every bucket prices at its own rate; Codex records cached input as a subset of input, so the cache read is taken back out of the input before the two price at the list and cached-input rates, and a read larger than its input is a broken record (turn 3: cache read exceeds input) rather than a discount. Cursor journals document no cache convention, so a Cursor turn prices only when it carries no cache tokens at all and otherwise reports cache accounting semantics unknown for cursor-cli — and the journals Cursor CLI writes today carry no billing counters, so a Cursor run reports token_usage_unavailable and a null cost whatever its model. A span with nothing to price costs 0, not null. The table records its source and the checked date the rates were last read against it, and an entry may carry its own where it came from a different vendor page; the rates are assumed to have held since each entry's effective date, so when a vendor page changes, add an entry with the new effective date and update checked — the figures a reader gets are only as current as that date. Which Codex and Cursor identities the table covers, and which it deliberately does not, is in Model pricing coverage; every figure is an estimate at published API rates, not what a subscription invoiced. The pricing columns (per-turn cache-creation tokens and speed, per-dispatch usage) arrived in schema 7, so the report refuses an older summaries.db until loom summarize --rebuild.
  • JSON only, and diff-stable, on work-report's terms: the same ordering, the same --since/--until/--db flags, and no generation timestamp, so two reports over the same range are byte-identical and a before/after diff shows only what changed.

Per-run report

loom run-report --run <id> measures everything attributable to one run — the parent's own span and every execution under it (subagents, routed lenses, Weft stage attempts, commands), nested and retried alike — where cost-report meters the parent span and the subagent rows it can see. It is one JSON document per run, for the CLI and for a UI, and is the read surface over the execution tree internal/runs assembles (docs/execution-records.md).

loom run-report --run 0f4c3a6e-2d1b-4b7e-9c8a-5e2f1d0a9b31 | jq .metrics.total
loom run-report --run transcript:claude-code:<session>:0 | jq '.telemetry.gaps'
  • Three scopes, each transcript counted once. metrics.parent is the root's parent-only span — for a declared run it starts at the first turn beginning at or after the record's started_at (telemetry.root_span: started_at), except that a run started during its invocation turn keeps that turn (invocation); historical runs remain invocation-anchored, and a root with no recognized invocation uses the whole session (session). metrics.descendants is every other execution, deduplicated by session; metrics.total is recomputed over the counted set. An execution naming a session another already counted — a stage recorded against the root's own transcript — is listed with counted: false and counted_by naming the one that did. Each scope carries turns, tool calls by kind and how many errored, tool time, tokens, failures, the models, efforts and CLI versions in force with how many turns carried each, execution time with how many executions were timed, and cost. Human interactions are the root's by construction — a descendant's user messages are the dispatching agent's prompts — so descendants reports 0 and total equals parent.
  • Tokens stay per runtime. Claude records input exclusive of the cache buckets; Codex records cached input as a subset of input. tokens_by_runtime keeps each runtime's buckets under a cache_semantics label (cache_separate, cache_read_included_in_input) and each runtime's total is summed under its own semantics, so total_tokens never counts a Codex cache read twice — and cost is priced under the same label, the Codex read taken out of the input before either is priced. Cursor journals expose context occupancy without billing counters: their bucket uses unknown cache semantics and NULL counters/cost, and a scope containing unavailable usage has NULL total tokens. A Cursor session that did record counters prices only while it carries no cache tokens, since unknown names exactly the convention that would be needed to price one. See Cursor measurement coverage.
  • Failures apart from hook signals. failures classes the error rows by source — tool (tool_error, exec_error, patch_error), api (api_error), process (turn_aborted), and other listed by source; hook_signals counts stop_hook rows separately, since a hook telling the agent to continue is not a failure.
  • Four measures of time, named. time.wall_ms is one span, the run's started_at to its ended_at (wall_basis), or to last_observed_at for a run still going. execution_time_ms sums every counted execution's own span, so parallel children add where the wall does not. tool_time_ms sums tool durations over every counted execution. legacy_active_ms is the parent span's alone — the same figure cost-report reports as active_ms for the run — and carries legacy_active_ms_semantics beside it: its terms overlap, and it is an upper bound on attention rather than exclusive activity. The descendants' and total sums are under metrics.descendants.legacy_active_ms and metrics.total.legacy_active_ms.
  • Outcome is the record's; completeness is separate. run.outcome is what the run record said (completed, failed, stopped), running when it declared a start and nothing has closed it, unknown when no record says — never inferred from telemetry. telemetry.state is complete only when every execution has ended and every one naming a transcript has that session in the database; otherwise partial, with gaps naming each cause — a session not folded, an execution still pending, a historical dispatch with no usage or no measured duration, an unresolved execution left uncounted. Partial coverage is named, never reported as zero usage. A terminal record arriving after a start-only one flips the outcome without changing usage.
  • Breakdowns and the tree. executions lists every node depth-first, root first, then the unresolved ones (placement), each with its own span's metrics and duration; stages groups stage attempts by (stage, occurrence) with a retries count; lenses joins the lens execution records with the transcript's lens attempts on (lens, round, attempt), listing an attempt from either side alone, each with an attribution — execution where a record joined, transcript where only the transcript paired a response (a native subagent lens writes no record), unknown where neither stands behind it; attempts is the flat retry view. tree, unresolved and diagnostics are the run as internal/runs reads it, with every empty list rendered [].
  • A missing rate leaves the metrics standing. Cost is priced as cost-report prices it, at the rates in internal/pricing/rates.json in force at the run's start; a model the table does not carry (coverage) leaves cost_usd null, pricing.available false and the cause in pricing_warnings, and every other figure is still reported. Each scope is priced on its own — a Claude parent at Claude's rates and semantics, a Codex descendant at OpenAI's — and the total is their sum, or null when any scope in it is. pricing names the table's currency, source and checked date. An unknown run id is an error (run not found: <id>); a summaries.db older than schema 9 is refused until loom summarize --rebuild.

Bug escape attribution

loom escapes attributes every done bug in the tk store to the agent sessions whose commits introduced it, and prints the share of loom commits that introduced a bug per (agent, model, cli_version), with precision against the hand-labelled fixture beneath.

loom escapes
jq -c 'select(.class == "unattributable") | {ticket, reason}' ~/.loom/escapes.jsonl
  • SZZ over the fix. A bug's fix commits are those on the registered repo's HEAD whose subject carries its [<id>] marker. The lines they deleted or modified are blamed at each fix's parent with whitespace ignored on both sides; generated paths (tests/golden/) are skipped, as are lines written by the bug's own fix commits.
  • One class per bug. single (one introducing commit), multi (several, credit 1/n each), or unattributable with a reason: no fix commit found, an add-only fix (no culprit is guessed), a git failure while blaming (written to stderr; the run continues), or no introducing commit in loom's commits table, which is where a human commit lands too. A blamed hash is joined to a loom commit only in the same repo, by origin remote (normalized, so SSH and HTTPS forms match) or cwd, so short-hash collisions across repos never cross-attribute.
  • The rate is introducing commits over commits. commits counts every distinct loom commit in the slice reachable from HEAD in a registered repo, introducing how many of them introduced at least one attributed bug, and rate is their ratio. credit is the fractional sum beside it: 1 per single bug, 1/n per commit of a multi bug, so a commit that introduced several bugs counts once in introducing and several times in credit.
  • Precision is printed with the rates. The hand-labelled fixture (internal/escapes/testdata/labels.jsonl) is embedded in the binary; the precision line (precision on fixture blame-stage singles) scores the fixture bugs the blame stage classes single, before the loom join, against their labelled introducing commits, the same measure TestPrecisionAgainstFixture holds at 60% or better.
  • Per-bug JSONL. Every done bug is written once to $LOOM_HOME/escapes.jsonl (--out overrides) with its fix commits, blamed commits and line counts, and the matched session with its /work runs, so a wrong attribution can be debugged from the file. --db overrides the database read; one older than schema 8 is refused.

Transport

A lightweight agent-session shipper. The client (loom shipper daemon) walks agent session files, ships byte-delta batches to the server (loom receiver) over HTTP, and persists per-session cursors so subsequent runs are incremental and idempotent. The shipper runs as a long-lived KeepAlive daemon with an in-process ticker (driven by interval_minutes in config); it does not rely on launchd's StartInterval, which dasd coalesces aggressively on modern macOS.

Agents supported: Cursor CLI (the received cursor-store-v1 journals, with children as separate sessions), Claude Code (sessions at ~/.claude/projects/<sanitized-cwd>/<uuid>.jsonl, plus the transcripts of the subagents that session dispatched, anywhere under ~/.claude/projects/<sanitized-cwd>/<uuid>/subagents/ — directly in it for a plain dispatch, one level deeper under workflows/<wf_id>/ for a workflow one, whose journal.jsonl is the workflow's own bookkeeping and is not a transcript) and Codex CLI (rollouts at ~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-<ts>-<uuid>.jsonl).

A session run from a throwaway working directory — a review lens started in an empty mktemp -d so the reviewed repo cannot instruct its reviewer — reports that directory as its cwd, which would make every run its own project and leave it with no knowledge scope. Its launcher stamps the checkout it is about in ~/.loom/attribution.jsonl and the Codex adapter resolves identity through it; the producer contract is docs/attribution-stamps.md.

State locations

~/.loom/
  config.json                                  # client: server URL, auth token, interval
  transport/
    cursors/source/<agent>/<key>.cursor        # client: next byte read from source
    cursors/ship/<agent>/<key>.cursor          # client: next byte shipped to receiver
    staging/<agent>/<project>/<uuid>.jsonl     # client: bytes captured locally
    staging/<agent>/<project>/<uuid>.meta.json # client: per-session project identity
    staging/<agent>/<project>/<parent>/subagents/
      <agent-id>.jsonl                         # client: subagent bytes captured locally
      <agent-id>.subagent.json                 # client: dispatch metadata (parent, agent type, tool use id)
  attribution.jsonl                            # producers: throwaway work root → checkout
    shipper.lock                               # client: flock, one shipper at a time
    shipper.log                                # client: launchd stdout/stderr capture
  received/                                    # server: default storage root
    <agent>/
      <sanitized-project>/
        <uuid>.jsonl                           # appended-to session file
        <uuid>.offset                          # next expected byte (idempotency)
        <uuid>.meta.json                       # project identity (git remote, raw cwd)
        <parent>/subagents/
          <agent-id>.jsonl                     # subagent transcript, with its own .offset/.meta.json
          <agent-id>.subagent.json             # dispatch metadata (parent, agent type, tool use id)
  summaries.db                                 # server: normalized summary database
  summarizer.log                               # server: summarizer launchd capture
  extract.state                                # server: extractor watermark + visited sessions
  extract-env                                  # server: extractor tunables baked into its plist
  extractor.log                                # server: extractor launchd capture
  knowledge/                                   # durable knowledge store (separate git repo)
  knowledge-git.log                            # knowledge-store commits that could not be recorded or pushed

State lives per-user per-machine. Override the root with LOOM_HOME=/some/path.

<agent> is claude-code, codex-cli, or cursor-cli. The two-stage shipper captures from the agent's source directory into local staging/, then ships staging deltas to the receiver — so the agent can clean up its own session files without losing data. Cursor's mutable SQLite records become a lossless journal in staging; the other transcript sources retain their JSONL byte framing.

A subagent transcript travels under the session that dispatched it: it stages at staging/<agent>/<project>/<parent>/subagents/, lands at received/<agent>/<project>/<parent>/subagents/, and carries a .subagent.json sidecar with what the agent recorded about the dispatch. Its cursor key <key> is <parent>.<agent-id>; for a transcript nested below subagents/, <agent-id> is its path under that directory joined with . (workflows.wf_5daf2eee-720.agent-a0e0), so it stays a single path component and two dispatches under one parent can never share a staging file or a cursor. A top-level session's <key> is its <uuid>.

Project identity

Each session carries an authoritative project identity captured at the shipper:

  • git_remote — git -C <cwd> remote get-url origin at capture time, normalized (SSH and HTTPS variants of the same origin collapse). Canonical when present.
  • cwd — the raw, unsanitized working directory the agent reported. Authoritative fallback when there's no git remote.
  • root_slug — the legacy sanitized path used as the on-disk storage directory, kept for backward compatibility.

Identity travels in wire.IngestRequest.ProjectIdentity (optional; pre-identity clients omit it), is persisted by the receiver as a <session>.meta.json sidecar next to the JSONL, and is folded into summaries.db (sessions.git_remote, sessions.cwd_raw). The TUI groups projects by git_remote, then cwd, then root_slug — so the same repo shipped from two laptops with different sanitized paths rolls up into one project, and basename collisions like code/loom vs other/loom stay separate.

Server setup

The server can be any Mac reachable from the client(s). It can be the same machine as the client for experimentation.

1. Generate and export a shared bearer token

export LOOM_RECEIVER_TOKEN="$(openssl rand -hex 32)"

On first loom install receiver, this value is persisted to ~/.loom/receiver-token (mode 0600), so later installs and the auto-updater's re-bootstrap don't need it re-exported. If you skip the export, an interactive install prompts for the token; a non-interactive install with no env var and no token file errors instead.

The same value goes into the client's ~/.loom/config.json on each shipping machine.

2. Install the receiver agent

loom install receiver

This:

  1. Resolves the bearer token (LOOM_RECEIVER_TOKEN → ~/.loom/receiver-token → interactive prompt) and persists it to ~/.loom/receiver-token (0600)
  2. Creates $LOOM_HOME/received/
  3. Writes ~/Library/LaunchAgents/com.loom.receiver.plist with KeepAlive=true (restarts on exit), RunAtLoad=true (starts immediately), and LOOM_HOME baked into EnvironmentVariables. The token is read from ~/.loom/receiver-token at runtime, not the plist, so it's not exposed via the plist or launchctl print
  4. Validates the plist with plutil -lint
  5. Boots out any prior instance, bootstraps the new one, and waits for launchd to run a process for it — kickstarting once if none appears, and failing the install by label if none comes up
  6. Polls http://127.0.0.1:8765/healthz for up to 10s to confirm it came up

The plist runs the loom binary at the absolute path of whichever loom was on $PATH at install time. Rebuild to the same path and the next respawn picks up new code.

For a remote server, replace 127.0.0.1 with its reachable address when verifying from elsewhere; also open port 8765 or set up a tunnel as needed.

Receiver flags (for manual runs)

For foreground debugging:

LOOM_RECEIVER_TOKEN=<token> loom receiver
# → listening on :8765, storage=/Users/<you>/.loom/received
Flag Default Purpose
--addr :8765 Listen address
--storage $LOOM_HOME/received Where to write session files
--auth-token (empty; falls back to env) Shared bearer token; empty disables auth

Auth token can also come from LOOM_RECEIVER_TOKEN, or the persisted ~/.loom/receiver-token written at install time. If none is set, the server accepts all requests (dev/localhost only).


Client (remote machine) setup

Each machine you want to ship sessions from runs its own loom shipper daemon + config + launchd agent.

1. Create ~/.loom/config.json

{
  "server_url": "http://your-server:8765",
  "auth_token": "the-same-token-as-the-server",
  "interval_minutes": 10
}
Field Required Default Notes
server_url yes — Base URL of the receiver; no trailing /v1/ingest
auth_token no empty Bearer token; must match the server
interval_minutes no 10 In-process ticker cadence inside loom shipper daemon
interval_seconds no unset Same cadence in seconds; when set it takes precedence over interval_minutes. Must be positive.
summarizer_interval_seconds no 30 Watch-mode sweep cadence baked into the summarizer plist by loom install summarizer. Must be positive.

The seconds fields are the only ones the server role reads: a server has no server_url, and loom install summarizer reads summarizer_interval_seconds from $LOOM_HOME/config.json when the file exists, falling back to 30 when it does not. A zero or negative value in either seconds field is rejected rather than defaulted.

Permissions: chmod 600 ~/.loom/config.json — it contains the bearer token.

2. Install the shipper agent

loom install shipper

This:

  1. Loads $LOOM_HOME/config.json to read the tick cadence (interval_seconds, else interval_minutes)
  2. Writes ~/Library/LaunchAgents/com.loom.shipper.plist with KeepAlive=true, ThrottleInterval=10, RunAtLoad=true, and ProgramArguments=[<loom>, shipper, daemon]
  3. Validates the plist with plutil -lint
  4. Boots out any prior instance and bootstraps the new one

The shipper plist intentionally has no StartInterval — dasd coalesces those aggressively on modern macOS (observed gaps of 24h+ in production). The daemon stays resident, runs the capture+ship pass on its own time.Ticker, and respawns on crash via KeepAlive.

3. Verify

loom status
tail -f ~/.loom/transport/shipper.log

First tick walks every session under ~/.claude/projects/ and ~/.codex/sessions/ and ships each one in full. Subsequent ticks ship only new bytes per session.


Uninstall

loom uninstall

Removes every loom launchd agent (tolerant of any being absent) and their plist files. Preserves ~/.loom/ state and the installed binary. Full wipe:

rm -rf ~/.loom
rm -f ~/.local/bin/loom

Commands reference

The loom binary is the single entry point. Subcommands:

Command What it does
loom shipper daemon Long-lived shipper; capture+ship on interval_minutes. The launchd target.
loom shipper once Single pass; ship any new bytes and exit. Manual debugging.
loom shipper health Last-sync, pending count, uncaptured bytes per project.
loom receiver Run the ingest server (:8765 by default).
loom summarize [--watch] Fold received sessions into ~/.loom/summaries.db.
loom summarize --rebuild Drop and re-fold the summary DB; the upgrade path for schema bumps.
loom summarize --strict One-shot sweep that exits non-zero when any session errored; the form a ticket verify: line uses.
loom extract [--watch] Run extract.py over summarized sessions that haven't been extracted yet.
loom retrospect <ticket-id> Re-extract every session whose commits closed a ticket, truths + decisions.
loom work-report /work-run compliance metrics from the summary DB, as JSON.
loom cost-report Per-/work-run cost — turns, tokens, tools, time, conditions — as JSON.
loom run-report --run <id> One run's tree with parent, descendant and total metrics, as JSON.
loom ui Interactive dashboard (alias: loom tui); --run <id> opens a run's detail.
loom install <component> Components: server / remote / receiver / summarizer / extractor / shipper. server/remote also record the machine role.
loom uninstall Remove every loom launchd agent. State preserved.
loom status Launchctl state per component + sync health + config presence.

./install.sh is a transitional forwarder (--install-X → loom install X); use the loom binary directly going forward.


Updates

The key design fact: the launchd plist pins an absolute binary path at install time. Whatever writes a new binary to that path — the updater installing a release, or you building locally — the next scheduled tick runs it, no reinstall needed.

Released updates

Install the updater (see Auto-update). It polls GitHub Releases, installs each new release over ~/.local/bin/loom, and kickstarts every agent. This is the production update channel; nothing manual is required.

Testing an unreleased build

To run code that hasn't been released yet, build in place over the pinned path and kickstart the daemons. The shipper reads its interval at daemon startup, the receiver re-execs on KeepAlive, and the summarizer's watch loop is interruptible — kickstarting all three is enough.

cd loom
go build -o ~/.local/bin/loom ./cmd/loom

launchctl kickstart -k gui/$(id -u)/com.loom.receiver
launchctl kickstart -k gui/$(id -u)/com.loom.summarizer
launchctl kickstart -k gui/$(id -u)/com.loom.shipper

The updater will reinstate the latest release on its next tick, so use this only for local testing.

When a reinstall is required

Re-run loom install <component> when any of these change:

  • Binary path — you moved the loom binary off the absolute path the plist pinned
  • interval_minutes / interval_seconds in config — only take effect at daemon startup; reinstall (or kickstart -k) the shipper after editing
  • summarizer_interval_seconds in config — baked into the summarizer plist as --interval at install time; reinstall the summarizer after editing
  • LOOM_HOME — if non-default, it's baked into EnvironmentVariables on every plist
  • LOOM_RECEIVER_TOKEN — persisted to ~/.loom/receiver-token at install time; re-export and reinstall (or edit the file) to rotate

loom install boots out any prior instance with the same label before bootstrapping the new plist, so reinstall is always safe.

Wire protocol changes

Both sides import loom/transport/internal/wire. If IngestRequest or IngestResponse ever changes shape, update both sides at roughly the same time. There's no version byte and no negotiation. A mismatch produces 400-level responses on the server and failed= counts on the client until both are on the same version. Failed batches retry on the next tick — no data loss during a brief skew.

Cursor and offset file formats

Both are plain decimal integers in files on disk. Not negotiated, not serialized across the wire. Unlikely to change; if they ever do, a one-shot migration would run on each side independently.

What persists across updates

  • ~/.loom/config.json — user-owned, never touched by the binary
  • ~/.loom/transport/cursors/ — client cursors; keep them, they make updates resumable
  • ~/.loom/received/ — server storage; keep it
  • ~/.loom/transport/shipper.log — append-only, rotate manually if it grows
  • ~/.loom/summaries.db — disposable. The summarizer rebuilds it from ~/.loom/received/ on demand. When this binary's schema is newer than the on-disk DB, the summarizer refuses to open it and instructs the user to re-run with --rebuild.

Summary DB upgrades

loom summarize --rebuild       # drops summaries.db and re-folds from received/

Use this whenever the summarizer reports summary db schema is outdated. The DB is treated as a derived artifact — there's no migration path because there doesn't need to be one.

Verifying a sweep from a ticket

A parser fix is honestly checked by a full sweep reporting zero errors, so a loom ticket's criterion is written:

verify: loom summarize --force --strict

ticket_verify execs that line as a single argv with no shell, so a | grep -c errored=0 over the log is literal text and never runs; the exit code is the only channel it reads, and the binary has to own it. Without --strict a sweep exits 0 whatever its errored count, and a bare loom summarize criterion would record pass on any corpus. --strict makes the one-shot sweep exit non-zero when any session errored; it is refused together with --watch, which has no exit to report.

The flag was chosen over a ./scripts/verify-sweep.sh wrapper: the wrapper would need a shell to grep the count out of the log, plus one more entry in the host's verify_allow, and with bash already listed on this host the narrowing buys nothing. Listing loom itself in the machine-local verify_allow in ~/.ticket/config.yaml grants more than a sweep — loom extract spawns an agent, and the sweep writes ~/.loom/summaries.db — but the DB is a disposable derived artifact rebuilt from received/, the list is already shell-wide through bash, and this is an attended single-user host, so neither adds reach a verify line does not already have. A machine wanting the narrower list drops bash and keeps loom, accepting loom extract as reachable, or replaces loom with a ./scripts/… entry.

A full forced sweep is ~36s on this host today (3204 sessions), inside tk's default 120s verify_timeout; raise loom's per-project verify_timeout in ~/.ticket/config.yaml when the corpus outgrows it.


Troubleshooting

First stop for any issue: loom status. It reports which agents are loaded, whether the config exists, and prints key launchctl fields.

config not found at ~/.loom/config.json — client hasn't been configured. Create the file (see "Client setup" step 1), then re-run loom install shipper.

LOOM_RECEIVER_TOKEN not set and no ~/.loom/receiver-token (from a non-interactive loom install receiver) — export the token first: export LOOM_RECEIVER_TOKEN="$(openssl rand -hex 32)", then re-run the install, or run the install interactively to be prompted.

All POSTs return 401 — token mismatch. Check that the server's ~/.loom/receiver-token matches auth_token in ~/.loom/config.json on the client. To rotate, edit ~/.loom/receiver-token (or re-export LOOM_RECEIVER_TOKEN and reinstall) and reload the receiver.

Shipper logs shipped=0 skipped=60 failed=0 — nothing to ship. All cursors are at EOF for every session. This is the steady state.

Shipper logs shipped=0 skipped=0 failed=60 — everything failed. Check server_url (is it reachable? curl <url>/healthz), check auth, and check the receiver's log at ~/.loom/receiver.log.

another shipper is running, skipping — the flock is held. Usually means the daemon is running and you tried loom shipper once against it. That's expected; the daemon already covers the workload.

launchctl print says "Could not find service" — the agent isn't loaded. Re-run loom install <component>, or check the relevant plist exists under ~/Library/LaunchAgents/ and is valid (plutil -lint <path>).

Shipper runs but nothing appears on the server — partial lines aren't shipped until the trailing \n arrives. If you're watching an active session, the last turn may lag the shipper by one tick. Confirm steady-state by reading the log a few ticks later.

Shipper hasn't fired in hours despite KeepAlive=true — check launchctl print gui/$UID/com.loom.shipper. state = running means the daemon is alive; if so, look at ~/.loom/transport/shipper.log for done captured=… ticks. state = not running with last exit code != 0 means the daemon is crash-looping; the log will show why.

About

Capture coding-agent sessions, summarize activity, and extract durable project knowledge through a CLI and terminal dashboard.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages