diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5ee5be0..6140dfa 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ "name": "phobos", "source": "./", "description": "Always-on efficiency discipline — triage token spend to task size, write minimal correct code, take fewer round-trips, keep context lean, use memory, route to the right tool. Three skills (phobos, phobos-code, phobos-plan) plus guard/state/status hooks.", - "version": "1.2.5", + "version": "1.3.0", "category": "productivity", "keywords": ["efficiency", "tokens", "cost", "context", "productivity", "workflow"], "license": "MIT" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a578219..90a5402 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin.json", "name": "phobos", "displayName": "phobos", - "version": "1.2.5", + "version": "1.3.0", "description": "Always-on efficiency discipline for Claude Code — triage token spend to task size, write minimal correct code, take fewer round-trips, keep context lean. Ships three skills plus guard/state/status hooks.", "author": { "name": "broisnischal", diff --git a/CHANGELOG.md b/CHANGELOG.md index f8dfd67..d1434cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## 1.3.0 — 2026-07-28 + +Tab-title working spinner, an in-session `/phobos-update` command, status line shows effort instead of cost, a 1M context-meter fix, and auto-ignored scratch files. + +### Added +- **Terminal tab-title spinner** (`spinner.sh`). While Claude works, the terminal + tab/window title animates with a braille spinner (`⠋ myrepo · working…`) and + resets to the plain project name when the turn ends — so a backgrounded tab + shows activity at a glance. Pure bash + OSC title escapes to the controlling + terminal (a detached, nohup'd animator started on `UserPromptSubmit`, killed on + `Stop`/`SessionEnd`); a quiet no-op where there's no tty. Opt out with + `PHOBOS_SPINNER=off`. +- **`/phobos-update` command** — update phobos from inside Claude Code. Installed + by `install.sh` into `~/.claude/commands/`; ships in the plugin as + `/phobos:update`. Runs `update.sh` for git checkouts, or points plugin installs + at `/plugin marketplace update phobos`. +- `update.sh` — pull the latest and re-apply in one command (`bash + phobos/update.sh`), passing install flags through so the install style is + preserved. `install.sh` referenced this script but it never existed. + +### Changed +- The status line now shows the current **reasoning effort** (`effort high`, + `effort max`, …) where it used to show session dollar cost. Reads + `.effort.level` from the statusLine JSON; the badge is hidden when the model + has no effort knob, and the pricey tiers (`high`, `xhigh`, `max`) are + colour-escalated so a stray high setting is obvious. Cost is still available + via `benchmark.sh` / `savings.sh`. + +### Fixed +- `context-warn.sh` no longer reports impossible readings like "context ~115% + full" on extended-context (1M) sessions. The `UserPromptSubmit` payload + doesn't carry the native `context_window` %, so the hook fell back to dividing + real tokens by a hardcoded 200k window — ~230k tokens then read as 115%. The + fallback now sizes the window from a `1m` marker on the model id/name, or + infers 1M when the token count already exceeds the 200k standard tier (the + only larger window), and caps the result at 100%. `statusline.sh`'s gauge got + the same guard. + +### Added +- Phobos now registers its per-repo scratch files (`.claude/phobos-activity.log`, + `phobos-state.md`, `phobos-benchmark.jsonl`) in the repo's `.git/info/exclude` + at session start, so they're kept out of git automatically — without touching + the user's tracked `.gitignore` (which would surface in their commits/PRs). + Idempotent, and a no-op outside a git repo. `phobos-guard-allow` is left + visible since it's user-authored config a team may want to share. + ## 1.2.5 — 2026-07-10 Drop the `⚠` caveat habit. diff --git a/README.md b/README.md index ce1fa9c..001ab53 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ phobos is also a [Claude Code plugin marketplace](https://code.claude.com/docs/e This installs all three skills (`phobos`, `phobos-code`, `phobos-plan`) and wires every hook automatically — no `settings.json` edits, updated with `/plugin marketplace update`. -One difference from `install.sh`: a plugin can't set the main status line — Claude Code only lets plugins define `agent`/`subagentStatusLine`, and the plugin's cache path is versioned and ephemeral. To get the cost/context gauge shown above, run `install.sh` (which wires it for you), or point your own `settings.json` `statusLine` at a stable clone: `bash "$HOME/.claude/skills/phobos/hooks/statusline.sh"`. +One difference from `install.sh`: a plugin can't set the main status line — Claude Code only lets plugins define `agent`/`subagentStatusLine`, and the plugin's cache path is versioned and ephemeral. To get the effort/context gauge shown above, run `install.sh` (which wires it for you), or point your own `settings.json` `statusLine` at a stable clone: `bash "$HOME/.claude/skills/phobos/hooks/statusline.sh"`. ## What you get @@ -47,7 +47,9 @@ One difference from `install.sh`: a plugin can't set the main status line — Cl - Session usage - Activity - State handoff +- Tab spinner - Skills for plan and code +- `/phobos-update` command ## The read guard @@ -97,6 +99,10 @@ _generated 2026-07-09T09:20Z · git a1b2c3d (main), 3 uncommitted — a snapshot Written to `.claude/phobos-state.md` on edit turns and before every compaction, and injected at session start — so a new session (or a post-`/compact`/`/clear` turn) re-orients from a small stamped snapshot instead of the transcript. The `git` stamp is the staleness flag: trust it as of that SHA, then verify. +## The tab spinner + +While Claude is working, the terminal tab/window title animates with a braille spinner (`⠋ myrepo · working…`) and resets to the plain project name when the turn ends — so a backgrounded tab shows activity at a glance. Pure bash + OSC title escapes written to the controlling terminal; a silent no-op where there's no tty. Turn it off with `PHOBOS_SPINNER=off`. + ## Activity ledger ``` @@ -128,8 +134,22 @@ Dollar figures are estimates, not an invoice. ## Update +From inside Claude Code, run the command (installed for you): + +``` +/phobos-update +``` + +It pulls the latest and re-applies. Or do it by hand — installed via `install.sh`: + ```sh -bash ~/.claude/skills/phobos/hooks/update.sh +bash ~/src/phobos/update.sh # same flags as install are honoured +``` + +Installed as a **plugin** (command is namespaced `/phobos:update`) — or update the marketplace directly: + +``` +/plugin marketplace update phobos ``` ## Tuning knobs @@ -139,8 +159,10 @@ bash ~/.claude/skills/phobos/hooks/update.sh - `PHOBOS_MAX_READ_LINES` — default `2000` - `PHOBOS_CMD_GUARD` — default `on` - `PHOBOS_REPEAT_MAX` — default `2` -- `PHOBOS_WARN_PCT` — default `75` +- `PHOBOS_WARN_PCT` — default `80` +- `PHOBOS_CRIT_PCT` — default `92` - `PHOBOS_CTX_LIMIT` — default `200000` +- `PHOBOS_SPINNER` — default `on` - `BASELINE_MULT` — default `1.5` Chat controls: `phobos:max`, "stop phobos", "normal mode". diff --git a/VERSION b/VERSION index c813fe1..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.5 +1.3.0 diff --git a/commands/update.md b/commands/update.md new file mode 100644 index 0000000..0115777 --- /dev/null +++ b/commands/update.md @@ -0,0 +1,16 @@ +--- +description: Update phobos to the latest version (pull + re-apply, or plugin marketplace) +allowed-tools: Bash +--- + +Update the phobos installation to the latest version. Be concise — do it, then report the result in one or two lines. + +1. Locate the install and pick the right path: + - **Manual install (`install.sh`)** — `~/.claude/skills/phobos` resolves to a git checkout. The repo root is two levels above the real path of that skill. Run the updater there: + ```sh + root=$(cd "$(dirname "$(readlink -f ~/.claude/skills/phobos)")/.." && pwd) && bash "$root/update.sh" + ``` + `update.sh` fast-forwards the checkout and re-applies the (idempotent) install, so any new hooks/skills land. + - **Plugin install** — the files live in a managed plugin cache, not a git checkout (the command above will fail or `update.sh` will say so). Do **not** git-pull it. Tell the user to run `/plugin marketplace update phobos` themselves — one slash command can't launch another. + +2. Report: old → new version (from the repo's `VERSION`), or "already up to date". Remind the user to restart the Claude Code session to load the changes. diff --git a/hooks/hooks.json b/hooks/hooks.json index e129e5a..b8238f6 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -10,14 +10,16 @@ "SessionEnd": [ { "hooks": [ - { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/skills/phobos/hooks/session-end.sh\"" } + { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/skills/phobos/hooks/session-end.sh\"" }, + { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/skills/phobos/hooks/spinner.sh\" stop" } ] } ], "Stop": [ { "hooks": [ - { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/skills/phobos/hooks/stop.sh\"" } + { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/skills/phobos/hooks/stop.sh\"" }, + { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/skills/phobos/hooks/spinner.sh\" stop" } ] } ], @@ -31,7 +33,8 @@ "UserPromptSubmit": [ { "hooks": [ - { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/skills/phobos/hooks/context-warn.sh\"" } + { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/skills/phobos/hooks/context-warn.sh\"" }, + { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/skills/phobos/hooks/spinner.sh\" start" } ] } ], diff --git a/install.sh b/install.sh index 3ef79a8..894ee6d 100755 --- a/install.sh +++ b/install.sh @@ -15,6 +15,7 @@ # --no-guard skip the PreToolUse read-guard hook # --no-cmd-guard skip the PreToolUse Bash/Grep guard # --no-statusline skip the status line +# --no-spinner skip the terminal tab-title "working" spinner # --quiet print only warnings and errors # # Respects CLAUDE_CONFIG_DIR (default ~/.claude). Undo: bash uninstall.sh @@ -24,7 +25,7 @@ root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cfg="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" settings="$cfg/settings.json" -skills=1 do_settings=1 guard=1 cmdguard=1 statusline=1 quiet=0 +skills=1 do_settings=1 guard=1 cmdguard=1 statusline=1 spinner=1 quiet=0 for a in "$@"; do case "$a" in --skills-only) do_settings=0 ;; @@ -32,8 +33,9 @@ for a in "$@"; do --no-guard) guard=0 ;; --no-cmd-guard) cmdguard=0 ;; --no-statusline) statusline=0 ;; + --no-spinner) spinner=0 ;; --quiet) quiet=1 ;; - -h|--help) sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,19p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "install.sh: unknown flag $a (see --help)" >&2; exit 1 ;; esac done @@ -59,6 +61,16 @@ if [ "$skills" = 1 ]; then fi done [ "$copied" = 1 ] && say "⚠ no symlink support here (Windows without Developer Mode?) — skills were copied, not linked. Re-run update.sh after a 'git pull' to refresh them." + + # /phobos-update slash command — a single file linked (or copied) into commands/. + mkdir -p "$cfg/commands" + clink="$cfg/commands/phobos-update.md"; ctarget="$root/commands/update.md" + [ -e "$clink" ] && [ ! -L "$clink" ] && rm -f "$clink" # replace a stale copy + if ln -sfn "$ctarget" "$clink" 2>/dev/null && [ -L "$clink" ]; then + say "✓ command: /phobos-update → $ctarget" + else + rm -f "$clink"; cp "$ctarget" "$clink"; say "✓ command: /phobos-update (copied)" + fi fi [ "$do_settings" = 1 ] || { say "Done (skills only). Restart your Claude Code session."; exit 0; } @@ -82,7 +94,7 @@ fi # ensure(event; matcher; command; marker): append the hook entry unless some # entry for that event already runs a command containing the marker. merged=$(printf '%s' "$cur" | jq \ - --arg hp "$hp" --argjson guard "$guard" --argjson cmdguard "$cmdguard" --argjson statusline "$statusline" ' + --arg hp "$hp" --argjson guard "$guard" --argjson cmdguard "$cmdguard" --argjson statusline "$statusline" --argjson spinner "$spinner" ' def ensure(ev; m; cmd; marker): (.hooks //= {}) | .hooks[ev] = ((.hooks[ev] // []) as $arr @@ -101,6 +113,11 @@ merged=$(printf '%s' "$cur" | jq \ | (if $cmdguard == 1 then ensure("PreToolUse"; "Bash|Grep"; "bash \"\($hp)/guard-cmd.sh\""; "phobos/hooks/guard-cmd.sh") else . end) + | (if $spinner == 1 + then ensure("UserPromptSubmit"; ""; "bash \"\($hp)/spinner.sh\" start"; "phobos/hooks/spinner.sh") + | ensure("Stop"; ""; "bash \"\($hp)/spinner.sh\" stop"; "phobos/hooks/spinner.sh") + | ensure("SessionEnd"; ""; "bash \"\($hp)/spinner.sh\" stop"; "phobos/hooks/spinner.sh") + else . end) | (if $statusline == 1 and ((.statusLine // {} | .command // "") | contains("phobos") or . == "") then .statusLine = {type:"command", command:"bash \"\($hp)/statusline.sh\""} else . end) @@ -109,12 +126,12 @@ merged=$(printf '%s' "$cur" | jq \ if [ "$statusline" = 1 ]; then existing=$(printf '%s' "$cur" | jq -r '.statusLine.command // ""') if [ -n "$existing" ] && ! printf '%s' "$existing" | grep -q phobos; then - echo "⚠ you already have a custom statusLine — left untouched. phobos's renderer (cost/time/context gauge) is at: $hp/statusline.sh" >&2 + echo "⚠ you already have a custom statusLine — left untouched. phobos's renderer (effort/context gauge) is at: $hp/statusline.sh" >&2 fi fi printf '%s\n' "$merged" | jq . > "$settings.tmp" && mv "$settings.tmp" "$settings" -say "✓ hooks: SessionStart · SessionEnd · Stop · PreCompact · UserPromptSubmit$( [ "$guard" = 1 ] && echo ' · PreToolUse(read-guard)')$( [ "$cmdguard" = 1 ] && echo ' · PreToolUse(cmd-guard)')" +say "✓ hooks: SessionStart · SessionEnd · Stop · PreCompact · UserPromptSubmit$( [ "$guard" = 1 ] && echo ' · PreToolUse(read-guard)')$( [ "$cmdguard" = 1 ] && echo ' · PreToolUse(cmd-guard)')$( [ "$spinner" = 1 ] && echo ' · tab-spinner')" [ "$statusline" = 1 ] && say "✓ statusline wired" say "" diff --git a/skills/phobos/hooks/context-warn.sh b/skills/phobos/hooks/context-warn.sh index f9c348f..d0e1ff1 100755 --- a/skills/phobos/hooks/context-warn.sh +++ b/skills/phobos/hooks/context-warn.sh @@ -22,6 +22,9 @@ IFS=$'\x1f' read -r tp sid < <( # PHOBOS_CTX_LIMIT fallback below assumes 200k, so on a 1M session it reads ~100% # when the window is only a fifth full — that was the source of false alarms. npct=$(printf '%s' "$in" | jq -r '(.context_window.used_percentage // empty) | floor' 2>/dev/null) +# Model id/name, if this hook is handed one — only used to size the window in the +# transcript fallback. Empty on versions that don't pass it (then we infer below). +mdl=$(printf '%s' "$in" | jq -r '(.model.id // .model.display_name // "") | ascii_downcase' 2>/dev/null) warn_at="${PHOBOS_WARN_PCT:-80}" crit_at="${PHOBOS_CRIT_PCT:-92}" @@ -36,8 +39,23 @@ else | (.input_tokens // 0) + (.cache_read_input_tokens // 0) + (.cache_creation_input_tokens // 0) ' 2>/dev/null | tail -n 1) [ -n "${used:-}" ] && [ "$used" -gt 0 ] 2>/dev/null || exit 0 - limit="${PHOBOS_CTX_LIMIT:-200000}" + # Denominator = the model's real window. This hook isn't handed the native + # context_window object (the status line is), so size the window from whatever + # signal we have, in order: an explicit override; a 1M marker on the model + # id/name; a used count already past the 200k standard window — which can only + # be a 1M session (the sole larger tier), the exact case that used to overflow + # to an impossible ">100%". + if [ -n "${PHOBOS_CTX_LIMIT:-}" ]; then + limit="$PHOBOS_CTX_LIMIT" + elif case "$mdl" in *1m*) true ;; *) false ;; esac; then + limit=1000000 + elif [ "$used" -gt 200000 ]; then + limit=1000000 + else + limit=200000 + fi pct=$(( used * 100 / limit )) + [ "$pct" -gt 100 ] && pct=100 detail=" (~$(( used / 1000 ))k/$(( limit / 1000 ))k tokens)" fi [ "$pct" -ge "$warn_at" ] || exit 0 diff --git a/skills/phobos/hooks/session-start.sh b/skills/phobos/hooks/session-start.sh index 862215e..115a1ea 100755 --- a/skills/phobos/hooks/session-start.sh +++ b/skills/phobos/hooks/session-start.sh @@ -28,3 +28,23 @@ elif [ -s "$log" ]; then echo "## Recent activity (this repo)" tail -n 8 "$log" fi + +# Keep phobos's own per-repo scratch files out of the user's git history without +# touching their tracked .gitignore (which would show up in their commits/PRs): +# register them in .git/info/exclude — a personal ignore list that is never +# pushed. Idempotent; a silent no-op outside a git repo. --git-common-dir points +# at the shared .git even from a worktree, so the rule lands in one place. +gitdir=$(git rev-parse --git-common-dir 2>/dev/null || true) +if [ -n "$gitdir" ] && [ -d "$gitdir" ]; then + excl="$gitdir/info/exclude" + if ! grep -qxF '.claude/phobos-activity.log' "$excl" 2>/dev/null; then + mkdir -p "$gitdir/info" 2>/dev/null || true + { + printf '%s\n' \ + '# phobos per-repo runtime files — personal breadcrumbs, not project data' \ + '.claude/phobos-activity.log' \ + '.claude/phobos-benchmark.jsonl' \ + '.claude/phobos-state.md' + } >> "$excl" 2>/dev/null || true + fi +fi diff --git a/skills/phobos/hooks/spinner.sh b/skills/phobos/hooks/spinner.sh new file mode 100644 index 0000000..068a460 --- /dev/null +++ b/skills/phobos/hooks/spinner.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# phobos tab-title spinner — animate the terminal tab/window title while Claude +# works, so a backgrounded tab shows activity at a glance. +# +# UserPromptSubmit -> spinner.sh start spawn the animator +# Stop / SessionEnd -> spinner.sh stop kill it, reset the title +# +# Pure bash + OSC title escape codes written to the controlling terminal — a +# quiet no-op wherever there is no tty (piped / non-interactive runs). Opt out +# with PHOBOS_SPINNER=off. PHOBOS_SPINNER_TTY overrides the target terminal +# (used by the test suite); PHOBOS_SPINNER_LABEL overrides the "working…" text. +set -uo pipefail + +[ "${PHOBOS_SPINNER:-on}" = "off" ] && exit 0 +mode="${1:-start}" +tty_dev="${PHOBOS_SPINNER_TTY:-/dev/tty}" + +in=$(cat 2>/dev/null || true) +read -r sid dir < <( + printf '%s' "$in" | jq -r '[(.session_id // "nosession"), + ((.cwd // .workspace.current_dir // ".") | split("/") | last)] | join(" ")' 2>/dev/null +) || true +sid="${sid:-nosession}"; dir="${dir:-}" +state="${TMPDIR:-/tmp}/phobos-spinner-${sid}" + +# Always clear any running animator first — makes both start and stop idempotent +# and stops overlapping daemons piling up across turns. +if [ -s "$state" ]; then + kill "$(cat "$state" 2>/dev/null)" 2>/dev/null || true + rm -f "$state" +fi + +# No controlling terminal to draw on? Nothing to do (append-probe never truncates +# a real file, and opening /dev/tty fails cleanly when there's no tty). +{ : >>"$tty_dev"; } 2>/dev/null || exit 0 + +if [ "$mode" = "stop" ]; then + printf '\033]0;%s\007' "$dir" >"$tty_dev" 2>/dev/null || true + exit 0 +fi + +# start: spawn a fully-detached animator. All std fds are redirected and nohup'd +# so the hook returns at once and Claude Code never waits on our stdout. +label="${PHOBOS_SPINNER_LABEL:-${dir:+$dir · }working…}" +nohup bash -c ' + tty=$1; label=$2 + frames="⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"; i=0 + ticks=$(( 30 * 60 * 10 )) # 30-min safety cap if Stop never fires + while [ "$ticks" -gt 0 ]; do + ch=${frames:i++%${#frames}:1} + printf "\033]0;%s %s\007" "$ch" "$label" >"$tty" 2>/dev/null || exit 0 + ticks=$((ticks-1)); sleep 0.1 + done +' _ "$tty_dev" "$label" >/dev/null 2>&1 "$state" +exit 0 diff --git a/skills/phobos/hooks/statusline.sh b/skills/phobos/hooks/statusline.sh index 23079be..0e31a85 100755 --- a/skills/phobos/hooks/statusline.sh +++ b/skills/phobos/hooks/statusline.sh @@ -10,11 +10,11 @@ in=$(cat) # One jq pass. Unit-separator delimited: unlike tabs, \x1f isn't IFS whitespace, # so empty fields (e.g. no transcript_path) don't shift the columns. -IFS=$'\x1f' read -r model dir cost added removed tp ctxpct rlpct < <( +IFS=$'\x1f' read -r model dir effort added removed tp ctxpct rlpct < <( printf '%s' "$in" | jq -r '[ (.model.display_name // .model.id // "?"), ((.workspace.current_dir // .cwd // ".") | split("/") | last | split("\\") | last), - (.cost.total_cost_usd // 0), + (.effort.level // ""), (.cost.total_lines_added // 0), (.cost.total_lines_removed // 0), (.transcript_path // ""), @@ -42,9 +42,15 @@ fi # model · dir out+=" ${dim}${model:-?} · ${dir:-.}${rst}" -# live session cost + wall time (from stdin, already computed — free) -if [ "${cost:-0}" != "0" ]; then - out+=" ${dim}·${rst} \$$(printf '%.3f' "${cost:-0}" 2>/dev/null || echo "$cost")" +# reasoning-effort badge (from stdin; absent when the model has no effort knob). +# Escalate colour on the pricey tiers so a stray high/max is obvious at a glance. +if [ -n "${effort:-}" ] && [ "${effort}" != "null" ]; then + case "$effort" in + max|xhigh) ecol="$red" ;; + high) ecol="$yellow" ;; + *) ecol="$dim" ;; + esac + out+=" ${dim}·${rst} ${ecol}effort ${effort}${rst}" fi if [ "${added:-0}" != "0" ] || [ "${removed:-0}" != "0" ]; then out+=" ${dim}· +${added:-0} -${removed:-0}${rst}" @@ -62,7 +68,17 @@ if ! [ "$pct" -ge 0 ] 2>/dev/null; then | (.input_tokens // 0) + (.cache_read_input_tokens // 0) + (.cache_creation_input_tokens // 0) ' 2>/dev/null | tail -n 1) if [ -n "${used:-}" ] && [ "$used" -gt 0 ] 2>/dev/null; then - pct=$(( used * 100 / ${PHOBOS_CTX_LIMIT:-200000} )) + # Size the window like context-warn.sh: explicit override, a 1M marker on + # the model name, or a used count already past the 200k standard window + # (only a 1M session can — the sole larger tier). Keeps the gauge from + # reading >100% on extended-context models when the native % is absent. + if [ -n "${PHOBOS_CTX_LIMIT:-}" ]; then lim="$PHOBOS_CTX_LIMIT" + elif case "$(printf '%s' "${model:-}" | tr '[:upper:]' '[:lower:]')" in *1m*) true ;; *) false ;; esac; then lim=1000000 + elif [ "$used" -gt 200000 ]; then lim=1000000 + else lim=200000 + fi + pct=$(( used * 100 / lim )) + [ "$pct" -gt 100 ] && pct=100 fi fi fi diff --git a/tests/run.sh b/tests/run.sh index cda9431..2fb328e 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -172,7 +172,9 @@ sl() { printf '%s' "$1" | bash "$hooks/statusline.sh" 2>/dev/null; } base='{"model":{"display_name":"Sonnet 5"},"workspace":{"current_dir":"/x/myrepo"},"cost":{"total_cost_usd":0.5,"total_duration_ms":95000}}' out=$(sl "$base") assert "statusline: badge + model + dir" "$out" 'phobos.*Sonnet 5 · myrepo' -assert "statusline: cost" "$out" '\$0\.500' +refute "statusline: no dollar cost" "$out" '\$[0-9]' +assert "statusline: effort badge" "$(sl "$(printf '%s' "$base" | jq -c '.effort={level:"high"}')")" 'effort high' +refute "statusline: no effort badge when absent" "$out" 'effort ' refute "statusline: no wall-clock time" "$out" '[0-9]+m[0-9]+s' winout=$(sl "$(printf '%s' "$base" | jq -c '.workspace.current_dir="C:\\Users\\Karjen\\myproj"')") assert "statusline: windows cwd -> basename" "$winout" 'Sonnet 5 · myproj' @@ -197,8 +199,8 @@ cw() { printf '{"transcript_path":"%s","session_id":"%s"}' "$fx/transcript.jsonl PHOBOS_CTX_LIMIT="$2" bash "$hooks/context-warn.sh" 2>/dev/null; } sid="phobos-test-$$"; rm -f "${TMPDIR:-/tmp}/phobos-warn-$sid" assert_empty "context-warn: silent under threshold" "$(cw "$sid" 200000)" -assert "context-warn: warns when nearly full" "$(cw "$sid" 8000)" '~76% full' -assert_empty "context-warn: rate-limited on repeat" "$(cw "$sid" 8000)" +assert "context-warn: warns when nearly full" "$(cw "$sid" 7000)" '~87% full' +assert_empty "context-warn: rate-limited on repeat" "$(cw "$sid" 7000)" rm -f "${TMPDIR:-/tmp}/phobos-warn-$sid" # Native context % (window-aware) overrides the transcript÷PHOBOS_CTX_LIMIT path. nsid="phobos-nat-$$"; rm -f "${TMPDIR:-/tmp}/phobos-warn-$nsid" @@ -207,6 +209,29 @@ assert "context-warn: native ctx % overrides limit" "$natout" '~82% full' refute "context-warn: native path drops token detail" "$natout" 'tokens\)' rm -f "${TMPDIR:-/tmp}/phobos-warn-$nsid" +# ── spinner.sh ─────────────────────────────────────────────────────────────── +# Draw on a temp file instead of a real /dev/tty. The daemon reopens the target +# with '>' each tick (truncate), so the file holds the latest OSC title write. +spin_tty="$tmp/spin-tty"; : > "$spin_tty" +spsid="phobos-spin-$$"; sppid="${TMPDIR:-/tmp}/phobos-spinner-$spsid"; rm -f "$sppid" +sp() { printf '{"session_id":"%s","cwd":"/x/db-studio"}' "$spsid" | \ + PHOBOS_SPINNER_TTY="$spin_tty" bash "$hooks/spinner.sh" "$1" 2>/dev/null; } +sp start +sppid_val=$(cat "$sppid" 2>/dev/null) +assert "spinner: start records a live daemon" "$(kill -0 "$sppid_val" 2>/dev/null && echo alive)" 'alive' +for _ in 1 2 3 4 5 6 7 8 9 10; do [ -s "$spin_tty" ] && break; sleep 0.05; done +assert "spinner: start writes an OSC title with the dir" "$(cat "$spin_tty")" 'db-studio' +sp stop +assert_empty "spinner: stop removes the pid file" "$([ -f "$sppid" ] && echo present)" +gone=; for _ in 1 2 3 4 5 6; do kill -0 "$sppid_val" 2>/dev/null || { gone=1; break; }; sleep 0.05; done +assert "spinner: stop kills the daemon" "$gone" '1' +assert "spinner: stop sets a plain dir title" "$(cat "$spin_tty")" 'db-studio' +: > "$spin_tty"; rm -f "$sppid" +printf '{"session_id":"%s"}' "$spsid" | PHOBOS_SPINNER=off PHOBOS_SPINNER_TTY="$spin_tty" bash "$hooks/spinner.sh" start 2>/dev/null +assert_empty "spinner: PHOBOS_SPINNER=off spawns nothing" "$([ -f "$sppid" ] && echo present)$(cat "$spin_tty")" +printf '{"session_id":"%s"}' "$spsid" | PHOBOS_SPINNER_TTY="$tmp/nope/tty" bash "$hooks/spinner.sh" start 2>/dev/null +assert_empty "spinner: no writable tty spawns nothing" "$([ -f "$sppid" ] && echo present)" + # ── benchmark.sh + savings.sh (viewers) ────────────────────────────────────── out=$(bash "$hooks/benchmark.sh" "$fx/benchmark.jsonl") assert "benchmark: totals" "$out" 'totals: 2 sessions · 23700 out tok' diff --git a/uninstall.sh b/uninstall.sh index 97dee96..5f0f947 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -24,6 +24,15 @@ for s in phobos phobos-code phobos-plan; do fi done +# /phobos-update command — remove a symlink into this checkout, or our own copy. +clink="$cfg/commands/phobos-update.md" +if [ -L "$clink" ]; then + ct=$(readlink -f "$clink" 2>/dev/null || readlink "$clink") + case "$ct" in "$root"/*) rm -f "$clink"; echo "✓ removed $clink" ;; esac +elif [ -f "$clink" ] && grep -q phobos "$clink" 2>/dev/null; then + rm -f "$clink"; echo "✓ removed copied command $clink" +fi + if [ -f "$settings" ] && command -v jq >/dev/null 2>&1; then cp "$settings" "$settings.phobos-bak" jq ' diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..6003f8d --- /dev/null +++ b/update.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# phobos updater — pull the latest and re-apply, in one command. +# +# bash phobos/update.sh +# +# Fast-forwards this checkout to the newest commit on its branch, then re-runs +# the (idempotent) installer so any new hooks land and copy-based skill installs +# get refreshed. Symlinked skills already point at the fresh files. Flags are +# passed straight through to install.sh, so an install done as --skills-only or +# --no-guard stays that way on update. +# +# Installed phobos as a Claude Code *plugin* instead of via install.sh? This +# script won't apply — update it from inside Claude Code with: +# /plugin marketplace update phobos +# +# Undo any update: git checkout of an older commit, then bash install.sh. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$root" + +git rev-parse --git-dir >/dev/null 2>&1 || { + echo "update.sh: $root isn't a git checkout, so there's nothing to pull." >&2 + echo " If you installed phobos as a Claude Code plugin, update it from inside" >&2 + echo " Claude Code with: /plugin marketplace update phobos" >&2 + exit 1 +} + +before=$(git rev-parse --short HEAD 2>/dev/null || echo '?') +echo "phobos: fetching latest…" +git pull --ff-only || { + echo "update.sh: 'git pull --ff-only' couldn't fast-forward — you likely have" >&2 + echo " local commits or uncommitted changes. Resolve those, then re-run." >&2 + exit 1 +} +after=$(git rev-parse --short HEAD 2>/dev/null || echo '?') +ver=$(cat VERSION 2>/dev/null || echo '?') + +if [ "$before" = "$after" ]; then + echo "phobos: already up to date (v$ver, $after)." +else + echo "phobos: updated $before → $after (v$ver)." +fi + +# Re-apply so new hooks / copied skills are picked up. Idempotent; passes flags +# through so the install style is preserved. +bash "$root/install.sh" "$@" + +echo "phobos: update complete. Restart your Claude Code session to load changes."