Design: split the-loop's config into CLI config and plugin config
Phase 2 of 3 (requirements → design → tasks). Derives from
docs/specs/issue-63/requirements.md.
Overview
Introduce a second, independent config file — the CLI config, cli-config.yaml — read only by the CLI daemon commands (gh-webhook, poll, sessions, events). It owns webhooks, polling, and eventLog, resolved in priority order: an explicit --config/-c flag, the THE_LOOP_CLI_CONFIG env var (same priority as the flag), ./.the-loop/cli-config.yaml (repo-relative — an operator may choose to track it in a specific repo), else ~/.the-loop/cli-config.yaml. The existing .the-loop/config.yaml becomes the plugin config: everything else, scoped to one repo, read by /the-loop:* commands and the skill. Both are validated by their own JSON Schema. A single new module, the_loop.cli_config, centralizes path resolution and the best-effort YAML-load pattern the CLI already uses everywhere, so the four command modules stop each hand-rolling it against the wrong (single, conflated) file.
Revised from the original single-tier (
$THE_LOOP_CLI_CONFIG, else~/.the-loop/config.yaml) design per PR #69 review: the location needed to be operator-configurable — including trackable inside a specific repo — not just globally-fixed-with-an-env-var-escape-hatch. See decision-032's Alternatives.
Architecture
flowchart LR
subgraph repo ["a repo checkout (optional CLI config home)"]
PC[".the-loop/config.yaml\n(plugin config)"]
RC[".the-loop/cli-config.yaml\n(optional, repo-tracked CLI config)"]
end
subgraph home ["~/.the-loop (fallback, or $THE_LOOP_CLI_CONFIG / --config)"]
CC["cli-config.yaml\n(CLI config)"]
end
CLIFLAG["--config / -c\n(highest priority)"] -.-> CLI
PC -->|"ticketing, workflow, tooling,\nreviews, autonomy, security, …"| SKILL["/the-loop:* commands\n+ skill"]
RC -->|"webhooks, polling, eventLog\n(cwd tier)"| CLI["the-loop gh-webhook / poll /\nsessions / events"]
CC -->|"webhooks, polling, eventLog\n(final fallback)"| CLINote what's deliberately not in this diagram: no edge from the plugin config to the CLI daemon. The first cut of this design had one (ticketing.github.owner/repo as a convenience fallback); PR #69 review removed it — see below.
- Plugin config (
.the-loop/config.yaml, unchanged location) — installed per repo; read bycommands/init.md,work-on.md,execute-tasks.md,upgrade-the-loop.md, andthe-loop scenarios(testing.integrationTestGlobs). Schema:.the-loop/config.schema.json, trimmed ofwebhooks/polling/observability.eventLog. - CLI config (new) — read by
gh-webhook/poll/sessions/eventsonly. Schema: new.the-loop/cli-config.schema.json(shipped alongside the plugin schema; the ".the-loop/" prefix here names where the-loop's own schema files live in this repo/package — see Data models below for where an operator's resolved copy actually lives, which is configurable). - No plugin-config fallback, anywhere (Requirement 4).
routing.authorizedUsers(webhook + poll) and a GitHub poll source'sreposare read exclusively from the CLI config — no fallback to any repo'sticketing.github.owner/repo. Unset means exactly that: the receiver/poller fail closed (noauthorizedUsers) or raise clearly (GitHubPollProvider.list_work_itemson norepos) rather than silently borrowing a value from a file whose entire purpose is the Claude/Cursor plugin. An operator who wants the old "zero-config in the repo I'm watching" convenience gets it through the cwd tier instead: put an explicit CLI config at./.the-loop/cli-config.yaml.
Components & interfaces
the_loop.cli_config (new)
CLI_CONFIG_ENV = "THE_LOOP_CLI_CONFIG"
CLI_CONFIG_FILENAME = "cli-config.yaml"
_override: Optional[Path] = None # set by cli.py's --config pre-scan
def set_override(path: Optional[Union[str, Path]]) -> None:
"""Set (or clear, with None) the --config/-c override."""
def default_cli_config_path() -> Path:
"""--config/-c override, else $THE_LOOP_CLI_CONFIG, else
./.the-loop/cli-config.yaml (if it exists), else ~/.the-loop/cli-config.yaml."""
def load_cli_config(path: Path, strict: bool = False) -> dict:
"""Best-effort (strict=False) or raising (strict=True) YAML parse of the
whole CLI config. Mirrors the parse-error handling gh_webhook/poll already
have (missing file / no PyYAML / bad YAML), extracted once."""A module-level _override (rather than threading a parameter everywhere) fits this codebase's existing style — eventlog._log is already a comparable module-level singleton — and the CLI is a short-lived, single-invocation process, so there's no concurrent-request concern to a mutable global the way there would be in a server.
gh_webhook.py and poll.py keep their own module-level _CONFIG_PATH (tests already monkeypatch that attribute name directly — see test_routing.py::test_read_gh_webhook_config_strict_vs_lenient), initialized from cli_config.default_cli_config_path() at import time as before. What's new: cli.py's main() re-resolves and reassigns both modules' _CONFIG_PATH (_refresh_cli_config_paths()) immediately after pre-scanning --config and beforebuild_parser() runs — solving a real ordering constraint: build_parser() calls every command's add_arguments() eagerly (existing behaviour, unrelated to this change), and add_arguments() is what reads the CLI config to compute other flags' defaults (e.g. --host). Argparse can't tell us --config's value until parsing finishes, but parsing finishes after those defaults are already computed — hence the tiny pre-scan. load_cli_config replaces the duplicated try/except-ImportError/except-YAMLError block in both files.
cli.py (top-level entry point)
build_parser()gains a global--config/-cargument (declared before the subparsers, so it must precede the subcommand:the-loop --config PATH gh-webhook start) — documents the flag and makesargs.configavailable, though its effect already happened via the pre-scan below._peek_config_flag(argv)— a throwawayargparse.ArgumentParser(add_help=False)declaring only--config/-c, parsed withparse_known_args(ignores every other flag) purely to learn the value before the real parser is built.main()— callscli_config.set_override(_peek_config_flag(argv))(unconditionally, including toNone, so an override never leaks across repeatedmain()calls in one process, e.g. under test), then_refresh_cli_config_paths(), thenbuild_parser().
gh_webhook.py
_CONFIG_PATH→cli_config.default_cli_config_path()at import time; reassigned bycli.py's_refresh_cli_config_paths()beforeadd_arguments()runs._read_gh_webhook_config→ unchanged behaviour, now readswebhooks.ghWebhookfrom the CLI config.- No
_ticketing_owner(), no_PLUGIN_CONFIG_PATH(Requirement 4) — the module has no code path that reads any repo's.the-loop/config.yaml._build_routinglost itsownerparameter;resolve_authorized_users(config.authorized_users)takes just the CLI config's list.
poll.py
_CONFIG_PATH→ CLI config path (drives theReloader, sopolling.sourceshot-reload continues to watch the CLI config file, satisfying Requirement 1.2).- No
_load_plugin_config(),_repos_from_ticketing(),_ticketing_owner(), or_PLUGIN_CONFIG_PATH(Requirement 4).build_provider(source, default_label=...)andGitHubPollProvider.from_sourcelost theirfallback_reposparameter; a source with noreposis empty, not "whateverticketing.githubhappens to say" —list_work_itemsraises a clearProviderErrorfor it rather than silently discovering nothing.
eventlog.py
load_config(config_path=...)'s default becomescli_config.default_cli_config_path()instead of.the-loop/config.yaml.- Reads the CLI config's top-level
eventLogkey (wasobservability.eventLogin the conflated file — the CLI config has no other reason to carry anobservabilitywrapper key, so it is flattened; documented as a rename in the migration note). configure_from_file/events.py's--filedefault are otherwise unchanged (they already call throughload_config()).
sessions_cmd.py
No change: _default_registry_dir() already goes through gh_webhook._load_config_defaults(), which now transparently points at the CLI config.
commands/upgrade-the-loop.md (instruction-level, no code)
/the-loop:upgrade-the-loop is a markdown-instruction command an agent follows, not a Python module — but Requirement 3 makes it responsible for actually performing the webhooks/polling/observability.eventLog → CLI config migration on an existing project, not just documenting that the move is possible. Its step 4 now names the migration explicitly: detect the pre-split shape in .the-loop/config.yaml, extract and rename (observability.eventLog → eventLog), ask the init-style yes/no question about where the CLI config should live, validate both resulting files against their own schemas, flag an empty authorizedUsers/repos under needs-user (Requirement 4 removed their fallback), and report the migration as its own line — never silently folded into a generic "drifted" report line or, worse, dropped.
Data models
Two schema files ship in this repo (and in the published plugin/package), same tree, new sibling name — not a location an operator's actual CLI config lives at (that's resolved per the priority order above; these are the validator files):
.the-loop/config.schema.json— plugin config.additionalProperties: false;webhooks,pollingproperties removed;observabilitykeepsdevLevel/runtimeLevel/browserLoggingonly (eventLogremoved)..the-loop/cli-config.schema.json(new) — CLI config.additionalProperties: false; propertiesversion,webhooks,polling,eventLog— the exact JSON Schema fragments moved verbatim from the plugin schema (no behavioural change to the shape ofwebhooks/polling;eventLog's shape isobservability.eventLog's, unwrapped).
Templates mirror the split: skills/the-loop/templates/config.yaml (plugin, trimmed) and a new skills/the-loop/templates/cli-config.yaml (CLI, commented, ships the same documented defaults webhooks/polling had, plus the priority order). This repo's own dogfood instance, .the-loop/cli-config.yaml, is checked in at exactly the repo-relative path the cwd tier checks (./.the-loop/cli-config.yaml) — the-loop's own repo dogfoods "track the CLI config in this repo" (the exact use case PR #69 review raised), and it's picked up automatically with zero env var or flag when the-loop <command> runs from this checkout. scripts/validate_config.py validates both this file and the shipped template against cli-config.schema.json.
Error handling
Identical to today, just re-homed:
- Missing CLI config file, missing PyYAML, or unparseable YAML → lenient callers (
strict=False) get{}and fall back to built-in_DEFAULTS; the hot-reload path (strict=True) raises so theReloaderkeeps the previous in-memory config rather than resetting to defaults on a transient broken save. Same contract as the pre-split_read_gh_webhook_config. - No CLI config file resolvable at all (fresh install, nothing configured yet) is not an error —
gh-webhook/pollstart with built-in defaults, same as an emptywebhooks/pollingblock today.
Security design
Enforcing
requirements.md's Security considerations.
- AuthN/AuthZ:
routing.authorizedUsers(CLI config, exclusively — Requirement 4) gates which GitHub actors the receiver/poller act on;resolve_authorized_usersnow takes only the configured list (noownerfallback parameter) and still fails closed (warns, authorizes nobody) when it's empty (decision-023's fail-closed guarantee, strengthened rather than weakened by removing the fallback). - Input validation & injection surfaces: no new untrusted-input surface for the
--config/$THE_LOOP_CLI_CONFIGtiers — both are set by the operator who starts the process, same trust level as any other CLI flag/env var. The new cwd tier (./.the-loop/cli-config.yaml) is the one genuinely new consideration: it makes the current working directory's contents part of config resolution. Mitigated by scope —load_cli_configonly ever parses YAML into the fixedwebhooks/polling/eventLogshape (schema-validated,additionalProperties: false); a planted file cannot execute code or escape that shape, only misconfigure the daemon's own routing/polling behaviour. An operator who does not trust their cwd passes--configexplicitly (highest priority, bypassing the cwd check entirely). - Secrets handling: unchanged —
secretEnvcontinues to name an env var; the webhook secret itself is never read from any config file or flag value. - Least privilege: unchanged — file permissions on wherever the CLI config resolves to are the operator's OS-level responsibility, called out in
cli/README.md(n/a to add code for; a stdlib-only CLI does not manage file ACLs). - Fail-closed behaviour: a corrupt/missing CLI config still yields
{}→ emptyauthorizedUsers→ the existing "warn, authorize nobody" fail-closed path. A bad edit to the CLI config while the daemon is running is logged and the previous validated config kept (Reloader), unchanged. - Abuse-case coverage: requirements' abuse case 1 (secret never at rest) — covered by
secretEnvstaying an env-var name in both schemas (neither accepts a literal secret). Abuse case 2 (hostile/misconfigured--config/$THE_LOOP_CLI_CONFIGtarget) — covered by the unchanged strict/lenient parse-error handling; proven bytest_read_gh_webhook_config_strict_vs_lenientreused against the new path. Abuse case 3 (planted cwd file) — covered by the schema-bounded parse (no code execution path) and the--configescape hatch; proven bytest_cli_config.py's cwd-tier tests.
Testing strategy
- Unit (
cli/tests/test_cli_config.py): the full priority chain — home default when nothing else set; cwd file wins over home; cwd file absent falls through to home; env var wins over cwd file;set_override/--configwins over everything;set_override(None)clears it (an autouse fixture resets the override before/after every test, since it's a module-level global). Plusload_cli_configlenient/strict behaviour on missing file / no PyYAML / bad YAML (mirrors the existing_read_gh_webhook_configcoverage, now against the shared helper), andcli.py's--configpre-scan: a value passed before the subcommand changes whatbuild_parser()computes as other flags' defaults. - Unit (existing
test_routing.py): unaffected in behaviour since it monkeypatches_CONFIG_PATHdirectly;test_cli_config.pyseparately assertsgh_webhook._CONFIG_PATH/poll._CONFIG_PATHdefault tocli_config.default_cli_config_path()at import time and that neither module carries a_PLUGIN_CONFIG_PATHattribute (Requirement 4). - Unit (
test_poller.py, updated):resolve_authorized_users/build_provider/GitHubPollProvider.from_sourcecalls dropped theirowner/fallback_reposarguments; a new test asserts a source with noreposresolves to an empty list (not a borrowed value) and thatlist_work_itemsraises for it. - Config validation (
scripts/validate_config.py, extended): validates.the-loop/cli-config.yaml(checked in at the exact cwd-tier path) andskills/the-loop/templates/cli-config.yamlagainst the newcli-config.schema.json, alongside the existing plugin-config targets. - Manual evidence:
the-loop gh-webhook start --routeandthe-loop poll start --onceexercised locally from this repo's root (picking up.the-loop/cli-config.yamlvia the cwd tier, no flag or env var needed) and again with an explicit--configpointed at a different file, confirming the override wins and hot-reload still fires on an edit to whichever file was actually resolved. - Manual evidence (Requirement 3, the
/upgrademigration): this repo's own pre-split.the-loop/config.yaml(git show f619a4d:.the-loop/config.yaml, the last commit before this PR) run through the extraction step 4 now describes — popwebhooks/polling/observability.eventLog, renameeventLog, validate both halves — both resulting documents passjsonschema.validateagainst the split schemas with no manual fixup. The extraction also surfaced that this repo's ownwebhooks.ghWebhook.routing.authorizedUserswas empty pre-split, i.e. exactly the needs-user case Requirement 3.3 flags — confirmed consistent with the[]already checked in at.the-loop/cli-config.yaml.
Trade-offs & decisions
Logged as docs/decisions/decision-032.md:
- Configurable 4-tier resolution over a single fixed global default — the first cut shipped only
$THE_LOOP_CLI_CONFIG, else~/.the-loop/config.yaml: simple, but it forced "global" as the only option. PR #69 review: an operator wants their CLI config checked in and versioned in their own dev-box repo — a legitimate, common case ("not tied to a single repo" means "not forced into one," not "forbidden from one").--config/cwd/home covers both without reintroducing "the daemon needs a home repo to find its config": the cwd tier is opt-in by construction (only engages if the operator puts a file there) and the home tier remains the always-available, zero-setup default. --configimplemented via a pre-scan, not per-subcommand flags — declaring--configonce, globally, ahead of any subcommand keeps it uniform acrossgh-webhook/poll/sessions/eventswith no repetition; the pre-scan (_peek_config_flag+_refresh_cli_config_paths) is the mechanical cost of argparse building every subcommand's defaults before a normal parse would give us the flag's value — the same two-phase-parse pattern common CLIs (Docker, Git) use for exactly this "a global flag affects other flags' defaults" shape.- A module-level override (
cli_config.set_override) over threading a parameter through every call site —gh_webhook.py/poll.py/eventlog.py/sessions_cmd.pyalready read config via free functions, not an injected object; the CLI is a short-lived, single-invocation process (no concurrent-request state to corrupt), so a settable module global (reset everymain()call, including toNone) is the pragmatic fit — consistent witheventlog._log's existing pattern. - Runtime state paths left alone (still out of scope) — pidfiles/registry/poll-state/ event-log defaults stay cwd-relative
.the-loop/...strings; only the config file moved/became configurable. Migrating state defaults too is a reasonable follow-up (re-evaluation trigger below) but doubles the blast radius for no requirement raised by either issue #63 or the PR #69 review. eventLogflattened out ofobservability— the CLI config has nothing else to put under anobservabilitywrapper oncedevLevel/runtimeLevel/browserLoggingstay with the plugin, so nesting would be ceremony with no sibling keys.- No plugin-config fallback for
authorizedUsers/repos, full stop — the first cut keptticketing.github.owner/repoas a convenience default. Review: that put the plugin config back in the CLI daemon's read path for exactly the case ("running in the one repo I watch") the split exists to not depend on. Removed entirely (Requirement 4); the cwd tier already covers the same convenience through a file whose purpose actually is the CLI daemon. - A plain yes/no onboarding question, not a full
x-onboarding-style schema — the CLI config has three top-level keys; the plugin's grouped, ask-level-driven onboarding machinery is built for a ~25-property schema and would be disproportionate ceremony here (Requirement 2.4, wired intocommands/init.md).
Re-evaluation triggers: an operator needs per-poll-source authorizedUsers (multi-tenant daemon watching repos with different trusted actors) — extend polling.sources[].authorizedUsers rather than the flat list; runtime-state paths get their own consolidation ask — revisit the "out of scope" note above; the CLI config schema grows enough properties to warrant its own x-onboarding-style grouping.
Open questions
None — mirrors requirements.md.