ADR-030: The CLI surface is the workflows, not the module layout

Status Active · Filed 2026-08-07

Context

The CLI had eleven commands because the dispatcher mapped one command per module: every module keeps a standalone main() (so a project can vendor one file), and registering each in cli.py was free. The surface therefore mirrored the package layout, and it kept growing with it — which is a projection of an internal structure onto an interface, the drift shape DP-3 names.

The evidence that the tail was dead was already written down, in three places:

  • reports.py says in its own docstring that the detail “only existed behind ref-status and pending, which nobody runs” — the reason the markdown reports exist at all.
  • Bare luria badges printed a stderr warning that in normal use luria index writes the badges — a command whose main output was advice not to use it. ADR-029 records the incident that prompted the warning: an adopter’s workflow ran it to no effect.
  • This repository’s own CLAUDE.md never told contributors about badges, reports or collect. The documented surface and the actual surface had already diverged.

Meanwhile two of the survivors are real but not human-facing: luria collect --commit is invoked exactly once, in ci.yml — running it by hand consumes fragments a reviewer was meant to see on the branch — and luria reports exists to produce the artifact actions/lint uploads.

Decision

Two tiers, and the help text says which is which.

  1. Contributor commandslint, link, index, journal, remotes, init. One per workflow: check, fix, regenerate, file an entry, resolve foreign references, adopt.
  2. CI commandsreports, collect. Listed under “run by CI, rarely by hand”, still runnable directly.
  3. Removedbadges, ref-status, pending. Each was subsumed before it was removed: luria index writes the badge region and luria lint checks it (ADR-029); both status reports print as one-line lint warnings (ADR-007) and land in full detail in the luria reports artifact.

Removed means gone. A removed name is an ordinary unknown command — exit 2, the usage, nothing else. A deprecation carries an obligation to a user base with something to migrate; this library is days old and has one user, so a shim that answers for the old names would be an affordance in service of a behavior that only exists because the affordance does. The usage text the refusal prints already names every command that took the old ones’ jobs, which is all of DP-1 the situation supports.

The modules keep their main()s — here, not as a rule. Removing a command can and often should take its code with it: less sandbag code makes the code that matters easier to find. These three entry points survive for reasons unrelated to the commands they used to back — the test suite drives them directly, python -m luria.ref_status --all is the interactive escape hatch, and the vendor-one-file property depends on them. When those reasons lapse, the code goes too.

Alternatives considered

  • Status quo — keep all eleven. Costs nothing today, but every command is documentation: eleven top-level names claim eleven workflows, and three of those claims were false. A surface that lies about what you’re supposed to run is the same drift the record machinery exists to prevent, on the machinery itself.
  • Fold link into lint --fix. The familiar linter idiom, the two already share doc_refs.py, and the check/fix shape would survive as a flag — shortening the list is a legitimate goal of this decision, so none of that is the objection. The reason link stays its own name (review’s, replacing the first draft’s): a future lint --fix plausibly repairs more than references — dates, populated fields, whatever else becomes mechanically fixable — and luria link is then the tool that does exactly the reference rewrite and nothing else, including path-scoped.
  • Delete the retired modules’ main()s too. A cleaner amputation, but it breaks the vendor-one-file property for zero surface gain — the CLI no longer advertises them either way — and it would have forced the interactive detail (--all, --as-of) to grow back inside reports immediately.
  • A RETIRED table: each old name refuses by pointing at its successor. The first draft shipped this, argued from DP-1 — the dispatcher knows where the job went, so say so. Review removed it: a deprecation shim communicates a migration timeline to users who need one, and this project has no such users — the names would survive solely to serve typists of names that no longer exist, which is the legacy-preservation reflex this whole decision exists to cut. DP-1 is satisfied by the refusal printing the usage, which names what does exist.

Consequences

  • luria --help, the README’s command table, both CLAUDE.mds and the Makefile now describe the same six-plus-two surface; the ref-status and pending make targets are gone.
  • ADR-007 is corrected in place to v2: its choice (reported, never enforced) stands, but its Decision section named the two standalone commands as the delivery mechanism, and that part is now this decision’s.
  • A script that still says luria badges or luria ref-status gets exit code 2 and the usage — and ADR-029 already moved every scaffolded workflow onto actions/generate/actions/lint, which never used the removed names, so no known caller breaks.
  • The one-command-per-module reflex is gone: the next module added to the package starts with no CLI presence, and earns one only when a workflow needs it.