CLI Reference
The built-in help is canonical. glomeris <command> --help renders from
src/cli/help.rs, which is the one place a command’s usage, flags, safety
semantics, examples and exit codes are written down, and whose output is held
to golden snapshots in tests/fixtures/help/ (HORO-1311). If this page and
--help ever disagree, --help is right and this page is stale.
What this page adds that --help deliberately does not: the JSON payload
shapes, the report field semantics, and the cross-references into the rest of
this book. It is the reference you read at a desk; --help is the one you read
mid-task.
glomeris
No arguments: prints glomeris <version> (from CARGO_PKG_VERSION) and a
pointer to glomeris --help. --version/-V prints the same version line on
its own.
Help surfaces
Everything below renders from the single COMMANDS table in
src/cli/help.rs. That table lived in src/main.rs until HORO-1311, where no
test could read it — so tests/shared_command_table.rs kept a hand-written
mirror of it, which drifted exactly as the pre-HORO-1050 duplication had
(HORO-1034: the unrecognized-command usage omitted llm-plan; by HORO-1311 the
test mirror omitted llm-check). There is now one table, read directly by both
the binary and its tests.
| Invocation | What it prints |
|---|---|
glomeris --help / -h / help | Every command, grouped by what it does to your machine, with a one-line summary each. |
glomeris <command> --help / -h | That command’s usage, safety statement, its subcommands (each with its own safety label), flags, examples, exit codes and related commands. |
glomeris help <command> | Identical to glomeris <command> --help. |
glomeris help exit-codes | The exit-status reference (see Exit codes). |
glomeris help <unknown-topic> | The list of topics that exist, to stderr, exit 2. |
glomeris <unknown-command> | A one-line error and a pointer to --help, to stderr, exit 2. |
glomeris <command> <bad-flag> | That command’s usage only, to stderr, exit 2. |
The groups in top-level help are INSPECT, PLAN, ACT, OBSERVE,
SERVICE and CONFIGURE, ordered so that the read-only commands come before
anything that can delete, and so that a first-time reader meets the product
before its preferences. A group heading describes consequence, not category: INSPECT says
“nothing is changed”, and ACT says its commands delete data and that every
deletion is policy-gated.
Note that a command’s group and its safety line describe only whether
Glomeris itself writes to the filesystem when you run it. They are not policy
classifications: AUTO_SAFE, ASK and PROTECTED classify resources, are
decided by the policy engine, and never appear in a help safety label. See
Safety Model.
Safety is declared per verb, not only per command
Four labels exist, in ascending order of consequence:
| Label | Means |
|---|---|
Read-only — changes nothing. | Nothing is written. |
Advisory — proposes, never executes. | Produces a plan; executes none of it. |
Writes only Glomeris's own state — never your files. | Writes the launch agent plist, the monitor’s history and heartbeat, the Autopilot envelope, or your stored preferences. Nothing you own. |
Can delete data — every deletion is policy-gated. | Deletes. |
For a command that takes subcommands, the consequence is a property of the
verb, not of the command name: daemon install writes a launch agent while
daemon status reads, and autopilot run deletes while autopilot show
prints a config file. Until HORO-1485 one label was declared per command, so
glomeris daemon --help printed “Read-only — changes nothing.” above
install, uninstall and run, and glomeris autopilot --help printed “Can
delete data” above show. No wording could have fixed either: the weakest
label is a false reassurance and the strongest a false warning, which is why
relabelling daemon as destructive was not an acceptable fix.
Each verb now carries its own label, printed beside it in the Subcommands
block. The command’s own safety line is the strongest claim reachable through
it, and when its verbs disagree it says so explicitly rather than picking one
of them:
Safety depends on the subcommand; each is labelled below. The strongest of
them: Writes only Glomeris's own state — never your files.
Two tests keep this honest, and they are deliberately not tests about strings.
src/cli/help.rs‘s command_safety_covers_every_reachable_subcommand asserts
the command’s label equals the strongest of its verbs’ — equality in both
directions, because under-stating and over-stating are both false — and
a_single_label_banner_is_true_of_every_verb_under_it asserts the same of the
rendered banner, since a correct table printed through a banner that ignored it
would still print a false claim. Neither could have caught the original defect
on its own: with no subcommands declared, both pass vacuously.
tests/subcommand_safety_is_honest.rs closes that gap from outside the table —
it reads src/main.rs’s dispatch arms, so a verb the binary accepts cannot go
undeclared, and it runs every surface labelled read-only against a disposable
HOME and requires the tree to be byte-for-byte unchanged, with
autopilot enable as the positive control that proves the observation would
notice a write.
Two surfaces are narrower on purpose. An unrecognized top-level command gets a
pointer rather than the manual — it previously reprinted the aggregate usage of
all thirteen commands, a 13-line, 146-column wall in answer to one mistyped
word. And a usage error inside a command prints only that command’s usage, so
getting a free flag wrong no longer tells you about daemon.
Byte counts: 1024-based, with KB/MB/GB labels
Every byte count this product renders — free_human, total_human,
logical_human, reclaimable_human, expected_reclaimed_human,
actual_reclaimed_human, and the same numbers in text output — comes from one
function, reporting::human_bytes. It divides by 1024 and labels the result
B/KB/MB/GB/TB/PB. So 2147483648 renders as 2.0 GB, where a
1000-based formatter would say 2.15 GB. Counts below 1024 are a bare integer
and B, with no decimal point.
The labels are not the IEC KiB/MiB/GiB spelling that strictly matches the
arithmetic. That is a deliberate, documented inconsistency rather than an
oversight: these are the units du -h and df -h print for the same
arithmetic, and the alternative is renaming every unit in every report and
fixture to spell out a distinction most readers of a storage tool do not draw.
--target parses the same way, so what you type and what you read back agree:
glomeris free --target 5GB means 5 × 1024³ bytes. A bare number or a B
suffix is raw bytes; a trailing % is a percentage of total capacity instead.
Clients should render the *_human string rather than scale the byte count
themselves. The menu-bar app did the latter with ByteCountFormatter, which
is 1000-based, so a 2 GiB cleanup appeared as a 2.0 GB estimate and a
2.15 GB result in the same panel (fixed in HORO-1312). Where a report offers
both fields, the raw *_bytes value is for arithmetic and the *_human string
is for display. A *_human field is null, never "0 B", when the underlying
probe produced no number — an aborted execution reclaimed nothing, which is a
different claim from having freed zero bytes.
glomeris daemon <subcommand>
macOS only (exits 1 with an error message on other platforms).
| Subcommand | Effect |
|---|---|
install | Writes a per-user launchd plist and launchctl load -ws it. |
uninstall | launchctl unload -ws the agent (best-effort) and removes the plist file. |
status | Prints whether the plist is installed, its path, and whether launchctl reports it loaded. |
run | Runs the polling loop in the foreground (this is what the installed agent actually executes). |
No subcommand, or an unrecognized one, prints usage to stderr and exits 2. See Daemon Lifecycle for details.
daemon status --json (HORO-1045) prints a DaemonStatusReport.
loaded (launchd-reported) and heartbeat_age_secs (derived from the poll
loop’s own last-write) are deliberately kept as two separate fields, never
collapsed into one healthy boolean — a loaded-but-wedged daemon and an
actually-polling one must stay distinguishable:
glomeris daemon status --json
{
"plist_installed": true,
"plist_path": "/Users/dev/Library/LaunchAgents/dev.glomeris.daemon.plist",
"loaded": true,
"heartbeat_age_secs": 42
}
heartbeat_age_secs is null when no heartbeat file exists yet (the
daemon has never run).
glomeris status [--json]
macOS only (exits 1 with an error message on other platforms). Not part of
daemon — this is the same one-shot disk-pressure reading daemon run’s
poll loop evaluates each tick, available on demand without needing the
daemon installed at all (HORO-955).
glomeris status --json
{
"total_bytes": 500000000000,
"free_bytes": 125000000000,
"used_percent": 75.0,
"free_human": "116.4 GB",
"total_human": "465.7 GB",
"pressure_state": "WARN"
}
pressure_state is one of "OK", "WARN", "CRITICAL" — see
Pressure Model.
glomeris scan [path] [top_k]
Not macOS-gated. Both arguments are positional and optional:
path— root directory to scan. Defaults to..top_k— number of largest entries to report. Defaults to20if omitted or unparseable asusize.
Prints a summary line (files visited, stop reason, incomplete-entry count) followed by one line per candidate: size, depth, path.
glomeris detect [--project-root <path>]... [--json] [--progress-json]
Not macOS-gated. Runs every detector in DetectorRegistry::builtin() exactly
once per invocation and prints, per detector: found (<N> evidence),
tool_absent, or failed: <reason>. The candidate report printed below those
lines comes out of that same single pass, so the two halves of the output
cannot describe different probes of a filesystem that changes between them
(HORO-1487).
--project-root <path> is optional and repeatable — pass it once per
project directory you want the cargo/node detectors to check for a
target//node_modules/ dir. Without it, those two detectors have no
project roots to scan and always report tool_absent.
--json prints a DetectReport — one candidate line per discovered
resource, including the already-computed
executable/offered_actions/refusal_reason triple (HORO-1053) a caller
(e.g. the menu-bar app) reads to decide what it can offer, without ever
re-deriving that from policy_label/reasons itself:
Candidates are returned biggest reclaimable size first (HORO-1307).
Before that they came back in detector-registration order, so a 40 GB Cargo
target/ could be printed below a 2 MB npm cache. Ties are broken first by
measurement quality — an exact size outranks a ≥ lower bound of the same
number, because the exact one is the claim you can act on — and then by
resource_id, so two runs over an unchanged machine produce the same order.
Candidates whose size could not be measured at all sort last rather than
being treated as zero. Ordering is applied at the single point where the
report is assembled, so --json and the human-readable output can never
disagree about it.
impact_tier is "unknown", "normal", "notable" or "large": a
pre-computed magnitude band, so a UI does not have to invent thresholds of
its own. It escalates on either an absolute size (≥ 1 GB is notable,
≥ 10 GB is large) or a share of remaining free space (≥ 5% is
notable, ≥ 20% is large) — measured against free rather than total space,
because the problem someone opens Glomeris with is “I am running out of
room”. A 400 MB cache is large on a machine with 1.6 GB left.
impact_tier is a size signal and nothing else. It is not a safety
signal, and it must never be read as one: a large candidate can be
PROTECTED, and a normal one can be AUTO_SAFE. executable,
offered_actions and refusal_reason remain the only statement about what
Glomeris is permitted to do.
glomeris detect --project-root ~/dev/myproject --json
{
"candidates": [
{
"resource_id": "cargo_target_dir:/Users/dev/proj/target",
"kind": "cargo_target_dir",
"reclaimable_bytes": 2147483648,
"reclaimable_human": "2.0 GB",
"reclaimable_bytes_is_lower_bound": false,
"impact_tier": "notable",
"policy_label": "AUTO_SAFE",
"reasons": ["no_active_use_observed"],
"executable": true,
"offered_actions": [
{
"action_id": "cargo.clean.target_dir",
"requires_confirmation": false
}
],
"refusal_reason": null
},
{
"resource_id": "docker_build_cache:docker",
"kind": "docker_build_cache",
"reclaimable_bytes": 10737418240,
"reclaimable_human": "10.0 GB",
"reclaimable_bytes_is_lower_bound": true,
"impact_tier": "large",
"policy_label": "UNKNOWN_INCOMPLETE",
"reasons": ["evidence_incomplete"],
"executable": false,
"offered_actions": [],
"refusal_reason": "no registered cleanup action for this resource kind"
}
],
"detectors": [
{"detector": "cargo_target_dir", "status": "found", "candidates_found": 1},
{"detector": "docker_build_cache", "status": "found", "candidates_found": 1},
{"detector": "node_modules", "status": "tool_absent", "candidates_found": 0},
{
"detector": "homebrew_cache",
"status": "failed",
"candidates_found": 0,
"reason": "brew --cache exited with status exit status: 1"
}
],
"discovery_complete": false
}
detectors reports one entry per detector that ran, in registry order, and
status is found, tool_absent or failed — the same three tokens
--progress-json uses below. reason is present only on failed, and is the
detector’s own account of what went wrong.
discovery_complete (HORO-1484) is false when at least one detector failed,
and it is derived from detectors rather than tracked separately, so the
summary cannot disagree with the array it summarises. When it is false,
candidates is not a complete account of what could be reclaimed, and no
consumer may present it as one — “nothing worth reclaiming” is a claim about
the machine, and a search that did not finish has not established it. A
tool_absent detector does not make discovery incomplete: a tool that is
not installed has nothing to report, which is a different fact from a tool that
was asked and could not answer.
--progress-json (HORO-1052) emits one NDJSON-encoded ProgressEvent line
to stderr per detector start/finish while discovery runs — a way for a
spawning UI to distinguish “still working” from “hung” on a slow/contended
host (v0.2.0 founder-dogfood measured a single discovery pass up to 3m40s).
Stdout is completely unaffected either way, --progress-json output can be
combined with --json, and nothing is emitted at all unless the flag is
passed:
glomeris detect --progress-json 2>&1 1>/dev/null
{"phase":"detector_started","detector":"cargo_target_dir"}
{"phase":"detector_finished","detector":"cargo_target_dir","candidates_found":1,"outcome":"found"}
{"phase":"detector_started","detector":"docker_images"}
{"phase":"detector_finished","detector":"docker_images","candidates_found":0,"outcome":"tool_absent"}
{"phase":"detector_started","detector":"homebrew_cache"}
{"phase":"detector_finished","detector":"homebrew_cache","candidates_found":0,"outcome":"failed","reason":"brew --cache exited with status exit status: 1"}
outcome (HORO-1484) is found, tool_absent or failed, and reason is
present only on failed. The last two lines above are why it exists: a
detector whose probe failed reports candidates_found: 0, exactly like one
that looked and found nothing, so a consumer reading only the count shows
“0 found” for a check that never ran. docker_images being absent is normal
and expected; homebrew_cache failing is not, and the two must not be
presented alike.
detect, explain, llm-plan, and execute all share this same
discovery phase and all support --progress-json identically.
glomeris explain <resource_id_or_path> [--project-root <path>]... [--json] [--progress-json]
Not macOS-gated. Runs the same discovery-and-classification pipeline as
detect, then prints the full evidence-and-policy picture for exactly the
one resource matching <resource_id_or_path> (a detect report’s
resource_id, or a filesystem path) — evidence provenance, size (logical
vs. reclaimable, explicitly labeled as different), active-use signals, and
policy classification (HORO-955). Exits 1 if no discovered candidate
matches the query.
--project-root <path> and --progress-json mean exactly what they mean
for detect above.
glomeris explain cargo_target_dir:/Users/dev/proj/target --json
{
"resource_id": "cargo_target_dir:/Users/dev/proj/target",
"kind": "cargo_target_dir",
"detector": "cargo_target_dir",
"sources": ["cargo metadata: target-dir"],
"logical_bytes": 2147483648,
"logical_human": "2.0 GB",
"reclaimable_bytes": 2147483648,
"reclaimable_human": "2.0 GB",
"reclaimable_bytes_is_lower_bound": false,
"completeness": "complete",
"confidence": "high",
"active_use_signals": [],
"regenerability": "regenerable_by_rebuild",
"policy_label": "AUTO_SAFE",
"reasons": ["no_active_use_observed"],
"native_cleanup_available": true,
"native_cleanup_action_id": "cargo.clean.target_dir",
"fingerprint_token": "<opaque token — copy verbatim, never hand-construct>",
"executable": true,
"offered_actions": [
{
"action_id": "cargo.clean.target_dir",
"requires_confirmation": false
}
],
"refusal_reason": null
}
fingerprint_token (HORO-1051) is an opaque, wire-safe encoding of the
resource’s identity fingerprint — null for a resource with no dev/inode/
mtime identity (e.g. Docker’s build cache). This is the exact token
execute --observed-fingerprint later expects back for an ASK-classified
resource; see execute’s section below.
glomeris clean --dry-run [--target <resource_id_or_path>] [--project-root <path>]...
Not macOS-gated. Renders what would be cleaned, without executing
anything — --dry-run is required; there is no non-dry-run execution path
on this subcommand (real destructive execution is glomeris free --target
or glomeris execute’s job). Without --target, every discovered
candidate is considered; with it, only the one matching resource is.
glomeris clean --dry-run
Human-readable output only — clean --dry-run has no --json mode. One
line per considered resource, either its rendered ActionPlan.explain text
or a skip_reason (e.g. PROTECTED, no registered action).
glomeris llm-plan [--project-root <path>]... [--plan-file <path>] [--json] [--progress-json] [--schema] [--print-payload]
Not macOS-gated. ADVISORY, NON-EXECUTING (HORO-1008) — never constructs a
policy::Approval and never calls policy::approval::authorize or
executor::execute. See BYOK LLM Planner for the full
configuration and safety-property writeup.
- Without
--plan-file, credentials are read only fromGLOMERIS_LLM_API_KEY/GLOMERIS_LLM_BASE_URL/GLOMERIS_LLM_MODEL(all three required, no default base URL) — never from a CLI flag.GLOMERIS_LLM_BASE_URLis the API root:/chat/completionsis appended to it verbatim, so it usually ends in/v1(https://gateway.example.com/v1). See BYOK LLM Planner. - A failed provider call is reported on the
provider error:line (or theprovider_errorJSON field) as one secret-free sentence naming the HTTP status, API style, request path, request id, and a bounded excerpt of the provider’s error body — never theAuthorizationheader, the key, the host, or the request payload. --plan-file <path>reads the file’s raw bytes as if they were the model’s raw response text, through the same validation pipeline the live provider uses — no network call, no API key required.--project-root <path>is optional and repeatable, same meaning asdetect’s flag above.--api-key/--key/--tokenare explicitly rejected (not accepted and ignored) — the error names$GLOMERIS_LLM_API_KEYinstead.--jsonprints the report as JSON. Each item carries the model’s ownpriority/model_reasonalongside the machine’spolicy_label,requested_action_id,explain,skip_reason,completeness,confidence, and a nestedcandidate— the byte-for-byte same projectiondetect --jsonprints for that resource, includingexecutable/offered_actions/refusal_reason(HORO-1308). A consumer deciding what may be done readscandidate;priority/model_reasonare the only two fields a provider chose, and the order ofitemsis the provider’s too —plan_with_llmnever sorts, it only drops.--jsonoutput is printed before aprovider_errorexit, so an exit1report is still complete and readable.--progress-jsonstreams the same NDJSON discovery progress asdetect, on stderr, leaving stdout a single clean JSON document. This is the exact pair (--json --progress-json) the menu-bar app’s AI Plan card spawns; see Menu Bar App.--print-payload(HORO-1298) runs discovery, prints the exact request a live run would send — the system prompt, the user prompt, and the local wire-id-to-real-resource table under a heading marking it as not sent — then returns before any provider is constructed. Requires no credential and makes no network call, so it cannot send what it displays. The outbound prompts identify resources only by positional alias (resource_1, …); absolute paths appear in the alias table and nowhere else. See BYOK LLM Planner.--schema(HORO-1048) prints an example, syntactically validLlmPlanJSON document to stdout and exits — a distinct, self-contained mode that never runs discovery, never reads--plan-file, and never checks live-mode credentials, regardless of what else is passed alongside it. See BYOK LLM Planner for the full example and field table.
glomeris llm-plan --schema
{
"items": [
{
"resource_id": "cargo_target_dir:/path/to/project/target",
"action_id": "cargo.clean.target_dir",
"priority": 1,
"reason": "stale build artifacts, not modified in 30 days"
}
]
}
This exact output round-trips unchanged through glomeris llm-plan --plan-file <path> — see the linked BYOK page for the full field table and
the round-trip test that proves it. The resource_id form shown here is
the one a human writes by hand in a fixture; a live model is given
positional wire aliases and answers with those, and both forms resolve.
Human-readable output always opens with LLM SUGGESTION — advisory only, nothing is executed by this command.
Exit codes for this subcommand specifically:
0— success, including zero suggestions or every suggestion beingPROTECTED.1— the provider call or response parsing failed,--plan-filenamed an unreadable path, or--print-payloadcould not serialize the request.2— usage error: an unrecognized argument,--plan-filewith no value, missing live-mode environment configuration, or an--api-key/--key/--tokenflag.
glomeris llm-check [--json]
Not macOS-gated. Tests the configured BYOK setup and reports whether the endpoint, credential and model work (HORO-1309). Runs no detectors, reads no project roots, collects no evidence, and consults no policy — it is not a planning command, and there is nothing it could execute.
- Sends the two fixed prompts in
actions::llm::CONNECTION_TEST_SYSTEM_PROMPT/CONNECTION_TEST_USER_PROMPT(“You are a connection test. Reply with the single word: ok.” / “ok”) through the sameLlmProvider::completea real plan uses. Same code path, so a pass means a plan will route and authenticate — not that a cheaper probe succeeded. - The prompts are constants with no interpolation, so a connection test
describes nothing about this machine: no path, no home directory, no account
name (
connection_test_prompts_describe_nothing_local). - Configuration comes only from
GLOMERIS_LLM_API_KEY/GLOMERIS_LLM_BASE_URL/GLOMERIS_LLM_MODEL, all three required, exactly as forllm-plan.--api-key/--key/--tokenare rejected by flag name, and the error names the flag only — never the value beside it. --jsonprints anLlmCheckReport:outcome,model,endpoint_path,error,response_excerpt. Human-readable output opens withLLM CONNECTION OKorLLM CONNECTION FAILED (<outcome>).outcomeis one of five tokens, produced by the singleactions::llm::llm_check_outcomemapping so the CLI, the book and the menu-bar app cannot disagree about what a failure was:
outcome | What happened | Where the fix is |
|---|---|---|
ok | The provider answered and its reply is excerpted in response_excerpt | — |
unreachable | No HTTP response at all — DNS, TLS, refused connection, timeout | Network, host name, or VPN |
rejected | The provider answered with a non-2xx status | Credential, or the base-URL path — read the path in error |
unusable_response | A 2xx response that was empty, not JSON, or missing choices[0].message.content | Model name, or a gateway not actually speaking the OpenAI shape |
misconfigured | A base URL that cannot work — validate_base_url refused it before anything was sent | The base URL itself; see BYOK LLM Planner |
The fifth is the odd one out: LlmError::InvalidConfiguration can only come
from constructing the provider, which happens before any report exists, so
glomeris llm-check never prints a report whose outcome is
misconfigured — it exits 2 with one line on stderr instead. The token is
in the vocabulary because that is the name for what happened, and a caller
branching on exit 2 (the menu-bar app does) classifies it that way itself
rather than inventing a sixth word for the same condition.
errorcarries the same secret-free sentencellm-plan’sprovider_errordoes — status, API style, request path,x-request-idwhen the provider sends one, and a bounded excerpt of the provider’s own error body, with the configured key scrubbed out of it. Never theAuthorizationheader, the key, the scheme, the host, or the query string.
glomeris llm-check --json
Exit codes for this subcommand specifically:
0— the provider answered andoutcomeisok.1— a check ran and did not pass (unreachable,rejected,unusable_response). The report is printed first, so an exit1still carries a complete, readable diagnosis on stdout.2— nothing was sent and there is no report at all: an unrecognized argument, an--api-key/--key/--tokenflag, a base URLvalidate_base_urlrefused, or missing configuration (the message names the three variable names, never a value). Stdout is empty; the reason is one line on stderr prefixedglomeris llm-check:.
The split matters for a caller branching on the code without parsing output,
which is exactly what the menu-bar app’s connection test does: 2 means “fix
your invocation or setup”, never “the network is having a bad day”. A 2 with
no report is the one case where a UI has to quote the CLI’s own sentence rather
than render a report.
glomeris execute --action-id <id> --resource-id <id> [--project-root <path>]... [--confirm-ask --observed-fingerprint <token>] [--json] [--progress-json]
macOS only (exits 1 with an error message on other platforms). The sole
interactive destructive-execution subcommand (HORO-1055) — the only place
in this CLI where a caller can trigger one specific, real destructive
action against one specific, real resource. --action-id and
--resource-id are both required; the caller supplies ONLY these
selectors (plus, for ASK, an observed fingerprint token) — there is no
flag to pass a PolicyClass, a raw filesystem path as a direct target, a
shell string, or any --force/override.
Internal flow, in one process:
- Acquires the same HORO-1054 execution lock
free/emergencyuse — held for the whole call, released on exit. - Runs the same discovery-and-classification pipeline
detect/explainuse (--project-root <path>is optional and repeatable, same meaning as elsewhere). - Resolves
--resource-idagainst the discovered candidates and--action-idagainst that resource’s own registered action — refusing if either does not resolve, or if the resolved action’s id differs from--action-id. - For an
ASK-classified resource, builds consent ONLY from decoding--observed-fingerprint(via the same token formatexplain --json’sfingerprint_tokenfield emits) — never from a fingerprint freshly observed by this same process, which would defeat the whole fingerprint-pinning purpose.--confirm-askand--observed-fingerprintmust be passed together or not at all. - Calls the real, unmodified
policy::approval::authorize, then — only if it returns an approval — the real, unmodifiedexecutor::execute.PROTECTEDrefuses unconditionally regardless of any flag combination;execute’s own deletion-time revalidation can still abort a plan that was authorized a moment earlier if the resource changed in between.
--json prints an ExecuteReport (action id, resource id, outcome,
failure/abort detail, expected vs. actual reclaimed bytes — the latter is
a real measurement, taken after execution, not an estimate) on the
Executed path. Every refusal/not-found/busy path instead prints an
ExecuteRefusalReport ({"reason": "...", "message": "..."}) to stdout
before exiting with the matching code below, so a --json caller never
gets silent stdout on a non-Executed outcome. reason is one of
resource_not_found, action_not_found, action_mismatch, protected,
ask_no_consent, ask_consent_mismatch, auto_safe_contract_violation,
or busy (HORO-1056: the lock-contention case below, the one refusal
that happens before discovery/resolution even runs) — each a distinct,
machine-readable value naming exactly which refusal/abort path fired,
never a generic error string.
An AUTO_SAFE resource needs no confirmation flags at all:
glomeris execute --action-id cargo.clean.target_dir \
--resource-id cargo_target_dir:/Users/dev/proj/target --json
{
"action_id": "cargo.clean.target_dir",
"resource_id": "cargo_target_dir:/Users/dev/proj/target",
"outcome": "succeeded",
"failure_message": null,
"abort_reason": null,
"expected_reclaimed_bytes": 2147483648,
"actual_reclaimed_bytes": 2147483648,
"expected_reclaimed_human": "2.0 GB",
"actual_reclaimed_human": "2.0 GB"
}
The two *_human strings are new in HORO-1312 and are what a UI should
display — see Byte counts.
An ASK-classified resource requires --confirm-ask plus the exact
--observed-fingerprint token captured from a prior explain --json call
on that same resource (never a fingerprint freshly observed by execute
itself) — $FINGERPRINT_TOKEN below is that call’s fingerprint_token
field, copied verbatim, never hand-constructed:
glomeris execute --action-id cargo.clean.target_dir \
--resource-id cargo_target_dir:/Users/dev/proj/target \
--confirm-ask --observed-fingerprint "$FINGERPRINT_TOKEN" \
--json --progress-json
If the resource’s identity changed between the explain call and this
execute call, revalidation aborts the plan rather than proceeding:
{
"action_id": "cargo.clean.target_dir",
"resource_id": "cargo_target_dir:/Users/dev/proj/target",
"outcome": "aborted_by_revalidation",
"failure_message": null,
"abort_reason": "ResourceIdentityChanged",
"expected_reclaimed_bytes": 2147483648,
"actual_reclaimed_bytes": null,
"expected_reclaimed_human": "2.0 GB",
"actual_reclaimed_human": null
}
Note both actual_* fields are null rather than 0/"0 B". Nothing was
deleted, which is not the same report as a cleanup that freed no bytes.
Every refusal path (e.g. PROTECTED, no consent supplied, a stale
fingerprint) prints an ExecuteRefusalReport instead, with --json:
{
"reason": "protected",
"message": "refused — this resource is PROTECTED; no flag combination can authorize executing against it"
}
Exit codes for this subcommand specifically:
0— the action executed and succeeded.1— the action executed but failed (a step of the plan errored).2— usage error: an unrecognized/missing argument,--confirm-askwithout--observed-fingerprint(or vice versa), or a malformed--observed-fingerprinttoken.3— refused by policy:PROTECTED(unconditional),ASKwith no consent supplied, orASKwith a supplied consent that did not match the freshly observed fingerprint.4— aborted byexecute’s own deletion-time revalidation (a TOCTOU- style guard: the resource’s identity or policy classification changed between authorization and execution).5—--resource-idmatched no discovered candidate, the resource had no registered action, or the resolved action’s id did not match the supplied--action-id.75— the execution lock is already held by anotherglomerisinvocation (seefree’s exit codes above;executereuses the exact sameEXIT_EXECUTION_LOCK_BUSYconstant — this ticket’s own AC described this case as exit6, but the already-established lock convention from HORO-1054 is kept rather than introducing a second, conflicting “busy” code). With--json, this prints anExecuteRefusalReportwithreason: "busy"to stdout (HORO-1056) — previously this path was silent on stdout even under--json.
glomeris emergency
macOS only (exits 1 with an error message on other platforms). Takes no
arguments, and rejects any with exit 2 — until HORO-1311 it silently discarded
them, so glomeris emergency --dry-run performed a real recovery run. See
Emergency Mode.
glomeris history [--json] [--limit <N>]
Reads back a bounded, oldest-first tail of the monitor’s history.tsv
(HORO-1046) — the same append-only file daemon run’s poll loop already
writes via PersistenceBackend::record. No new persistence format; this is
a read path only.
--limit <N> is optional and defaults to 20. It bounds how many of the
most recent pressure transitions are returned — a malformed line in
history.tsv is skipped rather than failing the whole read, and a missing
history file (the daemon has never run, or never recorded a transition)
renders as an empty list rather than an error.
With --json, prints a HistoryReport ({"events": [...]}); each event
has unix_time_secs, from, to, used_percent, free_bytes, and
free_human. Without --json, prints one line per event as plain text.
glomeris history --json --limit 2
{
"events": [
{
"unix_time_secs": 1700000000,
"from": "OK",
"to": "WARN",
"used_percent": 82.5,
"free_bytes": 80000000000,
"free_human": "74.5 GB"
},
{
"unix_time_secs": 1700000600,
"from": "WARN",
"to": "CRITICAL",
"used_percent": 95.1,
"free_bytes": 20000000000,
"free_human": "18.6 GB"
}
]
}
glomeris actions <list [--json]|history [--json] [--limit <N>]>
Neither subcommand is macOS-gated — list touches no filesystem/launchd
state at all, and history only reads a plain file.
glomeris actions list [--json]
Enumerates every action currently registered in ActionRegistry::builtin()
(HORO-1047) — the read path that replaced having to read
src/actions/homebrew.rs source directly to find a real action id string.
applies_to is a direct projection of each action’s own Action::applies_to,
never a hand-maintained list.
glomeris actions list --json
{
"actions": [
{ "action_id": "cargo.clean.target_dir", "applies_to": ["cargo_target_dir"] },
{ "action_id": "node.clean.node_modules", "applies_to": ["node_modules"] },
{ "action_id": "homebrew.cleanup.cache", "applies_to": ["homebrew_cache"] }
]
}
glomeris actions history [--json] [--limit <N>]
Reads back a bounded, oldest-first tail of actions.jsonl (HORO-1057) — the
real-execution audit trail that execute, free, emergency and
autopilot run each append to, best-effort, after their own outcome is
already decided. Unlike history.tsv (which records pressure transitions
only), this is the audit trail of what was actually executed: action id,
resource id, the policy label it was authorized under, outcome, abort reason
(when applicable), actual reclaimed bytes, and which real-execution path
produced it.
--limit <N> is optional and defaults to 20, same bounding/malformed-line-
skip/missing-file-empty contract as glomeris history. An audit-write
failure never affects the execution it was trying to record — the write is
best-effort and its result is never surfaced to the caller.
With --json, prints an ActionHistoryReport ({"events": [...]}); each
event has timestamp, action_id, resource_id, policy_label,
outcome, abort_reason, actual_reclaimed_bytes, actual_reclaimed_human,
and source. Without --json, prints one line per event as plain text.
source is one of five values, produced by ActionSource::as_str in
src/monitor/persistence.rs:
source | The path that executed it |
|---|---|
execute | glomeris execute, one action against one named resource |
free | glomeris free --target, the recovery loop |
emergency | glomeris emergency, machine-wide, AUTO_SAFE only |
autopilot_auto_safe | glomeris autopilot run, action policy allowed on its own |
autopilot_preauthorized_ask | glomeris autopilot run, action attempted only because autopilot enable --preauthorize-ask had already named that kind and reason |
The last two are deliberately distinct rather than one autopilot value: the
question an audit trail has to answer is not just what ran but who
permitted it, and a pre-authorized ASK was permitted by the operator
naming that resource kind, not by policy alone.
Note “attempted” in that last row. A record is written for a failed or aborted
attempt too — not for a refused one, which never reached the filesystem and
so appears in autopilot run’s own report instead. Today every
pre-authorized ASK aborts at
deletion-time revalidation — so autopilot_preauthorized_ask currently only
ever appears alongside outcome: "aborted_by_revalidation". That is a known
limitation with a named cause and a pinning test, not the intended end state:
see Known Limitations.
glomeris actions history --json --limit 2
{
"events": [
{
"timestamp": 1700000000,
"action_id": "cargo.clean.target_dir",
"resource_id": "cargo_target_dir:/Users/dev/proj/target",
"policy_label": "AUTO_SAFE",
"outcome": "succeeded",
"abort_reason": null,
"actual_reclaimed_bytes": 2147483648,
"actual_reclaimed_human": "2.0 GB",
"source": "execute"
},
{
"timestamp": 1700000600,
"action_id": "node.clean.node_modules",
"resource_id": "node_modules:/Users/dev/proj/node_modules",
"policy_label": "ASK",
"outcome": "aborted_by_revalidation",
"abort_reason": "ResourceIdentityChanged",
"actual_reclaimed_bytes": null,
"actual_reclaimed_human": null,
"source": "free"
}
]
}
glomeris free (--goal-used-percent <N> | --target <N%|NB>) [--dry-run] [--json] [--progress-json] [--stop-file <path>] [--autopilot [--unattended]] [--project-root <path>]...
macOS only (exits 1 with an error message on other platforms). Exactly one of the two goal flags is required; any other argument is rejected (usage printed to stderr, exit 2).
The two axes
The goal can be stated on either axis, and the two flags are not interchangeable wordings of one thing:
| Flag | Axis | Meaning |
|---|---|---|
--goal-used-percent <N> | disk used | Stop when the volume is at most N percent used. |
--target <N%|NB> | free space | Stop when at least this much of the volume is free. |
--goal-used-percent 60 and --target 40% request the same end state.
--goal-used-percent is the product-facing form — it is the number a person
reads off a status bar, and it is what the Glomeris app sends. --target is
the raw free-space floor the recovery loop itself works in, and its meaning
and value formats are unchanged.
Passing both exits 2 rather than picking one: they are different numbers about how much of a disk to delete, so there is no safe precedence between them.
Accepted --target value formats:
- A percentage: a number followed by
%, in the range0–100(e.g.--target 15%). Target is “at least this percent of total capacity free.” - An absolute byte amount: a number optionally followed by
B,KB,MB,GB, orTB(case-insensitive; no suffix means raw bytes). Multipliers are binary/1024-based —--target 5GBmeans5 * 1024^3bytes free, not5 * 10^9.
--goal-used-percent takes a plain number from 0 to 100; a trailing %
is tolerated. It is additionally checked against the current reading and
refused with exit 2 if it is not an improvement on current usage, because
recovering toward a goal you already satisfy would delete nothing and still
print a report that reads like a successful cleanup. That check runs before
the execution lock is taken and before anything is deleted. --target
deliberately keeps its older, looser behaviour: a free-space floor you already
exceed is a legitimate no-op probe.
Other flags
--dry-runprints the pre-flight for the goal — current usage, free bytes still needed, and the estimated reclaimable opportunity split by what policy would actually permit — then exits. It takes no execution lock and mutates nothing. A--targetpre-flight is rendered on the used axis too, so no surface has to show a percentage whose axis is unstated.--jsonprints a machine-readable report instead of prose. With--dry-runthat is aRecoveryPreviewReport; without it, aRecoveryRunReport. A goal refused before the run starts prints aRecoveryGoalRejectionReport(reason,message,goal_used_percent,current_used_percent) and still exits 2. A busy execution lock prints the same{"reason": "busy", ...}refusalexecute --jsondoes, and exits 75.--progress-jsonstreams NDJSON progress on stderr — one complete object per line, never on stdout, so a--jsonreport stays parseable as exactly one document. With--dry-runit is the discovery-scan streamdetectemits. Without it, it reports the recovery loop itself; see Watching a run below.--stop-file <path>asks the loop to stop after the action it is currently running, oncepathexists. See Stopping a run.--autopilotbounds the run by the stored Autopilot envelope instead of by this command’s own limits. See Running inside the grant.--unattendeddeclares that nobody asked for this run. Only valid with--autopilot, and it needs a second permission the grant states separately. See Runs nobody asked for.--project-root <path>is optional and repeatable, same meaning asdetect’s flag above — it feeds the sameDiscoveryContextthe recovery loop discovers candidates from.
Watching a run
With --progress-json, a real run emits one JSON object per line on stderr as
it proceeds. Every line carries a phase and the 1-based iteration it belongs
to:
phase | When | Also carries |
|---|---|---|
measured | The volume was read (loop step 1). The only statement of fact about free space in the stream. | total_bytes, free_bytes, used_percent, free_human, bytes_freed_so_far |
discovering | Detectors are being asked what exists now. Re-entered every iteration — no pass reuses an earlier one’s list. | — |
discovered | That pass finished. | candidates (how many have a resolvable action), detectors_failed |
revalidating | Evidence is being re-collected and reclassified before anything is chosen. | — |
action_started | A real mutation is about to run. | resource, action, policy_label, estimated_bytes |
action_finished | It finished. | resource, action, outcome, reclaimed_bytes, bytes_freed_so_far |
stop_requested | A stop was observed, between actions. | — |
Two rules hold across every line:
estimated_bytesis the only estimate.reclaimed_bytesandbytes_freed_so_farare measured from the filesystem. Never accumulate the estimate as progress — it is what the candidate claimed, not what happened.- A count that could not be measured is absent, not zero. An
action_finishedline with noreclaimed_bytesmeans the size was not determinable;"0 B"there would be a measurement nobody took.
These phase names never collide with detect’s discovery stream, so a client
reading both cannot mistake a scan for a run.
Stopping a run
--stop-file <path> is a sentinel: create the file and the loop stops after the
action it is currently running, finishing with
stop_reason: "stopped_by_user" and a complete report.
- The path must not exist yet (exit 2 otherwise). A sentinel left behind by an earlier run would stop the next one before it did anything, and the report would truthfully say the user stopped it while the user had done nothing.
- Cooperative, deliberately. Nothing here can interrupt a deletion mid-flight. A signal delivered partway through one would leave the filesystem in a state neither the loop nor the audit log could describe, so the loop asks between actions instead.
- Not valid with
--dry-run, which performs no actions to stop.
Running inside the grant
--autopilot runs this same loop under the standing grant
glomeris autopilot enable wrote. It starts no second loop and introduces no
second policy: every candidate is still classified, revalidated and executed
exactly as it would be otherwise, and the envelope can only withhold one.
- Purely subtractive. Anything an
--autopilotrun does, a run you started yourself would also have done. Nothing the envelope says can make aPROTECTEDresource executable, admit anASKwhose exact kind and reason were not pre-authorized, or reach past a scoped-path or revalidation check. - No grant, no run. Defaults grant nothing, so with Autopilot revoked or
never enabled the command exits 3 having attempted nothing — the same code
autopilot runuses, so a script can tell “not authorized” apart from a usage error (2) and from a failed run (1). A revoked envelope refuses exactly as an absent one does;revokekeeps the limits on file, and onlyenabledstands between them and a run. - Its limits replace this command’s, and are the stricter of the two. The
run-level action count and wall-clock budget come down to the envelope’s own
figures. Left at their defaults, the loop’s 600-second ceiling could cut short
a run the grant had authorized for the full 15 minutes and report
budget_exceeded— a true sentence about the wrong budget. - The envelope is printed first, on stderr, before anything is discovered. Output that may end in deletions opens with the authority it acted under rather than asking you to go and look it up afterwards. stdout still carries exactly one report.
- A stop the envelope caused says so.
stop_reason: "envelope_refused"with anenvelope_refusaltoken naming which limit it was — an exhausted action, byte or time budget, a kind outside the grant, a pressure floor not met, a grant revoked mid-run. That is deliberately notsafe_exhausted: “this disk has nothing safe left” and “Autopilot reached the limit you set” call for opposite next steps, and only one of them is a finding about the disk. - Not valid with
--dry-run(exit 2). An envelope is authority to execute; a preview executes nothing, so the flag would have nothing to narrow and the candidate list would look envelope-filtered while being the whole of it. Read the grant withglomeris autopilot show.
Runs nobody asked for
An envelope answers “what may a run do”. --unattended is about a different
question — “may a run start when nobody pressed anything” — and that is a
second permission, granted by
glomeris autopilot enable --respond-to-alerts and off by default.
- Two consents, not one. An enabled envelope alone does not authorize an
unprompted run, and
--unattendedwithout that permission exits 3 having attempted nothing. The reason is what the alternative would mean for a grant already on disk: somebody wrote it to bound a run they intended to start, and reading it as consent to act while they are away would widen a grant they already read and approved. - The check is here, in this binary.
--unattendedis the caller’s own statement about itself, and it exists so that whatever launched the process cannot be the thing that decides it was allowed to. Without the flag, a client starting an--autopilotrun on its own initiative would be indistinguishable from a person typing the same command. - It narrows nothing else. Every budget, the kind allowlist and the ASK
pre-authorizations apply to an unprompted run exactly as they do to one you
started.
--unattended --autopilotis not a mode with different limits; it is the same bounded run with one additional permission checked before it begins. revokestops it along with everything else, without clearing the preference — so revoking never looks as though it had silently reset what you chose.- Only with
--autopilot(exit 2 otherwise). A run nobody asked for is precisely the one that must be bounded by a grant somebody read, so the combination that would be unbounded is refused at the boundary rather than later.
Reported figures
Every percentage in the output names its axis (60% used (40% free),
20% free). Every byte figure in a finished run is measured, and
target_met is decided from the re-measured free space after the run, never
from the sum of what the detectors estimated. The reclaimable figures in a
--dry-run preview are estimates and are labelled as such; protected space is
counted but never added into any opportunity total.
The prose output prints a RecoveryReport: the goal or target, stop reason
(as both a token and a sentence — a run that stopped because nothing safe
remained says so rather than printing a success word), iterations run, actions
executed, actions declined/skipped, bytes freed, and free space before/after.
A run that stopped because nothing safe remained also reports what is still
there, as four separate counts rather than one total: candidates awaiting your
confirmation, candidates whose action cannot run against the resource as it
currently stands (a live tool, work in progress), protected resources, and — for
an --autopilot run — candidates this grant was not authorized to take. Each is
a different next step, so they are never summed, and the fourth is kept apart
from the other three for a further reason: those are facts about what is on the
disk, and that one is a fact about the run’s authority. Folded into the protected
count it would tell you something is off limits when it is one setting away. They
are counts of candidates, not bytes, because space the run was not permitted to
take is not an opportunity.
Under --json this is the remaining object, present only for
stop_reason: "safe_exhausted" and for an envelope_refused stop that had
already discovered candidates: no other stop concluded anything about the
candidates it never reached, so zeros there would be a claim the run did not
make. An envelope refused before discovery — revoked, or a pressure floor not
met — omits the object entirely for exactly that reason, rather than publishing a
breakdown of zeros that would read as a completed search.
See Safety Model and
Known Limitations for what this loop can and cannot
currently do end to end.
glomeris autopilot <show|enable|revoke|run>
The subcommand is optional and defaults to show, so a bare
glomeris autopilot reads the grant rather than acting on it. run is macOS
only (exits 1 with an error message on other platforms); the other three verbs
work anywhere.
| Verb | What it does | Can it delete? |
|---|---|---|
show | Prints the stored envelope and its path. Does not create the file it reads. | No |
enable | Writes a new envelope from this command line’s flags and turns Autopilot on. Requires --kinds. | No |
revoke | Turns Autopilot off, keeping the limits. Effective for the next run; nothing to restart. | No |
run | Considers discovered candidates within the envelope. Deletes unless --dry-run. Holds the execution lock. | Yes |
The flags, their defaults and their hard ceilings are documented in Autopilot, which is also where the argument for why an LLM plan file cannot expand authority lives. What this page adds:
--max-bytestakes raw bytes only — unlikefree --target, there is noGB/MBsuffix parsing here, because an envelope is written once and read many times and an exact number is easier to audit than a rounded one.--min-pressureaccepts anyPressureStatename case-insensitively (healthy,warn,pressured,critical,emergency) plus the literalnone. An unobservable reading fails any floor you set, rather than passing it.--respond-to-alertsbelongs toenableand takes no value. It grants the one thing the limits above cannot express: that a run may begin without being asked, in answer to a disk-pressure alert (free --autopilot --unattended). Off by default and deliberately not implied byenableitself; see Runs nobody asked for for why those are two consents. It changes nothing about what a run may do once it starts, andrevokesuspends it with the rest of the grant while keeping it on file.--preauthorize-askis repeatable and takeskind:reasonusing the same tagsglomeris actions listandglomeris explainprint. Like the four limit flags above it, it belongs toenable—runaccepts only--dry-run,--plan-fileand--project-root, and exits 2 on anything else, so a run cannot widen its own grant on the command line.--jsonbelongs toshow,enableandrevoke, and prints one shape from all three: the grant (enabled,allowed_kinds,ask_preauthorizations, the four limits,min_pressure, and the two unprompted-run fields below), the hardceilings, every choiceenablewould accept (allowlistable_kinds,preauthorizable_reasons,pressure_states), what no envelope can authorize (never_allowlistable_kinds,never_preauthorizable_reasons,never_executable_labels), theai_authoritysentences, andstored_at. It always describes what is in force after the command ran —enablereports from what reached the file, not from what it was about to write — so a client reads one shape to learn one thing.rundoes not take it: the one reason to add it would be to make triggering a run from a GUI easier, and Menu Bar App explains why there is no such button.- The JSON carries two fields about unprompted runs, and they answer
different questions.
respond_to_alertsis the setting as you left it, in force or not — the one a settings toggle binds to, so that revoking Autopilot does not read as having silently cleared the preference.starts_unpromptedis whether an unprompted run is authorized right now: this grant is in force and it says so. Anything that acts consults the second, and is not the thing that computes it —enabled && respond_to_alertsevaluated in a client would be that client deciding its own authority. runprints anAutopilotReport: one line per candidate with its kind, policy label, model rank and outcome, then the run totals (actions attempted, actions succeeded, bytes freed, whether a budget stopped it early) and the envelope it ran under. Refusals appear here and nowhere else —glomeris historyis a log of what happened to the filesystem, and a refusal did not touch it.
Exit codes: 0 success, including a run that found nothing it was allowed to
do; 1 an action failed, or the envelope file could not be read or written;
2 usage error, including an unknown resource kind, a limit above its ceiling,
or enable without --kinds; 3 Autopilot is not enabled, so nothing was
attempted; 75 another invocation holds the execution lock.
3 exists so that “no grant” is distinguishable from “granted, ran, found
nothing” in a script — both of which are quiet, and only one of which means
the user has something to configure.
glomeris settings <show|set> [--notify-at-used-percent <N>] [--default-goal-used-percent <N>] [--json]
The subcommand is optional and defaults to show, so a bare
glomeris settings reads your preferences rather than changing them.
| Verb | What it does | Can it delete? |
|---|---|---|
show | Prints both preferences and their path. Does not create the file it reads. | No |
set | Stores one or both preferences. Requires at least one flag. | No |
Two preferences live here, and the whole point of the surface is that they are not the same number:
| Preference | Question it answers | Range | Default |
|---|---|---|---|
--notify-at-used-percent | When should Glomeris call my attention to disk usage? | 1–99 percent used | 75 |
--default-goal-used-percent | Where should recovery stop? | 0–100 percent used | 70 |
Both are percent of capacity USED, never free — the same axis
free --goal-used-percent takes, converted to the core’s percent-free
--target by the single adapter documented in the glomeris free section
above. Every line this command prints names its axis, in prose and in JSON,
because 75 beside 70 with no label is the one misreading this feature
cannot afford.
What this page adds:
- The goal must be below the threshold. A goal at or above the point that
raised the alert would be satisfied the moment it was announced, so the pair
is refused with the reason
goal_not_below_notify_threshold. - Both flags are validated as a pair, in one step. Moving from
(75, 70)to(60, 55)is a valid destination that no single-field order can reach — whichever field moved first would be momentarily invalid against the old value of the other. Sosetrefuses you for the destination you asked for, never for the order your flags happened to appear in. - A trailing
%is accepted on either value, because a user typing what they read on screen has not made a mistake. setwith neither flag is a usage error rather than a successful no-op: a command line that did not say what it wanted should not report that it did it.- The four disk-pressure states (
healthy,warn,pressured,critical,emergencyboundaries) are not configurable and this command cannot reach them. That is deliberate: if thewarnboundary were a preference, lowering it would change what awarnrecorded last week meant, and raising it would make the next poll report a transition the disk never made. The alert threshold is a separate scalar layered over that machine, and its default tracks thewarnboundary so the product is no quieter than it is today. - Neither preference is an authorization. Raising a goal cannot make a
PROTECTEDresource deletable, cannot bypass anASK, and cannot widen an Autopilot envelope. These two numbers decide when the product speaks and where recovery aims; every gate between a candidate and its deletion is elsewhere. --jsonbelongs to both verbs and prints one shape from both:notify_at_used_percentandnotify_at_descriptionfor the threshold, adefault_goalobject carryingused_percent,free_percentanddescriptionfor the goal, aboundsobject carrying the four limits above asnotify_at_minimum_used_percent,notify_at_maximum_used_percent,goal_minimum_used_percentandgoal_maximum_used_percent,stored_at, andloaded_from_file.boundsis there so a settings screen can offer a control that cannot compose a value this command would refuse, without knowing the limits independently and drifting from them; the cross-field rule is deliberately absent from it, because a limit that moves as the other number moves is not a bound on either one. It always describes what is in force after the command ran.loaded_from_fileis reported rather than anis_defaultflag, because a user may legitimately store the default numbers and a client must be able to tell “nothing chosen yet” from “these were chosen”. A refusedsetprints a rejection object on stdout instead —reason(a stable token),message, and only whichever ofnotify_at_used_percent/goal_used_percentthe refusal actually involved.- The file is
~/Library/Application Support/Glomeris/settings.conf, beside the Autopilot envelope and in the same versioned format: aversionkey, onekey = valueper line, unknown keys an error rather than noise, and loading routed through the validating constructor so a hand-edited file cannot install a pair the CLI would have refused.
Exit codes: 0 the preferences were printed, or the change was stored; 1 the
settings file could not be read or written, or what it contains is not valid;
2 usage error, including set with neither flag, or a value on this command
line that was refused.
A refused stored file is 1, not 2: it is not this command line’s mistake,
and a script must be able to tell “you typed 120” from “the file on disk
disagrees with itself”.
glomeris external-context [--json]
What optional remote context may reach the model, and what never will. Read-only and off by default: with nothing configured it prints that nothing leaves this machine, which is a fact about the shipped product rather than a hypothetical.
Glomeris can optionally read a pull request’s state from GitHub and a work item’s state from Jira, as supporting evidence for ranking a working tree that looks stale. Neither is deletion authority — see the list at the end of this section — and neither is on until you write the configuration file.
This command asks nothing. Answering “what may leave” by leaving is the one shape it must not have: you would be making the very requests you are trying to understand, against a service that logs them, before deciding whether you want that. Everything it prints comes from your configuration file and from the value vocabularies the code itself serializes.
Three kinds of line, and the difference between them is the point:
| Section | What it describes |
|---|---|
| Providers | Your local setup. Whether each provider is configured, whether it is usable, the environment variable its credential is read from, the endpoint, and for GitHub the one repository host in scope. |
| Fields that may be sent | What could actually leave, one line per serialized key, each with its complete set of possible values. |
| Never sent | The explicit negative. |
What this page adds:
- “Not configured” and “configured but not usable” are different rows. A
provider whose credential environment variable is unset is reported as
configured with a refusal naming the variable, not as absent. The same
distinction runs all the way through the feature: a provider that could not
answer never reports an absence, and
no pull request was foundis a different value fromGitHub could not be reached. - The field list is the complete set, and it is derived rather than written.
Each state vocabulary comes from the enum the projection serializes, so a
variant added later cannot widen what really travels while this page keeps
understating it. A remote state reaches a model as a bounded token —
merged,in_progress,none_observed— with how many days ago it was read, and with the kind of failure when a provider could not answer. - Nothing identifying goes. Not the repository, the branch, the issue key, the pull request’s number, title, body or author, your account, or any path. The command prints that negative explicitly, because a list of what travels does not answer “did you send my branch name”.
- Your Jira account address is not echoed, even though the configuration file holds it. It identifies a person rather than a setting anybody debugs, and this report gets pasted into bug reports. The site is shown, because you need to see which one is asked.
- No credential value appears anywhere, in prose or in JSON. The variable is named; the value is never read by this command at all.
- Jira has no host scope and does not claim one. Correlation there requires
an explicit, deterministic issue key in the branch name —
HORO-1234, not a fuzzy match fromfix-storage-stuff— so there is no remote to compare against. GitHub’srepository_hostis the most privacy-relevant line in the report: a working tree whose remote is anywhere else is never named to that provider at all. - None of it is permission. A merged pull request and a closed ticket are
context for ranking. They cannot make a working tree deletable: a dirty
tree, an untracked file, a local commit made after the merge, or a process
holding the directory open each keep it protected or uncertain whatever a
remote service says. That is enforced in the policy engine, which never sees
external context, and pinned by
tests/external_context_grants_no_authority.rs. - Read-only by construction, not by convention. The providers can name
exactly one transport, whose whole vocabulary is a single GET;
scripts/check-external-context-is-read-only.shproves the module names no mutating verb, no second transport, and nothing that prints or writes. --jsonprints the same report — each provider’s configured and usable state, its credential variable, endpoint and host scope, every field that may be sent with its vocabulary, and thenever_sentlist. This is what the menu-bar app’s privacy preview reads.- The file is
~/Library/Application Support/Glomeris/external-context.conf, besidesettings.confand in the same versioned format.
Exit codes: 0 the report was printed; 2 usage error — this command takes no
arguments besides --json.
An unparseable configuration file is 0, not 1, which is the opposite of
settings show. The two commands answer different questions. settings show is
asked “what are my preferences”, and an unreadable file means it has no answer.
This one is asked “what could leave this machine”, and an unreadable file has a
complete and reassuring answer — nothing is configured, so nothing leaves — that
a non-zero exit and a bare stderr line would throw away. The refusal is reported
as the first line of the report instead.
glomeris pressure <show|notified|respond> [<answer>] [--json]
The seam between the background monitor and the menu-bar app. You are unlikely to type it; the app runs it every poll.
It exists because of a constraint neither side can work around alone: only an
app bundle can put buttons on a macOS notification, and the monitor
(glomeris daemon) is a bare launchd process, not a bundle. So the monitor
records that a notification is owed and the app raises it. This command is
how the app asks what is owed and reports which button was pressed.
The subcommand is optional and defaults to show, so a bare
glomeris pressure reads rather than writes.
| Verb | What it does | Can it delete? |
|---|---|---|
show | Prints the current episode, whether a notification is owed, your alert threshold, your recovery goal, and the answers that can be given. | No |
notified | Records that the notification was put on screen, so it is not raised again for the same episode. | No |
respond | Records which button was pressed. Takes one of the three answers below. | No |
What an episode is
One continuous stretch of disk usage being at or above
settings --notify-at-used-percent. It opens the first time an observation
reaches that threshold and closes only once usage has fallen three percentage
points below it. That gap is the hysteresis, and it is the whole reason one
spell of a full disk produces one notification rather than one per poll: a
volume hovering exactly on the threshold stays inside one episode, and it takes
a real recovery rather than a rounding wobble to end it.
Episode ids are monotonic and never reused, because the app uses the id as the notification’s identifier — two notifications about one episode coalesce, and two episodes never do.
The three answers
| Answer | What it means |
|---|---|
review_and_recover | Open the recovery screen for this episode. Opens it — it does not start recovery, and deletes nothing. |
remind_later | Say nothing for two hours, then notify again if usage is still above the threshold. The episode stays open, so this is a delay, not a dismissal. |
ignore_episode | Say nothing more about this spell of disk pressure. Monitoring continues and the next episode notifies again. |
Hyphens are accepted too (respond review-and-recover), because that is what a
command line looks like. Nothing else is: an unrecognized answer is refused
rather than mapped onto the nearest match, since the nearest match to a
misspelled ignore_episode is the one that opens recovery.
ignore_episode cannot switch notifications off. It is stored on the
episode, so it expires with it — there is no answer here, and no flag anywhere,
that silences monitoring indefinitely. That is the point of storing the answer
on the episode rather than in settings.
What this command does not do
- It does not observe.
shownever opens or closes an episode and never decides that a notification is owed; only the monitor’s own poll does. On a machine where the monitor is not installed there is therefore no episode at all, andshowsays so while still reporting whether usage is above your threshold. - It does not delete anything, and it cannot widen what may be deleted. An
episode decides when you are spoken to, never what is permitted. Answering
review_and_recoveropens a screen. Every gate between a candidate and its deletion is elsewhere and unchanged. - It does not start recovery, automatically or otherwise. Crossing a threshold raises a question; a human answers it.
--json
Belongs to all three verbs and prints one shape from all three, always describing the state after the command ran. This is what the menu-bar app reads.
current carries the disk reading with both percentages under separate names
and the byte figures; notify_at_used_percent/notify_at_description the
threshold; a default_goal object the recovery goal, in the same shape
settings --json prints it; threshold_crossed and notification_due the two
facts the app acts on; episode the episode’s identity, its opened/peak/latest
readings, how many notifications have been raised, the answer if one was given,
and the snooze deadline if one is pending; responses the answers that can be
given, so the app offers buttons it did not invent.
A refusal prints a rejection object on stdout instead — reason as a stable
token (no_open_episode, no_notification_due) and message — rather than
writing to stderr, because a refusal here is an ordinary outcome that the app
has to read and display.
Exit codes: 0 the episode was printed, or the answer was recorded; 1 the
episode state or your settings could not be read or written, or filesystem usage
could not be measured; 2 usage error, including an answer that is not one of
the three; 3 there was nothing to record.
3 rather than 1 for that last case, and this is the load-bearing part of the
contract: no episode is open, or no notification was owed. It is not a
failure. The usual cause is that the disk recovered while the notification was
on screen, and the app polls this command every thirty seconds — a client that
treated it as an error would retry at poll speed forever.
Exit codes
Also available as glomeris help exit-codes, which is the copy to trust — it
renders from the same table as the per-command help, so a command’s exit codes
cannot drift between its own --help and the summary.
0— success.1— a macOS-only command was run on a non-macOS platform, a platform-level operation (e.g. reading thelaunchdplist path) failed, or (forllm-planspecifically) the LLM provider call/response parsing failed, or--plan-filenamed an unreadable path, or (forllm-checkspecifically) the check ran and did not pass.2— usage error: unknown top-level command, unknowndaemonsubcommand, missing/unrecognizedfreearguments, or (forllm-plan/llm-checkspecifically) an unrecognized argument, a missing flag value, missing live-mode LLM environment configuration, a base URL that cannot work, or an--api-key/--key/--tokenflag.3— (autopilot runandfree --autopilotonly) this run was not authorized, so nothing was attempted: either Autopilot is not enabled, or--unattendedwas given and the grant does not allow starting a run unasked. One code for both because to a script they are the same fact.75— (free/emergency/execute/autopilot runonly) the HORO-1054 execution lock is already held by anotherglomerisinvocation.
glomeris execute has its own, more specific set of exit codes (0–5
plus 75) — see its own section above for the full table; a couple of
those codes (1, 2) overlap this list’s meanings but are worth reading
in full since execute is the one subcommand with real destructive
consequences.
See glomeris llm-plan’s and glomeris llm-check‘s own sections above for
those subcommands’ exit codes in full detail — llm-check’s 1/2 split in
particular carries a meaning this list cannot: whether a report exists.