CLI reference¶
For canonical flag lists, run lh <command> --help — this page is context, not a man page. Sections below follow the order of lh --help.
lh deploy¶
Deploys profiles, hooks, skills, and MCP server entries from your config to the agent's config directories. Run it after editing config.toml, after adding a new profile, after installing/uninstalling an MCP-backed memory tool (QMD, Engram), or after pulling repo changes that touch profile contents.
Its first deploy step assembles each profile's generated system doc from the role-named segments, before the profile symlinks are created. This also restamps the document with the running harness version after an upgrade; a sync error stops the deploy rather than linking stale content.
The MCP wiring step (ADR-024) probes each detected tool and merges an mcpServers block into every profile's .claude.json — the agent's own MCP file, not settings.json, which carries only hooks. Tools that are not installed get no entry. The merge is additive, so an entry for a tool you have since uninstalled is not pruned by a later deploy; remove it by hand.
It is idempotent: re-running on a clean tree is a no-op.
lh deploy # every profile
lh deploy --profile <name> # only that profile
Hooks the deploy leaves out, and says so¶
A profile runs one agent — [profiles.<name>].agent, else [agent].type — and
not every agent can feed every hook. A built-in declares the signals it needs to
read out of a transcript; when the profile's agent delivers none of them, the
deploy skips that hook for that profile and names it:
· stop-verify-guard omitted in 'throwaway': agent 'codex' does not deliver goal_status
This is not a deploy failure. The exit code is unchanged, every other hook in
that event is still written, and other profiles are unaffected — the line is the
deploy telling you that a hook you declared would have installed cleanly and then
never fired. lh doctor's Hook signals section reports the same pairs from
the same resolver, so a hook doctor names is a hook deploy leaves out. It applies
to hooks you declared explicitly in config.toml, not only to the defaults: a
capability the agent lacks is not something a declaration can override.
A built-in can also be declared for named agents only. pre-tool-use-graph-assist
runs on Claude Code alone, because Codex keeps graphify's own guard as the control
group of its measurement. Deploy leaves it out of every other agent's profile and
names that too:
· pre-tool-use-graph-assist omitted in 'cx': declared for agents claude-code
lh doctor reads the same merged set, so it never reports that hook on those
profiles.
Detected MCP servers that a profile's adapter does not place are also named,
for example · copilot/mcp: 3 detected servers not placed — adapter exposes no
MCP document (qmd, engram, graphify). This is informational and does not change
the deploy exit code.
--profile narrows the run to one profile: its system doc, its symlinks, its
settings.json and its MCP config are written, and no other profile is touched. An unknown
name fails without writing anything. The
snapshot below is still taken, and its manifest lists only the artifacts of the
profile that was deployed.
Snapshots and rollback¶
Every deploy first records the current state of every artifact it manages into
~/.config/lazy-harness/backups/deploy/<ts>/, and keeps the last ten. This is
unconditional — there is no flag to enable it and no version check that decides
whether it is worth doing.
The snapshot is a manifest rather than a directory of copied files: one entry per
artifact, carrying its absolute destination, its kind (file, symlink,
directory, or absent for a path that did not exist yet) and a content path
unique per destination. Keying on the destination is what lets two profiles'
settings.json be restored to their own profile instead of both receiving
whichever copy was written last.
lh deploy --snapshot # record a snapshot and exit, without deploying
lh deploy --rollback # restore every managed artifact from the newest snapshot
--rollback repoints symlinks that still exist, restores file contents, and
deletes artifacts that were absent when the snapshot was taken — so rolling back
a first deploy on a clean machine leaves nothing behind.
Migration backups (lh migrate) live under backups/migrate/ and are a separate
namespace: a deploy's prune cannot reach them, and lh migrate --rollback never
replays a deploy's log.
lh doctor¶
Checks environment health and reports the status of optional features. Use it as the first thing after install and any time something feels off. doctor is read-only — it never mutates anything.
The report is a sequence of sections. Most are silent when they have nothing to say, so a healthy machine prints far fewer than the full list below:
- Environment checks — Python version, agent binary present, config readable, profile dirs writable,
direnvdetected. -
Features section (ADR-025) — one row per memory-stack tool (
qmd,engram,graphify) with state (active,dormant,missing), installed version vs pinned version, and a one-line hint when something needs attention. Tools that need an explicit enable inconfig.toml(e.g.[memory.engram].enabled = true) show asdormantuntil the flag flips. -
Hook signals — one row per deployed hook whose declared signals the profile's agent cannot deliver. Each built-in declares what it needs to read out of a transcript (messages, tool calls, token usage, an explicit goal); an adapter's
TranscriptReaderdeclares what it can actually deliver. The row names the difference, so a hook that installs cleanly and would then never fire is visible before it is trusted —stop-verify-guardon an agent that supplies no goal status is the case it exists for. Deployed hooks are resolved the same waylh deployresolves them, not from a second copy of the defaults, and an adapter with no reader at all reports every declared signal as missing rather than erroring. An event the agent does not deliver at all is deliberately not reported here: nothing installs and nothing runs in that case, and it is the agent's event vocabulary that closes it rather than a transcript reader — collapsing the two would send you after the wrong mechanism. The section is omitted entirely when there is nothing to report. - Hook operations — one row per deployed hook whose declared
Operations (decision 9) this profile's agent cannot fully produce. Each built-in declares which tool operations it reasons about (run_command,read_file,modify_file,search_code); an adapter's own native-tool map declares which of those its tools can actually emit. The row names the operation that never arrives —pre-tool-use-securitylosingread_fileon Codex, whose tool map has no read tool at all — and says whether the hook is only partly blind or fully inert, the waypre-tool-use-read-sizeis on the same agent: it declares nothing else, so it installs, is asked about every tool call, and passes all of them. Closed by the agent's own tool map, never by this harness. The section is omitted entirely when there is nothing to report. - Transcripts — one line per profile answering a single derived question: is this agent writing transcripts that nothing reads? The verdict comes from the adapter and the disk, never from a declaration —
<config_dir>/<declared sessions dir>is globbed for*.jsonl, andisinstance(adapter, TranscriptReader)says whether a reader exists. Four outcomes: a reader with transcripts (reads), transcripts with no reader (unread— accurate for Copilot today, and a real regression the day it appears for Claude Code), a declared directory holding nothing (no transcripts, which also covers a declared directory that does not exist), and an agent that declares no sessions directory at all (declares no sessions directory). The absence of a location is decided first, and has to be:Path(x) / ""isx, so probing a config directory for an agent that declared nothing findssettings.jsonand reports every such agent as degraded. Nothing here failslh doctor— a reader that has not been written yet is not a misconfiguration of this machine. - Hook events — one row per deployed hook wired to an event this profile's agent does not carry at all. Distinct from a missing signal or a missing operation, both of which need the event delivered in the first place: here
hook_events()has no key for it, so the hook never installs and never runs. The fix is the agent's own event vocabulary, never aTranscriptReaderand never a tool-operation mapping. The section is omitted entirely when there is nothing to report. - MCP servers — one row per profile whose adapter produces the same fresh config plan with or without the detected MCP servers, naming every server that is therefore not placed. The footer explains that the adapter exposes no MCP document the harness may write and the binary owns its own file. This is reporting only, never a failed health check, and the section is omitted when every adapter places its servers or no server is detected.
- Profiles — one line per configured profile with its agent and whether its directory exists. Always printed.
- Shared roots — one line per directory two or more profiles claim, naming the claimants and which of them is the default. Silent when nothing is shared, and it never fails
lh doctor: an undeclared default is refused at launch, and this line exists so the refusal is visible before someone hits it. - Profile credentials — one line per non-default profile with no
<name>.envof its own in the secrets directory, which therefore launches under whatever credentials the ambient environment carries (ADR-045). The line names no environment variable and prints nothing out of the file — an enumeration of which variables count as credentials would report "clean" for whichever one it missed. Silent when every profile has its own file. - Plugin registry — one
✗line per Claude Code plugin registry entry (plugins/known_marketplaces.json,plugins/installed_plugins.json) whose recorded path does not exist, which makes Claude Code skip that marketplace's plugins silently. When the same path exists inside the profile it says to runlh deploy --profile <name>, which repairs it; otherwise to reinstall the plugin. Any such line failslh doctor. - Settings shape — the deployed
settings.jsonof every profile whose agent is Claude Code, run through the same detectorlh deploygates its writes with. One✗line per file carrying a key shaped like a hook declaration, naming the profile and the JSON path: Claude Code discards such a file whole — every hook, permission andenvwith it — and a profile nobody redeploys never meets the deploy-time refusal. The detector is a port of Claude Code 2.1.278's validator, so the line says so and shows what--versionreports for the binary that profile launches — the onelh runresolves, which prefers Claude Code's version-manager directory overclaudeonPATH(unknownwhen no binary resolves or the probe fails); it makes no claim about other releases. A file that cannot be read, is not JSON, or is JSON but not an object gets a!line saying its shape cannot be checked, without failing. Any✗line failslh doctor; silent when every file is clean or absent. - Knowledge dir — a single present/missing line for
[knowledge].root, printed only when one is configured. - Network egress — one line per remote
[metrics]sink, including a sink that is configured but inactive because the variable it names is unset or empty;local-onlywhen none is configured. Only the endpoint's origin is printed, never the resolved URL. - LLM roles — one row per role in
[llm.roles], each resolved and probed on its own so a broken entry is never reported by its healthy siblings (ADR-039). With no table declared it reports the deprecated single-backend form underdistill, because printing nothing would read as "nothing configured". - Engram persist — its own section, reporting the Stop-hook loop's health (
ok/warn/fail), classified fromengram_persist_metrics.jsonlin thelogs/directory of the agent runtime dir of the active profile, or of the[profiles] defaultprofile when run outside an agent session. Three thresholds apply: last-run age (warn ≥ 24 h, fail ≥ 7 d), recent failure rate over the runs considered (warn > 0%, fail > 10%), and cursor lag in bytes (warn when positive, fail ≥ 64 KiB unless the latest run hit its save cap, the persisted cursor advanced, and no save failed in that run). A large lag with verified cursor progress at the save cap is a warning labelledcatching up; a stalled cursor remains a failure. A skip event (theengrambinary was not found, so the run saved nothing) is a warning,N skipped, kept out of the failure rate because a skip is silent data loss rather than a failed save; the line says to set[memory.engram] binaryinconfig.toml. Entries quarantined after running out of save attempts are a warning,N entries quarantined, counted from each project's latest run until itsengram_quarantine.jsonl(beside that project'sengram_cursor.json) is emptied. PrintsNo runs yetwhen the hook has never fired. - Memory hygiene — project-memory drift for the resolved profile:
MEMORY.mdagainst its line and byte caps, the pending proposal queue, and weak session grades of the last seven days fromgrades.jsonlwith their most frequent issues. Only an over-capMEMORY.mdfails the check; the rest is informational. Silent when the working directory has no project memory. - Halted proposal queues — every
claude-md.proposal.mdon the machine, not just the current project's: each profile's declared sessions directory under<encoded>/memoryand every project under the knowledge store. One!line per queue at or above[compound_loop].max_pending_proposals— pending count, how many proposals the cap has held back when there are any — the lines ofproposals-held.jsonlnot yet requeued, the same setlh memory proposals held listshows (whenproposals-held-requeued.jsonlcannot be read, every held line is counted and an indented line names why) — oldest date (unknownwhen no rule sits under a dated header — the producer counts those toward the cap all the same), path, and thelh memory proposals list --memory-dir <path>that drains it — then the number of halted queues and the proposals pending in them, and the total held back. Counted with the same parserlh memory proposals listnumbers with, so archived blocks do not count. At the cap the compound loop stops queueing new proposals for that project — a proposal a session produces while the queue is full is held back inproposals-held.jsonl, never queued automatically — and the session-start notice only reaches sessions opened in it; this section is where a queue in a project nobody opens shows up. A warning, never a failure: a halted producer degrades memory capture, it does not break the machine. Silent when no queue is at the cap. - Evaluator contamination — counts the
handoff.mdfiles under the store'smemoryarea whose body carries the compound-loop evaluator prompt, and the session notes under itssessionsarea whose first user message is that prompt. The evaluator is a headless agent session, so its prompt reaches the store through the paths that file ordinary work; a regression of the guards that keep it out would otherwise stay silent. The store is resolved as the hooks resolve it (LAZY_KNOWLEDGE_ROOT, then[knowledge].root, then the default) and its area names come fromknowledge.toml. Only the first 8 KiB of each file is read. Healthy is one✓ noneline; contaminated is one!line with both counts and up to three example paths; no readable store is a greynot checkedline, and a scan that raises is a!line carrying the error. It never failslh doctor: contamination degrades what memory recovers, the machine keeps working. - Artifact versions — one line per deployed artifact written by a newer lazy-harness than the installed one. Reporting, not refusing: failing here would turn a stale artifact into an unusable machine. Silent when nothing is newer.
- Codex hook trust — per Codex profile, how many deployed hooks are untrusted and how many carry a stored hash. Nothing is ever reported as trusted: telling trusted from modified means recomputing Codex's own hash, which would mean reimplementing its TOML normalisation and going silently wrong on any upstream change, so a stored hash reports as
unknown. Never fails — untrusted is the expected state immediately after a deploy. Silent when no profile runs Codex, and when a Codex profile has no deployedhooks.json. - Scheduled jobs — one
!line per scheduled job whose last run exited non-zero, naming the job, the exit code and the log files it writes that exist on disk (<job>-stderr.log,<job>-stdout.log,<job>.logunder~/.local/share/lazy-harness/logs;no log files foundwhen none exist, as under systemd, which logs to the journal). Silent when every known exit is0; a job whose exit is unknown (never ran, cron, probe failed) is not a failure. It never failslh doctor: a registered job is reported bylh scheduler status, and this line says that one of them has been failing. - Graph repos — one line per repo in
lh knowledge graphscope (registered, plus those the graph-assist hook has been seen working in, markeddiscovered), printed only when at least one is in scope. A healthy repo is a single✓ … — okline; otherwise the repo gets a header and one line per problem: a hook ofpost-commit,post-checkout(graphify's) orpost-merge(the harness's) missing from<git-common-dir>/hooks, or not forwarded whencore.hooksPathis set (error); a graph older than the last commit touching a file graphify indexes as code, with the gap, or no graph at all; a lastgraphify-update.logresult for the repo that isfailed(timeouts included) orskipped; and top-level directories git ignores that the graph still holds nodes from, with the node count (warning). A configured repo whose path does not exist on this host is one grey· … — not on this hostline (skipped):config.tomlis shared across hosts, and another host may be the one that carries it. One last line, not per repo, compares the extension listlhvendors with the installed graphify'sCODE_EXTENSIONS; it readsskippedwhen graphify is absent or its interpreter cannot answer.graph.jsonis streamed line by line, never loaded whole. Anerrorin a repo present on this host failslh doctor(exit 1, in the text and--jsonforms alike); warnings, absent repos and a collector that could not run do not (ADR-071). A repo whose check crashes becomes anerrorline for that repo instead of aborting the report. - Launches — non-Claude launches per profile over 28 days, and the blast-radius kill criterion's verdict once its horizon opens; before that it prints the date the horizon opens.
A Sink freshness section reports, for every [metrics] sink plan_sinks() currently resolves as active (everything except sqlite_local, which is the local store itself, not a delivery pipeline), how long it has been since anything was last enqueued in sink_outbox — ok / warn (≥ 24 h) / fail (≥ 7 d) / missing (no history yet, not the same as stale). This catches a sink that quietly stopped moving data — e.g. an environment variable that resolved when the config was written but is unset wherever the scheduled ingest job actually runs — instead of doctor passing because the sink is merely configured. The same section reports two further, independent signals over the rows still undelivered in sink_outbox: how many times the drain has failed on the worst of them — warn at 3 failed attempts, fail at 6 — and how long the oldest of them has gone undelivered, warn at 24 h and fail at 3 days. Enqueue age cannot see a broken endpoint, because a sink that refuses every request keeps accepting enqueues just fine. Attempt counts are the right unit for a refused sink, since a failed row is returned to the queue with a longer backoff and no attempt ceiling. But neither signal sees a queue that stalls while every POST succeeds — a re-enqueue that returned an already-delivered row to pending made the drain re-send the head of the queue on every run and never reach anything behind it, with enqueue age at minutes and attempts at zero. That re-enqueue is now suppressed when the payload is unchanged, and a test holds ingest payloads byte-stable, so the stall is stopped at its cause. Age of the oldest undelivered row catches a queue that never delivers, and it is safe against the machine-nobody-used case that rules age out for the other two, because an idle machine has nothing undelivered to age. Age runs from when a row last became undelivered, not from when it was first enqueued: a reprice that returns weeks of delivered history to the queue is new work, not a stall. The thresholds stay loose enough for a real backlog to clear: the drain moves one batch per ingest, 50 by default, so even a 2500-event burst is gone in about 13 hours. The verdict is the worst of the two, so neither failure mode can hide behind the other's green. This line is printed only once the drain has actually failed: between an enqueue and the drain that follows it there is always an untried row, so a backlog on its own is not a fault. The section is omitted entirely when [monitoring].enabled is false or no remote sink is active, since neither case has anything to check.
lh doctor
lh doctor --json emits one JSON object instead of the Rich text above, for a script or CI gate to read by key rather than parse text: profiles, shared_roots, codex_trust, transcripts, launches, hook_signals, hook_operations, uncarried_events, mcp_gaps, graph_repos, evaluator_contamination. It is a deliberate subset of the text report — expensive sections that probe network or stateful filesystem data (egress, sink freshness, LLM backend reachability, memory hygiene, halted proposal queues, artifact versions, features, and settings shape, which runs the profile's Claude Code binary) are left out, while mcp_gaps reuses the local executable detection that deploy uses. graph_repos carries the same records as the text section (repos with path, discovered, status and checks; extensions; error, empty unless the collector itself failed) and reads local git state and graph.json only. This keeps --json fast and side-effect-free. The codex_trust verdicts are the same CodexHookTrust records the text section renders, so the two never disagree. Exit code and any error output share the same paths as the text form; only the successful case's stdout differs.
lh doctor --json
lh config¶
Interactive wizards that write a typed config block back into ~/.config/lazy-harness/config.toml (ADR-026). The wizards are opt-in — invoked explicitly per feature, never run on upgrade.
lh config <feature> --init runs the wizard for one feature, prompts for the values it needs, deep-merges the result into your existing config (preserving comments and unrelated sections), and exits. Re-running over an already-configured section asks before overwriting.
Currently shipped wizards:
lh config memory --init— writes[memory.engram]. Probes whetherengramis onPATHfirst; when missing, prints the pinned version and the install hint, then asks whether to write the section anyway (so a config can be staged before the binary lands). Prompts forenabled,git_sync(commit per-repo memory chunks under.engram/chunks/), andcloud(opt-in cloud sync, off by default to preserve the local-first guarantee). The pinned version is stamped automatically.lh config knowledge --init— writes[knowledge.structure]for Graphify. Same probe-and-stage pattern as the memory wizard: prompts forenabled. Pinned Graphify version is stamped automatically.
lh config migrate-knowledge is not a wizard. It rewrites an old [knowledge] block — path, sessions.subdir, learnings.subdir, and compound_loop.learnings_subdir — into the store-root shape, and is what the loader's error message points you at when it refuses a stale config. --root <path> sets the new [knowledge].root; a root you already set is never overwritten, and [compound_loop].lazymind_dir is left alone because it points at a vault rather than at the store.
Both wizards stamp the pinned tool version into the resulting block so lh doctor can later flag drift between the install and the config.
lh config memory --init
lh config knowledge --init
lh config migrate-knowledge --root ~/repos/lazy-knowledge
lh exec¶
Runs the agent non-interactively and writes one normalised JSON envelope to stdout. Where lh run execs the agent and disappears, lh exec stays alive to translate the agent's own output format into a provider-neutral result — so a caller can change agents without changing its invocation or its parsing.
The prompt is read from stdin, never from argv: argv is bounded by ARG_MAX (1 MiB on macOS) and a long prompt is not. Arguments after -- are forwarded to the agent verbatim; unknown arguments before -- are rejected rather than silently forwarded.
The agent's stderr passes through untouched. Nothing but the envelope is ever written to stdout, including on failure.
echo "summarise this repo" | lh exec --tier fast --no-tools
echo "$PROMPT" | lh exec --profile work --allow-tools Read,Grep --timeout 300
lh exec --dry-run --tier deep # plan only, no agent spawned, no stdin read
echo hi | lh exec -- --resume abc123 # everything after `--` goes to the agent
Selecting a model¶
--tier fast|balanced|deep is the provider-neutral vocabulary; each adapter maps it to a concrete model (Claude Code: haiku, sonnet, opus). --model <id> passes an id straight through and is not validated. The two are mutually exclusive, and with neither the provider picks its own default.
Tool policy¶
Three states, deliberately distinct:
| Flags | Meaning |
|---|---|
| (neither) | Leave the agent's own tool policy alone |
--no-tools |
Deny every tool the agent can be told to deny — pins the call to one turn |
--allow-tools Read,Grep |
Grant exactly these |
--allow-tools "" is refused: passing an empty allow-list to Claude Code is a no-op that silently leaves the default read tools enabled, so the ambiguity is rejected instead of inherited.
Isolation from the profile¶
A headless run is not a user session, so Claude Code is started with --setting-sources "": no user, project or local settings file is loaded. No plugin hook and none of the profile's session hooks fire — the run's prompt is not captured into memory, no compound-loop task is queued and no session note is exported.
The profile's tool guards are kept. Every hook lh deploy writes for the resolved profile under an event that can deny a tool call — PreToolUse, including pre-tool-use-security and pre-tool-use-git-scope with their onFailure: block — is passed back with --settings, with the same commands, matchers and --profile. External [hooks.pre_tool_use].external commands from config.toml come back with them; hooks another tool wrote into settings.json directly do not. A profile that declares no such hook gets no --settings, and an external command with an invalid placeholder — which lh deploy refuses too — makes lh exec exit 70 with error.kind: "config" rather than run unguarded.
What is still dropped with the settings: their permissions (tools are granted only by --allow-tools), their env block, their default model, the plugins, the MCP servers, and every hook on an event that cannot deny a tool call (SessionStart, UserPromptSubmit, Stop, SessionEnd, PreCompact, PostToolUse, PermissionRequest and the rest).
The transcript is kept. The session id lh exec pins is how a killed run is still priced and how --workload is attributed, and both read it.
A caller that needs the profile's settings — to exercise its hooks, for instance — forwards its own list after --, where the last one wins: lh exec -- --setting-sources user,project,local.
Inference mode: --role¶
lh exec --role <name> runs one completion through the backend that role names in [llm.roles], instead of launching the agent. No tools, no session, no agentic loop — the right shape for classification and other single-turn work, and the only way to reach a local model such as Ollama or MLX.
printf '%s' "$PROMPT" | lh exec --role classify --workload file-triage
lh exec --role classify --dry-run # resolved plan, no tokens spent
The envelope is the same lh.exec/v1 document, with mode naming which kind of run produced it:
| Field | In inference mode |
|---|---|
mode |
"inference" — non-null in both modes, so the null token fields are unambiguous |
output |
Always a string, never null. "" on every failure |
error.kind |
One of the five kinds below; non-null whenever success is false |
cost_usd |
Always null in inference mode, on every backend, together with cost_source; the cost is in the lh status tokens row |
harness.backend, harness.model |
Which one actually answered |
num_turns, session_id |
null — structural, not a failure |
prompt_tokens, output_tokens, cache_read_tokens, cache_creation_tokens |
The provider's usage report; null when the backend reports none (see below) |
An envelope with mode: "agent" is field-for-field what lh.exec/v1 emitted before this flag existed; mode is the only addition, so no existing consumer has to change.
Failure kinds and exit codes¶
error.kind |
Exit | What to do next |
|---|---|---|
backend-unreachable |
70 | Retry, or abandon the batch |
timeout |
124 | Retry with less input |
schema-violation |
70 | Do not retry the same model — it will repeat |
empty |
70 | Treat as an abstention |
backend-error |
70 | Read error.message |
Exit 2 is never emitted for a failed inference. It stays the usage-error code — a bad flag combination, raised before anything runs and carrying no envelope — so a caller that branches on the exit status before parsing stdout never discards a failure whose cause is in error.kind.
These five are exhaustive for mode: "inference"; the agent-mode kinds no-envelope and agent-error never appear here.
Flags alongside --role¶
| Flag | With --role |
|---|---|
--timeout, --profile, --dry-run |
Accepted, unchanged |
--workload |
Accepted; the recorded spend row is labelled inference:<role>:<label> |
--no-tools |
Accepted, explicit no-op |
--allow-tools |
Error |
--tier, --model |
Error — the role names the model |
--no-tools and --allow-tools are not symmetric. On a backend with no tools, --allow-tools requests a capability that does not exist and fails loudly; --no-tools merely asserts what already holds, so it is accepted. A wrapper may emit it unconditionally without branching on whether it passed --role.
The envelope's prompt_tokens, output_tokens, cache_read_tokens and cache_creation_tokens are the provider's own usage report, summed over the models the call billed; they stay null when the backend reports none. cost_usd stays null on this path — the cost is in the lh status tokens row.
--dry-run returns the same envelope shape — dry_run: true, success: true, exit_code: 0, output: "" — with the resolved backend, model, base_url and, when the backend uses api_key_env, whether that variable resolves. Never its value.
Attributing a run¶
--workload <label> records a free-form label against the session id lh exec
pins before it spawns the agent, so the cost the ingest later derives from that
session carries the label. It also reads LH_WORKLOAD, which is what lets a
caller configured as a plain binary string — lh exec --profile claude-personal — supply
one without changing its invocation.
The harness never interprets the label. Omitting it records nothing.
echo "$PROMPT" | lh exec --profile claude-personal --workload nightly-index
LH_WORKLOAD=nightly-index; export LH_WORKLOAD # same effect
lh status tokens --by workload --period all # what each caller cost
The label survives a run killed by --timeout, which is the point: the label is
written before the agent is spawned, so a kill cannot outrun it.
Envelope¶
{
"schema": "lh.exec/v1",
"dry_run": false,
"success": true,
"exit_code": 0,
"output": "…the agent's reply…",
"cost_usd": 0.0619,
"cost_source": "agent",
"duration_ms": 2100,
"prompt_tokens": 30885,
"output_tokens": 42,
"cache_creation_tokens": 30876,
"cache_read_tokens": 0,
"num_turns": 1,
"error": null,
"harness": {
"profile": "work",
"profile_source": "root-match",
"agent": "claude-code",
"binary": "/…/claude",
"config_dir": "/…/.claude-work",
"lh_version": "0.47.0",
"argv": ["/…/claude", "-p", "--output-format", "json", "--setting-sources", "", "--settings", "{\"hooks\": {\"PreToolUse\": […]}}"]
},
"raw": { "…": "the provider's own envelope, verbatim" }
}
Every numeric field is null when the provider did not report it — never 0. A provider without a prompt cache reports null cache tokens, because "no cache exists" and "nothing was cached this call" are different facts and a 0 enters a cost report as the second one. prompt_tokens is the sum of the uncached, cache-creation and cache-read counts: Claude Code's own input_tokens covers only the uncached slice of the final turn and reads as single digits on a 100k-token prompt.
cost_source says which door the figure came through: agent for the provider's
own number, passed through verbatim, or transcript for one measured from the
session the run wrote. It is non-null if and only if cost_usd is. The two can
disagree — the harness pricing table is the only thing that notices when a
provider's own accounting drifts — so a consumer summing costs across runs needs
to be able to tell them apart.
This cost_source is a different vocabulary from the one a metric row carries
after lh metrics ingest (ADR-050: pricing / subscription / null). The
two fields share a name across two unrelated schemas — this envelope, frozen
field-for-field under mode: "agent", and MetricEvent v3 — by coincidence,
not because they answer the same question: this one names which subsystem
produced cost_usd on one ad-hoc run, the ingest one names whether per-token
pricing applied and succeeded for a stored row. See Flat-rate agents and
unknown models
for the latter.
A run killed by --timeout is billed from its transcript rather than reported as
free: the session id is pinned before the spawn, so the turns the agent flushed
before the kill are still on disk under a known name. cost_usd, prompt_tokens,
output_tokens and the two cache counters are filled from it; num_turns and
duration_ms stay null, because the transcript does not measure them. All of
them stay null when the kill landed before the first turn was flushed, or when
any model in the transcript has no entry in the pricing table — tokens counted is
not the same fact as run priced.
harness.profile_source is explicit (--profile), root-match (the cwd fell under a configured root) or default-fallback (nothing matched, so the default profile was a guess). A caller recording which provider it was billed for should record this alongside the cost.
Exit codes¶
| Code | Meaning |
|---|---|
0 |
The agent ran and succeeded |
| (agent's own) | The agent ran and exited non-zero; mirrored verbatim |
2 |
Usage error — conflicting or unknown flags, unknown tier |
70 |
Harness failure before the agent ran: bad config, unknown profile, agent cannot run headless, binary not found, empty prompt |
124 |
The agent exceeded --timeout and its process group was killed |
error is null on success and otherwise carries a kind and a message. A
run that failed always carries one: success: false with error: null is not a
shape this command emits.
kind |
What happened | Where the cause is |
|---|---|---|
timeout |
The process group was killed at --timeout |
The timeout is the cause |
no-envelope |
The agent ran, exited non-zero and left nothing parseable on stdout | Its stderr, which passed through to the caller's and is not captured here |
agent-error |
The agent ran and reported its own failure in a well-formed envelope | output, which carries the agent's message |
config, empty-prompt, spawn-failed, and the launch kinds |
A harness failure before the agent ran | message, which is the whole explanation |
A caller that needs the cause of a no-envelope durably has to keep the agent's
stderr itself; lh exec never captures it. An agent that exits 0 with
unparseable stdout is not a failure at all — success is derived from the exit
code when the output cannot be parsed — so it carries error: null.
--timeout (default 600s, 0 disables) is owned by lh exec, which kills the agent's whole process group — killing only the direct child leaves grandchildren such as MCP servers running and billable. A caller wrapping lh exec in its own timeout should set that backstop above this one.
lh hook¶
Invokes a single built-in hook by name. This is what settings.json entries actually call — lh deploy writes lh hook <name> --profile <profile>, and the command reads the agent's payload on stdin, runs the builtin, and writes the agent's own output format back out.
--profile names the profile the hook runs under, which decides its memory scope and its metrics label. It is optional: without it the profile is resolved from the agent's config-dir variable, which is how entries written before the flag existed keep working. A profile name no config declares — a rename or a redeploy the settings file has not caught up with — is refused before any builtin runs, and no other profile is substituted: the profile decides the memory scope and metrics label, and a caller's agent never proves which identity it meant. A blocking hook answers with the caller's native denial when the payload identifies the agent (Claude Code sends prompt_id, Codex sends turn_id), otherwise it exits 2 with a diagnostic. An informational hook emits no context and reports the unknown profile on stderr. Repair the deployed --profile value by redeploying. An empty profile table, a machine that has not run lh init, is not a refusal.
You should rarely run this by hand. It is documented so the entries in profiles/<name>/settings.json make sense.
lh hook compound-loop --profile personal
lh hooks¶
Manages hooks (note the plural — distinct from lh hook).
lh hooks list enumerates every hook the harness knows about: built-ins shipped with lazy-harness, plus user hooks declared in [hooks.<event>] sections of config.toml.
lh hooks run <event> fires every hook registered for an event. This is the debugging entry point — use it to reproduce a hook misfire without bouncing through the agent.
lh hooks list
lh hooks run SessionStart
lh init¶
Initializes lazy-harness for a fresh machine: writes ~/.config/lazy-harness/config.toml, sets up data and cache dirs, and primes a default profile. Refuses to run on a machine that already has Claude Code state — that is what lh migrate is for.
Pass --force to wipe and reinitialize, backing up the existing config first.
lh init
lh init --force
lh knowledge¶
Manages the knowledge store and its QMD index.
lh knowledge init creates the store, writes its knowledge.toml marker, and creates the subdirectories the marker declares. --root <path> overrides where; without it the store root is resolved from $LAZY_KNOWLEDGE_ROOT, then [knowledge].root in config.toml, then the built-in default. Re-running it is safe: an existing marker is never overwritten.
lh knowledge path prints an absolute path inside the store. --kind root (the default), --kind sessions, or --kind learnings; the two subdirectory names come from the marker, never from configuration. It exits non-zero when the store has no readable marker, which makes it usable as a guard in shell scripts.
lh knowledge push runs one commit / fetch / merge / push cycle over the store and appends the outcome to knowledge-push.log. It is the command the scheduler job runs, and it is also safe to run by hand before switching machines. A clone that is only behind is fast-forwarded and reports updated. Diverged clones are merged, never rebased: handoff.md conflicts resolve newest-wins by their written_at timestamp, while *.jsonl and claude-md.proposal.md are union-merged through a .gitattributes the cycle maintains. A concurrent cycle is skipped silently (exit 0); a conflict on any other path, or a handoff without a usable timestamp, aborts the merge and exits 1. Producers — the session-export hook and the compound-loop worker — never invoke git themselves, so a dead remote or missing network delays a push but cannot lose a write.
lh knowledge status shows where the store lives, how many sessions are inside, and whether QMD is reachable.
lh knowledge sync rebuilds the BM25 lexical index. lh knowledge embed runs vector embedding for semantic search. Both accept --collection <name> to scope to one collection instead of all of them.
lh knowledge context-gen regenerates the auto-updated stats blocks inside QMD collection contexts (the <!-- auto --> markers). --dry-run shows changes without writing.
lh knowledge handoff-now forces a compound-loop evaluation for the current session, bypassing the debounce and growth gates the Stop hook applies. Same semantics as the SessionEnd hook (ADR-019); use it manually before /compact, before closing a terminal without /exit, or on Claude Code builds that predate the SessionEnd event.
lh knowledge export-session <session-file> is the escape hatch for sessions the Stop / session-export hook skipped — for example, a real session the non-interactive heuristic mis-classified. --force bypasses the interactive-session filter and the unchanged-file guard.
lh knowledge graph add|list|update manages the repos whose code graph is kept fresh.
Nothing rebuilds a code graph on its own in a worktree-first workflow: graphify's post-commit hook exits early when the git dir differs from the common dir, so a commit made inside a worktree never triggers a rebuild — and a squash merge landing on the remote produces no local commit at all. graph update is the scheduled sweep that closes that gap. Point a [scheduler.jobs.*] entry at it.
add registers a repo after checking it really is one, and is idempotent. It updates repos under [knowledge.structure] through the normal config writer, which preserves comments, formatting, and every key this version does not model. update walks every repo in scope, skips ones that have gone missing, keeps going past a repo that fails, and exits non-zero if any did. update --repo PATH updates just that repo, registered or not; it is what the post-merge hook below launches.
The scope is the registered repos plus the repos the graph-assist hook has been seen working in: any repo with a graphify-out/graph.json that appears as repo in a profile's graph_assist_metrics.jsonl. Discovered repos are kept in graph-repos.json under the lazy-harness data directory, never in config.toml, and graph list marks them discovered. A discovered repo stays after the metrics stop naming it, and is dropped on the next update once its graphify-out/graph.json is gone.
add and every update repair each repo first and print one repair line per action that was not already in place (a failed repair is reported and logged, and does not fail the run):
- post-merge hook — a marked block in the repo's
hooks/post-mergethat runslh knowledge graph update --repo <repo>detached after a pull or fast-forward, with output ingraphify-post-merge.log. It does nothing inside a linked worktree and never fails the merge. Another tool's content in the same hook is kept. - graphify hooks — runs
graphify hook installwhen graphify'spost-commitorpost-checkoutblock is missing.core.hooksPathis overridden for that run so the hooks land in the repo, not in a shared hooks directory. - ignored paths — directories git ignores only through the global excludes file are mirrored into a marked block of the repo's
info/exclude. Git's behaviour does not change; graphify, which does not read the global file, stops indexing them. The block is rebuilt on every run. After each successful rebuild it writes the graph-assist index (graphify-out/cache/lh-graph-assist.json) thatpre-tool-use-graph-assistreads; an index that cannot be written is reported and never fails the repo. Outcomes append tographify-update.log.
lh knowledge graph-assist report [--since YYYY-MM-DD] measures the pre-tool-use-graph-assist hook against its kill criteria, read-only. It reads every profile's transcripts from the profile's own config_dir and the hook's logs/graph_assist_metrics.jsonl, and reports per agent: sessions in repositories holding a graph, graph touch (sessions where the agent called graphify or the hook answered), graphify calls against code greps and, for Claude Code, hit precision, p95 latency and deflection. Sessions that made no tool call are left out. The default window is the last 14 days. A tripped criterion is listed; it becomes a decision only at the end of the rollout window.
lh knowledge init
lh knowledge path --kind learnings
lh knowledge push
lh knowledge status
lh knowledge sync --collection my-project
lh knowledge embed
lh knowledge context-gen --dry-run
lh knowledge handoff-now
lh knowledge export-session ~/.claude/projects/-Users-me-repo/abc123.jsonl --force
lh knowledge graph add ~/repos/my-project
lh knowledge graph list
lh knowledge graph update
lh knowledge graph-assist report --since 2026-09-25
lh memory¶
Diagnostic commands for the memory stack. Read-only inspection, a propose-only consolidator, and the Reconcile/Decay stages from ADR-040 — none of these write to MEMORY.md directly. The only commands that write at all are decay --apply (learning marks), proposals accept, reject and apply and proposals held requeue (the proposal files), and migrate --apply (moves memory into the store). Pair them with lh status memory for per-project counts.
lh memory status¶
Inventory of the current project's memory: the key its store path is built from, the resolved store directory, and what sits in it — MEMORY.md in lines and bytes, the curated memory documents, record counts and last-written dates for decisions.jsonl, failures.jsonl, grades.jsonl and the /coherence-audit ledger coherence-drift.jsonl (each only when present), and proposals by state.
It reports; it does not judge. The hygiene verdict — MEMORY.md against its ceiling, the age of the oldest pending proposal — belongs to lh doctor, and the cross-project table to lh status memory.
When the agent's own project directory still holds memory of its own, that appears as a second block. Two directories can carry a MEMORY.md for one repository and only the store one is read once a store resolves; lh memory legacy-check classifies the leftover.
A checkout with no git remote reports a local/ key and says so: memory deliberately stays out of the shared store, which is a git repository that gets pushed, rather than merging two machines' unrelated directories under one name.
lh memory status
lh memory status --memory-dir ~/repos/lazy/lazy-knowledge/memory/github.com/me/my-repo
lh memory consolidate¶
Proposes additions to MEMORY.md distilled from the most recent decisions and failures in the per-project memory dir. The command is read-only: it prints a proposal to stdout (typically a few bullet points fit for the curated semantic layer) and never edits MEMORY.md itself. Pair it with the warning emitted by pre-tool-use-memory-size when the file is near the 200-line or 12KB ceiling (ADR-030 G2).
Flags:
--memory-dir <path>— directory to read from. Defaults to<cwd>/memory.--last <n>— tail this many entries from each JSONL. Default50.--model <id>— headless model used to draft the proposal. Defaults to the model of thedistillrole.--timeout <seconds>— LLM invocation timeout for the backend thedistillrole routes to. Default120.
lh memory consolidate
lh memory consolidate --memory-dir ~/repos/lazy/lazy-knowledge/memory/github.com/me/my-repo --last 100
lh memory decay¶
Marks learnings status: superseded when they are still active and their origin_session is at least --horizon-days old (ADR-040). Nothing in the harness logs when a learning is retrieved, so age since origin_session is the deterministic proxy for "unreferenced" — a learning nobody has reinforced by the time the horizon closes.
Dry-run by default: lists candidates with their age and title. --apply rewrites status, deprecated_by, deprecated_on and deprecated_reason in place — the four fields every learning already carries as null — and leaves everything else in the file byte-identical. The command never deletes a file, and a learning once marked is not reconsidered on a later run.
Flags:
--learnings-dir <path>— directory to scan. Defaults to the knowledge store'slearnings/tree.--horizon-days <n>— age threshold in days. Default90.--apply— write the marks. Off by default.
lh memory decay
lh memory decay --horizon-days 180
lh memory decay --apply
lh memory reconcile¶
Reports two kinds of drift in decisions.jsonl, never resolves either (ADR-040):
- Schema drift — lines that do not share the field set most lines use, grouped and reported with line numbers. Deterministic, runs by default, scoped to every project in the knowledge store unless
--memory-dirnarrows it to one. - Contradicting decisions — pairs of entries within one project whose summaries conflict, found by sending the tail of
decisions.jsonlto thedistillrole. Off by default behind--check-contradictions: a barelh memory reconcilefans out over every project, and an LLM pass over all of them should be a deliberate choice.
There is no --apply. Schema drift has nothing correct to backfill; a contradiction is for a human to resolve, not the command.
Flags:
--memory-dir <path>— one project's memory dir. Defaults to every project in the store.--last <n>— tail this manydecisions.jsonlentries per project for--check-contradictions. Default50.--check-contradictions— also run the LLM contradiction pass.--model <id>/--timeout <seconds>— same asconsolidate, only used with--check-contradictions.
lh memory reconcile
lh memory reconcile --memory-dir ~/repos/lazy/lazy-knowledge/memory/github.com/me/my-repo --check-contradictions
lh memory rightsize¶
Lists every CLAUDE.md the harness can reach and which ceiling, if any, it breaches. Read-only.
Two populations are covered: each profile's own contract at <profile config_dir>/CLAUDE.md, which is loaded on every session in that profile, and the CLAUDE.md of every project reachable under a configured [profiles.*].roots entry — whether or not that project has memory in the knowledge store yet, since a contract can be oversized long before the project accumulates any.
The thresholds are the same 200-line and 12KB ceilings pre-tool-use-memory-size warns against, read from the same [hooks.pre_tool_use] config, so the command and the hook cannot silently disagree about what counts as too big.
lh memory rightsize
lh memory budget¶
Shows a read-only inventory of static instruction files selected for a profile and working directory. It lists global and repository-chain files, bytes and lines per file, their total, per-file configured limits, missing, unreadable, shadowed or truncated files, and known alternatives. The total covers selected readable source files only; when a Codex document is partially included, that total is an upper bound for this known subset, not the entire static prompt. Dynamic hook output, skills, tools, and Codex CLI or layered config overrides remain unknown. Claude imports, rules, and local or managed instructions are unresolved. The byte total is not a model-token measurement. Symlink aliases are reported and counted by path; their injection multiplicity is unresolved.
lh memory budget
lh memory budget --profile personal --cwd /path/to/repo --json
lh memory legacy-check¶
Reports per-project memory still sitting in a profile's projects/ tree rather than in the knowledge store, and classifies each one:
- orphaned — the store holds no copy, so nothing reads this memory any more. Move it with
lh memory migrate. - superseded — every line of every file in the leftover is present in the store copy, so the leftover is safe to delete. Coverage is line-level rather than byte-level on purpose: the store's
decisions.jsonlis an append-only superset that is never byte-identical to what was left behind. - diverged — the store holds a copy, but not all of it: the leftover has at least one file, or one line, the store lacks.
detailnames the files. Not safe to delete — diff them first. This is the case a presence-only check reported assuperseded. - unkeyable — the checkout it was named after is gone, or has no git remote to key on, so there is nowhere to move it.
lh memory legacy-check
lh memory proposals¶
Lifecycle for the claude-md.proposal.md entries the compound loop appends (see Memory: the compound loop). Every subcommand takes --memory-dir <path>; by default the per-project memory dir is resolved the way every other reader resolves it — into the knowledge store, keyed by the repository's git remote (core/memory_store.py), with the legacy per-cwd dir as the fallback. Inside a linked git worktree the key comes from the main checkout, so proposals stay on the file the loop writes to instead of following each worktree. lh memory status prints the resolved path.
lh memory proposals list— numbered table of pending proposals (index, date, rule excerpt).--jsonemits the same listing as JSON, indices included, forapply.lh memory proposals accept <N>— removes entry N from the pending file, archives it toclaude-md.accepted.mdwith the acceptance date, and prints the full rule. It never editsMEMORY.mdorCLAUDE.mditself — pasting the rule is the human's call.lh memory proposals reject <N> --reason "<text>"— removes entry N and records it inclaude-md.rejected.mdwith the date and reason. That file is an immunity registry: the grading prompt carries as many of its rules as fit a 20,000-character budget, newest first, with an instruction not to re-propose equivalents.lh memory proposals apply --verdicts <file>— drains many at once. The file is a JSON list of{"index": N, "verdict": "accept"|"reject", "reason": "..."}numbered againstlist --json. Every entry is validated before anything is written, and the verdicts are applied back-to-front, so an out-of-range or duplicate index leaves the queue untouched and the caller never has to iterate in reverse to avoidaccept/reject's shifting positions.lh memory proposals held(orlh memory proposals held list) — read-only listing of held proposals.held list --jsonemitsproposalswith source line indices and identities plusmalformed_lineswith excluded line numbers.--memory-dirmay sit onheldor on the subcommand; given in both places with different paths, the command refuses rather than pick one.lh memory proposals held requeue <N>— explicitly requeues source line N if the pending queue has room under[compound_loop].max_pending_proposals. A full queue reports its count and cap. The held source is preserved; a durable disposition records the action. Repeating the command cannot add the same source twice, including after an interrupted write. A rule already pending or previously requeued, compared after trimming surrounding whitespace as the pending parser does, is recorded as a duplicate without another pending entry.
The requeue protocol flushes and replaces the pending file before flushing and replacing the disposition file. Review commands reconcile a pending source marker before removing that proposal. This recovers process interruptions between steps. Directory syncing is attempted where supported; no power-loss guarantee is claimed on filesystems that reject directory syncing.
A queue that is never drained does not sit still: when a new batch would exceed [compound_loop].max_pending_proposals, the loop holds the whole batch in proposals-held.jsonl instead, and the session-start context says so. Capacity is checked while holding the project's memory lock.
lh memory proposals list
lh memory proposals accept 1
lh memory proposals reject 2 --reason "too strict for this repo"
# Whole queue in one pass
lh memory proposals list --json > /tmp/queue.json
# ...write verdicts against those indices...
lh memory proposals apply --verdicts /tmp/verdicts.json
lh migrate¶
Takes an existing Claude Code install and converts it into a lazy-harness setup: backs up state, detects profiles and LaunchAgents, rewrites paths, writes a new config.toml.
--dry-run analyzes and prints the plan without touching anything; the real migration requires a recent dry-run before it will execute. --rollback undoes the last migration using its rollback log.
lh migrate --dry-run
lh migrate
lh migrate --rollback
lh metrics¶
Manages the metrics pipeline: session-rollup ingestion plus per-event sink fanout.
lh metrics ingest¶
Walks each profile's agent-declared sessions directory (session_path(agent, config_dir, "sessions")), aggregates token usage per (session, model), prices it with [monitoring.pricing] overrides (falling back to DEFAULT_PRICING), and UPSERTs into session_stats. The pipeline is safe to run repeatedly — it re-reads every transcript on each run, so a pricing change reprices every session whose transcript is still on disk. Re-ingesting the same file is idempotent: totals are re-computed from the full (append-only) JSONL and overwrite prior rows, so token counts never accumulate double.
After the SQL upsert, every active sink declared in [metrics].sinks writes the resulting events. With the default ["sqlite_local"], that is a no-op write into the same DB. With http_remote added, a normal ingest also opportunistically drains the outbox in the same process, so a single lh metrics ingest tick covers both write and ship. Failed deliveries and drain exceptions print a warning on stderr without changing the exit code.
--dry-run parses everything into an in-memory DB so you can preview the scan without touching the real one. It does not write to sinks, enqueue events, or contact remote collectors. -v/--verbose surfaces any per-file errors the walk hit.
Pair with lh scheduler to keep the DB fresh — add a job under [scheduler.jobs.metrics-ingest] with a cron expression (e.g. */15 * * * *) calling lh metrics ingest.
lh metrics ingest --dry-run
lh metrics ingest
lh metrics drain¶
Force-drains the outbox for every configured remote sink without re-running ingest. Useful after a backend outage to flush the backlog without paying the cost of re-scanning every JSONL. Honors the same per-row exponential backoff and 60-second lease as the opportunistic drain inside ingest.
lh metrics drain
Output is one summary line: drain complete: <sent> sent, <failed> failed.
lh metrics backfill-host¶
Stamps the host dimension on stats rows written before it existed, and re-queues the events the remote already received without it.
Re-ingesting cannot recover these: the transcripts they were derived from are deleted long before the rows are. What makes the local host the right answer anyway is what a metrics DB is — the ingest reads only the transcripts on the machine it runs on and writes only that machine's store, so a row with no host was produced by the machine holding it. Run it on the machine that owns the DB. The default host is that machine's own; --host overrides it for a DB you are repairing from elsewhere.
Only rows the outbox still carries are re-queued. derive_event_id does not take host, so a resend upserts the remote row in place rather than landing as a second one — the cost is corrected, never doubled. A row the outbox no longer holds is stamped locally and left alone remotely: it was never sent, and minting an entry for it would push unsent history under the guise of a fix.
lh metrics backfill-host --dry-run
lh metrics backfill-host
# stamped 2526 rows as LazyMBP · 387 events re-queued for resend
Idempotent: a second run finds nothing left to stamp and reports 0.
lh metrics rename-profile¶
Renames a profile in the local metrics store: session_stats, loop_events and launches, in one transaction. session_stats rows also get a fresh event_id, since the remote sink upserts by that id and leaving it derived from the old name would double-count the next re-ingest or re-send of a continuing session.
lh metrics rename-profile lazy claude-lazy
# renamed 'lazy' -> 'claude-lazy':
# session_stats: 214
# loop_events: 12
# launches: 8
new must already be a profile config.toml declares — this command renames rows that exist, it does not declare a profile the config has never heard of. Idempotent: a second run finds no rows left carrying the old name and reports 0 everywhere.
Refuses when a sink_outbox row is still pending for one of the affected event_ids, when a pending row's own payload still names the old profile, or when a pending row's event_id matches no session_stats row at all, whatever its profile — a row already re-keyed in place is invisible to a join on event_id. Renaming out from under any of them would leave that row referencing an id the remote will no longer recognise. Drain the outbox first with lh metrics drain.
lh metrics status¶
Prints the local database summary (session count, accumulated cost, path), then per-sink outbox counters (pending, sending, sent) for every non-sqlite_local sink. Use it to spot a stuck http_remote without sqlite3-ing the DB.
lh metrics status
# sqlite_local 8431 sessions $412.87 ~/.local/share/lazy-harness/metrics.db
# http_remote pending: 12 sending: 0 sent: 8431
When sqlite_local is the only configured sink — the default — the command reports that no remote sinks are configured instead of printing nothing.
Mechanics — sinks, outbox, drain policy, idempotency: how the metrics ingest pipeline works.
lh metrics record-verify¶
Records a verify_ran event for the current session — the producer stop-verify-guard reads to decide whether a session that declared a goal actually verified before closing.
lh metrics record-verify # session id from $CLAUDE_CODE_SESSION_ID
lh metrics record-verify --session <id>
--session names the session explicitly; without it the command reads CLAUDE_CODE_SESSION_ID, which the agent sets in the environment of every command it runs and which carries the same value the Stop hook receives in its payload. With neither, the command fails rather than recording under an empty key. --db PATH overrides the database outright; without it the command resolves it exactly as the guard does, through resolve_db_path().
Invoke it as the final step of a verification procedure, never on its own: recording that verification happened without having verified silences the guard, which is worse than recording nothing.
lh metrics loops¶
Reports counts from the loop_events table, grouped by kind (e.g. session_closed, nontrivial_prompt, goal_declared, goal_absent), followed by the declared-goal rate. This is the phase-0 sensor data for the feedback-loop feature — see how the hooks complement each other for what writes to this table and when.
--days N restricts the count to events from the last N days; omit it to count everything. --db PATH overrides the DB path outright, bypassing config resolution entirely — useful for inspecting a specific file. Without --db, the command resolves the same way every other lh metrics/lh status command does: [monitoring] db from config.toml if set, otherwise data_dir()/metrics.db.
The declared rate is goal_declared / (goal_declared + goal_absent), rendered as a percentage with the raw counts alongside it; considered == 0 prints 0% rather than dividing by zero. Both event kinds come from the compound-loop worker grading a session, one verdict per session it grades — a UserPromptSubmit hook fires before any assistant text exists and cannot itself judge whether a criterion was declared. nontrivial_prompt is the separate, per-prompt sensor recorded by user_prompt_goal.py.
lh metrics loops
lh metrics loops --days 14
lh metrics loops --db ~/.local/share/lazy-harness/metrics.db
# goal_absent 6
# goal_declared 3
# nontrivial_prompt 9
# session_closed 12
#
# declared rate: 33% (3/9)
lh metrics launches¶
Reports the launches table — one row per agent launch actually started, written by lh run and lh exec — grouped by profile, agent and entry (run | exec), followed by the launch-to-session ratio per profile from MetricsDB.launch_to_session_ratio. This is the numerator and the calibration data the blast-radius kill criterion's adoption check reads — see the multi-agent blast-radius design, decision 1.
--days N sets the window both blocks share; it defaults to 28, matching launch_to_session_ratio's own default. --json emits the same two blocks as a JSON object with null for a ratio the window cannot compute — a profile with no ingested sessions in it — instead of the — the table prints. --db PATH overrides the database outright, resolved the same way as lh metrics loops otherwise.
lh metrics launches
lh metrics launches --days 7
lh metrics launches --json
# lazy claude-code run 12
# flex codex exec 3
#
# lazy launches=12 sessions=8 ratio=1.50
# flex launches=3 sessions=0 ratio=—
A profile's ratio is — (null under --json) when the window holds no ingested sessions for it — uncalibrated, never a fabricated zero.
lh profile¶
Manages agent profiles.
lh profile list prints a table of every configured profile — name, config dir, roots, whether the config dir actually exists on disk.
lh profile inspect [NAME] [--json] shows the default or named profile's resolved agent, identity, source and runtime paths, hook defaults and explicit overrides, suppressed defaults, omissions, and system-document and skill availability. Each configured hook has separate deployment evidence: deployed only when its event, command, and matcher match the parsed runtime file; drift when the matcher differs; missing when its command is absent; unreadable for an invalid runtime file; and unknown when no runtime file exists. Unmatched runtime hooks are listed separately as managed when the adapter's ownership record proves it, or unknown otherwise. Stale managed hooks count as deployment drift even when an explicit empty override configures none. Execution remains unknown; a Codex trust record does not prove a hook ran. Inspection reads files but writes nothing.
lh profile add <name> --config-dir <path> [--roots a,b,c] registers a new profile in config.toml. lh profile remove <name> does the inverse.
lh profile envrc walks every profile's roots and writes a managed .envrc block exporting the agent's config-dir env var (e.g. CLAUDE_CONFIG_DIR). With direnv installed, plain claude invocations inside a root then auto-pick the right profile. User content outside the managed block is preserved. --dry-run shows what it would write.
lh profile move --from <a> --to <b> relocates per-project conversation history between each profile's agent-declared sessions directories without losing JSONL history. Useful when reclassifying a project from one profile to another. Supports --projects a,b,c, --all, --overwrite, and --yes.
lh profile migrate <name> [--dry-run] moves a profile's root assets into segments. shared/ is deployed to every agent; <agent>/ — named by the registry, e.g. claude-code/, codex/ — only to a profile running that agent. An entry an adapter names in its config targets goes to that agent's segment (settings.json to claude-code/, hooks.json and config.toml to codex/); everything the registry does not claim goes to shared/. The assembled system docs and the segments they are built from stay at the profile root, where sync-system-doc writes them — but the same run renames the segments to their roles: CLAUDE.head.md → head.md, CLAUDE.tail.md → tail.md, and _common/CLAUDE.common.md → _common/common.md (ADR-055). Renames print their own rename / would rename lines and their own count. _common/ is the one thing outside the profile directory this touches, and only for that rename: the shared segment waits until no sibling profile still carries a legacy <stem>.head.md, printing keep _common/CLAUDE.common.md (still read by profile 'work') while it waits. A legacy file sitting beside its role-named replacement is reported as a leftover rather than renamed over it, and two legacy spellings that would claim one role are refused by name before anything moves. The plan is printed either way; --dry-run moves nothing, and a move that would overwrite an existing file is refused before anything moves. Migrating is optional — an unmigrated profile still deploys its root to every agent — and running it twice is a no-op.
lh profile sync-system-doc recomposes every profile's system doc from its segments, concatenating <profile>/head.md + _common/common.md + _common/<agent>.md + <profile>/tail.md in that order. A legacy-only profile is skipped and the result names its legacy head plus lh profile migrate <name>; a flat, hand-written system doc is also skipped, never erased. The post-tool-use-sync-system-doc hook runs the same code on every edit to a role-named segment, so this is the manual path — after a bulk edit, after pulling the profiles dir on another machine, or where that hook is not deployed. Renamed from lh profile sync-claude-md (decision 5, blast-radius design); the old name still works as a hidden alias.
lh profile list
lh profile inspect --json
lh profile add work --config-dir ~/.claude-work --roots ~/repos/work
lh profile envrc
lh profile move --from personal --to work --projects my-repo --yes
lh profile sync-system-doc
lh profile migrate lazy --dry-run
lh repo¶
Checks on a repository's own instruction files.
lh repo instructions¶
lh repo instructions # the current repository
lh repo instructions ../other # any path
lh repo instructions --manifest fleet.json
Verifies the portable instruction contract: root AGENTS.md exists and no
CLAUDE.md shadows its parent-chain discovery. Agent-specific guidance lives
in labelled sections of AGENTS.md, so root and nested sessions receive the
same contract in Claude Code and Codex.
--manifest accepts a per-run JSON inventory. Paths are relative to the manifest,
must stay inside its directory after symlink resolution, and cannot contain ...
Each repository needs a status: active, deferred, or upstream. Active
repositories keep the strict gate. Deferred and upstream repositories skip only
their own tree check; ancestor shadows are still findings. Use these statuses
only for repos with a recorded migration deferral or external ownership.
{
"repositories": [
{"path": "owned", "status": "active", "instruction_data": ["fixtures/CLAUDE.md"]},
{"path": "migration-pending", "status": "deferred"},
{"path": "third-party", "status": "upstream"}
]
}
instruction_data names existing nested CLAUDE.md files stored as data, one
exact repo-relative path each. Root CLAUDE.md cannot be excluded. A declared
file remains a finding when the caller's working directory is inside its
directory, because it then affects the active session. Other nested files and
ancestor shadows still fail. A manifest cannot be combined with positional
paths; without one, all paths remain strict.
Three findings, each naming the file to change:
| Code | Meaning |
|---|---|
missing-agents-md |
No root AGENTS.md exists, so repository rules are not portable. |
claude-md-shadows-agents |
A CLAUDE.md prevents Claude Code from walking the parent AGENTS.md chain. |
ancestor-claude-md-shadows-agents |
An ancestor CLAUDE.md blocks repository AGENTS.md discovery. |
Exit code 0 when the tree is clean, 1 with one line per finding — so it works as a CI step, not only as a local convenience.
lh run¶
Resolves the right profile for the current directory (or --profile <name>), sets the agent's config-dir env var, and execs the agent binary with all remaining args. This is the canonical way to launch the agent through the harness.
--list prints profiles and exits. --dry-run prints the resolved exec invocation without running.
Launched inside a git repository, the agent keeps its temporary files in the repository: lh run creates <repo>/tmp and points both TMPDIR (read by Codex and by the tools either agent runs) and CLAUDE_CODE_TMPDIR (read by Claude Code, which ignores TMPDIR) at it. From a linked worktree, <repo> is the main checkout, so temporary files outlive the worktree's removal. Keep /tmp/ in a gitignore; outside a repository, or if the directory cannot be created, both variables are left as the environment had them. lh exec shares this resolution.
If no configured root matches the current directory, the default profile is used and a warning naming the directory and the profile is written to stderr — never stdout, which belongs to the agent once lh run execs it. The warning is unconditional rather than interactive-only: a scheduled caller has no terminal and is the one most likely to be launching from an unrouted directory. It is suppressed when no profile declares a root at all, since routing by directory is then not in use and the default is the configured design rather than a guess.
lh run # launch agent for current cwd's profile
lh run --profile work
lh run --dry-run -- --resume
--agent — narrow resolution to one agent¶
--agent <prefix> (claude, codex, copilot) filters candidate profiles to that agent's before root resolution runs, which is what lets one roots entry shared by a Claude Code profile and a Codex profile resolve either one depending on which binary the caller wants. Without a root match, --agent falls back to profiles.default only if that profile runs the named agent; otherwise, if exactly one profile of that agent is configured, it resolves to that; otherwise lh run refuses, naming the agent and the directory — it never launches a profile of a different agent than the one asked for.
--profile and --agent together: --profile wins if its own agent matches --agent, else lh run refuses, naming both.
lh run --agent codex # this cwd's Codex profile, not the Claude default
lh run --agent codex --dry-run
lh exec takes the same flag through the same resolver.
--bypass — permission bypass as a declared intent¶
Every other argument lh run receives is forwarded to the agent untouched.
--bypass is the exception, and it exists because a forwarded permission flag
means different things to different binaries: the level is named, and the
profile's own adapter decides which flags express it.
| Level | Means |
|---|---|
enable |
Make bypass available; do not turn it on. |
activate |
Turn it on. Prompts stop; whatever sandbox exists stays. |
no-sandbox |
Also remove the sandbox, where one exists. |
The levels are ordered by how much they give away, which is why a missing one is never filled by its neighbour.
| Level | Claude Code | Codex |
|---|---|---|
enable |
--allow-dangerously-skip-permissions |
unsupported |
activate |
--dangerously-skip-permissions |
--approve-for-me |
no-sandbox |
unsupported | --dangerously-bypass-approvals-and-sandbox |
Copilot supports no level: nothing has been measured for it, and an unmeasured flag is not put in front of a binary.
An unsupported level is an error, not a fallback. lh run exits non-zero
and names the agent and the level; nothing is forwarded. Claude Code exposes no
OS-sandbox switch on its argv, so --bypass=no-sandbox on a Claude profile is
refused rather than quietly answered with the activate flag — which would
stop the prompts instead of removing the sandbox, a different and larger grant
than the one requested. Codex has nothing meaning "available but off", so
--bypass=enable is refused there for the same reason.
The expansion is inserted after argv[0] and before your own arguments, so
everything you pass still reaches the agent in order.
lh run --bypass=enable # claude: --allow-dangerously-skip-permissions
lh run --profile cx --bypass=activate # codex: --approve-for-me
lh run --dry-run --bypass=activate # see the expansion without launching
The per-agent mappings and the evidence behind each one are in ADR-049.
lh scheduler¶
Manages scheduled jobs declared in [scheduler.jobs.<name>]. The backend is auto-detected (launchd on macOS, systemd on Linux with systemctl, cron otherwise) or pinned via [scheduler] backend. All three install, uninstall and report state.
install is all-or-nothing: every job's schedule is translated before anything is written, so an expression the backend cannot express aborts the run and leaves the existing set untouched. On systemd, install also checks whether lingering is enabled and says so if it is not — user timers stop when your last session ends, and systemctl --user enable --now reports success either way.
lh scheduler install writes the platform-native unit files for every declared job, lh scheduler uninstall removes them, and lh scheduler status shows the active backend and per-job state. All three work on every supported platform.
status prints (exit N) after a job's state when the backend can say how the job's last run ended. launchd reads it from launchctl print (last exit code), systemd from the job's .service unit (ExecMainStatus), and cron keeps no per-job exit status. A job that has not run yet, a backend that cannot be asked and a failed probe all print nothing: an unknown exit is never shown as exit 0.
lh scheduler status
lh scheduler install
lh scheduler uninstall
lh selftest¶
Validates the lazy-harness install end-to-end: config parses, profiles deploy, hooks fire, scheduler reachable. Use it after upgrades, after editing config, or as a smoke test in scripts.
The loop-events group goes further than the others: it builds a throwaway git repository, runs the loop-event hook scripts the installed package ships, and reads back the rows they wrote. That catches attribution bugs — a session recorded against an artifact subdirectory instead of its repository, or a row that cannot name its profile — which unit tests cannot see, because they import the hooks rather than running them as the agent does.
The scheduler group's units-stale check compares each installed job against what the current version would write, and warns naming the jobs that differ. A plist, a timer unit and a crontab block are written once and read for months, so they outlive the code that produced them: a job can be installed, loaded and counted while carrying an environment or a schedule this version no longer generates. Re-run lh scheduler install to refresh them.
--json switches to machine-readable output. --fix attempts to repair fixable issues in place.
lh selftest
lh selftest --json
lh selftest --fix
lh status¶
Monitoring dashboard. With no subcommand, prints the overview panel. Ten subcommand views:
lh status overview— at-a-glance summary panel.lh status sessions— daily breakdown of sessions, tokens, cost.--period today|week|month|all. Framework inference calls (workloadinference:<role>) add their tokens and cost but are not counted as sessions, here or in thelh statusheader.lh status tokens— token / cost breakdown across any combination of dimensions. See below.lh status costs— legacy cost view, kept for back-compat.--period 7d|30d|month|all.lh status projects— per-project session counts and last activity.lh status profiles— per-profile config, hooks count, MCPs, auth state.lh status hooks— last fired hooks plus log health.lh status cron— scheduled jobs and their last runs, through whichever scheduler backend is active.lh status queue— compound-loop queue depth and recent worker activity.lh status memory— per-project decisions / failures / learnings counts.
Every view reads the same database, resolved the same way: [monitoring] db from config.toml when set, otherwise data_dir()/metrics.db (~/.local/share/lazy-harness/metrics.db on a default install). If the numbers look stale, the fix is upstream — run lh metrics ingest.
lh status
lh status sessions --period week
lh status tokens --by model --period month
lh status tokens¶
| Flag | Values | Default | Notes |
|---|---|---|---|
--by |
profile project model host workload agent day week month |
project + model |
Repeatable. Flag order is column order. |
--period |
today week month all, <N>d, YYYY-MM, YYYY-MM-DD |
month |
week and <N>d are rolling windows ending today. |
--profile |
any string | — | Case-insensitive substring filter. |
--model |
any string | — | Case-insensitive substring filter. |
--project |
any string | — | Case-insensitive substring filter. |
--host |
any string | — | Case-insensitive substring filter. |
--workload |
any string | — | Case-insensitive substring filter. |
--json |
flag | off | Emits the aggregation instead of the table. |
Each --by adds a grouping column, so --by month --by profile gives one row
per month per profile. With two or more dimensions the table also carries a
subtotal row per value of the first dimension. Filters narrow the rows without
adding a column: --profile work --by month is one row per month, work only.
--period accepts more than the four keywords — 30d for a rolling month,
2026-04 for one calendar month, 2026-04-15 for a single day.
lh status tokens --by profile --period all
lh status tokens --by month --by profile
lh status tokens --by model --profile work --period 30d
lh status tokens --by day --period 2026-04 --json
Full walkthrough with worked examples: Cost reporting.
lh statusline¶
Reads a Claude Code status payload on stdin and prints who and where the session is: profile model dir @branch. Context, cost and cache figures are not part of it; show them with a mod drawn above the prompt. Wired into a profile via settings.json:
"statusLine": { "type": "command", "command": "lh statusline" }
You generally do not invoke this by hand — Claude Code calls it on every status refresh.