{ config, lib, pkgs, globalInputs, system, ... }: with lib; let cfg = config.home.roles.development; # FIXME: Temporary override for upstream beads vendorHash mismatch # Remove after upstream fix: https://github.com/steveyegge/beads/issues/XXX beadsPackage = globalInputs.beads.packages.${system}.default.overrideAttrs (old: { vendorHash = "sha256-YU+bRLVlWtHzJ1QPzcKJ70f+ynp8lMoIeFlm+29BNPE="; # Performance fix: avoid WHERE IN (8000+ IDs) query pattern that hammers Dolt CPU # See: hq-ihwsj - bd list uses inefficient WHERE IN (all_ids) query pattern # The fix changes SearchIssues to SELECT all columns directly instead of: # 1. SELECT id FROM issues WHERE ... -> collect IDs # 2. SELECT * FROM issues WHERE id IN (all_ids) -> 8000+ placeholder IN clause patches = (old.patches or []) ++ [ ./beads-search-query-optimization.patch ]; }); # Gastown - multi-agent workspace manager (no upstream flake.nix yet) # Source is tracked via flake input for renovate updates gastownRev = builtins.substring 0 8 (globalInputs.gastown.rev or "unknown"); gastownPackage = pkgs.buildGoModule { pname = "gastown"; version = "unstable-${gastownRev}"; src = globalInputs.gastown; vendorHash = "sha256-ripY9vrYgVW8bngAyMLh0LkU/Xx1UUaLgmAA7/EmWQU="; subPackages = [ "cmd/gt" ]; doCheck = false; # Must match ldflags from gastown Makefile - BuiltProperly=1 is required # or gt will error with "This binary was built with 'go build' directly" ldflags = [ "-X github.com/steveyegge/gastown/internal/cmd.Version=${gastownRev}" "-X github.com/steveyegge/gastown/internal/cmd.Commit=${gastownRev}" "-X github.com/steveyegge/gastown/internal/cmd.BuildTime=nix-build" "-X github.com/steveyegge/gastown/internal/cmd.BuiltProperly=1" ]; # Bug fixes not yet merged upstream postPatch = '' # Fix validateRecipient bug: normalize addresses before comparison # See: https://github.com/steveyegge/gastown/issues/TBD substituteInPlace internal/mail/router.go \ --replace-fail \ 'if agentBeadToAddress(agent) == identity {' \ 'if AddressToIdentity(agentBeadToAddress(agent)) == AddressToIdentity(identity) {' # Fix agentBeadToAddress to use title field for hq- prefixed beads # Title should contain the address (e.g., "java/crew/americano") substituteInPlace internal/mail/router.go \ --replace-fail \ 'return parseAgentAddressFromDescription(bead.Description)' \ 'if bead.Title != "" && strings.Contains(bead.Title, "/") { return bead.Title }; return parseAgentAddressFromDescription(bead.Description)' # Fix agentBeadToAddress to handle rig-specific prefixes (j-, sc-, etc.) # Bead IDs like j-java-crew-americano should map to java/crew/americano substituteInPlace internal/mail/router.go \ --replace-fail \ '// Handle gt- prefixed IDs (legacy format) if !strings.HasPrefix(id, "gt-") { return "" // Not a valid agent bead ID }' \ '// Handle rig-specific prefixes: --- // Examples: j-java-crew-americano -> java/crew/americano idParts := strings.Split(id, "-") if len(idParts) >= 3 { for i, part := range idParts { if part == "crew" || part == "polecat" || part == "polecats" { if i >= 1 && i < len(idParts)-1 { rig := idParts[i-1] name := strings.Join(idParts[i+1:], "-") return rig + "/" + part + "/" + name } } if part == "witness" || part == "refinery" { if i >= 1 { return idParts[i-1] + "/" + part } } } } // Handle gt- prefixed IDs (legacy format) if !strings.HasPrefix(id, "gt-") { return "" // Not a valid agent bead ID }' # Fix crew/polecat home paths: remove incorrect /rig suffix substituteInPlace internal/cmd/role.go \ --replace-fail \ 'return filepath.Join(townRoot, rig, "polecats", polecat, "rig")' \ 'return filepath.Join(townRoot, rig, "polecats", polecat)' \ --replace-fail \ 'return filepath.Join(townRoot, rig, "crew", polecat, "rig")' \ 'return filepath.Join(townRoot, rig, "crew", polecat)' # Fix town root detection: don't map to Mayor (causes spurious mismatch warnings) substituteInPlace internal/cmd/prime.go \ --replace-fail \ 'if relPath == "." || relPath == "" { ctx.Role = RoleMayor return ctx } if len(parts) >= 1 && parts[0] == "mayor" {' \ 'if relPath == "." || relPath == "" { return ctx // RoleUnknown - town root is shared space } // Check for mayor role: mayor/ or mayor/rig/ if len(parts) >= 1 && parts[0] == "mayor" {' # Fix copyDir to handle symlinks (broken symlinks cause "no such file" errors) # See: https://github.com/steveyegge/gastown/issues/TBD substituteInPlace internal/git/git.go \ --replace-fail \ 'if entry.IsDir() {' \ '// Handle symlinks (recreate them, do not follow) if entry.Type()&os.ModeSymlink != 0 { linkTarget, err := os.Readlink(srcPath) if err != nil { return err } if err := os.Symlink(linkTarget, destPath); err != nil { return err } continue } if entry.IsDir() {' # Statusline optimization: skip detached sessions and cache results # Reduces Dolt CPU from ~70% to ~20% by avoiding beads queries for sessions nobody is watching # See: https://github.com/steveyegge/gastown/issues/TBD substituteInPlace internal/cmd/statusline.go \ --replace-fail \ '"strings"' \ '"strings" "time"' \ --replace-fail \ 'var ( statusLineSession string )' \ '// statusLineCacheTTL is how long cached status output remains valid. const statusLineCacheTTL = 10 * time.Second // statusLineCachePath returns the cache file path for a session. func statusLineCachePath(session string) string { return filepath.Join(os.TempDir(), fmt.Sprintf("gt-status-%s", session)) } // getStatusLineCache returns cached status if fresh, empty string otherwise. func getStatusLineCache(session string) string { path := statusLineCachePath(session) info, err := os.Stat(path) if err != nil { return "" } if time.Since(info.ModTime()) > statusLineCacheTTL { return "" } data, err := os.ReadFile(path) if err != nil { return "" } return string(data) } // setStatusLineCache writes status to cache file. func setStatusLineCache(session, status string) { path := statusLineCachePath(session) _ = os.WriteFile(path, []byte(status), 0644) } var ( statusLineSession string )' \ --replace-fail \ 'func runStatusLine(cmd *cobra.Command, args []string) error { t := tmux.NewTmux() // Get session environment' \ 'func runStatusLine(cmd *cobra.Command, args []string) error { t := tmux.NewTmux() // Optimization: skip expensive beads queries for detached sessions if statusLineSession != "" { if !t.IsSessionAttached(statusLineSession) { fmt.Print("○ |") return nil } // Check cache for attached sessions too if cached := getStatusLineCache(statusLineSession); cached != "" { fmt.Print(cached) return nil } } // Get session environment' \ --replace-fail \ ' // Output if len(parts) > 0 { fmt.Print(strings.Join(parts, " | ") + " |") } return nil } func runMayorStatusLine(t *tmux.Tmux) error {' \ ' // Output if len(parts) > 0 { output := strings.Join(parts, " | ") + " |" if statusLineSession != "" { setStatusLineCache(statusLineSession, output) } fmt.Print(output) } return nil } func runMayorStatusLine(t *tmux.Tmux) error {' \ --replace-fail \ 'fmt.Print(strings.Join(parts, " | ") + " |") return nil } // runDeaconStatusLine outputs status for the deacon session.' \ 'output := strings.Join(parts, " | ") + " |" if statusLineSession != "" { setStatusLineCache(statusLineSession, output) } fmt.Print(output) return nil } // runDeaconStatusLine outputs status for the deacon session.' \ --replace-fail \ 'fmt.Print(strings.Join(parts, " | ") + " |") return nil } // runWitnessStatusLine outputs status for a witness session. // Shows: crew count, hook or mail preview' \ 'output := strings.Join(parts, " | ") + " |" if statusLineSession != "" { setStatusLineCache(statusLineSession, output) } fmt.Print(output) return nil } // runWitnessStatusLine outputs status for a witness session. // Shows: crew count, hook or mail preview' \ --replace-fail \ 'fmt.Print(strings.Join(parts, " | ") + " |") return nil } // runRefineryStatusLine outputs status for a refinery session.' \ 'output := strings.Join(parts, " | ") + " |" if statusLineSession != "" { setStatusLineCache(statusLineSession, output) } fmt.Print(output) return nil } // runRefineryStatusLine outputs status for a refinery session.' \ --replace-fail \ 'fmt.Print(strings.Join(parts, " | ") + " |") return nil } // isSessionWorking detects' \ 'output := strings.Join(parts, " | ") + " |" if statusLineSession != "" { setStatusLineCache(statusLineSession, output) } fmt.Print(output) return nil } // isSessionWorking detects' ''; meta = with lib; { description = "Gas Town - multi-agent workspace manager by Steve Yegge"; homepage = "https://github.com/steveyegge/gastown"; license = licenses.mit; mainProgram = "gt"; }; }; # Perles - TUI for beads issue tracking (no upstream flake.nix yet) # Source is tracked via flake input for renovate updates perlesRev = builtins.substring 0 8 (globalInputs.perles.rev or "unknown"); perlesPackage = pkgs.buildGoModule { pname = "perles"; version = "unstable-${perlesRev}"; src = globalInputs.perles; vendorHash = "sha256-JHERJDzbiqgjWXwRhXVjgDEiDQ3AUXRIONotfPF21B0="; doCheck = false; ldflags = [ "-X main.version=${perlesRev}" ]; meta = with lib; { description = "Perles - Terminal UI for beads issue tracking"; homepage = "https://github.com/zjrosen/perles"; license = licenses.mit; mainProgram = "perles"; }; }; # Fetch the claude-plugins repository (for humanlayer commands/agents) # Update the rev to get newer versions of the commands claudePluginsRepo = builtins.fetchGit { url = "https://github.com/jeffh/claude-plugins.git"; # To update: change this to the latest commit hash # You can find the latest commit at: https://github.com/jeffh/claude-plugins/commits/main rev = "5e3e4d937162185b6d78c62022cbfd1c8ad42c4c"; ref = "main"; }; in { options.home.roles.development = { enable = mkEnableOption "Enable development tools and utilities"; allowArbitraryClaudeCodeModelSelection = mkOption { type = types.bool; default = false; description = '' Whether to preserve model specifications in Claude Code humanlayer commands and agents. When false (default), the model: line is stripped from frontmatter, allowing Claude Code to use its default model selection. When true, the model: specifications from the source files are preserved, allowing commands to specify opus/sonnet/haiku explicitly. ''; }; }; config = mkIf cfg.enable { home.packages = [ beadsPackage gastownPackage perlesPackage pkgs.unstable.claude-code pkgs.unstable.claude-code-router pkgs.unstable.codex pkgs.dolt pkgs.sqlite # Custom packages pkgs.custom.tea-rbw ]; # Install Claude Code humanlayer command and agent plugins home.activation.claudeCodeCommands = lib.hm.dag.entryAfter ["writeBoundary"] '' # Clean up old plugin-installed commands and agents to avoid duplicates rm -f ~/.claude/commands/humanlayer:* 2>/dev/null || true rm -f ~/.claude/agents/humanlayer:* 2>/dev/null || true # Create directories if they don't exist mkdir -p ~/.claude/commands mkdir -p ~/.claude/agents # Copy all humanlayer command files and remove model specifications for file in ${claudePluginsRepo}/humanlayer/commands/*.md; do if [ -f "$file" ]; then filename=$(basename "$file" .md) dest="$HOME/.claude/commands/humanlayer:''${filename}.md" rm -f "$dest" 2>/dev/null || true # Copy file and conditionally remove the "model:" line from frontmatter ${if cfg.allowArbitraryClaudeCodeModelSelection then "cp \"$file\" \"$dest\"" else "${pkgs.gnused}/bin/sed '/^model:/d' \"$file\" > \"$dest\"" } chmod u+w "$dest" 2>/dev/null || true fi done # Copy all humanlayer agent files and remove model specifications for file in ${claudePluginsRepo}/humanlayer/agents/*.md; do if [ -f "$file" ]; then filename=$(basename "$file" .md) dest="$HOME/.claude/agents/humanlayer:''${filename}.md" rm -f "$dest" 2>/dev/null || true # Copy file and conditionally remove the "model:" line from frontmatter ${if cfg.allowArbitraryClaudeCodeModelSelection then "cp \"$file\" \"$dest\"" else "${pkgs.gnused}/bin/sed '/^model:/d' \"$file\" > \"$dest\"" } chmod u+w "$dest" 2>/dev/null || true fi done # Copy local commands from this repo (with retry for race conditions with running Claude) for file in ${./commands}/*.md; do if [ -f "$file" ]; then filename=$(basename "$file" .md) dest="$HOME/.claude/commands/''${filename}.md" # Remove existing file first, then copy with retry on failure rm -f "$dest" 2>/dev/null || true if ! cp "$file" "$dest" 2>/dev/null; then sleep 0.5 cp "$file" "$dest" || echo "Warning: Failed to copy $filename.md to commands" fi chmod u+w "$dest" 2>/dev/null || true fi done # Copy local skills (reference materials) to skills subdirectory mkdir -p ~/.claude/commands/skills for file in ${./skills}/*.md; do if [ -f "$file" ]; then filename=$(basename "$file" .md) dest="$HOME/.claude/commands/skills/''${filename}.md" rm -f "$dest" 2>/dev/null || true if ! cp "$file" "$dest" 2>/dev/null; then sleep 0.5 cp "$file" "$dest" || echo "Warning: Failed to copy $filename.md to skills" fi chmod u+w "$dest" 2>/dev/null || true fi done # Copy micro-skills (compact reusable knowledge referenced by formulas) for file in ${./skills/micro}/*.md; do if [ -f "$file" ]; then dest="$HOME/.claude/commands/skills/$(basename "$file")" rm -f "$dest" 2>/dev/null || true cp "$file" "$dest" chmod u+w "$dest" 2>/dev/null || true fi done # Install beads formulas to user-level formula directory mkdir -p ~/.beads/formulas for file in ${./formulas}/*.formula.toml; do if [ -f "$file" ]; then dest="$HOME/.beads/formulas/$(basename "$file")" rm -f "$dest" 2>/dev/null || true cp "$file" "$dest" chmod u+w "$dest" 2>/dev/null || true fi done $DRY_RUN_CMD echo "Claude Code plugins installed: humanlayer commands/agents + local commands + local skills + formulas" ''; # Set up beads Claude Code integration (hooks for SessionStart/PreCompact) # This uses the CLI + hooks approach which is recommended over MCP for Claude Code home.activation.claudeCodeBeadsSetup = lib.hm.dag.entryAfter ["writeBoundary" "claudeCodeCommands"] '' # Run bd setup claude to install hooks into ~/.claude/settings.json # This is idempotent - safe to run multiple times ${beadsPackage}/bin/bd setup claude 2>/dev/null || true $DRY_RUN_CMD echo "Claude Code beads integration configured (hooks installed)" ''; # Beads timer gate checker (Linux only - uses systemd) # Runs every 5 minutes to auto-resolve expired timer gates across all beads projects # This enables self-scheduling molecules (watchers, patrols, etc.) systemd.user.services.beads-gate-check = lib.mkIf pkgs.stdenv.isLinux { Unit = { Description = "Check and resolve expired beads timer gates"; }; Service = { Type = "oneshot"; # Check gates in all workspaces that have running daemons ExecStart = pkgs.writeShellScript "beads-gate-check-all" '' # Get list of workspaces from daemon registry workspaces=$(${beadsPackage}/bin/bd daemon list --json 2>/dev/null | ${pkgs.jq}/bin/jq -r '.[].workspace // empty' 2>/dev/null) if [ -z "$workspaces" ]; then exit 0 # No beads workspaces, nothing to do fi for ws in $workspaces; do if [ -d "$ws" ]; then cd "$ws" && ${beadsPackage}/bin/bd gate check --type=timer --quiet 2>/dev/null || true fi done ''; }; }; systemd.user.timers.beads-gate-check = lib.mkIf pkgs.stdenv.isLinux { Unit = { Description = "Periodic beads timer gate check"; }; Timer = { OnBootSec = "5min"; OnUnitActiveSec = "5min"; }; Install = { WantedBy = [ "timers.target" ]; }; }; # Note: modules must be imported at top-level home config }; }