Capability: cli
The
the-loopPython 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 statusSHALL 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 starton alockedinstance 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 carryinstance:<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 atdebugor not at all — leavingpollto 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 --versionSHALL 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|closeSHALL manage the work-item ↔ harness-session registry used for webhook routing.link-prrecords 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'spullRequests[], and the plugin'sPostToolUsehook 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|cleanupSHALL 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):startspawns through the same dispatcher the daemon uses — workspace checkout, harness trust, tmux hosting, session announcement — or resumes a paused session;stoptakes the normal close path;cleanupreleases 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,controlsection — 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-commentskips it, and a missing/failingghwarns without undoing the local action.sessions listSHALL show each session's status (includingpaused) 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 asession.awaiting_inputevent — emitted (as a warning,comment_posted: false) even whenghfails, 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 viaPOST /api/v1/sessions/reply(see control-plane).the-loop add-collaborator @LOGIN [@LOGIN …] --work-item <ref>andthe-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 failingghnever 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. Likeask, 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 pathstoptakes, 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); thecontrolsection SHALL be cleared, which disarms the work item, and thepollsection 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 asealedrecord instead of resurrecting what was just removed. The command SHALL post no comment on the ticket, and there SHALL be noresetcontrol keyword: a comment must not be able to delete local state, and postingstop-executionwould record intent the reset has just cleared. The event log SHALL be appended to and never rewritten — onesession.resetper work item, including when nothing was found. A barereset(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-runSHALL 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 whencontrol.requireStartCommandis 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.jsonis 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>.jsonis one record per work item carrying acontrolsection (the last authorized start/stop/pause/resume) and apollsection (which comments have been seen);<root>/local/<slug>.jsonis 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 configuredregistryDir,eventLog.pathorpidfileis used verbatim — whileportable/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.jsoncarries the pointer, every choice a human froze (includingsessionPerPr,modelandeffort) and the pull requests delivering the item; the operator's<root>/portable/<slug>.jsoncarries what an authorized human said, what this deployment has seen, and the channel thread the-loop opened; the machine's<root>/local/<slug>.jsoncarries 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 whendocs/cli/state.mddisagrees 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 aurlbeside itsref, and the directory SHALL carryindex.json— one entry per record (ref,url,file, thesectionsit holds,sealedwhen it is an upgrade tombstone), ordered byref. 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 agithubref 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'shtml_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 fromticketing.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 enterprisegithub.api.baseUrl, else$GH_HOST, else the checkout'soriginremote (in-session only), else github.com. Every outboundghcall SHALL read the host back off the ref through one spelling —--hostname <host>forgh 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'sreposentry SHALL accept[HOST/]OWNER/REPOand 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.stateFileSHALL 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 byfind_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 absolutecwdand 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.gitignoreblock 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.--recomputeignores stored work-item state and derives the verdict from the artifacts alone.the-loop graph show|hooks|status|advance|run|forceSHALL inspect and drive the process graph (see process-graph).hooksreports the shipped hooks and the repository's own declarations without importing any of them.runis bounded by--max-nodesand 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.forceis the authorized-operator escape hatch: it requires a reason and moves the pointer without ever forging the bypassed gate's verdict.--refis 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'soriginremote in-session (issue-352) — plus theissue-<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 awarning:/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 oneintegrationsblock —integrations.github.cli.binaryreplaces theghBinarykey that was previously duplicated across three consumers. Dispatch policy SHALL live under a top-levelroutingkey, not underwebhooks.ghWebhook: the poller andthe-loop sessionsdispatch 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 tothe-loop migrate-config.Slack SHALL be configured in exactly one place:
channels.slack(the bot — channels). Theintegrations.slackincoming webhook and itsurl/urlEnvcarve-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, andthe-loop migrate-configremoves it. Bot and app tokens are env-only, likegithub.api.tokenEnvandwebhooks.ghWebhook.secretEnv.the-loop scenariosSHALL output the table of every Gherkin scenario covered by the integration tests (--format table|markdown|json; see testing-and-contracts).the-loop instructionsSHALL report every doc registered incustomInstructions.docs, in configured order, with its configured path, resolved absolute path,notesand state (present/missing/unreadable/invalid), in--format table|markdown|json. Everything notpresentcounts as unresolved, andcustomInstructions.onMissingdecides the exit code (error→ 1,warn/ignore→ 0) independently of the format. Likecheckandscenariosit 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|runSHALL 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'scritics[](issue-352); the command is no part of the daemon (decision-032).the-loop eventsSHALL query the structured JSONL event log of the CLI's own routing/dispatch/session decisions (see observability).the-loop install/the-loop upgradeSHALL install and upgrade the-loop itself — this CLI and the Claude Code plugin — at--scope user(default) or--scope project, namingcli,claudeorall(default: the CLI plus every harness found onPATH). 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.installis harness-shaped, so adding it is aBINARIESentry 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, andplannedunder--dry-run), exiting non-zero only when a step failed;--dry-runSHALL be that same plan with the execution left out, and--format jsonSHALL 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, andplugin install --helpfor a--scopeflag) rather than assuming a version, and SHALL pass the requested scope through instead of emulating it. A binary offeringplugin marketplacebut no workingplugin installSHALL 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.jsonat 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 upgradeSHALL 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.venvand SHALL NOT modify the project'spyproject.toml.The marketplace source for both verbs SHALL resolve
--from→ the CLI config'srouting.harnessPlugins.marketplaceRepo→ the shipped default, SHALL be validated asowner/repobefore 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 packagethe_loopand thethe-loopscript unchanged; see release-publishing).gh-webhook/poll/sessions/eventsSHALL 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.specDirin the CLI config (defaultdocs/specs) or--spec-dironcheck/graph; the phase label prefix is the constantloop:; the origin repository is the work item's ref (daemon) or the checkout'soriginremote (in-session); the critic roster is the CLI config'scritics[]; graph hooks are itsrouting.graph.hooks;scenariostakes--globandinstructionstakes--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,repositoriesand every other setting remain CLI-config-only with no fallback.the-loop start|stop|status|restartSHALL be the whole system's lifecycle surface (issue-228, decision-084).startreads the CLI config and starts, detached, every service it enables — the control-plane service (service.enabled, default on, with the MCP endpoint mounted perservice.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 nopolling.sources— or, since issue-348, no top-levelrepositories— being the misconfigured case) and exiting0only when every enabled service came up; one service's failure SHALL NOT hide the others' outcomes.stopSHALL stop every running service regardless of theenabledflags.restartSHALL compose stop → start,--with-upgraderunning the issue-152 installer plan (CLI component only) in between — a failed upgrade is reported and the start half still runs.POST /api/v1/restartSHALL schedule the same restart as a detached, fixed-argv process and answer immediately. Thepollcommand is removed;python -m the_loop.daemon_entry poller [--once]is the foreground/cron form, running the same relocated loop (the_loop.poller.daemon); thegh-webhookandservicecommands are removed with it (owner review on PR #229 — "It should all fold intothe-loop start"), the receiver's run loop relocated the same way (the_loop.webhook.daemon,daemon_entry gh-webhookas its foreground form).startSHALL boot one process by default (issue-231, decision-084 §8): with the service enabled andservice.hostIngressestrue (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, sostatus/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; andstopSHALL report a hosted ingress stopped only once its lock is released.hostIngresses: falserestores 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 attachhands the terminal to tmux,sessions resetmust 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 startreports a daemon as started only once it holds its pidfile lock (the service, only once/healthanswers), 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 inTHE_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--configSHALL 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 statusSHALL 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 pollerstartedAt,lastCycleAtand the last cycle's counters, as text or--format json, exiting0iff 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 oneconflictline 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.jsonafter 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'sdaemon_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
pidleft 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_recoveredwhen it answers). The heartbeat SHALL carry the scope facts (scopesPolled,scopesFailed,scopesSkipped) andthe-loop statusSHALL render onedegraded: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 — thethe-loopCLI before it builds its parser,python -m the_loop.daemon_entrybefore the daemon runs,python -m the_loop.api.servebefore 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 item | What changed | Links |
|---|---|---|
| issue-370 | sessions 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 gone | spec, webhook-triggers, issue |
| issue-368 | An 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 alone | spec, decision-128, issue |
| issue-358 | the-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 column | spec, decision-124, issue |
| issue-352 | The 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-339 | One 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 fine | spec, decision-119, supervision, issue |
| issue-331 | A 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 minted | spec, decision-114, issue |
| issue-322 | The 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 instance | spec, decision-110, instances, issue |
| issue-318 | The 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 value | spec, decision-108, issue |
| issue-315 | One 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 poller | spec, decision-106, issue |
| issue-311 | Audited every github.com assumption and made the host the ref's everywhere (2026-09-02): one resolver (ghhost.github_host — integrations.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 host | spec, decision-104, issue |
| issue-307 | the-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 does | spec, decision-102, add-collaborator, issue |
| issue-277 | the-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 on | spec, standing, issue |
| issue-274 | the-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 carry | spec, sessions, issue |
| issue-245 | the-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 watcher | spec, decision-094, issue |
| issue-228 | The 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 recorded | spec, decision-084, issue, issue-231 |
| issue-208 | the-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 service | spec, decision-078, issue |
| issue-205 | The 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 on | spec, decision-076, issue |
| issue-203 | integrations.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-only | spec, decision-075, issue |
| issue-194 | graph 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 asked | spec, process-graph, issue |
| issue-191 | poll 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/null | spec, decision-072, issue |
| issue-186 | sessions 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 reset | spec, interactive-sessions, issue |
| issue-161 | Re-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 review | spec, decision-058, control-plane, issue |
| issue-156 | Process runner removed; tmux is the only runner (2026-08-05): sessions start spawns tmux-hosted sessions unconditionally — there is no configured runner to pick | spec, interactive-sessions, issue |
| issue-152 | Added 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-157 | spec, decision-057, distribution, issue |
| issue-142 | webhooks.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 changed | spec, decision-053, webhook-triggers, issue |
| issue-137 | Added 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 rewrite | spec, decision-050, interactive-sessions, issue |
| issue-132 | Added 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 contents | spec, decision-049, spec-workflow, issue |
| issue-130 | portable/ 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, correctly | spec, decision-047, decision-048, issue |
| issue-128 | Generated 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 upgrade | spec, decision-046, issue |
| issue-121 | The 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-113 | spec, decision-044, issue |
| issue-117 | Documented 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 docs | spec, documentation, issue |
| issue-111 | Session 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 cycle | spec, issue |
| issue-109 | Added 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 extra | spec, process-graph, decision-041, decision-042, issue |
| issue-97 | PyYAML promoted from the [config] extra to a required runtime dependency; the three silent ImportError fallbacks removed and the zero-runtime-dependency guarantee retired | spec, decision-038 |
| issue-82 | Plugin config renamed config.yaml → harness-config.yaml (scenarios reads the new name with a pre-rename fallback); CLI config gained operator-declared collaborators + daemon-side notifications event filters | decision-035 |
| issue-78 | --version derives from package metadata instead of a hardcoded string that had frozen at 0.1.0 | spec |
| issue-63 | Split the CLI daemon's config (webhooks/polling/eventLog) out of the per-repo plugin config into an independent, repo-agnostic CLI config | spec, decision-032 |
| issue-50 | Added the structured event log and the events query command | spec, decision-025 |
| issue-21 | Published to PyPI as the-loopy-one with automatic semantic releases | spec, decision-019 |
| issue-106 | sessions 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 file | spec, decision-040, issue |
| issue-15 | Added sessions registry commands and webhook --route dispatch | spec, decision-016 |
| issue-11 | Added scenarios (queryable integration-test scenario table) | spec, decision-014 |
| issue-1 | Established the CLI skeleton and the gh-webhook receiver (v0) | spec, decision-005 |