refactor: mirror module namespaces to their directories (task 0031) #23

Merged
alexion merged 3 commits from task-0031-module-namespace-tidy into main 2026-07-23 21:41:56 -04:00
41 changed files with 165 additions and 15 deletions

View File

@@ -24,6 +24,12 @@ _Avoid_: loader, importer, scanner
The rule that every Module is imported unconditionally and guards its own body with `mkIf config.modules.<path>.enable`, so a Host reads as a checklist of `enable = true` flags.
_Avoid_: feature flag, toggle, opt-in
**Namespace convention**:
The rule that a Module's option path mirrors its directory path under `modules/`, so a file's location is its namespace.
A file whose name matches its enclosing directory is that directory's index node, declaring the directory's own segment rather than a doubled one.
A directory with no such file is a pure namespace prefix that carries no aggregate enable.
_Avoid_: option tree, module path, config key
**admin identity**:
The age identity held only in the operator's password manager, never committed, that is a recipient of every secrets file.
It is the recovery path for any wiped machine and the credential that authorizes registering a new host.

View File

@@ -0,0 +1,24 @@
---
status: accepted
---
# A Module's option namespace mirrors its directory
A Module's option path mirrors its directory path under `modules/`, so a file's location on disk is its namespace: `modules/agents/tools/gitea-axi.nix` declares `modules.agents.tools.gitea-axi`, and a subfolder like `agents/` or `tools/` is a real namespace segment, not a cosmetic grouping.
A file whose name matches its enclosing directory is that directory's index node, declaring the directory's own segment — its `enable` or aggregator — rather than a doubled segment, so `desktop/hyprland/hyprland.nix` owns `modules.desktop.hyprland` while `desktop/hyprland/hypridle.nix` nests under it as `modules.desktop.hyprland.hypridle`.
We chose this nested-mirrors-directory shape over the previous flat names (`modules.claude-code`, `modules.gitea-axi`) because the flat scheme let a Module sit anywhere on disk regardless of its option path, so the tree stopped predicting where a namespace lived.
Mirroring makes the two the single fact.
A pure grouping directory (`agents/`, `tools/`) contributes a namespace segment but declares no aggregate `enable`: agents are enabled à la carte, so there is deliberately no `modules.agents.enable` that would turn on a bundle nobody wants as a unit.
## Considered Options
- **Flat, location-independent names** (the prior state). Rejected: a Module's option path was unconstrained by its file's location, so the directory tree and the option tree drifted and neither could be read off the other.
- **A subfolder as cosmetic grouping only**, with the option path skipping the folder (`agents/pi.nix``modules.pi`). Rejected: it reintroduces the same drift for grouped Modules and makes the folder a lie the namespace does not tell.
- **An aggregator at every grouping level** (`modules.agents.enable`). Rejected: the agent Modules have no meaningful "all agents" bundle, and an aggregate enable there would invite turning on tools no Host wants together.
## Consequences
- The `agents/` group carries `claude-code`, `pi`, `skills`, and `tools/gitea-axi`, each enabled individually under `modules.agents.*`, with no `modules.agents.enable`.
- The index-file rule means adding a knob to an existing group (a new `desktop/hyprland/*.nix`) nests automatically without a naming decision, while a new top-level Module names its own segment.
- The `skills` Module remains the one deliberate exception to the Enable convention — it wires unconditionally — which the namespace convention does not change.

View File

@@ -0,0 +1,29 @@
## What to build
Tidy the module tree so a Module's option namespace mirrors its directory under `modules/`, adopt that as a documented convention, group the agent-related Modules under a new `agents/` directory, bring `desktop/hyprland/` into conformance, and drop the obsolete reference Module.
The convention: a Module's option path mirrors its directory path, and a file whose name matches its directory is that directory's index node — it declares the directory's own segment (its `enable`/aggregator) rather than a doubled segment. A file `foo.nix` in directory `d/` declares `modules.<…>.d.foo`. A group directory with no matching index file contributes a namespace segment but no aggregate `enable`.
Applying it:
- **Agents grouping.** Relocate the agent Modules under `modules.agents.*`: `claude-code` (its whole directory, assets included) → `modules.agents.claude-code`; `pi` flattened from its directory to a single file → `modules.agents.pi`; the skills Module renamed from `agent-skills``agents/skills.nix`; and `gitea-axi` into an `agents/tools/` subgroup → `modules.agents.tools.gitea-axi`. `tools/` is a real namespace segment, not a cosmetic folder.
- **No aggregators.** `agents/` and `tools/` are pure namespace prefixes — no `modules.agents.enable` or `modules.agents.tools.enable`. Agents are enabled à la carte.
- **Skills stays enable-less.** The skills Module keeps its current behaviour (unconditionally wires `programs.agents.skills`, empty list); it is the one deliberate exception to the Enable convention, marked as intentional by a self-contained comment in the file.
- **Desktop conformance.** Nest `hypridle` and `hyprlock` under `modules.desktop.hyprland.*` (matching the index-file rule, `hyprland.nix` being the index), and update `desktop.nix`'s aggregator to the new paths. The 13 flat `desktop/*.nix` Modules keep their `modules.desktop.<name>` names — broader semantic regrouping is explicitly out of scope for this task.
- **Remove the example Module.** Delete `modules/example.nix`; the documented convention and the many real Modules supersede its teaching role.
Also update the one Host that carries these Modules and the live documentation, and record the convention in the domain model.
## Acceptance criteria
- [x] `CONTEXT.md` gains a `Namespace convention` glossary entry stating the directory-mirrors-namespace rule and the index-file rule, in glossary style (no implementation detail).
- [x] An ADR (next number: `0004`) records the decision — nested-mirrors-directory over flat names, a subfolder as a real namespace segment, the index-file rule, and no `agents` aggregator — following the ADR format.
- [x] Agent Modules resolve under `modules.agents.*`: `modules.agents.claude-code.enable`, `modules.agents.pi.enable`, and `modules.agents.tools.gitea-axi.enable` exist; `modules.claude-code`, `modules.pi`, and `modules.gitea-axi` no longer resolve.
- [x] The skills Module lives at `agents/skills.nix` (renamed from `agent-skills.nix`), stays enable-less, still wires `programs.agents.skills`, and carries an in-file comment marking the Enable-convention exception as intentional.
- [x] Neither `modules.agents.enable` nor `modules.agents.tools.enable` exists (pure namespace prefixes, no aggregator).
- [x] `claude-code`'s assets (`CLAUDE.md`, `authentication.md`, `hooks/`, `skills/`) travel with the move and its relative references still resolve.
- [x] `desktop/hyprland/`: `modules.desktop.hyprland.hypridle` and `modules.desktop.hyprland.hyprlock` resolve; the old `modules.desktop.hypridle`/`modules.desktop.hyprlock` no longer exist; `desktop.nix` enables the new paths; `modules.desktop.enable` still brings up the whole session.
- [x] `modules/example.nix` is removed and `modules.example` no longer resolves.
- [x] `hosts/neogaia/default.nix` uses the new option paths for claude-code, pi, and gitea-axi.
- [x] The two live `CLAUDE.md` gotchas — the `gitea-axi` install line and the `claude-code` skill-source path — are updated to the new option/path; `.claude/tasks/*` are left unchanged as historical record.
- [x] `nix flake check` builds `checks.x86_64-linux.neogaia` green (moved files staged so evaluation sees them).

79
flake.lock generated
View File

@@ -181,6 +181,24 @@
"url": "https://flakehub.com/f/DeterminateSystems/flake-schemas/%3D0.5.0.tar.gz"
}
},
"flake-utils": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"fromYaml": {
"flake": false,
"locked": {
@@ -299,6 +317,27 @@
"type": "github"
}
},
"home-manager_4": {
"inputs": {
"nixpkgs": [
"skills",
"nixpkgs"
]
},
"locked": {
"lastModified": 1784725727,
"narHash": "sha256-J5+C9wsO0lhDyUalQzfplDbRjyHDYeEH5+9sdyXtwa8=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "041a999e8c1c5b731913855909e68d30ca69b8e0",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nixos-hardware": {
"inputs": {
"nixpkgs": [
@@ -441,10 +480,33 @@
"nixpkgs-stable": "nixpkgs-stable",
"nixpkgs-unstable": "nixpkgs-unstable",
"nixvim": "nixvim",
"skills": "skills",
"sops-nix": "sops-nix",
"stylix": "stylix"
}
},
"skills": {
"inputs": {
"flake-utils": "flake-utils",
"home-manager": "home-manager_4",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1784776947,
"narHash": "sha256-IGVn6Z7dfeArObZvz7cLsGqJD8ipP2VkAZC/bOJgvJI=",
"ref": "refs/heads/main",
"rev": "1caf18d72dd7cad7304401da0db6a6ba853d8827",
"revCount": 5,
"type": "git",
"url": "https://git.alexion.dev/alexion/skills"
},
"original": {
"type": "git",
"url": "https://git.alexion.dev/alexion/skills"
}
},
"sops-nix": {
"inputs": {
"nixpkgs": [
@@ -478,7 +540,7 @@
"nixpkgs"
],
"nur": "nur",
"systems": "systems_2",
"systems": "systems_3",
"tinted-kitty": "tinted-kitty",
"tinted-schemes": "tinted-schemes",
"tinted-tmux": "tinted-tmux",
@@ -529,6 +591,21 @@
"type": "github"
}
},
"systems_3": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"tinted-kitty": {
"flake": false,
"locked": {

View File

@@ -54,6 +54,13 @@
inputs.nixpkgs.follows = "nixpkgs";
};
# Personal agent skills, packaged as per-skill derivations with a
# home-manager module that places them under Claude Code's skills directory.
skills = {
url = "git+https://git.alexion.dev/alexion/skills";
inputs.nixpkgs.follows = "nixpkgs";
};
# CachyOS kernel and binary cache. Pins its own nixpkgs so its cache stays
# usable and the kernel is fetched from it.
chaotic.url = "github:chaotic-cx/nyx/nyxpkgs-unstable";

20
modules/agents/skills.nix Normal file
View File

@@ -0,0 +1,20 @@
{ config, inputs, ... }:
# Global agent skills from the skills flake, placed under the agent harness's
# skills directory so they are active in every project. The flake's home-manager
# module self-gates on the harness being enabled and installs nothing for an
# empty selection, so a host without one carries no skills either way.
#
# Unlike every other module, this one declares no `enable` flag and wires
# unconditionally, by design.
# The flake's self-gating above already makes it inert where the harness is
# absent, so a gate would guard nothing.
let
user = config.user.name;
# The skills installed globally, as derivations from the skills flake.
skills = [ ];
in
{
home-manager.sharedModules = [ inputs.skills.homeModules.default ];
home-manager.users.${user}.programs.agents.skills = skills;
}

View File

@@ -1,13 +0,0 @@
{ config, lib, ... }:
# Reference example for the module shape: imported unconditionally, but inert
# until a host sets its `enable` flag.
let
cfg = config.modules.example;
in
{
options.modules.example.enable = lib.mkEnableOption "the reference example module";
config = lib.mkIf cfg.enable {
environment.etc."skeleton-example".text = "This Module is enabled.\n";
};
}

View File

@@ -55,7 +55,7 @@ in
ls = "eza -al --color=always --group-directories-first --icons=always";
la = "eza -a --color=always --group-directories-first --icons=always";
ll = "eza -l --color=always --group-directories-first --icons=always";
lt = "eza -aT --color=always --group-directories-first --icons=always";
lt = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
"l." = "eza -a | grep -e '^\\.'";
};