Introduction

macOS dotfiles managed with nix-darwin and home-manager. Homebrew provides GUI apps; CLI tools are declared across Nix and mise.

Applying

sudo darwin-rebuild switch --flake ~/.dotfiles/nix#ranolp-work-MBP-26

Or with the shell alias:

rebuild

Secrets

Copy nix/home/local.nix.example to nix/home/local.nix and fill in private values (GPG signing key); local.nix is gitignored.

Nix

System configuration layer. Managed by nix-darwin with home-manager.

Managed by: nix flake (nix/flake.nix)

Flake Inputs

InputSourceNotes
nixpkgsnixpkgs-unstableMain package set
nixpkgs-misepinned nixpkgs revisionSupplies the cached aarch64-darwin mise package
nix-darwinLnL7/nix-darwin mastermacOS system config
home-managernix-community masterUser config
homebrew-brewHomebrew/brewLocked Homebrew source
nix-homebrewzhaofengli/nix-homebrewDeclarative Homebrew integration
nurnix-community/NURCommunity package set, including Firefox add-ons

Overlays

  • nixpkgs-mise: overlays the pinned mise package on macOS to use the cached aarch64-darwin binary.
  • nur: exposes the NUR package set as pkgs.nur (Firefox add-ons).

Nix Settings

SettingValue
experimental-featuresnix-command, flakes
optimise.automatictrue

Home Packages (nix, not mise)

PackagePurpose
ageEncryption tool
bunRuntime for the Herdr browser plugin
ffmpegComposes Jira QA review videos
gnupgGPG toolchain
nix-your-shellnix develop/nix-shell → nushell

macOS adds pinentry_mac, pinentry-tty, xcodes, docker-compose, gmp, and libyaml in nix/home/darwin/default.nix. Home Manager links the Compose plugin and installs the Xcodes release binary from nix/home/darwin/packages/xcodes.nix.

macOS Defaults

nix/darwin/default.nix enables Touch ID for sudo, hides the Dock automatically, disables recent apps, shows file extensions and hidden files in Finder, selects dark appearance, speeds up key repeat, and enables trackpad clicking and three-finger drag. It also disables the Bluetooth menu-bar item.

The activation script assigns the bottom-left Dock hot corner to Lock Screen, maps F18 to the Korean input toggle, disables Spotlight's Cmd+Space shortcut, and maps Cmd+Shift+S to the screenshot toolbar. It also disables the macOS 26 SwiftUI glass effect.

Services

macOS enables Syncthing and Espanso through Home Manager. Espanso's signed 2.3.0 app is downloaded and copied during activation; its match packages are declared in nix/home/darwin/default.nix.

xpkg

The Windows counterpart to nix: one command installs every declared package, font and config on a Windows machine.

Managed by: xpkg/install.nu, run through the rebuild alias.

rebuild is the apply command on every host

Every host has a rebuild shell alias that runs that host's apply command. When the user says "I ran rebuild", that command ran.

Hostrebuild runsDeclared in
Windows (nushell)nu ~/.dotfiles/xpkg/install.nuxpkg/configs/nushell/config.nu
macOSsudo darwin-rebuild switch --flake ~/.dotfiles/nix#ranolp-work-MBP-26nix/home/darwin/default.nix
Arch Linux on WSLhome-manager switch --flake ~/.dotfiles/nix#ranolp-archwsl -b before-hmnix/home/linux/default.nix

How an agent installs something on Windows

The user asks for "install X" or "upgrade X". The agent's whole job is the declaration:

  1. Add X to xpkg/windows/default.toml in the section that fits (table below).
  2. If the installer code cannot express it yet, extend xpkg/windows/install.nu.
  3. Verify without installing: nu-check --debug xpkg/windows/install.nu prints true, and open xpkg/windows/default.toml | get <section> shows the new entry.
  4. Commit on main and hand over rebuild.

The user downloads and installs X by running rebuild. The agent never runs winget install, wsl --install, or a function from install.nu itself, even when the user said "install". On 2026-11-13 an agent ran wsl --install archlinux and a Build Tools install by hand instead of declaring them, and the user had to stop it.

What rebuild does on Windows

xpkg/install.nu relaunches itself elevated through gsudo once when it is not admin; every later step inherits that elevation. It then runs its units concurrently wherever one does not need another's result:

  1. Settings, in the background -- xpkg/configs/install.nu --system starts as a background job and runs the units that only write the registry or powercfg, all at the same time: keyboard, lock, explorer, theme, chrome, audio, littlebigmouse, bin.
  2. Packages, in the foreground -- xpkg/windows/install.nu applies xpkg/windows/default.toml while the settings run. It keeps the terminal because the WSL passwd prompt needs it; each winget or direct install prints its result line when it finishes. A Packages failure is recorded rather than aborting, so the settings output still prints and rebuild exits non-zero at the end.
  3. Refresh PATH from the registry, so tools a package just installed are found by the later steps.
  4. Settings output -- the captured output of step 1 prints under == Settings.
  5. Configs and Fonts -- xpkg/configs/install.nu --rebuild renders the nushell and mise configs from the shared nix/home/configs sources and runs mise install, one after the other. Then it runs every remaining unit at the same time: gitconfig, gnupg, espanso, firefox, terminal, prismlauncher, vscode, claude, codex, opencode, pi, and Fonts (xpkg/apps/@fonts/install.nu, which downloads every font in fonts.toml and installs it for the current user). vscode, claude, codex, pi and opencode run only when that tool is installed.

gitconfig stays one unit, because concurrent git config writers collide on .gitconfig.lock. prismlauncher needs the JDK, pi needs pnpm, and Fonts needs uv on a cache miss; all three come from mise install, which is why that step finishes first.

Each unit is a nu <script> child whose stdout and stderr are captured together (xpkg/utils/parallel.nu). When a unit finishes, one block prints: ok <name> 1.2s in green or !! <name> exit N in red, then the unit's output indented 6 spaces. Blocks appear in finishing order and never interleave. Colours the unit script prints itself survive the capture; an external tool it runs sees a pipe instead of a terminal and may print without colour. The last line is == rebuild done in <N>s, the wall time of the elevated run. When Packages, Settings or Configs and Fonts failed, a red !! failed: <groups> line follows it and rebuild exits 1.

Run on its own, xpkg/configs/install.nu relaunches itself elevated when needed and applies every config, the settings units included, in one parallel group after mise install. It does not install fonts.

Inside Packages, winget and msiexec installs stay serial, because Windows Installer allows one MSI install at a time. An install that exits with 0x8A150102 (winget) or 1618 (msiexec), both meaning another install is running, is retried up to 10 times, 15 s apart. [direct.*] installers download in parallel, then install one at a time.

xpkg/windows/default.toml

The packages step reads winget list once, skips everything already installed, installs the rest, and never uninstalls anything except ids listed in remove.

SectionHoldsInstalled with
[winget] packageswinget ids for every Windows machinewinget install -eh --id <id>
[profiles.<hostname>] packageswinget ids for one machine onlysame
[winget.override]"<id>" = "<installer args>" for an id whose default install is not what is wantedwinget install ... --override "<args>"
skip_dependenciesids installed with --skip-dependencies
removeids to uninstall when presentwinget uninstall
[direct."<name>"]software not on winget: url, silent args, optional zip member; <name> is the start of its installed DisplayNamedownload, then run the installer or msiexec
[wsl.<distro>]a wsl --list --online distro name and its user; optional sparse = true, home-manager = truewsl --install <distro> --no-launch, then first-boot setup; sparse runs wsl --manage <distro> --set-sparse true --allow-unsafe on an existing distro; home-manager runs xpkg/setup-scripts/linux/arch.sh in the distro on every rebuild

A winget package installed but not declared is reported as a warning only.

Overrides apply on first install only

An installed package is skipped, so changing its [winget.override] line does nothing on a machine that already has it. Change the installed components by hand (for Visual Studio, the Visual Studio Installer's Modify), or uninstall it and run rebuild.

Microsoft.VisualStudio.BuildTools is the current override. Its bare install has no compiler, so the override adds the C++ build tools workload, MSVC x64/x86 and the Windows 11 SDK 10.0.26100.

WSL distros

For each [wsl.<distro>] missing from wsl -l -q, while the registry key HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending exists, the distro is skipped with a "reboot, then rebuild" notice. Otherwise:

  1. wsl --install <distro> --no-launch.
  2. If the distro still does not exist, the machine lacked the Virtual Machine Platform feature: wsl --install only turned it on and exited 0. rebuild prints reboot Windows, then run rebuild again and stops for that distro.
  3. xpkg/windows/wsl-first-boot.sh runs as root: it initialises the pacman keyring, installs sudo, creates user in wheel, and writes /etc/wsl.conf with systemd on and user as the default.
  4. passwd <user> asks the user for a password in the terminal.
  5. wsl --terminate <distro> so wsl.conf takes effect.

A distro that already exists is skipped, so the first-boot script never re-runs on it, except for sparse = true: when fsutil sparse queryflag on <BasePath>\ext4.vhdx (BasePath from the distro's HKCU\Software\Microsoft\Windows\CurrentVersion\Lxss key) says the file is not sparse, rebuild runs wsl --terminate <distro> then wsl --manage <distro> --set-sparse true --allow-unsafe and prints a !! line on failure. --allow-unsafe is required because Microsoft disabled sparse VHD in WSL 2.7 citing potential data corruption, so the key is opt-in. An already-sparse distro prints ==.

With home-manager = true, every rebuild (on an existing distro, and right after a successful first boot) pipes xpkg/setup-scripts/linux/arch.sh into the distro twice: wsl -d <distro> -u root -- bash -s -- system, then wsl -d <distro> -- bash -s -- home as the default user. The system phase runs pacman -S --needed git nix, enables nix-daemon.socket (systemd must be on, which the first-boot wsl.conf does), writes experimental-features = nix-command flakes into /etc/nix/nix.conf and runs systemctl try-restart nix-daemon.service only when that line changed, then creates /nix/store with nix store info if missing: the Arch nix package does not create it, and a user nix run fails with error: opening file "/nix/store": No such file or directory until it exists. The home phase clones https://github.com/RanolP/.dotfiles.git to ~/.dotfiles only when that path is missing, never touching an existing checkout, then runs nix run home-manager/master -- switch --flake ~/.dotfiles/nix#ranolp-archwsl -b before-hm. Each step prints == (already done) or ++ (changed); rebuild relays those lines and, on a non-zero exit, prints a !! line with the last three output lines. After that, rebuild inside the distro is home-manager switch directly.

The script also runs standalone on any Arch machine, as the normal user: bash xpkg/setup-scripts/linux/arch.sh with no argument runs the system phase through sudo and then the home phase. system or home as the argument runs that phase alone.

First install: the setup entry

The root setup file, served at https://dotfiles.ranolp.dev/setup, is one file that is valid POSIX sh, cmd batch and PowerShell at once, so the same URL bootstraps from any of the three shells:

  • sh (Arch Linux, or Git Bash on Windows): curl -fsSL https://dotfiles.ranolp.dev/setup | sh
  • PowerShell: irm https://dotfiles.ranolp.dev/setup | iex
  • cmd: curl -fsSL https://dotfiles.ranolp.dev/setup -o %TEMP%\setup.cmd && %TEMP%\setup.cmd

Each shell reads past the other two branches: sh sees line 1 as an echo to /dev/null and exits on line 3, cmd sees lines 2 and 3 as labels and stops at @exit /b, and PowerShell sees lines 1 to 6 as a here-string piped to Out-Null. Under cmd the first line echoes echo @' and @' before anything runs; that noise is harmless. The file stays LF: cmd runs it correctly with LF line endings, while CRLF would break the sh branch.

The sh branch runs xpkg/setup-scripts/sh.sh, which pipes linux/arch.sh into bash on Arch (detected from /etc/os-release), hands Git Bash (uname -s starting MINGW or MSYS) to the PowerShell script below, and exits 1 on anything else. The cmd and PowerShell branches both run xpkg/setup-scripts/windows.ps1 (Windows PowerShell 5.1 compatible), which installs Git.Git and Nushell.Nushell through winget only when winget list --exact --id <id> reports them absent, reloads Path from the registry, clones https://github.com/RanolP/.dotfiles.git to ~\.dotfiles only when that path is missing (an existing checkout is never touched), then runs nu ~\.dotfiles\xpkg\install.nu, the same entry as rebuild. Each step prints ==, ++ or !! like arch.sh; a failed step throws rather than calling exit, so irm | iex does not close the caller's window.

Homebrew

Declarative Homebrew managed by nix-homebrew, locked to the Homebrew/brew flake input.

Managed by: nix/darwin/default.nix

Activation Policy

SettingValue
autoUpdatefalse
upgradetrue
greedyCaskstrue
cleanupzap
extraFlags--force

Brews (CLI formulas)

FormulaPurpose
git-absorbAuto-fixup commits
git-filter-repoRewrite git history
mdbookBuild this documentation
libmagicLibrary used by the reuse pipx tool

Casks (GUI apps)

CaskApp
ghosttyTerminal emulator
raycastLauncher
karabiner-elementsKeyboard remapping
linearmouseMouse/trackpad customization
discordMessaging
bitwardenPassword manager
figmaDesign tool
slackMessaging
android-commandlinetoolsAndroid SDK manager
temurinOpenJDK (for Android builds)
google-chromeBrowser
notionNotes
keybaseEncrypted messaging / file storage
openusageUsage monitor
shottrScreenshot utility
menubarxMenu bar browser
proxymanHTTP debugging proxy
thawMenu bar utility

The displaylink, obs, steam, and tailscale-app casks are scoped to ranolp-work-MBP-26 in nix/darwin/default.nix.

Fonts (casks)

CaskFont
font-iosevka-nerd-fontIosevka Nerd Font — terminal / editor
font-pretendardPretendard — UI / Korean text

Nushell

Primary interactive shell.

Managed by: nix/home/programs/nushell.nix and the files under nix/home/configs/nushell/

Config Files

Source filePurpose
nix/home/configs/nushell/env.common.nuShared PATH, editor, GitHub token, and Claude updater settings
nix/home/configs/nushell/env.darwin.numacOS PATH, Android SDK, and Colima socket
nix/home/configs/nushell/env.linux.nuLinux profile PATH
nix/home/configs/nushell/config.nuStartup hooks, prompt behavior, and ccc profile selector

PATH Order (highest to lowest priority)

  1. ~/Library/Android/sdk/emulator
  2. ~/Library/Android/sdk/platform-tools
  3. /etc/profiles/per-user/ranolp/bin (nix per-user)
  4. ~/.local/bin
  5. ~/.local/share/mise/shims
  6. /nix/var/nix/profiles/default/bin (nix default)
  7. Existing PATH, with Homebrew's bin and sbin appended on macOS

Environment Variables

VariableValue
ANDROID_HOME~/Library/Android/sdk
GITHUB_TOKENgh auth token (auto-populated)
DOCKER_HOSTunix://~/.colima/default/docker.sock
EDITORnvim
VISUALcode --wait

Aliases

AliasExpands To
catbat
lseza --icons=auto --git --group-directories-first --header --time-style=relative
rebuildsudo /run/current-system/sw/bin/darwin-rebuild switch --flake ~/.dotfiles/nix#ranolp-work-MBP-26
prgh pr view -w
repogh repo view -w
ggit
gstgit status
glgit pull
gpgit push
gcgit commit -v

nix-your-shell

nix develop and nix-shell drop into nushell instead of bash. The nushell snippet is generated at home-manager activation and cached at ~/.cache/nix-your-shell.nu.

Zsh

Fallback shell and Claude Code Bash-tool shell.

Managed by: nix/darwin/default.nix (system), nix/home/darwin/default.nix (Claude wrapper), nix/home/darwin/programs/zsh.nix (home), nix/home/default.nix (mise zsh integration)

Configuration

The account login shell is /bin/sh. Home Manager makes the user-profile zsh resolve to Apple's /bin/zsh.

The Claude Code wrapper sets SHELL=/bin/zsh to short-circuit Claude Code's shell probe, which would otherwise select the nix zsh and encounter its SIGCHLD startup hang.

Ghostty launches Nushell as the primary interactive shell. Interactive Zsh remains configured with aliases and the mise activate hook.

Non-interactive Zsh reads .zshenv, which adds ~/.local/bin and the mise shims to PATH, disables the Claude Code autoupdater, and configures the Android SDK paths.

Only shells with CLAUDECODE set disable history. Guards in both .zshenv and .zshrc unset HISTFILE and zero the history sizes because Home Manager's .zshrc history block can restore HISTFILE after .zshenv runs.

Shared history, autosuggestions, and syntax highlighting are disabled.

Home Manager sets completionInit = "", leaving completion initialization out of the user's .zshrc.

nix-darwin sets enableCompletion = false and enableBashCompletion = false, leaving compinit and bashcompinit out of /etc/zshrc. These commands slowly scan nix-store paths in $fpath.

Starship

Cross-shell prompt.

Managed by: nix/home/programs/starship.nix

Prompt Modules (left to right)

directory → git_branch → git_commit → git_state → git_status → nodejs → python → rust → golang → kotlin → java → swift → $fill → cmd_duration → time → newline → character

The $fill module is a spacer — it pushes cmd_duration and time to the right edge of the terminal on the same line as the left modules.

When Nushell is in a WSL Windows-drive directory under /mnt/, it switches to starship-winfs.toml, which omits language modules that scan project files. The generated variant is declared in nix/home/programs/starship.nix.

Module Settings

ModuleSettingValue
cmd_durationmin_time5000ms
cmd_durationformat[took $duration]($style)
cmd_durationstyleyellow
timedisabledfalse
timeformat[🕐 $time]($style)
timestylecyan

Zellij

Terminal multiplexer.

Managed by: nix/home/programs/zellij.nix

Keybinds

ShortcutAction
Super+DNew pane to the right
Super+Shift+DNew pane below

These mirror the split bindings in Ghostty.

Behaviour

SettingValue
exit_on_closetrue
on_force_closequit
show_release_notesfalse
show_startup_tipsfalse
default_layoutdefault

Layout

Bare default layout — no tab bar, just a pane area (children).

Ghostty

Terminal emulator.

Managed by: nix/home/darwin/programs/ghostty.nix (config), installed via Homebrew cask

Settings

SettingValue
themeNord
font-familyIosevka Nerd Font Mono, Pretendard
font-size16
commandNushell from the Home Manager profile

Keybinds

BindingAction
Super+DNew split to the right
Super+Shift+DNew split below
Super+Alt+DFallback split to the right
Super+Alt+Shift+DFallback split below
Super+Alt+TNew tab
Super+Alt+WClose surface

Karabiner routes the unmodified Super shortcuts through herdr-key; the Alt shortcuts are Ghostty fallbacks when Herdr is not active.

package = null in home-manager — config is managed declaratively but the app binary comes from the Homebrew cask.

Herdr

Terminal workspace manager configured for Nushell, Ghostty, and the pinned browser and hands-free plugins.

Managed by: the macOS mise overlay and Home Manager configuration in nix/home/default.nix.

Configuration

Home Manager writes the Herdr configuration with Nushell as the default shell, Nord theme, and tab actions unbound. Workspaces remain the grouping level, with the tab bar hidden; the agent panel sorts by priority. prefix+d toggles dictation and prefix+g toggles gaze mouse through the hands-free plugin.

The activation entries in nix/home/default.nix link the pinned herdr-browser and herdr-handsfree plugin sources. The hands-free plugin uses a prebuilt macOS release binary. The browser plugin manifest is adjusted to call the declared Bun runtime.

nix/home/darwin/configs/karabiner/karabiner.json sends Ghostty's Cmd+D, Cmd+Shift+D, Cmd+T, and Cmd+W shortcuts to herdr-key. The helper in nix/home/darwin/default.nix sends them to Herdr when its window is active and otherwise redispatches the corresponding Ghostty shortcut.

Git

Managed by: nix/home/programs/git.nix

Identity

SettingValue
user.nameRanolP
user.emailme@ranolp.dev

Workflow

SettingValue
init.defaultBranchmain
push.autoSetupRemotetrue
pull.rebasetrue
merge.conflictstylezdiff3
rerere.enabledtrue

Signing

SettingValue
commit.gpgSigntrue
user.signingKeyBB9C29B5FA1C8305

See GnuPG for GPG setup.

Diff and Pager

SettingValue
core.pagerdelta
interactive.diffFilterdelta --color-only
delta.navigatetrue
delta.side-by-sidefalse
diff.colorMoveddefault
diff.algorithmhistogram

fast-apply

Local file-edit command for exact replacements and anchored block merges.

Managed by: nix/home/configs/bin/fast-apply, installed by nix/home/default.nix as an executable.

Modes

CommandBehavior
fast-apply FILE --old OLD --new NEWReplaces exactly one match; --all opts into replacing every match
fast-apply FILE --lazyReads a snippet from stdin and merges text between ordered, exact-match anchors
Either edit mode with --dry-runPrints the proposed diff without writing
fast-apply --selftestRuns the script's offline assertions

Edits refuse ambiguous or missing matches, write atomically, and print a diff. nix/home/configs/claude/hooks/file-edit-guard.py can direct shell-based file edits to this command.

VS Code

Managed by: nix/home/darwin/programs/vscode.nix via programs.vscode (home-manager)

mutableExtensionsDir = false — extensions are fully declarative; manually installed extensions are removed on rebuild.

Extensions

ExtensionPurpose
dbaeumer.vscode-eslintESLint integration
esbenp.prettier-vscodePrettier formatter
arcticicestudio.nord-visual-studio-codeNord color theme
vscode-icons-team.vscode-iconsFile icon theme
eamodio.gitlensGit history / blame
github.copilotAI completions
github.copilot-chatAI chat
github.vscode-pull-request-githubGitHub pull requests
thenuprojectcontributors.vscode-nushell-langNushell language support
shd101wyy.markdown-preview-enhanced 0.8.25Enhanced markdown preview

Undeclared Extensions (installed manually)

These are present but not yet declared in nix — they get wiped on rebuild:

  • anthropic.claude-code
  • mermaidchart.vscode-mermaid-chart
  • terrastruct.d2
  • tintinweb.graphviz-interactive-preview

Settings

SettingValue
editor.fontFamilyIosevka Nerd Font Mono, Pretendard
editor.fontSize14
editor.fontLigaturestrue
editor.formatOnSavetrue
editor.minimap.enabledtrue
editor.unicodeHighlight.nonBasicASCIIfalse
workbench.colorThemeNord
workbench.iconThemevscode-icons
update.modenone
extensions.autoUpdateoff
extensions.autoCheckUpdatesfalse
files.autoSaveonFocusChange
diffEditor.hideUnchangedRegions.enabledtrue
scm.defaultViewModetree
terminal.integrated.defaultProfile.osxnu
terminal.integrated.profiles.osx.nu.pathHome Manager profile's nu executable
git.autofetchall, every 60 seconds

Keybinds

BindingAction
Cmd+DSplit focused terminal
Cmd+Shift+DSplit focused terminal

Mise

Tool version manager. Replaces nvm, pyenv, rbenv, etc.

Managed by: nix/home/default.nix via programs.mise (home-manager). Shared pins live in nix/home/mise-global.toml; macOS adds a small platform-specific set.

Settings

SettingValue
experimentaltrue
pipx.uvxtrue (use uv as pipx backend)
Nushell integrationdisabled; shims are on PATH
Zsh integrationenabled

Tools

ToolVersionScope
node24.18.0Shared
python3.14.6Shared
rust1.96.1Shared
uv0.11.29Shared
fzf0.74.0Shared
bat0.26.1Shared
eza0.23.4Shared
ripgrep15.2.0Shared
fd10.4.2Shared
jq1.8.2Shared
duckdb1.5.4Shared
gh2.100.0Shared
delta0.19.2Shared
difftastic0.70.0Shared
claude2.1.288Shared
npm:@earendil-works/pi-coding-agent0.80.10Shared
npm:@getgrit/cli0.1.0-alpha.1743007075Shared
codex0.155.1Shared
npm:agent-browser0.34.0Shared
npm:agent-device0.20.9Shared
npm:ntn0.21.8Shared
npm:slopless0.2.23Shared
pipx:reuse6.2.0Shared
pipx:google-colab-cli0.7.4Shared
ubi:namespacelabs/foundation (nsc)0.0.573Shared
colima0.10.3macOS
lima2.1.4macOS
docker-cli29.6.0macOS
herdr0.7.5macOS

On macOS, a Home Manager launchd agent runs the pin-bump script daily at 10:30; the script's seven-day guard limits successful updates to weekly. Its source is nix/home/configs/mise/bump.py.

Claude Code

AI coding assistant CLI.

Managed by: nix/home/default.nix; the CLI version is pinned through mise in nix/home/mise-global.toml.

Configuration

SourcePurpose
nix/home/configs/.agents/AGENTS.md and nix/home/configs/claude/CLAUDE.mdShared and Claude-specific session rules
nix/home/configs/claude/settings.jsonClaude Code settings, plugins, and hook registration
nix/home/configs/claude/statusline.shThree-line statusline
nix/home/configs/claude/agents/Custom agent definitions
nix/home/configs/claude/hooks/Hook scripts linked by Home Manager
nix/home/configs/claude/mods/Local Claude Code plugins
nix/home/configs/.agents/skills/Skills shared with Codex
nix/home/configs/claude/mcp.json and nix/home/configs/claude/profile-wiring.shMCP server list and profile wiring

Home Manager generates a writable settings file from the repository config during activation. The selected concise-adhd-korean output style is generated from the shared nix/home/configs/.agents/output-manner.md rules.

The shared skill list in nix/home/default.nix links local skills into both Claude Code and Codex. It also fetches pinned skill sources for Anthropic, Notion, and TypeSafe, plus a Supermemory search skill.

Local Plugins

nix/home/configs/claude/settings.json loads three plugins from nix/home/configs/claude/mods/:

PluginRole
clmFolds earlier conversation turns into a ledger, provides the /clm board view, and coordinates co-worker sessions through the clm_coworker tool
codex-subagentStarts a Codex app-server bridge for Codex-backed agent tools
time-budgetGives estimate, checkpoint and overrun feedback; the extend tool records extra time in extensions.jsonl

Mod tests (mods/*/hooks/*.test.ts) import claude-code/testing, a synthetic module the claude binary injects (describe, expect, mock, test, tier), so bun test fails with "Cannot find module" on every mod. Run them with claude plugin test . from the mod folder; it takes a folder and runs every *.test.ts under it. claude test ... is a different thing: the wrapper reads test as a prompt and starts a session. The loader also requires that $ be passed only into a function declared in the same file as the hook, never across an import, so a helper module takes plain values.

clm co-workers

The earlier clm later queue (deferred requests held in session state, the Later queue band and /clm pop) is gone. Deferred work is now a shared board issue that a co-worker session claims.

Roles: the main session is the orchestrator and plans, lands and reassigns issues; co-worker sessions claim ready issues and work them in their own worktrees. The co-worker count is scalable: main sets maxCoworkers through the plan action (default 2, positive integer).

Deferred work: the prompt intent classifier returns deferred items (up to five) for work the user bounds as later ("나중에", "이건 다음에", "todo로"). An item whose quote is not an exact substring of the prompt is dropped. Each remaining item becomes a todo issue on the board, reusing the pending create when the titles match. Co-worker sessions skip this; main sends each live co-worker the new issue ids.

Board issues carry priority, ready and a claimant, and the board shows them on each card. The ready-to-land status has its own board column; only the co-worker tool can set it, because fold ops cannot produce it.

The clm_coworker tool takes an action, and each action is limited to one role except list:

  • claim (co-worker): takes the given or the next ready unclaimed issue of the repository (highest priority, then oldest), starts its worktree, and marks it doing.
  • ready (co-worker): checkpoint-commits the worktree, records its base and head SHAs, sets ready-to-land, returns the co-worker to idle, and sends main <issue> is ready to land (<base>..<head>).
  • release (co-worker): drops the claim and returns the issue to todo; the worktree stays attached to the issue.
  • plan (main): sets priority, ready or maxCoworkers; setting ready triggers dispatch.
  • revoke (main): clears the claim, returns the issue to todo, tells the old claimant to leave the worktree unchanged and, with to (a co-worker session id), tells that co-worker to claim it.
  • land (main): see below.
  • list (either): main's live session, co-workers with live or stale state, and the board with priority, ready and claimant.

Environment: a co-worker session starts with CLM_COWORKER=1 and CLM_MAIN_SESSION=<main session id>; without CLM_COWORKER=1 a session is a main. Each session registers its role and repository on session.start. The launch also passes main's own CLAUDE_CONFIG_DIR, so the two sessions run under the same configuration and can message each other.

On-disk state, all under ~/.claude-work/clm/ except the event log:

  • claims/<issue>/owner.json: an mkdir lock per issue. A claim whose owner has no live registry record is reclaimed.
  • coworkers/<session>.json: registry record (state starting, idle or busy, issue, heartbeat, mux; main also stores maxCoworkers). A heartbeat is written every 15 s and is live for 60 s.
  • worktrees/: one worktree per claimed issue.
  • ~/.claude-work/tracker/events/<session>.jsonl: the event log that the board folds from every session.

Messaging: the hooks send every hand-off themselves with $.session.send({ to: { sessionId }, text }), the same delivery as the model's SendMessage tool, addressed by the recipient's registry session id. A message to main goes to the newest live registry record with role main and the same stable main id, because /clear gives main a new session id. When a send is not delivered, $.ui.log records the reason and the tool result says the recipient could not be notified. The receiving model can answer with SendMessage to the sender of the message. An idle session that receives a message wakes and acts on it.

Land: land runs in main for a ready-to-land issue with a claimant and worktree. It refuses a dirty main checkout. Otherwise it runs git cherry-pick --no-commit <base>..<head>, which stages the commits without committing; main commits them afterwards in the repository's own subject format. On success the worktree is removed, the issue becomes done, and the co-worker is told so. On a conflict the cherry-pick is aborted, the issue returns to doing, and the claimant is told to rebase onto the current main HEAD and call ready again.

Auto-spawn: main polls every 5 s and after plan, revoke and land. When a ready unclaimed issue exists and a live co-worker is idle, main sends that co-worker a claim request, once per co-worker and issue, so the 5 s poll does not repeat it. A new co-worker is launched only when no live co-worker of this main is idle or starting, the live count is below maxCoworkers, and the one-minute load average is under 0.75 times the core count. The launch is a clm-coworker-<n> session started unfocused in the repository root with a brief to claim, work only in the returned worktree, and call ready.

Multiplexer detection reads the environment: HERDR_SOCKET_PATH or HERDR_ENV selects herdr (herdr tab create --no-focus, then herdr agent start), ZELLIJ selects zellij (zellij run --no-focus), TMUX selects tmux (tmux new-window -d). With none, no co-worker starts and the debug log records a manual command. The tmux launch is unverified: tmux is not installed on this machine. Load reading uses /proc/loadavg and nproc when /proc/loadavg is readable (Linux), otherwise sysctl -n vm.loadavg and sysctl -n hw.ncpu (macOS).

Statusline

nix/home/configs/claude/statusline.sh renders three lines from the session data:

  • Line 1: account email, folder, branch, staged and modified file counts, and pull request review state; model, effort, and thinking state appear at the right.
  • Line 2: cost and context percentage, changed line counts, and session duration.
  • Line 3: five-hour and weekly rate-limit usage, with reset times when available and unknown when the usage data is absent.

The script caches Git status briefly per session and right-aligns each row to the detected terminal width.

Alternative considered: Starship native statusline

Starship ships starship statusline claude-code as a drop-in Claude Code statusline. Evaluated 2026-07-03 and not adopted — it is not feature-complete against the script above:

  • Its three modules (claude_model, claude_context, claude_cost) cover only lines 1-2.
  • Rate limits (line 3) exist only in the unmerged, stalled upstream PR starship#7442 (claude_usage module), with no maintainer review as of 2026-06-21.
  • There is no upstream module for effort/thinking, PR number + review state, or staged/modified file counts.

Revisit if #7442 merges and effort/PR modules land upstream.

Hooks

The scripts under nix/home/configs/claude/hooks/ are registered in nix/home/configs/claude/settings.json by event and tool matcher. For example, git-push-guard.py restricts pushes to claude/* branches unless the repository's explicit local bypass is enabled; git-integrity-guard.py separately denies force-push and commit bypass flags.

Codex

OpenAI coding agent CLI, configured through Home Manager and mise.

Managed by: nix/home/configs/codex/config.toml and nix/home/default.nix.

Main Settings

SettingValue
modelgpt-5.6-terra
model reasoninghigh; plan mode uses xhigh
service tierfast
approvalson-request, reviewed by auto-review
sandboxworkspace-write, with network access
web searchlive
file openerVS Code
startup update checkdisabled
default subagentgpt-6-luna, xhigh reasoning

Because web search is live by default, a codex exec run that must stay offline passes -c web_search="disabled" -c tools.web_search=false and shows a probe proving no web tool answered. The codex-subagent mod already writes both settings into its worker config.

The agents section caps depth at 2 and concurrent threads per session at 8. The TUI status line shows the current directory, Git branch, model reasoning, context use, and five-hour and weekly limits.

Agents and Hooks

Home Manager links the code-reviewer, oracle, and prose-editor agent definitions from nix/home/configs/codex/agents/.

Codex runs shared policy scripts through nix/home/configs/codex/hooks/claude-hook-adapter.py. nix/home/configs/codex/config.toml registers them for Bash and apply_patch calls, tool completion, prompt submission, and session stop.

The generated Codex agent instructions combine nix/home/configs/.agents/AGENTS.md, nix/home/configs/codex/AGENTS.md, and the shared nix/home/configs/.agents/output-manner.md. The skills declared in nix/home/default.nix are linked for both Codex and Claude Code.

Home Manager generates the writable Codex config from the repository file during activation and retains local project trust and MCP server entries outside this repository.

Karabiner-Elements

Keyboard remapping.

Managed by: nix/home/darwin/configs/karabiner/karabiner.json (linked by nix/home/darwin/default.nix), installed via Homebrew cask

MacBook Internal Keyboard — Windows-style Layout

Remaps the built-in keyboard so modifier positions match a standard Windows keyboard muscle memory.

Physical KeyRemapped To
FnLeft Command
Left ControlFn
Left CommandLeft Control
Right CommandF18 (한영 toggle)

Dareu Z82 (vendor 9741, product 48)

External keyboard remapping for the Dareu Z82.

Physical KeyRemapped To
Left ControlLeft Command
Left CommandLeft Option
Left OptionLeft Control
Right OptionF18 (한영 toggle)

F18 → 한영

F18 is mapped to the Korean input method toggle (symbolic hotkey ID 60) via the macOS activation script in nix/darwin/default.nix.

Ghostty Shortcuts

In Ghostty, Karabiner routes Cmd+D, Cmd+Shift+D, Cmd+T, and Cmd+W to herdr-key. The helper sends the action to Herdr when its window is active and otherwise redispatches the split or tab action to Ghostty.

LinearMouse

Mouse and trackpad customization.

Managed by: nix/home/darwin/configs/linearmouse/linearmouse.json, linked by nix/home/darwin/default.nix; the app comes from the Homebrew cask

Ghostty: Middle-Click

AppButtonAction
com.mitchellh.ghosttyMiddle click (2)none (disabled)

Prevents accidental paste-on-middle-click in the terminal.

All Mice: Gestures

Middle-click drag gestures (threshold: 50px):

DirectionAction
UpMission Control
DownApp Exposé
LeftMove to right Space
RightMove to left Space

All Mice: Other

SettingValue
universalBackForwardtrue
vertical scroll reversetrue (natural scrolling disabled)

Raycast

Launcher. Replaces Spotlight.

Managed by: Homebrew cask

Spotlight's Cmd+Space shortcut (symbolic hotkey ID 64) is disabled via the activation script in nix/darwin/default.nix.

Firefox

Firefox Developer Edition browser.

Managed by: the firefox@developer-edition Homebrew cask in nix/darwin/default.nix installs the app; nix/home/darwin/programs/firefox.nix declares the default profile and add-ons.

What firefox.nix does

Home Manager's programs.firefox module manages the profile, not the app: package = null leaves installation to the Homebrew cask.

The activation script unregisters stale nix-store Firefox copies and asks the Homebrew app to become the default HTTPS handler with Firefox's -setDefaultBrowser option.

The profile gets Bitwarden, uBlock Origin, Dark Reader, and Tampermonkey from NUR. It also pins react-devtools, kagi-search, maxfocus, simple-translate, and multi-account-containers to immutable AMO file URLs and hashes in nix/home/darwin/programs/firefox.nix.

Why Firefox Developer Edition

We use Firefox Developer Edition. Here is why.

  • We reject Chromium-based browsers to help against browser-engine monopoly.
    Rejected: Google Chrome, Microsoft Edge, Ungoogled Chromium, Arc from The Browser Company, Dia from The Browser Company, Opera, Vivaldi, Naver Whale
  • Browser extensions must be free to use. In particular, uBlock Origin must be usable.
  • We reject Safari.
    Rejected: Apple Safari
  • We don't obsess over anonymity beyond what's needed.
    Rejected: Tor Browser
  • We need confidence that the browser will keep being maintained.
    Rejected: LibreWolf
  • DRM playback must work, for services like Laftel and Netflix.
    Rejected: Zen Browser, Pale Moon
  • The browser must embrace diversity.
    Rejected: Brave [2014 Apr]

Alternatives considered

Opera Neon, Zen, Arc, Dia: interesting new-style browsers, but judged too risky to use as a daily driver.

Other platforms

Windows installs the Mozilla.Firefox.DeveloperEdition winget package declared in xpkg/windows/default.toml. WSL skips Firefox entirely.

Chrome

Google Chrome browser.

Managed by: Homebrew cask

Enterprise Policy: Bitwarden Extension

Bitwarden is force-installed via managed Chrome policy so it's always present regardless of profile sync state.

Policy file: /Library/Google/Chrome/policies/managed/extensions.json

{
  "ExtensionInstallForcelist": [
    "fcoeoabgfenejglbffodgkkbkcdhcgfn;https://clients2.google.com/service/update2/crx"
  ]
}

Written by the darwin activation script in nix/darwin/default.nix.

Bitwarden

Password manager.

Managed by: Homebrew cask

Also force-installed in Chrome via enterprise policy — see Chrome.

Discord

Messaging.

Managed by: Homebrew cask

Slack

Messaging.

Managed by: Homebrew cask

Notion

Notes and documents, with the ntn CLI and its matching agent skill.

Managed by: Homebrew cask

The CLI version is pinned as npm:ntn in nix/home/mise-global.toml. nix/home/default.nix also links the pinned Notion notion-cli skill into both agent skill trees. On macOS, nix/home/darwin/default.nix provides a notion shim that directs callers to the official ntn command.

Figma

Design tool.

Managed by: Homebrew cask

Keybase

Encrypted messaging and file storage.

Managed by: Homebrew cask

Espanso

Cross-platform text expander written in Rust.

Managed by: home-manager service (services.espanso)

Runs as a background launchd agent (org.nix-community.home.espanso). Configured to start with espanso daemon directly to prevent espanso from self-registering a second launchd plist.

Packages

PackageSourceNotes
typsipkgs.fetchFromGitHub pinned to a commitTypst-y symbol/emoji expansions via \subset.eq\, :arm.mech:, etc.
nannosymSame typsi checkoutNanno's Symbols

Packages are placed at ~/.config/espanso/match/packages/ via xdg.configFile — no espanso install needed.

Syncthing

Continuous file sync across devices.

Managed by: home-manager service (services.syncthing.enable = true)

Runs as a background launchd service. Not a Homebrew cask — the daemon is managed declaratively by home-manager.

Android SDK

Android development environment.

Managed by: Homebrew casks in nix/darwin/default.nix and SDK activation in nix/home/darwin/default.nix

Components

ComponentSource
android-commandlinetoolsHomebrew cask
temurin (JDK)Homebrew cask
SDK packages, AVDinstalled by home-manager activation through Android CLI (android, launched by the commandlinetools cask)

Environment

VariableValue
ANDROID_HOME~/Library/Android/sdk
JAVA_HOMEresolved via /usr/libexec/java_home

Android SDK paths are also prepended to PATH in nix/home/configs/nushell/env.darwin.nu — see Nushell.

SDK Packages (auto-installed on activation)

  • platform-tools
  • platforms/android-35
  • build-tools/35.0.0
  • emulator
  • system-images/android-36/google_apis_playstore/arm64-v8a

Emulator

Activation creates the medium_phone AVD once with android emulator create medium_phone, which uses the Google Play system image above. Start it with android emulator start medium_phone.

The commandlinetools cask stays, because its zap trashes ~/.android, which holds every AVD and Android CLI itself. The standalone android-cli cask would also collide with it on the android binary.

Xcodes

Xcode version manager CLI.

Managed by: nix/home/darwin/packages/xcodes.nix (Home Manager package from the GitHub release binary, version 1.6.2)

Why Not Homebrew?

The Homebrew formula for xcodes builds from source using xcbuild, which requires Xcode to already be installed — a chicken-and-egg problem. This package uses the prebuilt binary from GitHub releases instead.

The package fetches the aarch64 macOS release archive from GitHub and installs its xcodes executable.

Xcode Selection

The activation script in nix/darwin/default.nix runs xcode-select -s for the latest installed Xcode*.app when one is present.

GnuPG

GPG toolchain for commit signing.

Managed by: nix/home/default.nix, nix/home/darwin/default.nix, and nix/home/linux/default.nix

Packages

PackagePurpose
gnupgCore GPG toolchain
pinentry_macPassphrase dialog on macOS
pinentry-ttyTTY pinentry on macOS
pinentry-cursesPinentry on Linux

gpg-agent.conf

nix/home/configs/gnupg/gpg-agent.conf is linked by Home Manager. The gpg-agent restarts automatically (gpgconf --kill gpg-agent) when the file changes.

The signing key used for git commits is BB9C29B5FA1C8305 — see Git.

jira-cli

Fine-grained editing of a Jira card's body. Select a node inside the card's Atlassian Document Format (ADF) tree with a CSS selector, stage the edit, then flush a batch of edits as a single write.

Managed by: nix/home/programs/jira-cli.nix — a writeShellScriptBin wrapper that runs nix/home/configs/jira-cli/jira.py through uv run --script. uv comes from mise, not from nixpkgs, so the wrapper resolves it off the caller's PATH and fails with a named instruction when it is missing.

jira.py is a PEP 723 entry point that dispatches into the jira_cli/ package beside it, one module per domain (the table below names them). Its dependencies (lxml, cssselect, jsonschema) are declared in the script header and resolved by uv at run time, so nothing is compiled during a rebuild.

Why it exists

The web editor is the only practical way to change one table cell, one heading, or one list item, and it makes those surgical changes slow and error-prone. This CLI turns them into one command.

Two constraints shaped the design: no OAuth app registration, and no credential to manage or rotate. Both hold. Auth is Dynamic Client Registration plus PKCE against the Atlassian MCP endpoint, so the first run registers its own public client and the refresh token renews access silently from then on.

Layers

LayerModuleWhat it does
Authjira_cli/auth.pyDCR on first login, PKCE authorization code, refresh on 401 or near expiry. ~/.config/jira-cli/credentials.json, mode 0600.
Transportjira_cli/mcp.pyMCP JSON-RPC over HTTPS with urllib, session id, SSE-or-JSON unwrapping.
ADFjira_cli/adf.pyProjects the ADF onto an lxml tree — one element per node, tag = the node's type, attrs as XML attributes, and a ptr attribute holding the RFC 6901 pointer. cssselect translates the selector to XPath. Also holds the JSON-pointer ops, the schema validation, the rendered plain-text view, and show, types, media ls, schema update.
Queuejira_cli/edit.py~/.config/jira-cli/queue.json. Each entry holds the issue key, the selector, the operation, the new content, the resolved pointers, and a deep snapshot of every matched node.
Applyjira_cli/edit.pyDry-runs every queued card, then writes. editJiraIssue with contentFormat: "adf", one call per card.
Cardsjira_cli/cards.pysearch, info, comments — reads that need no ADF tree, plus the one-line field flattening the listings share.
Workflowjira_cli/flow.py~/.config/jira-cli/workflow.json, one entry per project:issuetype, holding the sampled status graph.
Self-checkjira_cli/selfcheck.pyThe offline assertions behind jira selfcheck, run against fixture.adf.json.

Commands

jira login [--force]                                   # authorize this machine
jira edit queue -i KEY 'selector' < new.json           # replace the matched node
jira edit queue -i KEY 'selector' --before  < new.json
jira edit queue -i KEY 'selector' --after   < new.json
jira edit queue -i KEY 'selector' --append  < new.json
jira edit queue -i KEY 'selector' --delete
jira edit queue -i KEY 'selector' --text '새 제목'
jira edit queue -i KEY 'selector' --all                # allow a multi-node match
jira edit queue -i KEY --jq 'select(.type=="media")'   # escape hatch
jira edit status
jira edit drop N | --all
jira edit apply [--token=7f3a9c2e]
jira show  -i KEY [--pointer /content/3]               # default: the selector's XML view
jira show  -i KEY --json                               # raw ADF, the shape stdin wants
jira show  -i KEY --rendered                           # lossy plain text, cheapest to read
jira search 'project = PROJ AND status != Done'        # JQL, one row per card
jira info  -i KEY                                      # status, assignee, parent, labels
jira flow  -i KEY [--refresh]                          # every reachable status, and the route to each
jira move  -i KEY 'Dev Done'                           # walk that route for real, hop by hop
jira types [NAME]                                      # ADF vocabulary, from the schema
jira media ls -i KEY
jira schema update
jira selfcheck

jira --help carries the whole mental model — the staging workflow, a selector cheat sheet, what reads stdin, and the safety rules — so an agent can drive the tool cold without a skill. jira edit queue --help and jira edit apply --help add the per-command detail.

Three views of a body

jira show prints the same card three ways, and the default is the XML projection the selector layer already builds. Measured on nix/home/configs/jira-cli/fixture.adf.json:

ViewSizeWhat it is for
--xml (default)2527 chars, 39% smaller than the JSONThe selector surface: tag = node type, attrs as attributes, mark space-separated, ptr the JSON pointer. Read this to write a selector.
--json4101 charsRaw ADF. The only write format — copy a node here, edit it, feed it to jira edit queue.
--rendered134 charsMarkdown-ish plain text. The cheapest read, and never a write path.

The default changed to --xml because reading is the common case and the JSON view spends most of its bytes on punctuation. --xml is accepted explicitly so a script can state the view it wants.

--rendered walks the ADF rather than the lxml projection, so a link keeps its href and a media node keeps its id, where --xml shows only the bare mark name. It still drops every pointer, so nothing read this way can be edited and pushed back.

One consequence of making the projection a printed format: an attribute now renders as ADF's own literal instead of Python's repr. A boolean reads false, never False, which also means table[isNumberColumnEnabled="false"] matches — the selfcheck asserts exactly that.

Reading a card's fields

jira search 'JQL' and jira info -i KEY cover what the body-editing commands cannot see. Both are read-only.

search prints one row per card with the four fields that place it — status, assignee, parent, summary — because a wider default makes every row wrap. --fields status,priority,labels widens it, -n sets the page size (1-100, the MCP search caps a page at 100), and --json prints the raw response. A Jira timestamp is trimmed to the minute in both commands, since the milliseconds and the offset widen every row and no one reads them.

jira types answers the one question the help text cannot hold: the ADF vocabulary. With no argument it prints the 43 node types and the 17 mark types apart from each other, because a mark is never an element name and reaches a selector only through text[mark~="…"]. With a name it prints that type's required fields and its attrs schema. Both are derived from the schema file, so neither goes stale when jira schema update runs.

Each queue flag maps to exactly one RFC 6902 operation: replace, add at index, add at index+1, add at /-, remove. move and copy are excluded on purpose, because both mutate two locations and that breaks the one-selector-one-snapshot guard.

jira media ls exists because the MCP surface has no attachment tool, so file upload is impossible here. Upload through the web UI, then this command lists the media nodes already on the card with their ids so the CLI can position and reference them. External images work through a media node with type: "external" and a url.

Safety rules

A multi-match aborts. A selector matching two nodes is an error unless --all is passed. Taking the first match silently edits the wrong paragraph.

Pre-flight, then write. apply re-fetches every queued card and dry-runs the whole batch before writing anything. Jira has no transaction, so a half-applied batch cannot be rolled back, and a clean zero-write abort is the only safe failure.

Drift needs a content-hash token. When a re-resolved node no longer deep-equals its snapshot, apply prints both values and refuses:

$ jira edit apply
✗ PLAYGROUND-157  /content/3  큐에 담을 때와 내용이 다릅니다

    큐 스냅샷 : "설계 포인트 — 상태 모델"
    현재 카드 : "설계 포인트 — 상태 모델 (v2)"

  그래도 밀어붙이려면:
      jira edit apply --token=7f3a9c2e

The token is the first 8 hex of a SHA-256 over the drifted state, not a random string. A random token would need storage, expiry, and single-use bookkeeping, and it would still admit the worst case: a token issued at 10:00 and pasted at 10:30 silently authorizing a third change that arrived in between. A content hash cannot do that, because a further change yields a different hash and the stale token stops matching. One token covers the whole apply.

Selectors re-resolve at apply time. Within one batch a --delete shifts every later sibling's index, so a frozen pointer would patch the wrong node with no error. The pointer stored in the queue is diagnostic only, which is what lets jira edit status print without a network call.

Local schema validation before every write. The vendored draft-04 ADF schema validates the finished document. The whole-document schema is one giant anyOf, so its top-level error blames the innocent parent and dumps the parent's entire JSON. Validation instead walks bottom-up and checks each node against only the definitions that declare that node's own type, so the report names the node that is actually wrong:

✗ PLAYGROUND-157 would become invalid ADF, nothing written:
    /content/0: Additional properties are not allowed ('content' was unexpected)

Status transitions

Jira's transitions endpoint answers for a card's current status only, so a card can never show a chain longer than one hop — To Do → In Progress → Dev Done is invisible from a card sitting in To Do. jira flow samples the graph instead: for every status it does not yet know, one JQL query finds a card of the same project and issue type sitting in that status, and that card's transitions become the edges leaving it. Every call is a read, and the graph is cached per project:issuetype in ~/.config/jira-cli/workflow.json. --refresh re-samples.

jira flow -i <KEY> prints the card's current status, then one row per reachable status, cheapest route first. A row is a route and its transition ids: --<id>--> is a single transition, ==<id>==<id>==> is a chain in the order move will fire them, and ~~???~~> is a status Jira can reach that this graph has no route to. The card's own status is never a row, even where the workflow defines a self-loop back to it, because listing it would say nothing.

Coverage is every status the sample reached, not every status the project defines — Jira's MCP surface has no call that enumerates a workflow's statuses. Rows come from two places: a status any sampled transition points at, and a status already in the cache from an earlier card. A status that no sampled transition points at, and that no inspected card has ever sat in, is invisible; there is no ~~???~~> row for it, because its name was never learned. A status holding no card at all cannot be sampled, so it can only ever be seen as somebody's destination, and anything behind it prints as ~~???~~>. The closing line says the same thing from the other side: the graph is a sample, not the workflow definition. A sample is also one specific card, so a transition open to it may be closed to yours.

jira move walks the route for real. The sampled graph decides where to go; the card's own transition list is re-read before every hop and decides whether this card may go there. A refused hop stops the walk, names how far it got, and leaves the earlier hops applied — Jira has no transaction over a status change, so there is nothing to roll back to. After the last hop the card's status is re-read and compared with the target, which is what catches a workflow post function moving it somewhere else.

Schema

nix/home/configs/jira-cli/adf-schema-56.7.3.json is vendored verbatim from npm @atlaskit/adf-schema@56.7.3 (dist/json-schema/v1/full.json), Apache-2.0, Atlassian Pty Ltd. jira schema update downloads the current upstream schema to ~/.config/jira-cli/adf-schema.json, which takes precedence over the vendored copy when it exists. Delete that file to fall back to the pinned version.

Known limits

Markdown is a lossy view of a card and is never used as a write path — reading a card as markdown degrades an attached image to a blob: URL on a staging host. The MCP access token buys nothing on the REST gateway, so there is no REST fallback for anything the MCP surface omits. It is not a flat rejection, which is what makes it easy to misread — measured 2026-08-20 against api.atlassian.com/ex/jira/<cloudId>/rest/api/3/*:

EndpointResult
issue/{key}404 이슈가 존재하지 않거나 이를 볼 수 있는 권한이 없습니다 — while the same token reads that same card fine through getJiraIssue
project/search200, total: 0
status200, []
project/{key}/statuses404 project not found
myself, statuscategory401

The token authenticates at the gateway but carries no user identity there, so every project and issue reads as invisible rather than forbidden. The MCP endpoint applies the identity server-side; REST does not. One consequence shapes jira flow: project/{key}/statuses is the only call that enumerates a workflow's status set, and it is unreachable, so sampling real cards is the ceiling. File upload, bulk edit across cards from one selector, sprint and board operations, and Confluence are all out of scope. --rendered is a read path only: it has no pointers, so no edit can be expressed against it.

Agent Incidents

Long-form background for the rules in AGENTS.md at the repository root. Each entry records a failure that produced a rule, or the detail behind a rule too long to keep in a file that loads into every agent session. Read one after a rule surprises you, or before changing the rule itself.

2026-08-31 -- an agent installed packages imperatively instead of editing declarations

Every tool, app and version on this host is declared in exactly one file, so an install performed on the machine is drift that the next rebuild silently reverts or preserves at random.

Asked to find upgradeable software, an agent ran npm i -g corepack npm pi-subagents and brew upgrade. That left pi-subagents at a version no file declared, and it moved npm to 12.0.2 under a mise-managed node = "24.18.0" that reverts it on the next mise install. Meanwhile the flake inputs the agent never looked at were six weeks stale -- and those were the actual upgrade.

The answer to "upgrade X" is to edit the declaration and hand the rebuild over. The three declaration surfaces are also the three places an "what is upgradeable?" report comes from: mise outdated for the [tools] table of nix/home/mise-global.toml, brew outdated --greedy for homebrew.casks / homebrew.brews in nix/darwin/default.nix, and the lastModified of each nix flake metadata input for everything nixpkgs ships. A cask carries no version in the declaration -- nix-homebrew always installs the latest, so a cask upgrade is a brew upgrade the rebuild performs, not a version edit.

declarative-package-guard.py now denies the imperative form -- npm i -g, pipx install, cargo install, brew install|upgrade, mise use -g, nix profile install -- and names the file to edit instead.

2026-10-07 -- uncommitted fixes were stashed by another session, which fixed the same bug again

Several Claude sessions work in this one ~/.dotfiles checkout at once, all on main. A session finished fixes to the codex-subagent, clm and time-budget plugins, ran their tests and the nix build, and left them uncommitted while it waited for a "commit" request. Another session needed a clean tree, stashed those changes, and committed its own fix for the same codex 404 bug as b86b66b (claude: keep codex worker steps from falling through to Claude). Unstashing then conflicted in codex-subagent/hooks/register.ts and its test, and the work had to be reconciled by hand.

The user's verdict: "니가 커밋 안해서 그렇잖아..." and then "checkpoint마다 main에 커밋하고 푸시 전에 rebase를 해야하지". So a finished unit goes onto local main as a commit the moment its tests and build pass, with no permission asked. A commit is visible to every other session through git log, and a commit cannot be stashed out from under its author. The push still waits for an explicit request, and it always runs git fetch and git rebase origin/main first, because another session may have pushed in between. The ship skill carries the full sequence.

2026-08-31 -- a clean dry-run was handed over and the real rebuild died on simple-translate

nix build ... --dry-run never downloads anything. It prints the plan and stops. That makes it the right tool for auditing the "will be built" list for a source compile, and completely blind to a fixed-output hash mismatch, an eval error, or a failing builder.

A dry-run was reported clean and the rebuild was handed to the user. It died on simple-translate, because nix/home/darwin/programs/firefox.nix pinned a fixed hash to the moving addons.mozilla.org/firefox/downloads/latest/<slug>/latest.xpi alias, and AMO had published 3.1.0 over the pinned 3.0.1.

Two consequences. First, a hand-pinned AMO addon must name an immutable /downloads/file/<id>/<name>-<ver>.xpi URL rather than the latest.xpi alias. Second, the check that catches this class of failure is the real build: cd ~/.dotfiles/nix && nix build .#darwinConfigurations.ranolp-work-MBP-26.system --no-link. It needs no sudo and produces the exact derivation darwin-rebuild switch will activate, so a green run is evidence the handed-over rebuild will work.

espanso -- why it is fetched as a notarized release rather than built or installed as a cask

espanso is the worked example behind the GOLDEN RULE that no package may be built from source.

In nixpkgs it is source-only on darwin, so taking the nixpkgs package means compiling it. Its Homebrew cask is not an escape either: the cask's nested-dmg unpack is broken under nix-homebrew.

So nix/home/darwin/default.nix fetches the official upstream release, then mounts it and dittos the notarized app into ~/Applications at activation. That is a download, never a compile. The ditto step matters for a second reason: unlike a 7zz unpack, it preserves the code signature. A re-packed bundle triggers a "Espanso is damaged" error at launch, and codesign cannot re-seal the bundle inside the nix sandbox.

The broken seal also explains why espanso once lost Accessibility on every release bump (2026-08-11). macOS records a TCC grant made to an app whose seal does not validate as a bare cdhash requirement, which names one build; System Settings still showed the toggle on while the new build ran unpermitted. sudo tccutil reset Accessibility com.federicoterzi.espanso, then restarting the launchd job so the validly signed app asked again, produced identifier "com.federicoterzi.espanso" and anchor apple generic and certificate leaf[subject.OU] = "6424323YUH", which every later release from the same signer satisfies. Check the stored requirement with sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" "select quote(csreq) from access where client='com.federicoterzi.espanso';" and decode it with csreq -r <blob> -t.

2026-08-27 -- file-edit-guard.py was registered without being deployed and stopped every Bash call

A PreToolUse hook on the Bash matcher runs in front of every Bash call in every session on this machine, so a bad one is a total work stoppage that only a rebuild can lift.

file-edit-guard.py was registered in nix/home/configs/claude/settings.json while both its home.file entry in nix/home/default.nix and its git add were missing. Python exited 2 on the absent file, PreToolUse read exit 2 as a block, and every Bash call in every session died with can't open file '/Users/ranolp/.claude/hooks/file-edit-guard.py'. It stayed broken until the entry was added and the user rebuilt.

Two structural facts caused it. Nix deploys the hooks directory as one explicit entry per script, never by directory recursion, so a new script that is not listed is not deployed. And a flake reads the git tree, so a file that was never git added is invisible to the rebuild even when it exists on disk.

The exit-code contract, and what verify-claude-hook.sh actually tests

Measured on python 3.14.6 (2026-08-28), a hook's exit status decides what Claude Code does with the call it guards:

  • exit 1 -- an uncaught exception. Claude Code treats this as a non-blocking error, so the call proceeds unguarded.
  • exit 2 -- what a missing script file produces. PreToolUse reads exit 2 as a block.

That second case is the work stoppage described in the 2026-08-27 incident above.

./scripts/verify-claude-hook.sh nix/home/configs/claude/hooks/<name>.py runs the hook against a clean python:<host-version>-slim container -- --network none, --read-only, the repo mounted read-only, no ~/.claude, no site-packages -- over a battery of real stdin shapes: empty stdin, non-JSON, a JSON array, a null / numeric / list command, an unterminated heredoc, and a 1MB command.

It grades each result:

  • STOP -- the hook blocks or hangs every matched call. This is the total work stoppage.
  • OPEN -- the guard crashed, so the call proceeds unguarded.
  • SLOW -- the hook is slow enough to notice.

A hook that scores any STOP never gets registered.

2026-09-22 -- the user rewrote an agent's PR body and cut it to a seventh

Reported from another session working on a CI build-cache fix in a work repository. The user edited the agent's body by hand, showed both versions side by side, and told the agent to send the case here: "내가 고치는 걸 보고 잘 배워서 dotfiles한테 사례집 보내라". The agent's body ran about 2,900 bytes; the user's covered the same scope in about 400, roughly a seventh.

That repo's .github/PULL_REQUEST_TEMPLATE.md defines exactly four top-level headers: ## 개요, ## 작업 내역, ## 관련 카드, ## 변경 체크리스트.

The agent's version opened ## 개요 with five bullets (a measurement, two refuted hypotheses, the scope of application), added a top-level ## 실측 결과 section holding a four-row table plus three paragraphs explaining which number came from what, and wrote ## 작업 내역 as a numbered list of <short sha> <commit subject> items, each carrying two or three 리뷰 포인트: sub-bullets.

The user's version kept the same scope in this shape, with the measurements standing in for the real ones:

- CI에서 빌드 캐시가 적용되지 않아 활성화한다.

## 작업 내역

- 캐시 경로만이 문제였으므로 경로를 수정하고 캐시 히트를 확인
- 원격 빌드 캐시를 추가로 사용한다
- 캐시 오염을 막기 위해 버킷을 저장소별로 분리한다

### 실측 결과

- 빌드 시간 : <전> -> <후> (-N%p)
- 캐시 적중 태스크 : <전> -> <후> (-N%p)

증거: 1회차 (링크), 2회차 (링크)

Five rules come out of that edit.

## 개요 is one line. It says what was wrong and what was done, nothing else. Four of the agent's five 개요 bullets did not belong there. A refuted hypothesis stays out of the body entirely until a reviewer asks for it.

No ## section beyond the ones the repo template names. The agent created a top-level ## 실측 결과; the user demoted it to ### 실측 결과 under ## 작업 내역, saying "실측 결과 <- 이런 섹션은 표준이 아님". Anything extra goes in as a sub-header of an existing section.

작업 내역 is a list of what was done, not a list of commits. The user deleted every short sha and every commit subject and cut each item to one line. The reviewer already sees the commit list on the PR screen, so copying it into the body buys nothing. nix/home/configs/.agents/skills/github-master/guides/pr.md had asked for one numbered item per commit opening with the short sha and the commit subject; on the same day the user replaced that with one short line per group of commits that does one thing, and set the bar for a 리뷰 포인트: sub-bullet at "without it a Haiku-level reader could not understand the change".

A number is one A -> B (-N%p) line, not a table. The agent built a four-row table and the user kept the two load-bearing rows as plain lines. A table earns its place only at three or more columns with a different kind of thing per row.

Evidence is a 증거: <링크> line, not commentary. The agent spent three paragraphs attributing each number to a cause; the user replaced them with the two run links. Whatever the reviewer can confirm by opening the link does not get explained in the body.

One notation to carry verbatim: the user writes a reduction as -N%p, using the percentage-point symbol for a ratio change. Write it their way.

2026-10-15 -- an unasked native build froze the user's Mac (heavy-local-build-unasked)

A self-review brief for a work ticket said "if the existing APK cannot run the bundle, build it yourself". The worker took that as permission and started assembleDebug without asking. At the same moment another session's emulator, Metro and jest were running on the same machine; load average climbed to 25, and the Gradle and Kotlin daemons alone held about 180% CPU. The user's words: "이딴 짓거리를 제발 함부로 저지르지 마라 나도 컴퓨터를 써야 할 거 아님;;" -- they need the machine for their own work.

The Mac is the user's working computer, and several Claude sessions share it, so heavy jobs from different sessions stack on top of the user's own work. The (load) clauses of "Size the unit first" in nix/home/configs/claude/CLAUDE.md now make every heavy local job (a native build such as gradlew or xcodebuild, an extra emulator or simulator boot, Metro --reset-cache) an explicit question with its cost in one line. A worker brief says "stop and report if a build is needed" rather than "build if needed". An approved job runs under renice -n 15, and the emulators, Metro instances and Gradle daemons (./gradlew --stop) the session started are stopped when the check is done.

2026-07-30 -- a probe build replaced a shared design doc with four placeholder boxes

To diagnose a rendering problem, an agent published a stripped build of the user's design doc onto the doc's own live artifact URL. The good version was replaced by a page carrying four dashed "다이어그램 N -- 이 버전에서는 제외" placeholders, and the user was the one who noticed ("isn't it regression"). The version history made it recoverable, but the shared link stayed degraded in the meantime. The same session also inverted the user's version numbers ("v3 sharable, latest isn't") and stripped work that was already correct. A throwaway artifact answers the same diagnostic question at no cost to the user's link, so every probe goes to a fresh artifact, the live target only ever receives a complete state, and the user's version numbers are read back before acting on them. Rule: "DO (live target)" in nix/home/configs/claude/CLAUDE.md.

2026-08-18 -- an invented backend API shipped through a patched shared package

Wiring a feature whose backend contract did not exist yet, an agent created PUT/DELETE reservation endpoints and a new notification type out of nothing, and attached them to the shared API-endpoints package through a pnpm patch. A grep of the server repository for the feature's names returned zero hits: path, method, status codes and notification string were all invented, modelled on a neighbouring endpoint's shape and labelled a "temporary patch". The user's first reaction was "대체 왜 환각으로 api를 추가했나", and the next day everything was reverted. Three things failed: a goal word in the PR title ("실배선") outranked the missing contract, a cloned neighbouring shape was treated as a contract, and the "temporary" label was treated as an excuse. A missing contract is a stop condition: leave a labelled dummy and write what is missing in the code comment and the PR body.

2026-09-03 -- a review card was nearly dropped as "no iOS device" while a booted iPad was attached

Checking an iOS-only safe-area defect, an agent wrote "iOS 기기 필요 -- 판정 포기 유력" into the hand-off and reported the card as blocked. The user asked whether their iPad could be driven with agent-device; one agent-device devices --platform ios showed it booted=true, with the UI test runner signed and installed and the app already on it. The agent then proposed a simulator twice more. The user: "진짜 ㅅㅂ 다 해줬는데 안 쓰는 거 개선해야 한다". The existing "name you do not recognize" rule already covered "about to conclude that some capability is unavailable", but its only prescribed check was which, a PATH-level query; nothing told the agent to list what is attached behind a tool it already had. Hence "DO (inventory)": run the listing query and cite its empty output before declaring a device, account, certificate or build absent.

2026-09-22 -- a CLI's JSON output printed a live bearer token into the transcript

An agent ran an unfamiliar nsc cache gradle setup -o json without reading its --help first, and the command printed a live bearer token (nsct_...) straight into the conversation. Reading the help would have shown that the command writes credentials to stdout. The user: "앞으로 실행하기 전에 제발 도구 설명좀 읽자". Hence the help-first rule for an unfamiliar CLI, and "DO (secret)": send output that may carry a credential to a scratchpad file and extract only the needed field with jq.

2026-09-22 -- "send this to the dotfiles session" became a worker editing the rules files

The user told an agent to send an example of its failure to the dotfiles session. No session by that name was listed, so instead of stopping, the agent switched to the dotfiles:evolve and rule-write route and spawned a worker that would write the ~/.dotfiles rules files directly. The instruction was a message, not rule authoring, and that repository deploys to every session. The worker was stopped before any file changed. The user: "ㅅㅂ dotfiles를 직접 작성하는 월권은 대체 왜 한 건데". When a named target is not there, show what was found and end the turn on "which one?".

2026-09-30 -- a label-filtered volume delete removed two volumes nobody listed

To clean up a finished agent worktree stack, an agent handed the user docker volume rm $(docker volume ls -q --filter label=com.docker.compose.project=$P). Compose gives a volume with a fixed name: the project label of whichever stack first created it, so the filter also matched two fixed-name local database and secrets volumes from another project, and they were deleted (local development data only). An earlier listing command had been refused by the permission classifier, and the agent handed over the delete without any listing. A filtered delete is now preceded by the same filter's listing, and the delete names those targets as literals; with no listing, no delete is handed over.

2026-10-01 -- a "flatten" redesign was built for a span real data never contained

A trace-viewer prototype fixture put an invented span under every request root. The user asked to flatten that span into the request row, and the work was done. Afterwards it turned out the real telemetry has no such span at all. The user: "flatten할 게 없었네 애초에 니가 잘못된 걸 만든 게 문제였네". A fixture now holds only the span names and fields the real system's tests or source list, labels every stand-in, and a design question about fixture data first checks that the data exists.

2026-10-06 -- a "x" prompt saving was measured against the agent's own detour

Optimising a per-item LLM extraction prompt, an agent's early variants sent one item per call, re-sending the whole instruction block per item. Moving to larger batches then produced a "x" saving, but production had been batching items all along; against the production baseline the real gain was a small fraction of that. The user: "아 당연히 벌크로 했어야지 멍청한 것". An experiment now starts from the production shape (batch size, model, effort), and every gain is reported against that production baseline.

2026-10-19 -- a release-order answer listed bare PR numbers, twice in a row

Asked for the order of a multi-repository release, an agent answered with chains such as "# -> #" and "#, #, #". The user asked "wtf is ", and the next answer again gave two bare "PR #" references with no titles. The user: "내가 반복적으로 ID만 띡 던지지 말랬지 피드백을 해도 말을 안 들으니". The "Name every referent" rule already existed, which is evidence the prose rule alone does not hold. A later mention now uses a meaning-name ("the release PR"), and a draft is scanned for #\d+, 7+ hex characters and [A-Z]+-\d+ before sending. A Stop hook that forces a rewrite was rejected as the guard (""다시 쓰게" 하면 경험이 매우 별로였음").

2026-11-05 -- a deploy-split answer read the remote narrowly and contradicted its own output

Told "PR 개많아서 나눠서 배포하려는 중" (many PRs, deploying them in batches), an agent working across two repositories from a non-repo cwd ran gh pr list --author @me --state open and a merged list capped at 8. Its answer said a range of PRs had merged while its own output showed three of them open, missed a bot-authored release PR merged a minute earlier, and passed over mergeStateStatus: UNKNOWN on a PR that a later gh pr view showed CONFLICTING. The user: "ㅅㅂ 리모트나 읽어 똥멍청이야". The old (state) rule only said to read "the target's live state", which the agent did, so the gap was scope and resolution, not whether to read. The rule now scopes the read to the question (every author and bot, open and just merged, every repo the work spans), resolves UNKNOWN/pending fields by re-query, and ties each stated PR state to that output. A UserPromptSubmit hook that injected a PR snapshot was built and reverted: the user judged a case-specific hook worth little next to a correct instruction ("case-work적인 훅 말고 지시를 제대로 고쳐").