Skip to content

Capability: cli

The the-loop Python CLI companion — lightweight, one-dependency, extensible quality-of-life commands the plugin (and users) can call.

What it is

A Python package (cli/, import package the_loop, console script the-loop) with an extensible command registry. Python is deliberate: it leaves room for future self-learning/ML capabilities.

Current behaviour

  • The CLI SHALL register commands via an extensible registry (the_loop.commands).

  • The CLI config MAY name the instance it is (issue-322, instance): the-loop status SHALL print one line naming the instance (or (unnamed)), its scope mode and the sizes of its declared and managed sets, and SHALL carry the full instance document in --format json; the-loop sessions start on a locked instance SHALL refuse a work item outside its managed set with exit code 1 before recording or posting anything; and every keyword the CLI posts back to a ticket SHALL carry instance:<name> when the instance is named. See instances.

  • The CLI SHALL have exactly one runtime dependency, pyyaml>=6, and be stdlib otherwise. PyYAML is REQUIRED, not an extra: the CLI config, the harness config and every default the daemons read are YAML, so a missing parser used to degrade each read to empty, with the cause logged at debug or not at all — leaving poll to exit with "no polling sources configured" against a file that listed sources (issue-97, decision-038). The [config] extra that once carried it SHALL be retained as an empty, deprecated no-op so pinned install lines keep resolving.

  • the-loop --version SHALL report the installed package version, derived from package metadata (importlib.metadata.version("the-loopy-one")) rather than a hardcoded string, so it always tracks the actually-installed release (issue-78).

  • The lifecycle surface SHALL run/stop the HMAC-verified GitHub webhook receiver (see webhook-triggers).

  • the-loop sessions register|list|attach|link-pr|close SHALL manage the work-item ↔ harness-session registry used for webhook routing. link-pr records a pull request as delivering a work item (issue-274) — the step a session runs in the same breath as opening the pull request, because a pull request the-loop authored carries none of the linkages the router can otherwise infer. It is idempotent, refuses a work item linked to itself, and writes nothing when the work item has no session record on this machine. Since issue-370 it is the only writer of the work item's pullRequests[], and the plugin's PostToolUse hook runs it when a session creates a pull request, so the record does not depend on the agent remembering the step.

  • the-loop sessions start|pause|resume|stop|cleanup SHALL give an operator with shell access the same five commands an authorized user issues by keyword in a comment (issue-106, issue-186, see webhook-triggers): start spawns through the same dispatcher the daemon uses — workspace checkout, harness trust, tmux hosting, session announcement — or resumes a paused session; stop takes the normal close path; cleanup releases the work item's local resources through that same dispatcher (every endpoint's tmux session, the workspace checkout, the machine-local record), keeping the portable record and touching nothing remote, and reports each irreversible fact on its own line. Each invocation SHALL record the command in that work item's portable record (<state.root>/portable/<slug>.json, control section — issue-128) and SHALL post the same keyword back to the work item so its thread stays the full record of who asked for what. That comment SHALL carry the loop-prevention marker (authz.mark_self_authored), because the action has already been applied locally and neither ingress path may read it back and re-apply it. Posting is best-effort — --no-comment skips it, and a missing/failing gh warns without undoing the local action. sessions list SHALL show each session's status (including paused) and its last control command.

  • the-loop ask --work-item <ref> --question <text>|--question-file <path> SHALL post an agent's question on its work item with the loop-prevention marker stamped centrally and SHALL record the wait as a session.awaiting_input event — emitted (as a warning, comment_posted: false) even when gh fails, since the agent is waiting either way (issue-208, decision-078). It SHALL execute in-process rather than through the service: the escalation path must not depend on anything else of the-loop running. The answer arrives as a forwarded ticket comment, or straight into the pane via POST /api/v1/sessions/reply (see control-plane).

  • the-loop add-collaborator @LOGIN [@LOGIN …] --work-item <ref> and the-loop remove-collaborator @LOGIN … --work-item <ref> SHALL grant and revoke work-item collaborator status (issue-307, decision-102): a login whose comments on that one work item reach its session as agent input, and who can do nothing else — no control command, no spawn, no arming, no human gate (see webhook-triggers). Each SHALL apply the local effect first and post the same keyword and login back to the work item after, self-marked, so a failing gh never leaves the thread claiming a grant that was not made and never loses one that was. Every login SHALL be validated before anything is written, so one typo refuses the whole call rather than half-applying it; a login already on (or absent from) the roster SHALL be reported as unchanged and SHALL NOT be announced. Like ask, they SHALL execute in-process rather than through the service: a roster must stay fixable when nothing else of the-loop is running.

  • the-loop sessions reset --work-item <ref> [--work-item …] | --all [--dry-run] SHALL remove everything this machine remembers about a work item (issue-137, decision-050) — the verb for "I fixed a bug in the-loop itself; start this item over on the new code". A live session (active or paused) SHALL first be ended through the same close path stop takes, so no harness is left running against records that have gone; the session record SHALL then be deleted rather than closed (a closed record still lists and still attaches, which is the "still remembered" a reset ends); the control section SHALL be cleared, which disarms the work item, and the poll section cleared, which makes its thread first-sight again. The portable sections SHALL be cleared through the work-item store, never by unlinking the file, so a pre-issue-128 tree leaves a sealed record instead of resurrecting what was just removed. The command SHALL post no comment on the ticket, and there SHALL be no reset control keyword: a comment must not be able to delete local state, and posting stop-execution would record intent the reset has just cleared. The event log SHALL be appended to and never rewritten — one session.reset per work item, including when nothing was found. A bare reset (no selector) SHALL be refused rather than read as "everything", one invalid ref SHALL reset none of them, one failing work item SHALL NOT strand the rest, and --dry-run SHALL report the same list while changing nothing and emitting nothing. The operator SHALL be warned when the receiver is running (it can write poll state back) and when control.requireStartCommand is false (a first-sight item may re-spawn on the next poll cycle) — warnings, not refusals. Nothing in the repository is touched: docs/specs/<id>/work-item-state.json is checked in and re-derived from the artifacts.

  • Everything the CLI generates SHALL live under one configured root (state.root, default .the-loop, issue-106), resolved to an absolute path against the directory the config file's .the-loop/ sits in — never against a process's working directory (issue-339, decision-119). The resolution SHALL happen once, where the config is loaded, so that every consumer of that document — the heartbeat, the session registry, the event log, the channels store — names one directory in the daemon and the CLI alike; ~ SHALL be expanded. A mapping built without a config file behind it keeps the relative default, having nothing to anchor on. The tree SHALL be organised by portability rather than by which component writes it (issue-128, decision-046): <root>/portable/<slug>.json is one record per work item carrying a control section (the last authorized start/stop/pause/resume) and a poll section (which comments have been seen); <root>/local/<slug>.json is that work item's session handle; the event log and pidfile keep their places. The root supplies defaults only for the local paths — an explicitly configured registryDir, eventLog.path or pidfile is used verbatim — while portable/ always follows the root, so "where is the half I track?" has one answer. Two components write a work-item record, so every write SHALL replace only its own section (read-modify-write): a poll cycle must never erase a control command the other ingress recorded a moment earlier.

  • Within those files, every attribute SHALL have one home, chosen by the party it belongs to (issue-368, decision-128). Three files hold something about one work item, and who may write a file decides what may be in it: the repository's docs/specs/<id>/work-item-state.json carries the pointer, every choice a human froze (including sessionPerPr, model and effort) and the pull requests delivering the item; the operator's <root>/portable/<slug>.json carries what an authorized human said, what this deployment has seen, and the channel thread the-loop opened; the machine's <root>/local/<slug>.json carries one session per ref it serves and what it has already mirrored. Each attribute SHALL be written once, and any other file needing it SHALL carry an identity — a ref, a thread ts — never a second copy. A machine handle (a harness conversation id, a tmux target, an absolute path, a read cursor) SHALL NEVER be written to a tracked file. The classification SHALL be declared as data (state.ATTRIBUTES) and a test SHALL fail when a file grows an unclassified key, when a kind sits in a file its rule forbids, or when docs/cli/state.md disagrees with the declaration.

  • There SHALL be exactly one portable record per work item (issue-368): a pull request delivering a tracked work item SHALL NOT have a record of its own — its poll ledger is keyed under the owner's record, resolved before any write through this machine's session records, then the portable ledgers, then the router's linkage on the listed item. A pull request that delivers nothing tracked keeps its own record, because it is the work item. A closing pull request with an owner SHALL record its upstream state in the work item's checked-in file and drop its nested ledger, and SHALL NOT be stamped ended. A session listing SHALL consider only the files the registry itself wrote (<slug>.json, i.e. a name ending in -<number>) and SHALL ignore its neighbours silently, so the "skipping unreadable registry file" warning stays reserved for genuine corruption (issue-111).

  • Because portable/ is the one generated directory that is tracked, it SHALL be readable as well as writable (issue-130, decision-047): every record SHALL carry a url beside its ref, and the directory SHALL carry index.json — one entry per record (ref, url, file, the sections it holds, sealed when it is an upgrade tombstone), ordered by ref. The index SHALL be derived by scanning the directory on every write and removal, SHALL be removed with the last record, and SHALL be read by nothing in the-loop, so a stale, hand-edited or forged index changes no behaviour and the next write repairs it; a failure to write it SHALL be logged and SHALL NOT fail the record write it accompanies. A URL SHALL be derived only for a github ref whose owner and repo match GitHub's own name shape, and SHALL be omitted otherwise rather than guessed.

  • A work-item ref SHALL be <provider>:[<host>/]<owner>/<repo>#<number> (issue-130, decision-048): a work item that does not live on the provider's default host names that host, and one that does leaves it unwritten — so every ref written before this is unchanged, and so is its file name. Both ingresses SHALL identify the host from the event, never from configuration or assumption: the webhook receiver from the repository's html_url (falling back to the issue/PR URL, which is what the poller's synthesised payloads carry), and a polled work item from its own URL. The two derivations SHALL agree, because one keys the routing and the other keys the poll ledger. A path that is neither <owner>/<repo> nor <host>/<owner>/<repo> — where a host is a dotted name or one with an explicit port — SHALL be rejected as a malformed ref rather than read as a work item whose identity is a path fragment. A ref the-loop mints from configuration rather than from an event — the graph's own work item derived from ticketing.github, an inner loop's pull-request ref — SHALL carry the host one resolver answers (issue-311, decision-104): integrations.github.host, else the host of an enterprise github.api.baseUrl, else $GH_HOST, else the checkout's origin remote (in-session only), else github.com. Every outbound gh call SHALL read the host back off the ref through one spelling — --hostname <host> for gh api, [<host>/]<owner>/<repo> for --repo — written exactly when the host is not github.com, so a github.com deployment's argvs, refs and URLs are unchanged. A poll source's repos entry SHALL accept [HOST/]OWNER/REPO and SHALL claim only refs on that host.

  • The pre-issue-128 locations (<root>/sessions/, <root>/sessions/control/, <root>/sessions/poll-state.json, and the pre-issue-106 .the-loop/poll-state.json) SHALL still be read when a work item's new record has no such section, and written forward on the next write, so an upgrade neither re-baselines a watched thread nor forgets what an authorized user armed. Nothing writes to them. polling.stateFile SHALL be removed — a file path cannot address a per-work-item ledger — and a config still declaring it SHALL be refused with the replacement named, never ignored.

  • Every generated path SHALL be classified as portable or local (the_loop.state.GENERATED_PATHS, issue-128, decision-046): the work-item records are facts about the work — what was armed, which comments have been seen — and travel to another machine; the session registry, the event log and the pidfile are handles to the machine that made them and SHALL NOT be tracked. The session registry is excluded emphatically: a copied record is still counted live by find_by_work_item, so the duplicate guard would refuse the spawn the new machine needs and route events to a conversation that is not there — and it carries an absolute cwd and a resumable session id besides. The classification SHALL be declared as data and pinned by a test, so a new generated path cannot be added without answering whether it travels, and SHALL be published as a .gitignore block this repository itself uses (state on disk). the-loop SHALL never commit state on the operator's behalf.

  • the-loop check [<work item>|--all] SHALL evaluate a work item's nodes against its checked-in artifacts and report what is unmet (--format table|json). It SHALL be pure — no network, no subprocess, no mutation — which is what lets the same code run on every harness turn and in CI, so the gate is the runtime rather than a reimplementation of it. --recompute ignores stored work-item state and derives the verdict from the artifacts alone.

  • the-loop graph show|hooks|status|advance|run|force SHALL inspect and drive the process graph (see process-graph). hooks reports the shipped hooks and the repository's own declarations without importing any of them. run is bounded by --max-nodes and detects loops — a runaway loop is the one failure mode a deterministic driver can still have, so it gets an explicit ceiling rather than trust. force is the authorized-operator escape hatch: it requires a reason and moves the pointer without ever forging the bypassed gate's verdict. --ref is optional on every verb that runs hooks: omitted, it is derived from the origin repository — the work item's own, as the daemon knows it, or the checkout's origin remote in-session (issue-352) — plus the issue-<n> work-item id (issue-194). WHEN a verb's outbound call could not be made — no derivable ref, no credentials, an outage — THEN the command SHALL say so on stdout as a warning: / WARNING: line and SHALL keep its exit code: a degraded side effect is not a failed verb, and it is not a silent one either.

  • The CLI config SHALL carry a version, and the CLI SHALL refuse to run against a config older than the current schema version rather than guessing at the old shape (issue-109). Per-provider settings SHALL live under one integrations block — integrations.github.cli.binary replaces the ghBinary key that was previously duplicated across three consumers. Dispatch policy SHALL live under a top-levelrouting key, not under webhooks.ghWebhook: the poller and the-loop sessions dispatch on the same block, so nesting it under one ingress misstated its scope (issue-142). Both are breaking changes, handled by /the-loop:upgrade-the-loop, which shells out to the-loop migrate-config.

  • Slack SHALL be configured in exactly one place: channels.slack (the bot — channels). The integrations.slack incoming webhook and its url/urlEnv carve-out are retired (issue-245, the owner's convergence call on PR #267): a config still carrying the section is refused with the replacement named, and the-loop migrate-config removes it. Bot and app tokens are env-only, like github.api.tokenEnv and webhooks.ghWebhook.secretEnv.

  • the-loop scenarios SHALL output the table of every Gherkin scenario covered by the integration tests (--format table|markdown|json; see testing-and-contracts).

  • the-loop instructions SHALL report every doc registered in customInstructions.docs, in configured order, with its configured path, resolved absolute path, notes and state (present / missing / unreadable / invalid), in --format table|markdown|json. Everything not present counts as unresolved, and customInstructions.onMissing decides the exit code (error → 1, warn/ignore → 0) independently of the format. Like check and scenarios it is repo-scoped and pure — filesystem reads only — and it SHALL report facts about each doc, never its contents (see spec-workflow).

  • the-loop critic list|run SHALL list the configured critic harnesses and run one named critic-review round, printing its result as a single JSON envelope on stdout — the seam by which the running harness hands work to a different harness and reads back what it said (see review-loop). The roster is the operator's CLI config's critics[] (issue-352); the command is no part of the daemon (decision-032).

  • the-loop events SHALL query the structured JSONL event log of the CLI's own routing/dispatch/session decisions (see observability).

  • the-loop install / the-loop upgrade SHALL install and upgrade the-loop itself — this CLI and the Claude Code plugin — at --scope user (default) or --scope project, naming cli, claude or all (default: the CLI plus every harness found on PATH). Cursor SHALL NOT be a component until issue-157 establishes how a Cursor plugin is installed from a terminal (owner decision on PR #153); the_loop.install is harness-shaped, so adding it is a BINARIES entry plus a planner, not a new command. Both verbs SHALL build an ordered plan of steps, print the exact argv (or file) of each, and report one outcome per step (applied · already · skipped · failed, and planned under --dry-run), exiting non-zero only when a step failed; --dry-run SHALL be that same plan with the execution left out, and --format json SHALL emit the same records (issue-152, decision-057).

  • Installing a plugin SHALL be delegated to the harness's own installer where it has one: the-loop SHALL determine that by asking the binary (<binary> plugin --help, and plugin install --help for a --scope flag) rather than assuming a version, and SHALL pass the requested scope through instead of emulating it. A binary offering plugin marketplace but no working plugin install SHALL count as no surface — the split is real (Cursor 2.5) and running an install that cannot work would report a failure for an absent feature. WHERE no usable surface exists it SHALL fall back only to an already-documented route — the decision-054 settings keys, in the user file or <project>/.claude/settings.json at project scope, through the same non-destructive writer — and WHERE a requested scope cannot be expressed it SHALL report the component skipped with the manual instruction rather than install at a scope that was not asked for.

  • the-loop upgrade SHALL determine how the running CLI was installed from where its package lives (uv tool / pipx / pip) and use that method's upgrade command; a source checkout SHALL be reported skipped, naming the checkout, never installed over (issue-78's failure mode, closed from the other side). At project scope the CLI SHALL be installed into the project's .venv and SHALL NOT modify the project's pyproject.toml.

  • The marketplace source for both verbs SHALL resolve --from → the CLI config's routing.harnessPlugins.marketplaceRepo → the shipped default, SHALL be validated as owner/repo before it can reach a command line, a URL or a settings file (an invalid value exits 2 and touches nothing), and SHALL be printed in the plan header before anything is trusted. Every step SHALL be executed as an argv list with no shell.

  • The package SHALL be installable from PyPI as the-loopy-one (import package the_loop and the the-loop script unchanged; see release-publishing).

  • gh-webhook/poll/sessions/events SHALL read their defaults from a CLI config (cli-config.yaml) independent of any repo's .the-loop/harness-config.yaml (the plugin config) — resolved via --config/-c, else $THE_LOOP_CLI_CONFIG, else ./.the-loop/cli-config.yaml (repo-relative, so an operator can track it in a chosen repo), else ~/.the-loop/cli-config.yaml, so the CLI is not tied to a single repo (cli/README.md, decision-032).

  • The CLI SHALL NOT read any repository's harness config (issue-352, decision-123, superseding decision-044). That file is the agent's. What the CLI used to take from it has another source: the spec directory is routing.graph.specDir in the CLI config (default docs/specs) or --spec-dir on check/graph; the phase label prefix is the constant loop:; the origin repository is the work item's ref (daemon) or the checkout's origin remote (in-session); the critic roster is the CLI config's critics[]; graph hooks are its routing.graph.hooks; scenarios takes --glob and instructions takes --doc/--on-missing, both passed by the agent from the keys it read. The CLI SHALL neither write a harness config into a checkout (issue-193's adoption is retired) nor degrade on one it cannot parse, because it never opens one. authorizedUsers, repositories and every other setting remain CLI-config-only with no fallback.

  • the-loop start|stop|status|restart SHALL be the whole system's lifecycle surface (issue-228, decision-084). start reads the CLI config and starts, detached, every service it enables — the control-plane service (service.enabled, default on, with the MCP endpoint mounted per service.mcp.enabled), the webhook receiver (webhooks.ghWebhook.enabled, default off) and the poller (polling.enabled, default off) — reporting one outcome per service (started | already-running | disabled | misconfigured | failed, an enabled poller with no polling.sources — or, since issue-348, no top-level repositories — being the misconfigured case) and exiting 0 only when every enabled service came up; one service's failure SHALL NOT hide the others' outcomes. stop SHALL stop every running service regardless of the enabled flags. restart SHALL compose stop → start, --with-upgrade running the issue-152 installer plan (CLI component only) in between — a failed upgrade is reported and the start half still runs. POST /api/v1/restart SHALL schedule the same restart as a detached, fixed-argv process and answer immediately. The poll command is removed; python -m the_loop.daemon_entry poller [--once] is the foreground/cron form, running the same relocated loop (the_loop.poller.daemon); the gh-webhook and service commands are removed with it (owner review on PR #229 — "It should all fold into the-loop start"), the receiver's run loop relocated the same way (the_loop.webhook.daemon, daemon_entry gh-webhook as its foreground form).

  • start SHALL boot one process by default (issue-231, decision-084 §8): with the service enabled and service.hostIngresses true (the default), the enabled ingresses run as threads inside the service's lifespan — one pid, one logfile — each still holding its own pidfile flock under the service's pid, so status/stop, the single-instance guarantee and the daemons API answer unchanged. Hosted-ness SHALL be detected from the lock (holder pid equals the service's), never recorded in a file; an ingress lock already held by another process SHALL be skipped with a warning, never fought over; an enabled poller with no sources SHALL refuse to host while the service keeps serving; and stop SHALL report a hosted ingress stopped only once its lock is released. hostIngresses: false restores one process per enabled service, and a disabled service always means standalone ingresses.

  • Every core-capability command SHALL execute through the control-plane service as its only mode (issue-161). The exceptions are inherent, not transitional: sessions attach hands the terminal to tmux, sessions reset must work when nothing is running, the lifecycle commands and the daemon entry point run the processes themselves, and the bootstrap commands (install, upgrade, migrate-config, --version) precede any service. See control-plane, the capability that owns this behaviour.

  • A start SHALL be honest (issue-191, re-shaped by issue-228): the-loop start reports a daemon as started only once it holds its pidfile lock (the service, only once /health answers), so a process that exits during startup is a reported failure pointing at its logfile — never a silent one. Daemons are spawned into their own session with stdout/stderr appended to <state.root>/logs/, and SHALL carry the config path the spawning process resolved in THE_LOOP_CLI_CONFIG (issue-339): a daemon never sees --config, and before it carried one it re-resolved the config from its inherited working directory and could write its heartbeat, registry and event log under a root the CLI never read. The value SHALL only ever be the path the spawning process already resolved; a child that is itself a CLI invocation with --config SHALL still win.

  • The poller's pidfile SHALL be written by the surviving process under the single-instance lock, and a pidfile no live poller holds SHALL be reported as stale and removed by the next poller start rather than left for the operator.

  • the-loop status SHALL answer "is each service running, and is the poller making progress" in one command: per service enabled/running/pid (plus the service's URL, health and MCP exposure), and for the poller startedAt, lastCycleAt and the last cycle's counters, as text or --format json, exiting 0 iff every enabled service is running. It SHALL also name the files the answer is about (issue-339): the resolved config path and state root, and one conflict line per other candidate root that also holds a poller heartbeat — named and reported on, never merged, copied or deleted, and never moving the exit code, which only the enabled services decide. Liveness and the reported pid SHALL come from the lock and never from the heartbeat — the only formulation immune to pid reuse, and the only one a file cannot forge. The poller SHALL record that heartbeat at <state.root>/poll-status.json after every cycle, atomically; a heartbeat that cannot be written SHALL warn once and SHALL NOT interrupt polling, and an absent or unreadable one SHALL cost only the progress lines. The same facts SHALL be carried by the control plane's daemon_status.

  • The heartbeat SHALL NOT carry a pid (issue-205, decision-076): the pidfile is the single source of truth for which process is polling, and a pid left in a heartbeat by an older poller SHALL be read without error and ignored. The two files SHALL remain separate — the heartbeat's atomic rewrite replaces the inode the lock is held on, their lifetimes are opposite (the pidfile is removed on release, the heartbeat is kept), and so are their failure policies (a pidfile that cannot be written aborts the start; a heartbeat that cannot be written is swallowed).

  • A daemon started by the control plane SHALL have its output redirected to that daemon's logfile rather than to /dev/null — no start path silently discards the log.

  • A poll source SHALL list in scopes, and a scope SHALL fail alone (issue-315, decision-106). The provider contract's listing() returns the items found plus the scopes (repositories, for GitHub) that failed, were skipped or recovered; one scope's failure SHALL NOT discard another's items, SHALL be recorded per scope (poll.scope_error), and SHALL keep that scope's tracked items out of closure reconciliation — issue-159's "a partial listing proves nothing ended", per scope (reconciliation covers every tracked item since issue-329, not only active sessions; see webhook-triggers). A provider that has not learned scopes keeps its all-or-nothing behaviour. A repository whose GitHub Issues are disabled SHALL be classified permanent: surfaced once at warning level (poll.scope_degraded), its issues skipped while its pull requests are still polled, re-probed every 60 cycles, on a reload and on restart (poll.scope_recovered when it answers). The heartbeat SHALL carry the scope facts (scopesPolled, scopesFailed, scopesSkipped) and the-loop status SHALL render one degraded: line per scope — and say in words when no scope answered — without changing its exit code.

  • The CLI config MAY name an env file (env.file, issue-318, decision-108), and every the-loop process SHALL load it into its own environment first, at start — the the-loop CLI before it builds its parser, python -m the_loop.daemon_entry before the daemon runs, python -m the_loop.api.serve before it reads the config proper — so the variables the config names for its credentials (secretEnv, botTokenEnv, tokenEnv) are present before anything reads them; the daemons and the service the CLI spawns inherit its environment and load the file again on their own start. The file SHALL be read in dotenv format by a stdlib parser (the_loop.envfile; no interpolation, no multi-line values), a relative path SHALL resolve against the config file's directory with ~ expanded, and a variable already set in the environment SHALL NOT be overwritten. The config SHALL be read leniently for this purpose — a config the command will refuse (stale version, parse error) loads nothing. A missing, unreadable or malformed file SHALL be a warning naming the path, the line number or the error class — never a value or a line's text — and the process SHALL continue; a file readable by group or others SHALL be warned about and still loaded. The file is read once, at start, and SHALL NOT be re-read on config reload. The SDK does not load it: an embedding host owns its process environment.

Design

CLI documentation (source: docs/cli/) · cli/README.md (the PyPI package readme) · architecture § CLI companion

History

Work itemWhat changedLinks
issue-370sessions link-pr became the single writer of a work item's tracked pull requests (2026-09-16), and stopped depending on an agent remembering to run it: a PostToolUse hook records the pull request a session just created, and the tmux runner exports THE_LOOP_WORK_ITEM so the plugin's hooks can tell which work item they are in. The inference that covered the gap — the poller filing a labelled pull request under whichever issue GitHub's linkage named — is gonespec, webhook-triggers, issue
issue-368An attribute belongs to a party (2026-09-15): the rule that decides which of a work item's three files each attribute lives in, declared as data (state.ATTRIBUTES) and enforced by the portability suite. sessionPerPr, model, effort and the pull requests moved into the repository's own work-item-state.json; the portable graph section was retired (read for a work item frozen before the change, never written); the session block left the repository and session: inherit resolves through the session registry; a pull request's poll ledger moved under its owner, so one work item is one portable record; the channel binding moved to the operator's record and the read cursor to the machine's; the local record became a map of sessions keyed by ref, holding handles alonespec, decision-128, issue
issue-358the-loop models list|check — the verb that makes a model declaration true by asking each harness what it accepts and caching the verdict; sessions list gained a Model columnspec, decision-124, issue
issue-352The CLI stopped reading the harness config altogether (2026-09-12): the_loop.harness_config, its READS table, the packaged default and the adopt-on-spawn path are gone. routing.graph.specDir (default docs/specs) and --spec-dir on check/graph name the spec directory; loop: is a constant; the origin repository comes from the work item's ref or the checkout's origin remote; critics[] and routing.graph.hooks moved into the CLI config (repoHooks removed, CLI config 0.9.0); scenarios --glob and instructions --doc/--on-missing take what the agent read. The harness config shrank to the agent's policy (0.3.0)spec, decision-123, issue
issue-339One configuration resolves to one state root, in every process (2026-09-11): state.root is made absolute where the config is loaded, anchored on the directory the config's .the-loop/ sits in, so the ~30 layout_from_config call sites all name one directory whatever each process's cwd is; ~ is now expanded. Every spawn — the auto-started service, lifecycle.spawn_service, core.daemons.control_daemon — carries THE_LOOP_CLI_CONFIG = the path it resolved, the property schedule_restart already had. the-loop status prints its config, its root, and any rival root holding a second heartbeat. Before this, a daemon started in one directory and a CLI run from another silently addressed different files: a live poller was reported dead off a two-day-old heartbeat, and a dead one would have read as finespec, decision-119, supervision, issue
issue-331A poll source's bare OWNER/REPO is on the resolved GitHub host (2026-09-09): RepoSpec.parse / parse_repos take a default_host, from_source / build_provider carry it, and the daemon resolves it with ghhost.github_host from the CLI config it reads polling from — at pre-flight, at the first plan and on every hot reload — so listing, scope naming and owns() agree; describe() spells the host. Before it, a bare entry meant github.com to owns() and "wherever gh points" to the listing, and on GitHub Enterprise closure reconciliation silently refused every ref its own listing had mintedspec, decision-114, issue
issue-322The CLI config names the instance it is (instance.name, instance.scope.mode, instance.scope.workItems), the-loop status prints one line naming it with its mode and the size of its managed set (and carries the whole document as instance in JSON), the-loop sessions start on a locked instance is refused before anything is recorded or posted, and the keyword the CLI posts back carries instance:<name> on a named instancespec, decision-110, instances, issue
issue-318The CLI config names an env file (env.file) that every process entry point loads first, at start: a stdlib dotenv parser, config-relative resolution, the environment never overwritten, failures warned without a valuespec, decision-108, issue
issue-315One repository's failure is that repository's (2026-09-02): the provider contract lists in scopes (Listing, ScopeFailure, PollProvider.listing/scope_of), the GitHub provider lists each repository on its own and keeps a per-repository quarantine for the one permanent condition (gh's has disabled issues — issues skipped, pull requests still polled, re-probed every 60 cycles), the core records poll.scope_error / poll.scope_degraded / poll.scope_recovered and reconciles closures per scope, and the heartbeat's scopesPolled / scopesFailed / scopesSkipped become the-loop status's degraded: lines. Before it, one repository with Issues disabled took every repository in the source down with it while status reported a healthy pollerspec, decision-106, issue
issue-311Audited every github.com assumption and made the host the ref's everywhere (2026-09-02): one resolver (ghhost.github_hostintegrations.github.host, an enterprise api.baseUrl, $GH_HOST, the checkout's origin remote, github.com) answers for refs minted from ticketing.github and for inner-loop prRefs; every gh writer and reader (comments, reactions, linkage, the poller's GhClient, both graph transports) spells the host through comments.gh_host_args / [host/]owner/repo; the API transport derives https://<host>/api/v3 against the public default; the review brief accepts pull-request URLs on any host and puts slugs and bare numbers on the work item's; poll sources accept [HOST/]OWNER/REPO and own by hostspec, decision-104, issue
issue-307the-loop add-collaborator / remove-collaborator join the CLI (2026-08-31), the terminal form of the two new control keywords: they write one work item's collaborator roster, post the same keyword and login back to the ticket self-marked, validate every login before writing any of them, and run in-process for the same reason ask doesspec, decision-102, add-collaborator, issue
issue-277the-loop standing list / start / stop / restart / say — the operator's surface onto the sessions that belong to no work item, addressed by name. say pastes a message straight into a running session's terminal, which is how you talk to a session that has no comment thread to answer onspec, standing, issue
issue-274the-loop sessions link-pr --work-item <ref> --pull-request <ref|N> joins the registry surface, with POST /api/v1/sessions/link-pr and the link_pull_request MCP tool over the one core implementation: the session that opens a pull request records the binding the router prefers, instead of leaving it to be inferred from a closing reference, an issue-<n> branch or a closing keyword that a the-loop-authored pull request does not carryspec, sessions, issue
issue-245the-loop channels joins the CLI: status (config with token presence only), poll (one synchronous read cycle over the bound Slack threads — the cron/daemon-less form) and listen (Socket Mode, the no-polling reader). The daemons additionally run the poll-mode reader as a background watcherspec, decision-094, issue
issue-228The CLI's lifecycle became one surface (2026-08-14): the-loop start|stop|status|restart compose the control-plane service, webhook receiver and poller per new per-service enabled flags (service + MCP on by default, ingresses opt-in), the poll, gh-webhook and service commands were removed with the run loops relocated to the_loop.poller.daemon / the_loop.webhook.daemon (daemon_entry <poller|gh-webhook> [--once] is the foreground/cron form; the fold of the latter two is the owner's PR #229 review), the issue-191 double-fork went with them, restart --with-upgrade reuses the issue-152 installer plan, and service.enabled: false also refuses implicit auto-start. Amended in the same PR (issue-231, owner review round 2): service.hostIngresses (default true) makes start boot one process — the enabled ingresses run as threads inside the service, each keeping its own pidfile flock under the service's pid, hosted-ness detected from the lock and never recordedspec, decision-084, issue, issue-231
issue-208the-loop ask joins the CLI: an agent's question is posted with the loop-prevention marker stamped centrally and the wait recorded as session.awaiting_input; runs in-process because the escalation path must not depend on a running servicespec, decision-078, issue
issue-205The poller's heartbeat stopped carrying a pid nothing read: poll.pid — the flock — is the single source of truth for which process is polling, and an older heartbeat's pid is now dropped on read. The two files stay separate because the heartbeat's atomic rewrite would free the lock it is held onspec, decision-076, issue
issue-203integrations.slack gained an optional inline url, taking precedence over urlEnv, so the one value that turns notifications on stops living outside every config file the-loop owns — and a resolution failure now names both remedies instead of only the env var. Slack's webhook URL alone; tokens and signing secrets stay env-onlyspec, decision-075, issue
issue-194graph advance/run/skip/force stopped posting nothing when --ref was omitted: the ref is derived from the repository's ticketing.github plus the issue-<n> id, and an outbound hook that could not do its job now prints a warning: line (and records graph.hook_degraded) instead of leaving a clean wait over a ticket nobody was askedspec, process-graph, issue
issue-191poll start --daemon detaches for real (double-fork + setsid, stdout/stderr to <state.root>/logs/poller.out, pidfile written after the final fork under the lock), reports startup success or failure to its caller over a handshake instead of into a logfile, removes a stale pidfile instead of leaving it, and gains poll status — liveness from the lock, progress from a new per-cycle heartbeat, exit 0/1 so it is a health check. Control-plane starts log to a file instead of /dev/nullspec, decision-072, issue
issue-186sessions cleanup — a fifth control verb (CLI, HTTP and MCP) that releases a work item's local resources through the daemon's own dispatcher and keeps the portable record, unlike resetspec, interactive-sessions, issue
issue-161Re-layered as core → API → clients: the_loop.core facade, the control-plane service (service start|stop|status, no extras — it ships in the base install), every core-capability command routed through it, and the /mcp endpoint on the official MCP SDK. The UI was descoped from this work item on owner reviewspec, decision-058, control-plane, issue
issue-156Process runner removed; tmux is the only runner (2026-08-05): sessions start spawns tmux-hosted sessions unconditionally — there is no configured runner to pickspec, interactive-sessions, issue
issue-152Added install and upgrade: one plan-then-execute implementation, two verbs, covering the CLI and the Claude Code plugin at user or project scope. Drives the harness's own plugin CLI (probed, not assumed — a marketplace command without a working plugin install counts as no surface), falls back to the decision-054 settings keys, detects how the running CLI was installed, and reports every step's argv and outcome — --dry-run being the same plan minus the execution. Cursor parked on review and split out as issue-157spec, decision-057, distribution, issue
issue-142webhooks.ghWebhook.routing promoted to a top-level routing through the version-gated config migration (0.4.0), and the import seam that expressed the same misfiling removed: poll and sessions read dispatch policy through cli_config.load_routing_config instead of importing the webhook command's helper. A relocation only — no option's name, default or behaviour changedspec, decision-053, webhook-triggers, issue
issue-137Added sessions reset: one, several or all work items lose their session record, control and poll sections (and their checkout, per the close policy) so a work item starts over after a CLI fix. Composes the existing close and section-clearing paths — the seal rule is what stops a pre-issue-128 record coming back — adds SessionRegistry.forget, posts nothing to the ticket, and appends session.reset to a log it cannot rewritespec, decision-050, interactive-sessions, issue
issue-132Added instructions, the sixth harness-config read: it resolves every doc registered in customInstructions.docs and turns onMissing into an exit code, so a mistyped path stops being silent. Reports facts about each doc, never its contentsspec, decision-049, spec-workflow, issue
issue-130portable/ made readable: a derived index.json listing every record (ref, url, file, sections), rebuilt on every write and read by nothing, plus a url beside each record's ref. On PR review, refs learned about hosts[<host>/]<owner>/<repo>, identified at both ingresses from the event's own URLs — so a GitHub Enterprise work item links, and is identified, correctlyspec, decision-047, decision-048, issue
issue-128Generated state classified portable vs local (GENERATED_PATHS), documented file by file in docs/cli/state.md, and reorganised by that classification: one portable/<slug>.json per work item (control + poll, read-modify-write) tracked in git, machine-local handles under local/, three writer-shaped stores gone, polling.stateFile retired through the config migration, and the old locations read forward on upgradespec, decision-046, issue
issue-121The harness-config read surface stated as a direction rule and pinned: one reader module (the_loop.harness_config) with a declared READS tuple replacing three duplicated readers, a test asserting it against the schema and the docs, and the four pages that claimed the daemon never reads a repo's harness config corrected — it has, on the graphlink path, since issue-113spec, decision-044, issue
issue-117Documented as a product: an onboarding path plus one page per command under docs/cli/, every config option under docs/config/cli/, and a parity test that fails when a registered command has no page or a documented key is absent from the schema. check, graph and migrate-config documented for the first time; the integrations, routing.workspace, routing.graph and polling.maxRetries blocks written up; the removed ghBinary deleted from the docsspec, documentation, issue
issue-111Session listings recognise the registry's own files instead of every *.json in the shared <root>/sessions/ directory, so poll-state.json no longer reports as a corrupt registry entry on every poll cyclespec, issue
issue-109Added check and graph (the process-graph runtime), the integrations config block with configurable transports, a version-gated breaking CLI-config migration retiring ghBinary, and the slack extraspec, process-graph, decision-041, decision-042, issue
issue-97PyYAML promoted from the [config] extra to a required runtime dependency; the three silent ImportError fallbacks removed and the zero-runtime-dependency guarantee retiredspec, decision-038
issue-82Plugin config renamed config.yamlharness-config.yaml (scenarios reads the new name with a pre-rename fallback); CLI config gained operator-declared collaborators + daemon-side notifications event filtersdecision-035
issue-78--version derives from package metadata instead of a hardcoded string that had frozen at 0.1.0spec
issue-63Split the CLI daemon's config (webhooks/polling/eventLog) out of the per-repo plugin config into an independent, repo-agnostic CLI configspec, decision-032
issue-50Added the structured event log and the events query commandspec, decision-025
issue-21Published to PyPI as the-loopy-one with automatic semantic releasesspec, decision-019
issue-106sessions start/pause/resume/stop (CLI parity with the comment keywords, mirrored back to the ticket), paused sessions in sessions list, and one state.root for every generated filespec, decision-040, issue
issue-15Added sessions registry commands and webhook --route dispatchspec, decision-016
issue-11Added scenarios (queryable integration-test scenario table)spec, decision-014
issue-1Established the CLI skeleton and the gh-webhook receiver (v0)spec, decision-005

Released under the MIT License.