Agents

Agent Guide — NixOS Dotfiles Flake

Modular NixOS dotfiles and infrastructure flake maintained by alienzj. Forked from hlissner’s dotfiles, heavily extended. This document is the authoritative orientation for all AI agents (Claude, Gemini, Codex, etc.).


Quick Orientation

  • What this is: A NixOS flake composing 16+ hosts from reusable modules, with a custom CLI toolchain (hey), Janet scripting, and impermanent root.
  • How it builds: hey sync (nixos-rebuild), hey sync build-vm (VM test), nix eval .#nixosConfigurations.<host>.config.system.build.toplevel (dry eval).
  • Key constraint: Fully hermetic — zero impure evaluation. No --impure, no getEnv, no HEYENV. Host identity from flake attr name, user/theme from host config.

Repository Layout

flake.nix            Flake entry + 25+ inputs
default.nix          Root module (auto-imports modules/ recursively)
lib/                 Custom Nix library (mkOpt, mapModules, wrapFakeHome, mkFlake)
modules/             Reusable feature modules (the core of this repo)
hosts/               Per-host composition (each host imports its own modules/)
packages/            Local derivations (hey.packages.*)
overlays/            Nixpkgs overlays
config/              Raw dotfiles linked via home.file/home.configFile
bin/                 hey CLI (Janet) + unified binscripts (.zsh)
lib/hey/             Janet runtime modules (defcmd, sys, vars, etc.)
lib/zsh/             Zsh autoload bridge (hey.wm.name, hey.log, etc.)
docs/                Architecture and subsystem documentation

🧠 Agent Self-Study & Real-Time Search Policy

Because this repository uses niche languages (Janet) and complex declarative paradigms (Nix), you must not guess APIs.

  1. For Janet (*.janet):
    • Before writing complex Janet code, use your web search/browser tool to search site:janet-lang.org/docs <query> or read https://janet-lang.org/api/index.html.
    • Do not assume Clojure or Common Lisp standard libraries apply to Janet.
  2. For Nix (*.nix):
    • If unsure about a Nixpkgs function, search https://noogle.dev or search.nixos.org.
    • If unsure about Nix builtins, search https://nix.dev/manual/nix/latest/language/builtins.
  3. For Zsh (*.zsh):
    • Ensure scripts are POSIX compliant where possible, but leverage native Zsh arrays and parameter expansion heavily.
  4. Mandatory Idiom Review:
    • Always read docs/ai-language-idioms.md before executing large refactors in Nix, Janet, or Zsh to avoid common traps (e.g., infinite recursion in Nix, macro abuse in Janet).

Module System

Option DSL (lib/options.nix)

Always use the shorthand forms:

options.modules.<area>.<name> = {
  enable  = mkBoolOpt false;               # boolean toggle
  setting = mkOpt types.str "default";     # typed option
  desc    = mkOpt' types.str "" "Help\\n";  # with description
};

Never use raw mkOption — use mkOpt/mkBoolOpt/mkOpt' from hey.lib.

Module Template

{ hey, lib, config, pkgs, ... }:
with lib;
with hey.lib; let
  cfg = config.modules.<area>.<name>;
in {
  options.modules.<area>.<name> = { ... };
  config = mkIf cfg.enable (mkMerge [ { ... } { ... } ]);
}
  • Gate all behavior with mkIf cfg.enable.
  • Use mkMerge to separate system-level config from home-level file management.
  • Access hey.packages.* for local packages, hey.lib.* for library helpers.
  • Use assertions for invalid option combinations.

Auto-loading

mapModulesRec' ./modules import in default.nix automatically imports every .nix file under modules/. No manual import lists.

Recursive Loading Rules:

  • Skip Directory: Use a .noload file in a directory to skip auto-loading its contents. This is the preferred method when a directory contains internal parts that are manually imported elsewhere.
  • Ignore Prefix: Any directory or file starting with an underscore (e.g., _parts/ or _mixins.nix) is also ignored by the auto-loader.
  • Deduplication: Never manually import a file that is already within the auto-load path unless the directory is skipped via .noload. Manual imports of auto-loaded files cause “double-definition” issues (e.g., duplicated strings in configuration files).

Architecture Rules

Themes Decorate, Not Define (Two-Tiered)

  • Tier 1 — Global Defaults: System provides a production-ready baseline using Catppuccin Mocha when no active theme is set.
  • Tier 2 — Active Decoration: Individual themes (e.g., Alucard) surgically override the baseline via modules.theme options.
  • Requirement: The system must be fully functional and aesthetically coherent if modules.theme.active = null.
  • Centralization: All app-specific color/font mapping lives in modules/themes/apps/, not in feature modules.

Home Manager Is a File Bridge

Prefer NixOS system options over Home Manager options. Use home.file, home.configFile, home.dataFile as the bridge for user-space files. Never reach for home-manager.users.<name>.<...> directly — the aliases in modules/home.nix handle that.

Hybrid Fake HOME (Jailing)

Non-XDG apps (Steam, Firefox, Thunderbird, LMMS) get jailed to ~/.local/user via wrapFakeHome from lib/pkgs.nix. Use mkFakeHomeEntry for additional desktop entries within the fake home. XDG_* variables are not unset, allowing global font/theme sharing.

Pure & Isolated System Closures (No Target Architecture Leakage)

  • Pure Closures: When modifying or refactoring shared library code (such as lib/nixos.nix, lib/pkgs.nix, etc.), you must ensure that each host’s evaluated system configuration remains strictly isolated and untainted by other architectures.
  • No Target Leakage: If evaluating a host targeting x86_64-linux (e.g. lab-matrix), there must be absolutely no aarch64 package or builder paths in the output derivation. Conversely, aarch64-linux targets (e.g. sbc-opi5p) must evaluate cleanly as pure cross-compiled (or native) closures without leaking native host system packages that do not compile on the target platform.
  • Dynamic Package Binding: Always construct library helpers (e.g. wrappers, menu generators, environment builders) using the target host’s specific pkgs (bound dynamically during NixOS configuration evaluation), rather than utilizing the global static pkgs defined at flake load time.
  • Conditional Platforms: If a package or system tool has platform-specific compatibility restrictions in Nixpkgs (e.g. android-studio or arduino-ide which only target x86_64-linux), always gate its inclusion in system or user packages using lib.optionals pkgs.stdenv.hostPlatform.is<arch> (e.g., lib.optionals pkgs.stdenv.hostPlatform.isx86_64) rather than adding it unconditionally.

Profile System

Hosts compose themselves via profile strings:

  • modules.profiles.role"workstation", "server", "portable", "board"
  • modules.profiles.hardware — list like ["cpu/amd" "gpu/nvidia/pascal" "yubikey"]
  • modules.profiles.networks — list like ["ts0" "sz/homelab"]

Package Hierarchy

Three layers, in order of preference:

  1. pkgs.* — Nixpkgs (unstable channel)
  2. pkgs.pkgs-stable.* — Nixpkgs 25.11 fallback
  3. hey.packages.* — Local packages/ derivations (always use explicit hashes for fetchFromGitHub)

Creating Nix Packages (packages/)

When adding a new derivation to packages/, ensure its default.nix follows this pattern:

{
  self,    # Required: allows local packages to reference each other
  lib,
  stdenvNoCC,
  fetchFromGitHub,
  ...      # Recommended: prevents "unexpected argument" errors
}:
stdenvNoCC.mkDerivation rec {
  pname = "...";
  # ...
}

Crucial Rules:

  1. Argument self: You MUST include self in the function arguments. The mkFlake function in lib/nixos.nix calls pkgs.callPackage with { self = self.packages.${system}; }.
  2. Variadic Arguments: Always include ... in the argument list to prevent “unexpected argument” errors when callPackage passes extra context.
  3. Source Fetching: Use explicit hashes (SRI format) for fetchFromGitHub. Use nix-prefetch-url --unpack <url> to find the hash if needed.

Declarative Provisioning (ensures)

For services like PostgreSQL, avoid imperative scripts. Use the ensures pattern:

modules.services.dev.postgresql.ensures = [
  { username = "user"; database = "db"; passwordFile = config.age.secrets.pass.path; }
];

Adding a PostgreSQL-backed service

When creating a new service module that needs its own database, use the project’s PostgreSQL wrapper (modules/services/database/postgresql) — never services.postgresql directly:

modules.services.database.postgresql = {
  enable = true;
  # Extensions this service needs (pgvector, vectorchord, etc.)
  extraExtensions = ps: [ps.pgvector];
  # postgresql.conf overrides merged on top of module defaults
  settings = {
    shared_preload_libraries = ["vchord.so"];
    search_path = "\"$user\", public, vectors";
  };
  ensures = [
    {
      username = "myapp";
      database = "myapp";
      passwordFile = config.age.secrets.myapp-postgresql-password.path;
    }
  ];
};

Key rules:

  • enable = true is idempotent — multiple services can set it, the DB starts once.
  • extraExtensions is a function ps: [...] — it receives the pkgs.postgresql_18 extension set, not raw nixpkgs. Use ps.pgvector, ps.vectorchord, etc.
  • settings is merged via mkMerge into services.postgresql.settings. Use it for shared_preload_libraries, search_path, or any postgresql.conf key. No need to wrap values in lists — mkMerge handles list concatenation.
  • ensures handles user + database creation. The post-start script also sets passwords from agenix secrets when passwordFile is provided.
  • The wrapper pins PostgreSQL 18stateVersion does NOT affect the version. If you need a different version, override package.

Database State Operations

ensures handles user/role and database creation. It does not manage in-database state — user accounts, application rows, schema migrations involving data. When debugging or setting up a service that stores state in PostgreSQL:

  • Never run destructive queries (DROP, TRUNCATE, DELETE) without explicit user approval.
  • Always show the exact SQL before running it. Use sudo -u postgres psql -d <db> -c '<query>' — full CLI, no shorthand.
  • Prefer read-only queries first (SELECT, \dt, \du) to understand the current state.
  • For user-account operations (Atuin registration, app user creation, password resets): check if the application provides a CLI/API first. Direct SQL inserts are a last resort — app-level hashing and foreign-key constraints make manual inserts error-prone.
  • Temporary registration patterns: Some services (e.g., Atuin) gate user creation behind a config toggle (openRegistration). The Nix-idiomatic approach: enable the toggle, deploy, register the user through the application, disable the toggle, deploy again. Document the two-deploy cycle in the service’s module comment and in docs.

Ask me to approve any database mutation before executing it. Read-only queries are always fine.


Firewall Engineering Pattern (Dual-Stack LAN-Restriction)

To protect the homelab from public WAN exposure, we enforce a strict, consistent firewall engineering pattern across all modules.

The Rules:

  1. Gateways (Globally Open): Only public-facing edge gateways — Nginx (modules/services/web/nginx.nix) and Tailscale (modules/profiles/network/ts0.nix) — are permitted to set openFirewall = true or open global TCP/UDP ports.
  2. Internal Services (LAN/Tailscale Only): Every other service (e.g. SSH, Samba, Ollama, Jellyfin, Sunshine, Syncthing) MUST keep the upstream package’s firewall option disabled (openFirewall = false; explicitly).
  3. Firewall Helpers: Restrict inbound access strictly to Tailscale + trusted LAN using networking.firewall = mkFirewallFor { ... }; from lib/firewall.nix.

Standard Firewall Configuration Template:

    services.myservice = {
      enable = true;
      openFirewall = false; # Secure by default, LAN-restricted below
    };

    # Restrict incoming connections to Tailscale + trusted LAN ranges
    networking.firewall = mkIf cfg.openFirewall (
      hey.lib.firewall.mkFirewallFor {
        tcp = [ "8080" ];
        comment = "myservice";
      }
    );

Key Details:

  • All helpers (mkFirewallFor, mkRestrictedPort) generate dual-stack rules, applying rules to both iptables (IPv4 lanSources) and ip6tables (IPv6 lanSourcesV6).
  • Traffic arriving on tailscale0 completely bypasses the firewall via trustedInterfaces, meaning Tailscale connections succeed seamlessly even when openFirewall = false.
  • Always use colon syntax 137:138 for native range queries in tcp or udp arrays, or use tcpRange/udpRange helpers.

Firewall Engineering Pattern (Dual-Stack LAN-Restriction)

To protect the homelab from public WAN exposure, we enforce a strict, consistent firewall engineering pattern across all modules.

The Rules:

  1. Gateways (Globally Open): Only public-facing edge gateways — Nginx (modules/services/web/nginx.nix) and Tailscale (modules/profiles/network/ts0.nix) — are permitted to set openFirewall = true or open global TCP/UDP ports.
  2. Internal Services (LAN/Tailscale Only): Every other service (e.g. SSH, Samba, Ollama, Jellyfin, Sunshine, Syncthing) MUST keep the upstream package’s firewall option disabled (openFirewall = false; explicitly).
  3. Firewall Helpers: Restrict inbound access strictly to Tailscale + trusted LAN using networking.firewall = mkFirewallFor { ... }; from lib/firewall.nix.

Standard Firewall Configuration Template:

    services.myservice = {
      enable = true;
      openFirewall = false; # Secure by default, LAN-restricted below
    };

    # Restrict incoming connections to Tailscale + trusted LAN ranges
    networking.firewall = mkIf cfg.openFirewall (
      hey.lib.firewall.mkFirewallFor {
        tcp = [ "8080" ];
        comment = "myservice";
      }
    );

Key Details:

  • All helpers (mkFirewallFor, mkRestrictedPort) generate dual-stack rules, applying rules to both iptables (IPv4 lanSources) and ip6tables (IPv6 lanSourcesV6).
  • Traffic arriving on tailscale0 completely bypasses the firewall via trustedInterfaces, meaning Tailscale connections succeed seamlessly even when openFirewall = false.
  • Always use colon syntax 137:138 for native range queries in tcp or udp arrays, or use tcpRange/udpRange helpers.

Nginx Reverse Proxy Pattern — Service Module Template

When creating a new web service module behind nginx, follow this pattern. Deviating from it causes nginx -t failures at deploy time.

Library Helpers (lib/nginx.nix)

Two-tier vhost generators that replace ~15 lines of boilerplate:

HelperWhen to use
genNginxVhostStandard reverse-proxy service with a "/" proxyPass
genNginxVhostBaseService that manages its own locations (PHP-FPM, fcgiwrap, custom logic)

Standard Service Module Template

# modules/services/<area>/<name>.nix
{ hey, lib, config, pkgs, ... }:
with lib;
with hey.lib; let
  cfg = config.modules.services.<area>.<name>;
  baseDomain = config.modules.services.baseDomain;
in {
  options.modules.services.<area>.<name> = {
    enable = mkBoolOpt false;
    domain = mkOpt types.str "<name>.${baseDomain}";
    port = mkOpt types.port <pick-unused-port>;
    nginx = {
      enable = mkBoolOpt true;
      ssl = mkBoolOpt false;  # false on L2 hosts (lab-matrix), true on L1 (vps-pacman)
    };
    oauth2 = {
      enable = mkBoolOpt false;
      allowedGroups = mkOpt (types.listOf types.str) [];
    };
  };

  config = mkIf cfg.enable {
    services.nginx.virtualHosts = mkIf cfg.nginx.enable {
      "${cfg.domain}" = genNginxVhost {
        ssl = cfg.nginx.ssl;
        proxyPass = "http://127.0.0.1:${toString cfg.port}";
        proxyWebsockets = true;  # for WebSocket support
        quic = config.modules.services.web.nginx.quic;
        kTLS = config.modules.services.web.nginx.kTLS;
        oauth2 = cfg.oauth2.enable;
        oauth2Groups = cfg.oauth2.allowedGroups;
      };
    };
  };
}

Critical Rules — Read Before Adding extraConfig

The nixpkgs nginx module emits these directives from proxyWebsockets = true at the location level:

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

And from recommendedProxySettings = true (global, HTTP level):

proxy_redirect          off;
proxy_connect_timeout   300s;
proxy_send_timeout      300s;
proxy_read_timeout      300s;
proxy_http_version      1.1;           # HTTP-level default
proxy_set_header        "Connection" "";
include recommendedProxyConfig;        # Host, X-Real-IP, X-Forwarded-For/Proto/Host/Server

NEVER add these to extraConfig: proxy_http_version, proxy_set_header Upgrade, proxy_set_header Connection, or any header from recommendedProxyConfig. Doing so duplicates what nixpkgs already emits and breaks nginx -t with "<directive>" directive is duplicate.

For WebSocket locations that need custom headers beyond what proxyWebsockets provides (e.g., tcp_nodelay on, proxy_set_header Host $host), add ONLY those — never the three WebSocket headers.

Location emission order (nixpkgs default.nix lines 510-548):

1. proxy_pass
2. proxyWebsockets → Upgrade/Connection headers
3. extraConfig             ← your additions go here
4. include recommendedProxyConfig

Check existing config:

nginx -T 2>/dev/null | grep -n 'proxy_http_version\|proxy_set_header Upgrade\|proxy_set_header Connection'

For full details, see docs/web-services.md § Nixpkgs Nginx Module Internals.


Host Composition

Each host in hosts/ has:

hosts/<name>/
  default.nix              imports + system arch
  modules/
    modules.nix            the modules = { ... } attribute set  ← main toggle point
    hardware.nix           hardware/disks
    storage.nix            disko schema
    virt.nix               virtualization
    secrets.nix            agenix references

Keep host files thin. If logic may be shared, extract it into modules/.


Hey Toolchain

hey is the central orchestrator (Janet CLI at bin/hey):

CommandPurpose
hey syncnixos-rebuild boot
hey sync build-vmBuild a test VM
hey checkComprehensive validation (syntax, flake, eval, profile)
hey pullUpdate flake inputs
hey gcGarbage collection
hey hook <name>Trigger event hooks
hey .<script>Execute bin/ script (WM-aware resolution)
`hey vars get\set`
hey replJanet/Nix REPL

WM-agnostic scripts go in bin/. Use hey.wm.name or hey.wm.mode to adapt behavior per compositor (Hyprland, Niri, BSPWM). Common shared hooks (idle.zsh, battery.zsh, gamemode.zsh) live in bin/hooks/; WM-specific overrides go in config/$WM/hooks/ or hosts/$HOST/hooks/.

Janet Toolchain Style

  • Prefer native Janet features (&opt, &named, &keys, |()) over complex macros.
  • Use try/catch for error handling — never swallow errors by redirecting stderr to /dev/null.
  • Use defcmd macro (in lib/hey/cmd.janet) to define subcommands with automatic option parsing and validation.
  • Mock filesystem interactions with hey/mock.d; never run destructive tests on the host.

Verification & Development Loop

The verification tool is hey check. CRITICAL AI INSTRUCTION: If you have terminal execution capabilities, run hey check syntax first. DO NOT run hey check eval or hey check all automatically, as bad Nix code may cause infinite recursion and permanently hang the terminal. Instead, ask me (the user) to run hey check eval and report the results back to you.

hey check all                         # syntax + flake + eval for current host (default)
hey check syntax                      # parse all .nix files
hey check flake                       # nix flake check
hey check eval --host <name>          # deep evaluation, catches infinite recursion
hey check eval --all-hosts            # full repo health check
hey check eval --show-trace           # bisect dependency chains
hey check profile --host <name>       # eval flamegraph profiling (Nix 2.30+)
hey check profile --flamegraph --open # profile + generate SVG + open in browser

Other useful checks:

nix eval .#nixosConfigurations.<host>.config.system.build.toplevel
nix flake check
nix-instantiate --parse <file>

Janet Tests (Not implemented yet, don’t try)

jpm test           # or: judge test/hey

Add a test file in test/hey/ for every new library function or complex logic. Use VMs for integration tests that touch system state (hey build vm --host <name>).

Recursion Safety Rules

  • Avoid referencing config.modules.foo.enable inside a mkIf that defines config.modules.foo.
  • Ensure mkIf conditions never depend on the values they conditionally define.
  • Use --show-trace to bisect circular dependency chains.

Development Loop (Don’t trigger too much, only when updating .nix files)

  1. Modify — apply surgical changes to modules or hosts.
  2. Format — run alejandra <file> on any .nix files you modified to ensure consistent formatting.
  3. Verify — run hey check syntax immediately (if terminal access is available).
  4. Debug — if errors appear, use --show-trace; fix the logic.
  5. Iterate — re-verify until hey check syntax passes. If hey check itself is flawed, fix the toolchain.
  6. Ask User to Run hey check eval: Request the user to execute the deep evaluation check and report results.
  7. Deliver — only proceed to hey sync after a clean evaluation from the user.

A change is not complete until verification is attempted. If a check cannot run, say so explicitly.

Toolchain Verification Protocol

hey is built via a two-tier system:

  • Production: packages/hey/default.nix — a hermetic Nix derivation built during nixos-rebuild. The compiled binary and all Janet modules live in the nix store and are copied to every host as part of the system closure. No activation-time compilation needed.
  • Development: scripts/build_hey.zsh (or hey build hey) — compiles into $JANET_TREE/bin/ (~/.local/share/janet/jpm_tree/bin/). Because PATH lists $JANET_TREE/bin before the nix-store hey, the locally-built version takes priority during development.

If you modify the core hey toolchain (lib/hey/**, bin/hey, or bin/hey.d/**):

  1. Rebuild: You MUST run ./scripts/build_hey.zsh (or hey build hey) to apply changes to the compiled binary.
  2. Test Dev Binary: Verify your changes work at the dev level.
    • Zsh Interaction: Run a built-in script (e.g., hey .open-term).
    • Rofi Interaction: Test both Janet and Zsh menus (e.g., hey @rofi audiomenu, hey @rofi powermenu).
    • Launcher Entries: Verify the exec strings defined in modules/desktop/apps/rofi.nix still work.
  3. Test System Binary (critical for lib/hey/** changes): The dev build runs from source with real env vars and will not reproduce Janet image creation bugs. Always verify with the hermetic Nix build:
    nix build .#hey && result/bin/hey info && result/bin/hey exec /tmp/test_path.sh
    This builds the exact binary that hey sync deploys to /run/current-system/sw/bin/hey. Janet evaluates module-level def, var, and delay at image creation time (the Nix build sandbox), capturing build-time env vars (USER=nix-build, temp dirs). Only defn (pure functions called at runtime) is safe for values derived from os/getenv or filesystem reads. See docs/ai-language-idioms.md §Janet Image Creation Trap.
  4. Iteration: If any test fails (e.g., compilation error, missing symbols, or no UI response), refactor the relevant Janet/Zsh code, rebuild, and re-test.

Git & Commit Policy

Use git history as a model: small commits, conventional prefixes, one architectural idea per commit.

  • CRITICAL: Never execute hey sync, or any command requiring sudo automatically. You must stop and instruct me to run these commands.
  • Do not auto-commit by default after every edit.
  • Do commit automatically only when the user explicitly signals it (e.g. “commit this”, “make a commit”, “end-to-end”).
  • SSH-signed commits are fully automated — no YubiKey or manual touch needed.

Commit message pattern: <prefix>(<area>): <what changed>

Rules

  • Never rebase, amend published commits, or rewrite shared history. If a previous commit was wrong, fix forward with a deliberate follow-up commit — do not silently rewrite it. Rebasing destroys the real development record and breaks any branches based on those commits.
  • Each commit must be a single, correct, self-contained change. Do not create chains of incremental fix(previous-commit) patches. If you discover the initial implementation was incomplete, step back and design the correct solution before committing. A commit should never need its successor to “fix” it.
  • Update this file (AGENTS.md) in the same commit when architecture or workflow changes. If you change how paths resolve, how the build works, or how modules compose, this file must reflect that reality in the same commit. The agent guide must never drift from the code.
  • Just change current code. Do not create fix-up commits that patch something you just wrote. If you’re still iterating, the commit hasn’t happened yet — get it right once.

Before committing:

  • Run git diff --stat and show the diff summary.
  • Ask the user to confirm the changes look correct before staging.

Release Process

A GitHub Release is triggered automatically by pushing a version tag — no gh release create needed.

  • Workflow: .github/workflows/release.yml fires on push: tags: ['v*'] and uses softprops/action-gh-release@v2 to create the release with auto-generated notes and a docs PDF artifact.
  • How to release: git push origin dev --tags after creating an annotated tag (git tag -a v0.X.0 -m "..."). The CI pipeline handles the rest.
  • Versioning: Tags follow v<major>.<minor>.<patch> (e.g. v0.7.0). Increment the minor version for feature batches, patch for hotfixes.
  • Do not use gh release create — it’s redundant with the automated workflow.

AI Agent Post-Release Verification:

After pushing a new tag, the agent MUST verify that the release CI workflow succeeded. If gh is authenticated (gh auth status), use these commands:

# Check the latest release workflow runs (limit 6 covers the recent tag push)
gh run list --workflow=release.yml --limit 6

# Watch a specific run until completion (use the run ID from the list above)
gh run watch <run-id>

# If the run failed, inspect the logs to diagnose
gh run view <run-id> --log-failed

# Verify the release was created and has the PDF artifact
gh release view <tag-name>

If gh is not authenticated, fall back to the GitHub API anonymously:

curl -s 'https://api.github.com/repos/alienzj/dotfiles/actions/workflows/release.yml/runs?per_page=5' \
  | python3 -c "import json,sys; [print(f'{r[\"head_branch\"]:12} {r[\"conclusion\"] or \"running\":12}') for r in json.load(sys.stdin)['workflow_runs']]"

If the release workflow failed, the agent must:

  1. Diagnose the failure from the logs
  2. Fix the root cause (usually scripts/build-docs-pdf.sh YAML parse errors from --- horizontal rules in concatenated markdown files)
  3. Delete the broken GitHub Release (if one was partially created):
    gh release delete <tag> --yes
  4. Move the tag to the fix commit and force-push to re-trigger:
    git tag -f -a <tag> -m "<tag>: re-release with fix" <commit>
    git push origin <tag> --force

Refactoring Guidelines

Pre-Refactor “Read” Mandate

Before refactoring or calling a local library function, you MUST read its source code to understand its signature:

  • If working with hey commands, read lib/hey/cmd.janet and lib/hey/sys.janet first.
  • If working with modules, read lib/options.nix and lib/pkgs.nix first.
  • Read docs/ai-language-idioms.md to ensure your code aligns with repo idioms. Explain the function signature back to me in your “Thinking” process before you write the implementation.

Execution Steps

  1. Identify duplicated or host-specific logic.
  2. Extract a shared primitive into lib/, modules/, or bin/.
  3. Update all affected hosts/call sites to consume the shared abstraction (convert at least one real caller in the same change).
  4. Run hey check syntax — fix code until it passes; if hey is broken, fix the toolchain.
  5. Update docs (docs/*.md, README.md, this file) if architecture or workflow changed.
  6. Commit as one coherent slice with a conventional prefix (feat, fix, refactor, docs, chore).

Choosing the Right Layer

WhereWhat goes there
lib/*.nixPure Nix helpers, module-loading utilities
lib/pkgs.nixPackage wrappers and package-oriented helpers
lib/hey/*.janethey runtime helpers
lib/zsh/*Shell helper functions for scripts
bin/*.zshExecutable user-facing commands
modules/Reusable feature modules
hosts/<name>/Host-specific values only

Always extend an existing file if the concept already lives there. Add a helper only after finding at least one concrete caller.

Anti-patterns to Avoid

  • Editing many unrelated hosts during a library refactor unless required.
  • Introducing an abstraction without converting at least one real caller.
  • Writing docs that describe architecture not present in code.
  • Silently changing operator workflow without updating docs.
  • Putting host-specific values in shared modules.
  • Duplicating profile logic — use modules.profiles.* selectors.
  • Bypassing home.file — use the bridge aliases.
  • Hardcoding paths — use hey.dir, config.home.fakeDir, config.user.home, etc.
  • Adding browser-only settings to Thunderbird (it’s an email client).
  • Redundant Persistence: Never add subdirectories of ~/.local/share, ~/.local/state, ~/.local/user, or ~/.local/bin into home.persistence. These parent directories are already persisted globally in modules/persist.nix.
  • Auto-committing partial work that has not been verified.
  • Creating chains of fix(previous-commit) patches. If a change needs multiple follow-up fixes, the initial commit was not ready. Design the correct solution and commit once.
  • Rebasing to “clean up” history — this rewrites shared history and erases the real development record.

Documentation Rules

Update docs in the same commit when modifying:

  • Architecture or module boundaries
  • hey workflow or operator commands
  • Security posture, networking, or service topology
  • Installation, sync, build, or debugging procedures

Target document by scope:

FileScope
README.mdRepository overview and navigation
this file (AGENTS.md)Agent execution policy
docs/*.mdSubsystem-specific design and operator guidance
docs/ai-language-idioms.mdLanguage-specific traps and syntax guides for AI
CHANGELOG.mdAll significant additions, changes, and removals

Do not add aspirational claims unless the code already supports them.


Default Agent Workflow

  1. Read this file, docs/ai-language-idioms.md, and the relevant docs/ slice before proposing changes.
  2. Read the source of local functions (e.g., in lib/) you plan to call.
  3. Search the web for official Nix or Janet docs if unsure about standard library usage.
  4. Inspect the local architecture before introducing abstractions.
  5. Make the smallest useful change that removes duplication or improves structure.
  6. Run hey check syntax — iterate until it passes.
  7. Update docs if user-facing or architectural behavior changed.
  8. Update CHANGELOG.md with significant changes.
  9. Summarize risks, checks run, and any follow-up work.
  10. Ask user to run hey check eval and report the results.
  11. Commit only when requested.

Key Subsystems (docs/)

DocWhat it covers
editors.mdMulti-editor setup, VS Code extensions, Neovim LSP/DAP, AI agents, pipeline support
neovim.mdAstroNvim v6, lazy.nvim plugin management, LSP/DAP, customization
tmux.mdTmux session management, theme switching, status bar configuration
keybindings.mdCross-WM keybinding system, compositor-specific mapping
media.mdmpv configuration, ModernZ OSC, keybindings, jellyfin-mpv-shim integration
shell-history.mdAtuin + FZF + Zsh substring search three-tiered history system
software.mdFirefox hardening, Spotify theming, fake-home jailing
hardware.mdNVIDIA legacy pinning, CPU microarch, peripherals
packages.mdPackage hierarchy, hey.packages, overlays
desktop.mdMulti-desktop strategy, nested debugging
themes.mdDecoration pattern, app theming, compositor support
noter.mdNote-taking, sketching, and hybrid Zotero syncing
nix-expressions.mdmkOpt, mapModules, wrapFakeHome, mkAliasDefinitions, ensures
toolchain.mdhey CLI, defcmd, hooks, WM detection, exec-path, Janet image trap, two-tier dev/system
ai-language-idioms.mdNix/Janet/Zsh/matugen traps: infinite recursion, Janet image trap, matugen 4.0.0 filters
documentation-infra.mdDocs website (Astro), PDF generation (pandoc+typst), release pipeline
bin-scripts.mdAll bin/ scripts and their usage: WM tools, AI/LLM, media, hooks, dev utilities
path.mdComplete PATH construction, desktop file discovery, layer mechanisms, process-type differences, debugging
networking-vpn.mdTailscale, Headscale, MagicDNS, DERP
networking-ddns.mdDynamic DNS, public/private/CGNAT IPs, direct-to-home vs VPS paths, tradeoffs
networking-proxy.mdSing-box architecture, SSH tunnels, traffic routing
networking-topology.mdNetwork interfaces, physical link speeds, and workstation-NAS diagnostics
networking-firewall.mdFirewall helpers, lanSources, complete port table, decision guide, Docker/Podman caveats
router.mdASUS Merlin customizations, opkg/Entware setup, soft router platform analysis, and behavior management
sing-box.mdConfig lifecycle, DNS reference, validation, debugging, error patterns
power-management.mdUniversal role-aware sleep, caffeine, hardware idle
security.mdLanzaboote, LUKS2+FIDO2, YubiKey, agenix, AppArmor
security-auth-logic.mdAuth flow diagrams, permission model, OAuth2/OIDC token handling
git-signing.mdSSH-signed commits, GPG setup, allowed_signers, commit verification
vulnerability-response.mdCVE tracking, kernel module blocking, temporary mitigations
sso-identity.mdKanidm, OAuth2 Proxy, Nginx auth_request
web-services.mdNginx QUIC/HTTP3, PostgreSQL ensures + extensions + upgrades, ACME, DNS/ad-blocking architecture, Glance dashboard
service-audit.mdFleet health checks, journal scanning, weekly audit procedures
systemd-services.mdCustom systemd units, service lifecycle, per-session targets, debugging
storage-disko.mdDisko, Btrfs+LUKS2, impermanence, btrbk
persistence.mdImpermanence, persisted directory rules, tmpfs root
containers-virt.mdPodman, Docker, libvirt, MicroVM
backup.mdRestic backup workflows, SFTP backend configuration, and repository recovery
ai-ml.mdOllama, CUDA/ROCm, JupyterHub
data-science.mdUnified Python/R suites, Positron, Templates, Reproducibility
hardware-ai-architectures.mdAI PC, unified memory, Apple Silicon, and high-performance ARM workstation comparison
sbc-opi5p.mdOrange Pi 5 Plus setup, RK3588 kernel, edk2 UEFI, cross-compilation, installation
security-hardening.md7-layer defense: YubiKey, Lanzaboote, LUKS/FIDO2/TPM2, kernel, AppArmor, bubblewrap, firewall, systemd-lock-handler
email.mdStalwart public email: MX/DKIM/DMARC, nginx stream proxy, ACME, outbound relay
ssh.mdSSH config layers, agent vs IdentityFile, MaxAuthTries pitfall, agenix host keys
ops.mdFleet deployment, remote sync, maintenance commands
workflow.mdDevelopment workflow, debugging recipes, common task patterns
ai-parallel-workflow.mdMulti-agent background task orchestration
homelab-bootstrap.mdFresh homelab setup, Kanidm bootstrap, crash-loop diagnosis, troubleshooting
refactor-plan.mdRemaining TODOs: BSPWM parity, Niri window metadata, Janet tests