muse tag + muse label — rename label annotations, add protocol-native version tags
muse tag + muse label — rename label annotations, add protocol-native version tags
Background
Muse currently has two annotation systems:
muse tag add emotion:joyful— arbitrarynamespace:valuestrings attached to commits. Music-oriented, no versioning semantics. Lives under themuse tagcommand.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:
Rename the existing label annotation system from
muse tagtomuse 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/removeare deleted;muse label add/list/removereplace them.Reclaim
muse tagas the protocol-native semantic version tag command.muse tag add v0.2.0-rc14stamps HEAD with a version;muse tag listshows version history;muse tag push local v0.2.0-rc14sends 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:joyfulreplacesmuse tag add emotion:joyful— same functionality, correct name.muse tag add/list/removeare deleted entirely.muse tag add v0.2.0-rc14stamps HEAD (or any ref) with a semantic version tag, stored locally in.muse/version-tags/.muse tag listlists all version tags sorted by semver precedence — stable versions above pre-releases, higher numbers before lower.muse tag list --preincludes pre-release tags in the listing.muse tag latest [--pre]returns the highest-version tag.muse tag read v0.2.0-rc14shows the full record: commit_id, semver fields, author, created_at, message.muse tag delete v0.2.0-rc14removes a version tag.muse tag push <remote> v0.2.0-rc14sends a version tag to a remote.muse push local dev --tagsincludes all local version tags with the branch push.muse describe --jsonuses version tags as reference points (likegit describe --tags).v0.2.0-rc14is first-class — parsed, ordered, and sorted correctly relative tov0.2.0-rc13andv0.2.0.- All 48 test IDs (
LB_01–LB_08,VT_01–VT_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")
v1.0.0>v0.9.0(major/minor/patch compared left to right)- Stable beats any pre-release of the same M.M.P:
v1.0.0>v1.0.0-rc99 - 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 - Unrecognised pre-release identifiers sort lexicographically, after
alpha. - 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.py—TagRecord,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 withmuse label.muse tagcommand registration — it will still exist after Phase 0, but with zero subcommands (Phase 2 adds version-tag subcommands to it).
Deliverables
LB_01—muse/cli/commands/label.pycreated withrun_add,run_list,run_removemigrated verbatim fromtag.py's existing handlers. Module docstring updated to say "label annotations" not "semantic tags". All TypedDicts renamed_LabelAddJson,_LabelListJson,_LabelRemoveJson.LB_02—muse label add emotion:joyful --jsonreturns the same JSON shape as the formermuse tag add, withexit_code: 0. Human text output says"Labelled"not"Tagged".LB_03—muse label list --json,muse label list HEAD --json,muse label list --match 'emotion:*' --json,muse label list --sort created --jsonall work identically to the formermuse tag listvariants.LB_04—muse label remove emotion:joyful --jsonworks identically to the formermuse tag remove.LB_05—muse tag add,muse tag list,muse tag removeare deleted frommuse/cli/commands/tag.py. Themuse tagparser registration remains (Phase 2 will add subcommands to it). Runningmuse tagwith no subcommand shows help listing future version-tag subcommands (not an error, but does not dispatch the old add/list/remove).LB_06—muse/cli/commands/tag.pyretains_validate_tag_name,_tag_namespace,_sort_tags, and_tag_to_jsonhelpers only if they are still needed by the label module via import; otherwise move them tolabel.pyand remove fromtag.py.LB_07— All existing tests intests/test_phase3_tags_releases_json.pythat covermuse tag add/list/removeare updated to callmuse labelinstead and remain green. No test is deleted — only the command name changes in the test invocations.LB_08— Integration smoke test: create a repo, runmuse label add emotion:joyful,muse label list --json,muse label remove emotion:joyful --json; verify the full round-trip includingremoved_count: 1; confirmmuse tag add emotion:joyfulreturns 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_01—ParsedSemVerfrozen dataclass with fieldsmajor,minor,patch,pre,build; all present, no optional fields.preandbuildare""for stable releases — neverNone.VT_02—parse_semver(tag: str) -> ParsedSemVerparses"v0.2.0-rc14"and returnsParsedSemVer(0, 2, 0, "rc14", ""). RaisesValueErroron invalid input. Leadingvis required;"0.2.0"without thevprefix raisesValueError.VT_03—parse_semverhandles all supported pre-release forms:rc<N>,alpha,alpha.<N>,beta,beta.<N>, and generic dotted identifiers. Build metadata (+build.1) is parsed intobuildand stripped frompre. Invalid characters raiseValueErrorwith a specific human-readable message.VT_04—semver_key(sv: ParsedSemVer) -> tuplereturns 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_05—VersionTagRecordfrozen dataclass with all eight fields.to_dict()returns aVersionTagDict(TypedDict) withsemveras a nested dict.from_dict(d)deserialises defensively; missing or invalid fields produce sensible fallbacks (same pattern asTagRecord.from_dict).VT_06—compute_version_tag_id(repo_id, tag) -> strreturns a deterministicsha256:ID fromcontent_hash(repo_id + ":" + tag). Same semantics ascompute_tag_idinmuse/core/tags.py.VT_07—_validate_version_tag_name(name: str) -> strreturnsnameunchanged if valid; raisesValueErrorotherwise. Rules: must start with"v"; must parse cleanly withparse_semver; max 128 chars; no whitespace or control characters.VT_08—version_tag_path(root, tag) -> pathlib.Pathreturns.muse/version-tags/<tag>.json. RaisesValueErroron path-traversal characters.VT_09—write_version_tag(root, record: VersionTagRecord)atomically writes using_write_json_atomic. Creates.muse/version-tags/on first write.VT_10—read_version_tag(root, tag) -> VersionTagRecord | NonereturnsNoneif the file does not exist; raises on corrupt JSON.VT_11—list_version_tags(root) -> list[VersionTagRecord]enumerates.muse/version-tags/, returns records sorted bysemver_keydescending.[]when empty.VT_12—delete_version_tag(root, tag) -> boolremoves the file; returnsTrueif deleted,Falseif 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_13—muse tag add,muse tag list,muse tag read,muse tag delete,muse tag latest,muse tag pushsubcommands are registered inmuse/cli/commands/tag.py. The parser no longer hasadd/list/removelabel-style subcommands (those moved tomuse labelin Phase 0).VT_14—muse tag add v0.2.0-rc14 --jsontags HEAD; JSON matches the wire format above withstatus: "tagged"; exit 0.VT_15—muse tag add v0.2.0-rc14a second time returnsstatus: "already_tagged"with the existingtag_id; exit 0.--forceoverwrites an existing tag to point at a new commit.VT_16—muse tag add v0.2.0-rc14 <ref>tags the specified branch or commit ID;commit_idin JSON matches the resolved commit.VT_17—muse tag add v0.2.0-rc14 -m "stable RC before feature freeze" --jsonstores the message; it appears inmuse tag readoutput.VT_18—muse tag add not-semver --jsonreturns exit 1 with"error": "invalid_version_tag_name"and a message explaining the semver format.VT_19—muse tag add v0.2.0-rc14 --dry-run --jsonreturnsstatus: "dry_run",tag_id: null,commit_idset; nothing written to disk.VT_20—muse tag list --jsonreturnstotalandtagssorted semver-descending.total: 0,tags: [], exit 0 when empty.VT_21—muse tag listwithout--preomits pre-release tags (only stable versions shown).muse tag list --preincludes them. With zero stable tags and no--pre, returnstotal: 0.VT_22—muse tag list --sort created --jsonsorts bycreated_atdescending.--sort commitsorts lexicographically bycommit_id.VT_23—muse tag read v0.2.0-rc14 --jsonreturns the full record; exit 1 with"error": "version_tag_not_found"if missing.VT_24—muse tag delete v0.2.0-rc14 --jsonreturns{"deleted": true, ...}; idempotent:{"deleted": false, ...}exit 0 if not found.VT_25—muse tag latest --jsonreturns the single highest-version stable tag.--preincludes pre-release tags.{"latest": null, ...}exit 0 when none match.
Integration tests: tests/test_cmd_version_tags.py
Phase 3 — Push integration
Deliverables
VT_26—muse tag push <remote> v0.2.0-rc14 --jsonsends 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_27—muse tag push <remote> v0.2.0-rc14when the tag already exists on the remote at the same commit returns{"status": "already_current"}; exit 0 (idempotent).VT_28—muse tag push <remote> v0.2.0-rc14 --forceoverwrites 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_29—muse push local dev --tags --jsonpushes the branch AND all local version tags;pushJSON gains"tags_pushed"and"tags_skipped"integer fields.VT_30—muse push local dev(without--tags) does NOT push version tags — opt-in per push unlesspush.tags = trueis set in config.VT_31—muse config set push.tags truemakesmuse pushinclude tags by default;muse config get push.tags --jsonreturns the value.VT_32— Receiving a pushed version tag creates it in the remote's.muse/version-tags/; a subsequentmuse pull local devon a third clone fetches the tag;muse tag list --preshows it.VT_33— Integration test: create two version tags, push both withmuse 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_34—muse describe --jsonwalks 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_35—muse describe --jsonwhen HEAD is 3 commits afterv0.2.0-rc14returns{"tag": "v0.2.0-rc14", "distance": 3, "description": "v0.2.0-rc14-3-sha256abc123", ...}.VT_36—muse describe --pre --jsonincludes pre-release version tags in the search; without--pre, only stable tags are reference points.VT_37—muse describe --jsonwhen no version tags exist returns{"tag": null, "distance": null, "description": null, "commit_id": "sha256:...", "exit_code": 1}; exit 1.VT_38—muse describe <ref> --jsondescribes from the given branch or commit ref rather than HEAD.VT_39—muse describe --abbrev N --jsoncontrols the short-commit-id length in the description string (default 8 hex chars, nosha256:prefix).VT_40— Integration test: 5-commit history;v0.2.0-rc14on commit 1;muse describe --prefrom commit 4 returnsdistance: 3; from commit 1 returnsdistance: 0; without--preon a repo with only RC tags returns exit 1.
Test file: tests/test_cmd_describe_version_tags.py
Acceptance criteria
muse label add/list/removefully replacesmuse tag add/list/remove. The storage layer (.muse/tags/,muse/core/tags.py) is unchanged — only the CLI command moves.muse tag add not-semverreturns a parse error — the command is now version-only.muse tag add v0.2.0-rc14creates a local version tag with no hub connection required.v0.2.0-rc14is ordered correctly: abovev0.2.0-rc13, belowv0.2.0.muse tag list --preshows pre-release tags; without--pre, only stable.muse tag push local v0.2.0-rc14sends the tag to a local remote.muse push local dev --tagspushes all version tags alongside the branch.muse describe --pre --jsonfinds the nearest RC tag and reports distance.- All 48 test IDs (
LB_01–LB_08,VT_01–VT_40) are green.
Out of scope
- MuseHub GUI display of version tags — covered by existing GUI release tickets.
muse releaseintegration — a follow-up issue can wiremuse release add v0.2.0-rc14to 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 labelfor 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.
✅ All phases complete — closing
281 tests green across all 4 phases (LB_01–LB_08, VT_01–VT_40).
Phase 0 —
muse labelrename (LB_01–LB_08)muse/cli/commands/label.pycreated;run_add,run_list,run_removemigrated verbatimmuse label add/list/removefully replaces oldmuse tag add/list/removetag.py; storage layer (.muse/tags/,muse/core/tags.py) unchangedtests/test_cmd_label.py— all greenPhase 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_tagtests/test_core_version_tags.py— all greenPhase 2 —
muse tagCLI (VT_13–VT_25)muse tag add/list/read/delete/latest/pushregistered inmuse/cli/commands/tag.pytests/test_cmd_version_tags.py— all greenPhase 3 — Push integration (VT_26–VT_33)
muse tag push <remote> <tag>with force/conflict detectionmuse push local dev --tagssends branch + all version tagsmuse config set push.tags truefor per-repo defaultmuse pullfetches version tags from remotetests/test_cmd_version_tag_push.py— all greenMusehubVersionTagDB model,musehub_version_tagsservice,POST/GETwire endpointsPhase 4 —
muse describeintegration (VT_34–VT_40)muse describe [ref] [--pre] [--abbrev N] [--json]— clean pristine API, uses version tags only--long,--require-tag,--exact-match,--match,--refdescriptionfield (notname);distance: nullon no-tag;short_shais raw hex; exit 1 on no tagstests/test_cmd_describe_version_tags.py+ rewrote 4 legacy describe test filesDeveloper guide
Full idiomatic usage guide with real command output published as a mist: https://staging.musehub.ai/gabriel/mists/EjYGpUN3xgxy