gabriel / muse public
muse.plugin.zsh
1342 lines 49.4 KB
9ef121d1 feat: add Oh My ZSH plugin for Muse (all six phases) Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 # ==============================================================================
2 # muse.plugin.zsh — Oh My ZSH plugin for Muse version control
3 # ==============================================================================
4 #
5 # Domain-aware, agent-native ZSH integration for Muse: version control for
6 # multidimensional state. Music today, genomics/code/spacetime tomorrow.
7 #
8 # Sections
9 # §0 Configuration knobs (set in .zshrc before plugins=(… muse …))
10 # §1 Internal state ($MUSE_* env vars, $_MUSE_* private cache vars)
11 # §2 Core detection (zero-subprocess file reads for HEAD, domain, meta)
12 # §3 Cache management (warm/refresh/invalidate logic)
13 # §4 ZSH hooks (chpwd, preexec, precmd)
14 # §5 Prompt functions (muse_prompt_info, muse_rprompt_info, p10k segment)
15 # §6 Aliases (30+ m-prefixed shortcuts, domain shortcuts)
16 # §7 Workflow functions (muse-new-feat, muse-safe-merge, muse-quick-commit…)
17 # §8 Agent-native (muse-context, muse-agent-session, session replay…)
18 # §9 Visual tools (muse-graph, muse-timeline, fzf browsers…)
19 # §10 Powerlevel10k (prompt_muse_vcs, instant_prompt_muse_vcs)
20 # §11 Keybindings (Ctrl+B branch picker, ESC-M commit browser)
21 # §12 Hook system (user post-commit/checkout/merge callbacks)
22 # §13 Completion (registration of the _muse completion file)
23 # §14 Initialisation (warm the cache on plugin load)
24 #
25 # Quick start
26 # 1. Run tools/install-omzsh-plugin.sh from the muse repo root.
27 # 2. Add 'muse' to plugins=(...) in ~/.zshrc.
28 # 3. Reload: source ~/.zshrc
29 #
30 # Configuration (set these BEFORE sourcing / before plugins=() in .zshrc):
31 # MUSE_PROMPT_SHOW_DOMAIN=1 Include domain name in prompt (default 1)
32 # MUSE_PROMPT_SHOW_OPS=1 Include dirty-path count in prompt (default 1)
33 # MUSE_PROMPT_ICONS=1 Use emoji icons; 0 for plain ASCII (default 1)
34 # MUSE_AGENT_MODE=0 Machine-parseable mode for AI agents (default 0)
35 # MUSE_DIRTY_TIMEOUT=1 Seconds before dirty-check gives up (default 1)
36 # MUSE_SESSION_LOG_DIR Session log directory (default ~/.muse/sessions)
37 # MUSE_BIND_KEYS=1 Bind Ctrl+B / ESC-M shortcuts (default 1)
38 # MUSE_POST_COMMIT_CMD Shell cmd run after each muse commit
39 # MUSE_POST_CHECKOUT_CMD Shell cmd run after each muse checkout
40 # MUSE_POST_MERGE_CMD Shell cmd run after a clean muse merge
41 # ==============================================================================
42
43 # Require ZSH 5.0+ (associative arrays, autoload -Uz, EPOCHSECONDS)
44 autoload -Uz is-at-least
45 if ! is-at-least 5.0; then
46 print -P "%F{red}[muse] ZSH 5.0+ required (you have $ZSH_VERSION). Plugin not loaded.%f" >&2
47 return 1
48 fi
49
50 # Muse requires python3; so does this plugin (JSON/TOML parsing, one subprocess
51 # per prompt refresh — never on every keystroke).
52 if ! command -v python3 >/dev/null 2>&1; then
53 print -P "%F{red}[muse] python3 not found in PATH. Plugin not loaded.%f" >&2
54 return 1
55 fi
56
57 # Load datetime module for EPOCHREALTIME (millisecond session timestamps).
58 # zsh/datetime is standard since ZSH 4.3. Failure is silently ignored.
59 zmodload -i zsh/datetime 2>/dev/null
60
61 # ── §0 CONFIGURATION ─────────────────────────────────────────────────────────
62 # The := operator sets the variable only if it is unset or empty, so users can
63 # override any of these in their .zshrc before sourcing the plugin.
64
65 : ${MUSE_PROMPT_SHOW_DOMAIN:=1}
66 : ${MUSE_PROMPT_SHOW_OPS:=1}
67 : ${MUSE_PROMPT_ICONS:=1}
68 : ${MUSE_AGENT_MODE:=0}
69 : ${MUSE_DIRTY_TIMEOUT:=1}
70 : ${MUSE_SESSION_LOG_DIR:="$HOME/.muse/sessions"}
71 : ${MUSE_BIND_KEYS:=1}
72 : ${MUSE_POST_COMMIT_CMD:=}
73 : ${MUSE_POST_CHECKOUT_CMD:=}
74 : ${MUSE_POST_MERGE_CMD:=}
75
76 # Domain icon map — reassign individual elements before sourcing to override.
77 typeset -gA MUSE_DOMAIN_ICONS
78 MUSE_DOMAIN_ICONS=(
79 midi "♪"
80 code "⌥"
81 bitcoin "₿"
82 scaffold "⬡"
83 genomics "🧬"
84 spatial "◉"
85 _default "◈"
86 )
87
88 # Domain prompt-colour map (ZSH %F{…} codes).
89 typeset -gA MUSE_DOMAIN_COLORS
90 MUSE_DOMAIN_COLORS=(
91 midi "%F{magenta}"
92 code "%F{cyan}"
93 bitcoin "%F{yellow}"
94 scaffold "%F{blue}"
95 genomics "%F{green}"
96 spatial "%F{white}"
97 _default "%F{white}"
98 )
99
100 # ── §1 INTERNAL STATE ────────────────────────────────────────────────────────
101 # Exported vars ($MUSE_*) are visible to subprocesses — agents read these.
102 # Private vars ($_MUSE_*) control plugin-internal caching behaviour.
103
104 typeset -g MUSE_REPO_ROOT="" # absolute path to repo containing .muse/
105 typeset -g MUSE_DOMAIN="midi" # active domain plugin name
106 typeset -g MUSE_BRANCH="" # current branch, or 8-char SHA if detached
107 typeset -gi MUSE_DETACHED=0 # 1 when HEAD is a detached SHA
108 typeset -gi MUSE_DIRTY=0 # 1 when working tree has uncommitted changes
109 typeset -gi MUSE_DIRTY_COUNT=0 # number of changed paths
110 typeset -g MUSE_DIRTY_STATE="" # "clean" | "dirty" | "?" (timeout)
111 typeset -gi MUSE_MERGING=0 # 1 when MERGE_STATE.json exists
112 typeset -g MUSE_MERGE_BRANCH="" # branch being merged in
113 typeset -gi MUSE_CONFLICT_COUNT=0 # paths with unresolved conflicts
114 typeset -g MUSE_USER_TYPE="human" # "human" | "agent" (from config.toml)
115 typeset -g MUSE_LAST_SEMVER="" # sem_ver_bump of the HEAD commit
116
117 typeset -gi _MUSE_CACHE_VALID=0 # 0 = cache needs refresh
118 typeset -gi _MUSE_CMD_RAN=0 # 1 after any muse command is run
119 typeset -gF _MUSE_CMD_START=0.0 # EPOCHREALTIME when last muse cmd started
120 typeset -g _MUSE_LAST_CMD="" # full text of the last muse command
121
122 # Always exported — the machine-readable repo snapshot for AI agents.
123 export MUSE_CONTEXT_JSON=""
124
125 # Agent session identity — set by muse-agent-session, cleared by muse-agent-end.
126 export MUSE_SESSION_MODEL_ID=""
127 export MUSE_SESSION_AGENT_ID=""
128 export MUSE_SESSION_START=""
129 export MUSE_SESSION_LOG_FILE=""
130
131 # ── §2 CORE DETECTION ────────────────────────────────────────────────────────
132
133 # Walk up from $PWD to find the .muse/ directory. Sets MUSE_REPO_ROOT.
134 # Pure ZSH — zero subprocesses. Returns 1 if not inside a muse repo.
135 function _muse_find_root() {
136 local dir="$PWD"
137 while [[ "$dir" != "/" ]]; do
138 if [[ -d "$dir/.muse" ]]; then
139 MUSE_REPO_ROOT="$dir"
140 return 0
141 fi
142 dir="${dir:h}"
143 done
144 MUSE_REPO_ROOT=""
145 return 1
146 }
147
148 # Read branch from .muse/HEAD without forking.
149 # Sets MUSE_BRANCH (branch name or 8-char SHA) and MUSE_DETACHED.
150 function _muse_parse_head() {
151 local head_file="$MUSE_REPO_ROOT/.muse/HEAD"
152 if [[ ! -f "$head_file" ]]; then
153 MUSE_BRANCH=""; MUSE_DETACHED=0; return 1
154 fi
155 local raw
156 raw=$(<"$head_file")
157 if [[ "$raw" == "ref: refs/heads/"* ]]; then
158 MUSE_BRANCH="${raw#ref: refs/heads/}"
159 MUSE_DETACHED=0
160 else
161 # Detached HEAD — show short SHA
162 MUSE_BRANCH="${raw:0:8}"
163 MUSE_DETACHED=1
164 fi
165 }
166
167 # Read domain, user type, and merge state in one python3 invocation.
168 # Uses an environment variable to pass the repo root safely (handles spaces).
169 # Sets MUSE_DOMAIN, MUSE_USER_TYPE, MUSE_MERGING, MUSE_MERGE_BRANCH,
170 # MUSE_CONFLICT_COUNT.
171 function _muse_parse_meta() {
172 local output
173 output=$(MUSE_META_ROOT="$MUSE_REPO_ROOT" python3 <<'PYEOF' 2>/dev/null
174 import json, os, re, sys
175
176 root = os.environ.get('MUSE_META_ROOT', '')
177
178 # ── domain from repo.json ────────────────────────────────────────────────────
179 domain = 'midi'
180 try:
181 rj = json.load(open(os.path.join(root, '.muse', 'repo.json')))
182 domain = rj.get('domain', 'midi')
183 except Exception:
184 pass
185
186 # ── user.type from config.toml (stdlib tomllib, Python ≥ 3.11) ──────────────
187 user_type = 'human'
188 cfg_path = os.path.join(root, '.muse', 'config.toml')
189 if os.path.exists(cfg_path):
190 try:
191 import tomllib
192 with open(cfg_path, 'rb') as f:
193 cfg = tomllib.load(f)
194 user_type = cfg.get('user', {}).get('type', 'human')
195 except ImportError:
196 # Regex fallback for Python < 3.11 (edge case — Muse requires 3.14)
197 m = re.search(r'type\s*=\s*"(\w+)"', open(cfg_path).read())
198 if m:
199 user_type = m.group(1)
200 except Exception:
201 pass
202
203 # ── merge state from MERGE_STATE.json ────────────────────────────────────────
204 merging, merge_branch, conflict_count = 0, '', 0
205 merge_path = os.path.join(root, '.muse', 'MERGE_STATE.json')
206 if os.path.exists(merge_path):
207 try:
208 ms = json.load(open(merge_path))
209 merging = 1
210 merge_branch = ms.get('other_branch') or ms.get('theirs_commit', '')[:8]
211 conflict_count = len(ms.get('conflict_paths', []))
212 except Exception:
213 merging = 1 # file exists but unreadable — still in a merge
214
215 # Unit separator (0x1F) avoids clashes with any reasonable field value.
216 sep = '\x1f'
217 print(f'{domain}{sep}{user_type}{sep}{merging}{sep}{merge_branch}{sep}{conflict_count}')
218 PYEOF
219 )
220 if [[ -z "$output" ]]; then
221 MUSE_DOMAIN="midi"; MUSE_USER_TYPE="human"
222 MUSE_MERGING=0; MUSE_MERGE_BRANCH=""; MUSE_CONFLICT_COUNT=0
223 return 1
224 fi
225 IFS=$'\x1f' read -r MUSE_DOMAIN MUSE_USER_TYPE MUSE_MERGING \
226 MUSE_MERGE_BRANCH MUSE_CONFLICT_COUNT <<< "$output"
227 : ${MUSE_DOMAIN:=midi}
228 : ${MUSE_USER_TYPE:=human}
229 : ${MUSE_MERGING:=0}
230 : ${MUSE_CONFLICT_COUNT:=0}
231 }
232
233 # Run muse status --porcelain with a timeout. Counts changed paths.
234 # Sets MUSE_DIRTY, MUSE_DIRTY_COUNT, MUSE_DIRTY_STATE.
235 function _muse_check_dirty() {
236 local output rc count=0
237 output=$(cd "$MUSE_REPO_ROOT" && \
238 timeout "${MUSE_DIRTY_TIMEOUT}" muse status --porcelain 2>/dev/null)
239 rc=$?
240 if (( rc == 124 )); then
241 # timeout — show "?" in prompt rather than hanging
242 MUSE_DIRTY=0; MUSE_DIRTY_COUNT=0; MUSE_DIRTY_STATE="?"
243 return
244 fi
245 while IFS= read -r line; do
246 [[ "$line" == "##"* || -z "$line" ]] && continue
247 (( count++ ))
248 done <<< "$output"
249 if (( count > 0 )); then
250 MUSE_DIRTY=1; MUSE_DIRTY_COUNT=$count; MUSE_DIRTY_STATE="dirty"
251 else
252 MUSE_DIRTY=0; MUSE_DIRTY_COUNT=0; MUSE_DIRTY_STATE="clean"
253 fi
254 }
255
256 # Read sem_ver_bump from the HEAD commit record without forking.
257 # Sets MUSE_LAST_SEMVER ("major" | "minor" | "patch" | "").
258 function _muse_parse_semver() {
259 MUSE_LAST_SEMVER=""
260 [[ -z "$MUSE_BRANCH" || $MUSE_DETACHED -eq 1 ]] && return
261 local branch_file="$MUSE_REPO_ROOT/.muse/refs/heads/$MUSE_BRANCH"
262 [[ ! -f "$branch_file" ]] && return
263 local commit_id
264 commit_id=$(<"$branch_file")
265 local commit_file="$MUSE_REPO_ROOT/.muse/commits/${commit_id}.json"
266 [[ ! -f "$commit_file" ]] && return
267 MUSE_LAST_SEMVER=$(MUSE_META_CFILE="$commit_file" python3 <<'PYEOF' 2>/dev/null
268 import json, os
269 try:
270 d = json.load(open(os.environ['MUSE_META_CFILE']))
271 v = d.get('sem_ver_bump', 'none')
272 print('' if v == 'none' else v)
273 except Exception:
274 print('')
275 PYEOF
276 )
277 }
278
279 # Build the compact $MUSE_CONTEXT_JSON env var for agent consumption.
280 # Runs python3 to ensure correct JSON encoding of all values.
281 function _muse_build_context_json() {
282 MUSE_CONTEXT_JSON=$(python3 -c "
283 import json
284 ctx = {
285 'schema_version': 1,
286 'domain': '${MUSE_DOMAIN}',
287 'branch': '${MUSE_BRANCH}',
288 'repo_root': '${MUSE_REPO_ROOT//\'/\\'\\'}',
289 'dirty': bool(${MUSE_DIRTY:-0}),
290 'dirty_count': ${MUSE_DIRTY_COUNT:-0},
291 'merging': bool(${MUSE_MERGING:-0}),
292 'merge_branch': '${MUSE_MERGE_BRANCH}' or None,
293 'conflict_count': ${MUSE_CONFLICT_COUNT:-0},
294 'user_type': '${MUSE_USER_TYPE:-human}',
295 'agent_id': '${MUSE_SESSION_AGENT_ID}' or None,
296 'model_id': '${MUSE_SESSION_MODEL_ID}' or None,
297 'semver': '${MUSE_LAST_SEMVER}' or None,
298 }
299 print(json.dumps(ctx, separators=(',', ':')))
300 " 2>/dev/null)
301 export MUSE_CONTEXT_JSON
302 }
303
304 # ── §3 CACHE MANAGEMENT ──────────────────────────────────────────────────────
305
306 # Full refresh — includes dirty check. Called after muse commands and on cold
307 # cache entry. Clears all MUSE_* vars on non-repo directories.
308 function _muse_refresh() {
309 if ! _muse_find_root; then
310 MUSE_DOMAIN=""; MUSE_BRANCH=""
311 MUSE_DIRTY=0; MUSE_DIRTY_COUNT=0; MUSE_DIRTY_STATE=""
312 MUSE_MERGING=0; MUSE_MERGE_BRANCH=""; MUSE_CONFLICT_COUNT=0
313 export MUSE_CONTEXT_JSON=""
314 _MUSE_CACHE_VALID=1
315 _MUSE_CMD_RAN=0
316 return 1
317 fi
318 _muse_parse_head
319 _muse_parse_meta
320 _muse_check_dirty
321 _muse_parse_semver
322 _muse_build_context_json
323 _MUSE_CACHE_VALID=1
324 _MUSE_CMD_RAN=0
325 }
326
327 # Fast refresh — skips dirty check. Used on directory change and for p10k
328 # instant prompt where blocking the shell for a second is unacceptable.
329 function _muse_refresh_fast() {
330 if ! _muse_find_root; then
331 MUSE_DOMAIN=""; MUSE_BRANCH=""; export MUSE_CONTEXT_JSON=""
332 _MUSE_CACHE_VALID=1
333 return 1
334 fi
335 _muse_parse_head
336 _muse_parse_meta
337 _muse_build_context_json
338 _MUSE_CACHE_VALID=1
339 }
340
341 # ── §4 ZSH HOOKS ─────────────────────────────────────────────────────────────
342
343 # Invalidate the entire cache when changing directories. The fast refresh runs
344 # immediately so the next prompt shows the new repo's state without a delay.
345 function _muse_hook_chpwd() {
346 _MUSE_CACHE_VALID=0
347 _MUSE_CMD_RAN=0
348 MUSE_REPO_ROOT=""; MUSE_BRANCH=""
349 MUSE_DIRTY=0; MUSE_DIRTY_COUNT=0; MUSE_DIRTY_STATE=""
350 MUSE_MERGING=0; MUSE_MERGE_BRANCH=""; MUSE_CONFLICT_COUNT=0
351 export MUSE_CONTEXT_JSON=""
352 _muse_refresh_fast 2>/dev/null
353 }
354 chpwd_functions+=(_muse_hook_chpwd)
355
356 # Track when a muse (or aliased) command is about to run. Records timing for
357 # session logs and sets the refresh flag so the next prompt reflects changes.
358 function _muse_hook_preexec() {
359 local first_word="${${(z)1}[1]}"
360 # Match the raw muse binary and all m-prefixed aliases that wrap muse.
361 if [[ "$first_word" == "muse" || "$first_word" == m[a-z]* ]]; then
362 _MUSE_CMD_RAN=1
363 _MUSE_CMD_START=${EPOCHREALTIME:-0}
364 _MUSE_LAST_CMD="$1"
365 _muse_session_log_start "$1"
366 fi
367 }
368 preexec_functions+=(_muse_hook_preexec)
369
370 # Before each prompt: flush the session log entry, refresh the cache when
371 # needed, and emit $MUSE_CONTEXT_JSON to stderr in agent mode.
372 function _muse_hook_precmd() {
373 if (( _MUSE_CMD_START > 0 )); then
374 local epoch_now=${EPOCHREALTIME:-0}
375 local elapsed_ms=$(( int(($epoch_now - _MUSE_CMD_START) * 1000) ))
376 _muse_session_log_end $? $elapsed_ms
377 _MUSE_CMD_START=0.0
378 _muse_run_post_hooks
379 fi
380
381 if (( _MUSE_CACHE_VALID == 0 )); then
382 _muse_refresh 2>/dev/null
383 elif (( _MUSE_CMD_RAN )); then
384 _muse_refresh 2>/dev/null
385 fi
386
387 # In agent mode, always broadcast state to stderr for orchestrating processes.
388 if [[ "$MUSE_AGENT_MODE" == "1" && -n "$MUSE_REPO_ROOT" ]]; then
389 print -r -- "$MUSE_CONTEXT_JSON" >&2
390 fi
391 }
392 precmd_functions+=(_muse_hook_precmd)
393
394 # ── §5 PROMPT FUNCTIONS ──────────────────────────────────────────────────────
395
396 # Primary left-prompt segment. Add $(muse_prompt_info) to your $PROMPT, or
397 # set POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=(… muse_vcs …) for p10k.
398 # Emits nothing when not in a muse repo.
399 function muse_prompt_info() {
400 [[ -z "$MUSE_REPO_ROOT" ]] && return
401
402 if [[ "$MUSE_AGENT_MODE" == "1" ]]; then
403 _muse_prompt_machine
404 return
405 fi
406
407 local domain="${MUSE_DOMAIN:-?}"
408 local icon color
409 icon="${MUSE_DOMAIN_ICONS[$domain]:-${MUSE_DOMAIN_ICONS[_default]}}"
410 color="${MUSE_DOMAIN_COLORS[$domain]:-${MUSE_DOMAIN_COLORS[_default]}}"
411 [[ "$MUSE_PROMPT_ICONS" == "0" ]] && icon="[${domain}]"
412
413 local branch_display="$MUSE_BRANCH"
414 (( MUSE_DETACHED )) && branch_display="(detached:${MUSE_BRANCH})"
415
416 # ── Dirty indicator ────────────────────────────────────────────────────────
417 local dirty=""
418 case "$MUSE_DIRTY_STATE" in
419 "?")
420 dirty=" %F{yellow}?%f"
421 ;;
422 dirty)
423 if [[ "$MUSE_PROMPT_SHOW_OPS" == "1" && $MUSE_DIRTY_COUNT -gt 0 ]]; then
424 dirty=" %F{red}✗%f %F{white}${MUSE_DIRTY_COUNT}Δ%f"
425 else
426 dirty=" %F{red}✗%f"
427 fi
428 ;;
429 clean)
430 dirty=" %F{green}✓%f"
431 ;;
432 esac
433
434 # ── Merge indicator ────────────────────────────────────────────────────────
435 local merge=""
436 if (( MUSE_MERGING )); then
437 local cfl=""
438 if (( MUSE_CONFLICT_COUNT == 1 )); then
439 cfl=" (1 conflict)"
440 elif (( MUSE_CONFLICT_COUNT > 1 )); then
441 cfl=" (${MUSE_CONFLICT_COUNT} conflicts)"
442 fi
443 merge=" %F{yellow}⚡ ←%f %F{magenta}${MUSE_MERGE_BRANCH}${cfl}%f"
444 fi
445
446 # ── Agent badge ───────────────────────────────────────────────────────────
447 local agent=""
448 if [[ -n "$MUSE_SESSION_MODEL_ID" ]]; then
449 agent=" %F{blue}[🤖 ${MUSE_SESSION_MODEL_ID}]%f"
450 elif [[ "$MUSE_USER_TYPE" == "agent" ]]; then
451 agent=" %F{blue}[agent]%f"
452 fi
453
454 # ── Domain label (optional) ───────────────────────────────────────────────
455 local domain_label=""
456 [[ "$MUSE_PROMPT_SHOW_DOMAIN" == "1" ]] && domain_label="${domain}:"
457
458 echo -n "${color}${icon} ${domain_label}${branch_display}%f${dirty}${merge}${agent}"
459 }
460
461 # Right-prompt segment: shows the SemVer bump of the HEAD commit.
462 # Add $(muse_rprompt_info) to $RPROMPT or POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS.
463 function muse_rprompt_info() {
464 [[ -z "$MUSE_REPO_ROOT" || -z "$MUSE_LAST_SEMVER" ]] && return
465 [[ "$MUSE_AGENT_MODE" == "1" ]] && return
466 local color="%F{green}"
467 case "$MUSE_LAST_SEMVER" in
468 major) color="%F{red}" ;;
469 minor) color="%F{yellow}" ;;
470 esac
471 echo -n "${color}[${MUSE_LAST_SEMVER:u}]%f"
472 }
473
474 # Machine-readable prompt for MUSE_AGENT_MODE=1.
475 # Format: [domain|branch|clean/dirty:N|no-merge/merging:branch:N|agent:id]
476 function _muse_prompt_machine() {
477 local dirty_part="clean"
478 (( MUSE_DIRTY )) && dirty_part="dirty:${MUSE_DIRTY_COUNT}"
479 [[ "$MUSE_DIRTY_STATE" == "?" ]] && dirty_part="unknown"
480
481 local merge_part="no-merge"
482 (( MUSE_MERGING )) && \
483 merge_part="merging:${MUSE_MERGE_BRANCH}:${MUSE_CONFLICT_COUNT}"
484
485 local agent_part=""
486 [[ -n "$MUSE_SESSION_AGENT_ID" ]] && agent_part="|agent:${MUSE_SESSION_AGENT_ID}"
487
488 echo -n "[${MUSE_DOMAIN}|${MUSE_BRANCH}|${dirty_part}|${merge_part}${agent_part}]"
489 }
490
491 # ── §6 ALIASES ───────────────────────────────────────────────────────────────
492
493 # Core VCS
494 alias mst='muse status'
495 alias msts='muse status --short'
496 alias mstp='muse status --porcelain'
497 alias mstb='muse status --branch'
498 alias mcm='muse commit -m'
499 alias mco='muse checkout'
500 alias mlg='muse log'
501 alias mlgo='muse log --oneline'
502 alias mlgg='muse log --graph'
503 alias mlggs='muse log --graph --oneline'
504 alias mdf='muse diff'
505 alias mdfst='muse diff --stat'
506 alias mdfp='muse diff --patch'
507 alias mbr='muse branch'
508 alias mbrv='muse branch -v'
509 alias msh='muse show'
510 alias mbl='muse blame'
511 alias mrl='muse reflog'
512 alias mbs='muse bisect'
513
514 # Stash
515 alias msta='muse stash'
516 alias mstap='muse stash pop'
517 alias mstal='muse stash list'
518 alias mstad='muse stash drop'
519
520 # Tags
521 alias mtg='muse tag'
522
523 # Remote / networking
524 alias mfh='muse fetch'
525 alias mpull='muse pull' # mpull not mpl — avoids collision with muse plumbing
526 alias mpush='muse push'
527 alias mrm='muse remote'
528 alias mclone='muse clone'
529
530 # Worktree / workspace
531 alias mwt='muse worktree'
532 alias mwsp='muse workspace'
533
534 # Domain shortcuts
535 alias mmidi='muse midi'
536 alias mcode='muse code'
537 alias mcoord='muse coord'
538 alias mplumb='muse plumbing'
539
540 # Config / hub
541 alias mcfg='muse config'
542 alias mcfgs='muse config show'
543 alias mhub='muse hub'
544
545 # Misc porcelain
546 alias mchp='muse cherry-pick'
547 alias mrst='muse reset'
548 alias mrvt='muse revert'
549 alias mgc='muse gc'
550 alias mcheck='muse check'
551 alias mdoms='muse domains'
552 alias mannot='muse annotate'
553 alias mattr='muse attributes'
554
555 # ── §7 WORKFLOW FUNCTIONS ────────────────────────────────────────────────────
556
557 # Create and switch to feat/<name>.
558 function muse-new-feat() {
559 local name="${1:?Usage: muse-new-feat <name>}"
560 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
561 muse branch "feat/${name}" && muse checkout "feat/${name}"
562 }
563
564 # Create and switch to fix/<name>.
565 function muse-new-fix() {
566 local name="${1:?Usage: muse-new-fix <name>}"
567 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
568 muse branch "fix/${name}" && muse checkout "fix/${name}"
569 }
570
571 # Create and switch to refactor/<name>.
572 function muse-new-refactor() {
573 local name="${1:?Usage: muse-new-refactor <name>}"
574 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
575 muse branch "refactor/${name}" && muse checkout "refactor/${name}"
576 }
577
578 # Commit with an auto-timestamped WIP message.
579 function muse-wip() {
580 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
581 muse commit -m "[WIP] $(date -u +%Y-%m-%dT%H:%M:%SZ)"
582 }
583
584 # Fetch + pull + status in one go.
585 function muse-sync() {
586 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
587 muse fetch && muse pull && muse status
588 }
589
590 # Merge with structured conflict reporting and optional editor launch.
591 function muse-safe-merge() {
592 local branch="${1:?Usage: muse-safe-merge <branch>}"
593 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
594
595 muse merge "$branch"
596 local rc=$?
597
598 if (( rc != 0 )) && [[ -f "$MUSE_REPO_ROOT/.muse/MERGE_STATE.json" ]]; then
599 echo ""
600 echo "Conflicts detected:"
601 MUSE_META_ROOT="$MUSE_REPO_ROOT" python3 <<'PYEOF' 2>/dev/null
602 import json, os
603 root = os.environ['MUSE_META_ROOT']
604 d = json.load(open(os.path.join(root, '.muse', 'MERGE_STATE.json')))
605 other = d.get('other_branch', 'unknown')
606 for p in d.get('conflict_paths', []):
607 print(f' ✗ {p}')
608 print(f"\nResolve conflicts, then: muse commit -m \"Merge {other}\"")
609 PYEOF
610 if [[ -n "${EDITOR:-}" ]]; then
611 local conflict_paths
612 conflict_paths=$(MUSE_META_ROOT="$MUSE_REPO_ROOT" python3 <<'PYEOF' 2>/dev/null
613 import json, os
614 root = os.environ['MUSE_META_ROOT']
615 d = json.load(open(os.path.join(root, '.muse', 'MERGE_STATE.json')))
616 for p in d.get('conflict_paths', []):
617 print(p)
618 PYEOF
619 )
620 if [[ -n "$conflict_paths" ]]; then
621 echo ""
622 echo "Opening conflicts in $EDITOR..."
623 local paths_array=("${(f)conflict_paths}")
624 "$EDITOR" "${paths_array[@]}"
625 fi
626 fi
627 fi
628 return $rc
629 }
630
631 # Interactive guided commit with domain-aware metadata prompts.
632 function muse-quick-commit() {
633 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
634
635 local message
636 print -n "Commit message: "
637 read -r message
638 [[ -z "$message" ]] && { echo "Aborted." >&2; return 1; }
639
640 local -a meta_args
641 case "$MUSE_DOMAIN" in
642 midi)
643 local section track emotion
644 print -n "Section (verse/chorus/bridge, blank to skip): "; read -r section
645 [[ -n "$section" ]] && meta_args+=("--meta" "section=$section")
646 print -n "Track name (blank to skip): "; read -r track
647 [[ -n "$track" ]] && meta_args+=("--meta" "track=$track")
648 print -n "Emotion (blank to skip): "; read -r emotion
649 [[ -n "$emotion" ]] && meta_args+=("--meta" "emotion=$emotion")
650 ;;
651 code)
652 local module breaking
653 print -n "Module/package (blank to skip): "; read -r module
654 [[ -n "$module" ]] && meta_args+=("--meta" "module=$module")
655 print -n "Breaking change? (y/N): "; read -r breaking
656 [[ "$breaking" == [Yy]* ]] && meta_args+=("--meta" "breaking=true")
657 ;;
658 esac
659
660 [[ -n "$MUSE_SESSION_AGENT_ID" ]] && meta_args+=("--agent-id" "$MUSE_SESSION_AGENT_ID")
661 [[ -n "$MUSE_SESSION_MODEL_ID" ]] && meta_args+=("--model-id" "$MUSE_SESSION_MODEL_ID")
662
663 muse commit -m "$message" "${meta_args[@]}"
664 }
665
666 # Repo health summary: dirty state, merge, stashes, remotes.
667 function muse-health() {
668 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
669 local icon="${MUSE_DOMAIN_ICONS[$MUSE_DOMAIN]:-◈}"
670 echo ""
671 echo " ${icon} MUSE REPO HEALTH — ${MUSE_DOMAIN}:${MUSE_BRANCH}"
672 echo " ──────────────────────────────────────────────"
673
674 if (( MUSE_DIRTY )); then
675 echo " Working tree ✗ ${MUSE_DIRTY_COUNT} changed path(s)"
676 elif [[ "$MUSE_DIRTY_STATE" == "?" ]]; then
677 echo " Working tree ? (check timed out)"
678 else
679 echo " Working tree ✓ clean"
680 fi
681
682 if (( MUSE_MERGING )); then
683 echo " Merge state ⚡ in progress ← ${MUSE_MERGE_BRANCH} (${MUSE_CONFLICT_COUNT} conflict(s))"
684 else
685 echo " Merge state ✓ none"
686 fi
687
688 local stash_count=0
689 stash_count=$(cd "$MUSE_REPO_ROOT" && muse stash list 2>/dev/null | wc -l | tr -d ' ')
690 if (( stash_count > 0 )); then
691 echo " Stashes ⚠ ${stash_count} pending"
692 else
693 echo " Stashes ✓ none"
694 fi
695
696 local -a remotes=()
697 [[ -d "$MUSE_REPO_ROOT/.muse/remotes" ]] && \
698 remotes=($(ls "$MUSE_REPO_ROOT/.muse/remotes/" 2>/dev/null))
699 if (( ${#remotes[@]} > 0 )); then
700 echo " Remotes ✓ ${remotes[*]}"
701 else
702 echo " Remotes — none configured"
703 fi
704
705 echo " Domain ✓ ${MUSE_DOMAIN}"
706 echo " User type ✓ ${MUSE_USER_TYPE}"
707 [[ -n "$MUSE_SESSION_MODEL_ID" ]] && \
708 echo " Agent session ✓ ${MUSE_SESSION_MODEL_ID} / ${MUSE_SESSION_AGENT_ID}"
709 echo ""
710 }
711
712 # Show authorship provenance of the HEAD commit (human vs agent).
713 function muse-who-last() {
714 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
715 [[ -z "$MUSE_BRANCH" || $MUSE_DETACHED -eq 1 ]] && \
716 { echo "No named branch — detached HEAD." >&2; return 1; }
717
718 local branch_file="$MUSE_REPO_ROOT/.muse/refs/heads/$MUSE_BRANCH"
719 [[ ! -f "$branch_file" ]] && { echo "Branch ref not found." >&2; return 1; }
720
721 local commit_id
722 commit_id=$(<"$branch_file")
723 local commit_file="$MUSE_REPO_ROOT/.muse/commits/${commit_id}.json"
724 [[ ! -f "$commit_file" ]] && { echo "Commit record not found." >&2; return 1; }
725
726 MUSE_META_CFILE="$commit_file" python3 <<'PYEOF' 2>/dev/null
727 import json, os
728 d = json.load(open(os.environ['MUSE_META_CFILE']))
729 sha = d.get('commit_id', '')[:8]
730 author = d.get('author', 'unknown')
731 agent_id = d.get('agent_id', '')
732 model_id = d.get('model_id', '')
733 msg = d.get('message', '')[:72]
734 dt = d.get('committed_at', '')[:10]
735 semver = d.get('sem_ver_bump', 'none')
736 breaking = d.get('breaking_changes', [])
737
738 print(f' Last commit: {sha} ({dt})')
739 print(f' Message: "{msg}"')
740 if agent_id or model_id:
741 print(f' Author: {author} [AGENT]')
742 if model_id: print(f' Model: {model_id}')
743 if agent_id: print(f' Agent ID: {agent_id}')
744 else:
745 print(f' Author: {author} [human]')
746 if semver and semver != 'none':
747 print(f' SemVer: {semver.upper()}')
748 if breaking:
749 for b in breaking:
750 print(f' Breaking: {b}')
751 PYEOF
752 }
753
754 # Scan the last N commits and show who made them (human vs agent + model).
755 function muse-agent-blame() {
756 local n="${1:-10}"
757 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
758 [[ -z "$MUSE_BRANCH" ]] && { echo "No branch." >&2; return 1; }
759
760 MUSE_META_ROOT="$MUSE_REPO_ROOT" MUSE_META_BRANCH="$MUSE_BRANCH" \
761 MUSE_META_N="$n" python3 <<'PYEOF' 2>/dev/null
762 import json, os, sys
763
764 root = os.environ['MUSE_META_ROOT']
765 branch = os.environ['MUSE_META_BRANCH']
766 n = int(os.environ['MUSE_META_N'])
767
768 branch_file = os.path.join(root, '.muse', 'refs', 'heads', branch)
769 if not os.path.exists(branch_file):
770 print("Branch ref not found.", file=sys.stderr)
771 sys.exit(1)
772
773 commit_id = open(branch_file).read().strip()
774 seen, count = set(), 0
775
776 print(f"\n Agent provenance — last {n} commits on {branch}")
777 print(f" {'SHA':8} {'Date':10} {'Type':6} {'Author/Model':30} Message")
778 print(f" {'─'*8} {'─'*10} {'─'*6} {'─'*30} {'─'*30}")
779
780 while commit_id and count < n:
781 if commit_id in seen:
782 break
783 seen.add(commit_id)
784 cfile = os.path.join(root, '.muse', 'commits', f'{commit_id}.json')
785 if not os.path.exists(cfile):
786 break
787 d = json.load(open(cfile))
788 sha = commit_id[:8]
789 date = d.get('committed_at', '')[:10]
790 author = d.get('author', '?')
791 agent_id = d.get('agent_id', '')
792 model_id = d.get('model_id', '')
793 msg = d.get('message', '')[:30]
794 kind = 'agent' if (agent_id or model_id) else 'human'
795 who = (model_id or agent_id or author)[:30]
796 print(f" {sha} {date} {kind:6} {who:30} {msg}")
797 commit_id = d.get('parent_commit_id') or ''
798 count += 1
799 print()
800 PYEOF
801 }
802
803 # ── §8 AGENT-NATIVE FUNCTIONS ────────────────────────────────────────────────
804
805 # Output a compact, token-efficient repo context block for AI agent consumption.
806 # Flags: --json structured JSON | --toml TOML | --oneline single line
807 function muse-context() {
808 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
809
810 local fmt="human" oneline=0
811 for arg in "$@"; do
812 case "$arg" in
813 --json) fmt="json" ;;
814 --toml) fmt="toml" ;;
815 --oneline) oneline=1 ;;
816 esac
817 done
818
819 # Gather HEAD commit info via a single python3 call.
820 local commit_id="" commit_msg="" commit_date="" commit_author=""
821 local commit_agent_id="" commit_model_id="" commit_semver=""
822 if [[ -n "$MUSE_BRANCH" && $MUSE_DETACHED -eq 0 ]]; then
823 local branch_file="$MUSE_REPO_ROOT/.muse/refs/heads/$MUSE_BRANCH"
824 if [[ -f "$branch_file" ]]; then
825 commit_id=$(<"$branch_file")
826 local cfile="$MUSE_REPO_ROOT/.muse/commits/${commit_id}.json"
827 if [[ -f "$cfile" ]]; then
828 local raw_info
829 raw_info=$(MUSE_META_CFILE="$cfile" python3 <<'PYEOF' 2>/dev/null
830 import json, os
831 sep = '\x1f'
832 try:
833 d = json.load(open(os.environ['MUSE_META_CFILE']))
834 fields = [
835 d.get('message', '')[:60].replace('\n', ' '),
836 d.get('committed_at', '')[:10],
837 d.get('author', ''),
838 d.get('agent_id', ''),
839 d.get('model_id', ''),
840 d.get('sem_ver_bump', 'none'),
841 ]
842 print(sep.join(fields))
843 except Exception:
844 print(sep * 5)
845 PYEOF
846 )
847 IFS=$'\x1f' read -r commit_msg commit_date commit_author \
848 commit_agent_id commit_model_id commit_semver \
849 <<< "$raw_info"
850 fi
851 fi
852 fi
853
854 local stash_count=0
855 stash_count=$(cd "$MUSE_REPO_ROOT" && muse stash list 2>/dev/null | wc -l | tr -d ' ')
856
857 local -a remotes=()
858 [[ -d "$MUSE_REPO_ROOT/.muse/remotes" ]] && \
859 remotes=($(ls "$MUSE_REPO_ROOT/.muse/remotes/" 2>/dev/null))
860
861 # ── Output modes ────────────────────────────────────────────────────────────
862 if [[ $oneline -eq 1 ]]; then
863 local dirty_label="clean"
864 (( MUSE_DIRTY )) && dirty_label="dirty:${MUSE_DIRTY_COUNT}"
865 echo "${MUSE_DOMAIN}:${MUSE_BRANCH} ${dirty_label} commit:${commit_id:0:8}"
866 return
867 fi
868
869 if [[ "$fmt" == "json" ]]; then
870 echo "$MUSE_CONTEXT_JSON" | python3 -c \
871 "import json,sys; print(json.dumps(json.load(sys.stdin), indent=2))" 2>/dev/null
872 return
873 fi
874
875 if [[ "$fmt" == "toml" ]]; then
876 echo "[repo]"
877 echo "domain = \"$MUSE_DOMAIN\""
878 echo "branch = \"$MUSE_BRANCH\""
879 echo "dirty = $(( MUSE_DIRTY ))"
880 echo "dirty_count = $MUSE_DIRTY_COUNT"
881 echo "merging = $(( MUSE_MERGING ))"
882 [[ -n "$MUSE_MERGE_BRANCH" ]] && echo "merge_branch = \"$MUSE_MERGE_BRANCH\""
883 echo "conflict_count = $MUSE_CONFLICT_COUNT"
884 echo ""
885 echo "[commit]"
886 echo "id = \"${commit_id:0:8}\""
887 echo "message = \"$commit_msg\""
888 echo "date = \"$commit_date\""
889 echo "author = \"$commit_author\""
890 [[ -n "$commit_agent_id" ]] && echo "agent_id = \"$commit_agent_id\""
891 [[ -n "$commit_model_id" ]] && echo "model_id = \"$commit_model_id\""
892 echo ""
893 echo "[session]"
894 echo "user_type = \"$MUSE_USER_TYPE\""
895 [[ -n "$MUSE_SESSION_AGENT_ID" ]] && echo "agent_id = \"$MUSE_SESSION_AGENT_ID\""
896 [[ -n "$MUSE_SESSION_MODEL_ID" ]] && echo "model_id = \"$MUSE_SESSION_MODEL_ID\""
897 return
898 fi
899
900 # Human format — dense, minimal whitespace, designed for AI context injection.
901 local icon="${MUSE_DOMAIN_ICONS[$MUSE_DOMAIN]:-◈}"
902 echo ""
903 echo " MUSE REPO CONTEXT ${icon} ${MUSE_DOMAIN}:${MUSE_BRANCH}"
904 echo " ──────────────────────────────────────────────────────"
905 echo " domain $MUSE_DOMAIN"
906 echo " branch $MUSE_BRANCH"
907 if [[ -n "$commit_id" ]]; then
908 echo " commit ${commit_id:0:8} \"$commit_msg\" ($commit_date)"
909 if [[ -n "$commit_agent_id" || -n "$commit_model_id" ]]; then
910 echo " last author ${commit_author} [agent: ${commit_model_id:-${commit_agent_id}}]"
911 else
912 echo " last author $commit_author [human]"
913 fi
914 [[ -n "$commit_semver" && "$commit_semver" != "none" ]] && \
915 echo " semver ${commit_semver:u}"
916 fi
917 if (( MUSE_DIRTY )); then
918 echo " dirty yes — ${MUSE_DIRTY_COUNT} changed"
919 else
920 echo " dirty no"
921 fi
922 if (( MUSE_MERGING )); then
923 echo " merging yes ← $MUSE_MERGE_BRANCH (${MUSE_CONFLICT_COUNT} conflicts)"
924 else
925 echo " merging no"
926 fi
927 (( stash_count > 0 )) && echo " stashes $stash_count"
928 (( ${#remotes[@]} > 0 )) && echo " remotes ${remotes[*]}"
929 echo " user $MUSE_USER_TYPE"
930 [[ -n "$MUSE_SESSION_AGENT_ID" ]] && echo " agent $MUSE_SESSION_AGENT_ID"
931 [[ -n "$MUSE_SESSION_MODEL_ID" ]] && echo " model $MUSE_SESSION_MODEL_ID"
932 echo ""
933 }
934
935 # Begin an agent session. Sets identity env vars and starts a JSONL session log.
936 # Usage: muse-agent-session <model_id> [agent_id]
937 function muse-agent-session() {
938 local model_id="${1:?Usage: muse-agent-session <model_id> [agent_id]}"
939 local agent_id="${2:-agent-$$}"
940
941 export MUSE_SESSION_MODEL_ID="$model_id"
942 export MUSE_SESSION_AGENT_ID="$agent_id"
943 export MUSE_SESSION_START="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
944
945 mkdir -p "$MUSE_SESSION_LOG_DIR"
946 local log_file="$MUSE_SESSION_LOG_DIR/$(date -u +%Y%m%d-%H%M%S)-$$.jsonl"
947 export MUSE_SESSION_LOG_FILE="$log_file"
948
949 # Write session-start entry with pure ZSH printf (no subprocess).
950 printf '{"t":"%s","event":"session_start","model_id":"%s","agent_id":"%s","domain":"%s","branch":"%s","repo_root":"%s","pid":%d}\n' \
951 "$MUSE_SESSION_START" "$model_id" "$agent_id" \
952 "${MUSE_DOMAIN:-}" "${MUSE_BRANCH:-}" "${MUSE_REPO_ROOT//\"/\\\"}" \
953 $$ >> "$log_file"
954
955 echo " Agent session started"
956 echo " model $model_id"
957 echo " agent $agent_id"
958 echo " log $log_file"
959 _muse_refresh 2>/dev/null
960 }
961
962 # End the current agent session, write a summary entry, unset identity vars.
963 function muse-agent-end() {
964 if [[ -z "${MUSE_SESSION_MODEL_ID}" ]]; then
965 echo "No active agent session." >&2; return 1
966 fi
967 local t="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
968 if [[ -f "${MUSE_SESSION_LOG_FILE:-}" ]]; then
969 printf '{"t":"%s","event":"session_end","model_id":"%s","agent_id":"%s","start":"%s"}\n' \
970 "$t" "$MUSE_SESSION_MODEL_ID" "$MUSE_SESSION_AGENT_ID" \
971 "$MUSE_SESSION_START" >> "$MUSE_SESSION_LOG_FILE"
972 echo " Session log $MUSE_SESSION_LOG_FILE"
973 fi
974 echo " Session ended ${MUSE_SESSION_MODEL_ID} / ${MUSE_SESSION_AGENT_ID}"
975 unset MUSE_SESSION_MODEL_ID MUSE_SESSION_AGENT_ID \
976 MUSE_SESSION_START MUSE_SESSION_LOG_FILE
977 _muse_refresh 2>/dev/null
978 }
979
980 # muse commit wrapper that auto-injects agent identity from the active session.
981 # Usage: muse-agent-commit <message> [extra muse-commit flags…]
982 function muse-agent-commit() {
983 local message="${1:?Usage: muse-agent-commit <message> [flags]}"
984 shift
985 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
986 local -a extra
987 [[ -n "${MUSE_SESSION_AGENT_ID}" ]] && extra+=("--agent-id" "$MUSE_SESSION_AGENT_ID")
988 [[ -n "${MUSE_SESSION_MODEL_ID}" ]] && extra+=("--model-id" "$MUSE_SESSION_MODEL_ID")
989 muse commit -m "$message" "${extra[@]}" "$@"
990 }
991
992 # List recent sessions or replay a specific session log.
993 # Usage: muse-sessions → list
994 # muse-sessions <file> → replay
995 function muse-sessions() {
996 local session_file="${1:-}"
997 if [[ -z "$session_file" ]]; then
998 if [[ ! -d "$MUSE_SESSION_LOG_DIR" ]]; then
999 echo "No sessions yet. Start with: muse-agent-session <model_id>"
1000 return
1001 fi
1002 echo " Recent agent sessions ($MUSE_SESSION_LOG_DIR):"
1003 echo ""
1004 local f
1005 for f in "$MUSE_SESSION_LOG_DIR"/*.jsonl(N.Om[1,20]); do
1006 [[ ! -f "$f" ]] && continue
1007 local info
1008 info=$(MUSE_META_FILE="$f" python3 <<'PYEOF' 2>/dev/null
1009 import json, os
1010 try:
1011 line = open(os.environ['MUSE_META_FILE']).readline().strip()
1012 d = json.loads(line)
1013 print(f"{d.get('model_id','?'):30} {d.get('agent_id','?'):20} {d.get('t','?')[:19]}")
1014 except Exception:
1015 print('?')
1016 PYEOF
1017 )
1018 printf " %-40s %s\n" "${f##*/}" "$info"
1019 done
1020 return
1021 fi
1022
1023 [[ ! -f "$session_file" ]] && { echo "File not found: $session_file" >&2; return 1; }
1024 MUSE_META_FILE="$session_file" python3 <<'PYEOF' 2>/dev/null
1025 import json, os
1026 with open(os.environ['MUSE_META_FILE']) as f:
1027 for line in f:
1028 try:
1029 e = json.loads(line.strip())
1030 t = e.get('t', '')[:19]
1031 ev = e.get('event', '')
1032 if ev == 'session_start':
1033 print(f"{t} SESSION START model={e.get('model_id','?')} agent={e.get('agent_id','?')}")
1034 elif ev == 'session_end':
1035 print(f"{t} SESSION END")
1036 elif ev == 'cmd_end':
1037 print(f"{t} EXIT {e.get('exit','?')} ({e.get('elapsed_ms','?')}ms)")
1038 elif 'cmd' in e:
1039 print(f"{t} $ {e['cmd']}")
1040 except json.JSONDecodeError:
1041 pass
1042 PYEOF
1043 }
1044
1045 # Internal: write command-start JSONL entry. Pure ZSH printf — no subprocess.
1046 function _muse_session_log_start() {
1047 [[ -z "${MUSE_SESSION_LOG_FILE:-}" ]] && return
1048 local t cmd_esc cwd_esc
1049 t="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
1050 cmd_esc="${1//\\/\\\\}"; cmd_esc="${cmd_esc//\"/\\\"}"
1051 cwd_esc="${PWD//\\/\\\\}"; cwd_esc="${cwd_esc//\"/\\\"}"
1052 printf '{"t":"%s","cmd":"%s","cwd":"%s","domain":"%s","branch":"%s","pid":%d}\n' \
1053 "$t" "$cmd_esc" "$cwd_esc" \
1054 "${MUSE_DOMAIN:-}" "${MUSE_BRANCH:-}" $$ \
1055 >> "$MUSE_SESSION_LOG_FILE" 2>/dev/null
1056 }
1057
1058 # Internal: write command-end JSONL entry.
1059 function _muse_session_log_end() {
1060 [[ -z "${MUSE_SESSION_LOG_FILE:-}" ]] && return
1061 printf '{"t":"%s","event":"cmd_end","exit":%d,"elapsed_ms":%d}\n' \
1062 "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${1:-0}" "${2:-0}" \
1063 >> "$MUSE_SESSION_LOG_FILE" 2>/dev/null
1064 }
1065
1066 # ── §9 VISUAL TOOLS ──────────────────────────────────────────────────────────
1067
1068 # Colorised commit graph with domain theming, SemVer badges, agent markers.
1069 function muse-graph() {
1070 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
1071 cd "$MUSE_REPO_ROOT" && muse log --graph --oneline "$@" | \
1072 MUSE_META_DOMAIN="$MUSE_DOMAIN" python3 <<'PYEOF'
1073 import sys, re, os
1074
1075 DOMAIN_COLORS = {
1076 'midi': '\033[35m',
1077 'code': '\033[36m',
1078 'bitcoin': '\033[33m',
1079 'scaffold': '\033[34m',
1080 'genomics': '\033[32m',
1081 }
1082 R = '\033[0m'
1083 B = '\033[1m'
1084 DIM = '\033[2m'
1085 YLW = '\033[33m'
1086 RED = '\033[31m'
1087 GRN = '\033[32m'
1088 CYN = '\033[36m'
1089 MAG = '\033[35m'
1090
1091 dc = DOMAIN_COLORS.get(os.environ.get('MUSE_META_DOMAIN', ''), '\033[37m')
1092
1093 for raw in sys.stdin:
1094 line = raw.rstrip()
1095 # Graph chrome
1096 line = line.replace('*', f'{dc}*{R}')
1097 line = line.replace('|', f'{DIM}|{R}')
1098 line = line.replace('/', f'{DIM}/{R}')
1099 line = line.replace('\\', f'{DIM}\\{R}')
1100 # Commit SHAs
1101 line = re.sub(r'\b([0-9a-f]{7,8})\b', f'{YLW}\\1{R}', line)
1102 # Branch refs
1103 line = re.sub(r'HEAD -> ([^\s,)]+)', f'HEAD -> {B}{GRN}\\1{R}', line)
1104 line = re.sub(r'\(([^)]*)\)', lambda m: f'{DIM}({m.group(1)}){R}', line)
1105 # SemVer badges
1106 line = re.sub(r'\[MAJOR\]', f'{RED}[MAJOR]{R}', line)
1107 line = re.sub(r'\[MINOR\]', f'{YLW}[MINOR]{R}', line)
1108 line = re.sub(r'\[PATCH\]', f'{GRN}[PATCH]{R}', line)
1109 # Agent markers
1110 line = re.sub(r'\[agent:([^\]]+)\]', f'{CYN}[agent:\\1]{R}', line)
1111 print(line)
1112 PYEOF
1113 }
1114
1115 # Vertical timeline of the last N commits (default 20) with domain theming.
1116 function muse-timeline() {
1117 local n="${1:-20}"
1118 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
1119 local icon="${MUSE_DOMAIN_ICONS[$MUSE_DOMAIN]:-◈}"
1120 cd "$MUSE_REPO_ROOT" && muse log --oneline -n "$n" | \
1121 MUSE_META_DOMAIN="$MUSE_DOMAIN" MUSE_META_BRANCH="$MUSE_BRANCH" \
1122 MUSE_META_ICON="$icon" python3 <<'PYEOF'
1123 import sys, os
1124
1125 DOMAIN_COLORS = {
1126 'midi': '\033[35m',
1127 'code': '\033[36m',
1128 'bitcoin': '\033[33m',
1129 'scaffold': '\033[34m',
1130 'genomics': '\033[32m',
1131 }
1132 R = '\033[0m'
1133 YLW = '\033[33m'
1134 DIM = '\033[2m'
1135
1136 domain = os.environ.get('MUSE_META_DOMAIN', '')
1137 branch = os.environ.get('MUSE_META_BRANCH', '')
1138 icon = os.environ.get('MUSE_META_ICON', '◈')
1139 dc = DOMAIN_COLORS.get(domain, '\033[37m')
1140
1141 lines = [l.rstrip() for l in sys.stdin if l.strip()]
1142 if not lines:
1143 print(' (no commits)')
1144 sys.exit(0)
1145
1146 print(f' {dc}{icon} TIMELINE — {branch} (last {len(lines)} commits){R}')
1147 print(f' {DIM}{"─"*60}{R}')
1148 for i, line in enumerate(lines):
1149 parts = line.split(None, 1)
1150 sha = parts[0] if parts else '?'
1151 msg = parts[1] if len(parts) > 1 else ''
1152 node = f'{dc}◉{R}' if i == 0 else f'{dc}○{R}'
1153 print(f' {node} {YLW}{sha}{R} {msg}')
1154 if i < len(lines) - 1:
1155 print(f' {DIM}│{R}')
1156 print(f' {DIM}╵{R}')
1157 PYEOF
1158 }
1159
1160 # Show muse diff with bat/delta highlighting if available.
1161 function muse-diff-preview() {
1162 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
1163 local output
1164 output=$(cd "$MUSE_REPO_ROOT" && muse diff "$@")
1165 if command -v delta >/dev/null 2>&1; then
1166 echo "$output" | delta
1167 elif command -v bat >/dev/null 2>&1; then
1168 echo "$output" | bat --style plain --language diff
1169 else
1170 echo "$output"
1171 fi
1172 }
1173
1174 # Interactive commit browser via fzf. Enter: checkout; Ctrl-D: diff; Ctrl-Y: copy SHA.
1175 function muse-commit-browser() {
1176 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
1177 if ! command -v fzf >/dev/null 2>&1; then
1178 echo "fzf required (https://github.com/junegunn/fzf)." >&2; return 1
1179 fi
1180 local selected
1181 selected=$(cd "$MUSE_REPO_ROOT" && muse log --oneline | fzf \
1182 --ansi \
1183 --no-sort \
1184 --preview 'muse show {1} 2>/dev/null' \
1185 --preview-window 'right:60%:wrap' \
1186 --header "↵ checkout ctrl-d: diff ctrl-y: copy SHA ctrl-s: show" \
1187 --bind 'ctrl-d:execute(muse diff {1} 2>/dev/null | less -R)' \
1188 --bind 'ctrl-y:execute(echo -n {1} | pbcopy 2>/dev/null || echo -n {1} | xclip -selection clipboard 2>/dev/null; echo "copied {1}")' \
1189 --bind 'ctrl-s:execute(muse show {1} 2>/dev/null | less -R)' \
1190 --prompt "commit> ")
1191 if [[ -n "$selected" ]]; then
1192 local sha="${selected%% *}"
1193 echo "Checking out $sha…"
1194 muse checkout "$sha"
1195 fi
1196 }
1197
1198 # Interactive branch picker via fzf. Enter: checkout; Ctrl-D: delete branch.
1199 function muse-branch-picker() {
1200 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
1201 if ! command -v fzf >/dev/null 2>&1; then
1202 echo "fzf required (https://github.com/junegunn/fzf)." >&2; return 1
1203 fi
1204 local selected
1205 selected=$(cd "$MUSE_REPO_ROOT" && muse branch -v | fzf \
1206 --ansi \
1207 --preview 'echo {} | awk "{print \$1}" | sed "s/^\*//" | xargs muse log --oneline -n 8 2>/dev/null' \
1208 --preview-window 'right:50%' \
1209 --header "↵ checkout ctrl-d: delete" \
1210 --bind 'ctrl-d:execute(echo {} | awk "{print \$1}" | sed "s/^\*//" | xargs muse branch -d 2>/dev/null)' \
1211 --prompt "branch> ")
1212 if [[ -n "$selected" ]]; then
1213 local branch
1214 branch=$(echo "$selected" | awk '{print $1}' | sed 's/^\*//')
1215 branch="${branch## }"
1216 [[ -n "$branch" ]] && muse checkout "$branch"
1217 fi
1218 }
1219
1220 # Interactive stash browser via fzf. Enter: pop; Ctrl-D: drop.
1221 function muse-stash-browser() {
1222 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
1223 if ! command -v fzf >/dev/null 2>&1; then
1224 echo "fzf required (https://github.com/junegunn/fzf)." >&2; return 1
1225 fi
1226 local selected
1227 selected=$(cd "$MUSE_REPO_ROOT" && muse stash list 2>/dev/null | fzf \
1228 --ansi \
1229 --preview 'echo {} | awk "{print \$1}" | xargs muse stash show 2>/dev/null' \
1230 --preview-window 'right:50%' \
1231 --header "↵ pop ctrl-d: drop" \
1232 --bind 'ctrl-d:execute(echo {} | awk "{print \$1}" | xargs muse stash drop 2>/dev/null)' \
1233 --prompt "stash> ")
1234 if [[ -n "$selected" ]]; then
1235 local ref="${selected%% *}"
1236 muse stash pop "$ref"
1237 fi
1238 }
1239
1240 # Domain-specific live state overview.
1241 function muse-overview() {
1242 [[ -z "$MUSE_REPO_ROOT" ]] && { echo "Not in a muse repo." >&2; return 1; }
1243 local icon="${MUSE_DOMAIN_ICONS[$MUSE_DOMAIN]:-◈}"
1244 echo " ${icon} ${MUSE_DOMAIN:u} OVERVIEW — ${MUSE_BRANCH}"
1245 case "$MUSE_DOMAIN" in
1246 midi) cd "$MUSE_REPO_ROOT" && muse midi notes 2>/dev/null ;;
1247 code) cd "$MUSE_REPO_ROOT" && muse code symbols 2>/dev/null ;;
1248 bitcoin) cd "$MUSE_REPO_ROOT" && muse status 2>/dev/null ;;
1249 *) muse-context ;;
1250 esac
1251 }
1252
1253 # ── §10 POWERLEVEL10K INTEGRATION ────────────────────────────────────────────
1254
1255 # Left or right p10k segment. Add 'muse_vcs' to the relevant
1256 # POWERLEVEL9K_*_PROMPT_ELEMENTS array in your .p10k.zsh.
1257 function prompt_muse_vcs() {
1258 [[ -z "$MUSE_REPO_ROOT" ]] && return
1259 local info
1260 info="$(muse_prompt_info)"
1261 [[ -z "$info" ]] && return
1262 p10k segment -f white -t "$info"
1263 }
1264
1265 # Shown during instant prompt (before async workers complete).
1266 # Displays the cached state so the prompt is never blank.
1267 function instant_prompt_muse_vcs() {
1268 [[ -z "$MUSE_REPO_ROOT" ]] && return
1269 local info
1270 info="$(muse_prompt_info)"
1271 [[ -z "$info" ]] && return
1272 p10k segment -f white -t "$info"
1273 }
1274
1275 # ── §11 KEYBINDINGS ──────────────────────────────────────────────────────────
1276
1277 if [[ "$MUSE_BIND_KEYS" == "1" ]]; then
1278 # Ctrl+B → branch picker
1279 function _muse_widget_branch_picker() {
1280 muse-branch-picker
1281 zle reset-prompt
1282 }
1283 zle -N _muse_widget_branch_picker
1284 bindkey '^B' _muse_widget_branch_picker
1285
1286 # ESC-M → commit browser (ESC then M, avoids terminal Ctrl+Shift conflicts)
1287 function _muse_widget_commit_browser() {
1288 muse-commit-browser
1289 zle reset-prompt
1290 }
1291 zle -N _muse_widget_commit_browser
1292 bindkey '\eM' _muse_widget_commit_browser
1293
1294 # Ctrl+Shift+H → repo health (ESC-H)
1295 function _muse_widget_health() {
1296 echo ""
1297 muse-health
1298 zle reset-prompt
1299 }
1300 zle -N _muse_widget_health
1301 bindkey '\eH' _muse_widget_health
1302 fi
1303
1304 # ── §12 HOOK SYSTEM ──────────────────────────────────────────────────────────
1305
1306 # Run user-defined post-command callbacks. Called from _muse_hook_precmd after
1307 # a muse command completes. Users set MUSE_POST_*_CMD in their .zshrc.
1308 function _muse_run_post_hooks() {
1309 local cmd="$_MUSE_LAST_CMD"
1310 case "$cmd" in
1311 muse\ commit*|mcm*|muse-agent-commit*|muse-quick-commit*|muse-wip*)
1312 [[ -n "$MUSE_POST_COMMIT_CMD" ]] && eval "$MUSE_POST_COMMIT_CMD" 2>/dev/null
1313 ;;
1314 muse\ checkout*|mco*)
1315 [[ -n "$MUSE_POST_CHECKOUT_CMD" ]] && eval "$MUSE_POST_CHECKOUT_CMD" 2>/dev/null
1316 ;;
1317 muse\ merge*|muse-safe-merge*)
1318 if (( ! MUSE_MERGING )); then
1319 [[ -n "$MUSE_POST_MERGE_CMD" ]] && eval "$MUSE_POST_MERGE_CMD" 2>/dev/null
1320 fi
1321 ;;
1322 esac
1323 _MUSE_LAST_CMD=""
1324 }
1325
1326 # ── §13 COMPLETION REGISTRATION ─────────────────────────────────────────────
1327
1328 # Register the _muse completion function if the companion file exists alongside
1329 # this plugin (both are installed into $ZSH_CUSTOM/plugins/muse/ by the
1330 # install script).
1331 if [[ -f "${0:A:h}/_muse" ]]; then
1332 fpath=("${0:A:h}" $fpath)
1333 autoload -Uz compinit
1334 compdef _muse muse 2>/dev/null
1335 fi
1336
1337 # ── §14 INITIALISATION ───────────────────────────────────────────────────────
1338
1339 # Warm the cache immediately so the very first prompt shows repo state without
1340 # an extra precmd cycle. The fast variant skips dirty detection to avoid
1341 # blocking the shell startup.
1342 _muse_refresh_fast 2>/dev/null