diff --git a/.gitignore b/.gitignore index f1199783c..a76e14184 100644 --- a/.gitignore +++ b/.gitignore @@ -19,13 +19,3 @@ plaintext/ /result .wrangler/ -# cliamp's runtime files. Since 2026-09-13 ~/.config/cliamp is a real directory -# with only config.toml and themes/ linked in (home/home.nix), so these no -# longer land here; the lines stay so a stale checkout or a revert cannot -# commit them again. resume.json carries a Navidrome stream URL with a Subsonic -# token — never track it. -home/dot_config/cliamp/cliamp.log -home/dot_config/cliamp/cliamp.sock -home/dot_config/cliamp/cliamp.sock.pid -home/dot_config/cliamp/history.toml -home/dot_config/cliamp/resume.json diff --git a/AGENTS.md b/AGENTS.md index 3686dc7fe..4f14a3b2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,16 @@ +**Claude seats (2026-09-22).** Two Claude Code logins live on the coordinator +and nowhere else: `cc` is `~/.claude` (the personal Claude Max login, config file +`~/.claude.json`), `cc2` is `~/.claude-work` (the leger.run Claude Max login, +config file `~/.claude-work/.claude.json`), selected only by `CLAUDE_CONFIG_DIR` +in the fish launchers (`home/dot_config/fish/config.fish`). Both share the same +skills and settings links; login, history, sessions and trust are per seat, so a +session id resumes only on the seat and from the cwd that created it +(`claude-sessions` fans out over the seats). Claude never saves workspace trust +for `$HOME`, so the launchers move into `$CLAUDE_ENVELOPE` (default `~/today`) +when typed from `~`; never start a seat in the home directory. Logins are hand +`/login`s, never a delivered secret (the old claude-credentials seed was removed +this day). The seat meters are `~/.local/state/tally-rewrite/meters/.json`. + **Physical seats (2026-09-16, supersedes older headless/client-only wording below).** Tom is returning the coordinator to primary-desktop duty with two upright LG 5K displays side by side at scale 2. Both coordinator and client have diff --git a/DECISIONS.md b/DECISIONS.md index c759e6c59..892ef9143 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,37 @@ # DECISIONS +2026-09-23 the SessionEnd harvest hook is REMOVED; `harvest` stays a manual +verb. + +MEM-2 (dotfiles#339) wired SessionEnd -> `harvest` so a closing session +distilled itself. The script was careful — it always exited 0, it always +logged one line, it timed itself out under Claude Code's own timeout — and +none of that was the problem. The problem is structural: the session process +WAITS for a SessionEnd hook before it exits. On the runs that actually +harvested, that wait reached 57 s, and Claude Code aborted the hook and +printed `SessionEnd hook [...] failed: Hook cancelled` at every close. The +ledger it kept is the argument against it: of the last 101 runs, 46 created a +note, 1 updated one, and 54 skipped — most of those `one cleaned +user/assistant turn exceeds the declared utility context`. Every session end +paid the latency; fewer than half bought anything with it. + +Removed: the hook script, its home.nix link, the SessionEnd block in +home/dot_claude/settings.json, `checks.ai-memory-harvest-hook`, +tests/ai-memory-hook/, tools/mem-2-hook-oracle.sh, tools/mem-2-eval-probe.sh. +docs/local-ai/harvest-on-close.md is kept, banner-marked as removed. + +Kept: `ai_memory.py`, the `harvest` verb, its `--enqueue` leg (FIX-E08, +dotfiles#348) and MEM-3's probe. The `drain` skill still runs the verb on +demand, so the capability is intact — only the automatic leg is gone. + +The 2026-09-13 entry below noted that the ai-memory-harvest-hook check +asserted there is no SessionStart block, so a hook naming an unshipped script +could not return by accident. That guard is not lost: it is replaced by +`checks.no-claude-code-hooks`, which is strictly broader — it fails on ANY +hook block in settings.json, and on anything delivered into ~/.claude/hooks. +~/.claude/hooks itself stays a real, writable directory owned by no link. +Re-adding a hook of any kind is now a deliberate edit to that check. + 2026-09-13 flake checkouts no longer ride into host closures, chrome-stream is installed, and a switch refuses a stale raw-dotfiles checkout. diff --git a/docs/local-ai/harvest-on-close.md b/docs/local-ai/harvest-on-close.md index b3baf4553..7f84b5132 100644 --- a/docs/local-ai/harvest-on-close.md +++ b/docs/local-ai/harvest-on-close.md @@ -1,4 +1,28 @@ -# Harvest on close — the SessionEnd hook +# Harvest on close — the SessionEnd hook (REMOVED 2026-09-23) + +> **This mechanism no longer exists.** The SessionEnd hook, its script, its +> contract test, its flake check and its oracle were all removed on 2026-09-23. +> The document is kept as the record of what MEM-2 built and why it was undone; +> everything below describes the mechanism in the present tense as it stood +> until that date. Nothing below is live. +> +> **Why it went.** The session process WAITS for a SessionEnd hook before it +> exits. The runs that actually harvested took up to 57 s, so Claude Code +> aborted the hook and printed `SessionEnd hook [...] failed: Hook cancelled` +> on every close. Of the last 101 logged runs, 54 were skips — most of them +> `one cleaned user/assistant turn exceeds the declared utility context`. The +> cost was paid on every session end; the benefit landed on fewer than half. +> +> **What survives.** The `harvest` verb and `ai_memory.py` are untouched, and +> the `drain` skill still invokes them on demand. Only the automatic +> close-triggered leg is gone. MEM-3's probe (`tools/mem-3-eval-probe.sh`) +> still covers the verb. +> +> **The guard.** `flake.nix` now carries `checks.no-claude-code-hooks`, which +> fails if any hook block returns to `home/dot_claude/settings.json` or if +> anything is delivered into `~/.claude/hooks`. Re-adding a hook is a +> deliberate edit to that check. + MEM-2 (dotfiles#339). Mechanism: `~/sept8/MECHANISM-2026-09-07.md` §6b. Decisions: `~/research-methods/DECISIONS.md` D-E07, D-E13, D-E14. diff --git a/docs/theme-switcher-2026-09-17.md b/docs/theme-switcher-2026-09-17.md new file mode 100644 index 000000000..63db2c7c8 --- /dev/null +++ b/docs/theme-switcher-2026-09-17.md @@ -0,0 +1,127 @@ +# Theme switcher: noir, claude-dark, claude-light (2026-09-17) + +Three hand-curated themes, one palette source of truth in Nix, one symlink flipped +at runtime. No palette generation from wallpapers (matugen/pywal), no Home Manager +specialisations — see "Why not" below. Research report with the full option +analysis and the light-theme ANSI derivation: `~/colors/theme-switcher-design.md` +(not in this repo; ~/colors holds the colour investigation). + +## The three themes + +| theme | ground (terminal) | desktop | accents | source | +|---|---|---|---|---| +| `noir` | `#000000` | `#000000` | Claude Dark's 12-role syntax palette | Tom's catppuccin-noir; accents matched claude.ai's bundle bit for bit | +| `claude-dark` | `#1A1A1A` (code-block ground) | `#20201F` (`--bg-000`) | same as noir | `~/colors/waves/capture/claude-code-theme/claude-tokens-dark.json` | +| `claude-light` | `#F9F9F7` (`--bg-100`) | `#F0EFEC` (`--bg-300`) | Claude Light's 12-role palette | `claude-themes-SOURCE.json`, `claude-tokens-light.json` | + +noir and claude-dark differ only in grounds (`home/themes/claude-dark.nix` is +`noir // { ground; muted; … }`). Claude ships no 16-slot ANSI palette, so +claude-light's is designed: roles fill slots 1–6, `--warning-100` fills the yellow +Claude has no role for, and `color0/7/15` are dark (punctuation / text-secondary / +fg) because herdr's `terminal` theme uses `White`/`Gray` as foregrounds. Every slot +passes WCAG AA on white. It is a proposal to tune in `~/colors/colorlab`. + +## How it is wired + +``` +home/themes/{noir,claude-dark,claude-light}.nix palettes, keyed by ROLE +home/themes/default.nix render: palette -> per-app fragments +home/theme.nix HM: ~/.config/themes//{kitty.conf,niri.kdl,colors.fish,theme.lua,meta} + + activation: bootstrap/heal ~/.config/theme +~/.config/theme -> ~/.config/themes/ the pointer; the ONLY runtime state +home/dot_local/bin/theme flip the pointer, fire reloads +``` + +Every RAW config keeps its body raw and joins its fragment through the app's own +include of `~/.config/theme/` — the same move as `kitty-scrollback-nix.conf` +and `niri-local.kdl` (generated files at a neutral path, included from inside a +whole-dir RAW symlink). Home Manager owns `themes/` (plural); the switcher owns +`theme` (singular). Switching needs no rebuild; adding a theme or changing a +palette value does. + +| consumer | joins via | live reload on `theme ` | +|---|---|---| +| kitty | `include ${HOME}/.config/theme/kitty.conf` (kitty.conf) | `kitten @ --to unix:@kitty- load-config` per instance (sockets from `/proc/net/unix`) | +| ghostty (cmux Browser panes) | `config-file = ?/home/tom/.config/theme/ghostty` LAST in ghostty/config.ghostty (a config-file loads after its parent, so the fragment wins; absolute because Ghostty resolves relative paths against the symlinked config's dir) | nothing — libghostty reads the config when cmux opens a surface; new panes follow | +| niri | `include optional=true "~/.config/theme/niri.kdl"` LAST in config.kdl; sections merge, later wins | `niri msg action load-config-file` | +| herdr | `[theme] name = "terminal"`: every token is an ANSI slot | nothing — kitty reports the bg change via DEC 2031 (`CSI ?997;n`), herdr re-queries OSC 10/11/4 and repaints chrome + every pane | +| nvim | `~/.local/bin/nvim-lua/theme.lua` dofile()s `theme.lua`; catppuccin/bufferline/lualine read it | `nvim --server --remote-expr` → `require('theme').reload()` per instance | +| fish | `conf.d/colors.fish` sources the fragment; re-sources at the next prompt when the pointer moved | automatic (no universal variables: `fish_variables` is tracked) | +| starship | already ANSI-named; the one `#F47B85` became `red` | follows kitty | +| Claude Code | `theme` key in `~/.claude.json` ← `meta` `claude_code=` | written tmp+rename; takes effect on next start | +| GTK3 | `gsettings gtk-theme` (`theme apply`) → MacTahoe-Dark-grey / MacTahoe-Claude-{Dark,Light}-orange | live: GTK3's Wayland backend reads org.gnome.desktop.interface from dconf and follows "changed". Needs the schema on XDG_DATA_DIRS (modules/common.nix) and no `GTK_THEME` env — both fixed in this PR | +| GTK4 / libadwaita | `~/.config/gtk-4.0/{gtk.css,gtk-dark.css,assets}` → `~/.config/theme/gtk-4.0/` (home/theme.nix); `color-scheme` via gsettings | color-scheme live (portal); gtk.css on next app start | +| icons / folder colour | `gsettings icon-theme MacTahoe[-]-{dark,light}` (`theme icons`) | live; accent from the wallpaper, polarity from the theme | +| Chrome | follows `color-scheme` through the portal | live | +| qt6ct | not yet | — | + +**GTK themes.** `pkgs/mactahoe-gtk-theme.nix` builds MacTahoe from source per +`variant`: `oled` (noir, the existing OLED-black substitutions) and `claude` (both +the light and the dark branch of `src/sass/_colors.scss` recoloured to claude.ai's +tokens — bg-000/100/200, text-000/200/400, links = accent-100 — with the `orange` +accent slot set to Anthropic clay `#D97757`). Theme dirs: `MacTahoe-Claude-{Dark,Light}[-solid]-orange[-(x)hdpi]`. +`GTK_THEME` is gone from `environment.sessionVariables`: it pinned one theme for +the whole session and could not change live. It had been load-bearing only +because `gsettings-desktop-schemas` was never on XDG_DATA_DIRS, so GTK fell back +to `settings.ini`; verified with `gtk-query-settings` before and after. + +**Wallpaper accents and folder colours.** `wallpaper ` sets one of the +seven claude.ai/imagine grounds (oat olive cactus sky fig heather coral, all +rendered at 5120x2880 from the recovered SVG), remembers it in +`~/.local/state/wallpaper/accent`, and calls `theme icons`. +`pkgs/mactahoe-icon-theme.nix` prebuilds `MacTahoe-{,-light,-dark}` for all +seven — folders in the accent's darker ground (`--bg-primary-dark`) — so every +combination is on the system already; everything but the folder SVGs dedupes. + +`F2` opens an fzf picker for the theme, `Shift+F2` one for the accent (wallpaper + +folder colour), both in the F1/F9/F10 prompt style; `Mod+Shift+T` cycles. `theme` prints the current name; `theme list`, +`theme apply` (re-fire hooks, also run at login from startup.kdl), `theme toggle`, +`theme icons`. + +## Testing before a switch + +`home/themes/default.nix` is pure (`{ lib }`), so a preview renders without a +generation: + +```sh +nix eval --json --impure --expr 'let lib = (builtins.getFlake (toString ./.)).inputs.nixpkgs.lib; in (import ./home/themes { inherit lib; }).fragments' \ + | python3 -c 'import json,sys,os; [ (os.makedirs(os.path.dirname(p:=os.path.expanduser("~/.cache/theme-preview/"+k.split("/",1)[1])),exist_ok=True), open(p,"w").write(v)) for k,v in json.load(sys.stdin).items() ]' +ln -sfn ~/.cache/theme-preview/noir ~/.config/theme +THEMES_DIR=~/.cache/theme-preview theme claude-dark +``` + +`home.activation.themePointer` re-targets a pointer left outside `~/.config/themes/` +to the live theme of the same name on the next switch. + +## Why not + +- **Home Manager specialisations**: HM runs as a NixOS module here + (`flake.nix`, `home-manager.nixosModules.home-manager`), so the base generation + re-activates at every boot and a specialisation does not survive a reboot; and + out-of-store symlinks are theme-invariant by construction, so every themed file + would have to leave the RAW doctrine. +- **matugen / pywal / wallust / stylix**: generators for palettes derived from a + wallpaper; three fixed palettes need only their *template + include + reload* + mechanics, which is what `default.nix` + `theme` are. +- **darkman**: two states, no "noir vs claude-dark". Could later *schedule* + `theme claude-light` / `theme ` by time of day. + +## Hazards + +- A symlink retarget fires no watcher event: the explicit kitty/niri/nvim reloads + in `theme` are load-bearing. +- kitty's `dynamic_background_opacity` stays in the RAW kitty.conf: it cannot be + changed by reload and must be present at startup. +- herdr's in-app settings overlay upserts `[theme] name` and `auto_switch = false` + into the tracked `config.toml`. Do not use it; `terminal` is the one setting. +- `~/.claude.json` is rewritten by Claude Code itself: the switcher writes tmp+rename + and accepts a lost update against a running instance. Running agents keep their + theme until restarted. +- Client laptop: `git pull` ahead of `nixos-rebuild switch` leaves kitty/niri/fish/nvim + including a pointer that does not exist yet. niri's include is `optional`, nvim's + loader falls back to noir, fish and kitty fall back to their defaults — degraded, + not broken — until the switch renders `~/.config/themes/`. +- Kitty windows reload in place; agents' TUIs and GTK4 apps restart. +- Fonts (Anthropic Sans/Serif/Mono from ~/colors) are NOT part of this: another + session owns that spec; they are proprietary brand faces and must follow the + NAS `requireFile` pattern of pkgs/sf-pro.nix, never land in git. diff --git a/flake.lock b/flake.lock index bbddca031..a14843125 100644 --- a/flake.lock +++ b/flake.lock @@ -61,16 +61,16 @@ ] }, "locked": { - "lastModified": 1788011267, - "narHash": "sha256-mEq2kU+ljompTToZ44Afvm7d/rHJ5iMBl0tjZuyShJs=", + "lastModified": 1790001035, + "narHash": "sha256-1/SLBjSYBzDdDhvLQ83wrzBakyS/xNGgtxHGNuveOEQ=", "owner": "Mic92", "repo": "bun2nix", - "rev": "5765b0614591f75ee8ba5596e81ae85c167d1071", + "rev": "07a5bfc8ac36c5370199343fa620b69f63186e33", "type": "github" }, "original": { "owner": "Mic92", - "ref": "fix-structured-attrs-hook", + "ref": "llm-agents", "repo": "bun2nix", "type": "github" } @@ -503,11 +503,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1788932393, - "narHash": "sha256-xo0f0bAiTuMpm7ISCRoJGnqljcKXHBYdy9U4WgHmbGc=", + "lastModified": 1790135247, + "narHash": "sha256-HEx3RapORjJCm6JPVLAAhWDJ+FLlKOuGsomSzB5f+OA=", "owner": "numtide", "repo": "llm-agents.nix", - "rev": "4a6df59bfd94e0a0059455a15d0d1df8b7c7610c", + "rev": "f54d9ae0813ccd2ad41cf99abf8d1a2a58eedb24", "type": "github" }, "original": { @@ -707,11 +707,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1788894124, - "narHash": "sha256-guyexwrrF5GBKqjO0eg9LNIvrXn4j2frNak63sDr8zg=", + "lastModified": 1790055231, + "narHash": "sha256-QpXotokGh5eeMG1Njiuh27oP0rK6t5+cMp7ymXSs7mc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "da39501c8d0a093136854eddcd6927c8a8bb0d8f", + "rev": "b6c98e9e6633ee64753b594ff4a5febf0367fc00", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 0308a6c03..9155f32a2 100644 --- a/flake.nix +++ b/flake.nix @@ -289,7 +289,7 @@ # piri — niri IPC extension daemon (github.com/Asthestarsfalll/piri): one # Rust daemon that tails niri's event stream and layers plugins on top — # scratchpads, marks, window/workspace rules. We use it for the "music" - # auto-scratchpad (Mod+M toggles a right-side SoundCloud/cliamp pane). + # auto-scratchpad (Mod+M toggles a right-side SoundCloud pane). # Third-party but consumed exactly like tally: flake input pinned in # flake.lock, follows nixpkgs so the Rust build resolves against our one pin. # piri ships packages.default + a NixOS module, but NOT a home-manager @@ -660,6 +660,7 @@ # requiring the binaries to be installed on the caller's PATH. local-models-prune mactahoe-gtk-theme + mactahoe-claude-gtk-theme mactahoe-icon-theme music-acquire sfmono-liga @@ -2066,6 +2067,8 @@ assert coordinatorHome.services.tally.enable; # Direct Parakeet is coordinator-only; capture has no virtual mic or # service-start download. Native Herdr owns client key handling. + # The #448 socket-activation asserts rode in on the harvest-hook + # commit without #448 itself; they return with #448 (DF-6). assert coordinatorHome.systemd.user.services.parakeet-service.Install.WantedBy == [ "default.target" ]; assert !(coordinatorHome.systemd.user.services ? voxtype); @@ -2440,76 +2443,39 @@ touch "$out" ''; - # MEM-2 (dotfiles#339): SessionEnd -> the harvest verb. The asserts - # are EVALUATION-time on purpose — `nix flake check --offline --no-build` - # evaluates and does not build, so the wiring this unit adds (the hook - # block, the path it names, the timeout ordering, the delivered file) is - # checked by the same command the unit's oracle already runs. - ai-memory-harvest-hook = + # NO Claude Code hook is declared, and none is delivered. The MEM-2 + # SessionEnd harvest hook (dotfiles#339) was removed 2026-09-23: the + # session process WAITS for a SessionEnd hook, and the runs that + # actually harvested took up to 57 s, so Claude Code aborted the hook + # and reported "SessionEnd hook [...] failed: Hook cancelled" on every + # close. Of its last 101 logged runs 54 were skips. The harvest verb is + # untouched and still reachable on demand through the `drain` skill. + # + # This check is the GUARD on that removal, and it is deliberately + # broader than the block it replaces: it fails if ANY hook comes back + # into settings.json, not just a SessionEnd one. The original reason + # still holds — the dead SessionStart hook removed 2026-09-13 named + # ~/.claude/hooks/herdr-agent-state.sh, a file that existed in no + # repository and on no disk, so it failed on every session start. A + # hook naming a script the repository does not ship must not come back. + # Re-adding a hook here is a deliberate edit to this check. + # + # EVALUATION-time on purpose, like the block it replaces: + # `nix flake check --offline --no-build` evaluates and does not build. + no-claude-code-hooks = let - lib = nixpkgs.lib; homeConfig = self.nixosConfigurations.coordinator.config.home-manager.users.tom; settings = builtins.fromJSON (builtins.readFile ./home/dot_claude/settings.json); - hookPath = "/home/tom/.claude/hooks/ai-memory-harvest.sh"; - hookText = builtins.readFile ./home/dot_claude/hooks/ai-memory-harvest.sh; - sessionEnd = settings.hooks.SessionEnd; - entry = builtins.head (builtins.head sessionEnd).hooks; - # The script's own timeout must fire BEFORE Claude Code's, so the - # hook always ends by its own hand and always writes its log line. - scriptTimeout = 420; in - # Exactly one SessionEnd matcher, carrying exactly one command hook. - assert builtins.length sessionEnd == 1; - assert builtins.length (builtins.head sessionEnd).hooks == 1; - assert entry.type == "command"; - assert entry.command == "bash '${hookPath}'"; - assert entry.timeout > scriptTimeout; - assert lib.hasInfix "AI_MEMORY_HARVEST_HOOK_TIMEOUT:-${toString scriptTimeout}}" hookText; - # The hook runs `harvest` and nothing else: the drain verb, the - # journal and branch (a)'s live state dir are absent from the script. - assert lib.hasInfix "python3 \"$engine\" \"\${harvest_argv[@]}\"" hookText; - # FIX-E08 (dotfiles#348): the close -> row -> floor leg is wired. The - # verb is still `harvest` and the flag is `--enqueue`, so a hook that - # writes a note but no rows cannot pass evaluation again. - assert lib.hasInfix "harvest_argv=(harvest)" hookText; - assert lib.hasInfix "harvest_argv+=(--enqueue)" hookText; - assert !(lib.hasInfix "$engine\" drain" hookText); - assert !(lib.hasInfix "state/tally/" hookText); - # No SessionStart hook. The block MEM-2 found (DF-MEM-2-2) named - # ~/.claude/hooks/herdr-agent-state.sh, a file that existed in no - # repository and on no disk, so it failed on every session start; it - # was removed 2026-09-13 rather than restored. This assert keeps a - # hook naming a script the repository does not ship from coming back. - assert !(settings.hooks ? SessionStart); - # The file the block names is actually delivered, as ONE link (not a - # whole-dir one), so ~/.claude/hooks stays a real, writable directory. - assert homeConfig.home.file ? ".claude/hooks/ai-memory-harvest.sh"; - assert - homeConfig.home.file.".claude/hooks/ai-memory-harvest.sh".target - == ".claude/hooks/ai-memory-harvest.sh"; - # mkOutOfStoreSymlink names its store entry after the file it points - # at, so this is the out-of-store link and not a copied-in blob: the - # hook stays editable in the checkout, like every other raw dotfile. - assert lib.hasSuffix "-hm_aimemoryharvest.sh" ( - toString homeConfig.home.file.".claude/hooks/ai-memory-harvest.sh".source - ); + assert !(settings ? hooks); + # Nothing is delivered into ~/.claude/hooks, and the directory itself + # is still not a whole-dir link, so it stays real and writable. + assert !(homeConfig.home.file ? ".claude/hooks/ai-memory-harvest.sh"); assert !(homeConfig.home.file ? ".claude/hooks"); - pkgs.runCommand "ai-memory-harvest-hook" - { - nativeBuildInputs = [ pkgs.python3 ]; - } - '' - set -euo pipefail - - export HOME="$TMPDIR/home" - export PYTHONDONTWRITEBYTECODE=1 - export MEM2_HOOK=${./home/dot_claude/hooks/ai-memory-harvest.sh} - mkdir -p "$HOME" - - bash ${./tests/ai-memory-hook/harvest-hook-test.sh} - - touch "$out" - ''; + pkgs.runCommand "no-claude-code-hooks" { } '' + set -euo pipefail + touch "$out" + ''; print-paper = pkgs.runCommand "print-paper" diff --git a/home/dot_claude/hooks/ai-memory-harvest.sh b/home/dot_claude/hooks/ai-memory-harvest.sh deleted file mode 100644 index 77cc37a7f..000000000 --- a/home/dot_claude/hooks/ai-memory-harvest.sh +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env bash -# home/dot_claude/hooks/ai-memory-harvest.sh — SessionEnd -> the harvest verb. -# -# UNIT: MEM-2 (dotfiles#339). CARD: /home/tom/sept7/plan/UNITS-2026-09-06.json. -# SPEC: MECHANISM-2026-09-07 §6b ("harvest on close"); DECISIONS D-E07, D-E13. -# EXEMPLAR: the SessionStart block home/dot_claude/settings.json carried when -# this was written (removed 2026-09-13, DF-MEM-2-2); this is now the only hook -# the repository declares. -# -# WHAT CLAUDE CODE GIVES IT. The hook payload arrives on stdin as one JSON -# object — session_id, transcript_path, cwd, hook_event_name, reason — and the -# session process WAITS for this script before it exits (MEASURED 2026-09-07: a -# SessionEnd hook sleeping 15 s made `claude -p 'reply with the single word ok'` -# take 17 s wall). Three properties follow from that wait and none is optional: -# -# * it exits 0 on every path, including the ones that failed. A hook that can -# exit non-zero is a hook that can wedge a session end; -# * every path that spends anything runs the harvest under `timeout`, and that -# timeout is SMALLER than the hook timeout declared in settings.json, so the -# script always ends by its own hand and always reaches its own last line; -# * it appends exactly ONE line to /hook.log naming the session, -# whatever happened — created, updated, unchanged, skipped, failed, timeout. -# One session end, one line: the log is a ledger, not a transcript. -# -# WHAT IT NEVER DOES. It never runs `drain` and it never reads or writes the -# journal — `harvest` is the only verb, and the harvest store is the only thing -# written (D-E13 (3): the store is AI_MEMORY_HARVEST_DIR, else -# $XDG_STATE_HOME/tally-rewrite/harvest, else ~/.local/state/tally-rewrite/ -# harvest per D-E07; branch (a)'s live ~/.local/state/tally is never on any -# path here, and the string that would name it appears nowhere in this file at -# all -- the flake check asserts exactly that). -# -# WHAT IT ENQUEUES. With the harvest note on disk, the same run writes one -# validated enqueue row per unresolved unit of the distillation, under -# /enqueue/.enqueue.json — the verb's own `--enqueue` -# (FIX-E08, dotfiles#348). A harvest that changed nothing writes no rows, so a -# hook that fires twice does not enqueue the same units twice. -# -# It never runs on a child or subagent session: a transcript under a -# `subagents/` directory is refused here by name, and every other non-root -# session is refused by ai_memory.py's own root proof, surfaced as a logged skip. - -set -u - -started=$(date +%s) - -# -------------------------------------------------------------------------- -# The payload. `timeout` guards the read itself: a hook that blocks on a stdin -# that never closes has wedged the session end before it has done anything. -# -------------------------------------------------------------------------- -payload=$(timeout 5 cat 2>/dev/null || true) - -fields=$( - printf '%s' "$payload" | python3 -c ' -import json, sys - -try: - payload = json.loads(sys.stdin.read() or "{}") -except Exception: - payload = {} -if not isinstance(payload, dict): - payload = {} -for key in ("session_id", "transcript_path", "cwd"): - value = payload.get(key) - print(str(value).replace("\n", " ").strip() if isinstance(value, str) else "") -' 2>/dev/null || true -) - -session_id=$(printf '%s\n' "$fields" | sed -n '1p') -transcript_path=$(printf '%s\n' "$fields" | sed -n '2p') -session_cwd=$(printf '%s\n' "$fields" | sed -n '3p') - -# -------------------------------------------------------------------------- -# The store, which is also where this script's own log lives: one override -# moves both, so a test never has to point them apart (D-E13 (3)). -# -------------------------------------------------------------------------- -harvest_dir=${AI_MEMORY_HARVEST_DIR:-} -if [ -z "$harvest_dir" ]; then - harvest_dir="${XDG_STATE_HOME:-$HOME/.local/state}/tally-rewrite/harvest" -fi -mkdir -p "$harvest_dir" 2>/dev/null || true -hook_log="$harvest_dir/hook.log" - -# One line, one session end. `detail` is collapsed to a single line because a -# multi-line log entry would make "the log carries one line per session" false. -log_line() { - status=$1 - detail=$(printf '%s' "${2:-}" | tr '\n\t' ' ' | tr -s ' ' | cut -c1-400) - elapsed=$(( $(date +%s) - started )) - printf '%s session=%s status=%s elapsed=%ss %s\n' \ - "$(date -Is)" "${session_id:-unknown}" "$status" "$elapsed" "$detail" \ - >>"$hook_log" 2>/dev/null || true -} - -if [ -z "$session_id" ]; then - log_line skipped "the SessionEnd payload carried no session_id" - exit 0 -fi - -case "/$transcript_path/" in - */subagents/*) - log_line skipped "child session: transcript is under a subagents directory" - exit 0 - ;; -esac - -# -------------------------------------------------------------------------- -# Where the session's own traces are. The hook inherits CLAUDE_CONFIG_DIR from -# the session that is ending; when it does not, the transcript path names the -# same directory three levels up (/projects//.jsonl), which -# keeps a `claude` launched with an explicit config dir harvestable. -# -------------------------------------------------------------------------- -config_dir=${CLAUDE_CONFIG_DIR:-} -if [ -z "$config_dir" ] && [ -n "$transcript_path" ]; then - candidate=$(dirname "$(dirname "$(dirname "$transcript_path")")") - if [ -d "$candidate/projects" ]; then - config_dir=$candidate - fi -fi -[ -n "$config_dir" ] || config_dir="$HOME/.claude" - -engine=${AI_MEMORY_ENGINE:-} -if [ -z "$engine" ] || [ ! -f "$engine" ]; then - engine="" - for candidate in \ - "$config_dir/skills/drain/scripts/ai_memory.py" \ - "$HOME/.agents/skills/drain/scripts/ai_memory.py" \ - "$HOME/.claude/skills/drain/scripts/ai_memory.py"; do - if [ -f "$candidate" ]; then - engine=$candidate - break - fi - done -fi - -if [ -z "$engine" ]; then - log_line skipped "no ai_memory.py engine found from $config_dir" - exit 0 -fi - -# -------------------------------------------------------------------------- -# The harvest. Its own timeout is the one that fires: settings.json declares a -# larger hook timeout, so Claude Code never has to kill this script, and the log -# line below is always written. -# -# CLAUDE_CODE_CHILD_SESSION is CLEARED rather than trusted. Claude Code sets it -# on subprocesses of a tool call, so any session started from inside another -# session's Bash tool inherits it — and every hook of that session would then -# refuse a perfectly good root session (MEASURED 2026-09-07). The proof of -# rootness that matters is structural and ai_memory.py already makes it: the -# trace must carry a non-sidechain user/assistant record under this session_id, -# and traces under `subagents/` are excluded by name. That proof stands whatever -# the environment says, and a session that fails it is refused below. -# -------------------------------------------------------------------------- -harvest_timeout=${AI_MEMORY_HARVEST_HOOK_TIMEOUT:-420} - -# The enqueue leg (FIX-E08). `harvest` alone wrote the note and stopped there, -# so the close -> row -> floor leg was unwired: nothing scheduled or hooked ever -# called the verb's own `--enqueue`. It is passed here by default, and the rows -# land under /enqueue/ — inside the store this script already owns, -# never beside it and never in branch (a)'s. AI_MEMORY_HARVEST_ENQUEUE=0 is the -# one opt-out (a host whose validator is missing, say); it changes what the verb -# writes, never whether this script exits 0 or logs its one line. -harvest_argv=(harvest) -if [ "${AI_MEMORY_HARVEST_ENQUEUE:-1}" != "0" ]; then - harvest_argv+=(--enqueue) -fi - -if [ -n "$session_cwd" ] && [ -d "$session_cwd" ]; then - cd "$session_cwd" 2>/dev/null || cd "$HOME" 2>/dev/null || true -else - cd "$HOME" 2>/dev/null || true -fi - -# errexit is never on in this script; rc is captured and every branch below -# ends in `exit 0`. -output=$( - env \ - -u CLAUDE_CODE_CHILD_SESSION \ - -u CLAUDE_SESSION_ID \ - -u CODEX_THREAD_ID \ - CLAUDE_CODE_SESSION_ID="$session_id" \ - CLAUDE_CONFIG_DIR="$config_dir" \ - AI_MEMORY_HARVEST_DIR="$harvest_dir" \ - timeout -k 5 "$harvest_timeout" python3 "$engine" "${harvest_argv[@]}" 2>&1 -) -rc=$? - -case "$rc" in - 0) - # ai_memory.py prints "created: ", "updated: " or - # "unchanged: "; the word before the colon IS the status. - status=${output%%:*} - case "$status" in - created | updated | unchanged) ;; - *) status=harvested ;; - esac - log_line "$status" "$output" - ;; - 124 | 137) - log_line timeout "the harvest did not finish within ${harvest_timeout}s" - ;; - 1) - # ai_memory.py's own refusals — a non-root session, an unresolvable trace, - # no utility model on this host — all exit 1 with `ai-memory: `. - log_line skipped "$output" - ;; - *) - log_line failed "exit $rc: $output" - ;; -esac - -exit 0 diff --git a/home/dot_claude/settings.json b/home/dot_claude/settings.json index 5d80e5e5d..1b6d3b8ae 100644 --- a/home/dot_claude/settings.json +++ b/home/dot_claude/settings.json @@ -13,19 +13,5 @@ "effortLevel": "high", "skipDangerousModePermissionPrompt": true, "skipAutoPermissionPrompt": true, - "voiceEnabled": true, - "hooks": { - "SessionEnd": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "bash '/home/tom/.claude/hooks/ai-memory-harvest.sh'", - "timeout": 600 - } - ] - } - ] - } + "voiceEnabled": true } diff --git a/home/dot_config/cliamp/config.toml b/home/dot_config/cliamp/config.toml deleted file mode 100644 index 36ce053a5..000000000 --- a/home/dot_config/cliamp/config.toml +++ /dev/null @@ -1,55 +0,0 @@ -# Requires cliamp >= 1.10 (we ship 1.63.2, pkgs/cliamp.nix). The v1.9.0 this -# repo pinned until 2026-08-18 parsed config.toml with a flat key=value -# scanner over ten hardcoded keys: it skipped [section] headers, matched -# nothing below them, and did no ${VAR} expansion — so every section in this -# file was silently inert on that build. -# -# GAIN IN dB, NOT PERCENT — 0 is the "100%" setting: unity gain, samples pass -# through untouched, and the system mixer alone decides how loud things are. -# Negative attenuates, positive boosts. The ceiling is always +6; the floor is -# volume_min (default -50, itself clamped to [-90, 0]), and with no volume_min -# set here the effective floor is -50. The -30 dB figure cliamp's own `--vol` -# help text quotes belongs to other code paths entirely — the CLI flag, the Lua -# plugin API and the MPRIS volume mapping — not to this key. -# This read 70 until 2026-08-18, which clamped to +6 — a 2x boost applied -# before the system volume ever saw the signal, with clipping on loud masters. -volume = 0 -repeat = "off" -shuffle = false -theme = "" -provider = "navidrome" - -eq_preset = "Custom" -eq = [-1, 0, 0, 0, 0, 0, 0, 0, 0, 0] -speed = 1.00 -[navidrome] -# Tailnet MagicDNS name, not localhost: this config is shared fleet-wide and -# only the coordinator could reach localhost. It had a second reader, the -# zenbook-duo, until that host left the fleet on 2026-08-30. -url = "http://coordinator.tail8dd1.ts.net:4533" -user = "${NAVIDROME_USER}" -password = "${NAVIDROME_PASSWORD}" -browse_sort = "alphabeticalByName" -scrobble = true - -# SoundCloud: off by default upstream. yt-dlp-backed (no client_id/secret model — -# SoundCloud closed OAuth signups in 2014). `user` swaps the curated genre browse -# list for lemurisback's own Tracks/Likes/Reposts. -[soundcloud] -enabled = true -user = "lemurisback" -# Go+ restored 2026-08-03 (dotfiles#70). cliamp shells to -# `yt-dlp --cookies-from-browser chrome`, so Chrome must be signed into -# SoundCloud as lemurisback on whichever box runs cliamp (the coordinator). -# Note: SoundCloud now serves major-label/monetized catalog -# over DRM-encrypted HLS that yt-dlp cannot decrypt (won't-fix upstream, -# yt-dlp#17335) -- this unlocks the non-DRM Go+ subset (most indie/DJ -# uploads), not the whole catalog. -cookies_from = "chrome" - -# YouTube / YouTube Music: no config needed — works out of the box with cliamp's -# built-in fallback OAuth client. Just select it in the provider browser (Esc/b). -# -# Bandcamp: no dedicated provider/config section exists in cliamp. It plays only -# via direct yt-dlp URL handling — paste a bandcamp.com URL with `u` in the TUI, -# or `cliamp https://artist.bandcamp.com/album/x` from the shell. diff --git a/home/dot_config/cliamp/themes/catppuccin-noir.toml b/home/dot_config/cliamp/themes/catppuccin-noir.toml deleted file mode 100644 index 4491d14c0..000000000 --- a/home/dot_config/cliamp/themes/catppuccin-noir.toml +++ /dev/null @@ -1,11 +0,0 @@ -# catppuccin-noir — catppuccin mocha on pure OLED black (#000000), -# with Claude Code accent colors layered on top. -# Mirrors the exact overrides in dot_config/nvim/lua/plugins.lua -# and the kitty palette in dot_config/kitty/kitty.conf. - -accent = "#70B8FF" # blue — titles, seek bar, selection, interactive -bright_fg = "#EAECF0" # text — primary text, time display -fg = "#818898" # overlay1 — muted labels, help bar, inactive -green = "#9BE963" # green — playing indicator, volume bar, spectrum low -yellow = "#f9e2af" # yellow — spectrum middle -red = "#F47B85" # red — spectrum high, errors diff --git a/home/dot_config/fish/conf.d/colors.fish b/home/dot_config/fish/conf.d/colors.fish index 947522117..a7af207a7 100644 --- a/home/dot_config/fish/conf.d/colors.fish +++ b/home/dot_config/fish/conf.d/colors.fish @@ -1,63 +1,28 @@ -# Color Palette (Claude-inspired, mapped from catppuccin mocha nvim overrides) -set -l normal eaecf0 -set -l command 70b8ff -set -l param 9be963 -set -l keyword f47b85 -set -l quote 9be963 -#set -l redirection f5c2e7 # catppuccin pink — no nvim override -set -l end fbad60 -set -l comment 818898 -set -l error f47b85 -#set -l gray 6c7086 # catppuccin overlay0 — no nvim override -#set -l selection 313244 # catppuccin surface0 — no nvim override -#set -l search_match 313244 # catppuccin surface0 — no nvim override -set -l option 9be963 -#set -l operator f5c2e7 # catppuccin pink — no nvim override -#set -l escape eba0ac # catppuccin maroon — no nvim override -#set -l autosuggestion 6c7086 # catppuccin overlay0 — no nvim override -set -l cancel f47b85 -set -l cwd f9e2af -set -l user 5eeded -set -l host 70b8ff -set -l host_remote 9be963 -#set -l status f38ba8 -#set -l pager_progress 6c7086 # catppuccin overlay0 — no nvim override -#set -l pager_prefix f5c2e7 # catppuccin pink — no nvim override -set -l pager_completion eaecf0 -#set -l pager_description 6c7086 # catppuccin overlay0 — no nvim override - -# Syntax Highlighting Colors -set -g fish_color_normal $normal -set -g fish_color_command $command -set -g fish_color_param $param -set -g fish_color_keyword $keyword -set -g fish_color_quote $quote -#set -g fish_color_redirection $redirection # no nvim override -set -g fish_color_end $end -set -g fish_color_comment $comment -set -g fish_color_error $error -#set -g fish_color_gray $gray # no nvim override -#set -g fish_color_selection --background=$selection # no nvim override -#set -g fish_color_search_match --background=$search_match # no nvim override -set -g fish_color_option $option -#set -g fish_color_operator $operator # no nvim override -#set -g fish_color_escape $escape # no nvim override -#set -g fish_color_autosuggestion $autosuggestion # no nvim override -set -g fish_color_cancel $cancel -set -g fish_color_cwd $cwd -set -g fish_color_user $user -set -g fish_color_host $host -set -g fish_color_host_remote $host_remote +# Colours come from the theme fragment ~/.config/theme/colors.fish, rendered by +# home/theme.nix from home/themes/.nix for whichever theme ~/.config/theme +# points at (~/.local/bin/theme retargets it). This file only loads it — at +# startup, and again at the next prompt after the pointer has moved, so running +# shells follow a `theme` switch too. No universal variables: `set -U` would +# write fish_variables, which is tracked in this repo. +# +# The theme-independent bits stay here. set -g fish_color_cwd_root red set -g fish_color_history_current --bold set -g fish_color_match --background=brblue set -g fish_color_valid_path --underline +set -g fish_pager_color_selected_background -r -set -g fish_color_status f47b85 +function __theme_load --description 'source ~/.config/theme/colors.fish if the pointer moved' + set -l target (readlink ~/.config/theme 2>/dev/null) + if test -n "$target" -a "$target" != "$__theme_loaded" + set -g __theme_loaded $target + if test -r ~/.config/theme/colors.fish + source ~/.config/theme/colors.fish + end + end +end +__theme_load -# Completion Pager Colors -#set -g fish_pager_color_progress $pager_progress # no nvim override -#set -g fish_pager_color_prefix $pager_prefix # no nvim override -set -g fish_pager_color_completion $pager_completion -#set -g fish_pager_color_description $pager_description # no nvim override -set -g fish_pager_color_selected_background -r +function __theme_on_prompt --on-event fish_prompt + __theme_load +end diff --git a/home/dot_config/fish/config.fish b/home/dot_config/fish/config.fish index 86fcedec8..5fa17cde4 100644 --- a/home/dot_config/fish/config.fish +++ b/home/dot_config/fish/config.fish @@ -42,36 +42,51 @@ alias l.="eza -a | grep -E \"^\.\"" alias cd='z' # zi command -# Claude Code -alias cc='claude --dangerously-skip-permissions' -alias cac='claude --continue --dangerously-skip-permissions' -# Second Claude account (work). CLAUDE_CONFIG_DIR isolates login, history and -# state in ~/.claude-work; skills/settings are the same dotfiles links (home.nix). -alias cc2='env CLAUDE_CONFIG_DIR=$HOME/.claude-work claude --dangerously-skip-permissions' -alias cac2='env CLAUDE_CONFIG_DIR=$HOME/.claude-work claude --continue --dangerously-skip-permissions' +# Claude Code: two seats, one launcher shape (2026-09-22). +# cc / cac -> ~/.claude the personal Claude Max login (gmail org) +# cc2 / cac2 -> ~/.claude-work the leger.run Claude Max login +# CLAUDE_CONFIG_DIR isolates login, history and state per seat; skills and +# settings are the same dotfiles links for both (home.nix). TALLY_SEAT names the +# seat to hooks and receipts. Claude Code never saves workspace trust for $HOME +# (docs, security: "trust acceptance is held for the current session only and +# is not written to disk"), so a seat started in ~ shows "Accessing workspace" +# on every launch. Both launchers therefore move into the desk envelope when +# typed from ~; the shell stays there afterwards, which is where the desk +# session's files belong (~/today/CLAUDE.md section 4). +set -q CLAUDE_ENVELOPE; or set -gx CLAUDE_ENVELOPE $HOME/today +function __claude_seat --description 'start one Claude seat outside $HOME' + set -l seat $argv[1] + set -l config_dir $argv[2] + set -l args $argv[3..] + if test (pwd) = $HOME + if not test -d $CLAUDE_ENVELOPE + echo "$seat: \$CLAUDE_ENVELOPE ($CLAUDE_ENVELOPE) does not exist; cd into a project first" >&2 + return 1 + end + builtin cd $CLAUDE_ENVELOPE + echo "($seat: started in $CLAUDE_ENVELOPE, not in ~)" >&2 + end + # A terminal opened from inside a cc2 session already carries + # CLAUDE_CONFIG_DIR, so `cc` must clear it explicitly or it silently starts + # cc2 (measured 2026-09-22). Each seat sets its own value or none. + if test -n "$config_dir" + env CLAUDE_CONFIG_DIR=$config_dir TALLY_SEAT=$seat claude --dangerously-skip-permissions $args + else + env -u CLAUDE_CONFIG_DIR TALLY_SEAT=$seat claude --dangerously-skip-permissions $args + end +end +function cc; __claude_seat cc "" $argv; end +function cac; __claude_seat cc "" --continue $argv; end +function cc2; __claude_seat cc2 $HOME/.claude-work $argv; end +function cac2; __claude_seat cc2 $HOME/.claude-work --continue $argv; end # Third Claude account (2026-09-05), same rotation: state in ~/.claude-3. alias cc3='env CLAUDE_CONFIG_DIR=$HOME/.claude-3 claude --dangerously-skip-permissions' alias cac3='env CLAUDE_CONFIG_DIR=$HOME/.claude-3 claude --continue --dangerously-skip-permissions' # ── Music ──────────────────────────────────────────────────────────────── -# The library lives on the NAS and is served by Navidrome; cliamp is the client. -# `cliamp` here is the wrapper function in functions/cliamp.fish, which injects -# the server and credentials from agenix — so bare cliamp opens straight into -# the Navidrome browser with no argument needed. Inside the TUI: Shift+N opens -# Browse (By Album / By Artist / By Artist-Album), Ctrl+F searches, Q quits. -alias m='cliamp' # open the library browser -alias music='cliamp' # same, spelled out -alias mshuffle='cliamp --shuffle --auto-play' # open it with shuffle already on - -# Transport control from outside the TUI. cliamp registers MPRIS as -# org.mpris.MediaPlayer2.cliamp, so -p cliamp addresses it specifically rather -# than whatever player playerctl happens to enumerate first (Chrome also -# registers, and would otherwise swallow these). -alias mp='playerctl -p cliamp play-pause' # play/pause (F3 does this too) -alias mn='playerctl -p cliamp next' # next track -alias mb='playerctl -p cliamp previous' # back a track -alias mnow='playerctl -p cliamp metadata --format "{{artist}} — {{title}}"' # what's playing - +# The library lives on the NAS and is served by Navidrome (hosts/nas/media.nix). +# The cliamp TUI client and its m/music/mp/mn/mb/mnow aliases were removed +# 2026-09-17 (unused). alias mscan='navidrome-scan' # reindex after a beets pass (--full for tag-only edits) type -q atuin || exit diff --git a/home/dot_config/fish/functions/cliamp.fish b/home/dot_config/fish/functions/cliamp.fish deleted file mode 100644 index 73e988589..000000000 --- a/home/dot_config/fish/functions/cliamp.fish +++ /dev/null @@ -1,40 +0,0 @@ -function cliamp --wraps cliamp --description "cliamp wired to the NAS Navidrome (credentials from agenix)" - # Every variable here is declared at function scope BEFORE the if, then - # assigned inside it. fish scopes `set -l` to the enclosing block, so - # exporting from within the `if` would drop the values before the final - # `command cliamp` ever runs — the provider would silently vanish and cliamp - # would fall back to the radio browser. - set -l url $NAVIDROME_URL - set -l user "" - set -l pass "" - - # URL is the coordinator's tailnet name, never localhost or `nas`: Navidrome - # runs on the NAS (hosts/nas/media.nix), reached from the coordinator over - # the LAN (the 10.77.0.0/30 tether is retired, #264), so the coordinator's - # navidrome-relay socket (hosts/coordinator/nas-client.nix) is the sole - # front door for every box. - test -n "$url"; or set url http://coordinator.tail8dd1.ts.net:4533 - - set -l creds /run/agenix/navidrome-credentials - if test -r $creds - set user (grep '^NAVIDROME_USER=' $creds | string replace 'NAVIDROME_USER=' '') - set pass (grep '^NAVIDROME_PASSWORD=' $creds | string replace 'NAVIDROME_PASSWORD=' '') - else - # Only the coordinator gets the secret (secrets.nix). Anywhere else, - # say so instead of opening a silently Navidrome-less browser. - echo "cliamp: $creds unreadable — Navidrome disabled, local files only" >&2 - end - - # config.toml's [navidrome] block interpolates ${NAVIDROME_USER} and - # ${NAVIDROME_PASSWORD}, so those two names must match its placeholders - # exactly. NAVIDROME_URL/USER/PASS feed navidrome.NewFromEnv(), the - # config-less fallback main.go tries second — it needs all three non-empty - # or it returns nil. Exporting both sets means cliamp connects whether or - # not config.toml is present, and both paths name the same server. - set -lx NAVIDROME_URL $url - set -lx NAVIDROME_USER $user - set -lx NAVIDROME_PASSWORD $pass - set -lx NAVIDROME_PASS $pass - - command cliamp $argv -end diff --git a/home/dot_config/fish/functions/navidrome-scan.fish b/home/dot_config/fish/functions/navidrome-scan.fish index 5ffe7cadc..ae2b5b5d5 100644 --- a/home/dot_config/fish/functions/navidrome-scan.fish +++ b/home/dot_config/fish/functions/navidrome-scan.fish @@ -23,7 +23,7 @@ function navidrome-scan --description "Trigger a Navidrome library rescan and wa set -l url $NAVIDROME_URL test -n "$url"; or set url http://coordinator.tail8dd1.ts.net:4533 - set -l auth "u=$user&p=$pass&v=1.16.1&c=cliamp&f=json" + set -l auth "u=$user&p=$pass&v=1.16.1&c=navidrome-scan&f=json" # First contact wakes the relay and the sleeping NAS service; the HDD may # also have to spin up, so allow a generous timeout here. diff --git a/home/dot_config/ghostty/config.ghostty b/home/dot_config/ghostty/config.ghostty new file mode 100644 index 000000000..bbca8b32f --- /dev/null +++ b/home/dot_config/ghostty/config.ghostty @@ -0,0 +1,83 @@ +# ghostty — kitty.conf's twin (2026-09-17). +# +# The only Ghostty on the fleet today is libghostty inside cmux Browser's +# terminal panes (cmux-tui; ~/.local/opt/cmux-browser-nightly, not packaged yet). +# Its settings page's "Use Ghostty config" reads THIS file, +# $XDG_CONFIG_HOME/ghostty/config.ghostty. Every value below mirrors kitty.conf +# key for key, so a cmux pane looks like a kitty window — a kitty change +# belongs here too. Where Ghostty has no equivalent, the kitty key is named +# and the gap stated. The colours are NOT here: they join through the theme +# fragment at the bottom, like kitty's `include ${HOME}/.config/theme/kitty.conf`. + +# shell fish — kitty.conf says why: the login shell is bash fleet-wide (#236), +# fish stays the terminal UI. +command = fish +shell-integration = fish + +# Font — kitty's EXPLICIT families (2026-08-21): Liga SFMono Nerd Font, bold is +# SemiBold on purpose (the weight the fontconfig fallback actually served). +# Ghostty takes family + style, where kitty takes the full face name. +font-family = Liga SFMono Nerd Font +font-family-bold = Liga SFMono Nerd Font +font-style-bold = SemiBold +font-family-italic = Liga SFMono Nerd Font +font-style-italic = Italic +font-family-bold-italic = Liga SFMono Nerd Font +font-style-bold-italic = SemiBold Italic +# font_size 16.0 (2026-09-16): 14 + one legacy ctrl+= press. +font-size = 16 +# disable_ligatures never: ligatures are Ghostty's default, nothing to set. + +# Cursor: cursor_blink_interval 0. kitty's cursor_trail has no Ghostty +# equivalent (cursor_trail_color in the fragment is kitty-only). +cursor-style = block +cursor-style-blink = false + +# window_padding_width 10 +window-padding-x = 10 +window-padding-y = 10 + +# detect_urls yes / open_url_with default (xdg-open here too). +# underline_hyperlinks always: Ghostty underlines a link on hover only. +link-url = true +# confirm_os_window_close 0 +confirm-close-surface = false +# scrollback_lines 100000 — Ghostty caps scrollback in BYTES per surface +# (lazily allocated, compressed when idle): 100 MB ≈ 100k lines of 1 KB. +scrollback-limit = 100000000 +# enable_audio_bell no — Ghostty's default rings the system bell; cmux may +# still flag the surface (attention/title), never sound. +bell-features = no-system,no-audio +# copy_on_select clipboard +copy-on-select = clipboard +# THE THIN-CLIENT CLIPBOARD CONTRACT (kitty.conf, 2026-09-11), the OSC 52 +# half: a remote program may WRITE this seat's clipboard (write-clipboard) and +# a remote READ prompts (read-clipboard-ask). `clipboard-read = allow` would +# let anything the coordinator runs exfiltrate the clipboard silently, so it +# must never be set. +clipboard-write = allow +clipboard-read = ask + +# map ctrl+equal / ctrl+minus — kitty's legacy increase_font_size step is a +# fixed +2.0pt (0.48.0 boss.py); Ghostty's default step is 1, so pass the amount. +keybind = ctrl+equal=increase_font_size:2 +keybind = ctrl+minus=decrease_font_size:2 +# kitty_mod+h / kitty_mod+g (kitty-scrollback.nvim) and enabled_layouts splits +# have no Ghostty side: cmux owns splits, tabs and the scrollback viewer. + +# Frosted terminal (2026-09-16): background_opacity 0.96. The compositor blur +# rides on the kitty app-ids in niri's window-rules.kdl; cmux is a different +# app-id, so its panes are translucent without the frost until a rule lands. +background-opacity = 0.96 +# kitty does not dim inactive splits; Ghostty's default is 0.7. +unfocused-split-opacity = 1 + +# Colours: the theme fragment home/theme.nix renders from home/themes/*.nix +# under ~/.config/themes//ghostty, joined through the ~/.config/theme +# pointer that ~/.local/bin/theme retargets. Ghostty resolves a relative +# config-file against the directory of THIS file — which is a symlink into the +# checkout — so the path is absolute; $HOME is /home/tom on every host +# (home.homeDirectory). `?` = no error while the fragment is not rendered yet. +# A config-file loads AFTER the file that names it, so the fragment wins. +# No live reload: cmux reads the config when it opens a surface. +config-file = ?/home/tom/.config/theme/ghostty diff --git a/home/dot_config/herdr/config.toml b/home/dot_config/herdr/config.toml index d5a97ec7e..1c589c1c9 100644 --- a/home/dot_config/herdr/config.toml +++ b/home/dot_config/herdr/config.toml @@ -25,6 +25,20 @@ onboarding = false # default prefix can't silently move every binding under it. prefix = "ctrl+b" +[theme] +# "terminal" (2026-09-17): every token is an ANSI slot or Reset +# (src/app/state.rs Palette::terminal), so kitty paints herdr — chrome AND every +# pane — in whatever theme ~/.local/bin/theme has selected, and re-paints it +# live: herdr enables DEC mode 2031 on the host (src/terminal_theme.rs), kitty +# reports a changed default background as CSI ?997;n on reload, and herdr +# answers with a full OSC 10/11/4 re-query. Nothing here ever changes again for +# a theme switch. Before this there was no [theme] section at all, i.e. herdr's +# default catppuccin — not noir. Do NOT pick a theme from herdr's in-app +# settings overlay: it upserts `name` and `auto_switch = false` into THIS +# tracked file (src/config/write.rs). auto_switch stays off — it only chooses +# between two built-in names and ignores `name` while on. +name = "terminal" + [terminal] # fish is the terminal UI here; bash is only the LOGIN shell (#236). herdr's # default for this key is empty, which means "$SHELL, then /bin/sh" — and the diff --git a/home/dot_config/kitty/kitty.conf b/home/dot_config/kitty/kitty.conf index bf6a52741..5ec24c5f7 100644 --- a/home/dot_config/kitty/kitty.conf +++ b/home/dot_config/kitty/kitty.conf @@ -49,7 +49,7 @@ cursor_blink_interval 0 cursor_trail 1 cursor_trail_decay 0.15 0.4 cursor_trail_start_threshold 3 -cursor_trail_color #70B8FF +# cursor_trail_color comes from the theme fragment (include below). # Padding around the text area window_padding_width 10 @@ -197,74 +197,20 @@ mouse_map ctrl+shift+right press ungrabbed combine : mouse_select_command_output #map ctrl+shift+backspace restore_font_size #map ctrl+shift+f11 toggle_fullscreen # -# Claude palette on OLED black -# The basic colors -background #000000 -foreground #EAECF0 -# Selection blue matches Claude Code's internal renderer selection highlight -# (sampled #264F78 from its TUI — also VS Code's default editor.selectionBackground). -selection_background #264F78 -selection_foreground #EAECF0 -cursor_text_color #000000 -url_color #70B8FF - -# black -color0 #000000 -color8 #818898 - -# red -color1 #F47B85 -color9 #F47B85 - -# green -color2 #9BE963 -color10 #9BE963 - -# yellow -color3 #f9e2af -color11 #f9e2af - -# blue -color4 #70B8FF -color12 #70B8FF - -# magenta -color5 #CC7BF4 -color13 #CC7BF4 - -# cyan -color6 #5EEDED -color14 #5EEDED - -# white -color7 #D3D7DE -color15 #EAECF0 - -# cursor -cursor #EAECF0 - -# tabs -active_tab_background #000000 -active_tab_foreground #EAECF0 -inactive_tab_background #000000 -inactive_tab_foreground #818898 -tab_bar_background #000000 +# Colours (2026-09-17): every colour key lives in the theme fragment +# ~/.config/theme/kitty.conf — the symlink ~/.local/bin/theme retargets between +# the themes home/theme.nix renders under ~/.config/themes/ (palettes in +# home/themes/*.nix: noir = the Claude palette on OLED black, claude-dark, +# claude-light). kitty expands env vars in include paths; the symlink flip +# fires no watcher event, so `theme` also calls `kitten @ load-config` on every +# instance. The tab bar edge stays here: it is layout, not colour. +include ${HOME}/.config/theme/kitty.conf tab_bar_edge top -# marks -mark1_foreground #000000 -mark1_background #CC7BF4 - -# splits/windows -# Split divider: match niri's INACTIVE window border (#373735, layout.kdl:30) -# instead of the pink focus accent, so a kitty split reads as a quiet -# niri-native divider rather than a loud highlight (Tom 2026-07-15). -active_border_color #373735 -inactive_border_color #000000 - -# Opaque, always (Tom 2026-09-11): the seat is the Zenbook's OLED, and a -# true-black terminal on a true-black backdrop is the point. No blur rule in -# niri, no wallpaper behind it, and dynamic_ stays off so nothing can dial the -# opacity back at runtime. -background_opacity 1.0 -dynamic_background_opacity no +# Frosted terminal (Tom 2026-09-16): a slightly translucent background pairs +# with the niri blur rule for the kitty app-ids (window-rules.kdl), so the +# compositor frosts the Imagine olive wallpaper behind it — frosted glass, not +# transparency; text stays opaque. dynamic_ allows live +# `kitty @ set-background-opacity`. Was 1.0 / no from 2026-09-11 (OLED black). +background_opacity 0.96 +dynamic_background_opacity yes diff --git a/home/dot_config/niri/binds.kdl b/home/dot_config/niri/binds.kdl index 954584c2d..329ace992 100644 --- a/home/dot_config/niri/binds.kdl +++ b/home/dot_config/niri/binds.kdl @@ -69,6 +69,11 @@ binds { // scroll: bindsym $mod+E exec thunar Mod+E hotkey-overlay-title="File Manager" { spawn "nautilus"; } + // Cycle noir -> claude-dark -> claude-light (~/.local/bin/theme, 2026-09-17): + // one symlink flip plus live reloads of kitty, niri and nvim; herdr and + // fish follow on their own. Mod+T is still free. + Mod+Shift+T hotkey-overlay-title="Theme (cycle)" { spawn-sh "~/.local/bin/theme toggle"; } + // scroll: bindsym Ctrl+N exec flatpak run com.google.Chrome // scroll: bindsym Ctrl+Shift+N exec flatpak run com.google.Chrome --incognito // Chrome is installed from nixpkgs; invoke its stable binary directly. @@ -131,6 +136,11 @@ binds { // F1 opens the Alexa wake listener menu. F1/F2 no // longer carry brightness; the Duo keyboard's XF86MonBrightness keys do. F1 repeat=false hotkey-overlay-title="Alexa Wake On/Off" { spawn-sh "kitty --class wake-prompt -e ~/.local/bin/wake-toggle menu"; } + // F2 (2026-09-17): pick the theme — noir / claude-dark / claude-light. + // Shift+F2: pick the colour accent — one of the seven claude.ai/imagine + // grounds, as wallpaper AND matching folder colour (bin/wallpaper). + F2 repeat=false hotkey-overlay-title="Theme" { spawn-sh "kitty --class theme-prompt -e ~/.local/bin/theme-prompt theme"; } + Shift+F2 repeat=false hotkey-overlay-title="Accent (wallpaper + folders)" { spawn-sh "kitty --class theme-prompt -e ~/.local/bin/theme-prompt accent"; } F3 hotkey-overlay-title="Play/Pause" { spawn-sh "~/.local/bin/media play"; } F4 hotkey-overlay-title="Previous" { spawn-sh "~/.local/bin/media prev"; } F5 hotkey-overlay-title="Next" { spawn-sh "~/.local/bin/media next"; } @@ -159,9 +169,8 @@ binds { // [scratchpads.music]; daemon: home/piri.nix). Mod+M toggles it: first press // (nothing captured) spawns the SoundCloud PWA pinned to the right, later // presses show/hide. Mod+Shift+M captures whatever window is focused INTO the - // music pane — the "mark this as my music window" move, for the coming - // cliamp-in-kitty setup (focus the cliamp window, Mod+Shift+M, then Mod+M - // toggles it thereafter). + // music pane — the "mark this as my music window" move (focus it, + // Mod+Shift+M, then Mod+M toggles it thereafter). Mod+M hotkey-overlay-title="Music (toggle)" { spawn "piri" "scratchpads" "music" "toggle"; } Mod+Shift+M hotkey-overlay-title="Music (capture focused)" { spawn "piri" "scratchpads" "music" "add" "fromRight"; } // SUPERSEDED by the music scratchpad above (Mod+M was YouTube-Music-focus). diff --git a/home/dot_config/niri/config.kdl b/home/dot_config/niri/config.kdl index d10a22af4..44ab7a7ee 100644 --- a/home/dot_config/niri/config.kdl +++ b/home/dot_config/niri/config.kdl @@ -35,15 +35,26 @@ include "window-rules.kdl" include "misc.kdl" include "startup.kdl" -// Shared local overrides. Keep this include mandatory: the pinned niri 26.04 does -// not know `optional=true`, and local.kdl is always deployed with this directory. +// Shared local overrides. local.kdl is always deployed with this directory, so +// the include stays mandatory. (Earlier comments here claimed the pinned niri +// 26.04 knows neither `optional=true` nor `~` in include paths; both work — +// verified with `niri validate` 2026-09-17. The "error parsing KDL" people hit +// is what a MISSING non-optional include reports.) include "local.kdl" // Per-HOST niri config, generated by home.nix (xdg.configFile."niri-local.kdl"). // Absolute path: this file lives OUTSIDE the whole-dir out-of-store symlink so it -// can differ per host; niri include does NOT expand ~ or $HOME, and `optional` is -// unavailable on the pinned niri, so home.nix writes it on EVERY host. It is -// inert on every host but the client (2026-09-11), whose touch mapping and -// chord overrides live there; see home/home.nix. The slot stays because it is -// the only real per-host niri hook. See dotfiles#67. +// can differ per host; home.nix writes it on EVERY host. It is inert on every +// host but the client (2026-09-11), whose touch mapping and chord overrides +// live there; see home/home.nix. The slot stays because it is the only real +// per-host niri hook. See dotfiles#67. include "/home/tom/.config/niri-local.kdl" + +// Theme colours, LAST so they merge over layout.kdl / misc.kdl (niri merges +// sections across includes, later wins per property). ~/.config/theme is the +// symlink ~/.local/bin/theme retargets between the themes home/theme.nix +// renders (home/themes/*.nix). `optional`: a checkout pulled ahead of its +// switch must not take niri down — without the fragment niri falls back to +// its own default colours, nothing worse. A symlink retarget fires no watcher +// event, so `theme` also runs `niri msg action load-config-file`. +include optional=true "~/.config/theme/niri.kdl" diff --git a/home/dot_config/niri/layout.kdl b/home/dot_config/niri/layout.kdl index 59ac88439..3561c97b0 100644 --- a/home/dot_config/niri/layout.kdl +++ b/home/dot_config/niri/layout.kdl @@ -21,12 +21,11 @@ layout { } // scroll: default_border pixel 1 + // Colours (active/inactive/urgent) come from the theme fragment included + // last in config.kdl (~/.config/theme/niri.kdl); geometry lives here. border { on width 1 - active-color "#555451" - inactive-color "#373735" - urgent-color "#F47B85" } center-focused-column "on-overflow" @@ -54,17 +53,7 @@ layout { // scroll: layout_default_height 1.0 // ^ NO NIRI EQUIVALENT - tab-indicator { - active-color "#CC7BF4" - inactive-color "#818898" - urgent-color "#F47B85" - } - - insert-hint { - color "#CC7BF480" - } - - background-color "#000000" + // tab-indicator, insert-hint and background-color: theme fragment. struts {} diff --git a/home/dot_config/niri/misc.kdl b/home/dot_config/niri/misc.kdl index da6a57640..a685d408f 100644 --- a/home/dot_config/niri/misc.kdl +++ b/home/dot_config/niri/misc.kdl @@ -90,9 +90,7 @@ cursor { // scroll: exec hash dbus-update-activation-environment 2>/dev/null && \ // scroll: dbus-update-activation-environment --systemd DISPLAY WAYLAND_DISPLAY SWAYSOCK XDG_CURRENT_DESKTOP=sway // ^ DELETE: niri-session handles env export and D-Bus activation natively -overview { - backdrop-color "#000000" -} +// overview { backdrop-color } is set by the theme fragment (see config.kdl). gestures { hot-corners { diff --git a/home/dot_config/niri/piri.toml b/home/dot_config/niri/piri.toml index 0b1ae750e..18aa70859 100644 --- a/home/dot_config/niri/piri.toml +++ b/home/dot_config/niri/piri.toml @@ -25,10 +25,9 @@ default_margin = 0 # "music" — the right-side music pane (Mod+M toggles it; see binds.kdl). # First toggle with no captured window spawns `command` and pins it to the # right; subsequent toggles show/hide it. Today that's the SoundCloud PWA -# (app_id confirmed live: chrome-soundcloud.com__-Default). Later this becomes a -# cliamp-in-kitty pane — swap `command`/`app_id` here (hot-reloads), or capture -# any focused window into it at runtime with `piri scratchpads music add -# fromRight` (bound to Mod+Shift+M). +# (app_id confirmed live: chrome-soundcloud.com__-Default). Swap `command`/`app_id` +# here (hot-reloads), or capture any focused window into it at runtime with +# `piri scratchpads music add fromRight` (bound to Mod+Shift+M). [scratchpads.music] direction = "fromRight" command = "google-chrome-stable --app=https://soundcloud.com" diff --git a/home/dot_config/niri/startup.kdl b/home/dot_config/niri/startup.kdl index 2c27724de..51088297c 100644 --- a/home/dot_config/niri/startup.kdl +++ b/home/dot_config/niri/startup.kdl @@ -16,32 +16,30 @@ spawn-at-startup "sh" "-c" "sleep 0.5 && kanshi" // boot. Mod+D is ~/.local/bin/launcher now — an fzf script in a floating kitty, // with no daemon to start. -// No wallpaper daemon (Tom 2026-09-11): the backdrop is layout.kdl's -// background-color #000000, which the Zenbook's OLED renders as panel-off -// black. ~/.local/bin/wallpaper still exists for a one-off swaybg, and nothing -// restarts one at login. +// Wallpaper (Tom 2026-09-16): the claude.ai/imagine "olive" ribbons, rendered +// at 5120x2880 for the PA27JCV from the recovered source SVG (~/colors/waves). +// ~/.local/bin/wallpaper starts swaybg with that file as its default; run it +// with a path to swap the image at runtime. layout.kdl's background-color +// #000000 stays as the fallback under it. +spawn-at-startup "wallpaper" // ── gsettings ──────────────────────────────────────────────── // scroll: exec_always { // scroll: gsettings set $gnome-schema cursor-theme 'Bibata-Modern-Classic' spawn-at-startup "gsettings" "set" "org.gnome.desktop.interface" "cursor-theme" "Bibata-Modern-Classic" -// scroll: gsettings set $gnome-schema color-scheme 'prefer-dark' -spawn-at-startup "gsettings" "set" "org.gnome.desktop.interface" "color-scheme" "prefer-dark" // scroll: gsettings set $gnome-schema cursor-size '48' spawn-at-startup "gsettings" "set" "org.gnome.desktop.interface" "cursor-size" "48" // scroll: gsettings set $gnome-schema text-scaling-factor '1.0' spawn-at-startup "gsettings" "set" "org.gnome.desktop.interface" "text-scaling-factor" "1.0" -// scroll: gsettings set $gnome-schema gtk-theme 'Catppuccin-Noir-Standard-Green-Dark' -// ^ scroll-era catppuccin dropped 2026-07-04 — MacTahoe OLED end-to-end, matching home.nix gtk.theme -spawn-at-startup "gsettings" "set" "org.gnome.desktop.interface" "gtk-theme" "MacTahoe-Dark-grey" -// scroll: gsettings set org.gnome.desktop.interface.wm.preferences theme 'Catppuccin-Noir-Standard-Green-Dark' -// ^ corrected schema: org.gnome.desktop.wm.preferences (not .interface.wm.preferences) -spawn-at-startup "gsettings" "set" "org.gnome.desktop.wm.preferences" "theme" "MacTahoe-Dark-grey" // scroll: gsettings set org.gnome.desktop.wm.preferences button-layout "" spawn-at-startup "gsettings" "set" "org.gnome.desktop.wm.preferences" "button-layout" "" -// scroll: #gsettings set $gnome-schema icon-theme 'Your icon theme' -spawn-at-startup "gsettings" "set" "org.gnome.desktop.interface" "icon-theme" "MacTahoe-dark" +// gtk-theme, wm theme, icon-theme and color-scheme (2026-09-17): set by +// `theme apply` from the selected theme + wallpaper accent, idempotently, so a +// login re-asserts whatever ~/.config/theme points at. (The four gsettings +// lines that were here had been failing silently — no gsettings on the session +// PATH; GTK_THEME did the theming. Both fixed in modules/common.nix.) +spawn-at-startup "theme" "apply" // scroll: } // ^ note: exec_always re-ran on reload. spawn-at-startup runs once. gsettings persist, so fine. diff --git a/home/dot_config/niri/window-rules.kdl b/home/dot_config/niri/window-rules.kdl index 2d88522de..874bd4ff6 100644 --- a/home/dot_config/niri/window-rules.kdl +++ b/home/dot_config/niri/window-rules.kdl @@ -40,6 +40,20 @@ window-rule { } } +// F2 theme picker / Shift+F2 accent picker: the same compact floating prompt. +window-rule { + match app-id="theme-prompt" + open-floating true + open-focused true + default-column-width { fixed 640; } + default-window-height { fixed 300; } + border { + width 2 + active-color "#DA7756" + inactive-color "#DA7756" + } +} + // F1 Alexa wake picker: the same compact floating prompt as F9/F10. window-rule { match app-id="wake-prompt" @@ -157,6 +171,22 @@ window-rule { default-column-width {} } +// Frosted kitty (Tom 2026-09-16, restored for the Imagine olive wallpaper): +// kitty's background_opacity (0.96, kitty.conf) makes only the terminal bg +// translucent; this niri-side blur frosts the wallpaper behind it so the +// window reads as frosted glass, not see-through, and text stays crisp. +// One rule for every kitty-derived app-id: the plain terminal, the herdr +// projector (`kitty --class herdr-projector`), and the fzf *-prompt popups — +// kitty.conf's opacity applies to all of them, so all of them get the blur. +// Deleted 2026-09-11 for the Zenbook's true-black OLED; back now that the +// coordinator has a wallpaper again. +window-rule { + match app-id=r#"^(kitty|herdr-projector|[a-z-]+-prompt)$"# + background-effect { + blur true + } +} + // Frosted Nautilus: MacTahoe's "normal" (non -solid) gtk-4.0 variant already // ships semi-transparent headerbar/sidebar surfaces meant for compositor blur // behind them (home.nix links MacTahoe-Dark-grey's gtk-4.0 CSS) — this rule diff --git a/home/dot_config/nvim/lua/plugins.lua b/home/dot_config/nvim/lua/plugins.lua deleted file mode 100644 index d4e7f78de..000000000 --- a/home/dot_config/nvim/lua/plugins.lua +++ /dev/null @@ -1,398 +0,0 @@ -local fn = vim.fn -local opt = vim.opt -local g = vim.g - -local lazypath = fn.stdpath("data") .. "/lazy/lazy.nvim" -if not vim.uv.fs_stat(lazypath) then - fn.system({ - "git", - "clone", - "--filter=blob:none", - "https://github.com/folke/lazy.nvim.git", - "--branch=stable", -- latest stable release - lazypath, - }) -end - -opt.rtp:prepend(lazypath) - -g.mapleader = " " - -require("lazy").setup({ - { - 'mikesmithgh/kitty-scrollback.nvim', - enabled = true, - lazy = true, - cmd = { 'KittyScrollbackGenerateKittens', 'KittyScrollbackCheckHealth' }, - event = { 'User KittyScrollbackLaunch' }, - -- version = '*', -- latest stable version, may have breaking changes if major version changed - -- version = '^5.0.0', -- pin major version, include fixes and features that do not have breaking changes - config = function() - require('kitty-scrollback').setup() - end, - }, - { - 'tpope/vim-repeat', -- Required for leap.nvim dot-repeats - }, - { - url = 'https://codeberg.org/andyg/leap.nvim', - config = function() - vim.keymap.set({'n', 'x', 'o'}, 's', '(leap-forward)') - vim.keymap.set({'n', 'x', 'o'}, 'S', '(leap-backward)') - vim.keymap.set({'n', 'x', 'o'}, 'gs', '(leap-from-window)') - end - }, - { - 'catgoose/nvim-colorizer.lua', - config = function() - require('colorizer').setup() - end - }, - { - "lukas-reineke/indent-blankline.nvim", - main = "ibl", - opts = {}, - config = function() - require('plugins.indent_blankline') - end - }, - { - 'nvim-treesitter/nvim-treesitter', - lazy = false, - build = ':TSUpdate', - config = function() - require('nvim-treesitter').install({ 'markdown', 'markdown_inline' }) - vim.api.nvim_create_autocmd('FileType', { - pattern = { 'markdown_inline' }, - callback = function() vim.treesitter.start() end, - }) - end - }, - { - 'nvim-tree/nvim-tree.lua', - config = function() - require('plugins.nvim-tree') - end - }, - { - 'lewis6991/gitsigns.nvim', - dependencies = { 'nvim-lua/plenary.nvim' }, - config = function() - require('plugins.gitsigns') - end - }, - { - 'nvim-telescope/telescope.nvim', - dependencies = { 'nvim-lua/plenary.nvim' }, - config = function() - require('plugins.telescope') - end - }, - { - 'akinsho/bufferline.nvim', - config = function() - require('bufferline').setup({ - options = { - hover = { - enabled = true, - delay = 200, - reveal = {'close'} - } - }, - highlights = { - fill = { - bg = "#000000" - }, - background = { - bg = "#000000" - } - } - }) - end - }, - { - 'catppuccin/nvim', - name = 'catppuccin', - config = function() - require('catppuccin').setup({ - flavour = "mocha", - transparent_background = true, - color_overrides = { - mocha = { - base = "#000000", - mantle = "#000000", - crust = "#000000", - text = "#EAECF0", - subtext1 = "#D3D7DE", - overlay1 = "#818898", - red = "#F47B85", - green = "#9BE963", - blue = "#70B8FF", - mauve = "#CC7BF4", - peach = "#FBAD60", - teal = "#5EEDED", - }, - }, - custom_highlights = function(colors) - local U = require("catppuccin.utils.colors") - local bg_amount = 0.095 - local rainbow = { - colors.blue, -- rainbow1 (H1) - colors.peach, -- rainbow2 (H2) - colors.green, -- rainbow3 (H3) - colors.teal, -- rainbow4 (H4) - colors.yellow, -- rainbow5 (H5) - colors.mauve, -- rainbow6 (H6) - } - local highlights = {} - for i, color in ipairs(rainbow) do - highlights["rainbow" .. i] = { fg = color } - highlights["RenderMarkdownH" .. i] = { fg = color, bold = true } - highlights["RenderMarkdownH" .. i .. "Bg"] = { bg = U.darken(color, bg_amount, colors.base) } - end - highlights["RenderMarkdownCode"] = { bg = U.darken(colors.text, 0.05, colors.base) } - highlights["RenderMarkdownCodeInline"] = { fg = colors.peach } - highlights["RenderMarkdownBullet"] = { fg = colors.blue } - highlights["RenderMarkdownDash"] = { fg = colors.overlay1 } - highlights["RenderMarkdownQuote"] = { fg = colors.overlay1 } - highlights["RenderMarkdownLink"] = { fg = colors.blue } - highlights["RenderMarkdownChecked"] = { fg = colors.green } - highlights["RenderMarkdownUnchecked"] = { fg = colors.overlay1 } - highlights["RenderMarkdownTableHead"] = { fg = colors.blue } - highlights["RenderMarkdownTableRow"] = { fg = colors.subtext1 } - return highlights - end, - integrations = { - render_markdown = true, - }, - }) - vim.cmd.colorscheme "catppuccin" - end - }, - { - 'windwp/nvim-autopairs', - config = function() - require('nvim-autopairs').setup() - end - }, - { - "folke/twilight.nvim", - config = function() - require("twilight").setup() - end - }, - { - "folke/zen-mode.nvim", - config = function() - require("zen-mode").setup({ - window = { - width = 90, - options = { - signcolumn = "no", - number = false, - relativenumber = false, - cursorline = false, - }, - }, - plugins = { - twilight = { enabled = true }, -- auto-enable twilight in zen - kitty = { - enabled = true, - font = "+2", -- bump kitty font by 2pt in zen mode - }, - gitsigns = { enabled = false }, -- hide git signs in zen - }, - }) - end - }, - { - 'MeanderingProgrammer/render-markdown.nvim', - dependencies = { - 'nvim-treesitter/nvim-treesitter', - 'nvim-tree/nvim-web-devicons' - }, - ft = {'markdown'}, - opts = { - file_types = { 'markdown' }, - } - }, - -- Git integration stack (ordered by dependency) - { - "sindrets/diffview.nvim", - dependencies = { - "nvim-lua/plenary.nvim", - "nvim-tree/nvim-web-devicons", - }, - cmd = { "DiffviewOpen", "DiffviewClose", "DiffviewToggleFiles", "DiffviewFocusFiles" }, - config = function() - require("diffview").setup({ - default_args = { - DiffviewOpen = { '--imply-local' }, - }, - keymaps = { - view = { - { 'n', 'q', 'DiffviewClose', { desc = 'Close diffview' } }, - }, - file_panel = { - { 'n', 'q', 'DiffviewClose', { desc = 'Close diffview' } }, - }, - file_history_panel = { - { 'n', 'q', 'DiffviewClose', { desc = 'Close diffview' } }, - }, - }, - }) - vim.opt.fillchars:append { diff = "╱" } - vim.api.nvim_create_autocmd('User', { - pattern = 'DiffviewViewLeave', - callback = function() - vim.cmd ':DiffviewClose' - end, - }) - end - }, - { - "NeogitOrg/neogit", - dependencies = { - "nvim-lua/plenary.nvim", - "sindrets/diffview.nvim", - "nvim-telescope/telescope.nvim", - }, - cmd = "Neogit", - config = function() - require('plugins.neogit') - end - }, - { - "pwntester/octo.nvim", - dependencies = { - "nvim-lua/plenary.nvim", - "nvim-telescope/telescope.nvim", - "nvim-tree/nvim-web-devicons", - }, - cmd = "Octo", - config = function() - require("octo").setup() - vim.treesitter.language.register('markdown', 'octo') - end - }, - { - "HakonHarnes/img-clip.nvim", - event = "BufEnter", - opts = { - default = { - -- Save images relative to the current markdown file - relative_to_current_file = true, - -- Create an 'images' directory next to the markdown file - dir_path = function() - -- Gets the directory of the current file - local file_dir = vim.fn.expand('%:p:h') - local images_dir = file_dir .. '/images' - -- Create the images directory if it doesn't exist - vim.fn.mkdir(images_dir, 'p') - return 'images' -- Return relative path - end, - extension = "png", - prompt_for_file_name = true, - }, - filetypes = { - markdown = { - url_encode_path = true, - template = "![$CURSOR]($FILE_PATH)", - download_images = true, -- Enable downloading images from URLs - }, - }, - } - }, - { - "3rd/image.nvim", - build = false, - opts = { - backend = "kitty", - processor = "magick_cli", - integrations = { - markdown = { - enabled = true, - clear_in_insert_mode = false, - download_remote_images = true, - only_render_image_at_cursor = false, - } - }, - -- max_height_window_percentage = 80, -- uncomment to set max height to 80% of window - editor_only_render_when_focused = false, - window_overlap_clear_enabled = false, - hijack_file_patterns = { "*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.avif" }, - }, - }, - { - "karb94/neoscroll.nvim", - event = "BufRead", - config = function() - require('plugins.neoscroll') - end - }, - { - 'nvim-lualine/lualine.nvim', - dependencies = { 'nvim-tree/nvim-web-devicons', 'catppuccin/nvim' }, - config = function() - require('plugins.lualine').setup() - end, - event = 'VeryLazy', - }, - - -- ===== NEW PLUGINS ADDED BELOW ===== - - -- IMPROVED: pipeline.nvim - CI/CD pipeline viewer (using yq instead of make) - { - "topaxi/pipeline.nvim", - keys = { - { "ci", "Pipeline", desc = "Open pipeline.nvim" }, - }, - -- Removed build step since yq is already installed - opts = { - -- You can customize pipeline.nvim options here if needed - -- For example: - -- refresh_interval = 30, - -- browser = "firefox", - }, - }, - - -- NEW: diagram.nvim - Live diagram rendering (mermaid, plantuml, d2, gnuplot) - { - "3rd/diagram.nvim", - dependencies = { "3rd/image.nvim" }, -- Already have image.nvim above - opts = { - events = { - render_buffer = { "InsertLeave", "BufWinEnter", "TextChanged" }, - clear_buffer = { "BufLeave" }, - }, - renderer_options = { - mermaid = { theme = "default", scale = 1 }, - plantuml = { charset = "utf-8" }, - d2 = {}, - gnuplot = {}, - }, - }, - }, - - -- NEW: Mason + marksman LSP - { - "williamboman/mason.nvim", - dependencies = { - "williamboman/mason-lspconfig.nvim", - "neovim/nvim-lspconfig", - }, - ft = { "markdown", "md" }, - config = function() - require("mason").setup() - require("mason-lspconfig").setup({ - ensure_installed = { "marksman" }, - handlers = { - function(server_name) - require("lspconfig")[server_name].setup({}) - end, - }, - }) - end, - }, - -}) diff --git a/home/dot_config/nvim/lua/plugins/bufferline.lua b/home/dot_config/nvim/lua/plugins/bufferline.lua deleted file mode 100644 index 23b7c961e..000000000 --- a/home/dot_config/nvim/lua/plugins/bufferline.lua +++ /dev/null @@ -1,72 +0,0 @@ -local ok, bufferline = pcall(require, "bufferline") -if not ok then - return -end - -bufferline.setup { - options = { - offsets = { { filetype = "NvimTree", text = "Explorer", highlight = "Normal" } }, - separator_style = { "", ""}, - show_tab_indicators = false, - }, - highlights = { - fill = { - fg = "#000000", - bg = "#000000", - }, - background = { - fg = "#45475a", - bg = "#000000", - }, - - -- buffers - buffer_selected = { - fg = "#EAECF0", - bg = "#000000", - italic = false, - }, - buffer_visible = { - fg = "#45475a", - bg = "#000000", - }, - - -- close buttons - close_button = { - fg = "#45475a", - bg = "#000000", - }, - close_button_visible = { - fg = "#45475a", - bg = "#000000", - }, - close_button_selected = { - fg = "#F47B85", - bg = "#000000", - }, - - indicator_selected = { - fg = "#000000", - bg = "#000000", - }, - - -- modified - modified = { - fg = "#45475a", - bg = "#000000", - }, - modified_visible = { - fg = "#000000", - bg = "#000000", - }, - modified_selected = { - fg = "#9BE963", - bg = "#000000", - }, - - -- tabs - tab_close = { - fg = "#000000", - bg = "#000000", - }, - }, -} diff --git a/home/dot_config/nvim/lua/plugins/lualine.lua b/home/dot_config/nvim/lua/plugins/lualine.lua index 9082cb329..c8b2bf9e5 100644 --- a/home/dot_config/nvim/lua/plugins/lualine.lua +++ b/home/dot_config/nvim/lua/plugins/lualine.lua @@ -81,7 +81,7 @@ components.filename = { -- External changes indicator components.external_changes = { require('external-changes').lualine_component, - color = { fg = '#9BE963', gui = 'bold' }, + color = { gui = 'bold' }, -- fg set in setup() from the theme palette } -- Search count @@ -103,7 +103,7 @@ components.recording = { end return ' REC @' .. reg end, - color = { fg = '#F47B85', gui = 'bold' }, + color = { gui = 'bold' }, -- fg set in setup() from the theme palette cond = function() return vim.fn.reg_recording() ~= '' end, @@ -139,6 +139,14 @@ components.location = { -- Setup function function M.setup() + -- Palette colours are resolved here, not at module load, so theme.reload() + -- can call setup() again after a `theme` switch. + -- pcall: a missing theme module must never cost the status line (noir + -- fallback values, the same as theme.lua's own). + local ok, theme = pcall(require, 'theme') + local T = ok and theme.palette() or { green = '#9BE963', red = '#F47B85' } + components.external_changes.color.fg = T.green + components.recording.color.fg = T.red require('lualine').setup({ options = { theme = 'auto', diff --git a/home/dot_config/starship/starship.toml b/home/dot_config/starship/starship.toml index debc8b743..ad6ef8420 100644 --- a/home/dot_config/starship/starship.toml +++ b/home/dot_config/starship/starship.toml @@ -31,7 +31,7 @@ format = "[$branch]($style)" style = "bright-black" [git_status] -format = "[[(*$conflicted$untracked$modified$staged$renamed$deleted)](#F47B85) ($ahead_behind$stashed)]($style)" +format = "[[(*$conflicted$untracked$modified$staged$renamed$deleted)](red) ($ahead_behind$stashed)]($style)" style = "cyan" conflicted = "​" untracked = "​" diff --git a/home/dot_config/zathura/zathurarc b/home/dot_config/zathura/zathurarc deleted file mode 100644 index dd6b81918..000000000 --- a/home/dot_config/zathura/zathurarc +++ /dev/null @@ -1,39 +0,0 @@ -# vim: set filetype=conf : - -set default-fg "#CDD6F4" -set default-bg "#000000" - -set completion-bg "#313244" -set completion-fg "#CDD6F4" -set completion-highlight-bg "#575268" -set completion-highlight-fg "#CDD6F4" -set completion-group-bg "#313244" -set completion-group-fg "#89B4FA" - -set statusbar-fg "#CDD6F4" -set statusbar-bg "#313244" - -set notification-bg "#313244" -set notification-fg "#CDD6F4" -set notification-error-bg "#313244" -set notification-error-fg "#F38BA8" -set notification-warning-bg "#313244" -set notification-warning-fg "#FAE3B0" - -set inputbar-fg "#CDD6F4" -set inputbar-bg "#313244" - -set recolor-lightcolor "#000000" -set recolor-darkcolor "#CDD6F4" - -set index-fg "#CDD6F4" -set index-bg "#000000" -set index-active-fg "#CDD6F4" -set index-active-bg "#313244" - -set render-loading-bg "#000000" -set render-loading-fg "#CDD6F4" - -set highlight-color "#575268" -set highlight-fg "#F5C2E7" -set highlight-active-color "#F5C2E7" diff --git a/home/dot_local/bin/media b/home/dot_local/bin/media index ab7c3020e..7935b48dd 100755 --- a/home/dot_local/bin/media +++ b/home/dot_local/bin/media @@ -1,19 +1,11 @@ #!/usr/bin/env bash -# media -- MPRIS playback control (bound to F1-F10 in niri/binds.kdl) +# media -- MPRIS playback control (bound to F3-F5 in niri/binds.kdl) # -# Prefer cliamp when it is running. Bare `playerctl` acts on the first player it -# enumerates, and Chrome registers an MPRIS player too — so with a browser open -# the media keys would drive a background tab instead of the music player. -# Falls back to playerctl's default pick when cliamp is not running. -# ^cliamp, not an exact match: MPRIS names may carry an .instanceNNN suffix. -player=() -if playerctl -l 2>/dev/null | grep -q '^cliamp'; then - player=(-p cliamp) -fi - +# Acts on playerctl's default player. (Until 2026-09-17 this preferred cliamp +# when it was running; cliamp was removed, unused.) case "$1" in - play|toggle) playerctl "${player[@]}" play-pause ;; - next) playerctl "${player[@]}" next ;; - prev) playerctl "${player[@]}" previous ;; + play|toggle) playerctl play-pause ;; + next) playerctl next ;; + prev) playerctl previous ;; *) echo "Usage: $0 {play|next|prev}"; exit 1 ;; esac diff --git a/home/dot_local/bin/nvim-lua/theme.lua b/home/dot_local/bin/nvim-lua/theme.lua new file mode 100644 index 000000000..44acfa176 --- /dev/null +++ b/home/dot_local/bin/nvim-lua/theme.lua @@ -0,0 +1,154 @@ +-- theme.lua — the palette every colour in the nvim config comes from. +-- +-- Home Manager renders ~/.config/themes//theme.lua for each theme +-- (home/themes/*.nix); ~/.config/theme is the symlink ~/.local/bin/theme +-- retargets. This module dofile()s the current fragment and applies it to +-- catppuccin, bufferline and lualine — at startup from plugins.lua.in, and +-- again on `theme `, which calls reload() over --remote-expr on every +-- running nvim. +-- +-- Lives in ~/.local/bin/nvim-lua/, not ~/.config/nvim/lua/ (2026-09-17): that +-- directory is ONE out-of-store link into the checkout (home.nix +-- `home.file.".local/bin"`) and init.lua adds it to package.path before +-- plugins load, so require('theme') resolves the moment the checkout has this +-- file. Under nvim/lua/ it needed its own per-file link from a rebuild, and +-- lualine.lua (already linked) failed with "module 'theme' not found" on any +-- machine whose checkout was ahead of its switch. +local M = {} + +local FRAGMENT = vim.fn.expand('~/.config/theme/theme.lua') + +-- noir, verbatim, so nvim still looks right when the pointer is missing +-- (a checkout ahead of its switch on the client laptop). +local fallback = { + name = 'noir', polarity = 'dark', catppuccinFlavour = 'mocha', + ground = { base = '#000000', dim = '#000000', raised = '#000000', desktop = '#000000' }, + fg = '#EAECF0', fgDim = '#D3D7DE', comment = '#818898', muted = '#45475a', + red = '#F47B85', green = '#9BE963', yellow = '#f9e2af', blue = '#70B8FF', + magenta = '#CC7BF4', cyan = '#5EEDED', orange = '#FBAD60', + accent = '#70B8FF', brand = '#D97757', + selection = { bg = '#264F78', fg = '#EAECF0' }, + border = { active = '#555451', inactive = '#373735', urgent = '#F47B85' }, +} + +function M.palette() + local ok, t = pcall(dofile, FRAGMENT) + if ok and type(t) == 'table' and t.fg then + return t + end + return fallback +end + +-- catppuccin: flavour + colour overrides from the palette. transparent_background +-- keeps kitty's ground as the editor ground, so noir -> claude-dark needs no +-- editor change at all; only claude-light flips the flavour to latte. +function M.catppuccin_opts(T) + T = T or M.palette() + return { + flavour = T.catppuccinFlavour, + transparent_background = true, + color_overrides = { + [T.catppuccinFlavour] = { + base = T.ground.base, + mantle = T.ground.dim, + crust = T.ground.dim, + text = T.fg, + subtext1 = T.fgDim, + overlay1 = T.comment, + red = T.red, + green = T.green, + yellow = T.yellow, + blue = T.blue, + mauve = T.magenta, + peach = T.orange, + teal = T.cyan, + }, + }, + custom_highlights = function(colors) + local U = require('catppuccin.utils.colors') + local bg_amount = 0.095 + local rainbow = { + colors.blue, -- rainbow1 (H1) + colors.peach, -- rainbow2 (H2) + colors.green, -- rainbow3 (H3) + colors.teal, -- rainbow4 (H4) + colors.yellow, -- rainbow5 (H5) + colors.mauve, -- rainbow6 (H6) + } + local highlights = {} + for i, color in ipairs(rainbow) do + highlights['rainbow' .. i] = { fg = color } + highlights['RenderMarkdownH' .. i] = { fg = color, bold = true } + highlights['RenderMarkdownH' .. i .. 'Bg'] = { bg = U.darken(color, bg_amount, colors.base) } + end + highlights['RenderMarkdownCode'] = { bg = U.darken(colors.text, 0.05, colors.base) } + highlights['RenderMarkdownCodeInline'] = { fg = colors.peach } + highlights['RenderMarkdownBullet'] = { fg = colors.blue } + highlights['RenderMarkdownDash'] = { fg = colors.overlay1 } + highlights['RenderMarkdownQuote'] = { fg = colors.overlay1 } + highlights['RenderMarkdownLink'] = { fg = colors.blue } + highlights['RenderMarkdownChecked'] = { fg = colors.green } + highlights['RenderMarkdownUnchecked'] = { fg = colors.overlay1 } + highlights['RenderMarkdownTableHead'] = { fg = colors.blue } + highlights['RenderMarkdownTableRow'] = { fg = colors.subtext1 } + -- catppuccin's default Underlined has no fg (just the underline style), + -- so man pages, :help tags, etc. render in plain text color instead of + -- the Claude-blue used everywhere else for links/URLs. + highlights['Underlined'] = { fg = colors.blue, style = { 'underline' } } + return highlights + end, + integrations = { + render_markdown = true, + }, + } +end + +function M.apply_catppuccin(T) + require('catppuccin').setup(M.catppuccin_opts(T)) + vim.cmd.colorscheme('catppuccin') +end + +-- bufferline: the fill and the inactive-buffer strip take the theme ground. +function M.bufferline_opts(T) + T = T or M.palette() + return { + options = { + hover = { + enabled = true, + delay = 200, + reveal = { 'close' }, + }, + }, + highlights = { + fill = { bg = T.ground.base }, + background = { bg = T.ground.base }, + }, + } +end + +-- Re-read the fragment and re-apply everything. catppuccin's setup() does not +-- purge its own modules (that is what :CatppuccinCompile is for), so purge +-- first, then setup, recompile, and re-run the two plugins that consumed +-- palette values at their own setup time. +function M.reload() + local T = M.palette() + for name, _ in pairs(package.loaded) do + if name == 'catppuccin' or name:match('^catppuccin%.') then + package.loaded[name] = nil + end + end + require('catppuccin').setup(M.catppuccin_opts(T)) + pcall(vim.cmd, 'CatppuccinCompile') + vim.cmd.colorscheme('catppuccin') + local ok_b, bufferline = pcall(require, 'bufferline') + if ok_b then + pcall(bufferline.setup, M.bufferline_opts(T)) + end + local ok_l, lualine = pcall(require, 'plugins.lualine') + if ok_l and type(lualine) == 'table' and lualine.setup then + pcall(lualine.setup) + end + vim.notify('theme: ' .. T.name, vim.log.levels.INFO) +end + +return M diff --git a/home/dot_local/bin/theme b/home/dot_local/bin/theme new file mode 100755 index 000000000..e469b4250 --- /dev/null +++ b/home/dot_local/bin/theme @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# theme -- switch the dotfiles-wide colour theme at runtime, no rebuild. +# +# theme print the current theme +# theme list list the themes this generation rendered +# theme switch to (noir | claude-dark | claude-light) +# theme toggle cycle to the next theme in `theme list` order +# theme apply re-fire the reload hooks for the current theme +# theme icons re-pick the icon theme (polarity × wallpaper accent) +# +# Design: docs/theme-switcher-2026-09-17.md. Home Manager renders every theme's +# colour fragments under ~/.config/themes// (home/theme.nix, palettes in +# home/themes/*.nix). The RAW config files include ~/.config/theme/, +# where ~/.config/theme is a plain symlink this script retargets. A symlink +# retarget fires no file-watcher event, so every reload below is load-bearing. +set -euo pipefail + +cfg="${XDG_CONFIG_HOME:-$HOME/.config}" +link="$cfg/theme" +themes_dir="${THEMES_DIR:-$cfg/themes}" # THEMES_DIR: preview a render before its switch + +die() { printf 'theme: %s\n' "$*" >&2; exit 1; } + +# one switch at a time (F2 mashing, a login `apply` racing a manual switch) +exec 9> "${XDG_RUNTIME_DIR:-/tmp}/theme.lock" +flock -w 5 9 || die "another theme switch is still running" + +current() { + [[ -L "$link" ]] || return 1 + basename "$(readlink "$link")" +} + +list() { + [[ -d "$themes_dir" ]] || die "no rendered themes at $themes_dir (switch a generation with home/theme.nix first)" + find "$themes_dir" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort +} + +# meta is key=value, one per line, written by home/themes/default.nix. Read, +# never sourced. +meta() { + local key="$1" k v + while IFS='=' read -r k v; do + [[ "$k" == "$key" ]] && { printf '%s\n' "$v"; return 0; } + done < "$link/meta" + return 1 +} + +# --- reload hooks ----------------------------------------------------------- + +reload_kitty() { + # One abstract socket per instance: kitty.conf's `listen_on unix:@kitty-{kitty_pid}`. + # `load-config` with no file re-reads the running config, includes and all. + local sock n=0 + while read -r sock; do + kitten @ --to "unix:$sock" load-config >/dev/null 2>&1 && n=$((n + 1)) || true + done < <(awk '$NF ~ /^@kitty-[0-9]+$/ { print $NF }' /proc/net/unix | sort -u) + # a kitty without our listen_on (other config, mid-pull on the client) still + # reloads on SIGUSR1 — the Omarchy way; the socket path is just more precise. + if (( n == 0 )) && pkill -x -USR1 .kitty-wrapped 2>/dev/null; then + echo ' kitty: reloaded via SIGUSR1' + else + printf ' kitty: %d instance(s) reloaded\n' "$n" + fi +} + +reload_niri() { + if [[ -n "${NIRI_SOCKET:-}" ]] || compgen -G "${XDG_RUNTIME_DIR:-/run/user/$UID}/niri.*.sock" >/dev/null; then + if niri msg action load-config-file >/dev/null 2>&1; then + echo ' niri: config reloaded' + else + echo ' niri: reload failed (is a session running?)' >&2 + fi + fi +} + +reload_nvim() { + # nvim >= 0.9 listens on $XDG_RUNTIME_DIR/nvim..0. lua/theme.lua's + # reload() re-applies catppuccin, bufferline and lualine from the new fragment. + local sock n=0 + for sock in "${XDG_RUNTIME_DIR:-/run/user/$UID}"/nvim.*.0; do + [[ -S "$sock" ]] || continue + nvim --server "$sock" --remote-expr 'luaeval("require(\"theme\").reload() or \"ok\"")' >/dev/null 2>&1 && n=$((n + 1)) || true + done + printf ' nvim: %d instance(s) reloaded\n' "$n" +} + +# herdr: nothing to do. Its [theme] name = "terminal" paints every token from +# kitty's ANSI palette, and kitty reports the background change to it through +# DEC mode 2031 (CSI ?997;n) on reload, whereupon herdr re-queries OSC 10/11/4 +# and re-themes its chrome and every pane. Fish: conf.d/colors.fish re-sources +# the fragment at the next prompt when the pointer has moved. + +set_claude_code() { + # Claude Code reads ~/.claude.json at start, and rewrites it itself, so: + # tmp + rename, and a lost update against a running instance is accepted. + local want="$1" f="$HOME/.claude.json" tmp + [[ -f "$f" ]] || return 0 + command -v jq >/dev/null || return 0 + tmp="$(mktemp "$f.XXXXXX")" + if jq --arg t "$want" '.theme = $t' "$f" > "$tmp"; then + mv -T "$tmp" "$f" + printf ' claude code: theme=%s (takes effect on next start)\n' "$want" + else + rm -f "$tmp" + fi +} + +# GTK. GTK3 follows org.gnome.desktop.interface live (its Wayland backend reads +# gsettings/dconf — the schema is on XDG_DATA_DIRS since modules/common.nix +# 2026-09-17). libadwaita apps take color-scheme live and their gtk.css on +# next start (~/.config/gtk-4.0 points through ~/.config/theme, home/theme.nix). +# Chrome follows color-scheme through the portal. +gset() { gsettings set "$@" 2>/dev/null || printf ' gsettings: set %s failed\n' "$*" >&2; } + +apply_gtk() { + command -v gsettings >/dev/null || { echo ' gtk: no gsettings on PATH (pre-switch?)' >&2; return 0; } + local gtk_theme scheme + gtk_theme="$(meta gtk_theme || true)"; scheme="$(meta color_scheme || true)" + [[ -n "$gtk_theme" ]] || return 0 + gset org.gnome.desktop.interface gtk-theme "$gtk_theme" + gset org.gnome.desktop.wm.preferences theme "$gtk_theme" + gset org.gnome.desktop.interface color-scheme "$scheme" + printf ' gtk: %s, %s\n' "$gtk_theme" "$scheme" + apply_icons +} + +# MacTahoe[-]-{dark,light}: polarity from the theme, accent from the +# wallpaper (~/.local/state/wallpaper/accent, written by ~/.local/bin/wallpaper). +# Falls back to the stock blue folders when the accent variant is not installed. +apply_icons() { + command -v gsettings >/dev/null || return 0 + local suffix accent="" icons + suffix="$(meta icon_suffix || echo dark)" + [[ -r "${XDG_STATE_HOME:-$HOME/.local/state}/wallpaper/accent" ]] \ + && read -r accent < "${XDG_STATE_HOME:-$HOME/.local/state}/wallpaper/accent" + icons="MacTahoe-$suffix" + if [[ -n "$accent" ]]; then + local d + for d in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS//:/ }; do + [[ -d "$d/icons/MacTahoe-$accent-$suffix" ]] && { icons="MacTahoe-$accent-$suffix"; break; } + done + fi + gset org.gnome.desktop.interface icon-theme "$icons" + printf ' icons: %s\n' "$icons" +} + +apply() { + local name; name="$(current)" || die "no theme selected ($link missing)" + echo "theme: $name" + reload_kitty + reload_niri + reload_nvim + apply_gtk + set_claude_code "$(meta claude_code || echo dark)" +} + +switch_to() { + local name="$1" + [[ -d "$themes_dir/$name" && -f "$themes_dir/$name/meta" ]] \ + || die "unknown theme '$name' — try: $(list | tr '\n' ' ')" + # atomic retarget: build the new link beside the old one, then rename over it + ln -sfn "$themes_dir/$name" "$link.tmp" + mv -T "$link.tmp" "$link" + apply +} + +case "${1:-}" in + "") current || die "no theme selected" ;; + list) list ;; + apply) apply ;; + icons) [[ -L "$link" ]] || die "no theme selected"; apply_icons ;; + toggle) + mapfile -t names < <(list) + cur="$(current || true)"; next="${names[0]}" + for i in "${!names[@]}"; do + if [[ "${names[$i]}" == "$cur" ]]; then next="${names[$(( (i + 1) % ${#names[@]} ))]}"; fi + done + switch_to "$next" ;; + -h|--help) sed -n '2,11p' "$0" ;; + *) switch_to "$1" ;; +esac diff --git a/home/dot_local/bin/theme-prompt b/home/dot_local/bin/theme-prompt new file mode 100755 index 000000000..1265ad639 --- /dev/null +++ b/home/dot_local/bin/theme-prompt @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# F2 / Shift+F2 -> the theme picker and the accent picker: compact floating fzf +# prompts in the F1 (wake) / F9 (audio route) / F10 (sleep monitors) style. +# niri/binds.kdl runs `kitty --class theme-prompt -e ~/.local/bin/theme-prompt +# `, window-rules.kdl floats it. +# +# theme-prompt theme noir | claude-dark | claude-light -> ~/.local/bin/theme +# theme-prompt accent oat olive cactus sky fig heather coral +# -> ~/.local/bin/wallpaper (wallpaper + folder colour) +# +# Esc is a clean cancel. Everything a pick triggers is live (kitty, niri, +# herdr, fish, GTK3, folder colours); GTK4 apps and agent TUIs re-theme on +# their next start. +set -euo pipefail + +mode="${1:-theme}" + +mark() { [[ "$1" == "$2" ]] && echo '●' || echo ' '; } + +case "$mode" in + theme) + cur="$(theme 2>/dev/null || echo '-')" + rows() { + local t + for t in $(theme list 2>/dev/null || printf 'noir\nclaude-dark\nclaude-light\n'); do + printf '%-13s %s\n' "$t" "$(mark "$t" "$cur")" + done + } + prompt='theme ❯ ' ;; + accent) + cur="olive" + [[ -r "${XDG_STATE_HOME:-$HOME/.local/state}/wallpaper/accent" ]] \ + && read -r cur < "${XDG_STATE_HOME:-$HOME/.local/state}/wallpaper/accent" + rows() { + local a + for a in $(wallpaper list); do + printf '%-13s %s\n' "$a" "$(mark "$a" "$cur")" + done + } + prompt='accent ❯ ' ;; + *) printf 'theme-prompt: theme | accent\n' >&2; exit 1 ;; +esac + +pick="$(rows | fzf --prompt="$prompt" --header='● = current Esc cancels' --no-info --reverse --no-sort || true)" +[[ -n "$pick" ]] || exit 0 +name="$(awk '{print $1}' <<< "$pick")" + +case "$mode" in + theme) theme "$name" ;; + accent) wallpaper "$name" ;; +esac diff --git a/home/dot_local/bin/wallpaper b/home/dot_local/bin/wallpaper index c23ec33b5..aa4425494 100755 --- a/home/dot_local/bin/wallpaper +++ b/home/dot_local/bin/wallpaper @@ -1,14 +1,52 @@ #!/usr/bin/env bash -# wallpaper -- set desktop wallpaper -# Uses swaybg. Home Manager deploys the default repository wallpaper to -# ~/.local/share/wallpapers; this script re-points it at runtime. -DEFAULT="$HOME/.local/share/wallpapers/wallpaper.jpg" -path="${1:-$DEFAULT}" - -if [[ ! -f "$path" ]]; then - echo "File not found: $path" >&2 - exit 1 -fi - -pkill -x swaybg -setsid -f swaybg -i "$path" -m fill +# wallpaper -- set the desktop wallpaper (swaybg) and remember it. +# +# wallpaper restore the remembered wallpaper (login; default: olive) +# wallpaper one of the claude.ai/imagine themes rendered at 5120x2880: +# oat olive cactus sky fig heather coral +# wallpaper any image file, one-off (not an accent) +# wallpaper list +# +# The seven accents are the exact recovered claude.ai/imagine artwork +# (~/colors/waves/capture/imagine-background.svg) rasterised under Anthropic's +# own CSS recipe on each theme's ground (~/colors/waves/STATE.md). Home Manager +# deploys them to ~/.local/share/wallpapers. The accent is remembered in +# ~/.local/state/wallpaper/accent and also drives the folder colour: `theme +# icons` picks the MacTahoe--{dark,light} icon variant whose folders +# are that wallpaper's darker ground (pkgs/mactahoe-icon-theme.nix). +set -euo pipefail + +dir="$HOME/.local/share/wallpapers" +state="${XDG_STATE_HOME:-$HOME/.local/state}/wallpaper" +accents=(oat olive cactus sky fig heather coral) +default_accent="olive" + +die() { printf 'wallpaper: %s\n' "$*" >&2; exit 1; } +is_accent() { local a; for a in "${accents[@]}"; do [[ "$a" == "$1" ]] && return 0; done; return 1; } +accent_path() { printf '%s/imagine-%s-5120x2880.png\n' "$dir" "$1"; } + +show() { + local path="$1" + [[ -f "$path" ]] || die "file not found: $path" + pkill -x swaybg || true + setsid -f swaybg -i "$path" -m fill +} + +case "${1:-}" in + list) printf '%s\n' "${accents[@]}" ;; + "") + accent="$default_accent" + [[ -r "$state/accent" ]] && read -r accent < "$state/accent" + is_accent "$accent" || accent="$default_accent" + show "$(accent_path "$accent")" ;; + *) + if is_accent "$1"; then + show "$(accent_path "$1")" + mkdir -p "$state" + printf '%s\n' "$1" > "$state/accent.tmp" && mv -T "$state/accent.tmp" "$state/accent" + # folders follow the wallpaper (no-op until the switch that ships `theme`) + command -v theme >/dev/null && theme icons || true + else + show "$1" + fi ;; +esac diff --git a/home/dot_local/share/wallpapers/imagine-cactus-5120x2880.png b/home/dot_local/share/wallpapers/imagine-cactus-5120x2880.png new file mode 100644 index 000000000..1bff4a8a9 Binary files /dev/null and b/home/dot_local/share/wallpapers/imagine-cactus-5120x2880.png differ diff --git a/home/dot_local/share/wallpapers/imagine-coral-5120x2880.png b/home/dot_local/share/wallpapers/imagine-coral-5120x2880.png new file mode 100644 index 000000000..6507b87ef Binary files /dev/null and b/home/dot_local/share/wallpapers/imagine-coral-5120x2880.png differ diff --git a/home/dot_local/share/wallpapers/imagine-fig-5120x2880.png b/home/dot_local/share/wallpapers/imagine-fig-5120x2880.png new file mode 100644 index 000000000..fbd7987f0 Binary files /dev/null and b/home/dot_local/share/wallpapers/imagine-fig-5120x2880.png differ diff --git a/home/dot_local/share/wallpapers/imagine-heather-5120x2880.png b/home/dot_local/share/wallpapers/imagine-heather-5120x2880.png new file mode 100644 index 000000000..1b79c799c Binary files /dev/null and b/home/dot_local/share/wallpapers/imagine-heather-5120x2880.png differ diff --git a/home/dot_local/share/wallpapers/imagine-oat-5120x2880.png b/home/dot_local/share/wallpapers/imagine-oat-5120x2880.png new file mode 100644 index 000000000..40edab649 Binary files /dev/null and b/home/dot_local/share/wallpapers/imagine-oat-5120x2880.png differ diff --git a/home/dot_local/share/wallpapers/imagine-olive-5120x2880.png b/home/dot_local/share/wallpapers/imagine-olive-5120x2880.png new file mode 100644 index 000000000..fc441e7c0 Binary files /dev/null and b/home/dot_local/share/wallpapers/imagine-olive-5120x2880.png differ diff --git a/home/dot_local/share/wallpapers/imagine-sky-5120x2880.png b/home/dot_local/share/wallpapers/imagine-sky-5120x2880.png new file mode 100644 index 000000000..64f7ce92c Binary files /dev/null and b/home/dot_local/share/wallpapers/imagine-sky-5120x2880.png differ diff --git a/home/home.nix b/home/home.nix index 9d1c0988b..a71ca98ac 100644 --- a/home/home.nix +++ b/home/home.nix @@ -69,17 +69,12 @@ let configDirs = [ "niri" "kitty" + "ghostty" # kitty.conf's twin, read by libghostty in cmux Browser's panes "fish" "starship" - "zathura" "yt-dlp" "kanshi" "qt6ct" - # NOT cliamp: it writes its control socket, pidfile, log, play history and - # resume state beside its config, so a whole-dir link put all of that into - # the working tree (resume.json, carrying a Navidrome stream URL with its - # Subsonic token, was even committed). ~/.config/cliamp is a real directory - # now and only its config is linked, file by file, below. (2026-09-13) ]; # Python interpreter backing the niri helper bin/ scripts (wifi-menu, fzf-nmcli, …). @@ -133,6 +128,7 @@ in ./seat-feeder.nix ./ssh.nix ./tally.nix + ./theme.nix ./tally-filler.nix ./tally-pump.nix ./tally-uplink.nix @@ -160,44 +156,11 @@ in # --------------------------------------------------------------------------- # RAW configs (whole-dir per ~/.config/). # --------------------------------------------------------------------------- - # cliamp's one-time move from a whole-dir link to a real directory - # (2026-09-14). Home Manager cannot make that move by itself: its cleanup - # keeps the old ~/.config/cliamp link (the new generation still has a - # .config/cliamp path), and its link step then backs up config.toml and - # themes/ THROUGH that link, i.e. inside the repo checkout, and writes - # store symlinks there that resolve back to themselves. Reproduced with this - # Home Manager's own check-link-targets/cleanup/link scripts in a scratch - # HOME. So before linkGeneration, drop the old link when it points into a - # Home Manager generation, create the real directory, and carry cliamp's - # history.toml and resume.json across so nothing is lost. Idempotent: once - # ~/.config/cliamp is a real directory the test is false. Removable once - # every host has switched past it. - home.activation.cliampRealDir = lib.hm.dag.entryBetween [ "linkGeneration" ] [ "writeBoundary" ] '' - cliamp_dir="$HOME/.config/cliamp" - if [[ -L "$cliamp_dir" && "$(readlink "$cliamp_dir")" == $(readlink -e /nix/store)/*-home-manager-files/* ]]; then - cliamp_old="$(readlink -e "$cliamp_dir" || true)" - run rm $VERBOSE_ARG "$cliamp_dir" - run mkdir -p $VERBOSE_ARG "$cliamp_dir" - if [[ -n "$cliamp_old" ]]; then - for f in history.toml resume.json; do - if [[ -f "$cliamp_old/$f" && ! -e "$cliamp_dir/$f" ]]; then - run cp -p $VERBOSE_ARG "$cliamp_old/$f" "$cliamp_dir/$f" - fi - done - fi - fi - ''; - xdg.configFile = lib.genAttrs configDirs (d: { source = link "dot_config/${d}"; }) // { - # cliamp: config and themes only, so the directory itself stays writable - # for the runtime files (see the note in configDirs above). - "cliamp/config.toml".source = link "dot_config/cliamp/config.toml"; - "cliamp/themes".source = link "dot_config/cliamp/themes"; - # kitty/ is a whole-dir out-of-store symlink, so the store-path fragment can't # nest inside it — emit at a neutral path; kitty.conf includes it by absolute # (env-expanded) path. @@ -271,26 +234,18 @@ in '' // GENERATED per-host (home.nix). No host-specific niri config on ${hostName}. ''; - } - // ( - # GTK4 / libadwaita apps (Nautilus) ignore gtk-theme-name; the only override - # they honor is user CSS at ~/.config/gtk-4.0/. Link MacTahoe's gtk-4.0 assets - # there so Nautilus renders the theme from first boot — home-manager's gtk - # module does not do this, which is why nwg-look was needed before. - let - theme4 = "${pkgs.mactahoe-gtk-theme}/share/themes/MacTahoe-Dark-grey/gtk-4.0"; - in - { - "gtk-4.0/gtk.css".source = "${theme4}/gtk.css"; - "gtk-4.0/gtk-dark.css".source = "${theme4}/gtk-dark.css"; - "gtk-4.0/assets".source = "${theme4}/assets"; - } - ); - - # Belt-and-suspenders for any gsettings-aware app (agrees with GTK_THEME env). + }; + # (The gtk-4.0/{gtk.css,gtk-dark.css,assets} links that used to sit here — a + # store symlink of MacTahoe-Dark-grey's gtk-4.0 — moved to home/theme.nix, + # where they point through the ~/.config/theme pointer at whichever theme's + # MacTahoe variant is selected. 2026-09-17.) + + # gtk-theme / icon-theme / color-scheme are deliberately NOT pinned here any + # more (2026-09-17): the theme switcher owns them at runtime (`theme apply` + # → gsettings, docs/theme-switcher-2026-09-17.md), and a pinned value would + # snap a light session back to Dark on every switch. dconf keeps whatever + # `theme` last wrote. Fonts stay. dconf.settings."org/gnome/desktop/interface" = { - gtk-theme = "MacTahoe-Dark-grey"; - color-scheme = "prefer-dark"; # Interface fonts for Nautilus and every other GTK app that reads # font-name. sf-pro ships system-wide via modules/common.nix fonts.packages # (the one Apple family kept in the 2026-08-21 sweep — "too good to @@ -325,15 +280,17 @@ in home.file.".claude/rules/runtime-tests.md".source = link "agent-runtime-rules.md"; home.file.".codex/AGENTS.md".source = link "agent-runtime-rules.md"; - # SessionEnd -> the harvest verb (MEM-2, dotfiles#339). ONE link, not a - # whole-dir one, for the same reason as the lines above: ~/.claude/hooks must - # stay a real, writable directory, so a hook that is not delivered from this - # repository can still live beside it (the dead SessionStart hook that once - # named herdr-agent-state.sh there was removed 2026-09-13). settings.json is - # shared by all three Claude config dirs and names this hook by ABSOLUTE path, - # so one link serves ~/.claude, ~/.claude-work and ~/.claude-3 alike. - home.file.".claude/hooks/ai-memory-harvest.sh".source = - link "dot_claude/hooks/ai-memory-harvest.sh"; + # NO hook is delivered here any more. The SessionEnd harvest hook (MEM-2, + # dotfiles#339) was removed 2026-09-23: it blocked session exit for up to 57 s + # on the runs that actually harvested, and Claude Code reported the abort as + # "SessionEnd hook [...] failed: Hook cancelled" on every close. Of its last + # 101 logged runs, 54 were skips. The harvest verb itself is unchanged and + # still reachable on demand through the `drain` skill. + # + # ~/.claude/hooks is deliberately left as a real, writable directory owned by + # nobody: a hook that is not delivered from this repository can still live + # there (the dead SessionStart hook that once named herdr-agent-state.sh was + # removed the same way, 2026-09-13). # Second Claude account (work): `cc2`/`cac2` in fish set CLAUDE_CONFIG_DIR to # ~/.claude-work. Same skills + settings, separate .credentials.json/.claude.json. @@ -553,7 +510,6 @@ in mpv imv vlc - zathura ffmpeg-full ffmpegthumbnailer @@ -591,7 +547,6 @@ in pkgs.crm # vendored personal CRM CLI; data stays at its built-in notes path pkgs.dcal # vendored calendar CLI; data lives under XDG, nothing in git music-acquire # evidence-gated SoundCloud → YouTube → capture acquisition - cliamp # terminal music player → navidrome. overlay pkg, see pkgs/cliamp.nix uv # Astral Python pkg/project manager. "hot" overlay pkg — rides nixpkgs-fresh HEAD (flake.nix), so it stays latest independent of the main pin. # artifact system (md-artifact / presentation-beta / publish-artifact skills; diff --git a/home/nvim.nix b/home/nvim.nix index 6a45206b0..7d2b15649 100644 --- a/home/nvim.nix +++ b/home/nvim.nix @@ -179,8 +179,10 @@ let "lua/plugins/neoscroll.lua" "lua/plugins/nvim-tree.lua" "lua/plugins/telescope.lua" - "lua/plugins/bufferline.lua" "lua/plugins/lualine.lua" + # NOT lua/theme.lua: the theme switcher's palette loader lives in + # ~/.local/bin/nvim-lua/ (a whole-dir RAW link on init.lua's package.path), + # so a checkout pulled ahead of its switch still finds it. See its header. ]; # every binary the nvim config shells out to (replaces mason; covers all plugin CLIs) diff --git a/home/nvim/plugins.lua.in b/home/nvim/plugins.lua.in index fc22f4eed..21e656dde 100644 --- a/home/nvim/plugins.lua.in +++ b/home/nvim/plugins.lua.in @@ -121,23 +121,8 @@ require("lazy").setup({ 'akinsho/bufferline.nvim', dir = dir('bufferline.nvim'), config = function() - require('bufferline').setup({ - options = { - hover = { - enabled = true, - delay = 200, - reveal = {'close'} - } - }, - highlights = { - fill = { - bg = "#000000" - }, - background = { - bg = "#000000" - } - } - }) + -- options + theme-ground highlights from lua/theme.lua (see catppuccin above) + require('bufferline').setup(require('theme').bufferline_opts()) end }, { @@ -145,63 +130,10 @@ require("lazy").setup({ name = 'catppuccin', dir = dir('catppuccin'), config = function() - require('catppuccin').setup({ - flavour = "mocha", - transparent_background = true, - color_overrides = { - mocha = { - base = "#000000", - mantle = "#000000", - crust = "#000000", - text = "#EAECF0", - subtext1 = "#D3D7DE", - overlay1 = "#818898", - red = "#F47B85", - green = "#9BE963", - blue = "#70B8FF", - mauve = "#CC7BF4", - peach = "#FBAD60", - teal = "#5EEDED", - }, - }, - custom_highlights = function(colors) - local U = require("catppuccin.utils.colors") - local bg_amount = 0.095 - local rainbow = { - colors.blue, -- rainbow1 (H1) - colors.peach, -- rainbow2 (H2) - colors.green, -- rainbow3 (H3) - colors.teal, -- rainbow4 (H4) - colors.yellow, -- rainbow5 (H5) - colors.mauve, -- rainbow6 (H6) - } - local highlights = {} - for i, color in ipairs(rainbow) do - highlights["rainbow" .. i] = { fg = color } - highlights["RenderMarkdownH" .. i] = { fg = color, bold = true } - highlights["RenderMarkdownH" .. i .. "Bg"] = { bg = U.darken(color, bg_amount, colors.base) } - end - highlights["RenderMarkdownCode"] = { bg = U.darken(colors.text, 0.05, colors.base) } - highlights["RenderMarkdownCodeInline"] = { fg = colors.peach } - highlights["RenderMarkdownBullet"] = { fg = colors.blue } - highlights["RenderMarkdownDash"] = { fg = colors.overlay1 } - highlights["RenderMarkdownQuote"] = { fg = colors.overlay1 } - highlights["RenderMarkdownLink"] = { fg = colors.blue } - highlights["RenderMarkdownChecked"] = { fg = colors.green } - highlights["RenderMarkdownUnchecked"] = { fg = colors.overlay1 } - highlights["RenderMarkdownTableHead"] = { fg = colors.blue } - highlights["RenderMarkdownTableRow"] = { fg = colors.subtext1 } - -- catppuccin's default Underlined has no fg (just the underline style), - -- so man pages, :help tags, etc. render in plain text color instead of - -- the Claude-blue used everywhere else for links/URLs. - highlights["Underlined"] = { fg = colors.blue, style = { "underline" } } - return highlights - end, - integrations = { - render_markdown = true, - }, - }) - vim.cmd.colorscheme "catppuccin" + -- flavour, colour overrides and custom highlights all come from the RAW + -- lua/theme.lua, which reads ~/.config/theme/theme.lua (the theme + -- switcher's fragment) and can re-apply it live via reload(). + require('theme').apply_catppuccin() end }, { diff --git a/home/theme.nix b/home/theme.nix new file mode 100644 index 000000000..6b985b431 --- /dev/null +++ b/home/theme.nix @@ -0,0 +1,77 @@ +{ + config, + lib, + pkgs, + ... +}: +# Theme switcher — the Nix half (2026-09-17, docs/theme-switcher-2026-09-17.md). +# +# Every theme's color fragments are written under ~/.config/themes// in +# one generation (store-managed, re-emitted on switch). The RAW config files +# include ~/.config/theme/ — `theme`, singular, a plain symlink into +# `themes/` that ~/.local/bin/theme retargets at runtime and Home Manager never +# writes. Disjoint namespaces: HM owns the plural, the switcher owns the +# singular, so HM's cleanup can never eat the pointer and the pointer can never +# confuse HM's link step. Switching a theme therefore needs no rebuild; only +# adding a theme or changing a palette value does. +# +# The two precedents this copies: kitty-scrollback-nix.conf and niri-local.kdl +# (home.nix), generated files at a neutral ~/.config path pulled in by an +# absolute include from inside a whole-dir RAW symlink. +let + themes = import ./themes { inherit lib; }; + cfgHome = config.xdg.configHome; + # GTK4 / libadwaita apps ignore gtk-theme-name; the only override they honour + # is user CSS at ~/.config/gtk-4.0/. Each theme dir carries its MacTahoe + # variant's gtk-4.0/ (a store path), and ~/.config/gtk-4.0/{gtk.css, + # gtk-dark.css,assets} point THROUGH the ~/.config/theme pointer at it, so + # the one symlink flip re-themes GTK4 too (on the app's next start; GTK4 + # reads gtk.css once). GTK3 follows `gsettings gtk-theme` live instead. + gtk4Dirs = lib.mapAttrs' ( + name: t: + lib.nameValuePair "themes/${name}/gtk-4.0" { + source = "${pkgs.${t.gtk.package}}/share/themes/${t.gtk.theme}/gtk-4.0"; + } + ) themes.checked; + viaPointer = f: { + source = config.lib.file.mkOutOfStoreSymlink "${cfgHome}/theme/gtk-4.0/${f}"; + }; +in +{ + xdg.configFile = + lib.mapAttrs (_: text: { inherit text; }) themes.fragments + // gtk4Dirs + // { + "gtk-4.0/gtk.css" = viaPointer "gtk.css"; + "gtk-4.0/gtk-dark.css" = viaPointer "gtk-dark.css"; + "gtk-4.0/assets" = viaPointer "assets"; + }; + + # Every theme's GTK package must be on the profile so GTK finds + # share/themes/ through XDG_DATA_DIRS (home.nix's gtk.theme.package + # covers noir's; the claude variants ride along here). + # (unique on the attr NAMES: lib.unique on derivations compares attrsets and + # recurses forever.) + home.packages = map (n: pkgs.${n}) (lib.unique (lib.mapAttrsToList (_: t: t.gtk.package) themes.checked)); + + # Bootstrap and self-heal the pointer, never override a choice: + # - missing or dangling → noir + # - pointing outside ~/.config/themes/ (a pre-switch preview render) → the + # theme of the same name if it exists, else noir + # - already a live theme → untouched + home.activation.themePointer = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + theme_link="${cfgHome}/theme" + themes_dir="${cfgHome}/themes" + want="" + if [[ ! -e "$theme_link" ]]; then + want="noir" + elif [[ "$(readlink -f "$theme_link")" != "$themes_dir"/* ]]; then + want="$(basename "$(readlink "$theme_link")")" + [[ -d "$themes_dir/$want" ]] || want="noir" + fi + if [[ -n "$want" ]]; then + run ln -sfn $VERBOSE_ARG "$themes_dir/$want" "$theme_link.tmp" + run mv -T $VERBOSE_ARG "$theme_link.tmp" "$theme_link" + fi + ''; +} diff --git a/home/themes/claude-dark.nix b/home/themes/claude-dark.nix new file mode 100644 index 000000000..c83fb56ce --- /dev/null +++ b/home/themes/claude-dark.nix @@ -0,0 +1,34 @@ +# claude-dark — Claude's own dark mode, as claude.ai ships it. +# +# Same 12-role accent palette as noir (they are the same palette — Claude Dark +# was recovered from the app bundle and Tom's hand-sampled noir accents matched +# it bit for bit, ~/colors/waves/capture/claude-code-theme/README-THEMES.md). +# What changes is the ground: claude.ai's code blocks sit on #1A1A1A (--bg-000 +# at 50% over the page) and the page itself on #20201F (--bg-000 dark), with +# #151515 (--bg-100) one step below. Sources: claude-tokens-dark.json. +let + noir = import ./noir.nix; +in +noir +// { + name = "claude-dark"; + ground = { + base = "#1A1A1A"; # code-block ground: the closest analogue to a terminal + dim = "#151515"; # --bg-100 + raised = "#20201F"; # --bg-000 + desktop = "#20201F"; # the desktop reads one step above the terminal + }; + muted = "#52514E"; # --_gray-600 + cursorText = "#1A1A1A"; + ansi = noir.ansi // { + black = "#1A1A1A"; + }; + + # GTK: MacTahoe recoloured to Claude's dark tokens, clay accent + # (pkgs/mactahoe-gtk-theme.nix, variant claude). + gtk = { + package = "mactahoe-claude-gtk-theme"; + theme = "MacTahoe-Claude-Dark-orange"; + colorScheme = "prefer-dark"; + }; +} diff --git a/home/themes/claude-light.nix b/home/themes/claude-light.nix new file mode 100644 index 000000000..b627fa929 --- /dev/null +++ b/home/themes/claude-light.nix @@ -0,0 +1,75 @@ +# claude-light — Claude's own light mode, as claude.ai ships it. +# +# Accents are the Claude Light 12-role syntax palette from the app bundle +# (~/colors/waves/capture/claude-code-theme/claude-themes-SOURCE.json); grounds +# and chrome are the light-mode CSS tokens (claude-tokens-light.json). Claude's +# theme defines NO terminal.ansi* colors, so the 16-slot mapping below is +# designed, not copied — every slot passes WCAG AA for normal text on #FFFFFF +# (~/colors/theme-switcher-design.md §5). Tune in ~/colors/colorlab. +# +# color0/color7/color15 follow the noir convention (dark text slots, since herdr's +# `terminal` theme uses White/Gray as FOREGROUNDS) rather than Solarized's +# light-grey convention. The price, inherent to every light terminal theme: +# anything that paints bright-white-on-black gets #131313 on #2B303B. +{ + name = "claude-light"; + polarity = "light"; + catppuccinFlavour = "latte"; + claudeCode = "light"; + + ground = { + base = "#F9F9F7"; # --bg-100, the app's main surface + dim = "#F3F3F0"; # --bg-200 + raised = "#FFFFFF"; # --bg-000 + desktop = "#F0EFEC"; # --bg-300 + }; + + fg = "#131313"; # --text-000 (Claude Light syntax fg is #14181F, one step away) + fgDim = "#52514E"; # --cds-text-secondary light + comment = "#6E7687"; # Claude Light `comment` + muted = "#A5A49A"; # --_gray-300 + + red = "#B80A18"; # property + green = "#008000"; # string + yellow = "#A66A00"; # --warning-100 — the palette has no yellow role + blue = "#0051C2"; # function + magenta = "#8100C2"; # keyword + cyan = "#007F80"; # number + orange = "#B24A00"; # variable + + accent = "#0051C2"; + brand = "#D97757"; + + ansi = { + black = "#2B303B"; # punctuation + white = "#52514E"; # --cds-text-secondary + brightBlack = "#6E7687"; # comment + brightWhite = "#131313"; # fg + }; + + selection = { + bg = "#CDE2FB"; # --accent-900 light: Claude's own selection tint + fg = "#131313"; + }; + cursor = "#131313"; + cursorText = "#F9F9F7"; + + border = { + active = "#A5A49A"; # --_gray-300 + inactive = "#D2D1C7"; # --_gray-150 + urgent = "#B80A18"; + }; + tabIndicator = { + active = "#8100C2"; + inactive = "#97958D"; # --text-400 light + }; + insertHint = "#8100C280"; + + # GTK: MacTahoe recoloured to Claude's light tokens, clay accent + # (pkgs/mactahoe-gtk-theme.nix, variant claude). + gtk = { + package = "mactahoe-claude-gtk-theme"; + theme = "MacTahoe-Claude-Light-orange"; + colorScheme = "prefer-light"; + }; +} diff --git a/home/themes/default.nix b/home/themes/default.nix new file mode 100644 index 000000000..9d87628bb --- /dev/null +++ b/home/themes/default.nix @@ -0,0 +1,223 @@ +# The palette source of truth and the per-consumer renderers. +# +# Three hand-curated themes, no generation from wallpapers: each *.nix here is a +# plain attrset keyed by ROLE (fg, comment, red, ground.base, …), never by +# consumer. `render` turns one palette into the color FRAGMENT each app +# includes; home/theme.nix writes every fragment for every theme under +# ~/.config/themes// and the RAW config files join theirs through the +# app's own include of ~/.config/theme/, a symlink ~/.local/bin/theme +# flips at runtime. Design: docs/theme-switcher-2026-09-17.md. +# +# Pure: `{ lib }` only, so a preview can be rendered with a bare `nix eval` +# before any switch (see the doc's "Testing before a switch"). +{ lib }: +let + header = t: mark: "${mark} GENERATED by home/theme.nix from home/themes/${t.name}.nix — edit the palette, not this file."; + + kitty = t: '' + ${header t "#"} + # Joined by kitty.conf's `include ''${HOME}/.config/theme/kitty.conf`. + background ${t.ground.base} + foreground ${t.fg} + selection_background ${t.selection.bg} + selection_foreground ${t.selection.fg} + cursor ${t.cursor} + cursor_text_color ${t.cursorText} + cursor_trail_color ${t.accent} + url_color ${t.accent} + + # black + color0 ${t.ansi.black} + color8 ${t.ansi.brightBlack} + # red + color1 ${t.red} + color9 ${t.red} + # green + color2 ${t.green} + color10 ${t.green} + # yellow + color3 ${t.yellow} + color11 ${t.yellow} + # blue + color4 ${t.blue} + color12 ${t.blue} + # magenta + color5 ${t.magenta} + color13 ${t.magenta} + # cyan + color6 ${t.cyan} + color14 ${t.cyan} + # white + color7 ${t.ansi.white} + color15 ${t.ansi.brightWhite} + + # tabs + active_tab_background ${t.ground.base} + active_tab_foreground ${t.fg} + inactive_tab_background ${t.ground.dim} + inactive_tab_foreground ${t.comment} + tab_bar_background ${t.ground.dim} + + # marks + mark1_foreground ${t.ground.base} + mark1_background ${t.magenta} + + # splits: the divider matches niri's INACTIVE window border, so a kitty + # split reads as a quiet niri-native divider (Tom 2026-07-15). + active_border_color ${t.border.inactive} + inactive_border_color ${t.ground.base} + ''; + + # Ghostty (libghostty inside cmux Browser's terminal panes): the kitty + # fragment, key for key, in Ghostty's `key = value` grammar. + ghostty = t: '' + ${header t "#"} + # Joined by ghostty/config.ghostty's `config-file = ?/home/tom/.config/theme/ghostty`. + background = ${t.ground.base} + foreground = ${t.fg} + selection-background = ${t.selection.bg} + selection-foreground = ${t.selection.fg} + cursor-color = ${t.cursor} + cursor-text = ${t.cursorText} + + # black + palette = 0=${t.ansi.black} + palette = 8=${t.ansi.brightBlack} + # red + palette = 1=${t.red} + palette = 9=${t.red} + # green + palette = 2=${t.green} + palette = 10=${t.green} + # yellow + palette = 3=${t.yellow} + palette = 11=${t.yellow} + # blue + palette = 4=${t.blue} + palette = 12=${t.blue} + # magenta + palette = 5=${t.magenta} + palette = 13=${t.magenta} + # cyan + palette = 6=${t.cyan} + palette = 14=${t.cyan} + # white + palette = 7=${t.ansi.white} + palette = 15=${t.ansi.brightWhite} + + # splits: as kitty, the divider is niri's INACTIVE window border. + split-divider-color = ${t.border.inactive} + unfocused-split-fill = ${t.ground.base} + ''; + + niri = t: '' + ${header t "//"} + // Joined by niri/config.kdl's `include optional=true "~/.config/theme/niri.kdl"`, + // LAST, so these merge over layout.kdl / misc.kdl (niri merges sections + // across includes; later wins per property — wiki, Configuration: Include). + layout { + border { + on + active-color "${t.border.active}" + inactive-color "${t.border.inactive}" + urgent-color "${t.border.urgent}" + } + tab-indicator { + active-color "${t.tabIndicator.active}" + inactive-color "${t.tabIndicator.inactive}" + urgent-color "${t.border.urgent}" + } + insert-hint { + color "${t.insertHint}" + } + background-color "${t.ground.desktop}" + } + overview { + backdrop-color "${t.ground.desktop}" + } + ''; + + # fish wants bare hex, no '#'. + bare = c: lib.removePrefix "#" c; + fish = t: '' + ${header t "#"} + # Sourced by fish/conf.d/colors.fish (which also re-sources it when the + # ~/.config/theme symlink moves). Roles follow the nvim/kitty mapping. + set -g fish_color_normal ${bare t.fg} + set -g fish_color_command ${bare t.blue} + set -g fish_color_param ${bare t.green} + set -g fish_color_keyword ${bare t.red} + set -g fish_color_quote ${bare t.green} + set -g fish_color_end ${bare t.orange} + set -g fish_color_comment ${bare t.comment} + set -g fish_color_error ${bare t.red} + set -g fish_color_option ${bare t.green} + set -g fish_color_cancel ${bare t.red} + set -g fish_color_cwd ${bare t.yellow} + set -g fish_color_user ${bare t.cyan} + set -g fish_color_host ${bare t.blue} + set -g fish_color_host_remote ${bare t.green} + set -g fish_color_status ${bare t.red} + set -g fish_pager_color_completion ${bare t.fg} + ''; + + q = s: ''"${s}"''; + lua = t: '' + ${header t "--"} + -- dofile()'d by nvim/lua/theme.lua; returns the palette as a Lua table. + return { + name = ${q t.name}, + polarity = ${q t.polarity}, + catppuccinFlavour = ${q t.catppuccinFlavour}, + ground = { base = ${q t.ground.base}, dim = ${q t.ground.dim}, raised = ${q t.ground.raised}, desktop = ${q t.ground.desktop} }, + fg = ${q t.fg}, fgDim = ${q t.fgDim}, comment = ${q t.comment}, muted = ${q t.muted}, + red = ${q t.red}, green = ${q t.green}, yellow = ${q t.yellow}, blue = ${q t.blue}, + magenta = ${q t.magenta}, cyan = ${q t.cyan}, orange = ${q t.orange}, + accent = ${q t.accent}, brand = ${q t.brand}, + selection = { bg = ${q t.selection.bg}, fg = ${q t.selection.fg} }, + border = { active = ${q t.border.active}, inactive = ${q t.border.inactive}, urgent = ${q t.border.urgent} }, + } + ''; + + # key=value, read by ~/.local/bin/theme with `read`, never sourced. + # icon_suffix: the MacTahoe icon dirs are MacTahoe[-]-{dark,light}; + # the accent comes from `wallpaper`, the polarity from here. + meta = t: '' + name=${t.name} + polarity=${t.polarity} + claude_code=${t.claudeCode} + gtk_theme=${t.gtk.theme} + color_scheme=${t.gtk.colorScheme} + icon_suffix=${if t.polarity == "light" then "light" else "dark"} + ''; +in +rec { + themes = { + noir = import ./noir.nix; + claude-dark = import ./claude-dark.nix; + claude-light = import ./claude-light.nix; + }; + + # Every theme must fill every role: a missing key would surface as a Nix + # eval error deep in a string interpolation, so check up front. + roles = [ "name" "polarity" "catppuccinFlavour" "claudeCode" "ground" "fg" "fgDim" "comment" "muted" + "red" "green" "yellow" "blue" "magenta" "cyan" "orange" "accent" "brand" "ansi" "selection" + "cursor" "cursorText" "border" "tabIndicator" "insertHint" "gtk" ]; + checked = lib.mapAttrs (n: t: + let missing = lib.filter (r: !(t ? ${r})) roles; + in if missing == [ ] && t.name == n then t + else throw "home/themes/${n}.nix: name=${t.name}, missing roles: ${toString missing}") themes; + + render = t: { + "kitty.conf" = kitty t; + "ghostty" = ghostty t; + "niri.kdl" = niri t; + "colors.fish" = fish t; + "theme.lua" = lua t; + "meta" = meta t; + }; + + # { "themes//" = text; } for every theme — what theme.nix emits. + fragments = lib.concatMapAttrs (name: t: + lib.mapAttrs' (f: text: lib.nameValuePair "themes/${name}/${f}" text) (render t)) checked; +} diff --git a/home/themes/noir.nix b/home/themes/noir.nix new file mode 100644 index 000000000..2df31e362 --- /dev/null +++ b/home/themes/noir.nix @@ -0,0 +1,71 @@ +# noir — Tom's catppuccin-noir: Claude Dark's accents on pure OLED black. +# +# The accents are Claude Dark's 12-role syntax palette, bit for bit +# (~/colors/waves/capture/claude-code-theme/claude-themes-SOURCE.json, extracted +# from the claude.ai bundle). The grounds are #000000 — panel-off black on the +# Zenbook's OLED, and the habit the whole colorscheme investigation started from +# (~/colors/HANDOFF.md §1). noir differs from claude-dark ONLY in `ground` and +# `muted`; everything else is shared by construction, see claude-dark.nix. +{ + name = "noir"; + polarity = "dark"; + catppuccinFlavour = "mocha"; + claudeCode = "dark"; # ~/.claude.json "theme" + + ground = { + base = "#000000"; # terminal / editor background + dim = "#000000"; # tab bar, one step below base + raised = "#000000"; # popups, one step above base + desktop = "#000000"; # niri background-color + overview backdrop + }; + + fg = "#EAECF0"; # Claude Dark `fg` + fgDim = "#D3D7DE"; # Claude Dark `punctuation` + comment = "#818898"; # Claude Dark `comment` + muted = "#45475a"; # inactive UI text (catppuccin mocha surface1, as bufferline had it) + + red = "#F47B85"; # property + green = "#9BE963"; # string + yellow = "#f9e2af"; # catppuccin mocha yellow — Claude's palette has no yellow role + blue = "#70B8FF"; # function + magenta = "#CC7BF4"; # keyword + cyan = "#5EEDED"; # number + orange = "#FBAD60"; # variable + + accent = "#70B8FF"; # URLs, cursor trail + brand = "#D97757"; # Anthropic clay — identical in Claude's light and dark tokens + + # The 16 ANSI slots. Bright 1–6 equal normal 1–6: Claude's palette has exactly + # one hue per role, and that is how kitty.conf has always been. + ansi = { + black = "#000000"; + white = "#D3D7DE"; + brightBlack = "#818898"; + brightWhite = "#EAECF0"; + }; + + selection = { + bg = "#264F78"; # Claude Code's TUI selection highlight (also VS Code's default) + fg = "#EAECF0"; + }; + cursor = "#EAECF0"; + cursorText = "#000000"; + + border = { + active = "#555451"; + inactive = "#373735"; + urgent = "#F47B85"; + }; + tabIndicator = { + active = "#CC7BF4"; + inactive = "#818898"; + }; + insertHint = "#CC7BF480"; + + # GTK: the OLED-black MacTahoe (pkgs/mactahoe-gtk-theme.nix, variant oled). + gtk = { + package = "mactahoe-gtk-theme"; # pkgs attr, resolved in home/theme.nix + theme = "MacTahoe-Dark-grey"; + colorScheme = "prefer-dark"; + }; +} diff --git a/hosts/coordinator/disko.nix b/hosts/coordinator/disko.nix index 7140edc8e..e0f067d0d 100644 --- a/hosts/coordinator/disko.nix +++ b/hosts/coordinator/disko.nix @@ -64,6 +64,17 @@ # Wiped blank before the physical move (no partition table, no bootloader), # then laid out here. # + # ── OWNERSHIP (Tom, 2026-09-19) ───────────────────────────────────────────── + # THIS DISK BELONGS TO SODIMO. It is one of exactly two sodimo-owned items in + # the fleet, the other being the `worker` chassis. It is here only because the + # 2026-08-30 transition fitted Tom's own 1TB (26051Y809195) into worker and + # moved this one across. + # + # It LEAVES with worker (2+ months out as of 2026-09-19; per FRONT-10 no agent + # invents or enforces that date). /home lives on it, so that departure is a + # migration off this disk, not an unplug — plan it as one. Afterwards the + # coordinator runs on its own 1TB anchor 25140U804698 with no secondary. + # # ── The rule this disk exists to enforce ──────────────────────────────────── # The 1TB anchor holds the OS and only the OS: the nix store and everything # NixOS derives from it, /etc, /var, and the local-models weight collection. diff --git a/hosts/coordinator/services.nix b/hosts/coordinator/services.nix index 572a545d4..0ed3e5eca 100644 --- a/hosts/coordinator/services.nix +++ b/hosts/coordinator/services.nix @@ -43,9 +43,8 @@ in # satisfied WITHOUT a password file and the old immich-db.age secret is retired. # services.immich also subsumes the redis + machine-learning sidecars natively. # The navidrome-credentials secret is unrelated to the server — it is consumed -# client-side by the cliamp fish function — and is delivered in -# modules/secrets.nix (coordinator only since the zenbook-duo left the fleet, -# 2026-08-30; it was the second cliamp host). +# client-side by the navidrome-scan fish function — and is delivered in +# modules/secrets.nix (coordinator only). # # Reachability: both bind 0.0.0.0, but the firewall opens their ports ONLY on # tailscale0 (the trust model the wayvnc door used until the 2026-09-11 flip @@ -225,9 +224,7 @@ in # navidrome-credentials delivery moved to modules/secrets.nix (2026-07-13): # it's NOT consumed by the navidrome server here — only read client-side by - # the cliamp fish function. It moved there when cliamp gained a second host - # (zenbook-duo, retired 2026-08-30) and a single host-agnostic block beat - # duplicating the delivery; it stays there now that the coordinator is the - # only recipient again, because that is where secret delivery belongs. + # the navidrome-scan fish function. It stays there because that is where + # secret delivery belongs. }; } diff --git a/hosts/nas/disko.nix b/hosts/nas/disko.nix index 87df34a1e..6736258f4 100644 --- a/hosts/nas/disko.nix +++ b/hosts/nas/disko.nix @@ -42,6 +42,16 @@ # generated media (media.nix). Random-I/O state belongs here: it would be # wear on the eMMC and seek chatter on the HDD. The mountpoint move was # non-destructive — same filesystem, never reformatted. + # + # ── In the 2026-09-19 disk shuffle ────────────────────────────────────────── + # This Fanxiang is Tom's and is a CANDIDATE TO MOVE to the fanless desk NUC: + # 256GB is ample for a seat that only runs Chromium. In that option the 1TB + # freed by the worker return lands here instead, taking /mnt/fast to 1TB. + # The alternative sends the freed 1TB straight to the NUC and leaves this + # disk alone. Undecided as of 2026-09-19; either way nothing is purchased. + # If this disk does move, /mnt/fast is offline in the interim — it was 49% + # of 234G at the 2026-09-18 census and carries PostgreSQL, Navidrome state, + # Immich generated media and the remote journal. Check those before moving. disko.devices.disk.journal = { type = "disk"; device = "/dev/disk/by-id/nvme-Fanxiang_S500Pro_256GB_26040259615000015"; diff --git a/hosts/nas/media.nix b/hosts/nas/media.nix index f83d96420..315d0cb51 100644 --- a/hosts/nas/media.nix +++ b/hosts/nas/media.nix @@ -200,7 +200,7 @@ in # Enabled && ScanOnStartup). Upstream defaults this to TRUE, and this # is what was actually firing: the service is socket-activated, # StopWhenUnneeded, behind a proxy that exits after 15 min idle, so - # it restarts constantly and every cliamp launch or web-UI visit + # it restarts constantly and every client connect or web-UI visit # after a quiet spell triggered a full 34k-file walk. One was watched # taking 9+ minutes on 2026-08-18. ScanOnStartup = false; diff --git a/hosts/worker/disko.nix b/hosts/worker/disko.nix index 0f8f06405..9fd20028d 100644 --- a/hosts/worker/disko.nix +++ b/hosts/worker/disko.nix @@ -6,6 +6,22 @@ # 500GB paired against the coordinator's 1TB was the fleet's dual-node # capacity ceiling. # + # ── OWNERSHIP (Tom, 2026-09-19) ───────────────────────────────────────────── + # THIS DISK IS TOM'S, NOT SODIMO'S. sodimo owns exactly two items in this + # fleet: the `worker` chassis and the 500GB SN7100 260538801482 — and that + # 500GB is fitted in the COORDINATOR today, not here. When worker goes back + # (2+ months out as of 2026-09-19; per FRONT-10 no agent invents or enforces + # that date), the 500GB returns to this chassis and THIS 1TB COMES OUT. + # + # Do not read "worker's boot disk" as "unavailable". That is a filesystem + # fact, not an ownership fact, and conflating the two sent the silent-desk + # planning down a wrong path until it was corrected on 2026-09-19. + # + # The freed 1TB is the fleet's ENTIRE NVMe surplus. It is spoken for: either + # it becomes the fanless desk NUC's disk, or it upgrades the NAS /mnt/fast + # (256GB -> 1TB) and the NAS's Fanxiang 256GB goes to the NUC instead. + # Undecided. Standing constraint (Tom, 2026-09-19): NO NEW M.2 IS PURCHASED. + # # ── Why the attr is `w1t` and NOT `main` ──────────────────────────────────── # disko derives GPT partition NAMES from the attr: `main` yields # `disk-main-ESP` / `disk-main-root`. The coordinator's anchor uses those same diff --git a/modules/common.nix b/modules/common.nix index 2fcaa0bba..822b601a5 100644 --- a/modules/common.nix +++ b/modules/common.nix @@ -413,11 +413,15 @@ # --- env (qt + gtk theming) --- environment.sessionVariables = { QT_QPA_PLATFORMTHEME = "qt6ct"; - # GTK reads GTK_THEME with the highest priority. niri has no XSettings/settings - # daemon, so this system-wide export (reaching GUI apps via the PAM session) is - # what makes GTK3 apps like Nautilus honor the theme without nwg-look. It must be - # here, not home.sessionVariables (which only reaches interactive shells). - GTK_THEME = "MacTahoe-Dark-grey"; + # GTK_THEME is gone (2026-09-17). It pinned MacTahoe-Dark-grey for every GTK + # app because niri has no settings daemon — but GTK3's Wayland backend reads + # org.gnome.desktop.interface straight from gsettings/dconf and follows it + # LIVE, provided the schema is installed; it never was (no gschemas.compiled + # with that schema anywhere on XDG_DATA_DIRS), which is the actual reason + # gsettings-driven theming looked dead here. gsettings-desktop-schemas is in + # systemPackages below, and ~/.local/bin/theme sets gtk-theme / icon-theme / + # color-scheme per theme. An env var cannot change under a running session, + # so it had to go for the theme switcher to work without a re-login. # Chromium/Electron (google-chrome + PWA launchers) only run native Wayland # under niri with this set; otherwise they fall back to X11 and blur/fail. NIXOS_OZONE_WL = "1"; @@ -434,6 +438,19 @@ # --- base system packages (the rest are user packages in home/) --- environment.systemPackages = with pkgs; [ + # --- gsettings-driven GTK theming (theme switcher, 2026-09-17) --- + # glib: the `gsettings` CLI ~/.local/bin/theme calls (it was on no PATH in + # the session, so startup.kdl's old `spawn-at-startup "gsettings" …` lines + # had been failing silently). The schemas: nixpkgs installs them under + # share/gsettings-schemas//, which only wrapped apps see; GTK's own + # lookup is $XDG_DATA_DIRS/glib-2.0/schemas, so re-home them there + # (precompiled — no glib-compile-schemas pass depends on glib.dev). + glib + (runCommand "gsettings-desktop-schemas-on-xdg-data-dirs" { } '' + mkdir -p $out/share/glib-2.0/schemas + cp ${gsettings-desktop-schemas}/share/gsettings-schemas/*/glib-2.0/schemas/* $out/share/glib-2.0/schemas/ + test -f $out/share/glib-2.0/schemas/gschemas.compiled + '') git vim wl-clipboard diff --git a/modules/secrets.nix b/modules/secrets.nix index 20e6ede9f..135c0bd19 100644 --- a/modules/secrets.nix +++ b/modules/secrets.nix @@ -23,35 +23,12 @@ in config = lib.mkIf cfg.enable ( lib.mkMerge [ - # Claude Code OAuth credential — coordinator ONLY, and the recipient tier in - # ../secrets.nix now matches (aug04 ruling), so this MUST stay host-gated: - # no other host can decrypt the ciphertext, and declaring an undecryptable - # secret fails activation. The zenbook was already excluded (jul12 ruling: - # the laptop is a standalone backup operator for when the coordinator is - # unreachable, so it logs in with its OWN fresh OAuth session instead of - # inheriting the coordinator's token — two devices refreshing one shared - # token can race and sign each other out); the nas joined it aug04 for the - # simpler reason that it has no `claude` and never spent the token. - (lib.mkIf (config.networking.hostName == "coordinator") { - age.secrets.claude-credentials = { - file = ../secrets/claude-credentials.age; - owner = "tom"; - group = "users"; - mode = "600"; - }; - - # Seed the Claude Code OAuth credential once into a WRITABLE path Claude owns — - # agenix delivers a read-only /run/agenix symlink, but Claude must rewrite the - # file on token refresh, so copy rather than link, and only if absent. - system.userActivationScripts.seedClaudeCreds.text = '' - cred="$HOME/.claude/.credentials.json" - if [ ! -e "$cred" ] && [ -r "${config.age.secrets.claude-credentials.path}" ]; then - mkdir -p "$HOME/.claude" - cp "${config.age.secrets.claude-credentials.path}" "$cred" - chmod 600 "$cred" - fi - ''; - }) + # (claude-credentials — the Claude Code OAuth token seeded into + # ~/.claude/.credentials.json — was declared and copied here until + # 2026-09-22. The token had been dead since its August 4 rotation, and a + # seed that fires whenever the file is absent can only ever revert a fresh + # `/login` after a rebuild. Both Claude seats now log in by hand, once, + # on the coordinator: cc into ~/.claude, cc2 into ~/.claude-work.) # NOT ungated any more (2026-08-28). This block delivers ssh-user-key and # atuin-key, whose ciphertexts are encrypted to the `delivered` tier — and @@ -323,12 +300,10 @@ in # navidrome-credentials: NOT consumed by the navidrome server (which now # runs on the NAS, hosts/nas/media.nix, reached through the coordinator's - # navidrome-relay) — read client-side by the cliamp fish function, on - # whichever box cliamp runs from. Coordinator-only since the zenbook left - # the fleet (2026-08-30), matching the recipient tier in secrets.nix. The wrapper exports the - # file's NAVIDROME_PASSWORD under both that name (which config.toml's - # ${NAVIDROME_PASSWORD} placeholder interpolates) and NAVIDROME_PASS - # (which cliamp's config-less env fallback reads). + # navidrome-relay) — read client-side by the navidrome-scan fish function + # (NAVIDROME_USER / NAVIDROME_PASSWORD). Coordinator-only, matching the + # recipient tier in secrets.nix. (Its first reader, the cliamp TUI client, + # was removed 2026-09-17.) (lib.mkIf (config.networking.hostName == "coordinator") { age.secrets.navidrome-credentials = { file = ../secrets/navidrome-credentials.age; @@ -394,8 +369,7 @@ in }) # soundcloud-cookies: consumed by the music-consolidation drain's yt-dlp - # invocations (systemd user units, coordinator-only). NOT for cliamp — see - # secrets.nix for why cliamp needs no secret here. + # invocations (systemd user units, coordinator-only). (lib.mkIf (config.networking.hostName == "coordinator") { age.secrets.soundcloud-cookies = { file = ../secrets/soundcloud-cookies.age; diff --git a/overlays/default.nix b/overlays/default.nix index 7586ddccf..eec5f8be1 100644 --- a/overlays/default.nix +++ b/overlays/default.nix @@ -32,6 +32,9 @@ final: prev: { # the mactahoe-oled staging repo (since deleted 2026-07-04); pkgs/ is the home. # Icons: stock default (blue folders); GTK: light+dark grey, dark OLED-patched. mactahoe-gtk-theme = final.callPackage ../pkgs/mactahoe-gtk-theme.nix { }; + # the same source build recoloured to claude.ai's light/dark tokens, for the + # theme switcher's claude-dark / claude-light (home/themes/, docs/theme-switcher-2026-09-17.md) + mactahoe-claude-gtk-theme = final.callPackage ../pkgs/mactahoe-gtk-theme.nix { variant = "claude"; }; mactahoe-icon-theme = final.callPackage ../pkgs/mactahoe-icon-theme.nix { }; # Backlog.md — markdown-native task manager CLI (`backlog`). Not in nixpkgs; @@ -65,10 +68,6 @@ final: prev: { # Replaces paper-intake (paper-print-flush). See home/paper.nix. paper-daemon = final.callPackage ../pkgs/paper-daemon { }; - # cliamp — terminal music player (Winamp-inspired TUI). Not in nixpkgs (2026-07-06). - # Connects to navidrome via Subsonic API. Config at home/dot_config/cliamp/. - # CGO on Linux via ebitengine/oto → ALSA. See pkgs/cliamp.nix. - cliamp = final.callPackage ../pkgs/cliamp.nix { }; # CLI-Anything — pinned cli-hub Python app plus immutable Codex/Claude/Pi # integrations. Upstream has no flake; see modules/cli-anything.nix. diff --git a/pkgs/cliamp.nix b/pkgs/cliamp.nix deleted file mode 100644 index 68a2192f5..000000000 --- a/pkgs/cliamp.nix +++ /dev/null @@ -1,85 +0,0 @@ -{ - lib, - fetchFromGitHub, - buildGoModule, - pkg-config, - alsa-lib, - libogg, - libvorbis, - flac, - makeWrapper, - yt-dlp, - ffmpeg, -}: - -# cliamp — terminal music player (Winamp-inspired TUI). -# Packaged from source: not in nixpkgs (verified 2026-07-06). -# Uses CGO on Linux via ebitengine/oto → ALSA (libasound). -# -# Was pinned at v1.9.0 from 2026-07-06 until 2026-08-18. That release's -# Navidrome client was 143 lines exposing exactly three Subsonic endpoints — -# getPlaylists, getPlaylist, stream — so the library could only ever be browsed -# as playlists; there was no artist or album browsing at all. Its config loader -# was likewise a flat key=value scanner over ten hardcoded keys, which silently -# dropped every [navidrome]/[soundcloud] section and did no ${VAR} expansion, -# leaving the server configurable only through NAVIDROME_URL/USER/PASS. -# -# v1.63.2 fixes both: NavidromeClient now implements provider.ArtistBrowser and -# provider.AlbumBrowser over getArtists/getArtist/getAlbum/getAlbumList2/search3, -# and the config parser understands real sections plus ${VAR} interpolation from -# the environment. home/dot_config/cliamp/config.toml depends on both. -# -# vendorHash: from `go mod vendor` on the v1.63.2 source. If it drifts on a -# version bump, rebuild with lib.fakeHash and take the value from the error. -buildGoModule rec { - pname = "cliamp"; - version = "1.63.2"; - - src = fetchFromGitHub { - owner = "bjarneo"; - repo = "cliamp"; - rev = "v${version}"; - hash = "sha256-HqFDT8jGvrKqb6bupvXqZ5ECpvColRB5dXPwcKCX4RQ="; - }; - - vendorHash = "sha256-WYyv0w5KFA15axb+NA9tClfc1H4Znj8kI2boR8XziXg="; - - nativeBuildInputs = [ - pkg-config - makeWrapper - ]; - # alsa-lib for the oto audio backend; the ogg/vorbis/flac trio arrived with - # v1.63.2's go-librespot (Spotify) dependency, which cgo-links them via - # pkg-config and fails the build outright when they are absent. - buildInputs = [ - alsa-lib - libogg - libvorbis - flac - ]; - - ldflags = [ - "-s" - "-w" - "-X main.version=v${version}" - ]; - - # cliamp shells out to these by bare name for non-native formats (aac/opus/wma - # go through ffmpeg) and for SoundCloud/YouTube/Bandcamp URLs. Wrapping keeps - # it working regardless of what happens to be on the ambient PATH. - postInstall = '' - wrapProgram $out/bin/cliamp \ - --prefix PATH : ${lib.makeBinPath [ yt-dlp ffmpeg ]} - ''; - - meta = with lib; { - description = "Terminal music player (Winamp-inspired) with Navidrome/Subsonic, Spotify, and local file support"; - homepage = "https://www.cliamp.stream/"; - license = licenses.mit; - mainProgram = "cliamp"; - platforms = [ - "x86_64-linux" - "aarch64-linux" - ]; - }; -} diff --git a/pkgs/mactahoe-gtk-theme.nix b/pkgs/mactahoe-gtk-theme.nix index 4fb01032f..abb6a4ab7 100644 --- a/pkgs/mactahoe-gtk-theme.nix +++ b/pkgs/mactahoe-gtk-theme.nix @@ -1,19 +1,28 @@ -# MacTahoe GTK theme — light + dark, grey accent, with the custom OLED-black -# surfaces on the dark variants. +# MacTahoe GTK theme, recoloured at the SCSS source for the theme switcher. # # Built from upstream source (vinceliuice/MacTahoe-gtk-theme), NOT vendored. -# The ONLY customization is OLED black: upstream's dark surface colors in -# src/sass/_colors.scss (#242424 base/backdrop, #333333 bg/headerbar) forced to -# pure black ("rgba(5,5,5,0.96) → #000000 for true OLED black"). The -# swapped values sit in the dark-only branch of the if(...)s, so the Light -# variants are 100% stock. The grey accent and the solid opacity variant are -# STOCK install.sh flags, not customizations. +# One derivation per `variant`; the only customisation is a set of colour +# substitutions in src/sass/_colors.scss (and, for claude, the accent in +# _colors-palette.scss), applied with --replace-fail so upstream drift is +# caught at build time. The grey/orange accent and the solid opacity variant +# are STOCK install.sh flags, not customisations. +# +# oled (default) — Tom's noir: upstream's dark surfaces (#242424 +# base/backdrop, #333333 bg/headerbar) forced to pure black +# ("rgba(5,5,5,0.96) → #000000 for true OLED black"). Dark-only +# branch of the if(...)s, so its Light variants are 100% stock. +# Theme dirs: MacTahoe-{Dark,Light}[-solid]-grey[-(x)hdpi]. +# claude — both branches recoloured to claude.ai's own light and dark +# tokens (~/colors/waves/capture/claude-code-theme/claude-tokens- +# {light,dark}.json), accent = Anthropic clay #D97757 via the +# `orange` slot. Theme dirs: MacTahoe-Claude-{Dark,Light}[-solid]- +# orange[-(x)hdpi]. Consumed by home/themes/claude-{dark,light}.nix. # # Modeled on nixpkgs' whitesur-gtk-theme derivation — MacTahoe's install.sh is a # direct fork of WhiteSur's, so the same sudo/$HOME/shebang fixups apply. # # Builds once, then content-addressed: a normal `switch` reuses the store path -# (0s); it only rebuilds when `rev`/the OLED swap changes, and CI→cache means +# (0s); it only rebuilds when `rev`/a substitution changes, and CI→cache means # other devices substitute the result instead of building. { lib, @@ -25,10 +34,62 @@ libxml2, sassc, util-linux, + variant ? "oled", }: - +let + variants = { + oled = { + pname = "mactahoe-gtk-theme-oled-grey"; + themeName = "MacTahoe"; + accent = "grey"; + description = "MacTahoe GTK theme, light + dark, grey accent, custom OLED-black dark surfaces"; + # Dark variant only; $darker is off so only the 3rd value in each + # if(...) matters. base/backdrop → pure black, bg/headerbar → near-black + # so surfaces stay distinguishable. + colorsPatch = '' + substituteInPlace src/sass/_colors.scss \ + --replace-fail '#1f1f1f, #242424))' '#1f1f1f, #000000))' \ + --replace-fail '#282828, #333333))' '#282828, #0a0a0a))' \ + --replace-fail '#1e1e1e, #333333))' '#1e1e1e, #0a0a0a))' + ''; + }; + claude = { + pname = "mactahoe-gtk-theme-claude"; + themeName = "MacTahoe-Claude"; + accent = "orange"; + description = "MacTahoe GTK theme recoloured to claude.ai's light and dark tokens, clay accent"; + # Light = 1st value of each if(...), dark = 3rd. Tokens: bg-000/100/200 + # (#FFFFFF/#F9F9F7/#F3F3F0 light; #20201F/#151515 dark), text-000/200/400, + # cds-text-secondary, accent-100 (links). Dark page = bg-100 #151515 with + # bg-000 #20201F as the raised surface (headerbar, bg, sidebar), the + # relation MacTahoe's stock dark already has (base darker than bg). + colorsPatch = '' + substituteInPlace src/sass/_colors.scss \ + --replace-fail '#1f1f1f, #242424))' '#1f1f1f, #151515))' \ + --replace-fail "'light', #f5f5f5, if(" "'light', #F9F9F7, if(" \ + --replace-fail '#282828, #333333)' '#282828, #20201F)' \ + --replace-fail "'light', #f2f2f2," "'light', #F3F3F0," \ + --replace-fail "'light', #363636, #dadada)" "'light', #131313, #F9F9F7)" \ + --replace-fail "'light', #242424, #dedede)" "'light', #131313, #F9F9F7)" \ + --replace-fail "'light', #424242, #afafaf)" "'light', #383835, #C3C2B7)" \ + --replace-fail "'light', #565656, #999999)" "'light', #7B7974, #97958D)" \ + --replace-fail "'light', #ffffff, if(\$darker == 'true', #1e1e1e, #333333))" "'light', #F9F9F7, if(\$darker == 'true', #1e1e1e, #20201F))" \ + --replace-fail '#242424, #404040), #f5f5f5)' '#242424, #383835), #F3F3F0)' \ + --replace-fail "'light', #575757, #FDFDFD)" "'light', #52514E, #F9F9F7)" \ + --replace-fail '#eeeff2, #fefefe)' '#eeeff2, #F3F3F0)' \ + --replace-fail '#1a1a1a, #2a2a2a)' '#1a1a1a, #151515)' \ + --replace-fail '#5e81ac, #3484e2)' "#5e81ac, if(\$variant == 'light', #256ABF, #5598E7))" + # accent: the `orange` slot becomes Anthropic clay (--accent-brand, the + # same hex in Claude's light and dark tokens); built with --theme orange. + substituteInPlace src/sass/_colors-palette.scss \ + --replace-fail '$theme_color_orange: #E9873A;' '$theme_color_orange: #D97757;' + ''; + }; + }; + v = variants.${variant}; +in stdenvNoCC.mkDerivation { - pname = "mactahoe-gtk-theme-oled-grey"; + pname = v.pname; # Pinned rev (2026-06-19); bump deliberately to take upstream updates — then # `nix build` prints the new hash to paste below. The dark-only build was # verified in a nixos/nix container 2026-06-19 (6 variants, OLED-black @@ -71,14 +132,8 @@ stdenvNoCC.mkDerivation { --replace-fail 'prepare_deps() {' 'prepare_deps() { return 0;' \ --replace-fail 'installation_sorry() {' 'installation_sorry() { return 0;' - # ── the OLED customization (the whole reason this isn't just nixpkgs) ── - # Dark variant only; $darker is off so only the 3rd value in each - # if(...) matters. base/backdrop → pure black, bg/headerbar → near-black - # so surfaces stay distinguishable. --replace-fail catches upstream drift. - substituteInPlace src/sass/_colors.scss \ - --replace-fail '#1f1f1f, #242424))' '#1f1f1f, #000000))' \ - --replace-fail '#282828, #333333))' '#282828, #0a0a0a))' \ - --replace-fail '#1e1e1e, #333333))' '#1e1e1e, #0a0a0a))' + # ── the recolouring (the whole reason this isn't just nixpkgs) ── + ${v.colorsPatch} ''; dontBuild = true; @@ -89,8 +144,8 @@ stdenvNoCC.mkDerivation { # -c light/dark : both colors -t grey : grey accent -o normal/solid : # both opacities. install.sh also emits the -hdpi/-xhdpi variants # automatically, so all 6 dark directories are produced - # (MacTahoe-Dark-grey, -Dark-solid-grey, each + -hdpi/-xhdpi) plus the - # matching 6 Light dirs. + # (-Dark-, -Dark-solid-, each + -hdpi/-xhdpi) plus + # the matching 6 Light dirs. # MacTahoe's no-gnome-shell branch leaves SHELL_VERSION empty (upstream bug), # generating invalid SCSS ($GNOME_SHELL: ;). gnome-shell is never present in a # build sandbox, so set it explicitly — the overwriting line is gated behind @@ -100,8 +155,9 @@ stdenvNoCC.mkDerivation { # NB: --opacity/--color take ONE value per flag (install.sh does `shift 2`), # so multi-value variants must be passed as repeated flags, not space-listed. ./install.sh \ + --name ${v.themeName} \ --color light --color dark \ - --theme grey \ + --theme ${v.accent} \ --opacity normal --opacity solid \ --dest $out/share/themes jdupes --quiet --link-soft --recurse $out/share @@ -109,7 +165,7 @@ stdenvNoCC.mkDerivation { ''; meta = { - description = "MacTahoe GTK theme, light + dark, grey accent, custom OLED-black dark surfaces"; + description = v.description; homepage = "https://github.com/vinceliuice/MacTahoe-gtk-theme"; license = lib.licenses.gpl3Only; platforms = lib.platforms.linux; diff --git a/pkgs/mactahoe-icon-theme.nix b/pkgs/mactahoe-icon-theme.nix index 24f821a6f..289022b46 100644 --- a/pkgs/mactahoe-icon-theme.nix +++ b/pkgs/mactahoe-icon-theme.nix @@ -1,17 +1,20 @@ -# MacTahoe icon theme — stock default (blue folders). +# MacTahoe icon theme — stock default (blue folders) plus one folder-colour +# variant per claude.ai/imagine wallpaper accent. # -# 100% STOCK, zero flags beyond --name. Previously this built the `-t grey` -# variant (grey #686868 folders, itself also stock upstream); reverted to the -# default blue folder color 2026-07-04. The only reason this derivation exists -# at all is that MacTahoe (unlike its siblings whitesur-icon-theme / -# colloid-icon-theme) isn't in nixpkgs yet — re-check occasionally and drop -# this file when it lands. +# Stock build otherwise: MacTahoe (unlike whitesur-icon-theme / colloid-icon-theme) +# isn't in nixpkgs yet — re-check occasionally. Modeled on nixpkgs' +# whitesur-icon-theme derivation (same upstream author / same install.sh). # -# The default build emits all three dirs (COLOR_VARIANTS=('' '-light' '-dark')): -# MacTahoe, MacTahoe-light, MacTahoe-dark. -# -# Modeled on nixpkgs' whitesur-icon-theme derivation (same upstream author / -# same install.sh). No patch — so this could even be upstreamed to nixpkgs. +# The default build emits COLOR_VARIANTS=('' '-light' '-dark'): MacTahoe, +# MacTahoe-light, MacTahoe-dark. Folder colours are install.sh "themes": each +# stock one is a directory colors/color-/ of 18 folder SVGs drawn in ONE +# hex (grey = #686868) over white/black overlays, copied over places/scalable. +# The accents below add colors/color-/ generated from the grey set with +# that hex swapped for the wallpaper's own ground — the darker of the two +# grounds Anthropic ships per theme (~/colors/waves/capture/imagine-background.css +# --bg-primary-dark) — so `wallpaper olive` can put olive folders in Nautilus. +# Emitted as MacTahoe-{,-light,-dark}; everything but the folders +# dedupes to one copy under jdupes. Consumed by ~/.local/bin/theme (`theme icons`). { lib, stdenvNoCC, @@ -20,7 +23,24 @@ hicolor-icon-theme, jdupes, }: - +let + # accent → folder hex: the imagine theme's --bg-primary-dark. + accents = { + oat = "#d9d1c3"; + olive = "#697751"; + cactus = "#b6c8c1"; + sky = "#6488b4"; + fig = "#a55b74"; + heather = "#c0bed2"; + coral = "#dec4c4"; + }; + accentVariants = lib.concatMapStringsSep " " (a: "'-${a}'") (lib.attrNames accents); + mkAccent = a: hex: '' + cp -r colors/color-grey colors/color-${a} + sed -i "s/#686868/${hex}/g" colors/color-${a}/*.svg + grep -q "${hex}" colors/color-${a}/folder.svg + ''; +in stdenvNoCC.mkDerivation { pname = "mactahoe-icon-theme"; version = "0-unstable-2026-06-19"; @@ -46,13 +66,18 @@ stdenvNoCC.mkDerivation { postPatch = '' patchShebangs install.sh + # the accent folder sets, then make install.sh's `--theme all` mean + # "default + the accents" (the stock colours are not built). + ${lib.concatStrings (lib.mapAttrsToList mkAccent accents)} + substituteInPlace install.sh \ + --replace-fail "THEME_VARIANTS=(''' '-blue' '-purple' '-green' '-red' '-orange' '-yellow' '-grey' '-nord')" \ + "THEME_VARIANTS=(''' ${accentVariants})" ''; installPhase = '' runHook preInstall # --name is required: without it the theme dirs inherit the build-dir name. - # No --theme flag: default = blue folders. - ./install.sh --dest $out/share/icons --name MacTahoe + ./install.sh --dest $out/share/icons --name MacTahoe --theme all jdupes --link-soft --recurse $out/share runHook postInstall ''; @@ -60,10 +85,14 @@ stdenvNoCC.mkDerivation { # drop dangling symlinks upstream ships (same as nixpkgs whitesur-icon-theme) postFixup = '' find $out/share/icons -xtype l -delete + # every accent came out + for a in ${lib.concatStringsSep " " (lib.attrNames accents)}; do + test -d $out/share/icons/MacTahoe-$a-dark + done ''; meta = { - description = "MacTahoe icon theme (stock vinceliuice build, default blue folders)"; + description = "MacTahoe icon theme (stock vinceliuice build, default blue folders + imagine-accent folder variants)"; homepage = "https://github.com/vinceliuice/MacTahoe-icon-theme"; license = lib.licenses.gpl3Plus; platforms = lib.platforms.linux; diff --git a/secrets.nix b/secrets.nix index 03f43d3aa..5d94f6b0b 100644 --- a/secrets.nix +++ b/secrets.nix @@ -130,9 +130,7 @@ in # SoundCloud Go+ cookies.txt (Netscape format), consumed by the music-consolidation # drain's yt-dlp invocations (systemd user units on coordinator only — see that - # repo's docs/SPEC-2026-07-06-original.md). NOT for cliamp: cliamp shells to - # `yt-dlp --cookies-from-browser chrome` directly against a live signed-in browser - # and has no file-based cookie mode, so it needs no secret at all (dotfiles#70). + # repo's docs/SPEC-2026-07-06-original.md). "secrets/soundcloud-cookies.age".publicKeys = editors ++ coordinatorOnly; # YouTube Music cookies.txt (Netscape format), exported same sitting as the @@ -141,9 +139,9 @@ in # by DRM. Coordinator-only, same reasoning as soundcloud-cookies. "secrets/youtube-music-cookies.age".publicKeys = editors ++ coordinatorOnly; - # cliamp (client) reads this on the coordinator, where navidrome's relay - # lives. The zenbook was the second consumer until it left the fleet - # (2026-08-30); the laptops tier went with it. + # Read client-side on the coordinator, where navidrome's relay lives, by the + # navidrome-scan fish function (Subsonic API). Its first consumer, the cliamp + # TUI client, was removed 2026-09-17; the zenbook left the fleet 2026-08-30. "secrets/navidrome-credentials.age".publicKeys = editors ++ coordinatorOnly; # Immich full-permissions API key (photos.internal), read client-side by agent @@ -152,14 +150,8 @@ in # cleanup — as agenix ciphertext it survives reflash and never needs re-minting. "secrets/immich-api-key.age".publicKeys = editors ++ coordinatorOnly; - # Claude Code OAuth credential. Demoted from the common tier to coordinator-only - # (2026-08-04, Tom's ruling): the nas holds no `claude` binary and never ran an - # agent, so it had no use for the token; zenbook-duo was already excluded from - # delivery (jul12 ruling — it logs in with its OWN session, since two devices - # refreshing one shared token race and sign each other out). That left the - # coordinator as the only real consumer, so the recipient tier now says so — - # a token this wide should not be decryptable by boxes that never spend it. - "secrets/claude-credentials.age".publicKeys = editors ++ coordinatorOnly; + # (claude-credentials.age removed 2026-09-22: the seat logins are hand + # `/login`s on the coordinator, never a delivered secret; see modules/secrets.nix.) # Brother HL-L2445DW Web Based Management admin password, set 2026-08-21 when # the printer's forced default-password change gated its move onto the thomas diff --git a/secrets/claude-credentials.age b/secrets/claude-credentials.age deleted file mode 100644 index 8d0a443d1..000000000 Binary files a/secrets/claude-credentials.age and /dev/null differ diff --git a/tests/ai-memory-hook/harvest-hook-test.sh b/tests/ai-memory-hook/harvest-hook-test.sh deleted file mode 100644 index 9a9b4eca3..000000000 --- a/tests/ai-memory-hook/harvest-hook-test.sh +++ /dev/null @@ -1,333 +0,0 @@ -#!/usr/bin/env bash -# tests/ai-memory-hook/harvest-hook-test.sh — the SessionEnd hook's own tests. -# -# UNIT: MEM-2 (dotfiles#339). SUBJECT: -# home/dot_claude/hooks/ai-memory-harvest.sh. -# -# These are the hook's CONTRACT tests and they never spend the utility model: a -# fake engine stands in for ai_memory.py so every branch — created, unchanged, -# refusal, timeout, crash, no payload, subagent transcript — is reachable in -# milliseconds and offline. The end-to-end proof that the real engine, a real -# `claude` session and a real model produce a real harvest note is the unit's -# DOMINANT oracle, `tools/mem-2-hook-oracle.sh`; it is not this file's job. -# -# What every case asserts, without exception: the hook exits 0, and it appends -# exactly one line to /hook.log naming the session. -# -# Usage: bash tests/ai-memory-hook/harvest-hook-test.sh -# Exit: 0 every case passed; 1 a case failed (each failure is named on stdout). - -set -uo pipefail - -repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && cd .. && pwd) -# MEM2_HOOK lets the flake check point at the store copy of the hook; from a -# checkout the default is the repository's own file and no caller sets it. -hook=${MEM2_HOOK:-$repo/home/dot_claude/hooks/ai-memory-harvest.sh} - -if [ ! -f "$hook" ]; then - printf 'FAIL the hook does not exist at %s\n' "$hook" - exit 1 -fi - -work=$(mktemp -d "${TMPDIR:-/tmp}/mem2-hook-test.XXXXXX") -trap 'rm -rf "$work"' EXIT - -failures=0 -cases=0 - -fail() { - printf 'FAIL %-28s %s\n' "$case_name" "$*" - failures=$((failures + 1)) -} - -ok() { printf 'ok %-28s %s\n' "$case_name" "$*"; } - -# A fake ai_memory.py. FAKE_MODE selects the branch; FAKE_ENV_OUT records the -# environment the hook handed it, which is how the identity-export contract is -# asserted rather than assumed. -fake_engine="$work/fake_engine.py" -cat >"$fake_engine" <<'PYEOF' -import os -import sys -import time - -record = os.environ.get("FAKE_ENV_OUT") -if record: - with open(record, "w", encoding="utf-8") as handle: - for key in ( - "CLAUDE_CODE_SESSION_ID", - "CLAUDE_CONFIG_DIR", - "AI_MEMORY_HARVEST_DIR", - "CLAUDE_CODE_CHILD_SESSION", - "CODEX_THREAD_ID", - ): - handle.write(f"{key}={os.environ.get(key, '')}\n") - handle.write("ARGV=" + " ".join(sys.argv[1:]) + "\n") - handle.write("CWD=" + os.getcwd() + "\n") - -mode = os.environ.get("FAKE_MODE", "created") -note = os.path.join( - os.environ.get("AI_MEMORY_HARVEST_DIR", "."), - os.environ.get("CLAUDE_CODE_SESSION_ID", "unknown") + ".md", -) -if mode in ("created", "updated", "unchanged"): - if mode != "unchanged": - with open(note, "w", encoding="utf-8") as handle: - handle.write("fake harvest note\n") - print(f"{mode}: {note}") - raise SystemExit(0) -if mode == "refusal": - print( - "ai-memory: drain is only available in a root Claude Code session", - file=sys.stderr, - ) - raise SystemExit(1) -if mode == "crash": - print("Traceback (most recent call last):\nRuntimeError: boom", file=sys.stderr) - raise SystemExit(3) -if mode == "hang": - time.sleep(120) - raise SystemExit(0) -raise SystemExit(9) -PYEOF - -# One case: run the hook with a payload and a fake-engine mode, then hand the -# scratch harvest dir and the captured stdout back to the caller's assertions. -run_hook() { - local payload=$1 mode=$2 - shift 2 - store="$work/store.$cases" - envout="$work/env.$cases" - mkdir -p "$store" - printf '%s' "$payload" | env \ - AI_MEMORY_ENGINE="$fake_engine" \ - AI_MEMORY_HARVEST_DIR="$store" \ - AI_MEMORY_HARVEST_HOOK_TIMEOUT="${HOOK_TIMEOUT:-30}" \ - FAKE_MODE="$mode" \ - FAKE_ENV_OUT="$envout" \ - "$@" \ - bash "$hook" >"$work/stdout.$cases" 2>"$work/stderr.$cases" - hook_rc=$? - log="$store/hook.log" -} - -expect_exit_zero() { - if [ "$hook_rc" -ne 0 ]; then - fail "exited $hook_rc; a SessionEnd hook must exit 0 on every path" - return 1 - fi - return 0 -} - -expect_one_log_line() { - local want_session=$1 - if [ ! -f "$log" ]; then - fail "no hook.log was written to $store" - return 1 - fi - local lines - lines=$(wc -l <"$log") - if [ "$lines" -ne 1 ]; then - fail "hook.log carries $lines lines, expected exactly 1" - return 1 - fi - if ! grep -q "session=$want_session" "$log"; then - fail "hook.log line does not name session $want_session: $(cat "$log")" - return 1 - fi - return 0 -} - -expect_status() { - local want=$1 - if ! grep -q " status=$want " "$log"; then - fail "hook.log line is not status=$want: $(cat "$log")" - return 1 - fi - return 0 -} - -sid_a=11111111-2222-3333-4444-555555555555 -sid_b=66666666-7777-8888-9999-aaaaaaaaaaaa - -# --------------------------------------------------------------------------- -case_name="created" -cases=$((cases + 1)) -mkdir -p "$work/cfg/projects/-slug" -: >"$work/cfg/projects/-slug/$sid_a.jsonl" -payload=$( - printf '{"session_id":"%s","transcript_path":"%s","cwd":"%s","hook_event_name":"SessionEnd","reason":"other"}' \ - "$sid_a" "$work/cfg/projects/-slug/$sid_a.jsonl" "$work" -) -run_hook "$payload" created -if expect_exit_zero && expect_one_log_line "$sid_a" && expect_status created; then - if [ ! -f "$store/$sid_a.md" ]; then - fail "the engine's note is not in the store" - elif ! grep -q "^CLAUDE_CODE_SESSION_ID=$sid_a$" "$envout"; then - fail "the hook did not export CLAUDE_CODE_SESSION_ID from the payload" - elif ! grep -q "^AI_MEMORY_HARVEST_DIR=$store$" "$envout"; then - fail "the hook did not point the engine at the harvest store" - elif ! grep -q "^CLAUDE_CODE_CHILD_SESSION=$" "$envout"; then - fail "the hook left CLAUDE_CODE_CHILD_SESSION set; it must be cleared" - elif ! grep -q "^ARGV=harvest --enqueue$" "$envout"; then - fail "the hook ran '$(sed -n 's/^ARGV=//p' "$envout")', not 'harvest --enqueue'" - else - ok "note written, identity exported, verb is harvest --enqueue" - fi -fi - -# --------------------------------------------------------------------------- -# FIX-E08 (dotfiles#348): the close -> row -> floor leg. The hook passes the -# verb's own --enqueue by default, so a close writes rows and not only a note; -# AI_MEMORY_HARVEST_ENQUEUE=0 is the one opt-out and it changes nothing else -# about the hook's contract (still exit 0, still exactly one log line). -case_name="enqueue is on by default" -cases=$((cases + 1)) -run_hook "$payload" created -if expect_exit_zero && expect_one_log_line "$sid_a" && expect_status created; then - if ! grep -q '^ARGV=harvest --enqueue$' "$envout"; then - fail "the harvest argv is '$(sed -n 's/^ARGV=//p' "$envout")'; --enqueue is absent" - else - ok "the default argv carries --enqueue" - fi -fi - -case_name="enqueue opt-out" -cases=$((cases + 1)) -run_hook "$payload" created env AI_MEMORY_HARVEST_ENQUEUE=0 -if expect_exit_zero && expect_one_log_line "$sid_a" && expect_status created; then - if ! grep -q '^ARGV=harvest$' "$envout"; then - fail "AI_MEMORY_HARVEST_ENQUEUE=0 still ran '$(sed -n 's/^ARGV=//p' "$envout")'" - else - ok "the opt-out drops --enqueue and nothing else" - fi -fi - -# --------------------------------------------------------------------------- -# CLAUDE_CONFIG_DIR is derived from the transcript path when the hook does not -# inherit one, so a `claude` started with an explicit config dir stays -# harvestable. -case_name="config dir from transcript" -cases=$((cases + 1)) -run_hook "$payload" created env -u CLAUDE_CONFIG_DIR -if expect_exit_zero && expect_one_log_line "$sid_a"; then - if ! grep -q "^CLAUDE_CONFIG_DIR=$work/cfg$" "$envout"; then - fail "derived $(sed -n 's/^CLAUDE_CONFIG_DIR=//p' "$envout"), expected $work/cfg" - else - ok "derived $work/cfg from the transcript path" - fi -fi - -# --------------------------------------------------------------------------- -# A session already harvested is not paid for twice (D-E13 (5)). -case_name="unchanged" -cases=$((cases + 1)) -run_hook "$payload" unchanged -if expect_exit_zero && expect_one_log_line "$sid_a" && expect_status unchanged; then - ok "the short-circuit is logged as itself" -fi - -# --------------------------------------------------------------------------- -# The engine's own refusal — the drain's root-session rule — is a logged skip, -# never a failure and never a non-zero exit. -case_name="engine refusal is a skip" -cases=$((cases + 1)) -run_hook "$payload" refusal -if expect_exit_zero && expect_one_log_line "$sid_a" && expect_status skipped; then - if ! grep -q "root Claude Code session" "$log"; then - fail "the refusal's reason is not in the log line: $(cat "$log")" - elif [ -f "$store/$sid_a.md" ]; then - fail "a refused harvest still wrote a note" - else - ok "refusal surfaced as a skip with its reason" - fi -fi - -# --------------------------------------------------------------------------- -# A subagent transcript is refused by name, before python is started at all. -case_name="subagent transcript" -cases=$((cases + 1)) -sub_payload=$( - printf '{"session_id":"%s","transcript_path":"%s","cwd":"%s","hook_event_name":"SessionEnd","reason":"other"}' \ - "$sid_b" "$work/cfg/projects/-slug/subagents/$sid_b.jsonl" "$work" -) -run_hook "$sub_payload" created -if expect_exit_zero && expect_one_log_line "$sid_b" && expect_status skipped; then - if [ -f "$envout" ]; then - fail "the engine was started for a subagent session" - elif [ -f "$store/$sid_b.md" ]; then - fail "a subagent session was harvested" - else - ok "child session refused without spending anything" - fi -fi - -# --------------------------------------------------------------------------- -# An engine that crashes is a logged failure and still a clean session end. -case_name="engine crash" -cases=$((cases + 1)) -run_hook "$payload" crash -if expect_exit_zero && expect_one_log_line "$sid_a" && expect_status failed; then - ok "a crashing engine cannot wedge the session end" -fi - -# --------------------------------------------------------------------------- -# The hook's own timeout fires before settings.json's, so the log line is -# always reached. Two seconds against a 120 s sleep. -case_name="engine hang" -cases=$((cases + 1)) -HOOK_TIMEOUT=2 run_hook "$payload" hang -unset HOOK_TIMEOUT -if expect_exit_zero && expect_one_log_line "$sid_a" && expect_status timeout; then - ok "the harvest was cut off by the hook's own timeout" -fi - -# --------------------------------------------------------------------------- -case_name="empty payload" -cases=$((cases + 1)) -run_hook '' created -if expect_exit_zero; then - if [ ! -f "$log" ]; then - fail "no hook.log line for a payload-less invocation" - elif ! grep -q " status=skipped " "$log"; then - fail "an empty payload is not logged as a skip: $(cat "$log")" - else - ok "no session_id is a logged skip" - fi -fi - -# --------------------------------------------------------------------------- -case_name="malformed payload" -cases=$((cases + 1)) -run_hook 'not json at all' created -if expect_exit_zero; then - if [ ! -f "$log" ] || ! grep -q " status=skipped " "$log"; then - fail "malformed JSON is not a logged skip" - else - ok "malformed JSON is a logged skip" - fi -fi - -# --------------------------------------------------------------------------- -# The hook must never reach the journal and never reach branch (a)'s live state -# dir. `harvest` being the only verb is asserted above from the engine's own -# ARGV; this is the static half — no executable line even names the other store. -# (The engine PATH legitimately contains `drain`: ai_memory.py ships inside the -# drain skill. The verb is what matters, and the verb is asserted from ARGV.) -case_name="never the journal" -cases=$((cases + 1)) -if grep -nE 'journal|state/tally/|[^-]tally/' "$hook" \ - | grep -vE '^[0-9]+:[[:space:]]*#' >/dev/null; then - fail "an executable line names the journal or branch (a)'s ~/.local/state/tally/" -elif grep -nE 'ai_memory\.py" +drain|\$engine" +drain' "$hook" >/dev/null; then - fail "an executable line runs the drain verb" -else - ok "harvest is the only verb and the store is the only writable path" -fi - -printf '\n%d case(s), %d failure(s)\n' "$cases" "$failures" -if [ "$failures" -ne 0 ]; then - printf 'FAIL harvest-hook-test\n' - exit 1 -fi -printf 'PASS harvest-hook-test\n' diff --git a/tools/mem-2-eval-probe.sh b/tools/mem-2-eval-probe.sh deleted file mode 100755 index aaec96527..000000000 --- a/tools/mem-2-eval-probe.sh +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env bash -# tools/mem-2-eval-probe.sh — MEM-2's MECHANICAL EVALUATOR's own probe (step 2b). -# -# Not the card's clauses — the floor beneath them. The DOMINANT oracle ends ONE -# scratch session and asserts ONE note, ONE log line, an empty journal and a -# fence. Nothing in it, and nothing in tests/ai-memory-hook/harvest-hook-test.sh -# (which drives a FAKE engine), ever puts a SECOND real session end through the -# same store, or fires a SessionEnd TWICE for the same session, or hands the -# hook a store it cannot write. Four hermetic cases, all against the delivered -# hook and the real ai_memory.py, in one scratch tree: -# -# P1 two real root sessions ending into the SAME harvest store -> exactly two -# notes, one per session, and the FIRST note's bytes are unchanged by the -# second session. The store is per-session; nothing clobbers. -# P2 the hook.log is a LEDGER: exactly one line per session end, two lines -# after two ends, each naming its own session. -# P3 D-E14 (2) end to end: session B is launched with -# CLAUDE_CODE_CHILD_SESSION=1 in its environment — the state this seat's -# own shell is in. A hook that trusted the variable would refuse a -# perfectly good root session. Its note must exist all the same. -# P4 a THIRD SessionEnd fired for session A (the payload replayed straight at -# the hook) -> D-E13 (5)'s short-circuit under the hook: the log grows by -# exactly one line, that line says `unchanged`, and NO second .md appears. -# P5 the hook handed a harvest dir it cannot create -> still exits 0. A hook -# that can exit non-zero is a hook that can wedge a session end, and the -# unwritable store is the one path the offline suite never takes. -# -# Exit 0 all five hold; 1 a case failed; 2 usage/missing tool/missing credential. - -set -uo pipefail -repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -cd "$repo" || exit 2 - -hook_src="$repo/home/dot_claude/hooks/ai-memory-harvest.sh" -skill_src="$repo/home/dot_claude/skills/drain" -for p in "$hook_src" "$skill_src/scripts/ai_memory.py"; do - [ -e "$p" ] || { printf 'ERROR missing %s\n' "$p"; exit 2; } -done -for t in claude python3 jq timeout; do - command -v "$t" >/dev/null 2>&1 || { printf 'ERROR %s not on PATH\n' "$t"; exit 2; } -done -seat_cfg=${MEM2_SEAT_CONFIG_DIR:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}} -[ -e "$seat_cfg/.credentials.json" ] || { printf 'ERROR no credential at %s\n' "$seat_cfg"; exit 2; } - -failures=0 -fail() { printf 'FAIL %s\n' "$*"; failures=$((failures+1)); } -pass() { printf 'ok %s\n' "$*"; } - -work=$(mktemp -d "${TMPDIR:-/tmp}/mem-2-probe.XXXXXX") -trap '[ "${MEM2_KEEP:-0}" = 1 ] && printf "(kept %s)\n" "$work" || rm -rf "$work"' EXIT - -cfg="$work/cfg"; harvest="$work/harvest"; journal="$work/journal" -xdgc="$work/xdg-config"; xdgs="$work/xdg-state"; runtime="$work/runtime" -mkdir -p "$cfg/hooks" "$cfg/skills" "$harvest" "$journal" "$xdgc/ai-memory" "$xdgs" "$runtime" -chmod 700 "$runtime" -cp "$hook_src" "$cfg/hooks/ai-memory-harvest.sh" -ln -s "$skill_src" "$cfg/skills/drain" -ln -s "$seat_cfg/.credentials.json" "$cfg/.credentials.json" -printf '{"schema":1,"journal_dir":"%s"}\n' "$journal" >"$xdgc/ai-memory/config.json" -python3 - "$repo/home/dot_claude/settings.json" "$cfg/settings.json" "$cfg/hooks/ai-memory-harvest.sh" <<'PY' -import json,sys -src,dst,hook=sys.argv[1],sys.argv[2],sys.argv[3] -raw=open(src,encoding="utf-8").read() -r=raw.replace("/home/tom/.claude/hooks/ai-memory-harvest.sh",hook) -json.loads(r); open(dst,"w",encoding="utf-8").write(r) -PY -[ $? -eq 0 ] || { printf 'ERROR could not render settings.json\n'; exit 2; } - -run_session() { # $1 = out file, $2.. = extra env assignments - local out=$1; shift - env -u CLAUDE_CODE_SESSION_ID -u CLAUDE_SESSION_ID -u CODEX_THREAD_ID \ - -u CLAUDECODE -u CLAUDE_CODE_ENTRYPOINT -u CLAUDE_CODE_MESSAGING_SOCKET \ - -u CLAUDE_CODE_MESSAGING_TOKEN -u CLAUDE_CODE_BRIDGE_SESSION_ID \ - -u CLAUDE_PID -u CLAUDE_EFFORT -u AI_MEMORY_ENGINE \ - -u CLAUDE_CODE_CHILD_SESSION \ - "$@" \ - CLAUDE_CONFIG_DIR="$cfg" AI_MEMORY_HARVEST_DIR="$harvest" \ - XDG_CONFIG_HOME="$xdgc" XDG_STATE_HOME="$xdgs" XDG_RUNTIME_DIR="$runtime" \ - timeout 900 claude -p 'reply with the single word ok' --model haiku \ - --permission-mode bypassPermissions --output-format json \ - "$out" 2>"$out.err" -} - -printf '== session A (plain root session)\n' -run_session "$work/a.json"; rc_a=$? -sid_a=$(jq -r '.session_id // empty' "$work/a.json" 2>/dev/null) -printf 'A rc=%s session=%s\n' "$rc_a" "${sid_a:-none}" -[ "$rc_a" -eq 0 ] && [ -n "$sid_a" ] || { fail "session A did not complete: $(head -c 300 "$work/a.json.err")"; printf '\nFAIL mem-2-eval-probe\n'; exit 1; } -sha_a_before=$(sha256sum "$harvest/$sid_a.md" 2>/dev/null | cut -d' ' -f1) -lines_after_a=$(wc -l <"$harvest/hook.log" 2>/dev/null || echo 0) - -printf '\n== session B (CLAUDE_CODE_CHILD_SESSION=1 in its environment, D-E14 (2))\n' -run_session "$work/b.json" CLAUDE_CODE_CHILD_SESSION=1; rc_b=$? -sid_b=$(jq -r '.session_id // empty' "$work/b.json" 2>/dev/null) -printf 'B rc=%s session=%s\n' "$rc_b" "${sid_b:-none}" -[ "$rc_b" -eq 0 ] && [ -n "$sid_b" ] || { fail "session B did not complete: $(head -c 300 "$work/b.json.err")"; printf '\nFAIL mem-2-eval-probe\n'; exit 1; } - -printf '\n== the cases\n' -mapfile -t notes < <(find "$harvest" -maxdepth 1 -name '*.md' -type f -printf '%f\n' | sort) -if [ "${#notes[@]}" -ne 2 ]; then - fail "P1 expected two notes in the shared store, found ${#notes[@]}: ${notes[*]:-none}" -elif ! [ -f "$harvest/$sid_a.md" ] || ! [ -f "$harvest/$sid_b.md" ]; then - fail "P1 the two notes are not $sid_a.md and $sid_b.md: ${notes[*]}" -else - sha_a_after=$(sha256sum "$harvest/$sid_a.md" | cut -d' ' -f1) - if [ "$sha_a_before" != "$sha_a_after" ]; then - fail "P1 session B rewrote session A's note ($sha_a_before -> $sha_a_after)" - else - pass "P1 two sessions, two notes, A's bytes untouched by B" - fi -fi - -lines=$(wc -l <"$harvest/hook.log") -if [ "$lines" -ne 2 ]; then - fail "P2 hook.log carries $lines lines after two session ends, expected 2: $(tr '\n' '|' <"$harvest/hook.log")" -elif ! grep -q "session=$sid_a" "$harvest/hook.log" || ! grep -q "session=$sid_b" "$harvest/hook.log"; then - fail "P2 hook.log does not name both sessions: $(tr '\n' '|' <"$harvest/hook.log")" -else - pass "P2 hook.log is a ledger: one line per session end, each naming its own session ($lines_after_a -> $lines)" -fi - -if [ ! -f "$harvest/$sid_b.md" ]; then - fail "P3 session B, a ROOT session carrying CLAUDE_CODE_CHILD_SESSION=1, was refused" -elif ! grep -q "^harvested_at: " "$harvest/$sid_b.md"; then - fail "P3 session B's note is not a harvest-store note" -else - pass "P3 an inherited CLAUDE_CODE_CHILD_SESSION did not refuse a real root session" -fi - -# P4 — replay session A's SessionEnd at the hook, exactly as Claude Code fires it. -slug=$(printf '%s' "$repo" | sed 's|/|-|g') -payload=$(python3 -c ' -import json,sys -print(json.dumps({"session_id":sys.argv[1],"transcript_path":sys.argv[2], - "cwd":sys.argv[3],"hook_event_name":"SessionEnd","reason":"other"})) -' "$sid_a" "$cfg/projects/$slug/$sid_a.jsonl" "$repo") -printf '%s' "$payload" | env -u CLAUDE_CODE_CHILD_SESSION -u AI_MEMORY_ENGINE \ - CLAUDE_CONFIG_DIR="$cfg" AI_MEMORY_HARVEST_DIR="$harvest" \ - XDG_CONFIG_HOME="$xdgc" XDG_STATE_HOME="$xdgs" \ - bash "$cfg/hooks/ai-memory-harvest.sh" -replay_rc=$? -lines3=$(wc -l <"$harvest/hook.log") -notes3=$(find "$harvest" -maxdepth 1 -name '*.md' -type f | wc -l) -third=$(tail -1 "$harvest/hook.log") -if [ "$replay_rc" -ne 0 ]; then - fail "P4 the replayed SessionEnd exited $replay_rc, not 0" -elif [ "$lines3" -ne 3 ]; then - fail "P4 the log grew to $lines3 lines, expected 3" -elif [ "$notes3" -ne 2 ]; then - fail "P4 the replay created a note: $notes3 .md files, expected 2" -elif ! printf '%s' "$third" | grep -q "session=$sid_a status=unchanged "; then - fail "P4 the replayed end is not logged unchanged: $third" -else - pass "P4 a second SessionEnd for one session is 'unchanged', one more line, no new note" -fi - -# P5 — an unwritable store. The hook must still exit 0. -ro="$work/readonly"; mkdir -p "$ro"; chmod 500 "$ro" -printf '%s' "$payload" | env -u CLAUDE_CODE_CHILD_SESSION -u AI_MEMORY_ENGINE \ - CLAUDE_CONFIG_DIR="$cfg" AI_MEMORY_HARVEST_DIR="$ro/store" \ - XDG_CONFIG_HOME="$xdgc" XDG_STATE_HOME="$xdgs" \ - bash "$cfg/hooks/ai-memory-harvest.sh" >"$work/p5.out" 2>&1 -ro_rc=$? -chmod 700 "$ro" -if [ "$ro_rc" -ne 0 ]; then - fail "P5 an unwritable harvest store made the hook exit $ro_rc — it can wedge a session end" -elif [ -e "$ro/store" ]; then - fail "P5 the hook wrote into a store it should not have been able to create" -else - pass "P5 an unwritable store is survived: exit 0, nothing written" -fi - -# the fence, once more, over everything the probe did. -if [ -e "$xdgs/tally" ]; then - fail "FENCE the probe created $xdgs/tally" -else - pass "FENCE nothing reached branch (a)'s state path" -fi - -printf '\n-- hook.log --\n'; cat "$harvest/hook.log" -printf '\n-- %s.md front matter --\n' "$sid_a"; sed -n '1,12p' "$harvest/$sid_a.md" 2>/dev/null - -if [ "$failures" -ne 0 ]; then - printf '\nFAIL mem-2-eval-probe: %d case(s) failed\n' "$failures"; exit 1 -fi -printf '\nPASS mem-2-eval-probe: 5 case(s), 0 failure(s)\n' diff --git a/tools/mem-2-hook-oracle.sh b/tools/mem-2-hook-oracle.sh deleted file mode 100644 index e4369ede4..000000000 --- a/tools/mem-2-hook-oracle.sh +++ /dev/null @@ -1,544 +0,0 @@ -#!/usr/bin/env bash -# tools/mem-2-hook-oracle.sh — MEM-2 SESSIONEND-HOOK, the DOMINANT oracle. -# -# UNIT: MEM-2 (dotfiles#339). CARD: /home/tom/sept7/plan/UNITS-2026-09-06.json. -# SPEC: MECHANISM-2026-09-07 §6b; DECISIONS D-E07, D-E13. -# -# WHAT IT ASSERTS, in the manifest's own words. A scratch CLAUDE_CONFIG_DIR -# carrying the RENDERED settings.json and the hook script, with the harvest -# store pointed at a scratch directory: -# -# * `claude -p 'reply with the single word ok' --model haiku -# --permission-mode bypassPermissions --output-format json` completes; -# * afterwards EXACTLY ONE .md exists in the scratch harvest dir, -# it is the session that just ended, and its front matter says -# `harvested_at` — the harvest store's own variant, never a journal note -# (D-E13 (2)); -# * the scratch journal dir is EMPTY: a hook never writes the journal; -# * the hook.log carries ONE line, and it names the session; -# * /enqueue/ holds exactly one validated row per unresolved unit -# of the note — the close -> row -> floor leg (FIX-E08, dotfiles#348); -# * with the enqueue override UNSET, the engine still finds -# `tools/enqueue-row-check.py` by walking up (FIX-E08); -# * `nix flake check --offline --no-build` is green. -# -# It also asserts the fence: nothing this run does may land in branch (a)'s -# ~/.local/state/tally/, and no note for this session may appear in the live -# rewrite store either. XDG_STATE_HOME is pointed at the scratch tree so even a -# hook that ignored AI_MEMORY_HARVEST_DIR could not reach the real one. -# -# COST. NOTHING, by default. The oracle used to spend one real `claude -p` haiku -# turn on the seat every single run, so a non-author evaluator re-running the -# DOMINANT drew down the very seat the floor dispatches to (FIX-E08). MEM2_NO_SPEND -# is therefore ON by default: the session leg is stubbed with the repository's own -# claude-code trace fixture plus a synthetic SessionEnd payload, and the utility -# model is stubbed by a `utility-model` shim on PATH, so the run is offline, -# deterministic and free. What is NOT stubbed is everything the unit is about: the -# delivered hook script, the rendered settings.json, the real ai_memory.py engine, -# the real enqueue-row validator and the real store layout. -# -# The spending path is behind one explicit opt-in, MEM2_NO_SPEND=0: it runs the -# real `claude -p` end-to-end session and asserts the same clauses. Nothing else -# differs between the two modes. -# -# CREDENTIALS. MEM2_NO_SPEND=0 only. The scratch config dir gets the seat's -# `.credentials.json` as a SYMLINK. The oracle never opens it, never copies its -# bytes and never prints it; `claude` follows the link exactly as it would in the -# seat's own dir. In the default no-spend mode no credential is touched at all, -# and a stub `claude` on PATH records any invocation so "nothing was spent" is -# MEASURED rather than assumed. -# -# HOW TO RUN IT. -# -# MEM2_NO_SPEND=1 bash tools/mem-2-hook-oracle.sh # the default; free -# MEM2_NO_SPEND=0 bash tools/mem-2-hook-oracle.sh # spends one haiku turn -# -# MEM2_SEAT_CONFIG_DIR names the config dir whose credential is borrowed in -# spending mode (default: $CLAUDE_CONFIG_DIR, else ~/.claude). MEM2_KEEP=1 keeps -# the scratch tree for inspection. -# -# Exit: 0 every clause holds; 1 a clause failed (each failure is named on -# stdout); 2 usage, a missing tool, or a missing credential. - -set -uo pipefail - -repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -cd "$repo" || exit 2 - -hook_src="$repo/home/dot_claude/hooks/ai-memory-harvest.sh" -settings_src="$repo/home/dot_claude/settings.json" -skill_src="$repo/home/dot_claude/skills/drain" -hook_test="$repo/tests/ai-memory-hook/harvest-hook-test.sh" - -for path in "$hook_src" "$settings_src" "$skill_src/scripts/ai_memory.py" "$hook_test"; do - if [ ! -e "$path" ]; then - printf 'ERROR the oracle needs %s and it is not there\n' "$path" - exit 2 - fi -done - -no_spend=${MEM2_NO_SPEND:-1} -case "$no_spend" in - 0 | 1) ;; - *) - printf 'ERROR MEM2_NO_SPEND must be 0 or 1, not %s\n' "$no_spend" - exit 2 - ;; -esac - -required_tools="python3 jq nix timeout" -if [ "$no_spend" = "0" ]; then - required_tools="claude $required_tools" -fi -for tool in $required_tools; do - if ! command -v "$tool" >/dev/null 2>&1; then - printf 'ERROR %s is not on PATH\n' "$tool" - exit 2 - fi -done - -# The stub session's trace: the repository's own claude-code root fixture, the -# same twelve-line trace the engine's unit tests distil. -fixture_session=11111111-1111-4111-8111-111111111111 -fixture_trace="$repo/tests/ai-memory/fixtures/claude/projects/-synthetic/$fixture_session.jsonl" - -seat_cfg=${MEM2_SEAT_CONFIG_DIR:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}} -if [ "$no_spend" = "0" ]; then - if [ ! -e "$seat_cfg/.credentials.json" ]; then - printf 'ERROR no credential to borrow at %s/.credentials.json; set MEM2_SEAT_CONFIG_DIR\n' \ - "$seat_cfg" - exit 2 - fi -elif [ ! -f "$fixture_trace" ]; then - printf 'ERROR the no-spend mode needs the trace fixture at %s\n' "$fixture_trace" - exit 2 -fi - -failures=0 -fail() { - printf 'FAIL %s\n' "$*" - failures=$((failures + 1)) -} -pass() { printf 'ok %s\n' "$*"; } - -# --------------------------------------------------------------------------- -# 0. The hook's own contract tests. Offline, no model, no session: every branch -# of the hook against a fake engine. A hook that fails these has no business -# being handed a real session end. -# --------------------------------------------------------------------------- -printf '== the hook contract tests\n' -if ! bash "$hook_test"; then - printf '\nFAIL mem-2-hook-oracle: the hook contract tests are red\n' - exit 1 -fi - -# --------------------------------------------------------------------------- -# 0b. The enqueue validator, found with AI_MEMORY_ENQUEUE_CHECK UNSET (FIX-E08). -# Every other green in this tree sets that override, which is how a fixed -# `parents[3]` — two levels short of the repository root — survived. The -# engine is loaded from THIS worktree and asked where the validator is, with -# the override scrubbed: the answer must be the repository's own file. -# --------------------------------------------------------------------------- -printf '\n== the enqueue validator path, override unset\n' -if env -u AI_MEMORY_ENQUEUE_CHECK python3 - \ - "$skill_src/scripts/ai_memory.py" "$repo/tools/enqueue-row-check.py" <<'VALIDATOREOF' -import importlib.util -import sys -from pathlib import Path - -engine, expected = sys.argv[1], sys.argv[2] -spec = importlib.util.spec_from_file_location("ai_memory_oracle", engine) -module = importlib.util.module_from_spec(spec) -sys.modules["ai_memory_oracle"] = module -spec.loader.exec_module(module) -found = module.enqueue_check_path() -if not found.is_file(): - print(f"the validator is not a file at {found}") - raise SystemExit(1) -if found.resolve() != Path(expected).resolve(): - print(f"the validator resolved to {found}, expected {expected}") - raise SystemExit(1) -print(f"found {found}") -VALIDATOREOF -then - pass "enqueue_check_path() finds tools/enqueue-row-check.py with no override" -else - fail "enqueue_check_path() does not find the repository's validator with AI_MEMORY_ENQUEUE_CHECK unset" -fi - -# --------------------------------------------------------------------------- -# 1. The scratch world. -# --------------------------------------------------------------------------- -work=$(mktemp -d "${TMPDIR:-/tmp}/mem-2-oracle.XXXXXX") -cleanup() { - if [ "${MEM2_KEEP:-0}" = "1" ]; then - printf '\n(scratch tree kept at %s)\n' "$work" - else - rm -rf "$work" - fi -} -trap cleanup EXIT - -cfg="$work/cfg" -harvest="$work/harvest" -journal="$work/journal" -xdg_config="$work/xdg-config" -xdg_state="$work/xdg-state" -runtime="$work/runtime" -mkdir -p "$cfg/hooks" "$cfg/skills" "$harvest" "$journal" \ - "$xdg_config/ai-memory" "$xdg_state" "$runtime" -chmod 700 "$runtime" - -# The hook script, as delivered. -cp "$hook_src" "$cfg/hooks/ai-memory-harvest.sh" - -# The engine, found the way the hook finds it: under the config dir's own -# skills tree. This is THIS worktree's ai_memory.py, not the installed one. -ln -s "$skill_src" "$cfg/skills/drain" - -# The credential, as a link. Never read here — and never even linked in the -# default no-spend mode, where nothing authenticates anything. -if [ "$no_spend" = "0" ]; then - ln -s "$seat_cfg/.credentials.json" "$cfg/.credentials.json" -fi - -# A journal config pointing at the scratch journal dir, so "the journal stayed -# empty" is a statement about a real, configured, writable journal. -printf '{"schema":1,"journal_dir":"%s"}\n' "$journal" \ - >"$xdg_config/ai-memory/config.json" - -# The RENDERED settings.json: the repository's own file with the MEM-2 hook path -# resolved to the scratch copy. Nothing else is rewritten. (Until 2026-09-13 -# the file also carried a SessionStart block naming -# ~/.claude/hooks/herdr-agent-state.sh, a script that existed nowhere, MEASURED -# 2026-09-07; it failed here exactly as on the live box, without stopping the -# session. That block is gone — DF-MEM-2-2 discharged by removal — so the -# rendered file now declares SessionEnd alone.) -# -# A settings.json carrying NO SessionEnd block is rendered anyway, verbatim. -# That is the mutation hint's case, and the mutation must be observed the way it -# is written -- "no harvest file after the session ends" -- not short-circuited -# into a usage error before the session has even run. -python3 - "$settings_src" "$cfg/settings.json" "$cfg/hooks/ai-memory-harvest.sh" <<'PYEOF' -import json -import sys - -source, target, hook = sys.argv[1], sys.argv[2], sys.argv[3] -raw = open(source, encoding="utf-8").read() -rendered = raw.replace("/home/tom/.claude/hooks/ai-memory-harvest.sh", hook) -json.loads(rendered) -open(target, "w", encoding="utf-8").write(rendered) -if rendered == raw: - print( - "note: settings.json names no ai-memory-harvest.sh hook; rendered verbatim", - file=sys.stderr, - ) -PYEOF -if [ $? -ne 0 ]; then - printf 'ERROR the repository settings.json could not be rendered as JSON\n' - exit 2 -fi - -if python3 -c ' -import json, sys -settings = json.load(open(sys.argv[1], encoding="utf-8")) -sys.exit(0 if "SessionEnd" in settings.get("hooks", {}) else 1) -' "$cfg/settings.json"; then - printf 'rendered settings.json carries a SessionEnd block\n' -else - printf 'rendered settings.json carries NO SessionEnd block\n' -fi - -printf '\n== the scratch session\n' -printf 'config dir %s\n' "$cfg" -printf 'harvest dir %s\n' "$harvest" -printf 'journal dir %s\n' "$journal" - -# The live stores, as they stand before the run: the fence is asserted against -# these, not against an assumption that they are absent. -live_state="${XDG_STATE_HOME:-$HOME/.local/state}" -live_harvest="$live_state/tally-rewrite/harvest" - -# --------------------------------------------------------------------------- -# 2. The session. Every identity variable this shell carries is scrubbed: the -# scratch session is a ROOT session and must not inherit the identity, the -# child-session flag or the messaging bus of whatever launched the oracle. -# --------------------------------------------------------------------------- -out="$work/claude-out.json" -err="$work/claude-err.txt" -spend_log="$work/claude-invocations.txt" -: >"$spend_log" - -# The stub `claude`, on PATH for the no-spend mode: it spends nothing and records -# that it was called, so the zero-spend clause below is a measurement of the run -# and not a claim about the code. -mkdir -p "$work/bin" -cat >"$work/bin/claude" <>"$spend_log" -printf 'ERROR the no-spend mode must not invoke claude\n' >&2 -exit 97 -STUBEOF -chmod +x "$work/bin/claude" - -# The stub `utility-model`: one deterministic distillation, with two unresolved -# units, so the enqueue count clause has an expected number that does not depend -# on what a model happened to say. The engine, the validator and the row shape -# are the real ones. -cat >"$work/bin/utility-model" <<'STUBEOF' -#!/usr/bin/env python3 -import json -import sys - -sys.stdin.read() -result = { - "title": "MEM-2 hook oracle, no-spend mode", - "group": "harvest oracle", - "thread": ( - "The oracle drove the delivered SessionEnd hook against a fixture " - "trace, with the utility seam stubbed, and asserted the harvest note " - "and the enqueue rows it wrote." - ), - "decisions": ["The oracle does not spend a seat by default."], - "candidate_ideas": ["Assert the row count against the note itself."], - "constraints": ["The harvest store is the only thing written."], - "artifacts": ["tools/mem-2-hook-oracle.sh"], - "open_questions": ["Whether the floor consumes these rows yet."], - "resolved": False, - "unresolved_units": [ - "Wire the harvest enqueue rows into the floor's own events dir.", - "Prove the hook enqueues on a real session close.", - ], -} -print(json.dumps({"choices": [{"message": {"content": json.dumps(result)}}]})) -STUBEOF -chmod +x "$work/bin/utility-model" - -if [ "$no_spend" = "1" ]; then - # The session, without a session: the fixture trace is placed where a real - # Claude Code session would have left it, and the hook is handed the SessionEnd - # payload Claude Code would have handed it. Everything downstream of the - # payload — engine discovery, identity, trace resolution, provenance, the note, - # the rows, the log line — is the delivered code doing its real work. - session_id=$fixture_session - mkdir -p "$cfg/projects/-mem2-oracle" - cp "$fixture_trace" "$cfg/projects/-mem2-oracle/$session_id.jsonl" - payload=$( - printf '{"session_id":"%s","transcript_path":"%s","cwd":"%s","hook_event_name":"SessionEnd","reason":"other"}' \ - "$session_id" "$cfg/projects/-mem2-oracle/$session_id.jsonl" "$work" - ) - hook_cmd=$( - python3 - "$cfg/settings.json" <<'HOOKEOF' -import json -import sys - -settings = json.load(open(sys.argv[1], encoding="utf-8")) -blocks = settings.get("hooks", {}).get("SessionEnd", []) -hooks = blocks[0]["hooks"] if blocks else [] -print(hooks[0]["command"] if hooks else "") -HOOKEOF - ) - if [ -z "$hook_cmd" ]; then - fail "the rendered settings.json declares no SessionEnd command to run" - printf '\nFAIL mem-2-hook-oracle: %d clause(s) failed\n' "$failures" - exit 1 - fi - printf 'no-spend mode: no seat is touched; the SessionEnd command is run as declared\n' - printf 'SessionEnd %s\n' "$hook_cmd" - start=$(date +%s) - printf '%s' "$payload" | env \ - -u CLAUDE_CODE_CHILD_SESSION \ - -u CLAUDE_SESSION_ID \ - -u CODEX_THREAD_ID \ - -u AI_MEMORY_ENGINE \ - -u AI_MEMORY_ENQUEUE_CHECK \ - -u AI_MEMORY_HARVEST_ENQUEUE \ - PATH="$work/bin:$PATH" \ - CLAUDE_CONFIG_DIR="$cfg" \ - AI_MEMORY_HARVEST_DIR="$harvest" \ - XDG_CONFIG_HOME="$xdg_config" \ - XDG_STATE_HOME="$xdg_state" \ - XDG_RUNTIME_DIR="$runtime" \ - timeout 300 bash -c "$hook_cmd" >"$out" 2>"$err" - hook_rc=$? - printf 'the hook returned rc=%s wall=%ss\n' "$hook_rc" "$(( $(date +%s) - start ))" - if [ "$hook_rc" -ne 0 ]; then - fail "the SessionEnd hook exited $hook_rc; it must exit 0 on every path" - else - pass "the SessionEnd hook ran the harvest and exited 0" - fi -else -start=$(date +%s) -env \ - -u CLAUDE_CODE_CHILD_SESSION \ - -u CLAUDE_CODE_SESSION_ID \ - -u CLAUDE_SESSION_ID \ - -u CODEX_THREAD_ID \ - -u CLAUDECODE \ - -u CLAUDE_CODE_ENTRYPOINT \ - -u CLAUDE_CODE_MESSAGING_SOCKET \ - -u CLAUDE_CODE_MESSAGING_TOKEN \ - -u CLAUDE_CODE_BRIDGE_SESSION_ID \ - -u CLAUDE_PID \ - -u CLAUDE_EFFORT \ - -u AI_MEMORY_ENGINE \ - CLAUDE_CONFIG_DIR="$cfg" \ - AI_MEMORY_HARVEST_DIR="$harvest" \ - XDG_CONFIG_HOME="$xdg_config" \ - XDG_STATE_HOME="$xdg_state" \ - XDG_RUNTIME_DIR="$runtime" \ - timeout 900 claude -p 'reply with the single word ok' \ - --model haiku --permission-mode bypassPermissions --output-format json \ - "$out" 2>"$err" -claude_rc=$? -elapsed=$(( $(date +%s) - start )) -printf 'claude -p rc=%s wall=%ss\n' "$claude_rc" "$elapsed" - -if [ "$claude_rc" -ne 0 ]; then - fail "claude -p did not complete (rc=$claude_rc); stderr: $(head -c 400 "$err")" - printf '\nFAIL mem-2-hook-oracle: %d clause(s) failed\n' "$failures" - exit 1 -fi - -session_id=$(jq -r '.session_id // empty' "$out" 2>/dev/null) -if [ -z "$session_id" ]; then - fail "claude -p emitted no session_id in --output-format json" - printf '\nFAIL mem-2-hook-oracle: %d clause(s) failed\n' "$failures" - exit 1 -fi -pass "claude -p completed and reported session $session_id" -fi - -# --------------------------------------------------------------------------- -# 3. The clauses. -# --------------------------------------------------------------------------- -printf '\n== the clauses\n' - -notes=() -while IFS= read -r note; do - notes+=("$note") -done < <(find "$harvest" -maxdepth 1 -name '*.md' -type f | sort) - -if [ "${#notes[@]}" -ne 1 ]; then - fail "expected exactly one .md in $harvest, found ${#notes[@]}: ${notes[*]:-none}" -else - note=${notes[0]} - if [ "$(basename "$note")" != "$session_id.md" ]; then - fail "the note is $(basename "$note"), not $session_id.md" - else - pass "exactly one harvest note, and it is $session_id.md" - fi - if ! grep -q "^session_id: \"$session_id\"$" "$note"; then - fail "the note's front matter does not name session $session_id" - elif ! grep -q '^harvested_at: ' "$note"; then - fail "the note says no harvested_at: it is not a harvest-store note (D-E13 (2))" - elif grep -q '^drained_at: ' "$note"; then - fail "the note says drained_at: the hook produced a journal note" - elif ! grep -q '^source: "claude-code"$' "$note"; then - fail "the note's source is not claude-code" - else - pass "the note is the harvest store's own variant (harvested_at, no drained_at)" - fi -fi - -journal_entries=$(find "$journal" -mindepth 1 | wc -l) -if [ "$journal_entries" -ne 0 ]; then - fail "the scratch journal dir is not empty ($journal_entries entries); a hook never writes the journal" -else - pass "the scratch journal dir is empty" -fi - -hook_log="$harvest/hook.log" -if [ ! -f "$hook_log" ]; then - fail "no hook.log in $harvest" -else - log_lines=$(wc -l <"$hook_log") - if [ "$log_lines" -ne 1 ]; then - fail "hook.log carries $log_lines lines, expected exactly 1: $(tr '\n' '|' <"$hook_log")" - elif ! grep -q "session=$session_id" "$hook_log"; then - fail "the hook.log line does not name the session: $(cat "$hook_log")" - elif ! grep -qE ' status=(created|updated) ' "$hook_log"; then - fail "the hook.log line does not report a written note: $(cat "$hook_log")" - else - pass "hook.log carries one line naming the session: $(cat "$hook_log")" - fi -fi - -# The fence. Nothing under branch (a)'s live state dir, and no note for this -# session in the live rewrite store either. -if [ -e "$xdg_state/tally" ]; then - fail "the run created $xdg_state/tally; branch (a)'s state dir is fenced" -elif [ -e "$live_harvest/$session_id.md" ]; then - fail "a note for this session landed in the live store at $live_harvest" -else - pass "nothing reached the live state dirs" -fi - -# The close -> row -> floor leg (FIX-E08). One validated row per unresolved unit -# of the note that was just written — counted from the note itself, so the clause -# cannot be satisfied by a store that happens to hold rows from something else. -enqueue_store="$harvest/enqueue" -rows=0 -if [ -d "$enqueue_store" ]; then - rows=$(find "$enqueue_store" -maxdepth 1 -name '*.enqueue.json' -type f | wc -l) -fi -if [ "${#notes[@]}" -ne 1 ]; then - fail "no single note to count unresolved units from; the enqueue clause cannot be judged" -else - units=$(python3 - "${notes[0]}" <<'UNITSEOF' -import sys - -lines = open(sys.argv[1], encoding="utf-8").read().splitlines() -count = 0 -inside = False -for line in lines: - if line.startswith("## "): - inside = line.strip() == "## Unresolved units" - continue - if inside and line.startswith("- ") and line.strip() != "- None recorded.": - count += 1 -print(count) -UNITSEOF - ) - if [ "$units" -eq 0 ]; then - fail "the note records no unresolved units, so the enqueue clause proves nothing" - elif [ "$rows" -ne "$units" ]; then - fail "$enqueue_store holds $rows row(s), the note records $units unresolved unit(s)" - else - validated=0 - for row in "$enqueue_store"/*.enqueue.json; do - if python3 "$repo/tools/enqueue-row-check.py" "$row" >/dev/null 2>&1; then - validated=$((validated + 1)) - else - fail "the row $row does not pass tools/enqueue-row-check.py" - fi - done - if [ "$validated" -eq "$units" ]; then - pass "$rows validated enqueue row(s), one per unresolved unit of the note" - fi - fi -fi - -# Zero spend, MEASURED: in no-spend mode a stub `claude` sat on PATH for the whole -# run and was never called. -if [ "$no_spend" = "1" ]; then - if [ -s "$spend_log" ]; then - fail "the no-spend run invoked claude: $(tr '\n' '|' <"$spend_log")" - else - pass "nothing was spent: the stub claude on PATH was never invoked" - fi -fi - -printf '\n== nix flake check --offline --no-build\n' -if ! nix flake check --offline --no-build 2>&1 | tail -3; then - fail "nix flake check --offline --no-build is not green" -else - pass "nix flake check --offline --no-build is green" -fi - -if [ "$failures" -ne 0 ]; then - printf '\nFAIL mem-2-hook-oracle: %d clause(s) failed\n' "$failures" - exit 1 -fi -printf '\nPASS mem-2-hook-oracle: session %s ended, one harvest note exists and %s enqueue row(s) were written\n' \ - "$session_id" "$rows"