gabriel / muse public
agent_map.py python
159 lines 5.3 KB
8d5137ed fix(security): full surface hardening — validation, path containment, p… Gabriel Cardona <cgcardona@gmail.com> 4d ago
1 """muse agent-map — show which agents have edited which bars of a MIDI track.
2
3 Walks the commit graph and annotates each bar of the composition with the
4 agent (commit author) that last touched it. The musical equivalent of
5 ``git blame`` at the bar level — essential in a multi-agent swarm to
6 understand who owns what section.
7
8 Usage::
9
10 muse agent-map tracks/melody.mid
11 muse agent-map tracks/bass.mid --depth 20
12 muse agent-map tracks/piano.mid --json
13
14 Output::
15
16 Agent map: tracks/melody.mid
17
18 Bar Last author Commit Message
19 ──────────────────────────────────────────────────────────────
20 1 agent-melody-composer cb4afaed feat: add intro melody
21 2 agent-melody-composer cb4afaed feat: add intro melody
22 3 agent-harmoniser 9f3a12e7 feat: harmonise verse
23 4 agent-harmoniser 9f3a12e7 feat: harmonise verse
24 5 agent-arranger 1b2c3d4e refactor: restructure bridge
25 ...
26 """
27
28 from __future__ import annotations
29
30 import json
31 import logging
32 import pathlib
33 from typing import TypedDict
34
35 import typer
36
37 from muse.core.errors import ExitCode
38 from muse.core.repo import require_repo
39 from muse.core.store import resolve_commit_ref
40 from muse.core.validation import sanitize_display
41 from muse.plugins.midi._query import (
42 NoteInfo,
43 load_track,
44 notes_by_bar,
45 walk_commits_for_track,
46 )
47
48 logger = logging.getLogger(__name__)
49 app = typer.Typer()
50
51
52 class BarAttribution(TypedDict):
53 """Attribution record for one bar."""
54
55 bar: int
56 author: str
57 commit_id: str
58 message: str
59
60
61 def _read_repo_id(root: pathlib.Path) -> str:
62 import json as _json
63
64 return str(_json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
65
66
67 def _read_branch(root: pathlib.Path) -> str:
68 return (root / ".muse" / "HEAD").read_text().strip().removeprefix("refs/heads/").strip()
69
70
71 def _bar_set(notes: list[NoteInfo]) -> frozenset[int]:
72 return frozenset(notes_by_bar(notes).keys())
73
74
75 @app.callback(invoke_without_command=True)
76 def agent_map(
77 ctx: typer.Context,
78 track: str = typer.Argument(..., metavar="TRACK", help="Workspace-relative path to a .mid file."),
79 ref: str | None = typer.Option(
80 None, "--commit", "-c", metavar="REF",
81 help="Start walking from this commit (default: HEAD).",
82 ),
83 depth: int = typer.Option(
84 50, "--depth", "-d", metavar="N",
85 help="Maximum commits to walk back (default 50).",
86 ),
87 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
88 ) -> None:
89 """Show which agent last edited each bar of a MIDI track.
90
91 ``muse agent-map`` walks the commit graph from HEAD (or ``--commit``)
92 backward and annotates each bar with the commit that introduced or last
93 modified it. When multiple agents work on different sections of a
94 composition, this shows the ownership map at a glance.
95
96 Git cannot do this: it has no model of bars or note-level changes.
97 Muse tracks note-level diffs at every commit, enabling per-bar blame.
98 """
99 if depth < 1 or depth > 10_000:
100 typer.echo(f"❌ --depth must be between 1 and 10,000 (got {depth}).", err=True)
101 raise typer.Exit(code=ExitCode.USER_ERROR)
102 root = require_repo()
103 repo_id = _read_repo_id(root)
104 branch = _read_branch(root)
105
106 start_ref = ref or "HEAD"
107 start_commit = resolve_commit_ref(root, repo_id, branch, start_ref)
108 if start_commit is None:
109 typer.echo(f"❌ Commit '{start_ref}' not found.", err=True)
110 raise typer.Exit(code=ExitCode.USER_ERROR)
111
112 history = walk_commits_for_track(root, start_commit.commit_id, track, max_commits=depth)
113
114 # For each bar, find the most recent commit that contains it
115 bar_attr: dict[int, BarAttribution] = {}
116 prev_bars: frozenset[int] = frozenset()
117
118 for commit, manifest in history:
119 if manifest is None or track not in manifest:
120 continue
121 result = load_track(root, commit.commit_id, track)
122 if result is None:
123 continue
124 notes, _tpb = result
125 cur_bars = _bar_set(notes)
126
127 # Bars that appear now but not in the previous (newer) snapshot
128 new_bars = cur_bars - prev_bars if prev_bars else cur_bars
129
130 for bar in new_bars:
131 if bar not in bar_attr:
132 bar_attr[bar] = BarAttribution(
133 bar=bar,
134 author=sanitize_display(commit.author or "unknown"),
135 commit_id=commit.commit_id[:8],
136 message=sanitize_display((commit.message or "").splitlines()[0][:60]),
137 )
138 prev_bars = cur_bars
139
140 if not bar_attr:
141 typer.echo(f" (no bar attribution data found for '{track}')")
142 return
143
144 attributions = sorted(bar_attr.values(), key=lambda a: a["bar"])
145
146 if as_json:
147 typer.echo(json.dumps(
148 {"track": track, "start_ref": start_ref, "attributions": list(attributions)},
149 indent=2,
150 ))
151 return
152
153 typer.echo(f"\nAgent map: {track}\n")
154 typer.echo(f" {'Bar':>4} {'Last author':<28} {'Commit':<10} Message")
155 typer.echo(" " + "─" * 76)
156 for attr in attributions:
157 typer.echo(
158 f" {attr['bar']:>4} {attr['author']:<28} {attr['commit_id']:<10} {attr['message']}"
159 )