muse.plugin.zsh
| 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 |