gabriel / muse public
supported-commands-autoderive-plan.md markdown
326 lines 17.2 KB
Raw
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 9 days ago

supported_commands must be auto-derived, never hand-typed

Background

The proximate trigger

While debugging why https://staging.musehub.ai/domains showed zero results (root-caused and fixed separately: list_repos_for_domain compared the wrong column, and muse domains publish/update/delete never worked against a local HTTPS musehub at all because the CLI's HTTP helpers didn't use the shared mkcert-aware SSL context — both fixed), a third issue surfaced: every published domain reports the identical supported_commands: ["commit", "diff", "merge", "log", "status"], regardless of domain.

Root cause — verified against source, not assumed

muse domains publish's auto-derive path (run_publish, used whenever --capabilities is omitted) does this:

schema = plugin.schema()
capabilities = _Capabilities(
    dimensions=[... for d in schema["dimensions"]],       # REAL — from schema()
    artifact_types=[],
    merge_semantics=schema["merge_mode"],                 # REAL — from schema()
    supported_commands=["commit", "diff", "merge", "log", "status"],  # ← literal, ignores `schema` entirely
)

dimensions and merge_semantics are genuinely derived per-plugin (verified: @gabriel/code, @gabriel/identity, and @gabriel/mist each got different real dimensions when published). supported_commands is a bare Python list baked into the CLI, consulted for zero plugins, ever. There is currently no data source it could even be derived from — DomainSchema (muse/core/schema.py) has exactly six fields: domain, description, dimensions, top_level, merge_mode, schema_version. No commands concept exists there.

The explicit---capabilities path (used when a dev passes JSON directly) is honest by comparison — supported_commands there is exactly whatever the dev typed, defaulting to [] if omitted. That's still not future-proof: it depends on a human remembering to type an accurate list and keep it in sync as the domain's real CLI surface grows, which this issue's own trigger proves doesn't happen reliably.

The real source of truth already exists — it's just not exposed

muse code, muse mist, and muse social are each registered as a top-level argparse namespace with a real, live subcommand tree — the exact structure argparse itself uses to dispatch:

# app.py — muse code's ~50 subcommands, built inline
code_subs = code_parser.add_subparsers(dest="code_command", ...)
age_cmd.register(code_subs)           # "age"
api_surface.register(code_subs)       # "api-surface"
blast_risk.register(code_subs)        # "blast-risk"
... # dozens more, one .register() call per command module
# mist.py::register() — muse mist's 10 subcommands
subs = parser.add_subparsers(dest="mist_subcommand", ...)
subs.add_parser("create", ...)
subs.add_parser("list", ...)
... # fork, push, embed, delete, update, forks, raw

code_subs.choices.keys() (and the equivalent for mist/social) is already the authoritative, live list — the same object argparse dispatches from. identity and scaffold have no dedicated top-level namespace at all; for those, the honest value is [], not a borrowed universal list.

Design — the shape to build toward

A tiny, dependency-free registry, populated as a side effect of argparse tree construction — never hand-typed anywhere.

muse/cli/domain_command_registry.py: a plain dict plus register_namespace(name, iterable) / get_supported_commands(name). Each domain-named command module (app.py inline for code, mist.py::register(), social.py::register()) calls register_namespace(name, subs.choices.keys()) immediately after building its own subparser tree. This happens unconditionally on every CLI invocation — main() must build the full parser tree to parse sys.argv regardless of which command actually ran, so the registry is always populated before any dispatch, including run_publish itself.

run_publish's auto-derive path calls get_supported_commands(active_domain_name) instead of the literal. The explicit---capabilities-but-key-omitted case in run_publish also falls back to this (publish's active-domain context is trustworthy — it's literally what dimensions/merge_semantics already assume). Explicit "supported_commands": [...] in --capabilities still overrides, consistent with how viewer_type/merge_semantics already work.

run_update is explicitly excluded from this auto-derive fallback. Unlike publish, update carries no guarantee that the CLI's currently active domain plugin has anything to do with the domain actually being updated (you can update @aaronrene/knowtation while sitting in an unrelated local repo with code active) — auto-deriving there would silently attribute the wrong domain's commands. update's explicit --capabilities path keeps its current []-on-omission behavior.

Goal — definition of done

  1. supported_commands is never a hand-typed literal in run_publish's auto-derive path.
  2. A domain with a dedicated muse <name> <verb> namespace reports its real, current subcommand list at publish time.
  3. A domain without one (identity, scaffold) reports [].
  4. Adding a new muse code <newverb> command tomorrow requires zero changes anywhere for the next publish of @gabriel/code to pick it up correctly.
  5. Explicit --capabilities JSON with a supported_commands key still overrides auto-derivation, in both publish and update.
  6. Every deliverable is TDD'd: red test first, then green.

Phases

Ordered by load-bearing dependency — the registry must exist and be correctly populated before domains.py can consume it.

Phase 1 — The registry module, in isolation

  • [x] SCR_01muse/cli/domain_command_registry.py: register_namespace(name: str, commands: Iterable[str]) -> None and get_supported_commands(name: str) -> list[str]. Red test: querying an unregistered name returns []; registering then querying returns the registered list. Done.
  • [x] SCR_02 — Idempotent re-registration: calling register_namespace twice for the same name replaces the stored list, never appends or duplicates (guards against double-registration if main() is ever called more than once in a process, e.g. tests). Done.
  • [x] SCR_03 — Deterministic, sorted output regardless of insertion order — get_supported_commands must not depend on dict/set iteration order being stable across Python versions. Done — verified across list, set, and dict.keys() input (the real shape _SubParsersAction.choices will pass in Phase 2).

Exit gate — met. tests/test_domain_command_registry.py: 8/8 new tests pass (basic contract, idempotent re-registration ×2, deterministic ordering ×3, copy-not-reference safety). mypy clean. Zero dependencies on app.py or any command module — confirmed no circular-import risk is even possible, since the module imports nothing from this codebase at all beyond collections.abc.Iterable. Committed on feat/supported-commands-registry, not yet merged to dev.

Phase 2 — Wire the real namespaces into the registry

  • [x] SCR_04app.py calls register_namespace("code", code_subs.choices.keys()) immediately after every .register(code_subs) call for the code namespace has run. Test: after any CLI invocation (even an unrelated one — main() always builds the full tree), get_supported_commands("code") contains stable, known-permanent anchors (e.g. "grep", "impact", "symbols") and has more than 30 entries — asserting a floor and membership, not an exact list, so this test itself never becomes the new hardcoded array this issue exists to eliminate. Done.
  • [x] SCR_05mist.py::register() calls register_namespace("mist", subs.choices.keys()). Test: get_supported_commands("mist") contains "create", "fork", "embed" and explicitly does not contain "commit" or "diff" (those are universal muse commands, not mist-specific — proves the registry captures the dedicated namespace, not everything). Done.
  • [x] SCR_06 — Same pattern for social.py::register(). Done.
  • [x] SCR_07 — Domains with no dedicated top-level namespace (identity, scaffold) return [] from get_supported_commands — confirms the registry doesn't fabricate an entry for names it was never told about. Done.

Exit gate — met. tests/test_domain_command_registry_wiring.py: 10/10 new integration tests pass, invoking the real CLI via CliRunner and asserting on muse code/muse mist/muse social's actual argparse tree (membership + count floor, never an exact list). All 8 Phase 1 unit tests in tests/test_domain_command_registry.py still pass unchanged — 18/18 total across both files. mypy: identical error count on all three edited files before and after the change (app.py 165, mist.py 23, social.py 11 — all pre-existing, unrelated to this change; verified by diffing against each file's HEAD content). Wiring added: one register_namespace(...) call at the end of the code subparser block in app.py, and one each as the final line of mist.py::register() and social.py::register(). Committed on feat/supported-commands-registry, not yet merged to dev.

Phase 3 — Wire the registry into muse domains publish

  • [x] SCR_08 — Red test proving the bug this issue exists to fix: publishing @gabriel/code via the auto-derive path (no --capabilities) currently returns the hardcoded universal 5. Fix: replace the literal in run_publish with get_supported_commands(active_domain_name). Green: the same test now asserts the real code-domain list (membership + floor, not exact — same reasoning as SCR_04). Done.
  • [x] SCR_09 — Publishing @gabriel/identity via the auto-derive path now correctly returns [], not the old fake universal list. Done.
  • [x] SCR_10 — Explicit --capabilities '{"supported_commands": [...]}' still overrides auto-derivation — regression guard for existing, correct behavior. Done — pinned by a new test; was already passing before the fix (proves the override path was never broken).
  • [x] SCR_11 — Explicit --capabilities without a supported_commands key, in the publish path specifically, also falls back to get_supported_commands(active_domain_name) rather than defaulting to [] — makes the field correct regardless of how a dev constructs their --capabilities JSON, as long as they're inside the domain's own repo (which publish already assumes for dimensions/merge_semantics). Done.
  • [x] SCR_12run_update is explicitly not changed — a test confirms update's explicit---capabilities-with-omitted-key behavior is unchanged ([], not auto-derived), documenting the deliberate asymmetry from the Design section rather than leaving it to look like an oversight. Donerun_update source untouched; test pins the existing [] behavior as a regression guard.

Exit gate — met. New file tests/test_domains_publish_supported_commands.py: 5/5 new tests pass (SCR_08 code-domain real list, SCR_09 identity [], SCR_10 explicit override, SCR_11 explicit-JSON-omitted-key fallback, SCR_12 update stays []). Confirmed genuinely red before the fix (SCR_08/09/11 failed against the old hardcoded literal; SCR_10/12 passed immediately since those paths were never broken — expected, since they're regression guards, not new behavior). Zero regressions: all pre-existing domains-publish suites still pass unchanged — test_domains_publish.py (38), test_cmd_domains_hardening.py (189), test_stress_domains_publish.py (40) — 267 pre-existing tests plus the 5 new ones, 272 total, all green. mypy: identical error count on domains.py before and after (13, all pre-existing, unrelated to this change; verified by diffing against HEAD content). Implementation: active_domain_name resolution hoisted above the capabilities_json is not None branch so both the explicit-JSON path and the auto-derive path can consult it; both now call get_supported_commands(active_domain_name) in place of the old hardcoded literal, with the explicit-JSON path only falling back to it when supported_commands is absent from the parsed JSON (an explicit value still always wins). run_update left untouched by design. Committed on feat/supported-commands-registry, not yet merged to dev.

Exit gate: muse domains publish for code/mist/social domains reports real commands; identity/scaffold report []; explicit overrides still work; update's different (and correct) behavior is pinned by a test, not just prose.

Phase 4 — Live re-verification

  • [x] SCR_13 — Re-publish @gabriel/code, @gabriel/identity, @gabriel/mist on local musehub first (per the debugging convention already in use for this investigation) using the fixed CLI, confirm real supported_commands in the live JSON response for each. Done — see note below on why disposable -scr13 slugs were used instead of the canonical slugs.
  • [x] SCR_14 — Repeat against staging once local is confirmed correct. Done.

Exit gate — met. Live curl confirmation against both local (https://localhost:1337) and staging (https://staging.musehub.ai):

  • @gabriel/code-scr13 — 46 real commands (grep, impact, symbols, gravity, hotspots, ... ), confirmed != ["commit", "diff", "merge", "log", "status"] on both hubs.
  • @gabriel/identity-scr13[] on both hubs (no dedicated namespace).
  • @gabriel/mist-scr13["create", "delete", "embed", "fork", "forks", "list", "push", "raw", "read", "update"] on both hubs; confirmed "commit"/"diff" absent.

Why -scr13 slugs, not the canonical @gabriel/code etc.: the canonical domains were already registered from before this fix (with the old hardcoded 5), and muse domains delete is a soft is_deprecated flip, not a row delete — the server's (author_slug, slug) uniqueness constraint holds even on deprecated rows, so re-publishing under the same slug 409s. A first attempt at muse domains delete --slug code on local musehub to free the slug hit exactly this: no CLI/API path un-deprecates a domain, so @gabriel/code was stuck deprecated with no clean way back. Caught it, stopped, asked gabriel before doing anything further (per this workspace's data-loss/destructive-action rules) — gabriel approved a direct fix to the local dev Postgres row (UPDATE musehub_domains SET is_deprecated=false WHERE author_slug= 'gabriel' AND slug='code', musehub_postgres container on 127.0.0.1:5434), confirmed restored via GET /api/domains. Live verification of the actual fix then proceeded using disposable {code,identity,mist}-scr13 domains (published via the real, fixed CLI in throwaway /tmp repos with the matching domain= in repo.json), which avoided ever touching the canonical entries again. All three -scr13 domains were deprecated on both hubs after verification — is_deprecated: true is the correct terminal state for a throwaway verification artifact, not a mistake to fix. The canonical @gabriel/code/@gabriel/identity/@gabriel/mist entries on both local and staging are untouched (still is_deprecated: false) and will pick up real supported_commands the next time they're genuinely re-published — out of scope for this ticket, which was about fixing the mechanism, not re-publishing production catalog entries.

Committed on feat/supported-commands-registry, not yet merged to dev. No source changes in this phase — verification only.

Exit gate: Live, in-browser/curl confirmation against both local and staging — not just the test suite. @gabriel/code shows dozens of real commands; @gabriel/identity shows []; no domain shows the old fake universal 5 anymore.

Acceptance criteria (whole-issue gate)

  • Zero hardcoded command literals remain in run_publish's auto-derive path.
  • code/mist/social report real, current, non-fabricated command lists; identity/scaffold report [].
  • Explicit --capabilities overrides still work in both publish and update.
  • Full TDD coverage: every phase's deliverables have a red-then-green test.
  • Verified live on both local and staging, not just unit-tested.

Out of scope (explicit, for future issues)

  • muse domains update --capabilities silently wiping supported_commands (and dimensions/artifact_types) back to empty when they're omitted from an update's JSON, because update replaces the entire capabilities object rather than merging. Discovered while scoping this issue; real, but a distinct bug about update's replace-vs-merge semantics, not about command derivation. Tracked separately.
  • Giving identity or scaffold their own dedicated muse <name> <verb> CLI namespace — out of scope; this issue is about correctly reporting what already exists, not creating new CLI surface.
  • Any change to how dimensions/merge_semantics/viewer_type are derived — already correct, verified working throughout this investigation.
File History 2 commits
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 9 days ago
sha256:b7be56ec091919a612cffe7f3c8b600209d5155517443fdac0e16954c8c7d81f Merge 'task/git-export-ignored-file-deletion' into 'dev' — … Human 12 days ago