gabriel / muse public
Closed #56 Enhancement vcs
filed by gabriel human · 24 days ago

muse tag + muse label — rename label annotations, add protocol-native version tags

0 Anchors
Blast radius
Churn 30d
0 Proposals

muse tag + muse label — rename label annotations, add protocol-native version tags

Background

Muse currently has two annotation systems:

  1. muse tag add emotion:joyful — arbitrary namespace:value strings attached to commits. Music-oriented, no versioning semantics. Lives under the muse tag command.
  2. muse release add v0.2.0-rc8 — full semantic version records with MuseHub integration, changelogs, draft flags, and release channels. Heavyweight, hub-aware.

There are two problems with this state:

Problem 1 — muse tag owns the wrong concept. In every VCS (git, Mercurial, Darcs), "tag" means a named pointer to a commit — the version marker, the release stamp. The Muse tag command does something completely different: it attaches arbitrary label annotations. This is the wrong name. A developer arriving at Muse and running muse tag expects to stamp a version, not annotate an emotion. The label annotation system is a real and valuable feature — it just belongs under a better name.

Problem 2 — there is no lightweight version tag. What is missing is the equivalent of git tag v1.2.3 — a protocol-native, local-first named pointer that stamps a commit with a semantic version string. In git, lightweight tags are the foundation of versioned releases: they push alongside branches, are referenced by git describe, and require no remote infrastructure to create. Muse has no equivalent. muse release is too heavyweight (requires MuseHub for social utility); label tags have no versioning semantics.

The fix — two changes in one issue:

  1. Rename the existing label annotation system from muse tag to muse label. Storage layer (.muse/tags/, muse/core/tags.py) is unchanged — only the CLI command changes. Per workspace policy: no deprecated aliases, no fallbacks. muse tag add/list/remove are deleted; muse label add/list/remove replace them.

  2. Reclaim muse tag as the protocol-native semantic version tag command. muse tag add v0.2.0-rc14 stamps HEAD with a version; muse tag list shows version history; muse tag push local v0.2.0-rc14 sends the tag to a remote. Full SemVer 2.0 support including release candidates (rc14, beta.2, alpha.1).

Why not extend muse release? muse release is the right tool for publishing a release to MuseHub with web GUI metadata, changelogs, and asset attachments. Version tags are the simpler, local-first primitive that releases build on. Keeping them separate means you can tag a commit locally without a hub connection, push the tag later, and create a MuseHub release pointing at it when ready.

Goal

After this issue is complete:

  • muse label add emotion:joyful replaces muse tag add emotion:joyful — same functionality, correct name. muse tag add/list/remove are deleted entirely.
  • muse tag add v0.2.0-rc14 stamps HEAD (or any ref) with a semantic version tag, stored locally in .muse/version-tags/.
  • muse tag list lists all version tags sorted by semver precedence — stable versions above pre-releases, higher numbers before lower.
  • muse tag list --pre includes pre-release tags in the listing.
  • muse tag latest [--pre] returns the highest-version tag.
  • muse tag read v0.2.0-rc14 shows the full record: commit_id, semver fields, author, created_at, message.
  • muse tag delete v0.2.0-rc14 removes a version tag.
  • muse tag push <remote> v0.2.0-rc14 sends a version tag to a remote.
  • muse push local dev --tags includes all local version tags with the branch push.
  • muse describe --json uses version tags as reference points (like git describe --tags).
  • v0.2.0-rc14 is first-class — parsed, ordered, and sorted correctly relative to v0.2.0-rc13 and v0.2.0.
  • All 48 test IDs (LB_01LB_08, VT_01VT_40) are green.

Data shapes

ParsedSemVer

@dataclass(frozen=True)
class ParsedSemVer:
    major: int      # 0, 1, 2, ...
    minor: int
    patch: int
    pre:   str      # "rc14", "alpha.1", "beta.2", "" for stable
    build: str      # "+build.1" metadata (ignored in ordering), "" for most

VersionTagRecord

@dataclass(frozen=True)
class VersionTagRecord:
    tag_id:     str                    # sha256: content-addressed from repo_id + tag
    repo_id:    str                    # sha256: ties the tag to this repo
    tag:        str                    # "v0.2.0-rc14" — raw string exactly as typed
    semver:     ParsedSemVer           # structured parsed form
    commit_id:  str                    # sha256: the tagged commit
    created_at: datetime.datetime      # UTC
    author:     str                    # agent_id or user handle (from config)
    message:    str                    # optional annotation; "" when not provided

On-disk storage

.muse/version-tags/
    v0.2.0-rc8.json
    v0.2.0-rc14.json
    v1.0.0.json

One JSON file per version tag. File name is the raw tag string. tag_id inside the file provides tamper-evidence; the file name alone is enough for discovery.

Semver grammar

version     = "v" major "." minor "." patch ["-" pre-release] ["+" build]
pre-release = pre-identifier *("." pre-identifier)
pre-identifier = rc-id | alpha-id | beta-id | numeric | alphanumeric

rc-id    = "rc" 1*DIGIT          # rc1, rc14, rc100
alpha-id = "alpha" ["." 1*DIGIT] # alpha, alpha.1
beta-id  = "beta"  ["." 1*DIGIT] # beta, beta.2
numeric  = 1*DIGIT

Version precedence (descending — highest is "latest")

  1. v1.0.0 > v0.9.0 (major/minor/patch compared left to right)
  2. Stable beats any pre-release of the same M.M.P: v1.0.0 > v1.0.0-rc99
  3. Among pre-releases: rc > beta > alpha; then higher number beats lower: v0.2.0-rc14 > v0.2.0-rc13 > v0.2.0-beta.2 > v0.2.0-alpha.1
  4. Unrecognised pre-release identifiers sort lexicographically, after alpha.
  5. Build metadata (+build.1) is ignored in ordering (SemVer 2.0 §10).

JSON wire format — muse tag add (single tag)

{
  "exit_code":    0,
  "duration_ms":  0.6,
  "muse_version": "0.2.0rc14",
  "schema":       1,
  "timestamp":    "2026-06-28T22:00:00Z",
  "warnings":     [],
  "status":       "tagged",
  "tag_id":       "sha256:<64-hex>",
  "commit_id":    "sha256:<64-hex>",
  "tag":          "v0.2.0-rc14",
  "semver":       {"major": 0, "minor": 2, "patch": 0, "pre": "rc14", "build": ""},
  "author":       "gabriel",
  "created_at":   "2026-06-28T22:00:00+00:00",
  "message":      "",
  "dry_run":      false
}

JSON wire format — muse tag list

{
  "exit_code":    0,
  "duration_ms":  1.2,
  "muse_version": "0.2.0rc14",
  "schema":       1,
  "timestamp":    "2026-06-28T22:00:00Z",
  "warnings":     [],
  "total":        2,
  "tags": [
    {
      "tag_id":     "sha256:...",
      "tag":        "v0.2.0-rc14",
      "semver":     {"major": 0, "minor": 2, "patch": 0, "pre": "rc14", "build": ""},
      "commit_id":  "sha256:...",
      "author":     "gabriel",
      "created_at": "2026-06-28T22:00:00+00:00",
      "message":    ""
    }
  ]
}

JSON wire format — muse label add (single label)

Same shape as the existing muse tag add JSON — only the command path changes.


Phases


Phase 0 — Rename muse tag label annotations to muse label

No new functionality. Pure rename. Storage layer is unchanged. This phase is a prerequisite for Phase 2: muse tag must be empty before version tags can own it.

What changes

Before After
muse tag add emotion:joyful muse label add emotion:joyful
muse tag list [ref] [--match] muse label list [ref] [--match]
muse tag remove emotion:joyful muse label remove emotion:joyful
muse/cli/commands/tag.py (add/list/remove handlers) muse/cli/commands/label.py
.muse/tags/ storage .muse/tags/ storage (unchanged)
muse/core/tags.py muse/core/tags.py (unchanged)

What does NOT change

  • muse/core/tags.pyTagRecord, write_tag, get_all_tags, all I/O primitives. The storage layer is stable and correct; only the command interface moves.
  • .muse/tags/ on-disk layout — existing label records are not migrated. Any repo with existing label tag data continues to work with muse label.
  • muse tag command registration — it will still exist after Phase 0, but with zero subcommands (Phase 2 adds version-tag subcommands to it).

Deliverables

  • LB_01muse/cli/commands/label.py created with run_add, run_list, run_remove migrated verbatim from tag.py's existing handlers. Module docstring updated to say "label annotations" not "semantic tags". All TypedDicts renamed _LabelAddJson, _LabelListJson, _LabelRemoveJson.
  • LB_02muse label add emotion:joyful --json returns the same JSON shape as the former muse tag add, with exit_code: 0. Human text output says "Labelled" not "Tagged".
  • LB_03muse label list --json, muse label list HEAD --json, muse label list --match 'emotion:*' --json, muse label list --sort created --json all work identically to the former muse tag list variants.
  • LB_04muse label remove emotion:joyful --json works identically to the former muse tag remove.
  • LB_05muse tag add, muse tag list, muse tag remove are deleted from muse/cli/commands/tag.py. The muse tag parser registration remains (Phase 2 will add subcommands to it). Running muse tag with no subcommand shows help listing future version-tag subcommands (not an error, but does not dispatch the old add/list/remove).
  • LB_06muse/cli/commands/tag.py retains _validate_tag_name, _tag_namespace, _sort_tags, and _tag_to_json helpers only if they are still needed by the label module via import; otherwise move them to label.py and remove from tag.py.
  • LB_07 — All existing tests in tests/test_phase3_tags_releases_json.py that cover muse tag add/list/remove are updated to call muse label instead and remain green. No test is deleted — only the command name changes in the test invocations.
  • LB_08 — Integration smoke test: create a repo, run muse label add emotion:joyful, muse label list --json, muse label remove emotion:joyful --json; verify the full round-trip including removed_count: 1; confirm muse tag add emotion:joyful returns a parse error (command no longer exists).

Test file: tests/test_cmd_label.py (new) + updates to existing tag tests.


Phase 1 — Core version-tag data model (muse/core/version_tags.py)

New module. No dependencies on the CLI or MuseHub. All later phases build on this.

Deliverables

  • VT_01ParsedSemVer frozen dataclass with fields major, minor, patch, pre, build; all present, no optional fields. pre and build are "" for stable releases — never None.
  • VT_02parse_semver(tag: str) -> ParsedSemVer parses "v0.2.0-rc14" and returns ParsedSemVer(0, 2, 0, "rc14", ""). Raises ValueError on invalid input. Leading v is required; "0.2.0" without the v prefix raises ValueError.
  • VT_03parse_semver handles all supported pre-release forms: rc<N>, alpha, alpha.<N>, beta, beta.<N>, and generic dotted identifiers. Build metadata (+build.1) is parsed into build and stripped from pre. Invalid characters raise ValueError with a specific human-readable message.
  • VT_04semver_key(sv: ParsedSemVer) -> tuple returns a sort key for proper semver precedence: stable > rc > beta > alpha > generic pre. Higher numbers beat lower within the same tier. sorted(tags, key=lambda t: semver_key(t.semver), reverse=True) produces latest-first order.
  • VT_05VersionTagRecord frozen dataclass with all eight fields. to_dict() returns a VersionTagDict (TypedDict) with semver as a nested dict. from_dict(d) deserialises defensively; missing or invalid fields produce sensible fallbacks (same pattern as TagRecord.from_dict).
  • VT_06compute_version_tag_id(repo_id, tag) -> str returns a deterministic sha256: ID from content_hash(repo_id + ":" + tag). Same semantics as compute_tag_id in muse/core/tags.py.
  • VT_07_validate_version_tag_name(name: str) -> str returns name unchanged if valid; raises ValueError otherwise. Rules: must start with "v"; must parse cleanly with parse_semver; max 128 chars; no whitespace or control characters.
  • VT_08version_tag_path(root, tag) -> pathlib.Path returns .muse/version-tags/<tag>.json. Raises ValueError on path-traversal characters.
  • VT_09write_version_tag(root, record: VersionTagRecord) atomically writes using _write_json_atomic. Creates .muse/version-tags/ on first write.
  • VT_10read_version_tag(root, tag) -> VersionTagRecord | None returns None if the file does not exist; raises on corrupt JSON.
  • VT_11list_version_tags(root) -> list[VersionTagRecord] enumerates .muse/version-tags/, returns records sorted by semver_key descending. [] when empty.
  • VT_12delete_version_tag(root, tag) -> bool removes the file; returns True if deleted, False if not found. Atomic.

Unit tests: tests/test_core_version_tags.py


Phase 2 — muse tag CLI (version tags)

muse tag is now the version-tag command. All subcommands accept --json / -j.

CLI surface

muse tag add v0.2.0-rc14 [ref] [-m "message"] [--dry-run] [--force] [--json]
muse tag list                   [--pre] [--sort semver|created|commit] [--json]
muse tag read v0.2.0-rc14       [--json]
muse tag delete v0.2.0-rc14     [--json]
muse tag latest                 [--pre] [--json]
muse tag push <remote> v0.2.0-rc14 [--force] [--json]

Deliverables

  • VT_13muse tag add, muse tag list, muse tag read, muse tag delete, muse tag latest, muse tag push subcommands are registered in muse/cli/commands/tag.py. The parser no longer has add/list/remove label-style subcommands (those moved to muse label in Phase 0).
  • VT_14muse tag add v0.2.0-rc14 --json tags HEAD; JSON matches the wire format above with status: "tagged"; exit 0.
  • VT_15muse tag add v0.2.0-rc14 a second time returns status: "already_tagged" with the existing tag_id; exit 0. --force overwrites an existing tag to point at a new commit.
  • VT_16muse tag add v0.2.0-rc14 <ref> tags the specified branch or commit ID; commit_id in JSON matches the resolved commit.
  • VT_17muse tag add v0.2.0-rc14 -m "stable RC before feature freeze" --json stores the message; it appears in muse tag read output.
  • VT_18muse tag add not-semver --json returns exit 1 with "error": "invalid_version_tag_name" and a message explaining the semver format.
  • VT_19muse tag add v0.2.0-rc14 --dry-run --json returns status: "dry_run", tag_id: null, commit_id set; nothing written to disk.
  • VT_20muse tag list --json returns total and tags sorted semver-descending. total: 0, tags: [], exit 0 when empty.
  • VT_21muse tag list without --pre omits pre-release tags (only stable versions shown). muse tag list --pre includes them. With zero stable tags and no --pre, returns total: 0.
  • VT_22muse tag list --sort created --json sorts by created_at descending. --sort commit sorts lexicographically by commit_id.
  • VT_23muse tag read v0.2.0-rc14 --json returns the full record; exit 1 with "error": "version_tag_not_found" if missing.
  • VT_24muse tag delete v0.2.0-rc14 --json returns {"deleted": true, ...}; idempotent: {"deleted": false, ...} exit 0 if not found.
  • VT_25muse tag latest --json returns the single highest-version stable tag. --pre includes pre-release tags. {"latest": null, ...} exit 0 when none match.

Integration tests: tests/test_cmd_version_tags.py


Phase 3 — Push integration

Deliverables

  • VT_26muse tag push <remote> v0.2.0-rc14 --json sends the tag to the named remote; returns {"status": "pushed", "remote": "local", "tag": "v0.2.0-rc14", "commit_id": "...", ...}. Exit 1 if the tag does not exist locally or the remote is unreachable.
  • VT_27muse tag push <remote> v0.2.0-rc14 when the tag already exists on the remote at the same commit returns {"status": "already_current"}; exit 0 (idempotent).
  • VT_28muse tag push <remote> v0.2.0-rc14 --force overwrites the remote tag even if it points to a different commit. Without --force, a diverged remote tag returns exit 1 with "error": "remote_tag_conflict".
  • VT_29muse push local dev --tags --json pushes the branch AND all local version tags; push JSON gains "tags_pushed" and "tags_skipped" integer fields.
  • VT_30muse push local dev (without --tags) does NOT push version tags — opt-in per push unless push.tags = true is set in config.
  • VT_31muse config set push.tags true makes muse push include tags by default; muse config get push.tags --json returns the value.
  • VT_32 — Receiving a pushed version tag creates it in the remote's .muse/version-tags/; a subsequent muse pull local dev on a third clone fetches the tag; muse tag list --pre shows it.
  • VT_33 — Integration test: create two version tags, push both with muse push local dev --tags, verify both stored and fetchable.

Test file: tests/test_cmd_version_tag_push.py


Phase 4 — muse describe integration

CLI surface

muse describe [ref] [--pre] [--abbrev N] [--json]

Deliverables

  • VT_34muse describe --json walks backwards from HEAD; returns {"tag": "v0.2.0-rc14", "distance": 0, "commit_id": "sha256:...", "description": "v0.2.0-rc14", ...} when HEAD is exactly tagged.
  • VT_35muse describe --json when HEAD is 3 commits after v0.2.0-rc14 returns {"tag": "v0.2.0-rc14", "distance": 3, "description": "v0.2.0-rc14-3-sha256abc123", ...}.
  • VT_36muse describe --pre --json includes pre-release version tags in the search; without --pre, only stable tags are reference points.
  • VT_37muse describe --json when no version tags exist returns {"tag": null, "distance": null, "description": null, "commit_id": "sha256:...", "exit_code": 1}; exit 1.
  • VT_38muse describe <ref> --json describes from the given branch or commit ref rather than HEAD.
  • VT_39muse describe --abbrev N --json controls the short-commit-id length in the description string (default 8 hex chars, no sha256: prefix).
  • VT_40 — Integration test: 5-commit history; v0.2.0-rc14 on commit 1; muse describe --pre from commit 4 returns distance: 3; from commit 1 returns distance: 0; without --pre on a repo with only RC tags returns exit 1.

Test file: tests/test_cmd_describe_version_tags.py


Acceptance criteria

  • muse label add/list/remove fully replaces muse tag add/list/remove. The storage layer (.muse/tags/, muse/core/tags.py) is unchanged — only the CLI command moves.
  • muse tag add not-semver returns a parse error — the command is now version-only.
  • muse tag add v0.2.0-rc14 creates a local version tag with no hub connection required.
  • v0.2.0-rc14 is ordered correctly: above v0.2.0-rc13, below v0.2.0.
  • muse tag list --pre shows pre-release tags; without --pre, only stable.
  • muse tag push local v0.2.0-rc14 sends the tag to a local remote.
  • muse push local dev --tags pushes all version tags alongside the branch.
  • muse describe --pre --json finds the nearest RC tag and reports distance.
  • All 48 test IDs (LB_01LB_08, VT_01VT_40) are green.

Out of scope

  • MuseHub GUI display of version tags — covered by existing GUI release tickets.
  • muse release integration — a follow-up issue can wire muse release add v0.2.0-rc14 to create a version tag automatically.
  • muse tag sign — cryptographic signing of version tags (deferred).
  • Tag listing on the remote without fetching — requires a hub API endpoint (deferred).
  • Relative version resolution (v0.2.0-rc14~3) — deferred.
  • Non-SemVer version strings — out of scope; use muse label for freeform annotations.
  • Migration of .muse/tags/ storage path — the label tag storage keeps its existing layout; no data migration needed since the storage layer is unchanged.
Activity1
gabriel opened this issue 24 days ago
gabriel 24 days ago

✅ All phases complete — closing

281 tests green across all 4 phases (LB_01–LB_08, VT_01–VT_40).

Phase 0 — muse label rename (LB_01–LB_08)

  • muse/cli/commands/label.py created; run_add, run_list, run_remove migrated verbatim
  • muse label add/list/remove fully replaces old muse tag add/list/remove
  • Old label handlers deleted from tag.py; storage layer (.muse/tags/, muse/core/tags.py) unchanged
  • 22 tests in tests/test_cmd_label.py — all green

Phase 1 — Core version-tag data model (VT_01–VT_12)

  • muse/core/version_tags.py: ParsedSemVer, VersionTagRecord, parse_semver, semver_key, write_version_tag, read_version_tag, list_version_tags, delete_version_tag
  • Full SemVer 2.0 ordering: stable > rc > beta > alpha; higher number beats lower
  • 86 tests in tests/test_core_version_tags.py — all green

Phase 2 — muse tag CLI (VT_13–VT_25)

  • muse tag add/list/read/delete/latest/push registered in muse/cli/commands/tag.py
  • Dry-run, --force, --pre, --sort, idempotent re-tag, per-spec JSON shapes
  • 50 tests in tests/test_cmd_version_tags.py — all green

Phase 3 — Push integration (VT_26–VT_33)

  • muse tag push <remote> <tag> with force/conflict detection
  • muse push local dev --tags sends branch + all version tags
  • muse config set push.tags true for per-repo default
  • muse pull fetches version tags from remote
  • 21 tests in tests/test_cmd_version_tag_push.py — all green
  • Server-side: MusehubVersionTag DB model, musehub_version_tags service, POST/GET wire endpoints

Phase 4 — muse describe integration (VT_34–VT_40)

  • muse describe [ref] [--pre] [--abbrev N] [--json] — clean pristine API, uses version tags only
  • Removed legacy flags: --long, --require-tag, --exact-match, --match, --ref
  • description field (not name); distance: null on no-tag; short_sha is raw hex; exit 1 on no tags
  • 30 new tests in tests/test_cmd_describe_version_tags.py + rewrote 4 legacy describe test files
  • 102 total describe tests — all green

Developer guide

Full idiomatic usage guide with real command output published as a mist: https://staging.musehub.ai/gabriel/mists/EjYGpUN3xgxy

closed this issue 24 days ago